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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) 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.patch.yml +312 -0
  53. package/scripts/e2e-run.mjs +51 -0
  54. package/scripts/link-dsh.mjs +233 -0
  55. package/scripts/live/fake-llm.mjs +150 -0
  56. package/scripts/live-session-plugin.mjs +179 -0
  57. package/scripts/live-session-stock.mjs +106 -0
  58. package/scripts/live-session.mjs +139 -0
  59. package/src/client/derive.ts +18 -2
  60. package/src/closeout-report.ts +274 -0
  61. package/src/closeout-standards.ts +102 -0
  62. package/src/closeout.ts +39 -2
  63. package/src/commands.ts +116 -4
  64. package/src/config.ts +113 -0
  65. package/src/delegation.ts +336 -18
  66. package/src/enforcement.ts +262 -72
  67. package/src/errors.ts +197 -0
  68. package/src/git-context.ts +33 -2
  69. package/src/guard-log.ts +134 -0
  70. package/src/handoff.ts +62 -0
  71. package/src/hooks.ts +316 -0
  72. package/src/identity.ts +230 -0
  73. package/src/index.ts +385 -20
  74. package/src/job-log.ts +112 -0
  75. package/src/jobs-runner.ts +222 -0
  76. package/src/json-safe.ts +75 -0
  77. package/src/lock.ts +153 -16
  78. package/src/memory-feedback.ts +185 -0
  79. package/src/memory-select.ts +187 -0
  80. package/src/memory.ts +309 -0
  81. package/src/model-inventory.ts +196 -0
  82. package/src/phase-graph.ts +191 -0
  83. package/src/phase-rules.ts +236 -0
  84. package/src/plan-gate.ts +111 -0
  85. package/src/policy-globs.ts +636 -0
  86. package/src/policy-write.ts +210 -0
  87. package/src/policy.ts +70 -5
  88. package/src/recursive_ask.tool.ts +276 -0
  89. package/src/recursive_audit_team.tool.ts +7 -3
  90. package/src/recursive_closeout.tool.ts +36 -35
  91. package/src/recursive_delegate.tool.ts +194 -0
  92. package/src/recursive_init.tool.ts +4 -3
  93. package/src/recursive_lint.tool.ts +81 -6
  94. package/src/recursive_lock.tool.ts +21 -4
  95. package/src/recursive_phase.tool.ts +3 -2
  96. package/src/recursive_preview.tool.ts +142 -0
  97. package/src/recursive_review.tool.ts +190 -0
  98. package/src/recursive_scratch.tool.ts +5 -4
  99. package/src/recursive_status.tool.ts +3 -2
  100. package/src/recursive_worktree.tool.ts +6 -5
  101. package/src/result-cap.ts +130 -0
  102. package/src/review-round.ts +335 -0
  103. package/src/review.ts +17 -3
  104. package/src/role-route.ts +230 -0
  105. package/src/router.ts +128 -2
  106. package/src/runtime.ts +968 -39
  107. package/src/settlement.ts +355 -0
  108. package/src/skills-phase.ts +143 -0
  109. package/src/snapshot.ts +39 -8
  110. package/src/status.ts +209 -4
  111. package/src/teams-loop.ts +223 -9
  112. package/src/training.ts +565 -0
  113. package/src/ts-lint.ts +38 -4
  114. package/src/types.ts +51 -0
  115. package/src/workflow-audit.ts +288 -0
  116. package/scripts/install-preset.cmd +0 -7
  117. package/scripts/install-preset.js +0 -101
@@ -5,48 +5,153 @@
5
5
  * Layer 2 (tool guards) and Layer 8 (tamper) are the remaining enforcement
6
6
  * layers. Configurable strict|advisory per gate (default advisory).
7
7
  */
8
- import { existsSync, readFileSync } from 'node:fs'
8
+ import { existsSync, readdirSync } from 'node:fs'
9
9
  import { join, isAbsolute, resolve, sep } from 'node:path'
10
- import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
11
- import { getMdFieldValue } from './status.ts'
10
+ import { getLockStatus } from './lock.ts'
11
+ import { validateTransition, type GateCheckResult } from './lifecycle.ts'
12
+ import {
13
+ evaluateToolPolicy, loadToolPolicyFile,
14
+ builtInToolPolicyDefault, LOCK_TOOL_NAMES, WRITE_TOOL_NAMES,
15
+ type ToolPolicy, type ToolPolicyContext, type Decision as PolicyDecision,
16
+ } from './policy-globs.ts'
17
+ import { withPhaseBaseline, phaseNumberForArtifact, resolveFrom } from './phase-rules.ts'
18
+
19
+ /**
20
+ * The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
21
+ * beside the evaluator and the loader that use it as the ABSENT-file fallback,
22
+ * and re-exported here because it is part of the guard's documented behaviour.
23
+ * (It cannot live in a module of its own: the list needs the loader's predicate
24
+ * contract and the loader needs the list, which as two modules is a value-level
25
+ * import cycle — the very thing that made a top-level wiring call throw.)
26
+ */
27
+ export const builtInToolPolicy = builtInToolPolicyDefault
12
28
 
