From 3f6050f0ac1b2d8261de0a8612ffb83292baf6e4 Mon Sep 17 00:00:00 2001 From: thxCode Date: Tue, 29 Sep 2026 14:46:32 +0800 Subject: [PATCH 1/3] docs(specs): tick the completed tasks of the shipped S6 and S7 specs Both specs reached main with Status: Shipped and every one of their tasks unticked, although each task had landed in the same pull request that shipped the spec (#659 and #666). The boxes and the history then disagree, and completed work reads as outstanding. Tick all nine tasks of the prefetch spec and all eight of the image-source spec; no wording changes. Signed-off-by: thxCode --- specs/2026-09-27-model-image-source.md | 16 ++++++++-------- specs/2026-09-27-model-prefetch.md | 18 +++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/specs/2026-09-27-model-image-source.md b/specs/2026-09-27-model-image-source.md index 86d83fa0c..d7ae2a3b4 100644 --- a/specs/2026-09-27-model-image-source.md +++ b/specs/2026-09-27-model-image-source.md @@ -396,7 +396,7 @@ commit). Stage gates go to the coordinator: plan gate (this document), built gat T1 is a spike and is run first on purpose: if the kind environment cannot serve image volumes, the e2e environment decision escalates while the code tasks are still ahead of it, not after. -- [ ] **T1 · e2e environment verification (spike)** +- [x] **T1 · e2e environment verification (spike)** Blocked by: — Gate: review Owns: nothing in the tree (a local kind cluster, a probe Pod, evidence files only). @@ -408,7 +408,7 @@ the e2e environment decision escalates while the code tasks are still ahead of i decision rather than improvising. Verify: the probe Pod reads the image's marker file; the readings name the node image's containerd version and the feature state. -- [ ] **T2 · API + codegen** +- [x] **T2 · API + codegen** Blocked by: — Owns: `api/worker/v1alpha1/model_artifact.go`, `api/worker/v1alpha1/model_deployment.go`, generated trees (deepcopy, CRD, openapi, protobuf, applyconfigurations). @@ -416,7 +416,7 @@ the e2e environment decision escalates while the code tasks are still ahead of i identity and the absent resolved fields; `make generate` leaves a clean tree in the gen tree. Verify: `go build ./... && go test ./api/worker/...`. -- [ ] **T3 · Admission** +- [x] **T3 · Admission** Blocked by: T2 Owns: `pkg/kubediscovery/feature.go`, `pkg/worker/webhooks/worker/model_artifact.go`, @@ -425,13 +425,13 @@ the e2e environment decision escalates while the code tasks are still ahead of i wording above; the capability gate refusing below the floor and admitting at/above it, both directions unit-tested through the swappable version seam; the prefetch refusal. Verify: `go test ./pkg/kubediscovery/... ./pkg/worker/webhooks/worker/...`. -- [ ] **T4 · Resolution** +- [x] **T4 · Resolution** Blocked by: T2 Owns: `pkg/worker/controllers/worker/model_artifact.go` (+ tests). Acceptance: AC5, AC6 — first-pass resolution with a claim-shaped `resolved`; no nodes aggregation; no revalidation. Verify: `go test ./pkg/worker/controllers/worker/...`. -- [ ] **T5 · Renderer + placement** +- [x] **T5 · Renderer + placement** Blocked by: T3, T4 Owns: `pkg/worker/controllers/worker/model_artifact_placement.go`, `model_deployment_artifact.go`, `model_deployment.go`, `instance.go`, @@ -440,13 +440,13 @@ the e2e environment decision escalates while the code tasks are still ahead of i raise; `status.model` and `WeightsReady` behavior; the Setting-independence; the Instance node pre-check and the images-list preference as adjudicated (both in scope). Verify: `go test ./pkg/worker/controllers/worker/...`. -- [ ] **T6 · Docs** +- [x] **T6 · Docs** Blocked by: T5 Owns: the three docs paths of AC15/AC16. Acceptance: page routing, header/Contents/footer and the index entry per the docs skill; every number carries its version condition. Verify: `make lint docs`. -- [ ] **T7 · e2e** +- [x] **T7 · e2e** Blocked by: T1, T5 (T6 not required) Owns: the case script + SKILL.md row + execution evidence under the task directory's `spec-7/raw/`. @@ -454,7 +454,7 @@ the e2e environment decision escalates while the code tasks are still ahead of i escalates before this task starts); the capability-gate directions stay unit-tested (an old-version e2e cluster is out of scope). Verify: the case script against the cluster running the image built from the rebased head. -- [ ] **T8 · Ship prep** +- [x] **T8 · Ship prep** Blocked by: T1–T7 Owns: rebase, commit folding, chart-matrix local runs (Go change ⇒ REQUIRED: the CI chart matrix's 7 node images, locally, all rc=0), case number finalization, PR. diff --git a/specs/2026-09-27-model-prefetch.md b/specs/2026-09-27-model-prefetch.md index ee5637cd7..b4192a517 100644 --- a/specs/2026-09-27-model-prefetch.md +++ b/specs/2026-09-27-model-prefetch.md @@ -311,7 +311,7 @@ Tasks run sequentially in one seat (repo working agreement): every task starts w they run, and each task lands its own `--signoff` commit (folded per module at ship). No per-task review gates; the stage gates go to the coordinator (draft confirmed, plan gate, built gate). -- [ ] **T1 · API surface: three CRDs + NodeModelStore fields + codegen** +- [x] **T1 · API surface: three CRDs + NodeModelStore fields + codegen** Blocked by: None Owns: `api/worker/v1alpha1/model_store.go`, `api/worker/v1alpha1/model_store_binding.go`, `api/worker/v1alpha1/model_prefetch.go`, `api/worker/v1alpha1/node_model_store.go`, @@ -324,7 +324,7 @@ review gates; the stage gates go to the coordinator (draft confirmed, plan gate, API-level tests. Verify: `make generate && go build ./... && go test ./api/worker/...` -- [ ] **T2 · L3 merge + SelectorOverlap (worker)** +- [x] **T2 · L3 merge + SelectorOverlap (worker)** Blocked by: T1 Owns: `pkg/worker/controllers/worker/node_model_store.go`, `pkg/worker/controllers/worker/model_store.go` (+ tests) @@ -334,14 +334,14 @@ review gates; the stage gates go to the coordinator (draft confirmed, plan gate, `spec.pinned` field another controller owns. Verify: `go test ./pkg/worker/controllers/worker/... ./pkg/modelstore/...` -- [ ] **T3 · plugin pinned consumption** +- [x] **T3 · plugin pinned consumption** Blocked by: T1 Owns: `pkg/modelmanager/gc/gc.go`, `pkg/modelmanager/manager.go` (+ tests) Acceptance: AC11 — pinned digests are excluded from GC candidates while still counted in usage and capacity reporting; the saturated-state report is unchanged. Verify: `go test ./pkg/modelmanager/...` -- [ ] **T4 · prefetch controller (delivery + aggregation)** +- [x] **T4 · prefetch controller (delivery + aggregation)** Blocked by: T1 Owns: `pkg/worker/controllers/worker/model_prefetch.go` (+ tests), `pkg/worker/controllers/setup.go` @@ -351,7 +351,7 @@ review gates; the stage gates go to the coordinator (draft confirmed, plan gate, `Progressing` / `Available` / `Degraded` conditions; prefetch deletion removes its Pods. Verify: `go test ./pkg/worker/controllers/worker/...` -- [ ] **T5 · admission webhooks** +- [x] **T5 · admission webhooks** Blocked by: T1 Owns: `pkg/worker/webhooks/worker/model_store_binding.go`, `pkg/worker/webhooks/worker/model_prefetch.go`, `pkg/worker/webhooks/setup.go` (+ tests) @@ -362,7 +362,7 @@ review gates; the stage gates go to the coordinator (draft confirmed, plan gate, `pinned` without permission). Verify: `go test ./pkg/worker/webhooks/worker/...` -- [ ] **T6 · accounting + per-node pinned writer** +- [x] **T6 · accounting + per-node pinned writer** Blocked by: T4 Owns: `pkg/worker/controllers/worker/model_store_binding.go`, `pkg/worker/controllers/worker/model_prefetch.go` (pinned subset) (+ tests), @@ -373,7 +373,7 @@ review gates; the stage gates go to the coordinator (draft confirmed, plan gate, ownership split (prefetch controller writes `pinned`; the L1–L3 reconciler preserves it). Verify: `go test ./pkg/worker/controllers/worker/...` -- [ ] **T7 · v1 view for ModelPrefetch** +- [x] **T7 · v1 view for ModelPrefetch** Blocked by: T4 Owns: `pkg/worker/extensionapis/worker/model_prefetch.go` (+ tests), extension-API registration tables @@ -381,7 +381,7 @@ review gates; the stage gates go to the coordinator (draft confirmed, plan gate, precedent; the view serves delete (regression guard pattern). Verify: `go test ./pkg/worker/extensionapis/...` -- [ ] **T8 · docs** +- [x] **T8 · docs** Blocked by: T6, T7 Owns: `docs/**` (one new page + `docs/README.md` index entry + a `docs/settings.md` entry for the `model-prefetch-warmup-image` Setting) @@ -389,7 +389,7 @@ review gates; the stage gates go to the coordinator (draft confirmed, plan gate, vocabulary. Verify: `make lint docs` -- [ ] **T9 · e2e case on kind** +- [x] **T9 · e2e case on kind** Blocked by: T2, T3, T5, T6, T7 Owns: `.agents/skills/gpustack-operator-e2e/cases/case-.sh` (+ the SKILL.md case-table row; `` = main's max case number + 1, taken at my-ship after the final rebase) From 21a7a82755a141634f3d00adcff5d165045a56c0 Mon Sep 17 00:00:00 2001 From: thxCode Date: Tue, 29 Sep 2026 14:46:41 +0800 Subject: [PATCH 2/3] docs(specs): drop the closure-verification spec The document was a working scope draft for a verification pass, not a specification of anything the repository ships: nothing in the tree references it, and its content lives on in the task records it summarizes. A spec on main is a contract with its readers, and this one has no implementation to contract with. Signed-off-by: thxCode --- specs/2026-09-28-closure-verification.md | 130 ----------------------- 1 file changed, 130 deletions(-) delete mode 100644 specs/2026-09-28-closure-verification.md diff --git a/specs/2026-09-28-closure-verification.md b/specs/2026-09-28-closure-verification.md deleted file mode 100644 index 6a6d54065..000000000 --- a/specs/2026-09-28-closure-verification.md +++ /dev/null @@ -1,130 +0,0 @@ -# Spec: Closure verification for the model artifact line - -Status: Shipped -Type: Bug fix - -## Summary - -The model artifact line (model artifact, node model store, placement preference, download -progress, prefetch, peer sync, OCI image source) closes with a verification stage: every behavior -the earlier stages shipped is re-verified on the final tree, and every defect the review rounds -surfaced is either fixed, filed as an issue, or explicitly parked with its reason written down. -Five verification rows run on real nodes — a kind cluster does not count, because its kubelet is -a container — and eight open issues get a disposition. - -## Motivation - -Each earlier stage verified its own head, but main moved after every merge, and two rows of the -line's acceptance matrix were verified against trees or environments that predate later work: -the claim-before-bind ordering fix was never re-run against a real kubelet, and the KV-cache -dtype and fit-label checks predate the stages that landed after them. The line's standing rule is -that every claim carries evidence on the final tree or a written reason it does not. Separately, -the review rounds and stage handbacks accumulated small defects and hardening ideas; each one -needs a disposition — fixed, filed, or parked — instead of living in a handback. - -### Goals - -- Re-verify the five matrix rows on real nodes and record the evidence. -- Fix the small defects the review rounds filed, each in its own pull request. -- Diagnose the two Settings-propagation reports and the chart-e2e flake far enough to fix or - park them with the phenomenon written down. -- File the four hardening items that this stage does not fix. - -### Non-Goals - -- No new features. The single exception is the possible `Lapsed` condition for prefetch TTL - expiry (issue #661), which is an Open Question below with a recorded default. -- No re-verification of rows that already carry evidence on the final tree. -- No fixes for the four hardening items themselves; this stage only files them. - -## Proposal - -### Verification matrix - -Five rows, all on one nebius cluster with real nodes: two GPU nodes -(`gpu-h100-sxm` / `1gpu-16vcpu-200gb`) and two CPU nodes (`cpu-e2` / `2vcpu-8gb`), brought up -with the terraform under `testing/infra/clusters/nebius` and destroyed when the rows are done. -Each row's evidence lands in the stage's task directory. - -| Row | Claim under test | Why it needs a re-run | Nodes | Pass criterion | -|-----|------------------|-----------------------|-------|----------------| -| A1 | A persistent-volume-claim artifact is claimed before it is bound | The ordering fix was verified on kind, whose kubelet runs in a container; a VM kubelet is the real path | CPU | The claim-bind ordering holds on a real kubelet, observed through the artifact's status transitions | -| A2 | GPU end-to-end (case-104) | The case last ran before the image-source and peer-sync stages merged | GPU ×1 | case-104 passes against a build of the final tree | -| A3 | TAS preference on accelerator nodes (case 88) | Same — predates later merges | GPU ×2 | The placement preference holds in both the fits and the does-not-fit directions | -| A4 | KV-cache dtype mixing across an engine pair with a real Mooncake store (#591) | The recheck was never run against a real store with a mixed engine pair | GPU or CPU, see the narrowing clause | The store behaves correctly under mixed dtypes and the engine pair's dtype constraint holds | -| A5 | Per-card fit labels on a real accelerator node, and NodeFeature cleanup on device-manager teardown (#625) | Fit labels were checked against a synthetic device set, not a real NVIDIA node | GPU | Labels match the device's real properties; after teardown, every label the manager wrote is gone, asserted label by label | - -A4 carries a narrowing clause: if SGLang cannot run on the available nodes, the row narrows to -the store's behavior plus one engine pair's dtype constraint, and the reason is written into the -row's evidence note. A row is never silently dropped — it is either evidenced or its reason is on -record. - -### Issue dispositions - -One issue is a product-semantics question and is listed under Open Questions: #661 (a prefetch -whose TTL expired stays counted against `MinReady` forever). - -Fixed in this stage, each in its own pull request through the merge gate: - -- #662 — a warm-up pod deleted by its controller can be recreated under the same name while the - old pod is still terminating, losing roughly ten seconds to self-healing. Fix with - `generateName` or by waiting for deletion finality. -- #663 — a `ModelStore` does not watch its sibling stores, so `SelectorOverlap` lags until the - next resync. Add the watch with a narrow predicate. -- #595 — an `Instance` requesting less than one CPU is admitted but never renders a pod. Either - admission refuses it or rendering rounds it; pick one and record the choice. -- #587 — a replica that ends `Succeeded` after an eviction is never rebuilt and the deployment - stays `Starting`. The controller covers the state. -- #583 — an S3 CSI endpoint written as an in-cluster DNS name never mounts, because geesefs - resolves with the host resolver. Document the constraint and validate at the chart level; do - not patch geesefs. - -Diagnosed in this stage, then fixed or parked: - -- #630 and #653 are the same family — a Settings change does not always reach the nodes that a - delivery decision depends on. Diagnose the propagation timing first; the fix follows the root - cause. -- #648 — chart-e2e case 4's `csi-nfs-controller` stalls at 1/2 on kind. Time-boxed to two hours: - pin the subchart version or correct the assertion if the cause is found, otherwise record the - phenomenon and park it. - -Scale guard: any item that outgrows its estimate returns to the scope draft for a new -disposition instead of expanding this stage. - -Filed as issues, not fixed in this stage: - -- The plugin requesting `SYS_ADMIN` instead of `privileged`. -- Mounting only the kubelet subdirectories the plugin needs. -- The Collector releasing its lock when an entry is deleted. -- A scheduling constraint for workloads landing on nodes where the plugin is not registered. - -### Execution shape - -The coordinator runs the verification matrix directly, because cluster bring-up and teardown are -coordinator-only operations. The fixes go to implementation seats — one or two issues per seat, -each landing as its own pull request — with the coordinator reviewing the output, or are made by -the coordinator directly when the change is small. Every pull request passes the standing merge -gate: green checks, all review threads resolved, no fixup commits, and a clean merge-tree against -main. - -## Acceptance Criteria - -- AC1: Each of A1–A5 has evidence recorded in the stage's task directory, or a written narrowing - or parking reason in the row's evidence note. -- AC2: #661 has a recorded decision. If the default lands, the `Lapsed` condition is implemented - with tests and documented. -- AC3: #662, #663, #595, #587, and #583 are fixed and merged, or re-dispositioned with the reason - recorded. -- AC4: #630 and #653 have a written root cause; the fix is merged or a follow-up issue is filed. -- AC5: #648 has a recorded outcome: fixed, pinned, or parked with the phenomenon described. -- AC6: All four hardening items exist as issues. - -## Open Questions - -All questions were decided during execution; the records are kept here. - -- #661: when a prefetch's TTL expires, the prefetch stops counting toward `MinReady` and its pins - are released, which today leaves the object indistinguishable from one that never ran. Decided - 2026-09-28 (user): the default landed — the `Lapsed` condition shipped in #680, with no - re-warming and no object deletion; a pin means "until used", and silent re-warming would have - turned expiry into a refresh. From 15d1871b00fc4b4228e124daa8876ec13b98c25d Mon Sep 17 00:00:00 2001 From: thxCode Date: Tue, 29 Sep 2026 14:46:54 +0800 Subject: [PATCH 3/3] test(docs): fail on a silently unticked task in a shipped spec The S6 and S7 specs reached main with every task unticked although the work had landed, and the only thing that caught it was a reader. Add a fifth rule to check-specs.sh: in a Shipped spec, an unticked task's block must say why it stays open -- a written deferral ('tracked as', 'routed to', 'left unticked', 'deferred', 'moved to', 'parked', 'NOT DONE') or a 'Blocked by:' naming an external blocker. A list of the plan's own task numbers is its ordering, not a deferral, and 'None' or a bare dash says nothing; both are reported. The five legitimately open tasks already on main -- each blocked on hardware or routed elsewhere in writing -- pass unchanged and are the rule's baseline. Verified against the pre-fix S7 spec: seven of its eight unticked tasks are caught, the eighth names a real sentence and is the accepted hole of a tripwire rule. The selftest gains the shapes in both directions, including the ordering-is-not-a-deferral case. Signed-off-by: thxCode --- .../scripts/check-specs-selftest.sh | 90 ++++++++++++++++++- .../scripts/check-specs.sh | 63 +++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/.agents/skills/gpustack-operator-docs/scripts/check-specs-selftest.sh b/.agents/skills/gpustack-operator-docs/scripts/check-specs-selftest.sh index ed391f348..4f4606d26 100755 --- a/.agents/skills/gpustack-operator-docs/scripts/check-specs-selftest.sh +++ b/.agents/skills/gpustack-operator-docs/scripts/check-specs-selftest.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # check-specs-selftest.sh — proves check-specs.sh can fail. # -# Both of its rules pass on every file in this repository, and a check that has never been seen to +# Its rules pass on every file in this repository, and a check that has never been seen to # fail is indistinguishable from one that cannot. Each case below builds a throwaway tree, breaks # exactly one thing, and asserts the finding names it. Nothing in the real tree is touched. # @@ -336,6 +336,94 @@ build rm -f "$MINI/tree/specs/2026-01-02-blocked.md" expect_hit "deleting the target turns the surviving reference red" "no such file is under specs/" +echo +echo "=== unticked tasks: a bare one in a Shipped spec is caught ===" +build +cat >> "$SPEC" <<'EOF' + +- [ ] **T9 · The thing that never ran** + Owns: nothing in the tree + Verify: by hand +EOF +expect_hit "an unticked task with no deferral is caught" "unticked in a Shipped spec" + +echo +echo "=== unticked tasks: a written deferral is the legal shape ===" +build +cat >> "$SPEC" <<'EOF' + +- [ ] **T9 · The thing that never ran** + **Routed to the verification matrix, by decision, and left unticked on purpose.** + Owns: nothing in the tree +EOF +run +if [ "$rc" -eq 0 ]; then + pass "a written deferral passes" +else + fail "a written deferral should pass, got: $out" +fi + +build +cat >> "$SPEC" <<'EOF' + +- [ ] **T9 · The thing that never ran** + Blocked by: a cluster with RDMA-capable nodes. NOT DONE. +EOF +run +if [ "$rc" -eq 0 ]; then + pass "an external blocker passes" +else + fail "an external blocker should pass, got: $out" +fi + +echo +echo "=== unticked tasks: 'Blocked by: None' and a bare dash say nothing ===" +build +cat >> "$SPEC" <<'EOF' + +- [ ] **T9 · The thing that never ran** + Blocked by: None +EOF +expect_hit "Blocked by None is not a deferral" "unticked in a Shipped spec" + +build +cat >> "$SPEC" <<'EOF' + +- [ ] **T9 · The thing that never ran** + Blocked by: — +EOF +expect_hit "a bare dash is not a deferral" "unticked in a Shipped spec" + +echo +echo "=== unticked tasks: the plan's own ordering is not a deferral ===" +# The measured corruption shape: every task of a shipped spec unticked, with blocks whose only +# "Blocked by" lines are the plan's own task numbers. +build +cat >> "$SPEC" <<'EOF' + +- [ ] **T9 · The thing that never ran** + Blocked by: T2 + +- [ ] **T10 · The other thing** + Blocked by: T1–T7 +EOF +expect_hit "task-ordering blockers are not deferrals" "unticked in a Shipped spec" + +echo +echo "=== unticked tasks: a Building spec keeps its open tasks ===" +build +cat >> "$BLOCKED" <<'EOF' + +- [ ] **T9 · The thing that never ran** + Owns: nothing in the tree +EOF +run +if [ "$rc" -eq 0 ]; then + pass "an unticked task in a non-Shipped spec is the ordinary shape" +else + fail "a Building spec's open task should pass, got: $out" +fi + echo if [ "$fails" -gt 0 ]; then echo "SELFTEST FAILED: $fails case(s)." diff --git a/.agents/skills/gpustack-operator-docs/scripts/check-specs.sh b/.agents/skills/gpustack-operator-docs/scripts/check-specs.sh index fe3e78ac3..1c89fe1c7 100755 --- a/.agents/skills/gpustack-operator-docs/scripts/check-specs.sh +++ b/.agents/skills/gpustack-operator-docs/scripts/check-specs.sh @@ -26,6 +26,14 @@ # commands. Measured when a bug fix's specification was moved out: eight markers in two # shipped specifications went on naming it, and all three gates stayed green. # +# 5. a task left unticked in a Shipped spec whose block says nothing about why. The box and the +# history then disagree, and completed work reads as outstanding -- measured when two shipped +# specifications reached main with every one of their tasks unticked although every task had +# landed, and a reader was the only thing that caught it. The legal shapes are a written +# deferral in the task's own block ('tracked as', 'routed to', 'left unticked', 'deferred', +# 'moved to', 'parked', 'NOT DONE'), or a 'Blocked by:' naming an external blocker -- a list +# of the plan's own tasks is its ordering, not a deferral, and 'None' or a dash says nothing. +# # Rule 3 reads the whole markdown corpus, not just specs/: the construct is a command someone # re-runs, and it goes stale wherever it is written. # @@ -382,6 +390,61 @@ for f in $SPECS; do done < "$WORK/refs" done +# --- 5. an unticked task in a Shipped spec says why --------------------------- +echo "==> unticked tasks in shipped specs" + +for f in $SPECS; do + if [ ! -f "$f" ]; then + continue + fi + word=$(sed -n '3p' "$f" | sed -e 's/^Status: *//' -e 's/[ ].*$//') + if [ "$word" != "Shipped" ]; then + continue + fi + # A task's block runs from its "- [ ]" line to the next task line or the next heading. The block + # must carry its deferral: one of the written phrases, or a "Blocked by:" that names something + # other than the plan's own task ordering (T-numbers, dashes and "and" are stripped; what + # remains has to be words). "None", a bare dash, or silence all say nothing. + awk ' + function flush( v, t) { + if (!open) return + open = 0 + if (block ~ /[Tt]racked as/ || block ~ /[Ll]eft unticked/ || block ~ /[Dd]eferred/ || \ + block ~ /[Rr]outed to/ || block ~ /[Mm]oved to/ || block ~ /[Pp]arked/ || \ + index(block, "NOT DONE") > 0) return + if (match(block, /[Bb]locked by:[^\n]*/)) { + v = substr(block, RSTART + 11, RLENGTH - 11) + gsub(/\*\*/, "", v) + t = v + gsub(/[Tt][0-9]+/, "", t) + gsub(/[Aa]nd/, "", t) + # ASCII punctuation first, dashes as single-char patterns: a bracket holding them next to + # \t reads \t- as a range that swallows the alphabet, measured on BSD awk. + gsub(/[\t ,.;-]/, "", t) + gsub(/–/, "", t) + gsub(/—/, "", t) + if (t != "" && v !~ /^[ \t]*[Nn]one\.?[ \t]*$/) return + } + print start + } + /^- \[/ { + flush() + open = ($0 ~ /^- \[ \] \*\*/) + start = FNR + block = $0 "\n" + next + } + /^#/ { flush(); next } + { if (open) block = block $0 "\n" } + END { flush() } + ' "$f" > "$WORK/unticked" + while IFS= read -r at; do + if [ -n "$at" ]; then + err "$f:$at: this task is unticked in a Shipped spec and its block does not say why. If the work landed, tick the box; if it did not, the block must carry the deferral in words ('tracked as', 'routed to', 'left unticked', 'deferred', 'moved to', 'parked', 'NOT DONE') or a 'Blocked by:' naming an external blocker — a list of the plan's own tasks is its ordering, not a deferral. Unticked and silent is how completed work reads as outstanding." + fi + done < "$WORK/unticked" +done + echo if [ "$errors" -gt 0 ]; then echo "FAIL: $errors problem(s)."