@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
package/lib/status.d.ts CHANGED
@@ -1,4 +1,19 @@
1
- import type { ArtifactState, PhaseDef, RecursiveStatusResult } from './types.ts';
1
+ import type { ArtifactState, PendingWorkItem, PhaseDef, PhasePosition, RecursiveStatusResult } from './types.ts';
2
+ import { PHASE_POSITIONS } from './types.ts';
3
+ export { PHASE_POSITIONS };
4
+ export type { ArtifactState, PhasePosition };
5
+ /**
6
+ * T21 — derive the ONE named position of a phase from the fields consumers
7
+ * currently recombine for themselves. Total and disjoint: every reachable shape
8
+ * returns exactly one member of {@link PHASE_POSITIONS}, so two consumers cannot
9
+ * read the same phase and disagree.
10
+ *
11
+ * Order is the whole design. `tampered` outranks every other lock problem
12
+ * because a content/hash divergence is the more serious fact — a locked artifact
13
+ * whose bytes changed invalidates everything that cited it, whereas a failed gate
14
+ * on an otherwise-intact artifact is a fixable omission.
15
+ */
16
+ export declare function phasePosition(state: ArtifactState): PhasePosition;
2
17
  export declare const RUN_ARTIFACT_SEQUENCE: string[];
3
18
  export declare const PHASES: PhaseDef[];
4
19
  export declare function escapeRegExp(value: string): string;
@@ -15,5 +30,42 @@ export declare function lockHashFromContent(content: string): string;
15
30
  export declare function getWorkflowProfile(runDir: string): string;
16
31
  export { getLatestRunDirectory, discoverRuns, resolveRunDir } from './run.ts';
17
32
  export type { RunDiscoveryResult } from './run.ts';
33
+ /**
34
+ * T18 — unresolved in-flight work, DERIVED from the run directory.
35
+ *
36
+ * There is no ledger, no queue and no stored flag: this reads what is already on
37
+ * disk, which is what makes the quiescence rule in `lockArtifact` cheap enough to
38
+ * run on every lock. Plan §4.0: derived beats stored wherever derivation is
39
+ * cheap, and losing a derived fact costs nothing because it cannot be lost.
40
+ *
41
+ * WHY THIS IS THE ONLY CASE IMPLEMENTED. The pairing is created early in the
42
+ * happy path — `runtime.ts` writes `subagents/<id>/handoff.md` BEFORE the bundle
43
+ * and before the child starts — so the exposure is exactly the window between
44
+ * the handoff and the reply. The plan also names "a reopen plan without a
45
+ * completion marker" and "a closeout phase scaffolded without a receipt"; neither
46
+ * exists on disk (`reopenArtifact` reverts the artifact in place and invalidates
47
+ * receipts; a scaffolded-but-unlocked closeout artifact is the normal pre-lock
48
+ * state of every phase), and enforcing either would make locking impossible.
49
+ * That omission is deliberate and recorded on the item.
50
+ *
51
+ * A reply that exists but is empty is NOT a submission, so it stays pending —
52
+ * a child that created the file and wrote nothing has not answered.
53
+ *
54
+ * Total: a missing or unreadable run directory yields an empty set, never a throw.
55
+ */
56
+ export declare function pendingWork(runDir: string): PendingWorkItem[];
18
57
  export declare function getArtifactState(artifactPath: string, workflowProfile: string): ArtifactState;
58
+ /**
59
+ * Fold-cache diagnostics. Read-only observation, kept in the shipped API because
60
+ * "is the frame hitting?" is the first question when the board feels slow, and a
61
+ * counter is cheaper than guessing. `reparsed` counts EXISTING artifacts re-read
62
+ * (an absent one costs only an existsSync).
63
+ */
64
+ export declare function foldDiagnostics(): {
65
+ folds: number;
66
+ reuses: number;
67
+ reparsed: number;
68
+ };
69
+ /** Drop every frame and counter. For tests, and for a caller that knows the tree changed underneath. */
70
+ export declare function resetFoldCache(): void;
19
71
  export declare function foldRun(runDir: string, runId: string): RecursiveStatusResult;
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * The recursion concept's core state machine, expressed over the native
5
5
  * `agentTeams` service: ONE durable task per phase carries the loop —