13
29
  export type EnforcementMode = 'strict' | 'advisory'
14
30
 
31
+ /**
32
+ * T28 — the budgets. Every unbounded loop and every unbounded result gets a named,
33
+ * configurable cap, because a bound that is not named cannot be reviewed and a bound
34
+ * that is not configurable gets hardcoded per call site (which is exactly what
35
+ * happened to `maxDepth`).
36
+ */
37
+ export interface BudgetConfig {
38
+ /** Rounds ONE phase's audit loop may run, even when every round makes progress. */
39
+ maxAuditRounds: number
40
+ /**
41
+ * How many times a phase may be sent back for repair. Distinct from T20's
42
+ * no-progress bound: that one stops a loop REPEATING a finding, this one bounds a
43
+ * loop that keeps finding genuinely NEW things — the case the no-progress bound
44
+ * deliberately lets run.
45
+ */
46
+ maxRepairAttempts: number
47
+ /** The ceiling for the WHOLE recursion, not a fresh allowance at each level. */
48
+ maxDelegationDepth: number
49
+ /** Delegated children one phase may start. */
50
+ maxChildrenPerPhase: number
51
+ /** Byte ceiling for one tool result. T24's caps bound the COUNT of findings; a few enormous findings need this. */
52
+ maxResultBytes: number
53
+ }
54
+
55
+ export const DEFAULT_BUDGETS: BudgetConfig = {
56
+ // Matches the historical `maxRounds ?? 3`, so adding the config changes no default.
57
+ maxAuditRounds: 3,
58
+ maxRepairAttempts: 3,
59
+ // Matches the hardcoded `maxDepth ?? 2` this replaces, for the same reason.
60
+ maxDelegationDepth: 2,
61
+ maxChildrenPerPhase: 4,
62
+ maxResultBytes: 65_536,
63
+ }
64
+
15
65
  export interface EnforcementConfig {
16
66
  preStep: EnforcementMode
17
67
  toolGuards: EnforcementMode
18
68
  tamper: EnforcementMode
69
+ /** T28: the caps. Always present, so a caller never has to guess a default. */
70
+ budgets: BudgetConfig
19
71
  }
20
72
 
21
- /** Validate the enforcement config shape (unknown keys fail at plugin load). */
73
+ const CONFIG_KEYS = ['preStep', 'toolGuards', 'tamper', 'budgets']
74
+ const BUDGET_KEYS: ReadonlyArray<keyof BudgetConfig> = [
75
+ 'maxAuditRounds', 'maxRepairAttempts', 'maxDelegationDepth', 'maxChildrenPerPhase', 'maxResultBytes',
76
+ ]
77
+
78
+ /**
79
+ * Validate the enforcement config shape (unknown keys fail at plugin load).
80
+ *
81
+ * A budget must be a POSITIVE INTEGER. Zero and negatives are rejected rather than
82
+ * coerced: `0` would mean "never allowed", which bricks the phase it was meant to
83
+ * bound, and a negative would read as already-exceeded everywhere it is compared.
84
+ * A config error is loud at load, not mysterious later.
85
+ */
22
86
  export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
23
87
  const raw = (config ?? {}) as Record<string, unknown>
24
- const unknown = Object.keys(raw).filter((k) => !['preStep', 'toolGuards', 'tamper'].includes(k))
88
+ const unknown = Object.keys(raw).filter((k) => !CONFIG_KEYS.includes(k))
25
89
  if (unknown.length > 0) {
26
- throw new Error('EnforcementConfig has unknown key(s) ' + unknown.join(', ') + ' - config is { preStep, toolGuards, tamper }')
90
+ throw new Error('EnforcementConfig has unknown key(s) ' + unknown.join(', ') + ' - config is { preStep, toolGuards, tamper, budgets }')
27
91
  }
28
92
  const mode = (value: unknown): EnforcementMode => (value === 'strict' ? 'strict' : 'advisory')
