devflow-kit 2.4.0 → 3.0.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 (213) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.md +111 -18
  3. package/dist/agents/git.md +822 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/ambient.js +160 -145
  6. package/dist/cli/commands/attribution-prompts.js +1 -1
  7. package/dist/cli/commands/capture.js +29 -55
  8. package/dist/cli/commands/compliance-prompts.js +1 -1
  9. package/dist/cli/commands/compliance.js +48 -55
  10. package/dist/cli/commands/context.js +17 -32
  11. package/dist/cli/commands/debug.js +65 -26
  12. package/dist/cli/commands/flags.js +3 -3
  13. package/dist/cli/commands/hud.js +34 -10
  14. package/dist/cli/commands/init-seed.js +61 -27
  15. package/dist/cli/commands/init.js +649 -240
  16. package/dist/cli/commands/install-report.js +200 -0
  17. package/dist/cli/commands/knowledge/index.js +2 -2
  18. package/dist/cli/commands/knowledge/toggle.js +35 -37
  19. package/dist/cli/commands/learning.js +79 -57
  20. package/dist/cli/commands/legacy-hooks.js +11 -14
  21. package/dist/cli/commands/memory.js +134 -135
  22. package/dist/cli/commands/prompt-io.js +4 -4
  23. package/dist/cli/commands/proxy.js +23 -41
  24. package/dist/cli/commands/security.js +81 -29
  25. package/dist/cli/commands/skills.js +71 -7
  26. package/dist/cli/commands/tracker-prompts.js +145 -0
  27. package/dist/cli/commands/tracker.js +277 -0
  28. package/dist/cli/commands/uninstall.js +520 -169
  29. package/dist/cli.js +2 -0
  30. package/dist/commands/bug-analysis.md +58 -14
  31. package/dist/commands/code-review.md +110 -32
  32. package/dist/commands/debug.md +55 -11
  33. package/dist/commands/dynamic-build.md +344 -73
  34. package/dist/commands/dynamic-plan.md +77 -27
  35. package/dist/commands/dynamic-profile.md +25 -11
  36. package/dist/commands/dynamic-tickets.md +76 -15
  37. package/dist/commands/explore.md +37 -7
  38. package/dist/commands/implement.md +314 -62
  39. package/dist/commands/plan.md +146 -32
  40. package/dist/commands/release.md +64 -17
  41. package/dist/commands/research.md +34 -8
  42. package/dist/commands/resolve.md +196 -68
  43. package/dist/commands/self-review.md +45 -9
  44. package/dist/core/agent-models.js +55 -12
  45. package/dist/core/assets.js +58 -2
  46. package/dist/core/compliance-compose.js +27 -27
  47. package/dist/core/evidence-policy.js +363 -0
  48. package/dist/core/feature-config.js +200 -65
  49. package/dist/core/feature-switch.js +112 -0
  50. package/dist/core/flags.js +34 -6
  51. package/dist/core/fs-atomic.js +27 -0
  52. package/dist/core/hook-log-dirs.js +104 -0
  53. package/dist/core/learning-tuning-config.js +5 -3
  54. package/dist/core/ledger-root.js +102 -0
  55. package/dist/core/manifest.js +38 -10
  56. package/dist/core/mds-variants.js +798 -0
  57. package/dist/core/migrations.js +49 -23
  58. package/dist/core/model-discovery.js +12 -1
  59. package/dist/core/plugins.js +361 -12
  60. package/dist/core/project-paths.js +1 -18
  61. package/dist/core/proxy-log.js +8 -6
  62. package/dist/core/proxy-state.js +11 -8
  63. package/dist/core/reference-sweep.js +136 -0
  64. package/dist/core/same-location.js +25 -0
  65. package/dist/core/tracker.js +494 -0
  66. package/dist/hud/components/config-counts.js +15 -4
  67. package/dist/hud/components/learning-counts.js +14 -0
  68. package/dist/hud/config.js +2 -1
  69. package/dist/hud/cost-history.js +2 -4
  70. package/dist/hud/git.js +52 -7
  71. package/dist/hud/index.js +7 -9
  72. package/dist/skills/git/references/decision-markers.md +19 -0
  73. package/dist/skills/git/references/learn-conventions.md +56 -0
  74. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  75. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  76. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  77. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  78. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  79. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  80. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  81. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  82. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  83. package/dist/skills/git/references/publication-gate.md +13 -0
  84. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  85. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  87. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  89. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  90. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  91. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  92. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  93. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  94. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  95. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  96. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  97. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  98. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  99. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  100. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  101. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  102. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  103. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  104. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  105. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  106. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  107. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  108. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  109. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  110. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  111. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  112. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  113. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  114. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  115. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  116. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  117. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  118. package/dist/skills/git/references/trust-rule.md +7 -0
  119. package/dist/targets/claude-code/claude-paths.js +59 -57
  120. package/dist/targets/claude-code/compliance-install.js +49 -65
  121. package/dist/targets/claude-code/hooks.js +108 -3
  122. package/dist/targets/claude-code/installer.js +1187 -32
  123. package/dist/targets/claude-code/legacy.js +5 -0
  124. package/dist/targets/claude-code/post-install.js +366 -151
  125. package/dist/targets/claude-code/tracker-install.js +134 -0
  126. package/package.json +8 -6
  127. package/src/assets/agents/code.md +45 -6
  128. package/src/assets/agents/design.md +2 -1
  129. package/src/assets/agents/git.mds +825 -0
  130. package/src/assets/agents/knowledge.md +3 -3
  131. package/src/assets/agents/learning.md +11 -0
  132. package/src/assets/agents/review.md +3 -1
  133. package/src/assets/agents/synthesize.md +1 -1
  134. package/src/assets/agents/test.md +16 -5
  135. package/src/assets/agents/tracker.md +474 -0
  136. package/src/assets/agents/validate.md +7 -5
  137. package/src/assets/commands/_partials/_compliance.mds +19 -1
  138. package/src/assets/commands/_partials/_decisions.mds +15 -3
  139. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  140. package/src/assets/commands/_partials/_engine.mds +13 -11
  141. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  142. package/src/assets/commands/_partials/_factory.mds +1 -1
  143. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  144. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  145. package/src/assets/commands/_partials/_preamble.mds +2 -2
  146. package/src/assets/commands/_partials/_publication.mds +8 -2
  147. package/src/assets/commands/_partials/_settings.mds +28 -0
  148. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  149. package/src/assets/commands/_partials/_tracker.mds +18 -0
  150. package/src/assets/commands/_partials/_wave.mds +16 -10
  151. package/src/assets/commands/bug-analysis.mds +31 -19
  152. package/src/assets/commands/code-review.mds +67 -41
  153. package/src/assets/commands/debug.mds +13 -7
  154. package/src/assets/commands/dynamic-build.mds +274 -66
  155. package/src/assets/commands/dynamic-plan.mds +50 -23
  156. package/src/assets/commands/dynamic-profile.mds +24 -11
  157. package/src/assets/commands/dynamic-tickets.mds +63 -16
  158. package/src/assets/commands/explore.mds +4 -5
  159. package/src/assets/commands/implement.mds +234 -67
  160. package/src/assets/commands/plan.mds +91 -33
  161. package/src/assets/commands/release.md +64 -17
  162. package/src/assets/commands/research.mds +11 -9
  163. package/src/assets/commands/resolve.mds +150 -78
  164. package/src/assets/commands/self-review.mds +24 -25
  165. package/src/assets/mds/git/_pr.mds +331 -0
  166. package/src/assets/mds/git/_references.mds +135 -0
  167. package/src/assets/mds/tracker/_common.mds +156 -0
  168. package/src/assets/mds/tracker/_github.mds +472 -0
  169. package/src/assets/mds/tracker/_jira.mds +407 -0
  170. package/src/assets/mds/tracker/_linear.mds +449 -0
  171. package/src/assets/mds/tracker/_mcp.mds +305 -0
  172. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  173. package/src/assets/scripts/hooks/background-memory-update +40 -19
  174. package/src/assets/scripts/hooks/capture-prompt +18 -8
  175. package/src/assets/scripts/hooks/capture-question +18 -8
  176. package/src/assets/scripts/hooks/capture-turn +27 -13
  177. package/src/assets/scripts/hooks/debug-trace +11 -6
  178. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  179. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  180. package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
  181. package/src/assets/scripts/hooks/git-marker +48 -0
  182. package/src/assets/scripts/hooks/hook-log-init +3 -1
  183. package/src/assets/scripts/hooks/json-helper.cjs +228 -5
  184. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
  185. package/src/assets/scripts/hooks/log-paths +80 -0
  186. package/src/assets/scripts/hooks/memory-worker +22 -13
  187. package/src/assets/scripts/hooks/pre-compact-memory +44 -15
  188. package/src/assets/scripts/hooks/preamble +1 -4
  189. package/src/assets/scripts/hooks/queue-append +146 -28
  190. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  191. package/src/assets/scripts/hooks/session-start-context +534 -20
  192. package/src/assets/scripts/hooks/session-start-memory +38 -15
  193. package/src/assets/scripts/lib/project-config.cjs +633 -0
  194. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  195. package/src/assets/scripts/redact-secrets.cjs +490 -62
  196. package/src/assets/scripts/release-trace.cjs +1143 -0
  197. package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
  198. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  199. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  200. package/src/assets/skills/compliance/SKILL.md +4 -2
  201. package/src/assets/skills/docs-framework/SKILL.md +11 -10
  202. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  203. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  204. package/src/assets/skills/git/SKILL.md +8 -78
  205. package/src/assets/skills/git/references/github-api.md +179 -141
  206. package/src/assets/skills/git/references/patterns.md +11 -6
  207. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  208. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  209. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  210. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  211. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  212. package/src/targets/claude-code/templates/managed-settings.json +25 -9
  213. package/src/assets/agents/git.md +0 -938
