schema_version: 3
prefix: QCTL
style:
timezone: "-03:00"
section_order: [queue, horizon, archive]
active: QCTL-004
queue:
- id: QCTL-004
title: Point consumers at qctl check instead of copied Ajv tests
scope: cross-repo
outcome: >-
OMX seed, then kodus and pi-stuff, call qctl check. Copied
test/tasks-ledger.test.ts files can die once the pin is real.
blocked_by: []
acceptance:
- ompex/oh-my-pi seed uses mise run q -- check.
- pi-stuff and kodus-ai have a follow-up row or are converted.
- No consumer still vendors ajv or a copied tasks.schema.json.
- Each consumer $schema URL is a pinned qctl tag/release, not latest.
- 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-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.
blocked_by: []
acceptance:
- verctl, ctl-core, kodus-ai, pi-stuff and claude-code-source-recovery each declare schema_version 3 and a style block naming their timezone.
- Each archived row carries the moment recovered from the commit that first shows it archived, shifted into the declared zone; its recovered day matched the day it already recorded.
- verctl, ctl-core and claude-code-source-recovery bump their qctl pin in the same change, so their own check passes.
- The two ledgers under ~/workspace/omp are left alone; they are not ours.
- 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.
horizon:
- id: QCTL-007
title: Optional per-user daemon sharing one session and view
scope: ctl-core
kind: evaluation
outcome: >-
Multiple *ctl clients can attach to one local session the way
skill-cli does (Unix socket, NDJSON protocol, one dispatcher).
open: >-
skill-cli already has daemon + UDS + protocol crates. ctl-core
should reuse that shape only after pretty/JSON views are adopted
by at least one *ctl. Not a default feature.
notes: >-
Lower priority than clap chassis and schema-first views. Do not
pull tokio into ctl-core default features.
archive:
- 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"