@try-works/dsh-recursive-mode 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T36 — observe a delegated child's settlement WITHOUT reading session history.
|
|
3
|
+
*
|
|
4
|
+
* THE CONSTRAINT, VERIFIED IN THE CHECKOUT RATHER THAN ASSUMED. DSH prohibits new
|
|
5
|
+
* production calls to `Session.snapshotEvents()`, `Session.eventAt()` and
|
|
6
|
+
* `Session.ownEvents()`: *"existing logic may remain unmigrated for now, but new
|
|
7
|
+
* calls are prohibited"*, the rule is enforced by an executable lint check, and
|
|
8
|
+
* *"copying a waiver to a new production call violates this policy"*. The obvious
|
|
9
|
+
* implementation of an observer — scan the parent's log for the `subagent-settled`
|
|
10
|
+
* notice — is therefore exactly the thing that is forbidden, and it would also be
|
|
11
|
+
* the wrong shape: history reads make ordinary logic depend on storage.
|
|
12
|
+
*
|
|
13
|
+
* THE PRESCRIBED REPLACEMENT is to *"process the delivered current event"* or read
|
|
14
|
+
* maintained projection state. So this module never looks back. It recognises the
|
|
15
|
+
* settlement **at delivery time** (the `session/event` listener passes it as it is
|
|
16
|
+
* committed) and records it in the run's own file state, which is precisely the
|
|
17
|
+
* plugin's zero-emission, file-derived architecture: nothing is appended to the
|
|
18
|
+
* session, and the durable fact lands where every other plugin fact lands.
|
|
19
|
+
*
|
|
20
|
+
* WHY THE NOTICE ARRIVES AT ALL. A continuable child that finishes produces a
|
|
21
|
+
* durable user message in the parent whose source is `{ kind: 'subagent-settled' }`
|
|
22
|
+
* — the parent-side settlement seam. There is no promise to await, which is why
|
|
23
|
+
* `delegateContinuable` takes an injected observer instead of awaiting one.
|
|
24
|
+
*/
|
|
25
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'
|
|
26
|
+
import { dirname, join } from 'node:path'
|
|
27
|
+
import type { SubagentResultLike, ContinuableChildId, ContinuableMessageId } from './delegation.ts'
|
|
28
|
+
|
|
29
|
+
/** A settlement notice, as the plugin records it. */
|
|
30
|
+
export interface SettlementNotice {
|
|
31
|
+
/** The durable child this settles. */
|
|
32
|
+
childId: string
|
|
33
|
+
/** One line saying the child is finished and why, in the parent's vocabulary. */
|
|
34
|
+
summary: string
|
|
35
|
+
/** The child's own closing text, if it left any. */
|
|
36
|
+
closingText: string
|
|
37
|
+
/** The message id the notice arrived as, when the event carried one. */
|
|
38
|
+
messageId?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Minimal structural view of a delivered session event (no harness import). */
|
|
42
|
+
export interface SessionEventLike {
|
|
43
|
+
type?: string
|
|
44
|
+
data?: unknown
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
48
|
+
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function textOf(block: unknown): string {
|
|
52
|
+
const record = asRecord(block)
|
|
53
|
+
return record !== null && record.type === 'text' && typeof record.text === 'string' ? record.text : ''
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Recognise a settlement notice from a DELIVERED session event, or null when the
|
|
58
|
+
* event is not one. Pure: no I/O, no history read, safe on any event.
|
|
59
|
+
*
|
|
60
|
+
* The notice's shape is the harness's: a `user/message` whose `source.kind` is
|
|
61
|
+
* `subagent-settled` and whose `source.senderSessionId` names the child. The
|
|
62
|
+
* content is a summary line, then optionally `Its closing message:` followed by
|
|
63
|
+
* the child's own text — so the closing text is everything after that marker, and
|
|
64
|
+
* a child that left nothing yields the harness's own "It left no closing message."
|
|
65
|
+
*/
|
|
66
|
+
export function settlementFromEvent(event: SessionEventLike): SettlementNotice | null {
|
|
67
|
+
if (event?.type !== 'user/message') return null
|
|
68
|
+
const message = asRecord(event.data)
|
|
69
|
+
if (message === null) return null
|
|
70
|
+
const source = asRecord(message.source)
|
|
71
|
+
if (source === null || source.kind !== 'subagent-settled') return null
|
|
72
|
+
const childId = typeof source.senderSessionId === 'string' ? source.senderSessionId : ''
|
|
73
|
+
if (childId === '') return null
|
|
74
|
+
|
|
75
|
+
const content = Array.isArray(message.content) ? message.content : []
|
|
76
|
+
const texts = content.map(textOf).filter((t) => t !== '')
|
|
77
|
+
const markerIndex = texts.findIndex((t) => t === 'Its closing message:')
|
|
78
|
+
const summary = typeof source.summary === 'string' && source.summary !== ''
|
|
79
|
+
? source.summary
|
|
80
|
+
: (texts[0] ?? '')
|
|
81
|
+
const closingText = markerIndex >= 0 ? texts.slice(markerIndex + 1).join('\n\n') : ''
|
|
82
|
+
|
|
83
|
+
const notice: SettlementNotice = { childId, summary, closingText }
|
|
84
|
+
if (typeof message.id === 'string' && message.id !== '') notice.messageId = message.id
|
|
85
|
+
return notice
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The run-scoped append-only settlement log. */
|
|
89
|
+
export function settlementLogPath(runDir: string): string {
|
|
90
|
+
return join(runDir, 'subagents', 'settlements.jsonl')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Which run owns this child, resolved from the FILESYSTEM alone.
|
|
95
|
+
*
|
|
96
|
+
* The delivered event names the child (`source.senderSessionId`) but not the run,
|
|
97
|
+
* and the plugin's whole design is to derive placement from what is on disk rather
|
|
98
|
+
* than to keep a registry that could go stale. A delegation writes its child
|
|
99
|
+
* directories as `subagents/<delegationId>/child-<childId>/`, so the run holding
|
|
100
|
+
* that child is the run the settlement belongs to.
|
|
101
|
+
*
|
|
102
|
+
* AMBIGUITY RETURNS NULL rather than guessing. Two runs claiming the same child
|
|
103
|
+
* would mean a copied tree, and filing a settlement into the wrong run is worse
|
|
104
|
+
* than not filing it: it would attach one run's evidence to another run's chain.
|
|
105
|
+
* A miss costs a settlement the loop will report as "no settlement yet", which is
|
|
106
|
+
* recoverable; mis-filing is not.
|
|
107
|
+
*/
|
|
108
|
+
export function runDirForChild(root: string, childId: string): string | null { if (childId === '' || root === '') return null
|
|
109
|
+
const runsRoot = join(root, '.recursive', 'run')
|
|
110
|
+
let runs: string[]
|
|
111
|
+
try {
|
|
112
|
+
runs = readdirSync(runsRoot, { withFileTypes: true })
|
|
113
|
+
.filter((entry) => entry.isDirectory())
|
|
114
|
+
.map((entry) => entry.name)
|
|
115
|
+
.sort()
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const matches: string[] = []
|
|
121
|
+
for (const runName of runs) {
|
|
122
|
+
const runDir = join(runsRoot, runName)
|
|
123
|
+
let delegations: string[]
|
|
124
|
+
try {
|
|
125
|
+
delegations = readdirSync(join(runDir, 'subagents'), { withFileTypes: true })
|
|
126
|
+
.filter((entry) => entry.isDirectory())
|
|
127
|
+
.map((entry) => entry.name)
|
|
128
|
+
} catch {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
for (const delegationId of delegations) {
|
|
132
|
+
if (existsSync(join(runDir, 'subagents', delegationId, 'child-' + childId))) {
|
|
133
|
+
matches.push(runDir)
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return matches.length === 1 ? matches[0]! : null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Record one notice in the run's file state. APPEND-ONLY, matching the run's own
|
|
143
|
+
* evidence posture: a settlement that already happened is a fact, and rewriting
|
|
144
|
+
* the log would let a later write erase the record of an earlier round.
|
|
145
|
+
*/
|
|
146
|
+
export function recordSettlement(runDir: string, notice: SettlementNotice): string {
|
|
147
|
+
const path = settlementLogPath(runDir)
|
|
148
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
149
|
+
appendFileSync(path, JSON.stringify(notice) + '\n', 'utf8')
|
|
150
|
+
return path
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every recorded notice for a run, in arrival order. Never throws. */
|
|
154
|
+
/**
|
|
155
|
+
* ⚠ FU-17 — THE ADOPTION PATH, for a settlement whose child this plugin never filed.
|
|
156
|
+
*
|
|
157
|
+
* WHY IT EXISTS. `runDirForChild` attributes a child by the `subagents/<delegation>/child-<id>/` layout that a
|
|
158
|
+
* delegation writes, and it refuses to guess — its own comment: *"filing a settlement into the wrong run is worse
|
|
159
|
+
* than not filing it: it would attach one run's evidence to another run's chain. A miss costs a settlement the
|
|
160
|
+
* loop will report as 'no settlement yet', which is recoverable; mis-filing is not."*
|
|
161
|
+
*
|
|
162
|
+
* That refusal is right, and it has a cost this feature cannot accept: a child started through the harness's own
|
|
163
|
+
* tools (or any path that did not write a delegation directory) settles into nothing, so the run loses evidence
|
|
164
|
+
* of work that really happened — and a phase artifact that must cite an action record cannot cite one that was
|
|
165
|
+
* never filed.
|
|
166
|
+
*
|
|
167
|
+
* THE RULE IS PROGRESSIVE AND NEVER GUESSES BETWEEN RUNS:
|
|
168
|
+
* 1. exactly one run under the root → file into THAT run, under a clearly-marked `adopted-<child>/` directory,
|
|
169
|
+
* and say in the record that it was adopted and why;
|
|
170
|
+
* 2. more than one run → file into a ROOT-level `adopted-settlements.jsonl` instead, because
|
|
171
|
+
* choosing between runs is exactly the mis-filing the rule above forbids. The fact is still recorded, in a
|
|
172
|
+
* place that can attribute nothing to the wrong chain.
|
|
173
|
+
*
|
|
174
|
+
* Returns the path it wrote, or null when there was nothing to write. Never throws: this rides the same hot path
|
|
175
|
+
* as `captureSettlement` and must not break the session it observes.
|
|
176
|
+
*/
|
|
177
|
+
export function adoptSettlement(root: string, notice: SettlementNotice): string | null {
|
|
178
|
+
if (notice.childId === '') return null
|
|
179
|
+
try {
|
|
180
|
+
const runsRoot = join(root, '.recursive', 'run')
|
|
181
|
+
let runs: string[] = []
|
|
182
|
+
try {
|
|
183
|
+
runs = readdirSync(runsRoot, { withFileTypes: true })
|
|
184
|
+
.filter((entry) => entry.isDirectory())
|
|
185
|
+
.map((entry) => entry.name)
|
|
186
|
+
.sort()
|
|
187
|
+
} catch {
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Case 2 — ambiguous, so it is recorded OUTSIDE every run's evidence chain.
|
|
192
|
+
if (runs.length !== 1) {
|
|
193
|
+
const path = join(root, '.recursive', 'adopted-settlements.jsonl')
|
|
194
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
195
|
+
appendFileSync(path, JSON.stringify({
|
|
196
|
+
adopted: true,
|
|
197
|
+
reason: 'no delegation directory for this child, and ' + runs.length + ' runs exist',
|
|
198
|
+
run: null,
|
|
199
|
+
childId: notice.childId,
|
|
200
|
+
summary: notice.summary,
|
|
201
|
+
closingText: notice.closingText,
|
|
202
|
+
}) + '\n', 'utf8')
|
|
203
|
+
return path
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Case 1 — one run, so the child can only belong to it. Still marked as adopted: the delegation directory
|
|
207
|
+
// the normal path would have used does not exist, and a reader must be able to tell the two apart.
|
|
208
|
+
const path = join(runsRoot, runs[0]!, 'subagents', 'adopted-' + notice.childId, 'settlement.jsonl')
|
|
209
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
210
|
+
appendFileSync(path, JSON.stringify({
|
|
211
|
+
adopted: true,
|
|
212
|
+
reason: 'no delegation directory for this child; single-run workspace',
|
|
213
|
+
childId: notice.childId,
|
|
214
|
+
summary: notice.summary,
|
|
215
|
+
closingText: notice.closingText,
|
|
216
|
+
}) + '\n', 'utf8')
|
|
217
|
+
return path
|
|
218
|
+
} catch {
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function readSettlements(runDir: string): SettlementNotice[] { let raw: string
|
|
224
|
+
try {
|
|
225
|
+
raw = readFileSync(settlementLogPath(runDir), 'utf8')
|
|
226
|
+
} catch {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
229
|
+
const out: SettlementNotice[] = []
|
|
230
|
+
for (const line of raw.split('\n')) {
|
|
231
|
+
const trimmed = line.trim()
|
|
232
|
+
if (trimmed === '') continue
|
|
233
|
+
try {
|
|
234
|
+
const parsed = asRecord(JSON.parse(trimmed))
|
|
235
|
+
if (parsed !== null && typeof parsed.childId === 'string' && typeof parsed.summary === 'string') {
|
|
236
|
+
const notice: SettlementNotice = {
|
|
237
|
+
childId: parsed.childId,
|
|
238
|
+
summary: parsed.summary,
|
|
239
|
+
closingText: typeof parsed.closingText === 'string' ? parsed.closingText : '',
|
|
240
|
+
}
|
|
241
|
+
if (typeof parsed.messageId === 'string') notice.messageId = parsed.messageId
|
|
242
|
+
out.push(notice)
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// A malformed line is skipped rather than failing the whole read: a log
|
|
246
|
+
// that a partial write corrupted must not make the run unobservable.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The LATEST recorded settlement for one child, or null when none has landed. */
|
|
253
|
+
export function readSettlement(runDir: string, childId: string): SettlementNotice | null {
|
|
254
|
+
const mine = readSettlements(runDir).filter((n) => n.childId === childId)
|
|
255
|
+
return mine.length === 0 ? null : mine[mine.length - 1]!
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Turn a recorded settlement into the result shape the delegation loop reads.
|
|
260
|
+
*
|
|
261
|
+
* `success` is deliberately NOT asserted from the summary: the notice reports how
|
|
262
|
+
* the child's turn ENDED, not whether its work is acceptable. Acceptance is the
|
|
263
|
+
* verdict's job (`readVerdict` + `evaluateDelegationResult`), and defaulting to
|
|
264
|
+
* success here would let a stopped child be treated as a passing review — the
|
|
265
|
+
* exact silent-approval failure `delegateContinuable` refuses to fabricate.
|
|
266
|
+
*/
|
|
267
|
+
export function settlementResult(notice: SettlementNotice): SubagentResultLike {
|
|
268
|
+
const result: SubagentResultLike = {
|
|
269
|
+
output: notice.closingText,
|
|
270
|
+
stopReason: 'completed',
|
|
271
|
+
} as SubagentResultLike
|
|
272
|
+
// A child that reported structured JSON leaves it as the closing text.
|
|
273
|
+
const parsed = (() => {
|
|
274
|
+
try { return JSON.parse(notice.closingText) } catch { return undefined }
|
|
275
|
+
})()
|
|
276
|
+
if (parsed !== undefined) (result as { structured?: unknown }).structured = parsed
|
|
277
|
+
return result
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Build the `awaitRoundResult` observer the continuable loop injects.
|
|
282
|
+
*
|
|
283
|
+
* ⚠ IT NOW WAITS, AND THAT IS THE FU-9 DECISION (option a, chosen by the user). It used to read the
|
|
284
|
+
* settlement log ONCE and return null immediately, which the loop reports as `parked` — a correct signal, but
|
|
285
|
+
* it meant the parent returned before the child had run at all. The control that shows why that matters: the
|
|
286
|
+
* harness's own team fixture, same recipe, completed two teammates while this plugin's delegated child never
|
|
287
|
+
* got a turn — and the engine's contract explains it, `submitAdmitted` *"crosses the final admission cutoff
|
|
288
|
+
* and submits without yielding"*, so a parent that returns lets the host go idle, and an idle one-shot session
|
|
289
|
+
* never pumps the child's inbox.
|
|
290
|
+
*
|
|
291
|
+
* ⚠ THE PARK IS KEPT, as the fallback it always was. The wait is bounded by `timeoutMs`; when the deadline
|
|
292
|
+
* passes the observer STILL returns null, so the caller still parks, still resumes on a later turn with the
|
|
293
|
+
* SAME child id, and still never treats an unobserved round as approval — the T36 rules are intact. What
|
|
294
|
+
* changed is that parking is now what happens AFTER a real wait rather than INSTEAD of one.
|
|
295
|
+
*
|
|
296
|
+
* `sleep` is injectable so a test asserts the waiting without spending wall-clock time, and `timeoutMs: 0`
|
|
297
|
+
* reproduces the old read-once behaviour exactly — which is what the tests that assert a park use.
|
|
298
|
+
*/
|
|
299
|
+
export function settlementRoundObserver(
|
|
300
|
+
runDir: string,
|
|
301
|
+
options: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
|
|
302
|
+
): (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null> {
|
|
303
|
+
const timeoutMs = options.timeoutMs ?? 30_000
|
|
304
|
+
const pollMs = options.pollMs ?? 250
|
|
305
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
|
306
|
+
return async (childId: ContinuableChildId) => {
|
|
307
|
+
const deadline = Date.now() + timeoutMs
|
|
308
|
+
for (;;) {
|
|
309
|
+
const notice = readSettlement(runDir, String(childId))
|
|
310
|
+
if (notice !== null) return settlementResult(notice)
|
|
311
|
+
if (Date.now() >= deadline) return null
|
|
312
|
+
await sleep(pollMs)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Handle one delivered session event: record it when it is a settlement, and
|
|
319
|
+
* report whether it was one. Wired to `ctx.on('session/event', …)`, which the
|
|
320
|
+
* harness documents as the delivery point for every committed event.
|
|
321
|
+
*/
|
|
322
|
+
export function captureSettlement(runDir: string, event: SessionEventLike): SettlementNotice | null {
|
|
323
|
+
const notice = settlementFromEvent(event)
|
|
324
|
+
if (notice === null) return null
|
|
325
|
+
recordSettlement(runDir, notice)
|
|
326
|
+
return notice
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* FU-3 — the children a run has on its books, read from the layout the delegations already write.
|
|
331
|
+
*
|
|
332
|
+
* WHY FROM DISK AND NOT FROM MEMORY. A delegation records itself as `subagents/<delegationId>/child-<childId>/`
|
|
333
|
+
* (see {@link runDirForChild}), which is the same fact the settlement log is keyed on. Reading it back
|
|
334
|
+
* means a drain at closeout works **in a fresh process** — after a resume, a crash or a compaction — where
|
|
335
|
+
* an in-memory list of children would be empty and the run would silently leak every child it started.
|
|
336
|
+
*
|
|
337
|
+
* ⚠ AN EMPTY ANSWER IS A REAL ANSWER: a run that delegated nothing has no children, and the caller must
|
|
338
|
+
* be able to tell that from a failed lookup — hence a plain `[]` rather than `null`.
|
|
339
|
+
*/
|
|
340
|
+
export function runChildIds(runDir: string): string[] {
|
|
341
|
+
const base = join(runDir, 'subagents')
|
|
342
|
+
if (!existsSync(base)) return []
|
|
343
|
+
const ids: string[] = []
|
|
344
|
+
for (const delegation of readdirSync(base, { withFileTypes: true })) {
|
|
345
|
+
if (!delegation.isDirectory()) continue
|
|
346
|
+
const delegationDir = join(base, delegation.name)
|
|
347
|
+
for (const child of readdirSync(delegationDir, { withFileTypes: true })) {
|
|
348
|
+
if (child.isDirectory() && child.name.startsWith('child-')) {
|
|
349
|
+
const id = child.name.slice('child-'.length)
|
|
350
|
+
if (id !== '' && !ids.includes(id)) ids.push(id)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return ids
|
|
355
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T12 — register each phase's rules as a SKILL, so they are discoverable in the catalogue.
|
|
3
|
+
*
|
|
4
|
+
* WHY. Phase 8 writes skill memory to the filesystem, and a phase's rules otherwise live only in
|
|
5
|
+
* this plugin's `phase-rules.ts` — reachable by grepping a checkout, not by the agent or a child
|
|
6
|
+
* asking what a phase requires. The native `ctx.skills` registry is a *catalogue*: a skill it
|
|
7
|
+
* holds is discoverable by name, addressable by `get()`, and visible to a discovery consumer.
|
|
8
|
+
* Registering the phase rules there is what turns "the rules are in the source" into "the rules
|
|
9
|
+
* are answerable".
|
|
10
|
+
*
|
|
11
|
+
* ⚠ THE REGISTRATION IS A RUNTIME CONTRIBUTION, and the harness names that source explicitly
|
|
12
|
+
* (`SkillSource` includes `'runtime'`). Declaring `source: 'runtime'` is not decoration: it is how
|
|
13
|
+
* the registry can tell this skill apart from one a checkout or a user directory supplied, and it
|
|
14
|
+
* is what makes the plugin's contribution replaceable rather than tangled with the filesystem's.
|
|
15
|
+
*
|
|
16
|
+
* ⚠ AN ABSENT REGISTRY IS NOT A FAILURE. `ctx.skills` is an optional service; with none mounted
|
|
17
|
+
* the helper does nothing and SAYS it did nothing (`skipped: true`) — the plugin's convention for
|
|
18
|
+
* every optional seam, because a composition without a catalogue should still run the workflow.
|
|
19
|
+
*/
|
|
20
|
+
import { phaseRulesFor, type PhaseRules } from './phase-rules.ts'
|
|
21
|
+
|
|
22
|
+
/** The prefix every phase skill carries, so the plugin's contributions are recognisable. */
|
|
23
|
+
export const PHASE_SKILL_PREFIX = 'recursive-phase'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One runtime skill contribution.
|
|
27
|
+
*
|
|
28
|
+
* Structural rather than imported from the harness: the plugin models every harness touchpoint as
|
|
29
|
+
* a minimal seam, and this keeps the shape testable with a fake registry.
|
|
30
|
+
*/
|
|
31
|
+
export interface PhaseSkillRegistration {
|
|
32
|
+
name: string
|
|
33
|
+
description: string
|
|
34
|
+
whenToUse?: string
|
|
35
|
+
content: string
|
|
36
|
+
/** A RUNTIME contribution — see the module comment for why this is declared, not implied. */
|
|
37
|
+
source: 'runtime'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SkillRegistryLike {
|
|
41
|
+
register(registration: PhaseSkillRegistration): () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The skill name for a phase file.
|
|
46
|
+
*
|
|
47
|
+
* Kebab-case, because the registry addresses skills by a kebab-case identifier — and STABLE,
|
|
48
|
+
* because a discoverable rule nobody can name is not discoverable. `01.5-root-cause.md` becomes
|
|
49
|
+
* `recursive-phase-01-5-root-cause`: the dot cannot survive, and dropping the sub-phase number
|
|
50
|
+
* would collide it with a whole phase.
|
|
51
|
+
*/
|
|
52
|
+
export function phaseSkillName(fileName: string): string {
|
|
53
|
+
const slug = fileName
|
|
54
|
+
.replace(/\.md$/i, '')
|
|
55
|
+
.toLowerCase()
|
|
56
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
57
|
+
.replace(/^-+|-+$/g, '')
|
|
58
|
+
return PHASE_SKILL_PREFIX + '-' + slug
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The markdown body a reader gets from the catalogue. */
|
|
62
|
+
function phaseSkillContent(rules: PhaseRules): string {
|
|
63
|
+
const lines: string[] = []
|
|
64
|
+
lines.push('# Phase rules: ' + rules.fileName)
|
|
65
|
+
lines.push('')
|
|
66
|
+
lines.push('This phase is ' + rules.label + '. Its required sections are checked by the linter,')
|
|
67
|
+
lines.push('and a missing one is a lint failure — not a style preference.')
|
|
68
|
+
lines.push('')
|
|
69
|
+
lines.push('## Required sections')
|
|
70
|
+
lines.push('')
|
|
71
|
+
for (const section of rules.requiredSections) {
|
|
72
|
+
lines.push('- ' + section)
|
|
73
|
+
}
|
|
74
|
+
if (rules.requiredSections.length === 0) {
|
|
75
|
+
lines.push('- (none declared for this phase)')
|
|
76
|
+
}
|
|
77
|
+
lines.push('')
|
|
78
|
+
lines.push('## Flags')
|
|
79
|
+
lines.push('')
|
|
80
|
+
lines.push('- audited: ' + (rules.audited ? 'yes — this phase needs a delegated audit' : 'no'))
|
|
81
|
+
lines.push('- tdd: ' + (rules.tdd ? 'yes — the TDD Compliance gate applies' : 'no'))
|
|
82
|
+
lines.push('- qa: ' + (rules.qa ? 'yes — the manual QA gate applies' : 'no'))
|
|
83
|
+
return lines.join('\n')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build the registration for one phase. Pure, so the mapping is testable without a registry. */
|
|
87
|
+
export function phaseSkillRegistration(rules: PhaseRules): PhaseSkillRegistration {
|
|
88
|
+
const gates: string[] = []
|
|
89
|
+
if (rules.audited) gates.push('audited')
|
|
90
|
+
if (rules.tdd) gates.push('TDD')
|
|
91
|
+
if (rules.qa) gates.push('QA')
|
|
92
|
+
return {
|
|
93
|
+
name: phaseSkillName(rules.fileName),
|
|
94
|
+
description: 'Rules for ' + rules.fileName + ' (' + rules.label + '): its required sections and gates.',
|
|
95
|
+
whenToUse: 'Use when authoring, linting or reviewing ' + rules.fileName
|
|
96
|
+
+ (gates.length === 0 ? '.' : ', which carries the ' + gates.join(' / ') + ' gate(s).'),
|
|
97
|
+
content: phaseSkillContent(rules),
|
|
98
|
+
source: 'runtime',
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PhaseSkillRegistrationResult {
|
|
103
|
+
/** Names registered, in phase order. */
|
|
104
|
+
registered: string[]
|
|
105
|
+
/** Disposers, so a caller can withdraw the contribution with its own effects. */
|
|
106
|
+
disposers: Array<() => void>
|
|
107
|
+
/** True when there was no registry: nothing was registered, and that is not an error. */
|
|
108
|
+
skipped: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Register every phase's rules as a skill.
|
|
113
|
+
*
|
|
114
|
+
* Takes `fileNames` rather than reading a directory so the caller decides WHICH phases this
|
|
115
|
+
* composition exposes; the rules themselves come from `phaseRulesFor`, so there is one definition
|
|
116
|
+
* of what a phase requires and no second copy to drift.
|
|
117
|
+
*/
|
|
118
|
+
export function registerPhaseSkills(
|
|
119
|
+
skills: SkillRegistryLike | null | undefined,
|
|
120
|
+
fileNames: readonly string[],
|
|
121
|
+
workflowProfile?: string,
|
|
122
|
+
): PhaseSkillRegistrationResult {
|
|
123
|
+
if (skills === null || skills === undefined || typeof skills.register !== 'function') {
|
|
124
|
+
return { registered: [], disposers: [], skipped: true }
|
|
125
|
+
}
|
|
126
|
+
const registered: string[] = []
|
|
127
|
+
const disposers: Array<() => void> = []
|
|
128
|
+
for (const fileName of fileNames) {
|
|
129
|
+
const registration = phaseSkillRegistration(phaseRulesFor(fileName, workflowProfile))
|
|
130
|
+
disposers.push(skills.register(registration))
|
|
131
|
+
registered.push(registration.name)
|
|
132
|
+
}
|
|
133
|
+
return { registered, disposers, skipped: false }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A board-facing line for the registration result — says SKIPPED out loud rather than leaving a
|
|
138
|
+
* reader to infer a missing catalogue from an empty list.
|
|
139
|
+
*/
|
|
140
|
+
export function describePhaseSkills(result: PhaseSkillRegistrationResult): string {
|
|
141
|
+
if (result.skipped) return 'phase rules were NOT registered as skills: no skill registry is mounted'
|
|
142
|
+
return result.registered.length + ' phase rule skill(s) registered: ' + result.registered.join(', ')
|
|
143
|
+
}
|
package/src/snapshot.ts
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
import { readFileSync, existsSync } from 'node:fs'
|
|
13
13
|
import { join } from 'node:path'
|
|
14
14
|
import { discoverRuns } from './run.ts'
|
|
15
|
-
import { foldRun } from './status.ts'
|
|
15
|
+
import { foldRun, pendingWork } from './status.ts'
|
|
16
16
|
import { getLockStatus, receiptPath, readReceipt } from './lock.ts'
|
|
17
|
-
import type { RecursiveProjection, RecursiveRunCard, RecursivePhaseRow, RecursiveRunState } from './types.ts'
|
|
17
|
+
import type { PhasePosition, RecursiveProjection, RecursiveRunCard, RecursivePhaseRow, RecursiveRunState, RecursiveTamper } from './types.ts'
|
|
18
18
|
|
|
19
19
|
/** One locked-phase fact from a lock receipt (artifacts/<file>.receipt.json). */
|
|
20
20
|
interface LockFacts {
|
|
@@ -41,8 +41,11 @@ function lockFactsOf(runDir: string, artifact: string): LockFacts {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/** One phase row from a run's folded status (locked facts enriched). */
|
|
44
|
-
function phaseRowOf(runDir: string, key: string, file: string, status: string): RecursivePhaseRow {
|
|
44
|
+
function phaseRowOf(runDir: string, key: string, file: string, status: string, position?: PhasePosition): RecursivePhaseRow {
|
|
45
45
|
const row: RecursivePhaseRow = { phase: key, status }
|
|
46
|
+
// T21: carry the server's single derived position onto the wire so the board
|
|
47
|
+
// reads ONE value instead of recombining status/gates/lock fields itself.
|
|
48
|
+
if (position !== undefined) row.position = position
|
|
46
49
|
if (status === 'LOCKED') {
|
|
47
50
|
const facts = lockFactsOf(runDir, file)
|
|
48
51
|
if (facts.lockedAt !== undefined) row.lockedAt = facts.lockedAt
|
|
@@ -63,7 +66,7 @@ export function foldRunCard(runDir: string, runId: string, worktreeRoot: string)
|
|
|
63
66
|
// would never match the filename regex and would break columnForRun/cardFacts.
|
|
64
67
|
const phases: Record<string, RecursivePhaseRow> = {}
|
|
65
68
|
for (const phase of status.phases) {
|
|
66
|
-
if (phase.exists) phases[phase.file] = phaseRowOf(runDir, phase.file, phase.file, phase.status)
|
|
69
|
+
if (phase.exists) phases[phase.file] = phaseRowOf(runDir, phase.file, phase.file, phase.status, phase.position)
|
|
67
70
|
}
|
|
68
71
|
const state = runStateOf(status)
|
|
69
72
|
return {
|
|
@@ -71,17 +74,45 @@ export function foldRunCard(runDir: string, runId: string, worktreeRoot: string)
|
|
|
71
74
|
worktreeRoot,
|
|
72
75
|
phases,
|
|
73
76
|
state,
|
|
74
|
-
tampers:
|
|
77
|
+
tampers: tampersOf(status),
|
|
75
78
|
subagents: {},
|
|
79
|
+
// T18: the card carries the same DERIVED pending set the lock gate enforces,
|
|
80
|
+
// so the board explains a refusal instead of only recording it. Derived on
|
|
81
|
+
// every fold, so a cold resume shows it and no store is involved (§4.0).
|
|
82
|
+
pendingWork: pendingWork(runDir),
|
|
76
83
|
}
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
/**
|
|
87
|
+
* T15 (F) — tamper facts by DERIVATION, never by persistence.
|
|
88
|
+
*
|
|
89
|
+
* `foldRun` already decides, per phase, whether a LOCKED artifact's stored hash
|
|
90
|
+
* still matches its content: a mismatch lands in `lockProblems` as the literal
|
|
91
|
+
* 'LockHash mismatch' and clears `lockValid`. That IS the tamper fact, so the
|
|
92
|
+
* card reads it off the fold instead of replaying the observed-tamper log —
|
|
93
|
+
* which means it survives a cold resume, honours plan §4.0 ("derived beats
|
|
94
|
+
* stored"), and stays correct even if the log was rotated or never written.
|
|
95
|
+
*
|
|
96
|
+
* Keyed by artifact FILE NAME (e.g. '00-requirements.md'), matching the phase
|
|
97
|
+
* keys above. `foldRun` itself is untouched: its output shape is
|
|
98
|
+
* parity-asserted by tests/status.parity.spec.ts.
|
|
99
|
+
*/
|
|
100
|
+
function tampersOf(status: ReturnType<typeof foldRun>): Record<string, RecursiveTamper> {
|
|
101
|
+
const tampers: Record<string, RecursiveTamper> = {}
|
|
102
|
+
for (const phase of status.phases) {
|
|
103
|
+
if (phase.status !== 'LOCKED' || phase.lockValid) continue
|
|
104
|
+
if (!phase.lockProblems.includes('LockHash mismatch')) continue
|
|
105
|
+
tampers[phase.file] = { path: phase.file, reason: phase.lockProblems.join('; ') }
|
|
106
|
+
}
|
|
107
|
+
return tampers
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
/**
|
|
80
111
|
* Derive the run-level state from the folded status. The fs stores no run-level
|
|
81
112
|
* state file, so the honest mapping is: 'complete' when every non-optional phase
|
|
82
|
-
* exists and is lock-valid, else 'active'.
|
|
83
|
-
*
|
|
84
|
-
* limitation; future enhancement).
|
|
113
|
+
* exists and is lock-valid, else 'active'. Tamper facts ARE fs-recoverable and
|
|
114
|
+
* are derived by tampersOf above; gateBlocked and subagent activity are not
|
|
115
|
+
* (recorded limitation; future enhancement).
|
|
85
116
|
*/
|
|
86
117
|
function runStateOf(status: ReturnType<typeof foldRun>): RecursiveRunState {
|
|
87
118
|
const mandatory = status.phases.filter(p => !p.optional)
|