@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,118 @@
1
+ /**
2
+ * Idempotent scaffold installer + Stage B workflow init (R6). TS port of
3
+ * install-recursive-mode.py's core: bootstrap the /.recursive/ control plane
4
+ * when missing (no-op when present), enumerate runs as directory names only,
5
+ * and drive the agent/session-start Stage B (new vs resume) workspace-scoped
6
+ * to the session's control-plane root (R1).
7
+ */
8
+ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
11
+ export interface BootstrapResult {
12
+ root: string
13
+ bootstrapped: boolean
14
+ created: string[]
15
+ existing: string[]
16
+ }
17
+
18
+ export interface StageBResult {
19
+ root: string
20
+ source: 'new' | 'resume'
21
+ bootstrapped: boolean
22
+ runs: string[]
23
+ activeRunId?: string
24
+ }
25
+
26
+ const CONTROL_PLANE_FILES: Record<string, string> = {
27
+ 'RECURSIVE.md': '# recursive-mode\\n\\nrecursive-mode workflow. The canonical spec is maintained by the installed skill; this scaffold is the repo-local control plane.\\n',
28
+ 'STATE.md': '# STATE\\n\\nRepository state ledger.\\n',
29
+ 'DECISIONS.md': '# DECISIONS\\n\\nDecision ledger.\\n',
30
+ }
31
+
32
+ const MEMORY_FILES: Record<string, string> = {
33
+ 'memory/MEMORY.md': '# MEMORY\\n\\nDurable memory router.\\n',
34
+ 'memory/skills/SKILLS.md': '# SKILLS\\n\\nSkill memory router.\\n',
35
+ }
36
+
37
+ /**
38
+ * Idempotent scaffold install: creates the /.recursive/ control plane when
39
+ * missing. Never overwrites existing files; preserves unrelated content.
40
+ */
41
+ export function bootstrapScaffold(root: string): BootstrapResult {
42
+ const recursiveRoot = join(root, '.recursive')
43
+ const created: string[] = []
44
+ const existing: string[] = []
45
+
46
+ const writeIfMissing = (rel: string, content: string) => {
47
+ const path = join(recursiveRoot, rel)
48
+ if (existsSync(path)) {
49
+ existing.push(rel)
50
+ return
51
+ }
52
+ mkdirSync(join(path, '..'), { recursive: true })
53
+ writeFileSync(path, content, 'utf8')
54
+ created.push(rel)
55
+ }
56
+
57
+ for (const [rel, content] of Object.entries(CONTROL_PLANE_FILES)) writeIfMissing(rel, content)
58
+ for (const [rel, content] of Object.entries(MEMORY_FILES)) writeIfMissing(rel, content)
59
+ // run/ + config/ + memory/ dirs (empty dirs for the scaffold)
60
+ for (const dir of ['run', 'config', 'memory/training']) {
61
+ const path = join(recursiveRoot, dir)
62
+ if (!existsSync(path)) {
63
+ mkdirSync(path, { recursive: true })
64
+ created.push(dir + '/')
65
+ } else {
66
+ existing.push(dir + '/')
67
+ }
68
+ }
69
+
70
+ return {
71
+ root,
72
+ bootstrapped: created.length > 0,
73
+ created,
74
+ existing,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Enumerate runs as directory names only (bounded O(#dirs)). Never reads
80
+ * run docs; never returns file paths.
81
+ */
82
+ export function enumerateRuns(root: string): string[] {
83
+ const runRoot = join(root, '.recursive', 'run')
84
+ if (!existsSync(runRoot)) return []
85
+ return readdirSync(runRoot, { withFileTypes: true })
86
+ .filter(d => {
87
+ if (d.isDirectory()) return true
88
+ if (!d.isSymbolicLink()) return false
89
+ try { return statSync(join(runRoot, d.name)).isDirectory() } catch { return false }
90
+ })
91
+ .map(d => d.name)
92
+ .sort()
93
+ }
94
+
95
+ export interface StageBInput {
96
+ root: string
97
+ source: 'new' | 'resume'
98
+ activeRunId?: string
99
+ }
100
+
101
+ /**
102
+ * Stage B workflow init at agent/session-start. New: bootstrap if missing +
103
+ * enumerate runs. Resume: never re-bootstrap; enumerate + note the active run
104
+ * (the caller re-reads only that run's current phase doc). Always scoped to
105
+ * the given control-plane root.
106
+ */
107
+ export function stageBWorkflowInit(input: StageBInput): StageBResult {
108
+ const { root, source, activeRunId } = input
109
+ const bootstrap = bootstrapScaffold(root)
110
+ const runs = enumerateRuns(root)
111
+ return {
112
+ root,
113
+ source,
114
+ bootstrapped: source === 'new' ? bootstrap.bootstrapped : false,
115
+ runs,
116
+ activeRunId,
117
+ }
118
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Board overlay (Phase D R5, §11.8): the kanban view over the 'recursive'
3
+ * projection. Pure read-only renderer — no filesystem, no run-file scraping.
4
+ * Cards are grouped by worktree root (the projection's outer key) and mapped
5
+ * to their current-phase lane via derive.columnForRun.
6
+ */
7
+ import { createElement } from 'react'
8
+ import type { RecursiveProjection, RecursiveRunCard } from '../types.ts'
9
+ import type { RecursiveProjectionReader } from './contract.ts'
10
+ import { cardFacts, columnForRun, KANBAN_LANES } from './derive.ts'
11
+
12
+
13
+ /** Flatten the worktree-grouped projection into a stable run list. */
14
+ export function listRuns(projection: RecursiveProjection | undefined): RecursiveRunCard[] {
15
+ if (projection === undefined) return []
16
+ const runs: RecursiveRunCard[] = []
17
+ for (const worktreeRoot of Object.keys(projection)) {
18
+ for (const runId of Object.keys(projection[worktreeRoot])) {
19
+ runs.push(projection[worktreeRoot][runId])
20
+ }
21
+ }
22
+ return runs
23
+ }
24
+
25
+ export interface BoardProps {
26
+ useProjection: RecursiveProjectionReader
27
+ }
28
+
29
+ export function Board({ useProjection }: BoardProps) {
30
+ const projection = useProjection('recursive') as RecursiveProjection | undefined
31
+ const runs = listRuns(projection)
32
+ if (runs.length === 0) {
33
+ return createElement('div', { className: 'rec-board', 'data-empty': true },
34
+ createElement('p', { className: 'rec-board-empty' }, 'No recursive runs in this workspace yet.'),
35
+ )
36
+ }
37
+ return createElement('div', { className: 'rec-board' },
38
+ KANBAN_LANES.map((lane) => {
39
+ const laneRuns = runs.filter((r) => columnForRun(r) === lane.id)
40
+ return createElement('section', { key: lane.id, className: 'rec-lane' },
41
+ createElement('h2', { className: 'rec-lane-title' }, lane.label),
42
+ laneRuns.map((run) => {
43
+ const facts = cardFacts(run)
44
+ return createElement('article', { key: run.worktreeRoot + '\u0000' + run.runId, className: 'rec-card' },
45
+ createElement('div', { className: 'rec-card-head' },
46
+ createElement('strong', {}, run.runId),
47
+ facts.tampered && createElement('span', { className: 'rec-badge rec-badge-tamper', title: 'tampered' }, '!'),
48
+ ),
49
+ createElement('div', { className: 'rec-card-progress' },
50
+ createElement('div', { className: 'rec-card-progress-fill', style: { width: Math.round(facts.progress * 100) + '%' } }),
51
+ ),
52
+ createElement('div', { className: 'rec-card-meta' },
53
+ createElement('span', {}, facts.currentPhase ?? 'no phases'),
54
+ createElement('span', { className: 'rec-badge' }, facts.state),
55
+ ),
56
+ )
57
+ }),
58
+ )
59
+ }),
60
+ )
61
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Structural client seams (self-contained). The real @deepseek-ai/dsh-client-*
3
+ * packages are HOST-INJECTED at bundle time via dsh.client.inject (never
4
+ * installed in this package's dependency tree — they depend on workspace:^
5
+ * peers that resolve only inside the DSH workspace). We declare the minimal
6
+ * shapes we consume so the pure client modules type-check and bundle in
7
+ * isolation; the DSH loader links them to the real runtime identities at
8
+ * compose time.
9
+ *
10
+ * One deliberate narrowing: the client only READS (R9 read-only). It never
11
+ * calls back into the host; every host fact arrives through the projection.
12
+ */
13
+
14
+ /** SessionEvent-like carrier (the projection/node fold reads events by shape). */
15
+ export interface ClientSessionEvent {
16
+ type: string
17
+ seq?: number
18
+ time?: number
19
+ data?: Record<string, unknown>
20
+ }
21
+
22
+ /** A session header (cwd is the control-plane root). */
23
+ export interface ClientSessionHeader {
24
+ cwd?: string
25
+ }
26
+
27
+ /** Minimal appendable session (log-only write seam). */
28
+ export interface ClientSession {
29
+ header: ClientSessionHeader
30
+ append(type: string, data: unknown): unknown
31
+ }
32
+
33
+ /** The client root context the bundle receives from the module loader. */
34
+ export interface ClientContext {
35
+ slots: ClientSlots
36
+ get(name: string): unknown
37
+ }
38
+
39
+ /** A registered slot's options (list/keyed entries carry id/order/label). */
40
+ export interface SlotOptions {
41
+ name: string
42
+ id?: string
43
+ order?: number
44
+ label?: string
45
+ children?: unknown
46
+ store?: unknown
47
+ locale?: unknown
48
+ registrant?: unknown
49
+ }
50
+
51
+ /** The slot registry surface the client injects into. */
52
+ export interface ClientSlots {
53
+ inject(seat: string, factory: (ctx: unknown) => () => void): () => void
54
+ register(options: SlotOptions, component: unknown): () => void
55
+ }
56
+
57
+ /** useProjection('recursive') reader — undefined = capability absent. */
58
+ export type RecursiveProjectionReader = (key: 'recursive') => unknown
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Pure derivation over the projected RecursiveRunCard (Phase D R5/R6/R7):
3
+ * kanban lane mapping, card facts, conversation-node keying, and inspector
4
+ * phase-row expansion. EVERYTHING here is a pure function over the wire card
5
+ * — no React, no fs, no session window, no /.recursive/run/ scraping (the
6
+ * read-only-client + projection-over-files invariant, 00-requirements R9).
7
+ *
8
+ * The card arrives from the 'recursive' session projection (whole value,
9
+ * host-computed); this module only derives presentation from it.
10
+ */
11
+ import type { RecursiveRunCard, RecursivePhaseRow, RecursiveRunState } from '../types.ts'
12
+
13
+ /** The ordered kanban lanes (§11.4): one lane per phase group. */
14
+ export interface RecursiveLane {
15
+ /** Stable lane id. */
16
+ id: string
17
+ /** Human label. */
18
+ label: string
19
+ /** Phase-group keys folded into this lane. */
20
+ groups: readonly string[]
21
+ }
22
+
23
+ export const KANBAN_LANES: readonly RecursiveLane[] = [
24
+ { id: '0', label: 'Worktree & Requirements', groups: ['00'] },
25
+ { id: '1/1.5', label: 'Analysis', groups: ['01', '01.5'] },
26
+ { id: '2', label: 'TO-BE Plan', groups: ['02'] },
27
+ { id: '3/3.5', label: 'Implementation + Code Review', groups: ['03', '03.5'] },
28
+ { id: '4', label: 'Tests', groups: ['04'] },
29
+ { id: '5', label: 'Manual QA', groups: ['05'] },
30
+ { id: '6-8', label: 'Closeout', groups: ['06', '07', '08'] },
31
+ ]
32
+
33
+ const GROUP_ORDER: readonly string[] = ['00', '01', '01.5', '02', '03', '03.5', '04', '05', '06', '07', '08']
34
+
35
+ /** Mandatory phase groups: a run is only 'locked'-valid when every one of these is present and LOCKED (01.5 is optional). */
36
+ const MANDATORY_GROUPS: readonly string[] = ['00', '01', '02', '03', '03.5', '04', '05', '06', '07', '08']
37
+
38
+ /** Map a group id to its ordinal for stable ordering. */
39
+ const GROUP_INDEX = new Map(GROUP_ORDER.map((g, i) => [g, i]))
40
+
41
+ /** Group id for a phase filename (e.g. '03.5-code-review.md' -> '03.5'); null for non-phase names. */
42
+ export function phaseGroupOf(phase: string): string | null {
43
+ const m = /^(\d{2}(?:\.\d)?)(?:-|$)/.exec(phase)
44
+ if (m === null) return null
45
+ const g = m[1]
46
+ return GROUP_INDEX.has(g) ? g : null
47
+ }
48
+
49
+ /** Kanban lane id for a phase filename. */
50
+ export function laneOf(phase: string): string | null {
51
+ const g = phaseGroupOf(phase)
52
+ if (g === null) return null
53
+ for (const lane of KANBAN_LANES) {
54
+ if ((lane.groups as readonly string[]).includes(g)) return lane.id
55
+ }
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * The run's current-phase lane: the highest-ordered phase group present in the
61
+ * card wins (a run is "at" its furthest phase). No phases -> lane 0.
62
+ */
63
+ export function columnForRun(card: RecursiveRunCard): string {
64
+ let best: string | null = null
65
+ let bestIndex = -1
66
+ for (const key of Object.keys(card.phases)) {
67
+ const g = phaseGroupOf(key)
68
+ if (g === null) continue
69
+ const idx = GROUP_INDEX.get(g) ?? -1
70
+ if (idx > bestIndex) {
71
+ bestIndex = idx
72
+ best = g
73
+ }
74
+ }
75
+ if (best === null) return '0'
76
+ return laneOf(best + '-x') ?? '0'
77
+ }
78
+
79
+ /** Subagent chip (one per childId, latest status). */
80
+ export interface RecursiveSubagentChip {
81
+ childId: string
82
+ role: string
83
+ provider: string
84
+ status: 'running' | 'done' | 'failed'
85
+ }
86
+
87
+ /** Presentation facts derived from one card (§11.4). */
88
+ export interface RecursiveCardFacts {
89
+ runId: string
90
+ worktreeRoot: string
91
+ repo?: string
92
+ template?: string
93
+ state: RecursiveRunState
94
+ stateReason?: string
95
+ /** Phases locked so far. */
96
+ lockedCount: number
97
+ /** Distinct phase groups present (progress denominator). */
98
+ totalPhases: number
99
+ /** lockedCount / totalPhases (0 when totalPhases is 0). */
100
+ progress: number
101
+ /** Any tamper fact present. */
102
+ tampered: boolean
103
+ /** Latest gate block present. */
104
+ gateBlocked: boolean
105
+ gateKind?: string
106
+ /** Run was merged to a repo root. */
107
+ merged: boolean
108
+ mergedToRepoRoot?: string
109
+ /** Lock validity: tampered | locked | in-progress. */
110
+ lockValidity: 'tampered' | 'locked' | 'in-progress'
111
+ /** Latest current-phase label (or null). */
112
+ currentPhase: string | null
113
+ /** Subagent chips, latest status per child. */
114
+ subagents: RecursiveSubagentChip[]
115
+ }
116
+
117
+ /** Derive §11.4 presentation facts from one card (no fs, no session). */
118
+ export function cardFacts(card: RecursiveRunCard): RecursiveCardFacts {
119
+ const groups = new Set<string>()
120
+ for (const key of Object.keys(card.phases)) {
121
+ const g = phaseGroupOf(key)
122
+ if (g !== null) groups.add(g)
123
+ }
124
+ const lockedGroups = new Set<string>()
125
+ for (const key of Object.keys(card.phases)) {
126
+ const g = phaseGroupOf(key)
127
+ if (g !== null && card.phases[key].status === 'LOCKED') lockedGroups.add(g)
128
+ }
129
+ const lockedCount = lockedGroups.size
130
+ const totalPhases = groups.size
131
+
132
+ const subagents: RecursiveSubagentChip[] = Object.values(card.subagents).map(s => ({
133
+ childId: s.childId,
134
+ role: s.role,
135
+ provider: s.provider,
136
+ status: s.status ?? 'done',
137
+ }))
138
+
139
+ let currentPhase: string | null = null
140
+ let currentIdx = -1
141
+ for (const key of Object.keys(card.phases)) {
142
+ const g = phaseGroupOf(key)
143
+ if (g === null) continue
144
+ const idx = GROUP_INDEX.get(g) ?? -1
145
+ if (idx > currentIdx) {
146
+ currentIdx = idx
147
+ currentPhase = key
148
+ }
149
+ }
150
+
151
+ const tampered = Object.keys(card.tampers).length > 0
152
+ // Lock validity: tampered beats everything; else every MANDATORY phase present
153
+ // and LOCKED -> locked; else still in-progress (01.5 stays optional).
154
+ const allMandatoryLocked = MANDATORY_GROUPS.every((g) => {
155
+ const row = Object.entries(card.phases).find(([key]) => phaseGroupOf(key) === g)
156
+ return row !== undefined && row[1].status === 'LOCKED'
157
+ })
158
+ const lockValidity: RecursiveCardFacts['lockValidity'] = tampered
159
+ ? 'tampered'
160
+ : allMandatoryLocked
161
+ ? 'locked'
162
+ : 'in-progress'
163
+
164
+ return {
165
+ runId: card.runId,
166
+ worktreeRoot: card.worktreeRoot,
167
+ repo: card.repo,
168
+ template: card.template,
169
+ state: card.state,
170
+ stateReason: card.stateReason,
171
+ lockedCount,
172
+ totalPhases,
173
+ progress: totalPhases === 0 ? 0 : lockedCount / totalPhases,
174
+ tampered,
175
+ gateBlocked: card.gateBlocked !== undefined,
176
+ gateKind: card.gateBlocked?.kind,
177
+ merged: card.mergedToRepoRoot !== undefined,
178
+ mergedToRepoRoot: card.mergedToRepoRoot,
179
+ lockValidity,
180
+ currentPhase,
181
+ subagents,
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Stable conversation-node business id: runId + worktreeRoot (§11.6). The
187
+ * worktree root is part of the id so two runs with the same runId in
188
+ * different worktrees never collide — never "latest unfinished".
189
+ */
190
+ export function nodeKeyOf(runId: string, worktreeRoot: string): string {
191
+ return worktreeRoot + '\u0000' + runId
192
+ }
193
+
194
+ /** One expanded inspector phase row (§11.5). */
195
+ export interface RecursivePhaseInspectorRow {
196
+ /** Normalized phase key (group only, e.g. '03.5'). */
197
+ phase: string
198
+ /** Full filename if present, else null (a mandatory-empty row). */
199
+ fileName: string | null
200
+ status: string
201
+ lockedAt?: string
202
+ lockHash?: string
203
+ /** Whether the row is present in the card (vs a mandatory-empty slot). */
204
+ present: boolean
205
+ }
206
+
207
+ /**
208
+ * Expand a card's phase chain into full inspector rows: the always-shown
209
+ * '03.5' slot (§11.10 mandatory deviation) plus 01.5 when present, and
210
+ * 06/07/08 as three distinct rows (never one merged lane).
211
+ */
212
+ export function expandPhaseRows(card: RecursiveRunCard): RecursivePhaseInspectorRow[] {
213
+ const byGroup = new Map<string, RecursivePhaseRow>()
214
+ for (const [fileName, row] of Object.entries(card.phases)) {
215
+ const g = phaseGroupOf(fileName)
216
+ if (g !== null) byGroup.set(g, row)
217
+ }
218
+
219
+ const rows: RecursivePhaseInspectorRow[] = []
220
+ for (const g of GROUP_ORDER) {
221
+ // 01.5 is optional: only emit when present.
222
+ if (g === '01.5' && !byGroup.has(g)) continue
223
+ // 03.5 is always shown (§11.10).
224
+ const row = byGroup.get(g)
225
+ if (row === undefined) {
226
+ if (g === '03.5') {
227
+ rows.push({ phase: g, fileName: null, status: '—', present: false })
228
+ }
229
+ continue
230
+ }
231
+ rows.push({
232
+ phase: g,
233
+ fileName: row.phase,
234
+ status: row.status,
235
+ lockedAt: row.lockedAt,
236
+ lockHash: row.lockHash,
237
+ present: true,
238
+ })
239
+ }
240
+ return rows
241
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Client entry (Phase D R4): the browser half of dsh-recursive-mode. Receives
3
+ * the client root context from the DSH loader and registers the board,
4
+ * inspector, conversation node, status strip, and settings page (§11.8).
5
+ * READ-ONLY (R9): every host fact arrives through the recursive projection;
6
+ * this module never touches the filesystem.
7
+ *
8
+ * The apply(ctx) surface mirrors the shipped dsh-client-ui-* packages; the
9
+ * dsh.client.inject list in package.json names the host packages the loader
10
+ * must provide (they are injected at compose time, never installed here).
11
+ */
12
+ import type { ClientContext } from './contract.ts'
13
+ import { registerSlots } from './slots.ts'
14
+
15
+ export { recursiveNodeDefinition, RECURSIVE_NODE_KIND, matchRecursiveNode, foldRecursiveNode } from './node.ts'
16
+ export type { RecursiveNodeData, RecursiveNodeState } from './node.ts'
17
+ export { Board, listRuns } from './board.tsx'
18
+ export { Inspector } from './inspector.tsx'
19
+ export { StatusStrip } from './strip.tsx'
20
+ export { RecursiveSettings } from './settings.tsx'
21
+ export type { RecursiveProjectionReader } from './contract.ts'
22
+
23
+ /** Browser-half plugin entry (R4). */
24
+ export function apply(ctx: ClientContext): void {
25
+ // The conversation node is registered through the node-inject seam; the
26
+ // other surfaces through the slot registry.
27
+ registerSlots(ctx)
28
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Drill-down inspector (Phase D R6, §11.5): the details-seat swap rendering
3
+ * one run's phase chain (with the always-shown 3.5 row + full 6/7/8 rows),
4
+ * lock badges, and tamper/gate facts — all from the projection, never disk.
5
+ */
6
+ import { createElement } from 'react'
7
+ import type { RecursiveProjection } from '../types.ts'
8
+ import { cardFacts, expandPhaseRows } from './derive.ts'
9
+ import type { RecursiveProjectionReader } from './contract.ts'
10
+
11
+ export interface InspectorProps {
12
+ runId: string
13
+ worktreeRoot: string
14
+ useProjection: RecursiveProjectionReader
15
+ onBackToToolDetails: () => void
16
+ }
17
+
18
+ export function Inspector({ runId, worktreeRoot, useProjection, onBackToToolDetails }: InspectorProps) {
19
+ const projection = useProjection('recursive') as RecursiveProjection | undefined
20
+ const card = projection?.[worktreeRoot]?.[runId]
21
+ if (card === undefined) {
22
+ return createElement('div', { className: 'rec-inspector' },
23
+ createElement('p', {}, 'Run not found: ' + runId),
24
+ )
25
+ }
26
+ const facts = cardFacts(card)
27
+ const rows = expandPhaseRows(card)
28
+ return createElement('div', { className: 'rec-inspector' },
29
+ createElement('div', { className: 'rec-inspector-head' },
30
+ createElement('button', { onClick: onBackToToolDetails, className: 'rec-back' }, '← back to tool details'),
31
+ createElement('h2', {}, runId),
32
+ createElement('span', { className: 'rec-badge' }, facts.state),
33
+ facts.tampered && createElement('span', { className: 'rec-badge rec-badge-tamper' }, 'tampered'),
34
+ ),
35
+ createElement('section', { className: 'rec-inspector-section' },
36
+ createElement('h3', {}, 'Phases'),
37
+ rows.map((row) => createElement('div', { key: row.phase, className: 'rec-phase-row' },
38
+ createElement('span', { className: 'rec-phase-id' }, row.phase),
39
+ createElement('span', { className: 'rec-phase-name' }, row.fileName ?? '—'),
40
+ createElement('span', { className: 'rec-badge' }, row.status),
41
+ row.lockHash !== undefined && createElement('code', { className: 'rec-lockhash' }, '#' + row.lockHash.slice(0, 8)),
42
+ )),
43
+ ),
44
+ facts.gateBlocked && createElement('section', { className: 'rec-inspector-section' },
45
+ createElement('h3', {}, 'Gate'),
46
+ createElement('p', {}, 'Blocked (' + (facts.gateKind ?? '') + ')'),
47
+ ),
48
+ )
49
+ }