93
+
94
+ const rawBudgets = (raw.budgets ?? {}) as Record<string, unknown>
95
+ if (typeof raw.budgets !== 'undefined' && (raw.budgets === null || typeof raw.budgets !== 'object')) {
96
+ throw new Error('EnforcementConfig budgets must be an object of caps')
97
+ }
98
+ const unknownBudget = Object.keys(rawBudgets).filter((k) => !BUDGET_KEYS.includes(k as keyof BudgetConfig))
99
+ if (unknownBudget.length > 0) {
100
+ throw new Error('EnforcementConfig budgets has unknown key(s) ' + unknownBudget.join(', ') + ' - budgets are { ' + BUDGET_KEYS.join(', ') + ' }')
101
+ }
102
+ const budgets = { ...DEFAULT_BUDGETS }
103
+ for (const key of BUDGET_KEYS) {
104
+ const value = rawBudgets[key]
105
+ if (value === undefined) continue
106
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
107
+ throw new Error('EnforcementConfig budgets.' + key + ' must be a positive integer (got ' + JSON.stringify(value) + ')')
108
+ }
109
+ budgets[key] = value
110
+ }
111
+
29
112
  return {
30
113
  preStep: mode(raw.preStep),
31
114
  toolGuards: mode(raw.toolGuards),
32
115
  tamper: mode(raw.tamper),
116
+ budgets,
33
117
  }
34
118
  }
35
119
 
36
- export const DEFAULT_ENFORCEMENT: EnforcementConfig = { preStep: 'advisory', toolGuards: 'advisory', tamper: 'advisory' }
120
+ export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
121
+ preStep: 'advisory',
122
+ toolGuards: 'advisory',
123
+ tamper: 'advisory',
124
+ budgets: DEFAULT_BUDGETS,
125
+ }
37
126
 
38
- /** Tool names the locked-artifact write guard treats as write operations. */
39
- const WRITE_TOOL_NAMES = new Set(['write', 'edit', 'fs_write', 'fs-write', 'pwsh', 'shell', 'bash', 'run_code'])
127
+ /**
128
+ * T15: the rule that produced a guard decision (a machine-readable reason for
129
+ * every allow/deny/ask the guard hands back). `'none'` means no guard predicate
130
+ * fired; `'transition'` marks a decision whose only dissenting gate was the
131
+ * advisory transition gate (see consultTransitionGate).
132
+ */
133
+ export type GuardRule = 'lock-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none'
40
134
 
41
- /** Tool names the monotonic lock-order guard treats as lock operations. */
42
- const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'])
135
+ /** T15: the transition gate's verdict as attached to a decision (advisory only). */
136
+ export interface GuardTransition {
137
+ passed: boolean
138
+ failures: string[]
139
+ }
43
140
 
44
141
  /**
45
142
  * Layer 2 - tools/pre-execute guard decision.
46
143
  * Pure predicate: inspects the pending tool execution (name + args) against
47
144
  * the run tree under the given worktree root.
145
+ *
146
+ * T15: `rule` and `transition` are OPTIONAL and additive. They must stay
147
+ * optional — `coerceAskToDecision` is asserted with `toEqual({ kind: ... })`
148
+ * (an EXACT match) in tests/enforcement.spec.ts, so the coercion path may never
149
+ * grow extra keys. `evaluateToolGuard` itself always sets `rule`.
48
150
  */
49
- export type ToolGuardDecision = { kind: 'allow'; warn?: string } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
151
+ export type ToolGuardDecision =
152
+ | { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition }
153
+ | { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition }
154
+ | { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition }
50
155
 
