@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/lock.ts ADDED
@@ -0,0 +1,369 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * Lock-hash + lock-chain validation for recursive-mode runs.
7
+ *
8
+ * Ports recursive-lock.py / verify-locks.py / recursive_phase_rules.py
9
+ * semantics (byte-for-byte where observable):
10
+ * - lockHashFromContent: LF-normalize, strip LockHash lines, SHA-256 hex
11
+ * - getLockStatus: MISSING / DRAFT / STALE_LOCK / LOCKED
12
+ * - getPrerequisites / getPrerequisiteBlockers: sequence ordering
13
+ * - validateChain: per-phase validity + break + next legal phase
14
+ * - receipts: read/write/invalidate with Python-identical JSON
15
+ *
16
+ * R3 (run 02).
17
+ */
18
+
19
+ export const PHASE_SEQUENCE = [
20
+ '00-requirements.md',
21
+ '00-worktree.md',
22
+ '01-as-is.md',
23
+ '01.5-root-cause.md',
24
+ '02-to-be-plan.md',
25
+ '03-implementation-summary.md',
26
+ '03.5-code-review.md',
27
+ '04-test-summary.md',
28
+ '05-manual-qa.md',
29
+ '06-decisions-update.md',
30
+ '07-state-update.md',
31
+ '08-memory-impact.md',
32
+ ] as const
33
+
34
+ export const OPTIONAL_PHASES = new Set([
35
+ '01-as-is.md',
36
+ '01.5-root-cause.md',
37
+ '02-to-be-plan.md',
38
+ '03-implementation-summary.md',
39
+ '03.5-code-review.md',
40
+ '04-test-summary.md',
41
+ '05-manual-qa.md',
42
+ ])
43
+
44
+ const LOCK_HASH_LINE_RE = /^[ \t]*LockHash:.*(?:\n|$)/gm
45
+ const STATUS_RE = new RegExp('^[ \\t]*Status:\\s*(?:`|")?(\\w+)(?:`|")?\\s*$', 'm')
46
+ const LOCK_HASH_RE = new RegExp('^[ \\t]*LockHash:\\s*(?:`|")?([a-fA-F0-9]{64})(?:`|")?\\s*$', 'm')
47
+ const LOCKED_AT_RE = new RegExp('^[ \\t]*LockedAt:\\s*(?:`|")?([^`"\\r\\n]+)(?:`|")?\\s*$', 'm')
48
+
49
+ export interface LockReceipt {
50
+ artifact: string
51
+ artifact_path: string
52
+ artifact_hash: string
53
+ locked_at: string
54
+ prerequisite_hashes: Record<string, string>
55
+ previous_receipt_hash: string | null
56
+ receipt_hash: string
57
+ }
58
+
59
+ export type LockStatus = 'MISSING' | 'DRAFT' | 'STALE_LOCK' | 'LOCKED'
60
+
61
+ export interface PrerequisiteBlocker {
62
+ artifact: string
63
+ status: string
64
+ path: string
65
+ }
66
+
67
+ export interface StaleDownstream {
68
+ artifact: string
69
+ reason: string
70
+ }
71
+
72
+ export interface ChainPhaseResult {
73
+ file: string
74
+ status: LockStatus
75
+ lockValid: boolean
76
+ lockProblems: string[]
77
+ }
78
+
79
+ export interface LockChainResult {
80
+ runId: string
81
+ phases: ChainPhaseResult[]
82
+ breakPhase: string | null
83
+ nextLegalPhase: string | null
84
+ complete: boolean
85
+ staleReceipts: StaleDownstream[]
86
+ }
87
+
88
+ /** LF-normalize, strip every LockHash: line, return the normalized content. */
89
+ export function normalizeForLockHash(content: string): string {
90
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
91
+ return normalized.replace(LOCK_HASH_LINE_RE, '')
92
+ }
93
+
94
+ /** SHA-256 hex over the UTF-8 bytes of the normalized content. */
95
+ export function lockHashFromContent(content: string): string {
96
+ const normalized = normalizeForLockHash(content)
97
+ return createHash('sha256').update(normalized, 'utf8').digest('hex')
98
+ }
99
+
100
+ export function phaseIndex(artifactFile: string): number {
101
+ return PHASE_SEQUENCE.indexOf(artifactFile as (typeof PHASE_SEQUENCE)[number])
102
+ }
103
+
104
+ export function isCoreArtifact(artifactFile: string): boolean {
105
+ return phaseIndex(artifactFile) >= 0
106
+ }
107
+
108
+ /**
109
+ * Every earlier phase in PHASE_SEQUENCE that exists on disk.
110
+ * Mirrors: [phase for phase in PHASE_SEQUENCE[:idx] if (run_dir / phase).exists()]
111
+ */
112
+ export function getPrerequisites(runDir: string, artifactFile: string): string[] {
113
+ const idx = phaseIndex(artifactFile)
114
+ if (idx <= 0) return []
115
+ const prereqs: string[] = []
116
+ for (const phase of PHASE_SEQUENCE.slice(0, idx)) {
117
+ if (existsSync(join(runDir, phase))) prereqs.push(phase)
118
+ }
119
+ return prereqs
120
+ }
121
+
122
+ /**
123
+ * Canonical lock-validity classifier:
124
+ * MISSING (no file) / DRAFT (not LOCKED) / STALE_LOCK (missing or mismatched hash fields) / LOCKED.
125
+ */
126
+ export function getLockStatus(artifactPath: string): LockStatus {
127
+ if (!existsSync(artifactPath)) return 'MISSING'
128
+ let content: string
129
+ try { content = readFileSync(artifactPath, 'utf8') } catch { return 'MISSING' }
130
+ const statusMatch = STATUS_RE.exec(content)
131
+ if (!statusMatch || statusMatch[1] !== 'LOCKED') return 'DRAFT'
132
+ const hashMatch = LOCK_HASH_RE.exec(content)
133
+ const lockedAtMatch = LOCKED_AT_RE.exec(content)
134
+ if (!hashMatch || !lockedAtMatch) return 'STALE_LOCK'
135
+ const storedHash = hashMatch[1].toLowerCase()
136
+ const actualHash = lockHashFromContent(content)
137
+ if (storedHash !== actualHash) return 'STALE_LOCK'
138
+ return 'LOCKED'
139
+ }
140
+
141
+ /** Every prerequisite whose status is not LOCKED, in PHASE_SEQUENCE order. */
142
+ export function getPrerequisiteBlockers(runDir: string, artifactFile: string): PrerequisiteBlocker[] {
143
+ const blockers: PrerequisiteBlocker[] = []
144
+ for (const prereq of getPrerequisites(runDir, artifactFile)) {
145
+ const status = getLockStatus(join(runDir, prereq))
146
+ if (status !== 'LOCKED') {
147
+ blockers.push({ artifact: prereq, status, path: join(runDir, prereq) })
148
+ }
149
+ }
150
+ return blockers
151
+ }
152
+
153
+ export function receiptPath(runDir: string, artifactFile: string): string {
154
+ const stem = artifactFile.replace(/\.md$/, '')
155
+ return join(runDir, 'locks', stem + '.receipt.json')
156
+ }
157
+
158
+ export function readReceipt(runDir: string, artifactFile: string): LockReceipt | null {
159
+ const rpath = receiptPath(runDir, artifactFile)
160
+ if (!existsSync(rpath)) return null
161
+ try {
162
+ return JSON.parse(readFileSync(rpath, 'utf8')) as LockReceipt
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Write a lock receipt with Python-identical JSON semantics.
170
+ */
171
+ export function writeReceipt(runDir: string, artifactFile: string, artifactPath: string): LockReceipt {
172
+ const content = readFileSync(artifactPath, 'utf8')
173
+ const artifactHash = lockHashFromContent(content)
174
+
175
+ const prereqHashes: Record<string, string> = {}
176
+ for (const prereq of getPrerequisites(runDir, artifactFile)) {
177
+ const prereqPath = join(runDir, prereq)
178
+ if (existsSync(prereqPath)) {
179
+ const prereqStatus = getLockStatus(prereqPath)
180
+ if (prereqStatus !== 'LOCKED') {
181
+ throw new Error(`Cannot write receipt for '${artifactFile}': prerequisite '${prereq}' is not LOCKED (status: ${prereqStatus})`)
182
+ }
183
+ prereqHashes[prereq] = lockHashFromContent(readFileSync(prereqPath, 'utf8'))
184
+ }
185
+ }
186
+
187
+ const existing = readReceipt(runDir, artifactFile)
188
+ const prevReceiptHash: string | null = existing ? existing.receipt_hash : null
189
+
190
+ const now = new Date()
191
+ const lockedAt = now.toISOString().replace(/\.\d{3}Z$/, 'Z')
192
+
193
+ const base: Omit<LockReceipt, 'receipt_hash'> = {
194
+ artifact: artifactFile,
195
+ artifact_path: artifactPath,
196
+ artifact_hash: artifactHash,
197
+ locked_at: lockedAt,
198
+ prerequisite_hashes: prereqHashes,
199
+ previous_receipt_hash: prevReceiptHash,
200
+ }
201
+
202
+ // Python json.dumps(sort_keys=True) compact serialization:
203
+ // keys sorted lexicographically, separators (', ', ': '), ensure_ascii.
204
+ const receiptHash = createHash('sha256').update(pythonJsonDumps(base), 'utf8').digest('hex')
205
+
206
+ const receipt: LockReceipt = { ...base, receipt_hash: receiptHash }
207
+
208
+ const locksDir = join(runDir, 'locks')
209
+ mkdirSync(locksDir, { recursive: true })
210
+ const rpath = receiptPath(runDir, artifactFile)
211
+ writeFileSync(rpath, pythonJsonDumpsIndent(receipt), 'utf8')
212
+ return receipt
213
+ }
214
+
215
+ export function invalidateReceipt(runDir: string, artifactFile: string): boolean {
216
+ const rpath = receiptPath(runDir, artifactFile)
217
+ if (existsSync(rpath)) {
218
+ rmSync(rpath, { force: true })
219
+ return true
220
+ }
221
+ return false
222
+ }
223
+
224
+ /**
225
+ * Downstream phases (strictly after artifactFile in PHASE_SEQUENCE) whose
226
+ * receipt's prerequisite_hashes[artifactFile] differs from the current hash
227
+ * (or the artifact no longer exists).
228
+ */
229
+ export function getStaleDownstreamPhases(runDir: string, artifactFile: string): StaleDownstream[] {
230
+ const idx = phaseIndex(artifactFile)
231
+ if (idx < 0) return []
232
+ let currentHash: string | null = null
233
+ const artifactPath = join(runDir, artifactFile)
234
+ if (existsSync(artifactPath)) {
235
+ currentHash = lockHashFromContent(readFileSync(artifactPath, 'utf8'))
236
+ }
237
+ const stale: StaleDownstream[] = []
238
+ for (const downstream of PHASE_SEQUENCE.slice(idx + 1)) {
239
+ const receipt = readReceipt(runDir, downstream)
240
+ if (!receipt) continue
241
+ const prereqHashes = receipt.prerequisite_hashes ?? {}
242
+ if (!(artifactFile in prereqHashes)) continue
243
+ const storedHash = prereqHashes[artifactFile]
244
+ if (currentHash === null || storedHash !== currentHash) {
245
+ stale.push({ artifact: downstream, reason: `prerequisite '${artifactFile}' hash changed` })
246
+ }
247
+ }
248
+ return stale
249
+ }
250
+
251
+ export function getNextLegalPhase(runDir: string): string | null {
252
+ for (const phase of PHASE_SEQUENCE) {
253
+ const phasePath = join(runDir, phase)
254
+ const status = getLockStatus(phasePath)
255
+ if (status === 'LOCKED') continue
256
+ if (!existsSync(phasePath) && OPTIONAL_PHASES.has(phase)) continue
257
+ if (getPrerequisiteBlockers(runDir, phase).length > 0) return null
258
+ return phase
259
+ }
260
+ return null
261
+ }
262
+
263
+ /** All receipts whose prerequisite_hashes reference a missing or changed artifact. */
264
+ export function getAllStaleReceipts(runDir: string): StaleDownstream[] {
265
+ const stale: StaleDownstream[] = []
266
+ for (const phase of PHASE_SEQUENCE) {
267
+ const receipt = readReceipt(runDir, phase)
268
+ if (!receipt) continue
269
+ const prereqHashes = receipt.prerequisite_hashes ?? {}
270
+ for (const [prereq, storedHash] of Object.entries(prereqHashes)) {
271
+ const prereqPath = join(runDir, prereq)
272
+ if (!existsSync(prereqPath)) {
273
+ stale.push({ artifact: phase, reason: `prerequisite '${prereq}' no longer exists` })
274
+ continue
275
+ }
276
+ const currentHash = lockHashFromContent(readFileSync(prereqPath, 'utf8'))
277
+ if (storedHash !== currentHash) {
278
+ stale.push({ artifact: phase, reason: `prerequisite '${prereq}' content changed since lock at ${receipt.locked_at}` })
279
+ }
280
+ }
281
+ }
282
+ return stale
283
+ }
284
+
285
+ /**
286
+ * Validate the full lock chain of a run: per-phase lock status, break phase,
287
+ * next legal phase, completion, and stale receipts.
288
+ */
289
+ export function validateChain(runDir: string, runId: string): LockChainResult {
290
+ const phases: ChainPhaseResult[] = []
291
+ let breakPhase: string | null = null
292
+ let complete = true
293
+ for (const phase of PHASE_SEQUENCE) {
294
+ const phasePath = join(runDir, phase)
295
+ const status = getLockStatus(phasePath)
296
+ const problems: string[] = []
297
+ const lockValid = status === 'LOCKED'
298
+ if (!lockValid && breakPhase === null) {
299
+ breakPhase = phase
300
+ complete = false
301
+ }
302
+ if (!lockValid && status !== 'MISSING') {
303
+ if (status === 'DRAFT') problems.push(`Status is not LOCKED (${status})`)
304
+ else if (status === 'STALE_LOCK') problems.push('LockHash mismatch or missing lock fields')
305
+ }
306
+ phases.push({ file: phase, status, lockValid, lockProblems: problems })
307
+ }
308
+ const nextLegalPhase = getNextLegalPhase(runDir)
309
+ const staleReceipts = getAllStaleReceipts(runDir)
310
+ return { runId, phases, breakPhase, nextLegalPhase, complete, staleReceipts }
311
+ }
312
+
313
+ /**
314
+ * Python json.dumps(obj, sort_keys=True, separators=(', ', ': '), ensure_ascii=True)
315
+ * compact serialization. Exported for parity testing against the Python oracle.
316
+ */
317
+ export function serializeForReceiptHash(obj: object): string {
318
+ return pythonJsonDumps(obj)
319
+ }
320
+
321
+ /** Python json.dumps(obj, sort_keys=True) compact serialization (receipt_hash input). */
322
+ function pythonJsonDumps(obj: object): string {
323
+ const rec = obj as Record<string, unknown>
324
+ const keys = Object.keys(rec).sort()
325
+ const parts = keys.map(key => {
326
+ const value = rec[key]
327
+ return `${jsonString(key)}: ${jsonValue(value)}`
328
+ })
329
+ return '{' + parts.join(', ') + '}'
330
+ }
331
+
332
+ /** Python json.dumps(obj, indent=2, sort_keys=True) disk serialization. */
333
+ function pythonJsonDumpsIndent(obj: object): string {
334
+ const rec = obj as Record<string, unknown>
335
+ const keys = Object.keys(rec).sort()
336
+ const inner = keys.map(key => {
337
+ const value = rec[key]
338
+ return ' ' + jsonString(key) + ': ' + jsonValueIndent(value, 2)
339
+ })
340
+ return '{\n' + inner.join(',\n') + '\n}'
341
+ }
342
+
343
+ function jsonString(s: string): string {
344
+ return JSON.stringify(s)
345
+ }
346
+
347
+ function jsonValue(value: unknown): string {
348
+ if (value === null) return 'null'
349
+ if (typeof value === 'string') return jsonString(value)
350
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
351
+ if (Array.isArray(value)) return '[' + value.map(jsonValue).join(', ') + ']'
352
+ if (typeof value === 'object') return pythonJsonDumps(value as Record<string, unknown>)
353
+ return 'null'
354
+ }
355
+
356
+ function jsonValueIndent(value: unknown, depth: number): string {
357
+ if (value === null) return 'null'
358
+ if (typeof value === 'string') return jsonString(value)
359
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
360
+ if (Array.isArray(value)) return '[' + value.map(v => jsonValueIndent(v, depth)).join(', ') + ']'
361
+ if (typeof value === 'object') {
362
+ const obj = value as Record<string, unknown>
363
+ const keys = Object.keys(obj).sort()
364
+ const pad = ' '.repeat(depth)
365
+ const inner = keys.map(key => pad + ' ' + jsonString(key) + ': ' + jsonValueIndent(obj[key], depth + 1))
366
+ return '{\n' + inner.join(',\n') + '\n' + pad + '}'
367
+ }
368
+ return 'null'
369
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * recursive:policy prompt-section renderer (Phase C R5, PROPOSAL 8.4 Layer 3).
3
+ * Renders the CURRENT phase's contract from folded state + enforcement config:
4
+ * what must exist before advancing, which tools are denied/asked this phase,
5
+ * the strict/pragmatic TDD mode, the QA mode, and the lock chain - the same
6
+ * rules the machine gates enforce (no prompt/gate contradiction).
7
+ */
8
+ import { existsSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { PHASE_SEQUENCE, getLockStatus } from './lock.ts'
11
+ import { foldRun } from './status.ts'
12
+ import type { RecursivePhaseState } from './lifecycle.ts'
13
+ import type { EnforcementConfig } from './enforcement.ts'
14
+ import { DEFAULT_ENFORCEMENT } from './enforcement.ts'
15
+
16
+ export interface PolicyContext {
17
+ worktreeRoot: string
18
+ runId: string
19
+ folded: RecursivePhaseState | null
20
+ config?: EnforcementConfig
21
+ }
22
+
23
+ /**
24
+ * Render the current-phase contract. Empty string when no run is active.
25
+ */
26
+ export function renderRecursivePolicy(context: PolicyContext | null): string {
27
+ if (!context || !context.runId) return ''
28
+ const runDir = join(context.worktreeRoot, '.recursive', 'run', context.runId)
29
+ const folded = context.folded ?? null
30
+ const config = context.config ?? DEFAULT_ENFORCEMENT
31
+
32
+ // Derive the current phase from files (never trust an unchecked fold).
33
+ let currentPhase = folded?.phase ?? ''
34
+ let nextRequired = ''
35
+ const status = existsSync(runDir) ? foldRun(runDir, context.runId) : null
36
+ if (status?.currentPhase) {
37
+ currentPhase = status.currentPhase.label + ' (' + status.currentPhase.status + ')'
38
+ nextRequired = status.currentPhase.phaseName + ' (' + status.currentPhase.key + ')'
39
+ }
40
+
41
+ const strictness = (gate: EnforcementConfig[keyof EnforcementConfig]) => gate
42
+ const lines = [
43
+ 'You are in a recursive-mode session (enforcement active).',
44
+ '- Current phase: ' + (currentPhase || 'unknown'),
45
+ '- Next required artifact: ' + (nextRequired || 'none - run complete'),
46
+ '- Lock chain: phases lock monotonically (' + PHASE_SEQUENCE.join(' -> ') + ').',
47
+ '- Gates in force: pre-step ' + strictness(config.preStep) + ', tool guards ' + strictness(config.toolGuards) + ', tamper ' + strictness(config.tamper) + '.',
48
+ '- A transition that fails its gates is BLOCKED (strict) or warns (advisory); no rejected transition advances state.',
49
+ '- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.',
50
+ '- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA sign-off for human/hybrid modes.',
51
+ '- The control-plane root is resolved STRICTLY from this session workspace (never scan other workspaces).',
52
+ '- Scratch is disposable, git-ignored, and never citable as an Input.',
53
+ '- The workflow spec lives at /.recursive/RECURSIVE.md; bootstrap it when missing.',
54
+ ]
55
+ return lines.join('\n')
56
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * recursive session projection unit (Phase D R2/R9, PROPOSAL 11.2/11.4): the
3
+ * pure fold that maps SessionEvent[] -> per-run wire-JSON, grouped by
4
+ * worktree root first then runId. The board, inspector, node, and strip all
5
+ * render from this whole value; the client NEVER scrapes /.recursive/run/.
6
+ *
7
+ * The unit is three pure synchronous functions (init/apply/view) plus a zod
8
+ * schema + stateVersion (the session-projection ProjectionDefinition
9
+ * contract). apply returns the same state reference for unrelated events
10
+ * (Object.is zero-work rule), so non-recursive sessions cost nothing.
11
+ *
12
+ * Worktree scoping (binding invariant, run 03 R1): the fold only commits a
13
+ * recursive/* event whose worktreeRoot is INSIDE the workspace control-plane
14
+ * root captured at registration; out-of-workspace events are dropped.
15
+ *
16
+ * The core fold is structural (RecursiveEventLike) so it is unit-testable
17
+ * without a live session; the ProjectionDefinition adapts the framework's
18
+ * SessionEvent to it.
19
+ */
20
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
21
+ import { z as zod } from 'zod'
22
+ import type { ZodType } from 'zod'
23
+ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
24
+ import type { RecursiveProjection, RecursiveRunCard, RecursiveRunState, RecursivePhaseRow, RecursiveGateBlock, RecursiveTamper, RecursiveSubagent } from './types.ts'
25
+
26
+ /** Declare the 'recursive' projection key on the shared type table. */
27
+ declare module '@deepseek-ai/dsh-session-projection/types' {
28
+ interface SessionProjectionMap {
29
+ /** Runs grouped by worktree root, then runId. */
30
+ recursive: RecursiveProjection
31
+ }
32
+ }
33
+
34
+ /** Structural event shape the pure fold reads (unit-testable without a session). */
35
+ export interface RecursiveEventLike {
36
+ type: string
37
+ data?: Record<string, unknown>
38
+ }
39
+
40
+ /** Mutable fold accumulator (private to the pure fold; never leaks). */
41
+ export interface RecursiveFoldState {
42
+ /** The workspace control-plane root this unit is scoped to ('' = accept all). */
43
+ workspaceRoot: string
44
+ /** Runs grouped by worktree root then runId. */
45
+ byWorktree: RecursiveProjection
46
+ }
47
+
48
+ export function emptyRecursiveFoldState(workspaceRoot = ''): RecursiveFoldState {
49
+ // B6: the unit itself has no session/agent to learn the workspace root from —
50
+ // init() is synchronous and receives no context. The EMITTERS are the scope
51
+ // authority: they resolve the control-plane root per-call and never append a
52
+ // cross-workspace event. workspaceRoot = '' (accept-all) is therefore a
53
+ // per-session safety default, not a no-op: (a) each session's projection cell
54
+ // is its own workspace (the log belongs to one agent), and (b) the re-key
55
+ // guard still refuses a merge whose repoRoot falls outside the captured
56
+ // root. A host that registers a session-scoped root may still pass it; the
57
+ // default is the per-session invariant, not a cross-workspace hole.
58
+ return { workspaceRoot, byWorktree: {} }
59
+ }
60
+
61
+ /** Whether a worktreeRoot is inside (or equal to) the workspace root. */
62
+ export function isInsideWorkspace(worktreeRoot: string, workspaceRoot: string): boolean {
63
+ if (workspaceRoot === '') return true
64
+ const w = worktreeRoot.replace(/\\/g, '/').replace(/\/$/, '')
65
+ const r = workspaceRoot.replace(/\\/g, '/').replace(/\/$/, '')
66
+ return w === r || w.startsWith(r + '/')
67
+ }
68
+
69
+ function ensureCard(state: RecursiveFoldState, runId: string, worktreeRoot: string): RecursiveRunCard {
70
+ let tree = state.byWorktree[worktreeRoot]
71
+ if (tree === undefined) {
72
+ tree = {}
73
+ state.byWorktree[worktreeRoot] = tree
74
+ }
75
+ let card = tree[runId]
76
+ if (card === undefined) {
77
+ card = { runId, worktreeRoot, phases: {}, state: 'active', tampers: {}, subagents: {} }
78
+ tree[runId] = card
79
+ }
80
+ return card
81
+ }
82
+
83
+ function asRunState(value: unknown): RecursiveRunState {
84
+ return value === 'new' || value === 'active' || value === 'paused' || value === 'blocked' || value === 'complete'
85
+ ? value as RecursiveRunState
86
+ : 'active'
87
+ }
88
+
89
+ /** Pure transition: previous state + one committed event -> next state. */
90
+ /** Internal transition signals (never commit into the projection). */
91
+ const INTERNAL_RECURSIVE_EVENTS = new Set(['recursive/phase-intent'])
92
+
93
+ export function foldRecursiveProjection(state: RecursiveFoldState, event: RecursiveEventLike): RecursiveFoldState {
94
+ const type = event.type
95
+ if (typeof type !== 'string' || !type.startsWith('recursive/')) return state
96
+ if (INTERNAL_RECURSIVE_EVENTS.has(type)) return state
97
+ const data = event.data ?? {}
98
+ const runId = typeof data.runId === 'string' ? data.runId : ''
99
+ const worktreeRoot = typeof data.worktreeRoot === 'string' ? data.worktreeRoot : ''
100
+ if (runId === '' || worktreeRoot === '') return state
101
+ // Binding workspace-scoping invariant: drop out-of-workspace events.
102
+ if (!isInsideWorkspace(worktreeRoot, state.workspaceRoot)) return state
103
+
104
+ const card = ensureCard(state, runId, worktreeRoot)
105
+
106
+ switch (type) {
107
+ case 'recursive/run-created': {
108
+ if (typeof data.template === 'string') card.template = data.template
109
+ if (typeof data.repo === 'string') card.repo = data.repo
110
+ return state
111
+ }
112
+ case 'recursive/phase': {
113
+ const phase = String(data.phase ?? '')
114
+ if (phase === '') return state
115
+ const row: RecursivePhaseRow = card.phases[phase] ?? { phase, status: String(data.status ?? '') }
116
+ row.status = String(data.status ?? '')
117
+ card.phases[phase] = row
118
+ return state
119
+ }
120
+ case 'recursive/phase-locked': {
121
+ const phase = String(data.phase ?? '')
122
+ if (phase === '') return state
123
+ const row: RecursivePhaseRow = card.phases[phase] ?? { phase, status: 'LOCKED' }
124
+ row.status = 'LOCKED'
125
+ row.lockedAt = String(data.lockedAt ?? '')
126
+ row.lockHash = String(data.lockHash ?? '')
127
+ card.phases[phase] = row
128
+ return state
129
+ }
130
+ case 'recursive/gate-blocked': {
131
+ const gate: RecursiveGateBlock = {
132
+ failures: Array.isArray(data.failures) ? data.failures.map(String) : [],
133
+ kind: String(data.kind ?? ''),
134
+ }
135
+ card.gateBlocked = gate
136
+ return state
137
+ }
138
+ case 'recursive/tamper': {
139
+ const path = String(data.path ?? '')
140
+ if (path === '') return state
141
+ const t: RecursiveTamper = { path, reason: String(data.reason ?? '') }
142
+ card.tampers[path] = t
143
+ return state
144
+ }
145
+ case 'recursive/run-merged': {
146
+ const repoRoot = typeof data.repoRoot === 'string' ? data.repoRoot : ''
147
+ if (repoRoot === '') return state
148
+ // R9: a merged run is listed under the repo root, not the stale worktree
149
+ // path. Re-key by moving the card across the outer group — but only when
150
+ // the repo root itself is inside the workspace (binding invariant);
151
+ // otherwise the merge note is recorded without crossing scope.
152
+ card.mergedToRepoRoot = repoRoot
153
+ if (isInsideWorkspace(repoRoot, state.workspaceRoot)) {
154
+ const tree = state.byWorktree[worktreeRoot]
155
+ if (tree !== undefined && tree[runId] === card) {
156
+ delete tree[runId]
157
+ if (Object.keys(tree).length === 0) delete state.byWorktree[worktreeRoot]
158
+ }
159
+ const target = state.byWorktree[repoRoot] ?? (state.byWorktree[repoRoot] = {})
160
+ target[runId] = card
161
+ // The card's own worktreeRoot now reflects the merge target.
162
+ card.worktreeRoot = repoRoot
163
+ }
164
+ return state
165
+ }
166
+ case 'recursive/run-state': {
167
+ card.state = asRunState(data.state)
168
+ if (typeof data.reason === 'string') card.stateReason = data.reason
169
+ return state
170
+ }
171
+ case 'recursive/subagent-start':
172
+ case 'recursive/subagent-end': {
173
+ const childId = String(data.childId ?? '')
174
+ if (childId === '') return state
175
+ const sub: RecursiveSubagent = card.subagents[childId] ?? {
176
+ childId,
177
+ role: String(data.role ?? ''),
178
+ provider: String(data.provider ?? ''),
179
+ }
180
+ sub.role = String(data.role ?? sub.role)
181
+ sub.provider = String(data.provider ?? sub.provider)
182
+ if (type === 'recursive/subagent-end') {
183
+ sub.status = (data.status === 'running' || data.status === 'done' || data.status === 'failed')
184
+ ? data.status as 'running' | 'done' | 'failed'
185
+ : 'done'
186
+ }
187
+ card.subagents[childId] = sub
188
+ return state
189
+ }
190
+ default:
191
+ return state
192
+ }
193
+ }
194
+
195
+ /** Wire-payload schema (view output) — plain-JSON validation before it leaves the host. */
196
+ const recursivePhaseRowSchema = zod.object({
197
+ phase: zod.string(),
198
+ status: zod.string(),
199
+ lockedAt: zod.string().optional(),
200
+ lockHash: zod.string().optional(),
201
+ })
202
+
203
+ const recursiveSubagentSchema = zod.object({
204
+ childId: zod.string(),
205
+ role: zod.string(),
206
+ provider: zod.string(),
207
+ status: zod.enum(['running', 'done', 'failed']).optional(),
208
+ })
209
+
210
+ const recursiveRunCardSchema = zod.object({
211
+ runId: zod.string(),
212
+ worktreeRoot: zod.string(),
213
+ repo: zod.string().optional(),
214
+ template: zod.string().optional(),
215
+ phases: zod.record(zod.string(), recursivePhaseRowSchema),
216
+ state: zod.enum(['new', 'active', 'paused', 'blocked', 'complete']),
217
+ stateReason: zod.string().optional(),
218
+ gateBlocked: zod.object({ failures: zod.array(zod.string()), kind: zod.string() }).optional(),
219
+ tampers: zod.record(zod.string(), zod.object({ path: zod.string(), reason: zod.string() })),
220
+ subagents: zod.record(zod.string(), recursiveSubagentSchema),
221
+ mergedToRepoRoot: zod.string().optional(),
222
+ })
223
+
224
+ export const recursiveProjectionSchema: ZodType<RecursiveProjection> = zod.record(
225
+ zod.string(),
226
+ zod.record(zod.string(), recursiveRunCardSchema),
227
+ )
228
+
229
+ /** The registered projection unit (key 'recursive'). */
230
+ export const recursiveProjectionUnit: ProjectionDefinition<'recursive', RecursiveFoldState> = {
231
+ key: 'recursive',
232
+ schema: recursiveProjectionSchema,
233
+ init: () => emptyRecursiveFoldState(),
234
+ apply: (state, event: SessionEvent) => foldRecursiveProjection(state, event as RecursiveEventLike),
235
+ view: (state) => state.byWorktree,
236
+ stateVersion: 1,
237
+ }