qctl 0.2.2

Control in-repo YAML work queues
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
# yaml-language-server: $schema=./schema/tasks.schema.json
schema_version: 3
prefix: QCTL

style:
  timezone: "-03:00"
  section_order: [queue, horizon, archive]
active: QCTL-006

queue:
  - id: QCTL-006
    title: Archive from commit trailers in a pre-push hook
    scope: qctl
    outcome: >-
      A merged or pushed commit that closes a ledger id archives that
      row. An active id cannot sit on main after its close trailer landed.
    blocked_by: []
    acceptance:
      - "Trailers Closes PREFIX-NNN and Completes: PREFIX-NNN in the commit body are the only syntax. Subject-line slogans do not count."
      - "qctl hook install writes a pre-push (and optional commit-msg) hook that runs qctl close-from-git."
      - "qctl close-from-git reads commits being pushed (or main..HEAD), archives matching queued ids with evidence = the commit SHA, and fails if active still names a closed id."
      - "CI qctl check on default branch fails when a commit already on that branch closed an id that is still queued."
      - "The hook does not amend. It writes tasks.yaml in the working tree and requires that file to be part of the push (or a follow-up commit). No hook skip flags."
      - "Does not invent GitHub issue linking. Closes #12 stays GitHub's."
    notes: >-
      check now fails if a Closes/Completes trailer names a still-queued
      id. Auto-archive of tasks.yaml still needs QCTL-002. VER-003 went
      stale because check did not look at git and no commit had a trailer.

  - id: QCTL-008
    title: Adopt ctl-core in qctl and forkctl
    scope: cross-repo
    outcome: >-
      qctl and forkctl flatten ctl-core flags and go()/View::show.
      No third copy of styled help, color, or format.
    blocked_by: []
    acceptance:
      - qctl main uses ctl_core::prelude (go, OutputArgs or ColorLong, formatdoc).
      - forkctl drops its local help/color/format chassis in favor of ctl-core.
      - state-sync is a follow-up or the same PR if the diff stays small.
      - Pin is a ctl-core git rev or crates.io version, not a path dep on main.
    notes: >-
      Blocked in practice on ctl-core CTC-004 (harden). qctl cannot
      name a foreign prefix in blocked_by. Do not wait for QCTL-001.

  - id: QCTL-014
    title: Let a ledger declare whether rows are blank-separated
    scope: qctl
    outcome: fmt keeps one blank line between rows when the style asks for it, so a lost separator is repaired rather than found by eye.
    blocked_by: []
    acceptance:
      - A style option decides whether rows in a list are separated by exactly one blank line, defaulting to leaving them as written.
      - With it on, fmt inserts a missing separator and removes a doubled one; the first row of a list never gets one above it.
      - The two separators repaired by hand in QCTL-013 would have been fixed by running fmt.
    patch: blank-between-rows

  - id: QCTL-015
    title: Write a ledger atomically so a crash cannot truncate it
    scope: qctl
    outcome: A verb that dies mid-write leaves the ledger as it was, not half of it. The file is written beside itself and renamed over the original, so a reader sees one version or the other.
    blocked_by: []
    acceptance:
      - Every write goes through a temp file in the ledger's own directory, then a rename.
      - A test proves the original survives when the write fails after the temp file exists.
      - No verb calls fs::write on the ledger path.

  - id: QCTL-016
    title: Name the next row a ledger can actually start
    scope: qctl
    outcome: 'qctl answers what to work on next, instead of a human reading status and applying blocked_by in their head. One ledger first: the earliest queued row whose blockers are all archived.'
    blocked_by: []
    acceptance:
      - qctl next prints one id, or says why nothing is startable — every queued row blocked, or the queue empty.
      - It agrees with active when active is set and startable, and disagrees loudly when active names a row whose blockers are still queued.
      - A scenario in tests/fixtures covers a queue whose head is blocked and whose second row is not.

  - id: QCTL-017
    title: Decide whether a served include pins a tag or a commit
    scope: cross-repo
    outcome: Every consumer's task include names the same kind of ref, on purpose, and the reason is written down where the include is generated.
    blocked_by: []
    acceptance:
      - 'The decision is recorded in docs/served-files terms: a tag is ours and never moved, or a SHA because a tag can be.'
      - If SHAs win, the served-file template renders the tag's commit and every consumer moves in the same slice.
      - If tags win, the reasoning is in the repo, so the next review that raises it is answered by a link rather than an argument.

  - id: QCTL-018
    title: Stop the example catalog declaring a second qctl pin
    scope: qctl
    outcome: 'examples/mise.toml shows the shape consumers actually use: the include alone, with the served task carrying the version.'
    blocked_by: []
    acceptance:
      - "The mise.toml template drops its [tools] entry, and the rendered comment says the include ref is the only pin — whatever kind of ref QCTL-017 settles on."
      - README says the same thing.

  - id: QCTL-020
    title: Let a row wait on work in another ledger
    scope: qctl
    outcome: A blocked_by entry may name a foreign prefix, and check treats it as a dependency it deliberately cannot resolve rather than an error. No registry, no sources — the dependency is data, and this is the half that is.
    blocked_by: []
    acceptance:
      - A foreign prefix in blocked_by passes check, and status shows the row as blocked with the foreign id named.
      - A same-prefix id that does not exist still fails, exactly as it does today.
      - status says which foreign ids a row waits on, without claiming to know whether they are done.
      - 'qctl instructions states the rule: a foreign prefix is recorded, never resolved, until QCTL-019 decides how resolution works.'
      - "Which prefixes a ledger may name is declared in tasks.yaml, beside the rows — it is data about the work, so this phase adds no second file."

  - id: QCTL-021
    title: Style says how a scalar is quoted
    scope: qctl
    outcome: >-
      A ledger declares its quoting the way it declares its indent, so one
      file writes one fact one way whoever wrote the row.
    blocked_by: []
    acceptance:
      - "`style` carries the option, defaulting to what the serializer does today, so an existing ledger is unchanged until it asks."
      - "`fmt` rewrites a scalar that disagrees with the declared style, and `fmt --check` fails it, so a mix cannot survive a hook."
      - Every writer honours it, so a row a verb writes and a row fmt rewrites come out identical.
      - A test asserts the shape on a written row rather than on a hand-built string.
    notes: >-
      2026-08-19, raised on verctl#46 and corrected here after reading the
      code rather than the symptom. The first write-up called this qctl
      disagreeing with itself. It is not: every stamp qctl has ever written is
      unquoted, because `yaml_serde`'s emitter decides, and CTC's writer row
      already stated that intent — "the serializer quotes what needs quoting".
      The mix in verctl's ledger came from a foreign writer, the QCTL-011
      recovery script, which wrote `completed: "…"` as literal text.
      So the gap is a missing option, not a bug, and the code already says so:
      `format.rs` names "row key order and quote style" as the obvious next
      options, deliberately not decided, and `document.rs` disclaims deciding
      "how to quote a value". `style` has five options — timezone,
      section_order, indent, archive_order, normalize_on_write — and none is
      about scalar style. `check` cannot see quoting at all, because it
      validates the parsed value, where both forms are the same String.
      Nothing is broken today: every reader we ship takes an unquoted ISO-8601
      scalar as a string. The risk is a reader that does not — a YAML 1.1
      resolver takes it as a timestamp node while the `$schema` line says
      string. Do this with row key order, since format.rs pairs them, and
      leave the default at today's behaviour so no ledger moves unasked.

  - id: QCTL-022
    title: The bundled skill says which release it describes
    scope: qctl
    outcome: A reader can tell whether skills/qctl/SKILL.md predates the qctl they hold, without leaving the file.
    blocked_by: []
    acceptance:
      - 'skills/qctl/SKILL.md carries version: in its frontmatter, and the Version PR moves it with every other version site.'
      - 'It is a patterns pin, not a template: the skill is hand-authored prose and a template would own the whole document.'
      - 'The arity is once, so a second version: line stops the release instead of rewriting the wrong one.'
      - The vendored copy in the global skill set shows the released version, checked after the release rather than assumed.
    notes: >-
      2026-08-20, from the bundle contract written down as the
      `ctl-bundle-contract` skill (dotfiles b944acf9). A ctl CLI ships a usage
      skill, an installed instruction sheet and docs beside the binary, and the
      skill is vendored into the global set at `latest-release` — so the text a
      consumer loads is a released snapshot they cannot date from the outside.
      verctl did it first in verctl#50 and that is the shape to copy: a
      `patterns` entry matching `version: {version}` at arity `once`, plus a
      `pins` entry naming `skills/qctl/SKILL.md`. Not a template — the served
      files doc reaches for a template only when a file can be generated, and a
      skill is prose. Confirm no other `version: ` line exists in the document
      first, since the match is literal and the arity would fail the release.

  - id: QCTL-023
    title: check says which rules it enforces
    scope: qctl
    outcome: A consumer deleting a hand-rolled validator can prove qctl covers it by reading a published inventory, instead of building defect fixtures for an hour.
    blocked_by: []
    acceptance:
      - check --explain prints every rule, its exact message, and which lists it reads, then exits 0.
      - The inventory is machine-readable, so a consumer can diff it against their own rule set.
      - It is generated from the checks themselves, so it cannot drift from them.
    notes: >-
      docs/adoption-report.md §1 — they call it the cheapest item on their
      whole list and the most valuable for adoption, because it is a document
      rather than code. They proved `check` subsumes their 209 lines of Rego
      and Bun policy by writing ten defect ledgers by hand, one per rule, and
      running each through `qctl check --no-git`. All seven of their Rego deny
      rules and both halves of their script matched, and qctl was strictly
      wider: their script read queue and archive only, so a horizon row's
      `plan:` went unchecked. Their point is that the hour they spent is the
      hour every migrating consumer faces, and most will skip it and either
      keep both validators forever or delete on faith. `qctl check --help` at
      HEAD offers only --file and --no-git.

  - id: QCTL-026
    title: A check that cannot run says so instead of passing
    scope: qctl
    outcome: No qctl gate reports success by having skipped itself.
    blocked_by: []
    acceptance:
      - trailer_errors says so when closed_ids fails, instead of returning no errors.
      - The scan is skipped, and says it was, when the ledger resolves outside the repository that surrounds the cwd.
      - A test proves the silence is gone.
    notes: >-
      docs/adoption-report.md §4. `trailer_errors` (src/check.rs:50) does
      `let Ok(closed) = trailers::closed_ids(root) else { return Vec::new() }`
      — an environment where the git call fails drops a whole check class and
      the run passes quietly. The discoverability half is the same shape from
      the operator's side: validating a ledger that lives outside the current
      repository — a fixture, a scratch copy, a migration staging file — runs
      the trailer scan against whatever repo surrounds it, and nothing points
      at --no-git until the odd complaints start. verctl carries the same law
      as VER-036, for `expand` and `prove_default_history`.

  - id: QCTL-027
    title: One problem is reported once
    scope: qctl
    outcome: A ledger with ten problems prints ten lines, each readable in a terminal.
    blocked_by: []
    acceptance:
      - check prints one line per problem and a bail that counts them, not both.
      - The --no-git help text names the scratch-file case that makes it necessary.
    notes: >-
      docs/adoption-report.md §5. src/check.rs:40 prints each problem on
      its own line, then line 42 bails with all of them joined — twenty lines
      for ten facts, and the joined form is the one that wraps badly. Keep the
      per-line output and reduce the bail to the count and the path.

  - id: QCTL-028
    title: The example catalog shows a whole row of each kind
    scope: qctl
    outcome: Somebody writing a row by hand copies a populated example instead of learning the schema from error messages.
    blocked_by: []
    acceptance:
      - examples/tasks.yaml carries one maximal QueuedTask, one ArchivedTask and one HorizonTask.
      - A test parses it, so it cannot drift from the types.
    notes: >-
      docs/adoption-report.md §6. Writing their first horizon row by hand,
      their first attempt used `evidence: Shipped.` on an archive fixture. The
      error was good and specific — `/archive/0/evidence: "Shipped." is not of
      type "array"` — but they found it by trial. `qctl init` writes an empty
      ledger, and examples/ at HEAD holds only mise.toml. They name verctl's
      examples/ver.yaml plus tests/examples.rs as the pattern to copy and call
      it the best thing in verctl's documentation. Distinct from QCTL-018,
      which is about the catalog declaring a second qctl pin.

  - id: QCTL-029
    title: status and check speak JSON
    scope: qctl
    outcome: An agent reads the ledger state without parsing a table that is free to change shape.
    blocked_by: []
    acceptance:
      - A --format json option on status and check, matching how verctl spells it.
      - check exits non-zero with a JSON array of problems.
    notes: >-
      docs/adoption-report.md §7. They read status and check with agents
      far more often than with humans. This is qctl-only: verctl already has a
      global --format json, measured at HEAD on status, check, ci and assets —
      and `git -C ../verctl show v0.1.1:src/cli.rs` carries `FormatArgs` too,
      so it was already there in the verctl release they read and they missed
      it rather than reading an old tool. Which is itself the argument for QCTL-023: a feature
      nobody can find is a feature nobody has. Spell it the way verctl does, so
      one convention covers the family.

