@mjasnikovs/pi-task 0.21.9 → 0.22.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/dist/shared/child-process.js +57 -7
- package/dist/task/auto-orchestrator.js +76 -31
- package/dist/task/auto-prompts.d.ts +6 -0
- package/dist/task/auto-prompts.js +6 -0
- package/dist/task/decompose-granularity.d.ts +98 -0
- package/dist/task/decompose-granularity.js +124 -0
- package/dist/task/file-inventory.js +23 -4
- package/package.json +1 -1
|
@@ -335,15 +335,68 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
335
335
|
else
|
|
336
336
|
stdout += chunk;
|
|
337
337
|
});
|
|
338
|
+
// This accumulation is deliberately unbounded, unlike `discardStdout`
|
|
339
|
+
// above. The asymmetry was investigated on 2026-07-28 and the "stderr can
|
|
340
|
+
// overflow V8's max string length" hypothesis is REFUTED at ~5 orders of
|
|
341
|
+
// magnitude — do not re-open it without new volume evidence.
|
|
342
|
+
//
|
|
343
|
+
// Measured ceiling (binary search on `'a'.repeat`, not cited from memory):
|
|
344
|
+
// node v26.2.0 536,870,888 chars (512 MiB, 2^29-24), bun 1.3.14
|
|
345
|
+
// 2,147,483,647 (2 GiB). Both throw RangeError, not OOM — node's default
|
|
346
|
+
// heap is 4192 MiB, well clear of its own string ceiling. If it were ever
|
|
347
|
+
// reached the severity would be high: a throw in this handler escapes as
|
|
348
|
+
// an uncaughtException and the enclosing promise never settles (measured),
|
|
349
|
+
// so it would kill the host, not fail the child.
|
|
350
|
+
//
|
|
351
|
+
// Measured volume, real runs not synthetic: the largest single tool output
|
|
352
|
+
// in a full mx5 run is 5,043 bytes and that whole run's verify-debug.log is
|
|
353
|
+
// 112,216 bytes. The heaviest commands in the system are far under —
|
|
354
|
+
// pi-task's own 2,452-test suite emits 162 bytes of stderr, mx5's
|
|
355
|
+
// prettier+eslint+tsc lint 90, a cached `bun install` 0. Observed max is
|
|
356
|
+
// ~106,000x below the node ceiling; reaching it inside the 15-min command
|
|
357
|
+
// bound would need ~596 KB/s sustained on stderr for the full window.
|
|
358
|
+
//
|
|
359
|
+
// The structural reason is the population of children that reach here:
|
|
360
|
+
// `pi` model children (stderr near-empty), `git`, and one
|
|
361
|
+
// `npm install --loglevel=error`. Genuinely verbose work — test runners,
|
|
362
|
+
// builds, boot probes — does not use runChild; final-gate.ts spawns its own
|
|
363
|
+
// and already caps at 8000 chars. So `discardStdout`'s single caller
|
|
364
|
+
// (docs-core.ts) is not an oversight to mirror: it discards the verbose
|
|
365
|
+
// stream and KEEPS stderr precisely because stderr is that call's payload.
|
|
366
|
+
//
|
|
367
|
+
// A symmetric `discardStderr` would therefore have no caller, and a naive
|
|
368
|
+
// cap here would be actively unsafe: consumers disagree about which end
|
|
369
|
+
// carries the cause — phases.ts:474 takes the tail (-500), phases.ts:936
|
|
370
|
+
// takes the head (0, 300), and child-runner.ts:172 feeds the whole string
|
|
371
|
+
// into failure classification.
|
|
338
372
|
proc.stderr?.on('data', (d) => {
|
|
339
373
|
lastActivity = Date.now();
|
|
340
374
|
streamWatch?.note();
|
|
341
375
|
stderr += d.toString();
|
|
342
376
|
});
|
|
343
|
-
|
|
377
|
+
// One idempotent settle path for close/error/abort. Detaching the abort
|
|
378
|
+
// listener here is the point: `{once: true}` only fires-and-removes on an
|
|
379
|
+
// ACTUAL abort, so a child that finishes normally used to leave its
|
|
380
|
+
// listener on the signal forever. A TaskRunner shares ONE AbortController
|
|
381
|
+
// across every child of a run, so those listeners accumulated linearly and
|
|
382
|
+
// each one retained, via `killProc`'s closure, the finished child process,
|
|
383
|
+
// this invocation (including its full prompt), and `opts` — together with
|
|
384
|
+
// everything the caller's callbacks close over. See GitHub issue #9.
|
|
385
|
+
let settled = false;
|
|
386
|
+
const cleanup = () => {
|
|
344
387
|
if (stallTimer)
|
|
345
388
|
clearInterval(stallTimer);
|
|
346
389
|
streamWatch?.stop();
|
|
390
|
+
signal?.removeEventListener('abort', killProc);
|
|
391
|
+
};
|
|
392
|
+
const settle = (result) => {
|
|
393
|
+
cleanup();
|
|
394
|
+
if (settled)
|
|
395
|
+
return;
|
|
396
|
+
settled = true;
|
|
397
|
+
resolve(result);
|
|
398
|
+
};
|
|
399
|
+
proc.once('close', (code) => {
|
|
347
400
|
// The child has exited, but anything it backgrounded (a dev server) may
|
|
348
401
|
// still hold its process group and a port — reap the group so the next
|
|
349
402
|
// gate's boot check does not collide with our own orphan. Best-effort:
|
|
@@ -355,7 +408,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
355
408
|
if (sink)
|
|
356
409
|
sink.flush();
|
|
357
410
|
const text = sink ? sink.text : undefined;
|
|
358
|
-
|
|
411
|
+
settle({
|
|
359
412
|
stdout,
|
|
360
413
|
stderr,
|
|
361
414
|
exitCode: code ?? 0,
|
|
@@ -368,11 +421,8 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
368
421
|
: {})
|
|
369
422
|
});
|
|
370
423
|
});
|
|
371
|
-
proc.
|
|
372
|
-
|
|
373
|
-
clearInterval(stallTimer);
|
|
374
|
-
streamWatch?.stop();
|
|
375
|
-
resolve({ stdout, stderr, exitCode: 1, aborted });
|
|
424
|
+
proc.once('error', () => {
|
|
425
|
+
settle({ stdout, stderr, exitCode: 1, aborted });
|
|
376
426
|
});
|
|
377
427
|
if (signal) {
|
|
378
428
|
if (signal.aborted)
|
|
@@ -42,6 +42,7 @@ import { isYoloMode, yoloPickAnswer, yoloFinalGateChoice, YOLO_STAMP } from './y
|
|
|
42
42
|
import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
|
|
43
43
|
import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
|
|
44
44
|
import { reconcileTitleSources } from './decompose-fidelity.js';
|
|
45
|
+
import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
|
|
45
46
|
import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
|
|
46
47
|
import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
|
|
47
48
|
import { decideAdoption, groundedCoverage } from './coverage-loop.js';
|
|
@@ -424,6 +425,54 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
424
425
|
taskId: '',
|
|
425
426
|
signal: new AbortController().signal
|
|
426
427
|
}).catch(() => '');
|
|
428
|
+
// Requirement extraction (mx5 run 11, goal A): grounded requirement units,
|
|
429
|
+
// extracted from whatever structure the spec has, BEFORE decompose — they ride
|
|
430
|
+
// into the decompose prompt as a ledger (structure-mirroring can't discharge
|
|
431
|
+
// them) and drive the per-requirement coverage accounting below.
|
|
432
|
+
//
|
|
433
|
+
// Runs BEFORE clarify (it depends only on the inlined feature, never on the
|
|
434
|
+
// answers) so the plan-shape gate below has a real count to judge with: the
|
|
435
|
+
// host must not seize the granularity fork on a spec that has no breakdown to
|
|
436
|
+
// speak of. Best-effort:
|
|
437
|
+
// a fault leaves reqEntries empty and the whole channel degrades to the old
|
|
438
|
+
// behavior (one-liners / doc-less features naturally yield few or none).
|
|
439
|
+
let reqEntries = [];
|
|
440
|
+
try {
|
|
441
|
+
// Recall floor: the obligation-marked passages ride into the prompt as a
|
|
442
|
+
// checklist, and a marked passage that produced NO quote is hard evidence
|
|
443
|
+
// for one forced re-extraction (measured live: 1/5 extractions missed the
|
|
444
|
+
// entire marked testing section without this).
|
|
445
|
+
const passages = enumerateObligationPassages(featureForModel);
|
|
446
|
+
const extractOnce = async (hint) => keepGroundedRequirements(parseRequirementLines(await deps.runChild('requirement-extract', '', prependHint(hint, REQUIREMENT_EXTRACT_PROMPT(featureForModel, passages)))), featureForModel);
|
|
447
|
+
reqEntries = await extractOnce(null);
|
|
448
|
+
const uncovered = uncoveredPassages(passages, reqEntries);
|
|
449
|
+
if (uncovered.length > 0) {
|
|
450
|
+
logPlanDebug(cwd, `requirement extraction: ${uncovered.length} obligation-marked passage(s) `
|
|
451
|
+
+ 'uncovered — forcing one re-extraction');
|
|
452
|
+
const retry = await extractOnce(extractionRetryHint(uncovered));
|
|
453
|
+
// Union of both grounded passes (keepGrounded dedupes).
|
|
454
|
+
reqEntries = keepGroundedRequirements([...reqEntries, ...retry], featureForModel);
|
|
455
|
+
}
|
|
456
|
+
// Bound with marked-passage priority — a plain first-N cap truncates the
|
|
457
|
+
// doc's tail sections (measured live: an eager model fills 40 top-down).
|
|
458
|
+
reqEntries = capRequirements(reqEntries, passages, featureForModel);
|
|
459
|
+
logPlanDebug(cwd, `requirement extraction: ${reqEntries.length} grounded requirement(s) kept`);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// best-effort channel
|
|
463
|
+
}
|
|
464
|
+
// Granularity floor (mx5 Jul 25 vs Jul 27): the plan's task COUNT was being set
|
|
465
|
+
// by an auto-resolved clarify line the user never saw — the same spec planned
|
|
466
|
+
// into 41 tasks one day and 11 the next, with identical code. Derive the floor
|
|
467
|
+
// from the requirements a task can own, so an unreviewable "one task per
|
|
468
|
+
// milestone" decision cannot collapse the plan; it also gates whether the
|
|
469
|
+
// plan-shape fork below is the host's to answer at all. 0 ownable ⇒ no channel.
|
|
470
|
+
const ownableRequirements = reqEntries.filter(e => !isCrossCuttingRequirement(e.quote)).length;
|
|
471
|
+
const coarseFloor = granularityFloor(ownableRequirements);
|
|
472
|
+
if (coarseFloor > 0) {
|
|
473
|
+
logPlanDebug(cwd, `granularity floor: ${ownableRequirements} ownable requirement(s) ⇒ at least `
|
|
474
|
+
+ `${coarseFloor} task(s)`);
|
|
475
|
+
}
|
|
427
476
|
const answers = [];
|
|
428
477
|
// Plain text of every question already shown, for the duplicate backstop.
|
|
429
478
|
const askedQuestions = [];
|
|
@@ -458,6 +507,19 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
458
507
|
const shownQ = renderInlineMarkdown(question, theme);
|
|
459
508
|
const plainQ = stripInlineMarkdown(question);
|
|
460
509
|
askedQuestions.push(plainQ);
|
|
510
|
+
// PLAN SHAPE is the host's call, not the triage's (mx5 41→11 tasks on the
|
|
511
|
+
// same spec, same base commit, same code — see decompose-granularity.ts).
|
|
512
|
+
// The triage answers this fork for itself in 8/8 live reps and stamps it
|
|
513
|
+
// "already settled by the spec" while the spec settles no such thing, so the
|
|
514
|
+
// single most load-bearing decision in a run was an invisible coin flip.
|
|
515
|
+
// Answer it deterministically instead: same channel, same transcript, but a
|
|
516
|
+
// fixed value the user can read in the AUTO file and override next run.
|
|
517
|
+
if (planShapeIsHostsToAnswer(ownableRequirements) && isPlanShapeQuestion(plainQ)) {
|
|
518
|
+
logPlanDebug(cwd, `plan-shape question answered host-side (not the triage): ${plainQ.replace(/\s+/g, ' ').slice(0, 120)}`);
|
|
519
|
+
answers.push(`Q${answers.length + 1}: ${plainQ}\n`
|
|
520
|
+
+ `A${answers.length + 1}: ${PLAN_SHAPE_ANSWER} (host-set — plan granularity is not left to chance)`);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
461
523
|
// Answer-side triage (grill parity): if the inlined spec already settles
|
|
462
524
|
// this question, auto-resolve it and never show it. The resolved value is
|
|
463
525
|
// recorded so decompose sees the decision and the next gen call's priorQA
|
|
@@ -544,37 +606,6 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
544
606
|
ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
|
|
545
607
|
}
|
|
546
608
|
const clarifications = answers.join('\n');
|
|
547
|
-
// Requirement extraction (mx5 run 11, goal A): grounded requirement units,
|
|
548
|
-
// extracted from whatever structure the spec has, BEFORE decompose — they ride
|
|
549
|
-
// into the decompose prompt as a ledger (structure-mirroring can't discharge
|
|
550
|
-
// them) and drive the per-requirement coverage accounting below. Best-effort:
|
|
551
|
-
// a fault leaves reqEntries empty and the whole channel degrades to the old
|
|
552
|
-
// behavior (one-liners / doc-less features naturally yield few or none).
|
|
553
|
-
let reqEntries = [];
|
|
554
|
-
try {
|
|
555
|
-
// Recall floor: the obligation-marked passages ride into the prompt as a
|
|
556
|
-
// checklist, and a marked passage that produced NO quote is hard evidence
|
|
557
|
-
// for one forced re-extraction (measured live: 1/5 extractions missed the
|
|
558
|
-
// entire marked testing section without this).
|
|
559
|
-
const passages = enumerateObligationPassages(featureForModel);
|
|
560
|
-
const extractOnce = async (hint) => keepGroundedRequirements(parseRequirementLines(await deps.runChild('requirement-extract', '', prependHint(hint, REQUIREMENT_EXTRACT_PROMPT(featureForModel, passages)))), featureForModel);
|
|
561
|
-
reqEntries = await extractOnce(null);
|
|
562
|
-
const uncovered = uncoveredPassages(passages, reqEntries);
|
|
563
|
-
if (uncovered.length > 0) {
|
|
564
|
-
logPlanDebug(cwd, `requirement extraction: ${uncovered.length} obligation-marked passage(s) `
|
|
565
|
-
+ 'uncovered — forcing one re-extraction');
|
|
566
|
-
const retry = await extractOnce(extractionRetryHint(uncovered));
|
|
567
|
-
// Union of both grounded passes (keepGrounded dedupes).
|
|
568
|
-
reqEntries = keepGroundedRequirements([...reqEntries, ...retry], featureForModel);
|
|
569
|
-
}
|
|
570
|
-
// Bound with marked-passage priority — a plain first-N cap truncates the
|
|
571
|
-
// doc's tail sections (measured live: an eager model fills 40 top-down).
|
|
572
|
-
reqEntries = capRequirements(reqEntries, passages, featureForModel);
|
|
573
|
-
logPlanDebug(cwd, `requirement extraction: ${reqEntries.length} grounded requirement(s) kept`);
|
|
574
|
-
}
|
|
575
|
-
catch {
|
|
576
|
-
// best-effort channel
|
|
577
|
-
}
|
|
578
609
|
// Artifact-production closure, plan side (mx5 run 13, PROMPT 2): runtime
|
|
579
610
|
// files the spec REFERENCES (server snippets, prose "serve the built
|
|
580
611
|
// index.html") that neither its file tree, its parsed build outputs, nor the
|
|
@@ -636,6 +667,20 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
636
667
|
const listRaw = await deps.runChild('auto-decompose', 'read', decomposePrompt);
|
|
637
668
|
let planTitles = parsePlan(listRaw);
|
|
638
669
|
logPlanDebug(cwd, `decompose produced ${planTitles.length} title(s)`);
|
|
670
|
+
// BRACES for the floor: the prompt clause alone is a preference the model can
|
|
671
|
+
// ignore, so a plan under the floor is sent back ONCE to be split (never
|
|
672
|
+
// regenerated — a fresh roll can drop a covered area, mx5 run 12). Longer plan
|
|
673
|
+
// wins; a still-coarse plan falls through to the coverage judge as before, so
|
|
674
|
+
// this can never block planning.
|
|
675
|
+
if (isTooCoarse(planTitles.length, coarseFloor)) {
|
|
676
|
+
logPlanDebug(cwd, `plan under the granularity floor (${planTitles.length} < ${coarseFloor}) — `
|
|
677
|
+
+ 'reprompting once to split');
|
|
678
|
+
const splitRaw = await deps.runChild('auto-decompose', 'read', prependHint(granularitySplitHint(planTitles.length, ownableRequirements), decomposePrompt));
|
|
679
|
+
const splitTitles = parsePlan(splitRaw);
|
|
680
|
+
logPlanDebug(cwd, `granularity split-retry produced ${splitTitles.length} title(s)`);
|
|
681
|
+
if (splitTitles.length > planTitles.length)
|
|
682
|
+
planTitles = splitTitles;
|
|
683
|
+
}
|
|
639
684
|
// Distrust floor (see isSuspectPlan): a ≤2-title plan for a multi-KB spec is
|
|
640
685
|
// regenerated once BEFORE the judge runs — the judge cannot be trusted to
|
|
641
686
|
// catch it (3/10 live false-pass) and a hinted retry heals it reliably
|
|
@@ -16,6 +16,12 @@ export declare const AUTO_CLARIFY_PROMPT: (feature: string, priorQA: string) =>
|
|
|
16
16
|
* `noBatchTests` adds the anti-batch-test rule (batch-test-task.ts) — emitted ONLY
|
|
17
17
|
* when the decisions mandate tests-in-the-same-change, so every other run sees the
|
|
18
18
|
* prompt it always saw. It is the belt; the host-side rewrite is the lever.
|
|
19
|
+
*
|
|
20
|
+
* Plan GRANULARITY is deliberately NOT a rule here — it rides in CLARIFICATIONS
|
|
21
|
+
* (decompose-granularity.ts). Live A/B: as a RULES line replacing "prefer a
|
|
22
|
+
* handful", it made plan size explode (81 and 85 titles for a spec whose healthy
|
|
23
|
+
* plan is ~30, one 120k-context blowup); as a clarification, with the counterweight
|
|
24
|
+
* below left intact, the same directive holds 20–39 across 16 reps.
|
|
19
25
|
*/
|
|
20
26
|
export declare const AUTO_DECOMPOSE_PROMPT: (feature: string, clarifications: string, requirementsLedger?: string, noBatchTests?: boolean) => string;
|
|
21
27
|
/**
|
|
@@ -76,6 +76,12 @@ NONE`;
|
|
|
76
76
|
* `noBatchTests` adds the anti-batch-test rule (batch-test-task.ts) — emitted ONLY
|
|
77
77
|
* when the decisions mandate tests-in-the-same-change, so every other run sees the
|
|
78
78
|
* prompt it always saw. It is the belt; the host-side rewrite is the lever.
|
|
79
|
+
*
|
|
80
|
+
* Plan GRANULARITY is deliberately NOT a rule here — it rides in CLARIFICATIONS
|
|
81
|
+
* (decompose-granularity.ts). Live A/B: as a RULES line replacing "prefer a
|
|
82
|
+
* handful", it made plan size explode (81 and 85 titles for a spec whose healthy
|
|
83
|
+
* plan is ~30, one 120k-context blowup); as a clarification, with the counterweight
|
|
84
|
+
* below left intact, the same directive holds 20–39 across 16 reps.
|
|
79
85
|
*/
|
|
80
86
|
export const AUTO_DECOMPOSE_PROMPT = (feature, clarifications, requirementsLedger = '', noBatchTests = false) => `Split this feature into an ordered list of implementation tasks. Each task
|
|
81
87
|
will be handed, by its title, to a separate pipeline that does its own research
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decompose-granularity — the deterministic FLOOR on how finely /task-auto cuts
|
|
3
|
+
* a feature into tasks.
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (mx5, Jul 25 vs Jul 27): the SAME design doc, from the
|
|
6
|
+
* SAME base commit, with a byte-identical planning path, planned once into 41
|
|
7
|
+
* tasks and once into 11. The whole 4x difference is one line of clarify text.
|
|
8
|
+
* /task-auto's clarify head asks a plan-shape question first ("one task per
|
|
9
|
+
* milestone, or split smaller?" — 8/8 live reps), the answer-side triage
|
|
10
|
+
* auto-resolves it 8/8 and stamps it "already settled by the spec", so the user
|
|
11
|
+
* never sees the fork; and the answer decides the whole plan. Live A/B, run 18's
|
|
12
|
+
* transcript with ONLY that line swapped (n=8/arm): coarse mean 11.4 titles vs
|
|
13
|
+
* fine mean 28.6, 63.5/64 pairwise wins, p<0.001.
|
|
14
|
+
*
|
|
15
|
+
* The spec does NOT settle it. mx5's §12 is titled "Build order (milestones)" —
|
|
16
|
+
* an ORDER, 9 items, not a task breakdown. So the plan's granularity, the single
|
|
17
|
+
* highest-leverage decision in a run (each title is handed to its own pipeline
|
|
18
|
+
* that researches and specs it alone), was being decided by a coin flip nobody
|
|
19
|
+
* could see, review, or reproduce.
|
|
20
|
+
*
|
|
21
|
+
* Surfacing the question does not fix it: under YOLO/unattended `yoloPickAnswer`
|
|
22
|
+
* takes the recommendation, which is the same stochastic line. The fix has to be
|
|
23
|
+
* host-side and deterministic, so this module derives the floor from integers the
|
|
24
|
+
* run already has — the count of grounded requirements that a task can OWN.
|
|
25
|
+
*
|
|
26
|
+
* floor = ceil(ownable requirements / MAX_REQUIREMENTS_PER_TASK)
|
|
27
|
+
*
|
|
28
|
+
* MAX_REQUIREMENTS_PER_TASK = 2 is anchored on the two real mx5 plans, not on
|
|
29
|
+
* taste: the 41-task plan carried 0.8 ownable requirements per task, the collapsed
|
|
30
|
+
* 11-task plan carried 2.8. A ceiling of 2 sits between them — it rejects the
|
|
31
|
+
* collapse without demanding the finest plan ever observed.
|
|
32
|
+
*
|
|
33
|
+
* Spec-shape-agnostic: the only inputs are two integers. A CLI, a library, a
|
|
34
|
+
* refactor, a docs job all flow through the same arithmetic, and a feature with
|
|
35
|
+
* no extracted requirements (a one-liner, a doc-less request) yields floor 0 —
|
|
36
|
+
* the whole channel degrades to exactly the previous behaviour.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* The most distinct grounded requirements one task may carry before the plan is
|
|
40
|
+
* judged too coarse. See the module docstring for the mx5 anchoring.
|
|
41
|
+
*/
|
|
42
|
+
export declare const MAX_REQUIREMENTS_PER_TASK = 2;
|
|
43
|
+
/**
|
|
44
|
+
* Fewest task titles a plan may have for `ownable` requirements. Zero when the
|
|
45
|
+
* requirement channel produced nothing, which disables every check below.
|
|
46
|
+
*/
|
|
47
|
+
export declare function granularityFloor(ownable: number): number;
|
|
48
|
+
/** Is this plan too coarse for the requirements it has to carry? */
|
|
49
|
+
export declare function isTooCoarse(titles: number, floor: number): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Fewest ownable requirements a feature needs before the host takes the
|
|
52
|
+
* plan-shape fork away from the triage.
|
|
53
|
+
*
|
|
54
|
+
* Below this the fork is not load-bearing — the whole plan is one or two tasks
|
|
55
|
+
* either way — and seizing it does harm: live smoke over 24 prompts, the host
|
|
56
|
+
* directive turned "rename the `foo()` helper across the repo" (1 requirement)
|
|
57
|
+
* into a 6-task plan and "add a .editorconfig" (4) into 4. Every case at or above
|
|
58
|
+
* this cut planned inside its expected range. So: a feature with real breadth
|
|
59
|
+
* gets the deterministic answer, a chore keeps the old triage path untouched.
|
|
60
|
+
*/
|
|
61
|
+
export declare const MIN_REQUIREMENTS_FOR_PLAN_SHAPE = 5;
|
|
62
|
+
/** Does this feature have enough distinct deliverables for granularity to matter? */
|
|
63
|
+
export declare function planShapeIsHostsToAnswer(ownable: number): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Does this clarify question decide how finely the feature is CUT into tasks?
|
|
66
|
+
*
|
|
67
|
+
* Deterministic and narrow on purpose. It must fire on the fork the triage keeps
|
|
68
|
+
* answering for itself ("follow the milestones as-is, or split more granularly?")
|
|
69
|
+
* and stay off ordinary scope questions — an over-eager classifier would replace
|
|
70
|
+
* a real user decision with the host's. Matched against the plain-text question.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isPlanShapeQuestion(question: string): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* BELT — the host's own answer to that fork, recorded in the clarify transcript in
|
|
75
|
+
* place of the triage's.
|
|
76
|
+
*
|
|
77
|
+
* WHY A CLARIFICATION AND NOT A DECOMPOSE RULE. Both were measured live. As a
|
|
78
|
+
* RULES line replacing "prefer a handful of substantial tasks", the same directive
|
|
79
|
+
* removed the collapse but destroyed plan-size control: 66, then 81 and 85 titles
|
|
80
|
+
* for a spec whose healthy plan is ~30, plus one decompose child that blew the
|
|
81
|
+
* model's 120k context window and killed the planning phase (the baseline produced
|
|
82
|
+
* no such failure in 27 reps). Naming a target count made it worse, not better.
|
|
83
|
+
* In the CLARIFICATIONS block, with the "prefer a handful" counterweight left
|
|
84
|
+
* intact, the identical directive held 20–39 titles across 16 reps. The channel is
|
|
85
|
+
* part of the lever, not a detail.
|
|
86
|
+
*
|
|
87
|
+
* Deliberately count-free: the spec-derived floor stays host-side, where it is
|
|
88
|
+
* enforced silently and cannot be chased.
|
|
89
|
+
*/
|
|
90
|
+
export declare const PLAN_SHAPE_QUESTION = "How finely should this feature be split into tasks?";
|
|
91
|
+
export declare const PLAN_SHAPE_ANSWER: string;
|
|
92
|
+
/**
|
|
93
|
+
* BRACES — the reprompt when the returned plan lands under the floor. Also
|
|
94
|
+
* countless, for the reason above: it asks for a SPLIT of the plan in hand rather
|
|
95
|
+
* than a fresh roll (a regeneration is a new stochastic draw over the whole plan
|
|
96
|
+
* and can drop an area the current one covers — mx5 run 12).
|
|
97
|
+
*/
|
|
98
|
+
export declare function granularitySplitHint(titles: number, ownable: number): string;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decompose-granularity — the deterministic FLOOR on how finely /task-auto cuts
|
|
3
|
+
* a feature into tasks.
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (mx5, Jul 25 vs Jul 27): the SAME design doc, from the
|
|
6
|
+
* SAME base commit, with a byte-identical planning path, planned once into 41
|
|
7
|
+
* tasks and once into 11. The whole 4x difference is one line of clarify text.
|
|
8
|
+
* /task-auto's clarify head asks a plan-shape question first ("one task per
|
|
9
|
+
* milestone, or split smaller?" — 8/8 live reps), the answer-side triage
|
|
10
|
+
* auto-resolves it 8/8 and stamps it "already settled by the spec", so the user
|
|
11
|
+
* never sees the fork; and the answer decides the whole plan. Live A/B, run 18's
|
|
12
|
+
* transcript with ONLY that line swapped (n=8/arm): coarse mean 11.4 titles vs
|
|
13
|
+
* fine mean 28.6, 63.5/64 pairwise wins, p<0.001.
|
|
14
|
+
*
|
|
15
|
+
* The spec does NOT settle it. mx5's §12 is titled "Build order (milestones)" —
|
|
16
|
+
* an ORDER, 9 items, not a task breakdown. So the plan's granularity, the single
|
|
17
|
+
* highest-leverage decision in a run (each title is handed to its own pipeline
|
|
18
|
+
* that researches and specs it alone), was being decided by a coin flip nobody
|
|
19
|
+
* could see, review, or reproduce.
|
|
20
|
+
*
|
|
21
|
+
* Surfacing the question does not fix it: under YOLO/unattended `yoloPickAnswer`
|
|
22
|
+
* takes the recommendation, which is the same stochastic line. The fix has to be
|
|
23
|
+
* host-side and deterministic, so this module derives the floor from integers the
|
|
24
|
+
* run already has — the count of grounded requirements that a task can OWN.
|
|
25
|
+
*
|
|
26
|
+
* floor = ceil(ownable requirements / MAX_REQUIREMENTS_PER_TASK)
|
|
27
|
+
*
|
|
28
|
+
* MAX_REQUIREMENTS_PER_TASK = 2 is anchored on the two real mx5 plans, not on
|
|
29
|
+
* taste: the 41-task plan carried 0.8 ownable requirements per task, the collapsed
|
|
30
|
+
* 11-task plan carried 2.8. A ceiling of 2 sits between them — it rejects the
|
|
31
|
+
* collapse without demanding the finest plan ever observed.
|
|
32
|
+
*
|
|
33
|
+
* Spec-shape-agnostic: the only inputs are two integers. A CLI, a library, a
|
|
34
|
+
* refactor, a docs job all flow through the same arithmetic, and a feature with
|
|
35
|
+
* no extracted requirements (a one-liner, a doc-less request) yields floor 0 —
|
|
36
|
+
* the whole channel degrades to exactly the previous behaviour.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* The most distinct grounded requirements one task may carry before the plan is
|
|
40
|
+
* judged too coarse. See the module docstring for the mx5 anchoring.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_REQUIREMENTS_PER_TASK = 2;
|
|
43
|
+
/**
|
|
44
|
+
* Fewest task titles a plan may have for `ownable` requirements. Zero when the
|
|
45
|
+
* requirement channel produced nothing, which disables every check below.
|
|
46
|
+
*/
|
|
47
|
+
export function granularityFloor(ownable) {
|
|
48
|
+
return ownable <= 0 ? 0 : Math.ceil(ownable / MAX_REQUIREMENTS_PER_TASK);
|
|
49
|
+
}
|
|
50
|
+
/** Is this plan too coarse for the requirements it has to carry? */
|
|
51
|
+
export function isTooCoarse(titles, floor) {
|
|
52
|
+
return floor > 0 && titles < floor;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fewest ownable requirements a feature needs before the host takes the
|
|
56
|
+
* plan-shape fork away from the triage.
|
|
57
|
+
*
|
|
58
|
+
* Below this the fork is not load-bearing — the whole plan is one or two tasks
|
|
59
|
+
* either way — and seizing it does harm: live smoke over 24 prompts, the host
|
|
60
|
+
* directive turned "rename the `foo()` helper across the repo" (1 requirement)
|
|
61
|
+
* into a 6-task plan and "add a .editorconfig" (4) into 4. Every case at or above
|
|
62
|
+
* this cut planned inside its expected range. So: a feature with real breadth
|
|
63
|
+
* gets the deterministic answer, a chore keeps the old triage path untouched.
|
|
64
|
+
*/
|
|
65
|
+
export const MIN_REQUIREMENTS_FOR_PLAN_SHAPE = 5;
|
|
66
|
+
/** Does this feature have enough distinct deliverables for granularity to matter? */
|
|
67
|
+
export function planShapeIsHostsToAnswer(ownable) {
|
|
68
|
+
return ownable >= MIN_REQUIREMENTS_FOR_PLAN_SHAPE;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Does this clarify question decide how finely the feature is CUT into tasks?
|
|
72
|
+
*
|
|
73
|
+
* Deterministic and narrow on purpose. It must fire on the fork the triage keeps
|
|
74
|
+
* answering for itself ("follow the milestones as-is, or split more granularly?")
|
|
75
|
+
* and stay off ordinary scope questions — an over-eager classifier would replace
|
|
76
|
+
* a real user decision with the host's. Matched against the plain-text question.
|
|
77
|
+
*/
|
|
78
|
+
export function isPlanShapeQuestion(question) {
|
|
79
|
+
const q = question.toLowerCase();
|
|
80
|
+
// The fork has to be about the BREAKDOWN itself…
|
|
81
|
+
const aboutBreakdown = /\b(task breakdown|break(ing)? (it|this|the \w+) down|decompos\w*|split\w*|subdivid\w*|granular\w*|fine[- ]grained|one task per|task per (milestone|section|step|phase|feature)|per[- ](milestone|route|component|page|module)\b|(own|separate|standalone|dedicated|self[- ]contained|single)\s+(\w+\s+)?tasks?\b)/.test(q);
|
|
82
|
+
if (!aboutBreakdown)
|
|
83
|
+
return false;
|
|
84
|
+
// …and offer a coarse/fine choice over the plan's own units.
|
|
85
|
+
return /\b(milestone|section|step|phase|task|tasks)\b/.test(q);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* BELT — the host's own answer to that fork, recorded in the clarify transcript in
|
|
89
|
+
* place of the triage's.
|
|
90
|
+
*
|
|
91
|
+
* WHY A CLARIFICATION AND NOT A DECOMPOSE RULE. Both were measured live. As a
|
|
92
|
+
* RULES line replacing "prefer a handful of substantial tasks", the same directive
|
|
93
|
+
* removed the collapse but destroyed plan-size control: 66, then 81 and 85 titles
|
|
94
|
+
* for a spec whose healthy plan is ~30, plus one decompose child that blew the
|
|
95
|
+
* model's 120k context window and killed the planning phase (the baseline produced
|
|
96
|
+
* no such failure in 27 reps). Naming a target count made it worse, not better.
|
|
97
|
+
* In the CLARIFICATIONS block, with the "prefer a handful" counterweight left
|
|
98
|
+
* intact, the identical directive held 20–39 titles across 16 reps. The channel is
|
|
99
|
+
* part of the lever, not a detail.
|
|
100
|
+
*
|
|
101
|
+
* Deliberately count-free: the spec-derived floor stays host-side, where it is
|
|
102
|
+
* enforced silently and cannot be chased.
|
|
103
|
+
*/
|
|
104
|
+
export const PLAN_SHAPE_QUESTION = 'How finely should this feature be split into tasks?';
|
|
105
|
+
export const PLAN_SHAPE_ANSWER = 'subdivide into smaller per-deliverable tasks — one task per route, page, screen,'
|
|
106
|
+
+ ' module, schema, or pipeline stage — rather than one task per milestone or spec'
|
|
107
|
+
+ ' section. A milestone or section that spans several deliverables becomes several'
|
|
108
|
+
+ ' tasks. (host-set: the spec fixes the build ORDER, not the task breakdown, so'
|
|
109
|
+
+ ' pi-task settles granularity deterministically instead of guessing per run)';
|
|
110
|
+
/**
|
|
111
|
+
* BRACES — the reprompt when the returned plan lands under the floor. Also
|
|
112
|
+
* countless, for the reason above: it asks for a SPLIT of the plan in hand rather
|
|
113
|
+
* than a fresh roll (a regeneration is a new stochastic draw over the whole plan
|
|
114
|
+
* and can drop an area the current one covers — mx5 run 12).
|
|
115
|
+
*/
|
|
116
|
+
export function granularitySplitHint(titles, ownable) {
|
|
117
|
+
return (`[SYSTEM NOTE: your plan of ${titles} task(s) is too coarse for the`
|
|
118
|
+
+ ` ${ownable} required contents this spec lists — several tasks each bundle work`
|
|
119
|
+
+ ' that belongs in separate ones. Emit the SAME plan with those tasks SPLIT: keep'
|
|
120
|
+
+ ' every task that is already one deliverable, and break each one that bundles'
|
|
121
|
+
+ ' several routes, pages, modules, or pipeline stages into one task per piece.'
|
|
122
|
+
+ ' Do not drop anything, do not merge, do not pad with trivia — split what is'
|
|
123
|
+
+ ' already there.]');
|
|
124
|
+
}
|
|
@@ -37,12 +37,31 @@ function runGitLsFiles(cwd, signal) {
|
|
|
37
37
|
proc.stdout?.on('data', (d) => {
|
|
38
38
|
stdout += d.toString();
|
|
39
39
|
});
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// Same detach discipline as runChild (GitHub issue #9): `signal` is the
|
|
41
|
+
// run-long orchestrator signal, so a listener left behind by a normally
|
|
42
|
+
// finished `git ls-files` would retain that child for the whole run.
|
|
43
|
+
const onAbort = () => {
|
|
43
44
|
if (!proc.killed)
|
|
44
45
|
proc.kill('SIGTERM');
|
|
45
|
-
}
|
|
46
|
+
};
|
|
47
|
+
let settled = false;
|
|
48
|
+
const settle = (value) => {
|
|
49
|
+
signal?.removeEventListener('abort', onAbort);
|
|
50
|
+
if (settled)
|
|
51
|
+
return;
|
|
52
|
+
settled = true;
|
|
53
|
+
resolve(value);
|
|
54
|
+
};
|
|
55
|
+
proc.once('error', () => settle(''));
|
|
56
|
+
proc.once('close', code => settle(code === 0 ? stdout : ''));
|
|
57
|
+
if (signal) {
|
|
58
|
+
// An already-aborted signal never emits 'abort', so without this check
|
|
59
|
+
// a run cancelled before this point would let the child run to term.
|
|
60
|
+
if (signal.aborted)
|
|
61
|
+
onAbort();
|
|
62
|
+
else
|
|
63
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
64
|
+
}
|
|
46
65
|
});
|
|
47
66
|
}
|
|
48
67
|
/** Cap output to maxLines real (non-blank) paths; tag truncation when cut. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|