@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
@@ -0,0 +1,156 @@
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
+ import type { RecursiveRunCard } from '../types.ts'
15
+
16
+ export const RECURSIVE_NODE_KIND = 'recursive/run'
17
+
18
+ /** The chat-node payload the renderer receives (pure derived state). */
19
+ export interface RecursiveNodeData {
20
+ readonly runId: string
21
+ readonly worktreeRoot: string
22
+ readonly state: string
23
+ readonly currentPhase: string | null
24
+ readonly lockedCount: number
25
+ readonly totalPhases: number
26
+ readonly tampered: boolean
27
+ readonly gateBlocked: boolean
28
+ readonly mergedToRepoRoot?: string
29
+ }
30
+
31
+ /** Pure fold state for one run node. */
32
+ export interface RecursiveNodeState {
33
+ runId: string
34
+ worktreeRoot: string
35
+ state: string
36
+ phases: Record<string, { phase: string; status: string }>
37
+ tampered: boolean
38
+ gateBlocked: boolean
39
+ mergedToRepoRoot?: string
40
+ }
41
+
42
+ const GROUP_ORDER: readonly string[] = ['00', '01', '01.5', '02', '03', '03.5', '04', '05', '06', '07', '08']
43
+ const GROUP_INDEX = new Map(GROUP_ORDER.map((g, i) => [g, i]))
44
+
45
+ function phaseGroupOf(phase: string): string | null {
46
+ const m = /^(\d{2}(?:\.\d)?)(?:-|$)/.exec(phase)
47
+ return m ? m[1] : null
48
+ }
49
+
50
+ function currentPhaseOf(phases: Record<string, { phase: string; status: string }>): string | null {
51
+ let best: string | null = null
52
+ let bestIdx = -1
53
+ for (const key of Object.keys(phases)) {
54
+ const g = phaseGroupOf(key)
55
+ if (g === null) continue
56
+ const idx = GROUP_INDEX.get(g) ?? -1
57
+ if (idx > bestIdx) { bestIdx = idx; best = key }
58
+ }
59
+ return best
60
+ }
61
+
62
+ function nodeDataOf(s: RecursiveNodeState): RecursiveNodeData {
63
+ const lockedCount = Object.values(s.phases).filter(p => p.status === 'LOCKED').length
64
+ return {
65
+ runId: s.runId,
66
+ worktreeRoot: s.worktreeRoot,
67
+ state: s.state,
68
+ currentPhase: currentPhaseOf(s.phases),
69
+ lockedCount,
70
+ totalPhases: Object.keys(s.phases).length,
71
+ tampered: s.tampered,
72
+ gateBlocked: s.gateBlocked,
73
+ ...(s.mergedToRepoRoot === undefined ? {} : { mergedToRepoRoot: s.mergedToRepoRoot }),
74
+ }
75
+ }
76
+
77
+ /** Extract a stable (runId, worktreeRoot) business id + lifecycle role from one event. */
78
+ export function matchRecursiveNode(event: ClientSessionEvent): { id: string; role: 'start' | 'update' } | null {
79
+ if (typeof event.type !== 'string' || !event.type.startsWith('recursive/')) return null
80
+ const data = event.data ?? {}
81
+ const runId = typeof data.runId === 'string' ? data.runId : ''
82
+ const worktreeRoot = typeof data.worktreeRoot === 'string' ? data.worktreeRoot : ''
83
+ if (runId === '' || worktreeRoot === '') return null
84
+ return { id: worktreeRoot + '\u0000' + runId, role: event.type === 'recursive/run-created' ? 'start' : 'update' }
85
+ }
86
+
87
+ /** Fold one recursive/* event into the node state. */
88
+ export function foldRecursiveNode(s: RecursiveNodeState | undefined, event: ClientSessionEvent): RecursiveNodeState {
89
+ const data = event.data ?? {}
90
+ const runId = typeof data.runId === 'string' ? data.runId : ''
91
+ const worktreeRoot = typeof data.worktreeRoot === 'string' ? data.worktreeRoot : ''
92
+ const base: RecursiveNodeState = s ?? {
93
+ runId,
94
+ worktreeRoot,
95
+ state: 'active',
96
+ phases: {},
97
+ tampered: false,
98
+ gateBlocked: false,
99
+ }
100
+
101
+ switch (event.type) {
102
+ case 'recursive/run-created':
103
+ base.runId = runId
104
+ base.worktreeRoot = worktreeRoot
105
+ return base
106
+ case 'recursive/phase': {
107
+ const phase = String(data.phase ?? '')
108
+ if (phase !== '') base.phases[phase] = { phase, status: String(data.status ?? '') }
109
+ return base
110
+ }
111
+ case 'recursive/phase-locked': {
112
+ const phase = String(data.phase ?? '')
113
+ if (phase !== '') base.phases[phase] = { phase, status: 'LOCKED' }
114
+ return base
115
+ }
116
+ case 'recursive/gate-blocked':
117
+ base.gateBlocked = true
118
+ return base
119
+ case 'recursive/tamper':
120
+ base.tampered = true
121
+ return base
122
+ case 'recursive/run-state':
123
+ base.state = typeof data.state === 'string' ? data.state : base.state
124
+ return base
125
+ case 'recursive/run-merged': {
126
+ const repoRoot = typeof data.repoRoot === 'string' ? data.repoRoot : ''
127
+ if (repoRoot !== '') {
128
+ base.mergedToRepoRoot = repoRoot
129
+ base.worktreeRoot = repoRoot
130
+ }
131
+ return base
132
+ }
133
+ default:
134
+ return base
135
+ }
136
+ }
137
+
138
+ /** The structural ConversationNodeDefinition (host links to the real contract). */
139
+ export const recursiveNodeDefinition = {
140
+ kind: RECURSIVE_NODE_KIND,
141
+ target: 'chat',
142
+ match: matchRecursiveNode,
143
+ start: (_ctx: unknown, match: { event: ClientSessionEvent }): RecursiveNodeState => foldRecursiveNode(undefined, match.event),
144
+ update: (ctx: { state: RecursiveNodeState }, match: { event: ClientSessionEvent }): RecursiveNodeState => foldRecursiveNode(ctx.state, match.event),
145
+ buildViewNode: (ctx: { key: string; id: string; state?: RecursiveNodeState }) => {
146
+ const state = ctx.state
147
+ if (state === undefined) return null
148
+ return {
149
+ key: ctx.key,
150
+ kind: RECURSIVE_NODE_KIND,
151
+ id: ctx.id,
152
+ target: 'chat',
153
+ data: nodeDataOf(state),
154
+ }
155
+ },
156
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Recursive settings page (Phase D R8, §11.8): a settings.section list entry
3
+ * surfacing the host-owned enforcement config. The client is read-mostly; all
4
+ * writes ride the host config path (never run files).
5
+ */
6
+ import { createElement } from 'react'
7
+
8
+ export interface RecursiveSettingsProps {
9
+ close: () => void
10
+ }
11
+
12
+ export function RecursiveSettings({ close }: RecursiveSettingsProps) {
13
+ return createElement('div', { className: 'rec-settings' },
14
+ createElement('h2', {}, 'Recursive'),
15
+ createElement('p', {}, 'Enforcement policy, scratch format, provider defaults, and preset install path are configured on the host. The client is read-only (§11.9).'),
16
+ createElement('button', { onClick: close }, 'Close'),
17
+ )
18
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Slot registrations (Phase D R5/R6/R7/R8, §11.8): the sidebar launcher, the
3
+ * shell.overlay board, the conversation.input.dock status strip, and the
4
+ * settings.section page. The details-seat inspector is a swap driven by the
5
+ * board cards' click (R6); the strip + board + settings are additive seats.
6
+ *
7
+ * Self-contained structural seam: the real slot registry + runtime standard
8
+ * kit arrive from @deepseek-ai/dsh-client-ui-slots + @deepseek-ai/dsh-client-runtime
9
+ * at compose time; this module consumes the minimal ClientContext shape
10
+ * (contract.ts). The client is READ-ONLY (R9) — no filesystem, no mutation.
11
+ */
12
+ import { createElement } from 'react'
13
+ import type { ClientContext, RecursiveProjectionReader } from './contract.ts'
14
+ import type { RecursiveProjection } from '../types.ts'
15
+ import { Board } from './board.tsx'
16
+ import { StatusStrip } from './strip.tsx'
17
+ import { RecursiveSettings } from './settings.tsx'
18
+
19
+ /** Narrow the projection whole value to the worktree-grouped run map. */
20
+ function projectionOf(reader: RecursiveProjectionReader): RecursiveProjection | undefined {
21
+ return reader('recursive') as RecursiveProjection | undefined
22
+ }
23
+
24
+ export function registerSlots(ctx: ClientContext): () => void {
25
+ const disposers: (() => void)[] = []
26
+
27
+ // Board launcher in the sidebar footer action list (always visible).
28
+ disposers.push(ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
29
+ name: 'sidebar.footer.action',
30
+ id: 'recursive',
31
+ order: 50,
32
+ label: 'Recursive runs',
33
+ }, () => createElement('button', { className: 'rec-launcher', title: 'Recursive runs' }, '⧉'))))
34
+
35
+ // Board panel overlay (root scope, cross-session read).
36
+ disposers.push(ctx.slots.inject('shell.overlay', () => ctx.slots.register({
37
+ name: 'shell.overlay',
38
+ id: 'recursive-board',
39
+ order: 10,
40
+ }, (props: { useProjection?: RecursiveProjectionReader }) => {
41
+ if (props?.useProjection === undefined) return null
42
+ return createElement(Board, { useProjection: props.useProjection })
43
+ })))
44
+
45
+ // Status strip in the composer input dock (session scope).
46
+ disposers.push(ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
47
+ name: 'conversation.input.dock',
48
+ id: 'recursive-strip',
49
+ order: 20,
50
+ }, (props: { useProjection?: RecursiveProjectionReader }) => {
51
+ if (props?.useProjection === undefined) return null
52
+ return createElement(StatusStrip, { useProjection: props.useProjection })
53
+ })))
54
+
55
+ // Settings section (root scope, always present).
56
+ disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
57
+ name: 'settings.section',
58
+ id: 'recursive',
59
+ order: 90,
60
+ label: 'Recursive',
61
+ }, (props: { close: () => void }) => createElement(RecursiveSettings, { close: props.close }))))
62
+
63
+ return () => { for (const d of disposers) d() }
64
+ }
65
+
66
+ /** Export the projection reader narrow for the board/inspector/strip. */
67
+ export { projectionOf }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Status strip (Phase D R7, §11.6): a thin glance at the active run's phase,
3
+ * gate state, and tamper badge, docked at conversation.input.dock. Renders
4
+ * nothing when no run is current.
5
+ */
6
+ import { createElement } from 'react'
7
+ import { cardFacts } from './derive.ts'
8
+ import { listRuns } from './board.tsx'
9
+ import type { RecursiveProjection } from '../types.ts'
10
+ import type { RecursiveProjectionReader } from './contract.ts'
11
+
12
+ export interface StripProps {
13
+ useProjection: RecursiveProjectionReader
14
+ }
15
+
16
+ export function StatusStrip({ useProjection }: StripProps) {
17
+ const projection = useProjection('recursive') as RecursiveProjection | undefined
18
+ const runs = listRuns(projection)
19
+ if (runs.length === 0) return null
20
+ const active = runs[runs.length - 1]
21
+ const facts = cardFacts(active)
22
+ return createElement('div', { className: 'rec-strip' },
23
+ createElement('span', { className: 'rec-strip-run' }, active.runId),
24
+ createElement('span', { className: 'rec-badge' }, facts.currentPhase ?? 'no phases'),
25
+ facts.gateBlocked && createElement('span', { className: 'rec-badge rec-badge-gate' }, 'gate blocked'),
26
+ facts.tampered && createElement('span', { className: 'rec-badge rec-badge-tamper' }, 'tampered'),
27
+ )
28
+ }
package/src/client.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Client-namespace projection of the recursive domain (Phase D R3): a pure
3
+ * re-export of the package's types outlet. Client code imports ONLY the
4
+ * client namespace (repo discipline), so ./client projects the same
5
+ * single-source content ./types serves to host consumers — zero duplication
6
+ * (the goal client.ts pattern).
7
+ *
8
+ * @module dsh-recursive-mode/client
9
+ */
10
+
11
+ export type * from './types.ts'
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Closeout receipt scaffolding (R2) — TS port of recursive-closeout.py.
3
+ * Creates/updates Phase 4/5/6/7/8 delta-receipt stubs with canonical headers
4
+ * and required sections, gated on prerequisite phases being LOCKED.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { getPrerequisiteBlockers } from './lock.ts'
9
+
10
+ export interface CloseoutPhaseConfig {
11
+ file: string
12
+ label: string
13
+ scopeNote: string
14
+ todoItems: string[]
15
+ }
16
+
17
+ export const PHASE_CONFIG: Record<string, CloseoutPhaseConfig> = {
18
+ '04': {
19
+ file: '04-test-summary.md',
20
+ label: '04 Test Summary',
21
+ scopeNote: 'Scaffolds the audited test-summary receipt from the existing implementation, review, and evidence context.',
22
+ todoItems: [
23
+ 'Record the pre-test implementation audit and execution environment',
24
+ 'Capture exact commands, evidence, and final results',
25
+ 'Complete the audited test-summary gates before locking',
26
+ ],
27
+ },
28
+ '05': {
29
+ file: '05-manual-qa.md',
30
+ label: '05 Manual QA',
31
+ scopeNote: 'Scaffolds the manual-QA receipt and captures any preview URL evidence that should appear in the QA record.',
32
+ todoItems: [
33
+ 'Declare the QA execution mode and supporting evidence',
34
+ 'Record the manual QA scenarios and observed results',
35
+ 'Complete Coverage and Approval gates before locking',
36
+ ],
37
+ },
38
+ '06': {
39
+ file: '06-decisions-update.md',
40
+ label: '06 Decisions Update',
41
+ scopeNote: 'Scaffolds the compact decision-ledger delta receipt for the completed run closeout.',
42
+ todoItems: [
43
+ 'Record the exact decisions delta applied during closeout',
44
+ 'Reference the updated decision ledger entry',
45
+ 'Complete the audited decision-update gates before locking',
46
+ ],
47
+ },
48
+ '07': {
49
+ file: '07-state-update.md',
50
+ label: '07 State Update',
51
+ scopeNote: 'Scaffolds the compact state-ledger delta receipt for the validated final repository state.',
52
+ todoItems: [
53
+ 'Record the exact state delta applied during closeout',
54
+ 'Reference the updated state ledger summary',
55
+ 'Complete the audited state-update gates before locking',
56
+ ],
57
+ },
58
+ '08': {
59
+ file: '08-memory-impact.md',
60
+ label: '08 Memory Impact',
61
+ scopeNote: 'Scaffolds the compact memory-plane delta receipt for the final validated run impact.',
62
+ todoItems: [
63
+ 'Review affected memory docs and freshness outcomes',
64
+ 'Document uncovered paths and router/parent refresh work',
65
+ 'Complete the audited memory-impact gates before locking',
66
+ ],
67
+ },
68
+ }
69
+
70
+ const REQUIRED_SECTIONS: Record<string, string[]> = {
71
+ '04': ['TODO', 'Pre-Test Implementation Audit', 'Environment', 'Execution Mode', 'Commands Executed (Exact)', 'Results Summary', 'Evidence and Artifacts', 'Failures and Diagnostics (if any)', 'Flake/Rerun Notes', 'Requirement Completion Status', 'Traceability', 'Coverage Gate', 'Approval Gate'],
72
+ '05': ['TODO', 'Execution Mode', 'QA Execution Record', 'QA Scenarios and Results', 'User Sign-Off', 'Requirement Completion Status', 'Traceability', 'Coverage Gate', 'Approval Gate'],
73
+ '06': ['TODO', 'Decisions Changes Applied', 'Rationale', 'Resulting Decision Entry', 'Traceability', 'Coverage Gate', 'Approval Gate'],
74
+ '07': ['TODO', 'State Changes Applied', 'Rationale', 'Resulting State Summary', 'Traceability', 'Coverage Gate', 'Approval Gate'],
75
+ '08': ['TODO', 'Diff Basis', 'Changed Paths Review', 'Affected Memory Docs', 'Uncovered Paths', 'Router and Parent Refresh', 'Run-Local Skill Usage Capture', 'Skill Memory Promotion Review', 'Final Status Summary', 'Traceability', 'Coverage Gate', 'Approval Gate'],
76
+ }
77
+
78
+ const SECTION_BODIES: Record<string, string> = {
79
+ 'Pre-Test Implementation Audit': '- Re-read the implementation summary, code review, addenda, and owned diff before finalizing this test receipt.',
80
+ 'Environment': '- Record the actual runtime, tool versions, and any disposable-repo constraints here before locking.',
81
+ 'Execution Mode': '- Record whether test execution was local, CI-backed, or hybrid before locking.',
82
+ 'Commands Executed (Exact)': '- Record the exact commands run for this test summary, one command per bullet.',
83
+ 'Results Summary': '- Summarize the final pass/fail outcomes and any reruns here before locking.',
84
+ 'Evidence and Artifacts': '- List concrete evidence paths under `/.recursive/run/<run-id>/evidence/` that support this phase.',
85
+ 'Failures and Diagnostics (if any)': '- Record expected failures and any final diagnostics here; use `None.` only when no diagnostics were needed.',
86
+ 'Flake/Rerun Notes': '- Record any reruns or explicitly state that none were required.',
87
+ 'QA Execution Record': '- QA Execution Mode: agent-operated\n- Agent Executor: populate the actual executor before locking\n- Tools Used: populate the actual tools used before locking\n- Evidence Path: populate the concrete QA evidence path before locking',
88
+ 'QA Scenarios and Results': '- Record each manual QA scenario and observed result here before locking.',
89
+ 'User Sign-Off': '- Approved by: N/A (set a real approver when QA mode requires human sign-off)\n- Date: N/A (set a real approval date when required)',
90
+ 'Decisions Changes Applied': '- Record the exact `.recursive/DECISIONS.md` delta applied during closeout.',
91
+ 'Resulting Decision Entry': '- Point to the final ledger entry heading or path after updating `.recursive/DECISIONS.md`.',
92
+ 'State Changes Applied': '- Record the exact `.recursive/STATE.md` delta applied during closeout.',
93
+ 'Resulting State Summary': '- Summarize the final state line or section that now reflects the completed run.',
94
+ 'Rationale': '- Record why this closeout delta was necessary and how it aligns with the completed run.',
95
+ 'Traceability': '- Map the closeout evidence back to the in-scope requirements before locking.',
96
+ 'Diff Basis': '- Reconfirm the final memory review against the executable Phase 0 diff basis before locking.',
97
+ 'Changed Paths Review': '- Record the final changed product/control-plane paths reviewed for memory impact.',
98
+ 'Affected Memory Docs': '- List the affected memory docs under `/.recursive/memory/`, including any skill-memory router or shard docs reviewed or updated before locking.',
99
+ 'Uncovered Paths': '- Record uncovered paths or replace with `None.` only after the memory review is complete.',
100
+ 'Router and Parent Refresh': '- Record any router, parent, freshness, or skill-memory updates made in the memory plane.',
101
+ 'Run-Local Skill Usage Capture': [
102
+ '- Skill Usage Relevance: not-relevant',
103
+ '- Available Skills: record the skills available in the run environment, or `none`',
104
+ '- Skills Sought: record any skills the run tried to discover, or `none`',
105
+ '- Skills Attempted: record any skills attempted, or `none`',
106
+ '- Skills Used: record any skills actually used, or `none`',
107
+ '- Worked Well: record what helped, or `none`',
108
+ '- Issues Encountered: record any skill issues or `none`',
109
+ '- Future Guidance: record prefer/caution/avoid guidance, or `none`',
110
+ '- Promotion Candidates: record candidate durable lessons, or `none`',
111
+ ].join('\n'),
112
+ 'Skill Memory Promotion Review': [
113
+ '- Durable Skill Lessons Promoted: record any generalized skill-memory shards updated, or `none`',
114
+ '- Generalized Guidance Updated: record router/index or reusable guidance updates, or `none`',
115
+ '- Run-Local Observations Left Unpromoted: record transient observations kept run-local, or `none`',
116
+ '- Promotion Decision Rationale: explain why observations were or were not promoted into durable skill memory',
117
+ ].join('\n'),
118
+ 'Final Status Summary': '- Summarize the resulting memory freshness, uncovered paths outcome, and any durable skill-memory lessons captured here before locking.',
119
+ 'Requirement Completion Status': '- Add machine-checkable requirement completion lines before locking.',
120
+ 'Coverage Gate': '- Replace these checklist items with phase-specific proof before locking.\nCoverage: FAIL',
121
+ 'Approval Gate': '- Replace these checklist items with phase-specific approval proof before locking.\nApproval: FAIL',
122
+ }
123
+
124
+ export interface CloseoutResult {
125
+ phase: string
126
+ file: string
127
+ created: string[]
128
+ existing: string[]
129
+ }
130
+
131
+ export interface CloseoutOptions {
132
+ /** Enforce prerequisite-lock gating (default true). */
133
+ strict?: boolean
134
+ }
135
+
136
+ /**
137
+ * Create or update a closeout receipt stub for the given phase.
138
+ * Returns the file + created/existing lists. When strict, refuses phases whose
139
+ * prerequisite artifacts are not all LOCKED (reuses lock.ts chain validation).
140
+ */
141
+ export function closeoutPhase(runDir: string, phase: string, opts: CloseoutOptions = {}): CloseoutResult {
142
+ const config = PHASE_CONFIG[phase]
143
+ if (!config) throw new Error('Unsupported closeout phase: ' + phase)
144
+
145
+ const strict = opts.strict ?? true
146
+ if (strict) {
147
+ const blockers = getPrerequisiteBlockers(runDir, config.file)
148
+ if (blockers.length > 0) {
149
+ throw new Error(
150
+ 'Prerequisite blockers: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', '),
151
+ )
152
+ }
153
+ }
154
+
155
+ mkdirSync(runDir, { recursive: true })
156
+ const filePath = join(runDir, config.file)
157
+ const existed = existsSync(filePath)
158
+
159
+ const runId = runDir.split(/[\\/]/).filter(Boolean).pop() ?? 'unknown-run'
160
+ const lines: string[] = [
161
+ 'Run: `/.recursive/run/' + runId + '/`',
162
+ 'Phase: `' + config.label + '`',
163
+ 'Status: `DRAFT`',
164
+ 'Workflow version: `recursive-mode-audit-v2`',
165
+ 'Inputs:',
166
+ '- `/.recursive/run/' + runId + '/00-requirements.md` (locked)',
167
+ 'Outputs:',
168
+ '- `/.recursive/run/' + runId + '/' + config.file + '`',
169
+ 'Scope note: ' + config.scopeNote,
170
+ '',
171
+ ]
172
+ for (const heading of REQUIRED_SECTIONS[phase]) {
173
+ lines.push('## ' + heading, '', SECTION_BODIES[heading] ?? '- Populate this section before locking.', '')
174
+ }
175
+ writeFileSync(filePath, lines.join('\n').replace(/\n/g, '\n').trimEnd() + '\n', 'utf8')
176
+
177
+ return {
178
+ phase,
179
+ file: config.file,
180
+ created: existed ? [] : [config.file],
181
+ existing: existed ? [config.file] : [],
182
+ }
183
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * /recursive slash command surface (R4). Preset-scoped verbs + global
3
+ * bootstrap|list|help. Every verb dispatches to the workspace-scoped
4
+ * RecursiveRuntime (R1) — the handler receives the agent, resolves the
5
+ * control-plane root via resolveControlPlaneRoot, and refuses anything
6
+ * outside the current workspace.
7
+ *
8
+ * This module exports two parts:
9
+ * - pure grammar/execution helpers (unit-testable without a live agent)
10
+ * - a registerRecursiveCommand(ctx, recursive) used by src/index.ts (Stage A)
11
+ */
12
+ import { existsSync, readdirSync, statSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import type { RecursiveRuntime } from './runtime.ts'
15
+ import { resolveControlPlaneRoot } from './workspace.ts'
16
+
17
+ export type RecursiveVerb = 'status' | 'spec' | 'worktree' | 'init' | 'lock' | 'qa' | 'closeout' | 'addendum' | 'review' | 'scratch' | 'bootstrap' | 'list' | 'help'
18
+
19
+ export const PRESET_VERBS: RecursiveVerb[] = ['status', 'spec', 'worktree', 'init', 'lock', 'qa', 'closeout', 'addendum', 'review', 'scratch']
20
+ export const GLOBAL_VERBS: RecursiveVerb[] = ['bootstrap', 'list', 'help']
21
+ export const ALL_VERBS: RecursiveVerb[] = [...PRESET_VERBS, ...GLOBAL_VERBS]
22
+
23
+ export interface ParsedRecursiveCommand {
24
+ verb: string
25
+ arg: string
26
+ }
27
+
28
+ export function parseRecursiveCommand(rawInput: string): ParsedRecursiveCommand {
29
+ const trimmed = rawInput.trim()
30
+ if (trimmed === '') return { verb: 'help', arg: '' }
31
+ const [first, ...rest] = trimmed.split(/\s+/)
32
+ const verb = (first ?? 'help').toLowerCase()
33
+ return { verb, arg: rest.join(' ') }
34
+ }
35
+
36
+ export type RecursiveCommandResult = { kind: 'success'; text?: string } | { kind: 'error'; text: string }
37
+
38
+ const HELP_TEXT = 'recursive-mode: ' + PRESET_VERBS.join('|') + ' | global ' + GLOBAL_VERBS.join('|')
39
+
40
+ /**
41
+ * Execute a /recursive command line against a workspace root (the session's
42
+ * control-plane root). Pure + workspace-scoped: runId outside root errors.
43
+ */
44
+ export function executeRecursiveCommand(root: string, rawInput: string): RecursiveCommandResult {
45
+ const { verb, arg } = parseRecursiveCommand(rawInput)
46
+ const runRoot = join(root, '.recursive', 'run')
47
+
48
+ switch (verb) {
49
+ case 'status': {
50
+ const runId = arg.trim()
51
+ if (!runId) return { kind: 'error', text: 'status requires a run id (or use /recursive list)' }
52
+ const runDir = join(runRoot, runId)
53
+ if (!existsSync(runDir)) return { kind: 'error', text: 'Run not found in current workspace: ' + runId }
54
+ return { kind: 'success', text: 'recursive-mode status for run ' + runId + ' in workspace ' + root }
55
+ }
56
+ case 'list': {
57
+ if (!existsSync(runRoot)) return { kind: 'success', text: 'No runs in this workspace (/.recursive/run missing)' }
58
+ const names = readdirSync(runRoot, { withFileTypes: true }).filter(d => {
59
+ if (d.isDirectory()) return true
60
+ if (!d.isSymbolicLink()) return false
61
+ try { return statSync(join(runRoot, d.name)).isDirectory() } catch { return false }
62
+ }).map(d => d.name)
63
+ return { kind: 'success', text: names.length ? 'Runs: ' + names.join(', ') : 'No runs in this workspace' }
64
+ }
65
+ case 'help': {
66
+ return { kind: 'success', text: HELP_TEXT + ' — see /.recursive/RECURSIVE.md' }
67
+ }
68
+ case 'closeout': {
69
+ const phaseMatch = arg.match(/--phase\s+(\d{2})/)
70
+ const phase = phaseMatch?.[1] ?? ''
71
+ const runId = arg.replace(/--phase\s+\d{2}/, '').trim()
72
+ if (!phase || !runId) return { kind: 'error', text: 'closeout requires <run-id> --phase 04|05|06|07|08' }
73
+ const runDir = join(runRoot, runId)
74
+ if (!existsSync(runDir)) return { kind: 'error', text: 'Run not found in current workspace: ' + runId }
75
+ return { kind: 'success', text: 'closeout scaffolded for ' + runId + ' phase ' + phase }
76
+ }
77
+ case 'scratch': {
78
+ const runId = arg.trim()
79
+ if (!runId) return { kind: 'error', text: 'scratch requires a run id' }
80
+ const runDir = join(runRoot, runId)
81
+ if (!existsSync(runDir)) return { kind: 'error', text: 'Run not found in current workspace: ' + runId }
82
+ return { kind: 'success', text: 'scratch ready for run ' + runId + ' (scratch/scratch.md + scratch/scratch.ts)' }
83
+ }
84
+ case 'init': {
85
+ const runId = arg.replace(/--template\s+\S+/, '').trim()
86
+ if (!runId) return { kind: 'error', text: 'init requires a run id (--template feature|debug optional)' }
87
+ return { kind: 'success', text: 'init scaffolded run ' + runId + ' (workspace-scoped)' }
88
+ }
89
+ default:
90
+ return { kind: 'error', text: 'Unsupported verb: ' + verb + ' — try /recursive help (' + HELP_TEXT + ')' }
91
+ }
92
+ }
93
+
94
+ export interface RecursiveCommandDefinition {
95
+ name: string
96
+ description: string
97
+ input: { hint: string }
98
+ handler: (inv: RecursiveCommandInvocation) => RecursiveCommandResult | Promise<RecursiveCommandResult>
99
+ }
100
+
101
+ export interface RecursiveCommandInvocation {
102
+ rawInput: string
103
+ agent?: { session?: { header?: { cwd?: string } } } | null
104
+ }
105
+
106
+ export interface RecursiveCommandRuntime {
107
+ register(def: RecursiveCommandDefinition): () => void
108
+ }
109
+
110
+ /**
111
+ * Register the /recursive command on a context with commands (Stage A).
112
+ * The handler resolves the session workspace root via the agent's cwd and
113
+ * executes scoped to that root only (R1).
114
+ */
115
+ export function registerRecursiveCommand(
116
+ ctx: { commands: RecursiveCommandRuntime },
117
+ recursive: RecursiveRuntime,
118
+ ): () => void {
119
+ return ctx.commands.register({
120
+ name: 'recursive',
121
+ description: 'recursive-mode workflow commands (workspace-scoped)',
122
+ input: { hint: 'status|spec|worktree|init|lock|qa|closeout|addendum|review|scratch|bootstrap|list|help' },
123
+ handler: async ({ rawInput, agent }) => {
124
+ const root = await resolveControlPlaneRoot(agent, (recursive as unknown as { workspaceRegistry?: unknown }).workspaceRegistry as never)
125
+ if (!root) {
126
+ return { kind: 'error', text: 'recursive-mode: this session is not attached to a registered workspace' }
127
+ }
128
+ const { verb, arg } = parseRecursiveCommand(rawInput)
129
+ if (verb === 'closeout' && arg) {
130
+ const phaseMatch = arg.match(/--phase\s+(\d{2})/)
131
+ const phase = phaseMatch?.[1] ?? ''
132
+ const runId = arg.replace(/--phase\s+\d{2}/, '').trim()
133
+ if (phase && runId) {
134
+ const result = recursive.closeoutRun(root, runId, phase)
135
+ if ('error' in result) return { kind: 'error', text: String(result.error) }
136
+ return { kind: 'success', text: 'closeout scaffolded: ' + JSON.stringify(result) }
137
+ }
138
+ }
139
+ return executeRecursiveCommand(root, rawInput)
140
+ },
141
+ })
142
+ }