@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
@@ -42,42 +42,44 @@ const MAX_NOTES = 40;
42
42
  /** A single fact is one line; anything longer is prose, not a fact. */
43
43
  const MAX_NOTE_LENGTH = 240;
44
44
  /**
45
- * Field separator between a fact and its origin in the stored file. A tab never
46
- * occurs in a one-line fact (facts are prose), so it round-trips cleanly and any
47
- * stray tab in an emitted fact is normalised to a space before storage.
48
- */
49
- const ORIGIN_SEP = '\t';
50
- /**
51
- * Parse the stored file into fact+origin records. A line with no separator parses
52
- * with an empty origin rather than being dropped, so a cache written before
53
- * provenance existed still reads — and `buildEnvNotesBlock` renders those as
54
- * "origin unrecorded" instead of silently claiming an author.
45
+ * Field separator between a note's fields in the stored file. A tab never occurs
46
+ * in a one-line fact (facts are prose), so it round-trips cleanly and any stray
47
+ * tab in an emitted fact is normalised to a space before storage.
55
48
  */
49
+ const FIELD_SEP = '\t';
50
+ /** Stored line: `fact TAB origin TAB runId TAB subject TAB resolvedBy`, where every
51
+ * field after the fact may be absent — a one- or two-field line is what earlier
52
+ * versions wrote, and it still reads. */
56
53
  export function parseEnvNotes(raw) {
57
54
  const out = [];
58
55
  for (const line of raw.split('\n')) {
59
56
  const t = line.trim();
60
57
  if (t.length === 0)
61
58
  continue;
62
- const i = t.indexOf(ORIGIN_SEP);
63
- if (i === -1)
64
- out.push({ fact: t, origin: '' });
65
- else
66
- out.push({ fact: t.slice(0, i).trim(), origin: t.slice(i + 1).trim() });
59
+ const [fact, origin, runId, subject, resolvedBy] = t.split(FIELD_SEP).map(f => f.trim());
60
+ out.push({
61
+ fact,
62
+ origin: origin ?? '',
63
+ runId: runId ?? '',
64
+ // A legacy line carries no subject, so it gets the one the host would
65
+ // derive for it today — which is what makes the old file dedupe too.
66
+ subject: subject || deriveSubject(fact),
67
+ ...(resolvedBy ? { resolvedBy } : {})
68
+ });
67
69
  }
68
70
  return out;
69
71
  }
70
72
  function serializeNote(n) {
71
- return n.origin ? `${n.fact}${ORIGIN_SEP}${n.origin}` : n.fact;
73
+ return [n.fact, n.origin, n.runId, n.subject, n.resolvedBy ?? ''].join(FIELD_SEP).trimEnd();
72
74
  }
73
- /** Keyed on the fact alone (case-insensitive): a fact already present keeps its
74
- * ORIGINAL origin — provenance traces to who first established it. */
75
+ /** Keyed on the SUBJECT (case-insensitive), latest statement wins. */
75
76
  const ledger = makeLedger({
76
77
  file: ENV_NOTES_FILE,
77
78
  max: MAX_NOTES,
78
- key: n => n.fact.toLowerCase(),
79
+ key: n => n.subject.toLowerCase(),
79
80
  serialize: serializeNote,
80
- parse: parseEnvNotes
81
+ parse: parseEnvNotes,
82
+ conflict: 'replace'
81
83
  });
