@nurix/etna 0.20.15 → 0.20.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/data/harness-index.json +1 -1
  2. package/data/harnesses/code/CLAUDE.md +8 -2
  3. package/data/harnesses/code/agents/bookkeeper.md +1 -0
  4. package/data/harnesses/code/agents/polygraph-finder.md +4 -0
  5. package/data/harnesses/code/harness.json +2 -2
  6. package/data/harnesses/code/hooks/awareness.mjs +16 -3
  7. package/data/harnesses/code/hooks/lib/state.mjs +1 -0
  8. package/data/harnesses/code/hooks/lib/sweep.mjs +21 -3
  9. package/data/harnesses/code/skills/documentation/SKILL.md +28 -0
  10. package/data/harnesses/code/skills/documentation/scripts/comment-strip.mjs +339 -0
  11. package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +42 -10
  12. package/data/harnesses/code/skills/documentation/scripts/source-lint.mjs +222 -0
  13. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -2
  14. package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +1 -1
  15. package/data/harnesses/code/skills/polygraph/SKILL.md +1 -1
  16. package/data/harnesses/code/skills/testing/SKILL.md +1 -1
  17. package/data/harnesses/delivery/harness.json +2 -2
  18. package/data/harnesses/delivery/skills/documentation/SKILL.md +28 -0
  19. package/data/harnesses/delivery/skills/documentation/scripts/comment-strip.mjs +339 -0
  20. package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +42 -10
  21. package/data/harnesses/delivery/skills/documentation/scripts/source-lint.mjs +222 -0
  22. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -2
  23. package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +1 -1
  24. package/data/harnesses/etna/harness.json +2 -2
  25. package/data/harnesses/etna/skills/documentation/SKILL.md +28 -0
  26. package/data/harnesses/etna/skills/documentation/scripts/comment-strip.mjs +339 -0
  27. package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +42 -10
  28. package/data/harnesses/etna/skills/documentation/scripts/source-lint.mjs +222 -0
  29. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -2
  30. package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +1 -1
  31. package/package.json +2 -2
@@ -17,19 +17,36 @@ const RULES = [
17
17
  { id: 'status-glyph', severity: 'warn', scope: 'truth', exempt: /(^|\/)docs\/(tests|operations)\//, pattern: /(^|\s)(✅|⏳|- \[[ x]\])/u, message: 'status glyph / checklist in a truth doc — tracking belongs in a record' },
18
18
  // Truth docs are supposed to cite records, and record filenames are dated — so a date inside a link
19
19
  // target or a code span is a path, not narration. Strip those before matching.
20
- { id: 'date-in-truth-doc', severity: 'warn', scope: 'truth', strip: /\[[^\]]*\]\([^)]*\)|`[^`]*`/g, pattern: /\b20\d{2}-\d{2}-\d{2}\b/, message: 'date in a truth doc — only the AD Decision Changelog and structure.md Health Log tails carry dates' },
20
+ { id: 'date-in-truth-doc', severity: 'block', scope: 'truth', strip: /\[[^\]]*\]\([^)]*\)|`[^`]*`|^(last-reviewed|last-updated|rev|harness-version):.*$/gm, pattern: /\b20\d{2}-\d{2}-\d{2}\b/, message: 'date in a truth doc — cite the record by name and link; the closed exceptions are the frontmatter tooling keys, an AD Decision Changelog tail and structure.md\'s Health Log tail' },
21
21
  ];
22
22
 
23
23
  // Sections inside a truth doc where dated history is sanctioned (the two bridges).
24
24
  const BRIDGE_HEADINGS = /^#{1,4}\s+(decision changelog|health log)\b/i;
25
- // Instruction files and generated trees are a different genre — out of this linter's scope.
26
- const SKIP = /(^|\/)(\.claude|node_modules|skills|rules|agents|harness|packages\/[^/]+\/data)\/|(^|\/)(CLAUDE|AGENTS)\.md$|\.bkp\./;
27
- const RECORD = /(^|\/)docs\/(history|research|memory)\//;
25
+ // Instruction files, the three harness-installed mirrors and generated trees are out of scope; the authoring
26
+ // roots are anchored to the repo root.
27
+ const SKIP = /^(\.claude|\.agents|\.codex|skills|rules|agents|harness)\/|(^|\/)(node_modules|packages\/[^/]+\/data)\/|(^|\/)(CLAUDE|AGENTS)\.md$|\.bkp\./;
28
+ // `operations/issues/YYYY-MM-DD-{slug}.md` is a sanctioned record genre (`type: issue`, the
29
+ // `issue-bridge` skill's local pack) living outside the record folders, so it is named here too —
30
+ // graded as a truth document it fails the date rule for carrying the date its filename already does.
31
+ const RECORD = /(^|\/)docs\/(history|research|memory)\/|(^|\/)docs\/operations\/issues\//;
28
32
 
