@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.
Files changed (88) hide show
  1. package/cordis.patch.yml +12 -0
  2. package/lib/bootstrap.d.ts +35 -0
  3. package/lib/client/board.d.ts +10 -0
  4. package/lib/client/contract.d.ts +51 -0
  5. package/lib/client/derive.d.ts +92 -0
  6. package/lib/client/index.d.ts +21 -0
  7. package/lib/client/inspector.d.ts +10 -0
  8. package/lib/client/node.d.ts +71 -0
  9. package/lib/client/settings.d.ts +6 -0
  10. package/lib/client/slots.d.ts +7 -0
  11. package/lib/client/strip.d.ts +7 -0
  12. package/lib/client.d.ts +10 -0
  13. package/lib/client.js +490 -0
  14. package/lib/closeout.d.ts +23 -0
  15. package/lib/commands.d.ts +51 -0
  16. package/lib/delegation.d.ts +92 -0
  17. package/lib/enforcement.d.ts +53 -0
  18. package/lib/events.d.ts +173 -0
  19. package/lib/handoff.d.ts +51 -0
  20. package/lib/index.d.ts +40 -0
  21. package/lib/lifecycle.d.ts +107 -0
  22. package/lib/lock.d.ts +92 -0
  23. package/lib/policy.d.ts +12 -0
  24. package/lib/projection.d.ts +29 -0
  25. package/lib/recursive_closeout.tool.d.ts +8 -0
  26. package/lib/recursive_init.tool.d.ts +2 -0
  27. package/lib/recursive_lint.tool.d.ts +2 -0
  28. package/lib/recursive_lock.tool.d.ts +2 -0
  29. package/lib/recursive_scratch.tool.d.ts +7 -0
  30. package/lib/recursive_status.tool.d.ts +2 -0
  31. package/lib/review.d.ts +39 -0
  32. package/lib/router.d.ts +77 -0
  33. package/lib/run.d.ts +29 -0
  34. package/lib/runtime.d.ts +241 -0
  35. package/lib/scratch.d.ts +18 -0
  36. package/lib/status.d.ts +19 -0
  37. package/lib/types.d.ts +104 -0
  38. package/lib/workspace.d.ts +50 -0
  39. package/package.json +119 -0
  40. package/preset/recursive/agent.cordis.yml +282 -0
  41. package/preset/recursive/preset.yml +3 -0
  42. package/scripts/install-recursive-mode.ps1 +956 -0
  43. package/scripts/install-recursive-mode.py +750 -0
  44. package/scripts/lint-recursive-run.py +2868 -0
  45. package/scripts/recursive-closeout.py +541 -0
  46. package/scripts/recursive-init.py +356 -0
  47. package/scripts/recursive-lock.py +302 -0
  48. package/scripts/recursive-status.py +2124 -0
  49. package/scripts/recursive_phase_rules.py +367 -0
  50. package/scripts/recursive_router_lib.py +2282 -0
  51. package/scripts/test-recursive-mode-smoke.ts +204 -0
  52. package/scripts/verify-locks.py +353 -0
  53. package/src/bootstrap.ts +118 -0
  54. package/src/client/board.tsx +61 -0
  55. package/src/client/contract.ts +58 -0
  56. package/src/client/derive.ts +241 -0
  57. package/src/client/index.ts +28 -0
  58. package/src/client/inspector.tsx +49 -0
  59. package/src/client/node.ts +156 -0
  60. package/src/client/settings.tsx +18 -0
  61. package/src/client/slots.ts +67 -0
  62. package/src/client/strip.tsx +28 -0
  63. package/src/client.ts +11 -0
  64. package/src/closeout.ts +183 -0
  65. package/src/commands.ts +142 -0
  66. package/src/delegation.ts +306 -0
  67. package/src/enforcement.ts +180 -0
  68. package/src/events.ts +173 -0
  69. package/src/handoff.ts +165 -0
  70. package/src/index.ts +283 -0
  71. package/src/lifecycle.ts +235 -0
  72. package/src/lock.ts +369 -0
  73. package/src/policy.ts +56 -0
  74. package/src/projection.ts +237 -0
  75. package/src/recursive_closeout.tool.ts +35 -0
  76. package/src/recursive_init.tool.ts +28 -0
  77. package/src/recursive_lint.tool.ts +29 -0
  78. package/src/recursive_lock.tool.ts +33 -0
  79. package/src/recursive_scratch.tool.ts +42 -0
  80. package/src/recursive_status.tool.ts +24 -0
  81. package/src/review.ts +178 -0
  82. package/src/router.ts +197 -0
  83. package/src/run.ts +85 -0
  84. package/src/runtime.ts +564 -0
  85. package/src/scratch.ts +85 -0
  86. package/src/status.ts +194 -0
  87. package/src/types.ts +112 -0
  88. package/src/workspace.ts +67 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,564 @@