82
84
  export function envNotesFile(cwd) {
83
85
  return ledger.path(cwd);
@@ -86,24 +88,91 @@ export function envNotesFile(cwd) {
86
88
  export async function readEnvNotes(cwd) {
87
89
  return ledger.readRaw(cwd);
88
90
  }
91
+ /** Leading words a subject may be built from: the generic ones say nothing about
92
+ * which fact this is. */
93
+ const SUBJECT_STOPWORDS = new Set([
94
+ 'the',
95
+ 'this',
96
+ 'that',
97
+ 'these',
98
+ 'those',
99
+ 'and',
100
+ 'but',
101
+ 'for',
102
+ 'from',
103
+ 'with',
104
+ 'current',
105
+ 'still',
106
+ 'now',
107
+ 'not',
108
+ 'are',
109
+ 'was',
110
+ 'were'
111
+ ]);
112
+ function subjectWords(s) {
113
+ return s
114
+ .toLowerCase()
115
+ .split(/[^a-z0-9]+/)
116
+ .filter(t => t.length > 2 && !SUBJECT_STOPWORDS.has(t));
117
+ }
118
+ /**
119
+ * The subject of a fact the child did not name one for.
120
+ *
121
+ * A fact identifies what it is ABOUT by naming it first, and three rungs cover
122
+ * what an environment fact names: the SERVICE it reached (a URL authority), the
123
+ * COMMAND it ran (the first code span, by its basename so a tool named by path is
124
+ * the same tool), or — for the rest — its leading words.
125
+ *
126
+ * Measured against the 40-note cache a real 21-task run left behind, where 34
127
+ * slots restate two facts: it resolves them to nine subjects, so the twelfth
128
+ * re-measurement of one database replaces the eleventh instead of joining it.
129
+ */
130
+ export function deriveSubject(fact) {
131
+ const url = /\b[a-z][a-z0-9+.-]*:\/\/(?:[^\s/@`]*@)?([^\s/`,;)]+)/i.exec(fact);
132
+ if (url)
133
+ return url[1].toLowerCase();
134
+ const code = /`([^`]+)`/.exec(fact);
135
+ if (code) {
136
+ // A leading `VAR=value` is the environment of the command, not the command.
137
+ const word = code[1]
138
+ .trim()
139
+ .split(/\s+/)
140
+ .find(w => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
141
+ if (word !== undefined && word.length > 0) {
142
+ const bare = /^\.{0,2}\//.test(word) ? (word.split('/').pop() ?? word) : word;
143
+ if (bare.length > 0)
144
+ return bare.toLowerCase();
145
+ }
146
+ }
147
+ return subjectWords(fact).slice(0, 2).join(' ');
148
+ }
89
149
  /**
90
- * Pull `ENV-NOTE: <fact>` lines out of a child's answer text: trimmed,
91
- * deduplicated case-insensitively, and dropped when empty or over
92
- * MAX_NOTE_LENGTH. Run: a `VERDICT:` line and even a near-miss `ENV-NOTES:` line
150
+ * Pull the facts out of a child's answer text. Three forms are accepted:
151
+ * `ENV-NOTE[<subject>]: <fact>` (what the instruction asks for), the bare
152
+ * `ENV-NOTE: <fact>` whose subject the host derives, and
153
+ * `ENV-NOTE-RESOLVED[<subject>]: <what was observed>`, which retracts a recorded
154
+ * claim. Facts are trimmed, deduped case-insensitively, and dropped when empty or
155
+ * over MAX_NOTE_LENGTH. A `VERDICT:` line and even a near-miss `ENV-NOTES:` line
93
156
  * match nothing, so a verdict cannot leak into the fact cache.
94
157
  */
95
158
  export function extractEnvNotes(text) {
96
159
  const notes = [];
97
160
  const seen = new Set();
98
- for (const m of text.matchAll(/^[ \t]*ENV-NOTE:[ \t]*(.+)$/gm)) {
99
- const note = m[1].trim();
100
- if (note.length === 0 || note.length > MAX_NOTE_LENGTH)
161
+ const line = /^[ \t]*ENV-NOTE(-RESOLVED)?(?:\[([^\]\n]*)\])?:[ \t]*(.+)$/gm;
162
+ for (const m of text.matchAll(line)) {
163
+ const fact = m[3].trim();
164
+ if (fact.length === 0 || fact.length > MAX_NOTE_LENGTH)
101
165
  continue;
102
- const key = note.toLowerCase();
166
+ const key = fact.toLowerCase();
103
167
  if (seen.has(key))
104
168
  continue;
105
169
  seen.add(key);
106
- notes.push(note);
170
+ const named = (m[2] ?? '').trim();
171
+ notes.push({
172
+ subject: named.length > 0 ? named : deriveSubject(fact),
173
+ fact,
174
+ ...(m[1] ? { resolved: true } : {})
175
+ });
107
176
  }
108
177
  return notes;
109
178
  }
@@ -140,50 +209,77 @@ export function isExcuseNote(fact) {
140
209
  return EXCUSE_PATTERNS.some(re => re.test(fact));
141
210
  }
142
211
  /**
143
- * Append newly discovered facts to the cache, deduplicated against what is
144
- * already there (case-insensitive fact match), keeping the newest MAX_NOTES.
145
- * Each new fact is stamped with the `origin` task that recorded it; a fact already
146
- * present keeps its ORIGINAL origin, so provenance traces to whoever first
147
- * established it. Confirmed by re-appending the same fact under a different task:
148
- * one entry survives, still carrying the first writer.
212
+ * Store what a child shared, keyed by SUBJECT: a fresh statement about a subject
213
+ * REPLACES the one the cache held, so the newest measurement is the one later
214
+ * children read and a stale fact cannot outlive its correction. Each note is
215
+ * stamped with the `origin` task and the `runId` that recorded it — the run is
216
+ * what the prompt block scopes on, the task is the provenance a reader is told to
217
+ * re-validate against.
218
+ *
219
+ * A retraction stores the observation that closed the subject and marks it
220
+ * RESOLVED, so it stays as an audit line without being carried into another
221
+ * child's prompt.
149
222
  *
150
223
  * Tabs in an emitted fact are normalised to spaces before storage, which is what
151
224
  * keeps the separator unambiguous. The cap holds too — sixty further facts leave
152
225
  * MAX_NOTES stored. Failures are swallowed: the cache is a sharpener, never a
153
226
  * blocker.
154
227
  */
155
- export async function appendEnvNotes(cwd, notes, origin = '') {
228
+ export async function appendEnvNotes(cwd, notes, origin = '', runId = '') {
156
229
  const fresh = [];
157
230
  for (const note of notes) {
158
- const fact = note.trim().replace(/\t/g, ' ');
159
- if (fact.length === 0)
231
+ const fact = note.fact.trim().replace(/\t/g, ' ');
232
+ const subject = note.subject.trim().replace(/\t/g, ' ');
233
+ if (fact.length === 0 || subject.length === 0)
160
234
  continue;
161
- fresh.push({ fact, origin: origin.trim() });
235
+ fresh.push({
236
+ fact,
237
+ origin: origin.trim(),
238
+ runId: runId.trim(),
239
+ subject,
240
+ // The field is the resolved FLAG as well as its provenance, so an
241
+ // unattributed retraction still needs a value to survive the round-trip.
242
+ ...(note.resolved ? { resolvedBy: origin.trim() || 'unrecorded' } : {})
243
+ });
162
244
  }
163
245
  await ledger.append(cwd, fresh);
164
246
  }
247
+ function noteLine(n) {
248
+ const origin = n.origin ? ` — recorded by ${n.origin}` : ' — origin unrecorded';
249
+ const flag = isExcuseNote(n.fact) ?
250
+ ' [EXCUSE-CLASS — re-validate in the CURRENT tree before citing this to wave off any failure]'
251
+ : '';
252
+ return `- ${n.fact}${origin}${flag}`;
253
+ }
165
254
  /**
166
255
  * The prompt block a gate child receives when notes exist. Two things are
167
256
  * load-bearing: the no-waiver caveat (facts save re-discovery time but grant no
168
257
  * license to prepare/repair) and the trust discipline (a note is second-hand
169
258
  * until re-validated; an EXCUSE-CLASS note may not wave off a failure without a
170
259
  * live re-check; a grep of a generated artifact is not evidence of absence).
260
+ *
261
+ * THIS RUN leads. A fact measured by a sibling task minutes ago describes the tree
262
+ * the child is standing in; one from a run last week describes a tree that has
263
+ * moved since, and reading the two as one list is how a stale fact gets cited as
264
+ * current. Resolved subjects are dropped from both lists: a retracted claim is
265
+ * exactly the thing that must not be carried forward. `runId` '' (a cache written
266
+ * before runs were stamped) reads as an earlier run.
171
267
  */
172
- export function buildEnvNotesBlock(raw) {
173
- const notes = parseEnvNotes(raw);
268
+ export function buildEnvNotesBlock(raw, runId = '') {
269
+ const notes = parseEnvNotes(raw).filter(n => n.resolvedBy === undefined);
174
270
  if (notes.length === 0)
175
271
  return '';
176
- const lines = notes.map(n => {
177
- const origin = n.origin ? ` — recorded by ${n.origin}` : ' — origin unrecorded';
178
- const flag = isExcuseNote(n.fact) ?
179
- ' [EXCUSE-CLASS re-validate in the CURRENT tree before citing this to wave off any failure]'
180
- : '';
181
- return `- ${n.fact}${origin}${flag}`;
182
- });
272
+ const inRun = (n) => runId.length > 0 && n.runId === runId;
273
+ const mine = notes.filter(inRun);
274
+ const earlier = notes.filter(n => !inRun(n));
275
+ const earlierBlock = earlier.length === 0 ? []
276
+ : mine.length === 0 ? earlier.map(noteLine)
277
+ : ['', 'From EARLIER runs (an older tree — trust these less):', ...earlier.map(noteLine)];
183
278
  return [
184
279
  'KNOWN ENVIRONMENT FACTS — recorded by earlier verification passes in this run',
185
280
  '(second-hand, may be stale or WRONG):',
186
- ...lines,
281
+ ...mine.map(noteLine),
282
+ ...earlierBlock,
187
283
  '',
188
284
  'These facts only save you re-discovery time (where credentials/config live, which',
189
285
  'tools are installed, which services are reachable). They are NOT a license to',
@@ -216,8 +312,14 @@ export const ENV_NOTE_EMIT_INSTRUCTION = [
216
312
  'ENVIRONMENT FACTS — share what you discover: when you establish a durable fact about',
217
313
  'THIS MACHINE or the project environment (a service reachable/absent at an address, where',
218
314
  'credentials/config live, a tool or runtime present/missing and its version), emit a line',
219
- ' ENV-NOTE: <one-line fact>',
220
- 'anywhere in your answer, one per fact. A fact is something you OBSERVED to be true of the',
315
+ ' ENV-NOTE[<subject>]: <one-line fact>',
316
+ 'anywhere in your answer, one per fact. <subject> names WHAT the fact is about in a word or',
317
+ 'two — the service, the tool, the command (`postgres:5432`, `bun test`, `docker compose`).',
318
+ 'It is the cache key: a fact you emit under a subject REPLACES the one recorded for it, so',
319
+ 'a re-measurement corrects the record instead of stacking another copy beside it. If a fact',
320
+ 'listed above turns out NOT to be true any more, retract it with',
321
+ ' ENV-NOTE-RESOLVED[<subject>]: <what you observed instead>',
322
+ 'A fact is something you OBSERVED to be true of the',
221
323
  'machine or environment — never a task verdict, never spec content, never a judgment about',
222
324
  'the code. In particular do NOT record an absence you inferred from grepping a built or',
223
325
  'minified artifact (identifiers there are mangled — a zero-hit grep proves nothing), and',
@@ -23,6 +23,8 @@
23
23
  * `### npm:` then `### docs:` then `### url:` then `### service:`.
