@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.
@@ -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
  }
@@ -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
  }
@@ -211,7 +211,33 @@ export function currentPhaseArtifact(worktreeRoot: string, runId: string): strin
211
211
  best = name
212
212
  }
213
213
  }
214
- return best
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
- if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return null
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. This is EXACTLY detectTamper's own admission test (same
505
- // normalized string, same two conditions), so it can never reject a
506
- // candidate detectTamper would have accepted.
507
- const normalized = displayPath.replace(/\\/g, '/')
508
- if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return
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