@mjasnikovs/pi-task 0.40.49 → 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 (126) 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/child-process.js +8 -2
  6. package/dist/shared/content-tokens.d.ts +16 -0
  7. package/dist/shared/content-tokens.js +137 -0
  8. package/dist/shared/leftovers.d.ts +6 -1
  9. package/dist/shared/leftovers.js +6 -1
  10. package/dist/task/accept-debt.d.ts +15 -1
  11. package/dist/task/accept-debt.js +4 -0
  12. package/dist/task/artifact-closure.js +7 -1
  13. package/dist/task/auto-io.d.ts +59 -7
  14. package/dist/task/auto-io.js +140 -47
  15. package/dist/task/auto-orchestrator.d.ts +22 -19
  16. package/dist/task/auto-orchestrator.js +126 -112
  17. package/dist/task/child-runner.d.ts +18 -0
  18. package/dist/task/child-runner.js +7 -8
  19. package/dist/task/command-run.d.ts +12 -2
  20. package/dist/task/command-run.js +45 -20
  21. package/dist/task/constraint-policy.d.ts +52 -0
  22. package/dist/task/constraint-policy.js +66 -0
  23. package/dist/task/context-silence.d.ts +5 -4
  24. package/dist/task/context-silence.js +13 -5
  25. package/dist/task/coverage-loop.d.ts +7 -4
  26. package/dist/task/coverage-loop.js +16 -138
  27. package/dist/task/debug-log.d.ts +25 -2
  28. package/dist/task/debug-log.js +64 -10
  29. package/dist/task/decompose-fidelity.d.ts +17 -4
  30. package/dist/task/decompose-fidelity.js +95 -55
  31. package/dist/task/decompose-granularity.d.ts +7 -7
  32. package/dist/task/decompose-granularity.js +8 -8
  33. package/dist/task/env-notes.d.ts +65 -19
  34. package/dist/task/env-notes.js +152 -50
  35. package/dist/task/external-context.js +25 -5
  36. package/dist/task/fix-context.d.ts +39 -0
  37. package/dist/task/fix-context.js +58 -0
  38. package/dist/task/gate-deps.d.ts +49 -0
  39. package/dist/task/gate-deps.js +199 -38
  40. package/dist/task/gate-evidence.d.ts +51 -0
  41. package/dist/task/gate-evidence.js +90 -0
  42. package/dist/task/gate-resolution.d.ts +119 -0
  43. package/dist/task/gate-resolution.js +120 -0
  44. package/dist/task/git-state-guard.js +3 -26
  45. package/dist/task/handoff.d.ts +35 -0
  46. package/dist/task/handoff.js +100 -0
  47. package/dist/task/health-baseline.d.ts +87 -0
  48. package/dist/task/health-baseline.js +158 -0
  49. package/dist/task/ledger.d.ts +10 -0
  50. package/dist/task/ledger.js +22 -11
  51. package/dist/task/lint-fix.d.ts +11 -0
  52. package/dist/task/lint-fix.js +14 -8
  53. package/dist/task/loop-detector.d.ts +52 -3
  54. package/dist/task/loop-detector.js +81 -5
  55. package/dist/task/mentions.d.ts +17 -0
  56. package/dist/task/mentions.js +86 -0
  57. package/dist/task/orchestrator.d.ts +53 -9
  58. package/dist/task/orchestrator.js +104 -28
  59. package/dist/task/orientation.d.ts +135 -29
  60. package/dist/task/orientation.js +259 -67
  61. package/dist/task/owned-freeze-reassign.d.ts +7 -3
  62. package/dist/task/owned-freeze-reassign.js +13 -5
  63. package/dist/task/parsers.d.ts +6 -1
  64. package/dist/task/parsers.js +21 -4
  65. package/dist/task/phases.d.ts +1 -1
  66. package/dist/task/phases.js +125 -153
  67. package/dist/task/plan-orchestrator.js +5 -6
  68. package/dist/task/plan-rounds.d.ts +1 -0
  69. package/dist/task/plan-rounds.js +8 -1
  70. package/dist/task/prohibition-probe.d.ts +6 -1
  71. package/dist/task/prohibition-probe.js +26 -6
  72. package/dist/task/prompts.js +13 -2
  73. package/dist/task/qa-transcript.d.ts +17 -0
  74. package/dist/task/qa-transcript.js +28 -0
  75. package/dist/task/question-source.d.ts +16 -2
  76. package/dist/task/question-source.js +21 -8
  77. package/dist/task/repo-health-check.d.ts +27 -0
  78. package/dist/task/repo-health-check.js +39 -5
  79. package/dist/task/requirements.d.ts +67 -13
  80. package/dist/task/requirements.js +129 -67
  81. package/dist/task/research-worker.d.ts +43 -2
  82. package/dist/task/research-worker.js +103 -8
  83. package/dist/task/run-bracket.d.ts +9 -4
  84. package/dist/task/run-bracket.js +9 -0
  85. package/dist/task/run-context.d.ts +152 -0
  86. package/dist/task/run-context.js +277 -0
  87. package/dist/task/spec-doc.d.ts +78 -0
  88. package/dist/task/spec-doc.js +205 -0
  89. package/dist/task/spec-model.d.ts +70 -0
  90. package/dist/task/spec-model.js +139 -0
  91. package/dist/task/stall-detector.d.ts +1 -1
  92. package/dist/task/stall-detector.js +9 -5
  93. package/dist/task/state-dir.d.ts +29 -0
  94. package/dist/task/state-dir.js +80 -0
  95. package/dist/task/suppression-probe.d.ts +67 -0
  96. package/dist/task/suppression-probe.js +78 -0
  97. package/dist/task/task-gates.d.ts +39 -57
  98. package/dist/task/task-gates.js +115 -195
  99. package/dist/task/task-io.d.ts +10 -0
  100. package/dist/task/task-io.js +69 -18
  101. package/dist/task/task-parsers.js +6 -6
  102. package/dist/task/task-types.d.ts +7 -0
  103. package/dist/task/timings.d.ts +11 -0
  104. package/dist/task/timings.js +21 -0
  105. package/dist/task/tree-hash.d.ts +8 -0
  106. package/dist/task/tree-hash.js +55 -0
  107. package/dist/task/verify-reconcile.d.ts +4 -3
  108. package/dist/task/verify-reconcile.js +7 -8
  109. package/dist/task/verify-resolution.d.ts +28 -5
  110. package/dist/task/verify-resolution.js +70 -13
  111. package/dist/task/verify-work.d.ts +98 -35
  112. package/dist/task/verify-work.js +227 -60
  113. package/dist/task/yolo.d.ts +0 -13
  114. package/dist/task/yolo.js +9 -21
  115. package/dist/workers/pi-worker-core.d.ts +20 -0
  116. package/dist/workers/pi-worker-core.js +16 -11
  117. package/dist/workers/pi-worker-docs.d.ts +6 -5
  118. package/dist/workers/pi-worker-docs.js +8 -7
  119. package/dist/workers/pi-worker-fetch.d.ts +3 -2
  120. package/dist/workers/pi-worker-fetch.js +5 -4
  121. package/dist/workers/pi-worker-search.js +5 -4
  122. package/dist/workers/research-cache.d.ts +23 -1
  123. package/dist/workers/research-cache.js +63 -3
  124. package/dist/workers/shared.d.ts +18 -0
  125. package/dist/workers/shared.js +38 -22
  126. package/package.json +1 -1