24
24
  */
25
25
  import { chooseEcosystem, declaredDepNames, defaultEcosystemIo } from '../workers/docs-ecosystems.js';
26
+ import { queryTokenKey } from '../workers/research-cache.js';
27
+ import { cachedWorkerCall, workerAnswer, workerUnavailable } from '../workers/shared.js';
26
28
  import { docsRaw } from '../workers/docs-core.js';
27
29
  import { fetchRaw } from '../workers/fetch-core.js';
28
30
  import { formatNpmVersionSection, npmVersionLookup } from '../workers/npm-version.js';
@@ -121,6 +123,21 @@ export async function buildExternalContext(source, deps, lookups, policy = {}) {
121
123
  return '';
122
124
  return `EXTERNAL CONTEXT\n${sections.join('\n\n')}\n\n`;
123
125
  }
126
+ /**
127
+ * One enrichment lookup, through the per-run research cache (workers/shared.ts).
128
+ *
129
+ * A null result is a lookup that produced no answer — a refused ecosystem, a dead
130
+ * registry, a page that would not fetch — and is never stored, so a transient
131
+ * failure costs one task's block rather than the whole run's. The cached value is
132
+ * the RESULT, not rendered text: the block assembly below formats it.
133
+ */
134
+ async function cached(cwd, tool, key, run) {
135
+ const { details } = await cachedWorkerCall(cwd, tool, key, async () => {
136
+ const value = await run();
137
+ return value === null ? workerUnavailable('', value, 'no-answer') : workerAnswer('', value);
138
+ });
139
+ return details;
140
+ }
124
141
  /**
125
142
  * The RESEARCH-phase binding: raw workers, no caps, live versions for every
126
143
  * named dep, truncated bodies, timed, and short-circuited when there is nothing
@@ -147,7 +164,7 @@ export async function gatherExternalContext(refined, deps) {
147
164
  const info = await npmVersionFn(pkg, { signal: deps.signal });
148
165
  return info ? { info, label: 'npm' } : null;
149
166
  };
150
- const versionLookup = async (pkg) => {
167
+ const liveVersion = async (pkg) => {
151
168
  const choice = chooseEcosystem({
152
169
  cwd: deps.cwd,
153
170
  declaresPackage: p => p.declaredRange(p.parentPackage(pkg), deps.cwd) !== null,
@@ -171,8 +188,11 @@ export async function gatherExternalContext(refined, deps) {
171
188
  const info = await choice.profile.latest(pkg, io);
172
189
  return info ? { info, label: choice.profile.registryLabel } : null;
173
190
  };
191
+ // A registry round-trip per declared dependency, per task. The answer does not
192
+ // move within a run, so the first task of the run pays for it.
193
+ const versionLookup = (pkg) => cached(deps.cwd, 'enrichment:version', pkg, () => liveVersion(pkg));
174
194
  return buildExternalContext(refined, deps, {
175
- docs: async (pkg) => {
195
+ docs: pkg => cached(deps.cwd, 'enrichment:docs', `${pkg}\u0000${queryTokenKey(docsQuery)}`, async () => {
176
196
  const r = await docsRawFn({
177
197
  pkg,
178
198
  query: docsQuery,
@@ -189,11 +209,11 @@ export async function gatherExternalContext(refined, deps) {
189
209
  .slice(0, RAW_BODY_LIMIT)
190
210
  : undefined
191
211
  };
192
- },
193
- url: async (url) => {
212
+ }),
213
+ url: url => cached(deps.cwd, 'enrichment:url', url, async () => {
194
214
  const r = await fetchRawFn({ url, signal: deps.signal });
195
215
  return { body: r.markdown.slice(0, RAW_BODY_LIMIT) };
196
- },
216
+ }),
197
217
  search: deps.searchFn
198
218
  }, {
199
219
  versionLookup,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * fix-context — what an AUTOFIX re-run is told about the failure it exists to fix.
3
+ *
4
+ * It used to be one prose string: the FAIL reason, plus a diagnosis paragraph if
5
+ * a judge happened to produce one. Everything the gate had already computed
6
+ * stayed behind — which CLASS of failure it was, how many attempts had already
7
+ * been spent on it, what the deterministic probes found, and whether the spec
8
+ * contradicts itself. So the re-run re-derived what the gate knew, and in 0034 it
9
+ * re-derived it wrong: told only "lint fails", it widened suppressions until the
10
+ * check went quiet.
11
+ *
12
+ * A value, then, and one renderer. The banner keeps its RE-ATTEMPT opening — it
13
+ * is the sentence the implementer reads first and the one thing about the old
14
+ * string worth keeping.
15
+ */
16
+ import type { Disposition, SpecContradiction } from './gate-resolution.js';
17
+ import type { ProbeFindings, VerifyOutcome } from './verify-work.js';
18
+ export interface FixContext {
19
+ outcome: VerifyOutcome;
20
+ disposition: Disposition;
21
+ /** The deterministic findings the verify pass computed for this verdict. */
22
+ probes: ProbeFindings;
23
+ /** Which unattended re-run this is, 1-based. */
24
+ attempt: number;
25
+ contradiction?: SpecContradiction | null;
26
+ /** The resolution judge's located cause, when it found one beyond the FAIL text. */
27
+ diagnosis?: string;
28
+ /** What the user typed instead of picking a card. */
29
+ guidance?: string;
30
+ }
31
+ /**
32
+ * The RE-ATTEMPT banner prepended to the delivered spec.
33
+ *
34
+ * The fail CLASS leads because it decides what a fix may even look like: a
35
+ * `repo-health` failure is about the project's own statics, a `model-verdict` one
36
+ * is about behavior the spec demanded, and confusing the two is how a re-run
37
+ * "fixes" a verdict by silencing a linter.
38
+ */
39
+ export declare function formatFixBanner(ctx: FixContext): string;
@@ -0,0 +1,58 @@
1
+ import { VERIFY_FAIL_PREFIX } from './verify-work.js';
2
+ const PROBE_LABELS = {
3
+ repoHealth: 'inherited repo health (NOT yours to fix)',
4
+ substitution: 'tests this task itself wrote',
5
+ prohibition: 'spec-forbidden paths this task modified',
6
+ crossTaskDeletion: "another task's deliverables this task deletes",
7
+ probeGaming: 'lines written to quiet a check',
8
+ skipEscape: 'VERIFY commands that skip themselves',
9
+ foreignPath: 'absolute paths that resolve nowhere here',
10
+ scriptEscape: 'check scripts that cannot fail',
11
+ runnerGlob: 'test runners claiming the same files',
12
+ testAssembly: 'tests that rebuild production wiring',
13
+ suppressionWidening: 'suppressions this task added'
14
+ };
15
+ function probeBlock(probes) {
16
+ const out = [];
17
+ for (const [key, findings] of Object.entries(probes)) {
18
+ if (!findings || findings.length === 0)
19
+ continue;
20
+ out.push(`- ${PROBE_LABELS[key] ?? key}:`);
21
+ for (const f of findings)
22
+ out.push(` ${f}`);
23
+ }
24
+ return out.length === 0 ? [] : ['', 'WHAT THE DETERMINISTIC PROBES ALREADY FOUND:', ...out];
25
+ }
26
+ /**
27
+ * The RE-ATTEMPT banner prepended to the delivered spec.
28
+ *
29
+ * The fail CLASS leads because it decides what a fix may even look like: a
30
+ * `repo-health` failure is about the project's own statics, a `model-verdict` one
31
+ * is about behavior the spec demanded, and confusing the two is how a re-run
32
+ * "fixes" a verdict by silencing a linter.
33
+ */
34
+ export function formatFixBanner(ctx) {
35
+ const failClass = ctx.outcome.failClass;
36
+ const lines = [
37
+ 'RE-ATTEMPT — your previous implementation of this task FAILED verification.',
38
+ "Fix the cause below, then make the spec's VERIFY block pass. Do NOT start over;",
39
+ 'change only what is needed to resolve the failure.',
40
+ '',
41
+ `ATTEMPT ${ctx.attempt} of this task's fix budget.`
42
+ ];
43
+ if (failClass) {
44
+ lines.push(`FAILURE CLASS: ${failClass} — ${VERIFY_FAIL_PREFIX[failClass]}. A fix that does not`, 'move THIS class has not fixed anything; suppressing, disabling, deleting or', 'skipping the check that reported it is a defect, not a fix.');
45
+ }
46
+ lines.push('', `VERIFICATION FAILURE:\n${(ctx.outcome.reason ?? '').trim()}`);
47
+ if (ctx.diagnosis && ctx.diagnosis.trim().length > 0) {
48
+ lines.push('', `DIAGNOSIS (a read-only investigation of this failure found):\n${ctx.diagnosis.trim()}`);
49
+ }
50
+ lines.push(...probeBlock(ctx.probes));
51
+ if (ctx.contradiction) {
52
+ lines.push('', `SPEC CONTRADICTION: \`${ctx.contradiction.frozenPath}\` is frozen by this task's own`, `spec, and ${ctx.contradiction.criterion || 'the failing check'} cannot be satisfied`, 'without editing it. Do NOT edit the frozen path. Report the contradiction in your', 'summary and fix whatever else you can reach.');
53
+ }
54
+ if (ctx.guidance && ctx.guidance.trim().length > 0) {
55
+ lines.push('', `User guidance: ${ctx.guidance.trim()}`);
56
+ }
57
+ return lines.join('\n');
58
+ }
@@ -1,10 +1,13 @@
1
1
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import type { GateDeps } from './task-gates.js';
3
+ import { type HealthBaseline } from './health-baseline.js';
3
4
  import { type VerifyProbes } from './verify-work.js';