29
- // Classify a path: skipped genre, record, or truth.
30
- function scopeOf(path) {
33
+ const RECORD_TYPES = /^(changelog-entry|plan|migration|migration-runbook|research|brainstorm|journal|record|ledger|issue|triage|project-memory|agent-memory)$/;
34
+ const TRUTH_LOCATIONS = /(^|\/)docs\/(features|architecture|tests|acceptance)\//;
35
+
36
+ // The genre a document declares for itself, from its own frontmatter `type:` key, or null.
37
+ function declaredType(text) {
38
+ if (!text.startsWith('---')) return null;
39
+ const end = text.indexOf('\n---', 3);
40
+ const m = /^type:\s*(\S+)\s*$/m.exec(end === -1 ? text : text.slice(0, end));
41
+ return m ? m[1] : null;
42
+ }
43
+
44
+ // Classify a document: record or truth, the declared genre outranking the path.
45
+ function scopeOf(path, text) {
31
46
  if (SKIP.test(path)) return 'skip';
32
- return RECORD.test(path) ? 'record' : 'truth';
47
+ if (RECORD.test(path)) return 'record';
48
+ const declared = text === undefined ? null : declaredType(text);
49
+ return declared && RECORD_TYPES.test(declared) ? 'record' : 'truth';
33
50
  }
34
51
 
35
52
  // Yield prose lines only: frontmatter, fenced code blocks, and bridge sections drop out.
@@ -37,13 +54,21 @@ function proseLines(text) {
37
54
  const lines = text.split('\n');
38
55
  const out = [];
39
56
  let inFence = false;
57
+ let fenceMarker = '';
40
58
  let inFrontmatter = lines[0] === '---';
41
59
  let inBridge = false;
42
60
  let bridgeLevel = 0;
43
61
  for (let i = inFrontmatter ? 1 : 0; i < lines.length; i++) {
44
62
  const line = lines[i];
45
63
  if (inFrontmatter) { if (line === '---') inFrontmatter = false; continue; }
46
- if (/^\s*(```|~~~|````)/.test(line)) { inFence = !inFence; continue; }
64
+ // A fence closes only on a marker at least as long as the one that opened it, so a nested ```yaml
65
+ // inside a ```markdown block does not invert the state and expose the outer block as prose.
66
+ const fence = /^\s*(`{3,}|~{3,})/.exec(line);
67
+ if (fence) {
68
+ if (!inFence) { inFence = true; fenceMarker = fence[1]; }
69
+ else if (fence[1][0] === fenceMarker[0] && fence[1].length >= fenceMarker.length) inFence = false;
70
+ continue;
71
+ }
47
72
  if (inFence) continue;
48
73
  const heading = line.match(/^(#{1,4})\s/);
49
74
  if (heading) {
@@ -64,9 +89,9 @@ function fileDisables(text) {
64
89
  }
65
90
 
66
91
  function lintFile(path, findings) {
67
- const scope = scopeOf(path);
68
- if (scope === 'skip' || !existsSync(path)) return;
92
+ if (SKIP.test(path) || !existsSync(path)) return;
69
93
  const text = readFileSync(path, 'utf8');
94
+ const scope = scopeOf(path, text);
70
95
  const disabled = fileDisables(text);
71
96
  const lines = proseLines(text);
72
97
 
@@ -85,6 +110,13 @@ function lintFile(path, findings) {
85
110
  }
86
111
  }
87
112
 
113
+ // A record genre declared in a settled truth location is a contradiction, not a classification.
114
+ if (TRUTH_LOCATIONS.test(path)) {
115
+ const declared = declaredType(text);
116
+ if (declared && RECORD_TYPES.test(declared) && !disabled.has('record-type-in-truth-location'))
117
+ findings.push({ path, n: 1, severity: 'block', id: 'record-type-in-truth-location', message: `\`type: ${declared}\` is a record genre declared in a truth location — move the file to \`docs/history/\` if it is a record, or give it its location's genre` });
118
+ }
119
+
88
120
  // A folder index (Readme.md/README.md) is a browse page, not a feature doc — it owns no code.
89
121
  const isFolderIndex = /(^|\/)(readme)\.md$/i.test(path);
90
122
  if (scope === 'truth' && !isFolderIndex && /(^|\/)docs\/features\/[^/]+\.md$/.test(path)) {
@@ -0,0 +1,222 @@
1
+ // skills/documentation/scripts/source-lint.mjs - mechanical checks over source comments (header path, density, tense, shouting)
2
+ // Usage: node source-lint.mjs <file | dir ...> | node source-lint.mjs --changed (git-changed + untracked source files)
3
+ // Exit 1 when any block-severity finding survives. Escape hatch: a `source-lint-disable: id[, id]` comment,
4
+ // file-wide in the first 20 lines or on the offending line; the reason goes in a second comment beside it.
5
+ // The convention itself is the harness `CLAUDE.md` § File Structure; this checks only what is mechanical.
6
+
7
+ import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
8
+ import { execSync, spawnSync } from 'node:child_process';
9
+ import { basename, extname } from 'node:path';
10
+
11
+ // Comment syntax per extension, and whether a leading `#!` shebang may precede the header.
12
+ const DIALECTS = {
13
+ '.ts': '//', '.tsx': '//', '.mts': '//', '.cts': '//',
14
+ '.js': '//', '.jsx': '//', '.mjs': '//', '.cjs': '//',
15
+ '.rs': '//', '.go': '//', '.java': '//', '.kt': '//', '.swift': '//', '.c': '//', '.h': '//', '.cpp': '//',
16
+ '.css': '/*', '.scss': '//',
17
+ '.sql': '--',
18
+ '.py': '#', '.sh': '#', '.zsh': '#', '.bash': '#', '.rb': '#',
19
+ };
20
+
21
+ // Generated trees, vendored copies, and formats with no comment syntax are a different genre. `.claude/`,
22
+ // `.agents/` and `.codex/` are harness-owned in every consumer — overwritten on the next roll, so a finding
23
+ // there names work nobody in that repo can land. The forge lints its own copies at `harness/`, not here.
24
+ const SKIP = /(^|\/)(node_modules|dist|build|target|\.git|\.claude|\.agents|\.codex|coverage|__screenshots__|data\/harnesses)\/|\.min\.|\.d\.ts$|(^|\/)(pnpm-lock|package-lock)\./;
25
+ // A generated file says so in its own first lines; the convention exempts it.
26
+ const GENERATED = /@generated|DO NOT EDIT|AUTO-GENERATED|This file was generated/i;
27
+ // Below this a file is too small for a density ratio to mean anything.
28
+ const RATIO_MIN_LINES = 40;
29
+ // A file-level ceiling, not a target. The house style sits near 14%; a file past this is carrying prose.
30
+ // It grades INHERITED density, so it only warns: a file somebody else wrote at 42% is not this session's
31
+ // debt, and a wall that blocks on it stops every commit in the repo until a rewrite pass lands.
32
+ const RATIO_CEILING = 0.35;
33
+ // The ceiling that actually holds the line, because it grades only the lines THIS change adds. The measured
34
+ // regression that prompted the rule ran at 28% on new files while the tree sat at 14% — a file-level 35%
35
+ // ceiling passes that cleanly, so the file-level rule cannot be what stops it. You own what you add.
36
+ const ADDED_RATIO_CEILING = 0.25;
37
+ // Below this an added-line ratio is noise — a two-line fix with one comment is not a prose problem.
38
+ const ADDED_MIN_LINES = 20;
39
+ // The header's own ceiling in wrapped lines, from the measured p99 of the convention's own sweep.
40
+ const HEADER_MAX_LINES = 6;
41
+ // A comment block longer than this is a document living in a source file.
42
+ const BLOCK_MAX_LINES = 12;
43
+
44
+ const RULES = {
45
+ 'header-missing': { severity: 'block', message: 'no file-path header comment — add one naming this file\'s repo-relative path' },
46
+ 'header-path-stale': { severity: 'block', message: 'the header names another path — a header a move left behind reads as current' },
47
+ 'header-too-long': { severity: 'warn', message: `header past ${HEADER_MAX_LINES} lines — two or three sentences, and the fuller account goes in its doc` },
48
+ 'comment-ratio': { severity: 'warn', message: `comment lines over ${Math.round(RATIO_CEILING * 100)}% of the file — the rationale belongs in the owning doc` },
49
+ 'comment-ratio-added': { severity: 'block', message: `over ${Math.round(ADDED_RATIO_CEILING * 100)}% of the lines this change adds are comments — one sentence per symbol, and the rationale goes in the owning doc` },
50
+ 'comment-block-long': { severity: 'warn', message: `a comment block past ${BLOCK_MAX_LINES} lines — one sentence per symbol; a paragraph is a doc, unless it names a booby trap` },
51
+ 'comment-narration': { severity: 'warn', message: 'history, a ticket or decision-record reference, or a dated note in a comment — the journey belongs in a record' },
52
+ 'comment-shouting': { severity: 'warn', message: 'an ALL-CAPS clause opener — comments are third-person present tense, not emphasis' },
53
+ };
54
+
55
+ const NARRATION = /\bused to\b|\bno longer\b|\bpreviously\b(?!-)|\bwas (renamed|removed|extracted|folded|migrated|replaced|ported)\b|\bported from\b|\bfor now\b|\bnot yet implemented\b|\b(AD|ADR)-\d+\b|\b20\d{2}-\d{2}-\d{2}\b/i;
56
+ const SHOUTING = /(?:^|[.:;]\s+)([A-Z][A-Z0-9 ,'`\-]{14,})(?=[.,:]|\s+[a-z])/;
57
+
58
+ // The comment token for a path, or null when the format has none.
59
+ function tokenFor(path) {
60
+ return DIALECTS[extname(path)] ?? null;
61
+ }
62
+
63
+ // True when the line is a comment line in this dialect (an open block, a continuation, or a line comment).
64
+ function isCommentLine(line, token) {
65
+ const t = line.trim();
66
+ if (t === '') return false;
67
+ if (token === '//') return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('*/');
68
+ if (token === '/*') return t.startsWith('/*') || t.startsWith('*') || t.startsWith('*/');
69
+ if (token === '--') return t.startsWith('--') || t.startsWith('/*') || t.startsWith('*');
70
+ return t.startsWith(token);
71
+ }
72
+
73
+ // The leading comment block: its lines, skipping a shebang and any blank lines above it.
74
+ function leadingBlock(lines, token) {
75
+ let i = 0;
76
+ if (lines[0]?.startsWith('#!')) i = 1;
77
+ while (i < lines.length && lines[i].trim() === '') i++;
78
+ const block = [];
79
+ while (i < lines.length && isCommentLine(lines[i], token)) {
80
+ block.push(lines[i]);
81
+ i++;
82
+ }
83
+ return block;
84
+ }
85
+
86
+ // Rule ids disabled file-wide (first 20 lines) or on one line.
87
+ function disabledIds(text) {
88
+ const head = text.split('\n').slice(0, 20).join('\n');
89
+ const ids = new Set();
90
+ for (const m of head.matchAll(/source-lint-disable:\s*([a-z0-9,\s-]+)/g))
91
+ for (const id of m[1].split(',')) ids.add(id.trim());
92
+ return ids;
93
+ }
94
+
95
+ // The comment ratio of the lines this change ADDS, or null where there is nothing to grade. An untracked
96
+ // file is added whole; a tracked one is graded on its `+` lines against HEAD. Reading the diff rather than
97
+ // the file is what makes the rule fair: inherited prose is somebody else's row, and a session that adds
98
+ // twenty clean lines to a 60%-comment file passes.
99
+ function addedRatio(path, token) {
100
+ let added;
101
+ try {
102
+ const tracked = spawnSync('git', ['ls-files', '--error-unmatch', path], { encoding: 'utf8' }).status === 0;
103
+ if (!tracked) added = readFileSync(path, 'utf8').split('\n');
104
+ else {
105
+ const out = spawnSync('git', ['diff', '-U0', '--no-color', 'HEAD', '--', path], { encoding: 'utf8' });
106
+ if (out.status !== 0) return null;
107
+ added = out.stdout.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).map((l) => l.slice(1));
108
+ }
109
+ } catch {
110
+ return null;
111
+ }
112
+ const nonBlank = added.filter((l) => l.trim() !== '');
113
+ if (nonBlank.length < ADDED_MIN_LINES) return null;
114
+ const comments = nonBlank.filter((l) => isCommentLine(l, token)).length;
115
+ return { ratio: comments / nonBlank.length, comments, lines: nonBlank.length };
116
+ }
117
+
118
+ function lintFile(path, findings) {
119
+ if (SKIP.test(path)) return false;
120
+ const token = tokenFor(path);
121
+ if (!token) return false;
122
+ let text;
123
+ try {
124
+ text = readFileSync(path, 'utf8');
125
+ } catch {
126
+ return false;
127
+ }
128
+ if (GENERATED.test(text.split('\n').slice(0, 5).join('\n'))) return false;
129
+
130
+ const lines = text.split('\n');
131
+ const disabled = disabledIds(text);
132
+ const add = (id, n, excerpt) => {
133
+ if (disabled.has(id)) return;
134
+ const lineDisable = /source-lint-disable:\s*([a-z0-9,\s-]+)/.exec(lines[n - 1] ?? '');
135
+ if (lineDisable && lineDisable[1].split(',').some((x) => x.trim() === id)) return;
136
+ findings.push({ path, n, id, ...RULES[id], excerpt });
137
+ };
138
+
139
+ // --- the header ---------------------------------------------------------------------------------
140
+ const block = leadingBlock(lines, token);
141
+ if (block.length === 0) add('header-missing', 1);
142
+ else {
143
+ const head = block.join('\n');
144
+ // The FIRST path-shaped token, compared exactly — containment would pass a header reading
145
+ // `packages/old-name/src/app.ts` for a file at `src/app.ts`, which is the rename this catches.
146
+ const named = /([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z]{1,5})\b/.exec(head);
147
+ if (!named) add('header-missing', 1);
148
+ else if (named[1] !== path) {
149
+ if (basename(named[1]) === basename(path)) add('header-path-stale', 1, named[1]);
150
+ else add('header-missing', 1, `names ${named[1]}`);
151
+ }
152
+ if (block.length > HEADER_MAX_LINES) add('header-too-long', 1, `${block.length} lines`);
153
+ }
154
+
155
+ // --- density, block length, and comment prose --------------------------------------------------
156
+ let commentLines = 0;
157
+ let nonBlank = 0;
158
+ let runStart = 0;
159
+ let run = 0;
160
+ lines.forEach((line, i) => {
161
+ if (line.trim() !== '') nonBlank++;
162
+ if (isCommentLine(line, token)) {
163
+ commentLines++;
164
+ if (run === 0) runStart = i + 1;
165
+ run++;
166
+ const body = line.replace(/^\s*(\/\/+|\/\*+|\*+\/?|--+|#+)\s?/, '');
167
+ if (NARRATION.test(body)) add('comment-narration', i + 1, body.slice(0, 90));
168
+ const shout = SHOUTING.exec(body);
169
+ if (shout) add('comment-shouting', i + 1, shout[1].slice(0, 60));
170
+ return;
171
+ }
172
+ if (run > BLOCK_MAX_LINES) add('comment-block-long', runStart, `${run} lines`);
173
+ run = 0;
174
+ });
175
+ if (run > BLOCK_MAX_LINES) add('comment-block-long', runStart, `${run} lines`);
176
+ if (nonBlank >= RATIO_MIN_LINES) {
177
+ const ratio = commentLines / nonBlank;
178
+ if (ratio > RATIO_CEILING) add('comment-ratio', 1, `${Math.round(ratio * 100)}% of ${nonBlank} lines`);
179
+ }
180
+ const grown = addedRatio(path, token);
181
+ if (grown && grown.ratio > ADDED_RATIO_CEILING)
182
+ add('comment-ratio-added', 1, `${grown.comments} of ${grown.lines} added lines`);
183
+ return true;
184
+ }
185
+
186
+ function expandDirs(paths) {
187
+ const out = [];
188
+ for (const p of paths) {
189
+ if (!existsSync(p)) continue;
190
+ if (!statSync(p).isDirectory()) {
191
+ out.push(p);
192
+ continue;
193
+ }
194
+ for (const e of readdirSync(p, { withFileTypes: true })) {
195
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
196
+ out.push(...expandDirs([`${p.replace(/\/$/, '')}/${e.name}`]));
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ const args = process.argv.slice(2);
203
+ let files = args.filter((a) => a !== '--changed');
204
+ if (args.includes('--changed')) {
205
+ const out = execSync('git diff --name-only HEAD; git ls-files --others --exclude-standard', { encoding: 'utf8', shell: '/bin/bash' });
206
+ files = [...new Set([...files, ...out.split('\n').filter(Boolean)])];
207
+ }
208
+ files = expandDirs(files);
209
+ if (!files.length) {
210
+ console.error('usage: node source-lint.mjs <file | dir ...> | --changed');
211
+ process.exit(2);
212
+ }
213
+
214
+ const findings = [];
215
+ let checked = 0;
216
+ for (const f of files) if (lintFile(f, findings)) checked++;
217
+
218
+ for (const f of findings.sort((a, b) => (a.severity === b.severity ? 0 : a.severity === 'block' ? -1 : 1)))
219
+ console.log(`${f.path}:${f.n} [${f.severity}] ${f.id} — ${f.message}${f.excerpt ? `: "${f.excerpt}"` : ''}`);
220
+ const blocks = findings.filter((f) => f.severity === 'block').length;
221
+ console.log(`\n${findings.length} finding(s), ${blocks} blocking, ${checked} file(s) checked.`);
222
+ process.exit(blocks ? 1 : 0);
@@ -35,7 +35,7 @@ Required: **Conventions**, at least one numbered behavior section, **Failure Rep
35
35
  - **State each section's trace.** `Pinned:` names the suite test that now guards it, or says `driven only` **with the reason** — an unmarked section is indistinguishable from an unexamined one.
36
36
  - **State each section's `AC:` keys.** Where the repo carries a keyed acceptance-criteria source, each section names the keys it proves, so the chain from criterion to checklist to suite test survives the last hop; the gate reports coverage and backlogs uncovered keys. `none — not AC-mapped` is the honest line where no keyed source exists.
37
37
  - **A known-failing assertion stays written as the contract.** A checklist states what the feature promises; the *run* records the failure and the backlog carries the defect. Never soften an assertion to make a run go green.
38
- - **Each driven section carries a dated `Driven` line** — `Driven {YYYY-MM-DD} · PASS|FAIL ({environment}).` plus the evidence the assertion turned on. It is the only thing separating a promise someone verified from a promise someone typed, and its date is what makes staleness visible: a section still carrying last quarter's date next to a surface rewritten since is the file telling you it was not re-driven. `FAIL` is stamped and kept, with the backlog item it raised; a section blocked rather than driven says `NOT DRIVEN — {what blocked it}`.
38
+ - **No section carries a date.** This file is a truth document: it states what must be true and what guards it, and the *event* of driving — the date, the environment, the evidence the assertion turned on, a `FAIL` and the backlog item it raised — is recorded in the changelog entry of the unit that drove it, where a dated line belongs. What separates a promise somebody verified from a promise somebody typed is the `Pinned:` trace, and it is stronger than a stamp because it re-checks itself on every suite run. A section a drive could not reach says `not reachable — {what blocks it}` on its trace line, in the present tense, and keeps saying it until the blocker is gone.
39
39
  - **The file is maintained by whoever drives it.** Nothing else watches it — a checklist owns no code, so no ownership glob implicates it and no hook fires when the surface underneath it changes. Retiring dead sections and repairing traces is part of running the gate, not a separate cleanup someone schedules.
40
40
 
41
41
  ```
@@ -63,7 +63,6 @@ Each step is an action; each **Assert** is the observable it must produce.
63
63
  - [ ] **Assert**: {the observable, with exact literals}
64
64
  - [ ] **Assert**: {the persistence claim, where the behavior writes something}
65
65
 
66
- > Driven {YYYY-MM-DD} · PASS ({environment}). {the evidence — the literal observed, the row that moved}
67
66
  > Pinned: `{tests/path/to/file.test.ts}` — or `driven only — {why no durable test is bought}` — or `none yet — {the test this section owes}`
68
67
  > AC: {the acceptance-criteria keys this section proves} — or `none — not AC-mapped`
69
68
 
@@ -21,4 +21,4 @@ The shared work queue keeping documentation and code in sync — a queue, never
21
21
 
22
22
  **A waiver covers a finding only when it names the finding's kind and its subject in backticks** — the matcher (`waiverCovers`) tests the backticked subject exactly, so a waiver written without them silences nothing and the wall stands. A `waived:` row's `expires=` date is enforced: CI honors the waiver until the date, then fails. `dead-glob` waivers double as the dead-glob allowlist; `ownership-overlap` waivers cover the CI ownership check the same way.
23
23
 
24
- Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`. The CI ownership check additionally accepts `waived:` rows of kind `ownership-overlap`.
24
+ Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited` · `source-lint`. The CI ownership check additionally accepts `waived:` rows of kind `ownership-overlap`.
@@ -43,7 +43,7 @@ Both modes run the same sequence; the modes differ only in how the target list a
43
43
  6. **Judge.** Read every non-COHERENT row's evidence yourself. CONTESTED rows you judge with both sets of evidence in hand; a row you doubt goes to a `polygraph-verifier` (claim and subject only) and comes back CONTESTED if the two disagree. QUESTION rows — and every proposed deletion, with its inbound count — go to the owner **now, inline**, batched and ranked by how many rows each answer unblocks; work proceeds on everything else while they are open.
44
44
  7. **Packet and apply.** One doc, one `polygraph-applier`, always — `audit.mjs` asserts no collision. MISDESCRIBED rows and in-scope UNDOCUMENTED rows that passed the residue guard go to the owning doc's packet; RESIDUE, UNBUILT-abandoned, and every code-side row go to the register (the backlog, in its existing grammar, deduped against what is already there); frozen records resolve `exempt-frozen`. Fold each applier's outcome file into row status by hand, then run `audit.mjs` again.
45
45
  8. **Run the repo's doc gates in your own seat** — lint, links, ownership — over every doc touched; a delegated gate that dies reports nothing, which reads exactly like a pass.
46
- 9. **Report** in prose: docs audited, claims checked, corrected, registered, contested-and-judged, questions answered; the refuted-on-apply count is the quality signal, stated every time. Then delete the run folder — also when rows are still open for the owner; they are in the reply.
46
+ 9. **Report** in prose: docs audited, claims checked, corrected, registered, contested-and-judged, questions answered, comment claims rescued into their doc and comment residue registered; the refuted-on-apply count is the quality signal, stated every time. Then delete the run folder — also when rows are still open for the owner; they are in the reply.
47
47
 
48
48
  ## Gate mode — the closing write
49
49
 
@@ -183,6 +183,6 @@ Runs as the tail of every build pass — the session opens it the moment the uni
183
183
  5. **Discharge every ledger row into exactly one outcome**: **satisfied** (test green, or driven check passed), **owes nothing** (with the reason — compiler-guarded, covered elsewhere), or **blocked** (missing infrastructure named, row converted to a backlog item). **Open by re-reading the ledger from the plan file on disk, never from conversation memory** — a context-held copy is how rows get missed. **Write each row's outcome into its `Outcome` cell in the plan file** — exactly `satisfied`, `owes nothing — {reason}`, or `blocked → backlog` — because the Stop wall and CI read that cell, and a row left unexamined blocks completion.
184
184
  6. **Report results honestly, including failures**; record which test now guards each checklist section (`Pinned:` traces) and each section's `AC:` line where the repo carries a keyed acceptance-criteria source, closing with the coverage count — `AC coverage: {n}/{m}; uncovered: {keys} → backlog`.
185
185
 
186
- **Stamp every section you drove, in the file, before reporting.** One line per section: `Driven {YYYY-MM-DD} · PASS|FAIL ({environment}).` followed by the evidence — the literal observed, the row version, the request body, whatever the assertion turned on. A section left unstamped since an earlier run keeps that run's older date, which is the signal that it was not re-driven. **A `FAIL` is stamped `FAIL` and stays** — with the backlog item it raised — because a corpus of driven records that has never recorded a failure is either describing a flawless product or being written by someone who already knew the answer, and a reader cannot tell which. Where the driving was blocked rather than failed, say `NOT DRIVEN — {what blocked it}`; an unexamined section and a passing one must never look alike.
186
+ **Record every section you drove — in the changelog entry, not in the checklist.** One line per section: what was driven, PASS or FAIL, the environment, and the evidence the assertion turned on (the literal observed, the row version, the request body). It goes in the entry because it is a dated event, and the checklist is a truth document that carries no dates (the `documentation` skill § Present tense, always). **A `FAIL` is recorded as a `FAIL` and stays** — with the backlog item it raised — because a corpus of driven records that has never recorded a failure is either describing a flawless product or being written by someone who already knew the answer, and a reader cannot tell which. In the checklist itself, the section's `Pinned:` trace is what distinguishes a verified promise from a typed one; a section the drive could not reach says `not reachable — {what blocks it}` there, so an unexamined section and a guarded one never look alike.
187
187
 
188
188
  Completion steps — freezing the plan, the changelog entry — are the root policy file's, not yours.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "delivery",
3
3
  "name": "delivery",
4
- "version": "0.2.30",
4
+ "version": "0.2.32",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -10,5 +10,5 @@
10
10
  "seeds": [],
11
11
  "tools": [],
12
12
  "docsRoot": "docs",
13
- "digest": "688776066c84"
13
+ "digest": "94b63ce8cf37"
14
14
  }
@@ -11,6 +11,26 @@ Document contracts and architecture, not implementation details. Show function s
11
11
 
12
12
  ---
13
13
 
14
+ ## Where a file's documentation lives
15
+
16
+ **In `docs/`, never in the file's comments** — and this skill is the half of that rule that makes it safe. A source file carries a repo-relative path header of two or three present-tense sentences and one third-person sentence above each non-obvious function; the shape is the harness's `CLAUDE.md` § File Structure, which is canonical and is not restated here. What lands **here** is everything that shape displaces: design rationale, the alternatives weighed, the measurement behind a constant, the reason an obvious-looking change is wrong.
17
+
18
+ **Cutting a comment is a move, not a deletion.** A claim taken out of a source file is owed one of two things before the cut is done, and the choice is per claim, not per file:
19
+
20
+ | The claim is | What it earns |
21
+ | --- | --- |
22
+ | Worth keeping, and its doc does not state it | A section in the **owning feature doc** (the ownership index resolves which), carrying the claim and the argument for why the codebase is worse without it — never the comment's wording, which was written for a reader holding the code |
23
+ | Already stated in its doc | Nothing — but you **open that doc and find the sentence** before saying so. An assumed duplicate is how a claim is lost |
24
+ | History, a ticket or decision-record reference, a dated note, a divider, or narration of code that no longer exists | Nothing, and no record. A record lives in `docs/history/`; these are not claims about the present |
25
+
26
+ **Count what you cut, and prove the cut was only comments.** A comment sweep states comment lines before and after per file, so a drop nobody accounted for is visible as a number rather than as a feeling. [`scripts/comment-strip.mjs`](scripts/comment-strip.mjs) turns that from a claim into a check: it strips comments from both revisions — honouring strings, template and character literals, regex literals, raw strings and JSX comment braces — and diffs what is left, so a sweep that quietly changed executable code cannot pass as a cleanup. `comment-strip.mjs --rev <before> --to <after> <paths…>` exits 0 for "no executable code changed" and 1 naming the file and the first diverging line. Run it over any pass that touches more than a handful of files; a sweep without it is an assertion.
27
+
28
+ **The mechanical half of the shape is checked, not remembered.** [`scripts/source-lint.mjs`](scripts/source-lint.mjs) `--changed` reports a header that names another path or none, a change whose added lines are over a quarter comments, and the four shapes the convention deletes outright — narration, ALL-CAPS emphasis, an over-long header, a comment block that has become a document. Block-severity findings must be fixed; the escape hatch is a `source-lint-disable: <id>` comment with the reason in a second comment beside it, and the only finding that legitimately takes one is a booby trap. The harness detector raises a bookkeeping row for the same condition, so an unfixed breach outlives the session that wrote it.
29
+
30
+ **Never convert a booby trap into a one-liner.** A comment whose subject is a danger on an edge that looks safe — a pinned dependency's limitation, a measurement that justifies a narrow allowlist, a two-clock interaction — is load-bearing where it sits, because the reader who needs it is mid-edit and will not go looking. Compress it if it rambles; do not relocate it. The tell is that the comment names a version, a measurement, or a condition under which it expires.
31
+
32
+ ---
33
+
14
34
  ## Present tense, always
15
35
 
16
36
  **A document states what is — never how it got there, and never how far along it is.** Write as if the current design had always been the plan. This is the most-violated rule in the skill, so it sits at the top; it applies to every document written or edited through this skill, whatever the template.
@@ -24,6 +44,12 @@ Document contracts and architecture, not implementation details. Show function s
24
44
 
25
45
  The journey — dates, migrations, progress, supersessions — lives only in records (`docs/history/`); when reality changes, a truth document simply becomes correct. The full strip-list and the record-side rules: [`references/style-guide.md`](references/style-guide.md) § The Two Natures — Tense & History.
26
46
 
47
+ **A date in a truth document is a defect, and the list of exceptions is closed.** Not a softened preference: a truth document carries no dated line at all, because a date answers *when did this happen*, which is a record's question, and a reader who needs the answer is reading the wrong file. The four exceptions, and there are no others: the frontmatter's tooling keys (`last-reviewed:`, `last-updated:`, `rev:` — written by tooling, never by you), an Architecture Decision's **Decision Changelog** tail, `docs/structure.md`'s **Health Log** tail, and a dated path inside a link or code span, which is a *citation* of a record rather than a claim about the present.
48
+
49
+ **Cite a record by what it is, never by when it was.** `the dev reset runbook` links to the file; "the 2026-09-13 reset" states a date the reader must now keep in their head and that goes stale as soon as a second reset happens. The link carries the date in its path, where it belongs, and the prose stays true forever.
50
+
51
+ **A verification stamp is a record, not a status line.** "Driven 2026-09-13 · PASS" in a behavioural checklist is the clearest instance of this defect: it reads as documentation and is actually a dated event. The section states what must be true and which suite test guards it; *that* somebody drove it on a Tuesday belongs to the changelog entry of the unit that drove it, and staleness is then visible from something that re-checks itself — a pin that runs in CI — rather than from a date nobody re-stamped. A section with no automated guard says so in its trace, which is a stronger signal than an old date, because it cannot silently become wrong.
52
+
27
53
  The mechanical subset of these rules is checked by [`scripts/doc-lint.mjs`](scripts/doc-lint.mjs) — run it over changed docs before finishing. Block-severity findings must be fixed; `<!-- doc-lint-disable: <id> -->` with a reason beside it is the escape hatch. Two invocation traps: the disable comment takes **bare rule ids only** — prose inside the comment silently breaks the parse, so put the reason in a second comment beside it — and the script grades truth-vs-record scope by matching `docs/(history|research|memory)/` against **the path string it is given**, so always hand it a repo-root-relative path; linting a record from inside its own folder grades it as a truth document.
28
54
 
29
55
  ---
@@ -155,3 +181,5 @@ update, and the `maintenance` agent's baseline pass, route there.
155
181
  | `references/untemplated.md` | How to write a document no template covers (nature, placement, shape) + the co-authoring workflow |
156
182
  | `references/examples.md` | Good vs bad documentation examples |
157
183
  | `scripts/doc-lint.mjs` | Mechanical checks over written docs (tense narration, dates, stubs, redirects) — run over changed docs before finishing |
184
+ | `scripts/source-lint.mjs` | Mechanical checks over source **comments** (header path identity, the added-line comment ratio, narration, shouting) — run `--changed` before finishing any change that writes source |
185
+ | `scripts/comment-strip.mjs` | The proof a comment sweep changed no executable code: strips comments from two revisions and diffs what is left. Exit 0 is the claim; exit 1 names the file and the first diverging line |