@@ -10,8 +10,9 @@
10
10
  *
11
11
  * Mechanism (spec-shape-agnostic, contracts.ts pattern):
12
12
  * 1. EXTRACT requirement units as VERBATIM quotes from whatever structure the
13
- * spec has (headings, tables, bullets, prose) — each host-GROUNDED by the
14
- * normalised-substring guard, so a fabricated requirement can never enter.
13
+ * spec has (headings, bullets, prose) — each host-GROUNDED by the
14
+ * normalised-substring guard against a CANDIDATE block (`RequirementPolicy`),
15
+ * so neither a fabricated requirement nor a DDL column can enter.
15
16
  * 2. MAP each grounded requirement against the task list (a per-requirement
16
17
  * verdict: TASK n / CROSS-CUTTING / NONE). Completeness is then computed
17
18
  * HOST-SIDE from the map — a blanket "COMPLETE" is structurally impossible
@@ -29,6 +30,7 @@
29
30
  */
30
31
  import { normalise } from './contracts.js';
31
32
  import { makeLedger } from './ledger.js';
33
+ import { blocksOf, demark, groundIn, parseSpecDoc, preambleOf, sectionPlains } from './spec-doc.js';
32
34
  const REQUIREMENTS_FILE = 'requirements.md';
33
35
  /** Cap kept entries so the injected block stays bounded on a large design. */
34
36
  const MAX_REQUIREMENTS = 40;
@@ -36,6 +38,16 @@ const MAX_REQUIREMENTS = 40;
36
38
  const MAX_REQUIREMENT_LENGTH = 300;
37
39
  /** Too short to state an obligation (and to ground unambiguously). */
38
40
  const MIN_QUOTE_LENGTH = 6;
41
+ /** The checklist rides into the extraction prompt, so it stays readable. */
42
+ const MAX_OBLIGATION_PASSAGES = 20;
43
+ export const REQUIREMENT_POLICY = {
44
+ candidateKinds: new Set(['para', 'list-item', 'quote']),
45
+ excludePreamble: true
46
+ };
47
+ function candidates(doc, policy) {
48
+ const excluded = policy.excludePreamble ? new Set(preambleOf(doc)) : new Set();
49
+ return b => policy.candidateKinds.has(b.kind) && !excluded.has(b);
50
+ }
39
51
  function carriedLineKey(line) {
40
52
  const q = /"([^"]+)"/.exec(line);
41
53
  return normalise(q ? q[1] : line);
@@ -69,22 +81,27 @@ export function parseRequirementLines(text) {
69
81
  }
70
82
  return entries;
71
83
  }
