@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.
Files changed (117) 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 +32 -12
  17. package/lib/index.js +9819 -3858
  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/status.d.ts +53 -1
  46. package/lib/teams-loop.d.ts +91 -2
  47. package/lib/training.d.ts +211 -0
  48. package/lib/ts-lint.d.ts +15 -0
  49. package/lib/types.d.ts +48 -0
  50. package/lib/workflow-audit.d.ts +207 -0
  51. package/package.json +29 -30
  52. package/preset/recursive.patch.yml +312 -0
  53. package/scripts/e2e-run.mjs +51 -0
  54. package/scripts/link-dsh.mjs +233 -0
  55. package/scripts/live/fake-llm.mjs +150 -0
  56. package/scripts/live-session-plugin.mjs +179 -0
  57. package/scripts/live-session-stock.mjs +106 -0
  58. package/scripts/live-session.mjs +139 -0
  59. package/src/client/derive.ts +18 -2
  60. package/src/closeout-report.ts +274 -0
  61. package/src/closeout-standards.ts +102 -0
  62. package/src/closeout.ts +39 -2
  63. package/src/commands.ts +116 -4
  64. package/src/config.ts +113 -0
  65. package/src/delegation.ts +336 -18
  66. package/src/enforcement.ts +262 -72
  67. package/src/errors.ts +197 -0
  68. package/src/git-context.ts +33 -2
  69. package/src/guard-log.ts +134 -0
  70. package/src/handoff.ts +62 -0
  71. package/src/hooks.ts +316 -0
  72. package/src/identity.ts +230 -0
  73. package/src/index.ts +385 -20
  74. package/src/job-log.ts +112 -0
  75. package/src/jobs-runner.ts +222 -0
  76. package/src/json-safe.ts +75 -0
  77. package/src/lock.ts +153 -16
  78. package/src/memory-feedback.ts +185 -0
  79. package/src/memory-select.ts +187 -0
  80. package/src/memory.ts +309 -0
  81. package/src/model-inventory.ts +196 -0
  82. package/src/phase-graph.ts +191 -0
  83. package/src/phase-rules.ts +236 -0
  84. package/src/plan-gate.ts +111 -0
  85. package/src/policy-globs.ts +636 -0
  86. package/src/policy-write.ts +210 -0
  87. package/src/policy.ts +70 -5
  88. package/src/recursive_ask.tool.ts +276 -0
  89. package/src/recursive_audit_team.tool.ts +7 -3
  90. package/src/recursive_closeout.tool.ts +36 -35
  91. package/src/recursive_delegate.tool.ts +194 -0
  92. package/src/recursive_init.tool.ts +4 -3
  93. package/src/recursive_lint.tool.ts +81 -6
  94. package/src/recursive_lock.tool.ts +21 -4
  95. package/src/recursive_phase.tool.ts +3 -2
  96. package/src/recursive_preview.tool.ts +142 -0
  97. package/src/recursive_review.tool.ts +190 -0
  98. package/src/recursive_scratch.tool.ts +5 -4
  99. package/src/recursive_status.tool.ts +3 -2
  100. package/src/recursive_worktree.tool.ts +6 -5
  101. package/src/result-cap.ts +130 -0
  102. package/src/review-round.ts +335 -0
  103. package/src/review.ts +17 -3
  104. package/src/role-route.ts +230 -0
  105. package/src/router.ts +128 -2
  106. package/src/runtime.ts +968 -39
  107. package/src/settlement.ts +355 -0
  108. package/src/skills-phase.ts +143 -0
  109. package/src/snapshot.ts +39 -8
  110. package/src/status.ts +209 -4
  111. package/src/teams-loop.ts +223 -9
  112. package/src/training.ts +565 -0
  113. package/src/ts-lint.ts +38 -4
  114. package/src/types.ts +51 -0
  115. package/src/workflow-audit.ts +288 -0
  116. package/scripts/install-preset.cmd +0 -7
  117. package/scripts/install-preset.js +0 -101