@@ -6,17 +6,44 @@
6
6
  // Installed as a top-level sibling of hud.sh under ~/.devflow/scripts/.
7
7
  //
8
8
  // Usage: node redact-secrets.cjs <input-file> <output-file>
9
+ // node redact-secrets.cjs --emit <input-file>
10
+ //
11
+ // The two modes exist because their sinks differ, not for convenience.
12
+ // <in> <out> FILE sink. The caller gates the post with a shell `&&` chain and
13
+ // passes the scrubbed FILE to `--body-file`.
14
+ // --emit TOOL-CALL sink (GAP-04). A tracker reached through a tool call has
15
+ // no `--body-file` and no shell operator between the scrub and the
16
+ // post, so the `&&` gate cannot exist. Instead the scrubbed bytes
17
+ // are printed behind a framing line only this script can produce,
18
+ // and LINE 1 is always that line:
19
+ // D11-OK <nonce> <sha256> <bytes> <n> [type:count,…]
20
+ // <the scrubbed body>
21
+ // Stdout whose line 1 is not the framing is a body that was never
22
+ // scrubbed, and a body that would itself have carried a framing
23
+ // line is refused rather than emitted — so "the bytes after line 1"
24
+ // and "the bytes after the framing" can never name different bytes.
25
+ // No failure ever writes body bytes: a failure the mode
26
+ // owns is EXACTLY `D11-FAIL <reason>` and nothing else, and the two
27
+ // that precede or escape mode selection — a usage error and an
28
+ // internal error — leave stdout entirely EMPTY. The consumer gates
29
+ // on the presence of `D11-OK`, so all three are one case to it.
9
30
  //
