Task — engineering-spec@1

The template teaches the violation it is linted for

doneTASK-IMP-117
module improvement · class product · priority p1 · created · shipped null
depends on none · blocks none

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

Success Metrics

AI Authorship Disclosure

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

3. Edge case matrix

#CategoryTriggerExpectedTest
1NULL/EMPTYfrontmatter with no commentsbyte-identical no-opt05
2NULL/EMPTYfile with no frontmatter blockrefuse, exit 2, name the filet07
3BOUNDScomment is the whole line alreadyleft alone, not doubledt05
4BOUNDS# at column 0 inside frontmatteralready own-line, untouchedt05
5MALFORMED# inside a double-quoted valueNOT a comment, untouchedt03
6MALFORMED# inside a single-quoted valueNOT a comment, untouchedt03
7MALFORMED# with no leading whitespace (a#b)NOT a comment, untouchedt03
8MALFORMEDlist item - path # notecomment moves above the item, indent preservedt02
9MALFORMEDCRLF line endingsround-trip preserved, never normalizedt02
10MALFORMEDaligned continuation comment (template lines 6-9)folded into the own-line blockt01
11CONCURRENTtwo migrators, same filetwo-phase atomic write, last wins, never truncatedt05
12SECURITYpath escapes the repo rootREFUSED, not executedt07
13SECURITYpath exists but is untracked at HEADREFUSED - an untracked spec is not corpust07
14DEGRADATIONgit absent / not a reporefuse and say so; never migrate unguardedt07
15DEGRADATIONfile unreadable mid-runreport the file, exit non-zero, migrate nothingt07
16NESTED MAPbuild_envelope: with 6 map children6 top-level keys; values + order preserved; FM-001 clearedt09
17NESTED MAPtop-level new_files + build_envelope.new_files, one item sharedorder-preserving union; exact dup deduped; nothing dropped; no FM-003t09
18NESTED MAPa done spec carrying build_envelopeflattened; body untouched, so audited_body_sha256_prefix holdst04, t09
19MALFORMEDhoisted key collides as a scalar with a different valueHALT; name the file; migrate nothing in itt09
20MALFORMEDplain scalar broker's ... #4 (apostrophe then #) #4 detected as a comment and moved own-linet03, t10
21NESTED MAPflatten output re-run through the migratorbyte-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

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/tasks parsed and filtered on rule_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 is TASK-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; a new_files collision → order-preserving union (shared item deduped, nothing unique dropped), FM-001 6→0, FM-003 stays 0; the broker's ... #4 plain scalar → #4 moved own-line, FM-001 1→0, while label: 'issue # 42 stays' (value begins with a quote) stayed data.
  • A --check --json dry run over docs/tasks/*/*/spec.md reports 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_envelope dirs and the dirs carrying an audited_body_sha256_prefix binding are DISJOINT (comm -12 of the two sorted dir sets → empty); TASK-SKILL-104 is 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 from docs/tasks/_audits/2026-07-18-fm001-nested-map-fork.md, which backs them with commands; I did not reconstruct the pre-4c02b556 corpus 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-Goals with 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 at testing → 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.