6
- * createTask (pending) → claim (in_progress) → audit round → on REVISE
7
- * updateTask(edit, repair instruction) → re-audit the SAME task → on APPROVE
6
+ * createTask (pending) → claim (in_progress) → audit round → on REVISE* updateTask(edit, repair instruction) → re-audit the SAME task → on APPROVE
8
7
  * updateTask(complete) → lock. A stuck reviewer is interrupted through the
9
8
  * team's own kill switch; a REJECT or round-cap releases the task and fails
10
9
  * loud — a lock NEVER happens before an APPROVE verdict.
@@ -104,6 +103,11 @@ export interface AuditLoopRound {
104
103
  readonly verdict: AuditVerdict;
105
104
  readonly repair?: string;
106
105
  readonly taskRevision: number;
106
+ /**
107
+ * T20: true when this round produced the SAME progress cursor as its predecessor —
108
+ * the same finding, restated. Rendered so the board shows a loop as a loop.
109
+ */
110
+ readonly noProgress?: boolean;
107
111
  }
108
112
  /** The auditToPass result. */
109
113
  export interface AuditToPassResult {
@@ -116,6 +120,52 @@ export interface AuditToPassResult {
116
120
  readonly locked: boolean;
117
121
  /** Latest task view (board-facing per-phase history). */
118
122
  readonly taskView?: TeamTaskViewLike;
123
+ /**
124
+ * T19: true when this call was recognised as a repeat of an audit-to-pass that had
125
+ * already completed for this run and phase, so no second task was created. The
126
+ * caller must read the PHASE's lock state itself — this result cannot claim the
127
+ * artifact is locked, because a recognised repeat performs no locking.
128
+ */
129
+ readonly recognisedRepeat?: boolean;
130
+ /**
131
+ * T20: how many rounds ran. The absolute backstop, reported so a caller can see how
132
+ * close the loop came to its cap.
133
+ */
134
+ readonly attempts?: number;
135
+ /** T20: consecutive rounds that produced the SAME progress cursor. */
136
+ readonly consecutiveNoProgress?: number;
137
+ /** T20: the last round's progress cursor — what the round said was wrong. */
138
+ readonly progressCursor?: string;
139
+ /** T28: how many repairs were requested before the loop stopped. */
140
+ readonly repairAttempts?: number;
141
+ /**
142
+ * T20: true when the loop stopped in a state that must NOT be retried
143
+ * automatically — no progress, or the attempt cap. The caller has to decide to
144
+ * resume, deliberately, rather than a loop quietly grinding on or quietly giving up.
145
+ */
146
+ readonly resumeRequired?: boolean;
147
+ }
148
+ /**
149
+ * T19: the operation-index seam.
150
+ *
151
+ * INJECTED, not imported. `auditToPass` is pure over its seams — it has no `runDir`
152
+ * and no filesystem access, which is exactly what lets the whole loop be driven by
153
+ * fakes in tests. Reaching for `recordOperation` directly would have traded that away
154
+ * for one line of convenience, so the caller supplies persistence instead.
155
+ */
156
+ export interface AuditOperationsSeamLike {
157
+ /** The previously recorded attempt for an id, or null when it is new. */
158
+ find?: (id: string) => {
159
+ id: string;
160
+ outcome?: string;
161
+ } | null;
162
+ /** Record one attempt. Best-effort: a failed write must not change the loop's outcome. */
163
+ record?: (record: {
164
+ id: string;
165
+ act: string;
166
+ at: string;
167
+ outcome?: string;
168
+ }) => void;
119
169
  }
120
170
  /** Inputs for one audit-to-pass loop. */
121
171
  export interface AuditToPassInput {
@@ -130,6 +180,11 @@ export interface AuditToPassInput {
130
180
  readonly blockedBy?: readonly TeamTaskIdLike[];
131
181
  /** Write scopes for the phase artifact (advisory, overlap-warned). */
132
182
  readonly writeScopes?: readonly string[];
183
+ /**
184
+ * T19: the operation index, injected. Absent it, the loop runs exactly as before —
185
+ * no pre-check, no records, no behaviour change.
186
+ */
187
+ readonly operations?: AuditOperationsSeamLike;
133
188
  /** Run ONE audit round for the current task; live usage delegates (T4). */
134
189
  readonly runAuditRound: (round: number, task: TeamTaskViewLike) => Promise<AuditRoundOutcome>;
135
190
  /** Lock the phase artifact — called ONLY after an APPROVE verdict. */
@@ -138,6 +193,25 @@ export interface AuditToPassInput {
138
193
  readonly reviewerName?: string;
139
194
  /** Round cap (fail loud past it; no lock). */
140
195
  readonly maxRounds?: number;
196
+ /**
197
+ * T20: how many CONSECUTIVE rounds may repeat the same progress cursor before the
198
+ * loop stops and demands a resume. Default 2, so three identical rounds terminate:
199
+ * the first sets the cursor and the next two repeat it. Absent the bound, a phase
200
+ * could restate one unfixed finding until the attempt cap, which is the wrong
201
+ * budget — a round count indulges a loop while cutting off genuine progress.
202
+ */
203
+ readonly maxNoProgress?: number;
204
+ /**
205
+ * T28: the configurable caps. `maxAuditRounds` is the absolute round ceiling and
206
+ * `maxRepairAttempts` bounds how many times a phase may be sent back for repair
207
+ * even when every round finds something NEW — the case T20's no-progress bound
208
+ * deliberately lets run. Both are optional so an existing caller keeps its
209
+ * behaviour, with the round cap falling back to `maxRounds ?? 3`.
210
+ */
211
+ readonly budgets?: {
212
+ readonly maxAuditRounds?: number;
213
+ readonly maxRepairAttempts?: number;
214
+ };
141
215
  /** Per-round wait timeout before the audit round runs (skipped without the seam). */
142
216
  readonly waitTimeoutMs?: number;
143
217
  }
@@ -155,6 +229,21 @@ export declare function renderTaskHistory(task: TeamTaskViewLike | undefined, ro
155
229
  * → APPROVE: updateTask(complete) → lockPhase()
156
230
  * → REJECT / cap / stuck: updateTask(release) + interrupt, NO lock.
157
231
  */
232
+ /**
233
+ * T19 wrapper: recognise a repeat, then record what happened.
234
+ *
235
+ * The identity covers this run and phase. A call whose operation was already recorded
236
+ * as APPLIED returns immediately without creating a second task — the acceptance's
237
+ * "recognised no-op rather than a second execution". Every other outcome (rejected,
238
+ * capped, errored) records as `unaccepted`, so a retry still runs: a failed audit must
239
+ * stay retryable, and only a COMPLETED one is a repeat.
240
+ *
241
+ * Per-round entries are recorded after the loop from the result's own round trail.
242
+ * That is one write per call instead of one per round, and it keeps the loop body
243
+ * free of persistence — but it does mean a crash MID-loop leaves the whole operation
244
+ * unrecorded, which is the honest trade for not threading a writer through every
245
+ * branch.
246
+ */
158
247
  export declare function auditToPass(input: AuditToPassInput): Promise<AuditToPassResult>;
159
248
  /** Whether a task view is currently claimed by the named owner (board-facing). */
160
249
  export declare function isTaskClaimedBy(task: TeamTaskViewLike, ownerName: string): boolean;
@@ -0,0 +1,211 @@
1
+ /** The artifact whose lock marks a run as complete enough to learn from. */
2
+ export declare const PHASE8_ARTIFACT = "08-memory-impact.md";
3
+ /** The parent's exit codes, kept as names so a caller cannot mistake one failure for the other. */
4
+ export declare const TRAINING_EXIT: {
5
+ /** The extractor could not be reached or run. */
6
+ readonly EXTRACTOR_UNAVAILABLE: 2;
7
+ /** There was not enough evidence to extract anything. */
8
+ readonly INSUFFICIENT_EVIDENCE: 3;
9
+ };
10
+ export type TrainingCode = 'OK' | 'EXTRACTOR_UNAVAILABLE' | 'INSUFFICIENT_EVIDENCE';
11
+ export interface TrainingResult {
12
+ code: TrainingCode;
13
+ /** The parent's exit code: 0 on success, otherwise 2 or 3. Never a silent success. */
14
+ exit: number;
15
+ reason: string;
16
+ /** Files written. EMPTY on every failure path — asserted, not promised. */
17
+ writes: string[];
18
+ }
19
+ /** How many runs have a LOCKED phase-8 artifact. The gate's only input. */
20
+ export declare function countPhase8LockedRuns(root: string, readText?: (path: string) => string | null): number;
21
+ /**
22
+ * The gate: extraction needs MORE THAN ONE locked run.
23
+ *
24
+ * ⚠ ONE RUN IS NOT EVIDENCE — it is an anecdote, and a memory file written from a single run would
25
+ * teach the next run that one run's accidents are rules. The parent skips with an explanation rather
26
+ * than extracting from what it has, and this returns the explanation as part of the result.
27
+ */
28
+ export declare function trainingGate(lockedRuns: number): TrainingResult;
29
+ /** A learning candidate, before grouping. */
30
+ export interface TrainingItem {
31
+ runId: string;
32
+ /** Changed paths or cited files — the stronger signal of which subsystem this belongs to. */
33
+ paths: string[];
34
+ text: string;
35
+ }
36
+ /**
37
+ * Infer the subsystem from changed paths.
38
+ *
39
+ * ⚠ PATHS BEAT PROSE. The parent is explicit that changed paths are the stronger signal, so this
40
+ * prefers them and falls back to a stable `unclassified` bucket rather than guessing from wording —
41
+ * a wrong subsystem files a learning where nobody will look for it.
42
+ */
43
+ export declare function inferSubsystem(item: TrainingItem): string;
44
+ export interface TrainingGroup {
45
+ subsystem: string;
46
+ items: TrainingItem[];
47
+ /** How many DISTINCT runs contributed. A single-run group must not train on its own. */
48
+ runs: number;
49
+ mode: 'contrastive' | 'winner-only';
50
+ }
51
+ /**
52
+ * Group items by subsystem and decide each group's mode.
53
+ *
54
+ * ⚠ REFUSE TO TRAIN ON A SINGLE-RUN GROUP ALONE. Two items from one run are one observation written
55
+ * twice, so such a group is DROPPED rather than trained on — the parent's rule, and the reason a
56
+ * group reports its distinct-run count. Winners and losers in one group support a CONTRASTIVE
57
+ * learning; otherwise the group is winner-only.
58
+ */
59
+ export declare function groupLearnings(items: readonly TrainingItem[], isWinner: (item: TrainingItem) => boolean): TrainingGroup[];
60
+ /**
61
+ * The trigger, run at the **RE-RUN** of closeout phase 08.
62
+ *
63
+ * ⚠ NOT AT THE FIRST LOCK, deliberately and per the parent: a run that has just locked would be
64
+ * training on itself, and its own conclusions would be promoted to memory before anything else had a
65
+ * chance to contradict them. The caller says `rerun: true` to mean "phase 08 has been through closeout
66
+ * more than once"; the first lock reports `OK` with an empty `writes` and a reason that says why.
67
+ */
68
+ export declare function runPhase8Trigger(root: string, runId: string, options?: {
69
+ rerun?: boolean;
70
+ extractorAvailable?: boolean;
71
+ items?: readonly TrainingItem[];
72
+ isWinner?: (item: TrainingItem) => boolean;
73
+ /**
74
+ * The write seam. ABSENT IS NOT SUCCESS: with no writer the groups are planned and reported, and
75
+ * the reason SAYS the writes did not happen — because a result that looked successful while
76
+ * writing nothing is precisely the failure the parent's contract forbids ("do not claim memory
77
+ * updates").
78
+ */
79
+ write?: (relativePath: string, content: string) => string;
80
+ /**
81
+ * The registry read seam. WITHOUT IT THE REGISTRY IS NOT REFRESHED and the result SAYS SO, because
82
+ * a silent half-write would leave `MEMORY.md` describing a plane that has changed underneath it.
83
+ */
84
+ readText?: (relativePath: string) => string | null;
85
+ /**
86
+ * FU-5: THE PRODUCTION SPAWN, injected. When the caller supplies no `items`, the trigger RUNS the
87
+ * extractor through this runner — which is the link that was missing entirely: `extractAndGroup` was
88
+ * referenced only by its own definition, so the round trip existed and nothing invoked it.
89
+ */
90
+ runner?: (cmd: string) => ExtractorRun;
91
+ }): TrainingResult;
92
+ /**
93
+ * Render a group's shard.
94
+ *
95
+ * ⚠ ONE ITEM PER RUN IS NAMED, so a reader can trace a learning back to the run that produced it —
96
+ * and the group is never presented as more evidence than it is.
97
+ */
98
+ export declare function renderGroupShard(group: TrainingGroup): string;
99
+ /**
100
+ * T30 — the extractor, resolved from the environment.
101
+ *
102
+ * ⚠ THE EXTRACTOR IS NEVER EMBEDDED. The parent delegates through a command
103
+ * (`RECURSIVE_TRAINING_EXTRACTOR_CMD`) or a response file rather than shipping an LLM client, and this
104
+ * keeps that: the plugin resolves a command and hands it to a runner it is given.
105
+ *
106
+ * ⚠ WHY THE RUNNER IS INJECTED RATHER THAN SPAWNED HERE. This harness's file sandbox denies a child
107
+ * process the PIPED stdio a capture needs, so a module that spawned directly would be untestable in the
108
+ * environment it runs in — and a rule that cannot be tested is a rule that will rot. The DECISION lives
109
+ * here and is asserted with a fake runner; the SPAWN lives at the caller.
110
+ */
111
+ export declare const TRAINING_EXTRACTOR_ENV = "RECURSIVE_TRAINING_EXTRACTOR_CMD";
112
+ /**
113
+ * FU-5 — the RESPONSE FILE, which is what makes the spawn possible in a confined sandbox.
114
+ *
115
+ * ⚠ WHY A FILE AND NOT A PIPE. This harness's sandbox denies a child process the piped stdio a capture
116
+ * needs, so a spawn that read the extractor's stdout would fail with EPERM **in the environment it runs
117
+ * in**. The parent's own interface already solves this: it delegates through `--response-file`, i.e. the
118
+ * extractor WRITES ITS ANSWER TO A PATH. The plugin spawns with `stdio: 'ignore'` (which the sandbox
119
+ * allows), hands the path over in an environment variable, and reads the file afterwards.
120
+ */
121
+ export declare const TRAINING_RESPONSE_FILE_ENV = "RECURSIVE_TRAINING_RESPONSE_FILE";
122
+ /**
123
+ * Build the production runner: spawn the command, then read the file it was asked to write.
124
+ *
125
+ * ⚠ IT NEVER THROWS. A missing binary, a non-zero exit and a missing response file all come back as a
126
+ * non-zero `status` with an explanatory `stdout`, so `runExtractor` maps them to a TYPED failure — the
127
+ * closeout must not die because an extractor is misconfigured.
128
+ */
129
+ export declare function spawnExtractorRunner(options: {
130
+ cwd: string;
131
+ responseFile: string;
132
+ /** Injected for tests; defaults to `node:child_process.spawnSync`. */
133
+ spawn?: (cmd: string, args: string[], opts: Record<string, unknown>) => {
134
+ status: number | null;
135
+ error?: Error;
136
+ };
137
+ }): (cmd: string) => ExtractorRun;
138
+ export declare function resolveExtractor(env: Record<string, string | undefined>): string | null;
139
+ /** What a runner reports back. `status` is the process exit code; `stdout` its captured output. */
140
+ export interface ExtractorRun {
141
+ status: number;
142
+ stdout: string;
143
+ }
144
+ export interface ExtractorOutcome {
145
+ ok: boolean;
146
+ /** Present only when `ok`; the raw JSON the extractor produced. */
147
+ payload?: unknown;
148
+ /** Present only when not `ok` — why, in the parent's terms. */
149
+ failure?: 'EXTRACTOR_UNAVAILABLE' | 'MALFORMED_OUTPUT';
150
+ reason: string;
151
+ }
152
+ /**
153
+ * Run the extractor and interpret its answer.
154
+ *
155
+ * ⚠ A NON-ZERO STATUS AND MALFORMED OUTPUT ARE BOTH FAILURES, and neither is "no items" — a malformed
156
+ * answer is a broken extractor, not a run with nothing to learn, and collapsing the two would report
157
+ * exit 3 (insufficient evidence) for a bug that deserves exit 2.
158
+ */
159
+ export declare function runExtractor(runner: (cmd: string) => ExtractorRun, cmd: string | null): ExtractorOutcome;
160
+ /**
161
+ * T30 — the registry line for a shard, and the registry update.
162
+ *
163
+ * ⚠ THE REGISTRY IS REFRESHED BY REPLACING A SHARD'S LINE, NOT BY APPENDING. Two lines for one shard
164
+ * would make `MEMORY.md` claim the plane holds something twice, and the loader reads the registry
165
+ * first — so a duplicated marker is not cosmetic, it is a wrong answer about what exists.
166
+ *
167
+ * ⚠ AND A SHARD IS NEVER REMOVED HERE. Per the memory-worker discipline this borrows: **supersede,
168
+ * never delete.** A shard that stops being written keeps its line and its history; removing it is a
169
+ * tombstone decision, not a side effect of training.
170
+ */
171
+ export declare function registryLine(shardPath: string, taskType: string): string;
172
+ export declare function updateMemoryRegistry(existing: string, entries: ReadonlyArray<{
173
+ path: string;
174
+ taskType: string;
175
+ }>): string;
176
+ /**
177
+ * T30 — the task-type shard.
178
+ *
179
+ * ⚠ `task-type` IS READ FROM THE GROUP'S MODE, and that is an INTERPRETATION rather than a measured
180
+ * fact: the parent writes `memory/training/<task-type>.md` without defining the key in the material I
181
+ * have, so the mode a group was extracted under (`contrastive` / `winner-only`) is what distinguishes
182
+ * one training shard from another here. Named so a reader can disagree with it instead of discovering it.
183
+ */
184
+ export declare function taskTypeShardPath(mode: TrainingGroup['mode']): string;
185
+ export declare function renderTaskTypeShard(groups: readonly TrainingGroup[]): string;
186
+ /**
187
+ * T30 — turn the extractor's payload into items the grouping can use.
188
+ *
189
+ * ⚠ A MALFORMED PAYLOAD IS NOT A BROKEN EXTRACTOR. By the time this runs the extractor has exited 0 and
190
+ * produced parseable JSON, so the transport is fine; a payload carrying no items is an extractor that
191
+ * found nothing, which is **exit 3, not exit 2**. Keeping those apart is why `runExtractor` answers
192
+ * first and this second.
193
+ *
194
+ * ⚠ A PARTIAL ANSWER NEITHER LOSES THE BATCH NOR INVENTS EVIDENCE: an entry missing its text is
195
+ * SKIPPED rather than defaulted, because an item with invented text would be taught as a learning
196
+ * nobody extracted. An entry with no `runId` is skipped too — the grouping counts DISTINCT runs, so an
197
+ * unattributed observation would be pooled into a run it did not come from.
198
+ */
199
+ export declare function parseExtractorItems(payload: unknown): TrainingItem[];
200
+ /**
201
+ * T30 — the whole round trip, in the order the failure codes demand: resolve the command, run it,
202
+ * parse the payload, then group. Each stage keeps its own meaning — no command or a non-zero exit is
203
+ * **exit 2**; a payload with nothing usable in it is **exit 3**.
204
+ */
205
+ export declare function extractAndGroup(runner: (cmd: string) => ExtractorRun, env: Record<string, string | undefined>, options?: {
206
+ isWinner?: (item: TrainingItem) => boolean;
207
+ }): {
208
+ outcome: ExtractorOutcome;
209
+ items: TrainingItem[];
210
+ groups: TrainingGroup[];
211
+ };
package/lib/ts-lint.d.ts CHANGED
@@ -123,6 +123,21 @@ export declare function getPhaseOwnedActualChangedFiles(fileName: string, actual
123
123
  export declare function getRelatedAddendaPaths(runDir: string, artifactName: string): string[];
124
124
  /** get_stage_local_addenda_paths: run_dir/addenda/<base>.addendum-*.md. */
125
125
  export declare function getStageLocalAddendaPaths(runDir: string, artifactName: string): string[];
126
+ /**
127
+ * get_run_tree_addenda: EVERY addendum in the run, wherever it was filed.
128
+ *
129
+ * ⚠ WHY THIS EXISTS BESIDE THE TWO ABOVE: both scan `run_dir/addenda/` and nothing else, and flatly. But an
130
+ * addendum is written BESIDE THE ARTIFACT IT CLOSES — the run ROOT is where the real ones live, e.g.
131
+ * `02-to-be-plan.addendum-r4-r2-mount-resolution.md`, which the comments elsewhere in this file cite by name
132
+ * — and a stage may file one in a subfolder. A reader that consults only `addenda/` therefore misses
133
+ * exactly the addenda that closed an upstream gap, which are the ones a closeout most needs to see.
134
+ *
135
+ * Returns run-relative POSIX paths, sorted, so a caller can join them onto the run directory and cite them
136
+ * verbatim. Matching uses {@link isAddendumArtifact} — the SAME predicate the rest of the plugin uses — so a
137
+ * name can never count as an addendum in one place and not another. `upstream-gap` names are matched too:
138
+ * they are the back-edge form of an addendum, and {@link getRelatedAddendaPaths} already treats them as one.
139
+ */
140
+ export declare function getRunTreeAddenda(runDir: string): string[];
126
141
  /** get_current_phase_upstream_gap_addenda_paths: run_dir/addenda/<base>.upstream-gap.*.addendum-*.md. */
127
142
  export declare function getCurrentPhaseUpstreamGapAddendaPaths(runDir: string, artifactName: string): string[];
128
143
  /** get_header_field_block: lines under `Field:` until a stop field. */
package/lib/types.d.ts CHANGED
@@ -20,6 +20,25 @@ export interface ArtifactState {
20
20
  todoHasSection: boolean;
21
21
  todoUnchecked: number;
22
22
  }
23
+ /**
24
+ * T21 — ONE named position per phase, derived from the fields every consumer
25
+ * currently recombines for itself (`status`, `lockValid`, `lockProblems`,
26
+ * `blockers`). Two consumers recombining those independently is exactly how two
27
+ * consumers come to disagree about a phase's state; a single derived value gives
28
+ * them nothing to disagree about.
29
+ *
30
+ * Closed vocabulary, total and disjoint (see `phasePosition`):
31
+ * `absent` the artifact does not exist
32
+ * `skipped` an optional (or legacy-profile late) phase that is not present
33
+ * `draft` present and unlocked, with no blockers
34
+ * `blocked` present and unlocked, with at least one blocker
35
+ * `invalid-lock` LOCKED but failing a condition other than its hash
36
+ * `locked` LOCKED and lock-valid
37
+ * `tampered` LOCKED with a hash mismatch — the more serious fact, so it wins
38
+ * over any other lock problem
39
+ */
40
+ export type PhasePosition = 'absent' | 'skipped' | 'draft' | 'blocked' | 'invalid-lock' | 'locked' | 'tampered';
41
+ export declare const PHASE_POSITIONS: readonly PhasePosition[];
23
42
  export interface PhaseState {
24
43
  key: string;
25
44
  label: string;
@@ -30,6 +49,8 @@ export interface PhaseState {
30
49
  lockValid: boolean;
31
50
  lockProblems: string[];
32
51
  blockers: string[];
52
+ /** T21: the single derived state of this phase. */
53
+ position: PhasePosition;
33
54
  }
34
55
  export interface RecursiveStatusResult {
35
56
  runId: string;
@@ -59,6 +80,21 @@ export interface RecursiveTamper {
59
80
  path: string;
60
81
  reason: string;
61
82
  }
83
+ /**
84
+ * T18 — one piece of unresolved in-flight work, DERIVED from the run directory
85
+ * (there is no ledger). `unanswered-delegation` is a `handoff.md` with no reply
86
+ * yet; `empty-reply` is a reply file that exists but carries nothing, which is
87
+ * not a submission.
88
+ */
89
+ export interface PendingWorkItem {
90
+ kind: 'unanswered-delegation' | 'empty-reply';
91
+ /** The delegation directory name, so a refusal can name what is blocking. */
92
+ delegationId: string;
93
+ /** Repo-relative path of the file that would settle it, or of the handoff. */
94
+ path: string;
95
+ /** One sentence for a human, naming the delegation and what is missing. */
96
+ detail: string;
97
+ }
62
98
  /** One subagent activity fact (start or end). */
63
99
  export interface RecursiveSubagent {
64
100
  childId: string;
@@ -72,6 +108,12 @@ export interface RecursivePhaseRow {
72
108
  status: string;
73
109
  lockedAt?: string;
74
110
  lockHash?: string;
111
+ /**
112
+ * T21: the same single derived position `foldRun` publishes, carried onto the
113
+ * wire so the board and the tooling cannot disagree about a phase's state.
114
+ * Optional so a row produced before this field existed stays readable.
115
+ */
116
+ position?: PhasePosition;
75
117
  }
76
118
  /**
77
119
  * The per-run wire card the recursive projection folds: the board, inspector,
@@ -93,6 +135,12 @@ export interface RecursiveRunCard {
93
135
  tampers: Record<string, RecursiveTamper>;
94
136
  /** Subagent activity (latest status wins per childId). */
95
137
  subagents: Record<string, RecursiveSubagent>;
138
+ /**
139
+ * T18: unresolved in-flight work, DERIVED from the run directory on every fold
140
+ * (never stored). Optional so an older producer's card stays readable; the
141
+ * folder always sets it. A non-empty array explains why a lock will be refused.
142
+ */
143
+ pendingWork?: PendingWorkItem[];
96
144
  /** Set on recursive/run-merged; the run re-keys to this root. */
97
145
  mergedToRepoRoot?: string;
98
146
  }