@try-works/dsh-recursive-mode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cordis.patch.yml +12 -0
- package/lib/bootstrap.d.ts +35 -0
- package/lib/client/board.d.ts +10 -0
- package/lib/client/contract.d.ts +51 -0
- package/lib/client/derive.d.ts +92 -0
- package/lib/client/index.d.ts +21 -0
- package/lib/client/inspector.d.ts +10 -0
- package/lib/client/node.d.ts +71 -0
- package/lib/client/settings.d.ts +6 -0
- package/lib/client/slots.d.ts +7 -0
- package/lib/client/strip.d.ts +7 -0
- package/lib/client.d.ts +10 -0
- package/lib/client.js +490 -0
- package/lib/closeout.d.ts +23 -0
- package/lib/commands.d.ts +51 -0
- package/lib/delegation.d.ts +92 -0
- package/lib/enforcement.d.ts +53 -0
- package/lib/events.d.ts +173 -0
- package/lib/handoff.d.ts +51 -0
- package/lib/index.d.ts +40 -0
- package/lib/lifecycle.d.ts +107 -0
- package/lib/lock.d.ts +92 -0
- package/lib/policy.d.ts +12 -0
- package/lib/projection.d.ts +29 -0
- package/lib/recursive_closeout.tool.d.ts +8 -0
- package/lib/recursive_init.tool.d.ts +2 -0
- package/lib/recursive_lint.tool.d.ts +2 -0
- package/lib/recursive_lock.tool.d.ts +2 -0
- package/lib/recursive_scratch.tool.d.ts +7 -0
- package/lib/recursive_status.tool.d.ts +2 -0
- package/lib/review.d.ts +39 -0
- package/lib/router.d.ts +77 -0
- package/lib/run.d.ts +29 -0
- package/lib/runtime.d.ts +241 -0
- package/lib/scratch.d.ts +18 -0
- package/lib/status.d.ts +19 -0
- package/lib/types.d.ts +104 -0
- package/lib/workspace.d.ts +50 -0
- package/package.json +119 -0
- package/preset/recursive/agent.cordis.yml +282 -0
- package/preset/recursive/preset.yml +3 -0
- package/scripts/install-recursive-mode.ps1 +956 -0
- package/scripts/install-recursive-mode.py +750 -0
- package/scripts/lint-recursive-run.py +2868 -0
- package/scripts/recursive-closeout.py +541 -0
- package/scripts/recursive-init.py +356 -0
- package/scripts/recursive-lock.py +302 -0
- package/scripts/recursive-status.py +2124 -0
- package/scripts/recursive_phase_rules.py +367 -0
- package/scripts/recursive_router_lib.py +2282 -0
- package/scripts/test-recursive-mode-smoke.ts +204 -0
- package/scripts/verify-locks.py +353 -0
- package/src/bootstrap.ts +118 -0
- package/src/client/board.tsx +61 -0
- package/src/client/contract.ts +58 -0
- package/src/client/derive.ts +241 -0
- package/src/client/index.ts +28 -0
- package/src/client/inspector.tsx +49 -0
- package/src/client/node.ts +156 -0
- package/src/client/settings.tsx +18 -0
- package/src/client/slots.ts +67 -0
- package/src/client/strip.tsx +28 -0
- package/src/client.ts +11 -0
- package/src/closeout.ts +183 -0
- package/src/commands.ts +142 -0
- package/src/delegation.ts +306 -0
- package/src/enforcement.ts +180 -0
- package/src/events.ts +173 -0
- package/src/handoff.ts +165 -0
- package/src/index.ts +283 -0
- package/src/lifecycle.ts +235 -0
- package/src/lock.ts +369 -0
- package/src/policy.ts +56 -0
- package/src/projection.ts +237 -0
- package/src/recursive_closeout.tool.ts +35 -0
- package/src/recursive_init.tool.ts +28 -0
- package/src/recursive_lint.tool.ts +29 -0
- package/src/recursive_lock.tool.ts +33 -0
- package/src/recursive_scratch.tool.ts +42 -0
- package/src/recursive_status.tool.ts +24 -0
- package/src/review.ts +178 -0
- package/src/router.ts +197 -0
- package/src/run.ts +85 -0
- package/src/runtime.ts +564 -0
- package/src/scratch.ts +85 -0
- package/src/status.ts +194 -0
- package/src/types.ts +112 -0
- package/src/workspace.ts +67 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-driven delegation + report/reference validation + durable action
|
|
3
|
+
* records (Phase B R4/R6/R7, PROPOSAL 10.5/10.9). The ENFORCED path (distinct
|
|
4
|
+
* from the agent-driven subagent tool): the plugin calls ctx.subagents.start()
|
|
5
|
+
* with the full SubagentStartRequest (outputSchema/toolFilter/maxDepth) and
|
|
6
|
+
* validates the child's references before writing an action record.
|
|
7
|
+
*
|
|
8
|
+
* Workspace-scoped (run 03 R1) + fail-loud + optionality-preserving.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { join, resolve, sep } from 'node:path'
|
|
12
|
+
import { contentSha256 } from './review.ts'
|
|
13
|
+
|
|
14
|
+
/** Minimal host-realm contract for ctx.subagents (the seam we call). */
|
|
15
|
+
export interface SubagentsRuntimeLike {
|
|
16
|
+
start(name: string, request: SubagentStartRequestLike): Promise<SubagentResultLike>
|
|
17
|
+
getProvider?(name: string): unknown
|
|
18
|
+
list?(): unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SubagentStartRequestLike {
|
|
22
|
+
prompt: unknown[] // ContentBlock[]
|
|
23
|
+
label?: string
|
|
24
|
+
outputSchema?: Record<string, unknown>
|
|
25
|
+
toolFilter?: unknown
|
|
26
|
+
maxDepth?: number
|
|
27
|
+
persona?: string
|
|
28
|
+
parent?: unknown
|
|
29
|
+
signal?: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SubagentResultLike {
|
|
33
|
+
output?: string
|
|
34
|
+
structured?: unknown
|
|
35
|
+
stopReason?: string
|
|
36
|
+
success?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DelegationError extends Error {
|
|
40
|
+
code?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function delegationError(message: string, code: string): DelegationError {
|
|
44
|
+
const err = new Error(message) as DelegationError
|
|
45
|
+
err.code = code
|
|
46
|
+
return err
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Object-rooted output schema for a delegated review. */
|
|
50
|
+
export function reviewOutputSchema(): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
verdict: { type: 'string', enum: ['APPROVE', 'REJECT', 'REVISE'] },
|
|
55
|
+
findings: {
|
|
56
|
+
type: 'array',
|
|
57
|
+
items: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
severity: { type: 'string', enum: ['INFO', 'LOW', 'MEDIUM', 'HIGH'] },
|
|
61
|
+
title: { type: 'string' },
|
|
62
|
+
detail: { type: 'string' },
|
|
63
|
+
},
|
|
64
|
+
required: ['severity', 'title', 'detail'],
|
|
65
|
+
additionalProperties: false,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
references: {
|
|
69
|
+
type: 'array',
|
|
70
|
+
items: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
path: { type: 'string' },
|
|
74
|
+
lineRange: { type: 'string' },
|
|
75
|
+
},
|
|
76
|
+
required: ['path'],
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
required: ['verdict', 'findings', 'references'],
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Default toolFilter for a delegated reviewer (run-relevant, workspace-scoped). */
|
|
87
|
+
export function defaultReviewToolFilter(): unknown {
|
|
88
|
+
return { allow: ['fs_read', 'grep', 'glob'] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Call ctx.subagents.start() with the full request. Fail loud on capability
|
|
93
|
+
* mismatch, missing provider, or unsupported schema (never silent).
|
|
94
|
+
*/
|
|
95
|
+
export async function delegate(input: {
|
|
96
|
+
subagents: SubagentsRuntimeLike
|
|
97
|
+
provider: string
|
|
98
|
+
request: SubagentStartRequestLike
|
|
99
|
+
}): Promise<SubagentResultLike> {
|
|
100
|
+
const { subagents, provider, request } = input
|
|
101
|
+
if (!subagents || typeof subagents.start !== 'function') {
|
|
102
|
+
throw delegationError('ctx.subagents.start is not available (no subagent seam)', 'NO_PROVIDER')
|
|
103
|
+
}
|
|
104
|
+
if (!provider) {
|
|
105
|
+
throw delegationError('No provider named for delegation', 'NO_PROVIDER')
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return await subagents.start(provider, request)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
111
|
+
const code = (err as { code?: string })?.code
|
|
112
|
+
if (code === 'UNSUPPORTED_CAPABILITY' || message.includes('UNSUPPORTED_CAPABILITY') || message.includes('does not support')) {
|
|
113
|
+
throw delegationError(message, 'UNSUPPORTED_CAPABILITY')
|
|
114
|
+
}
|
|
115
|
+
throw delegationError(message, 'DELEGATION_FAILED')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface Reference {
|
|
120
|
+
path: string
|
|
121
|
+
lineRange?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ReferenceCheck {
|
|
125
|
+
ok: boolean
|
|
126
|
+
failures: string[]
|
|
127
|
+
checked: { path: string; ok: boolean; reason?: string }[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function norm(repoRelative: string): string {
|
|
131
|
+
return repoRelative.replace(/\\/g, '/').replace(/^\/+/, '')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function resolveUnderRoot(root: string, repoRelative: string): string {
|
|
135
|
+
const normalized = norm(repoRelative)
|
|
136
|
+
const rootAbs = resolve(root)
|
|
137
|
+
const abs = resolve(rootAbs, normalized)
|
|
138
|
+
const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
|
|
139
|
+
if (abs !== rootAbs && !abs.startsWith(rootPrefix)) {
|
|
140
|
+
throw new Error('Path escapes the workspace root: ' + repoRelative)
|
|
141
|
+
}
|
|
142
|
+
return abs
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Validate every claimed reference: the path exists under root, and a line
|
|
147
|
+
* range (e.g. '10-20' or '10') is within the file's line count.
|
|
148
|
+
*/
|
|
149
|
+
export function validateReferences(root: string, references: Reference[]): ReferenceCheck {
|
|
150
|
+
const checked: ReferenceCheck['checked'] = []
|
|
151
|
+
const failures: string[] = []
|
|
152
|
+
for (const ref of references) {
|
|
153
|
+
const rel = norm(ref.path)
|
|
154
|
+
if (!rel) {
|
|
155
|
+
checked.push({ path: ref.path, ok: false, reason: 'empty path' })
|
|
156
|
+
failures.push('empty reference path')
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
let abs: string
|
|
160
|
+
try {
|
|
161
|
+
abs = resolveUnderRoot(root, rel)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
164
|
+
checked.push({ path: ref.path, ok: false, reason })
|
|
165
|
+
failures.push(rel + ': ' + reason)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
if (!existsSync(abs)) {
|
|
169
|
+
checked.push({ path: ref.path, ok: false, reason: 'path does not exist' })
|
|
170
|
+
failures.push(rel + ': path does not exist')
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (ref.lineRange) {
|
|
174
|
+
const total = readFileSync(abs, 'utf8').replace(/\r\n/g, '\n').split('\n').length
|
|
175
|
+
const range = ref.lineRange.trim()
|
|
176
|
+
const single = /^\d+$/.test(range)
|
|
177
|
+
const pair = /^(\d+)-(\d+)$/.exec(range)
|
|
178
|
+
let start = 0
|
|
179
|
+
let end = 0
|
|
180
|
+
if (single) {
|
|
181
|
+
start = Number(range)
|
|
182
|
+
end = Number(range)
|
|
183
|
+
} else if (pair) {
|
|
184
|
+
start = Number(pair[1])
|
|
185
|
+
end = Number(pair[2])
|
|
186
|
+
} else {
|
|
187
|
+
checked.push({ path: ref.path, ok: false, reason: 'invalid lineRange ' + range })
|
|
188
|
+
failures.push(rel + ': invalid lineRange ' + range)
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (start < 1 || end < start || end > total) {
|
|
192
|
+
const reason = 'lineRange ' + range + ' out of bounds (file has ' + total + ' lines)'
|
|
193
|
+
checked.push({ path: ref.path, ok: false, reason })
|
|
194
|
+
failures.push(rel + ': ' + reason)
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
checked.push({ path: ref.path, ok: true })
|
|
199
|
+
}
|
|
200
|
+
return { ok: failures.length === 0, failures, checked }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface ActionRecordInput {
|
|
204
|
+
root: string
|
|
205
|
+
runId: string
|
|
206
|
+
subagentId: string
|
|
207
|
+
phase: string
|
|
208
|
+
purpose: string
|
|
209
|
+
executionMode: string
|
|
210
|
+
artifactPath?: string
|
|
211
|
+
upstreamArtifacts?: string[]
|
|
212
|
+
reviewBundle?: string
|
|
213
|
+
diffBasis?: string
|
|
214
|
+
codeRefs?: string[]
|
|
215
|
+
auditQuestions?: string[]
|
|
216
|
+
actionsTaken?: string[]
|
|
217
|
+
createdFiles?: string[]
|
|
218
|
+
modifiedFiles?: string[]
|
|
219
|
+
reviewedFiles?: string[]
|
|
220
|
+
findings?: string[]
|
|
221
|
+
success: boolean
|
|
222
|
+
stopReason?: string
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function slugify(value: string): string {
|
|
226
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'action'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Write a durable action record under subagents/ with the canonical sections
|
|
231
|
+
* (matches recursive-subagent-action.py). A success:false attempt is written
|
|
232
|
+
* with a failed status and is NOT accepted.
|
|
233
|
+
*/
|
|
234
|
+
export function writeActionRecord(input: ActionRecordInput): string {
|
|
235
|
+
const { root, runId } = input
|
|
236
|
+
const dir = resolveUnderRoot(root, '.recursive/run/' + runId + '/subagents')
|
|
237
|
+
mkdirSync(dir, { recursive: true })
|
|
238
|
+
const fileName = Date.now() + '-' + slugify(input.subagentId) + '-action.md'
|
|
239
|
+
const path = join(dir, fileName)
|
|
240
|
+
|
|
241
|
+
const list = (title: string, values: string[]) => {
|
|
242
|
+
const out: string[] = [title]
|
|
243
|
+
if (!values?.length) {
|
|
244
|
+
out.push('- none')
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
for (const v of values) out.push('- ' + String.fromCharCode(96) + norm(v) + String.fromCharCode(96))
|
|
248
|
+
return out
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const lines: string[] = [
|
|
252
|
+
'# Subagent action record: ' + input.subagentId,
|
|
253
|
+
'',
|
|
254
|
+
'## Metadata',
|
|
255
|
+
'- Subagent ID: ' + input.subagentId,
|
|
256
|
+
'- Phase: ' + input.phase,
|
|
257
|
+
'- Purpose: ' + input.purpose,
|
|
258
|
+
'- Execution Mode: ' + input.executionMode,
|
|
259
|
+
'- Status: ' + (input.success ? 'accepted' : 'failed'),
|
|
260
|
+
'- Stop Reason: ' + (input.stopReason ?? 'n/a'),
|
|
261
|
+
...(input.artifactPath ? ['- Current Artifact: ' + String.fromCharCode(96) + norm(input.artifactPath) + String.fromCharCode(96)] : []),
|
|
262
|
+
...(input.reviewBundle ? ['- Review Bundle: ' + String.fromCharCode(96) + norm(input.reviewBundle) + String.fromCharCode(96)] : []),
|
|
263
|
+
'',
|
|
264
|
+
'## Inputs Provided',
|
|
265
|
+
...list('', input.upstreamArtifacts ?? []).slice(1),
|
|
266
|
+
'',
|
|
267
|
+
'## Routing',
|
|
268
|
+
...(input.diffBasis ? ['- Diff Basis: ' + input.diffBasis] : ['- Diff Basis: n/a']),
|
|
269
|
+
'',
|
|
270
|
+
'## Claimed Actions Taken',
|
|
271
|
+
...list('', input.actionsTaken ?? []).slice(1),
|
|
272
|
+
'',
|
|
273
|
+
'## Claimed File Impact',
|
|
274
|
+
...(input.createdFiles?.length ? ['### Created', ...list('', input.createdFiles).slice(1)] : ['### Created', '- none']),
|
|
275
|
+
...(input.modifiedFiles?.length ? ['### Modified', ...list('', input.modifiedFiles).slice(1)] : ['### Modified', '- none']),
|
|
276
|
+
...(input.reviewedFiles?.length ? ['### Reviewed', ...list('', input.reviewedFiles).slice(1)] : ['### Reviewed', '- none']),
|
|
277
|
+
'',
|
|
278
|
+
'## Claimed Artifact Impact',
|
|
279
|
+
...list('### Read', input.upstreamArtifacts ?? []).slice(1),
|
|
280
|
+
'',
|
|
281
|
+
'## Claimed Findings',
|
|
282
|
+
...(input.findings?.length ? input.findings.map((f) => '- ' + f) : ['- none']),
|
|
283
|
+
'',
|
|
284
|
+
'## Verification Handoff',
|
|
285
|
+
'- Inspect first: ' + (input.artifactPath ? String.fromCharCode(96) + norm(input.artifactPath) + String.fromCharCode(96) : 'n/a'),
|
|
286
|
+
'- Notes: main agent must verify every claimed reference against actual files, actual recursive artifacts, and the actual diff before acceptance.',
|
|
287
|
+
'',
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
writeFileSync(path, lines.join('\n'), 'utf8')
|
|
291
|
+
return path
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Accept/reject a delegation result: a success:false or non-completed
|
|
296
|
+
* stopReason is a failed attempt (diagnostics preserved, NOT accepted).
|
|
297
|
+
*/
|
|
298
|
+
export function evaluateDelegationResult(result: SubagentResultLike): { accepted: boolean; reason: string } {
|
|
299
|
+
if (result.success === false) {
|
|
300
|
+
return { accepted: false, reason: 'delegation reported success:false' }
|
|
301
|
+
}
|
|
302
|
+
if (result.stopReason && result.stopReason !== 'completed') {
|
|
303
|
+
return { accepted: false, reason: 'delegation stopped with reason ' + result.stopReason }
|
|
304
|
+
}
|
|
305
|
+
return { accepted: true, reason: 'delegation completed' }
|
|
306
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enforcement config + pre-step gate + tool guards + tamper detection
|
|
3
|
+
* (Phase C R3/R4/R7/R8, PROPOSAL 8.4/8.6/13.5).
|
|
4
|
+
*
|
|
5
|
+
* Layers 1 and 2 are CALLERS of the lifecycle transition set - they never
|
|
6
|
+
* reimplement the predicates. Configurable strict|advisory per gate
|
|
7
|
+
* (default advisory).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join, isAbsolute, resolve, sep } from 'node:path'
|
|
11
|
+
import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
|
|
12
|
+
import { getMdFieldValue } from './status.ts'
|
|
13
|
+
import { validateTransition, type PhaseTransitionIntent, type SessionEventLike, detectTransitionIntent } from './lifecycle.ts'
|
|
14
|
+
|
|
15
|
+
export type EnforcementMode = 'strict' | 'advisory'
|
|
16
|
+
|
|
17
|
+
export interface EnforcementConfig {
|
|
18
|
+
preStep: EnforcementMode
|
|
19
|
+
toolGuards: EnforcementMode
|
|
20
|
+
tamper: EnforcementMode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Validate the enforcement config shape (unknown keys fail at plugin load). */
|
|
24
|
+
export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
|
|
25
|
+
const raw = (config ?? {}) as Record<string, unknown>
|
|
26
|
+
const unknown = Object.keys(raw).filter((k) => !['preStep', 'toolGuards', 'tamper'].includes(k))
|
|
27
|
+
if (unknown.length > 0) {
|
|
28
|
+
throw new Error('EnforcementConfig has unknown key(s) ' + unknown.join(', ') + ' - config is { preStep, toolGuards, tamper }')
|
|
29
|
+
}
|
|
30
|
+
const mode = (value: unknown): EnforcementMode => (value === 'strict' ? 'strict' : 'advisory')
|
|
31
|
+
return {
|
|
32
|
+
preStep: mode(raw.preStep),
|
|
33
|
+
toolGuards: mode(raw.toolGuards),
|
|
34
|
+
tamper: mode(raw.tamper),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_ENFORCEMENT: EnforcementConfig = { preStep: 'advisory', toolGuards: 'advisory', tamper: 'advisory' }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Layer 1 - agent/pre-step phase-transition gate decision.
|
|
42
|
+
* Reads on TRANSITION INTENT ONLY (13.5): no transition intent means the step
|
|
43
|
+
* passes through untouched. On a transition intent whose gates fail:
|
|
44
|
+
* - strict -> reject (turn ends blocked, no model call spent)
|
|
45
|
+
* - advisory -> enter (warn only; a recursive/gate-blocked event is emitted)
|
|
46
|
+
*/
|
|
47
|
+
export interface PreStepGateDecision {
|
|
48
|
+
kind: 'reject' | 'enter'
|
|
49
|
+
gateBlocked: boolean
|
|
50
|
+
failures: string[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function evaluatePreStepGate(
|
|
54
|
+
events: readonly SessionEventLike[],
|
|
55
|
+
mode: EnforcementMode = 'advisory',
|
|
56
|
+
): PreStepGateDecision {
|
|
57
|
+
const intent = detectTransitionIntent(events)
|
|
58
|
+
if (!intent || intent.worktreeRoot === '' || intent.runId === '') {
|
|
59
|
+
return { kind: 'enter', gateBlocked: false, failures: [] }
|
|
60
|
+
}
|
|
61
|
+
const check = validateTransition(intent)
|
|
62
|
+
if (check.passed) return { kind: 'enter', gateBlocked: false, failures: [] }
|
|
63
|
+
return { kind: mode === 'strict' ? 'reject' : 'enter', gateBlocked: true, failures: check.failures }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Tool names the locked-artifact write guard treats as write operations. */
|
|
67
|
+
const WRITE_TOOL_NAMES = new Set(['write', 'edit', 'fs_write', 'fs-write', 'pwsh', 'shell', 'bash', 'run_code'])
|
|
68
|
+
|
|
69
|
+
/** Tool names the monotonic lock-order guard treats as lock operations. */
|
|
70
|
+
const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'])
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Layer 2 - tools/pre-execute guard decision.
|
|
74
|
+
* Pure predicate: inspects the pending tool execution (name + args) against
|
|
75
|
+
* the run tree under the given worktree root.
|
|
76
|
+
*/
|
|
77
|
+
export type ToolGuardDecision = { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
|
|
78
|
+
|
|
79
|
+
export interface ToolExecLike {
|
|
80
|
+
name: string
|
|
81
|
+
arguments?: unknown
|
|
82
|
+
agent?: unknown
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function evaluateToolGuard(
|
|
86
|
+
exec: ToolExecLike,
|
|
87
|
+
worktreeRoot: string,
|
|
88
|
+
activeRunId: string,
|
|
89
|
+
mode: EnforcementMode = 'advisory',
|
|
90
|
+
): ToolGuardDecision {
|
|
91
|
+
const name = exec.name
|
|
92
|
+
const args = (exec.arguments ?? {}) as Record<string, unknown>
|
|
93
|
+
const runDir = join(worktreeRoot, '.recursive', 'run', activeRunId)
|
|
94
|
+
|
|
95
|
+
// Monotonic lock-order denial (recursive_lock out of order).
|
|
96
|
+
if (LOCK_TOOL_NAMES.has(name)) {
|
|
97
|
+
const artifact = String(args.artifact ?? '')
|
|
98
|
+
if (artifact) {
|
|
99
|
+
const blockers = getPrerequisiteBlockers(runDir, artifact)
|
|
100
|
+
if (blockers.length > 0) {
|
|
101
|
+
const reason = 'monotonic lock-order: ' + blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ')
|
|
102
|
+
return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
|
|
103
|
+
}
|
|
104
|
+
// TDD evidence gating on Phase 3 lock.
|
|
105
|
+
if (artifact === '03-implementation-summary.md') {
|
|
106
|
+
const artifactPath = join(runDir, artifact)
|
|
107
|
+
const content = existsSync(artifactPath) ? readFileSync(artifactPath, 'utf8') : ''
|
|
108
|
+
const tddMode = getMdFieldValue(content, 'TDD Mode') ?? ''
|
|
109
|
+
if (tddMode === 'strict') {
|
|
110
|
+
const hasRed = /RED|red evidence/i.test(content)
|
|
111
|
+
const hasGreen = /GREEN|green evidence/i.test(content)
|
|
112
|
+
if (!hasRed || !hasGreen) {
|
|
113
|
+
const reason = 'TDD Mode: strict requires RED + GREEN evidence before locking Phase 3'
|
|
114
|
+
return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Locked-artifact write denial.
|
|
122
|
+
if (WRITE_TOOL_NAMES.has(name)) {
|
|
123
|
+
const target = firstTargetPath(args)
|
|
124
|
+
if (target) {
|
|
125
|
+
const normalized = target.replace(/\\/g, '/')
|
|
126
|
+
if (normalized.endsWith('.md') && normalized.includes('/.recursive/run/')) {
|
|
127
|
+
const abs = resolveTargetPath(normalized, worktreeRoot)
|
|
128
|
+
if (abs && getLockStatus(abs) === 'LOCKED') {
|
|
129
|
+
const reason = 'locked-artifact write denial: ' + normalized + ' carries Status: LOCKED (reopen explicitly to edit)'
|
|
130
|
+
return mode === 'strict' ? { kind: 'deny', reason } : { kind: 'ask', reason }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { kind: 'allow' }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Resolve a tool-target path to an absolute path under the worktree root. */
|
|
140
|
+
function resolveTargetPath(target: string, worktreeRoot: string): string | null {
|
|
141
|
+
const normalized = target.replace(/\\/g, '/')
|
|
142
|
+
if (isAbsolute(normalized)) {
|
|
143
|
+
const abs = resolve(normalized)
|
|
144
|
+
const rootAbs = resolve(worktreeRoot)
|
|
145
|
+
const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
|
|
146
|
+
if (abs !== rootAbs && !abs.startsWith(rootPrefix)) return null
|
|
147
|
+
return abs
|
|
148
|
+
}
|
|
149
|
+
return resolve(worktreeRoot, normalized.replace(/^\.?\/?/, ''))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Extract the first candidate target path from a tool's arguments. */
|
|
153
|
+
function firstTargetPath(args: Record<string, unknown>): string | null {
|
|
154
|
+
for (const key of ['file_path', 'path', 'command', 'target', 'filePath']) {
|
|
155
|
+
const value = args[key]
|
|
156
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Layer 8 - fs/observed lock-tamper detection.
|
|
163
|
+
* A locked *.md whose observed version differs from the stored LockHash is
|
|
164
|
+
* a tamper. Returns a tamper reason (or null when clean/not-applicable).
|
|
165
|
+
*/
|
|
166
|
+
export function detectTamper(
|
|
167
|
+
targetPath: string,
|
|
168
|
+
worktreeRoot: string,
|
|
169
|
+
activeRunId: string,
|
|
170
|
+
): { runId: string; path: string; reason: string } | null {
|
|
171
|
+
const normalized = targetPath.replace(/\\/g, '/')
|
|
172
|
+
if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return null
|
|
173
|
+
const abs = resolveTargetPath(normalized, worktreeRoot)
|
|
174
|
+
if (!abs || !existsSync(abs)) return null
|
|
175
|
+
const status = getLockStatus(abs)
|
|
176
|
+
if (status === 'STALE_LOCK') {
|
|
177
|
+
return { runId: activeRunId, path: normalized, reason: 'locked artifact hash mismatch (tampered): ' + normalized }
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recursive/* session events (Phase D R1, PROPOSAL 11.2/11.6): the complete
|
|
3
|
+
* durable event vocabulary the projection folds, the board renders, and the
|
|
4
|
+
* conversation node correlates. Every payload carries a non-empty
|
|
5
|
+
* { runId, worktreeRoot } so the whole plane is worktree-aware and the
|
|
6
|
+
* projection can group runs by their control-plane root (binding
|
|
7
|
+
* workspace-scoping invariant, run 03 R1).
|
|
8
|
+
*
|
|
9
|
+
* Events are LOG-ONLY (no live mirror): the projection unit (projection.ts)
|
|
10
|
+
* is the only derived view. Payloads are plain JSON and declared on the
|
|
11
|
+
* SessionEventMap merge via @deepseek-ai/dsh-session/types.
|
|
12
|
+
*
|
|
13
|
+
* The pure constructor helpers below enforce the runId + non-empty
|
|
14
|
+
* worktreeRoot invariant at emit time, so a malformed event fails loud at
|
|
15
|
+
* the emit site instead of silently landing cross-workspace.
|
|
16
|
+
*/
|
|
17
|
+
import type { RecursiveRunState } from './types.ts'
|
|
18
|
+
|
|
19
|
+
/** Declare the recursive/* event family on the session event map (merge-extensible). */
|
|
20
|
+
declare module '@deepseek-ai/dsh-session/types' {
|
|
21
|
+
interface SessionEventMap {
|
|
22
|
+
/**
|
|
23
|
+
* A transition the lifecycle driver is ABOUT to validate/commit (logged
|
|
24
|
+
* before any file write). The pre-step gate reads this as the caller of the
|
|
25
|
+
* transition set — never a projection event (log-only internal signal).
|
|
26
|
+
*/
|
|
27
|
+
'recursive/phase-intent': {
|
|
28
|
+
runId: string
|
|
29
|
+
worktreeRoot: string
|
|
30
|
+
targetArtifact: string
|
|
31
|
+
kind: 'lock' | 'reopen' | 'advance'
|
|
32
|
+
evidence?: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
/** A run was created/bootstrapped in this workspace. */
|
|
35
|
+
'recursive/run-created': {
|
|
36
|
+
runId: string
|
|
37
|
+
worktreeRoot: string
|
|
38
|
+
template?: string
|
|
39
|
+
repo?: string
|
|
40
|
+
}
|
|
41
|
+
/** A phase transition committed (validate -> commit -> flush -> emit). */
|
|
42
|
+
'recursive/phase': {
|
|
43
|
+
runId: string
|
|
44
|
+
worktreeRoot: string
|
|
45
|
+
phase: string
|
|
46
|
+
status: string
|
|
47
|
+
}
|
|
48
|
+
/** A phase artifact was locked (lock hash + timestamp recorded). */
|
|
49
|
+
'recursive/phase-locked': {
|
|
50
|
+
runId: string
|
|
51
|
+
worktreeRoot: string
|
|
52
|
+
phase: string
|
|
53
|
+
lockedAt: string
|
|
54
|
+
lockHash: string
|
|
55
|
+
}
|
|
56
|
+
/** A transition's gate predicate failed (rejected in strict, warned in advisory). */
|
|
57
|
+
'recursive/gate-blocked': {
|
|
58
|
+
runId: string
|
|
59
|
+
worktreeRoot: string
|
|
60
|
+
phase: string
|
|
61
|
+
failures: string[]
|
|
62
|
+
kind: string
|
|
63
|
+
}
|
|
64
|
+
/** fs/observed detected a change to a locked artifact outside a tool call. */
|
|
65
|
+
'recursive/tamper': {
|
|
66
|
+
runId: string
|
|
67
|
+
worktreeRoot: string
|
|
68
|
+
path: string
|
|
69
|
+
reason: string
|
|
70
|
+
}
|
|
71
|
+
/** A run was merged back to the repo root (conversation history re-keys). */
|
|
72
|
+
'recursive/run-merged': {
|
|
73
|
+
runId: string
|
|
74
|
+
worktreeRoot: string
|
|
75
|
+
repoRoot: string
|
|
76
|
+
}
|
|
77
|
+
/** Run-level lifecycle flag (active/paused/blocked/complete + reason). */
|
|
78
|
+
'recursive/run-state': {
|
|
79
|
+
runId: string
|
|
80
|
+
worktreeRoot: string
|
|
81
|
+
state: RecursiveRunState
|
|
82
|
+
reason?: string
|
|
83
|
+
}
|
|
84
|
+
/** A subagent started for this run. */
|
|
85
|
+
'recursive/subagent-start': {
|
|
86
|
+
runId: string
|
|
87
|
+
worktreeRoot: string
|
|
88
|
+
childId: string
|
|
89
|
+
role: string
|
|
90
|
+
provider: string
|
|
91
|
+
}
|
|
92
|
+
/** A subagent finished for this run. */
|
|
93
|
+
'recursive/subagent-end': {
|
|
94
|
+
runId: string
|
|
95
|
+
worktreeRoot: string
|
|
96
|
+
childId: string
|
|
97
|
+
role: string
|
|
98
|
+
provider: string
|
|
99
|
+
status: 'running' | 'done' | 'failed'
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Enforce the runId + non-empty worktreeRoot invariant at emit time. */
|
|
105
|
+
export function requireRunKey(runId: string, worktreeRoot: string): void {
|
|
106
|
+
if (typeof runId !== 'string' || runId.trim() === '') {
|
|
107
|
+
throw new Error('recursive event requires a non-empty runId')
|
|
108
|
+
}
|
|
109
|
+
if (typeof worktreeRoot !== 'string' || worktreeRoot.trim() === '') {
|
|
110
|
+
throw new Error('recursive event requires a non-empty worktreeRoot')
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface PhaseIntentInput { runId: string; worktreeRoot: string; targetArtifact: string; kind: 'lock' | 'reopen' | 'advance'; evidence?: Record<string, unknown> }
|
|
115
|
+
export interface RunCreatedInput { runId: string; worktreeRoot: string; template?: string; repo?: string }
|
|
116
|
+
export interface PhaseInput { runId: string; worktreeRoot: string; phase: string; status: string }
|
|
117
|
+
export interface PhaseLockedInput { runId: string; worktreeRoot: string; phase: string; lockedAt: string; lockHash: string }
|
|
118
|
+
export interface GateBlockedInput { runId: string; worktreeRoot: string; phase: string; failures: string[]; kind: string }
|
|
119
|
+
export interface TamperInput { runId: string; worktreeRoot: string; path: string; reason: string }
|
|
120
|
+
export interface RunMergedInput { runId: string; worktreeRoot: string; repoRoot: string }
|
|
121
|
+
export interface RunStateInput { runId: string; worktreeRoot: string; state: RecursiveRunState; reason?: string }
|
|
122
|
+
export interface SubagentInput { runId: string; worktreeRoot: string; childId: string; role: string; provider: string }
|
|
123
|
+
export interface SubagentEndInput extends SubagentInput { status: 'running' | 'done' | 'failed' }
|
|
124
|
+
|
|
125
|
+
export function phaseIntent(data: PhaseIntentInput): PhaseIntentInput {
|
|
126
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
127
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, targetArtifact: data.targetArtifact, kind: data.kind, ...(data.evidence === undefined ? {} : { evidence: data.evidence }) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function runCreated(data: RunCreatedInput): RunCreatedInput {
|
|
131
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
132
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, ...(data.template === undefined ? {} : { template: data.template }), ...(data.repo === undefined ? {} : { repo: data.repo }) }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function phase(data: PhaseInput): PhaseInput {
|
|
136
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
137
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, phase: data.phase, status: data.status }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function phaseLocked(data: PhaseLockedInput): PhaseLockedInput {
|
|
141
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
142
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, phase: data.phase, lockedAt: data.lockedAt, lockHash: data.lockHash }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function gateBlocked(data: GateBlockedInput): GateBlockedInput {
|
|
146
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
147
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, phase: data.phase, failures: data.failures, kind: data.kind }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function tamper(data: TamperInput): TamperInput {
|
|
151
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
152
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, path: data.path, reason: data.reason }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function runMerged(data: RunMergedInput): RunMergedInput {
|
|
156
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
157
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, repoRoot: data.repoRoot }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function runState(data: RunStateInput): RunStateInput {
|
|
161
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
162
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, state: data.state, ...(data.reason === undefined ? {} : { reason: data.reason }) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function subagentStart(data: SubagentInput): SubagentInput {
|
|
166
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
167
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, childId: data.childId, role: data.role, provider: data.provider }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function subagentEnd(data: SubagentEndInput): SubagentEndInput {
|
|
171
|
+
requireRunKey(data.runId, data.worktreeRoot)
|
|
172
|
+
return { runId: data.runId, worktreeRoot: data.worktreeRoot, childId: data.childId, role: data.role, provider: data.provider, status: data.status }
|
|
173
|
+
}
|