@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.
- package/cordis.patch.yml +12 -0
- package/lib/bootstrap.d.ts +35 -0
- package/lib/client/board.d.ts +10 -0
- package/lib/client/contract.d.ts +51 -0
- package/lib/client/derive.d.ts +92 -0
- package/lib/client/index.d.ts +21 -0
- package/lib/client/inspector.d.ts +10 -0
- package/lib/client/node.d.ts +71 -0
- package/lib/client/settings.d.ts +6 -0
- package/lib/client/slots.d.ts +7 -0
- package/lib/client/strip.d.ts +7 -0
- package/lib/client.d.ts +10 -0
- package/lib/client.js +490 -0
- package/lib/closeout.d.ts +23 -0
- package/lib/commands.d.ts +51 -0
- package/lib/delegation.d.ts +92 -0
- package/lib/enforcement.d.ts +53 -0
- package/lib/events.d.ts +173 -0
- package/lib/handoff.d.ts +51 -0
- package/lib/index.d.ts +40 -0
- package/lib/lifecycle.d.ts +107 -0
- package/lib/lock.d.ts +92 -0
- package/lib/policy.d.ts +12 -0
- package/lib/projection.d.ts +29 -0
- package/lib/recursive_closeout.tool.d.ts +8 -0
- package/lib/recursive_init.tool.d.ts +2 -0
- package/lib/recursive_lint.tool.d.ts +2 -0
- package/lib/recursive_lock.tool.d.ts +2 -0
- package/lib/recursive_scratch.tool.d.ts +7 -0
- package/lib/recursive_status.tool.d.ts +2 -0
- package/lib/review.d.ts +39 -0
- package/lib/router.d.ts +77 -0
- package/lib/run.d.ts +29 -0
- package/lib/runtime.d.ts +241 -0
- package/lib/scratch.d.ts +18 -0
- package/lib/status.d.ts +19 -0
- package/lib/types.d.ts +104 -0
- package/lib/workspace.d.ts +50 -0
- package/package.json +119 -0
- package/preset/recursive/agent.cordis.yml +282 -0
- package/preset/recursive/preset.yml +3 -0
- package/scripts/install-recursive-mode.ps1 +956 -0
- package/scripts/install-recursive-mode.py +750 -0
- package/scripts/lint-recursive-run.py +2868 -0
- package/scripts/recursive-closeout.py +541 -0
- package/scripts/recursive-init.py +356 -0
- package/scripts/recursive-lock.py +302 -0
- package/scripts/recursive-status.py +2124 -0
- package/scripts/recursive_phase_rules.py +367 -0
- package/scripts/recursive_router_lib.py +2282 -0
- package/scripts/test-recursive-mode-smoke.ts +204 -0
- package/scripts/verify-locks.py +353 -0
- package/src/bootstrap.ts +118 -0
- package/src/client/board.tsx +61 -0
- package/src/client/contract.ts +58 -0
- package/src/client/derive.ts +241 -0
- package/src/client/index.ts +28 -0
- package/src/client/inspector.tsx +49 -0
- package/src/client/node.ts +156 -0
- package/src/client/settings.tsx +18 -0
- package/src/client/slots.ts +67 -0
- package/src/client/strip.tsx +28 -0
- package/src/client.ts +11 -0
- package/src/closeout.ts +183 -0
- package/src/commands.ts +142 -0
- package/src/delegation.ts +306 -0
- package/src/enforcement.ts +180 -0
- package/src/events.ts +173 -0
- package/src/handoff.ts +165 -0
- package/src/index.ts +283 -0
- package/src/lifecycle.ts +235 -0
- package/src/lock.ts +369 -0
- package/src/policy.ts +56 -0
- package/src/projection.ts +237 -0
- package/src/recursive_closeout.tool.ts +35 -0
- package/src/recursive_init.tool.ts +28 -0
- package/src/recursive_lint.tool.ts +29 -0
- package/src/recursive_lock.tool.ts +33 -0
- package/src/recursive_scratch.tool.ts +42 -0
- package/src/recursive_status.tool.ts +24 -0
- package/src/review.ts +178 -0
- package/src/router.ts +197 -0
- package/src/run.ts +85 -0
- package/src/runtime.ts +564 -0
- package/src/scratch.ts +85 -0
- package/src/status.ts +194 -0
- package/src/types.ts +112 -0
- package/src/workspace.ts +67 -0
package/src/bootstrap.ts
ADDED
|
@@ -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
|
+
}
|