devflow-kit 3.3.0 → 3.4.0

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 (138) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/agents/code.md +330 -0
  3. package/{src/assets → dist}/agents/design.md +1 -1
  4. package/{src/assets → dist}/agents/diagnose.md +1 -2
  5. package/dist/agents/git.md +29 -56
  6. package/{src/assets → dist}/agents/knowledge.md +4 -3
  7. package/{src/assets → dist}/agents/research.md +2 -2
  8. package/{src/assets → dist}/agents/review.md +8 -7
  9. package/{src/assets → dist}/agents/scrutinize.md +1 -1
  10. package/dist/agents/skim.md +148 -0
  11. package/{src/assets → dist}/agents/triage.md +1 -1
  12. package/dist/cli/commands/init.js +62 -0
  13. package/dist/cli/commands/learning.js +38 -3
  14. package/dist/cli/commands/uninstall.js +42 -1
  15. package/dist/commands/bug-analysis.md +30 -8
  16. package/dist/commands/code-review.md +141 -60
  17. package/dist/commands/debug.md +14 -12
  18. package/dist/commands/dynamic-build.md +37 -38
  19. package/dist/commands/dynamic-plan.md +30 -18
  20. package/dist/commands/dynamic-profile.md +27 -13
  21. package/dist/commands/dynamic-tickets.md +28 -14
  22. package/dist/commands/explore.md +15 -13
  23. package/dist/commands/implement.md +33 -28
  24. package/dist/commands/plan.md +37 -24
  25. package/dist/commands/release.md +69 -4
  26. package/dist/commands/research.md +33 -11
  27. package/dist/commands/resolve.md +35 -32
  28. package/dist/commands/self-review.md +36 -23
  29. package/dist/core/agent-models.js +43 -0
  30. package/dist/core/assets.js +55 -10
  31. package/dist/core/claude-md-audit.js +190 -0
  32. package/dist/core/feature-switch.js +20 -1
  33. package/dist/core/flags.js +28 -0
  34. package/dist/core/fs-atomic.js +8 -3
  35. package/dist/core/learning-variants.js +213 -0
  36. package/dist/core/manifest.js +62 -0
  37. package/dist/core/mds-variants.js +38 -1
  38. package/dist/core/plugins.js +71 -9
  39. package/{src/assets → dist/learning-off}/agents/code.md +6 -10
  40. package/dist/learning-off/agents/design.md +119 -0
  41. package/dist/learning-off/agents/diagnose.md +210 -0
  42. package/dist/learning-off/agents/knowledge.md +90 -0
  43. package/dist/learning-off/agents/research.md +149 -0
  44. package/dist/learning-off/agents/review.md +228 -0
  45. package/dist/learning-off/agents/scrutinize.md +117 -0
  46. package/{src/assets → dist/learning-off}/agents/skim.md +1 -8
  47. package/dist/learning-off/agents/triage.md +163 -0
  48. package/dist/learning-off/commands/bug-analysis.md +420 -0
  49. package/dist/learning-off/commands/code-review.md +525 -0
  50. package/dist/learning-off/commands/debug.md +294 -0
  51. package/dist/learning-off/commands/dynamic-build.md +1255 -0
  52. package/dist/learning-off/commands/dynamic-plan.md +424 -0
  53. package/dist/learning-off/commands/dynamic-profile.md +214 -0
  54. package/dist/learning-off/commands/dynamic-tickets.md +632 -0
  55. package/dist/learning-off/commands/explore.md +210 -0
  56. package/dist/learning-off/commands/implement.md +808 -0
  57. package/dist/learning-off/commands/plan.md +664 -0
  58. package/dist/learning-off/commands/release.md +310 -0
  59. package/dist/learning-off/commands/research.md +222 -0
  60. package/dist/learning-off/commands/resolve.md +837 -0
  61. package/dist/learning-off/commands/self-review.md +266 -0
  62. package/dist/skills/git/references/tracker/_contract.md +33 -0
  63. package/dist/skills/git/references/tracker/github/fetch-issue.md +2 -0
  64. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +2 -0
  65. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +4 -0
  66. package/dist/skills/git/references/tracker/github/post-wave-report.md +2 -0
  67. package/dist/skills/git/references/tracker/github/setup-task.md +12 -0
  68. package/dist/skills/git/references/tracker/jira/associate-release.md +1 -1
  69. package/dist/skills/git/references/tracker/jira/fetch-issue.md +2 -0
  70. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +2 -0
  71. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +4 -0
  72. package/dist/skills/git/references/tracker/jira/post-wave-report.md +2 -0
  73. package/dist/skills/git/references/tracker/jira/setup-task.md +14 -2
  74. package/dist/skills/git/references/tracker/linear/associate-release.md +1 -1
  75. package/dist/skills/git/references/tracker/linear/fetch-issue.md +2 -0
  76. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +2 -0
  77. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +4 -0
  78. package/dist/skills/git/references/tracker/linear/post-wave-report.md +2 -0
  79. package/dist/skills/git/references/tracker/linear/setup-task.md +14 -2
  80. package/dist/targets/claude-code/installer.js +72 -36
  81. package/dist/targets/claude-code/language-stamp.js +185 -0
  82. package/dist/targets/claude-code/learning-install.js +489 -0
  83. package/package.json +1 -1
  84. package/src/assets/agents/code.mds +339 -0
  85. package/src/assets/agents/design.mds +149 -0
  86. package/src/assets/agents/diagnose.mds +225 -0
  87. package/src/assets/agents/evaluate.md +1 -3
  88. package/src/assets/agents/git.mds +29 -56
  89. package/src/assets/agents/knowledge.mds +125 -0
  90. package/src/assets/agents/research.mds +176 -0
  91. package/src/assets/agents/review.mds +286 -0
  92. package/src/assets/agents/scrutinize.mds +132 -0
  93. package/src/assets/agents/skim.mds +161 -0
  94. package/src/assets/agents/triage.mds +194 -0
  95. package/src/assets/agents/validate.md +8 -6
  96. package/src/assets/commands/_partials/_compliance.mds +5 -4
  97. package/src/assets/commands/_partials/_decisions.mds +31 -0
  98. package/src/assets/commands/_partials/_engine.mds +9 -1
  99. package/src/assets/commands/_partials/_knowledge.mds +25 -12
  100. package/src/assets/commands/_partials/_preamble.mds +33 -9
  101. package/src/assets/commands/_partials/_publication.mds +5 -4
  102. package/src/assets/commands/_partials/_settings.mds +13 -5
  103. package/src/assets/commands/_partials/_wave.mds +8 -0
  104. package/src/assets/commands/bug-analysis.mds +24 -2
  105. package/src/assets/commands/code-review.mds +147 -44
  106. package/src/assets/commands/debug.mds +17 -1
  107. package/src/assets/commands/dynamic-build.mds +33 -2
  108. package/src/assets/commands/dynamic-plan.mds +36 -6
  109. package/src/assets/commands/dynamic-profile.mds +9 -1
  110. package/src/assets/commands/dynamic-tickets.mds +16 -2
  111. package/src/assets/commands/explore.mds +27 -1
  112. package/src/assets/commands/implement.mds +41 -8
  113. package/src/assets/commands/plan.mds +47 -8
  114. package/src/assets/commands/{release.md → release.mds} +27 -24
  115. package/src/assets/commands/research.mds +28 -4
  116. package/src/assets/commands/resolve.mds +43 -2
  117. package/src/assets/commands/self-review.mds +30 -5
  118. package/src/assets/mds/tracker/_contract.mds +72 -0
  119. package/src/assets/mds/tracker/_github.mds +13 -2
  120. package/src/assets/mds/tracker/_jira.mds +17 -5
  121. package/src/assets/mds/tracker/_linear.mds +17 -5
  122. package/src/assets/mds/tracker/_mcp.mds +2 -2
  123. package/src/assets/mds/tracker/_steps.mds +97 -0
  124. package/src/assets/rules/context-economy.md +10 -0
  125. package/src/assets/rules/go.md +1 -0
  126. package/src/assets/rules/java.md +1 -0
  127. package/src/assets/rules/python.md +1 -0
  128. package/src/assets/rules/rust.md +1 -0
  129. package/src/assets/rules/typescript.md +1 -0
  130. package/src/assets/scripts/claude-md-audit.cjs +611 -0
  131. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +1 -2
  132. package/src/assets/scripts/hooks/json-helper.cjs +13 -5
  133. package/src/assets/scripts/hooks/json-parse +34 -10
  134. package/src/assets/scripts/hooks/session-start-context +315 -7
  135. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  136. package/src/assets/skills/apply-feature-knowledge/SKILL.md +5 -5
  137. package/src/assets/skills/feature-knowledge/SKILL.md +43 -12
  138. package/src/assets/skills/quality-gates/SKILL.md +1 -1
