@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/handoff.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * File-backed delegation handoff docs (Phase B R2, PROPOSAL 10.6): a
3
+ * main-agent handoff.md plus per-child brief.md / reply.md / scratch.md. The
4
+ * delegation prompt is reference-based (pointers, not full context), so the
5
+ * child reads the files it needs rather than receiving a monolithic paste.
6
+ *
7
+ * Workspace-scoped (run 03 R1): every path resolves under the given root.
8
+ */
9
+ import { mkdirSync, writeFileSync } from 'node:fs'
10
+ import { join, resolve, sep } from 'node:path'
11
+
12
+ export interface HandoffInput {
13
+ root: string
14
+ runId: string
15
+ delegationId: string
16
+ role: string
17
+ objective: string
18
+ runDocRefs: string[]
19
+ codeRefs: string[]
20
+ auditQuestions: string[]
21
+ requiredOutput: string
22
+ decisionBasis: string
23
+ constraints?: string[]
24
+ }
25
+
26
+ export interface ChildBriefInput {
27
+ root: string
28
+ runId: string
29
+ delegationId: string
30
+ childId: string
31
+ slice: string
32
+ replyContract?: string
33
+ }
34
+
35
+ function norm(repoRelative: string): string {
36
+ return repoRelative.replace(/\\/g, '/').replace(/^\/+/, '')
37
+ }
38
+
39
+ function resolveUnderRoot(root: string, repoRelative: string): string {
40
+ const normalized = norm(repoRelative)
41
+ const rootAbs = resolve(root)
42
+ const abs = resolve(rootAbs, normalized)
43
+ const rootPrefix = rootAbs.endsWith(sep) ? rootAbs : rootAbs + sep
44
+ if (abs !== rootAbs && !abs.startsWith(rootPrefix)) {
45
+ throw new Error('Path escapes the workspace root: ' + repoRelative)
46
+ }
47
+ return abs
48
+ }
49
+
50
+ function subagentsDir(root: string, runId: string): string {
51
+ return resolveUnderRoot(root, '.recursive/run/' + runId + '/subagents')
52
+ }
53
+
54
+ function bulletList(title: string, values: string[]): string[] {
55
+ const lines: string[] = [title]
56
+ if (!values.length) {
57
+ lines.push('- none')
58
+ return lines
59
+ }
60
+ for (const v of values) lines.push('- ' + String.fromCharCode(96) + norm(v) + String.fromCharCode(96))
61
+ return lines
62
+ }
63
+
64
+ /** Main-agent handoff doc: the full delegation info for one delegation. */
65
+ export function createHandoff(input: HandoffInput): string {
66
+ const { root, runId, delegationId, role, objective } = input
67
+ const dir = join(subagentsDir(root, runId), delegationId)
68
+ mkdirSync(dir, { recursive: true })
69
+ const path = join(dir, 'handoff.md')
70
+
71
+ const lines: string[] = [
72
+ '# Delegation handoff: ' + delegationId,
73
+ '',
74
+ 'Role: ' + role,
75
+ 'Objective: ' + objective,
76
+ '',
77
+ '## Run Document References',
78
+ ...bulletList('', input.runDocRefs).slice(1),
79
+ '',
80
+ '## Code References (with line ranges where applicable)',
81
+ ...bulletList('', input.codeRefs).slice(1),
82
+ '',
83
+ '## Audit Questions',
84
+ ...(input.auditQuestions.length ? input.auditQuestions.map((q, i) => String(i + 1) + '. ' + q) : ['- none']),
85
+ '',
86
+ '## Required Output Shape',
87
+ '',
88
+ input.requiredOutput,
89
+ '',
90
+ '## Constraints',
91
+ ...(input.constraints?.length ? input.constraints.map((c) => '- ' + c) : ['- none']),
92
+ '',
93
+ '## Delegation Decision Basis',
94
+ '',
95
+ input.decisionBasis,
96
+ '',
97
+ ]
98
+
99
+ writeFileSync(path, lines.join('\n'), 'utf8')
100
+ return path
101
+ }
102
+
103
+ /** Per-child brief: the receiving slice for one child. */
104
+ export function createChildBrief(input: ChildBriefInput): string {
105
+ const { root, runId, delegationId, childId, slice } = input
106
+ const dir = join(subagentsDir(root, runId), delegationId, 'child-' + childId)
107
+ mkdirSync(dir, { recursive: true })
108
+ const path = join(dir, 'brief.md')
109
+ const rootNorm = resolve(root).replace(/\\/g, '/')
110
+
111
+ const lines: string[] = [
112
+ '# Child brief: ' + childId,
113
+ '',
114
+ 'Delegation: ' + delegationId,
115
+ 'Reply file: ' + replyPath(input).replace(/\\/g, '/').replace(rootNorm + '/', ''),
116
+ '',
117
+ '## Your Slice',
118
+ '',
119
+ slice,
120
+ '',
121
+ '## Reply Contract',
122
+ '',
123
+ input.replyContract ?? 'Write your submission to reply.md, then call the report tool citing reply.md.',
124
+ '',
125
+ ]
126
+
127
+ writeFileSync(path, lines.join('\n'), 'utf8')
128
+ return path
129
+ }
130
+
131
+ /** Reply path the child must write its submission to. */
132
+ export function replyPath(input: { root: string; runId: string; delegationId: string; childId: string }): string {
133
+ const { root, runId, delegationId, childId } = input
134
+ return join(subagentsDir(root, runId), delegationId, 'child-' + childId, 'reply.md')
135
+ }
136
+
137
+ /** Child-scoped disposable scratch (Phase B R5, PROPOSAL 10.7). */
138
+ export function childScratchPath(input: { root: string; runId: string; childId: string }): string {
139
+ const { root, runId, childId } = input
140
+ return resolveUnderRoot(root, '.recursive/run/' + runId + '/scratch/' + childId + '.md')
141
+ }
142
+
143
+ /**
144
+ * Build the reference-based delegation prompt (PROPOSAL 10.6): a pointer to
145
+ * handoff.md + brief.md, instructions to write reply.md and call report citing
146
+ * it. Short, not a monolithic paste.
147
+ */
148
+ export function buildDelegationPrompt(input: { root: string; runId: string; delegationId: string; childId: string; handoffPath: string; briefPath: string }): string {
149
+ const { root, runId, delegationId, childId, handoffPath, briefPath } = input
150
+ const rootNorm = resolve(root).replace(/\\/g, '/')
151
+ const handoffRel = norm(handoffPath).replace(rootNorm + '/', '')
152
+ const briefRel = norm(briefPath).replace(rootNorm + '/', '')
153
+ const replyRel = norm(replyPath(input)).replace(rootNorm + '/', '')
154
+ return [
155
+ 'You are a delegated worker for recursive-mode delegation ' + delegationId + ' (child ' + childId + ').',
156
+ '',
157
+ '1. Read the full delegation handoff: ' + handoffRel,
158
+ '2. Read your receiving slice: ' + briefRel,
159
+ '3. Do the work described in your slice, reading the referenced run docs and code files as needed.',
160
+ '4. Write your complete submission to: ' + replyRel,
161
+ '5. Call the report tool citing ' + replyRel + ' and include your verdict, findings, and every reference you used.',
162
+ '',
163
+ 'Do not modify files outside your child scratch and reply.md unless the handoff explicitly authorizes it.',
164
+ ].join('\n')
165
+ }
package/src/index.ts ADDED
@@ -0,0 +1,283 @@
1
+ import { type Context } from '@deepseek-ai/cordis'
2
+ import type { Session } from '@deepseek-ai/dsh-session'
3
+ import { RecursiveRuntime } from './runtime.ts'
4
+ import { createRecursiveStatusTool } from './recursive_status.tool.ts'
5
+ import { createRecursiveInitTool } from './recursive_init.tool.ts'
6
+ import { createRecursiveLockTool } from './recursive_lock.tool.ts'
7
+ import { createRecursiveLintTool } from './recursive_lint.tool.ts'
8
+ import { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
9
+ import { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
10
+ import { registerRecursiveCommand } from './commands.ts'
11
+ import { stageBWorkflowInit } from './bootstrap.ts'
12
+ import { evaluatePreStepGate, evaluateToolGuard, detectTamper, resolveEnforcementConfig, DEFAULT_ENFORCEMENT, type EnforcementConfig } from './enforcement.ts'
13
+ import { detectTransitionIntent } from './lifecycle.ts'
14
+ import { renderRecursivePolicy } from './policy.ts'
15
+ import {
16
+ gateBlocked as gateBlockedEvent,
17
+ tamper as tamperEvent,
18
+ phaseIntent as phaseIntentEvent,
19
+ runCreated as runCreatedEvent,
20
+ phase as phaseEvent,
21
+ phaseLocked as phaseLockedEvent,
22
+ runState as runStateEvent,
23
+ runMerged as runMergedEvent,
24
+ subagentStart as subagentStartEvent,
25
+ subagentEnd as subagentEndEvent,
26
+ } from './events.ts'
27
+ import { recursiveProjectionUnit } from './projection.ts'
28
+
29
+ export const name = '@try-works/dsh-recursive-mode'
30
+
31
+ export { RecursiveRuntime } from './runtime.ts'
32
+ export { createRecursiveStatusTool } from './recursive_status.tool.ts'
33
+ export { createRecursiveInitTool } from './recursive_init.tool.ts'
34
+ export { createRecursiveLockTool } from './recursive_lock.tool.ts'
35
+ export { createRecursiveLintTool } from './recursive_lint.tool.ts'
36
+ export { createRecursiveCloseoutTool } from './recursive_closeout.tool.ts'
37
+ export { createRecursiveScratchTool } from './recursive_scratch.tool.ts'
38
+ export * from './status.ts'
39
+ export {
40
+ PHASE_SEQUENCE,
41
+ OPTIONAL_PHASES,
42
+ normalizeForLockHash,
43
+ lockHashFromContent,
44
+ phaseIndex,
45
+ isCoreArtifact,
46
+ getPrerequisites,
47
+ getLockStatus,
48
+ getPrerequisiteBlockers,
49
+ receiptPath,
50
+ readReceipt,
51
+ writeReceipt,
52
+ invalidateReceipt,
53
+ getStaleDownstreamPhases,
54
+ getNextLegalPhase,
55
+ getAllStaleReceipts,
56
+ validateChain,
57
+ } from './lock.ts'
58
+ export type {
59
+ LockReceipt,
60
+ LockStatus,
61
+ PrerequisiteBlocker,
62
+ StaleDownstream,
63
+ ChainPhaseResult,
64
+ LockChainResult,
65
+ } from './lock.ts'
66
+ export * from './run.ts'
67
+ export * from './review.ts'
68
+ export * from './handoff.ts'
69
+ export * from './router.ts'
70
+ export * from './delegation.ts'
71
+ export * from './lifecycle.ts'
72
+ export * from './enforcement.ts'
73
+ export * from './policy.ts'
74
+ export * from './events.ts'
75
+ export * from './projection.ts'
76
+
77
+ /**
78
+ * Bundle plugin entry. The Loader activates this row once `tools` is available
79
+ * (`inject` below); the RecursiveRuntime service is constructed directly so it
80
+ * is provided on `ctx.recursive` for the lifetime of this fiber, and the
81
+ * read-path tools (status/init/lock/lint) are registered through it (R2/R4).
82
+ */
83
+ export const inject = ['tools']
84
+
85
+ /**
86
+ * Two-stage init (PROPOSAL §5.10):
87
+ * - Stage A (mount-time, this apply): register the isolated ctx.recursive
88
+ * service + the recursive_* tools + the /recursive command + the
89
+ * recursive:policy prompt section. No repo/run work here.
90
+ * - Stage B (agent/session-start): resolve the session's workspace root (R1),
91
+ * bootstrap the scaffold if missing, enumerate runs as dir names only, and
92
+ * inject a workspace-scoped notice. See stageBWorkflowInit + the
93
+ * agent/session-start listener below.
94
+ */
95
+ export function apply(ctx: Context) {
96
+ ctx.effect(function* () {
97
+ // Workspace registry: optional host service (durable). Access via ctx.get —
98
+ // property access requires inject and would fail boot when undeclared.
99
+ // Resolve the control-plane root strictly from the session agent's cwd.
100
+ const workspaceRegistry = ctx.get('workspaceRegistry') as never
101
+ const recursive = new RecursiveRuntime(ctx, { repoRoot: process.cwd(), workspaceRegistry })
102
+
103
+ /** Typed agent payload shape (session is a full Session). */
104
+ type AgentLike = { session?: Session | null }
105
+
106
+ /** Extract the session from a listener payload. */
107
+ const sessionOf = (p: unknown): Session | null => {
108
+ const a = (p as { agent?: AgentLike | null } | null)?.agent
109
+ return a?.session ?? null
110
+ }
111
+
112
+ /** Resolve the session's control-plane root (registry first, cwd fallback). */
113
+ const rootOf = async (p: unknown): Promise<string> => {
114
+ const a = (p as { agent?: AgentLike | null } | null)?.agent
115
+ const root = await recursive.resolveRootFor(a as { session?: { header?: { cwd?: string } } } | null)
116
+ return root ?? a?.session?.header.cwd ?? process.cwd()
117
+ }
118
+
119
+ /** Emit one recursive/* event into the session log (log-only). */
120
+ const emit = (session: Session | null, type: string, data: unknown) => {
121
+ if (!session) return
122
+ try { (session.append as unknown as (t: string, d: unknown) => void)(type, data) } catch { /* log-only; never fail the gate on an emit error */ }
123
+ }
124
+ const disposers = [
125
+ ctx.tools.register(createRecursiveStatusTool(recursive)),
126
+ ctx.tools.register(createRecursiveInitTool(recursive)),
127
+ ctx.tools.register(createRecursiveLockTool(recursive)),
128
+ ctx.tools.register(createRecursiveLintTool(recursive)),
129
+ ctx.tools.register(createRecursiveCloseoutTool(recursive)),
130
+ ctx.tools.register(createRecursiveScratchTool(recursive)),
131
+ ]
132
+
133
+ // /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
134
+ const commands = ctx.get('commands') as { register: (def: unknown) => () => void } | undefined
135
+ if (commands) {
136
+ disposers.push(registerRecursiveCommand({ commands } as never, recursive))
137
+ }
138
+
139
+ // recursive:policy prompt section (Phase C R5): workspace-scoped behavior +
140
+ // current-phase contract rendered from folded state + enforcement config.
141
+ const systemPrompt = ctx.get('systemPrompt') as { section: (def: unknown) => () => void } | undefined
142
+ if (systemPrompt) {
143
+ disposers.push(systemPrompt.section({
144
+ name: 'recursive:policy',
145
+ order: 55,
146
+ text: (context: unknown) => {
147
+ const agent = (context as { agent?: { session?: { header?: { cwd?: string }; events?: { type: string; data?: Record<string, unknown> }[] } } } | undefined)?.agent
148
+ if (!agent) return ''
149
+ const cwd = agent.session?.header?.cwd ?? ''
150
+ if (!cwd) return ''
151
+ const events = agent.session?.events ?? []
152
+ const intent = detectTransitionIntent(events as never)
153
+ if (!intent || intent.worktreeRoot === '') return ''
154
+ return renderRecursivePolicy({ worktreeRoot: intent.worktreeRoot, runId: intent.runId, folded: recursive.foldPhase(events as never), config: recursive.enforcementConfig })
155
+ },
156
+ }))
157
+ }
158
+
159
+ // Phase C R3: agent/pre-step phase-transition gate (Layer 1, caller of the
160
+ // lifecycle transition set). Reads on transition intent only; rejects (strict)
161
+ // or warns (advisory) when the target gates fail.
162
+ const agentEvents = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
163
+ if (agentEvents.on) {
164
+ disposers.push(agentEvents.on('agent/pre-step', (payload, next) => {
165
+ const p = payload as { agent?: { session?: { events?: { type: string; data?: Record<string, unknown> }[] } } } | null
166
+ const events = p?.agent?.session?.events ?? []
167
+ const session = sessionOf(p)
168
+ const intent = recursive.detectTransitionIntent(events as never)
169
+ const decision = evaluatePreStepGate(events as never, recursive.enforcementConfig.preStep)
170
+ void (async () => {
171
+ // Emit a committed run-state (blocked) when a gate rejects the intent —
172
+ // the board then shows the run blocked, not merely a log message.
173
+ if (decision.gateBlocked && intent?.runId) {
174
+ const worktreeRoot = intent?.worktreeRoot ?? (await rootOf(p))
175
+ emit(session, 'recursive/gate-blocked', gateBlockedEvent({ runId: intent.runId, worktreeRoot, phase: intent?.targetArtifact ?? '', failures: decision.failures, kind: 'pre-step' }))
176
+ emit(session, 'recursive/run-state', runStateEvent({ runId: intent.runId, worktreeRoot, state: 'blocked', reason: 'gate-blocked: ' + decision.failures.join('; ') }))
177
+ }
178
+ })()
179
+ if (decision.kind === 'reject') return { kind: 'reject' }
180
+ return typeof next === 'function' ? next() : { kind: 'enter', messages: [] }
181
+ }))
182
+ }
183
+
184
+ // Phase C R4: tools/pre-execute surgical guards (Layer 2, caller of the
185
+ // transition set). Scope-filtered to the active run's worktree.
186
+ const toolRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown, next?: unknown) => unknown) => () => void }
187
+ if (toolRuntime.on) {
188
+ disposers.push(toolRuntime.on('tools/pre-execute', (payload, next) => {
189
+ const exec = payload as { name?: string; arguments?: unknown; agent?: { session?: { header?: { cwd?: string } } } | null } | null
190
+ if (!exec?.name) return typeof next === 'function' ? next() : { kind: 'allow' }
191
+ // B3: per-call root is the session cwd (authoritative when the registry is
192
+ // absent), never process.cwd().
193
+ const root = exec?.agent?.session?.header?.cwd ?? ''
194
+ const decision = evaluateToolGuard(exec as never, root, '', recursive.enforcementConfig.toolGuards)
195
+ if (decision.kind === 'allow') return typeof next === 'function' ? next() : { kind: 'allow' }
196
+ if (decision.kind === 'deny') return decision
197
+ // ask -> in advisory we let it through with a warn (the approval seam is Phase D)
198
+ return typeof next === 'function' ? next() : { kind: 'allow' }
199
+ }))
200
+ }
201
+
202
+ // Phase C R8 + Phase D R1: fs/observed lock-tamper warnings -> recursive/tamper event.
203
+ const fsRuntime = ctx as unknown as { on?: (event: string, listener: (payload: unknown) => void) => () => void }
204
+ if (fsRuntime.on) {
205
+ disposers.push(fsRuntime.on('fs/observed', (payload) => {
206
+ const p = payload as { target?: { displayPath?: string }; agent?: AgentLike | null } | null
207
+ const path = p?.target?.displayPath ?? ''
208
+ if (!path) return
209
+ // B3: tamper detection is scoped to the SESSION's control-plane root,
210
+ // not the host checkout.
211
+ const sessionCwd = p?.agent?.session?.header.cwd ?? ''
212
+ const tamper = detectTamper(path, sessionCwd, '')
213
+ if (!tamper) return
214
+ // Resolve the worktree root from the tampered path (strip /.recursive/run/<id>/).
215
+ const normalized = path.replace(/\\/g, '/')
216
+ const marker = '/.recursive/run/'
217
+ const idx = normalized.indexOf(marker)
218
+ const worktreeRoot = idx >= 0 ? normalized.slice(0, idx) : sessionCwd
219
+ // Prefer the observing session's own log (additive); fall back to the
220
+ // session store list only when the payload carries no session.
221
+ const session = sessionOf(p) ?? null
222
+ if (session) {
223
+ emit(session, 'recursive/tamper', tamperEvent({ runId: tamper.runId, worktreeRoot, path: tamper.path, reason: tamper.reason }))
224
+ return
225
+ }
226
+ const sessions = ctx.get('sessions') as { list?: () => Session[] } | undefined
227
+ const match = sessions?.list?.().find((s) => {
228
+ const cwd = (s.header?.cwd ?? '').replace(/\\/g, '/')
229
+ return cwd === '' || cwd === worktreeRoot || cwd.startsWith(worktreeRoot + '/')
230
+ })
231
+ if (match) emit(match, 'recursive/tamper', tamperEvent({ runId: tamper.runId, worktreeRoot, path: tamper.path, reason: tamper.reason }))
232
+ }))
233
+ }
234
+
235
+ // Phase D R2: register the recursive projection unit (activates only when a
236
+ // projection registry is composed — headless assemblies stay unaffected).
237
+ // The unit's cell is per-session: a session's own log is its workspace, so
238
+ // an accept-all init is scoped by the session boundary (see projection.ts
239
+ // emptyRecursiveFoldState). The registry (if present) is cached here so the
240
+ // session-start boundary can re-read run state after the projection drove.
241
+ let sessionProjections: { snapshot?: (session: Session) => { values: Partial<Record<string, unknown>> } } | null = null
242
+ ctx.inject(['sessionProjections'], (projectionCtx) => {
243
+ const registry = (projectionCtx as { sessionProjections?: { register: (def: unknown) => () => void; snapshot?: (session: Session) => unknown } }).sessionProjections
244
+ if (registry?.register) {
245
+ disposers.push(registry.register(recursiveProjectionUnit))
246
+ sessionProjections = registry as { snapshot?: (session: Session) => { values: Partial<Record<string, unknown>> } }
247
+ }
248
+ })
249
+
250
+ // Stage B (R6): agent/session-start workflow init, workspace-scoped.
251
+ // NEW sessions emit recursive/run-state('active') after init; resumed
252
+ // sessions replay the projection (already folded from the persisted log) —
253
+ // no duplicate run-created. run-created is emitted by the lifecycle at the
254
+ // init/lock boundary (the only place a run's files first exist).
255
+ const events = ctx as unknown as { on?: (event: string, listener: (payload: unknown) => void) => () => void }
256
+ if (events.on) {
257
+ disposers.push(events.on('agent/session-start', (payload) => {
258
+ const p = payload as { agent?: AgentLike | null; source?: 'new' | 'resume' }
259
+ const session = sessionOf(p)
260
+ const cwd = p?.agent?.session?.header?.cwd
261
+ if (!cwd) return
262
+ void (async () => {
263
+ const root = await recursive.resolveRootFor(p?.agent as { session?: { header?: { cwd?: string } } } | null)
264
+ if (!root) return
265
+ const result = stageBWorkflowInit({ root, source: p.source === 'resume' ? 'resume' : 'new' })
266
+ // Emit run-state for the active run (from the projected wire value, the
267
+ // one same-source view) when the projection registry is present.
268
+ if (session) {
269
+ const runId = result.activeRunId ?? result.runs[result.runs.length - 1] ?? ''
270
+ if (runId) {
271
+ const snap = sessionProjections?.snapshot?.(session)
272
+ const proj = (snap?.values as { recursive?: Record<string, Record<string, { state?: string }>> } | undefined)?.recursive
273
+ const state = proj?.[root]?.[runId]?.state ?? 'active'
274
+ emit(session, 'recursive/run-state', runStateEvent({ runId, worktreeRoot: root, state: state as 'new' | 'active' | 'paused' | 'blocked' | 'complete' }))
275
+ }
276
+ }
277
+ })()
278
+ }))
279
+ }
280
+
281
+ yield () => { for (const d of disposers) d() }
282
+ })
283
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Run state machine + serialized transition driver + recursive/* events
3
+ * (Phase C R1/R2/R6, PROPOSAL 8.8).
4
+ *
5
+ * Authority is TRANSITIONS AND EVENTS ONLY - this module never stores run
6
+ * state in a second place. It reconciles the file tree via the existing
7
+ * read path (run.ts/status.ts/lock.ts), validates the target phase's gates
8
+ * (PROPOSAL 8.4), delegates the artifact write to lock.ts (canonical
9
+ * lock-hash + monotonic chain preserved), and emits the recursive/* events.
10
+ * State is DERIVED on every transition; resume/fork/session-restart
11
+ * reconstruct identical state by re-reading files + folding the session log.
12
+ */
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { getPrerequisiteBlockers, getLockStatus } from './lock.ts'
16
+ import { getMdFieldValue, getGateStatus } from './status.ts'
17
+
18
+ /** Run-level durable states (PROPOSAL 8.8). */
19
+ export const RUN_STATES = ['new', 'active', 'paused', 'blocked', 'complete'] as const
20
+ export type RunState = (typeof RUN_STATES)[number]
21
+
22
+ /** A proposed phase transition the gates validate before any file write. */
23
+ export interface PhaseTransitionIntent {
24
+ runId: string
25
+ worktreeRoot: string
26
+ targetArtifact: string
27
+ kind: 'lock' | 'reopen' | 'advance'
28
+ evidence?: {
29
+ tddMode?: string
30
+ redEvidencePath?: string
31
+ greenEvidencePath?: string
32
+ qaMode?: string
33
+ qaSignOff?: boolean
34
+ }
35
+ }
36
+
37
+ /** Folded phase state (last-wins over recursive/phase + recursive/run-state). */
38
+ export interface RecursivePhaseState {
39
+ runId: string
40
+ phase: string
41
+ status: string
42
+ runState: RunState
43
+ }
44
+
45
+ /** A session-event-like carrier (the pure fold reads events by shape). */
46
+ export interface SessionEventLike {
47
+ type: string
48
+ data?: Record<string, unknown>
49
+ }
50
+
51
+ /** Gate check result - the transition set's single output. */
52
+ export interface GateCheckResult {
53
+ passed: boolean
54
+ failures: string[]
55
+ }
56
+
57
+ /**
58
+ * Payload shapes for the recursive/* events. These are LEGACY structural
59
+ * views kept for internal consumers; the single source of truth for the
60
+ * emitted payloads is events.ts (every event carries { runId, worktreeRoot }).
61
+ * Aligned here so no local interface drifts out of the worktree-keyed
62
+ * invariant (B7).
63
+ */
64
+ export interface RecursivePhaseEvent { runId: string; worktreeRoot: string; phase: string; status: string }
65
+ export interface RecursiveRunStateEvent { runId: string; worktreeRoot: string; state: RunState; reason?: string }
66
+ export interface RecursiveGateBlockedEvent { runId: string; worktreeRoot: string; phase: string; failures: string[]; kind: string }
67
+ export interface RecursiveTamperEvent { runId: string; worktreeRoot: string; path: string; reason: string }
68
+ export interface RecursiveTransitionFailedEvent { runId: string; worktreeRoot: string; phase: string; error: string }
69
+
70
+ /** The audited phase files whose lock requires Audit: PASS (parity with status.ts). */
71
+ const AUDITED_PHASE_FILES = new Set([
72
+ '01-as-is.md', '01.5-root-cause.md', '02-to-be-plan.md', '03-implementation-summary.md',
73
+ '03.5-code-review.md', '04-test-summary.md', '06-decisions-update.md', '07-state-update.md', '08-memory-impact.md',
74
+ ])
75
+
76
+ /**
77
+ * Pure last-wins fold over the recursive/* events (the foldPlanMode pattern).
78
+ * A log with no recursive/phase folds to null; the last recursive/run-state
79
+ * wins for the run-level flag.
80
+ */
81
+ export function foldRecursivePhase(events: readonly SessionEventLike[], end = events.length): RecursivePhaseState | null {
82
+ let runId = ''
83
+ let phaseName = ''
84
+ let status = ''
85
+ let runState: RunState = 'active'
86
+ let seen = false
87
+ let index = 0
88
+ for (const event of events) {
89
+ if (index >= end) break
90
+ index++
91
+ if (event.type === 'recursive/phase') {
92
+ const d = event.data ?? {}
93
+ runId = String(d.runId ?? runId)
94
+ phaseName = String(d.phase ?? phaseName)
95
+ status = String(d.status ?? status)
96
+ seen = true
97
+ } else if (event.type === 'recursive/run-state') {
98
+ const d = event.data ?? {}
99
+ runId = String(d.runId ?? runId)
100
+ runState = asRunState(d.state)
101
+ seen = true
102
+ }
103
+ }
104
+ return seen ? { runId, phase: phaseName, status, runState } : null
105
+ }
106
+
107
+ function asRunState(value: unknown): RunState {
108
+ return RUN_STATES.includes(value as RunState) ? (value as RunState) : 'active'
109
+ }
110
+
111
+ /** Whether the session log holds an opened turn without its closing turn/end. */
112
+ export function hasOpenTurn(events: readonly SessionEventLike[]): boolean {
113
+ let open = false
114
+ for (const event of events) {
115
+ if (event.type === 'turn/start') open = true
116
+ else if (event.type === 'turn/end') open = false
117
+ }
118
+ return open
119
+ }
120
+
121
+ /** Detect a pending transition intent from the session log (the lock tool logs it). */
122
+ export function detectTransitionIntent(events: readonly SessionEventLike[]): PhaseTransitionIntent | null {
123
+ let intent: PhaseTransitionIntent | null = null
124
+ for (const event of events) {
125
+ if (event.type === 'recursive/phase-intent') {
126
+ const d = event.data ?? {}
127
+ intent = {
128
+ runId: String(d.runId ?? ''),
129
+ worktreeRoot: String(d.worktreeRoot ?? ''),
130
+ targetArtifact: String(d.targetArtifact ?? ''),
131
+ kind: (d.kind === 'reopen' || d.kind === 'advance' ? d.kind : 'lock'),
132
+ evidence: d.evidence as PhaseTransitionIntent['evidence'],
133
+ }
134
+ }
135
+ }
136
+ return intent
137
+ }
138
+
139
+ /**
140
+ * Validate a proposed transition against the target phase's gates (PROPOSAL 8.4).
141
+ * Pure: reads the current file tree + lock.ts chain; writes nothing.
142
+ */
143
+ export function validateTransition(intent: PhaseTransitionIntent): GateCheckResult {
144
+ const failures: string[] = []
145
+ const runDir = join(intent.worktreeRoot, '.recursive', 'run', intent.runId)
146
+ const artifactPath = join(runDir, intent.targetArtifact)
147
+
148
+ if (intent.kind === 'reopen') {
149
+ const status = getLockStatus(artifactPath)
150
+ if (status !== 'LOCKED') failures.push('reopen requires a LOCKED artifact (current: ' + status + ')')
151
+ return { passed: failures.length === 0, failures }
152
+ }
153
+
154
+ // 1. Phase doc exists.
155
+ if (!existsSync(artifactPath)) {
156
+ failures.push('phase doc does not exist: ' + intent.targetArtifact)
157
+ return { passed: false, failures }
158
+ }
159
+
160
+ const content = readFileSync(artifactPath, 'utf8')
161
+
162
+ // 2. Monotonic gating (parity with recursive-lock.py).
163
+ const blockers = getPrerequisiteBlockers(runDir, intent.targetArtifact)
164
+ for (const b of blockers) failures.push('unlocked prerequisite: ' + b.artifact + ' (' + b.status + ')')
165
+
166
+ // 3. TDD evidence (Phase 3).
167
+ if (intent.targetArtifact === '03-implementation-summary.md') {
168
+ const tddMode = getMdFieldValue(content, 'TDD Mode') ?? intent.evidence?.tddMode ?? ''
169
+ if (tddMode === 'strict') {
170
+ const red = intent.evidence?.redEvidencePath ?? ''
171
+ const green = intent.evidence?.greenEvidencePath ?? ''
172
+ if (!red || !existsSync(join(runDir, red))) failures.push('TDD Mode: strict requires a RED evidence path')
173
+ if (!green || !existsSync(join(runDir, green))) failures.push('TDD Mode: strict requires a GREEN evidence path')
174
+ } else if (tddMode === 'pragmatic') {
175
+ if (!/rationale/i.test(content)) failures.push('TDD Mode: pragmatic requires an exception rationale')
176
+ } else {
177
+ failures.push('TDD Mode must be declared strict|pragmatic')
178
+ }
179
+ }
180
+
181
+ // 4. Audit closed (audited phases).
182
+ if (AUDITED_PHASE_FILES.has(intent.targetArtifact)) {
183
+ if (getGateStatus(content, 'Audit') !== 'PASS') failures.push('audited phase must end with Audit: PASS')
184
+ if (!/Requirement Completion Status/.test(content)) failures.push('audited phase requires a Requirement Completion Status section')
185
+ if (!/Delegation Decision Basis|Delegation Override Reason|Subagent Capability Probe/.test(content)) {
186
+ failures.push('audited phase requires Delegation Decision Basis / Subagent Capability Probe')
187
+ }
188
+ }
189
+
190
+ // 5. QA sign-off (Phase 5).
191
+ if (intent.targetArtifact === '05-manual-qa.md') {
192
+ const qaMode = getMdFieldValue(content, 'QA Execution Mode') ?? intent.evidence?.qaMode ?? ''
193
+ if (!qaMode) failures.push('QA Execution Mode must be declared human|agent-operated|hybrid')
194
+ else if ((qaMode === 'human' || qaMode === 'hybrid') && intent.evidence?.qaSignOff !== true) {
195
+ failures.push('QA Execution Mode ' + qaMode + ' requires user sign-off')
196
+ }
197
+ }
198
+
199
+ // 6. Effective inputs re-read.
200
+ if (!/Effective Inputs Re-read/.test(content)) failures.push('phase doc must re-read effective inputs (## Effective Inputs Re-read)')
201
+
202
+ return { passed: failures.length === 0, failures }
203
+ }
204
+
205
+ /**
206
+ * A serialized per-run transition driver (coalesced - the single-reservation
207
+ * pattern). Two concurrent 'lock Phase 3' intents queue; the second observes
208
+ * the first's committed state instead of racing the write.
209
+ */
210
+ export class LifecycleDriver {
211
+ private readonly drivers = new Map<string, Promise<void>>()
212
+
213
+ /** Run one transition serially per runId. */
214
+ serialize(runId: string, run: () => Promise<void>): Promise<void> {
215
+ const previous = this.drivers.get(runId) ?? Promise.resolve()
216
+ const next = previous.then(run, run)
217
+ this.drivers.set(runId, next.then(() => undefined, () => undefined))
218
+ return next
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Couple a gate-block to the goal service (PROPOSAL 8.4 goal integration).
224
+ * Graceful no-op when the goal service or agent is unavailable.
225
+ */
226
+ export function coupleGateBlockToGoal(
227
+ goalService: { block?: (agent: unknown, ref: unknown, reason: unknown) => unknown } | null | undefined,
228
+ agent: unknown,
229
+ ref: unknown,
230
+ reason: { code: string; message: string },
231
+ ): boolean {
232
+ if (!goalService || typeof goalService.block !== 'function' || !agent) return false
233
+ goalService.block(agent, ref, reason)
234
+ return true
235
+ }