horizon:
  - id: QCTL-019
    title: Resolve a foreign blocker instead of only recording it
    scope: qctl
    kind: evaluation
    open: >-
      QCTL-020 lets a row say it waits on VER-025 and stops there, on purpose.
      Reading that row — is it archived yet — means fetching another repository
      at a pinned ref. Is that worth building, and is a stale answer from a pin
      better than no answer?
    outcome: >-
      A decision, written down, on what a source is, where it is declared, and
      what reading one costs.
    notes: >-
      2026-08-19, from migrating six owned ledgers by hand in one pass, of
      which three are private. What the pass actually taught:
      Cross-repo order is real and invisible. QCTL-003 waited on QCTL-009 in the
      same ledger and that was easy to see; verctl VER-025 will rewrite the
      config this repo just gained, and nothing anywhere records that. A foreign
      blocked_by is the first thing worth having.
      Prefixes already carry the repository. Every one of the six is unique,
      and this ledger already reserves the rule that a foreign prefix in
      blocked_by is not waited on.
      2026-08-19, read from a package manager's own source rather than its docs.
      The donor considered first treats a git source as always a **whole
      repository**: it refuses a path that is not exactly user/project, and its
      source type carries no subdirectory at all, because selection inside a
      package is done by globs in that package's own manifest. Protocol is gated
      deliberately — without a scheme prefix the string must already name one —
      and the prefix is what buys the host shorthands. A ref is `@ref`.
      So it is the wrong donor for the path half. A ledger is a file inside a
      repository, and the syntax that says protocol, subpath, and ref at once is
      the go-getter form this repo's own task includes already use:
      `git::https://github.com/o/r.git//tasks/q?ref=v0.1.2`.
      2026-08-19, decided by Victor: a source is structured fields, not a URI.
      `repo` is parsed by gix-url — https, ssh, scp-like and file all parse, and
      its Scheme carries the protocol distinction the donor spends a prefix on —
      `dir` and `ref` beside it. gix-url derives serde, so the config
      deserializes without a line of parsing. The shorter-syntax question
      dissolves rather than being answered: a URI is long because it packs four
      facts into one string, and packing them is also what forces a parser we
      would then own. PURL was the runner-up — the only one-string form with a
      real spec and a maintained crate (packageurl 0.7.0, `#subpath` first
      class) — and it loses on exactly the protocol point, because ssh and
      self-hosted are qualifiers there, not types.
      Decided with it: resolution reads a pinned ref, never a checkout. A working
      tree can be dirty, on a branch, or behind, so reading one would reintroduce
      the drift a pin exists to remove. A foreign row's state is its state at the
      ref this repo names.
      Where it goes: `.ctl/q.yaml`, settled on verctl VER-025 where the name was
      being chosen — one `.ctl/` directory, one file per tool, named after the
      task an operator runs rather than the crate that reads it, with served-file
      templates shared at `.ctl/templates/`.
      What anything local does earn, and it is not a registry: caching. A
      user-level SQLite store keyed by the exact commit SHA a ref resolved to —
      pinned to the SHA, not the ref, so it can never answer for a tag that
      moved, and so two repos naming the same commit read it once. That is the
      cache side of the line below, and the only local state this design wants.
      One red line, so it does not get relitigated: a database may be a derived
      cache and never the authority. The record reviews as a diff and orders by
      file position, and both are gone the moment rows live in SQLite. An index
      rebuilt from the files costs nothing, because deleting it changes no
      answer.

