@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
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { docsFocused } from '../workers/docs-core.js';
7
- import { detectEcosystems } from '../workers/docs-ecosystems.js';
8
7
  import { fetchFocused } from '../workers/fetch-core.js';
9
8
  import { runWorker } from '../workers/pi-worker-core.js';
10
9
  import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
@@ -14,14 +13,14 @@ import { snapshotLeverEnv, workerProgressCeilingMs, projectDocsBudget, projectDo
14
13
  import { isIntegrationUnknown } from './unknown-routing.js';
15
14
  import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
16
15
  import { demoteUnsourcedAttributions } from './context-attribution.js';
17
- import { getFileInventory } from './file-inventory.js';
18
- import { buildOrientation, orientationTier } from './orientation.js';
16
+ import { ORIENTATION_TIERS, orientationTier } from './orientation.js';
19
17
  import { getConfig } from '../config/config.js';
20
- import { readFile } from 'node:fs/promises';
21
- import { resolve } from 'node:path';
22
18
  import { buildExternalContext, gatherExternalContext } from './external-context.js';
19
+ import { currentRunContext } from './run-context.js';
20
+ import { readableMentions } from './mentions.js';
23
21
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS } from './prompts.js';
24
- import { appendGateRecord, readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
22
+ import { appendGateRecord, readSection, readTaskFile, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
23
+ import { extractSection } from './task-parsers.js';
25
24
  import { applyDeprecations } from './deprecated-constraint.js';
26
25
  import { applyRefutations } from './refuted-constraint.js';
27
26
  import { spawnSync } from 'node:child_process';
@@ -37,16 +36,24 @@ import { settleQuestion } from './question-dialog.js';
37
36
  import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js';
38
37
  import { GREP_THEATER_RETRY_HINT } from './verify-quality.js';
39
38
  import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
40
- import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
39
+ import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTask, appendOwnedConstraints } from './requirements.js';
41
40
  import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
42
41
  import { trackedSourceOracle } from './owned-freeze-conflict.js';
43
42
  import { groupArgsForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED, ChildFailureError, isFatalChildCause } from './child-runner.js';
44
- import { runResearchWorker, researchWorkerCacheHeading } from './research-worker.js';
43
+ import { runResearchStage, runResearchWorker, researchWorkerCacheHeading } from './research-worker.js';
45
44
  import { SessionUI, notifyRun } from '../remote/bridge.js';
46
45
  import { isYoloMode, yoloPickAutoAnswer } from './yolo.js';
47
46
  import { QaTranscript, GRILL_QA_POLICY } from './qa-transcript.js';
48
47
  // ─── Re-export constants from their home modules ────────────────────────────
49
48
  export { MAX_GRILL_QUESTIONS };
49
+ /**
50
+ * This run's context, or a throwaway one for a caller outside a run bracket (a
51
+ * direct phase call in a test). Every per-project fact a phase asks for goes
52
+ * through it, so a /task-auto run derives each one once instead of per task.
53
+ */
54
+ function runContextFor(deps) {
55
+ return deps.runContext ?? currentRunContext(deps.cwd);
56
+ }
50
57
  // ─── Tooling helpers ─────────────────────────────────────────────────────────
51
58
  /** Extract the TOOLING section commands from a research output string. */
52
59
  export function extractToolingCommands(research) {
@@ -116,23 +123,16 @@ const REFINE_PRESERVE_DIRECTIVE = 'EXISTING FILES ON DISK — AUTHORITATIVE (ove
116
123
  export async function refineExistingFilesBlock(deps) {
117
124
  if (!getConfig().orientation)
118
125
  return '';
119
- const inventoryRaw = await getFileInventory(deps.cwd, deps.signal).catch(() => '');
120
- if (inventoryRaw.length === 0)
121
- return '';
122
- const paths = inventoryRaw
123
- .split('\n')
126
+ const runContext = runContextFor(deps);
127
+ const paths = (await runContext.inventoryPaths())
124
128
  .map(l => l.trim())
125
- .filter(l => l.length > 0 && (orientationTier(l) === 0 || orientationTier(l) === 1));
129
+ .filter(l => {
130
+ const tier = orientationTier(l);
131
+ return tier !== null && tier <= ORIENTATION_TIERS.config;
132
+ });
126
133
  if (paths.length === 0)
127
134
  return '';
128
- const { block } = await buildOrientation(paths, async (p) => {
129
- try {
130
- return await readFile(resolve(deps.cwd, p), 'utf8');
131
- }
132
- catch {
133
- return null;
134
- }
135
- });
135
+ const { block } = await runContext.orientationOf(paths);
136
136
  return block.trim().length === 0 ? '' : `${REFINE_PRESERVE_DIRECTIVE}\n\n${block.trim()}`;
137
137
  }
138
138
  /**
@@ -163,9 +163,8 @@ export async function phaseCarriedBlocks(deps) {
163
163
  return [contracts, requirements, owned].filter(b => b.length > 0).join('\n');
164
164
  }
165
165
  /**
166
- * The owned (task-mapped) requirements for THIS task: matched by
167
- * the plan title the coverage map keyed them to, which is the task's stored
168
- * `raw prompt` section verbatim. Empty outside /task-auto runs, for spliced
166
+ * The owned (task-mapped) requirements for THIS task, joined on the plan key the
167
+ * coverage map assigned them to. Empty outside /task-auto runs, for spliced
169
168
  * repair tasks, and when the plan recorded no mapping — all of which make this a
170
169
  * no-op. This is the BELT (a prompt block, into refine and compose);
171
170
  * `appendOwnedConstraints` on the final spec is the BRACES. The belt is a request
@@ -173,20 +172,27 @@ export async function phaseCarriedBlocks(deps) {
173
172
  */
174
173
  async function ownedForThisTask(deps) {
175
174
  try {
176
- const title = (await readSection(deps.cwd, deps.taskId, 'raw prompt')) ?? '';
177
- return ownedForTitle(await readOwnedRequirements(deps.cwd), title.trim());
175
+ return ownedForTask(await readOwnedRequirements(deps.cwd), await planJoin(deps));
178
176
  }
179
177
  catch {
180
178
  return [];
181
179
  }
182
180
  }
183
- /** This task's plan title — the owned ledger's join key. */
184
- async function planTitle(deps) {
181
+ /**
182
+ * How this task addresses the owned ledger: its plan key, and its plan title —
183
+ * the stored `raw prompt` section verbatim — for a ledger written before keys.
184
+ */
185
+ async function planJoin(deps) {
185
186
  try {
186
- return ((await readSection(deps.cwd, deps.taskId, 'raw prompt')) ?? '').trim();
187
+ const { frontMatter, body } = await readTaskFile(deps.cwd, deps.taskId);
188
+ const title = extractSection(body, 'raw prompt') ?? '';
189
+ return {
190
+ ...(frontMatter.plan_key !== undefined && { key: frontMatter.plan_key }),
191
+ title: title.trim()
192
+ };
187
193
  }
188
194
  catch {
189
- return '';
195
+ return { title: '' };
190
196
  }
191
197
  }
192
198
  /** The `isSource` oracle production uses: git tracks the path in this tree. */
@@ -214,12 +220,12 @@ export async function resolveOwnedFreezeForThisTask(deps, spec) {
214
220
  const ledger = await readOwnedRequirements(deps.cwd).catch(() => []);
215
221
  if (ledger.length === 0)
216
222
  return spec;
217
- const title = await planTitle(deps);
218
- if (title.length === 0)
223
+ const join = await planJoin(deps);
224
+ if (join.title.length === 0)
219
225
  return spec;
220
226
  const res = detachUnsatisfiableRequirements({
221
227
  spec,
222
- title,
228
+ ...join,
223
229
  ledger,
224
230
  isSource: repoSourceOracle(deps.cwd)
225
231
  });
@@ -247,10 +253,10 @@ export async function claimOwnedFreezeForThisTask(deps, refined) {
247
253
  const ledger = await readOwnedRequirements(deps.cwd).catch(() => []);
248
254
  if (unclaimedPendingRequirements(ledger).length === 0)
249
255
  return;
250
- const title = await planTitle(deps);
251
- if (title.length === 0)
256
+ const join = await planJoin(deps);
257
+ if (join.title.length === 0)
252
258
  return;
253
- const res = claimPendingRequirements({ intent: refined, title, ledger });
259
+ const res = claimPendingRequirements({ intent: refined, ...join, ledger });
254
260
  if (res.actions.length === 0)
255
261
  return;
256
262
  deps.logDebug?.(formatReassignActions(res.actions));
@@ -298,29 +304,43 @@ export async function phaseVerifyTooling(deps, research) {
298
304
  if (!commands || commands.length === 0) {
299
305
  return replaceToolingWithVerified(research, []);
300
306
  }
301
- const toolingList = commands.join('\n');
302
- let verifyOutput;
303
- try {
304
- verifyOutput = await runPhaseChild(deps, 'verify-tooling', 'read,bash', VERIFY_TOOLING_PROMPT(toolingList));
305
- }
306
- catch (e) {
307
- if (isFatalChildCause(e))
308
- throw e;
309
- // The fallback ships the tooling list UNVERIFIED, which is the right
310
- // degrade for a child that merely failed. A hung command is different: it
311
- // cost the ceiling on every strike and says the SPEC named something
312
- // unbounded, so it is the one cause worth a trail line rather than silence.
313
- if (e instanceof ChildFailureError && e.failure.kind === 'command-timeout') {
314
- deps.logDebug?.(`verify-tooling: ${e.message} shipping the list unverified`);
307
+ // UNVERIFIED is the degrade for a child that merely failed, and it is decided
308
+ // here rather than inside the run context so a failure is never cached as a
309
+ // verdict: the next task asks again.
310
+ let unverified = false;
311
+ let inconclusive = false;
312
+ const verified = await runContextFor(deps).verifiedToolingFor(commands, async (unknown) => {
313
+ let output;
314
+ try {
315
+ output = await runPhaseChild(deps, 'verify-tooling', 'read,bash', VERIFY_TOOLING_PROMPT(unknown.join('\n')));
316
+ }
317
+ catch (e) {
318
+ if (isFatalChildCause(e))
319
+ throw e;
320
+ // A hung command is the one cause worth a trail line: it cost the
321
+ // ceiling on every strike and says the SPEC named something unbounded.
322
+ if (e instanceof ChildFailureError && e.failure.kind === 'command-timeout') {
323
+ deps.logDebug?.(`verify-tooling: ${e.message} — shipping the list unverified`);
324
+ }
325
+ unverified = true;
326
+ return { verified: [], rejected: [] };
315
327
  }
328
+ const parsed = parseVerifyToolingOutput(output);
329
+ inconclusive = parsed.verified.length === 0 && parsed.rejected.length === 0;
330
+ return {
331
+ verified: parsed.verified.map(v => ({ cmd: v.cmd, class: v.class })),
332
+ rejected: parsed.rejected.map(r => r.cmd)
333
+ };
334
+ });
335
+ if (unverified)
316
336
  return replaceToolingWithVerified(research, commands);
317
- }
318
- const parsed = parseVerifyToolingOutput(verifyOutput);
319
- const verifiedSection = parsed.verified.length > 0 ? parsed.verified.join('\n')
320
- : parsed.rejected.length > 0 ? '(none verified)'
321
- : '(verification inconclusive)';
322
- await setTaskSection(deps.cwd, deps.taskId, 'verified tooling', verifiedSection);
323
- return replaceToolingWithVerified(research, parsed.verified);
337
+ const cmds = verified.map(v => v.cmd);
338
+ // Written per task for provenance even though the verdicts are the run's: the
339
+ // task file has to say what THIS task was told its tooling was.
340
+ await setTaskSection(deps.cwd, deps.taskId, 'verified tooling', cmds.length > 0 ? verified.map(v => `${v.cmd} ${v.class}`).join('\n')
341
+ : inconclusive ? '(verification inconclusive)'
342
+ : '(none verified)');
343
+ return replaceToolingWithVerified(research, cmds);
324
344
  }
325
345
  /**
326
346
  * The worker channels the APIS research worker is given.
@@ -389,22 +409,6 @@ export function scopedToolingGoal(refined) {
389
409
  const firstBullet = goal.search(/\n[ \t]*[-*]\s/);
390
410
  return firstBullet === -1 ? goal : goal.slice(0, firstBullet).trim();
391
411
  }
392
- /**
393
- * Dependency names declared by the project manifest, used by the CONTEXT post-check to
394
- * tell "this bullet is about an external library" from "this bullet is about our source".
395
- * A missing or malformed package.json yields none, which makes the post-check a no-op
396
- * rather than an error — a non-node project must still be able to run research.
397
- */
398
- async function manifestDependencyNames(cwd) {
399
- try {
400
- const raw = await readFile(resolve(cwd, 'package.json'), 'utf8');
401
- const pkg = JSON.parse(raw);
402
- return [...Object.keys(pkg.dependencies ?? {}), ...Object.keys(pkg.devDependencies ?? {})];
403
- }
404
- catch {
405
- return [];
406
- }
407
- }
408
412
  /**
409
413
  * Prepended to worker:apis's prompt on the ONE retry the zero-retrieval gate triggers. It
410
414
  * names the exact failure (a section written with no retrieval) so the correction is concrete,
@@ -436,42 +440,43 @@ const CONTEXT_SILENT_RETRY_PREAMBLE = 'STOP. Your previous attempt at this task
436
440
  + 'nothing else. Keep the same rules as before: state an external library/API behaviour as fact '
437
441
  + 'ONLY when quoting an EXTERNAL CONTEXT block; otherwise write it as an "unverified:" open '
438
442
  + 'question. One claim per bullet. Better to emit three sharp sourced bullets than to say nothing.';
439
- export async function phaseResearch(deps, refined) {
440
- const fileInventoryFn = deps.getFileInventory ?? getFileInventory;
443
+ export async function phaseResearch(deps, refined, rawPrompt = '') {
444
+ const runContext = runContextFor(deps);
441
445
  const runWorkerFn = deps.runWorker ?? ((_label, input) => runWorker(input));
442
446
  const externalContext = await gatherExternalContext(refined, deps);
443
- // Pre-compute the project file inventory once and hand it to every worker.
447
+ // The project file inventory, derived ONCE PER RUN and handed to every worker.
444
448
  // Workers can then jump straight to targeted read/grep on known paths
445
449
  // instead of each spawning its own discovery loop (find/ls). A '' result
446
450
  // (non-git repo, git missing, abort) silently falls back to the original
447
- // behavior.
448
- const inventoryRaw = await fileInventoryFn(deps.cwd, deps.signal).catch(() => '');
451
+ // behavior. The seam wins when a caller supplied one.
452
+ const inventoryRaw = deps.getFileInventory ?
453
+ await deps.getFileInventory(deps.cwd, deps.signal).catch(() => '')
454
+ : await runContext.inventory();
449
455
  const inventoryHeader = inventoryRaw.length > 0 ? `PROJECT FILE INVENTORY\n${inventoryRaw}\n\n` : '';
450
- // Pre-read the project's orientation core (manifest, config, domain types,
451
- // schema, entrypoints, API surface) ONCE and hand the full contents to the
452
- // READ-HEAVY workers in their header. The workers run as separate child
453
- // processes, so without this each one that explores re-reads the same hot
454
- // files cold. Bounded by a hard byte budget so it can't overflow on a large
455
- // repo; purely additive (nothing is blocked) so it can only remove a
456
- // redundant read, never hide a file.
456
+ // The spec document(s) this task's own prompt points at. A cited doc is the
457
+ // one file orientation cannot select by convention and the one every worker
458
+ // needs in the mx5 run it was read 50 times — so the mentions are threaded
459
+ // here and the `cited` rule (orientation.ts) puts it first in line for the
460
+ // byte budget.
461
+ const cited = await readableMentions(deps.cwd, rawPrompt);
462
+ // Pre-read the project's orientation core (the cited doc, manifest, config,
463
+ // project rules, domain types, schema, entrypoints, API surface) ONCE and hand
464
+ // the full contents to the EXPLORING workers in their header. The workers run
465
+ // as separate child processes, so without this each one that explores re-reads
466
+ // the same hot files cold. Bounded by a hard byte budget so it can't overflow
467
+ // on a large repo; purely additive (nothing is blocked) so it can only remove
468
+ // a redundant read, never hide a file.
457
469
  //
458
- // Applied to FILES and APIS only NOT CONTEXT/TOOLING, and the split is by
459
- // whether the worker reads at all. FILES and APIS explore by reading, so
460
- // pre-supplying the core replaces reads they would otherwise make. CONTEXT
461
- // works from the inventory and grep, and TOOLING is scoped to the GOAL prose
462
- // and single-read-guarded, so for those two the block is pure prefill with no
463
- // read to displace. Orientation only goes where reads actually happen.
464
- const orientationPaths = getConfig().orientation && inventoryRaw.length > 0 ?
465
- inventoryRaw.split('\n').filter(l => l.trim().length > 0)
466
- : [];
467
- const orientation = await buildOrientation(orientationPaths, async (path) => {
468
- try {
469
- return await readFile(resolve(deps.cwd, path), 'utf8');
470
- }
471
- catch {
472
- return null;
473
- }
474
- }).catch(() => ({ block: '', supplied: new Set() }));
470
+ // All but TOOLING, and the split is by whether the worker reads at all. FILES,
471
+ // APIS and CONTEXT all explore CONTEXT with `read,grep` so pre-supplying
472
+ // the core replaces reads they would otherwise make. TOOLING is scoped to the
473
+ // GOAL prose and single-read-guarded, so for it the block is pure prefill with
474
+ // no read to displace.
475
+ const orientation = getConfig().orientation && inventoryRaw.length > 0 ?
476
+ deps.getFileInventory ?
477
+ await runContext.orientationOf(inventoryRaw.split('\n').filter(l => l.trim().length > 0), cited)
478
+ : await runContext.orientation(cited)
479
+ : { block: '', supplied: new Set() };
475
480
  if (orientation.supplied.size > 0) {
476
481
  deps.logDebug?.(`orientation: pre-supplied ${orientation.supplied.size} core files`);
477
482
  }
@@ -479,7 +484,7 @@ export async function phaseResearch(deps, refined) {
479
484
  // Braces for the CONTEXT worker's LIVE-DATA RULE (the belt is the prompt itself).
480
485
  // Judged against the EXTERNAL CONTEXT this run actually gathered — the same string
481
486
  // the worker is handed below — and the manifest's dependency names.
482
- const manifestPackages = await manifestDependencyNames(deps.cwd);
487
+ const manifestPackages = runContext.manifestDeps();
483
488
  // The spec-cited-URL lever from ./spec-urls.ts is built and unit-tested but is
484
489
  // NOT WIRED HERE: pointing the worker at a page it should have read did not
485
490
  // change what it produced, so the block would be prefill on every APIS prompt
@@ -522,14 +527,11 @@ export async function phaseResearch(deps, refined) {
522
527
  }
523
528
  return r;
524
529
  });
525
- // Run the four workers ONE AT A TIME by default. Which order wins depends on
526
- // the backend: concurrent streams share one local GPU and slow each other
527
- // down, so the sum of four fast workers can beat the max of four slowed ones,
528
- // while a backend that genuinely serves parallel streams has no such tradeoff.
529
- // `parallelResearchWorkers` is the opt-in for those backends. The worker's
530
- // reasoning level (config/reasoning.ts `research` group) changes decode length
531
- // and so changes the answer too, which is why this is a config knob and not a
532
- // constant.
530
+ // The four workers and the ONE dependency between them: APIS is written
531
+ // against the FILES map, and everything else is independent. `runResearchStage`
532
+ // reads that off `after`, so FILES runs, then APIS, with CONTEXT and TOOLING
533
+ // alongside both and `researchConcurrency: 'serial'` still runs them in order
534
+ // for a single local GPU, where concurrent streams share one device.
533
535
  //
534
536
  // Result order (files, apis, context, tooling) is preserved for assembly.
535
537
  // Resolved once: `searchConfigured()` reads the environment, and the tools
@@ -545,14 +547,15 @@ export async function phaseResearch(deps, refined) {
545
547
  {
546
548
  section: 'APIS',
547
549
  label: 'worker:apis',
550
+ // The one declared edge: FILES' finished map rides along, so the worker
551
+ // doesn't re-derive where-things-live through project-docs queries the
552
+ // FILES worker just answered.
553
+ after: ['FILES'],
548
554
  // Read-heavy: gets the orientation core (see note above). Search/fetch
549
555
  // ride along only when a Brave key exists — see SEARCH_EXTENSION_PATH.
550
- // FILES' finished map rides along when available (serial default), so
551
- // the worker doesn't re-derive where-things-live through project-docs
552
- // queries the FILES worker just answered.
553
556
  prompt: prior => orientation.block
554
557
  + promptHeader
555
- + RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined, detectEcosystems(deps.cwd))
558
+ + RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined, runContext.ecosystems())
556
559
  + (searchConfigured() ? RESEARCH_SEARCH_HINT : '')
557
560
  // Empty unless PI_TASK_PROJECT_DOCS_BUDGET is set. The tool-side
558
561
  // half lives in pi-worker-docs.ts; a budget enforced without being
@@ -585,7 +588,9 @@ export async function phaseResearch(deps, refined) {
585
588
  {
586
589
  section: 'CONTEXT',
587
590
  label: 'worker:context',
588
- prompt: promptHeader + RESEARCH_CONTEXT_PROMPT(refined),
591
+ // Read-heavy too: it holds `read,grep` and reads to understand, so the
592
+ // core displaces reads here exactly as it does for FILES and APIS.
593
+ prompt: orientation.block + promptHeader + RESEARCH_CONTEXT_PROMPT(refined),
589
594
  // Context owns architectural understanding, not path discovery —
590
595
  // FILES handles that. Dropping `find`/`ls` keeps the worker from
591
596
  // spawning long enumeration loops whose output then inflates
@@ -632,15 +637,7 @@ export async function phaseResearch(deps, refined) {
632
637
  extensions: [SINGLE_READ_EXTENSION_PATH]
633
638
  }
634
639
  ];
635
- // Persisting a worker's section is a read-modify-write of the shared task
636
- // file, so writes are chained through one lock — a no-op in serial mode,
637
- // load-bearing in parallel mode where two workers can settle together.
638
- let persistChain = Promise.resolve();
639
- const persistSection = (heading, text) => {
640
- const next = persistChain.then(() => setTaskSection(deps.cwd, deps.taskId, heading, text));
641
- persistChain = next.catch(() => { });
642
- return next;
643
- };
640
+ const persistSection = (heading, text) => setTaskSection(deps.cwd, deps.taskId, heading, text);
644
641
  /**
645
642
  * This phase's binding of the research-worker driver: everything about THIS
646
643
  * RUN, gathered once, so each of the four rows is plain data.
@@ -665,32 +662,7 @@ export async function phaseResearch(deps, refined) {
665
662
  persistSection,
666
663
  leverEnv
667
664
  }, prior);
668
- const sections = [];
669
- if (!getConfig().parallelResearchWorkers) {
670
- // Default: ONE AT A TIME (see the note above the worker specs) — a fatal
671
- // failure throws before later workers run, and each worker can see the
672
- // finished sections before it (APIS builds on the FILES map).
673
- for (const spec of workerSpecs) {
674
- sections.push(await drive(spec, sections));
675
- }
676
- }
677
- else {
678
- // Opt-in for parallel-capable backends. allSettled (not all): every
679
- // worker runs to its own outcome first, so one fatal failure cannot
680
- // orphan the others' output — their sections persist for the resume
681
- // before the failure is thrown. Assembly order stays the spec order
682
- // regardless of completion order. No prior sections exist here, so prompt
683
- // builders get none — APIS runs without the FILES map it gets when serial.
684
- const settled = await Promise.allSettled(workerSpecs.map(spec => drive(spec, [])));
685
- for (const s of settled) {
686
- if (s.status === 'rejected')
687
- throw s.reason;
688
- }
689
- for (const s of settled) {
690
- if (s.status === 'fulfilled')
691
- sections.push(s.value);
692
- }
693
- }
665
+ const sections = await runResearchStage(workerSpecs, drive, getConfig().researchConcurrency);
694
666
  // All workers succeeded — the assembled output below becomes the canonical
695
667
  // 'research' section (written by the orchestrator). The per-worker caches
696
668
  // exist only to survive a mid-phase failure, so drop them now to avoid
@@ -1162,7 +1134,7 @@ export async function refinePhase(d, p) {
1162
1134
  */
1163
1135
  export async function researchPhase(d, p) {
1164
1136
  const tResearch = Date.now();
1165
- const rawResearch = await phaseResearch(d, p.refined);
1137
+ const rawResearch = await phaseResearch(d, p.refined, p.rawPrompt);
1166
1138
  d.recordSubStep?.('workers', Date.now() - tResearch);
1167
1139
  const tVerify = Date.now();
1168
1140
  const out = await phaseVerifyTooling(d, rawResearch);
@@ -21,12 +21,11 @@
21
21
  * receives beyond a bare /task is HANDOFF_DELIVERABLE_RULE, which rides on every
22
22
  * handoff, and the decisions block when anything was settled.
23
23
  */
24
- import * as path from 'node:path';
25
24
  import { prependHint, USER_CANCELLED } from './child-runner.js';
26
25
  import { PLAN_QUESTION_PROMPT, PLAN_ANSWER_PROMPT } from './plan-prompts.js';
27
26
  import { runPlanSession, ASK_TITLE } from './plan-session.js';
28
27
  import { allocatePlanId, buildPlanBody, buildHandoffPrompt, formatPlanDecisions } from './plan-io.js';
29
- import { writeTaskFile, readTaskFile, setTaskSection, readSection, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
28
+ import { writeTaskFile, readTaskFile, setTaskSection, readSection, updateTaskFrontMatter, taskFilePath } from './task-io.js';
30
29
  import { extractSection } from './task-parsers.js';
31
30
  import { collectTreeChanges } from './gate-deps.js';
32
31
  import { PLAN_TOOLS, newTreeChanges, isEmptyChange, formatReadOnlyViolation } from './plan-readonly.js';
@@ -38,7 +37,8 @@ import { SessionUI, registerBridgeCommand, notifyBoth, notifyRun, isRemoteOrigin
38
37
  import { withRun, announceTerminal } from './run-bracket.js';
39
38
  import { getConfig } from '../config/config.js';
40
39
  import { isYoloMode } from './yolo.js';
41
- import { gateDebugWriter } from './debug-log.js';
40
+ import { appendDebugLine, gateDebugWriter } from './debug-log.js';
41
+ import { runLogPath } from './state-dir.js';
42
42
  import { getParentContextWindow } from './context-usage.js';
43
43
  import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
44
44
  import * as fsp from 'node:fs/promises';
@@ -59,8 +59,7 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
59
59
  const childStatus = new ChildStatus({ parentContextWindow: getParentContextWindow(ctx) });
60
60
  const title = deriveTitle(task);
61
61
  const logDebug = gateDebugWriter((msg) => {
62
- const line = `${new Date().toISOString()} ${msg}\n`;
63
- void fsp.appendFile(path.join(tasksDir(cwd), `${planId}-debug.log`), line).catch(() => { });
62
+ appendDebugLine(runLogPath(cwd, `${planId}-debug.log`), msg);
64
63
  });
65
64
  const phaseDeps = {
66
65
  cwd,
@@ -225,7 +224,7 @@ export async function discardEmptyPlanFile(cwd, planId) {
225
224
  if (extractSection(body, 'notes') !== null)
226
225
  return;
227
226
  await fsp.rm(taskFilePath(cwd, planId), { force: true });
228
- await fsp.rm(path.join(tasksDir(cwd), `${planId}-debug.log`), { force: true });
227
+ await fsp.rm(runLogPath(cwd, `${planId}-debug.log`), { force: true });
229
228
  }
230
229
  catch {
231
230
  /* best-effort: an unreadable file is left exactly where it is */
@@ -48,6 +48,7 @@ export declare class CoverageLedger {
48
48
  private _round;
49
49
  private _cap;
50
50
  private _bonusUsed;
51
+ private _terminal;
51
52
  constructor(_best: ScoredPlan, _opts: CoverageLedgerOptions);
52
53
  /** The best-covered plan seen so far — the one that reprompts, and the one that ships. */
53
54
  best(): ScoredPlan;
@@ -27,6 +27,7 @@ export class CoverageLedger {
27
27
  _round = 0;
28
28
  _cap;
29
29
  _bonusUsed = false;
30
+ _terminal = false;
30
31
  constructor(_best, _opts) {
31
32
  this._best = _best;
32
33
  this._opts = _opts;
@@ -42,7 +43,7 @@ export class CoverageLedger {
42
43
  }
43
44
  /** May another reprompt round run? */
44
45
  mayRetry() {
45
- return this._round < this._cap;
46
+ return !this._terminal && this._round < this._cap;
46
47
  }
47
48
  /** Spend a round. Call once per reprompt, before the child runs. */
48
49
  startRound() {
@@ -65,6 +66,11 @@ export class CoverageLedger {
65
66
  */
66
67
  consider(cand) {
67
68
  const decision = decideAdoption(this._best.plan, cand.plan, this._opts.hasRequirements);
69
+ // A terminal verdict ends the loop HERE, before the bonus round can extend
70
+ // it: the bonus exists to buy one more draw, and the whole finding of a
71
+ // terminal verdict is that another draw answers the same question again.
72
+ if (decision.terminal === true)
73
+ this._terminal = true;
68
74
  if (!decision.adopt)
69
75
  return { adopted: false, decision, grantedBonusRound: false };
70
76
  const priorCovered = this._best.plan.covered.size;
@@ -73,6 +79,7 @@ export class CoverageLedger {
73
79
  // never come from different rounds.
74
80
  this._best = cand;
75
81
  const grant = !this._bonusUsed
82
+ && !this._terminal
76
83
  && this._round >= this._cap
77
84
  && this._opts.hasRequirements
78
85
  && cand.plan.covered.size > priorCovered
@@ -26,6 +26,7 @@
26
26
  * violated.
27
27
  */
28
28
  import type { ChangedFile } from './substitution-probe.js';
29
+ import { type ConstraintProvenance, type QaResolver } from './spec-model.js';
29
30
  /** One "do not modify X" constraint extracted from the spec text. */
30
31
  export interface Prohibition {
31
32
  /** The forbidden path exactly as the spec spells it (file or directory). */
@@ -33,6 +34,10 @@ export interface Prohibition {
33
34
  /** The full spec line carrying the prohibition, so the verify child judges
34
35
  * against the EXACT wording — including any exception clause it states. */
35
36
  constraint: string;
37
+ /** Where the constraint carrying this ban came from. An advisory one is
38
+ * reported and does not decide the verdict alone — 0043 failed on a ban a
39
+ * grill auto-answer invented. */
40
+ provenance: ConstraintProvenance;
36
41
  }
37
42
  /**
38
43
  * Does this line express a modification ban? Matches the active forms ("do not
@@ -47,7 +52,7 @@ export declare const PROHIBITION_RE: RegExp;
47
52
  * Prose-only prohibitions ("do not modify server-side code" with no path named)
48
53
  * extract nothing — the prompt-level rule still covers them.
49
54
  */
50
- export declare function extractProhibitions(spec: string): Prohibition[];
55
+ export declare function extractProhibitions(spec: string, resolve?: QaResolver): Prohibition[];
51
56
  /**
52
57
  * Intersect the spec's prohibitions with the task's changed files (git shape —
53
58
  * the same collector the substitution probe uses). A prohibition matches a
@@ -1,3 +1,5 @@
1
+ import { parseSpec } from './spec-model.js';
2
+ import { weightTag } from './constraint-policy.js';
1
3
  /**
2
4
  * Does this line express a modification ban? Matches the active forms ("do not
3
5
  * modify", "must not touch", "never edit", "don't change") and the passive form
@@ -23,22 +25,39 @@ function looksLikePath(token) {
23
25
  * Prose-only prohibitions ("do not modify server-side code" with no path named)
24
26
  * extract nothing — the prompt-level rule still covers them.
25
27
  */
26
- export function extractProhibitions(spec) {
28
+ export function extractProhibitions(spec, resolve) {
27
29
  const out = [];
28
30
  const seen = new Set();
29
- for (const line of spec.split('\n')) {
30
- if (!PROHIBITION_RE.test(line))
31
+ for (const { text, provenance } of prohibitionCandidates(spec, resolve)) {
32
+ if (!PROHIBITION_RE.test(text))
31
33
  continue;
32
- for (const m of line.matchAll(/`([^`]+)`/g)) {
34
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
33
35
  const token = m[1].trim();
34
36
  if (!looksLikePath(token) || seen.has(token))
35
37
  continue;
36
38
  seen.add(token);
37
- out.push({ path: token, constraint: line.trim() });
39
+ out.push({ path: token, constraint: text.trim(), provenance });
38
40
  }
39
41
  }
40
42
  return out;
41
43
  }
44
+ /**
45
+ * The lines a ban can live on: the spec's parsed CONSTRAINTS, each with its own
46
+ * provenance — or, for text that parses to no constraints at all, every line at
47
+ * `derived`.
48
+ *
49
+ * The fallback is not a courtesy to malformed specs. Half this probe's callers
50
+ * hand it a bare constraint list or a fragment rather than a four-section spec,
51
+ * and a probe that saw nothing in those would silently stop finding the
52
+ * violations it was built for.
53
+ */
54
+ function prohibitionCandidates(spec, resolve) {
55
+ const parsed = parseSpec(spec, resolve);
56
+ if (parsed.constraints.length > 0) {
57
+ return parsed.constraints.flatMap(c => c.text.split('\n').map(text => ({ text, provenance: c.provenance })));
58
+ }
59
+ return spec.split('\n').map(text => ({ text, provenance: 'derived' }));
60
+ }
42
61
  /** Normalise a path for comparison: strip leading ./ and trailing /. */
43
62
  const norm = (p) => p.replace(/^\.\//, '').replace(/\/+$/, '');
44
63
  /**
@@ -58,7 +77,8 @@ export function findProhibitionViolations(prohibitions, files) {
58
77
  });
59
78
  if (!hit)
60
79
  continue;
61
- findings.push(`${f.path} — modified by this task, but the spec forbids it: "${hit.constraint}"`);
80
+ findings.push(`${f.path} ${weightTag(hit.provenance)} — modified by this task, but the spec `
81
+ + `forbids it: "${hit.constraint}"`);
62
82
  }
63
83
  return findings;
64
84
  }