qctl 0.4.6

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
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
# yaml-language-server: $schema=./schema/tasks.schema.json
schema_version: 4
prefix: QCTL

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

queue:
  - 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-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-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-030
    title: Let a verb update fields on an existing row
    scope: qctl
    outcome: Notes, acceptance, title, scope, outcome, plan, and links on a queued or horizon row can be changed without splicing YAML.
    blocked_by: []
    acceptance:
      - qctl edit ID rewrites only the named fields on that row and leaves unrelated rows byte-identical.
      - Notes append without replacing existing items; notes can be removed by index or exact text and reordered by one permutation.
      - A missing id fails; archive rows accept only note, evidence, and link operations; id and completed are never editable.
      - Queue positioning is one parameter and refuses any move that would place a row before its blocker or a dependent before the edited row.
      - Help, instructions, the bundled skill, fixtures, and snapshots describe the same edit and park grammar.
    notes:
      - 'Operator, 2026-08-26: how do we just update metadata on an existing task? add --notes is create-time only. Do not cover this with a second ledger skill or a blessed YAML splice. Document::set already patches a top-level scalar; the verb needs the same surgical rewrite on a row.'
      - '2026-09-12 recovery: preserved the inherited dirty implementation without reset, restore, stash, or clean; tracked diff SHA-256 3a353b12517300ba40f217de3d8f8641953050d574e47f8ae6333eff30faae1d.'
      - '2026-09-12 operator decisions: both releases are patch bumps; edit collapses position and removal flags; Rust code follows nested feature modules; qctl is reviewed as a PR stack.'
    plan: goals/row-edit/plan.md

  - id: QCTL-039
    title: Write a note that needs quoting as a plain folded block
    scope: qctl writer
    outcome: A dated note is written as '- >-' followed by its text, so the ledger carries no indentation indicator it does not need.
    blocked_by: []
    acceptance:
      - A single-line note that the serializer would quote is written as '- >-' with the text on the next line.
      - The '2' indentation indicator is written only when the note starts with a space.
      - Every note still reads back to exactly the string that was given; the existing round-trip check stays.
      - qctl fmt rewrites existing '>2-' notes to '>-' where the indicator is not needed, and changes no other bytes.
    notes:
      - >2-
        2026-09-28: src/document/notes.rs item() writes '- >2-' for every single-line note that yaml_serde would quote. Nearly every note starts with a date and a colon, which as a plain scalar would read as a mapping, so nearly every note takes this path. '>' and '-' are needed for an exact round trip; the '2' is needed only for a leading space.

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.

  - id: QCTL-041
    title: Decide what an archived row keeps so its evidence can be checked
    scope: qctl archive and park
    kind: research
    open: >-
      `archive` and `park` drop `blocked_by` and `acceptance`. Evidence exists
      to answer the acceptance criteria, and without them nobody can check it
      later. Should an archived row keep `acceptance`? If the criteria changed
      during the work, should the row keep what was accepted, what was
      delivered, or both? What should `park` keep for a row that may return?
    outcome: >-
      A decision, written down, on which fields `archive` and `park` keep, and
      whether `check` requires each evidence line to answer a criterion.
    notes:
      - >-
        2026-09-29: raised by a review of an archived row that had lost its
        criteria. `archive` has dropped both fields since the line-level
        rewrite, before the move to yamled. Rows archived by hand keep them,
        so a ledger such as yamled's now holds both shapes.