72
- /** THE ANTI-SYNTHESIS GUARD: keep only entries whose quote is a normalised
73
- * substring of the source doc (same rule as keepGroundedContracts). Does NOT
74
- * cap capping is capRequirements' job, which protects obligation-marked
75
- * passages from doc-order truncation. */
76
- export function keepGroundedRequirements(entries, sourceDoc) {
77
- const haystack = normalise(sourceDoc);
84
+ /** THE ANTI-SYNTHESIS GUARD: keep only entries whose quote grounds in a CANDIDATE
85
+ * block of the source doc (same rule as keepGroundedContracts, narrowed by the
86
+ * policy). A quote whose only match lies in an excluded block is dropped — the
87
+ * model is free to propose it, the document decides. Does NOT cap — capping is
88
+ * capRequirements' job, which protects obligation-marked passages from doc-order
89
+ * truncation. */
90
+ export function keepGroundedRequirements(entries, sourceDoc, policy = REQUIREMENT_POLICY) {
91
+ const doc = typeof sourceDoc === 'string' ? parseSpecDoc(sourceDoc) : sourceDoc;
92
+ const accept = candidates(doc, policy);
93
+ const preamble = new Set(preambleOf(doc));
78
94
  const seen = new Set();
79
95
  const kept = [];
80
96
  for (const e of entries) {
81
- const key = normalise(e.quote);
97
+ const key = normalise(demark(e.quote));
82
98
  if (key.length === 0 || seen.has(key))
83
99
  continue;
84
- if (!haystack.includes(key))
100
+ const block = groundIn(doc, e.quote, accept);
101
+ if (block === null)
85
102
  continue;
86
103
  seen.add(key);
87
- kept.push(e);
104
+ kept.push({ ...e, line: block.line, preamble: preamble.has(block) });
88
105
  }
89
106
  return kept;
90
107
  }
@@ -110,9 +127,9 @@ export function capRequirements(entries, passages, sourceDoc,
110
127
  deprioritiseLowValue = true) {
111
128
  if (entries.length <= MAX_REQUIREMENTS)
112
129
  return entries;
113
- const norms = passages.map(normalise);
130
+ const norms = passages.map(p => normalise(demark(p)));
114
131
  const covers = (e) => {
115
- const q = normalise(e.quote);
132
+ const q = normalise(demark(e.quote));
116
133
  return norms.some(p => p.includes(q));
117
134
  };
118
135
  const marked = entries.filter(covers);
@@ -218,24 +235,6 @@ function budgetedByObligation(entries, budget, sourceDoc) {
218
235
  .map(x => x.e);
219
236
  return [...keep, ...restored];
220
237
  }
221
- /** The doc split into heading-delimited sections, each pre-normalised for
222
- * containment tests. Text before the first heading is its own section. */
223
- function normalisedSections(doc) {
224
- const out = [];
225
- let current = [];
226
- for (const line of doc.replace(/\r\n?/g, '\n').split('\n')) {
227
- if (/^#{1,6}\s+\S/.test(line)) {
228
- if (current.length > 0)
229
- out.push(normalise(current.join('\n')));
230
- current = [line];
231
- continue;
232
- }
233
- current.push(line);
234
- }
235
- if (current.length > 0)
236
- out.push(normalise(current.join('\n')));
237
- return out.filter(s => s.length > 0);
238
- }
239
238
  /** Round-robin fill across doc sections: bucket each entry by the FIRST section
240
239
  * whose normalised text contains its quote (the same containment rule that
241
240
  * grounded it), take each bucket's entries in in-section order, one per bucket
@@ -246,10 +245,10 @@ function sectionFairFill(entries, budget, sourceDoc) {
246
245
  return [];
247
246
  if (!sourceDoc)
248
247
  return entries.slice(0, budget);
249
- const sections = normalisedSections(sourceDoc);
248
+ const sections = sectionPlains(parseSpecDoc(sourceDoc));
250
249
  const buckets = new Map();
251
250
  entries.forEach((e, given) => {
252
- const q = normalise(e.quote);
251
+ const q = normalise(demark(e.quote));
253
252
  let b = sections.findIndex(s => s.includes(q));
254
253
  let at;
255
254
  if (b < 0) {
@@ -292,19 +291,23 @@ function sectionFairFill(entries, budget, sourceDoc) {
292
291
  * uncoveredPassages() below turns "a marked passage produced no quote" into hard
293
292
  * evidence for one forced re-extraction.
294
293
  */
295
- export function enumerateObligationPassages(doc) {
294
+ export function enumerateObligationPassages(doc, policy = REQUIREMENT_POLICY) {
295
+ const parsed = typeof doc === 'string' ? parseSpecDoc(doc) : doc;
296
+ const accept = candidates(parsed, policy);
296
297
  const out = [];
297
- // Normalise CRLF/CR LF: a Windows-authored spec would otherwise collapse
298
- // into one giant paragraph (the split marker never matches `\r\n\r\n`) and
299
- // the per-obligation recall floor would enumerate nothing.
300
- for (const para of doc.replace(/\r\n?/g, '\n').split(/\n[ \t]*\n/)) {
301
- const p = para.trim();
298
+ // The SAME policy grounding uses. A marked passage no quote can be grounded
299
+ // in would report itself uncovered forever and force a re-extraction every
300
+ // round that can never discharge it.
301
+ for (const b of blocksOf(parsed)) {
302
+ if (!accept(b))
303
+ continue;
304
+ const p = b.text.trim();
302
305
  if (p.length < MIN_QUOTE_LENGTH)
303
306
  continue;
304
307
  if (!/\b(required|must)\b/i.test(p))
305
308
  continue;
306
309
  out.push(p);
307
- if (out.length >= 20)
310
+ if (out.length >= MAX_OBLIGATION_PASSAGES)
308
311
  break;
309
312
  }
310
313
  return out;
@@ -318,9 +321,9 @@ function passageHead(p) {
318
321
  * extraction recall failed there (a kept quote "covers" a passage when the
319
322
  * passage contains it, normalised). */
320
323
  export function uncoveredPassages(passages, kept) {
321
- const keptNorm = kept.map(e => normalise(e.quote));
324
+ const keptNorm = kept.map(e => normalise(demark(e.quote)));
322
325
  return passages.filter(p => {
323
- const pn = normalise(p);
326
+ const pn = normalise(demark(p));
324
327
  return !keptNorm.some(q => pn.includes(q));
325
328
  });
326
329
  }
@@ -342,7 +345,7 @@ export const REQUIREMENT_EXTRACT_PROMPT = (feature, passages = []) => [
342
345
  'functional behavior, constraints, quality bars, security/accessibility rules, and any',
343
346
  'MANDATED METHODOLOGY (testing cadence, verification practice, required scripts, files,',
344
347
  'directory structures, databases). Extract from WHATEVER structure the text has —',
345
- 'numbered sections, tables, bullet lists, or flowing prose with no headings at all.',
348
+ 'numbered sections, bullet lists, or flowing prose with no headings at all.',
346
349
  "Pay particular attention to obligations that are NOT part of the text's main",
347
350
  'feature/milestone structure (a "required" testing or security section, an obligation',
348
351
  'buried mid-prose) — those are the ones downstream planning loses.',
@@ -366,20 +369,24 @@ export const REQUIREMENT_EXTRACT_PROMPT = (feature, passages = []) => [
366
369
  'paraphrase, merge, normalise, or complete it; ungrounded quotes are DISCARDED',
367
370
  'host-side. (2) Prefer the single sentence or line that states the obligation most',
368
371
  'directly. (3) One obligation per line. (4) Do NOT quote examples, rationale, or',
369
- 'reference links. (5) Never invent a requirement the text does not state.',
372
+ 'reference links. (5) Never invent a requirement the text does not state. (6) Quote',
373
+ 'the PROSE that states the obligation — a quote taken from a code/DDL block, from a',
374
+ "table row, or from the text's opening description is DISCARDED host-side; where an",
375
+ 'obligation only appears there, quote the sentence that introduces it instead.',
370
376
  '',
371
377
  'Output the REQUIREMENT: lines and nothing else. If the text states no requirements,',
372
378
  'output nothing.'
373
379
  ].join('\n');
374
380
  /**
375
381
  * A requirement no single task can ever OWN: a PROHIBITION (it states what must
376
- * NOT exist or happen — there is no task that "delivers" an absence) or a GLOBAL
377
- * POLICY (a product-wide rule every slice obeys, not one slice's deliverable). The
378
- * per-task coverage map maps both to NONE forever, so left in the `unmapped` set
379
- * they hold the decompose loop's verdict at INCOMPLETE and make it regenerate the
380
- * whole plan every round which can replace a good plan with a worse one. These
381
- * belong in the CROSS-CUTTING carry, injected verbatim into every task, never fed
382
- * back as a missing area.
382
+ * NOT exist or happen — there is no task that "delivers" an absence), a GLOBAL
383
+ * POLICY (a product-wide rule every slice obeys, not one slice's deliverable), or
384
+ * a DESCRIPTION (what the product IS, which no slice delivers either). The
385
+ * per-task coverage map maps all three to NONE forever, so left in the `unmapped`
386
+ * set they hold the decompose loop's verdict at INCOMPLETE and make it regenerate
387
+ * the whole plan every round which can replace a good plan with a worse one.
388
+ * These belong in the CROSS-CUTTING carry, injected verbatim into every task,
389
+ * never fed back as a missing area.
383
390
  *
384
391
  * Deterministic and precision-biased: it only reclassifies clear prohibitions and
385
392
  * clearly product-global policies. It does NOT need to catch every un-ownable line
@@ -387,19 +394,52 @@ export const REQUIREMENT_EXTRACT_PROMPT = (feature, passages = []) => [
387
394
  * miss here can at most cost one wasted regeneration, never a dropped area. Spec-
388
395
  * shape/domain agnostic: pure phrasing, no feature nouns.
389
396
  */
390
- const PROHIBITION_RE = /\b(?:must not|must never|shall not|should not|may not|cannot|can'?t|won'?t|do(?:es)? not|don'?t|doesn'?t|no|not|never|none|without|avoids?|prohibit(?:ed|s|ing)?|forbid(?:den|s)?|disallow(?:ed|s|ing)?|excludes?|excluded|neither|nor)\b/i;
397
+ // Bare `no`, `not` and `none` are NOT here. They read as prohibitions in a
398
+ // grammar that has none: "the page renders even when the user is not logged in"
399
+ // is an ownable behaviour statement, and the bare-negative rule swept it — and
400
+ // everything like it — into a carry no task ever delivers. What remains is modal
401
+ // negation and explicit exclusion verbs, which cannot fire that way.
402
+ const PROHIBITION_RE = /\b(?:must not|must never|shall not|shall never|should not|may not|cannot|can'?t|won'?t|will not|do(?:es)? not|don'?t|doesn'?t|never|without|avoids?|prohibit(?:ed|s|ing)?|forbid(?:den|s)?|disallow(?:ed|s|ing)?|excludes?|excluded|neither|nor)\b/i;
403
+ /** "must have NO runtime dependencies" — the absence shape bare `no` used to
404
+ * carry. Anchored to an existence verb AND gated by a modal below, because
405
+ * without the modal it is ordinary description: "the empty state shows when
406
+ * there are no listings yet" obligates nothing. */
407
+ const NEGATED_EXISTENCE_RE = /\b(?:is|are|be|been|being|has|have|had|contains?|ships?|leaves?|with)\s+no\b/i;
391
408
  // Kept narrow on purpose — bare "all"/"every"/"any" appear in plenty of ownable
392
409
  // feature statements ("lists all photos"), so the global branch keys only on
393
410
  // scope words that name the WHOLE product and is additionally gated by a modal.
394
411
  const GLOBAL_SCOPE_RE = /\b(?:everywhere|throughout|always|global(?:ly)?|across (?:the|all|every)|site-?wide|app(?:lication)?-?wide|universal(?:ly)?|consistent(?:ly)?|entire (?:app|application|site|codebase|product|system|ui|project))\b/i;
395
412
  const MODAL_RE = /\b(?:must|shall|should|require[sd]?|required|needs? to|has to|have to)\b/i;
396
- export function isCrossCuttingRequirement(quote) {
413
+ /**
414
+ * `descriptive` is the class the coverage loop must not chase: a line that says
415
+ * what the product IS rather than what the work must do. It cost two whole
416
+ * rejected decompose rounds live — "Invite-only used-parts marketplace for a
417
+ * local Mazda MX-5 club." was extracted as a requirement, mapped NONE by every
418
+ * round because no task delivers a sentence, and held the verdict INCOMPLETE.
419
+ *
420
+ * Both marks are required, not either: preamble POSITION (above every heading,
421
+ * where a spec states its subject) and the absence of any modal. A preamble
422
+ * sentence that does carry a modal is a real obligation stated up front, and a
423
+ * modal-free sentence anywhere else is the ordinary shape of a feature statement
424
+ * — treating either alone as descriptive would empty the coverage gate.
425
+ */
426
+ export function classifyRequirement(quote, fromPreamble = false) {
397
427
  const q = quote.trim();
398
428
  if (PROHIBITION_RE.test(q))
399
- return true;
429
+ return 'prohibition';
430
+ if (NEGATED_EXISTENCE_RE.test(q) && MODAL_RE.test(q))
431
+ return 'prohibition';
400
432
  if (GLOBAL_SCOPE_RE.test(q) && MODAL_RE.test(q))
401
- return true;
402
- return false;
433
+ return 'global-policy';
434
+ if (fromPreamble && !MODAL_RE.test(q))
435
+ return 'descriptive';
436
+ return 'ownable';
437
+ }
438
+ /** A requirement no single task can ever OWN, judged on the quote alone — the
439
+ * shape `groundedCoverage` and the granularity floor get. */
440
+ export function isCrossCuttingRequirement(quote) {
441
+ const c = classifyRequirement(quote);
442
+ return c === 'prohibition' || c === 'global-policy';
403
443
  }
404
444
  /** Requirement INDICES a task owns (a `TASK n` verdict), the monotonic-replacement
405
445
  * signal (coverage-loop.ts). Index-aligned with the requirements list. */
@@ -466,11 +506,13 @@ export function accountCoverage(requirements, mappings) {
466
506
  acc.mapped.push({ req: requirements[i], task: m.task });
467
507
  else if (m.kind === 'cross')
468
508
  acc.crossCutting.push(requirements[i]);
469
- // NONE — but a prohibition/global-policy requirement can never be OWNED by
470
- // a task (it states an absence or a product-wide rule); the model maps it
471
- // NONE every round, which forces endless whole-plan regeneration.
472
- // Carry it cross-cutting instead, so it stops driving the coverage loop.
473
- else if (isCrossCuttingRequirement(requirements[i].quote))
509
+ // NONE — but a prohibition, a global policy and a product description can
510
+ // never be OWNED by a task (an absence, a product-wide rule, a statement of
511
+ // what the thing IS); the model maps them NONE every round, which forces
512
+ // endless whole-plan regeneration. Carry them cross-cutting instead, so
513
+ // they stop driving the coverage loop — carried, never dropped.
514
+ else if (classifyRequirement(requirements[i].quote, requirements[i].preamble ?? false)
515
+ !== 'ownable')
474
516
  acc.crossCutting.push(requirements[i]);
475
517
  else
476
518
  acc.unmapped.push(requirements[i]);
@@ -570,6 +612,7 @@ const ownedLedger = makeLedger({
570
612
  key: o => normalise(o.quote),
571
613
  serialize: o => `OWNED: "${o.quote}"${o.anchor ? ` [anchor: ${o.anchor}]` : ''}`
572
614
  + (o.pending && o.pending.length > 0 ? ` [pending: ${o.pending.join(', ')}]` : '')
615
+ + (o.key ? ` [key: ${o.key}]` : '')
573
616
  + ` [title: ${o.title.replace(/\n/g, ' ')}]`,
574
617
  parse: parseOwnedRequirements
575
618
  });
@@ -587,30 +630,49 @@ export async function readOwnedRequirements(cwd) {
587
630
  }
588
631
  export function parseOwnedRequirements(text) {
589
632
  const out = [];
590
- for (const m of text.matchAll(/^OWNED:\s*"([^"\n]+)"(?:\s*\[anchor:\s*([^\]]*)\])?(?:\s*\[pending:\s*([^\]]*)\])?\s*\[title:\s*([^\n]+)\]\s*$/gim)) {
633
+ for (const m of text.matchAll(/^OWNED:\s*"([^"\n]+)"(?:\s*\[anchor:\s*([^\]]*)\])?(?:\s*\[pending:\s*([^\]]*)\])?(?:\s*\[key:\s*([^\]]*)\])?\s*\[title:\s*([^\n]+)\]\s*$/gim)) {
591
634
  const pending = (m[3] ?? '')
592
635
  .split(',')
593
636
  .map(p => p.trim())
594
637
  .filter(p => p.length > 0);
638
+ const key = (m[4] ?? '').trim();
595
639
  out.push({
596
640
  quote: m[1].trim(),
597
641
  anchor: (m[2] ?? '').trim(),
598
- title: m[4].replace(/\]\s*$/, '').trim(),
642
+ title: m[5].replace(/\]\s*$/, '').trim(),
643
+ ...(key.length > 0 ? { key } : {}),
599
644
  ...(pending.length > 0 ? { pending } : {})
600
645
  });
601
646
  }
602
647
  return out;
603
648
  }
649
+ /** A DETACHED entry (`pending`) is owned by nobody until a task claims it, so no
650
+ * join returns it — its `title` is provenance, not ownership. */
651
+ const isOwned = (o) => !(o.pending && o.pending.length > 0);
604
652
  /** The owned entries whose plan title matches THIS task's title (normalised
605
653
  * equality — titles travel verbatim from the plan list into task creation;
606
- * spliced repair tasks simply match nothing). A DETACHED entry (`pending`) is
607
- * owned by nobody until a task claims it, so it is never returned here — its
608
- * `title` is provenance, not ownership. */
654
+ * spliced repair tasks simply match nothing). */
609
655
  export function ownedForTitle(owned, title) {
610
656
  const t = normalise(title);
611
657
  if (t.length === 0)
612
658
  return [];
613
- return owned.filter(o => normalise(o.title) === t && !(o.pending && o.pending.length > 0));
659
+ return owned.filter(o => normalise(o.title) === t && isOwned(o));
660
+ }
661
+ /** The owned entries assigned to THIS task's plan entry. */
662
+ export function ownedForKey(owned, key) {
663
+ return owned.filter(o => o.key === key && isOwned(o));
664
+ }
665
+ /**
666
+ * THIS task's owned entries. The key is the join; the title is the fallback for a
667
+ * ledger written before keys existed, and only then — once ANY entry carries a
668
+ * key the ledger is a keyed one, and a task the plan did not key (a spliced
669
+ * repair step) owns nothing, which is the same answer the title join gave it.
670
+ */
671
+ export function ownedForTask(owned, join) {
672
+ if (join.key !== undefined && owned.some(o => o.key !== undefined)) {
673
+ return ownedForKey(owned, join.key);
674
+ }
675
+ return ownedForTitle(owned, join.title);
614
676
  }
615
677
  /** The injection block for a task's OWN mapped obligations. Mirrors
616
678
  * buildRequirementsBlock (the directive pattern that measurably works) but is
@@ -18,6 +18,7 @@
18
18
  * named measure; and `confirmedEmpty` suppresses the silent gate, because that
19
19
  * retry already asked "you wrote nothing" and got the same answer.
20
20
  */
21
+ import type { ResearchConcurrency } from '../config/config.js';
21
22
  import type { RunWorkerInput, RunWorkerResult } from '../workers/pi-worker-core.js';
22
23
  import type { SpawnFn } from '../shared/child-process.js';
23
24
  import type { DebugLine } from './debug-log.js';
@@ -29,8 +30,17 @@ import type { DebugLine } from './debug-log.js';
29
30
  export interface ResearchWorkerSpec {
30
31
  section: string;
31
32
  label: string;
32
- /** Static, or built from the sections completed so far (serial mode hands
33
- * APIS the finished FILES map; parallel mode hands it nothing). */
33
+ /**
34
+ * Sections that must be FINISHED before this worker starts — the edges of the
35
+ * stage's execution graph (see {@link runResearchStage}).
36
+ *
37
+ * A dependency is declared because the worker READS the other's output, not to
38
+ * order the machine: APIS builds on the FILES map, and everything else is free
39
+ * to run alongside. Absent means "no dependency", which is what lets three of
40
+ * the four workers overlap.
41
+ */
42
+ after?: string[];
43
+ /** Static, or built from the sections this worker declared it comes `after`. */
34
44
  prompt: string | ((prior: ReadonlyArray<{
35
45
  name: string;
36
46
  text: string;
@@ -178,3 +188,34 @@ export declare function runResearchWorker(spec: ResearchWorkerSpec, run: Researc
178
188
  name: string;
179
189
  text: string;
180
190
  }>;
191
+ /** One finished research section, under the heading compose assembles it by. */
192
+ export interface ResearchSection {
193
+ name: string;
194
+ text: string;
195
+ }
196
+ /** How one worker is actually run. The stage schedules; this does the work. */
197
+ export type DriveWorker = (spec: ResearchWorkerSpec, prior: ReadonlyArray<ResearchSection>) => Promise<ResearchSection>;
198
+ /**
199
+ * Run a stage of research workers, honouring the dependencies they declare.
200
+ *
201
+ * WHY A GRAPH. Running the four one at a time cost 24 % of a run's wall clock,
202
+ * and running them all at once lost the one handoff that matters: APIS is written
203
+ * against the FILES map, and under a flat `Promise.all` it got nothing. Only ONE
204
+ * edge exists — the other three questions are independent — so a scheduler that
205
+ * reads `after` runs FILES, then APIS, with CONTEXT and TOOLING alongside both,
206
+ * and the handoff survives the concurrency.
207
+ *
208
+ * `prior` is a worker's DECLARED dependencies, not "whatever happened to finish".
209
+ * The second would make a prompt a function of scheduling order, so the same task
210
+ * would produce different prompts on two machines.
211
+ *
212
+ * FAILURE. A worker whose dependency never produced a section does not start; its
213
+ * independent siblings still finish, so their sections reach the task file for the
214
+ * resume. The first failure in spec order is then thrown — every worker has
215
+ * settled by that point, which is what keeps a fatal APIS from orphaning the
216
+ * three that answered.
217
+ *
218
+ * `serial` is preserved for a single local GPU, where concurrent streams share one
219
+ * device and the sum of four fast workers beats the max of four slowed ones.
220
+ */
221
+ export declare function runResearchStage(specs: ReadonlyArray<ResearchWorkerSpec>, drive: DriveWorker, mode?: ResearchConcurrency): Promise<ResearchSection[]>;
@@ -19,9 +19,10 @@
19
19
  * retry already asked "you wrote nothing" and got the same answer.
20
20
  */
21
21
  import { classifyWorkerFailure } from '../workers/worker-failure.js';
22
+ import { describeLoopHit } from './loop-detector.js';
22
23
  import { classifyContextSilence, countBullets } from './context-silence.js';
23
24
  import { cancelCheckpoint } from './cancel-points.js';
24
- import { USER_CANCELLED } from './child-runner.js';
25
+ import { appendLoopEvents, USER_CANCELLED } from './child-runner.js';
25
26
  /**
26
27
  * Task-file heading under which a research worker's validated output is cached.
27
28
  * A resumed research phase reads these to skip workers that already succeeded,
@@ -73,15 +74,11 @@ export function classifyResearchWorker(name, result) {
73
74
  const failure = classifyWorkerFailure(result);
74
75
  if (failure) {
75
76
  switch (failure.kind) {
76
- case 'loop': {
77
- const argsStr = JSON.stringify(failure.hit.call.args);
77
+ case 'loop':
78
78
  return {
79
79
  kind: 'runaway',
80
- reason: `stuck in a loop called ${failure.hit.call.name}(${argsStr}) `
81
- + `×${failure.hit.count} in the last ${failure.hit.windowSize} calls `
82
- + `and still looped after restarts`
80
+ reason: `${describeLoopHit(failure.hit)} and did not recover after restarts`
83
81
  };
84
- }
85
82
  case 'worker-timeout':
86
83
  return { kind: 'runaway', reason: 'timed out after restarts' };
87
84
  case 'command-timeout':
@@ -231,7 +228,16 @@ export async function runResearchWorker(spec, run, prior = []) {
231
228
  }
232
229
  run.logDebug?.(`${spec.label}: start`);
233
230
  const basePrompt = typeof spec.prompt === 'function' ? spec.prompt(prior) : spec.prompt;
234
- const runOnce = (extraPreamble) => run.record(spec.label, run.runWorker(spec.label, {
231
+ // A research worker's loop kill is the one the run pays most for — it is
232
+ // what discards a restart's minutes — and it was the only child whose kills
233
+ // never reached the task file's `## loop events`, so the trail said the phase
234
+ // ran clean. Awaited so the section is on disk before the next gate reads it.
235
+ const runOnce = async (extraPreamble) => {
236
+ const result = await runWorkerOnce(extraPreamble);
237
+ await appendLoopEvents(run.cwd, run.taskId, spec.label, result);
238
+ return result;
239
+ };
240
+ const runWorkerOnce = (extraPreamble) => run.record(spec.label, run.runWorker(spec.label, {
235
241
  prompt: extraPreamble ? `${extraPreamble}\n\n${basePrompt}` : basePrompt,
236
242
  cwd: run.cwd,
237
243
  contextWindow: run.contextWindow,
@@ -416,3 +422,92 @@ export async function runResearchWorker(spec, run, prior = []) {
416
422
  }
417
423
  return { name: spec.section, text: sectionText };
418
424
  }
425
+ /**
426
+ * Run a stage of research workers, honouring the dependencies they declare.
427
+ *
428
+ * WHY A GRAPH. Running the four one at a time cost 24 % of a run's wall clock,
429
+ * and running them all at once lost the one handoff that matters: APIS is written
430
+ * against the FILES map, and under a flat `Promise.all` it got nothing. Only ONE
431
+ * edge exists — the other three questions are independent — so a scheduler that
432
+ * reads `after` runs FILES, then APIS, with CONTEXT and TOOLING alongside both,
433
+ * and the handoff survives the concurrency.
434
+ *
435
+ * `prior` is a worker's DECLARED dependencies, not "whatever happened to finish".
436
+ * The second would make a prompt a function of scheduling order, so the same task
437
+ * would produce different prompts on two machines.
438
+ *
439
+ * FAILURE. A worker whose dependency never produced a section does not start; its
440
+ * independent siblings still finish, so their sections reach the task file for the
441
+ * resume. The first failure in spec order is then thrown — every worker has
442
+ * settled by that point, which is what keeps a fatal APIS from orphaning the
443
+ * three that answered.
444
+ *
445
+ * `serial` is preserved for a single local GPU, where concurrent streams share one
446
+ * device and the sum of four fast workers beats the max of four slowed ones.
447
+ */
448
+ export async function runResearchStage(specs, drive, mode = 'graph') {
449
+ if (mode === 'serial') {
450
+ const done = [];
451
+ for (const spec of specs)
452
+ done.push(await drive(spec, done));
453
+ return done;
454
+ }
455
+ const bySection = new Map(specs.map(s => [s.section, s]));
456
+ assertSchedulable(specs, bySection);
457
+ const done = new Map();
458
+ const failed = new Map();
459
+ const started = new Map();
460
+ const run = (spec) => {
461
+ const inFlight = started.get(spec.section);
462
+ if (inFlight)
463
+ return inFlight;
464
+ const after = spec.after ?? [];
465
+ const p = (async () => {
466
+ await Promise.all(after.map(dep => run(bySection.get(dep))));
467
+ if (after.some(dep => !done.has(dep)))
468
+ return;
469
+ try {
470
+ done.set(spec.section, await drive(spec, after.map(dep => done.get(dep))));
471
+ }
472
+ catch (e) {
473
+ failed.set(spec.section, e);
474
+ }
475
+ })();
476
+ started.set(spec.section, p);
477
+ return p;
478
+ };
479
+ await Promise.all(specs.map(run));
480
+ for (const spec of specs) {
481
+ if (failed.has(spec.section))
482
+ throw failed.get(spec.section);
483
+ }
484
+ return specs.flatMap(s => {
485
+ const section = done.get(s.section);
486
+ return section ? [section] : [];
487
+ });
488
+ }
489
+ /**
490
+ * Reject a graph the scheduler cannot run before any child is spawned: an `after`
491
+ * naming a section that is not in this stage, or a cycle. Either would otherwise
492
+ * surface as a research phase that never returns.
493
+ */
494
+ function assertSchedulable(specs, bySection) {
495
+ const settled = new Set();
496
+ const walk = (spec, chain) => {
497
+ if (settled.has(spec.section))
498
+ return;
499
+ if (chain.includes(spec.section)) {
500
+ throw new Error(`research stage: dependency cycle ${[...chain, spec.section].join(' → ')}`);
501
+ }
502
+ for (const dep of spec.after ?? []) {
503
+ const next = bySection.get(dep);
504
+ if (!next) {
505
+ throw new Error(`research stage: ${spec.section} declares after: ${dep}, which is not a worker`);
506
+ }
507
+ walk(next, [...chain, spec.section]);
508
+ }
509
+ settled.add(spec.section);
510
+ };
511
+ for (const spec of specs)
512
+ walk(spec, []);
513
+ }
@@ -4,14 +4,16 @@
4
4
  * Every long-running command — `/task`, `/task-auto`, `/task-auto-resume`,
5
5
  * `/task-plan`, and `TaskRunner.run` inside all of them — spends most of its
6
6
  * life with the host session idle: the spec phases, the planning children and
7
- * every gate are child `pi` processes, not host turns. Two things must be true
8
- * for exactly that window, and both are refcounted because the runs nest
9
- * (`/task-auto` brackets its loop, each task inside brackets its own run):
7
+ * every gate are child `pi` processes, not host turns. Three things must be true
8
+ * for exactly that window, and all of them account for nesting because the runs
9
+ * nest (`/task-auto` brackets its loop, each task inside brackets its own run):
10
10
  *
11
11
  * 1. mid-run input is HELD, not queued or turned into a competing turn
12
12
  * (`mid-run-input.ts`: `beginRun`/`endRun`);
13
13
  * 2. the raw-stdin interception that makes the terminal behave like the
14
- * browser is ARMED (`cancel-input.ts`: `armCancelListener`/`disarm…`).
14
+ * browser is ARMED (`cancel-input.ts`: `armCancelListener`/`disarm…`);
15
+ * 3. the run's per-project facts are DERIVED ONCE and shared by every task in
16
+ * it (`run-context.ts`: `openRunContext`/`closeRunContext`).
15
17
  *
16
18
  * The teardown order is not observable — both halves are synchronous, the
17
19
  * listener only consults `isRunActive()` on a keystroke, and no keystroke can
@@ -27,6 +29,9 @@
27
29
  * driving the other's lifecycle, or a third counter with both modules demoted to
28
30
  * flags — a wider change than the drift it prevents. What prevents drift now is
29
31
  * that this bracket is the ONLY production caller of either pair.
32
+ *
33
+ * The run context counts nothing: it is opened by the outermost bracket only, and
34
+ * an inner one is handed the same object.
30
35
  */
31
36
  import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
32
37
  export interface RunBracketOptions {
@@ -4,6 +4,7 @@ import { reportDroppedInput } from './dropped-input.js';
4
4
  import { publishLifecycleNotice } from '../remote/bridge.js';
5
5
  import { pushNotify } from '../remote/push.js';
6
6
  import { resetCancel } from './cancel-points.js';
7
+ import { closeRunContext, openRunContext } from './run-context.js';
7
8
  /**
8
9
  * Run `fn` as the owner of the session: hold mid-run input and arm the terminal
9
10
  * interception for exactly its duration, then release both — on return AND on
@@ -22,6 +23,12 @@ export async function withRun(ctx, opts, fn) {
22
23
  resetCancel();
23
24
  beginRun();
24
25
  armCancelListener(ctx, opts.onCancel);
26
+ // The run's per-project facts live as long as the OUTERMOST bracket: one
27
+ // inventory, one orientation core, one set of verified tooling commands for
28
+ // every task inside it. An inner bracket joins that run rather than opening
29
+ // its own — and must not close it either, which is why this follows
30
+ // `outermost` instead of refcounting a third time.
31
+ const runContext = outermost ? openRunContext(ctx.cwd) : null;
25
32
  try {
26
33
  return await fn();
27
34
  }
@@ -29,6 +36,8 @@ export async function withRun(ctx, opts, fn) {
29
36
  disarmCancelListener();
30
37
  if (outermost)
31
38
  resetCancel();
39
+ if (runContext)
40
+ closeRunContext(runContext);
32
41
  reportDroppedInput(endRun(), ctx);
33
42
  }
34
43
  }