@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.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
package/src/enforcement.ts
CHANGED
|
@@ -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,
|
|
8
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
9
9
|
import { join, isAbsolute, resolve, sep } from 'node:path'
|
|
10
|
-
import { getLockStatus
|
|
11
|
-
import {
|
|
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
|
-
|
|
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) => !
|
|
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 = {
|
|
120
|
+
export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
|
|
121
|
+
preStep: 'advisory',
|
|
122
|
+
toolGuards: 'advisory',
|
|
123
|
+
tamper: 'advisory',
|
|
124
|
+
budgets: DEFAULT_BUDGETS,
|
|
125
|
+
}
|
|
37
126
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
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
|
-
/**
|
|
42
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|
package/src/git-context.ts
CHANGED
|
@@ -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
|
+
}
|