@@ -0,0 +1,611 @@
1
+ #!/usr/bin/env node
2
+ // src/assets/scripts/claude-md-audit.cjs
3
+ //
4
+ // D-CLAUDE-MD-IMPORT-AUDIT: finds the CLAUDE.md `@path` imports that put a large
5
+ // file into every thread's always-loaded context, and says so once. Installed as a
6
+ // top-level sibling of resolve-settings.cjs under ~/.devflow/scripts/, where the
7
+ // SessionStart hook `session-start-context` runs it (Section 6); the CLI reaches
8
+ // the same file through the typed facade src/core/claude-md-audit.ts (`devflow
9
+ // init`), so the grammar, the bounds, the display text and the stamp format exist
10
+ // exactly once, here.
11
+ //
12
+ // THE AUDIT WRITES NOTHING. `audit()` only stats and reads; the caller (the hook,
13
+ // or init) applies every gate and writes the stamp. A test asserts byte and mtime
14
+ // equality for every audited file.
15
+ //
16
+ // Usage (the hook's view):
17
+ // node claude-md-audit.cjs hook <home> <stampFile> <root>...
18
+ // stdout, one framed block, or nothing and a non-zero exit on failure:
19
+ // devflow-claude-md-audit 1
20
+ // M <display line> zero or more; the systemMessage, one line each
21
+ // V 1 / R / P / K / E rows the new stamp text (D-AUDIT-STAMP), in file order
22
+ // END
23
+ // The hook needs ONE node process per changed start and no JSON parse, so the
24
+ // block is line-oriented. `audit()` returns the same facts as one JSON-able object.
25
+ //
26
+ // ── Roots ──────────────────────────────────────────────────────────────────────
27
+ // The caller names the roots: `CLAUDE.md` in the Claude config directory, and in a
28
+ // git project that is not HOME its `CLAUDE.md`, `.claude/CLAUDE.md` and
29
+ // `CLAUDE.local.md` at the toplevel. Not roots (OQ3): ancestor directories, lazily
30
+ // loaded subdirectory files, `rules/*.md` and `AGENTS.md`.
31
+ //
32
+ // ── Import grammar (devflow's specification) ───────────────────────────────────
33
+ // An `@` starts an import at the start of a line or after whitespace, so
34
+ // `user@example.com` is not one. The path is the run of characters from
35
+ // `A-Za-z0-9._/~+-` after it, trailing dots dropped; an empty token is not an
36
+ // import. `~/` expands to HOME, an absolute path stays absolute, anything else
37
+ // resolves against the importing file's directory. Fenced blocks follow the rules
38
+ // of `fenceOpen`/`fenceCloses` in pr-evidence.cjs (up to three spaces of indent; a
39
+ // backtick or tilde run of three or more; a backtick fence's info string holds no
40
+ // backtick; the closer uses the same character, a run at least as long and nothing
41
+ // after it but whitespace; an unclosed fence runs to the end of the scanned text).
42
+ // An inline code span is a backtick run closed by the next run of the same length
43
+ // on the same line; a run with no closer on its line is literal. Multi-line spans
44
+ // are not recognised, and four-space indented code is not exempted (OQ10): a false
45
+ // positive costs one line of output.
46
+ //
47
+ // ── OQ9: agreement with Claude Code's own rules ────────────────────────────────
48
+ // Checked 2026-10-10 against https://code.claude.com/docs/en/memory ("Import
49
+ // additional files", "How CLAUDE.md files load", "My CLAUDE.md is too large"). It
50
+ // agrees on: the `@path/to/import` syntax anywhere in a line; relative paths
51
+ // resolving against the FILE CONTAINING the import, not the working directory;
52
+ // absolute and `~/` paths; imports being expanded at launch (so an import never
53
+ // reduces context); code spans and fenced blocks skipped; a path wrapped in quotes
54
+ // not imported; `CLAUDE.local.md` loading beside `CLAUDE.md`; `.claude/CLAUDE.md`
55
+ // being a project instruction file. Differences, each a decision here:
56
+ // 1. Depth. The current page says "a maximum depth of four hops"
57
+ // (https://code.claude.com/docs/en/memory, fetched 2026-10-10); the older
58
+ // docs.anthropic.com and docs.claude.com snapshots said five. MAX_HOPS follows
59
+ // the current page: the audit measures what Claude Code loads now, and a fifth
60
+ // hop would count bytes it does not load. MAX_HOPS is the one constant to
61
+ // change if upstream moves again.
62
+ // 2. Escaped spaces. Upstream follows a path whose spaces are written `\ `. This
63
+ // grammar stops at the backslash, so such an import is not followed (a missed
64
+ // finding, never a false one).
65
+ // 3. Size skip. Upstream skips a file over 4 MiB, so it adds nothing to context.
66
+ // SKIP_FILE_BYTES mirrors that: such a file is neither counted nor followed.
67
+ // 4. Approval. A project-level import that resolves outside the working
68
+ // directory waits for a one-time approval dialog upstream, and a declined one
69
+ // stays disabled. The audit cannot see that state and counts such an import.
70
+ // 5. Roots. Upstream also loads CLAUDE.md files from every ancestor directory and
71
+ // `.claude/rules/`, and `AGENTS.md` where no CLAUDE.md exists; they are not
72
+ // audited (OQ3), as is `claudeMdExcludes`.
73
+ // 6. HTML comments. Upstream strips block-level `<!-- -->` comments before the
74
+ // content is injected; whether import parsing sees them first is not
75
+ // documented, so an `@path` inside one is counted here.
76
+ //
77
+ // ── Files and bounds ───────────────────────────────────────────────────────────
78
+ // `stat` follows symlinks. Only regular files are opened: a FIFO, device, socket or
79
+ // directory is recorded and contributes nothing, and the open itself is
80
+ // non-blocking and re-checked with fstat, so a swap between the two cannot hang the
81
+ // run. A missing or unreadable target contributes nothing and fails nothing. The
82
+ // visited set is keyed by realpath, so a diamond counts a file once and a cycle
83
+ // ends. The caps, not a wall-clock timeout, bound the work: at most
84
+ // MAX_PATHS_PER_ROOT paths examined per root (existing or not), the first
85
+ // SCAN_BYTES of a file scanned for imports though the whole file is sized, and
86
+ // MAX_BYTES_READ bytes read per run. The root is hop 0 and an import of a hop-n
87
+ // file is hop n+1; hops 1 to MAX_HOPS are counted, and the files at MAX_HOPS are
88
+ // not read (nothing they import would be counted), so a hop MAX_HOPS+1 file is
89
+ // neither read nor counted.
90
+ //
91
+ // ── Thresholds (decimal bytes, as the plan writes sizes) ───────────────────────
92
+ // File finding: an import reached in a root's chain whose size is over
93
+ // FILE_THRESHOLD_BYTES (a root has none: it is not an import). Chain finding: a
94
+ // root's `total` (its size plus each distinct regular file reached through
95
+ // imports) over CHAIN_THRESHOLD_BYTES, naming the root, the total and the largest
96
+ // member (root included). A walk the caps stopped makes `total` a lower bound:
97
+ // `truncated`, and the display says "at least".
98
+ //
99
+ // ── Display ────────────────────────────────────────────────────────────────────
100
+ // A path shown in a systemMessage or an init line must match `^[ -~]+$`
101
+ // (printable ASCII); any other is shown as `<path not shown>` with its size. The
102
+ // audit reads the SIZE of files a repository's CLAUDE.md names, nothing else of
103
+ // theirs, and shows a size only for a finding over a threshold.
104
+ //
105
+ // ── The stamp (D-AUDIT-STAMP) ──────────────────────────────────────────────────
106
+ // One machine-root file, $HOME/.devflow/.claude-md-audit, written by the caller.
107
+ // Line-oriented, ASCII control bytes never recorded (a path holding one is simply
108
+ // left out, so a setup with one re-audits at every start):
109
+ // V 1 format version, first line
110
+ // R <root path> the gated root set, in call order
111
+ // P <flag> <path> an examined path; flag 0 absent, 1 regular file,
112
+ // 2 exists but is not a regular file
113
+ // K <key> a finding already displayed (once-per-key); at most
114
+ // MAX_KEYS, oldest dropped
115
+ // E <reason> the audit failed; written with the R rows only
116
+ // The hook's fast path reads only R and P rows, with shell builtins. The key of a
117
+ // file finding is `file <size> <mtime> <path>` and of a chain finding `chain
118
+ // <total> <mtime> <root path>`, mtimes in whole seconds as get-mtime returns them.
119
+ // A finding is recorded as displayed only after it was shown, so a lost stamp
120
+ // update can repeat a message and can never hide a new finding. A stamp over
121
+ // MAX_STAMP_BYTES, or one that is not a regular file, is read as absent.
122
+
123
+ 'use strict';
124
+
125
+ const fs = require('fs');
126
+ const path = require('path');
127
+
128
+ /** Decimal bytes: an import over this is a file finding. */
129
+ const FILE_THRESHOLD_BYTES = 10000;
130
+ /** Decimal bytes: a root whose chain totals over this is a chain finding. */
131
+ const CHAIN_THRESHOLD_BYTES = 40000;
132
+ /** The deepest hop counted; the root is hop 0. */
133
+ const MAX_HOPS = 4;
134
+ /** Paths examined per root, existing or not. */
135
+ const MAX_PATHS_PER_ROOT = 64;
136
+ /** Bytes of one file scanned for imports. */
137
+ const SCAN_BYTES = 65536;
138
+ /** Bytes read in one run. */
139
+ const MAX_BYTES_READ = 1048576;
140
+ /** A stamp over this is read as absent. */
141
+ const MAX_STAMP_BYTES = 262144;
142
+ /** Finding keys kept in the stamp. */
143
+ const MAX_KEYS = 256;
144
+ /** Claude Code skips a file over 4 MiB; so does the audit (OQ9, difference 3). */
145
+ const SKIP_FILE_BYTES = 4 * 1024 * 1024;
146
+ /** Upper bound on read(2) calls for one file: SCAN_BYTES in chunks of at least one byte per call. */
147
+ const MAX_READ_CALLS = 1024;
148
+
149
+ const NOT_SHOWN = '<path not shown>';
150
+ const STAMP_VERSION_ROW = 'V 1';
151
+ const HOOK_MAGIC = 'devflow-claude-md-audit 1';
152
+ const HOOK_END = 'END';
153
+
154
+ /** P-row flags. */
155
+ const PATH_ABSENT = 0;
156
+ const PATH_FILE = 1;
157
+ const PATH_OTHER = 2;
158
+
159
+ const IMPORT_CHARS = /[A-Za-z0-9._/~+-]/;
160
+ const CONTROL_BYTE = /[\u0000-\u001f\u007f]/;
161
+ const DISPLAYABLE = /^[ -~]+$/;
162
+
163
+ // ── Pure text helpers ──────────────────────────────────────────────────────────
164
+
165
+ /** 14220 -> "14,220". */
166
+ function formatBytes(n) {
167
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
168
+ }
169
+
170
+ /** A CommonMark fence opener (the rule of `fenceOpen` in pr-evidence.cjs). */
171
+ function fenceOpen(line) {
172
+ const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
173
+ if (m === null) return null;
174
+ if (m[1][0] === '`' && m[2].includes('`')) return null;
175
+ return { ch: m[1][0], len: m[1].length };
176
+ }
177
+
178
+ /** Whether `line` closes `fence` (the rule of `fenceCloses` in pr-evidence.cjs). */
179
+ function fenceCloses(line, fence) {
180
+ const m = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
181
+ return m !== null && m[1][0] === fence.ch && m[1].length >= fence.len;
182
+ }
183
+
184
+ /**
185
+ * `line` with each inline code span replaced by a placeholder that is not
186
+ * whitespace (so an `@` right after a span is not read as following whitespace).
187
+ * A backtick run closes at the next run of the same length on the same line; a run
188
+ * with no closer is literal text.
189
+ */
190
+ function maskCodeSpans(line) {
191
+ let out = '';
192
+ let i = 0;
193
+ // Each pass consumes at least one character, so the loop ends within line.length passes.
194
+ for (let guard = 0; i < line.length && guard <= line.length; guard++) {
195
+ if (line[i] !== '`') {
196
+ out += line[i];
197
+ i += 1;
198
+ continue;
199
+ }
200
+ let runEnd = i;
201
+ while (runEnd < line.length && line[runEnd] === '`') runEnd += 1;
202
+ const runLength = runEnd - i;
203
+ let close = -1;
204
+ let j = runEnd;
205
+ for (let step = 0; j < line.length && step <= line.length; step++) {
206
+ if (line[j] !== '`') {
207
+ j += 1;
208
+ continue;
209
+ }
210
+ let end = j;
211
+ while (end < line.length && line[end] === '`') end += 1;
212
+ if (end - j === runLength) {
213
+ close = j;
214
+ break;
215
+ }
216
+ j = end;
217
+ }
218
+ if (close === -1) {
219
+ out += line.slice(i, runEnd);
220
+ i = runEnd;
221
+ } else {
222
+ out += '\u0001'.repeat(close + runLength - i);
223
+ i = close + runLength;
224
+ }
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * The import tokens of `text`, in document order: the path after each `@` that
231
+ * starts a line or follows whitespace, outside fenced blocks and inline code spans.
232
+ *
233
+ * @param {string} text
234
+ * @returns {string[]}
235
+ */
236
+ function extractImports(text) {
237
+ const tokens = [];
238
+ let fence = null;
239
+ for (const raw of String(text).split('\n')) {
240
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
241
+ if (fence !== null) {
242
+ if (fenceCloses(line, fence)) fence = null;
243
+ continue;
244
+ }
245
+ const opened = fenceOpen(line);
246
+ if (opened !== null) {
247
+ fence = opened;
248
+ continue;
249
+ }
250
+ const masked = maskCodeSpans(line);
251
+ for (let i = 0; i < masked.length; i++) {
252
+ if (masked[i] !== '@') continue;
253
+ if (i > 0 && !/\s/.test(masked[i - 1])) continue;
254
+ let end = i + 1;
255
+ while (end < masked.length && IMPORT_CHARS.test(masked[end])) end += 1;
256
+ let token = masked.slice(i + 1, end);
257
+ while (token.endsWith('.')) token = token.slice(0, -1);
258
+ if (token !== '') tokens.push(token);
259
+ }
260
+ }
261
+ return tokens;
262
+ }
263
+
264
+ /** The absolute path an import token names, from the file that holds it. */
265
+ function resolveImport(token, importerPath, home) {
266
+ if (token === '~') return home;
267
+ if (token.startsWith('~/')) return path.join(home, token.slice(2));
268
+ if (path.isAbsolute(token)) return path.normalize(token);
269
+ return path.resolve(path.dirname(importerPath), token);
270
+ }
271
+
272
+ /** Whether `p` may appear in a systemMessage or an init line. */
273
+ function isDisplayable(p) {
274
+ return DISPLAYABLE.test(p);
275
+ }
276
+
277
+ function shownPath(p) {
278
+ return isDisplayable(p) ? p : NOT_SHOWN;
279
+ }
280
+
281
+ /** Whether `text` can be one stamp row: no control byte. */
282
+ function isRecordable(text) {
283
+ return !CONTROL_BYTE.test(text);
284
+ }
285
+
286
+ // ── File access (read-only) ────────────────────────────────────────────────────
287
+
288
+ function statOrNull(p) {
289
+ try {
290
+ return fs.statSync(p);
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ function realpathOrSelf(p) {
297
+ try {
298
+ return fs.realpathSync(p);
299
+ } catch {
300
+ return p;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Up to `limit` bytes of the regular file at `p` as `{ text, bytes }`, or null when
306
+ * it cannot be read or is not a regular file. The open is O_NONBLOCK, so a FIFO
307
+ * swapped in after the stat cannot hang it, and fstat re-checks the type before a
308
+ * byte is read.
309
+ */
310
+ function readHead(p, limit) {
311
+ let fd = null;
312
+ try {
313
+ fd = fs.openSync(p, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
314
+ if (!fs.fstatSync(fd).isFile()) return null;
315
+ const buf = Buffer.allocUnsafe(limit);
316
+ let got = 0;
317
+ for (let call = 0; got < limit && call < MAX_READ_CALLS; call++) {
318
+ const n = fs.readSync(fd, buf, got, limit - got, null);
319
+ if (n === 0) break;
320
+ got += n;
321
+ }
322
+ return { text: buf.toString('utf8', 0, got), bytes: got };
323
+ } catch {
324
+ return null;
325
+ } finally {
326
+ if (fd !== null) {
327
+ try { fs.closeSync(fd); } catch { /* nothing left to release */ }
328
+ }
329
+ }
330
+ }
331
+
332
+ /**
333
+ * The stamp's text, or null when the path is absent, is a symlink or other
334
+ * non-regular file, is over MAX_STAMP_BYTES or cannot be read.
335
+ */
336
+ function readStampFile(stampPath) {
337
+ let st;
338
+ try {
339
+ st = fs.lstatSync(stampPath);
340
+ } catch {
341
+ return null;
342
+ }
343
+ if (!st.isFile() || st.size > MAX_STAMP_BYTES) return null;
344
+ const head = readHead(stampPath, Math.max(st.size, 1));
345
+ return head === null ? null : head.text;
346
+ }
347
+
348
+ // ── The walk ───────────────────────────────────────────────────────────────────
349
+
350
+ /**
351
+ * Walk one root's import chain breadth-first, so a file reached by two routes is
352
+ * judged at its shortest hop count.
353
+ */
354
+ function walkRoot(rootPath, ctx) {
355
+ const examined = new Map();
356
+ const seenReal = new Set();
357
+ const members = [];
358
+ const fileFindings = [];
359
+ let total = 0;
360
+ let largest = null;
361
+ let truncated = false;
362
+ let exists = false;
363
+ let rootSize = 0;
364
+ let rootMtime = 0;
365
+ const queue = [{ p: rootPath, hop: 0 }];
366
+ const queued = new Set([rootPath]);
367
+
368
+ // The queue grows by at most the imports of each examined path, and examined paths are capped.
369
+ for (let cursor = 0; cursor < queue.length; cursor++) {
370
+ const { p, hop } = queue[cursor];
371
+ if (examined.has(p)) continue;
372
+ if (examined.size >= MAX_PATHS_PER_ROOT) {
373
+ truncated = true;
374
+ break;
375
+ }
376
+ const st = statOrNull(p);
377
+ if (st === null) {
378
+ examined.set(p, PATH_ABSENT);
379
+ continue;
380
+ }
381
+ if (!st.isFile()) {
382
+ examined.set(p, PATH_OTHER);
383
+ continue;
384
+ }
385
+ examined.set(p, PATH_FILE);
386
+ if (st.size > SKIP_FILE_BYTES) continue;
387
+ const real = realpathOrSelf(p);
388
+ if (seenReal.has(real)) continue;
389
+ seenReal.add(real);
390
+ ctx.counted.add(real);
391
+
392
+ const mtime = Math.floor(st.mtimeMs / 1000);
393
+ total += st.size;
394
+ members.push({ path: p, size: st.size, mtime, hop });
395
+ if (hop === 0) {
396
+ exists = true;
397
+ rootSize = st.size;
398
+ rootMtime = mtime;
399
+ }
400
+ if (largest === null || st.size > largest.size) largest = { path: p, size: st.size };
401
+ if (hop >= 1 && st.size > FILE_THRESHOLD_BYTES) {
402
+ fileFindings.push({ kind: 'file', path: p, size: st.size, mtime, hop });
403
+ }
404
+ if (hop >= MAX_HOPS) continue;
405
+
406
+ const remaining = MAX_BYTES_READ - ctx.bytesRead;
407
+ const want = Math.min(st.size, SCAN_BYTES, remaining);
408
+ if (remaining < Math.min(st.size, SCAN_BYTES)) {
409
+ // The run's byte budget cuts this scan short: imports may be missed, so the total is a lower bound.
410
+ truncated = true;
411
+ ctx.truncated = true;
412
+ }
413
+ if (want <= 0) continue;
414
+ const head = readHead(p, want);
415
+ if (head === null) continue;
416
+ ctx.bytesRead += head.bytes;
417
+ for (const token of extractImports(head.text)) {
418
+ const next = resolveImport(token, p, ctx.home);
419
+ if (examined.has(next) || queued.has(next)) continue;
420
+ queued.add(next);
421
+ queue.push({ p: next, hop: hop + 1 });
422
+ }
423
+ }
424
+
425
+ const findings = [...fileFindings];
426
+ if (total > CHAIN_THRESHOLD_BYTES && largest !== null) {
427
+ findings.push({
428
+ kind: 'chain',
429
+ path: rootPath,
430
+ total,
431
+ mtime: rootMtime,
432
+ largest,
433
+ truncated,
434
+ });
435
+ }
436
+ return {
437
+ path: rootPath,
438
+ exists,
439
+ size: rootSize,
440
+ total,
441
+ largest,
442
+ truncated,
443
+ members,
444
+ examined: [...examined.entries()].map(([p, flag]) => ({ path: p, flag })),
445
+ findings,
446
+ };
447
+ }
448
+
449
+ // ── Findings: keys and display ─────────────────────────────────────────────────
450
+
451
+ function findingKey(f) {
452
+ return f.kind === 'file'
453
+ ? `file ${f.size} ${f.mtime} ${f.path}`
454
+ : `chain ${f.total} ${f.mtime} ${f.path}`;
455
+ }
456
+
457
+ /** The one display line of a finding. Used by the hook and by init alike. */
458
+ function formatFinding(f) {
459
+ if (f.kind === 'file') {
460
+ return `CLAUDE.md import audit: ${shownPath(f.path)} is ${formatBytes(f.size)} bytes `
461
+ + `(over the ${formatBytes(FILE_THRESHOLD_BYTES)}-byte import threshold).`;
462
+ }
463
+ const bound = f.truncated ? 'at least ' : '';
464
+ return `CLAUDE.md import audit: ${shownPath(f.path)} loads ${bound}${formatBytes(f.total)} bytes through its imports `
465
+ + `(over the ${formatBytes(CHAIN_THRESHOLD_BYTES)}-byte chain threshold); the largest file is `
466
+ + `${shownPath(f.largest.path)} at ${formatBytes(f.largest.size)} bytes.`;
467
+ }
468
+
469
+ // ── The stamp ──────────────────────────────────────────────────────────────────
470
+
471
+ /**
472
+ * Parse stamp text. Unknown rows are ignored; text that does not open with the
473
+ * version row is read as an empty stamp.
474
+ *
475
+ * @returns {{ valid: boolean, roots: string[], examined: {path: string, flag: number}[], keys: string[], error: boolean }}
476
+ */
477
+ function parseStamp(text) {
478
+ const out = { valid: false, roots: [], examined: [], keys: [], error: false };
479
+ if (typeof text !== 'string') return out;
480
+ const lines = text.split('\n');
481
+ if (lines[0] !== STAMP_VERSION_ROW) return out;
482
+ out.valid = true;
483
+ for (const line of lines.slice(1)) {
484
+ if (line.startsWith('R ')) out.roots.push(line.slice(2));
485
+ else if (line.startsWith('P ') && /^P [012] /.test(line)) out.examined.push({ path: line.slice(4), flag: Number(line[2]) });
486
+ else if (line.startsWith('K ')) out.keys.push(line.slice(2));
487
+ else if (line.startsWith('E ')) out.error = true;
488
+ }
489
+ return out;
490
+ }
491
+
492
+ /**
493
+ * Render the stamp text (rows joined by "\n", ending in one "\n").
494
+ *
495
+ * @param {{ roots: string[], examined: {path: string, flag: number}[], keys: string[], error?: boolean }} s
496
+ */
497
+ function renderStamp(s) {
498
+ const rows = [STAMP_VERSION_ROW];
499
+ for (const root of s.roots) if (isRecordable(root)) rows.push(`R ${root}`);
500
+ for (const e of s.examined) if (isRecordable(e.path)) rows.push(`P ${e.flag} ${e.path}`);
501
+ for (const key of s.keys.slice(-MAX_KEYS)) if (isRecordable(key)) rows.push(`K ${key}`);
502
+ if (s.error === true) rows.push('E audit-failed');
503
+ return `${rows.join('\n')}\n`;
504
+ }
505
+
506
+ // ── The audit ──────────────────────────────────────────────────────────────────
507
+
508
+ /**
509
+ * Audit `input.roots` and return everything a caller needs, without writing anything.
510
+ *
511
+ * @param {{ roots: string[], home: string, keys?: string[] }} input
512
+ * `roots` are absolute root FILE paths in call order; `keys` are the stamp's
513
+ * already-displayed finding keys.
514
+ * @returns {{
515
+ * ok: true,
516
+ * roots: object[], findings: object[], examined: {path: string, flag: number}[],
517
+ * filesExamined: number, pathsExamined: number, bytesRead: number, truncated: boolean,
518
+ * lines: string[], shown: string[], stamp: string
519
+ * }}
520
+ */
521
+ function audit(input) {
522
+ const home = path.resolve(input.home);
523
+ const priorKeys = Array.isArray(input.keys) ? input.keys.filter(k => typeof k === 'string') : [];
524
+ const ctx = { home, bytesRead: 0, counted: new Set(), truncated: false };
525
+ const roots = input.roots.map(root => walkRoot(path.resolve(root), ctx));
526
+
527
+ const examinedByPath = new Map();
528
+ for (const root of roots) for (const e of root.examined) if (!examinedByPath.has(e.path)) examinedByPath.set(e.path, e.flag);
529
+ const examined = [...examinedByPath.entries()].map(([p, flag]) => ({ path: p, flag }));
530
+
531
+ const findings = roots.flatMap(root => root.findings);
532
+ const already = new Set(priorKeys);
533
+ const lines = [];
534
+ const shown = [];
535
+ for (const f of findings) {
536
+ const key = findingKey(f);
537
+ if (already.has(key)) continue;
538
+ already.add(key);
539
+ lines.push(formatFinding(f));
540
+ shown.push(key);
541
+ }
542
+
543
+ const keys = [...priorKeys, ...shown.filter(isRecordable)].slice(-MAX_KEYS);
544
+ // The R rows keep each root as the caller spelled it, so the hook's string comparison
545
+ // of the root set holds even for a root the walk normalised.
546
+ const stamp = renderStamp({ roots: input.roots, examined, keys });
547
+ return {
548
+ ok: true,
549
+ roots: roots.map(({ members: _members, examined: _examined, findings: _findings, ...rest }) => rest),
550
+ findings,
551
+ examined,
552
+ filesExamined: ctx.counted.size,
553
+ pathsExamined: examined.length,
554
+ bytesRead: ctx.bytesRead,
555
+ truncated: ctx.truncated || roots.some(r => r.truncated),
556
+ lines,
557
+ shown,
558
+ stamp,
559
+ };
560
+ }
561
+
562
+ // ── CLI (the hook's view) ──────────────────────────────────────────────────────
563
+
564
+ /**
565
+ * `hook <home> <stampFile> <root>...` -> the framed block on stdout. Returns the
566
+ * exit code; it never throws.
567
+ */
568
+ function main(argv, stdout) {
569
+ const [mode, home, stampFile, ...roots] = argv;
570
+ if (mode !== 'hook' || !home || !stampFile) return 2;
571
+ try {
572
+ const stampText = readStampFile(stampFile);
573
+ const keys = stampText === null ? [] : parseStamp(stampText).keys;
574
+ const result = audit({ roots, home, keys });
575
+ const block = [HOOK_MAGIC, ...result.lines.map(line => `M ${line}`), result.stamp.replace(/\n$/, ''), HOOK_END];
576
+ stdout.write(`${block.join('\n')}\n`);
577
+ return 0;
578
+ } catch (err) {
579
+ process.stderr.write(`claude-md-audit: ${err instanceof Error ? err.message : String(err)}\n`);
580
+ return 1;
581
+ }
582
+ }
583
+
584
+ if (require.main === module) {
585
+ process.exitCode = main(process.argv.slice(2), process.stdout);
586
+ }
587
+
588
+ module.exports = Object.freeze({
589
+ FILE_THRESHOLD_BYTES,
590
+ CHAIN_THRESHOLD_BYTES,
591
+ MAX_HOPS,
592
+ MAX_PATHS_PER_ROOT,
593
+ SCAN_BYTES,
594
+ MAX_BYTES_READ,
595
+ MAX_STAMP_BYTES,
596
+ MAX_KEYS,
597
+ SKIP_FILE_BYTES,
598
+ NOT_SHOWN,
599
+ HOOK_MAGIC,
600
+ HOOK_END,
601
+ extractImports,
602
+ isDisplayable,
603
+ isRecordable,
604
+ formatFinding,
605
+ findingKey,
606
+ parseStamp,
607
+ renderStamp,
608
+ readStampFile,
609
+ audit,
610
+ main,
611
+ });
@@ -16,6 +16,5 @@ Operating rules:
16
16
  - Subagents see none of this conversation. Make every delegation self-contained: goal, constraints, relevant session decisions and facts, exact paths. A deliverable that draws on the conversation (issue, PR, report) needs the substance in the prompt — not a pointer to it.
17
17
  - Report cap: ask every direct delegation for a final report of at most about 1,500 tokens — findings, paths and verdicts, not file dumps.
18
18
  - Parallelize independent delegations in one message. Git operations stay sequential.
19
- - Feature knowledge (direct delegations only — workflow skills handle their own): before delegating non-trivial code work, match the task area against .devflow/features/index.md and pass matching KNOWLEDGE.md content as FEATURE_KNOWLEDGE; after delegated changes to a covered area, spawn Knowledge to refresh that KB.
20
- - Decisions (direct delegations only — workflow skills load their own): pass the index named under PROJECT DECISIONS as DECISIONS_CONTEXT — its content, read once — to every agent that takes it.
19
+ - Feature knowledge (direct delegations only — workflow skills handle their own): before delegating non-trivial code work, match the task area against .devflow/features/index.md and pass each matching KB's one to three most relevant `## Rules` bullets (verbatim, IDs included; a KB without Rules: Anti-Patterns or Gotchas entries, cited by section), its path and its `##` heading index as FEATURE_KNOWLEDGE; after delegated changes to a covered area, spawn Knowledge to refresh that KB.
21
20
  - Plan handoff: if the user's first message begins with `Implement the following plan:`, say so in one sentence, then immediately invoke devflow:implement via the Skill tool with no arguments — the plan is already in this conversation. Do not pause to ask.
@@ -13,7 +13,7 @@
13
13
  // get-field <field> [default] Read field from stdin JSON
14
14
  // get-string-field <field> Read field from stdin JSON only when it is a string
15
15
  // extract-cwd-field <field> Extract cwd + arbitrary field, SOH-byte delimited
16
- // session-output <context> Build SessionStart output envelope
16
+ // session-output <context> [message] Build SessionStart output envelope (+ systemMessage)
17
17
  // prompt-output <context> Build UserPromptSubmit output envelope
18
18
  // backup-construct Build pre-compact backup JSON from --arg pairs
19
19
  // assign-anchor <decision|pitfall> <obs_id>
@@ -293,13 +293,21 @@ try {
293
293
  }
294
294
 
295
295
  case 'session-output': {
296
+ // D-SYSTEMMESSAGE-ENVELOPE (json-parse json_session_output): an optional second
297
+ // argument is a user-facing message, carried in the top-level `systemMessage` key.
298
+ // One argument, or an empty message, gives today's envelope; a message with an
299
+ // empty context gives `systemMessage` alone, with no hookSpecificOutput key.
296
300
  const ctx = args[0];
297
- console.log(JSON.stringify({
298
- hookSpecificOutput: {
301
+ const message = args[1] || '';
302
+ const envelope = {};
303
+ if (message === '' || ctx !== '') {
304
+ envelope.hookSpecificOutput = {
299
305
  hookEventName: 'SessionStart',
300
306
  additionalContext: ctx,
301
- },
302
- }));
307
+ };
308
+ }
309
+ if (message !== '') envelope.systemMessage = message;
310
+ console.log(JSON.stringify(envelope));
303
311
  break;
304
312
  }
305
313