@try-works/dsh-recursive-mode 0.4.2 → 0.4.4
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/README.md +20 -15
- package/lib/client/index.d.ts +3 -1
- package/lib/client/settings-view.d.ts +97 -0
- package/lib/client/settings.d.ts +25 -1
- package/lib/client.js +530 -11
- package/lib/enforcement.d.ts +37 -0
- package/lib/index.js +123 -18
- package/lib/memory-feedback.d.ts +25 -2
- package/package.json +1 -1
- package/preset/recursive/agent.cordis.yml +5 -4
- package/preset/recursive.patch.yml +5 -4
- package/src/client/index.ts +3 -1
- package/src/client/settings-view.ts +211 -0
- package/src/client/settings.tsx +194 -8
- package/src/client/slots.ts +15 -2
- package/src/client/styles.ts +237 -0
- package/src/enforcement.ts +74 -3
- package/src/index.ts +47 -10
- package/src/memory-feedback.ts +50 -5
- package/src/phase-rules.ts +10 -1
- package/src/policy-globs.ts +25 -3
- package/src/policy-write.ts +1 -1
- package/src/runtime.ts +29 -0
package/src/client/settings.tsx
CHANGED
|
@@ -1,18 +1,204 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recursive settings page (
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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('
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|
package/src/client/slots.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/client/styles.ts
CHANGED
|
@@ -860,6 +860,243 @@ const BOARD_CSS = `
|
|
|
860
860
|
border-color: var(--dsw-alias-state-warn-primary);
|
|
861
861
|
}
|
|
862
862
|
|
|
863
|
+
/* ===== Settings section (Settings -> Recursive): the live projection report =====
|
|
864
|
+
This panel lives INSIDE the settings shell, so it consumes the shell's own
|
|
865
|
+
--dsw-alias-* theme tokens (like .rec-badge above) instead of the board's
|
|
866
|
+
scoped --board-* paper tokens: no theme toggle, and it follows the app theme. */
|
|
867
|
+
.rec-settings {
|
|
868
|
+
display: flex;
|
|
869
|
+
flex-direction: column;
|
|
870
|
+
gap: 16px;
|
|
871
|
+
max-width: 940px;
|
|
872
|
+
padding: 2px 2px 10px;
|
|
873
|
+
color: var(--dsw-alias-label-primary);
|
|
874
|
+
font-size: 13px;
|
|
875
|
+
line-height: 1.5;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
.rec-settings-header {
|
|
879
|
+
display: flex;
|
|
880
|
+
align-items: center;
|
|
881
|
+
gap: 10px;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
.rec-settings-title {
|
|
885
|
+
margin: 0;
|
|
886
|
+
font-size: 16px;
|
|
887
|
+
font-weight: 600;
|
|
888
|
+
letter-spacing: -0.01em;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
.rec-settings-tag {
|
|
892
|
+
flex: none;
|
|
893
|
+
padding: 2px 8px;
|
|
894
|
+
font-size: 11px;
|
|
895
|
+
font-weight: 500;
|
|
896
|
+
letter-spacing: 0.03em;
|
|
897
|
+
text-transform: uppercase;
|
|
898
|
+
color: var(--dsw-alias-label-tertiary);
|
|
899
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
900
|
+
border-radius: 999px;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
.rec-settings-close {
|
|
904
|
+
margin-left: auto;
|
|
905
|
+
padding: 5px 12px;
|
|
906
|
+
font: inherit;
|
|
907
|
+
font-size: 12px;
|
|
908
|
+
color: var(--dsw-alias-label-primary);
|
|
909
|
+
background: transparent;
|
|
910
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
911
|
+
border-radius: 8px;
|
|
912
|
+
cursor: pointer;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
.rec-settings-close:hover {
|
|
916
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
.rec-settings-lede,
|
|
920
|
+
.rec-settings-hint,
|
|
921
|
+
.rec-settings-none {
|
|
922
|
+
margin: 0;
|
|
923
|
+
color: var(--dsw-alias-label-secondary);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
.rec-settings-hint,
|
|
927
|
+
.rec-settings-none {
|
|
928
|
+
font-size: 12px;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
.rec-settings-section,
|
|
932
|
+
.rec-settings-notcarried,
|
|
933
|
+
.rec-settings-run {
|
|
934
|
+
display: flex;
|
|
935
|
+
flex-direction: column;
|
|
936
|
+
gap: 8px;
|
|
937
|
+
padding: 12px 14px;
|
|
938
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
939
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
940
|
+
border-radius: 10px;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.rec-settings-h3 {
|
|
944
|
+
margin: 0;
|
|
945
|
+
font-size: 12px;
|
|
946
|
+
font-weight: 600;
|
|
947
|
+
letter-spacing: 0.04em;
|
|
948
|
+
text-transform: uppercase;
|
|
949
|
+
color: var(--dsw-alias-label-tertiary);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.rec-settings-h4 {
|
|
953
|
+
margin: 6px 0 0;
|
|
954
|
+
font-size: 12px;
|
|
955
|
+
font-weight: 600;
|
|
956
|
+
color: var(--dsw-alias-label-secondary);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.rec-settings-run-header {
|
|
960
|
+
display: flex;
|
|
961
|
+
align-items: center;
|
|
962
|
+
gap: 10px;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
.rec-settings-run-title {
|
|
966
|
+
margin: 0;
|
|
967
|
+
font-size: 14px;
|
|
968
|
+
font-weight: 600;
|
|
969
|
+
overflow-wrap: anywhere;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
.rec-settings-run-root {
|
|
973
|
+
flex: 1;
|
|
974
|
+
min-width: 0;
|
|
975
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
976
|
+
font-size: 11px;
|
|
977
|
+
color: var(--dsw-alias-label-tertiary);
|
|
978
|
+
overflow: hidden;
|
|
979
|
+
text-overflow: ellipsis;
|
|
980
|
+
white-space: nowrap;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/* Solid state pill, same vocabulary as .rec-pill but on the SHELL tokens
|
|
984
|
+
(the board's --board-* aliases are scoped to .rec-board/.rec-inspector). */
|
|
985
|
+
.rec-settings-pill {
|
|
986
|
+
flex: none;
|
|
987
|
+
padding: 3px 10px;
|
|
988
|
+
font-size: 11px;
|
|
989
|
+
font-weight: 500;
|
|
990
|
+
line-height: 1;
|
|
991
|
+
border-radius: 999px;
|
|
992
|
+
color: var(--dsw-alias-label-primary-foreground);
|
|
993
|
+
background: var(--dsw-alias-label-tertiary);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
.rec-settings-pill[data-pill='locked'] { background: var(--dsw-alias-state-success-primary); }
|
|
997
|
+
.rec-settings-pill[data-pill='in-progress'] { background: var(--dsw-alias-state-business-primary); }
|
|
998
|
+
.rec-settings-pill[data-pill='paused'] { background: var(--dsw-alias-state-warn-primary); }
|
|
999
|
+
.rec-settings-pill[data-pill='blocked'] { background: var(--dsw-alias-state-error-primary); }
|
|
1000
|
+
.rec-settings-pill[data-pill='tampered'] { background: var(--dsw-alias-state-error-primary); }
|
|
1001
|
+
.rec-settings-pill[data-pill='advisory'] { background: var(--dsw-alias-state-warn-secondary); }
|
|
1002
|
+
.rec-settings-pill[data-pill='neutral'] { background: var(--dsw-alias-label-tertiary); }
|
|
1003
|
+
|
|
1004
|
+
dl.rec-settings-source,
|
|
1005
|
+
dl.rec-settings-rows {
|
|
1006
|
+
display: grid;
|
|
1007
|
+
grid-template-columns: minmax(160px, 300px) 1fr;
|
|
1008
|
+
gap: 4px 16px;
|
|
1009
|
+
margin: 0;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
.rec-settings-label {
|
|
1013
|
+
font-size: 12px;
|
|
1014
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
.rec-settings-value {
|
|
1018
|
+
margin: 0;
|
|
1019
|
+
color: var(--dsw-alias-label-primary);
|
|
1020
|
+
overflow-wrap: anywhere;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/* An absent value is a STATEMENT, not an empty row — always visibly marked. */
|
|
1024
|
+
.rec-settings-absent {
|
|
1025
|
+
color: var(--dsw-alias-state-warn-label);
|
|
1026
|
+
font-style: italic;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
.rec-settings-phases {
|
|
1030
|
+
display: flex;
|
|
1031
|
+
flex-direction: column;
|
|
1032
|
+
gap: 4px;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
.rec-settings-phase {
|
|
1036
|
+
display: flex;
|
|
1037
|
+
align-items: baseline;
|
|
1038
|
+
flex-wrap: wrap;
|
|
1039
|
+
gap: 10px;
|
|
1040
|
+
padding: 4px 10px;
|
|
1041
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1042
|
+
border-radius: 6px;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
.rec-settings-phase-id {
|
|
1046
|
+
flex: none;
|
|
1047
|
+
min-width: 40px;
|
|
1048
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1049
|
+
font-size: 11px;
|
|
1050
|
+
color: var(--dsw-alias-label-secondary);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.rec-settings-phase-name {
|
|
1054
|
+
flex: 1;
|
|
1055
|
+
min-width: 140px;
|
|
1056
|
+
overflow-wrap: anywhere;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.rec-settings-phase-status,
|
|
1060
|
+
.rec-settings-phase-pos {
|
|
1061
|
+
flex: none;
|
|
1062
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1063
|
+
font-size: 11px;
|
|
1064
|
+
letter-spacing: 0.02em;
|
|
1065
|
+
color: var(--dsw-alias-label-secondary);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
.rec-settings-phase-lock {
|
|
1069
|
+
flex: none;
|
|
1070
|
+
font-size: 11px;
|
|
1071
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
.rec-settings-items {
|
|
1075
|
+
display: flex;
|
|
1076
|
+
flex-direction: column;
|
|
1077
|
+
gap: 4px;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
.rec-settings-item {
|
|
1081
|
+
display: flex;
|
|
1082
|
+
align-items: baseline;
|
|
1083
|
+
gap: 10px;
|
|
1084
|
+
font-size: 12px;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
.rec-settings-item-id {
|
|
1088
|
+
flex: none;
|
|
1089
|
+
min-width: 96px;
|
|
1090
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1091
|
+
color: var(--dsw-alias-label-secondary);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
.rec-settings-item-text {
|
|
1095
|
+
flex: 1;
|
|
1096
|
+
min-width: 0;
|
|
1097
|
+
overflow-wrap: anywhere;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
863
1100
|
@media (prefers-reduced-motion: reduce) {
|
|
864
1101
|
.rec-card, .rec-back, .rec-close, .rec-theme-toggle { transition: none; }
|
|
865
1102
|
}
|
package/src/enforcement.ts
CHANGED
|
@@ -211,7 +211,33 @@ export function currentPhaseArtifact(worktreeRoot: string, runId: string): strin
|
|
|
211
211
|
best = name
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
|
-
|
|
214
|
+
// ⚠ FIX 2 (half a) — PRESENCE IS NO LONGER EVIDENCE OF PROGRESS, AND THIS USED TO ASSUME IT WAS.
|
|
215
|
+
//
|
|
216
|
+
// The loop above returns the HIGHEST-numbered phase artifact present, on the documented assumption that "a run at
|
|
217
|
+
// phase 3 has `00`-`03` on disk". That assumption stopped being true when `recursive_init` began scaffolding ALL
|
|
218
|
+
// TWELVE artifacts, `08-memory-impact.md` included — so from turn 0 the phase-8 baseline governed every guard call.
|
|
219
|
+
// Phase 8 is the documentation phase, whose rule denies writes outside the run tree, so a run sitting at phase 3
|
|
220
|
+
// had its SOURCE EDITS denied as though it were finished and merely writing up. A live verification pass recorded
|
|
221
|
+
// five denials out of five while trying to author phase artifacts.
|
|
222
|
+
//
|
|
223
|
+
// The workflow's own notion of progress is the LOCK, so the phase in force is the LOWEST-numbered artifact that is
|
|
224
|
+
// not locked — the phase actually being worked on. Once everything is locked the run is complete, and the previous
|
|
225
|
+
// answer still stands, which keeps the old behaviour exactly where the old reasoning held. Still read from the
|
|
226
|
+
// filesystem on every call, for the same no-cache reason the directory listing is.
|
|
227
|
+
let inForce = ''
|
|
228
|
+
let inForcePhase = Number.POSITIVE_INFINITY
|
|
229
|
+
for (const name of names) {
|
|
230
|
+
if (!name.endsWith('.md')) continue
|
|
231
|
+
const phase = phaseNumberForArtifact(name)
|
|
232
|
+
if (!phase) continue
|
|
233
|
+
const value = Number(phase)
|
|
234
|
+
if (getLockStatus(join(runDir, name)) === 'LOCKED') continue
|
|
235
|
+
if (value < inForcePhase) {
|
|
236
|
+
inForcePhase = value
|
|
237
|
+
inForce = name
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return inForce !== '' ? inForce : best
|
|
215
241
|
}
|
|
216
242
|
|
|
217
243
|
export function evaluateToolGuard(
|
|
@@ -335,10 +361,56 @@ function resolveTargetPath(target: string, worktreeRoot: string): string | null
|
|
|
335
361
|
return abs
|
|
336
362
|
}
|
|
337
363
|
|
|
364
|
+
/**
|
|
365
|
+
* The ADMISSION test for the tamper path: the resolved absolute path when
|
|
366
|
+
* `targetPath` names a run-tree `*.md` — a tamper CANDIDATE — and `null`
|
|
367
|
+
* otherwise. Pure path arithmetic on every branch (no filesystem work), so a
|
|
368
|
+
* caller may use it as a cheap shape check before paying for `existsSync`.
|
|
369
|
+
*
|
|
370
|
+
* ⚠ THE BLIND SPOT THIS FUNCTION EXISTS TO CLOSE. The admission test used to be a
|
|
371
|
+
* substring test on the target STRING alone, looking for `/.recursive/run/`. A
|
|
372
|
+
* repo-relative path has no separator before `.recursive`, so
|
|
373
|
+
* `.recursive/run/<id>/00-requirements.md` — the spelling a model actually types,
|
|
374
|
+
* and its backslash form — was rejected before ANYTHING was examined, and
|
|
375
|
+
* tampering with a locked artifact through that spelling was invisible
|
|
376
|
+
* (measured: `detectTamper` returned a record for the absolute path and `null`
|
|
377
|
+
* for the relative one, on the same file). The identical defect, in the identical
|
|
378
|
+
* spelling, was fixed one module over in `policy-globs.ts` `lockedWriteRule`; this
|
|
379
|
+
* is that fix's shape, reused rather than reinvented.
|
|
380
|
+
*
|
|
381
|
+
* So the marker is looked for on the path the target RESOLVES to as well as on
|
|
382
|
+
* the string as written. The `||` is load-bearing and the string test is KEPT
|
|
383
|
+
* rather than replaced, because a resolved-only test would SHRINK the admitted
|
|
384
|
+
* set: an absolute target that literally carries the marker but resolves away
|
|
385
|
+
* from it (`…/.recursive/run/../…`) was caught before and must stay caught. The
|
|
386
|
+
* net effect is a strict SUPERSET of the previous behaviour, so no tamper that
|
|
387
|
+
* was visible before can become invisible.
|
|
388
|
+
*
|
|
389
|
+
* EXPORTED because `src/index.ts`'s `fs/observed` listener must apply the SAME
|
|
390
|
+
* admission test before calling `detectTamper`. That listener used to carry a
|
|
391
|
+
* hand-copied mirror of this test, and a mirror is exactly what leaves half the
|
|
392
|
+
* defect behind: widening `detectTamper` alone changes nothing, because the
|
|
393
|
+
* listener rejects the spelling first. One function cannot disagree with itself.
|
|
394
|
+
*/
|
|
395
|
+
export function tamperCandidatePath(targetPath: string, worktreeRoot: string): string | null {
|
|
396
|
+
const normalized = targetPath.replace(/\\/g, '/')
|
|
397
|
+
if (!normalized.endsWith('.md')) return null
|
|
398
|
+
const abs = resolveTargetPath(normalized, worktreeRoot)
|
|
399
|
+
if (!abs) return null
|
|
400
|
+
const resolved = abs.replace(/\\/g, '/')
|
|
401
|
+
if (!normalized.includes('/.recursive/run/') && !resolved.includes('/.recursive/run/')) return null
|
|
402
|
+
return abs
|
|
403
|
+
}
|
|
404
|
+
|
|
338
405
|
/**
|
|
339
406
|
* Layer 8 - fs/observed lock-tamper detection.
|
|
340
407
|
* A locked *.md whose observed version differs from the stored LockHash is
|
|
341
408
|
* a tamper. Returns a tamper reason (or null when clean/not-applicable).
|
|
409
|
+
*
|
|
410
|
+
* The admission test lives in `tamperCandidatePath` (above), shared with the
|
|
411
|
+
* `fs/observed` listener in `src/index.ts` — see the note there for why sharing
|
|
412
|
+
* it is the point and not a tidiness preference. What this function reports is
|
|
413
|
+
* unchanged: the same record shape, carrying the target AS WRITTEN.
|
|
342
414
|
*/
|
|
343
415
|
export function detectTamper(
|
|
344
416
|
targetPath: string,
|
|
@@ -346,8 +418,7 @@ export function detectTamper(
|
|
|
346
418
|
activeRunId: string,
|
|
347
419
|
): { runId: string; path: string; reason: string } | null {
|
|
348
420
|
const normalized = targetPath.replace(/\\/g, '/')
|
|
349
|
-
|
|
350
|
-
const abs = resolveTargetPath(normalized, worktreeRoot)
|
|
421
|
+
const abs = tamperCandidatePath(normalized, worktreeRoot)
|
|
351
422
|
if (!abs || !existsSync(abs)) return null
|
|
352
423
|
const status = getLockStatus(abs)
|
|
353
424
|
if (status === 'STALE_LOCK') {
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { createRecursiveAskTool } from './recursive_ask.tool.ts'
|
|
|
24
24
|
import { createRecursivePreviewTool } from './recursive_preview.tool.ts'
|
|
25
25
|
import type { SubagentsRuntimeLike } from './delegation.ts'
|
|
26
26
|
import { registerRecursiveCommand } from './commands.ts'
|
|
27
|
-
import { evaluateToolGuard, coerceAskToDecision, type ToolGuardDecision } from './enforcement.ts'
|
|
27
|
+
import { evaluateToolGuard, coerceAskToDecision, tamperCandidatePath, type ToolGuardDecision } from './enforcement.ts'
|
|
28
28
|
import { appendGuardDecision, appendObservedTamper, type GuardDecisionRecord } from './guard-log.ts'
|
|
29
29
|
import type { GoalServiceLike } from './goals-projection.ts'
|
|
30
30
|
import type { TeamRuntimeLike } from './teams-loop.ts'
|
|
@@ -307,9 +307,36 @@ export function apply(ctx: Context, config?: RecursiveModeConfig) {
|
|
|
307
307
|
ctx.tools.register(createRecursiveAskTool(recursive)),
|
|
308
308
|
// T26: the read-only view of what the enforcement contract will do, before it fires.
|
|
309
309
|
ctx.tools.register(createRecursivePreviewTool(recursive)),
|
|
310
|
-
...(agentTeams ? [ctx.tools.register(createRecursiveAuditTeamTool(agentTeams))] : []),
|
|
311
310
|
]
|
|
312
311
|
|
|
312
|
+
// ⚠ FIX 1 — THE THIRD SEAM NEEDED THE SAME LATE ATTACH AS THE OTHER TWO, AND DID NOT HAVE IT.
|
|
313
|
+
//
|
|
314
|
+
// The line that used to sit in the array above was `...(agentTeams ? [register(...)] : [])` — a ONE-SHOT
|
|
315
|
+
// `ctx.get('agentTeams')` taken at apply time. A live verification pass found the consequence: `team_task_create`
|
|
316
|
+
// worked in the same session whose tool catalog lacked `recursive_audit_team`, because the service was mounted
|
|
317
|
+
// AFTER this plugin applied. The plugin shipped 13 tool files and offered 12.
|
|
318
|
+
//
|
|
319
|
+
// `subagents` and `llm` already solve this with `ctx.inject` (above); this is that pattern, with one addition the
|
|
320
|
+
// others do not need: the tool may only be registered ONCE, because the one-shot path can already have taken it.
|
|
321
|
+
let auditTeamRegistered = agentTeams !== undefined && agentTeams !== null
|
|
322
|
+
if (auditTeamRegistered) disposers.push(ctx.tools.register(createRecursiveAuditTeamTool(agentTeams ?? null)))
|
|
323
|
+
ctx.inject(['agentTeams'], (teamCtx: Context) => {
|
|
324
|
+
if (auditTeamRegistered) return
|
|
325
|
+
const late = teamCtx.get('agentTeams') as TeamRuntimeLike | undefined
|
|
326
|
+
if (late === undefined || late === null) return
|
|
327
|
+
auditTeamRegistered = true
|
|
328
|
+
// ⚠ CORRECTED COMMENT. This registers through the OUTER plugin context (`ctx`), NOT through the
|
|
329
|
+
// injecting `teamCtx` — `teamCtx` is used on the line above only to READ the late service, and the
|
|
330
|
+
// sibling `subagents`/`llm` injects use their callback context the same way. The comment that used to
|
|
331
|
+
// sit here claimed the injecting scope owned the registration, which is not what this call does.
|
|
332
|
+
//
|
|
333
|
+
// WHAT IS NOT CLAIMED: that the fiber withdraws this registration. Nobody has observed that — no test
|
|
334
|
+
// covers the late `recursive_audit_team` being withdrawn — and the disposer returned here is not
|
|
335
|
+
// retained, unlike the eager registration above, which pushes its own onto `disposers`. Until a test
|
|
336
|
+
// observes the withdrawal, this comment promises nothing about it.
|
|
337
|
+
ctx.tools.register(createRecursiveAuditTeamTool(late))
|
|
338
|
+
})
|
|
339
|
+
|
|
313
340
|
// /recursive command (R4): preset-scoped registration, workspace-scoped dispatch.
|
|
314
341
|
const commands = ctx.get('commands') as { register: (def: unknown) => () => void } | undefined
|
|
315
342
|
if (commands) {
|
|
@@ -501,16 +528,26 @@ export function apply(ctx: Context, config?: RecursiveModeConfig) {
|
|
|
501
528
|
if (!displayPath) return
|
|
502
529
|
// Cheap shape test BEFORE any filesystem work: fs/observed fires on reads
|
|
503
530
|
// too, so enumerating runs for every observation would be a readdir per
|
|
504
|
-
// file touch.
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
// The actor is the tool execution. This event cannot await, so the root is
|
|
510
|
-
// the actor's session cwd (the same B4 sync shortcut fsPolicyIntent takes:
|
|
511
|
-
// the session cwd is authoritative, the registry path is async-only).
|
|
531
|
+
// file touch. The actor is the tool execution. This event cannot await, so
|
|
532
|
+
// the root is the actor's session cwd (the same B4 sync shortcut
|
|
533
|
+
// fsPolicyIntent takes: the session cwd is authoritative, the registry
|
|
534
|
+
// path is async-only). Resolving the cwd first is free — plain property
|
|
535
|
+
// reads — and the admission test needs it.
|
|
512
536
|
const cwd = (actor as { agent?: { session?: { header?: { cwd?: string } } } } | null)?.agent?.session?.header?.cwd ?? ''
|
|
513
537
|
if (!cwd) return
|
|
538
|
+
// ⚠ AND THIS IS `detectTamper`'s OWN ADMISSION TEST, CALLED RATHER THAN COPIED.
|
|
539
|
+
//
|
|
540
|
+
// It used to be an inline hand-copy — `endsWith('.md') && includes('/.recursive/run/')`
|
|
541
|
+
// — and a hand-copy is what made the tamper guard blind to one spelling of one
|
|
542
|
+
// path: the substring test needs a separator BEFORE `.recursive`, which a
|
|
543
|
+
// repo-relative target (`displayPath` as a model would type it) does not have, so
|
|
544
|
+
// the listener rejected the candidate here and `detectTamper` was never reached.
|
|
545
|
+
// Widening only `detectTamper` would have changed nothing observable. The test now
|
|
546
|
+
// lives in one place (`tamperCandidatePath`), so the two cannot disagree; it stays
|
|
547
|
+
// pure path arithmetic, so the "no filesystem work before admission" property the
|
|
548
|
+
// shape check exists for is preserved.
|
|
549
|
+
const normalized = displayPath.replace(/\\/g, '/')
|
|
550
|
+
if (!tamperCandidatePath(normalized, cwd)) return
|
|
514
551
|
const runId = resolveRunDir(cwd)?.runId ?? ''
|
|
515
552
|
const tamper = recursive.detectTamper(normalized, cwd, runId)
|
|
516
553
|
if (!tamper) return
|