10
31
  // Exit codes:
11
32
  // 0 success (zero or more redactions made)
12
- // 1 usage error (wrong number of arguments)
33
+ // 1 usage error (wrong arity, or an unrecognised flag)
13
34
  // 2 input file unreadable or larger than 1 MiB
14
- // 3 output file write failed
35
+ // 3 output file write failed — the FILE sink only; `--emit` writes no file
15
36
  // 4 internal / unexpected error
37
+ // 5 --emit only: a gate refused — the second scrub pass was non-zero, the
38
+ // scrubbed body carried a framing line of its own, or a nonce could not be
39
+ // generated. Distinct from 4 so a caller can tell "the body must not be
40
+ // posted" from "the script broke": the first is final, the second is retried.
16
41
  //
17
42
  // Design constraints (binding):
18
- // PF-011 writes via temp-sibling + rename (atomic same-fs write; readers see
19
- // old-or-new, never a momentarily absent file)
43
+ // PF-011 the file sink — the one file this script writes — goes via
44
+ // temp-sibling + rename (atomic same-fs write; readers see old-or-new,
45
+ // never a momentarily absent file). `--emit` prints to stdout and
46
+ // touches no file, so it has nothing to protect
20
47
  // PF-014 never call process.exit() inside any scope with pending cleanup or
21
48
  // buffered output; main() returns an exit code; the single top-level
22
49
  // boundary writes stdout SYNCHRONOUSLY then sets process.exitCode so
@@ -30,6 +57,9 @@
30
57
  'use strict';
31
58
 
32
59
  const fs = require('fs');
60
+ // Genuinely new: no hashing or randomness helper exists anywhere else
61
+ // under src/assets/scripts. frameEmit is the ONLY consumer.
62
+ const crypto = require('crypto');
33
63
 
34
64
  // ---------------------------------------------------------------------------
35
65
  // Constants
@@ -38,6 +68,83 @@ const fs = require('fs');
38
68
  /** Maximum allowed input size in bytes (1 MiB). */
39
69
  const MAX_INPUT_BYTES = 1048576;
40
70
 
71
+ /**
72
+ * Nonce width, in hex characters (16 random bytes).
73
+ *
74
+ * The nonce is per-invocation and REQUIRED (§14.9-3), and it is the SECOND of two
75
+ * independent controls over the same forgery. `FRAMING_IN_BODY_RE` below is the
76
+ * first: no emitted body can hold a framing line at all. The nonce is what a
77
+ * consumer still has if it reads the framing from somewhere other than line 1 —
78
+ * a fixed `D11-OK` literal would be reproducible by anyone who can write an issue
79
+ * comment, and an unpredictable one is not.
80
+ *
81
+ * Exported so the framing grammar's guard pins its width from here rather than
82
+ * from a retyped number.
83
+ */
84
+ const NONCE_HEX_CHARS = 32;
85
+
86
+ /**
87
+ * A BODY line that would read as framing.
88
+ *
89
+ * The framing's one job is to say where the scrubbed bytes begin, and it can only
90
+ * do that if line 1 is the only line shaped like it. Composed bodies carry
91
+ * untrusted issue and comment text, so a body is one comment away from holding a
92
+ * `D11-OK`-shaped line of its own — and a consumer that looked for "a D11-OK
93
+ * line" instead of "line 1" would take the planted one, post the attacker's half
94
+ * under a devflow-authored marker, and suppress the real summary along with its
95
+ * SECRET-EXPOSED rotation warning.
96
+ *
97
+ * Refusing such a body here is what makes the line-1 rule MECHANICAL: the prose
98
+ * rule then describes a property of every body this script can emit, instead of
99
+ * an obligation nine documents have to restate correctly.
100
+ *
101
+ * PF-018: bounded — a fixed alternation over two literals, anchored per line by
102
+ * the `m` flag, with no quantifier to backtrack through. The trailing space is
103
+ * load-bearing: it is what keeps prose such as `D11-FAILURE` out of the refusal.
104
+ */
105
+ const FRAMING_IN_BODY_RE = /^D11-(OK|FAIL) /m;
106
+
107
+ /** The `SCRUB: ` prefix — one spelling, shared by formatScrubLine and frameEmit. */
108
+ const SCRUB_LINE_PREFIX = 'SCRUB: ';
109
+
110
+ /** The exact text a clean pass produces. The second pass returning THIS is the gate. */
111
+ const ZERO_SCRUB_LINE = SCRUB_LINE_PREFIX + '0 []';
112
+
113
+ /**
114
+ * Every reason that may follow `D11-FAIL `.
115
+ *
116
+ * Bare lowercase tokens, never prose and never a path: stdout is read back by an
117
+ * agent and pasted into reports, so a reason carrying a tmpdir path or input
118
+ * bytes would travel with it. The human-readable diagnosis goes to stderr, which
119
+ * no recipe forwards.
120
+ *
121
+ * A closed registry rather than inline strings, for the reason
122
+ * compliance-compose.ts states about its token tables: a guard asserts the shape
123
+ * of every entry, and an entry added inline would not be covered by it.
124
+ */
125
+ const D11_FAIL_REASONS = Object.freeze({
126
+ INPUT_UNREADABLE: 'input-unreadable',
127
+ INPUT_TOO_LARGE: 'input-too-large',
128
+ SECOND_PASS_NONZERO: 'second-pass-nonzero',
129
+ BODY_CONTAINS_FRAMING: 'body-contains-framing',
130
+ NONCE_UNAVAILABLE: 'nonce-unavailable',
131
+ });
132
+
133
+ // EVERY reason here is emitted by an arm of the emit mode, and a test drives the
134
+ // arms and compares what they produce against this registry. Two tokens a reader
135
+ // might expect are deliberately absent:
136
+ //
137
+ // internal-error the top-level catch fires BEFORE the boundary has written
138
+ // anything and knows no mode, so it leaves stdout empty
139
+ // rather than framing a reason.
140
+ // output-unwritable the emit mode's sink is stdout and it writes no file, so
141
+ // no write of its own can fail. Exit 3 belongs to the file
142
+ // mode, which returns a bare code and frames nothing.
143
+ //
144
+ // A token with no arm that can emit it is a value in a closed vocabulary that a
145
+ // consumer can never see, and it reads as a live refusal to anyone auditing the
146
+ // set.
147
+
41
148
  // ---------------------------------------------------------------------------
