@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.
- package/README.md +7 -4
- package/dist/config/config.d.ts +61 -18
- package/dist/config/config.js +50 -6
- package/dist/config/register.js +18 -5
- package/dist/shared/content-tokens.d.ts +16 -0
- package/dist/shared/content-tokens.js +137 -0
- package/dist/task/accept-debt.d.ts +15 -1
- package/dist/task/accept-debt.js +4 -0
- package/dist/task/artifact-closure.js +7 -1
- package/dist/task/auto-io.d.ts +59 -7
- package/dist/task/auto-io.js +140 -47
- package/dist/task/auto-orchestrator.d.ts +22 -19
- package/dist/task/auto-orchestrator.js +126 -112
- package/dist/task/child-runner.d.ts +18 -0
- package/dist/task/child-runner.js +7 -8
- package/dist/task/command-run.d.ts +4 -0
- package/dist/task/command-run.js +4 -2
- package/dist/task/constraint-policy.d.ts +52 -0
- package/dist/task/constraint-policy.js +66 -0
- package/dist/task/context-silence.d.ts +5 -4
- package/dist/task/context-silence.js +13 -5
- package/dist/task/coverage-loop.d.ts +7 -4
- package/dist/task/coverage-loop.js +16 -138
- package/dist/task/debug-log.d.ts +25 -2
- package/dist/task/debug-log.js +64 -10
- package/dist/task/decompose-fidelity.d.ts +17 -4
- package/dist/task/decompose-fidelity.js +95 -55
- package/dist/task/decompose-granularity.d.ts +7 -7
- package/dist/task/decompose-granularity.js +8 -8
- package/dist/task/env-notes.d.ts +65 -19
- package/dist/task/env-notes.js +152 -50
- package/dist/task/external-context.js +25 -5
- package/dist/task/fix-context.d.ts +39 -0
- package/dist/task/fix-context.js +58 -0
- package/dist/task/gate-deps.d.ts +49 -0
- package/dist/task/gate-deps.js +199 -38
- package/dist/task/gate-evidence.d.ts +51 -0
- package/dist/task/gate-evidence.js +90 -0
- package/dist/task/gate-resolution.d.ts +119 -0
- package/dist/task/gate-resolution.js +120 -0
- package/dist/task/git-state-guard.js +3 -26
- package/dist/task/handoff.d.ts +35 -0
- package/dist/task/handoff.js +100 -0
- package/dist/task/health-baseline.d.ts +87 -0
- package/dist/task/health-baseline.js +158 -0
- package/dist/task/ledger.d.ts +10 -0
- package/dist/task/ledger.js +22 -11
- package/dist/task/lint-fix.d.ts +11 -0
- package/dist/task/lint-fix.js +14 -8
- package/dist/task/loop-detector.d.ts +52 -3
- package/dist/task/loop-detector.js +81 -5
- package/dist/task/mentions.d.ts +17 -0
- package/dist/task/mentions.js +86 -0
- package/dist/task/orchestrator.d.ts +53 -9
- package/dist/task/orchestrator.js +104 -28
- package/dist/task/orientation.d.ts +135 -29
- package/dist/task/orientation.js +259 -67
- package/dist/task/owned-freeze-reassign.d.ts +7 -3
- package/dist/task/owned-freeze-reassign.js +13 -5
- package/dist/task/parsers.d.ts +6 -1
- package/dist/task/parsers.js +21 -4
- package/dist/task/phases.d.ts +1 -1
- package/dist/task/phases.js +125 -153
- package/dist/task/plan-orchestrator.js +5 -6
- package/dist/task/plan-rounds.d.ts +1 -0
- package/dist/task/plan-rounds.js +8 -1
- package/dist/task/prohibition-probe.d.ts +6 -1
- package/dist/task/prohibition-probe.js +26 -6
- package/dist/task/prompts.js +13 -2
- package/dist/task/qa-transcript.d.ts +17 -0
- package/dist/task/qa-transcript.js +28 -0
- package/dist/task/question-source.d.ts +16 -2
- package/dist/task/question-source.js +21 -8
- package/dist/task/repo-health-check.d.ts +27 -0
- package/dist/task/repo-health-check.js +39 -5
- package/dist/task/requirements.d.ts +67 -13
- package/dist/task/requirements.js +129 -67
- package/dist/task/research-worker.d.ts +43 -2
- package/dist/task/research-worker.js +103 -8
- package/dist/task/run-bracket.d.ts +9 -4
- package/dist/task/run-bracket.js +9 -0
- package/dist/task/run-context.d.ts +152 -0
- package/dist/task/run-context.js +277 -0
- package/dist/task/spec-doc.d.ts +78 -0
- package/dist/task/spec-doc.js +205 -0
- package/dist/task/spec-model.d.ts +70 -0
- package/dist/task/spec-model.js +139 -0
- package/dist/task/stall-detector.d.ts +1 -1
- package/dist/task/stall-detector.js +9 -5
- package/dist/task/state-dir.d.ts +29 -0
- package/dist/task/state-dir.js +80 -0
- package/dist/task/suppression-probe.d.ts +67 -0
- package/dist/task/suppression-probe.js +78 -0
- package/dist/task/task-gates.d.ts +39 -57
- package/dist/task/task-gates.js +115 -195
- package/dist/task/task-io.d.ts +10 -0
- package/dist/task/task-io.js +69 -18
- package/dist/task/task-parsers.js +6 -6
- package/dist/task/task-types.d.ts +7 -0
- package/dist/task/timings.d.ts +11 -0
- package/dist/task/timings.js +21 -0
- package/dist/task/tree-hash.d.ts +8 -0
- package/dist/task/tree-hash.js +55 -0
- package/dist/task/verify-reconcile.d.ts +4 -3
- package/dist/task/verify-reconcile.js +7 -8
- package/dist/task/verify-resolution.d.ts +28 -5
- package/dist/task/verify-resolution.js +70 -13
- package/dist/task/verify-work.d.ts +98 -35
- package/dist/task/verify-work.js +227 -60
- package/dist/task/yolo.d.ts +0 -13
- package/dist/task/yolo.js +9 -21
- package/dist/workers/pi-worker-core.d.ts +20 -0
- package/dist/workers/pi-worker-core.js +16 -11
- package/dist/workers/pi-worker-docs.d.ts +6 -5
- package/dist/workers/pi-worker-docs.js +8 -7
- package/dist/workers/pi-worker-fetch.d.ts +3 -2
- package/dist/workers/pi-worker-fetch.js +5 -4
- package/dist/workers/pi-worker-search.js +5 -4
- package/dist/workers/research-cache.d.ts +23 -1
- package/dist/workers/research-cache.js +63 -3
- package/dist/workers/shared.d.ts +18 -0
- package/dist/workers/shared.js +38 -22
- 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,
|
|
14
|
-
* normalised-substring guard
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
for (const
|
|
301
|
-
|
|
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 >=
|
|
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,
|
|
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)
|
|
377
|
-
* POLICY (a product-wide rule every slice obeys, not one slice's deliverable)
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
402
|
-
|
|
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
|
|
470
|
-
// a task (
|
|
471
|
-
// NONE every round, which forces
|
|
472
|
-
// Carry
|
|
473
|
-
|
|
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[
|
|
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).
|
|
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 &&
|
|
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
|
-
/**
|
|
33
|
-
*
|
|
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:
|
|
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
|
-
|
|
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.
|
|
8
|
-
* for exactly that window, and
|
|
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 {
|
package/dist/task/run-bracket.js
CHANGED
|
@@ -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
|
}
|