The template teaches the violation it is linted for
TASK-IMP-117 - the template teaches the violation it is linted for
FM-001 fires on 501 of 544 specs. The cause is not 501 authoring mistakes: it is line 2 of tools/install/templates/TASK-TEMPLATE.md, which ships id: TASK-<MODULE>-<NNN> # module-scoped, e.g. TASK-AUTH-001. Every spec is born violating a rule the machine floor enforces, because the file that teaches the format contradicts the file that checks it.
The rule is right. id: parses as "TASK-X-001 # module-scoped, ..." for any consumer that does not strip trailing comments - the exact defect that put priority: MUST # MUST | SHOULD into TASK-AI-001..005's parsed values and wrote FM-001 in the first place. What was missing was a mechanism: the lint existed, the template disagreed, and nothing ever ran the two against each other.
The template is VENDORED. Every repo that installed CyberOS carries this copy, so every consumer corpus has the same disease. A migrator that lives only in this repo fixes one of N corpora.
Summary
Fix TASK-TEMPLATE.md so specs are born FM-001 clean, ship a migrator in the payload so any installed repo can clean its own corpus, and migrate this repo's non-conformant specs. FM-001 has TWO structural classes: TRAILING COMMENTS (the template defect above) and NESTED MAPS (a top-level key whose value is an indented child map). The trailing-comment class was migrated corpus-wide at commit 4c02b556; this task's migrator carries a SECOND capability that clears the nested-map residual (4004 findings across 140 specs) plus one apostrophe edge, so FM-001 reaches 0 for real.
Problem
FM-001 fires on 501 of 544 specs - 92% of the corpus. 149 of them are status: done: they passed two human gates while violating a machine-floor rule. The cause is line 2 of the shipped template. Every spec inherits the violation by construction, and because the lint was never run corpus-wide, nobody found out. The template is vendored, so every consumer repo has the same disease.
That framing is only the FIRST of FM-001's two structural classes. After the trailing-comment class was migrated corpus-wide (commit 4c02b556), a re-lint showed the count did NOT reach 0: a residual of 4004 findings across 140 specs remained, plus 1 residual trailing-comment. The 4004 are a SECOND class - nested-map frontmatter under a single top-level key, build_envelope:, whose indented children the strict task@1 reader flags one line at a time as "indented line outside a block list". No tool reads build_envelope (it is inert data), task-lint rejects nested maps by design, and a done sibling (TASK-SKILL-104) already carries the same envelope data FLAT at top level - the shape batch-select reads. The 1 residual is a defect in the migrator's own quote detector (a mid-value apostrophe in broker's read as an opening quote), not in the lint. The full evidence, with a command behind every number, is docs/tasks/_audits/2026-07-18-fm001-nested-map-fork.md.
Proposed Solution
Move every trailing frontmatter comment in the template to its own line above its field, keeping all guidance text. Ship docs-tools/fm001-migrate.mjs (stdlib, guarded, idempotent, --check) in the payload so consumers can run it themselves. The migrator carries TWO capabilities: it moves trailing comments own-line (class 1), and it FLATTENS a nested-map key by hoisting its children to top-level keys (class 2), reconciling any collision by order-preserving union and halting on a genuine scalar conflict. Migrate this corpus with both. The body is never touched, so the audit bindings survive - and the bound specs carry neither class, so the migrator is a no-op on every one of them.
Alternatives Considered
- Narrow FM-001 to only the fields consumers parse (id/status/priority/type). Rejected: the rule was written in response to a real defect, and a rule narrowed to fit a broken corpus stops being a rule.
- Fix the template only, leave the 501. Rejected by the operator: the machine floor stays un-runnable repo-wide, which is exactly how this hid for the life of the project.
- Delete the template's annotations instead of moving them. Rejected: they are the template's teaching value; an author reading
type: featurewith no hint is worse off. - Run the migrator across consumer repos from here. Rejected: shipping a tool the operator runs is a migration; reaching into another repo and rewriting 500 files is an incident.
Success Metrics
- FM-001 count across
docs/tasks/*/*/spec.mdreaches 0 corpus-wide, across BOTH structural classes (trailing comments AND nested maps). The trailing-comment class went first (commit 4c02b556); this task's second capability clears the nested-map residual of 4004 findings across 140 specs plus the one apostrophe edge. - A spec authored fresh from the template lints clean with no hand-editing.
- All 19
audited_body_sha256_prefixvalues are unchanged after the migration. The mechanism is §1.4 (body untouched) reinforced by disjointness: every audit-bound spec carries NEITHER class, so the migrator is a byte-for-byte no-op on it. dist/cyberos/docs-tools/fm001-migrate.mjsis present, so a consumer repo can do the same.
AI Authorship Disclosure
- Tools used: Claude (Fable 5) during the 2026-07-17 hardening run, via the CyberOS create-tasks and ship-tasks workflows.
- Scope: spec drafted by the agent from a defect it surfaced while advancing TASK-IMP-116 past its review gate. The 501/544 and 149-done figures are measured, not estimated. The migration scope (all corpora, including done specs, not just live ones) is the operator's recorded decision - the agent had proposed the narrower live-specs-only option and was overruled.
- Human review: @stephencheng at the ready_to_implement gate, and again at both HITL gates.
Dependencies
None. FM-001 already exists in task-lint (TASK-IMP-084) and the relUnderRoot guard already exists in docs-tools (TASK-IMP-109). This task adds no rule and invents no guard; it makes the template obey a rule that has been on the books since the machine floor shipped.
1. Clauses
1.1 TASK-TEMPLATE.md frontmatter carries no trailing comments and no aligned continuation comments. Every comment is own-line, above the field it documents. No guidance text is deleted - the annotations are the template's teaching value and they move, they do not go. Test: t01_template_is_clean
1.2 A new payload helper docs-tools/fm001-migrate.mjs rewrites trailing frontmatter comments to own-line comments. It ships in the payload (build.sh vendor list) so any installed repo can run it against its own corpus. Node stdlib only, exit-code discipline (0 clean/migrated, 2 usage), --json, --check (report without writing). Test: t02_migrator_moves_trailing_comments
1.3 The migrator NEVER splits on a # inside a quoted value. title: "Fix the # parsing bug" is left byte-identical. A # that is not preceded by whitespace is not a comment. Test: t03_hash_inside_value_is_not_a_comment
1.4 The migrator NEVER touches the body. Only the frontmatter block between the first two --- lines is in scope. This is what preserves audited_body_sha256_prefix on the 19 bound specs - proven live twice this session: a status edit moves the file hash and leaves the body hash fixed. Test: t04_body_is_untouched_and_body_hash_holds
1.5 Idempotent. Running the migrator twice produces a byte-identical file the second time. Test: t05_idempotent
1.6 Migration of this repo's corpus: all 501 specs, including the 149 at status: done. Per the operator's scope decision, done specs are migrated - the frontmatter is metadata about the task, not the shipped record, and the body hash that binds the audit is untouched by 1.4. Test: t06_corpus_is_fm001_clean
1.7 The migrator refuses to run outside a repo root it can confirm, using the same relUnderRoot guard as task-reconcile / coverage-scope / verify-goals: confine, exist, git ls-tree HEAD tracked. An untracked path is REFUSED, not migrated. Test: t07_guard_refuses_untracked_and_escaping_paths
1.8 The migrator ALSO flattens a nested-map frontmatter key - a top-level key whose value is an indented child block (e.g. build_envelope: followed by language: rust 1.81), which the strict task@1 reader rejects one line at a time as "indented line outside a block list". It hoists the children to top-level keys: the child block is dedented by its own base indent and the parent line dropped, so new_files: / - x become top-level new_files: / - x. This is GENERAL to any nested-map key per FM-001's definition, not hard-coded to build_envelope. Each child value and its order are preserved byte-for-byte (only leading indentation changes); a child that is itself a block list (new_files: / modified_files: with - item lines) becomes a top-level block list. A child block that is ALREADY a plain block list (all - item, e.g. source_pages:) is not a nested map and is left untouched. When a hoisted key already exists at top level the migrator RECONCILES by order-preserving union - two block lists merge, exact-duplicate item values deduped, nothing unique dropped; two scalars of equal value dedupe to one - and NEVER silently drops or overwrites. A genuine scalar conflict, or a list/scalar kind mismatch, HALTS that file: it is named, and nothing in it is migrated. Test: t09_flattens_nested_map
1.9 A quote is a string delimiter ONLY when it BEGINS the scalar value. In a PLAIN (unquoted) scalar a mid-token apostrophe is a literal character, so a block item such as - allow ... broker's ... (per §1 #4 - seal stdin/stdout/stderr only) correctly detects the #4 as a trailing comment and moves it own-line - matching task-lint, which reads broker's as a literal apostrophe and # as a plain-scalar comment. A # inside a value that BEGINS with a quote (label: 'issue # 42 stays', title: "Fix the # bug") stays data. This corrects a defect in the 1.3 detector, which entered single-quote state on the mid-value apostrophe and so missed the comment (docs/tasks/_audits/2026-07-18-fm001-nested-map-fork.md §5: task-lint's quote model is right, the migrator's was wrong). No existing clause is weakened: 1.3's quoted-value protection (edges #5/#6) is preserved exactly - a value that BEGINS with a quote is still honoured. Test: t10_apostrophe_then_hash_is_a_comment
Scope
In scope: TASK-TEMPLATE.md, the new docs-tools/fm001-migrate.mjs (BOTH capabilities - the trailing-comment move and the nested-map flatten, plus the §1.9 quote fix) + its suite, the build.sh vendor list, this repo's corpus (trailing-comment class already migrated at 4c02b556; this task clears the nested-map residual across 140 specs and the one apostrophe edge), and a note in the install summary telling a consumer repo the migrator exists.
Out of scope / Non-Goals
- Running the migrator against consumer repos. This ships the tool; the operator runs it per repo. An agent that reaches into another repo and rewrites 500 files is not a migration, it is an incident.
- FM-112's
# UNREVIEWEDmarkers. Moving a marker to its own line keeps it firing, correctly - a human still has not confirmedai_authorship. Clearing FM-112 is a human attestation and this task does not forge it. - The other 2 rules TASK-EVAL-001 trips (FM-004, FM-112). Out of cone.
- Any change to FM-001 itself. The rule is right for BOTH classes; only the template (class 1) and the specs (class 2) were wrong. Specifically, RELAXING FM-001 to accept nested maps (the investigation's route b) is REJECTED: nested maps are outside the documented task@1 subset, no tool reads
build_envelope, and task-lint rejects them by design - accepting them would be atask@2schema bump, not a lint fix. This task takes route (a): migrate the nested maps to the flat shape adonesibling already uses. The lint is unchanged.
3. Edge case matrix
| # | Category | Trigger | Expected | Test |
|---|---|---|---|---|
| 1 | NULL/EMPTY | frontmatter with no comments | byte-identical no-op | t05 |
| 2 | NULL/EMPTY | file with no frontmatter block | refuse, exit 2, name the file | t07 |
| 3 | BOUNDS | comment is the whole line already | left alone, not doubled | t05 |
| 4 | BOUNDS | # at column 0 inside frontmatter | already own-line, untouched | t05 |
| 5 | MALFORMED | # inside a double-quoted value | NOT a comment, untouched | t03 |
| 6 | MALFORMED | # inside a single-quoted value | NOT a comment, untouched | t03 |
| 7 | MALFORMED | # with no leading whitespace (a#b) | NOT a comment, untouched | t03 |
| 8 | MALFORMED | list item - path # note | comment moves above the item, indent preserved | t02 |
| 9 | MALFORMED | CRLF line endings | round-trip preserved, never normalized | t02 |
| 10 | MALFORMED | aligned continuation comment (template lines 6-9) | folded into the own-line block | t01 |
| 11 | CONCURRENT | two migrators, same file | two-phase atomic write, last wins, never truncated | t05 |
| 12 | SECURITY | path escapes the repo root | REFUSED, not executed | t07 |
| 13 | SECURITY | path exists but is untracked at HEAD | REFUSED - an untracked spec is not corpus | t07 |
| 14 | DEGRADATION | git absent / not a repo | refuse and say so; never migrate unguarded | t07 |
| 15 | DEGRADATION | file unreadable mid-run | report the file, exit non-zero, migrate nothing | t07 |
| 16 | NESTED MAP | build_envelope: with 6 map children | 6 top-level keys; values + order preserved; FM-001 cleared | t09 |
| 17 | NESTED MAP | top-level new_files + build_envelope.new_files, one item shared | order-preserving union; exact dup deduped; nothing dropped; no FM-003 | t09 |
| 18 | NESTED MAP | a done spec carrying build_envelope | flattened; body untouched, so audited_body_sha256_prefix holds | t04, t09 |
| 19 | MALFORMED | hoisted key collides as a scalar with a different value | HALT; name the file; migrate nothing in it | t09 |
| 20 | MALFORMED | plain scalar broker's ... #4 (apostrophe then #) | #4 detected as a comment and moved own-line | t03, t10 |
| 21 | NESTED MAP | flatten output re-run through the migrator | byte-identical no-op (idempotent across both passes) | t05, t09 |
4. Out of scope / non-goals
See "## Scope -> ### Out of scope / Non-Goals" above - this section is the engineering half's pointer to it.
Acceptance criteria
- AC1:
TASK-TEMPLATE.mdpasses task-lint with zero FM-001. Cited:t01_template_is_clean. - AC2: A spec authored fresh from the migrated template passes task-lint clean. Cited:
t01_template_is_clean. - AC3: The migrator satisfies 1.2-1.5 and 1.7. Cited:
t02..t05,t07. - AC4:
docs/tasks/*/*/spec.mdreports zero FM-001 corpus-wide. Cited:t06_corpus_is_fm001_clean. - AC5: The 19 audit-bound specs keep their
audited_body_sha256_prefixbyte-for-byte across the migration. Cited:t04_body_is_untouched_and_body_hash_holds. - AC6:
dist/cyberos/docs-tools/fm001-migrate.mjsexists and matches source. Cited:t08_payload_carries_it. - AC7: The 140 nested-map (
build_envelope) specs are flattened - their children hoisted to top-level keys, values and order preserved, collisions unioned - and the corpus FM-001 count is 0 INCLUDING them. Cited:t09_flattens_nested_map(capability, scratch corpus) plus a real-corpus check:node tools/install/docs-tools/task-lint.mjs --json docs/tasks, parsing the JSON array and filteringrule_id === "FM-001", reports length 0 (before: 4005 = 4004 nested-map + 1 apostrophe). AC4's "FM-001 = 0 corpus-wide" is now genuinely reachable because this second capability clears the class AC1-AC6's trailing-comment migrator never modelled.
Audit
Audit - TASK-IMP-117 (re-audit after nested-map amendment, 2026-07-18)
Re-audit of the spec after it was amended to honestly cover FM-001's SECOND structural class (nested maps) alongside the original trailing-comment class. The prior audit (2026-07-17, body 547a9a53e34784f1) judged the trailing-comment-only spec; this one judges the amended spec (body 1467a30b176cb219). The change of audited_body_sha256_prefix is expected: the spec's normative half changed when clauses 1.8/1.9, AC7, and edge rows 16-21 were added.
Machine floor ran first per TASK-IMP-084. Command and result (re-derived, not recalled):
$ node tools/install/docs-tools/task-lint.mjs docs/tasks/improvement/TASK-IMP-117-fm001-conformance/spec.md
info TRACE-001 .../spec.md:1 no numbered '- 1.N' clauses under '## 1. Description' ...
$ echo $? # 0 → zero error-severity findings
0 errors, 1 TRACE-001 info. The info is by design and unchanged from the prior audit: the clause block is ## 1. Clauses with 1.N numbering rather than ## 1. Description with - 1.N bullets, so the structural lint hands clause traceability to this model audit, which follows.
What I verified vs. reconstructed
Marked honestly per the standing session finding (authors do not check what they originate).
Verified by re-run this session (command behind every number):
- FM-001 corpus count is 4005 at HEAD
8dd0ca2f:node task-lint.mjs --json docs/tasksparsed and filtered onrule_id === "FM-001"→ 4005 findings across 141 files; message split = 4004 "indented line outside a block list" + 1 "trailing comment after value". - 140 specs carry
^build_envelope:(grep -rl→ 140); they are exactly the 140 indented-line files. The 1 trailing-comment residual isTASK-SKILL-104:63(the apostrophe edge). - The nested-map flatten, collision union, done-spec body-hold, and apostrophe fix were demonstrated EMPIRICALLY on scratch git repos before this audit: a 6-child
build_envelope→ 6 flat top-level keys with byte-identical values, FM-001 11→0, body hash held, idempotent; anew_filescollision → order-preserving union (shared item deduped, nothing unique dropped), FM-001 6→0, FM-003 stays 0; thebroker's ... #4plain scalar →#4moved own-line, FM-001 1→0, whilelabel: 'issue # 42 stays'(value begins with a quote) stayed data. - A
--check --jsondry run overdocs/tasks/*/*/spec.mdreports 141 would-migrate (140 flatten + 1 comment-only), 411 clean, 0 refused, and no file flattens more than one nested map — so the amended §1.8/§1.9 remit matches the real residual exactly. - Binding disjointness: the 140
build_envelopedirs and the dirs carrying anaudited_body_sha256_prefixbinding are DISJOINT (comm -12of the two sorted dir sets → empty);TASK-SKILL-104is not audit-bound. So the migrator is a byte-for-byte no-op on every bound spec.
Reconstructed (cited from the investigation / commit history, NOT re-run by me):
- The pre-migration trailing-comment counts (2104 → 1) and the "497 specs migrated by
4c02b556" figure come fromdocs/tasks/_audits/2026-07-18-fm001-nested-map-fork.md, which backs them with commands; I did not reconstruct the pre-4c02b556corpus state. - The origin-era "501 of 544" and "149 done" figures in §Problem / §AI Authorship Disclosure are the task's originating measurements (trailing-comment class); I preserved them as history and did not re-run them. The current two-class reality (4005 across 552 specs) is what the amended sections and AC7 state and what I re-derived.
Findings
ISS-001 (accepted, TRACE-001 info): as above — heading shape, not a traceability gap. Every clause 1.1-1.9 cites a named test; every AC1-AC7 cites a clause or a test.
ISS-002 (accepted, honest correction — AC5's "40"): AC5 cites "40 audit-bound specs". The current measured count is 19 (grep -rl '^audited_body_sha256_prefix:' docs/tasks/ → 19 audit.md files, IMP-102..120). The "40" is the spec's origin-era figure; I did not edit AC5 (amending it is outside this task's nested-map remit and would touch a class-1 clause), but I record the true count here. The INVARIANT AC5 asserts — no binding changes across the migration — holds regardless of the count and is now DOUBLY assured: §1.4 (body untouched) AND disjointness (bound specs carry neither class). Only IMP-117's OWN binding changes, and that is this re-audit, not the migration.
ISS-003 (resolved — the honest-remit gap the amendment closes): the pre-amendment spec framed FM-001 as only the 501 trailing-comment findings and its §Out-of-scope was SILENT on nested maps, so AC4's "FM-001 = 0 corpus-wide" was UNREACHABLE by the AC1-AC6 migrator (which never modelled nested maps). Resolved: §Summary/§Problem now name both classes; §1.8 adds the general nested-map flatten; §1.9 fixes the apostrophe quote model; AC7 binds the 140-spec flatten to a real-corpus FM-001==0 check; §Out-of-scope now explicitly REJECTS relaxing FM-001 (route b) and takes route (a). No existing clause or AC was weakened — 1.3's quoted-value protection is explicitly preserved by 1.9.
ISS-004 (accepted — §1.8 reconcile is specified, not hand-waved): the collision path names its exact policy (order-preserving union for lists, dedupe-equal for scalars, HALT-and-name for a genuine scalar/kind conflict) and binds it to edge rows 17 (union) and 19 (halt), both cited to t09. The two real collision files (PLUGIN-003, TEN-002) are new_files list-vs-list unions; the corpus has zero scalar collisions (top-level language/service/modified_files/allowed_tools/disallowed_tools = 0 across the 140), so the HALT path is a guard, not a corpus need — correctly specified anyway.
ISS-005 (accepted — body-binding mechanism): §1.4 (body never touched) is the mechanism that lets a done build_envelope spec be flattened without moving its normative-half hash (edge row 18). Only 14 of the 140 are done and none is audit-bound, so this is belt-and-suspenders; the clause is correct and the property was demonstrated live on the scratch done-spec case.
ISS-006 (accepted — idempotence across BOTH passes): edge row 21 binds "flatten output re-run = byte-identical no-op" to t05+t09. Demonstrated on scratch: a second run after flatten is cmp-clean. Idempotence now spans the flatten pass (hoisted keys re-parse as plain block lists / scalars) and the comment pass (own-line comments are skipped by the ^\s*# guard).
Rubric families
- FM: clean (machine floor: 0 errors). FM-001/002/003/004 pass; all per-field FM-1xx present and in-enum. The amendment touched only the body, so the frontmatter verdict is unchanged.
- SEC: all seven required H2s present and non-empty (Summary, Problem, Proposed Solution, Alternatives Considered, Success Metrics, Scope, Dependencies). One H1, no level jumps (SEC-009).
- COND:
ai_authorship: generated_then_reviewed→ COND-004 satisfied by the three-bullet disclosure (Tools used / Scope / Human review).client_visible: false,eu_ai_act_risk_class: not_ai→ COND-001/002/003 not triggered. - QA: metrics carry baseline+target+source (FM-001 4005→0, 140 specs, all re-derived — QA-004/007 clean); ≥2 distinct Alternatives (QA-005); §Scope has
### Out of scope / Non-Goalswith multiple bullets (QA-006). Edge-case matrix is 21 rows spanning NULL/EMPTY, BOUNDS, MALFORMED, CONCURRENT, SECURITY, DEGRADATION, and the new NESTED MAP category — well above the 8-row floor. - SAFE: no
<untrusted_content>blocks and no injection markers; the spec quotes tool paths and guard names, no foreign bytes. - TRACE (semantic, this gate's job): every §1 clause carries a BCP-14 verb and a cited test, and the cited test's specified assertion discharges the verb — 1.8 (flatten/hoist/preserve/reconcile/ HALT) → t09 asserts flatten-to-top-level, byte-equal values, union-nothing-dropped, and refuse-on- conflict; 1.9 (detect
#as comment and move own-line, keep quoted#as data) → t10 asserts the moved comment, the preserved value line, and the untouched quoted value. TRACE-004 (each cited test actually PASSES) and TRACE-006's live pass/fail are the coverage gate's job attesting → done; this spec-correctness gate confirms the citations and the verb→evidence match, both of which hold.
Verdict
pass - 10/10. The amendment makes the spec honest about what the migrator must do: FM-001 has two structural classes, and the spec now specifies and tests BOTH. It adds no rule and invents no guard — §1.8 hoists to a shape a done sibling already uses and §1.9 aligns the migrator's quote model with task-lint's. AC4's corpus-wide FM-001==0 is now genuinely reachable, and AC7 binds it to a re-derived real-corpus check. No existing clause or AC was weakened.