42
149
  // Shannon entropy
43
150
  // Bounded by string length; O(n) time, O(distinct-chars) space.
@@ -69,23 +176,47 @@ function shannonEntropy(s) {
69
176
  // Applied before every replacement to guard against false positives.
70
177
  // ---------------------------------------------------------------------------
71
178
 
179
+ /**
180
+ * A marker this script's own passes write. The slug vocabulary is lowercase and
181
+ * hyphenated, so the class is bounded and the pattern cannot span two markers.
182
+ */
183
+ const REDACTION_MARKER_RE = /\[REDACTED:[a-z][a-z-]{1,38}\]/g;
184
+
72
185
  /**
73
186
  * @param {string} candidate The matched text (or value portion) to test.
74
187
  * @returns {boolean}
75
188
  */
76
189
  function shouldSkip(candidate) {
77
- // Idempotency guard: already-redacted markers are never re-matched
78
- if (candidate.includes('[REDACTED:')) return true;
190
+ // Idempotency guard, marker-STRIPPED rather than contains-based (GAP-54).
191
+ //
192
+ // A value this script already produced is markers and whitespace and nothing
193
+ // else, so removing them leaves nothing and the value is skipped — which is
194
+ // what keeps the second pass at zero and the `--emit` gate open. Bytes that
195
+ // SURVIVE the strip are not this script's output: `[REDACTED:` is a literal
196
+ // anyone can type into an issue comment, and a contains-check let one disarm
197
+ // rule 8 — the only generic `key = value` rule — for the whole line. An
198
+ // anchored check cannot serve here either: a value holding two markers would
199
+ // fail it, the second pass could never return zero, and the gate would refuse
200
+ // every body.
201
+ if (candidate.replace(REDACTION_MARKER_RE, '').trim() === '') return true;
79
202
 
80
203
  // Environment variable references (value is not the secret itself)
81
204
  if (candidate.includes('process.env.')) return true;
82
205
  if (candidate.includes('os.environ')) return true;
83
206
 
84
- // Template / shell variable references (bounded alternation, no ReDoS risk)
85
- if (/\$\{[^}]{0,300}\}/.test(candidate)) return true; // ${VAR}
86
- if (/\$[A-Za-z_][A-Za-z0-9_]*/.test(candidate)) return true; // $VAR
87
- if (/\{\{[^}]{0,300}\}\}/.test(candidate)) return true; // {{ template }}
88
- if (/<[^>]{0,300}>/.test(candidate)) return true; // <placeholder>
207
+ // Template / shell variable references (bounded alternation, no ReDoS risk).
208
+ //
209
+ // ANCHORED, both ends (GAP-54). These four skips exist to keep AUTHOR fixtures
210
+ // readable — `api_key = "${DEPLOY_KEY}"` is documentation, not a credential. A
211
+ // value that merely CONTAINS a placeholder is a different thing: `api_key =
212
+ // "<ref> a8Kd91jZx0Qw7Lp2Vn"` would disarm rule 8 — the only generic
213
+ // `key = value` rule — and provider-rendered bodies and remote issue text are
214
+ // exactly what flows into a composed comment sink. So the skip fires only when
215
+ // the value IS the placeholder and nothing else.
216
+ if (/^\$\{[^}]{0,300}\}$/.test(candidate)) return true; // ${VAR}
217
+ if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(candidate)) return true; // $VAR
218
+ if (/^\{\{[^}]{0,300}\}\}$/.test(candidate)) return true; // {{ template }}
219
+ if (/^<[^>]{0,300}>$/.test(candidate)) return true; // <placeholder>
89
220
 
90
221
  // Keyword / low-entropy values that are never real secrets
91
222
  if (/^(null|undefined|true|false|none|changeme|example)$/i.test(candidate)) return true;
