@codedology/code-forge 0.1.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 (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,787 @@
1
+ /**
2
+ * The one forbidden-command list (plan §8.4), its structural matcher, and its three per-CLI
3
+ * renderers.
4
+ *
5
+ * ## What this is — and what it is not
6
+ *
7
+ * This is a DENYLIST, and a denylist can never be complete: a shell can spell any command in
8
+ * endless ways (`sh -c '…'`, `xargs rm`, `find -delete`, a git alias defined with `git config`,
9
+ * `sudo`/`env`/`nice` wrappers, a script file the coder writes and then runs, …). It is ONE
10
+ * layer of three, and must never be described or relied on as the only one:
11
+ *
12
+ * 1. The sandbox the coder process runs in (working-directory confinement, `--restricted`
13
+ * file tools, no production credentials in the environment) — the layer that actually
14
+ * bounds what a command can reach.
15
+ * 2. This list — rendered into each CLI's own deny flag (Claude `--disallowedTools`, Grok
16
+ * `--deny`, Codex execpolicy rules) and checked by `isForbidden()` before the package itself
17
+ * spawns anything on a coder's behalf.
18
+ * 3. The block gates (B5: safe-edit, scope, transcript grep) — which judge the RESULT (a
19
+ * deleted test file, a rewritten history, a forbidden verb in the transcript) regardless of
20
+ * how the command was spelled.
21
+ *
22
+ * ## `isForbidden()` — the structural matcher
23
+ *
24
+ * It normalizes argv before matching, so the common respellings of a listed command are caught:
25
+ * - `argv[0]` is compared by basename (`/usr/bin/git`, `/bin/rm`, `git.exe` → `git`, `rm`).
26
+ * - git's global options before the subcommand are skipped (`git -C . push --force`,
27
+ * `git -c k=v reset --hard`, `git --git-dir=.git clean -fd`, `git --work-tree . rm x`).
28
+ * An UNKNOWN option there is tried both as a lone flag and as a flag taking the next token as
29
+ * its value, and the argv is forbidden if either reading matches — fail closed.
30
+ * - flags are matched anywhere after the command (`git push origin main --force`), in `--flag`,
31
+ * `--flag=value` (`--force-with-lease=main:abc`) and combined short-cluster (`-uf`, `-Rf`,
32
+ * `-rfv`) spellings, plus `+refspec` pushes (`git push origin +main`).
33
+ * - `contains` tokens match as a SUBSTRING of any argv token (`--db=prod_main`,
34
+ * `postgres://prod-host/db`); they do not match across two tokens (`--env production`).
35
+ * Known limits, beyond the unbounded respellings listed above: it sees only argv the package
36
+ * itself is about to run — never the commands a coder runs inside its own Bash tool (layers 1
37
+ * and 3 cover those); git aliases and `-c alias.*` definitions are not expanded.
38
+ *
39
+ * ## Entry kinds
40
+ *
41
+ * - `prefix` — the normalized argv's first N tokens equal `tokens` (e.g. `git clean`).
42
+ * - `commandFlag` — the normalized argv starts with `command`, AND for EVERY group in `groups`
43
+ * at least one later token matches that group: equal to a member of `anyOf`, starting with a
44
+ * member of `anyPrefixOf`, or a single-dash short-option cluster (`-rfv`) containing one of
45
+ * `shortLetters`. One group = "any of these flags"; two groups = "one of each" (e.g. `git
46
+ * branch --delete` AND `--force`).
47
+ * - `contains` — every member of `tokens` is a substring of some argv token (a configured
48
+ * `production.markers` / `production.names` value; `mergeForbidden()` adds the real ones).
49
+ * - `anyPrefix` (B8, C18) — the normalized argv starts with ANY of `prefixes`, after launchers
50
+ * are unwrapped (`npx`/`bunx` with their options, `-p/--package <pkg>` included; `pnpm
51
+ * exec|dlx`; `yarn dlx`; `node [options] <file>`), a `code-forge.mjs` / `@codedology/code-forge[@x]`
52
+ * token read as `code-forge` (a repeated bin name after the package is dropped), and option
53
+ * tokens between the words skipped (`code-forge --verbose worker`).
54
+ * - `path` (B8, C18) — protects the signer's files (plan §8.6). `paths` are `~/…` (the user's
55
+ * home, matched as `~`, `$HOME`, `${HOME}` or the real `os.homedir()` at call time) or
56
+ * project-relative (matched at token start or after `/`, `=`, `:`, whitespace or a quote — so
57
+ * a path inside a `bash -c "…"` / `node -e "…"` script token is seen too). `access:
58
+ * 'readwrite'` forbids ANY argv naming the path; `access: 'write'` forbids it only when the
59
+ * command — after the wrappers `sudo`/`env [VAR=x…]`/`xargs`/`nice`/`timeout <n>`/`command`
60
+ * — is a write verb (`WRITE_VERBS`; `sed`/`perl` with `-i…`, `--in-place[=…]`; `git
61
+ * mv|rm|restore|checkout`). A `*` in a path matches within one segment.
62
+ * Known limits: a relative spelling from inside `$HOME` (`cd ~; cat .code-forge/runs/x.key`),
63
+ * a shell redirection (not argv), and a WRITE hidden inside a script string (`bash -c "cp x
64
+ * .code-forge/reviews/y"` — the verb is `bash`) are not seen here; `scanTranscript()` and the
65
+ * block gate are the detectors for those, and the signer is tamper EVIDENCE, not a same-user
66
+ * boundary (§8.6).
67
+ *
68
+ * ## Renderers — what the CLIs can and cannot enforce
69
+ *
70
+ * The pinned CLIs' deny rules match a command PREFIX (Claude `Bash(<prefix>:*)`, documented in
71
+ * `test/fixtures/help/claude-2.1.282.txt`; Grok's `--deny` is its documented alias; Codex's
72
+ * execpolicy matches a token-array prefix). So each renderer emits, per entry, one concrete rule
73
+ * per flag spelling in the position right after the command (`Bash(git push --force:*)`,
74
+ * `Bash(git push -f:*)`, `Bash(rm -rf:*)`, …). That blocks the common spellings at the CLI layer;
75
+ * a flag placed after other arguments (`git push origin main --force`), a global option before
76
+ * the subcommand, or a `+refspec` escapes a prefix rule — those are caught by `isForbidden()`
77
+ * (for argv the package runs) and by the gates (layer 3). An entry is `enforced: false` only when
78
+ * the CLI syntax cannot express it at all: the `contains` kind everywhere (no pinned CLI
79
+ * documents a match-anywhere rule), and the `path` kind for Codex. For Codex the `path` entries
80
+ * are NOT ENFORCED AT ALL: execpolicy matches command prefixes, not file paths, and the
81
+ * `workspace-write` sandbox neither stops reads of `~/.code-forge/runs/` (it limits writes, not
82
+ * reads) nor writes to `.code-forge/reviews/` or `.code-forge/queue/*.done` (they are INSIDE the
83
+ * workspace). On Codex the prose list, `scanTranscript()` and the block gate are the only
84
+ * detectors. For Claude — and Grok, whose renderer IS `renderForClaude` (`--deny` is the
85
+ * documented alias of `--disallowedTools`) — a `path` entry renders as file-tool rules
86
+ * `Read(<glob>)` / `Edit(<glob>)` / `Write(<glob>)` (`Read` only for `readwrite`), `~/` kept as
87
+ * the home prefix and each directory widened to `<dir>/**`. Whether the pinned Claude or Grok
88
+ * honours path-scoped rules is unproven (plan [A22], §0.6.7: `doctor` probes it);
89
+ * `scanTranscript()` is the detector either way.
90
+ */
91
+
92
+ import os from 'node:os';
93
+ import path from 'node:path';
94
+
95
+ /**
96
+ * @typedef {object} FlagGroup
97
+ * @property {ReadonlyArray<string>} [anyOf] - exact-token flags, any one.
98
+ * @property {ReadonlyArray<string>} [anyPrefixOf] - token prefixes, any one (`--force-with-lease=`, `+`).
99
+ * @property {ReadonlyArray<string>} [shortLetters] - letters matched inside a `-xyz` short-option cluster.
100
+ */
101
+
102
+ /**
103
+ * @typedef {object} ForbiddenEntry
104
+ * @property {string} id
105
+ * @property {"prefix"|"contains"|"commandFlag"|"anyPrefix"|"path"} kind
106
+ * @property {ReadonlyArray<string>} [tokens] - `prefix` and `contains` kinds.
107
+ * @property {ReadonlyArray<string>} [command] - `commandFlag` kind: the required argv prefix.
108
+ * @property {ReadonlyArray<FlagGroup>} [groups] - `commandFlag` kind: every group must match.
109
+ * @property {ReadonlyArray<ReadonlyArray<string>>} [prefixes] - `anyPrefix` kind.
110
+ * @property {ReadonlyArray<string>} [paths] - `path` kind: `~/…` or project-relative.
111
+ * @property {"readwrite"|"write"} [access] - `path` kind.
112
+ * @property {string} description
113
+ */
114
+
115
+ /**
116
+ * @param {FlagGroup} group
117
+ * @returns {FlagGroup}
118
+ */
119
+ function freezeGroup(group) {
120
+ /** @type {Record<string, unknown>} */
121
+ const frozen = {};
122
+ for (const [key, value] of Object.entries(group)) {
123
+ frozen[key] = Object.freeze([...value]);
124
+ }
125
+ return Object.freeze(/** @type {FlagGroup} */ (frozen));
126
+ }
127
+
128
+ /**
129
+ * @param {object} entry
130
+ * @returns {ForbiddenEntry}
131
+ */
132
+ function freezeEntry(entry) {
133
+ /** @type {Record<string, unknown>} */
134
+ const frozen = { ...entry };
135
+ for (const key of ['tokens', 'command', 'paths']) {
136
+ if (Array.isArray(frozen[key])) {
137
+ frozen[key] = Object.freeze([...frozen[key]]);
138
+ }
139
+ }
140
+ if (Array.isArray(frozen.prefixes)) {
141
+ frozen.prefixes = Object.freeze(frozen.prefixes.map((p) => Object.freeze([...p])));
142
+ }
143
+ if (Array.isArray(frozen.groups)) {
144
+ frozen.groups = Object.freeze(frozen.groups.map(freezeGroup));
145
+ }
146
+ return Object.freeze(/** @type {ForbiddenEntry} */ (frozen));
147
+ }
148
+
149
+ /** @type {ReadonlyArray<ForbiddenEntry>} */
150
+ export const FORBIDDEN = Object.freeze(
151
+ [
152
+ { id: 'gh-pr-ready', kind: 'prefix', tokens: ['gh', 'pr', 'ready'], description: 'Mark a PR ready for review' },
153
+ { id: 'gh-pr-merge', kind: 'prefix', tokens: ['gh', 'pr', 'merge'], description: 'Merge a PR' },
154
+ { id: 'gh-pr-close', kind: 'prefix', tokens: ['gh', 'pr', 'close'], description: 'Close a PR' },
155
+ {
156
+ id: 'gh-pr-base-retarget',
157
+ kind: 'commandFlag',
158
+ command: ['gh', 'pr', 'edit'],
159
+ // `-B` as a prefix also covers pflag's attached form `-Bmain`.
160
+ groups: [{ anyOf: ['--base', '-B'], anyPrefixOf: ['--base=', '-B'] }],
161
+ description: 'Retarget a PR base branch (--base / -B / --base=, anywhere after `gh pr edit`)',
162
+ },
163
+ {
164
+ id: 'git-push-force',
165
+ kind: 'commandFlag',
166
+ command: ['git', 'push'],
167
+ groups: [
168
+ {
169
+ anyOf: ['--force', '-f', '--force-with-lease', '--force-if-includes', '--mirror'],
170
+ anyPrefixOf: ['--force-with-lease=', '--force-if-includes=', '+'],
171
+ shortLetters: ['f'],
172
+ },
173
+ ],
174
+ description:
175
+ 'Force-push (rewrites remote history): --force/-f (also inside a cluster like -uf), ' +
176
+ '--force-with-lease[=…], --force-if-includes, --mirror, or a +refspec',
177
+ },
178
+ {
179
+ id: 'git-reset-hard',
180
+ kind: 'commandFlag',
181
+ command: ['git', 'reset'],
182
+ groups: [{ anyOf: ['--hard'] }],
183
+ description: 'Discard local changes (--hard anywhere after `git reset`)',
184
+ },
185
+ {
186
+ id: 'git-checkout-discard',
187
+ kind: 'commandFlag',
188
+ command: ['git', 'checkout'],
189
+ groups: [{ anyOf: ['--', '--force'], shortLetters: ['f'] }],
190
+ description: 'Discard working-tree changes (`git checkout [<ref>] -- <path>`, or a forced checkout)',
191
+ },
192
+ {
193
+ id: 'git-restore',
194
+ kind: 'prefix',
195
+ tokens: ['git', 'restore'],
196
+ description: 'Discard working-tree changes to a path (the modern replacement for checkout --)',
197
+ },
198
+ { id: 'git-clean', kind: 'prefix', tokens: ['git', 'clean'], description: 'Delete untracked files' },
199
+ { id: 'git-stash', kind: 'prefix', tokens: ['git', 'stash'], description: 'Stash working-tree state' },
200
+ {
201
+ id: 'git-branch-force-delete',
202
+ kind: 'commandFlag',
203
+ command: ['git', 'branch'],
204
+ groups: [{ anyOf: ['-D'], shortLetters: ['D'] }],
205
+ description: 'Force-delete a branch (short form -D, also inside a cluster)',
206
+ },
207
+ {
208
+ id: 'git-branch-force-delete-long',
209
+ kind: 'commandFlag',
210
+ command: ['git', 'branch'],
211
+ groups: [
212
+ { anyOf: ['--delete'], shortLetters: ['d'] },
213
+ { anyOf: ['--force'], shortLetters: ['f'] },
214
+ ],
215
+ description: 'Force-delete a branch (--delete/-d together with --force/-f, any order)',
216
+ },
217
+ {
218
+ id: 'git-rm',
219
+ kind: 'prefix',
220
+ tokens: ['git', 'rm'],
221
+ description: 'Delete a tracked file via git — includes deleting a test file',
222
+ },
223
+ {
224
+ id: 'rm-rf',
225
+ kind: 'commandFlag',
226
+ command: ['rm'],
227
+ groups: [{ anyOf: ['--recursive', '--force'], shortLetters: ['r', 'R', 'f'] }],
228
+ description:
229
+ 'rm with ANY recursive or force flag (-r, -R, -f, any cluster containing one, --recursive, ' +
230
+ '--force) — a coder never legitimately needs either; the package cleans .code-forge/ itself',
231
+ },
232
+ {
233
+ id: 'production-marker',
234
+ kind: 'contains',
235
+ tokens: ['--env=production'],
236
+ description:
237
+ 'Any argv token CONTAINING a configured production.markers/production.names value (env flag, DB ' +
238
+ 'name, prod host, deploy tool) — this entry is a placeholder; mergeForbidden() adds the real, ' +
239
+ 'project-configured tokens',
240
+ },
241
+ // ★ B8 (C4, C18): the signer's files and the worker, plan §8.4 / §8.6.
242
+ {
243
+ id: 'code-forge-runs-access',
244
+ kind: 'path',
245
+ paths: ['~/.code-forge/runs'],
246
+ access: 'readwrite',
247
+ description:
248
+ 'Any read or write of ~/.code-forge/runs/ (the run record pin and the per-run HMAC key) — ' +
249
+ 'a coder never needs it; reading the key is a named rule-break, not a boundary (§8.6)',
250
+ },
251
+ {
252
+ id: 'code-forge-worker-from-coder',
253
+ kind: 'anyPrefix',
254
+ prefixes: [
255
+ ['code-forge', 'worker'],
256
+ ['forge', 'worker'],
257
+ ['code-forge', 'run', 'start'],
258
+ ['forge', 'run', 'start'],
259
+ ],
260
+ description: 'Start a worker or a run (forge worker / code-forge worker / run start) from a coder — the orchestrator only',
261
+ },
262
+ {
263
+ id: 'code-forge-reviews-write',
264
+ kind: 'path',
265
+ paths: ['.code-forge/reviews', '.code-forge/queue/*.done'],
266
+ access: 'write',
267
+ description: 'Any write under .code-forge/reviews/ or to .code-forge/queue/*.done — only the worker writes review results',
268
+ },
269
+ ].map(freezeEntry),
270
+ );
271
+
272
+ /**
273
+ * Coder-only entries (B12b, plan §4.9): verbs only a human (through the orchestrator) may run.
274
+ * They are NOT part of `FORBIDDEN` (whose rendered count other blocks pin); `mergeForbidden()`
275
+ * always appends them, so every list built for a coder carries them.
276
+ * - `code-forge block waive` — waiving a review finding is human-only;
277
+ * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check.
278
+ * @type {ReadonlyArray<ForbiddenEntry>}
279
+ */
280
+ export const CODER_ONLY_FORBIDDEN = Object.freeze(
281
+ [
282
+ {
283
+ id: 'code-forge-block-waive-from-coder',
284
+ kind: 'anyPrefix',
285
+ prefixes: [
286
+ ['code-forge', 'block', 'waive'],
287
+ ['forge', 'block', 'waive'],
288
+ ],
289
+ description: 'Waive a review finding (forge block waive) — human-only, run by the orchestrator after the human said so (§4.9)',
290
+ },
291
+ {
292
+ id: 'code-forge-no-require-reviews',
293
+ kind: 'contains',
294
+ tokens: ['--no-require-reviews'],
295
+ description: 'Close a block without the per-file review check (--no-require-reviews) — human-only',
296
+ },
297
+ ].map(freezeEntry),
298
+ );
299
+
300
+ /**
301
+ * Merge project-specific tokens (from `production.markers` / `production.names`, schema owned by
302
+ * B1) into the static list as additional `contains` entries, after the coder-only entries
303
+ * (`CODER_ONLY_FORBIDDEN`, always included). The static list never changes.
304
+ * `contains` entries are never rendered into a CLI rule string (see the renderers), so a token
305
+ * may hold any character — `db.prod:5432` and `postgres://prod-host` are both valid.
306
+ *
307
+ * @param {string[]} [extraTokens]
308
+ * @returns {ForbiddenEntry[]}
309
+ * @throws {TypeError} if `extraTokens` is not an array (a bare string would otherwise be iterated
310
+ * one character at a time), or if any token is not a non-empty, non-whitespace-only string —
311
+ * an empty token is a substring of every argv token and would forbid everything.
312
+ */
313
+ export function mergeForbidden(extraTokens = []) {
314
+ if (!Array.isArray(extraTokens)) {
315
+ throw new TypeError(`mergeForbidden: extraTokens must be an array of strings, got ${typeof extraTokens}`);
316
+ }
317
+ for (const token of extraTokens) {
318
+ if (typeof token !== 'string' || token.trim().length === 0) {
319
+ throw new TypeError(`mergeForbidden: invalid token ${JSON.stringify(token)} — must be a non-empty string`);
320
+ }
321
+ }
322
+ const extras = extraTokens.map((token, i) =>
323
+ freezeEntry({
324
+ id: `production-marker-extra-${i}`,
325
+ kind: 'contains',
326
+ tokens: [token],
327
+ description: `Configured production marker: ${token}`,
328
+ }),
329
+ );
330
+ return [...FORBIDDEN, ...CODER_ONLY_FORBIDDEN, ...extras];
331
+ }
332
+
333
+ // ── Normalization ────────────────────────────────────────────────────────────
334
+
335
+ /** git global options that take the NEXT argv token as their value (when not written `--opt=value`). */
336
+ const GIT_GLOBAL_WITH_VALUE = new Set([
337
+ '-C',
338
+ '-c',
339
+ '--git-dir',
340
+ '--work-tree',
341
+ '--namespace',
342
+ '--config-env',
343
+ '--attr-source',
344
+ '--super-prefix',
345
+ '--list-cmds',
346
+ ]);
347
+
348
+ /** git global options that stand alone. */
349
+ const GIT_GLOBAL_NO_VALUE = new Set([
350
+ '-p',
351
+ '-P',
352
+ '--paginate',
353
+ '--no-pager',
354
+ '--bare',
355
+ '--no-replace-objects',
356
+ '--literal-pathspecs',
357
+ '--glob-pathspecs',
358
+ '--noglob-pathspecs',
359
+ '--icase-pathspecs',
360
+ '--no-optional-locks',
361
+ '--no-lazy-fetch',
362
+ '--no-advice',
363
+ '--exec-path',
364
+ '--html-path',
365
+ '--man-path',
366
+ '--info-path',
367
+ ]);
368
+
369
+ /** Upper bound on alternative readings of an argv with unknown git global options. */
370
+ const MAX_GIT_READINGS = 64;
371
+
372
+ /**
373
+ * @param {string} token
374
+ * @returns {string} the command name: basename, minus a Windows `.exe` suffix.
375
+ */
376
+ function commandName(token) {
377
+ return path.basename(token).replace(/\.exe$/i, '');
378
+ }
379
+
380
+ /**
381
+ * Every plausible `[subcommand, ...rest]` tail of a git argv, after skipping global options.
382
+ * @param {ReadonlyArray<string>} argv
383
+ * @param {number} i
384
+ * @param {string[][]} out
385
+ */
386
+ function collectGitTails(argv, i, out) {
387
+ while (i < argv.length && out.length < MAX_GIT_READINGS) {
388
+ const token = argv[i];
389
+ if (!token.startsWith('-')) {
390
+ out.push(argv.slice(i));
391
+ return;
392
+ }
393
+ if (token === '--' || (token.startsWith('--') && token.includes('=')) || GIT_GLOBAL_NO_VALUE.has(token)) {
394
+ i += 1;
395
+ } else if (GIT_GLOBAL_WITH_VALUE.has(token)) {
396
+ i += 2;
397
+ } else if (/^-[Cc]./.test(token)) {
398
+ i += 1; // value attached to -C / -c
399
+ } else {
400
+ // Unknown option: fail closed by trying it both as a lone flag and as one taking a value.
401
+ collectGitTails(argv, i + 2, out);
402
+ i += 1;
403
+ }
404
+ }
405
+ }
406
+
407
+ /**
408
+ * @param {ReadonlyArray<string>} argv
409
+ * @returns {string[][]} the normalized readings of argv to match entries against.
410
+ */
411
+ function normalizedReadings(argv) {
412
+ const name = commandName(argv[0]);
413
+ if (name !== 'git') {
414
+ return [[name, ...argv.slice(1)]];
415
+ }
416
+ /** @type {string[][]} */
417
+ const tails = [];
418
+ collectGitTails(argv, 1, tails);
419
+ return tails.length > 0 ? tails.map((tail) => ['git', ...tail]) : [['git']];
420
+ }
421
+
422
+ /**
423
+ * Unwrap a launcher so `npx -y @codedology/code-forge worker` reads as `code-forge worker`.
424
+ * @param {ReadonlyArray<string>} reading
425
+ * @returns {ReadonlyArray<string>}
426
+ */
427
+ function unwrapLauncher(reading) {
428
+ let rest = reading;
429
+ const skipOptions = () => {
430
+ while (rest.length > 0 && rest[0].startsWith('-')) {
431
+ const takesValue = rest[0] === '-p' || rest[0] === '--package' || rest[0] === '-r' || rest[0] === '--require';
432
+ rest = rest.slice(takesValue ? 2 : 1);
433
+ }
434
+ };
435
+ if (rest[0] === 'npx' || rest[0] === 'bunx') {
436
+ rest = rest.slice(1);
437
+ skipOptions();
438
+ } else if ((rest[0] === 'pnpm' || rest[0] === 'yarn') && (rest[1] === 'exec' || rest[1] === 'dlx')) {
439
+ rest = rest.slice(2);
440
+ skipOptions();
441
+ } else if (rest[0] === 'node') {
442
+ rest = rest.slice(1);
443
+ skipOptions();
444
+ }
445
+ if (rest.length === 0) return rest;
446
+ const isPackage = (/** @type {string} */ t) => /^@codedology\/code-forge(@.*)?$/.test(t) || /^code-forge(\.mjs)?(@.*)?$/.test(path.basename(t));
447
+ const head = rest[0];
448
+ let tail = rest.slice(1);
449
+ if (isPackage(head) && tail.length > 0 && isPackage(tail[0])) tail = tail.slice(1);
450
+ return [isPackage(head) ? 'code-forge' : commandName(head), ...tail.filter((t) => !t.startsWith('-'))];
451
+ }
452
+
453
+ /** Commands that write the file they are given (`>`/`>>` and the Claude file tools come from transcripts). */
454
+ const WRITE_VERBS = new Set([
455
+ 'cp', 'mv', 'tee', 'touch', 'rm', 'ln', 'install', 'truncate', 'dd', 'rsync', 'chmod', 'chown',
456
+ 'unlink', 'mkdir', 'rmdir', '>', '>>', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
457
+ ]);
458
+
459
+ /** git subcommands that write a path they name. */
460
+ const GIT_WRITE_SUBCOMMANDS = new Set(['mv', 'rm', 'restore', 'checkout']);
461
+
462
+ /** Wrappers skipped before the write verb is read. */
463
+ const WRAPPERS = new Set(['sudo', 'env', 'xargs', 'nice', 'timeout', 'command', 'nohup']);
464
+
465
+ /** @param {string} token @returns {boolean} `sed -i`, `-i.bak`, `perl -pi`, `--in-place[=…]` */
466
+ const isInPlaceFlag = (token) => /^-[a-zA-Z]*i/.test(token) || /^--in-place(=|$)/.test(token);
467
+
468
+ /**
469
+ * Does `argv` (a raw command, wrappers allowed) write the files it names?
470
+ * @param {ReadonlyArray<string>} argv
471
+ * @returns {boolean}
472
+ */
473
+ function isWriteCommand(argv) {
474
+ let i = 0;
475
+ while (i < argv.length && WRAPPERS.has(commandName(argv[i]))) {
476
+ i += 1;
477
+ // wrapper options, `VAR=x` assignments (env) and a duration (timeout) are not the verb
478
+ while (i < argv.length && (argv[i].startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[i]) || /^\d+(\.\d+)?[smhd]?$/.test(argv[i]))) i += 1;
479
+ }
480
+ const verb = i < argv.length ? commandName(argv[i]) : '';
481
+ if (WRITE_VERBS.has(verb)) return true;
482
+ if ((verb === 'sed' || verb === 'perl') && argv.slice(i + 1).some(isInPlaceFlag)) return true;
483
+ if (verb === 'git') return normalizedReadings(argv.slice(i)).some((r) => GIT_WRITE_SUBCOMMANDS.has(r[1]));
484
+ return false;
485
+ }
486
+
487
+ /** @param {string} text */
488
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
489
+
490
+ /** @type {Map<string, RegExp>} compiled path patterns, keyed by home dir + path */
491
+ const pathPatternCache = new Map();
492
+
493
+ /**
494
+ * A regex matching an argv token that names protected path `p` (see the `path` kind doc).
495
+ * @param {string} p
496
+ * @returns {RegExp}
497
+ */
498
+ function pathPattern(p) {
499
+ const home = os.homedir();
500
+ const cacheKey = `${home}\u0000${p}`;
501
+ const cached = pathPatternCache.get(cacheKey);
502
+ if (cached) return cached;
503
+ const glob = (/** @type {string} */ s) => s.split('*').map(escapeRegExp).join('[^/\\s]*');
504
+ let pattern;
505
+ if (p.startsWith('~/')) {
506
+ const homes = ['~', '\\$HOME', '\\$\\{HOME\\}', escapeRegExp(home)];
507
+ pattern = new RegExp(`(?:^|[=:\\s'"])(?:${homes.join('|')})/${glob(p.slice(2))}(?:/|$|[\\s'";)])`);
508
+ } else {
509
+ pattern = new RegExp(`(?:^|[/=:\\s'"])${glob(p)}(?:/|$|[\\s'";)])`);
510
+ }
511
+ pathPatternCache.set(cacheKey, pattern);
512
+ return pattern;
513
+ }
514
+
515
+ /** @param {string} token @param {ForbiddenEntry} entry @returns {boolean} */
516
+ const namesPath = (token, entry) => entry.paths.some((p) => pathPattern(p).test(token));
517
+
518
+ // ── Matching ─────────────────────────────────────────────────────────────────
519
+
520
+ /**
521
+ * @param {ReadonlyArray<string>} argv
522
+ * @param {ReadonlyArray<string>} prefix
523
+ * @returns {boolean}
524
+ */
525
+ function startsWithPrefix(argv, prefix) {
526
+ if (argv.length < prefix.length) return false;
527
+ return prefix.every((token, i) => argv[i] === token);
528
+ }
529
+
530
+ /**
531
+ * @param {string} token
532
+ * @param {FlagGroup} group
533
+ * @returns {boolean}
534
+ */
535
+ function tokenMatchesGroup(token, group) {
536
+ if (group.anyOf?.includes(token)) return true;
537
+ if (group.anyPrefixOf?.some((p) => token.startsWith(p))) return true;
538
+ if (group.shortLetters && /^-[^-]/.test(token)) {
539
+ const cluster = token.slice(1);
540
+ return group.shortLetters.some((letter) => cluster.includes(letter));
541
+ }
542
+ return false;
543
+ }
544
+
545
+ /**
546
+ * @param {ReadonlyArray<string>} reading - a normalized argv
547
+ * @param {ReadonlyArray<string>} rawArgv - the argv as given (for `contains`)
548
+ * @param {ForbiddenEntry} entry
549
+ * @returns {boolean}
550
+ */
551
+ function matchesEntry(reading, rawArgv, entry) {
552
+ if (entry.kind === 'contains') {
553
+ return entry.tokens.every((t) => rawArgv.some((a) => a.includes(t)));
554
+ }
555
+ if (entry.kind === 'prefix') {
556
+ return startsWithPrefix(reading, entry.tokens);
557
+ }
558
+ if (entry.kind === 'anyPrefix') {
559
+ const unwrapped = unwrapLauncher(reading);
560
+ return entry.prefixes.some((prefix) => startsWithPrefix(unwrapped, prefix));
561
+ }
562
+ if (entry.kind === 'path') {
563
+ const named = rawArgv.some((token) => namesPath(token, entry));
564
+ if (!named || entry.access === 'readwrite') return named;
565
+ return isWriteCommand(rawArgv);
566
+ }
567
+ if (!startsWithPrefix(reading, entry.command)) return false;
568
+ const rest = reading.slice(entry.command.length);
569
+ return entry.groups.every((group) => rest.some((token) => tokenMatchesGroup(token, group)));
570
+ }
571
+
572
+ /**
573
+ * The package's own structural check (see the module doc for what it normalizes and its limits).
574
+ *
575
+ * @param {ReadonlyArray<string>} argv
576
+ * @param {ReadonlyArray<ForbiddenEntry>} [list]
577
+ * @returns {ForbiddenEntry | null} the first matching entry in list order, or null if argv is clear.
578
+ * @throws {TypeError} if argv is not an array of strings.
579
+ */
580
+ export function isForbidden(argv, list = FORBIDDEN) {
581
+ if (!Array.isArray(argv) || !argv.every((a) => typeof a === 'string')) {
582
+ throw new TypeError('isForbidden: argv must be an array of strings');
583
+ }
584
+ if (argv.length === 0) return null;
585
+ const readings = normalizedReadings(argv);
586
+ for (const entry of list) {
587
+ if (readings.some((reading) => matchesEntry(reading, argv, entry))) {
588
+ return entry;
589
+ }
590
+ }
591
+ return null;
592
+ }
593
+
594
+ // ── Rendering ────────────────────────────────────────────────────────────────
595
+
596
+ /**
597
+ * The literal flag spellings a prefix rule should carry for one group: every `anyOf` flag, every
598
+ * short letter as `-x`, and every two-letter cluster of distinct short letters (`-rf`, `-fR`, …).
599
+ * `anyPrefixOf` forms (`--base=`, `+refspec`) have no fixed spelling and are left to `isForbidden`.
600
+ * @param {FlagGroup} group
601
+ * @returns {string[]}
602
+ */
603
+ function groupSpellings(group) {
604
+ const spellings = [...(group.anyOf ?? [])];
605
+ const letters = group.shortLetters ?? [];
606
+ for (const a of letters) {
607
+ spellings.push(`-${a}`);
608
+ }
609
+ for (const a of letters) {
610
+ for (const b of letters) {
611
+ if (a !== b) spellings.push(`-${a}${b}`);
612
+ }
613
+ }
614
+ return [...new Set(spellings)];
615
+ }
616
+
617
+ /**
618
+ * Every ordering of the groups × every choice of spelling per group.
619
+ * @param {ReadonlyArray<FlagGroup>} groups
620
+ * @returns {string[][]}
621
+ */
622
+ function flagSequences(groups) {
623
+ if (groups.length === 0) return [[]];
624
+ /** @type {string[][]} */
625
+ const sequences = [];
626
+ groups.forEach((group, i) => {
627
+ const others = [...groups.slice(0, i), ...groups.slice(i + 1)];
628
+ for (const spelling of groupSpellings(group)) {
629
+ for (const tail of flagSequences(others)) {
630
+ sequences.push([spelling, ...tail]);
631
+ }
632
+ }
633
+ });
634
+ return sequences;
635
+ }
636
+
637
+ /**
638
+ * The token-array prefixes an entry renders to (empty for `contains`: not expressible).
639
+ * @param {ForbiddenEntry} entry
640
+ * @returns {string[][]}
641
+ */
642
+ function renderPatterns(entry) {
643
+ if (entry.kind === 'prefix') {
644
+ return [[...entry.tokens]];
645
+ }
646
+ if (entry.kind === 'commandFlag') {
647
+ const seen = new Set();
648
+ /** @type {string[][]} */
649
+ const patterns = [];
650
+ for (const flags of flagSequences(entry.groups)) {
651
+ const pattern = [...entry.command, ...flags];
652
+ const key = pattern.join('\u0000');
653
+ if (!seen.has(key)) {
654
+ seen.add(key);
655
+ patterns.push(pattern);
656
+ }
657
+ }
658
+ return patterns;
659
+ }
660
+ if (entry.kind === 'anyPrefix') {
661
+ return entry.prefixes.flatMap((prefix) =>
662
+ prefix[0] === 'code-forge'
663
+ ? [[...prefix], ['npx', ...prefix], ['npx', '@codedology/code-forge', ...prefix.slice(1)]]
664
+ : [[...prefix]],
665
+ );
666
+ }
667
+ return [];
668
+ }
669
+
670
+ /**
671
+ * File-tool rules for a `path` entry: `Read`/`Edit`/`Write` (`Read` only for `readwrite`), a
672
+ * directory widened to `<dir>/**`.
673
+ * @param {ForbiddenEntry} entry
674
+ * @returns {string[]}
675
+ */
676
+ function pathRules(entry) {
677
+ if (entry.kind !== 'path') return [];
678
+ const tools = entry.access === 'readwrite' ? ['Read', 'Edit', 'Write'] : ['Edit', 'Write'];
679
+ return entry.paths.flatMap((p) => tools.map((tool) => `${tool}(${p.includes('*') ? p : `${p}/**`})`));
680
+ }
681
+
682
+ /**
683
+ * Render the list as Claude Code `--disallowedTools` values, e.g. `Bash(git reset --hard:*)`.
684
+ * One item per entry; `rules` holds one prefix rule per flag spelling (see the module doc for
685
+ * what prefix rules cannot reach), plus the file-tool rules of a `path` entry. `enforced` is
686
+ * false only when no rule can be expressed.
687
+ * @param {ReadonlyArray<ForbiddenEntry>} [list]
688
+ * @returns {{id: string, rules: string[], enforced: boolean}[]}
689
+ */
690
+ export function renderForClaude(list = FORBIDDEN) {
691
+ return list.map((entry) => {
692
+ const rules = [...renderPatterns(entry).map((pattern) => `Bash(${pattern.join(' ')}:*)`), ...pathRules(entry)];
693
+ return { id: entry.id, rules, enforced: rules.length > 0 };
694
+ });
695
+ }
696
+
697
+ /**
698
+ * Render the list as Grok `--deny` rule values. Grok's `--deny` is the documented compat alias
699
+ * of `--disallowedTools` (`grok --help`, pinned fixture), so it shares Claude's rule syntax.
700
+ * @param {ReadonlyArray<ForbiddenEntry>} [list]
701
+ * @returns {{id: string, rules: string[], enforced: boolean}[]}
702
+ */
703
+ export function renderForGrok(list = FORBIDDEN) {
704
+ return renderForClaude(list);
705
+ }
706
+
707
+ /**
708
+ * Render the list as execpolicy rule objects for Codex's rules file (the file wiring is probed
709
+ * and pinned by `src/engines/**`, a later block; this only shapes the data). Each `patterns`
710
+ * element is a token-array prefix; `decision` is always `forbidden`.
711
+ * @param {ReadonlyArray<ForbiddenEntry>} [list]
712
+ * @returns {{id: string, patterns: string[][], decision: 'forbidden', enforced: boolean, description: string}[]}
713
+ */
714
+ export function renderForCodex(list = FORBIDDEN) {
715
+ return list.map((entry) => {
716
+ const patterns = renderPatterns(entry);
717
+ return {
718
+ id: entry.id,
719
+ patterns,
720
+ decision: /** @type {'forbidden'} */ ('forbidden'),
721
+ enforced: patterns.length > 0,
722
+ description: entry.description,
723
+ };
724
+ });
725
+ }
726
+
727
+ // ── Transcripts ──────────────────────────────────────────────────────────────
728
+
729
+ /**
730
+ * Scan captured coder output (subprocess stdout, Solo `search_output` text, a harness log) for
731
+ * any entry of `list`, line by line: each line is split into shell-ish tokens (whitespace,
732
+ * quotes, `;|&(),{}[]`; `>`/`>>` kept as their own token), so a command anywhere in the line —
733
+ * `$ cat ~/.code-forge/runs/r.key`, `Write(.code-forge/reviews/x.json)`, a stream-json
734
+ * `"name":"Write"` followed by the path — is found. It is a grep: a line that merely MENTIONS a
735
+ * forbidden command is a hit too (fail closed; the block gate reports it as `rule_break`, B5).
736
+ *
737
+ * Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
738
+ * tokens (a `write` path entry hits when a write command starts before a token naming the path);
739
+ * command entries run only at tokens that can start one (their first word, a launcher, a
740
+ * code-forge bin), each on a window of `TRANSCRIPT_WINDOW` tokens — a flag further than that
741
+ * from its command is not seen. One hit per (line, id), in list order.
742
+ * @param {string} text
743
+ * @param {ReadonlyArray<ForbiddenEntry>} [list]
744
+ * @returns {{id: string, line: number}[]}
745
+ */
746
+ export function scanTranscript(text, list = FORBIDDEN) {
747
+ const commandEntries = list.filter((e) => e.kind === 'prefix' || e.kind === 'commandFlag' || e.kind === 'anyPrefix');
748
+ const firstWords = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'node']);
749
+ for (const e of commandEntries) {
750
+ if (e.kind === 'prefix') firstWords.add(e.tokens[0]);
751
+ else if (e.kind === 'commandFlag') firstWords.add(e.command[0]);
752
+ else for (const p of e.prefixes) firstWords.add(p[0]);
753
+ }
754
+ const writeStarts = new Set([...WRITE_VERBS, ...WRAPPERS, 'sed', 'perl', 'git']);
755
+ /** @type {{id: string, line: number}[]} */
756
+ const hits = [];
757
+ String(text)
758
+ .split('\n')
759
+ .forEach((lineText, index) => {
760
+ const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
761
+ const ids = new Set();
762
+ let firstWrite = -1;
763
+ for (let i = 0; i < tokens.length && firstWrite < 0; i += 1) {
764
+ if (writeStarts.has(commandName(tokens[i])) && isWriteCommand(tokens.slice(i, i + TRANSCRIPT_WINDOW))) firstWrite = i;
765
+ }
766
+ for (const entry of list) {
767
+ if (entry.kind === 'contains' && matchesEntry([], tokens, entry)) ids.add(entry.id);
768
+ if (entry.kind !== 'path') continue;
769
+ const named = tokens.findIndex((t, i) => (entry.access === 'readwrite' || (firstWrite >= 0 && i > firstWrite)) && namesPath(t, entry));
770
+ if (named >= 0) ids.add(entry.id);
771
+ }
772
+ for (let i = 0; i < tokens.length; i += 1) {
773
+ if (!firstWords.has(commandName(tokens[i])) && !tokens[i].includes('code-forge')) continue;
774
+ const window = tokens.slice(i, i + TRANSCRIPT_WINDOW);
775
+ for (const entry of commandEntries) {
776
+ if (!ids.has(entry.id) && isForbidden(window, [entry])) ids.add(entry.id);
777
+ }
778
+ }
779
+ for (const entry of list) {
780
+ if (ids.has(entry.id)) hits.push({ id: entry.id, line: index + 1 });
781
+ }
782
+ });
783
+ return hits;
784
+ }
785
+
786
+ /** How many tokens after a command start `scanTranscript` looks at. */
787
+ const TRANSCRIPT_WINDOW = 64;