archive:
  - id: QCTL-024
    title: Horizon has verbs, so a row is not hand-edited into place
    scope: qctl
    outcome: Moving a row between horizon and queue is one command that respects the invariants, not four hand edits.
    notes: >-
      docs/adoption-report.md §2. instructions.md is already upfront that
      horizon is edited by hand until these verbs exist; they hit it on day
      one, and the hand edit is the thing the same document tells agents not to
      do. Worth keeping from their write-up: they call the HorizonTask schema
      shape *good* and ask us not to change it. Having no acceptance and no
      blocked_by mechanically forced them to split one vague request into a
      queue row with provable acceptance and a horizon row with a named
      blocker, which they say they would not have split as cleanly on
      judgement alone. promote is the verb that matters more than park:
      horizon to queue is four hand edits with the active invariant to respect.
    completed: 2026-08-22T14:18:45
    evidence:
      - park writes a horizon row from title, scope, outcome, kind and open.
      - promote moves a horizon row onto the queue tail, dropping kind and open, and leaves active alone.
      - instructions.md no longer says horizon is edited in the yaml.
    disposition: completed

  - id: QCTL-025
    title: add writes the row you meant, where you meant it
    scope: qctl
    outcome: A row added by the verb needs no hand edit afterwards, and lands where its priority says.
    notes: >-
      docs/adoption-report.md §3, filed together because both halves are
      the same verb falling short of the schema. `AddArgs` (src/cli.rs:85) is
      title, scope, outcome, acceptance and patch; QueuedTask also has notes,
      blocked_by, plan and links. Every row they added needed a hand edit
      straight after `add`, always for notes, which is where their conventions
      put the evidence — so the verb bought them the id and the placement and
      they edited the file anyway. Their reasoning for why blocked_by is safe
      to accept — a blocker must be earlier, and `add` appends to the tail, so
      any queued id qualifies — holds only while the tail is the only landing
      place. Placement is the other half of the ask: queue order is priority,
      so anything urgent means a hand edit or an immediate `start`, which
      forces it to the head and makes it active, usually wrong. Take both and
      the two halves collide: `add --before X --blocked-by X` would write a row
      ahead of its own blocker, and `graph_errors` (src/ledger.rs:196-200)
      rejects it as "is not earlier" — the verb writing a ledger its own check
      refuses. So the blocker is validated against the resolved insertion
      index, not against mere membership. Confirmed while filing this very row:
      seven rows added, seven notes hand-edited in afterwards.
    completed: 2026-08-22T14:08:34
    evidence:
      - add writes notes, blocked_by, plan, and links.
      - before and after place the row; a blocker that would not sit earlier is refused.
      - horizon writes a horizon row.
    disposition: completed

  - id: QCTL-004
    title: Point consumers at qctl check instead of copied Ajv tests
    scope: cross-repo
    outcome: >-
      Every owned consumer calls qctl check. Copied
      test/tasks-ledger.test.ts files can die once the pin is real.
    notes: >-
      2026-08-19: three of four lines are met. Two private consumers deleted
      their vendored tasks.schema.json — one with its Ajv spec, one with the
      check-jsonschema tool that only existed to run it — and both now gate on
      `q check` from `?ref=v0.1.2`; ctl-core#9 and verctl#41 moved their
      $schema URLs to the same release. What is left is a third private ledger,
      whose change is open and deliberately not merged by us — that ledger
      churns, so the PR carries the recipe to regenerate rather than a rebase.
      The fourth line names an upstream engagement and needs a decision rather
      than work: QCTL-011 settled that those ledgers are not ours, which
      contradicts this line. Either that seed is a different thing than those
      ledgers, or the line should go.
    completed: 2026-08-19T22:24:32
    evidence:
      - 'Two private consumers deleted their vendored tasks.schema.json, one with its Ajv spec and one with the check-jsonschema tool that only ran it, the latter keeping Conftest for its own graph policy and plan-path rule. Both now gate on q check from ?ref=v0.1.2. ctl-core#9, verctl#41 and a third private ledger moved their $schema URLs to that release. No owned consumer vendors a schema or an Ajv test. The upstream line was dropped rather than met: that repo is not ours.'
    disposition: completed

  - id: QCTL-011
    title: Migrate the five other owned ledgers to schema 3
    scope: workspace
    outcome: Every ledger under this operator's own repositories declares schema_version 3, stamps completion with a moment, and declares the zone that moment is in.
    notes: >-
      2026-08-19: four of five migrated — ctl-core#9, verctl#41 and two private
      ledgers, each with a style block naming -03:00 and every archived row
      carrying an instant recovered from git. The third private one is open and
      ours to leave alone; its stamps were placeholders reading 12:00:00, now
      real instants in that PR.
      Two corrections to this row's own wording, both learned by doing it. The
      moment is the commit in which a row **last** entered the archive, not the
      first: one ledger had three rows archived, re-queued, and archived again,
      and the first commit dates a stay that ended. And the day gate cannot be
      absolute — three rows entered the archive at 00:21 on 2026-08-18 while
      saying 2026-08-17, the day the work finished. The commit instant is what
      `completed` means, so those three were accepted by name rather than by
      loosening the rule for everything.
    completed: 2026-08-19T22:24:20
    evidence:
      - 'All six owned ledgers speak schema 3 — three public (ctl-core#9, verctl#41, this one) and three private — each with a style block naming -03:00 and every archived row carrying the instant it last entered the archive, recovered from git. 88 rows across five repos; the day gate held everywhere except three rows that entered the archive at 00:21 and were accepted by name. Two upstream ledgers that are not ours were left alone.'
    disposition: completed

  - id: QCTL-003
    title: "Publish native qctl so mise github: can install it"
    scope: release
    outcome: >-
      A tagged release ships darwin-arm64 and linux-x64 binaries. The
      tasks/q catalog pin is a real immutable ref.
    notes: >-
      2026-08-17: the lane exists — CI, Version PR, publish, and both
      tarballs, all through released verctl, same shape as ctl-core.
      0.0.1 was cut by hand from this workstation, which is why it
      shipped macos-arm64 only.
      2026-08-18: 0.0.2's first Version PR bumped Cargo.toml and left
      Cargo.lock at 0.0.1, so Verify's `cargo package --locked` hit a dirty
      tree. Fixed by declaring `[prepare]` here and moving to verctl 0.0.3,
      which is the release that commits `[prepare].stage` files. The nscloud
      note is withdrawn, not deferred: this repo is public, so
      macos-latest/ubuntu-latest are the runners it wants, and verctl 0.0.3
      makes them a default rather than a hardcoded map.
      2026-08-19, corrected: 0.0.2 did ship after that fix. Its release
      carries qctl_0.0.2_linux_x64.tar.gz and qctl_0.0.2_macos_arm64.tar.gz
      alongside tasks.schema.json, so the first acceptance line is already
      met and the earlier "never shipped" note was stale. What is left is
      proving a clean consumer can install and check against a real tag.
      examples/mise.toml does exist, and said `?ref=v0.0.1` — correcting that
      is QCTL-009's subject, which is why this row now waits on it: a tag
      whose task file installs 0.0.1 cannot prove the last line.
    completed: 2026-08-19T16:12:33
    evidence:
      - v0.1.1 ships qctl_0.1.1_macos_arm64.tar.gz and qctl_0.1.1_linux_x64.tar.gz, examples/mise.toml at that tag pins 0.1.1, and a scratch consumer holding only that file ran mise install, then mise run q -- init and check ok against qctl 0.1.1 fetched from the tagged include.
    disposition: completed

  - id: QCTL-009
    title: Declare this repo's served files so its tag stops lying
    scope: qctl
    outcome: >-
      Every ref a consumer fetches out of a qctl tag names that tag's
      version, without anybody bumping five files by hand.
    notes: >-
      2026-08-18, verified against the published tag: `git show
      v0.0.2:tasks/q/q` still says `"github:…/qctl" = "0.0.1"`, and
      examples/mise.toml and README in that tree say `?ref=v0.0.1`. So the
      current release ships a task file that installs the previous release —
      the exact failure verctl VER-017/VER-018 were built to end, live in this
      repo's latest tag. Five sites, counted: the [tools] pin and the ?ref= in
      examples/mise.toml, the `#MISE tools` header at tasks/q/q line 5, and
      the ?ref= plus the install hint in README. They were hand-bumped once as
      a bridge and drifted again, which is the argument for declaring them
      rather than remembering them. Split out of QCTL-003, which was about
      publishing native binaries and got that done — this is the part that
      never landed, and it is unblocked now that verctl 0.0.4 exists.
      2026-08-19: declared. `.verctl/templates/q.jinja` and
      `mise.toml.jinja` render `tasks/q/q` (0755) and `examples/mise.toml`,
      README's two spellings are `[patterns.catalog]` and
      `[patterns.install]` at arity once, and the release lane runs verctl
      0.0.4. Proven locally in a clone: `verctl prepare` renders all three
      files byte-identical to the committed ones with only 0.1.0 → 0.1.1
      moved. One thing worth knowing: a template git does not track renders
      nothing — the dry run listed README alone until the templates were
      staged. The last line waits on the 0.1.1 tag.
    completed: 2026-08-19T16:12:33
    evidence:
      - Rendered from .verctl/templates and declared as [patterns]; verctl 0.0.4 rewrote all five sites onto the Version PR commit. git show v0.1.1:tasks/q/q names 0.1.1, mode 100755, and examples/mise.toml and README say v0.1.1 in that tree.
    disposition: completed

  - id: QCTL-013
    title: Stop a cut row taking two separators with it
    scope: qctl
    outcome: Removing a row from between two others leaves those two separated the way they were.
    patch: cut-separator
    completed: 2026-08-19T02:03:57
    evidence:
      - 'qctl PR #18, squashed 5dc2467.'
    disposition: completed

  - id: QCTL-012
    title: Declare a ledger's style, and add qctl fmt to apply it
    scope: qctl
    outcome: A ledger states its own formatting, qctl fmt applies it, and the verbs write new text in it.
    patch: style-and-fmt
    completed: 2026-08-19T01:46:55
    evidence:
      - 'qctl PR #17, squashed 91adda1.'
    disposition: completed

  - id: QCTL-010
    title: Stamp completion with an instant, not a day
    scope: qctl
    outcome: An archived row says when it left the queue, to the second, in one shape that sorts.
    patch: completed-instant
    completed: 2026-08-19T01:06:14
    evidence:
      - 'qctl PR #16, squashed 408f0d0.'
    disposition: completed

  - id: QCTL-002
    title: A mutation rewrites the lines it changes, not the file
    scope: qctl
    outcome: >-
      add, start, and archive change only the bytes they must, so a ledger
      edit is reviewable as a diff, two people can edit different rows
      without conflicting, and the file still reads the way it was written.
    notes: >-
      2026-08-18, measured on the real thing rather than assumed. One
      `qctl start VER-020` against a copy of verctl's 427-line tasks.yaml
      changed 641 lines — every line in the file — and stretched the longest
      line from 153 to 2158 characters. Six distinct losses, all from the
      same cause: mutate.rs parses to serde_yml `Value`, which keeps values
      and drops everything else, then serializes from scratch. Gone are
      comments (line 1 `# yaml-language-server: $schema=…` included), blank
      lines between rows, two-space sequence indentation, folded `>-`
      scalars (flattened to single long lines), double quotes (rewritten to
      single), and inline flow sequences (expanded to block lists). The
      values themselves survive — `check` still passes on the output — so
      this is entirely a writer problem, not a schema one.
      The shortest demonstration needs two commands and no existing file:
      `qctl init` writes the `# yaml-language-server:` header, and the first
      `qctl add` deletes it. The tool destroys its own output on the first
      verb after creating it.
      Why it matters beyond taste: a whole-file diff cannot be reviewed, and
      two people editing different rows conflict on every line. It also
      blocks verctl VER-021, which installs a `$schema` line in every ctl
      repo that qctl would delete on the next verb.
      Until this ships, a comment in a ledger does not survive the next verb,
      so nothing belongs beside a key that could live in notes instead.
      The writer, chosen against the alternatives rather than assumed:
      yamlpath + yamlpatch (zizmor's crates, MIT, 460k and 236k downloads,
      tree-sitter-yaml spans and comment-preserving patch ops). Rust has no
      mature toml_edit equivalent for YAML, so the field was searched and
      each candidate tried against the real ledger.
      Rejected, with the reason each was rejected. yaml-edit 0.2.3 (rowan
      lossless CST, the closest analogue on paper) reported this repo's
      seven-row queue as three rows, so remove-then-insert silently did
      nothing, and it dropped the `# yaml-language-server:` line — the exact
      byte the task exists to protect. yaml-rt, edikt-yaml, yaml-serde-edit
      and fyaml all describe the right thing, including a typed-overlay
      writer, but were published weeks ago and have double- to
      triple-digit downloads; this tool is a dependency of every ledger in
      the workspace. rust-yaml has adoption but no span or patch API.
      Two gaps in the chosen pair are ours to close, both measured.
      One: tree-sitter attributes a comment to the row *above* it, so
      `removal_span` on a row swallows the next row's leading comment while
      excluding its own. Reordering therefore takes the item's own span from
      `query_exact` and applies one attribution rule of our own — a comment
      line directly above a row, with no blank line between, belongs to that
      row — with the parser still deciding what is a comment. YAML has no
      formal comment ownership, so every round-tripping library invents this
      rule; ruamel.yaml included.
      Two: `Op::Append` refuses a flow sequence, so `add` on a
      freshly initialized ledger (`queue: []`) needs its own path.
      Verified for the writer, so it does not have to be discovered mid-build:
      the queued row type serializes into that value type with its absent
      fields skipped, so a generated row carries no `patch: null` and no empty
      `links`, and the serializer quotes what needs quoting.
      Two defects this verb rewrite also owns. A blocker resolves against the
      queue alone, so archiving an id leaves every row that named it failing
      `check` with "is not queued" — the verb has to drop the archived id from
      the rows that named it, which is also why every blocked row in this
      ledger would otherwise break the moment its blocker ships. And `archive`
      is the one verb that never loads the ledger before editing it, so it
      alone will edit a file whose values do not pass.
      Also known and accepted: a generated row's nested sequence items come
      out at the serializer's indentation, one level shallower than the
      hand-written rows. That applies only to rows with no prior formatting
      to preserve.
    completed: 2026-08-18T23:00:41
    evidence:
      - 'qctl PR #15'
      - 'one start on verctl''s 479-line ledger: 641 changed lines before, 67 after; longest line 235 both ways'
      - 8 before/after fixtures in tests/fixtures/mutations, asserted as whole files
      - '3 mutations bite: comment attribution 2, list indentation 6, blank-run cut 1'
      - archive now clears the archived id from every blocked_by, in the style it was written in
    disposition: completed

  - id: QCTL-001
    title: Own the ledger contract as Rust types via schemars
    scope: qctl
    completed: 2026-08-18T22:46:12
    disposition: completed
    outcome: >-
      The work-queue shape is declared once, as Rust types. serde reads and
      writes it, garde refuses the values a type cannot, and schemars
      generates the committed JSON Schema from the same declarations.
    evidence:
      - "qctl main f35ccfd (#13), squashed from five commits"
      - "src/schema.rs is the only statement of the shape; the hand-written schema stopped being source"
      - "qctl schema regenerates schema/tasks.schema.json; a test fails when the committed file is stale"
      - "12 suites, 45 cases; 4 mutations proved them: schema ignored fails 19, format-as-annotation fails 3, garde off fails both verb cases, one-pass off fails 1"
      - "verctl and ctl-core ledgers still check ok against the generated schema"
      - "the pinned versioned $schema URL is deliberately not here; QCTL-004 owns it"

  - id: QCTL-005
    title: Start the shared CLI crate (short/long, help, negations)
    scope: cross-repo
    completed: 2026-08-16T23:08:28
    disposition: completed
    outcome: >-
      forkctl, qctl, and verctl share one clap chassis instead of
      copying flag style.
    evidence:
      - "github.com/victor-software-house/ctl-core main 02c7e9f"
      - "verctl ver-004-version-pr consumes ctl-core via git rev"
      - "prelude, Render, formatdoc, FormatArgs/ColorLong, View"