@mjasnikovs/pi-task 0.38.16 → 0.38.17
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/config/config.d.ts +26 -0
- package/dist/config/config.js +68 -17
- package/dist/task/accept-debt.js +2 -1
- package/dist/task/artifact-closure.js +18 -63
- package/dist/task/auto-orchestrator.js +205 -214
- package/dist/task/boot-probe.d.ts +46 -0
- package/dist/task/boot-probe.js +41 -21
- package/dist/task/coverage-loop.d.ts +11 -0
- package/dist/task/coverage-loop.js +16 -0
- package/dist/task/final-gate-fix.js +14 -24
- package/dist/task/final-gate.js +6 -1
- package/dist/task/fix-child.d.ts +64 -0
- package/dist/task/fix-child.js +66 -0
- package/dist/task/lint-fix.d.ts +7 -0
- package/dist/task/lint-fix.js +45 -9
- package/dist/task/orchestrator.js +9 -2
- package/dist/task/phases.d.ts +66 -4
- package/dist/task/phases.js +94 -34
- package/dist/task/plan-rounds.d.ts +86 -0
- package/dist/task/plan-rounds.js +105 -0
- package/dist/task/plan-session.d.ts +31 -21
- package/dist/task/plan-session.js +97 -120
- package/dist/task/qa-transcript.d.ts +100 -0
- package/dist/task/qa-transcript.js +99 -0
- package/dist/task/question-source.d.ts +117 -0
- package/dist/task/question-source.js +174 -0
- package/dist/task/serve-entry.js +6 -57
- package/dist/task/shipped-source.d.ts +67 -0
- package/dist/task/shipped-source.js +144 -0
- package/dist/task/task-gates.d.ts +1 -1
- package/dist/task/task-gates.js +4 -2
- package/dist/task/verify-work.d.ts +46 -0
- package/dist/task/verify-work.js +51 -3
- package/dist/workers/docs-core.d.ts +71 -1
- package/dist/workers/docs-core.js +131 -71
- package/dist/workers/pi-worker-core.js +23 -8
- package/package.json +1 -1
|
@@ -10,11 +10,10 @@ import * as fsp from 'node:fs/promises';
|
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { gateRunTask, markResumable } from './orchestrator.js';
|
|
12
12
|
import { RUN_END_POLICY, runSucceeded } from './run-end.js';
|
|
13
|
-
import {
|
|
13
|
+
import { parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
|
|
14
14
|
import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
|
|
15
15
|
import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
|
|
16
16
|
import { GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT } from './prompts.js';
|
|
17
|
-
import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
|
|
18
17
|
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseCoverageVerdict, parseTaskList, checkOffTask, stampTaskInProgress, insertTaskAfter, findResumableAutoDetailed } from './auto-io.js';
|
|
19
18
|
import { decideResume, UNATTENDED_STATES } from './resume-gap.js';
|
|
20
19
|
import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairTitleFile, buildRepairTitle, buildRepairScopeFence, extractFailingCommand } from './root-cause-repair.js';
|
|
@@ -36,14 +35,18 @@ import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
|
|
|
36
35
|
import { spawnCommand } from './command-run.js';
|
|
37
36
|
import { getConfig } from '../config/config.js';
|
|
38
37
|
import { debugLogLevel, shouldLogDebug } from './debug-log.js';
|
|
39
|
-
import { isYoloMode, yoloPickAnswer
|
|
38
|
+
import { isYoloMode, yoloPickAnswer } from './yolo.js';
|
|
39
|
+
import { QaTranscript, CLARIFY_QA_POLICY } from './qa-transcript.js';
|
|
40
|
+
import { makeQuestionSource } from './question-source.js';
|
|
41
|
+
import { CoverageLedger } from './plan-rounds.js';
|
|
42
|
+
import { CLARIFY_QUALITY_RULES, PLAN_FORMAT_HINT } from './plan-session.js';
|
|
40
43
|
import { configureResearchRun, resumeResearchRun } from '../workers/research-cache.js';
|
|
41
44
|
import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, appendContracts } from './contracts.js';
|
|
42
45
|
import { reconcileTitleSources } from './decompose-fidelity.js';
|
|
43
46
|
import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
|
|
44
47
|
import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
|
|
45
48
|
import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
|
|
46
|
-
import {
|
|
49
|
+
import { groundedCoverage } from './coverage-loop.js';
|
|
47
50
|
import { buildOptionCards, resolveAnswer } from './question-dialog.js';
|
|
48
51
|
import { TERMINAL_OUTCOMES, formatAt, formatWhy } from './terminal-outcome.js';
|
|
49
52
|
import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
|
|
@@ -155,17 +158,6 @@ function logPlanDebug(cwd, msg) {
|
|
|
155
158
|
.then(() => fsp.appendFile(path.join(dir, 'plan-debug.log'), line))
|
|
156
159
|
.catch(() => { });
|
|
157
160
|
}
|
|
158
|
-
/** Normalise a missing-area string for cross-round identity — lowercased alnum
|
|
159
|
-
* words, punctuation and quote-wrapping collapsed. Used only to tell whether an
|
|
160
|
-
* adopted plan introduced a NEW gap versus re-surfacing the same one (#2 bonus
|
|
161
|
-
* round); intentionally coarse, so trivial rewording of the same area does not
|
|
162
|
-
* read as new and buy an extra round. */
|
|
163
|
-
function normMissingArea(s) {
|
|
164
|
-
return s
|
|
165
|
-
.toLowerCase()
|
|
166
|
-
.replace(/[^a-z0-9]+/g, ' ')
|
|
167
|
-
.trim();
|
|
168
|
-
}
|
|
169
161
|
/**
|
|
170
162
|
* Clarify's answer-side TRIAGE — the second stage /task-auto's clarify gate was
|
|
171
163
|
* missing that /task's grill already had. /task-auto's clarify was single-stage:
|
|
@@ -509,40 +501,43 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
|
|
|
509
501
|
const { featureForModel, existingFilesBlock, ownableRequirements } = oriented;
|
|
510
502
|
const theme = ctx.ui.theme;
|
|
511
503
|
const ui = new SessionUI(ctx);
|
|
512
|
-
|
|
513
|
-
//
|
|
514
|
-
|
|
504
|
+
// ONE record, one numbering, one provenance table (task/qa-transcript.ts).
|
|
505
|
+
// Clarify's generator DOES see provenance — a question the triage already
|
|
506
|
+
// settled must read as settled so it is not re-asked — which is the one thing
|
|
507
|
+
// it and grill genuinely disagree about, and is now a named policy field.
|
|
508
|
+
const transcript = new QaTranscript(CLARIFY_QA_POLICY);
|
|
515
509
|
// Deterministic guard against a model that ignores "never re-ask": consecutive
|
|
516
510
|
// near-duplicate questions are reprompted with an explicit hint, and once it
|
|
517
511
|
// strikes out (can't produce anything novel) we stop instead of barraging the
|
|
518
512
|
// user with the same decision worded N ways. Also caps the absolute count.
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
513
|
+
// The generate → parse → pick → dedupe → re-prompt state machine is
|
|
514
|
+
// task/question-source.ts, shared with the plan session. Clarify used to write
|
|
515
|
+
// its own, and it had drifted from the sibling in five ways — every one of
|
|
516
|
+
// which the shared source closes for free: `parsed[0]` became `pickQuestion`
|
|
517
|
+
// (an analysis note is no longer shown as the question, and the SUGGESTED
|
|
518
|
+
// attached further down is no longer lost), and an unparseable reply now buys
|
|
519
|
+
// one format re-prompt instead of ending the whole clarify — and decomposing
|
|
520
|
+
// the feature with ZERO clarifications — on a formatting slip.
|
|
521
|
+
//
|
|
522
|
+
// Of plan's three quality rules only the DEFERRAL guard crosses. The other two
|
|
523
|
+
// cost an extra child call every time they fire, and clarify is the most A/B'd
|
|
524
|
+
// path here; moving them is its own experiment. See CLARIFY_QUALITY_RULES.
|
|
525
|
+
const source = makeQuestionSource({
|
|
526
|
+
generate: hint => deps.runChild('auto-clarify', 'read', prependHint(hint, AUTO_CLARIFY_PROMPT(featureForModel, transcript.forGenerator()))),
|
|
527
|
+
formatHint: PLAN_FORMAT_HINT,
|
|
528
|
+
rules: CLARIFY_QUALITY_RULES,
|
|
529
|
+
cap: MAX_CLARIFY_QUESTIONS,
|
|
530
|
+
log: msg => logPlanDebug(cwd, `clarify: ${msg}`)
|
|
531
|
+
});
|
|
532
|
+
for (;;) {
|
|
533
|
+
const drawn = await source.next();
|
|
534
|
+
if (drawn.kind === 'exhausted')
|
|
535
|
+
break;
|
|
536
|
+
const { question, suggested, alt } = drawn.q;
|
|
541
537
|
// Render markdown (bold/code) for the displayed prompt; keep plain text
|
|
542
538
|
// for the editable default and the persisted file.
|
|
543
539
|
const shownQ = renderInlineMarkdown(question, theme);
|
|
544
|
-
const plainQ =
|
|
545
|
-
askedQuestions.push(plainQ);
|
|
540
|
+
const plainQ = drawn.plain;
|
|
546
541
|
// PLAN SHAPE is the host's call, not the triage's (mx5 41→11 tasks on the
|
|
547
542
|
// same spec, same base commit, same code — see decompose-granularity.ts).
|
|
548
543
|
// The triage answers this fork for itself in 8/8 live reps and stamps it
|
|
@@ -552,8 +547,7 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
|
|
|
552
547
|
// fixed value the user can read in the AUTO file and override next run.
|
|
553
548
|
if (planShapeIsHostsToAnswer(ownableRequirements) && isPlanShapeQuestion(plainQ)) {
|
|
554
549
|
logPlanDebug(cwd, `plan-shape question answered host-side (not the triage): ${plainQ.replace(/\s+/g, ' ').slice(0, 120)}`);
|
|
555
|
-
|
|
556
|
-
+ `A${answers.length + 1}: ${PLAN_SHAPE_ANSWER} (host-set — plan granularity is not left to chance)`);
|
|
550
|
+
transcript.add('host-set', plainQ, PLAN_SHAPE_ANSWER);
|
|
557
551
|
continue;
|
|
558
552
|
}
|
|
559
553
|
// Answer-side triage (grill parity): if the inlined spec already settles
|
|
@@ -562,8 +556,7 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
|
|
|
562
556
|
// won't re-ask it.
|
|
563
557
|
const autoResolved = await triageClarifyQuestion(deps, cwd, featureForModel, existingFilesBlock, plainQ);
|
|
564
558
|
if (autoResolved !== null) {
|
|
565
|
-
|
|
566
|
-
+ `A${answers.length + 1}: ${autoResolved} (auto-resolved — already settled by the spec)`);
|
|
559
|
+
transcript.add('auto-resolved', plainQ, autoResolved);
|
|
567
560
|
continue;
|
|
568
561
|
}
|
|
569
562
|
const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
|
|
@@ -577,9 +570,10 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
|
|
|
577
570
|
...(plainAlt !== undefined && { alt: plainAlt })
|
|
578
571
|
});
|
|
579
572
|
if (yolo !== null) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
573
|
+
if (yolo.kind === 'answer')
|
|
574
|
+
transcript.add('yolo', plainQ, yolo.answer);
|
|
575
|
+
else
|
|
576
|
+
transcript.add('yolo-skip', plainQ, `(skipped — ${yolo.note})`);
|
|
583
577
|
continue;
|
|
584
578
|
}
|
|
585
579
|
// The picker cards and the reply mapping are shared with /task's grill
|
|
@@ -611,21 +605,15 @@ export async function elicitClarifications(ctx, cwd, deps, oriented) {
|
|
|
611
605
|
announceDone(ctx, '/task-auto cancelled.', 'warning');
|
|
612
606
|
return null;
|
|
613
607
|
}
|
|
614
|
-
const resolved = resolveAnswer(pending, a);
|
|
615
|
-
// Clarify's transcript records PROVENANCE; grill's deliberately does not,
|
|
616
|
-
// because grill's is fed back verbatim into the next grill-gen prompt. That
|
|
617
|
-
// is now the only difference between the two dialogs, and it is one line.
|
|
618
608
|
// An accept covers both routes to it: submitting empty, and pressing the
|
|
619
|
-
// single green card.
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
: resolved.answer;
|
|
623
|
-
answers.push(`Q${answers.length + 1}: ${plainQ}\nA${answers.length + 1}: ${answer}`);
|
|
609
|
+
// single green card. The suffix is the policy's, not this call site's.
|
|
610
|
+
const resolved = resolveAnswer(pending, a);
|
|
611
|
+
transcript.add(resolved.source === 'accepted' ? 'accepted' : 'typed', plainQ, resolved.answer);
|
|
624
612
|
}
|
|
625
|
-
if (
|
|
613
|
+
if (transcript.length === 0) {
|
|
626
614
|
ctx.ui.notify('No clarifying questions needed — planning tasks…', 'info');
|
|
627
615
|
}
|
|
628
|
-
return
|
|
616
|
+
return transcript.forRecord();
|
|
629
617
|
}
|
|
630
618
|
/**
|
|
631
619
|
* DECOMPOSE — turn the settled feature into a task list, then defend that list's
|
|
@@ -824,20 +812,16 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
|
|
|
824
812
|
// It is also the ONLY handle on the accounting. There used to be a second,
|
|
825
813
|
// `let accounting`, carried alongside — see the ScoredPlan doc comment for the
|
|
826
814
|
// requirement-to-wrong-task bug that cost us.
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
// exactly one bonus round when — and only when — an adoption introduces a NEW
|
|
836
|
-
// missing area at the cap. Bounded to one so a judge that flags forever still
|
|
837
|
-
// cannot loop the plan phase; a persistent (non-new) gap never re-triggers it.
|
|
838
|
-
let roundCap = MAX_COVERAGE_ROUNDS;
|
|
839
|
-
let bonusRoundUsed = false;
|
|
815
|
+
// The record, and the decisions it makes: task/plan-rounds.ts. This was five
|
|
816
|
+
// locals threaded by closure through a ~90-line loop, plus a
|
|
817
|
+
// snapshot-before-overwrite pair that existed only because the bonus-round
|
|
818
|
+
// decision was made downstream from the evidence it needed.
|
|
819
|
+
const rounds = new CoverageLedger(await scorePlan(planTitles), {
|
|
820
|
+
cap: MAX_COVERAGE_ROUNDS,
|
|
821
|
+
hasRequirements
|
|
822
|
+
});
|
|
840
823
|
for (;;) {
|
|
824
|
+
const best = rounds.best();
|
|
841
825
|
if (best.plan.titles.length === 0)
|
|
842
826
|
break;
|
|
843
827
|
if (best.plan.missing.length === 0) {
|
|
@@ -854,40 +838,19 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
|
|
|
854
838
|
}
|
|
855
839
|
break;
|
|
856
840
|
}
|
|
857
|
-
if (
|
|
841
|
+
if (!rounds.mayRetry())
|
|
858
842
|
break;
|
|
859
|
-
round
|
|
843
|
+
const round = rounds.startRound();
|
|
860
844
|
logPlanDebug(cwd, `decompose-coverage round ${round}: INCOMPLETE — missing: `
|
|
861
845
|
+ best.plan.missing.join('; ').slice(0, 300));
|
|
862
846
|
const retryTitles = parsePlan(await deps.runChild('auto-decompose', 'read', prependHint(coverageRepromptHint(best.plan.missing), decomposePrompt)));
|
|
863
847
|
logPlanDebug(cwd, `decompose retry produced ${retryTitles.length} title(s)`);
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
// judge that just relabels the same-shaped plan's gap does not qualify),
|
|
871
|
-
// and it must expose a NEW area (a gap already present is one we have or
|
|
872
|
-
// will reprompt against anyway). Requirements-path only: without grounded
|
|
873
|
-
// requirements "missing" is pure holistic-judge free-text that can change
|
|
874
|
-
// every round, so there is no trustworthy "grew"/"new" signal to gate on.
|
|
875
|
-
const priorCovered = best.plan.covered.size;
|
|
876
|
-
const priorMissing = new Set(best.plan.missing.map(normMissingArea));
|
|
877
|
-
// The WHOLE scored plan is adopted, titles and accounting together.
|
|
878
|
-
// This used to be two assignments, and the second one kept the OLD
|
|
879
|
-
// plan's accounting whenever the new plan's coverage-map child faulted
|
|
880
|
-
// (`cand.accounting ?? accounting`) — binding requirements to titles
|
|
881
|
-
// they were never mapped against. See the ScoredPlan doc comment.
|
|
882
|
-
best = cand;
|
|
883
|
-
logPlanDebug(cwd, `decompose retry ADOPTED — ${decision.reason}`);
|
|
884
|
-
if (!bonusRoundUsed
|
|
885
|
-
&& round >= roundCap
|
|
886
|
-
&& hasRequirements
|
|
887
|
-
&& cand.plan.covered.size > priorCovered
|
|
888
|
-
&& cand.plan.missing.some(m => !priorMissing.has(normMissingArea(m)))) {
|
|
889
|
-
bonusRoundUsed = true;
|
|
890
|
-
roundCap++;
|
|
848
|
+
// Compare, replace the plan WHOLE, and decide the bonus round — one call,
|
|
849
|
+
// so there is no window in which a snapshot and a replacement disagree.
|
|
850
|
+
const outcome = rounds.consider(await scorePlan(retryTitles));
|
|
851
|
+
if (outcome.adopted) {
|
|
852
|
+
logPlanDebug(cwd, `decompose retry ADOPTED — ${outcome.decision.reason}`);
|
|
853
|
+
if (outcome.grantedBonusRound) {
|
|
891
854
|
logPlanDebug(cwd, 'decompose-coverage: bonus round granted — adoption grew coverage and '
|
|
892
855
|
+ 'exposed a new uncovered area at the cap');
|
|
893
856
|
}
|
|
@@ -896,20 +859,22 @@ export async function coverPlan(ctx, cwd, deps, oriented, clarifications, decomp
|
|
|
896
859
|
// Rejected: keep the better current plan. The loop re-checks it at the
|
|
897
860
|
// top (still incomplete ⇒ another bounded reprompt) but its coverage is
|
|
898
861
|
// never sacrificed to a worse regeneration.
|
|
899
|
-
logPlanDebug(cwd, `decompose retry REJECTED — ${decision.reason}`
|
|
900
|
-
+ (decision.dropped.length > 0 ?
|
|
901
|
-
` [would drop: ${decision.dropped
|
|
862
|
+
logPlanDebug(cwd, `decompose retry REJECTED — ${outcome.decision.reason}`
|
|
863
|
+
+ (outcome.decision.dropped.length > 0 ?
|
|
864
|
+
` [would drop: ${outcome.decision.dropped
|
|
902
865
|
.map(i => `"${reqEntries[i].quote}"`)
|
|
903
866
|
.join('; ')
|
|
904
867
|
.slice(0, 200)}]`
|
|
905
868
|
: ''));
|
|
906
869
|
}
|
|
907
870
|
}
|
|
871
|
+
const best = rounds.best();
|
|
872
|
+
const round = rounds.round();
|
|
908
873
|
planTitles = best.plan.titles;
|
|
909
874
|
// Exhausted still INCOMPLETE: the best plan ships (the gate is best-effort), but
|
|
910
875
|
// silently shipping a KNOWN-gapped plan is how mx5 run 5 lost its whole test
|
|
911
876
|
// suite — tell the user what is still uncovered.
|
|
912
|
-
const unresolvedMissing =
|
|
877
|
+
const unresolvedMissing = rounds.unresolved();
|
|
913
878
|
if (unresolvedMissing !== null) {
|
|
914
879
|
logPlanDebug(cwd, `decompose-coverage exhausted ${round} round(s) still INCOMPLETE — missing: `
|
|
915
880
|
+ unresolvedMissing.join('; ').slice(0, 300));
|
|
@@ -1195,6 +1160,29 @@ let autoRunning = false;
|
|
|
1195
1160
|
export function requestAutoCancel() {
|
|
1196
1161
|
requestCancel();
|
|
1197
1162
|
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Report a stash pushed during one task and left behind.
|
|
1165
|
+
*
|
|
1166
|
+
* An orphan stash later pops as an unresolvable conflict (mx5 run 6), so the
|
|
1167
|
+
* capture before the task and this check after it are ONE fact. They were a local
|
|
1168
|
+
* and a check ~120 lines and three returns apart, which is how the check came to
|
|
1169
|
+
* run only on the success path. Best-effort: never throws, so it cannot mask the
|
|
1170
|
+
* outcome of the attempt it closes.
|
|
1171
|
+
*/
|
|
1172
|
+
async function reportStashDrift(active, deps, cwd, id, title, before) {
|
|
1173
|
+
if (!deps.stashRef || before === undefined)
|
|
1174
|
+
return;
|
|
1175
|
+
try {
|
|
1176
|
+
const after = await deps.stashRef(cwd);
|
|
1177
|
+
if (after === before)
|
|
1178
|
+
return;
|
|
1179
|
+
active.ui.notify(`${id}: the git stash stack changed during "${title}" and was left that way — `
|
|
1180
|
+
+ 'inspect `git stash list`; an orphan stash later pops as an unresolvable conflict.', 'warning');
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
// a git failure here is inconclusive, never a report
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1198
1186
|
/**
|
|
1199
1187
|
* Announce a terminal /task-auto-overall outcome both in the terminal and to
|
|
1200
1188
|
* subscribed devices. The push body reuses the exact terminal message, so a
|
|
@@ -1302,117 +1290,120 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1302
1290
|
// during the task (impl model or any child) and left behind is called
|
|
1303
1291
|
// out instead of silently waiting to detonate in a later task.
|
|
1304
1292
|
const stashBefore = deps.stashRef ? await deps.stashRef(cwd) : undefined;
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
const res = await deps.runTask(active, cwd, next.title, {
|
|
1314
|
-
resumeId,
|
|
1315
|
-
// Fence this step against re-expanding the whole referenced spec:
|
|
1316
|
-
// name the sibling steps so refine bounds this step's slice. Only
|
|
1317
|
-
// matters when refine runs fresh (a resumed task past refine ignores
|
|
1318
|
-
// it), but always supplied so a resume that restarts at refine is
|
|
1319
|
-
// fenced too.
|
|
1320
|
-
planContext: buildStepFence(entries.map(e => e.title), next.index),
|
|
1321
|
-
onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
|
|
1322
|
-
});
|
|
1323
|
-
active = res.ctx ?? active;
|
|
1324
|
-
// One dispatch over the named ending. The five-branch ladder this
|
|
1325
|
-
// replaces had to ask `isCancelRequested()` — a module global
|
|
1326
|
-
// `/task-cancel` never sets — to tell a user stop from a fault, so a
|
|
1327
|
-
// cancel during a task was announced in red as "stopped … fix and
|
|
1328
|
-
// resume" and the inner file's `cancelled` was overwritten with
|
|
1329
|
-
// `failed`. The runner names the ending now; resumability is
|
|
1330
|
-
// RUN_END_POLICY's; only the wording is this command's.
|
|
1331
|
-
if (!runSucceeded(res.end)) {
|
|
1332
|
-
const policy = RUN_END_POLICY[res.end.kind];
|
|
1333
|
-
// Demote the INNER task file: it reads `completed` from
|
|
1334
|
-
// spec-handoff, and leaving it that way is how a failed run's task
|
|
1335
|
-
// file claimed success in the run 6 audit.
|
|
1336
|
-
if (policy.resumable)
|
|
1337
|
-
await markResumable(cwd, res.taskId);
|
|
1338
|
-
// The PLAN fails only on a fault. A declined-steer interrupt leaves
|
|
1339
|
-
// it in progress so /task-auto-resume re-delivers this task's spec.
|
|
1340
|
-
if (policy.failsRun)
|
|
1341
|
-
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
1342
|
-
const why = res.end.kind === 'failed' && res.end.reason ?
|
|
1343
|
-
` — ${res.end.reason.slice(0, 160)}`
|
|
1344
|
-
: '';
|
|
1345
|
-
const msg = res.end.kind === 'no-session' ?
|
|
1346
|
-
`${id} paused — could not start a session. Run /task-auto-resume to retry.`
|
|
1347
|
-
: res.end.kind === 'cancelled' ?
|
|
1348
|
-
`${id} cancelled during "${next.title}" — resume with /task-auto-resume.`
|
|
1349
|
-
: res.end.kind === 'interrupted' ?
|
|
1350
|
-
// The user interrupted implementation (ESC) and then declined
|
|
1351
|
-
// to steer — they want to stop here. Paused without checking
|
|
1352
|
-
// the task off, so /task-auto-resume re-delivers this task's
|
|
1353
|
-
// spec. (A plain ESC followed by steering text never reaches
|
|
1354
|
-
// here — that loops inside runSingleTask until a turn ends.)
|
|
1355
|
-
`${id} paused at "${next.title}" — resume with /task-auto-resume.`
|
|
1356
|
-
: `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`;
|
|
1357
|
-
announceDone(active, msg, policy.level);
|
|
1358
|
-
return;
|
|
1359
|
-
}
|
|
1360
|
-
// GATE: actually RUN the task's verification against the just-finished
|
|
1361
|
-
// work, then hold the committed result to AGENTS.md / CLAUDE.md. Shared
|
|
1362
|
-
// verbatim with /task so both commands gate identically — see
|
|
1363
|
-
// runGatesForTask. `done` means the work verified (or was accepted),
|
|
1364
|
-
// was checked off + committed, and enforcement ran; every other kind is
|
|
1365
|
-
// a terminal stop this loop announces with its own /task-auto-resume
|
|
1366
|
-
// wording (the shared gate is command-agnostic).
|
|
1367
|
-
const gate = await runGatesForTask(active, deps, {
|
|
1368
|
-
cwd,
|
|
1369
|
-
taskId: res.taskId,
|
|
1370
|
-
title: next.title,
|
|
1371
|
-
tag: id,
|
|
1372
|
-
// Fence an AUTOFIX re-run against re-expanding the whole spec.
|
|
1373
|
-
planContext: buildStepFence(entries.map(e => e.title), next.index),
|
|
1374
|
-
// res.ok === true means runner.run() completed, so res.taskId is the
|
|
1375
|
-
// allocated TASK_NNNN id (never empty here). The parent task-list
|
|
1376
|
-
// check-off runs after verify passes/accepts and before the commit,
|
|
1377
|
-
// so the commit captures the checked box too.
|
|
1378
|
-
onVerified: () => checkOffTask(cwd, id, next.index, res.taskId, next.title)
|
|
1379
|
-
});
|
|
1380
|
-
active = gate.ctx;
|
|
1381
|
-
// Every terminal gate outcome — what to demote, what to fail, what to
|
|
1382
|
-
// say — comes from TERMINAL_OUTCOMES, shared verbatim with /task's
|
|
1383
|
-
// loop. `done` alone is not terminal here: it falls through to the
|
|
1384
|
-
// next task.
|
|
1385
|
-
if (gate.kind !== 'done') {
|
|
1386
|
-
const outcome = TERMINAL_OUTCOMES[gate.kind];
|
|
1387
|
-
if (outcome.markResumable)
|
|
1388
|
-
await markResumable(cwd, res.taskId);
|
|
1389
|
-
if (outcome.failParent) {
|
|
1390
|
-
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
1293
|
+
try {
|
|
1294
|
+
// SAFE CHECKPOINT (pre-task): the tree is committed and no inner task
|
|
1295
|
+
// is stamped yet, so stopping here just leaves this entry unchecked —
|
|
1296
|
+
// a resume restarts it from scratch. Cheapest possible stop, and the
|
|
1297
|
+
// last one before we commit to a ~30-minute task.
|
|
1298
|
+
if (cancelCheckpoint('pre-task')) {
|
|
1299
|
+
announceDone(active, `${id} cancelled before "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
1300
|
+
return;
|
|
1391
1301
|
}
|
|
1392
|
-
|
|
1302
|
+
const res = await deps.runTask(active, cwd, next.title, {
|
|
1303
|
+
resumeId,
|
|
1304
|
+
// Fence this step against re-expanding the whole referenced spec:
|
|
1305
|
+
// name the sibling steps so refine bounds this step's slice. Only
|
|
1306
|
+
// matters when refine runs fresh (a resumed task past refine ignores
|
|
1307
|
+
// it), but always supplied so a resume that restarts at refine is
|
|
1308
|
+
// fenced too.
|
|
1309
|
+
planContext: buildStepFence(entries.map(e => e.title), next.index),
|
|
1310
|
+
onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
|
|
1311
|
+
});
|
|
1312
|
+
active = res.ctx ?? active;
|
|
1313
|
+
// One dispatch over the named ending. The five-branch ladder this
|
|
1314
|
+
// replaces had to ask `isCancelRequested()` — a module global
|
|
1315
|
+
// `/task-cancel` never sets — to tell a user stop from a fault, so a
|
|
1316
|
+
// cancel during a task was announced in red as "stopped … fix and
|
|
1317
|
+
// resume" and the inner file's `cancelled` was overwritten with
|
|
1318
|
+
// `failed`. The runner names the ending now; resumability is
|
|
1319
|
+
// RUN_END_POLICY's; only the wording is this command's.
|
|
1320
|
+
if (!runSucceeded(res.end)) {
|
|
1321
|
+
const policy = RUN_END_POLICY[res.end.kind];
|
|
1322
|
+
// Demote the INNER task file: it reads `completed` from
|
|
1323
|
+
// spec-handoff, and leaving it that way is how a failed run's task
|
|
1324
|
+
// file claimed success in the run 6 audit.
|
|
1325
|
+
if (policy.resumable)
|
|
1326
|
+
await markResumable(cwd, res.taskId);
|
|
1327
|
+
// The PLAN fails only on a fault. A declined-steer interrupt leaves
|
|
1328
|
+
// it in progress so /task-auto-resume re-delivers this task's spec.
|
|
1329
|
+
if (policy.failsRun)
|
|
1330
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
1331
|
+
const why = res.end.kind === 'failed' && res.end.reason ?
|
|
1332
|
+
` — ${res.end.reason.slice(0, 160)}`
|
|
1333
|
+
: '';
|
|
1334
|
+
const msg = res.end.kind === 'no-session' ?
|
|
1335
|
+
`${id} paused — could not start a session. Run /task-auto-resume to retry.`
|
|
1336
|
+
: res.end.kind === 'cancelled' ?
|
|
1337
|
+
`${id} cancelled during "${next.title}" — resume with /task-auto-resume.`
|
|
1338
|
+
: res.end.kind === 'interrupted' ?
|
|
1339
|
+
// The user interrupted implementation (ESC) and then declined
|
|
1340
|
+
// to steer — they want to stop here. Paused without checking
|
|
1341
|
+
// the task off, so /task-auto-resume re-delivers this task's
|
|
1342
|
+
// spec. (A plain ESC followed by steering text never reaches
|
|
1343
|
+
// here — that loops inside runSingleTask until a turn ends.)
|
|
1344
|
+
`${id} paused at "${next.title}" — resume with /task-auto-resume.`
|
|
1345
|
+
: `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`;
|
|
1346
|
+
announceDone(active, msg, policy.level);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
// GATE: actually RUN the task's verification against the just-finished
|
|
1350
|
+
// work, then hold the committed result to AGENTS.md / CLAUDE.md. Shared
|
|
1351
|
+
// verbatim with /task so both commands gate identically — see
|
|
1352
|
+
// runGatesForTask. `done` means the work verified (or was accepted),
|
|
1353
|
+
// was checked off + committed, and enforcement ran; every other kind is
|
|
1354
|
+
// a terminal stop this loop announces with its own /task-auto-resume
|
|
1355
|
+
// wording (the shared gate is command-agnostic).
|
|
1356
|
+
const gate = await runGatesForTask(active, deps, {
|
|
1357
|
+
cwd,
|
|
1358
|
+
taskId: res.taskId,
|
|
1359
|
+
title: next.title,
|
|
1393
1360
|
tag: id,
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1361
|
+
// Fence an AUTOFIX re-run against re-expanding the whole spec.
|
|
1362
|
+
planContext: buildStepFence(entries.map(e => e.title), next.index),
|
|
1363
|
+
// res.ok === true means runner.run() completed, so res.taskId is the
|
|
1364
|
+
// allocated TASK_NNNN id (never empty here). The parent task-list
|
|
1365
|
+
// check-off runs after verify passes/accepts and before the commit,
|
|
1366
|
+
// so the commit captures the checked box too.
|
|
1367
|
+
onVerified: () => checkOffTask(cwd, id, next.index, res.taskId, next.title)
|
|
1368
|
+
});
|
|
1369
|
+
active = gate.ctx;
|
|
1370
|
+
// Every terminal gate outcome — what to demote, what to fail, what to
|
|
1371
|
+
// say — comes from TERMINAL_OUTCOMES, shared verbatim with /task's
|
|
1372
|
+
// loop. `done` alone is not terminal here: it falls through to the
|
|
1373
|
+
// next task.
|
|
1374
|
+
if (gate.kind !== 'done') {
|
|
1375
|
+
const outcome = TERMINAL_OUTCOMES[gate.kind];
|
|
1376
|
+
if (outcome.markResumable)
|
|
1377
|
+
await markResumable(cwd, res.taskId);
|
|
1378
|
+
if (outcome.failParent) {
|
|
1379
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
1380
|
+
}
|
|
1381
|
+
announceDone(active, outcome.message({
|
|
1382
|
+
tag: id,
|
|
1383
|
+
at: formatAt(next.title),
|
|
1384
|
+
why: formatWhy(gate.kind === 'failed' ? gate.reason : undefined),
|
|
1385
|
+
resumeCmd: '/task-auto-resume'
|
|
1386
|
+
}), outcome.level);
|
|
1387
|
+
return;
|
|
1415
1388
|
}
|
|
1389
|
+
// ROOT-CAUSE REPAIR (mx5 run 14 item 5): the gate may have attributed a
|
|
1390
|
+
// FAIL to a pre-existing defect in a file some OTHER task created. It can
|
|
1391
|
+
// only QUEUE that finding — mutating the plan is this loop's job. Drain
|
|
1392
|
+
// the queue and splice a scoped repair step in right after the step that
|
|
1393
|
+
// just finished, so the defect is fixed BEFORE the next dependent task
|
|
1394
|
+
// trips over it too (run 14 recorded the same `test/teardown.ts` cause
|
|
1395
|
+
// twice, scheduled nothing, and the bug outlived ~24h of the run).
|
|
1396
|
+
await schedulePendingRepairs(cwd, id, next.index, active, deps);
|
|
1397
|
+
}
|
|
1398
|
+
finally {
|
|
1399
|
+
// EVERY exit from this attempt passes here — the two mid-attempt
|
|
1400
|
+
// returns and a throw included. The check used to sit at the very
|
|
1401
|
+
// end of the fall-through, ~120 lines and three returns below the
|
|
1402
|
+
// capture, so it ran only when the task SUCCEEDED and the gate said
|
|
1403
|
+
// `done`. On a failed or interrupted task the user is told to run
|
|
1404
|
+
// /task-auto-resume, straight onto the landmine the guard exists to
|
|
1405
|
+
// name. The pairing is structural now, not positional.
|
|
1406
|
+
await reportStashDrift(active, deps, cwd, id, next.title, stashBefore);
|
|
1416
1407
|
}
|
|
1417
1408
|
}
|
|
1418
1409
|
}
|
|
@@ -135,6 +135,52 @@ export interface BootDeps {
|
|
|
135
135
|
preferredPort?: () => Promise<number | null>;
|
|
136
136
|
/** Does anything answer HTTP on 127.0.0.1:`port`? Injected for tests. */
|
|
137
137
|
httpProbe?: (port: number) => boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Spawn the boot child. THE SUBJECT of this check, and the one thing `BootDeps`
|
|
140
|
+
* did not seam.
|
|
141
|
+
*
|
|
142
|
+
* Nine fields above inject something the check LOOKS AT; `spawn` was imported
|
|
143
|
+
* directly, so the ~220-line state machine below — seven locals threaded by
|
|
144
|
+
* closure, four `BootOutcome` kinds, five exit arms — was reachable only through
|
|
145
|
+
* a real process on a real clock. Measured: 52 tests / 13.6s, with 300–5000ms
|
|
146
|
+
* grace windows scripted as real `process.execPath -e` children.
|
|
147
|
+
*
|
|
148
|
+
* `BootChild` is defined from what this function CALLS, not from Node's
|
|
149
|
+
* `ChildProcess` — the same way `driveSession(cdp: CdpLike, …)` was defined from
|
|
150
|
+
* the two `Cdp` methods it uses. A scripted fake is a dozen lines.
|
|
151
|
+
*/
|
|
152
|
+
spawnBoot?: (bin: string, args: string[], opts: BootSpawnOptions) => BootChild;
|
|
153
|
+
/**
|
|
154
|
+
* Tear down the child's whole process group. Injected with `spawnBoot`, because
|
|
155
|
+
* a fake child has no group to kill and a real `process.kill(-pid)` against a
|
|
156
|
+
* fake pid would signal something else entirely.
|
|
157
|
+
*/
|
|
158
|
+
killGroup?: (pid: number, signal: NodeJS.Signals) => void;
|
|
159
|
+
}
|
|
160
|
+
/** What `runBootCheck` passes to its spawn. */
|
|
161
|
+
export interface BootSpawnOptions {
|
|
162
|
+
cwd: string;
|
|
163
|
+
detached: true;
|
|
164
|
+
stdio: ['ignore', 'pipe', 'pipe'];
|
|
165
|
+
env: Record<string, string | undefined>;
|
|
166
|
+
}
|
|
167
|
+
/** A stream the boot check reads output from. */
|
|
168
|
+
export interface BootStream {
|
|
169
|
+
on: (event: 'data', cb: (chunk: Buffer | string) => void) => void;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The boot child, as the check actually uses it: a pid, two output streams, an
|
|
173
|
+
* `error` event and an `exit` event carrying (status, signal).
|
|
174
|
+
*/
|
|
175
|
+
export interface BootChild {
|
|
176
|
+
pid?: number | undefined;
|
|
177
|
+
unref?: () => void;
|
|
178
|
+
stdout?: BootStream | null;
|
|
179
|
+
stderr?: BootStream | null;
|
|
180
|
+
on: {
|
|
181
|
+
(event: 'error', cb: (err: Error) => void): void;
|
|
182
|
+
(event: 'exit', cb: (status: number | null, signal: NodeJS.Signals | null) => void): void;
|
|
183
|
+
};
|
|
138
184
|
}
|
|
139
185
|
/**
|
|
140
186
|
* Does the finished run stand up a listening HTTP server? Deterministic, from the
|