@try-works/dsh-recursive-mode 0.3.1 → 0.4.1

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 (118) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +32 -12
  17. package/lib/index.js +9819 -3858
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive/agent.cordis.yml +2 -2
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/src/client/derive.ts +18 -2
  61. package/src/closeout-report.ts +274 -0
  62. package/src/closeout-standards.ts +102 -0
  63. package/src/closeout.ts +39 -2
  64. package/src/commands.ts +116 -4
  65. package/src/config.ts +113 -0
  66. package/src/delegation.ts +336 -18
  67. package/src/enforcement.ts +262 -72
  68. package/src/errors.ts +197 -0
  69. package/src/git-context.ts +33 -2
  70. package/src/guard-log.ts +134 -0
  71. package/src/handoff.ts +62 -0
  72. package/src/hooks.ts +316 -0
  73. package/src/identity.ts +230 -0
  74. package/src/index.ts +385 -20
  75. package/src/job-log.ts +112 -0
  76. package/src/jobs-runner.ts +222 -0
  77. package/src/json-safe.ts +75 -0
  78. package/src/lock.ts +153 -16
  79. package/src/memory-feedback.ts +185 -0
  80. package/src/memory-select.ts +187 -0
  81. package/src/memory.ts +309 -0
  82. package/src/model-inventory.ts +196 -0
  83. package/src/phase-graph.ts +191 -0
  84. package/src/phase-rules.ts +236 -0
  85. package/src/plan-gate.ts +111 -0
  86. package/src/policy-globs.ts +636 -0
  87. package/src/policy-write.ts +210 -0
  88. package/src/policy.ts +70 -5
  89. package/src/recursive_ask.tool.ts +276 -0
  90. package/src/recursive_audit_team.tool.ts +7 -3
  91. package/src/recursive_closeout.tool.ts +36 -35
  92. package/src/recursive_delegate.tool.ts +194 -0
  93. package/src/recursive_init.tool.ts +4 -3
  94. package/src/recursive_lint.tool.ts +81 -6
  95. package/src/recursive_lock.tool.ts +21 -4
  96. package/src/recursive_phase.tool.ts +3 -2
  97. package/src/recursive_preview.tool.ts +142 -0
  98. package/src/recursive_review.tool.ts +190 -0
  99. package/src/recursive_scratch.tool.ts +5 -4
  100. package/src/recursive_status.tool.ts +3 -2
  101. package/src/recursive_worktree.tool.ts +6 -5
  102. package/src/result-cap.ts +130 -0
  103. package/src/review-round.ts +335 -0
  104. package/src/review.ts +17 -3
  105. package/src/role-route.ts +230 -0
  106. package/src/router.ts +128 -2
  107. package/src/runtime.ts +968 -39
  108. package/src/settlement.ts +355 -0
  109. package/src/skills-phase.ts +143 -0
  110. package/src/snapshot.ts +39 -8
  111. package/src/status.ts +209 -4
  112. package/src/teams-loop.ts +223 -9
  113. package/src/training.ts +565 -0
  114. package/src/ts-lint.ts +38 -4
  115. package/src/types.ts +51 -0
  116. package/src/workflow-audit.ts +288 -0
  117. package/scripts/install-preset.cmd +0 -7
  118. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,636 @@
