@try-works/dsh-recursive-mode 0.1.3 → 0.1.5

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 (126) hide show
  1. package/cordis.patch.yml +15 -10
  2. package/lib/bootstrap.d.ts +9 -2
  3. package/lib/client/apply-guard.d.ts +14 -0
  4. package/lib/client/board.d.ts +5 -4
  5. package/lib/client/contract.d.ts +57 -24
  6. package/lib/client/host-api.d.ts +26 -0
  7. package/lib/client/index.d.ts +16 -9
  8. package/lib/client/inspector.d.ts +3 -3
  9. package/lib/client/slots.d.ts +4 -6
  10. package/lib/client/strip.d.ts +4 -3
  11. package/lib/client/use-live.d.ts +9 -0
  12. package/lib/client.js +252 -174
  13. package/lib/fs-intent.d.ts +34 -0
  14. package/lib/index.d.ts +12 -11
  15. package/lib/index.js +1008 -4507
  16. package/lib/init-templates.d.ts +41 -0
  17. package/lib/live-route.d.ts +69 -0
  18. package/lib/phase-rules.d.ts +34 -0
  19. package/lib/policy.d.ts +1 -1
  20. package/lib/runtime.d.ts +24 -17
  21. package/lib/snapshot.d.ts +11 -0
  22. package/package.json +4 -3
  23. package/preset/recursive/agent.cordis.yml +7 -4
  24. package/preset/recursive/preset.yml +1 -1
  25. package/references/agents-block.md +95 -0
  26. package/references/artifact-template.md +2574 -0
  27. package/references/bodies/claude.md +7 -0
  28. package/references/bodies/codex-agents.md +95 -0
  29. package/references/bodies/copilot.md +7 -0
  30. package/references/bodies/cursorrules.md +7 -0
  31. package/references/bodies/decisions.md +5 -0
  32. package/references/bodies/delegated-verification.md +49 -0
  33. package/references/bodies/memory-router.md +45 -0
  34. package/references/bodies/phase8-skill-memory.md +48 -0
  35. package/references/bodies/plans-bridge.md +43 -0
  36. package/references/bodies/recursive-agents-router.md +64 -0
  37. package/references/bodies/skill-discovery.md +51 -0
  38. package/references/bodies/skill-memory-router.md +42 -0
  39. package/references/bodies/state.md +5 -0
  40. package/references/bootstrap/RECURSIVE.md +2362 -0
  41. package/references/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  42. package/references/scripts/lint-recursive-run.ps1 +25 -0
  43. package/references/scripts/lint-recursive-run.py +2870 -0
  44. package/references/scripts/recursive-closeout.ps1 +38 -0
  45. package/references/scripts/recursive-closeout.py +541 -0
  46. package/references/scripts/recursive-init.ps1 +515 -0
  47. package/references/scripts/recursive-init.py +356 -0
  48. package/references/scripts/recursive-lock.ps1 +26 -0
  49. package/references/scripts/recursive-lock.py +302 -0
  50. package/references/scripts/recursive-review-bundle.ps1 +58 -0
  51. package/references/scripts/recursive-review-bundle.py +503 -0
  52. package/references/scripts/recursive-router-cli-configure.ps1 +2 -0
  53. package/references/scripts/recursive-router-cli-configure.py +9 -0
  54. package/references/scripts/recursive-router-cli-init.ps1 +2 -0
  55. package/references/scripts/recursive-router-cli-init.py +9 -0
  56. package/references/scripts/recursive-router-cli-invoke.ps1 +2 -0
  57. package/references/scripts/recursive-router-cli-invoke.py +9 -0
  58. package/references/scripts/recursive-router-cli-probe.ps1 +2 -0
  59. package/references/scripts/recursive-router-cli-probe.py +9 -0
  60. package/references/scripts/recursive-router-cli-resolve.ps1 +2 -0
  61. package/references/scripts/recursive-router-cli-resolve.py +9 -0
  62. package/references/scripts/recursive-router-cli-validate.ps1 +2 -0
  63. package/references/scripts/recursive-router-cli-validate.py +9 -0
  64. package/references/scripts/recursive-router-configure.ps1 +27 -0
  65. package/references/scripts/recursive-router-configure.py +74 -0
  66. package/references/scripts/recursive-router-init.ps1 +17 -0
  67. package/references/scripts/recursive-router-init.py +29 -0
  68. package/references/scripts/recursive-router-invoke.ps1 +47 -0
  69. package/references/scripts/recursive-router-invoke.py +103 -0
  70. package/references/scripts/recursive-router-probe.ps1 +25 -0
  71. package/references/scripts/recursive-router-probe.py +44 -0
  72. package/references/scripts/recursive-router-resolve.ps1 +26 -0
  73. package/references/scripts/recursive-router-resolve.py +46 -0
  74. package/references/scripts/recursive-router-validate.ps1 +17 -0
  75. package/references/scripts/recursive-router-validate.py +27 -0
  76. package/references/scripts/recursive-status.ps1 +23 -0
  77. package/references/scripts/recursive-status.py +2124 -0
  78. package/references/scripts/recursive-subagent-action.ps1 +98 -0
  79. package/references/scripts/recursive-subagent-action.py +197 -0
  80. package/references/scripts/recursive-training-extract.ps1 +23 -0
  81. package/references/scripts/recursive-training-extract.py +99 -0
  82. package/references/scripts/recursive-training-grpo.ps1 +52 -0
  83. package/references/scripts/recursive-training-grpo.py +1341 -0
  84. package/references/scripts/recursive-training-loader.ps1 +78 -0
  85. package/references/scripts/recursive-training-loader.py +558 -0
  86. package/references/scripts/recursive-training-mcp.ps1 +29 -0
  87. package/references/scripts/recursive-training-mcp.py +261 -0
  88. package/references/scripts/recursive-training-phase8-trigger.ps1 +55 -0
  89. package/references/scripts/recursive-training-phase8-trigger.py +137 -0
  90. package/references/scripts/recursive-training-sync.ps1 +29 -0
  91. package/references/scripts/recursive-training-sync.py +183 -0
  92. package/references/scripts/recursive_phase_rules.py +367 -0
  93. package/references/scripts/recursive_router_cli_lib.py +2 -0
  94. package/references/scripts/recursive_router_lib.py +2282 -0
  95. package/references/scripts/verify-locks.ps1 +25 -0
  96. package/references/scripts/verify-locks.py +353 -0
  97. package/scripts/__pycache__/lint-recursive-run.cpython-314.pyc +0 -0
  98. package/scripts/__pycache__/recursive_phase_rules.cpython-314.pyc +0 -0
  99. package/scripts/install-preset.cmd +7 -0
  100. package/scripts/install-preset.js +101 -0
  101. package/scripts/lint-recursive-run.py +7 -5
  102. package/scripts/test-recursive-mode-smoke.ts +29 -28
  103. package/src/bootstrap.ts +253 -39
  104. package/src/client/apply-guard.ts +23 -0
  105. package/src/client/board.tsx +0 -0
  106. package/src/client/contract.ts +72 -24
  107. package/src/client/host-api.ts +90 -0
  108. package/src/client/index.ts +22 -11
  109. package/src/client/inspector.tsx +7 -9
  110. package/src/client/slots.ts +70 -26
  111. package/src/client/strip.tsx +9 -9
  112. package/src/client/use-live.ts +52 -0
  113. package/src/fs-intent.ts +84 -0
  114. package/src/index.ts +100 -155
  115. package/src/init-templates.ts +276 -0
  116. package/src/live-route.ts +163 -0
  117. package/src/phase-rules.ts +262 -0
  118. package/src/policy.ts +29 -5
  119. package/src/runtime.ts +129 -77
  120. package/src/snapshot.ts +104 -0
  121. package/lib/client/node.d.ts +0 -71
  122. package/lib/events.d.ts +0 -173
  123. package/lib/projection.d.ts +0 -29
  124. package/src/client/node.ts +0 -156
  125. package/src/events.ts +0 -173
  126. package/src/projection.ts +0 -237