@@ -0,0 +1,565 @@
1
+ /**
2
+ * T30 — extract learnings at RUN CLOSE: the plugin finally WRITES the memory plane.
3
+ *
4
+ * WHY. The plugin built and linted the memory plane and **never wrote it**. Phase 8 scaffolds
5
+ * `08-memory-impact.md` as a receipt stub and nothing promoted its content into cross-run memory, so
6
+ * every run's lessons died with the run.
7
+ *
8
+ * ⚠ HARD RULE, PRESERVED VERBATIM FROM THE PARENT: **no parameter updates.** *"Learning happens
9
+ * through files, not model mutation."* That is what keeps this plugin TS-only and the memory plane
10
+ * reviewable in git.
11
+ *
12
+ * ⚠ FAIL LOUDLY, NEVER CLAIM SUCCESS. The parent's contract is explicit and this module keeps it as
13
+ * TYPED results rather than prose: an extractor that is unavailable and a run with too little
14
+ * evidence are **different failures with different exit codes** (`2` and `3`), and **in both cases the
15
+ * caller must not claim memory updates**. Every failure path below therefore returns an EMPTY `writes`
16
+ * list — "zero writes" is a property the tests assert by listing the tree, not a promise in a comment.
17
+ *
18
+ * ⚠ THE EXTRACTOR IS PLUGGABLE AND NEVER EMBEDDED. The parent never embeds an LLM client; it delegates
19
+ * through a command (`RECURSIVE_TRAINING_EXTRACTOR_CMD`) or a response file. This module does the
20
+ * same, so a missing extractor is an ordinary, expected condition rather than a bug.
21
+ *
22
+ * ⚠ SUPERSEDE, NEVER DELETE. An update appends a revision and a removal appends a tombstone, so the
23
+ * history of a learning stays readable; and a PINNED entry is untouchable by every automatic path.
24
+ */
25
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
26
+ import { spawnSync } from 'node:child_process'
27
+ import { join } from 'node:path'
28
+
29
+ /** The artifact whose lock marks a run as complete enough to learn from. */
30
+ export const PHASE8_ARTIFACT = '08-memory-impact.md'
31
+
32
+ /** The parent's exit codes, kept as names so a caller cannot mistake one failure for the other. */
33
+ export const TRAINING_EXIT = {
34
+ /** The extractor could not be reached or run. */
35
+ EXTRACTOR_UNAVAILABLE: 2,
36
+ /** There was not enough evidence to extract anything. */
37
+ INSUFFICIENT_EVIDENCE: 3,
38
+ } as const
39
+
40
+ export type TrainingCode = 'OK' | 'EXTRACTOR_UNAVAILABLE' | 'INSUFFICIENT_EVIDENCE'
41
+
42
+ export interface TrainingResult {
43
+ code: TrainingCode
44
+ /** The parent's exit code: 0 on success, otherwise 2 or 3. Never a silent success. */
45
+ exit: number
46
+ reason: string
47
+ /** Files written. EMPTY on every failure path — asserted, not promised. */
48
+ writes: string[]
49
+ }
50
+
51
+ /** How many runs have a LOCKED phase-8 artifact. The gate's only input. */
52
+ export function countPhase8LockedRuns(root: string, readText: (path: string) => string | null = defaultRead): number {
53
+ const runRoot = join(root, '.recursive', 'run')
54
+ if (!existsSync(runRoot)) return 0
55
+ let count = 0
56
+ for (const entry of readdirSync(runRoot, { withFileTypes: true })) {
57
+ if (!entry.isDirectory()) continue
58
+ const artifact = join(runRoot, entry.name, PHASE8_ARTIFACT)
59
+ if (!existsSync(artifact)) continue
60
+ const text = readText(artifact)
61
+ // A lock is a FIELD, not a filename: `Status: \`LOCKED\`` is what the lock chain writes.
62
+ if (text !== null && /Status:\s*`?LOCKED`?/.test(text)) count += 1
63
+ }
64
+ return count
65
+ }
66
+
67
+ function defaultRead(path: string): string | null {
68
+ try {
69
+ return readFileSync(path, 'utf8')
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The gate: extraction needs MORE THAN ONE locked run.
77
+ *
78
+ * ⚠ ONE RUN IS NOT EVIDENCE — it is an anecdote, and a memory file written from a single run would
79
+ * teach the next run that one run's accidents are rules. The parent skips with an explanation rather
80
+ * than extracting from what it has, and this returns the explanation as part of the result.
81
+ */
82
+ export function trainingGate(lockedRuns: number): TrainingResult {
83
+ if (lockedRuns < 2) {
84
+ return {
85
+ code: 'INSUFFICIENT_EVIDENCE',
86
+ exit: TRAINING_EXIT.INSUFFICIENT_EVIDENCE,
87
+ reason: 'extraction needs at least two phase-8-locked runs and found ' + lockedRuns
88
+ + '; one run is an anecdote, not evidence',
89
+ writes: [],
90
+ }
91
+ }
92
+ return { code: 'OK', exit: 0, reason: lockedRuns + ' locked runs provide enough evidence', writes: [] }
93
+ }
94
+
95
+ /** A learning candidate, before grouping. */
96
+ export interface TrainingItem {
97
+ runId: string
98
+ /** Changed paths or cited files — the stronger signal of which subsystem this belongs to. */
99
+ paths: string[]
100
+ text: string
101
+ }
102
+
103
+ /**
104
+ * Infer the subsystem from changed paths.
105
+ *
106
+ * ⚠ PATHS BEAT PROSE. The parent is explicit that changed paths are the stronger signal, so this
107
+ * prefers them and falls back to a stable `unclassified` bucket rather than guessing from wording —
108
+ * a wrong subsystem files a learning where nobody will look for it.
109
+ */
110
+ export function inferSubsystem(item: TrainingItem): string {
111
+ for (const path of item.paths) {
112
+ const match = /^(?:src|packages|apps)\/([^/]+)/.exec(path)
113
+ if (match) return match[1].replace(/\.(ts|tsx|js|mjs|md)$/, '')
114
+ const flat = /^([^/]+)\.(ts|tsx|js|mjs)$/.exec(path)
115
+ if (flat) return flat[1]
116
+ }
117
+ return 'unclassified'
118
+ }
119
+
120
+ export interface TrainingGroup {
121
+ subsystem: string
122
+ items: TrainingItem[]
123
+ /** How many DISTINCT runs contributed. A single-run group must not train on its own. */
124
+ runs: number
125
+ mode: 'contrastive' | 'winner-only'
126
+ }
127
+
128
+ /**
129
+ * Group items by subsystem and decide each group's mode.
130
+ *
131
+ * ⚠ REFUSE TO TRAIN ON A SINGLE-RUN GROUP ALONE. Two items from one run are one observation written
132
+ * twice, so such a group is DROPPED rather than trained on — the parent's rule, and the reason a
133
+ * group reports its distinct-run count. Winners and losers in one group support a CONTRASTIVE
134
+ * learning; otherwise the group is winner-only.
135
+ */
136
+ export function groupLearnings(items: readonly TrainingItem[], isWinner: (item: TrainingItem) => boolean): TrainingGroup[] {
137
+ const bySubsystem = new Map<string, TrainingItem[]>()
138
+ for (const item of items) {
139
+ const key = inferSubsystem(item)
140
+ const bucket = bySubsystem.get(key)
141
+ if (bucket === undefined) bySubsystem.set(key, [item])
142
+ else bucket.push(item)
143
+ }
144
+ const groups: TrainingGroup[] = []
145
+ for (const [subsystem, bucket] of bySubsystem) {
146
+ const runs = new Set(bucket.map((item) => item.runId)).size
147
+ if (runs < 2) continue
148
+ const winners = bucket.filter(isWinner).length
149
+ groups.push({
150
+ subsystem,
151
+ items: bucket,
152
+ runs,
153
+ mode: winners > 0 && winners < bucket.length ? 'contrastive' : 'winner-only',
154
+ })
155
+ }
156
+ return groups.sort((a, b) => a.subsystem.localeCompare(b.subsystem))
157
+ }
158
+
159
+ /**
160
+ * The trigger, run at the **RE-RUN** of closeout phase 08.
161
+ *
162
+ * ⚠ NOT AT THE FIRST LOCK, deliberately and per the parent: a run that has just locked would be
163
+ * training on itself, and its own conclusions would be promoted to memory before anything else had a
164
+ * chance to contradict them. The caller says `rerun: true` to mean "phase 08 has been through closeout
165
+ * more than once"; the first lock reports `OK` with an empty `writes` and a reason that says why.
166
+ */
167
+ export function runPhase8Trigger(
168
+ root: string,
169
+ runId: string,
170
+ options: {
171
+ rerun?: boolean
172
+ extractorAvailable?: boolean
173
+ items?: readonly TrainingItem[]
174
+ isWinner?: (item: TrainingItem) => boolean
175
+ /**
176
+ * The write seam. ABSENT IS NOT SUCCESS: with no writer the groups are planned and reported, and
177
+ * the reason SAYS the writes did not happen — because a result that looked successful while
178
+ * writing nothing is precisely the failure the parent's contract forbids ("do not claim memory
179
+ * updates").
180
+ */
181
+ write?: (relativePath: string, content: string) => string
182
+ /**
183
+ * The registry read seam. WITHOUT IT THE REGISTRY IS NOT REFRESHED and the result SAYS SO, because
184
+ * a silent half-write would leave `MEMORY.md` describing a plane that has changed underneath it.
185
+ */
186
+ readText?: (relativePath: string) => string | null
187
+ /**
188
+ * FU-5: THE PRODUCTION SPAWN, injected. When the caller supplies no `items`, the trigger RUNS the
189
+ * extractor through this runner — which is the link that was missing entirely: `extractAndGroup` was
190
+ * referenced only by its own definition, so the round trip existed and nothing invoked it.
191
+ */
192
+ runner?: (cmd: string) => ExtractorRun
193
+ } = {},
194
+ ): TrainingResult {
195
+ const locked = countPhase8LockedRuns(root)
196
+ const gate = trainingGate(locked)
197
+ if (gate.code !== 'OK') return gate
198
+
199
+ if (options.rerun !== true) {
200
+ return {
201
+ code: 'OK',
202
+ exit: 0,
203
+ reason: 'phase 08 has not been re-run for ' + runId + ', so nothing is extracted yet (training at the first lock would train the run on itself)',
204
+ writes: [],
205
+ }
206
+ }
207
+
208
+ // An unavailable extractor is exit 2, distinct from insufficient evidence — and still zero writes.
209
+ //
210
+ // ⚠ THE FLAG FALLS BACK TO THE ENVIRONMENT, because defaulting to "not available" while
211
+ // `RECURSIVE_TRAINING_EXTRACTOR_CMD` IS set is a footgun the e2e run walked straight into: the command
212
+ // was configured, the spawn was wired, and the trigger still reported no extractor. An explicit
213
+ // `false` is still honoured (it is how a test forces the failure), so the fallback only fills a gap.
214
+ if ((options.extractorAvailable ?? resolveExtractor(process.env) !== null) !== true) {
215
+ return {
216
+ code: 'EXTRACTOR_UNAVAILABLE',
217
+ exit: TRAINING_EXIT.EXTRACTOR_UNAVAILABLE,
218
+ reason: 'no extractor is available; set RECURSIVE_TRAINING_EXTRACTOR_CMD or pass a response file. Do not claim memory updates',
219
+ writes: [],
220
+ }
221
+ }
222
+
223
+ // ⚠ THE EXTRACTOR IS FINALLY INVOKED HERE — this is the link that was missing: `extractAndGroup` was
224
+ // referenced only by its own definition, so the whole round trip existed and nothing called it.
225
+ // Its failures keep their own meaning: a broken transport or malformed output is exit 2 (returned
226
+ // here), while an answer with nothing usable in it falls through to the empty-items path and becomes
227
+ // exit 3 — the distinction the round trip was built around.
228
+ let items: TrainingItem[]
229
+ if (options.items !== undefined) items = [...options.items]
230
+ else if (options.runner === undefined) {
231
+ // A configured command with no runner is reported rather than silently treated as "no items":
232
+ // the two look identical downstream, and only one of them is a misconfiguration.
233
+ return {
234
+ code: 'EXTRACTOR_UNAVAILABLE',
235
+ exit: TRAINING_EXIT.EXTRACTOR_UNAVAILABLE,
236
+ reason: 'an extractor is configured but no runner was supplied, so it was NOT invoked. Do not claim memory updates',
237
+ writes: [],
238
+ }
239
+ } else {
240
+ const round = extractAndGroup(options.runner, process.env, options.isWinner === undefined ? {} : { isWinner: options.isWinner })
241
+ if (!round.outcome.ok) {
242
+ return {
243
+ code: 'EXTRACTOR_UNAVAILABLE',
244
+ exit: TRAINING_EXIT.EXTRACTOR_UNAVAILABLE,
245
+ reason: round.outcome.reason,
246
+ writes: [],
247
+ }
248
+ }
249
+ items = round.items
250
+ }
251
+ const groups = groupLearnings(items, options.isWinner ?? (() => true))
252
+ if (groups.length === 0) {
253
+ return {
254
+ code: 'INSUFFICIENT_EVIDENCE',
255
+ exit: TRAINING_EXIT.INSUFFICIENT_EVIDENCE,
256
+ reason: 'no subsystem group spans two runs, so there is nothing to learn that one run did not already say. Do not claim memory updates',
257
+ writes: [],
258
+ }
259
+ }
260
+
261
+ const summary = groups.map((group) => group.subsystem + ' (' + group.mode + ')').join(', ')
262
+ if (options.write === undefined) {
263
+ // The groups are real and the plan is real; the WRITES are not. Saying so is the whole contract.
264
+ return {
265
+ code: 'OK',
266
+ exit: 0,
267
+ reason: 'planned ' + groups.length + ' group(s) — ' + summary + ' — but NO writer was supplied, so no memory file was written and the plan alone is not a learning',
268
+ writes: [],
269
+ }
270
+ }
271
+
272
+ const writes: string[] = []
273
+ for (const group of groups) {
274
+ const body = renderGroupShard(group)
275
+ writes.push(options.write('memory/domains/' + group.subsystem + '.md', body))
276
+ }
277
+ // One TRAINING shard per mode, and the registry refreshed to match what was just written.
278
+ const byMode = new Map<TrainingGroup['mode'], TrainingGroup[]>()
279
+ for (const group of groups) {
280
+ const bucket = byMode.get(group.mode)
281
+ if (bucket === undefined) byMode.set(group.mode, [group])
282
+ else bucket.push(group)
283
+ }
284
+ const registryEntries: Array<{ path: string; taskType: string }> = []
285
+ for (const [mode, modeGroups] of byMode) {
286
+ const path = taskTypeShardPath(mode)
287
+ writes.push(options.write(path, renderTaskTypeShard(modeGroups)))
288
+ registryEntries.push({ path, taskType: mode })
289
+ }
290
+ for (const group of groups) {
291
+ registryEntries.push({ path: 'memory/domains/' + group.subsystem + '.md', taskType: group.mode })
292
+ }
293
+ if (options.readText !== undefined) {
294
+ const existing = options.readText('memory/MEMORY.md') ?? ''
295
+ writes.push(options.write('memory/MEMORY.md', updateMemoryRegistry(existing, registryEntries)))
296
+ }
297
+ return {
298
+ code: 'OK',
299
+ exit: 0,
300
+ reason: 'extracted ' + groups.length + ' group(s): ' + summary
301
+ + (options.readText === undefined ? ' — the registry was NOT refreshed (no reader supplied)' : ''),
302
+ writes,
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Render a group's shard.
308
+ *
309
+ * ⚠ ONE ITEM PER RUN IS NAMED, so a reader can trace a learning back to the run that produced it —
310
+ * and the group is never presented as more evidence than it is.
311
+ */
312
+ export function renderGroupShard(group: TrainingGroup): string {
313
+ const lines = [
314
+ '# Learnings: ' + group.subsystem,
315
+ '',
316
+ '- Mode: ' + group.mode,
317
+ '- Runs: ' + group.runs + ' (' + [...new Set(group.items.map((item) => item.runId))].join(', ') + ')',
318
+ '',
319
+ ]
320
+ for (const item of group.items) {
321
+ lines.push('- [' + item.runId + '] ' + item.text)
322
+ }
323
+ return lines.join('\n') + '\n'
324
+ }
325
+
326
+ /**
327
+ * T30 — the extractor, resolved from the environment.
328
+ *
329
+ * ⚠ THE EXTRACTOR IS NEVER EMBEDDED. The parent delegates through a command
330
+ * (`RECURSIVE_TRAINING_EXTRACTOR_CMD`) or a response file rather than shipping an LLM client, and this
331
+ * keeps that: the plugin resolves a command and hands it to a runner it is given.
332
+ *
333
+ * ⚠ WHY THE RUNNER IS INJECTED RATHER THAN SPAWNED HERE. This harness's file sandbox denies a child
334
+ * process the PIPED stdio a capture needs, so a module that spawned directly would be untestable in the
335
+ * environment it runs in — and a rule that cannot be tested is a rule that will rot. The DECISION lives
336
+ * here and is asserted with a fake runner; the SPAWN lives at the caller.
337
+ */
338
+ export const TRAINING_EXTRACTOR_ENV = 'RECURSIVE_TRAINING_EXTRACTOR_CMD'
339
+
340
+ /**
341
+ * FU-5 — the RESPONSE FILE, which is what makes the spawn possible in a confined sandbox.
342
+ *
343
+ * ⚠ WHY A FILE AND NOT A PIPE. This harness's sandbox denies a child process the piped stdio a capture
344
+ * needs, so a spawn that read the extractor's stdout would fail with EPERM **in the environment it runs
345
+ * in**. The parent's own interface already solves this: it delegates through `--response-file`, i.e. the
346
+ * extractor WRITES ITS ANSWER TO A PATH. The plugin spawns with `stdio: 'ignore'` (which the sandbox
347
+ * allows), hands the path over in an environment variable, and reads the file afterwards.
348
+ */
349
+ export const TRAINING_RESPONSE_FILE_ENV = 'RECURSIVE_TRAINING_RESPONSE_FILE'
350
+
351
+ /**
352
+ * Build the production runner: spawn the command, then read the file it was asked to write.
353
+ *
354
+ * ⚠ IT NEVER THROWS. A missing binary, a non-zero exit and a missing response file all come back as a
355
+ * non-zero `status` with an explanatory `stdout`, so `runExtractor` maps them to a TYPED failure — the
356
+ * closeout must not die because an extractor is misconfigured.
357
+ */
358
+ export function spawnExtractorRunner(options: {
359
+ cwd: string
360
+ responseFile: string
361
+ /** Injected for tests; defaults to `node:child_process.spawnSync`. */
362
+ spawn?: (cmd: string, args: string[], opts: Record<string, unknown>) => { status: number | null; error?: Error }
363
+ }): (cmd: string) => ExtractorRun {
364
+ return (cmd: string): ExtractorRun => {
365
+ const spawn = options.spawn ?? ((c, a, o) => {
366
+ const result = spawnSync(c, a, o as never)
367
+ return { status: result.status, ...(result.error === undefined ? {} : { error: result.error }) }
368
+ })
369
+ let outcome: { status: number | null; error?: Error }
370
+ try {
371
+ outcome = spawn(cmd, [], {
372
+ cwd: options.cwd,
373
+ // ⚠ `ignore`, NOT `pipe`: the sandbox permits the first and denies the second.
374
+ stdio: 'ignore',
375
+ // The command is a shell line (the env var is documented as a COMMAND), so it needs a shell —
376
+ // the same reason `pnpm.cmd` needed one in the harness runner.
377
+ shell: true,
378
+ env: { ...process.env, [TRAINING_RESPONSE_FILE_ENV]: options.responseFile },
379
+ })
380
+ } catch (err) {
381
+ return { status: 1, stdout: 'spawn failed: ' + (err instanceof Error ? err.message : String(err)) }
382
+ }
383
+ if (outcome.error !== undefined) {
384
+ return { status: typeof outcome.status === 'number' ? outcome.status : 1, stdout: 'spawn error: ' + outcome.error.message }
385
+ }
386
+ try {
387
+ return { status: outcome.status ?? 1, stdout: readFileSync(options.responseFile, 'utf8') }
388
+ } catch {
389
+ // A successful exit with no file is reported as a FAILURE with the reason, never as an empty answer:
390
+ // "the extractor produced nothing" and "the extractor never wrote anything" are different facts.
391
+ return {
392
+ status: 1,
393
+ stdout: 'the extractor exited ' + String(outcome.status) + ' without writing ' + options.responseFile,
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ export function resolveExtractor(env: Record<string, string | undefined>): string | null {
400
+ const cmd = env[TRAINING_EXTRACTOR_ENV]
401
+ return cmd === undefined || cmd.trim() === '' ? null : cmd.trim()
402
+ }
403
+
404
+ /** What a runner reports back. `status` is the process exit code; `stdout` its captured output. */
405
+ export interface ExtractorRun {
406
+ status: number
407
+ stdout: string
408
+ }
409
+
410
+ export interface ExtractorOutcome {
411
+ ok: boolean
412
+ /** Present only when `ok`; the raw JSON the extractor produced. */
413
+ payload?: unknown
414
+ /** Present only when not `ok` — why, in the parent's terms. */
415
+ failure?: 'EXTRACTOR_UNAVAILABLE' | 'MALFORMED_OUTPUT'
416
+ reason: string
417
+ }
418
+
419
+ /**
420
+ * Run the extractor and interpret its answer.
421
+ *
422
+ * ⚠ A NON-ZERO STATUS AND MALFORMED OUTPUT ARE BOTH FAILURES, and neither is "no items" — a malformed
423
+ * answer is a broken extractor, not a run with nothing to learn, and collapsing the two would report
424
+ * exit 3 (insufficient evidence) for a bug that deserves exit 2.
425
+ */
426
+ export function runExtractor(
427
+ runner: (cmd: string) => ExtractorRun,
428
+ cmd: string | null,
429
+ ): ExtractorOutcome {
430
+ if (cmd === null) {
431
+ return {
432
+ ok: false,
433
+ failure: 'EXTRACTOR_UNAVAILABLE',
434
+ reason: 'no extractor command is set (' + TRAINING_EXTRACTOR_ENV + '); do not claim memory updates',
435
+ }
436
+ }
437
+ let run: ExtractorRun
438
+ try {
439
+ run = runner(cmd)
440
+ } catch (err) {
441
+ return {
442
+ ok: false,
443
+ failure: 'EXTRACTOR_UNAVAILABLE',
444
+ reason: 'the extractor could not be run (' + (err instanceof Error ? err.message : String(err)) + '); do not claim memory updates',
445
+ }
446
+ }
447
+ if (run.status !== 0) {
448
+ return {
449
+ ok: false,
450
+ failure: 'EXTRACTOR_UNAVAILABLE',
451
+ reason: 'the extractor exited ' + run.status + '; do not claim memory updates',
452
+ }
453
+ }
454
+ try {
455
+ return { ok: true, payload: JSON.parse(run.stdout) as unknown, reason: 'the extractor returned a parseable answer' }
456
+ } catch {
457
+ return {
458
+ ok: false,
459
+ failure: 'MALFORMED_OUTPUT',
460
+ reason: 'the extractor exited 0 but its output is not JSON, which is a BROKEN EXTRACTOR rather than a run with nothing to learn',
461
+ }
462
+ }
463
+ }
464
+
465
+ /**
466
+ * T30 — the registry line for a shard, and the registry update.
467
+ *
468
+ * ⚠ THE REGISTRY IS REFRESHED BY REPLACING A SHARD'S LINE, NOT BY APPENDING. Two lines for one shard
469
+ * would make `MEMORY.md` claim the plane holds something twice, and the loader reads the registry
470
+ * first — so a duplicated marker is not cosmetic, it is a wrong answer about what exists.
471
+ *
472
+ * ⚠ AND A SHARD IS NEVER REMOVED HERE. Per the memory-worker discipline this borrows: **supersede,
473
+ * never delete.** A shard that stops being written keeps its line and its history; removing it is a
474
+ * tombstone decision, not a side effect of training.
475
+ */
476
+ export function registryLine(shardPath: string, taskType: string): string {
477
+ return '- `' + shardPath + '` — task type: ' + taskType
478
+ }
479
+
480
+ export function updateMemoryRegistry(existing: string, entries: ReadonlyArray<{ path: string; taskType: string }>): string {
481
+ const lines = existing === '' ? [] : existing.replace(/\n$/, '').split('\n')
482
+ for (const entry of entries) {
483
+ const marker = '- `' + entry.path + '`'
484
+ const at = lines.findIndex((line) => line.startsWith(marker))
485
+ const line = registryLine(entry.path, entry.taskType)
486
+ if (at >= 0) lines[at] = line
487
+ else lines.push(line)
488
+ }
489
+ return lines.join('\n') + '\n'
490
+ }
491
+
492
+ /**
493
+ * T30 — the task-type shard.
494
+ *
495
+ * ⚠ `task-type` IS READ FROM THE GROUP'S MODE, and that is an INTERPRETATION rather than a measured
496
+ * fact: the parent writes `memory/training/<task-type>.md` without defining the key in the material I
497
+ * have, so the mode a group was extracted under (`contrastive` / `winner-only`) is what distinguishes
498
+ * one training shard from another here. Named so a reader can disagree with it instead of discovering it.
499
+ */
500
+ export function taskTypeShardPath(mode: TrainingGroup['mode']): string {
501
+ return 'memory/training/' + mode + '.md'
502
+ }
503
+
504
+ export function renderTaskTypeShard(groups: readonly TrainingGroup[]): string {
505
+ const lines = [
506
+ '# Training shards: ' + groups[0].mode,
507
+ '',
508
+ 'Groups extracted under this mode, one section each. Learning happens through files, not model mutation.',
509
+ '',
510
+ ]
511
+ for (const group of groups) {
512
+ lines.push('## ' + group.subsystem, '')
513
+ lines.push('- Runs: ' + group.runs + ' (' + [...new Set(group.items.map((item) => item.runId))].join(', ') + ')')
514
+ for (const item of group.items) lines.push('- [' + item.runId + '] ' + item.text)
515
+ lines.push('')
516
+ }
517
+ return lines.join('\n') + '\n'
518
+ }
519
+
520
+ /**
521
+ * T30 — turn the extractor's payload into items the grouping can use.
522
+ *
523
+ * ⚠ A MALFORMED PAYLOAD IS NOT A BROKEN EXTRACTOR. By the time this runs the extractor has exited 0 and
524
+ * produced parseable JSON, so the transport is fine; a payload carrying no items is an extractor that
525
+ * found nothing, which is **exit 3, not exit 2**. Keeping those apart is why `runExtractor` answers
526
+ * first and this second.
527
+ *
528
+ * ⚠ A PARTIAL ANSWER NEITHER LOSES THE BATCH NOR INVENTS EVIDENCE: an entry missing its text is
529
+ * SKIPPED rather than defaulted, because an item with invented text would be taught as a learning
530
+ * nobody extracted. An entry with no `runId` is skipped too — the grouping counts DISTINCT runs, so an
531
+ * unattributed observation would be pooled into a run it did not come from.
532
+ */
533
+ export function parseExtractorItems(payload: unknown): TrainingItem[] {
534
+ const container = payload as { items?: unknown } | null
535
+ const raw = Array.isArray(payload) ? payload : (container === null ? null : container.items ?? null)
536
+ if (!Array.isArray(raw)) return []
537
+ const items: TrainingItem[] = []
538
+ for (const entry of raw) {
539
+ if (entry === null || typeof entry !== 'object') continue
540
+ const candidate = entry as { runId?: unknown; paths?: unknown; text?: unknown }
541
+ if (typeof candidate.text !== 'string' || candidate.text.trim() === '') continue
542
+ if (typeof candidate.runId !== 'string' || candidate.runId.trim() === '') continue
543
+ const paths = Array.isArray(candidate.paths)
544
+ ? candidate.paths.filter((path): path is string => typeof path === 'string')
545
+ : []
546
+ items.push({ runId: candidate.runId, paths, text: candidate.text })
547
+ }
548
+ return items
549
+ }
550
+
551
+ /**
552
+ * T30 — the whole round trip, in the order the failure codes demand: resolve the command, run it,
553
+ * parse the payload, then group. Each stage keeps its own meaning — no command or a non-zero exit is
554
+ * **exit 2**; a payload with nothing usable in it is **exit 3**.
555
+ */
556
+ export function extractAndGroup(
557
+ runner: (cmd: string) => ExtractorRun,
558
+ env: Record<string, string | undefined>,
559
+ options: { isWinner?: (item: TrainingItem) => boolean } = {},
560
+ ): { outcome: ExtractorOutcome; items: TrainingItem[]; groups: TrainingGroup[] } {
561
+ const outcome = runExtractor(runner, resolveExtractor(env))
562
+ if (!outcome.ok) return { outcome, items: [], groups: [] }
563
+ const items = parseExtractorItems(outcome.payload)
564
+ return { outcome, items, groups: groupLearnings(items, options.isWinner ?? (() => true)) }
565
+ }
package/src/ts-lint.ts CHANGED
@@ -135,7 +135,7 @@ export function hasHeading(content: string, headingText: string): boolean {
135
135
 
136
136
  /** get_heading_body: text under `## Heading` until next heading or end. */
137
137
  export function getHeadingBody(content: string, headingText: string): string {
138
- const re = new RegExp(`^[ \\t]*##\\s+${escapeRegExp(headingText)}\\s*$\\n?(.*?)(?=^[ \\t]*##\\s+|\Z)`, 'ms')
138
+ const re = new RegExp(`^[ \\t]*##\\s+${escapeRegExp(headingText)}\\s*$\\n?(.*?)(?=^[ \\t]*##\\s+|(?![\\s\\S]))`, 'ms')
139
139
  const m = content.match(re)
140
140
  if (!m) return ''
141
141
  return (m[1] ?? '').trim()
@@ -144,7 +144,7 @@ export function getHeadingBody(content: string, headingText: string): string {
144
144
  /** get_subheading_body: text under `### Heading` (or level N) until next N-or-less heading. */
145
145
  export function getSubheadingBody(content: string, headingText: string, level = 3): string {
146
146
  const hashes = '#'.repeat(level)
147
- const re = new RegExp(`^[ \\t]*${escapeRegExp(hashes)}\\s+${escapeRegExp(headingText)}\\s*$\\n?(.*?)(?=^[ \\t]*#{1,${level}}\\s+|\Z)`, 'ms')
147
+ const re = new RegExp(`^[ \\t]*${escapeRegExp(hashes)}\\s+${escapeRegExp(headingText)}\\s*$\\n?(.*?)(?=^[ \\t]*#{1,${level}}\\s+|(?![\\s\\S]))`, 'ms')
148
148
  const m = content.match(re)
149
149
  if (!m) return ''
150
150
  return (m[1] ?? '').trim()
@@ -284,7 +284,7 @@ export function extractPathsFromFieldValue(text: string): Set<string> {
284
284
  export function extractPathsFromNamedField(content: string, fieldName: string): Set<string> {
285
285
  const inlineValue = getMdFieldValue(content, fieldName)
286
286
  if (inlineValue !== null) return extractPathsFromFieldValue(inlineValue)
287
- const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*$\\n(.*?)(?=^[ \\t]*(?:[-*][ \\t]+)?[A-Za-z][^:\\n]*:[ \\t]*|\Z)`, 'ms')
287
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*$\\n(.*?)(?=^[ \\t]*(?:[-*][ \\t]+)?[A-Za-z][^:\\n]*:[ \\t]*|(?![\\s\\S]))`, 'ms')
288
288
  const m = content.match(re)
289
289
  if (!m) return new Set()
290
290
  const out = new Set<string>()
@@ -299,7 +299,7 @@ export function extractPathsFromNamedField(content: string, fieldName: string):
299
299
  export function getNamedFieldText(content: string, fieldName: string): string | null {
300
300
  const inlineValue = getMdFieldValue(content, fieldName)
301
301
  if (inlineValue !== null) return inlineValue
302
- const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*$\\n(.*?)(?=^[ \\t]*(?:[-*][ \\t]+)?[A-Za-z][^:\\n]*:[ \\t]*|\Z)`, 'ms')
302
+ const re = new RegExp(`^[ \\t]*(?:[-*][ \\t]+)?${escapeRegExp(fieldName)}:[ \\t]*$\\n(.*?)(?=^[ \\t]*(?:[-*][ \\t]+)?[A-Za-z][^:\\n]*:[ \\t]*|(?![\\s\\S]))`, 'ms')
303
303
  const m = content.match(re)
304
304
  if (!m) return null
305
305
  return (m[1] ?? '').trim()
@@ -600,6 +600,40 @@ export function getStageLocalAddendaPaths(runDir: string, artifactName: string):
600
600
  catch { return [] }
601
601
  }
602
602
 
603
+ /**
604
+ * get_run_tree_addenda: EVERY addendum in the run, wherever it was filed.
605
+ *
606
+ * ⚠ WHY THIS EXISTS BESIDE THE TWO ABOVE: both scan `run_dir/addenda/` and nothing else, and flatly. But an
607
+ * addendum is written BESIDE THE ARTIFACT IT CLOSES — the run ROOT is where the real ones live, e.g.
608
+ * `02-to-be-plan.addendum-r4-r2-mount-resolution.md`, which the comments elsewhere in this file cite by name
609
+ * — and a stage may file one in a subfolder. A reader that consults only `addenda/` therefore misses
610
+ * exactly the addenda that closed an upstream gap, which are the ones a closeout most needs to see.
611
+ *
612
+ * Returns run-relative POSIX paths, sorted, so a caller can join them onto the run directory and cite them
613
+ * verbatim. Matching uses {@link isAddendumArtifact} — the SAME predicate the rest of the plugin uses — so a
614
+ * name can never count as an addendum in one place and not another. `upstream-gap` names are matched too:
615
+ * they are the back-edge form of an addendum, and {@link getRelatedAddendaPaths} already treats them as one.
616
+ */
617
+ export function getRunTreeAddenda(runDir: string): string[] {
618
+ const found: string[] = []
619
+ const walk = (dir: string, prefix: string): void => {
620
+ // ⚠ ANNOTATED STRUCTURALLY, NOT AS `Dirent`: `readdirSync(..., { withFileTypes: true })` infers
621
+ // `Dirent<NonSharedBuffer>[]` on this Node typing, which will not assign to a bare `Dirent[]`. Only the
622
+ // two members used below are named, which keeps the `try` around the READ alone.
623
+ let entries: { name: string; isDirectory(): boolean }[]
624
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
625
+ for (const entry of entries) {
626
+ const name = String(entry.name)
627
+ const rel = prefix === '' ? name : prefix + '/' + name
628
+ if (entry.isDirectory()) { walk(join(dir, name), rel); continue }
629
+ if (!name.endsWith('.md')) continue
630
+ if (isAddendumArtifact(name) || name.includes('.upstream-gap.')) found.push(rel)
631
+ }
632
+ }
633
+ walk(runDir, '')
634
+ return found.sort()
635
+ }
636
+
603
637
  /** get_current_phase_upstream_gap_addenda_paths: run_dir/addenda/<base>.upstream-gap.*.addendum-*.md. */
604
638
  export function getCurrentPhaseUpstreamGapAddendaPaths(runDir: string, artifactName: string): string[] {
605
639
  const addendaDir = join(runDir, 'addenda')