51
156
  export interface ToolExecLike {
52
157
  name: string
@@ -54,6 +159,61 @@ export interface ToolExecLike {
54
159
  agent?: unknown
55
160
  }
56
161
 
162
+ /* -------------------------------------------------------------------------- */
163
+ /* T16 — the policy the guard evaluates */
164
+ /* -------------------------------------------------------------------------- */
165
+
166
+ /**
167
+ * The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
168
+ * beside the evaluator and the loader that use it as the ABSENT-file fallback,
169
+ * and re-exported here because it is part of the guard's documented behaviour.
170
+ * (It cannot live in a module of its own: the list needs the loader's predicate
171
+ * contract and the loader needs the list, which as two modules is a value-level
172
+ * import cycle — the very thing that made a top-level wiring call throw.)
173
+ */
174
+
175
+ /**
176
+ * The policy in force for one guard call: the worktree's policy file when
177
+ * present (a broken file fails closed), the built-in list when absent, plus the
178
+ * current phase's narrowing baseline. Exported so a reviewer — and
179
+ * `recursive:policy` later — can read the effective rules instead of inferring
180
+ * them from a code path.
181
+ */
182
+ export function resolveToolPolicyForGuard(worktreeRoot: string, runId: string): ToolPolicy {
183
+ const loaded = loadToolPolicyFile(worktreeRoot)
184
+ return withPhaseBaseline(loaded.policy, currentPhaseArtifact(worktreeRoot, runId))
185
+ }
186
+
187
+ /**
188
+ * The artifact whose phase baseline applies: the HIGHEST-numbered phase artifact
189
+ * present in the run (a run at phase 3 has `00`-`03` on disk). Read from the
190
+ * filesystem on every call — the same no-cache discipline the active run id
191
+ * needs, because a cached phase would apply yesterday's baseline to today's lock.
192
+ */
193
+ export function currentPhaseArtifact(worktreeRoot: string, runId: string): string {
194
+ if (!worktreeRoot || !runId) return ''
195
+ const runDir = join(worktreeRoot, '.recursive', 'run', runId)
196
+ let names: string[]
197
+ try {
198
+ names = readdirSync(runDir)
199
+ } catch {
200
+ return ''
201
+ }
202
+ let best = ''
203
+ let bestPhase = -1
204
+ for (const name of names) {
205
+ if (!name.endsWith('.md')) continue
206
+ const phase = phaseNumberForArtifact(name)
207
+ if (!phase) continue
208
+ const value = Number(phase)
209
+ if (value > bestPhase) {
210
+ bestPhase = value
211
+ best = name
212
+ }
213
+ }
214
+ return best
215
+ }
216
+
57
217
  export function evaluateToolGuard(
58
218
  exec: ToolExecLike,
59
219
  worktreeRoot: string,
@@ -62,50 +222,86 @@ export function evaluateToolGuard(
62
222
  ): ToolGuardDecision {
63
223
  const name = exec.name
64
224
  const args = (exec.arguments ?? {}) as Record<string, unknown>
65
- const runDir = join(worktreeRoot, '.recursive', 'run', activeRunId)
66
-
67
- // Monotonic lock-order denial (recursive_lock out of order).
68
- if (LOCK_TOOL_NAMES.has(name)) {
69
- const artifact = String(args.artifact ?? '')
70
- if (artifact) {
71
- const blockers = getPrerequisiteBlockers(runDir, artifact)
72
- if (blockers.length > 0) {
73
- const reason = 'monotonic lock-order: ' + blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ')
74
- return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
75
- }
76
- // TDD evidence gating on Phase 3 lock.
77
- if (artifact === '03-implementation-summary.md') {
78
- const artifactPath = join(runDir, artifact)
79
- const content = existsSync(artifactPath) ? readFileSync(artifactPath, 'utf8') : ''
80
- const tddMode = getMdFieldValue(content, 'TDD Mode') ?? ''
81
- if (tddMode === 'strict') {
82
- const hasRed = /RED|red evidence/i.test(content)
83
- const hasGreen = /GREEN|green evidence/i.test(content)
84
- if (!hasRed || !hasGreen) {
85
- const reason = 'TDD Mode: strict requires RED + GREEN evidence before locking Phase 3'
86
- return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
87
- }
88
- }
89
- }
90
- }
91
- }
225
+ const runId = typeof activeRunId === 'string' ? activeRunId.trim() : ''
226
+ const runDir = join(worktreeRoot, '.recursive', 'run', runId)
92
227
 
93
- // Locked-artifact write denial.
94
- if (WRITE_TOOL_NAMES.has(name)) {
95
- const target = firstTargetPath(args)
96
- if (target) {
97
- const normalized = target.replace(/\\/g, '/')
98
- if (normalized.endsWith('.md') && normalized.includes('/.recursive/run/')) {
99
- const abs = resolveTargetPath(normalized, worktreeRoot)
100
- if (abs && getLockStatus(abs) === 'LOCKED') {
101
- const reason = 'locked-artifact write denial: ' + normalized + ' carries Status: LOCKED (reopen explicitly to edit)'
102
- return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
103
- }
104
- }
105
- }
228
+ // T15: the transition gate is consulted BEFORE the verdict so its result can
229
+ // ride along on every lock-tool decision — including a refusal.
230
+ const transition = consultTransitionGate(name, args, worktreeRoot, runId)
231
+
232
+ // T16: the verdict comes from the ordered policy, not from branches here. The
233
+ // policy file is re-read per call on purpose: a policy a human just edited
234
+ // must take effect on the next tool call, not after a restart.
235
+ const policy = resolveToolPolicyForGuard(worktreeRoot, runId)
236
+ const context: ToolPolicyContext = { args, runDir, runId, worktreeRoot }
237
+ const decision = evaluateToolPolicy(policy, name, args, context)
238
+ return advisory(verdictFor(mode, decision), transition)
239
+ }
240
+
241
+ /**
242
+ * Map the policy's verdict onto the guard's decision kind: `strict` denies,
243
+ * `advisory` asks (the pre-T16 wording, unchanged), `allow` stays an allow. The
244
+ * decision's `rule` is the label of the rule that decided it, so a policy
245
+ * verdict is traceable to an auditable line in the policy file.
246
+ */
247
+ function verdictFor(mode: EnforcementMode, decision: PolicyDecision): ToolGuardDecision {
248
+ const rule = (decision.rule ?? 'none') as GuardRule
249
+ if (decision.kind === 'allow') return { kind: 'allow', rule }
250
+ const reason = decision.reason ?? 'tool policy denied this call'
251
+ return mode === 'strict' ? { kind: 'deny', reason, rule } : { kind: 'ask', reason, rule }
252
+ }
253
+
254
+ /**
255
+ * T15 — consult the transition gate (`validateTransition`) from the guard,
256
+ * ADVISORY-FIRST.
257
+ *
258
+ * The gate's result is ATTACHED to the decision as `transition` and NEVER
259
+ * changes the verdict (allow/deny/ask), which continues to come from the
260
+ * guard's own predicates. Rationale: `validateTransition` enforces a SUPERSET
261
+ * of the guard's checks — it additionally requires `Audit: PASS`, a
262
+ * `Requirement Completion Status` section, a delegation-basis marker,
263
+ * `## Effective Inputs Re-read`, and (for phase 3) that the RED/GREEN evidence
264
+ * FILES exist. Promoting those to blocking would deny locks the lock tool
265
+ * itself allows, and would take the parity fixtures red — they carry none of
266
+ * those markers. So: the gate reports, the guard decides, and a gate failure
267
+ * under an otherwise-allowed call surfaces as an advisory `warn` (never a
268
+ * silent allow).
269
+ *
270
+ * Only lock-tool calls with a REAL run id and a non-empty artifact are
271
+ * consulted; a missing run id means the caller has no run context (the gate
272
+ * would resolve prerequisites against the run ROOT and be meaningless).
273
+ */
274
+ function consultTransitionGate(
275
+ name: string,
276
+ args: Record<string, unknown>,
277
+ worktreeRoot: string,
278
+ runId: string,
279
+ ): GateCheckResult | undefined {
280
+ if (!LOCK_TOOL_NAMES.has(name)) return undefined
281
+ if (!worktreeRoot || !runId) return undefined
282
+ const artifact = String(args.artifact ?? '')
283
+ if (!artifact) return undefined
284
+ try {
285
+ return validateTransition({ runId, worktreeRoot, targetArtifact: artifact, kind: 'lock' })
286
+ } catch {
287
+ // Best-effort evidence: a transition read must never fail the guard call.
288
+ return undefined
106
289
  }
290
+ }
107
291
 
108
- return { kind: 'allow' }
292
+ /** Attach the transition verdict, and warn when the guard allows against it. */
293
+ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | undefined): ToolGuardDecision {
294
+ if (!transition) return decision
295
+ if (decision.kind !== 'allow') return { ...decision, transition }
296
+ if (transition.passed) return { ...decision, transition }
297
+ // The verdict stays `allow`; the gate only names itself as the dissenting
298
+ // voice, so an advisory pass is never silent.
299
+ return {
300
+ ...decision,
301
+ rule: 'transition',
302
+ warn: 'transition gate (advisory) failed: ' + transition.failures.join('; '),
303
+ transition,
304
+ }
109
305
  }
110
306
 
111
307
  /**
@@ -123,26 +319,20 @@ export function coerceAskToDecision(decision: ToolGuardDecision, mode: Enforceme
123
319
  return { kind: 'allow', warn: decision.reason ?? 'ask under advisory enforcement allows' }
124
320
  }
125
321
 
126
- /** Resolve a tool-target path to an absolute path under the worktree root. */
322
+ /**
323
+ * Resolve a tool-target path to an absolute path under the worktree root.
324
+ * T16: the path arithmetic moved to `phase-rules.ts` (shared with the phase
325
+ * baselines, so the guard and a baseline can never disagree about which path a
326
+ * call names); what stays here is this module's containment rule.
327
+ */
127
328
  function resolveTargetPath(target: string, worktreeRoot: string): string | null {
128
- const normalized = target.replace(/\\/g, '/')
129
- if (isAbsolute(normalized)) {
130
- const abs = resolve(normalized)
131
- const rootAbs = resolve(worktreeRoot)
132
- const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
133
- if (abs !== rootAbs && !abs.startsWith(rootPrefix)) return null
134
- return abs
135
- }
136
- return resolve(worktreeRoot, normalized.replace(/^\.?\/?/, ''))
137
- }
138
-
139
- /** Extract the first candidate target path from a tool's arguments. */
140
- function firstTargetPath(args: Record<string, unknown>): string | null {
141
- for (const key of ['file_path', 'path', 'command', 'target', 'filePath']) {
142
- const value = args[key]
143
- if (typeof value === 'string' && value.trim() !== '') return value.trim()
144
- }
145
- return null
329
+ const abs = resolveFrom(worktreeRoot, target)
330
+ if (!abs) return null
331
+ if (!isAbsolute(target.replace(/\\/g, '/'))) return abs
332
+ const rootAbs = resolve(worktreeRoot)
333
+ const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
334
+ if (abs !== rootAbs && !abs.startsWith(rootPrefix)) return null
335
+ return abs
146
336
  }
147
337
 
148
338
  /**
package/src/errors.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * T24 — stable, greppable, self-sufficient tool errors.
3
+ *
4
+ * WHY PROSE AND NOT A JSON ENVELOPE. A structured error envelope reaches the
5
+ * model double-escaped and is materially harder to read than a sentence, and a
6
+ * model that cannot read a refusal cannot correct it. So every error here
7
+ * renders as ONE sentence:
8
+ *
9
+ * <code> <class>: <problem>[ - <detail>]. Next: <the exact call that resolves it>.
10
+ *
11
+ * The leading `<code> <class>` pair is STABLE and greppable, so a consumer that
12
+ * is not an LLM — a test, a board badge, a log grep — can branch on it without
13
+ * parsing prose. The `Next:` clause names a real tool call, because "invalid
14
+ * input" without a route is a refusal the caller can only guess at.
15
+ *
16
+ * Codes are `RM<group><serial>` where the THIRD character is the class group:
17
+ * 1 input, 2 value, 3 workspace, 4 state, 5 runtime, 6 capability. Because the
18
+ * group is embedded in the code, a log grep for `RM1` finds every input-shaped
19
+ * failure without knowing this registry — and `tests/errors.spec.ts` asserts the
20
+ * third character matches the entry's own class, so the grouping cannot drift.
21
+ */
22
+
23
+ /** What kind of thing went wrong. Stable vocabulary — consumers branch on it. */
24
+ export type ToolErrorClass = 'input' | 'value' | 'workspace' | 'state' | 'runtime' | 'capability'
25
+
26
+ export interface ToolErrorSpec {
27
+ /** Stable `RM<group><serial>` code. Never reuse one for a different problem. */
28
+ code: string
29
+ klass: ToolErrorClass
30
+ /** The fixed problem statement, no trailing punctuation. */
31
+ problem: string
32
+ /** What resolves it, naming the exact call. No trailing punctuation. */
33
+ next: string
34
+ }
35
+
36
+ /**
37
+ * The registry. Every `recursive_*` tool error must come from here: a tool that
38
+ * invents its own sentence is a tool whose refusals cannot be grepped, and
39
+ * `tests/errors.spec.ts` asserts the codes are unique and well formed so the
40
+ * registry cannot rot into duplicates.
41
+ */
42
+ export const TOOL_ERRORS = {
43
+ /* 1xxx — input the caller must supply. */
44
+ BAD_PROBE_ARGUMENTS: {
45
+ code: 'RM1144',
46
+ klass: 'input',
47
+ problem: 'probeArguments is not a JSON object',
48
+ next: 'pass a JSON object such as {"artifact":"01-as-is.md"} so the preview can evaluate the guard against real arguments',
49
+ },
50
+ BAD_ASK_GATE: {
51
+ code: 'RM1141',
52
+ klass: 'input',
53
+ problem: 'the requested human gate is not one of tdd-mode, qa-signoff or gate-block',
54
+ next: 'call recursive_ask with gate: tdd-mode | qa-signoff | gate-block',
55
+ },
56
+ BAD_ASK_ANSWER: {
57
+ code: 'RM1142',
58
+ klass: 'input',
59
+ problem: 'the answer is not one of the labels the gate offered',
60
+ next: 'use one of the labels the ask returned; an unoffered answer reads as a decision while being a transcription error',
61
+ },
62
+ MISSING_ASK_ARTIFACT: {
63
+ code: 'RM1143',
64
+ klass: 'input',
65
+ problem: 'this gate has no default artifact, so one must be named',
66
+ next: 'pass artifact: <file> so the answer has somewhere durable to land',
67
+ },
68
+ MISSING_RUN_ID: {
69
+ code: 'RM1101',
70
+ klass: 'input',
71
+ problem: 'runId is required',
72
+ next: 'call recursive_status with no runId to see the latest run id in this workspace',
73
+ },
74
+ MISSING_ARTIFACT: {
75
+ code: 'RM1102',
76
+ klass: 'input',
77
+ problem: 'artifact is required',
78
+ next: 'call recursive_phase to see which artifact the run is currently on',
79
+ },
80
+ MISSING_PHASE_AND_RUN: {
81
+ code: 'RM1103',
82
+ klass: 'input',
83
+ problem: 'phase and runId are required',
84
+ next: 'call recursive_status to read the run id, then pass phase as 04|05|06|07|08',
85
+ },
86
+ MISSING_SCRATCH_ARGS: {
87
+ code: 'RM1104',
88
+ klass: 'input',
89
+ problem: 'action, runId and target are all required',
90
+ next: 'call recursive_scratch with action=read|write|append, a runId, and target=md|ts',
91
+ },
92
+ MISSING_CREATE_RUN_ID: {
93
+ code: 'RM1105',
94
+ klass: 'input',
95
+ problem: 'runId is required for create',
96
+ next: 'call recursive_init with a runId to scaffold the run first',
97
+ },
98
+ MISSING_PROMOTE_BRANCHES: {
99
+ code: 'RM1106',
100
+ klass: 'input',
101
+ problem: 'fromBranch and toBranch are required for promote',
102
+ next: 'call recursive_worktree with action=promote plus fromBranch and toBranch',
103
+ },
104
+
105
+ /* 2xxx — a supplied value is outside its allowed set. */
106
+ BAD_TARGET: {
107
+ code: 'RM2201',
108
+ klass: 'value',
109
+ problem: 'target must be md or ts',
110
+ next: 'pass target=md for /.recursive/run/<id>/scratch/scratch.md or target=ts for scratch.ts',
111
+ },
112
+ BAD_ACTION: {
113
+ code: 'RM2202',
114
+ klass: 'value',
115
+ problem: 'action must be create | promote | status',
116
+ next: 'pass action=create, action=promote or action=status',
117
+ },
118
+
119
+ /* 3xxx — the session is not attached to a control plane. */
120
+ NO_WORKSPACE: {
121
+ code: 'RM3301',
122
+ klass: 'workspace',
123
+ problem: 'this session is not attached to a registered workspace',
124
+ next: 'open the session inside a workspace directory; the control-plane root is resolved from the session cwd',
125
+ },
126
+
127
+ /* 4xxx — the workspace exists but the state the call needs does not. */
128
+ NO_RUN: {
129
+ code: 'RM4401',
130
+ klass: 'state',
131
+ problem: 'no recursive run exists in this workspace',
132
+ next: 'call recursive_init with a runId to scaffold the first run',
133
+ },
134
+ NO_PHASE: {
135
+ code: 'RM4402',
136
+ klass: 'state',
137
+ problem: 'no current recursive phase could be determined',
138
+ next: 'call recursive_init to scaffold the run, or recursive_status to inspect why every phase is locked',
139
+ },
140
+
141
+ PENDING_WORK: {
142
+ code: 'RM4403',
143
+ klass: 'state',
144
+ problem: 'the run has unresolved delegated work, so this phase cannot lock yet',
145
+ next: 'call recursive_status to see the pending delegation, have the child write its reply.md, then lock again',
146
+ },
147
+
148
+ /* 5xxx — the runtime refused an operation it understands. */
149
+
150
+ RUNTIME_REFUSED: {
151
+ code: 'RM5501',
152
+ klass: 'runtime',
153
+ problem: 'the recursive runtime refused the operation',
154
+ next: 'fix the cause named in the detail and retry; a gate refusal names the artifact and its status',
155
+ },
156
+
157
+ /* 6xxx — an optional host service is absent (the plugin degrades, never crashes). */
158
+ TEAM_SERVICE_UNAVAILABLE: {
159
+ code: 'RM6601',
160
+ klass: 'capability',
161
+ problem: 'the agent-teams service is not available in this composition',
162
+ next: 'use recursive_lock and recursive_lint directly, or mount a composition that provides ctx.agentTeams',
163
+ },
164
+ } as const satisfies Record<string, ToolErrorSpec>
165
+
166
+ export type ToolErrorName = keyof typeof TOOL_ERRORS
167
+
168
+ /**
169
+ * Render one registry entry as the single sentence a tool returns.
170
+ * `detail` carries the run-specific part (a run id, an artifact, a gate).
171
+ */
172
+ export function toolError(name: ToolErrorName, detail?: string): string {
173
+ const spec: ToolErrorSpec = TOOL_ERRORS[name]
174
+ const head = spec.code + ' ' + spec.klass + ': ' + spec.problem
175
+ const withDetail = detail && detail.trim() !== '' ? head + ' - ' + detail.trim() : head
176
+ return withDetail + '. Next: ' + spec.next + '.'
177
+ }
178
+
179
+ /** True when a string already carries a registry code — used to avoid double-wrapping. */
180
+ export function hasToolErrorCode(message: string): boolean {
181
+ return /^RM\d{4}\b/.test(message.trim())
182
+ }
183
+
184
+ /**
185
+ * Give a thrown runtime message a stable code WITHOUT nesting one that is
186
+ * already there.
187
+ *
188
+ * A refusal the runtime already expressed in this registry's vocabulary (for
189
+ * example `RM4403` pending work) is passed through untouched: wrapping it would
190
+ * bury the real code inside `RM5501`'s detail and make the greppable handle
191
+ * useless, which is the whole reason the registry exists. A bare thrown message
192
+ * has no code to branch on, so it is wrapped and its sentence survives as the
193
+ * detail.
194
+ */
195
+ export function codeRuntimeRefusal(message: string): string {
196
+ return hasToolErrorCode(message) ? message : toolError('RUNTIME_REFUSED', message)
197
+ }
@@ -49,8 +49,7 @@ export function gitRun(repoRoot: string, ...args: string[]): string | null {
49
49
  }
50
50
 
51
51
  /** True when a non-equal git-dir vs git-common-dir marks a linked worktree. */
52
- export function isLinkedWorktree(gitDir: string | null, commonDir: string | null): boolean {
53
- if (!gitDir || !commonDir) return false
52
+ export function isLinkedWorktree(gitDir: string | null, commonDir: string | null): boolean { if (!gitDir || !commonDir) return false
54
53
  return gitDir !== commonDir
55
54
  }
56
55
 
@@ -130,3 +129,35 @@ export function verifyWorktreeBranch(repoRoot: string, recordedBranch: string |
130
129
  if (live !== recordedBranch) return { ok: false, reason: `checkout is on branch '${live}' but 00-worktree.md records '${recordedBranch}'` }
131
130
  return { ok: true, reason: null }
132
131
  }
132
+
133
+ /**
134
+ * FU-4 — the paths this checkout has actually changed, for the memory loader's path weighting.
135
+ *
136
+ * WHY IT EXISTS. T29's selection weights a shard that names a path the run has changed **above** one that
137
+ * merely shares wording with the query — but nothing computed those paths, so the weighting was exercised
138
+ * by tests and never by a run. A weighting that is never fed is a rule that does not exist in production.
139
+ *
140
+ * ⚠ IT REUSES `gitRun` AND THE SAME TWO QUERIES `ts-lint.ts` ALREADY USES (`diff --name-only` for tracked
141
+ * changes, `ls-files --others` for new files). A second way of asking git the same question is a second
142
+ * thing to get subtly wrong — and the lint path is the one with a parity golden behind it.
143
+ *
144
+ * ⚠ IT IS CAPPED, AND IT IS BEST-EFFORT. Uncapped, a large checkout would let path matches swamp the
145
+ * query score entirely, so the list is capped; and a repo where git fails returns `[]` rather than
146
+ * throwing, because a memory hint must never be the reason a phase call fails.
147
+ */
148
+ export const MAX_CHANGED_PATHS = 50
149
+
150
+ export function changedPaths(repoRoot: string, limit: number = MAX_CHANGED_PATHS): string[] {
151
+ const tracked = gitRun(repoRoot, 'diff', '--name-only', 'HEAD')
152
+ const untracked = gitRun(repoRoot, 'ls-files', '--others', '--exclude-standard')
153
+ const paths: string[] = []
154
+ for (const blob of [tracked, untracked]) {
155
+ if (blob === null) continue
156
+ for (const line of blob.split('\n')) {
157
+ const path = line.trim().replace(/\\/g, '/')
158
+ if (path !== '' && !paths.includes(path)) paths.push(path)
159
+ if (paths.length >= limit) return paths
160
+ }
161
+ }
162
+ return paths
163
+ }