@mjasnikovs/pi-task 0.40.50 → 0.41.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 (123) hide show
  1. package/README.md +7 -4
  2. package/dist/config/config.d.ts +61 -18
  3. package/dist/config/config.js +50 -6
  4. package/dist/config/register.js +18 -5
  5. package/dist/shared/content-tokens.d.ts +16 -0
  6. package/dist/shared/content-tokens.js +137 -0
  7. package/dist/task/accept-debt.d.ts +15 -1
  8. package/dist/task/accept-debt.js +4 -0
  9. package/dist/task/artifact-closure.js +7 -1
  10. package/dist/task/auto-io.d.ts +59 -7
  11. package/dist/task/auto-io.js +140 -47
  12. package/dist/task/auto-orchestrator.d.ts +22 -19
  13. package/dist/task/auto-orchestrator.js +126 -112
  14. package/dist/task/child-runner.d.ts +18 -0
  15. package/dist/task/child-runner.js +7 -8
  16. package/dist/task/command-run.d.ts +4 -0
  17. package/dist/task/command-run.js +4 -2
  18. package/dist/task/constraint-policy.d.ts +52 -0
  19. package/dist/task/constraint-policy.js +66 -0
  20. package/dist/task/context-silence.d.ts +5 -4
  21. package/dist/task/context-silence.js +13 -5
  22. package/dist/task/coverage-loop.d.ts +7 -4
  23. package/dist/task/coverage-loop.js +16 -138
  24. package/dist/task/debug-log.d.ts +25 -2
  25. package/dist/task/debug-log.js +64 -10
  26. package/dist/task/decompose-fidelity.d.ts +17 -4
  27. package/dist/task/decompose-fidelity.js +95 -55
  28. package/dist/task/decompose-granularity.d.ts +7 -7
  29. package/dist/task/decompose-granularity.js +8 -8
  30. package/dist/task/env-notes.d.ts +65 -19
  31. package/dist/task/env-notes.js +152 -50
  32. package/dist/task/external-context.js +25 -5
  33. package/dist/task/fix-context.d.ts +39 -0
  34. package/dist/task/fix-context.js +58 -0
  35. package/dist/task/gate-deps.d.ts +49 -0
  36. package/dist/task/gate-deps.js +199 -38
  37. package/dist/task/gate-evidence.d.ts +51 -0
  38. package/dist/task/gate-evidence.js +90 -0
  39. package/dist/task/gate-resolution.d.ts +119 -0
  40. package/dist/task/gate-resolution.js +120 -0
  41. package/dist/task/git-state-guard.js +3 -26
  42. package/dist/task/handoff.d.ts +35 -0
  43. package/dist/task/handoff.js +100 -0
  44. package/dist/task/health-baseline.d.ts +87 -0
  45. package/dist/task/health-baseline.js +158 -0
  46. package/dist/task/ledger.d.ts +10 -0
  47. package/dist/task/ledger.js +22 -11
  48. package/dist/task/lint-fix.d.ts +11 -0
  49. package/dist/task/lint-fix.js +14 -8
  50. package/dist/task/loop-detector.d.ts +52 -3
  51. package/dist/task/loop-detector.js +81 -5
  52. package/dist/task/mentions.d.ts +17 -0
  53. package/dist/task/mentions.js +86 -0
  54. package/dist/task/orchestrator.d.ts +53 -9
  55. package/dist/task/orchestrator.js +104 -28
  56. package/dist/task/orientation.d.ts +135 -29
  57. package/dist/task/orientation.js +259 -67
  58. package/dist/task/owned-freeze-reassign.d.ts +7 -3
  59. package/dist/task/owned-freeze-reassign.js +13 -5
  60. package/dist/task/parsers.d.ts +6 -1
  61. package/dist/task/parsers.js +21 -4
  62. package/dist/task/phases.d.ts +1 -1
  63. package/dist/task/phases.js +125 -153
  64. package/dist/task/plan-orchestrator.js +5 -6
  65. package/dist/task/plan-rounds.d.ts +1 -0
  66. package/dist/task/plan-rounds.js +8 -1
  67. package/dist/task/prohibition-probe.d.ts +6 -1
  68. package/dist/task/prohibition-probe.js +26 -6
  69. package/dist/task/prompts.js +13 -2
  70. package/dist/task/qa-transcript.d.ts +17 -0
  71. package/dist/task/qa-transcript.js +28 -0
  72. package/dist/task/question-source.d.ts +16 -2
  73. package/dist/task/question-source.js +21 -8
  74. package/dist/task/repo-health-check.d.ts +27 -0
  75. package/dist/task/repo-health-check.js +39 -5
  76. package/dist/task/requirements.d.ts +67 -13
  77. package/dist/task/requirements.js +129 -67
  78. package/dist/task/research-worker.d.ts +43 -2
  79. package/dist/task/research-worker.js +103 -8
  80. package/dist/task/run-bracket.d.ts +9 -4
  81. package/dist/task/run-bracket.js +9 -0
  82. package/dist/task/run-context.d.ts +152 -0
  83. package/dist/task/run-context.js +277 -0
  84. package/dist/task/spec-doc.d.ts +78 -0
  85. package/dist/task/spec-doc.js +205 -0
  86. package/dist/task/spec-model.d.ts +70 -0
  87. package/dist/task/spec-model.js +139 -0
  88. package/dist/task/stall-detector.d.ts +1 -1
  89. package/dist/task/stall-detector.js +9 -5
  90. package/dist/task/state-dir.d.ts +29 -0
  91. package/dist/task/state-dir.js +80 -0
  92. package/dist/task/suppression-probe.d.ts +67 -0
  93. package/dist/task/suppression-probe.js +78 -0
  94. package/dist/task/task-gates.d.ts +39 -57
  95. package/dist/task/task-gates.js +115 -195
  96. package/dist/task/task-io.d.ts +10 -0
  97. package/dist/task/task-io.js +69 -18
  98. package/dist/task/task-parsers.js +6 -6
  99. package/dist/task/task-types.d.ts +7 -0
  100. package/dist/task/timings.d.ts +11 -0
  101. package/dist/task/timings.js +21 -0
  102. package/dist/task/tree-hash.d.ts +8 -0
  103. package/dist/task/tree-hash.js +55 -0
  104. package/dist/task/verify-reconcile.d.ts +4 -3
  105. package/dist/task/verify-reconcile.js +7 -8
  106. package/dist/task/verify-resolution.d.ts +28 -5
  107. package/dist/task/verify-resolution.js +70 -13
  108. package/dist/task/verify-work.d.ts +98 -35
  109. package/dist/task/verify-work.js +227 -60
  110. package/dist/task/yolo.d.ts +0 -13
  111. package/dist/task/yolo.js +9 -21
  112. package/dist/workers/pi-worker-core.d.ts +20 -0
  113. package/dist/workers/pi-worker-core.js +16 -11
  114. package/dist/workers/pi-worker-docs.d.ts +6 -5
  115. package/dist/workers/pi-worker-docs.js +8 -7
  116. package/dist/workers/pi-worker-fetch.d.ts +3 -2
  117. package/dist/workers/pi-worker-fetch.js +5 -4
  118. package/dist/workers/pi-worker-search.js +5 -4
  119. package/dist/workers/research-cache.d.ts +23 -1
  120. package/dist/workers/research-cache.js +63 -3
  121. package/dist/workers/shared.d.ts +18 -0
  122. package/dist/workers/shared.js +38 -22
  123. package/package.json +1 -1