5
+ import type { CommandRunner } from './command-run.js';
4
6
  import { type FinalFixResult } from './final-gate-fix.js';
5
7
  import { type AddedLine } from './probe-gaming.js';
6
8
  import { type ChangedFile } from './substitution-probe.js';
7
9
  import { type TreeChangeSummary, type IgnoredSnapshot } from './write-guard.js';
10
+ import { type DiffLine, type SuppressionHit } from './suppression-probe.js';
8
11
  /** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
9
12
  * command so this module stays free of the orchestrators (avoids an import cycle). */
10
13
  export type RunTaskFn = GateDeps['runTask'];
@@ -152,6 +155,25 @@ export declare function collectTestAssemblyFindings(cwd: string, changed: Change
152
155
  * would otherwise be the same four-line try/catch at four gate sites.
153
156
  */
154
157
  export declare function readSpecForVerification(cwd: string, taskId: string): Promise<string | null>;
158
+ /**
159
+ * The task's rendered Q&A, which is what a constraint's `[from: Q<n>]` tag
160
+ * resolves against (see qa-transcript's `qaKindsFromRecord`). Null when the task
161
+ * asked nothing, or the file cannot be read — both mean every tagged constraint
162
+ * reads as `derived`, and `constraint-policy.ts` says what that is worth.
163
+ */
164
+ export declare function readQaRecordForVerification(cwd: string, taskId: string): Promise<string | null>;
165
+ /**
166
+ * The health baseline the verify gate's differential needs: the one stored on the
167
+ * task file, or — for a task file that has none — one established lazily from a
168
+ * DETACHED WORKTREE at HEAD and written back, so the next resolution round does
169
+ * not pay for it again.
170
+ *
171
+ * Everything here is best-effort. A baseline that cannot be established is null,
172
+ * and `classifyHealthDelta` already states what an absent baseline means; failing
173
+ * the gate because the differential could not run would be the harness blaming
174
+ * the work for its own gap.
175
+ */
176
+ export declare function healthBaselineFor(cwd: string, taskId: string, signal: AbortSignal): Promise<HealthBaseline | null>;
155
177
  /**
156
178
  * Bind the deterministic verify probes — THE one place the collectors above meet
157
179
  * the probe table in verify-work.ts. One entry per `BoundProbeKey`; the table row
@@ -169,8 +191,35 @@ export declare function buildVerifyProbes(params: {
169
191
  signal?: AbortSignal;
170
192
  taskId: string;
171
193
  spec: string | null;
194
+ /** The task's `## grill Q&A`, so a prohibition carries the WEIGHT of the
195
+ * constraint that states it rather than being unwaivable by default. */
196
+ qaRecord?: string | null;
172
197
  log?: (msg: string) => void;
198
+ /** Names the gate-evidence command currently running, for the caller's live
199
+ * status line — the same channel `runRepoHealthCheck` takes. */
200
+ onCommand?: (cmd: string) => void;
201
+ /** The spawner for the gate-evidence commands, as `runRepoHealthCheck` takes
202
+ * one: injected so a session's command count is assertable without a shell. */
203
+ run?: CommandRunner;
173
204
  }): VerifyProbes;
205
+ /**
206
+ * The task's net-new suppressions: the files its work touched (the same tree
207
+ * shape the write-guard reads), diffed against the checkpoint HEAD, counted per
208
+ * pattern.
209
+ *
210
+ * The pattern set follows the repo's detected ecosystems, so a Go project is not
211
+ * scanned for `@ts-expect-error` and a TypeScript one is not scanned for
212
+ * `//nolint`; `suppressionPatterns` in the config adds project-specific rows.
213
+ * Failures degrade to no findings — a sharpener, never a blocker.
214
+ */
215
+ export declare function collectSuppressionHits(cwd: string, signal?: AbortSignal): Promise<SuppressionHit[]>;
216
+ /**
217
+ * A unified diff's content lines, tagged with their side and their file. The `+++`
218
+ * header names the file and the `---`/`+++` headers themselves are excluded, which
219
+ * is the one thing a naive `startsWith('+')` scan gets wrong — it counts every
220
+ * changed file's own header as an added line.
221
+ */
222
+ export declare function parseDiffLines(diff: string): DiffLine[];
174
223
  /**
175
224
  * Build the gate deps for one command run. `runTask` is the orchestrator's
176
225
  * implementation re-runner, injected by the caller. The returned object also drives