package/src/runtime.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Service, type Context } from '@deepseek-ai/cordis'
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { execFileSync } from 'node:child_process'
3
4
  import { join } from 'node:path'
5
+ import { referencesRoot } from './bootstrap.ts'
6
+ import { requirementsContent, worktreeContent, laterPhaseContent, detectGitContext, RUN_SCAFFOLD_DIRS, type GitContext } from './init-templates.ts'
4
7
  import { foldRun, resolveRunDir } from './status.ts'
5
8
  import {
6
9
  getLockStatus,
@@ -22,9 +25,8 @@ import { delegate, validateReferences, writeActionRecord, evaluateDelegationResu
22
25
  import { foldRecursivePhase, validateTransition, detectTransitionIntent, LifecycleDriver, coupleGateBlockToGoal, type PhaseTransitionIntent, type SessionEventLike, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts'
23
26
  import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluatePreStepGate, evaluateToolGuard, detectTamper, type EnforcementConfig, type PreStepGateDecision, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts'
24
27
  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
28
  import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
27
- import { foldRecursiveProjection as foldProjectionEvent, emptyRecursiveFoldState, recursiveProjectionUnit, isInsideWorkspace, type RecursiveEventLike, type RecursiveFoldState } from './projection.ts'
29
+ import { snapshotWorkspace } from './snapshot.ts'
28
30
 
29
31
  declare module '@deepseek-ai/cordis' {
30
32
  interface Context {
@@ -303,23 +305,28 @@ export class RecursiveRuntime extends Service {
303
305
  }
304
306
 
305
307
  /**
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.
308
+ * SP2 R1 route adapter: resolve the control-plane root for the live route.
309
+ * sessionId PRIMARY the host looks up the attached session header cwd and
310
+ * resolves the root from THAT; the client-passed cwd is a fallback hint only
311
+ * (hydration / headless callers). Two sessions in one workspace collapse to
312
+ * one root; a subdir cwd resolves up to the workspace root.
309
313
  */
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 */ }
314
+ async resolveRootForRoute(sessionId: string | undefined, cwd: string, sessionsStore?: { get?: (id: string) => { header?: { cwd?: string } } | undefined } | null): Promise<string | null> {
315
+ // 1) Attached session header (authoritative when present).
316
+ if (sessionId && sessionsStore?.get) {
317
+ const attached = sessionsStore.get(sessionId)
318
+ const headerCwd = attached?.header?.cwd
319
+ if (headerCwd) {
320
+ const root = await resolveControlPlaneRoot({ session: { header: { cwd: headerCwd } } }, this.workspaceRegistry, this.repoRoot)
321
+ if (root) return root
322
+ }
323
+ }
324
+ // 2) Client cwd fallback (hydration / headless) — still registry-canonicalized.
325
+ if (cwd) {
326
+ const root = await resolveControlPlaneRoot({ session: { header: { cwd } } }, this.workspaceRegistry, this.repoRoot)
327
+ if (root) return root
328
+ }
329
+ return null
323
330
  }
324
331
 
325
332
  async status(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<RecursiveStatusResult | null> {
@@ -331,8 +338,12 @@ export class RecursiveRuntime extends Service {
331
338
  }
332
339
 
333
340
  /**
334
- * Scaffold a run directory with stub artifact headers (no-op if exists).
335
- * Returns the run dir + created artifacts.
341
+ * Scaffold a run directory with FULL per-phase templates (no-op if exists).
342
+ * 00-requirements.md + 00-worktree.md are byte-identical to canonical
343
+ * recursive-init.py (incl. git-context prefill); later phases carry every
344
+ * required section (get_artifact_required_sections) + TODO + FAIL gates.
345
+ * Also scaffolds addenda/subagents/router-prompts/evidence dirs. Returns the
346
+ * run dir + created artifacts.
336
347
  */
337
348
  async initRun(runId: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<{ runDir: string; runId: string; created: string[]; existing: string[] }> {
338
349
  const root = await this.resolveRootFor(agent)
@@ -341,23 +352,50 @@ export class RecursiveRuntime extends Service {
341
352
  mkdirSync(runDir, { recursive: true })
342
353
  const created: string[] = []
343
354
  const existing: string[] = []
344
- for (const [file, fields] of Object.entries(ARTIFACT_STUB)) {
355
+
356
+ // Scaffold dirs (canonical recursive-init sequence).
357
+ for (const dir of RUN_SCAFFOLD_DIRS) {
358
+ const p = join(runDir, dir)
359
+ if (existsSync(p)) { existing.push(dir + '/'); continue }
360
+ mkdirSync(p, { recursive: true })
361
+ created.push(dir + '/')
362
+ }
363
+
364
+ // Git context for the Phase 0 diff-basis prefill (canonical parity).
365
+ const { context: gitContext, error: prefillError } = detectGitContext(root)
366
+
367
+ // Phase 0 templates: byte-identical to canonical recursive-init.py.
368
+ const phase0: Array<[string, string]> = [
369
+ ['00-requirements.md', requirementsContent(runId, 'feature', '')],
370
+ ['00-worktree.md', worktreeContent(runId, root, gitContext, prefillError)],
371
+ ]
372
+ for (const [file, content] of phase0) {
345
373
  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')
374
+ if (existsSync(path)) { existing.push(file); continue }
375
+ writeFileSync(path, content, 'utf8')
352
376
  created.push(file)
353
377
  }
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)
378
+
379
+ // Later phases: full required-sections scaffold.
380
+ const laterPhases = [
381
+ '01-as-is.md',
382
+ '01.5-root-cause.md',
383
+ '02-to-be-plan.md',
384
+ '03-implementation-summary.md',
385
+ '03.5-code-review.md',
386
+ '04-test-summary.md',
387
+ '05-manual-qa.md',
388
+ '06-decisions-update.md',
389
+ '07-state-update.md',
390
+ '08-memory-impact.md',
391
+ ]
392
+ for (const file of laterPhases) {
393
+ const path = join(runDir, file)
394
+ if (existsSync(path)) { existing.push(file); continue }
395
+ writeFileSync(path, laterPhaseContent(runId, file), 'utf8')
396
+ created.push(file)
360
397
  }
398
+
361
399
  return { runDir, runId, created, existing }
362
400
  }
363
401
 
@@ -376,9 +414,8 @@ export class RecursiveRuntime extends Service {
376
414
  if (!existsSync(artifactPath)) throw new Error('Artifact not found: ' + artifact)
377
415
  const status = getLockStatus(artifactPath)
378
416
  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)
417
+ // B2: the pre-step gate reads transition intent from the session log; the
418
+ // durable commit below is what the live fs route folds. No phase-intent event.
382
419
  const blockers = getPrerequisiteBlockers(runDir, artifact)
383
420
  if (blockers.length > 0) {
384
421
  throw new Error('Prerequisite blockers: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', '))
@@ -392,11 +429,7 @@ export class RecursiveRuntime extends Service {
392
429
  content = setOrInsertField(content, 'LockHash', lockHash, ['LockedAt', 'Status'])
393
430
  writeFileSync(artifactPath, content, 'utf8')
394
431
  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)
432
+ // B2: the commit is durable — the live fs route folds it on the next GET.
400
433
  return {
401
434
  artifact,
402
435
  runId,
@@ -419,11 +452,7 @@ export class RecursiveRuntime extends Service {
419
452
  const hadReceipt = invalidateReceipt(runDir, artifact)
420
453
  const stale = getStaleDownstreamPhases(runDir, artifact)
421
454
  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)
455
+ // B2: reopen reverts to DRAFT — the live fs route folds the reverted state.
427
456
  return {
428
457
  artifact,
429
458
  runId,
@@ -435,8 +464,12 @@ export class RecursiveRuntime extends Service {
435
464
  }
436
465
 
437
466
  /**
438
- * Lint an artifact for phase-specific issues. Minimal structural checks:
439
- * gates present, TODO section, Status field, LockHash consistency.
467
+ * Lint an artifact with FULL canonical parity: shells out to the vendored
468
+ * lint-recursive-run.py (byte-identical to canonical) and parses its
469
+ * [FAIL]/[WARN] lines + Summary counts. `errors` = FAIL lines, `warnings` =
470
+ * WARN lines, `passed` = no FAIL (WARN-only passes, matching the canonical
471
+ * non-strict verdict). Falls back to the structural check only if the vendored
472
+ * script is unavailable (e.g. bundled lib without references/).
440
473
  */
441
474
  async lintArtifact(runId: string, artifact?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<LintArtifactResult> {
442
475
  const root = await this.resolveRootFor(agent)
@@ -447,6 +480,24 @@ export class RecursiveRuntime extends Service {
447
480
  if (!existsSync(artifactPath)) {
448
481
  return { artifact: target, runId, errors: ['Artifact not found'], warnings: [], passed: false }
449
482
  }
483
+ const lintScript = join(referencesRoot(), 'scripts', 'lint-recursive-run.py')
484
+ if (existsSync(lintScript)) {
485
+ try {
486
+ const stdout = execFileSync('python', [lintScript, '--repo-root', root, '--run-id', runId], {
487
+ encoding: 'utf8',
488
+ stdio: ['ignore', 'pipe', 'ignore'],
489
+ timeout: 60000,
490
+ })
491
+ return parseLintOutput(stdout, target, runId)
492
+ } catch (err) {
493
+ const stdout = (err as { stdout?: string })?.stdout ?? ''
494
+ if (stdout.includes('Summary')) {
495
+ return parseLintOutput(stdout, target, runId)
496
+ }
497
+ // script failed to run entirely — fall through to structural check
498
+ }
499
+ }
500
+ // Structural fallback (no vendored script available).
450
501
  const content = readFileSync(artifactPath, 'utf8')
451
502
  const errors: string[] = []
452
503
  const warnings: string[] = []
@@ -456,10 +507,7 @@ export class RecursiveRuntime extends Service {
456
507
  else if (status === 'STALE_LOCK') errors.push('LockHash mismatch or missing lock fields')
457
508
  if (!/^[ \t]*## TODO[ \t]*$/m.test(content)) errors.push('Missing ## TODO section')
458
509
  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')
510
+ if (!new RegExp('^[ \t]*' + gate + ':\s*(PASS|FAIL)\s*$', 'm').test(content)) warnings.push('Missing ' + gate + ' gate')
463
511
  }
464
512
  return { artifact: target, runId, errors, warnings, passed: errors.length === 0 }
465
513
  }
@@ -504,31 +552,6 @@ export class RecursiveRuntime extends Service {
504
552
  return coupleGateBlockToGoal(goalService as never, agent, ref, reason)
505
553
  }
506
554
 
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
555
  /** Phase C R7: resolve the enforcement config (strict|advisory, default advisory). */
533
556
  get enforcementConfig(): EnforcementConfig {
534
557
  return this._enforcementConfig ?? DEFAULT_ENFORCEMENT
@@ -562,3 +585,32 @@ function setOrInsertField(content: string, fieldName: string, value: string, aft
562
585
  function escapeRegExp(s: string): string {
563
586
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
564
587
  }
588
+
589
+ /**
590
+ * Parse the vendored lint-recursive-run.py stdout into a LintArtifactResult.
591
+ * [FAIL] lines -> errors, [WARN] lines -> warnings. When the run directory has
592
+ * multiple artifacts, FAIL/WARN lines carry the artifact path; we keep only the
593
+ * lines for the requested artifact (or all when the path is ambiguous).
594
+ */
595
+ function parseLintOutput(stdout: string, target: string, runId: string): LintArtifactResult {
596
+ const errors: string[] = []
597
+ const warnings: string[] = []
598
+ const failRe = /^\[FAIL\]\s+(.*)$/gm
599
+ const warnRe = /^\[WARN\]\s+(.*)$/gm
600
+ const targetSuffix = target.replace(/^.*[\\/]/, '')
601
+ const isForTarget = (line: string) => {
602
+ const pathMatch = line.match(/[A-Za-z0-9_.-]+\.md/)
603
+ return !pathMatch || pathMatch[0] === targetSuffix
604
+ }
605
+ let m: RegExpExecArray | null
606
+ while ((m = failRe.exec(stdout)) !== null) {
607
+ if (isForTarget(m[1])) errors.push(m[1].trim())
608
+ }
609
+ while ((m = warnRe.exec(stdout)) !== null) {
610
+ if (isForTarget(m[1])) warnings.push(m[1].trim())
611
+ }
612
+ // If the target artifact appears nowhere in the output but the run was linted,
613
+ // keep the parsed errors/warnings (canonical script lints the whole run).
614
+ return { artifact: target, runId, errors, warnings, passed: errors.length === 0 }
615
+ }
616
+
@@ -0,0 +1,104 @@
1
+ /**
2
+ * snapshotWorkspace(root): fold the filesystem into the RecursiveProjection whole value.
3
+ *
4
+ * SP2 R1 (live-route) — the ONLY projection path. The board/strip read this through
5
+ * the host HTTP/SSE route; cold resume and every GET are a FRESH fs read (the route
6
+ * never serves a cached/stale snapshot). Per-workspace: root is the resolved
7
+ * control-plane root (cwd == workspace root, cwd == subdir, or cwd == repo root).
8
+ *
9
+ * Phase-lock facts (lockedAt/lockHash) are enriched from lock receipts because
10
+ * foldRun PhaseState carries no lock fields; state comes from getArtifactState.
11
+ */
12
+ import { readFileSync, existsSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { discoverRuns } from './run.ts'
15
+ import { foldRun } from './status.ts'
16
+ import { getLockStatus, receiptPath, readReceipt } from './lock.ts'
17
+ import type { RecursiveProjection, RecursiveRunCard, RecursivePhaseRow, RecursiveRunState } from './types.ts'
18
+
19
+ /** One locked-phase fact from a lock receipt (artifacts/<file>.receipt.json). */
20
+ interface LockFacts {
21
+ lockedAt?: string
22
+ lockHash?: string
23
+ }
24
+
25
+ /** Resolve a run's lock facts for one artifact file (receipt first, status fallback). */
26
+ function lockFactsOf(runDir: string, artifact: string): LockFacts {
27
+ const receipt = readReceipt(runDir, artifact)
28
+ if (receipt && typeof receipt.locked_at === 'string' && typeof receipt.artifact_hash === 'string') {
29
+ return {
30
+ lockedAt: receipt.locked_at,
31
+ lockHash: receipt.artifact_hash,
32
+ }
33
+ }
34
+ // No usable receipt: fall back to the artifact's own LockedAt/LockHash fields.
35
+ const status = getLockStatus(join(runDir, artifact))
36
+ if (status !== 'LOCKED') return {}
37
+ return {
38
+ lockedAt: new Date(0).toISOString().replace(/\.\d{3}Z$/, 'Z'),
39
+ lockHash: '0'.repeat(64),
40
+ }
41
+ }
42
+
43
+ /** One phase row from a run's folded status (locked facts enriched). */
44
+ function phaseRowOf(runDir: string, key: string, file: string, status: string): RecursivePhaseRow {
45
+ const row: RecursivePhaseRow = { phase: key, status }
46
+ if (status === 'LOCKED') {
47
+ const facts = lockFactsOf(runDir, file)
48
+ if (facts.lockedAt !== undefined) row.lockedAt = facts.lockedAt
49
+ if (facts.lockHash !== undefined) row.lockHash = facts.lockHash
50
+ }
51
+ return row
52
+ }
53
+
54
+ /**
55
+ * Fold one run directory into a RecursiveRunCard (the wire shape the board renders).
56
+ * Runs that no longer exist on disk are omitted (the fs is the source of truth).
57
+ */
58
+ export function foldRunCard(runDir: string, runId: string, worktreeRoot: string): RecursiveRunCard {
59
+ const status = foldRun(runDir, runId)
60
+ // Wire contract parity (deleted projection.ts): phases are keyed by FILENAME
61
+ // (the recursive/phase event carried phase: '03-implementation-summary.md'),
62
+ // which is what derive.ts phaseGroupOf parses. phase.key ('03', '00R', '00W')
63
+ // would never match the filename regex and would break columnForRun/cardFacts.
64
+ const phases: Record<string, RecursivePhaseRow> = {}
65
+ for (const phase of status.phases) {
66
+ if (phase.exists) phases[phase.file] = phaseRowOf(runDir, phase.file, phase.file, phase.status)
67
+ }
68
+ const state = runStateOf(status)
69
+ return {
70
+ runId,
71
+ worktreeRoot,
72
+ phases,
73
+ state,
74
+ tampers: {},
75
+ subagents: {},
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Derive the run-level state from the folded status. The fs stores no run-level
81
+ * state file, so the honest mapping is: 'complete' when every non-optional phase
82
+ * exists and is lock-valid, else 'active'. Transient facts (gateBlocked, tamper,
83
+ * subagent activity) are NOT fs-recoverable and read as absent (recorded
84
+ * limitation; future enhancement).
85
+ */
86
+ function runStateOf(status: ReturnType<typeof foldRun>): RecursiveRunState {
87
+ const mandatory = status.phases.filter(p => !p.optional)
88
+ const complete = mandatory.every(p => p.exists && p.lockValid)
89
+ return complete ? 'complete' : 'active'
90
+ }
91
+
92
+ /**
93
+ * Fold every run under <root>/.recursive/run/ into the projection, grouped by
94
+ * worktree root then runId. A fresh fs read on every call (cold-resume live).
95
+ */
96
+ export function snapshotWorkspace(root: string): RecursiveProjection {
97
+ const projection: RecursiveProjection = {}
98
+ for (const runId of discoverRuns(root)) {
99
+ const runDir = join(root, '.recursive', 'run', runId)
100
+ projection[root] ??= {}
101
+ projection[root][runId] = foldRunCard(runDir, runId, root)
102
+ }
103
+ return projection
104
+ }
@@ -1,71 +0,0 @@
1
- /**
2
- * Conversation run node (Phase D R7, §11.6): a structural ConversationNodeDefinition
3
- * over the recursive/* event family, keyed by runId + worktreeRoot. One compact,
4
- * live node per run in the Chat view. PURE fold (no React) — the renderer lives
5
- * in slots.ts.
6
- *
7
- * The real ConversationNodeDefinition contract arrives from
8
- * @deepseek-ai/dsh-client-runtime/client at compose time; the structural shape
9
- * here is type-compatible with the cookbook surface (match/start/update/
10
- * buildViewNode + target/kind). The host links the bundle's exports to the real
11
- * runtime identities.
12
- */
13
- import type { ClientSessionEvent } from './contract.ts';
14
- export declare const RECURSIVE_NODE_KIND = "recursive/run";
15
- /** The chat-node payload the renderer receives (pure derived state). */
16
- export interface RecursiveNodeData {
17
- readonly runId: string;
18
- readonly worktreeRoot: string;
19
- readonly state: string;
20
- readonly currentPhase: string | null;
21
- readonly lockedCount: number;
22
- readonly totalPhases: number;
23
- readonly tampered: boolean;
24
- readonly gateBlocked: boolean;
25
- readonly mergedToRepoRoot?: string;
26
- }
27
- /** Pure fold state for one run node. */
28
- export interface RecursiveNodeState {
29
- runId: string;
30
- worktreeRoot: string;
31
- state: string;
32
- phases: Record<string, {
33
- phase: string;
34
- status: string;
35
- }>;
36
- tampered: boolean;
37
- gateBlocked: boolean;
38
- mergedToRepoRoot?: string;
39
- }
40
- /** Extract a stable (runId, worktreeRoot) business id + lifecycle role from one event. */
41
- export declare function matchRecursiveNode(event: ClientSessionEvent): {
42
- id: string;
43
- role: 'start' | 'update';
44
- } | null;
45
- /** Fold one recursive/* event into the node state. */
46
- export declare function foldRecursiveNode(s: RecursiveNodeState | undefined, event: ClientSessionEvent): RecursiveNodeState;
47
- /** The structural ConversationNodeDefinition (host links to the real contract). */
48
- export declare const recursiveNodeDefinition: {
49
- kind: string;
50
- target: string;
51
- match: typeof matchRecursiveNode;
52
- start: (_ctx: unknown, match: {
53
- event: ClientSessionEvent;
54
- }) => RecursiveNodeState;
55
- update: (ctx: {
56
- state: RecursiveNodeState;
57
- }, match: {
58
- event: ClientSessionEvent;
59
- }) => RecursiveNodeState;
60
- buildViewNode: (ctx: {
61
- key: string;
62
- id: string;
63
- state?: RecursiveNodeState;
64
- }) => {
65
- key: string;
66
- kind: string;
67
- id: string;
68
- target: string;
69
- data: RecursiveNodeData;
70
- } | null;
71
- };
package/lib/events.d.ts DELETED
@@ -1,173 +0,0 @@
1
- /**
2
- * recursive/* session events (Phase D R1, PROPOSAL 11.2/11.6): the complete
3
- * durable event vocabulary the projection folds, the board renders, and the
4
- * conversation node correlates. Every payload carries a non-empty
5
- * { runId, worktreeRoot } so the whole plane is worktree-aware and the
6
- * projection can group runs by their control-plane root (binding
7
- * workspace-scoping invariant, run 03 R1).
8
- *
9
- * Events are LOG-ONLY (no live mirror): the projection unit (projection.ts)
10
- * is the only derived view. Payloads are plain JSON and declared on the
11
- * SessionEventMap merge via @deepseek-ai/dsh-session/types.
12
- *
13
- * The pure constructor helpers below enforce the runId + non-empty
14
- * worktreeRoot invariant at emit time, so a malformed event fails loud at
15
- * the emit site instead of silently landing cross-workspace.
16
- */
17
- import type { RecursiveRunState } from './types.ts';
18
- /** Declare the recursive/* event family on the session event map (merge-extensible). */
19
- declare module '@deepseek-ai/dsh-session/types' {
20
- interface SessionEventMap {
21
- /**
22
- * A transition the lifecycle driver is ABOUT to validate/commit (logged
23
- * before any file write). The pre-step gate reads this as the caller of the
24
- * transition set — never a projection event (log-only internal signal).
25
- */
26
- 'recursive/phase-intent': {
27
- runId: string;
28
- worktreeRoot: string;
29
- targetArtifact: string;
30
- kind: 'lock' | 'reopen' | 'advance';
31
- evidence?: Record<string, unknown>;
32
- };
33
- /** A run was created/bootstrapped in this workspace. */
34
- 'recursive/run-created': {
35
- runId: string;
36
- worktreeRoot: string;
37
- template?: string;
38
- repo?: string;
39
- };
40
- /** A phase transition committed (validate -> commit -> flush -> emit). */
41
- 'recursive/phase': {
42
- runId: string;
43
- worktreeRoot: string;
44
- phase: string;
45
- status: string;
46
- };
47
- /** A phase artifact was locked (lock hash + timestamp recorded). */
48
- 'recursive/phase-locked': {
49
- runId: string;
50
- worktreeRoot: string;
51
- phase: string;
52
- lockedAt: string;
53
- lockHash: string;
54
- };
55
- /** A transition's gate predicate failed (rejected in strict, warned in advisory). */
56
- 'recursive/gate-blocked': {
57
- runId: string;
58
- worktreeRoot: string;
59
- phase: string;
60
- failures: string[];
61
- kind: string;
62
- };
63
- /** fs/observed detected a change to a locked artifact outside a tool call. */
64
- 'recursive/tamper': {
65
- runId: string;
66
- worktreeRoot: string;
67
- path: string;
68
- reason: string;
69
- };
70
- /** A run was merged back to the repo root (conversation history re-keys). */
71
- 'recursive/run-merged': {
72
- runId: string;
73
- worktreeRoot: string;
74
- repoRoot: string;
75
- };
76
- /** Run-level lifecycle flag (active/paused/blocked/complete + reason). */
77
- 'recursive/run-state': {
78
- runId: string;
79
- worktreeRoot: string;
80
- state: RecursiveRunState;
81
- reason?: string;
82
- };
83
- /** A subagent started for this run. */
84
- 'recursive/subagent-start': {
85
- runId: string;
86
- worktreeRoot: string;
87
- childId: string;
88
- role: string;
89
- provider: string;
90
- };
91
- /** A subagent finished for this run. */
92
- 'recursive/subagent-end': {
93
- runId: string;
94
- worktreeRoot: string;
95
- childId: string;
96
- role: string;
97
- provider: string;
98
- status: 'running' | 'done' | 'failed';
99
- };
100
- }
101
- }
102
- /** Enforce the runId + non-empty worktreeRoot invariant at emit time. */
103
- export declare function requireRunKey(runId: string, worktreeRoot: string): void;
104
- export interface PhaseIntentInput {
105
- runId: string;
106
- worktreeRoot: string;
107
- targetArtifact: string;
108
- kind: 'lock' | 'reopen' | 'advance';
109
- evidence?: Record<string, unknown>;
110
- }
111
- export interface RunCreatedInput {
112
- runId: string;
113
- worktreeRoot: string;
114
- template?: string;
115
- repo?: string;
116
- }
117
- export interface PhaseInput {
118
- runId: string;
119
- worktreeRoot: string;
120
- phase: string;
121
- status: string;
122
- }
123
- export interface PhaseLockedInput {
124
- runId: string;
125
- worktreeRoot: string;
126
- phase: string;
127
- lockedAt: string;
128
- lockHash: string;
129
- }
130
- export interface GateBlockedInput {
131
- runId: string;
132
- worktreeRoot: string;
133
- phase: string;
134
- failures: string[];
135
- kind: string;
136
- }
137
- export interface TamperInput {
138
- runId: string;
139
- worktreeRoot: string;
140
- path: string;
141
- reason: string;
142
- }
143
- export interface RunMergedInput {
144
- runId: string;
145
- worktreeRoot: string;
146
- repoRoot: string;
147
- }
148
- export interface RunStateInput {
149
- runId: string;
150
- worktreeRoot: string;
151
- state: RecursiveRunState;
152
- reason?: string;
153
- }
154
- export interface SubagentInput {
155
- runId: string;
156
- worktreeRoot: string;
157
- childId: string;
158
- role: string;
159
- provider: string;
160
- }
161
- export interface SubagentEndInput extends SubagentInput {
162
- status: 'running' | 'done' | 'failed';
163
- }
164
- export declare function phaseIntent(data: PhaseIntentInput): PhaseIntentInput;
165
- export declare function runCreated(data: RunCreatedInput): RunCreatedInput;
166
- export declare function phase(data: PhaseInput): PhaseInput;
167
- export declare function phaseLocked(data: PhaseLockedInput): PhaseLockedInput;
168
- export declare function gateBlocked(data: GateBlockedInput): GateBlockedInput;
169
- export declare function tamper(data: TamperInput): TamperInput;
170
- export declare function runMerged(data: RunMergedInput): RunMergedInput;
171
- export declare function runState(data: RunStateInput): RunStateInput;
172
- export declare function subagentStart(data: SubagentInput): SubagentInput;
173
- export declare function subagentEnd(data: SubagentEndInput): SubagentEndInput;