@@ -261,6 +261,7 @@ export async function runBoundedLintFix(deps) {
261
261
  deps.log?.(`lint-fix REVERT-GUARD — discarded ${violations.length} work file(s)`);
262
262
  return {
263
263
  ok: false,
264
+ class: 'not-applied',
264
265
  reason: `revert-guard: fix pass discarded work (${violations.slice(0, 3).join(', ')}`
265
266
  + `${violations.length > 3 ? ', …' : ''}) — fix ${snapshot ? 'rolled back' : 'REJECTED but no snapshot to restore'}`
266
267
  };
@@ -288,6 +289,7 @@ export async function runBoundedLintFix(deps) {
288
289
  deps.log?.(`lint-fix CROSS-TASK-DELETION GUARD — ${named[0]}`);
289
290
  return {
290
291
  ok: false,
292
+ class: 'not-applied',
291
293
  reason: `cross-task-deletion: fix child DELETED sibling task deliverable(s) `
292
294
  + `(${named.slice(0, 3).join('; ')}${crossDeletions.length > 3 ? '; …' : ''}) `
293
295
  + `— restored from HEAD; deleting another task's committed work is not a fix`
@@ -311,6 +313,8 @@ export async function runBoundedLintFix(deps) {
311
313
  deps.log?.(`lint-fix FROZEN-PATH GUARD — ${frozenViolations.slice(0, 3).join(', ')}`);
312
314
  return {
313
315
  ok: false,
316
+ class: 'frozen-path',
317
+ contradiction: { criterion: deps.failReason, frozenPath: frozenViolations[0] },
314
318
  reason: `frozen-path: fix child modified spec-frozen path(s) `
315
319
  + `(${frozenViolations.slice(0, 3).join(', ')}`
316
320
  + `${frozenViolations.length > 3 ? ', …' : ''}) — `
@@ -322,7 +326,7 @@ export async function runBoundedLintFix(deps) {
322
326
  }
323
327
  if (end.kind === 'error') {
324
328
  deps.log?.(`lint-fix child failed — ${end.msg}`);
325
- return { ok: false, reason: `fix child failed: ${end.msg}` };
329
+ return { ok: false, class: 'not-applied', reason: `fix child failed: ${end.msg}` };
326
330
  }
327
331
  if (end.kind === 'blocked')
328
332
  deps.log?.(`lint-fix BLOCKED — ${end.note}`);
@@ -344,28 +348,30 @@ export async function runBoundedLintFix(deps) {
344
348
  const health = await deps.repoHealth();
345
349
  if (!health.ok) {
346
350
  if (end.kind === 'blocked') {
347
- return { ok: false, reason: `fix child blocked: ${end.note}` };
351
+ return { ok: false, class: 'not-applied', reason: `fix child blocked: ${end.note}` };
348
352
  }
349
353
  // FROZEN-PATH TRACE on non-convergence: when the child was honest — it did
350
354
  // NOT touch the frozen path, so the guard above never tripped — but the
351
355
  // check is still red and its own output NAMES a frozen path (the typed
352
356
  // ESLint message quoted in this file's header), the findings can only be
353
- // fixed by an edit this task's spec forbids. Report it under the same
354
- // `frozen-path:` prefix as the guard trip, so the gate loop can route
355
- // straight to the human picker instead of burning unattended AUTOFIX
356
- // rounds an impl re-run under the same freeze cannot converge out of.
357
+ // fixed by an edit this task's spec forbids. Report the same
358
+ // `frozen-path` class as the guard trip, so the gate's decision table
359
+ // exits on round one instead of burning unattended AUTOFIX rounds an impl
360
+ // re-run under the same freeze cannot converge out of.
357
361
  const implicated = frozen.filter(p => pathNamedIn(`${health.reason}\n${health.output ?? ''}`, p));
358
362
  if (implicated.length > 0) {
359
363
  deps.log?.(`lint-fix FROZEN-PATH TRACE — ${implicated.slice(0, 3).join(', ')}`);
360
364
  return {
361
365
  ok: false,
366
+ class: 'frozen-path',
367
+ contradiction: { criterion: health.reason, frozenPath: implicated[0] },
362
368
  reason: `frozen-path: static findings implicate spec-frozen path(s) `
363
369
  + `(${implicated.slice(0, 3).join(', ')}`
364
370
  + `${implicated.length > 3 ? ', …' : ''}) — did not converge `
365
371
  + `(${health.reason}); a fix under this task's constraints cannot converge`
366
372
  };
367
373
  }
368
- return { ok: false, reason: `did not converge: ${health.reason}` };
374
+ return { ok: false, class: 'not-applied', reason: `did not converge: ${health.reason}` };
369
375
  }
370
- return { ok: true, reason: guardNote };
376
+ return { ok: true, class: 'converged', ...(guardNote === undefined ? {} : { reason: guardNote }) };
371
377
  }
@@ -30,7 +30,15 @@ export interface ToolCall {
30
30
  export interface LoopHit {
31
31
  call: ToolCall;
32
32
  count: number;
33
- windowSize: number;
33
+ /**
34
+ * How many recent calls `count` was counted over.
35
+ *
36
+ * OPTIONAL because a stall hit counts a streak or a byte total, not a window
37
+ * — it used to carry 0 there, which every renderer printed as "in the last 0
38
+ * calls". Absent means "this hit has no window"; a renderer must say what the
39
+ * hit actually measured instead.
40
+ */
41
+ windowSize?: number;
34
42
  /**
35
43
  * Set when the kill came from the whole-run StallDetector rather than this
36
44
  * short-window detector, naming which of its two rules tripped
@@ -63,11 +71,25 @@ export declare class LoopDetector {
63
71
  /** Revisits of one path needed to trip; defaults to the exact threshold. */
64
72
  private readonly pathThreshold;
65
73
  private readonly buf;
74
+ /**
75
+ * Every path the attempt has targeted, with the span it covered — the WHOLE
76
+ * attempt, not the window `buf` holds. It is the only record of what a killed
77
+ * attempt actually read: the kill fires from the tool-call hook, before a byte
78
+ * of answer text streams, so text carry-forward is empty by construction and
79
+ * the re-spawn would otherwise start blind.
80
+ */
81
+ private readonly visits;
66
82
  constructor(window?: number, threshold?: number,
67
83
  /** Revisits of one path needed to trip; defaults to the exact threshold. */
68
84
  pathThreshold?: number);
69
85
  /** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
70
86
  record(call: ToolCall): LoopHit | null;
87
+ /**
88
+ * The attempt's READ-SET, in first-seen order: every path it opened, with the
89
+ * line span when the call named one. This is what a restart carries instead of
90
+ * answer text — see {@link visits}.
91
+ */
92
+ visited(): string[];
71
93
  /**
72
94
  * Count same-path calls in the window that are "revisits" — accesses that end
73
95
  * no further into the file than the furthest line already covered for that
@@ -84,5 +106,32 @@ export declare class LoopDetector {
84
106
  */
85
107
  private countRevisits;
86
108
  }
87
- /** The restart hint a re-spawned child gets after a loop kill: names the call. */
88
- export declare function formatLoopHint(hit: LoopHit): string;
109
+ /**
110
+ * What killed the attempt, as prose a human reads in a degraded section or a
111
+ * `loop events` line.
112
+ *
113
+ * ONE renderer because each rule measures something different — calls in a
114
+ * window, a consecutive streak, bytes against a context window — and a shared
115
+ * "×N in the last M calls" template printed the streak's absent window as
116
+ * "in the last 0 calls".
117
+ */
118
+ export declare function describeLoopHit(hit: LoopHit): string;
119
+ /**
120
+ * The read-set clause a restart hint carries, or '' when the killed attempt
121
+ * opened nothing.
122
+ *
123
+ * It is the only thing a loop restart CAN carry: the kill fires from the
124
+ * tool-call hook, so there is no partial answer to hand forward (which is why
125
+ * `loop` is absent from CARRY_FORWARD_REASONS in pi-worker-core.ts). Without it
126
+ * "do not re-read what you have already read" names no files and the re-spawn
127
+ * re-reads the same ones.
128
+ *
129
+ * Bounded so a rotation over hundreds of files cannot outgrow the prompt it is
130
+ * prepended to; the count keeps the elision honest.
131
+ */
132
+ export declare function formatReadSet(visited: readonly string[]): string;
133
+ /**
134
+ * The restart hint a re-spawned child gets after a loop kill: names the call, and
135
+ * the read-set the killed attempt built up.
136
+ */
137
+ export declare function formatLoopHint(hit: LoopHit, visited?: readonly string[]): string;
@@ -96,6 +96,14 @@ export class LoopDetector {
96
96
  threshold;
97
97
  pathThreshold;
98
98
  buf = [];
99
+ /**
100
+ * Every path the attempt has targeted, with the span it covered — the WHOLE
101
+ * attempt, not the window `buf` holds. It is the only record of what a killed
102
+ * attempt actually read: the kill fires from the tool-call hook, before a byte
103
+ * of answer text streams, so text carry-forward is empty by construction and
104
+ * the re-spawn would otherwise start blind.
105
+ */
106
+ visits = new Map();
99
107
  constructor(window = LOOP_WINDOW, threshold = LOOP_THRESHOLD,
100
108
  /** Revisits of one path needed to trip; defaults to the exact threshold. */
101
109
  pathThreshold = threshold) {
@@ -107,9 +115,17 @@ export class LoopDetector {
107
115
  record(call) {
108
116
  const key = loopKey(call);
109
117
  const offset = readOffset(call.args);
110
- this.buf.push({ key, path: primaryPath(call.args), offset, end: readEnd(call.args, offset) });
118
+ const path = primaryPath(call.args);
119
+ const end = readEnd(call.args, offset);
120
+ this.buf.push({ key, path, offset, end });
111
121
  if (this.buf.length > this.window)
112
122
  this.buf.shift();
123
+ if (path !== null) {
124
+ const seen = this.visits.get(path);
125
+ this.visits.set(path, seen ?
126
+ { from: Math.min(seen.from, offset), to: Math.max(seen.to, end) }
127
+ : { from: offset, to: end });
128
+ }
113
129
  // 1. Exact-match loop: identical (name, args) repeated past threshold.
114
130
  let exact = 0;
115
131
  for (const e of this.buf)
@@ -120,7 +136,6 @@ export class LoopDetector {
120
136
  // 2. Path-aware loop: the same file re-targeted without forward progress.
121
137
  // Caught here precisely because varied offset/limit change the exact key on
122
138
  // every call, so pattern 1 above can never see it.
123
- const path = this.buf[this.buf.length - 1].path;
124
139
  if (path !== null) {
125
140
  const revisits = this.countRevisits(path);
126
141
  if (revisits >= this.pathThreshold) {
@@ -129,6 +144,14 @@ export class LoopDetector {
129
144
  }
130
145
  return null;
131
146
  }
147
+ /**
148
+ * The attempt's READ-SET, in first-seen order: every path it opened, with the
149
+ * line span when the call named one. This is what a restart carries instead of
150
+ * answer text — see {@link visits}.
151
+ */
152
+ visited() {
153
+ return [...this.visits].map(([path, span]) => span.to === Infinity ? path : `${path} (lines ${span.from}-${span.to})`);
154
+ }
132
155
  /**
133
156
  * Count same-path calls in the window that are "revisits" — accesses that end
134
157
  * no further into the file than the furthest line already covered for that
@@ -157,11 +180,64 @@ export class LoopDetector {
157
180
  return revisits;
158
181
  }
159
182
  }
160
- /** The restart hint a re-spawned child gets after a loop kill: names the call. */
161
- export function formatLoopHint(hit) {
183
+ /**
184
+ * What killed the attempt, as prose a human reads in a degraded section or a
185
+ * `loop events` line.
186
+ *
187
+ * ONE renderer because each rule measures something different — calls in a
188
+ * window, a consecutive streak, bytes against a context window — and a shared
189
+ * "×N in the last M calls" template printed the streak's absent window as
190
+ * "in the last 0 calls".
191
+ */
192
+ export function describeLoopHit(hit) {
193
+ const call = `${hit.call.name}(${JSON.stringify(hit.call.args)})`;
194
+ if (hit.stall === 'context-churn') {
195
+ return (`pulled in about ${hit.count} tokens of tool output, more than its `
196
+ + `${hit.windowSize}-token context window holds (last call ${call})`);
197
+ }
198
+ if (hit.stall === 'no-new-ground') {
199
+ return (`stopped covering new ground — ${hit.count} consecutive tool calls returned `
200
+ + `nothing it had not already seen (last call ${call})`);
201
+ }
202
+ return `stuck in a loop — called ${call} ×${hit.count} in the last ${hit.windowSize} calls`;
203
+ }
204
+ /**
205
+ * The read-set clause a restart hint carries, or '' when the killed attempt
206
+ * opened nothing.
207
+ *
208
+ * It is the only thing a loop restart CAN carry: the kill fires from the
209
+ * tool-call hook, so there is no partial answer to hand forward (which is why
210
+ * `loop` is absent from CARRY_FORWARD_REASONS in pi-worker-core.ts). Without it
211
+ * "do not re-read what you have already read" names no files and the re-spawn
212
+ * re-reads the same ones.
213
+ *
214
+ * Bounded so a rotation over hundreds of files cannot outgrow the prompt it is
215
+ * prepended to; the count keeps the elision honest.
216
+ */
217
+ export function formatReadSet(visited) {
218
+ if (visited.length === 0)
219
+ return '';
220
+ const shown = visited.slice(0, MAX_READ_SET_ENTRIES);
221
+ const rest = visited.length - shown.length;
222
+ return (` You have already read, do not re-open: ${shown.join(', ')}`
223
+ + (rest > 0 ? `, and ${rest} more` : '')
224
+ + '.');
225
+ }
226
+ /**
227
+ * How many paths a restart hint lists. The read-set is a reminder, not an
228
+ * inventory: past a screenful the tail stops being read and only costs prefill,
229
+ * and the elision line still tells the child there was more.
230
+ */
231
+ const MAX_READ_SET_ENTRIES = 40;
232
+ /**
233
+ * The restart hint a re-spawned child gets after a loop kill: names the call, and
234
+ * the read-set the killed attempt built up.
235
+ */
236
+ export function formatLoopHint(hit, visited = []) {
162
237
  const argsStr = JSON.stringify(hit.call.args);
163
238
  return (`[SYSTEM NOTE: Your prior attempt called ${hit.call.name}(${argsStr}) `
164
239
  + `${hit.count} times in the last ${hit.windowSize} tool calls — you appeared to be `
165
240
  + `stuck in a loop. Avoid repeating that exact call; if you've already seen its result, `
166
- + `work from memory or pick a different angle.]`);
241
+ + `work from memory or pick a different angle.`
242
+ + `${formatReadSet(visited)}]`);
167
243
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Expand any @file references in the feature text by appending each referenced
3
+ * file's contents, so the planning children (clarify, decompose) always see the
4
+ * real spec inline instead of relying on the model to open the file itself.
5
+ * Without this, clarify on a one-line "Implement @spec.md" tends to bail with
6
+ * NONE because, to the model, the request looks small and unambiguous.
7
+ * Unreadable mentions (typos, non-file @tokens) are left untouched; the feature
8
+ * is returned verbatim when nothing readable is referenced.
9
+ */
10
+ export declare function expandFeatureMentions(cwd: string, feature: string): Promise<string>;
11
+ /**
12
+ * The @file references in the feature that point at a readable file on disk —
13
+ * the bare path tokens, deduped, in first-seen order. Unreadable mentions
14
+ * (typos, non-file @tokens) are dropped so we never advertise a missing file as
15
+ * an authoritative spec.
16
+ */
17
+ export declare function readableMentions(cwd: string, feature: string): Promise<string[]>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `@file` references in a prompt, and the two things done with them: inlining the
3
+ * file's contents for the planning children, and naming the files themselves.
4
+ *
5
+ * Its own module because both the PLANNING side (auto-orchestrator, which threads
6
+ * the spec doc into every task title) and the PHASE side (research, which lets the
7
+ * orientation core select a cited doc) need the same answer, and the phase side
8
+ * cannot import the orchestrator that imports it.
9
+ */
10
+ import * as fsp from 'node:fs/promises';
11
+ import * as path from 'node:path';
12
+ import { readTextFile } from '../shared/fs-text.js';
13
+ const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
14
+ // Trailing punctuation a user naturally types AFTER an @-mention when it sits in
15
+ // prose — "Implement @design.md, reuse…" or "see @spec.md." — which the greedy
16
+ // [^\s]+ above would otherwise swallow into the path. Left unstripped, the
17
+ // resulting "design.md," resolves to no file, expansion is silently skipped, and
18
+ // the planner reasons over a one-line "Implement @design.md" with NO spec inline
19
+ // → it fabricates generic questions and tasks the spec never called for.
20
+ //
21
+ // Measured against a real file: the greedy token from "Implement @design.md,
22
+ // reuse the parser" is `design.md,`, which does not exist; stripped, `design.md`
23
+ // does. None of these chars are legitimate trailing characters of a doc path.
24
+ const MENTION_TRAILING_PUNCT = /[.,;:!?)\]}>"']+$/;
25
+ /** The cleaned path token of an @-mention: greedy match minus trailing prose punctuation. */
26
+ function mentionPath(token) {
27
+ return token.replace(MENTION_TRAILING_PUNCT, '');
28
+ }
29
+ /** Every distinct @-mention path in `text`, in first-seen order. */
30
+ function mentionPaths(text) {
31
+ const out = [];
32
+ const seen = new Set();
33
+ for (const m of text.matchAll(MENTION_RE)) {
34
+ const rel = mentionPath(m[1]);
35
+ if (rel === '' || seen.has(rel))
36
+ continue;
37
+ seen.add(rel);
38
+ out.push(rel);
39
+ }
40
+ return out;
41
+ }
42
+ /**
43
+ * Expand any @file references in the feature text by appending each referenced
44
+ * file's contents, so the planning children (clarify, decompose) always see the
45
+ * real spec inline instead of relying on the model to open the file itself.
46
+ * Without this, clarify on a one-line "Implement @spec.md" tends to bail with
47
+ * NONE because, to the model, the request looks small and unambiguous.
48
+ * Unreadable mentions (typos, non-file @tokens) are left untouched; the feature
49
+ * is returned verbatim when nothing readable is referenced.
50
+ */
51
+ export async function expandFeatureMentions(cwd, feature) {
52
+ const blocks = [];
53
+ for (const rel of mentionPaths(feature)) {
54
+ try {
55
+ // Normalize CRLF/CR so an @-mentioned design doc saved on Windows
56
+ // inlines with LF endings the downstream phase parsers expect.
57
+ const body = await readTextFile(path.resolve(cwd, rel));
58
+ if (body.trim().length > 0) {
59
+ blocks.push(`--- contents of ${rel} ---\n${body.trim()}`);
60
+ }
61
+ }
62
+ catch {
63
+ // not a readable file — leave the @token in place, skip expansion
64
+ }
65
+ }
66
+ return blocks.length === 0 ? feature : `${feature.trim()}\n\n${blocks.join('\n\n')}`;
67
+ }
68
+ /**
69
+ * The @file references in the feature that point at a readable file on disk —
70
+ * the bare path tokens, deduped, in first-seen order. Unreadable mentions
71
+ * (typos, non-file @tokens) are dropped so we never advertise a missing file as
72
+ * an authoritative spec.
73
+ */
74
+ export async function readableMentions(cwd, feature) {
75
+ const out = [];
76
+ for (const rel of mentionPaths(feature)) {
77
+ try {
78
+ await fsp.access(path.resolve(cwd, rel));
79
+ out.push(rel);
80
+ }
81
+ catch {
82
+ // not a readable file — don't thread it into task titles
83
+ }
84
+ }
85
+ return out;
86
+ }
@@ -19,6 +19,8 @@
19
19
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
20
20
  import { type WidgetState } from './widget.js';
21
21
  import { type RunTaskFn } from './gate-deps.js';
22
+ import { type HealthBaseline } from './health-baseline.js';
23
+ import { type FixContext } from './fix-context.js';
22
24
  import { type GateDeps } from './task-gates.js';
23
25
  import { type PhaseSeams } from './child-runner.js';
24
26
  import type { PiModel } from '../shared/model-resolve.js';
@@ -68,14 +70,34 @@ export interface TaskRunnerOptions {
68
70
  */
69
71
  planContext?: string;
70
72
  /**
71
- * Marks this run as a verify-FAIL re-attempt. When set (only by /task-auto's
72
- * autofix path, with `resumeId` pointing at the already-composed task), the
73
- * text the verify gate's failure reason plus any guidance the user typed
74
- * is prepended to the delivered spec as a RE-ATTEMPT banner, so the
75
- * implementer fixes the specific failure and re-satisfies the VERIFY block
76
- * rather than blindly redoing the task. Empty/undefined on a first attempt.
73
+ * The /task-auto plan entry this run implements (`TaskEntry.key`). Recorded in
74
+ * the task file's front matter, which is where every phase reads it back —
75
+ * this option only exists to carry it in on a fresh start. Undefined for a
76
+ * bare /task, which belongs to no plan.
77
77
  */
78
- fixInstruction?: string;
78
+ planKey?: string;
79
+ /**
80
+ * Marks this run as a verify-FAIL re-attempt (set only by the gate loop's
81
+ * autofix path, with `resumeId` pointing at the already-composed task):
82
+ * everything the gate knows about the failure — its class, the attempt
83
+ * number, what the deterministic probes found, any spec contradiction —
84
+ * rendered ahead of the delivered spec by `formatFixBanner`. Undefined on a
85
+ * first attempt.
86
+ */
87
+ fixContext?: FixContext;
88
+ /**
89
+ * The repo-health baseline for the tree this task starts from
90
+ * (health-baseline.ts), written to `## health baseline` once the task file
91
+ * exists. A thunk because only the runner knows whether the file already
92
+ * carries one — a resumed or auto-fixed task must keep the ORIGINAL baseline,
93
+ * and re-running the project's statics to discard the result is the cost this
94
+ * saves. Absent → no section, and the verify gate establishes one lazily.
95
+ *
96
+ * It is handed the RUNNER'S ctx, not the caller's: `runSingleTask` replaces the
97
+ * session before the runner starts, so the ctx a call site closed over is torn
98
+ * down by the time this fires and its status loader would throw on it.
99
+ */
100
+ healthBaseline?: (ctx: ExtensionCommandContext) => Promise<HealthBaseline | null>;
79
101
  /** True when the caller awaits the implementation turn (waitForImplementation):
80
102
  * the impl widget stays armed across the whole impl phase (incl. compaction /
81
103
  * steer turns) and is disarmed here. False for fire-and-forget /task, where the
@@ -91,7 +113,9 @@ export declare class TaskRunner {
91
113
  private readonly _sendSpec;
92
114
  private readonly _onStart;
93
115
  private readonly _planContext;
94
- private readonly _fixInstruction;
116
+ private readonly _planKey;
117
+ private readonly _fixContext;
118
+ private readonly _healthBaseline;
95
119
  /** See {@link TaskRunnerOptions.implAwaited}. */
96
120
  private readonly _implAwaited;
97
121
  private readonly _abort;
@@ -109,6 +133,12 @@ export declare class TaskRunner {
109
133
  */
110
134
  private readonly _timings;
111
135
  private _currentPhaseChildren;
136
+ /**
137
+ * Whether this run has already appended its timings block. The success path
138
+ * writes, and a cancel raised AFTER it still lands in the catch — which would
139
+ * otherwise append a second block for the same attempt.
140
+ */
141
+ private _timingsWritten;
112
142
  constructor(opts: TaskRunnerOptions);
113
143
  get taskId(): string;
114
144
  get signal(): AbortSignal;
@@ -127,12 +157,26 @@ export declare class TaskRunner {
127
157
  * `runSingleTask` path on its own.
128
158
  */
129
159
  run(): Promise<RunEnd>;
160
+ /**
161
+ * Store the task's repo-health baseline, ONCE. A re-entry — a resume, an
162
+ * autofix re-run — must keep the original: the baseline answers "what was
163
+ * already broken when this task started", and re-taking it on a tree the task
164
+ * has since edited answers a different question with the same name.
165
+ */
166
+ private _writeHealthBaseline;
130
167
  private _run;
131
168
  /** Stop the phase widget — clearing both the terminal and remote surfaces —
132
169
  * exactly once. Nulling the disposer makes repeat calls no-ops, so the
133
170
  * failure flash that handleFailure sets after the catch isn't wiped by the
134
171
  * finally block's call. */
135
172
  private _disposeWidget;
173
+ private _writeTimings;
174
+ /**
175
+ * Append this delivery to the `## handoff` ledger. The attempt number is the
176
+ * count of deliveries already recorded rather than anything this process
177
+ * carries: a re-entry is a FRESH runner and knows nothing of the one before it.
178
+ */
179
+ private _recordHandoff;
136
180
  private _deliverSpec;
137
181
  /**
138
182
  * The spec as the implementer should receive it (Layer B). Layer A strips phantom
@@ -145,7 +189,7 @@ export declare class TaskRunner {
145
189
  */
146
190
  private _specForDelivery;
147
191
  }
148
- export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId' | 'seams' | 'onStart' | 'planContext' | 'fixInstruction'> {
192
+ export interface RunSingleTaskOptions extends Pick<TaskRunnerOptions, 'resumeId' | 'seams' | 'onStart' | 'planContext' | 'planKey' | 'fixContext' | 'healthBaseline'> {
149
193
  /** Await the session going idle after the spec is delivered, so the caller
150
194
  * blocks until the agent has implemented it. Default false. */
151
195
  waitForImplementation?: boolean;