1
+ /**
2
+ * T16 — declarative, ordered tool policy (plan §4 T16).
3
+ *
4
+ * Enforcement used to be a hardcoded `WRITE_TOOL_NAMES` set plus a
5
+ * `endsWith('.md') && includes('/.recursive/run/')` substring test inside
6
+ * `evaluateToolGuard`. A code path is not auditable; an ordered rule list is.
7
+ * This module is the rule list and its evaluator, plus the loader — nothing
8
+ * else. The RULES THEMSELVES live in `src/enforcement.ts` (built-in defaults)
9
+ * and `src/phase-rules.ts` (per-phase baselines), so this module stays pure and
10
+ * importable by both without a cycle.
11
+ *
12
+ * - PRECEDENCE is SPECIFICITY FIRST, THEN FILE ORDER (see
13
+ * `patternSpecificity`/`evaluateToolPolicy`): the most specific matching
14
+ * pattern decides, and equally specific rules are decided by file order. This
15
+ * is what makes "deny beats a matching allow" true no matter where the
16
+ * catch-all `*` sits in the file;
17
+ * - a matched `deny` before a matched `allow` of the same specificity wins, and
18
+ * the shipped lists keep every `deny` above every `allow` as a READABILITY
19
+ * convention, not as the correctness mechanism;
20
+ * - an INVALID pattern fails the WHOLE policy closed — deny everything, and
21
+ * the reason NAMES the offending pattern;
22
+ * - NO match yields `ask` — not deny, not allow. `ask` is the only verdict
23
+ * that neither silently permits nor bricks the session, so it is the default.
24
+ *
25
+ * ---------------------------------------------------------------------------
26
+ * THE ABSENT-vs-PRESENT DISTINCTION (read this before changing the loader)
27
+ * ---------------------------------------------------------------------------
28
+ * ABSENT policy file -> the BUILT-IN default rule list, which reproduces
29
+ * the guard behaviour that shipped before T16. This
30
+ * is the SAFE DEFAULT: every existing repo, and every
31
+ * temp repo a test creates, keeps working.
32
+ * PRESENT but empty -> FAIL CLOSED: deny everything. An empty allow-list
33
+ * is not an allow-all list.
34
+ * PRESENT but invalid -> FAIL CLOSED: deny everything, naming the pattern /
35
+ * rule field that is wrong.
36
+ *
37
+ * Collapsing those two cases in EITHER direction is the bug this comment exists
38
+ * to prevent: treating absent as invalid BRICKS every repo (all tool calls
39
+ * denied), and treating invalid as absent silently ignores the policy a human
40
+ * just wrote and believes is in force.
41
+ */
42
+ import { existsSync, readFileSync } from 'node:fs'
43
+ import { join } from 'node:path'
44
+ import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
45
+ import { getMdFieldValue } from './status.ts'
46
+ import { policyTargetPath, resolveFrom } from './phase-rules.ts'
47
+
48
+ /** The three verdicts a rule may carry. */
49
+ export type Verdict = 'allow' | 'deny' | 'ask'
50
+
51
+ /**
52
+ * The policy engine's own decision shape (no `warn`/`transition` — those are the
53
+ * guard's, see enforcement.ts). `rule` is the `label` of the rule that decided
54
+ * the call, so a verdict is traceable to an auditable line in the policy file;
55
+ * it is absent when the no-match default decided.
56
+ */
57
+ export interface Decision {
58
+ kind: Verdict
59
+ reason?: string
60
+ rule?: string
61
+ }
62
+
63
+ /**
64
+ * Extra facts a rule predicate may need. `args` is always the tool call's
65
+ * arguments; the run coordinates are present only when the caller has them
66
+ * (a guard call from a real session does; a pure policy unit test need not).
67
+ */
68
+ export interface ToolPolicyContext {
69
+ args: Record<string, unknown>
70
+ runDir?: string
71
+ runId?: string
72
+ worktreeRoot?: string
73
+ }
74
+
75
+ /**
76
+ * What a rule predicate returns when the condition it guards DOES apply:
77
+ * the verdict, plus an optional `detail` appended to the rule's own `reason`.
78
+ * The rule keeps the static, auditable sentence (the same sentence the policy
79
+ * file and the built-in list carry, so the two cannot drift); the predicate adds
80
+ * only the per-call particular — which artifact blocked the lock, and its status.
81
+ *
82
+ * `null` means the condition does NOT apply, and the engine falls through to the
83
+ * NEXT rule. That fall-through is how an id-glob rule such as `write` can guard
84
+ * write tools without deciding for an ordinary write that violates nothing
85
+ * (which a bare pattern match would otherwise deny).
86
+ */
87
+ export interface ToolPolicyPredicateMatch {
88
+ verdict: Verdict
89
+ detail?: string
90
+ }
91
+
92
+ export type ToolPolicyPredicate = (
93
+ id: string,
94
+ args: Record<string, unknown>,
95
+ ctx: ToolPolicyContext,
96
+ ) => ToolPolicyPredicateMatch | null
97
+
98
+ export interface ToolPolicyRule {
99
+ pattern: string
100
+ verdict: Verdict
101
+ reason: string
102
+ /** The guard's machine-readable rule label for a decision this rule makes. */
103
+ label?: string
104
+ predicate?: ToolPolicyPredicate
105
+ }
106
+
107
+ /**
108
+ * The `defaults` block of a policy file. `no_match_verdict` is documented for a
109
+ * human reader and may only ever be `"ask"`: the no-match default is a property
110
+ * of the engine, not a knob, because a configurable one would let a policy file
111
+ * silently allow (or deny) every unlisted tool.
112
+ */
113
+ export interface ToolPolicyFileDefaults {
114
+ no_match_verdict?: 'ask'
115
+ }
116
+
117
+ export interface ToolPolicy {
118
+ version: number
119
+ description?: string
120
+ rules: ToolPolicyRule[]
121
+ }
122
+
123
+ /** Where the shipped default policy lives, relative to the worktree root. */
124
+ export const TOOL_POLICY_RELATIVE_PATH = '.recursive/config/recursive-permissions.json'
125
+
126
+ /** Absolute path of the policy file for a worktree. */
127
+ export function toolPolicyPath(worktreeRoot: string): string {
128
+ return join(worktreeRoot, ...TOOL_POLICY_RELATIVE_PATH.split('/'))
129
+ }
130
+
131
+ /* -------------------------------------------------------------------------- */
132
+ /* Glob matching */
133
+ /* -------------------------------------------------------------------------- */
134
+
135
+ /**
136
+ * Pattern grammar, deliberately tiny so a human can predict a verdict:
137
+ * `*` any run of characters, INCLUDING the empty run (catch-all)
138
+ * `<literal>` the exact tool id
139
+ * Combining them gives the documented id-glob form `worker::*` (every id that
140
+ * starts with the literal `worker::`) and `recursive_*`.
141
+ *
142
+ * A literal character must be one a tool id may contain: letters, digits, `_`,
143
+ * `-`, `.` and `:`. Anything else — an empty/blank pattern, a space, a path
144
+ * separator, a regex metacharacter such as `[` or `?` — is INVALID, and an
145
+ * invalid pattern fails the whole policy closed. That strictness is the point:
146
+ * a pattern a human believes matches something it does not is a silent hole.
147
+ */
148
+ const LITERAL = /^[A-Za-z0-9_.:-]$/
149
+
150
+ /** Collapse runs of `*`, so bare `**` is the same catch-all as `*`. */
151
+ function normalizePattern(pattern: string): string {
152
+ return pattern.replace(/\*+/g, '*')
153
+ }
154
+
155
+ /**
156
+ * The syntax error in `pattern`, or null when it is well formed.
157
+ *
158
+ * Beyond the character rule there are two STRUCTURAL rules, because a pattern
159
+ * that a human reads as "the worker tools" must not be a silent no-op:
160
+ * - a pattern may not END with a separator (`worker::`, `a.`, `a:`) — a
161
+ * dangling separator names nothing, so it would sit in the file looking like
162
+ * a rule while matching no tool id at all;
163
+ * - a pattern may not START with one (`.foo`), for the same reason.
164
+ * `worker::*` is unaffected: it ends with the wildcard, which is how the id-glob
165
+ * form is written.
166
+ */
167
+ export function patternSyntaxError(pattern: unknown): string | null {
168
+ if (typeof pattern !== 'string') return 'pattern must be a string, got ' + (pattern === null ? 'null' : typeof pattern)
169
+ if (pattern.length === 0) return 'pattern is empty'
170
+ if (pattern.trim().length === 0) return 'pattern is blank'
171
+ for (const ch of pattern) {
172
+ if (ch === '*') continue
173
+ if (!LITERAL.test(ch)) return 'pattern contains the unsupported character ' + JSON.stringify(ch)
174
+ }
175
+ if (/[.:]$/.test(pattern)) return 'pattern ends with a separator, which names nothing (write "worker::*", not "worker::")'
176
+ if (/^[.:]/.test(pattern)) return 'pattern starts with a separator, which names nothing'
177
+ return null
178
+ }
179
+
180
+ export function isValidGlobPattern(pattern: unknown): boolean {
181
+ return patternSyntaxError(pattern) === null
182
+ }
183
+
184
+ /**
185
+ * Glob-match a tool id. Every `*` matches strictly up to the next literal, so
186
+ * the matcher is anchored at both ends: `worker::*` does NOT match `xworker::y`.
187
+ * Single-restart backtracking, no recursion.
188
+ */
189
+ export function globMatch(pattern: string, id: string): boolean {
190
+ const p = normalizePattern(pattern)
191
+ const n = p.length
192
+ let pi = 0
193
+ let si = 0
194
+ let star = -1
195
+ let mark = 0
196
+ while (si < id.length) {
197
+ if (pi < n && p[pi] !== '*' && p[pi] === id[si]) {
198
+ pi++
199
+ si++
200
+ continue
201
+ }
202
+ if (pi < n && p[pi] === '*') {
203
+ star = pi
204
+ mark = si
205
+ pi++
206
+ continue
207
+ }
208
+ if (star >= 0) {
209
+ // Give the star one more character and retry from just after it.
210
+ pi = star + 1
211
+ mark++
212
+ si = mark
213
+ continue
214
+ }
215
+ return false
216
+ }
217
+ while (pi < n && p[pi] === '*') pi++
218
+ return pi === n
219
+ }
220
+
221
+ /* -------------------------------------------------------------------------- */
222
+ /* Evaluation */
223
+ /* -------------------------------------------------------------------------- */
224
+
225
+ /** The first syntax/contract problem in a policy, in rule order. */
226
+ export function firstPolicyDefect(policy: ToolPolicy): string | null {
227
+ if (!policy || typeof policy !== 'object') return 'policy must be a JSON object'
228
+ if (!Array.isArray(policy.rules)) return 'policy.rules must be an array'
229
+ for (const rule of policy.rules as unknown[]) {
230
+ if (!rule || typeof rule !== 'object') return 'policy.rules contains a non-object entry'
231
+ const r = rule as Record<string, unknown>
232
+ const pattern = typeof r.pattern === 'string' ? r.pattern : JSON.stringify(r.pattern)
233
+ const unknownKeys = Object.keys(r).filter((k) => !['pattern', 'verdict', 'reason', 'label', 'predicate'].includes(k))
234
+ if (unknownKeys.length > 0) return 'rule ' + pattern + ' has unknown key(s) ' + unknownKeys.join(', ')
235
+ const syntax = patternSyntaxError(r.pattern)
236
+ if (syntax) return 'invalid pattern ' + pattern + ': ' + syntax
237
+ if (r.verdict !== 'allow' && r.verdict !== 'deny' && r.verdict !== 'ask') {
238
+ return 'rule ' + pattern + ' has an invalid verdict ' + JSON.stringify(r.verdict) + ' - expected allow | deny | ask'
239
+ }
240
+ if (typeof r.reason !== 'string' || r.reason.trim() === '') {
241
+ return 'rule ' + pattern + ' has no reason - a verdict with no stated reason is not auditable'
242
+ }
243
+ if (r.predicate !== undefined && typeof r.predicate !== 'function') {
244
+ return 'rule ' + pattern + ' has a non-function predicate'
245
+ }
246
+ }
247
+ return null
248
+ }
249
+
250
+ /** The fail-closed decision: everything is denied, and the defect is named. */
251
+ export function failClosedDecision(defect: string): Decision {
252
+ return { kind: 'deny', reason: 'tool policy failed closed - ' + defect }
253
+ }
254
+
255
+ /** True when the pattern is the bare catch-all (`*`), which is least specific. */
256
+ export function isCatchAllPattern(pattern: unknown): boolean {
257
+ return pattern === '*'
258
+ }
259
+
260
+ /**
261
+ * Evaluation PRECEDENCE: SPECIFICITY FIRST, THEN FILE ORDER.
262
+ *
263
+ * T16 asks for "deny wins over allow" AND for "first-match-wins", which cannot
264
+ * both hold under adversarial ordering. The reconciliation — and the semantics
265
+ * this engine implements — is:
266
+ *
267
+ * 1. Among the rules whose pattern MATCHES the call, a rule with a MORE
268
+ * SPECIFIC pattern outranks one with a less specific pattern. So a
269
+ * catch-all `*` allow can never shield an id from a specific `deny` written
270
+ * BELOW it.
271
+ * 2. Among equally specific matching rules, the EARLIER rule in the file wins.
272
+ * 3. A rule whose predicate ABSTAINS (`null`) does not participate at all: it
273
+ * is as if the rule were not in the file for this call, so the next rule in
274
+ * precedence order decides.
275
+ *
276
+ * Why not plain first-match-wins: it would make "deny wins over allow" merely a
277
+ * property of how the file happens to be ordered. A hand-edit that put a
278
+ * catch-all allow at the top would then silently disable every deny in the file,
279
+ * which is precisely the failure the phrase exists to prevent.
280
+ *
281
+ * Specificity tiers, most to least:
282
+ * 3 an EXACT id (`write`) — the narrowest thing a pattern can name;
283
+ * 2 a PREFIX glob (`worker::*`, `recursive_*`) — still names a family;
284
+ * 1 a glob with a wildcard in the middle (`*_write`) — matched anywhere in
285
+ * the id, so it is narrower than the bare catch-all but names no family;
286
+ * 0 the bare catch-all (`*`).
287
+ * These are TIERS, not a fine-grained metric: patterns within a tier are
288
+ * "equally specific" and are decided by file order.
289
+ */
290
+ export function patternSpecificity(pattern: string): number {
291
+ const p = normalizePattern(pattern)
292
+ if (p === '*') return 0
293
+ if (!p.includes('*')) return 3
294
+ if (p.endsWith('*')) return 2
295
+ return 1
296
+ }
297
+
298
+ /**
299
+ * Evaluate a tool call against a policy. PURE: no filesystem, no config, no
300
+ * caching. The loader resolves WHICH policy applies; this decides what it says,
301
+ * using the precedence above.
302
+ */
303
+ export function evaluateToolPolicy(
304
+ policy: ToolPolicy,
305
+ id: string,
306
+ args: Record<string, unknown> = {},
307
+ ctx?: ToolPolicyContext,
308
+ ): Decision {
309
+ const context: ToolPolicyContext = ctx ?? { args }
310
+ const defect = firstPolicyDefect(policy)
311
+ if (defect) return failClosedDecision(defect)
312
+
313
+ // Specificity first, file order as the tie-break (a stable sort keeps the
314
+ // policy's own order within a tier).
315
+ const matching = policy.rules
316
+ .map((rule, index) => ({ rule, index }))
317
+ .filter(({ rule }) => globMatch(rule.pattern, id))
318
+ .sort((a, b) => {
319
+ const bySpecificity = patternSpecificity(b.rule.pattern) - patternSpecificity(a.rule.pattern)
320
+ return bySpecificity !== 0 ? bySpecificity : a.index - b.index
321
+ })
322
+
323
+ for (const { rule } of matching) {
324
+ if (rule.predicate) {
325
+ const match = rule.predicate(id, args, context)
326
+ // Abstention: this rule does not govern this call, so it does not
327
+ // participate and the next rule in precedence order decides.
328
+ if (match === null) continue
329
+ return decide(rule, match.verdict, match.detail ? rule.reason + ' ' + match.detail : rule.reason)
330
+ }
331
+ return decide(rule, rule.verdict, rule.reason)
332
+ }
333
+ // NO MATCH -> ask. Not deny (which would brick an unlisted tool), not allow
334
+ // (which would be a silent permit).
335
+ return { kind: 'ask', reason: 'no policy rule matches ' + id + ' - ask is the no-match default' }
336
+ }
337
+
338
+ /** One place where a rule's verdict becomes a decision, so `label` cannot drift. */
339
+ function decide(rule: ToolPolicyRule, kind: Verdict, reason: string): Decision {
340
+ const decision: Decision = { kind, reason }
341
+ if (rule.label) decision.rule = rule.label
342
+ return decision
343
+ }
344
+
345
+ /* -------------------------------------------------------------------------- */
346
+ /* Loading (absent -> built-in, present-but-broken -> fail closed) */
347
+ /* -------------------------------------------------------------------------- */
348
+
349
+ export interface ToolPolicyLoadResult {
350
+ ok: boolean
351
+ /** `file` when a policy file was found, `builtin` when it was ABSENT. */
352
+ source: 'file' | 'builtin'
353
+ policy: ToolPolicy
354
+ /** Absolute path of the policy file, when one was found. */
355
+ path?: string
356
+ /** Why the file could not be used, when `ok` is false. */
357
+ error?: string
358
+ }
359
+
360
+ /**
361
+ * The ABSENT-file fallback policy, and the slot it lives in.
362
+ *
363
+ * `src/policy-builtin.ts` used to hold the list and call `setBuiltInToolPolicy`
364
+ * at its own top level. That was an import cycle — the list needs this module's
365
+ * predicate contract, this module imported the list — and a cycle means whichever
366
+ * entry loads first observes a partially initialised namespace, which is how a
367
+ * top-level wiring call came to throw `setBuiltInToolPolicy is not a function`.
368
+ *
369
+ * So: the list is defined BELOW, in this module, and wired by the
370
+ * `setBuiltInToolPolicy(builtInToolPolicyDefault)` call at its end. A consumer
371
+ * that imports only `policy-globs.ts` — the loader's own unit tests, for one —
372
+ * therefore gets the safe default without having to remember to install it, and
373
+ * no wiring step can be forgotten.
374
+ *
375
+ * `setBuiltInToolPolicy` stays exported for a caller that genuinely wants a
376
+ * different fallback. The initial value below is unreachable once this module has
377
+ * finished evaluating; it exists so that a hypothetical unwired state denies
378
+ * rather than silently allows, and names the missing wire instead of being
379
+ * mysterious.
380
+ */
381
+ let builtInPolicy: () => ToolPolicy = () =>
382
+ failClosedPolicy('no built-in tool policy is wired (setBuiltInToolPolicy was never called)')
383
+
384
+ /** Wire the built-in default policy used when no policy file exists. */
385
+ export function setBuiltInToolPolicy(factory: () => ToolPolicy): void {
386
+ builtInPolicy = factory
387
+ }
388
+
389
+ /** The built-in policy in force for the absent-file case. */
390
+ export function builtInToolPolicy(): ToolPolicy {
391
+ return builtInPolicy()
392
+ }
393
+
394
+ /* -------------------------------------------------------------------------- */
395
+ /* Built-in default rules — the ABSENT-file fallback */
396
+ /* -------------------------------------------------------------------------- */
397
+
398
+ /** Tool names the locked-artifact write rule guards. */
399
+ export const WRITE_TOOL_NAMES = new Set(['write', 'edit', 'fs_write', 'fs-write', 'pwsh', 'shell', 'bash', 'run_code'])
400
+
401
+ /** Tool names the monotonic lock-order rule guards. */
402
+ export const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'])
403
+
404
+ /**
405
+ * Monotonic lock-order rule: a denial when an earlier phase is not locked,
406
+ * `null` when nothing blocks the lock. The rule carries the static, auditable
407
+ * sentence; the predicate adds the blocking artifact and its status, which is
408
+ * what distinguishes a guard refusal from `lockArtifact`'s own
409
+ * `Prerequisite blockers:` error (`tests/guard-path.spec.ts` asserts both).
410
+ */
411
+ function lockOrderRule(artifact: unknown, runDir: string | undefined): ToolPolicyPredicateMatch | null {
412
+ const name = String(artifact ?? '')
413
+ if (!name || !runDir) return null
414
+ const blockers = getPrerequisiteBlockers(runDir, name)
415
+ if (blockers.length === 0) return null
416
+ return { verdict: 'deny', detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ') }
417
+ }
418
+
419
+ /**
420
+ * Locked-artifact write rule: a denial when the target carries
421
+ * `Status: LOCKED`, `null` otherwise. Only a run-tree `*.md` is a candidate —
422
+ * the same admission test the pre-T16 branch used.
423
+ */
424
+ function lockedWriteRule(target: string | null, worktreeRoot: string | undefined): ToolPolicyPredicateMatch | null {
425
+ if (!target || !worktreeRoot) return null
426
+ const normalized = target.replace(/\\/g, '/')
427
+ if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return null
428
+ const abs = resolveFrom(worktreeRoot, normalized)
429
+ if (!abs || getLockStatus(abs) !== 'LOCKED') return null
430
+ return { verdict: 'deny', detail: normalized + ' carries Status: LOCKED (reopen explicitly to edit)' }
431
+ }
432
+
433
+ /**
434
+ * The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
435
+ * data:
436
+ *
437
+ * `recursive_lock*` -> the monotonic lock-order denial;
438
+ * the write-tool ids -> the locked-artifact write denial;
439
+ * `*` -> allow, so an ordinary tool is not turned into an `ask`.
440
+ *
441
+ * Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
442
+ * fact about the list rather than a hope. The predicates return `null` when
443
+ * their condition does not apply, so a matched-but-clean write falls through to
444
+ * the allow instead of being denied by the pattern alone.
445
+ *
446
+ * `.recursive/config/recursive-permissions.json` mirrors this list in the same
447
+ * order with the same reasons: a reviewer reads either one and predicts the same
448
+ * verdict.
449
+ */
450
+ export function builtInToolPolicyRules(): ToolPolicyRule[] {
451
+ const rules: ToolPolicyRule[] = [
452
+ {
453
+ pattern: 'recursive_lock*',
454
+ verdict: 'deny',
455
+ reason: 'monotonic lock-order: an earlier phase must be locked first',
456
+ label: 'lock-order',
457
+ predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null),
458
+ },
459
+ ]
460
+ for (const name of WRITE_TOOL_NAMES) {
461
+ rules.push({
462
+ pattern: name,
463
+ verdict: 'deny',
464
+ reason: 'locked-artifact write denial: the target carries Status: LOCKED',
465
+ label: 'locked-write',
466
+ predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null),
467
+ })
468
+ }
469
+ rules.push({
470
+ pattern: '*',
471
+ verdict: 'allow',
472
+ reason: 'no deny rule matches this tool',
473
+ })
474
+ return rules
475
+ }
476
+
477
+ /** The built-in default policy (the absent-file fallback), as a policy object. */
478
+ export function builtInToolPolicyDefault(): ToolPolicy {
479
+ return { version: 1, description: 'built-in default tool policy (no policy file present)', rules: builtInToolPolicyRules() }
480
+ }
481
+
482
+ /**
483
+ * Attach the CODE-side condition to a rule that was authored in JSON.
484
+ *
485
+ * This is the seam that makes a policy file honest. JSON cannot carry a
486
+ * function, so a `deny` rule read from a file has no way to know whether its
487
+ * condition actually holds — and an unguarded `deny` is an UNCONDITIONAL denial,
488
+ * i.e. a policy file that denies locks whose prerequisites are met and writes to
489
+ * artifacts that are not locked. The pattern identifies the rule's subject, so
490
+ * the pattern is what selects the condition:
491
+ *
492
+ * `recursive_lock*` -> the monotonic lock-order condition;
493
+ * any write-tool id -> the locked-artifact condition.
494
+ *
495
+ * A pattern the code knows nothing about keeps NO condition and is therefore an
496
+ * absolute verdict — which is the honest reading of a rule like
497
+ * `{"pattern": "run_code", "verdict": "deny"}` with no lock condition behind it.
498
+ */
499
+ export function attachPolicyPredicate(rule: ToolPolicyRule): ToolPolicyRule {
500
+ if (rule.predicate) return rule
501
+ if (rule.pattern === 'recursive_lock*') {
502
+ return { ...rule, predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null) }
503
+ }
504
+ if (WRITE_TOOL_NAMES.has(rule.pattern)) {
505
+ return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null) }
506
+ }
507
+ return rule
508
+ }
509
+
510
+ /**
511
+ * TDD evidence predicate for a phase-3 lock (the `tdd-evidence` guard rule): the
512
+ * denial match when the artifact declares `TDD Mode: strict` without both RED
513
+ * and GREEN evidence, `null` otherwise — nothing to say, so the global lock rules
514
+ * still apply. `phase-rules.ts` uses it for the phase-3 baseline.
515
+ */
516
+ export function tddEvidenceVerdict(artifact: string, runDir: string | undefined): ToolPolicyPredicateMatch | null {
517
+ if (!runDir || artifact !== '03-implementation-summary.md') return null
518
+ const artifactPath = join(runDir, artifact)
519
+ if (!existsSync(artifactPath)) return null
520
+ const content = readFileSync(artifactPath, 'utf8')
521
+ const tddMode = getMdFieldValue(content, 'TDD Mode') ?? ''
522
+ if (tddMode !== 'strict') return null
523
+ const hasRed = /RED|red evidence/i.test(content)
524
+ const hasGreen = /GREEN|green evidence/i.test(content)
525
+ if (hasRed && hasGreen) return null
526
+ return { verdict: 'deny' }
527
+ }
528
+
529
+ // At DEFINITION time, not as a separate wiring step: any module that imports
530
+ // this file gets the absent-file safe default.
531
+ setBuiltInToolPolicy(builtInToolPolicyDefault)
532
+
533
+ /**
534
+ * Read and validate the policy file. NEVER throws: an unreadable or malformed
535
+ * file yields `ok: false` plus a fail-closed policy, so the guard reasons about
536
+ * it instead of crashing the tools/pre-execute listener.
537
+ */
538
+ export function loadToolPolicyFile(worktreeRoot: string): ToolPolicyLoadResult {
539
+ const path = toolPolicyPath(worktreeRoot)
540
+ if (!existsSync(path)) {
541
+ // ABSENT -> the built-in list that reproduces pre-T16 behaviour. Do NOT make
542
+ // this the fail-closed branch: every repo without a policy file would then
543
+ // deny every tool call.
544
+ return { ok: true, source: 'builtin', policy: builtInToolPolicy() }
545
+ }
546
+ let raw: string
547
+ try {
548
+ raw = readFileSync(path, 'utf8')
549
+ } catch (error) {
550
+ const defect = 'policy file is unreadable: ' + messageOf(error)
551
+ return { ok: false, source: 'file', path, policy: failClosedPolicy(defect), error: defect }
552
+ }
553
+ // A PRESENT file is authoritative even when broken: a human wrote it, so a
554
+ // defect must fail closed loudly rather than silently fall back to built-ins.
555
+ const parsed = parseToolPolicy(raw, path)
556
+ return { ok: parsed.ok, source: 'file', path, policy: parsed.policy, error: parsed.error }
557
+ }
558
+
559
+ /**
560
+ * Parse policy JSON into a policy. A parse/validation failure does NOT throw and
561
+ * does NOT return an empty rule list (which would mean "ask everything" and hide
562
+ * the defect) — it returns a single deny-all rule carrying the defect, plus
563
+ * `ok: false` and the same defect as `error`, so a caller can tell a healthy
564
+ * policy from a fail-closed one WITHOUT having to compare reasons. Both the
565
+ * malformed-JSON case and the PRESENT-but-empty case are `ok: false`.
566
+ */
567
+ export function parseToolPolicy(raw: string, path?: string): ToolPolicyParseResult {
568
+ let parsed: unknown
569
+ try {
570
+ parsed = JSON.parse(raw)
571
+ } catch (error) {
572
+ return parseFailure('policy file is not valid JSON: ' + messageOf(error))
573
+ }
574
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
575
+ return parseFailure('policy must be a JSON object with a rules array')
576
+ }
577
+ const record = parsed as Record<string, unknown>
578
+ const unknown = Object.keys(record).filter((k) => !['version', 'description', 'defaults', 'rules'].includes(k))
579
+ if (unknown.length > 0) return parseFailure('policy has unknown key(s) ' + unknown.join(', '))
580
+ if (record.version !== undefined && typeof record.version !== 'number') {
581
+ return parseFailure('policy.version must be a number')
582
+ }
583
+ const defaults = record.defaults as Record<string, unknown> | undefined
584
+ if (defaults !== undefined) {
585
+ if (!defaults || typeof defaults !== 'object' || Array.isArray(defaults)) {
586
+ return parseFailure('policy.defaults must be an object')
587
+ }
588
+ const unknownDefaults = Object.keys(defaults).filter((k) => k !== 'no_match_verdict')
589
+ if (unknownDefaults.length > 0) return parseFailure('policy.defaults has unknown key(s) ' + unknownDefaults.join(', '))
590
+ if (defaults.no_match_verdict !== undefined && defaults.no_match_verdict !== 'ask') {
591
+ return parseFailure('policy.defaults.no_match_verdict must be "ask" - the no-match verdict is not configurable')
592
+ }
593
+ }
594
+ if (!Array.isArray(record.rules)) return parseFailure('policy.rules must be an array')
595
+ const rules = record.rules as ToolPolicyRule[]
596
+ if (rules.length === 0) {
597
+ // PRESENT but EMPTY -> fail closed. Not "ask everything", not "allow all".
598
+ const where = path ?? TOOL_POLICY_RELATIVE_PATH
599
+ return parseFailure('policy at ' + where + ' is PRESENT but declares no rules - every tool call is denied (an empty policy is not an allow-all policy)')
600
+ }
601
+ const policy: ToolPolicy = { version: typeof record.version === 'number' ? record.version : 1, rules: rules.map(attachPolicyPredicate) }
602
+ if (typeof record.description === 'string') policy.description = record.description
603
+ const defect = firstPolicyDefect(policy)
604
+ if (defect) return parseFailure(defect)
605
+ return { ok: true, policy }
606
+ }
607
+
608
+ export interface ToolPolicyParseResult {
609
+ ok: boolean
610
+ policy: ToolPolicy
611
+ /** The defect the policy failed closed on, when `ok` is false. */
612
+ error?: string
613
+ }
614
+
615
+ /** A parse result that fails closed, carrying the defect as both error and reason. */
616
+ function parseFailure(defect: string): ToolPolicyParseResult {
617
+ return { ok: false, policy: failClosedPolicy(defect), error: defect }
618
+ }
619
+
620
+ /** A one-rule policy denying everything, with the defect as its reason. */
621
+ export function failClosedPolicy(defect: string): ToolPolicy {
622
+ return { version: 1, rules: [{ pattern: '*', verdict: 'deny', reason: 'tool policy failed closed - ' + defect }] }
623
+ }
624
+
625
+ function messageOf(error: unknown): string {
626
+ return error instanceof Error ? error.message : String(error)
627
+ }
628
+
629
+ /**
630
+ * Resolve the effective policy for a worktree: the file when present (even when
631
+ * broken — a present-but-invalid file fails closed), the built-in list when
632
+ * absent. Thin, so callers never re-implement the absent/present distinction.
633
+ */
634
+ export function resolveToolPolicy(worktreeRoot: string): ToolPolicy {
635
+ return loadToolPolicyFile(worktreeRoot).policy
636
+ }