@try-works/dsh-recursive-mode 0.4.3 → 0.4.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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Pure display model for the Recursive settings panel (read-only report).
3
+ *
4
+ * WHY THIS MODULE EXISTS. The old panel rendered one sentence and no values — it
5
+ * claimed to report the host's configuration while reporting nothing. The data
6
+ * was already arriving: `host-api.ts` serves {root, projection, revision} on
7
+ * every fs change plus a 15s heartbeat, and `derive.ts` already derives board
8
+ * facts from the same projection. This module adds no data path; it is the same
9
+ * projection read for a different surface, and it is PURE (no React, no fs, no
10
+ * session window — the read-only-client + projection-over-files invariant, §11.9).
11
+ *
12
+ * THE ABSENCE RULE (`text()` + `SettingsRow.value === null`): a value is printed
13
+ * only when the projection carried it. A missing/blank value renders as
14
+ * `not reported` — never an empty row and never a plausible default. Note that
15
+ * `derive.cardFacts()` deliberately defaults a subagent's absent `status` to
16
+ * 'done'; this model does NOT, because a report that invents "done" is a report
17
+ * that lies. Required collection fields (`tampers`, `subagents`) that are present
18
+ * but empty print `none` — that IS their value; fields the card omits print
19
+ * `not reported`.
20
+ */
21
+ import type { PhasePosition, RecursiveRunCard } from '../types.ts'
22
+ import type { LiveProjectionValue } from './contract.ts'
23
+ import { listRuns } from './board.tsx'
24
+ import { cardFacts, cardPill, expandPhaseRows, phaseStatusPill, type PillKind } from './derive.ts'
25
+
26
+ /** A label/value line. `value === null` means the projection carried NO value. */
27
+ export interface SettingsRow {
28
+ id: string
29
+ label: string
30
+ value: string | null
31
+ }
32
+
33
+ /** One folded phase row, with the T21 single derived position printed verbatim. */
34
+ export interface SettingsPhaseRow {
35
+ phase: string
36
+ /** The phase doc filename, or null for a slot this run does not have (03.5). */
37
+ fileName: string | null
38
+ /** The raw folded status ('LOCKED' / 'DRAFT' / '—'). */
39
+ status: string
40
+ /** T21 single derived position; null when the producer carried none. */
41
+ position: PhasePosition | null
42
+ lockedAt: string | null
43
+ lockHash: string | null
44
+ present: boolean
45
+ pill: PillKind
46
+ }
47
+
48
+ /** One subagent record: role/provider exactly as the host fold recorded them. */
49
+ export interface SettingsSubagentRow {
50
+ childId: string
51
+ role: string | null
52
+ provider: string | null
53
+ status: string | null
54
+ }
55
+
56
+ /** One unresolved in-flight work item (T18). */
57
+ export interface SettingsPendingRow {
58
+ kind: string
59
+ delegationId: string
60
+ detail: string
61
+ }
62
+
63
+ /** One run's report. */
64
+ export interface SettingsRunView {
65
+ runId: string
66
+ worktreeRoot: string
67
+ state: string
68
+ pill: PillKind
69
+ /** Scalar facts; a null value means the card did not carry that field. */
70
+ rows: SettingsRow[]
71
+ phases: SettingsPhaseRow[]
72
+ /** null when the card carried no `subagents` key; [] when it carried an empty one. */
73
+ subagents: SettingsSubagentRow[] | null
74
+ /** null when the card carried no `pendingWork` key; [] when it carried an empty one. */
75
+ pendingWork: SettingsPendingRow[] | null
76
+ }
77
+
78
+ /** Whether the route has answered, and whether it resolved a recursive root. */
79
+ export type SettingsStatus = 'no-frame' | 'no-root' | 'connected'
80
+
81
+ /** The whole panel model. */
82
+ export interface SettingsView {
83
+ status: SettingsStatus
84
+ root: string | null
85
+ revision: number | null
86
+ runs: SettingsRunView[]
87
+ /**
88
+ * Route-level facts the payload does not carry AT ALL — the panel names them
89
+ * so a reader is never left to assume the client is hiding them.
90
+ */
91
+ notCarried: SettingsRow[]
92
+ }
93
+
94
+ /**
95
+ * The `/state` payload is {root, projection, revision} (host-api.ts /
96
+ * live-route.ts, both typed by LiveProjectionValue). Nothing else rides it, so
97
+ * anything else is reported as not carried rather than guessed.
98
+ */
99
+ export const NOT_CARRIED: readonly SettingsRow[] = [
100
+ { id: 'enforcement', label: 'Enforcement modes (preStep / toolGuards / tamper)', value: null },
101
+ { id: 'router-defaults', label: 'Router defaults (defaults.subagent) and per-role routes', value: null },
102
+ { id: 'scratch-format', label: 'Scratch format', value: null },
103
+ ]
104
+
105
+ /** A carried string, or null when the producer carried nothing to print. */
106
+ function text(value: string | undefined | null): string | null {
107
+ if (value === undefined || value === null) return null
108
+ return value.trim() === '' ? null : value
109
+ }
110
+
111
+ /**
112
+ * Read a field the WIRE may omit even where the type calls it required (an
113
+ * older or partial producer). The comparison itself has to happen here: with
114
+ * `strict`, `card.subagents === undefined` is a no-overlap error at the call
115
+ * site, but a missing field must still read as "not reported", not crash.
116
+ */
117
+ function carried<T>(value: T | undefined): T | undefined {
118
+ return value
119
+ }
120
+
121
+ /** `none` for a present-but-empty map, null for a map the card did not carry. */
122
+ function tamperValue(card: RecursiveRunCard): string | null {
123
+ const tampers = carried(card.tampers)
124
+ if (tampers === undefined) return null
125
+ const facts = Object.values(tampers)
126
+ if (facts.length === 0) return 'none'
127
+ return facts.map((f) => (text(f.path) ?? '') + ': ' + (text(f.reason) ?? 'no reason carried')).join(' · ')
128
+ }
129
+
130
+ /** Scalar facts for one run, in a fixed order. */
131
+ function runRows(card: RecursiveRunCard): SettingsRow[] {
132
+ const facts = cardFacts(card)
133
+ const gate = card.gateBlocked
134
+ return [
135
+ { id: 'state', label: 'run state', value: text(card.state) },
136
+ { id: 'state-reason', label: 'state reason', value: text(card.stateReason) },
137
+ { id: 'lock', label: 'lock position', value: facts.lockValidity },
138
+ { id: 'locked', label: 'locked phase groups', value: String(facts.lockedCount) + ' of ' + String(facts.totalPhases) + ' present' },
139
+ { id: 'current-phase', label: 'current phase', value: text(facts.currentPhase) },
140
+ {
141
+ id: 'gate',
142
+ label: 'gate block',
143
+ value: gate === undefined
144
+ ? null
145
+ : (text(gate.kind) ?? 'kind not carried') + ': ' + (gate.failures.length === 0 ? 'no failures listed' : gate.failures.join('; ')),
146
+ },
147
+ { id: 'tampers', label: 'tamper facts', value: tamperValue(card) },
148
+ { id: 'repo', label: 'repo', value: text(card.repo) },
149
+ { id: 'template', label: 'template', value: text(card.template) },
150
+ { id: 'merged', label: 'merged to repo root', value: text(card.mergedToRepoRoot) },
151
+ ]
152
+ }
153
+
154
+ /** The folded phase chain, with the card's own `position` read off the wire row. */
155
+ function phaseRows(card: RecursiveRunCard): SettingsPhaseRow[] {
156
+ return expandPhaseRows(card).map((row) => {
157
+ const wired = row.fileName === null ? undefined : card.phases[row.fileName]
158
+ return {
159
+ phase: row.phase,
160
+ fileName: row.fileName,
161
+ status: row.status,
162
+ position: wired?.position ?? null,
163
+ lockedAt: text(row.lockedAt),
164
+ lockHash: text(row.lockHash),
165
+ present: row.present,
166
+ pill: phaseStatusPill(row.status),
167
+ }
168
+ })
169
+ }
170
+
171
+ /** One run card -> its report. */
172
+ export function settingsRunView(card: RecursiveRunCard): SettingsRunView {
173
+ const subagents = carried(card.subagents)
174
+ const pendingWork = carried(card.pendingWork)
175
+ return {
176
+ runId: card.runId,
177
+ worktreeRoot: card.worktreeRoot,
178
+ state: card.state,
179
+ pill: cardPill(card),
180
+ rows: runRows(card),
181
+ phases: phaseRows(card),
182
+ subagents: subagents === undefined
183
+ ? null
184
+ : Object.values(subagents).map((s) => ({
185
+ childId: s.childId,
186
+ role: text(s.role),
187
+ provider: text(s.provider),
188
+ status: text(s.status),
189
+ })),
190
+ pendingWork: pendingWork === undefined
191
+ ? null
192
+ : pendingWork.map((p) => ({ kind: p.kind, delegationId: p.delegationId, detail: p.detail })),
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The panel model for one live snapshot (null = the route has not answered yet).
198
+ * Every run in the projection is reported; nothing is filtered or defaulted.
199
+ */
200
+ export function settingsView(snapshot: LiveProjectionValue | null): SettingsView {
201
+ if (snapshot === null) {
202
+ return { status: 'no-frame', root: null, revision: null, runs: [], notCarried: [...NOT_CARRIED] }
203
+ }
204
+ return {
205
+ status: snapshot.root === null ? 'no-root' : 'connected',
206
+ root: text(snapshot.root),
207
+ revision: typeof snapshot.revision === 'number' ? snapshot.revision : null,
208
+ runs: listRuns(snapshot.projection).map(settingsRunView),
209
+ notCarried: [...NOT_CARRIED],
210
+ }
211
+ }
@@ -1,18 +1,204 @@
1
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).
2
+ * Recursive settings page (§11.8): a settings.section list entry that REPORTS the
3
+ * live projection the host route serves — the enforcement-free facts the wire
4
+ * actually carries (run state + lock position, the folded phase chain with its
5
+ * T21 position, tamper/gate/pending facts, subagent role+provider records).
6
+ *
7
+ * WHAT THIS PANEL IS NOT: it is not a config editor and not a second board. The
8
+ * client is read-only (§11.9): it GETs the host route and reads no files. Every
9
+ * value is printed verbatim from the projection; a field the payload does not
10
+ * carry is printed as `not reported`, and the route-level fields the payload can
11
+ * never carry (enforcement modes, router defaults, scratch format) are listed by
12
+ * name at the bottom so a reader is never left guessing what is missing.
13
+ *
14
+ * `RecursiveSettings` is the pure renderer (props in, tree out). The live seat is
15
+ * `RecursiveSettingsLive`, which resolves the workspace scope exactly like the
16
+ * board seats and hands the snapshot down — the same shape board.tsx and
17
+ * inspector.tsx consume.
5
18
  */
6
- import { createElement } from 'react'
19
+ import { createElement, type ReactElement, type ReactNode } from 'react'
20
+ import { RECURSIVE_API_PREFIX } from '../live-route.ts'
21
+ import {
22
+ currentSessionCwd,
23
+ currentWorkspacePath,
24
+ type LiveProjectionValue,
25
+ type SessionListStateLike,
26
+ type SnapshotSelectorHook,
27
+ type WorkspaceListStateLike,
28
+ } from './contract.ts'
29
+ import { PILL_LABELS } from './derive.ts'
30
+ import { useLiveProjection } from './use-live.ts'
31
+ import {
32
+ settingsView,
33
+ type SettingsPendingRow,
34
+ type SettingsPhaseRow,
35
+ type SettingsRow,
36
+ type SettingsRunView,
37
+ type SettingsStatus,
38
+ type SettingsSubagentRow,
39
+ type SettingsView,
40
+ } from './settings-view.ts'
41
+
42
+ /** Rendered in place of every value the projection did not carry. */
43
+ export const ABSENT_LABEL = 'not reported'
7
44
 
8
45
  export interface RecursiveSettingsProps {
9
46
  close: () => void
47
+ /** The live host-route frame, or null while the first fetch is in flight. */
48
+ snapshot?: LiveProjectionValue | null
49
+ }
50
+
51
+ /** The route-answer states, each said plainly instead of implied by an empty panel. */
52
+ const STATUS_TEXT: Record<SettingsStatus, string> = {
53
+ 'no-frame': 'The host route has not answered for this workspace yet. Every value below is unknown — not a default.',
54
+ 'no-root': 'The host route answered, but resolved no recursive root for this workspace (no .recursive/ run layer found).',
55
+ connected: 'Live.',
56
+ }
57
+
58
+ const STATUS_VALUE: Record<SettingsStatus, string> = {
59
+ 'no-frame': 'no frame yet',
60
+ 'no-root': 'answered · no recursive root',
61
+ connected: 'connected',
62
+ }
63
+
64
+ /** A label/value report block (`dl` — the semantics of a definition list). */
65
+ function rows(className: string, items: readonly SettingsRow[]): ReactElement {
66
+ return createElement('dl', { className },
67
+ items.flatMap((row) => [
68
+ createElement('dt', { key: row.id + '-k', className: 'rec-settings-label' }, row.label),
69
+ createElement('dd', {
70
+ key: row.id + '-v',
71
+ className: row.value === null ? 'rec-settings-value rec-settings-absent' : 'rec-settings-value',
72
+ }, row.value ?? ABSENT_LABEL),
73
+ ]),
74
+ )
75
+ }
76
+
77
+ /** The frame's own facts: what the route answered, and how much it carried. */
78
+ function sourceRows(view: SettingsView): SettingsRow[] {
79
+ return [
80
+ { id: 'status', label: 'route status', value: STATUS_VALUE[view.status] },
81
+ { id: 'root', label: 'workspace root', value: view.root },
82
+ { id: 'revision', label: 'revision', value: view.revision === null ? null : String(view.revision) },
83
+ { id: 'runs', label: 'runs in the projection', value: view.status === 'no-frame' ? null : String(view.runs.length) },
84
+ ]
85
+ }
86
+
87
+ function phaseNode(row: SettingsPhaseRow): ReactElement {
88
+ const lock: ReactNode[] = []
89
+ if (row.lockedAt !== null) lock.push(createElement('span', { key: 'at' }, 'locked at ' + row.lockedAt))
90
+ if (row.lockHash !== null) lock.push(createElement('code', { key: 'hash', className: 'rec-lockhash' }, '#' + row.lockHash.slice(0, 8)))
91
+ return createElement('div', { key: row.phase, className: 'rec-settings-phase' },
92
+ createElement('span', { className: 'rec-settings-phase-id' }, row.phase),
93
+ createElement('span', { className: 'rec-settings-phase-name' }, row.fileName ?? 'not present in this run'),
94
+ createElement('span', { className: 'rec-settings-phase-status' }, row.status),
95
+ createElement('span', {
96
+ className: row.position === null ? 'rec-settings-phase-pos rec-settings-absent' : 'rec-settings-phase-pos',
97
+ }, row.position ?? ABSENT_LABEL),
98
+ lock.length > 0 && createElement('span', { className: 'rec-settings-phase-lock' }, lock),
99
+ )
100
+ }
101
+
102
+ function subagentNode(s: SettingsSubagentRow): ReactElement {
103
+ return createElement('div', { key: s.childId, className: 'rec-settings-item' },
104
+ createElement('span', { className: 'rec-settings-item-id' }, s.childId),
105
+ createElement('span', { className: 'rec-settings-item-text' },
106
+ 'role ' + (s.role ?? ABSENT_LABEL) + ' · provider ' + (s.provider ?? ABSENT_LABEL) + ' · status ' + (s.status ?? ABSENT_LABEL)),
107
+ )
108
+ }
109
+
110
+ function pendingNode(p: SettingsPendingRow): ReactElement {
111
+ return createElement('div', { key: p.kind + '\u0000' + p.delegationId, className: 'rec-settings-item' },
112
+ createElement('span', { className: 'rec-settings-item-id' }, p.kind),
113
+ createElement('span', { className: 'rec-settings-item-text' }, p.delegationId + ' — ' + p.detail),
114
+ )
115
+ }
116
+
117
+ /**
118
+ * A collection field: null = the card carried no such key (not reported),
119
+ * [] = the card carried an empty one (that empty value IS the report).
120
+ */
121
+ function collection<T>(items: readonly T[] | null, render: (item: T) => ReactElement, none: string): ReactElement {
122
+ if (items === null) {
123
+ return createElement('p', { className: 'rec-settings-value rec-settings-absent' }, ABSENT_LABEL + ' — the card carries no such field')
124
+ }
125
+ if (items.length === 0) return createElement('p', { className: 'rec-settings-value rec-settings-none' }, none)
126
+ return createElement('div', { className: 'rec-settings-items' }, items.map(render))
10
127
  }
11
128
 
12
- export function RecursiveSettings({ close }: RecursiveSettingsProps) {
129
+ function runSection(run: SettingsRunView): ReactElement {
130
+ const present = run.phases.filter((p) => p.present).length
131
+ return createElement('section', { key: run.worktreeRoot + '\u0000' + run.runId, className: 'rec-settings-run' },
132
+ createElement('div', { className: 'rec-settings-run-header' },
133
+ createElement('h3', { className: 'rec-settings-run-title' }, run.runId),
134
+ createElement('span', { className: 'rec-settings-pill', 'data-pill': run.pill }, PILL_LABELS[run.pill]),
135
+ createElement('span', { className: 'rec-settings-run-root' }, run.worktreeRoot),
136
+ ),
137
+ rows('rec-settings-rows', run.rows),
138
+ createElement('h4', { className: 'rec-settings-h4' }, 'Phases (' + String(present) + ' present)'),
139
+ createElement('p', { className: 'rec-settings-hint' },
140
+ 'status is the folded artifact status; position is the host\'s single derived phase position (T21).'),
141
+ run.phases.length === 0
142
+ ? createElement('p', { className: 'rec-settings-value rec-settings-none' }, 'none — the card carries no phase rows')
143
+ : createElement('div', { className: 'rec-settings-phases' }, run.phases.map(phaseNode)),
144
+ createElement('h4', { className: 'rec-settings-h4' }, 'Subagents (role and provider as the host recorded them)'),
145
+ collection(run.subagents, subagentNode, 'none — the card carries an empty subagent map'),
146
+ createElement('h4', { className: 'rec-settings-h4' }, 'Pending work (unresolved in-flight delegations)'),
147
+ collection(run.pendingWork, pendingNode, 'none — the card carries an empty pending set'),
148
+ )
149
+ }
150
+
151
+ /** The panel: a report of one live frame. Pure — snapshot in, tree out. */
152
+ export function RecursiveSettings({ close, snapshot = null }: RecursiveSettingsProps) {
153
+ const view = settingsView(snapshot)
13
154
  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'),
155
+ createElement('header', { className: 'rec-settings-header' },
156
+ createElement('h2', { className: 'rec-settings-title' }, 'Recursive'),
157
+ createElement('span', { className: 'rec-settings-tag' }, 'live projection · read-only'),
158
+ createElement('button', { type: 'button', className: 'rec-settings-close', onClick: close }, 'Close'),
159
+ ),
160
+ createElement('p', { className: 'rec-settings-lede' },
161
+ 'Read verbatim from the host route (GET ' + RECURSIVE_API_PREFIX + '/state + GET ' + RECURSIVE_API_PREFIX
162
+ + '/events). This client writes nothing and reads no files; anything the projection does not carry is printed as "'
163
+ + ABSENT_LABEL + '".'),
164
+ rows('rec-settings-source', sourceRows(view)),
165
+ view.status !== 'connected' && createElement('p', { className: 'rec-settings-hint' }, STATUS_TEXT[view.status]),
166
+ view.status === 'connected' && view.runs.length === 0
167
+ && createElement('p', { className: 'rec-settings-value rec-settings-none' }, 'none — the projection carries no runs in this workspace'),
168
+ view.runs.map(runSection),
169
+ createElement('section', { className: 'rec-settings-notcarried' },
170
+ createElement('h3', { className: 'rec-settings-h3' }, 'Not carried by this route'),
171
+ createElement('p', { className: 'rec-settings-hint' },
172
+ 'Configured on the host, but the payload carries only {root, projection, revision} — so this panel reports them as unknown rather than guessing.'),
173
+ rows('rec-settings-rows', view.notCarried),
174
+ ),
17
175
  )
18
176
  }
177
+
178
+ /** The settings seat props: the root standard kit plus the shell's close affordance. */
179
+ export interface RecursiveSettingsSeatProps {
180
+ close: () => void
181
+ useSessions?: SnapshotSelectorHook<SessionListStateLike>
182
+ useWorkspaces?: SnapshotSelectorHook<WorkspaceListStateLike>
183
+ }
184
+
185
+ export interface RecursiveSettingsLiveProps {
186
+ close: () => void
187
+ useSessions: SnapshotSelectorHook<SessionListStateLike>
188
+ useWorkspaces: SnapshotSelectorHook<WorkspaceListStateLike>
189
+ }
190
+
191
+ /**
192
+ * The live seat: same scope resolution as the board seats (sessionId PRIMARY,
193
+ * workspace path as the cwd hint, session cwd while workspaces hydrate). Hooks
194
+ * are called unconditionally — the seat only renders this component when both
195
+ * selector hooks are present (slots.ts), so the hook order is fixed.
196
+ */
197
+ export function RecursiveSettingsLive({ close, useSessions, useWorkspaces }: RecursiveSettingsLiveProps) {
198
+ const sessions = useSessions((s) => s)
199
+ const workspaces: WorkspaceListStateLike = useWorkspaces((s) => s) ?? { items: [], recentWorkspaceId: undefined }
200
+ const wsPath = currentWorkspacePath(workspaces, sessions)
201
+ const cwd = wsPath !== '' ? wsPath : currentSessionCwd(sessions)
202
+ const snapshot = useLiveProjection({ sessionId: sessions.current, cwd })
203
+ return createElement(RecursiveSettings, { close, snapshot })
204
+ }
@@ -21,7 +21,7 @@ import type { ClientContext, SessionListStateLike, SnapshotSelectorHook, Workspa
21
21
  import { isRecursivePreset, currentWorkspacePath } from './contract.ts'
22
22
  import { Board } from './board.tsx'
23
23
  import { Inspector } from './inspector.tsx'
24
- import { RecursiveSettings } from './settings.tsx'
24
+ import { RecursiveSettings, RecursiveSettingsLive, type RecursiveSettingsSeatProps } from './settings.tsx'
25
25
  import { useLiveProjection } from './use-live.ts'
26
26
  import { boardState, useBoardState } from './open-state.ts'
27
27
  import { injectBoardStyles } from './styles.ts'
@@ -139,12 +139,25 @@ export function registerSlots(ctx: ClientContext): () => void {
139
139
  })))
