@try-works/dsh-recursive-mode 0.3.1 → 0.4.1
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/agent.cordis.yml +2 -2
- 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,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T10 — track a long operation as a NATIVE job.
|
|
3
|
+
*
|
|
4
|
+
* WHY. `lintRun`, `createLinkedWorktree` and `delegateReview` run synchronously in-block,
|
|
5
|
+
* so a hung one is invisible on the board and unstoppable from it. The native registry
|
|
6
|
+
* already owns identity, lifecycle state, a bounded output ring and a kill switch; the
|
|
7
|
+
* plugin should not invent any of that, so this adapts the plugin's long operations onto
|
|
8
|
+
* `ctx.jobs` instead of building a parallel notion of "running".
|
|
9
|
+
*
|
|
10
|
+
* ⚠ AN OPTIONAL SERVICE, DEGRADED HONESTLY. `ctx.jobs` is absent in a composition that does
|
|
11
|
+
* not mount the registry. In that case the work runs DIRECTLY and the result says
|
|
12
|
+
* `tracked: false` — because failing a lint (or a worktree create) merely because no board
|
|
13
|
+
* is attached would be a worse failure than losing the progress display. The caller can
|
|
14
|
+
* always tell which happened, so "untracked" is never silently passed off as "tracked".
|
|
15
|
+
*
|
|
16
|
+
* ⚠ A CANCELLED OPERATION SETTLES `killed`, NOT `failed`. The distinction is the whole
|
|
17
|
+
* point of exposing a kill switch: "the operator stopped this" and "this broke" call for
|
|
18
|
+
* different responses, and a board that renders a deliberate kill as a failure teaches
|
|
19
|
+
* people to distrust the kill switch.
|
|
20
|
+
*
|
|
21
|
+
* ⚠ THE TIMEOUT IS A KILL, NOT A HANG. A deadline reached means the registry's `cancel` is
|
|
22
|
+
* invoked and the outcome is reported as killed with the reason — so a hung operation
|
|
23
|
+
* cannot leave a caller waiting forever on a promise nobody owns.
|
|
24
|
+
*
|
|
25
|
+
* Types are structural (the plugin's established seam style), so this module depends on the
|
|
26
|
+
* SHAPE of the registry rather than on the package, and a test can drive it with a fake
|
|
27
|
+
* without mounting the real service.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The producer face the registry hands `run`: identity plus the ring and progress writers. */
|
|
31
|
+
export interface JobHandleLike {
|
|
32
|
+
readonly id: string
|
|
33
|
+
append(text: string, options?: { stream?: string }): void
|
|
34
|
+
updateProgress(line: string): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How a job ended. Mirrors the registry's own vocabulary verbatim. */
|
|
38
|
+
export interface JobOutcomeLike {
|
|
39
|
+
status: 'completed' | 'killed' | 'failed'
|
|
40
|
+
detail?: string
|
|
41
|
+
result?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface JobHooksLike {
|
|
45
|
+
cancel(reason?: string): void
|
|
46
|
+
done: Promise<JobOutcomeLike>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface JobSpecLike {
|
|
50
|
+
kind: string
|
|
51
|
+
label: string
|
|
52
|
+
owner?: unknown
|
|
53
|
+
outputLimitBytes?: number
|
|
54
|
+
run(job: JobHandleLike): JobHooksLike
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface JobsRegistryLike {
|
|
58
|
+
start(spec: JobSpecLike): string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What one tracked run produced. */
|
|
62
|
+
export interface JobRunResult<T> {
|
|
63
|
+
/** False when no registry was available and the work ran inline. */
|
|
64
|
+
tracked: boolean
|
|
65
|
+
/** The registry-issued id, when there was one. */
|
|
66
|
+
jobId?: string
|
|
67
|
+
status: 'completed' | 'killed' | 'failed'
|
|
68
|
+
/** The terminal reason, for the board's status line. */
|
|
69
|
+
detail?: string
|
|
70
|
+
/** The operation's own value; absent when it was killed or threw. */
|
|
71
|
+
value?: T
|
|
72
|
+
/** The error message when the operation threw. */
|
|
73
|
+
error?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TrackedRunOptions<T> {
|
|
77
|
+
/** Producer kind, also the id prefix: `lint`, `worktree`, `delegation`. */
|
|
78
|
+
kind: string
|
|
79
|
+
/** One-line, model-facing label. */
|
|
80
|
+
label: string
|
|
81
|
+
owner?: unknown
|
|
82
|
+
/** A deadline. Reaching it CANCELS the work rather than abandoning it. */
|
|
83
|
+
timeoutMs?: number
|
|
84
|
+
/** The work. It receives a progress reporter and an abort signal to honour. */
|
|
85
|
+
run: (context: { report: (line: string) => void; signal: AbortSignal }) => Promise<T>
|
|
86
|
+
/** Render the value into the job's terminal result line. */
|
|
87
|
+
render?: (value: T) => string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The reason used when a deadline cancelled the work. */
|
|
91
|
+
export const TIMEOUT_REASON = 'timed out'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run `options.run` as a native job when a registry is available, inline when it is not.
|
|
95
|
+
*
|
|
96
|
+
* Never throws for a failure of the WORK: the outcome carries `status: 'failed'` and the
|
|
97
|
+
* message, because a caller tracking a job wants to read what happened rather than catch it.
|
|
98
|
+
*/
|
|
99
|
+
export async function runTracked<T>(
|
|
100
|
+
jobs: JobsRegistryLike | null | undefined,
|
|
101
|
+
options: TrackedRunOptions<T>,
|
|
102
|
+
): Promise<JobRunResult<T>> {
|
|
103
|
+
if (jobs === null || jobs === undefined || typeof jobs.start !== 'function') {
|
|
104
|
+
// No board to report to: do the work, and SAY it was untracked.
|
|
105
|
+
try {
|
|
106
|
+
const value = await options.run({ report: () => {}, signal: new AbortController().signal })
|
|
107
|
+
return { tracked: false, status: 'completed', value }
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return {
|
|
110
|
+
tracked: false,
|
|
111
|
+
status: 'failed',
|
|
112
|
+
error: err instanceof Error ? err.message : String(err),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let value: T | undefined
|
|
118
|
+
let error: string | undefined
|
|
119
|
+
// Captured from inside the synchronous starter so the caller can await it after
|
|
120
|
+
// `start` returns the id — the registry's contract is that `run` is called once,
|
|
121
|
+
// synchronously, and that its returned hooks are the only handle on the work.
|
|
122
|
+
let captured: JobHooksLike | null = null
|
|
123
|
+
|
|
124
|
+
const jobId = jobs.start({
|
|
125
|
+
kind: options.kind,
|
|
126
|
+
label: options.label,
|
|
127
|
+
...(options.owner === undefined ? {} : { owner: options.owner }),
|
|
128
|
+
run: (job) => {
|
|
129
|
+
const controller = new AbortController()
|
|
130
|
+
const done = (async (): Promise<JobOutcomeLike> => {
|
|
131
|
+
try {
|
|
132
|
+
value = await options.run({
|
|
133
|
+
report: (line) => job.updateProgress(line),
|
|
134
|
+
signal: controller.signal,
|
|
135
|
+
})
|
|
136
|
+
return { status: 'completed', result: options.render ? options.render(value) : undefined }
|
|
137
|
+
} catch (err) {
|
|
138
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
139
|
+
// A cancelled operation reports KILLED, not failed: the signal being aborted is how
|
|
140
|
+
// a deliberate stop reaches the work, and "stopped" is not "broken". Checked BEFORE
|
|
141
|
+
// recording an error, so a kill does not ALSO leave an `error` behind — an error
|
|
142
|
+
// field says "something broke", and carrying one beside `killed` contradicts the
|
|
143
|
+
// status a board is rendering.
|
|
144
|
+
if (controller.signal.aborted) {
|
|
145
|
+
return { status: 'killed', detail: abortReason(controller.signal) }
|
|
146
|
+
}
|
|
147
|
+
error = message
|
|
148
|
+
return { status: 'failed', detail: message }
|
|
149
|
+
}
|
|
150
|
+
})()
|
|
151
|
+
captured = {
|
|
152
|
+
cancel: (reason?: string) => {
|
|
153
|
+
// Abort first so the work can wind down, and record the reason for the outcome.
|
|
154
|
+
controller.abort(reason ?? 'cancelled')
|
|
155
|
+
},
|
|
156
|
+
done,
|
|
157
|
+
}
|
|
158
|
+
return captured
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const hooks = captured as JobHooksLike | null
|
|
163
|
+
if (hooks === null) {
|
|
164
|
+
// The registry did not invoke `run`, so there is no handle on the work at all. Report
|
|
165
|
+
// it rather than pretending a job exists.
|
|
166
|
+
return { tracked: true, jobId, status: 'failed', error: 'the job registry did not start the work' }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (options.timeoutMs === undefined) {
|
|
170
|
+
const outcome = await hooks.done
|
|
171
|
+
return { tracked: true, jobId, ...outcome, ...(value === undefined ? {} : { value }), ...(error === undefined ? {} : { error }) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const outcome = await withDeadline(hooks, options.timeoutMs)
|
|
175
|
+
return { tracked: true, jobId, ...outcome, ...(value === undefined ? {} : { value }), ...(error === undefined ? {} : { error }) }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Await the outcome, or cancel the work and report a kill when the deadline passes.
|
|
180
|
+
*
|
|
181
|
+
* The timer is always cleared, so a completed run does not keep the process alive — a
|
|
182
|
+
* leaked timer in a long-lived plugin is a slow leak that only shows up much later.
|
|
183
|
+
*/
|
|
184
|
+
async function withDeadline(hooks: JobHooksLike, timeoutMs: number): Promise<JobOutcomeLike> {
|
|
185
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
186
|
+
try {
|
|
187
|
+
return await Promise.race([
|
|
188
|
+
hooks.done,
|
|
189
|
+
new Promise<JobOutcomeLike>((resolve) => {
|
|
190
|
+
timer = setTimeout(() => {
|
|
191
|
+
hooks.cancel(TIMEOUT_REASON)
|
|
192
|
+
// The outcome is reported here rather than awaited again: `cancel` is documented
|
|
193
|
+
// as eventually settling `done`, and a producer that ignores the signal must not
|
|
194
|
+
// be able to hold the caller hostage.
|
|
195
|
+
resolve({ status: 'killed', detail: TIMEOUT_REASON })
|
|
196
|
+
}, timeoutMs)
|
|
197
|
+
}),
|
|
198
|
+
])
|
|
199
|
+
} finally {
|
|
200
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The abort reason as a string, whatever the caller passed. Exported for a caller that
|
|
205
|
+
* turns an abort into its own action (T39 interrupts the child on a delegation kill). */
|
|
206
|
+
export function abortReason(signal: AbortSignal): string {
|
|
207
|
+
const reason = (signal as unknown as { reason?: unknown }).reason
|
|
208
|
+
if (typeof reason === 'string' && reason !== '') return reason
|
|
209
|
+
return 'cancelled'
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A board-facing one-liner for one run: what happened, and whether it was even tracked.
|
|
214
|
+
*
|
|
215
|
+
* Says "untracked" OUT LOUD rather than omitting the field, so a reader never has to infer
|
|
216
|
+
* from a missing id whether the job ran without a board or the board lost it.
|
|
217
|
+
*/
|
|
218
|
+
export function describeJobRun<T>(result: JobRunResult<T>): string {
|
|
219
|
+
const where = result.tracked ? 'job ' + (result.jobId ?? '(no id)') : 'untracked (no job registry)'
|
|
220
|
+
const why = result.detail ?? result.error
|
|
221
|
+
return result.status + ' — ' + where + (why === undefined ? '' : ': ' + why)
|
|
222
|
+
}
|
package/src/json-safe.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lossless-JSON projection for tool results (FU-9 defect fix).
|
|
3
|
+
*
|
|
4
|
+
* ⚠ THE DEFECT THIS EXISTS FOR, measured live: the host refused a review result with
|
|
5
|
+
*
|
|
6
|
+
* Error: tool "recursive_review" returned invalid output: value is not lossless JSON
|
|
7
|
+
* ToolOutputError, code INVALID_TOOL_OUTPUT
|
|
8
|
+
*
|
|
9
|
+
* The tool returned its service result through a TYPE CAST — `return outcome as unknown as JsonValue` — and a
|
|
10
|
+
* cast is a promise the compiler cannot keep. The outcome carries live objects: the delegation's per-round
|
|
11
|
+
* `result` is whatever the host returned for a child run, which in a real session holds an Agent or a provider
|
|
12
|
+
* seam, and neither survives `JSON.stringify`.
|
|
13
|
+
*
|
|
14
|
+
* ⚠ WHY A GENERAL PROJECTION RATHER THAN STRIPPING THE KNOWN FIELD: I could delete the `result` property and
|
|
15
|
+
* pass this round's test, but the next field the engine adds would break the tool again — and the failure mode
|
|
16
|
+
* is a REFUSED RESULT, which tells the caller nothing about the work that was done. A projection that handles
|
|
17
|
+
* cycles, functions and class instances is correct for every future field rather than for this one.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ AND IT IS HONEST ABOUT WHAT IT DROPS: a function becomes nothing, a cycle becomes a marker string, and a
|
|
20
|
+
* class instance becomes its own enumerable properties. Nothing is invented, and nothing silently vanishes
|
|
21
|
+
* without a trace in the output — a dropped key simply is not there, which is what "lossless JSON" means.
|
|
22
|
+
*/
|
|
23
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
24
|
+
|
|
25
|
+
/** What a reference to an already-visited object becomes, so a cycle is visible rather than dropped. */
|
|
26
|
+
export const CIRCULAR_MARKER = '[circular]'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Project any value into something `JSON.stringify` accepts, without throwing.
|
|
30
|
+
*
|
|
31
|
+
* Returns `null` for values that have no JSON form at all (a lone function, a symbol, `undefined`), which the
|
|
32
|
+
* caller decides how to present. Objects are walked by their OWN ENUMERABLE keys, so a class instance projects
|
|
33
|
+
* to its data and its methods are left behind — which is exactly what an Agent or a seam needs to become.
|
|
34
|
+
*/
|
|
35
|
+
export function toLosslessJson(value: unknown, seen: WeakSet<object> = new WeakSet()): JsonValue | null {
|
|
36
|
+
if (value === null) return null
|
|
37
|
+
const kind = typeof value
|
|
38
|
+
if (kind === 'string') return value as string
|
|
39
|
+
if (kind === 'boolean') return value as boolean
|
|
40
|
+
if (kind === 'number') return Number.isFinite(value as number) ? (value as number) : String(value)
|
|
41
|
+
if (kind === 'bigint') return (value as bigint).toString()
|
|
42
|
+
if (kind === 'undefined' || kind === 'function' || kind === 'symbol') return null
|
|
43
|
+
|
|
44
|
+
const object = value as object
|
|
45
|
+
if (seen.has(object)) return CIRCULAR_MARKER
|
|
46
|
+
seen.add(object)
|
|
47
|
+
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
const items: JsonValue[] = []
|
|
50
|
+
for (const item of value) {
|
|
51
|
+
const projected = toLosslessJson(item, seen)
|
|
52
|
+
// An array element with no JSON form is dropped rather than turned into null, because a null would claim
|
|
53
|
+
// the element existed and was empty.
|
|
54
|
+
if (projected !== null) items.push(projected)
|
|
55
|
+
}
|
|
56
|
+
return items
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (value instanceof Date) return value.toISOString()
|
|
60
|
+
if (value instanceof Error) return value.message
|
|
61
|
+
|
|
62
|
+
const out: Record<string, JsonValue> = {}
|
|
63
|
+
for (const key of Object.keys(object)) {
|
|
64
|
+
let raw: unknown
|
|
65
|
+
try {
|
|
66
|
+
raw = (object as Record<string, unknown>)[key]
|
|
67
|
+
} catch {
|
|
68
|
+
// A getter that throws is not a value: skip it rather than failing the whole result.
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const projected = toLosslessJson(raw, seen)
|
|
72
|
+
if (projected !== null) out[key] = projected
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
package/src/lock.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
+
// T17: the graph model, for the queries that are graph operations (see getPrerequisites).
|
|
5
|
+
import { buildPhaseGraph, nextLegalPhase, prerequisitesOf } from './phase-graph.ts'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Lock-hash + lock-chain validation for recursive-mode runs.
|
|
@@ -110,13 +112,14 @@ export function isCoreArtifact(artifactFile: string): boolean {
|
|
|
110
112
|
* Mirrors: [phase for phase in PHASE_SEQUENCE[:idx] if (run_dir / phase).exists()]
|
|
111
113
|
*/
|
|
112
114
|
export function getPrerequisites(runDir: string, artifactFile: string): string[] {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
// T17: delegated to the graph — the same query expressed as IN-EDGES. The first attempt FAILED the
|
|
116
|
+
// parity golden because the graph only had nodes for artifacts already on disk, while this function
|
|
117
|
+
// is asked about files that do not exist yet (checking prerequisites BEFORE creating one is the
|
|
118
|
+
// normal case). The graph now takes the queried artifact as an edge TARGET that emits no outgoing
|
|
119
|
+
// edge, which is exactly this function's semantics — and `lock.parity.spec.ts` is the proof.
|
|
120
|
+
const present = PHASE_SEQUENCE.filter((phase) => existsSync(join(runDir, phase)))
|
|
121
|
+
const graph = buildPhaseGraph({ sequence: PHASE_SEQUENCE, present, queried: [artifactFile] })
|
|
122
|
+
return prerequisitesOf(graph, artifactFile)
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/**
|
|
@@ -249,15 +252,14 @@ export function getStaleDownstreamPhases(runDir: string, artifactFile: string):
|
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
export function getNextLegalPhase(runDir: string): string | null {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
return null
|
|
255
|
+
// T17: delegated to the graph, which now models all three shipped rules (skip LOCKED; skip an
|
|
256
|
+
// ABSENT OPTIONAL phase; report BLOCKED rather than moving past a blocked node). `queried` carries
|
|
257
|
+
// the WHOLE sequence because an absent, required phase is a legitimate answer here, and the parity
|
|
258
|
+
// specs are the proof — the same goldens that caught the previous attempt.
|
|
259
|
+
const present = PHASE_SEQUENCE.filter((phase) => existsSync(join(runDir, phase)))
|
|
260
|
+
const locked = present.filter((phase) => getLockStatus(join(runDir, phase)) === 'LOCKED')
|
|
261
|
+
const graph = buildPhaseGraph({ sequence: PHASE_SEQUENCE, present, locked, queried: PHASE_SEQUENCE })
|
|
262
|
+
return nextLegalPhase(graph, { optional: OPTIONAL_PHASES })
|
|
261
263
|
}
|
|
262
264
|
|
|
263
265
|
/** All receipts whose prerequisite_hashes reference a missing or changed artifact. */
|
|
@@ -310,6 +312,141 @@ export function validateChain(runDir: string, runId: string): LockChainResult {
|
|
|
310
312
|
return { runId, phases, breakPhase, nextLegalPhase, complete, staleReceipts }
|
|
311
313
|
}
|
|
312
314
|
|
|
315
|
+
/** Why a receipt chain failed verification. */
|
|
316
|
+
export type ReceiptChainBreakKind =
|
|
317
|
+
| 'receipt-hash-mismatch'
|
|
318
|
+
| 'prerequisite-hash-mismatch'
|
|
319
|
+
| 'missing-prerequisite-receipt'
|
|
320
|
+
| 'malformed-previous-hash'
|
|
321
|
+
|
|
322
|
+
/** One broken link, naming the phase at fault and both values involved. */
|
|
323
|
+
export interface ReceiptChainBreak {
|
|
324
|
+
/** The artifact whose receipt is at fault. */
|
|
325
|
+
phase: string
|
|
326
|
+
kind: ReceiptChainBreakKind
|
|
327
|
+
/** A sentence naming both values, so the break is diagnosable without a debugger. */
|
|
328
|
+
detail: string
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface ReceiptChainResult {
|
|
332
|
+
runId: string
|
|
333
|
+
ok: boolean
|
|
334
|
+
/** How many receipts were examined (absent phases are skipped, not counted). */
|
|
335
|
+
checked: number
|
|
336
|
+
breaks: ReceiptChainBreak[]
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const RECEIPT_HASH_RE = /^[a-fA-F0-9]{64}$/
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* T32 — verify a run's receipts against each other and against the artifacts they
|
|
343
|
+
* cite. Closes review finding E: the receipt hash was COMPUTED on every lock and
|
|
344
|
+
* never re-derived, so `previous_receipt_hash` was written and read by nothing.
|
|
345
|
+
*
|
|
346
|
+
* READ-ONLY, deliberately. The chain is evidence, and a verification that rewrites
|
|
347
|
+
* what it verifies is worthless — so this never repairs, re-hashes or touches a
|
|
348
|
+
* receipt, and a caller can run it as often as it likes.
|
|
349
|
+
*
|
|
350
|
+
* WHAT IT CHECKS, and what it refuses to claim. `previous_receipt_hash` chains to the
|
|
351
|
+
* SAME artifact's previous receipt, and there is exactly one receipt file per
|
|
352
|
+
* artifact, so the receipt it names has been overwritten and its LINKAGE cannot be
|
|
353
|
+
* verified from disk. Rather than assert a linkage it cannot prove, this checks:
|
|
354
|
+
* 1. receipt integrity — the stored `receipt_hash` must equal the hash recomputed
|
|
355
|
+
* from the receipt's own fields (catches an edit after the fact);
|
|
356
|
+
* 2. prerequisite agreement — each recorded `prerequisite_hashes[p]` must equal
|
|
357
|
+
* p's CURRENT artifact hash (catches an upstream changed after locking);
|
|
358
|
+
* 3. gaps — a receipt citing a prerequisite that has no receipt at all;
|
|
359
|
+
* 4. a well-formedness check on `previous_receipt_hash`, which the writer could
|
|
360
|
+
* only ever produce as a sha256 hex digest or null.
|
|
361
|
+
*
|
|
362
|
+
* Never throws: an unreadable receipt is skipped, and a missing run directory is an
|
|
363
|
+
* empty result rather than an error.
|
|
364
|
+
*/
|
|
365
|
+
export function validateReceiptChain(runDir: string, runId: string): ReceiptChainResult {
|
|
366
|
+
const breaks: ReceiptChainBreak[] = []
|
|
367
|
+
let checked = 0
|
|
368
|
+
|
|
369
|
+
for (const phase of PHASE_SEQUENCE) {
|
|
370
|
+
const receipt = readReceipt(runDir, phase)
|
|
371
|
+
if (receipt === null) continue
|
|
372
|
+
checked += 1
|
|
373
|
+
|
|
374
|
+
// 1. INTEGRITY. Recompute the hash from every field the writer covered.
|
|
375
|
+
const { receipt_hash: storedHash, ...base } = receipt
|
|
376
|
+
const expectedHash = createHash('sha256').update(pythonJsonDumps(base), 'utf8').digest('hex')
|
|
377
|
+
if (storedHash !== expectedHash) {
|
|
378
|
+
// Two hashes alone say "something changed" but not WHAT, so the detail also
|
|
379
|
+
// compares the receipt's recorded artifact hash against the artifact on disk.
|
|
380
|
+
// That single comparison separates the two causes a reader cares about: the
|
|
381
|
+
// ARTIFACT was edited after locking, or the RECEIPT was edited after writing.
|
|
382
|
+
let onDisk: string | null = null
|
|
383
|
+
try {
|
|
384
|
+
onDisk = lockHashFromContent(readFileSync(join(runDir, phase), 'utf8'))
|
|
385
|
+
} catch {
|
|
386
|
+
onDisk = null
|
|
387
|
+
}
|
|
388
|
+
const recorded = String(receipt.artifact_hash)
|
|
389
|
+
const which = onDisk === null
|
|
390
|
+
? 'the artifact cannot be read now'
|
|
391
|
+
: onDisk === recorded
|
|
392
|
+
? 'the artifact still matches its recorded hash ' + recorded + ', so the RECEIPT was edited'
|
|
393
|
+
: 'the artifact now hashes ' + onDisk + ' but the receipt records ' + recorded + ', so the ARTIFACT changed after locking'
|
|
394
|
+
breaks.push({
|
|
395
|
+
phase,
|
|
396
|
+
kind: 'receipt-hash-mismatch',
|
|
397
|
+
detail: 'receipt_hash ' + String(storedHash) + ' does not match the recomputed ' + expectedHash + '; ' + which,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 4. WELL-FORMEDNESS first among the cheap checks, and NOT skipped when the
|
|
402
|
+
// integrity check already failed: a tampered field is exactly when a malformed
|
|
403
|
+
// one is worth naming too.
|
|
404
|
+
const previous = receipt.previous_receipt_hash
|
|
405
|
+
if (previous !== null && previous !== undefined && !RECEIPT_HASH_RE.test(String(previous))) {
|
|
406
|
+
breaks.push({
|
|
407
|
+
phase,
|
|
408
|
+
kind: 'malformed-previous-hash',
|
|
409
|
+
detail: 'previous_receipt_hash ' + JSON.stringify(previous) + ' is not a sha256 hex digest or null',
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 2 + 3. The cited prerequisites must still agree with what is on disk.
|
|
414
|
+
const cited = receipt.prerequisite_hashes ?? {}
|
|
415
|
+
for (const prereq of Object.keys(cited)) {
|
|
416
|
+
const recorded = cited[prereq]
|
|
417
|
+
if (readReceipt(runDir, prereq) === null) {
|
|
418
|
+
breaks.push({
|
|
419
|
+
phase,
|
|
420
|
+
kind: 'missing-prerequisite-receipt',
|
|
421
|
+
detail: 'cites ' + prereq + ' as a locked prerequisite, but no receipt exists for it',
|
|
422
|
+
})
|
|
423
|
+
continue
|
|
424
|
+
}
|
|
425
|
+
let currentHash: string | null = null
|
|
426
|
+
try {
|
|
427
|
+
currentHash = lockHashFromContent(readFileSync(join(runDir, prereq), 'utf8'))
|
|
428
|
+
} catch {
|
|
429
|
+
currentHash = null
|
|
430
|
+
}
|
|
431
|
+
if (currentHash === null) {
|
|
432
|
+
breaks.push({
|
|
433
|
+
phase,
|
|
434
|
+
kind: 'prerequisite-hash-mismatch',
|
|
435
|
+
detail: 'cites ' + prereq + ' at ' + String(recorded) + ', but its artifact cannot be read now',
|
|
436
|
+
})
|
|
437
|
+
} else if (currentHash !== recorded) {
|
|
438
|
+
breaks.push({
|
|
439
|
+
phase,
|
|
440
|
+
kind: 'prerequisite-hash-mismatch',
|
|
441
|
+
detail: 'recorded ' + String(recorded) + ' for ' + prereq + ', which now hashes ' + currentHash,
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return { runId, ok: breaks.length === 0, checked, breaks }
|
|
448
|
+
}
|
|
449
|
+
|
|
313
450
|
/**
|
|
314
451
|
* Python json.dumps(obj, sort_keys=True, separators=(', ', ': '), ensure_ascii=True)
|
|
315
452
|
* compact serialization. Exported for parity testing against the Python oracle.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory feedback (FU-13 P3) — which entries were injected into which run, and how that run turned out.
|
|
3
|
+
*
|
|
4
|
+
* ⚠ WHAT THIS IS, HONESTLY LABELLED: a COUNTER, not learning. `TRAINING.md` puts model training (the
|
|
5
|
+
* reference's 58 KB GRPO script) out of scope for a workflow plugin. What is in scope is the loop that makes
|
|
6
|
+
* retrieval improve with evidence: record what was injected, settle it against the run's outcome, and let the
|
|
7
|
+
* selector prefer what has held up.
|
|
8
|
+
*
|
|
9
|
+
* ⚠ AND WHY THE COUNTERS LIVE IN A SIDECAR, NOT IN THE SHARDS. The memory plane's documents are
|
|
10
|
+
* human-authored: a person writes the lesson. Rewriting one to bump a number would be the silent-overwrite
|
|
11
|
+
* defect this repo already produced once (the closeout writing over phase documents), applied to the memory
|
|
12
|
+
* plane. So the counters live in `.recursive/memory/.feedback.json`, which is machine-owned and disposable —
|
|
13
|
+
* delete it and you lose ranking evidence, not knowledge.
|
|
14
|
+
*
|
|
15
|
+
* ⚠ NO TIMESTAMPS ANYWHERE: two runs with the same inputs must produce identical files, which is the same
|
|
16
|
+
* discipline the lock receipts, the closeout receipts and the selection output all follow.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { dirname, join } from 'node:path'
|
|
20
|
+
|
|
21
|
+
/** Where the machine-owned counters live — inside the memory plane, but never a shard a person writes. */
|
|
22
|
+
export const FEEDBACK_FILE = 'memory/.feedback.json'
|
|
23
|
+
|
|
24
|
+
/** Where a run records what it was shown. */
|
|
25
|
+
export const INJECTIONS_FILE = 'memory-injections.json'
|
|
26
|
+
|
|
27
|
+
/** One entry the agent was shown, as the run recorded it. */
|
|
28
|
+
export interface InjectionRecord {
|
|
29
|
+
/** The entry's source path, which is also its identity in the counters. */
|
|
30
|
+
source: string
|
|
31
|
+
title: string
|
|
32
|
+
/** The phase it was injected for. */
|
|
33
|
+
phase: string
|
|
34
|
+
score: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FeedbackCounter {
|
|
38
|
+
/** Times an entry was injected for a phase that then locked on that round. */
|
|
39
|
+
applied: number
|
|
40
|
+
/** Times an entry was injected for a phase that had to come round again before it locked. */
|
|
41
|
+
contradicted: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type FeedbackBook = Record<string, FeedbackCounter>
|
|
45
|
+
|
|
46
|
+
/** Read the counters. A missing or unreadable file is an empty book, never an error. */
|
|
47
|
+
export function readFeedback(root: string, readFile: (path: string) => string | null = defaultRead): FeedbackBook {
|
|
48
|
+
const text = readFile(join(root, FEEDBACK_FILE))
|
|
49
|
+
if (text === null || text.trim() === '') return {}
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(text) as unknown
|
|
52
|
+
if (typeof parsed !== 'object' || parsed === null) return {}
|
|
53
|
+
const book: FeedbackBook = {}
|
|
54
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
55
|
+
const record = value as Partial<FeedbackCounter> | null
|
|
56
|
+
if (record === null || typeof record !== 'object') continue
|
|
57
|
+
book[key] = {
|
|
58
|
+
applied: Number.isFinite(record.applied) ? Number(record.applied) : 0,
|
|
59
|
+
contradicted: Number.isFinite(record.contradicted) ? Number(record.contradicted) : 0,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return book
|
|
63
|
+
} catch {
|
|
64
|
+
return {}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read what a run recorded being shown. */
|
|
69
|
+
export function readInjections(runDir: string, readFile: (path: string) => string | null = defaultRead): InjectionRecord[] {
|
|
70
|
+
const text = readFile(join(runDir, INJECTIONS_FILE))
|
|
71
|
+
if (text === null || text.trim() === '') return []
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse(text) as unknown
|
|
74
|
+
return Array.isArray(parsed) ? parsed as InjectionRecord[] : []
|
|
75
|
+
} catch {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Record what the run was shown, MERGED by (source, title, phase).
|
|
82
|
+
*
|
|
83
|
+
* ⚠ MERGED RATHER THAN APPENDED, because a phase can be re-entered while it is still DRAFT and the same
|
|
84
|
+
* entries are selected again. Appending would count one decision as four, and the counters exist to be
|
|
85
|
+
* evidence. The highest score seen wins, since that is what the agent was most recently shown.
|
|
86
|
+
*/
|
|
87
|
+
export function recordInjection(
|
|
88
|
+
runDir: string,
|
|
89
|
+
entries: readonly { source: string; title: string; score: number }[],
|
|
90
|
+
phase: string,
|
|
91
|
+
write: (path: string, content: string) => void = defaultWrite,
|
|
92
|
+
readFile: (path: string) => string | null = defaultRead,
|
|
93
|
+
): InjectionRecord[] {
|
|
94
|
+
const existing = readInjections(runDir, readFile)
|
|
95
|
+
const byKey = new Map<string, InjectionRecord>()
|
|
96
|
+
for (const record of existing) byKey.set(keyOf(record), record)
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
const candidate: InjectionRecord = { source: entry.source, title: entry.title, phase, score: entry.score }
|
|
99
|
+
const key = keyOf(candidate)
|
|
100
|
+
const prior = byKey.get(key)
|
|
101
|
+
byKey.set(key, prior === undefined || candidate.score > prior.score ? candidate : prior)
|
|
102
|
+
}
|
|
103
|
+
// Deterministic order, so the file is comparable between runs.
|
|
104
|
+
const merged = [...byKey.values()].sort((a, b) => a.phase.localeCompare(b.phase)
|
|
105
|
+
|| a.source.localeCompare(b.source) || a.title.localeCompare(b.title))
|
|
106
|
+
write(join(runDir, INJECTIONS_FILE), JSON.stringify(merged, null, 2) + '\n')
|
|
107
|
+
return merged
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Settle a finished run against its own outcome and return the updated book.
|
|
112
|
+
*
|
|
113
|
+
* ⚠ THE OUTCOME SIGNAL, and it is deliberately modest: an entry injected for a phase that **locked** was
|
|
114
|
+
* APPLIED; an entry injected for a phase that had to come round again before it locked is CONTRADICTED — the
|
|
115
|
+
* memory did not carry the phase the first time. That is a real signal available from the run's own files,
|
|
116
|
+
* and it is not dressed up as more than that: no model was trained, and an entry is never deleted for losing.
|
|
117
|
+
*/
|
|
118
|
+
export function settleInjections(
|
|
119
|
+
root: string,
|
|
120
|
+
runDir: string,
|
|
121
|
+
lockedPhases: readonly string[],
|
|
122
|
+
write: (path: string, content: string) => void = defaultWrite,
|
|
123
|
+
readFile: (path: string) => string | null = defaultRead,
|
|
124
|
+
): FeedbackBook {
|
|
125
|
+
const injections = readInjections(runDir, readFile)
|
|
126
|
+
const book = readFeedback(root, readFile)
|
|
127
|
+
const locked = new Set(lockedPhases)
|
|
128
|
+
|
|
129
|
+
// ⚠ ONLY `applied` IS SETTLED HERE, and the first version of this claimed more than it could show. It tried
|
|
130
|
+
// to count a CONTRADICTION when "the phase needed more than one round", but `recordInjection` MERGES by
|
|
131
|
+
// (source, title, phase) precisely so a re-entered DRAFT phase counts once — so the re-entry history that
|
|
132
|
+
// rule needs is discarded before settle ever sees it, and the counts came out wrong in a way a reader would
|
|
133
|
+
// have believed. `contradicted` stays in the type and in `feedbackBonus`, because the ranking already knows
|
|
134
|
+
// what to do with it; what is missing is an honest SOURCE for it (a re-opened phase), and until there is
|
|
135
|
+
// one, this function reports only what it can prove.
|
|
136
|
+
for (const record of injections) {
|
|
137
|
+
if (!locked.has(record.phase)) continue
|
|
138
|
+
const counter = book[record.source] ?? { applied: 0, contradicted: 0 }
|
|
139
|
+
counter.applied += 1
|
|
140
|
+
book[record.source] = counter
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
write(join(root, FEEDBACK_FILE), JSON.stringify(sortBook(book), null, 2) + '\n')
|
|
144
|
+
return book
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What the counters are worth in the ranking: `applied - contradicted`, clamped to one step.
|
|
149
|
+
*
|
|
150
|
+
* Clamped because a single long-lived entry should not be able to dominate the ranking forever, and because
|
|
151
|
+
* the counter is evidence about retrieval, not a verdict about the lesson.
|
|
152
|
+
*/
|
|
153
|
+
export function feedbackBonus(book: FeedbackBook, source: string): number {
|
|
154
|
+
const counter = book[source]
|
|
155
|
+
if (counter === undefined) return 0
|
|
156
|
+
const net = counter.applied - counter.contradicted
|
|
157
|
+
return net === 0 ? 0 : net > 0 ? 1 : -1
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function countPhase(injections: readonly InjectionRecord[], phase: string): number {
|
|
161
|
+
return injections.filter((record) => record.phase === phase).length
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function keyOf(record: { source: string; title: string; phase: string }): string {
|
|
165
|
+
return record.phase + '\u0000' + record.source + '\u0000' + record.title
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sortBook(book: FeedbackBook): FeedbackBook {
|
|
169
|
+
const sorted: FeedbackBook = {}
|
|
170
|
+
for (const key of Object.keys(book).sort()) sorted[key] = book[key]
|
|
171
|
+
return sorted
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function defaultRead(path: string): string | null {
|
|
175
|
+
try {
|
|
176
|
+
return readFileSync(path, 'utf8')
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function defaultWrite(path: string, content: string): void {
|
|
183
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
184
|
+
writeFileSync(path, content, 'utf8')
|
|
185
|
+
}
|