1
+ import { Service, type Context } from '@deepseek-ai/cordis'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { foldRun, resolveRunDir } from './status.ts'
5
+ import {
6
+ getLockStatus,
7
+ getNextLegalPhase,
8
+ getPrerequisiteBlockers,
9
+ getStaleDownstreamPhases,
10
+ invalidateReceipt,
11
+ lockHashFromContent,
12
+ writeReceipt,
13
+ } from './lock.ts'
14
+ import type { RecursiveStatusResult } from './types.ts'
15
+ import { resolveControlPlaneRoot, type WorkspaceRegistryLike } from './workspace.ts'
16
+ import { closeoutPhase } from './closeout.ts'
17
+ import { readScratch, writeScratch, appendScratch, type ScratchTarget } from './scratch.ts'
18
+ import { buildReviewBundle, type ReviewBundleInput } from './review.ts'
19
+ import { createHandoff, createChildBrief, replyPath, childScratchPath, buildDelegationPrompt, type HandoffInput, type ChildBriefInput } from './handoff.ts'
20
+ import { loadRouterPolicy, routerPolicyPath, resolveRole, capabilityProbe, delegationDecisionBasis, type RouterPolicy, type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts'
21
+ import { delegate, validateReferences, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput } from './delegation.ts'
22
+ import { foldRecursivePhase, validateTransition, detectTransitionIntent, LifecycleDriver, coupleGateBlockToGoal, type PhaseTransitionIntent, type SessionEventLike, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts'
23
+ import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluatePreStepGate, evaluateToolGuard, detectTamper, type EnforcementConfig, type PreStepGateDecision, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts'
24
+ import type { Session } from '@deepseek-ai/dsh-session'
25
+ import { runCreated as runCreatedEvent, phase as phaseEvent, phaseLocked as phaseLockedEvent, runState as runStateEvent } from './events.ts'
26
+ import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
27
+ import { foldRecursiveProjection as foldProjectionEvent, emptyRecursiveFoldState, recursiveProjectionUnit, isInsideWorkspace, type RecursiveEventLike, type RecursiveFoldState } from './projection.ts'
28
+
29
+ declare module '@deepseek-ai/cordis' {
30
+ interface Context {
31
+ recursive: RecursiveRuntime
32
+ }
33
+ }
34
+
35
+ export interface LockArtifactResult {
36
+ artifact: string
37
+ runId: string
38
+ status: string
39
+ lockedAt: string | null
40
+ lockHash: string | null
41
+ receipt?: unknown
42
+ blockers: string[]
43
+ }
44
+
45
+ export interface LintArtifactResult {
46
+ artifact: string
47
+ runId: string
48
+ errors: string[]
49
+ warnings: string[]
50
+ passed: boolean
51
+ }
52
+
53
+ const ARTIFACT_STUB = {
54
+ '00-requirements.md': ['Run: ', 'Phase: 0', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
55
+ '00-worktree.md': ['Run: ', 'Phase: 0 (Worktree)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
56
+ '01-as-is.md': ['Run: ', 'Phase: 1 (AS-IS)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
57
+ '02-to-be-plan.md': ['Run: ', 'Phase: 2 (TO-BE Plan)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
58
+ '03-implementation-summary.md': ['Run: ', 'Phase: 3 (Implementation)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
59
+ '04-test-summary.md': ['Run: ', 'Phase: 4 (Test Summary)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
60
+ '05-manual-qa.md': ['Run: ', 'Phase: 5 (Manual QA)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
61
+ '06-decisions-update.md': ['Run: ', 'Phase: 6 (Decisions Update)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
62
+ '07-state-update.md': ['Run: ', 'Phase: 7 (State Update)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
63
+ '08-memory-impact.md': ['Run: ', 'Phase: 8 (Memory Impact)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
64
+ }
65
+
66
+ export class RecursiveRuntime extends Service {
67
+ /** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
68
+
69
+ constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike } = {}) {
70
+ super(ctx, 'recursive')
71
+ this.repoRoot = config.repoRoot ?? process.cwd()
72
+ this.workspaceRegistry = config.workspaceRegistry ?? null
73
+ }
74
+
75
+ private readonly repoRoot: string
76
+ private readonly workspaceRegistry: WorkspaceRegistryLike | null
77
+ private _enforcementConfig: EnforcementConfig | null = null
78
+
79
+ /**
80
+ * Workspace-scoped control-plane root (R1 binding invariant).
81
+ * Resolves the session agent's canonical cwd -> workspace path via the
82
+ * registry; NEVER scans list(). Returns null when unavailable (defer).
83
+ */
84
+ async resolveWorkspaceRoot(agent?: { session?: { header?: { cwd?: string } } } | null): Promise<string | null> {
85
+ return resolveControlPlaneRoot(agent, this.workspaceRegistry)
86
+ }
87
+
88
+ /**
89
+ * Run-scoped closeout receipt scaffold (R2), rooted under the given
90
+ * workspace root. Refuses runIds outside the root (never crosses workspaces).
91
+ */
92
+ closeoutRun(root: string, runId: string, phase: string) {
93
+ const runDir = join(root, '.recursive', 'run', runId)
94
+ const runRoot = join(root, '.recursive', 'run')
95
+ // workspace-scoping guard: the run must be under this root
96
+ if (!runDir.startsWith(runRoot) || !existsSync(runDir)) {
97
+ return { error: 'Run not found in current workspace: ' + runId }
98
+ }
99
+ try {
100
+ const result = closeoutPhase(runDir, phase)
101
+ return { closeoutPhase: phase, runId, ...result }
102
+ } catch (err) {
103
+ return { error: (err as Error).message }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Run-scoped scratchpad access (R5), rooted under the given workspace root.
109
+ */
110
+ scratchRun(root: string, runId: string, action: string, target: ScratchTarget, content?: string) {
111
+ const runDir = join(root, '.recursive', 'run', runId)
112
+ const runRoot = join(root, '.recursive', 'run')
113
+ if (!runDir.startsWith(runRoot) || !existsSync(runDir)) {
114
+ return { error: 'Run not found in current workspace: ' + runId }
115
+ }
116
+ try {
117
+ if (action === 'read') {
118
+ return { runId, target, action, content: readScratch(runDir, target), path: join(runDir, 'scratch', 'scratch.' + target) }
119
+ }
120
+ if (action === 'write') {
121
+ const path = writeScratch(runDir, target, content ?? '')
122
+ return { runId, target, action, path }
123
+ }
124
+ if (action === 'append') {
125
+ const path = appendScratch(runDir, target, content ?? '')
126
+ return { runId, target, action, path }
127
+ }
128
+ return { error: 'Unsupported scratch action: ' + action + ' (expected read|write|append)' }
129
+ } catch (err) {
130
+ return { error: (err as Error).message }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Phase B (native delegation): build a review bundle (R1) + file-backed
136
+ * handoff docs (R2), resolve the role via the router policy (R3), and call
137
+ * ctx.subagents.start() with the full request (R4). Workspace-scoped: every
138
+ * path resolves under the session's control-plane root.
139
+ */
140
+ async delegateReview(input: {
141
+ root: string
142
+ runId: string
143
+ phase: string
144
+ role: string
145
+ delegationId: string
146
+ childId: string
147
+ artifactPath: string
148
+ upstreamArtifacts: string[]
149
+ auditQuestions: string[]
150
+ requiredOutput: string
151
+ codeRefs?: string[]
152
+ changedFiles?: string[]
153
+ diffBasis?: ReviewBundleInput['diffBasis']
154
+ policyPath?: string
155
+ providers?: Record<string, SubagentProviderLike>
156
+ subagents?: SubagentsRuntimeLike
157
+ maxDepth?: number
158
+ toolFilter?: unknown
159
+ }) {
160
+ const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root))
161
+ const providers = input.providers ?? {}
162
+ const decision = resolveRole(input.role, policy, providers)
163
+ const probe = capabilityProbe({ providers, role: input.role, policy })
164
+
165
+ // R1 bundle + R2 handoff/brief/prompt (file-backed context-in contract).
166
+ const bundle = buildReviewBundle({
167
+ root: input.root,
168
+ runId: input.runId,
169
+ phase: input.phase,
170
+ role: input.role,
171
+ artifactPath: input.artifactPath,
172
+ upstreamArtifacts: input.upstreamArtifacts,
173
+ auditQuestions: input.auditQuestions,
174
+ requiredOutput: input.requiredOutput,
175
+ codeRefs: input.codeRefs,
176
+ changedFiles: input.changedFiles,
177
+ diffBasis: input.diffBasis,
178
+ })
179
+ const handoffPath = createHandoff({
180
+ root: input.root,
181
+ runId: input.runId,
182
+ delegationId: input.delegationId,
183
+ role: input.role,
184
+ objective: input.requiredOutput,
185
+ runDocRefs: input.upstreamArtifacts,
186
+ codeRefs: input.codeRefs ?? [],
187
+ auditQuestions: input.auditQuestions,
188
+ requiredOutput: input.requiredOutput,
189
+ decisionBasis: decision.reason,
190
+ constraints: [
191
+ 'Workspace-scoped: never read another workspace\'s .recursive/ tree.',
192
+ 'Optionality: if the probe fails, fall back to self-audit — never weaken the audit.',
193
+ 'Fail loud: capability mismatches reject; do not silently degrade.',
194
+ ],
195
+ })
196
+ const briefPath = createChildBrief({
197
+ root: input.root,
198
+ runId: input.runId,
199
+ delegationId: input.delegationId,
200
+ childId: input.childId,
201
+ slice: 'Perform the delegated ' + input.role + ' for run ' + input.runId + ' (' + input.phase + ') and write your submission to reply.md.',
202
+ })
203
+ const prompt = buildDelegationPrompt({
204
+ root: input.root,
205
+ runId: input.runId,
206
+ delegationId: input.delegationId,
207
+ childId: input.childId,
208
+ handoffPath,
209
+ briefPath,
210
+ })
211
+
212
+ // R4: plugin-driven delegation with the full request shape.
213
+ const request: SubagentStartRequestLike = {
214
+ prompt: [{ type: 'text', text: prompt }],
215
+ label: input.delegationId + '/' + input.childId,
216
+ outputSchema: reviewOutputSchema(),
217
+ toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
218
+ maxDepth: input.maxDepth ?? 2,
219
+ }
220
+
221
+ let result: SubagentResultLike | null = null
222
+ let error: string | null = null
223
+ if (decision.tier === 'native' || decision.tier === 'external-cli') {
224
+ if (!input.subagents) {
225
+ error = 'no ctx.subagents runtime available (self-audit fallback)'
226
+ } else {
227
+ try {
228
+ result = await delegate({
229
+ subagents: input.subagents,
230
+ provider: decision.provider as string,
231
+ request,
232
+ })
233
+ } catch (err) {
234
+ error = (err as Error).message
235
+ }
236
+ }
237
+ } else {
238
+ error = 'delegation resolved to ' + decision.tier + ' (' + decision.reason + ')'
239
+ }
240
+
241
+ const evaluation = result ? evaluateDelegationResult(result) : { accepted: false, reason: error ?? 'no result' }
242
+
243
+ // R6: record the attempt as an action record (accepted only if evaluation passes).
244
+ const actionRecordPath = writeActionRecord({
245
+ root: input.root,
246
+ runId: input.runId,
247
+ subagentId: input.childId,
248
+ phase: input.phase,
249
+ purpose: input.role + ' for run ' + input.runId,
250
+ executionMode: decision.tier,
251
+ artifactPath: input.artifactPath,
252
+ upstreamArtifacts: input.upstreamArtifacts,
253
+ reviewBundle: bundle.repoRelativePath,
254
+ diffBasis: input.diffBasis?.normalizedDiffCommand,
255
+ codeRefs: input.codeRefs,
256
+ auditQuestions: input.auditQuestions,
257
+ findings: evaluation.accepted && result?.structured ? [(result.structured as { verdict?: string })?.verdict ?? 'accepted'] : undefined,
258
+ success: evaluation.accepted,
259
+ stopReason: result?.stopReason,
260
+ })
261
+
262
+ return {
263
+ decision,
264
+ probe,
265
+ bundle,
266
+ handoffPath,
267
+ briefPath,
268
+ replyPath: replyPath({ root: input.root, runId: input.runId, delegationId: input.delegationId, childId: input.childId }),
269
+ childScratchPath: childScratchPath({ root: input.root, runId: input.runId, childId: input.childId }),
270
+ prompt,
271
+ request,
272
+ result,
273
+ evaluation,
274
+ actionRecordPath,
275
+ error,
276
+ }
277
+ }
278
+
279
+ /** R6: validate a child's claimed references against actual files. */
280
+ validateReferences(root: string, references: Reference[]) {
281
+ return validateReferences(root, references)
282
+ }
283
+
284
+ /** R7: probe availability for a role and render the decision basis prose. */
285
+ probeDelegation(root: string, role: string, providers: Record<string, SubagentProviderLike> = {}) {
286
+ const policy = loadRouterPolicy(routerPolicyPath(root))
287
+ const decision = resolveRole(role, policy, providers)
288
+ const probe = capabilityProbe({ providers, role, policy })
289
+ return {
290
+ decision,
291
+ probe,
292
+ basis: delegationDecisionBasis({ role, available: probe.available, provider: probe.provider, fallback: 'self-audit' }),
293
+ }
294
+ }
295
+
296
+ /**
297
+ * B3: per-call workspace root resolution. The control-plane root is the
298
+ * session's cwd (or registry-canonicalized), NEVER process.cwd() — the host
299
+ * checkout is not the run's workspace. Reads resolve under that root only.
300
+ */
301
+ async resolveRootFor(agent?: { session?: { header?: { cwd?: string } } } | null): Promise<string | null> {
302
+ return resolveControlPlaneRoot(agent, this.workspaceRegistry, this.repoRoot)
303
+ }
304
+
305
+ /**
306
+ * Emit a recursive/* event into the session whose control-plane root matches
307
+ * `root` (additive log-only; the projection drives from these). Falls back to
308
+ * the agent's session when passed, else the session store list.
309
+ */
310
+ private emitToSession(root: string, type: string, data: unknown, agent?: { session?: { header?: { cwd?: string } } | null } | null) {
311
+ const sessions = this.ctx.get('sessions') as { list?: () => Session[] } | undefined
312
+ const candidates: Session[] = []
313
+ if (agent?.session) candidates.push(agent.session as unknown as Session)
314
+ const listed = sessions?.list?.() ?? []
315
+ for (const s of listed) candidates.push(s)
316
+ const norm = (x: string) => x.split(String.fromCharCode(92)).join("/")
317
+ const target = candidates.find((s) => {
318
+ const cwd = norm(s.header?.cwd ?? "")
319
+ return cwd === "" || cwd === norm(root) || cwd.startsWith(norm(root) + "/")
320
+ })
321
+ if (!target) return
322
+ try { (target.append as unknown as (t: string, d: unknown) => void)(type, data) } catch { /* log-only */ }
323
+ }
324
+
325
+ async status(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<RecursiveStatusResult | null> {
326
+ const root = await this.resolveRootFor(agent)
327
+ if (!root) return null
328
+ const resolved = resolveRunDir(root, runId)
329
+ if (!resolved) return null
330
+ return foldRun(resolved.runDir, resolved.runId)
331
+ }
332
+
333
+ /**
334
+ * Scaffold a run directory with stub artifact headers (no-op if exists).
335
+ * Returns the run dir + created artifacts.
336
+ */
337
+ async initRun(runId: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<{ runDir: string; runId: string; created: string[]; existing: string[] }> {
338
+ const root = await this.resolveRootFor(agent)
339
+ if (!root) throw new Error('cannot resolve workspace control-plane root for this session')
340
+ const runDir = join(root, '.recursive', 'run', runId)
341
+ mkdirSync(runDir, { recursive: true })
342
+ const created: string[] = []
343
+ const existing: string[] = []
344
+ for (const [file, fields] of Object.entries(ARTIFACT_STUB)) {
345
+ const path = join(runDir, file)
346
+ if (existsSync(path)) {
347
+ existing.push(file)
348
+ continue
349
+ }
350
+ const header = fields.map((f, i) => (i === 0 ? f + runId : f)).join('\n')
351
+ writeFileSync(path, '# ' + file.replace(/\.md$/, '') + ' — ' + file + '\n\n' + header + '\n', 'utf8')
352
+ created.push(file)
353
+ }
354
+ // B2: a run's files first exist here — the run-created event feeds the
355
+ // projection's worktree-grouped card. Emit only when the scaffold is fresh
356
+ // (created.length > 0) so resume/ensure does not double-log.
357
+ if (created.length > 0) {
358
+ this.emitToSession(root, 'recursive/run-created', runCreatedEvent({ runId, worktreeRoot: root }), agent)
359
+ this.emitToSession(root, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: 'new' }), agent)
360
+ }
361
+ return { runDir, runId, created, existing }
362
+ }
363
+
364
+ /**
365
+ * Lock a DRAFT artifact (or reopen a LOCKED one). Validates prerequisites;
366
+ * writes Status/LockedAt/LockHash + receipt. Returns the lock result.
367
+ */
368
+ async lockArtifact(runId: string, artifact: string, reopen = false, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<LockArtifactResult> {
369
+ const root = await this.resolveRootFor(agent)
370
+ if (!root) throw new Error('cannot resolve workspace control-plane root for this session')
371
+ const runDir = join(root, '.recursive', 'run', runId)
372
+ const artifactPath = join(runDir, artifact)
373
+ if (reopen) {
374
+ return this.reopenArtifact(root, runDir, runId, artifact, artifactPath, agent)
375
+ }
376
+ if (!existsSync(artifactPath)) throw new Error('Artifact not found: ' + artifact)
377
+ const status = getLockStatus(artifactPath)
378
+ if (status === 'LOCKED') throw new Error('Artifact already LOCKED: ' + artifact)
379
+ // B2: log the phase-intent BEFORE validation so the pre-step gate reads it
380
+ // (the caller of the transition set). Never a projection event.
381
+ this.emitToSession(root, 'recursive/phase-intent', { runId, worktreeRoot: root, targetArtifact: artifact, kind: 'lock' }, agent)
382
+ const blockers = getPrerequisiteBlockers(runDir, artifact)
383
+ if (blockers.length > 0) {
384
+ throw new Error('Prerequisite blockers: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', '))
385
+ }
386
+ let content = readFileSync(artifactPath, 'utf8')
387
+ const lockedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
388
+ content = setOrInsertField(content, 'Status', 'LOCKED', ['Phase'])
389
+ content = setOrInsertField(content, 'LockedAt', lockedAt, ['Status'])
390
+ const provisional = setOrInsertField(content, 'LockHash', '0'.repeat(64), ['LockedAt', 'Status'])
391
+ const lockHash = lockHashFromContent(provisional)
392
+ content = setOrInsertField(content, 'LockHash', lockHash, ['LockedAt', 'Status'])
393
+ writeFileSync(artifactPath, content, 'utf8')
394
+ const receipt = writeReceipt(runDir, artifact, artifactPath)
395
+ // B2: the commit is durable — emit phase + phase-locked (the projection's
396
+ // last-wins phase chain + lock badge + truncated hash).
397
+ this.emitToSession(root, 'recursive/phase', phaseEvent({ runId, worktreeRoot: root, phase: artifact, status: 'LOCKED' }), agent)
398
+ this.emitToSession(root, 'recursive/phase-locked', phaseLockedEvent({ runId, worktreeRoot: root, phase: artifact, lockedAt, lockHash }), agent)
399
+ this.emitToSession(root, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: 'active' }), agent)
400
+ return {
401
+ artifact,
402
+ runId,
403
+ status: 'LOCKED',
404
+ lockedAt,
405
+ lockHash,
406
+ receipt,
407
+ blockers: [],
408
+ }
409
+ }
410
+
411
+ /** Reopen a LOCKED artifact to DRAFT (delete LockedAt/LockHash, invalidate downstream receipts). */
412
+ private reopenArtifact(root: string, runDir: string, runId: string, artifact: string, artifactPath: string, agent?: { session?: { header?: { cwd?: string } } } | null): LockArtifactResult {
413
+ if (!existsSync(artifactPath)) throw new Error('Artifact not found: ' + artifact)
414
+ let content = readFileSync(artifactPath, 'utf8')
415
+ content = content.replace(/^[ \t]*Status:.*$/m, 'Status: `DRAFT`')
416
+ content = content.replace(/^[ \t]*LockedAt:.*\n?/m, '')
417
+ content = content.replace(/^[ \t]*LockHash:.*\n?/m, '')
418
+ writeFileSync(artifactPath, content, 'utf8')
419
+ const hadReceipt = invalidateReceipt(runDir, artifact)
420
+ const stale = getStaleDownstreamPhases(runDir, artifact)
421
+ for (const entry of stale) invalidateReceipt(runDir, entry.artifact)
422
+ // B2: reopen reverts to DRAFT — log phase-intent + emit the transition so
423
+ // the pre-step gate and projection both see the reverted state.
424
+ this.emitToSession(root, 'recursive/phase-intent', { runId, worktreeRoot: root, targetArtifact: artifact, kind: 'reopen' }, agent)
425
+ this.emitToSession(root, 'recursive/phase', phaseEvent({ runId, worktreeRoot: root, phase: artifact, status: 'DRAFT' }), agent)
426
+ this.emitToSession(root, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: 'active', reason: 'reopened ' + artifact }), agent)
427
+ return {
428
+ artifact,
429
+ runId,
430
+ status: 'DRAFT',
431
+ lockedAt: null,
432
+ lockHash: null,
433
+ blockers: hadReceipt || stale.length > 0 ? ['Downstream receipts invalidated'] : [],
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Lint an artifact for phase-specific issues. Minimal structural checks:
439
+ * gates present, TODO section, Status field, LockHash consistency.
440
+ */
441
+ async lintArtifact(runId: string, artifact?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<LintArtifactResult> {
442
+ const root = await this.resolveRootFor(agent)
443
+ if (!root) throw new Error('cannot resolve workspace control-plane root for this session')
444
+ const runDir = join(root, '.recursive', 'run', runId)
445
+ const target = artifact ?? getNextLegalPhase(runDir) ?? '00-requirements.md'
446
+ const artifactPath = join(runDir, target)
447
+ if (!existsSync(artifactPath)) {
448
+ return { artifact: target, runId, errors: ['Artifact not found'], warnings: [], passed: false }
449
+ }
450
+ const content = readFileSync(artifactPath, 'utf8')
451
+ const errors: string[] = []
452
+ const warnings: string[] = []
453
+ const status = getLockStatus(artifactPath)
454
+ if (status === 'MISSING') errors.push('File missing')
455
+ else if (status === 'DRAFT') warnings.push('Artifact is DRAFT (not locked)')
456
+ else if (status === 'STALE_LOCK') errors.push('LockHash mismatch or missing lock fields')
457
+ if (!/^[ \t]*## TODO[ \t]*$/m.test(content)) errors.push('Missing ## TODO section')
458
+ for (const gate of ['Coverage', 'Approval']) {
459
+ if (!new RegExp('^[ \\t]*' + gate + ':\\s*(PASS|FAIL)\\s*$', 'm').test(content)) warnings.push('Missing ' + gate + ' gate')
460
+ }
461
+ if (target === '03-implementation-summary.md' && !new RegExp('^[ \\t]*TDD Compliance:\\s*(PASS|FAIL)\\s*$', 'm').test(content)) {
462
+ warnings.push('Missing TDD Compliance gate')
463
+ }
464
+ return { artifact: target, runId, errors, warnings, passed: errors.length === 0 }
465
+ }
466
+
467
+ /** Phase C R1/R2: fold the log into phase state; validate a transition intent. */
468
+ foldPhase(events: readonly SessionEventLike[]): RecursivePhaseState | null {
469
+ return foldRecursivePhase(events)
470
+ }
471
+
472
+ validateTransition(intent: PhaseTransitionIntent): GateCheckResult {
473
+ return validateTransition(intent)
474
+ }
475
+
476
+ detectTransitionIntent(events: readonly SessionEventLike[]): PhaseTransitionIntent | null {
477
+ return detectTransitionIntent(events)
478
+ }
479
+
480
+ /** Phase C R3: Layer 1 pre-step gate decision (caller of the transition set). */
481
+ gatePreStep(events: readonly SessionEventLike[], config?: EnforcementConfig): PreStepGateDecision {
482
+ const mode = (config ?? this.enforcementConfig).preStep
483
+ return evaluatePreStepGate(events, mode)
484
+ }
485
+
486
+ /** Phase C R4: Layer 2 tool guard decision (caller of the transition set). */
487
+ guardTool(exec: ToolExecLike, root: string, runId: string, config?: EnforcementConfig): ToolGuardDecision {
488
+ const mode = (config ?? this.enforcementConfig).toolGuards
489
+ return evaluateToolGuard(exec, root, runId, mode)
490
+ }
491
+
492
+ /** Phase C R8: fs/observed tamper detection. */
493
+ detectTamper(targetPath: string, root: string, runId: string) {
494
+ return detectTamper(targetPath, root, runId)
495
+ }
496
+
497
+ /** Phase C R5: render the current-phase policy contract. */
498
+ renderPolicy(root: string, runId: string, folded: RecursivePhaseState | null): string {
499
+ return renderRecursivePolicy({ worktreeRoot: root, runId, folded, config: this.enforcementConfig } as PolicyContext)
500
+ }
501
+
502
+ /** Phase C R6: couple a gate-block to the goal service (graceful no-op). */
503
+ coupleGateBlockToGoal(goalService: unknown, agent: unknown, ref: unknown, reason: { code: string; message: string }): boolean {
504
+ return coupleGateBlockToGoal(goalService as never, agent, ref, reason)
505
+ }
506
+
507
+ /** Phase D R1: append a recursive/* event through the host session adapter (worktree-keyed). */
508
+ emitRecursiveEvent(session: unknown, type: string, data: Record<string, unknown>): void {
509
+ const s = session as { append?: (type: string, data: unknown) => void } | null | undefined
510
+ if (s && typeof s.append === 'function') {
511
+ s.append(type, data)
512
+ }
513
+ }
514
+
515
+ /** Phase D R2/R9: fold a session log into the worktree-grouped projection. */
516
+ foldRecursiveProjection(events: readonly RecursiveEventLike[], workspaceRoot = ''): RecursiveFoldState {
517
+ let state = emptyRecursiveFoldState(workspaceRoot)
518
+ for (const event of events) state = foldProjectionEvent(state, event)
519
+ return state
520
+ }
521
+
522
+ /** Phase D R2: the registered projection unit (key 'recursive'). */
523
+ get projectionUnit() {
524
+ return recursiveProjectionUnit
525
+ }
526
+
527
+ /** Phase D R9: whether a worktreeRoot is inside the workspace control-plane root. */
528
+ isInsideWorkspace(worktreeRoot: string, workspaceRoot: string): boolean {
529
+ return isInsideWorkspace(worktreeRoot, workspaceRoot)
530
+ }
531
+
532
+ /** Phase C R7: resolve the enforcement config (strict|advisory, default advisory). */
533
+ get enforcementConfig(): EnforcementConfig {
534
+ return this._enforcementConfig ?? DEFAULT_ENFORCEMENT
535
+ }
536
+
537
+ setEnforcementConfig(config: unknown): EnforcementConfig {
538
+ this._enforcementConfig = resolveEnforcementConfig(config)
539
+ return this._enforcementConfig
540
+ }
541
+ }
542
+
543
+ /** set_or_insert_field: replace first field occurrence, else insert after the last listed after-field. */
544
+ function setOrInsertField(content: string, fieldName: string, value: string, afterFields: string[]): string {
545
+ const line = fieldName + ': `' + value + '`'
546
+ const fieldRe = new RegExp('^[ \\t]*(?:[-*][ \\t]+)?' + escapeRegExp(fieldName) + ':\\s*.*$', 'm')
547
+ if (fieldRe.test(content)) {
548
+ return content.replace(fieldRe, line)
549
+ }
550
+ const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
551
+ let insertAt = 0
552
+ for (const afterField of afterFields) {
553
+ const afterRe = new RegExp('^[ \\t]*(?:[-*][ \\t]+)?' + escapeRegExp(afterField) + ':\\s*.*$', 'm')
554
+ for (let i = 0; i < lines.length; i++) {
555
+ if (afterRe.test(lines[i])) insertAt = Math.max(insertAt, i + 1)
556
+ }
557
+ }
558
+ lines.splice(insertAt, 0, line)
559
+ return lines.join('\n')
560
+ }
561
+
562
+ function escapeRegExp(s: string): string {
563
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
564
+ }
package/src/scratch.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Run-scoped disposable scratchpad (R5). Lives at <run-dir>/scratch/scratch.md
3
+ * and <run-dir>/scratch/scratch.ts, is git-ignored, and is NEVER citable as an
4
+ * Input in phase docs (enforced by the recursive:policy + lint, Phase C).
5
+ * The plugin never creates a run implicitly: writing scratch requires an
6
+ * existing run directory.
7
+ *
8
+ * Phase B R5 adds child-scoped scratch (PROPOSAL 10.7): each delegated child
9
+ * gets its OWN disposable scratch under <run-dir>/scratch/<child-id>.md, while
10
+ * the parent's scratch.md stays the main agent's working memory. A child MAY
11
+ * read the parent scratch when the prompt includes it, but writes go only to
12
+ * the child's own file.
13
+ */
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
15
+ import { join, resolve, sep } from 'node:path'
16
+
17
+ export type ScratchTarget = 'md' | 'ts'
18
+
19
+ export function scratchPathFor(runDir: string, target: ScratchTarget): string {
20
+ if (target !== 'md' && target !== 'ts') {
21
+ throw new Error('Unsupported scratch target: ' + String(target) + ' (expected md or ts)')
22
+ }
23
+ return join(runDir, 'scratch', 'scratch.' + target)
24
+ }
25
+
26
+ function assertRunExists(runDir: string): void {
27
+ if (!existsSync(runDir)) {
28
+ throw new Error('Run directory does not exist: ' + runDir + ' (scratch never creates runs)')
29
+ }
30
+ }
31
+
32
+ export function readScratch(runDir: string, target: ScratchTarget): string {
33
+ assertRunExists(runDir)
34
+ const path = scratchPathFor(runDir, target)
35
+ if (!existsSync(path)) return ''
36
+ return readFileSync(path, 'utf8')
37
+ }
38
+
39
+ export function writeScratch(runDir: string, target: ScratchTarget, content: string): string {
40
+ assertRunExists(runDir)
41
+ const path = scratchPathFor(runDir, target)
42
+ mkdirSync(join(runDir, 'scratch'), { recursive: true })
43
+ writeFileSync(path, content, 'utf8')
44
+ return path
45
+ }
46
+
47
+ export function appendScratch(runDir: string, target: ScratchTarget, content: string): string {
48
+ assertRunExists(runDir)
49
+ const path = scratchPathFor(runDir, target)
50
+ mkdirSync(join(runDir, 'scratch'), { recursive: true })
51
+ appendFileSync(path, content + '\n', 'utf8')
52
+ return path
53
+ }
54
+
55
+ /**
56
+ * Child-scoped scratch path (Phase B R5): <run-dir>/scratch/<child-id>.md.
57
+ * Resolved under the run dir; a child id escaping the scratch dir is rejected.
58
+ */
59
+ export function childScratchPath(runDir: string, childId: string): string {
60
+ assertRunExists(runDir)
61
+ const scratchDir = resolve(runDir, 'scratch')
62
+ const candidate = resolve(scratchDir, childId + '.md')
63
+ const prefix = scratchDir.endsWith(sep) ? scratchDir : scratchDir + sep
64
+ if (!candidate.startsWith(prefix)) {
65
+ throw new Error('Child scratch escapes the run scratch dir: ' + childId)
66
+ }
67
+ return candidate
68
+ }
69
+
70
+ /** Read-only access to the PARENT's scratch (the main agent's working memory). */
71
+ export function readParentScratch(runDir: string, target: ScratchTarget = 'md'): string {
72
+ return readScratch(runDir, target)
73
+ }
74
+
75
+ /**
76
+ * Write ONLY the child's own scratch file. Refuses any target outside
77
+ * <run-dir>/scratch/<child-id>.md (a child cannot overwrite the parent's
78
+ * scratch.md through this writer).
79
+ */
80
+ export function writeChildScratch(runDir: string, childId: string, content: string): string {
81
+ const path = childScratchPath(runDir, childId)
82
+ mkdirSync(join(runDir, 'scratch'), { recursive: true })
83
+ writeFileSync(path, content, 'utf8')
84
+ return path
85
+ }