140
140
 
141
141
  // Settings section (root scope, always present; no gate — configuration is always available).
142
+ // The seat receives the shell's `close` PLUS the root standard kit (useSessions/useWorkspaces,
143
+ // scoped-slots standardProps), so the panel can subscribe to the SAME live route the board
144
+ // reads and report the projection. Without the kit the panel still renders — with every value
145
+ // reported as absent (never guessed), which is the honest degradation.
142
146
  disposers.push(ctx.slots.inject('settings.section', () => ctx.slots.register({
143
147
  name: 'settings.section',
144
148
  id: 'recursive',
145
149
  order: 90,
146
150
  label: 'Recursive',
147
- }, (props: { close: () => void }) => createElement(RecursiveSettings, { close: props.close }))))
151
+ }, (props: RecursiveSettingsSeatProps) => {
152
+ if (props.useSessions === undefined || props.useWorkspaces === undefined) {
153
+ return createElement(RecursiveSettings, { close: props.close })
154
+ }
155
+ return createElement(RecursiveSettingsLive, {
156
+ close: props.close,
157
+ useSessions: props.useSessions,
158
+ useWorkspaces: props.useWorkspaces,
159
+ })
160
+ })))
148
161
 
149
162
  return () => { for (const d of disposers) d() }
150
163
  }