@@ -301,7 +432,159 @@ function formatScrubLine(counts) {
301
432
  const entries = Object.entries(counts);
302
433
  const total = entries.reduce((sum, [, n]) => sum + n, 0);
303
434
  const parts = entries.map(([slug, n]) => slug + ':' + n);
304
- return 'SCRUB: ' + total + ' [' + parts.join(',') + ']';
435
+ return SCRUB_LINE_PREFIX + total + ' [' + parts.join(',') + ']';
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // [DR-14] Three pure helpers, and main() is a dispatcher over them
440
+ //
441
+ // Without this split main() would parse arguments, run two scrub passes,
442
+ // generate randomness, hash, manage a temp-file lifecycle, select between two
443
+ // output framings and choose among four exit codes — nine responsibilities in
444
+ // the D11 sink for every provider, with the only structural mitigation being
445
+ // boundary-scoped. Each helper below is also the ONLY way to reach one arm:
446
+ // parseArgs is observable without a subprocess, and the nonce-failure arm is
447
+ // reachable through injection and through nothing else.
448
+ // ---------------------------------------------------------------------------
449
+
450
+ /**
451
+ * @typedef {{ kind: 'emit', inputPath: string }} EmitArgs
452
+ * @typedef {{ kind: 'file', inputPath: string, outputPath: string }} FileArgs
453
+ * @typedef {{ kind: 'usage', usage: string }} UsageError
454
+ */
455
+
456
+ /**
457
+ * Parse argv into a mode and its positionals.
458
+ *
459
+ * THE FLAG IS READ BEFORE THE POSITIONALS ARE BOUND, so `--emit` can never bind
460
+ * as a FILENAME and die at statSync with exit 2 — reporting "your input is
461
+ * missing" for what is actually an unsupported flag.
462
+ *
463
+ * `kind` is the DISCRIMINANT, and main() dispatches on it alone. The three
464
+ * shapes also differ in which fields they carry, but reading the mode off field
465
+ * presence makes a renamed field — or a fourth shape — resolve to an existing
466
+ * arm instead of failing, and the arms differ in whether the scrubbed body
467
+ * reaches stdout.
468
+ *
469
+ * Arity is exact in both modes. A third positional is a usage error rather than
470
+ * an ignored argument: `--emit in out` is a caller who believes they are writing
471
+ * a file, and silently printing the body to stdout instead would put a scrubbed
472
+ * comment body into a terminal log they never read.
473
+ *
474
+ * @param {string[]} argv process.argv
475
+ * @returns {EmitArgs | FileArgs | UsageError}
476
+ */
477
+ function parseArgs(argv) {
478
+ const FILE_USAGE = 'Usage: node redact-secrets.cjs <input-file> <output-file>';
479
+ const EMIT_USAGE = 'Usage: node redact-secrets.cjs --emit <input-file>';
480
+
481
+ let emit = false;
482
+ /** @type {string[]} */
483
+ const positionals = [];
484
+ for (const arg of argv.slice(2)) {
485
+ if (arg === '--emit') {
486
+ emit = true;
487
+ continue;
488
+ }
489
+ if (arg.startsWith('-')) {
490
+ return {
491
+ kind: 'usage',
492
+ usage: 'redact-secrets: unrecognised flag ' + arg + '\n' + FILE_USAGE + '\n' + EMIT_USAGE,
493
+ };
494
+ }
495
+ positionals.push(arg);
496
+ }
497
+
498
+ if (emit) {
499
+ if (positionals.length !== 1) return { kind: 'usage', usage: EMIT_USAGE };
500
+ return { kind: 'emit', inputPath: positionals[0] };
501
+ }
502
+ if (positionals.length !== 2) return { kind: 'usage', usage: FILE_USAGE };
503
+ return { kind: 'file', inputPath: positionals[0], outputPath: positionals[1] };
504
+ }
505
+
506
+ /**
507
+ * Scrub, then scrub the RESULT again.
508
+ *
509
+ * The second pass is the gate: it re-scrubs what the first pass produced, so a
510
+ * zero second count is evidence that the first pass left nothing behind. Passing
511
+ * the ORIGINAL content twice would find the same secrets again and the gate would
512
+ * never open — the one wiring mistake that turns the whole mode off, which is why
513
+ * a test pins which content the second call receives.
514
+ *
515
+ * Nearly free: idempotency is already pinned by shouldSkip's marker-stripped
516
+ * skip, which passes over a value that is this script's own output.
517
+ *
518
+ * @param {string} content
519
+ * @param {(c: string) => ScrubResult} [scrubFn] Injectable so the refusal arm is
520
+ * provable without a pathological fixture — the real rules ARE idempotent, so
521
+ * no input reaches a non-zero second pass.
522
+ * @returns {{ text: string, first: Record<string, number>, second: Record<string, number> }}
523
+ */
524
+ function scrubTwice(content, scrubFn) {
525
+ const doScrub = scrubFn || scrub;
526
+ const first = doScrub(content);
527
+ const second = doScrub(first.result);
528
+ return { text: first.result, first: first.counts, second: second.counts };
529
+ }
530
+
531
+ /** Default nonce source: 16 CSPRNG bytes as lowercase hex. */
532
+ function defaultNonceSource() {
533
+ return crypto.randomBytes(NONCE_HEX_CHARS / 2).toString('hex');
534
+ }
535
+
536
+ /**
537
+ * Build the `D11-OK` framing line for a scrubbed body.
538
+ *
539
+ * The line carries four facts, and each answers a specific way the channel can
540
+ * fail between this process's stdout and the tool call that posts the body:
541
+ * <nonce> per-invocation, so the line cannot be forged from inside the body;
542
+ * <sha256> identifies these exact bytes;
543
+ * <bytes> [DR-06] the UTF-8 byte length, so a consumer can detect a
544
+ * harness-TRUNCATED result. Truncation keeps line 1 intact, so a bare
545
+ * "no framing line ⇒ do not post" gate passes while the body is
546
+ * partial — a guard that appears to work while failing;
547
+ * <n> […] [DR-01] the FIRST pass's count and per-type payload. The second
548
+ * pass is always zero by construction, so without this the only
549
+ * signal that a real credential was present is computed and discarded,
550
+ * and the user is never told to rotate it.
551
+ *
552
+ * formatScrubLine stays the sole producer of the `N [type:count,…]` text; this
553
+ * embeds it by stripping the shared prefix rather than re-deriving the format.
554
+ *
555
+ * @param {string} scrubbed The scrubbed body.
556
+ * @param {string} scrubLine The FIRST pass's formatScrubLine output.
557
+ * @param {() => unknown} [nonceSource] `unknown` is the contract this function
558
+ * implements: it type-checks what the source returns and refuses anything that
559
+ * is not 32 hex characters, so declaring `() => string` would describe a
560
+ * narrower contract than the code and force every malformed-nonce fixture to
561
+ * cast past the check it exists to prove.
562
+ * @returns {{ emitLine: string, body: string } | { error: string }}
563
+ */
564
+ function frameEmit(scrubbed, scrubLine, nonceSource) {
565
+ const source = nonceSource || defaultNonceSource;
566
+ let nonce;
567
+ try {
568
+ nonce = source();
569
+ } catch (/** @type {any} */ err) {
570
+ return { error: 'nonce generation failed: ' + (err.code || err.message) };
571
+ }
572
+ if (typeof nonce !== 'string' || !new RegExp('^[0-9a-f]{' + NONCE_HEX_CHARS + '}$').test(nonce)) {
573
+ // A short, empty or non-hex nonce is unforgeable-by-accident only; treating it
574
+ // as usable would ship a framing line whose one security property is absent.
575
+ return { error: 'nonce generation failed: malformed nonce' };
576
+ }
577
+
578
+ const sha256 = crypto.createHash('sha256').update(scrubbed, 'utf8').digest('hex');
579
+ const bytes = Buffer.byteLength(scrubbed, 'utf8');
580
+ const payload = scrubLine.startsWith(SCRUB_LINE_PREFIX)
581
+ ? scrubLine.slice(SCRUB_LINE_PREFIX.length)
582
+ : scrubLine;
583
+
584
+ return {
585
+ emitLine: 'D11-OK ' + nonce + ' ' + sha256 + ' ' + bytes + ' ' + payload,
586
+ body: scrubbed,
587
+ };
305
588
  }
306
589
 
307
590
  // ---------------------------------------------------------------------------
@@ -315,61 +598,74 @@ function formatScrubLine(counts) {
315
598
  // ---------------------------------------------------------------------------
316
599
 
317
600
  /**
318
- * @param {string[]} argv process.argv
319
- * @returns {number | { scrubLine: string }}
601
+ * Read the input, enforcing the size bound.
602
+ *
603
+ * Shared by both modes so the two cannot drift on what "unreadable" means. The
604
+ * `message` is exactly the stderr text the file mode has always written — the
605
+ * mode-specific part is only which stdout framing (if any) accompanies it.
606
+ *
607
+ * @param {string} inputPath
608
+ * @returns {{ ok: true, content: string } | { ok: false, code: number, reason: string, message: string }}
320
609
  */
321
- function main(argv) {
322
- const inputPath = argv[2];
323
- const outputPath = argv[3];
324
-
325
- if (!inputPath || !outputPath) {
326
- process.stderr.write('Usage: node redact-secrets.cjs <input-file> <output-file>\n');
327
- return 1;
328
- }
329
-
330
- // ---- stat input ----
610
+ function readInput(inputPath) {
331
611
  let stat;
332
612
  try {
333
613
  stat = fs.statSync(inputPath);
334
614
  } catch (/** @type {any} */ err) {
335
- process.stderr.write(
336
- 'redact-secrets: cannot stat input: ' + inputPath + ': ' + (err.code || err.message) + '\n',
337
- );
338
- return 2;
615
+ return {
616
+ ok: false,
617
+ code: 2,
618
+ reason: D11_FAIL_REASONS.INPUT_UNREADABLE,
619
+ message: 'redact-secrets: cannot stat input: ' + inputPath + ': ' + (err.code || err.message) + '\n',
620
+ };
339
621
  }
340
622
 
341
623
  if (stat.size > MAX_INPUT_BYTES) {
342
- process.stderr.write(
343
- 'redact-secrets: input exceeds 1 MiB: ' + inputPath + '\n',
344
- );
345
- return 2;
624
+ return {
625
+ ok: false,
626
+ code: 2,
627
+ reason: D11_FAIL_REASONS.INPUT_TOO_LARGE,
628
+ message: 'redact-secrets: input exceeds 1 MiB: ' + inputPath + '\n',
629
+ };
346
630
  }
347
631
 
348
- // ---- read input ----
349
- let rawBuffer;
350
632
  try {
351
- rawBuffer = fs.readFileSync(inputPath);
633
+ return { ok: true, content: fs.readFileSync(inputPath).toString('utf8') };
352
634
  } catch (/** @type {any} */ err) {
353
- process.stderr.write(
354
- 'redact-secrets: cannot read input: ' + inputPath + ': ' + (err.code || err.message) + '\n',
355
- );
356
- return 2;
635
+ return {
636
+ ok: false,
637
+ code: 2,
638
+ reason: D11_FAIL_REASONS.INPUT_UNREADABLE,
639
+ message: 'redact-secrets: cannot read input: ' + inputPath + ': ' + (err.code || err.message) + '\n',
640
+ };
357
641
  }
642
+ }
358
643
 
359
- // ---- scrub ----
360
- const content = rawBuffer.toString('utf8');
644
+ /**
645
+ * The FILE-sink mode — byte-for-byte the behaviour that shipped before `--emit`.
646
+ *
647
+ * Deliberately calls `scrub` ONCE, not scrubTwice: the recipes, the Tracker
648
+ * agent's write chain and every `gh` call depend on this path's exact stdout and
649
+ * exit codes, and a second pass here would add a failure mode to a contract
650
+ * nothing asked to change.
651
+ *
652
+ * @param {FileArgs} args
653
+ * @param {string} content
654
+ * @returns {number | { scrubLine: string }}
655
+ */
656
+ function runFileMode(args, content) {
361
657
  const { result, counts } = scrub(content);
362
658
 
363
659
  // ---- atomic write (PF-011: temp-sibling + rename) ----
364
- const tmpPath = outputPath + '.tmp';
660
+ const tmpPath = args.outputPath + '.tmp';
365
661
  try {
366
662
  fs.writeFileSync(tmpPath, result, 'utf8');
367
- fs.renameSync(tmpPath, outputPath);
663
+ fs.renameSync(tmpPath, args.outputPath);
368
664
  } catch (/** @type {any} */ err) {
369
665
  // Best-effort cleanup of the temp file; ignore errors (the temp may not exist)
370
666
  try { fs.unlinkSync(tmpPath); } catch (_) { /* intentionally ignored */ }
371
667
  process.stderr.write(
372
- 'redact-secrets: cannot write output: ' + outputPath + ': ' + (err.code || err.message) + '\n',
668
+ 'redact-secrets: cannot write output: ' + args.outputPath + ': ' + (err.code || err.message) + '\n',
373
669
  );
374
670
  return 3;
375
671
  }
@@ -377,34 +673,166 @@ function main(argv) {
377
673
  return { scrubLine: formatScrubLine(counts) };
378
674
  }
379
675
 
676
+ /**
677
+ * The TOOL-CALL-sink mode.
678
+ *
679
+ * Always returns an `{ emitLine, body, code }` triple, and `body` is `''` on
680
+ * every non-zero code. That is what makes "no body on any non-zero exit" a
681
+ * property of the type rather than a rule each arm has to remember: the boundary
682
+ * writes `emitLine + '\n' + body` unconditionally, so a failing arm cannot emit a
683
+ * body even by forgetting to suppress one.
684
+ *
685
+ * THIS MODE TOUCHES NO FILE. It holds the bytes it returns and the boundary
686
+ * prints them, so there is nothing for a write discipline to protect: a scrubbed
687
+ * comment body put on disk is a second copy with the input directory's lifetime,
688
+ * and proving that directory writable would let a filesystem property refuse a
689
+ * clean, fully gated body. The file mode's temp-sibling + rename (PF-011) guards
690
+ * the one file this script does write.
691
+ *
692
+ * @param {string} content
693
+ * @param {{ scrubFn?: (c: string) => ScrubResult, nonceSource?: () => unknown }} deps
694
+ * @returns {{ emitLine: string, body: string, code: number }}
695
+ */
696
+ function runEmitMode(content, deps) {
697
+ const { text, first, second } = scrubTwice(content, deps.scrubFn);
698
+
699
+ // THE GATE. A non-zero second pass means the first pass did not hold, so the
700
+ // body is not publishable and no amount of re-running changes that.
701
+ const secondLine = formatScrubLine(second);
702
+ if (secondLine !== ZERO_SCRUB_LINE) {
703
+ process.stderr.write(
704
+ 'redact-secrets: second scrub pass was non-zero (' + secondLine + ') — refusing to emit\n',
705
+ );
706
+ return { emitLine: 'D11-FAIL ' + D11_FAIL_REASONS.SECOND_PASS_NONZERO, body: '', code: 5 };
707
+ }
708
+
709
+ // THE FRAMING GATE. A body that carries a framing line of its own lets its
710
+ // author decide where a consumer thinks the body begins. Refusing is fail-closed
711
+ // in the direction the sink needs: the item degrades and nothing is posted.
712
+ if (FRAMING_IN_BODY_RE.test(text)) {
713
+ process.stderr.write(
714
+ 'redact-secrets: the scrubbed body carries a D11 framing line — refusing to emit\n',
715
+ );
716
+ return { emitLine: 'D11-FAIL ' + D11_FAIL_REASONS.BODY_CONTAINS_FRAMING, body: '', code: 5 };
717
+ }
718
+
719
+ const framed = frameEmit(text, formatScrubLine(first), deps.nonceSource);
720
+ if (framed.error !== undefined) {
721
+ process.stderr.write('redact-secrets: ' + framed.error + ' — refusing to emit\n');
722
+ return { emitLine: 'D11-FAIL ' + D11_FAIL_REASONS.NONCE_UNAVAILABLE, body: '', code: 5 };
723
+ }
724
+
725
+ return { emitLine: framed.emitLine, body: framed.body, code: 0 };
726
+ }
727
+
728
+ /**
729
+ * @param {string[]} argv process.argv
730
+ * @param {{ scrubFn?: (c: string) => ScrubResult, nonceSource?: () => unknown }} [deps]
731
+ * Injected only by tests, and only to reach the two arms no fixture can: a
732
+ * non-idempotent scrub and an unavailable nonce. Defaulted here rather than at
733
+ * each use site so production has exactly one set of dependencies.
734
+ * @returns {number | { scrubLine: string } | { emitLine: string, body: string, code: number }}
735
+ */
736
+ function main(argv, deps) {
737
+ const args = parseArgs(argv);
738
+ if (args.kind === 'usage') {
739
+ // The one failure that precedes mode selection, so no framing line can
740
+ // describe it: stdout stays entirely empty and stderr carries the usage.
741
+ process.stderr.write(args.usage + '\n');
742
+ return 1;
743
+ }
744
+
745
+ const read = readInput(args.inputPath);
746
+ if (!read.ok) {
747
+ process.stderr.write(read.message);
748
+ // Each mode owns the SHAPE of its refusal: the tool-call sink frames every
749
+ // failure it can name, the file sink returns a bare code.
750
+ return args.kind === 'emit'
751
+ ? { emitLine: 'D11-FAIL ' + read.reason, body: '', code: read.code }
752
+ : read.code;
753
+ }
754
+
755
+ return args.kind === 'emit'
756
+ ? runEmitMode(read.content, deps || {})
757
+ : runFileMode(args, read.content);
758
+ }
759
+
380
760
  // ---------------------------------------------------------------------------
381
761
  // Top-level boundary
382
762
  //
383
763
  // This is the ONLY place that writes to stdout and sets process.exitCode.
384
764
  // No other code path may call process.exit() or write to stdout.
385
765
  // (PF-014: single synchronous write, no pending cleanup, no buffered output)
766
+ //
767
+ // AMENDED for --emit, not bypassed. main()'s return widened from
768
+ // `number | {scrubLine}` to also carry `{emitLine, body, code}`, and the write
769
+ // became a TWO-BRANCH synchronous write — one branch per output shape. There are
770
+ // still exactly two process.stdout.write sites in this file, and a guard asserts
771
+ // that count: a third site is precisely how a body would reach stdout without
772
+ // passing the gate.
773
+ //
774
+ // The emit branch writes `emitLine + '\n' + body` UNCONDITIONALLY, because a
775
+ // failing emit result carries `body: ''` by construction. Suppressing the body
776
+ // here instead would put the "no body on failure" property in this block, where
777
+ // a future arm could forget it; putting it in the result keeps it a property of
778
+ // every arm that can produce one.
779
+ //
780
+ // Guarded by `require.main === module` so the pure helpers above are importable
781
+ // by their unit tests. Nothing else changes: `node redact-secrets.cjs …` still
782
+ // enters here, and the block is still the only exit-code and stdout authority.
386
783
  // ---------------------------------------------------------------------------
387
784
 
388
- let exitCode = 0;
389
- let scrubLine = /** @type {string | null} */ (null);
785
+ if (require.main === module) {
786
+ let exitCode = 0;
787
+ let scrubLine = /** @type {string | null} */ (null);
788
+ let emitted = /** @type {{ emitLine: string, body: string, code: number } | null} */ (null);
789
+
790
+ try {
791
+ const mainResult = main(process.argv);
792
+ if (typeof mainResult === 'number') {
793
+ exitCode = mainResult;
794
+ } else if (mainResult.emitLine !== undefined) {
795
+ emitted = /** @type {any} */ (mainResult);
796
+ exitCode = emitted.code;
797
+ } else {
798
+ scrubLine = /** @type {any} */ (mainResult).scrubLine;
799
+ exitCode = 0;
800
+ }
801
+ } catch (/** @type {any} */ err) {
802
+ process.stderr.write('redact-secrets: internal error: ' + err.message + '\n');
803
+ exitCode = 4;
804
+ }
390
805
 
391
- try {
392
- const mainResult = main(process.argv);
393
- if (typeof mainResult === 'number') {
394
- exitCode = mainResult;
395
- } else {
396
- scrubLine = mainResult.scrubLine;
397
- exitCode = 0;
806
+ // Synchronous stdout writes (must complete before process exits) — one per
807
+ // output shape, and no third site anywhere in this file.
808
+ if (emitted !== null) {
809
+ process.stdout.write(emitted.emitLine + '\n' + emitted.body);
810
+ } else if (scrubLine !== null) {
811
+ process.stdout.write(scrubLine + '\n');
398
812
  }
399
- } catch (/** @type {any} */ err) {
400
- process.stderr.write('redact-secrets: internal error: ' + err.message + '\n');
401
- exitCode = 4;
402
- }
403
813
 
404
- // Synchronous stdout write (must complete before process exits)
405
- if (scrubLine !== null) {
406
- process.stdout.write(scrubLine + '\n');
814
+ // Set exitCode (preferred over process.exit() — does not bypass event loop cleanup)
815
+ process.exitCode = exitCode;
407
816
  }
408
817
 
409
- // Set exitCode (preferred over process.exit() — does not bypass event loop cleanup)
410
- process.exitCode = exitCode;
818
+ // ---------------------------------------------------------------------------
819
+ // Exports — for the unit tests of the pure helpers only [DR-14]
820
+ //
821
+ // parseArgs' behaviour is otherwise observable only end-to-end, and the
822
+ // nonce-failure arm is not observable at all: no argv and no fixture can make
823
+ // crypto.randomBytes fail. Exporting the helpers is what makes those two arms
824
+ // assertable instead of argued-from-construction.
825
+ // ---------------------------------------------------------------------------
826
+
827
+ module.exports = {
828
+ NONCE_HEX_CHARS,
829
+ ZERO_SCRUB_LINE,
830
+ D11_FAIL_REASONS: Object.freeze(Object.values(D11_FAIL_REASONS)),
831
+ shouldSkip,
832
+ scrub,
833
+ formatScrubLine,
834
+ parseArgs,
835
+ scrubTwice,
836
+ frameEmit,
837
+ main,
838
+ };