archive:
  - id: QCTL-040
    title: Read ledgers through the ctl-core loader and edit them through yamled
    scope: qctl reader and writer
    outcome: qctl reads every YAML input through ctl-core's serde-saphyr loader and edits ledgers through yamled, and carries no YAML crate of its own.
    plan: openspec/changes/archive/2026-09-29-yaml-stack/proposal.md
    links:
      - https://github.com/victor-software-house/qctl/pull/81
      - https://github.com/victor-software-house/qctl/pull/82
    completed: 2026-09-29T01:34:18
    evidence:
      - 'Released as qctl 0.4.5 (https://github.com/victor-software-house/qctl/releases/tag/v0.4.5): verify on the build host and in CI, Kody review clean; cargo tree names no YAML crate but yamled 0.0.4, and ctl-core 0.6.8 resolves the same yamled.'
      - One snapshot changed, edit-moves-a-row-before, because a moved row now keeps its list's tight spacing instead of gaining a blank line; the other 32 are unchanged.
    disposition: completed
    notes:
      - >2-
        2026-09-28: Waits on ctl-core CTC-008 (the loader) and the first yamled release with the location index and edits (yamled YMD-001). QCTL-039's note style becomes TextStyle::Folded on push_text.
      - >2-
        2026-09-29: The stack review found that a note ending in two line breaks, written as |+, left its row unmovable; yamled 0.0.4 moves such a row with its kept lines (https://github.com/victor-software-house/yamled/pull/10).

  - id: QCTL-038
    title: Archive the adopt-ctl-core-0-6 OpenSpec change
    scope: presentation
    outcome: The finished change leaves openspec/changes and its presentation spec lands in openspec/specs.
    links:
      - https://github.com/victor-software-house/qctl/pull/74
    completed: 2026-09-23T20:13:55
    evidence:
      - https://github.com/victor-software-house/qctl/pull/76
    disposition: completed

  - id: QCTL-037
    title: Adopt the ctl-core 0.6 presentation defaults
    scope: presentation
    outcome: 'qctl output uses the shared default look: borderless records, the id role for row ids, pretty JSON, and an 80-column fallback in piped captures.'
    links:
      - https://github.com/victor-software-house/ctl-core/tree/main/openspec/changes/choose-visual-identity
    completed: 2026-09-23T19:25:46
    evidence:
      - https://github.com/victor-software-house/qctl/pull/72
    disposition: completed

  - id: QCTL-036
    title: Adopt isolated Surface model feature
    scope: qctl
    outcome: qctl uses ctl-core surface-model for its short-option test without enabling MiniJinja in any qctl dependency graph.
    notes:
      - Stacked above QCTL-035 so the cache mitigation and dependency-isolation adoption merge and release together.
    completed: 2026-09-12T23:49:18
    evidence:
      - 'qctl 3fe13db: ctl-core 0.5.2 surface-model replaces surface in tests; MiniJinja is absent from normal and all-target trees; short-option test, full verify, schema stability, ledger check, and two patch fragments pass.'
    disposition: completed

  - id: QCTL-035
    title: Silence rust-cache package cleanup noise
    scope: qctl-ci
    outcome: The publish job removes only disposable Cargo package staging before rust-cache cleanup, avoiding false ENOENT annotations without changing cache keys, scopes, targets, or save policy.
    notes:
      - >2-
        Swatinem/rust-cache 2.9.2 issue #193: its tests-profile cleanup starts un-awaited scans of absent target and trybuild children, so try/catch cannot suppress their rejected promises.
    completed: 2026-09-12T23:30:43
    evidence:
      - https://github.com/victor-software-house/qctl/pull/66
      - 'qctl 7e506a8: workflow YAML, release-equivalent Cargo package staging, cache-preservation sentinel, repository ledger, and CI pass; cache configuration is unchanged.'
    disposition: completed

  - id: QCTL-034
    title: Adopt released ctl-core contracts
    scope: qctl
    outcome: qctl pins ctl-core 0.5.1, inherits its automatic width policy, and verifies short options through Surface without adding surface-only dependencies to the release binary.
    notes:
      - Final adoption slice above the reviewed qctl stack, unblocked by the published ctl-core 0.5.1 patch release.
    completed: 2026-09-12T22:51:16
    evidence:
      - https://github.com/victor-software-house/qctl/pull/64
      - 'qctl d772827: ctl-core 0.5.1 is pinned with surface test-only; the shared short-option contract, production dependency exclusion, full verification, schema stability, repository ledger, and CI pass.'
    disposition: completed

  - id: QCTL-032
    title: Validate one gapless global task corpus
    scope: qctl
    outcome: Queue, horizon, and archive partition one globally unique, gapless task-id sequence.
    notes:
      - 'Operator direction, 2026-09-12: id continuity and uniqueness apply to the entire task corpus, never independently within queue, horizon, or archive.'
      - QCTL-007 was restored from commit history as a dropped tombstone because commit e5e48b removed the duplicated daemon proposal after ctl-core CTC-003 became its owner; the id was not reused.
    completed: 2026-09-12T18:32:08
    evidence:
      - https://github.com/victor-software-house/qctl/pull/61
      - 'qctl b3389ec: global gap, duplicate, status-partition, bounded-id, instructions, schema, and graph coverage passed mise run verify and PR CI/Kody with zero open findings.'
    disposition: completed

  - id: QCTL-033
    title: Write readable structured note items
    scope: qctl
    outcome: Every note remains one list item with intentional line and paragraph breaks readable in YAML.
    notes:
      - 'Operator direction: notes are lists, and each repeated -n is one item.'
      - Intentional lines and blank paragraph separators must survive readback; qctl does not insert mechanical sentence wraps.
    completed: 2026-09-12T18:02:14
    evidence:
      - https://github.com/victor-software-house/qctl/pull/62
      - 'qctl ba09083: readable ordered notes preserve content and formatting boundaries through add, edit, and fmt; focused tests, mise run verify, and repository-ledger check passed on macbook-portable.'
    disposition: completed

  - id: QCTL-031
    title: Define acceptance as observable closure truth
    scope: qctl
    outcome: Every qctl surface teaches acceptance as demonstrably true end conditions, while plans and notes carry implementation method.
    notes:
      - 'Operator follow-up, 2026-09-12: make acceptance semantics unmistakable without changing the existing demonstrably-true schema contract.'
    completed: 2026-09-12T16:41:25
    evidence:
      - https://github.com/victor-software-house/qctl/pull/60
      - 'qctl 04509b1: help, instructions, skill, README, and schema state observable closure conditions; focused tests and mise run verify pass on macbook-portable.'
    disposition: completed

  - 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.
    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.
    completed: 2026-08-24T00:49:59
    evidence:
      - https://github.com/victor-software-house/qctl/pull/55
    disposition: completed

  - 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.
    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.
    completed: 2026-08-24T00:49:59
    evidence:
      - https://github.com/victor-software-house/qctl/pull/55
    disposition: completed

  - id: QCTL-008
    title: Adopt ctl-core in qctl and forkctl
    scope: cross-repo
    outcome: >-
      Qctl and forkctl return typed reports through ctl-core App and Present.
      No consumer owns a second help, stream, color, width, table, or JSON path.
    notes:
      - >-
        Forkctl shipped on ctl-core 0.4.1. Qctl's established -f/--file exposed
        the missing reusable composition, so ctl-core 0.4.2 added FormatLong
        before qctl pinned it and deleted every domain print path.
    completed: 2026-08-24T00:49:58
    evidence:
      - https://github.com/victor-software-house/qctl/pull/55
    disposition: completed

  - 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.
    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`.
    completed: 2026-08-23T12:36:32
    evidence:
      - https://github.com/victor-software-house/qctl/pull/51 squash-merged as d8bcb56
    disposition: completed

  - 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.
    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.
    completed: 2026-08-23T12:36:32
    evidence:
      - https://github.com/victor-software-house/qctl/pull/48 squash-merged as 4aee207
    disposition: completed

  - 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-007
    title: Optional per-user daemon sharing one session and view
    scope: ctl-core
    completed: 2026-08-19T21:13:45
    outcome: The duplicate qctl daemon proposal is removed; ctl-core CTC-003 owns the evaluation.
    evidence:
      - commit e5e48b removed the qctl row after the same evaluation moved to ctl-core CTC-003.
    disposition: dropped
    notes:
      - >-
        Restored as a historical tombstone when qctl began enforcing a gapless
        global id corpus. This does not revive the proposal or reuse the id.

  - 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"