@try-works/dsh-recursive-mode 0.3.0 → 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.
Files changed (120) hide show
  1. package/README.md +959 -0
  2. package/lib/client.js +9 -2
  3. package/lib/closeout-report.d.ts +113 -0
  4. package/lib/closeout-standards.d.ts +35 -0
  5. package/lib/closeout.d.ts +12 -0
  6. package/lib/commands.d.ts +1 -1
  7. package/lib/config.d.ts +202 -0
  8. package/lib/delegation.d.ts +123 -3
  9. package/lib/enforcement.d.ts +90 -1
  10. package/lib/errors.d.ts +168 -0
  11. package/lib/git-context.d.ts +17 -0
  12. package/lib/guard-log.d.ts +39 -0
  13. package/lib/handoff.d.ts +29 -0
  14. package/lib/hooks.d.ts +103 -0
  15. package/lib/identity.d.ts +61 -0
  16. package/lib/index.d.ts +33 -12
  17. package/lib/index.js +10017 -3969
  18. package/lib/job-log.d.ts +34 -0
  19. package/lib/jobs-runner.d.ts +105 -0
  20. package/lib/json-safe.d.ts +33 -0
  21. package/lib/lock.d.ts +42 -0
  22. package/lib/memory-feedback.d.ts +52 -0
  23. package/lib/memory-select.d.ts +78 -0
  24. package/lib/memory.d.ts +137 -0
  25. package/lib/model-inventory.d.ts +106 -0
  26. package/lib/phase-graph.d.ts +111 -0
  27. package/lib/phase-rules.d.ts +67 -8
  28. package/lib/plan-gate.d.ts +68 -0
  29. package/lib/policy-globs.d.ts +222 -0
  30. package/lib/policy-write.d.ts +42 -0
  31. package/lib/policy.d.ts +39 -0
  32. package/lib/recursive_ask.tool.d.ts +88 -0
  33. package/lib/recursive_closeout.tool.d.ts +1 -1
  34. package/lib/recursive_delegate.tool.d.ts +22 -0
  35. package/lib/recursive_preview.tool.d.ts +48 -0
  36. package/lib/recursive_review.tool.d.ts +28 -0
  37. package/lib/result-cap.d.ts +70 -0
  38. package/lib/review-round.d.ts +82 -0
  39. package/lib/review.d.ts +9 -0
  40. package/lib/role-route.d.ts +122 -0
  41. package/lib/router.d.ts +90 -5
  42. package/lib/runtime.d.ts +252 -12
  43. package/lib/settlement.d.ts +132 -0
  44. package/lib/skills-phase.d.ts +71 -0
  45. package/lib/skills.d.ts +70 -0
  46. package/lib/status.d.ts +53 -1
  47. package/lib/teams-loop.d.ts +91 -2
  48. package/lib/training.d.ts +211 -0
  49. package/lib/ts-lint.d.ts +15 -0
  50. package/lib/types.d.ts +48 -0
  51. package/lib/workflow-audit.d.ts +207 -0
  52. package/package.json +31 -31
  53. package/preset/recursive.patch.yml +312 -0
  54. package/scripts/e2e-run.mjs +51 -0
  55. package/scripts/link-dsh.mjs +233 -0
  56. package/scripts/live/fake-llm.mjs +150 -0
  57. package/scripts/live-session-plugin.mjs +179 -0
  58. package/scripts/live-session-stock.mjs +106 -0
  59. package/scripts/live-session.mjs +139 -0
  60. package/skills/recursive-mode/SKILL.md +66 -0
  61. package/src/client/derive.ts +18 -2
  62. package/src/closeout-report.ts +274 -0
  63. package/src/closeout-standards.ts +102 -0
  64. package/src/closeout.ts +39 -2
  65. package/src/commands.ts +116 -4
  66. package/src/config.ts +113 -0
  67. package/src/delegation.ts +336 -18
  68. package/src/enforcement.ts +262 -72
  69. package/src/errors.ts +197 -0
  70. package/src/git-context.ts +33 -2
  71. package/src/guard-log.ts +134 -0
  72. package/src/handoff.ts +62 -0
  73. package/src/hooks.ts +316 -0
  74. package/src/identity.ts +230 -0
  75. package/src/index.ts +394 -20
  76. package/src/job-log.ts +112 -0
  77. package/src/jobs-runner.ts +222 -0
  78. package/src/json-safe.ts +75 -0
  79. package/src/lock.ts +153 -16
  80. package/src/memory-feedback.ts +185 -0
  81. package/src/memory-select.ts +187 -0
  82. package/src/memory.ts +309 -0
  83. package/src/model-inventory.ts +196 -0
  84. package/src/phase-graph.ts +191 -0
  85. package/src/phase-rules.ts +236 -0
  86. package/src/plan-gate.ts +111 -0
  87. package/src/policy-globs.ts +636 -0
  88. package/src/policy-write.ts +210 -0
  89. package/src/policy.ts +70 -5
  90. package/src/recursive_ask.tool.ts +276 -0
  91. package/src/recursive_audit_team.tool.ts +7 -3
  92. package/src/recursive_closeout.tool.ts +36 -35
  93. package/src/recursive_delegate.tool.ts +194 -0
  94. package/src/recursive_init.tool.ts +4 -3
  95. package/src/recursive_lint.tool.ts +81 -6
  96. package/src/recursive_lock.tool.ts +21 -4
  97. package/src/recursive_phase.tool.ts +3 -2
  98. package/src/recursive_preview.tool.ts +142 -0
  99. package/src/recursive_review.tool.ts +190 -0
  100. package/src/recursive_scratch.tool.ts +5 -4
  101. package/src/recursive_status.tool.ts +3 -2
  102. package/src/recursive_worktree.tool.ts +6 -5
  103. package/src/result-cap.ts +130 -0
  104. package/src/review-round.ts +335 -0
  105. package/src/review.ts +17 -3
  106. package/src/role-route.ts +230 -0
  107. package/src/router.ts +128 -2
  108. package/src/runtime.ts +968 -39
  109. package/src/settlement.ts +355 -0
  110. package/src/skills-phase.ts +143 -0
  111. package/src/skills.ts +151 -0
  112. package/src/snapshot.ts +39 -8
  113. package/src/status.ts +209 -4
  114. package/src/teams-loop.ts +223 -9
  115. package/src/training.ts +565 -0
  116. package/src/ts-lint.ts +38 -4
  117. package/src/types.ts +51 -0
  118. package/src/workflow-audit.ts +288 -0
  119. package/scripts/install-preset.cmd +0 -7
  120. 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
+ }
@@ -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
- const idx = phaseIndex(artifactFile)
114
- if (idx <= 0) return []
115
- const prereqs: string[] = []
116
- for (const phase of PHASE_SEQUENCE.slice(0, idx)) {
117
- if (existsSync(join(runDir, phase))) prereqs.push(phase)
118
- }
119
- return prereqs
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
- for (const phase of PHASE_SEQUENCE) {
253
- const phasePath = join(runDir, phase)
254
- const status = getLockStatus(phasePath)
255
- if (status === 'LOCKED') continue
256
- if (!existsSync(phasePath) && OPTIONAL_PHASES.has(phase)) continue
257
- if (getPrerequisiteBlockers(runDir, phase).length > 0) return null
258
- return phase
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
+ }