@mjasnikovs/pi-task 0.17.0 → 0.17.1
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/register.js +2 -2
- package/dist/task/auto-orchestrator.d.ts +5 -66
- package/dist/task/auto-orchestrator.js +47 -387
- package/dist/task/gate-deps.d.ts +16 -0
- package/dist/task/gate-deps.js +221 -0
- package/dist/task/orchestrator.d.ts +21 -0
- package/dist/task/orchestrator.js +134 -1
- package/dist/task/task-gates.d.ts +148 -0
- package/dist/task/task-gates.js +135 -0
- package/package.json +1 -1
package/dist/config/register.js
CHANGED
|
@@ -21,12 +21,12 @@ const ITEMS = [
|
|
|
21
21
|
{
|
|
22
22
|
id: 'verifyWork',
|
|
23
23
|
label: 'verify work',
|
|
24
|
-
description: "After each /task-auto task, RUN its spec's VERIFY block in the workspace and report a PASS/FAIL verdict (also the signal that lets 'enforce guidelines' fix safely)"
|
|
24
|
+
description: "After each /task (and /task-auto task), RUN its spec's VERIFY block in the workspace and report a PASS/FAIL verdict (also the signal that lets 'enforce guidelines' fix safely). Enabling it makes /task wait for the implementation"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
id: 'enforceGuidelines',
|
|
28
28
|
label: 'enforce guidelines',
|
|
29
|
-
description: "Check each /task-auto commit against AGENTS.md/CLAUDE.md. Needs 'verify work' to FIX drift (fixes are reverted if they regress verification); without it, only reports violations"
|
|
29
|
+
description: "Check each /task and /task-auto commit against AGENTS.md/CLAUDE.md. Needs 'verify work' to FIX drift (fixes are reverted if they regress verification); without it, only reports violations. Enabling it makes /task wait for the implementation"
|
|
30
30
|
}
|
|
31
31
|
];
|
|
32
32
|
function makeTheme(theme) {
|
|
@@ -1,74 +1,13 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import type
|
|
3
|
-
import { type CommitResult } from './auto-commit.js';
|
|
4
|
-
import { type EnforceOutcome } from './enforce-guidelines.js';
|
|
5
|
-
import { type VerifyOutcome } from './verify-work.js';
|
|
6
|
-
import { type ResolutionOutcome } from './verify-resolution.js';
|
|
2
|
+
import { type GateDeps } from './task-gates.js';
|
|
7
3
|
/**
|
|
8
4
|
* Injectable seams so the planner and loop are testable without spawning pi.
|
|
9
|
-
* `runChild` is used by planAuto;
|
|
5
|
+
* `runChild` is the planning-only seam used by planAuto; everything else (runTask,
|
|
6
|
+
* commit, verify, enforce, recommend, revert) is the shared post-implementation
|
|
7
|
+
* gate surface defined by {@link GateDeps} and built by {@link buildGateDeps}.
|
|
10
8
|
*/
|
|
11
|
-
export interface AutoDeps {
|
|
9
|
+
export interface AutoDeps extends GateDeps {
|
|
12
10
|
runChild: (name: string, tools: string, prompt: string) => Promise<string>;
|
|
13
|
-
runTask: (ctx: ExtensionCommandContext, cwd: string, title: string, opts?: {
|
|
14
|
-
/** Resume this inner task id instead of allocating a fresh one. */
|
|
15
|
-
resumeId?: string;
|
|
16
|
-
/** Called with the inner task id once its file exists, before phases. */
|
|
17
|
-
onStart?: (taskId: string) => void | Promise<void>;
|
|
18
|
-
/** Scope fence naming the sibling steps, forwarded into refine. */
|
|
19
|
-
planContext?: string;
|
|
20
|
-
/** Verify-FAIL autofix re-attempt: prepended to the delivered spec as
|
|
21
|
-
* a RE-ATTEMPT banner so the re-run fixes that specific failure. */
|
|
22
|
-
fixInstruction?: string;
|
|
23
|
-
}) => Promise<RunSingleTaskResult>;
|
|
24
|
-
/** Snapshot the working tree into one commit after a task passes. */
|
|
25
|
-
commit: (cwd: string, message: string) => Promise<CommitResult>;
|
|
26
|
-
/**
|
|
27
|
-
* Verify the just-finished task's work against the project's AGENTS.md /
|
|
28
|
-
* CLAUDE.md guidelines (and auto-fix violations) BEFORE it is committed.
|
|
29
|
-
* Optional: when absent (tests, or the feature disabled), the loop treats it
|
|
30
|
-
* as a pass. ok === false blocks the commit and fails the task.
|
|
31
|
-
*
|
|
32
|
-
* Takes the loop's CURRENT ctx (not the one captured at defaultDeps time):
|
|
33
|
-
* each task runs in a fresh session, so the captured ctx is stale by the time
|
|
34
|
-
* enforcement runs and using it for the loader widget throws "stale ctx".
|
|
35
|
-
*/
|
|
36
|
-
enforce?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string,
|
|
37
|
-
/**
|
|
38
|
-
* `'edit'` (read,edit — fix in place) only when there is a clean
|
|
39
|
-
* verification signal to guard the edits against; otherwise `'flag'`
|
|
40
|
-
* (read-only, report don't fix). The loop picks the mode from whether the
|
|
41
|
-
* verify gate produced a genuine clean pass.
|
|
42
|
-
*/
|
|
43
|
-
mode: 'edit' | 'flag') => Promise<EnforceOutcome>;
|
|
44
|
-
/**
|
|
45
|
-
* Verify the just-finished task's work by RUNNING its composed spec's VERIFY
|
|
46
|
-
* block in the real workspace (a fresh child of the same local model, with a
|
|
47
|
-
* `bash` tool), then reporting a PASS/FAIL verdict. Optional: absent in tests
|
|
48
|
-
* or when the `verifyWork` flag is off, in which case the loop treats it as a
|
|
49
|
-
* pass. Runs BEFORE the task is checked off/committed and is a hard GATE: ok
|
|
50
|
-
* === false stops the run and fails the task (left unchecked so resume re-runs
|
|
51
|
-
* it). Needs the inner taskId to read that task's spec.
|
|
52
|
-
*/
|
|
53
|
-
verify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
|
|
54
|
-
/**
|
|
55
|
-
* After a verify FAIL, research whether to recommend AUTOFIX (re-do the work)
|
|
56
|
-
* or ACCEPT (the gate misjudged a good artifact) — read-only, same local
|
|
57
|
-
* model. Only sets which card the picker tints RECOMMENDED; the user always
|
|
58
|
-
* makes the final call. Optional: absent in tests or when verify is off, in
|
|
59
|
-
* which case the loop defaults the recommendation to AUTOFIX. Needs the inner
|
|
60
|
-
* taskId (to read the spec) and the gate's failure reason.
|
|
61
|
-
*/
|
|
62
|
-
recommend?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string, failReason: string) => Promise<ResolutionOutcome>;
|
|
63
|
-
/**
|
|
64
|
-
* Discard the working-tree edits an `'edit'` enforcement pass made, restoring
|
|
65
|
-
* the verified task commit. The differential guard calls this when re-running
|
|
66
|
-
* verification AFTER enforcement reports a regression: the guideline fixes are
|
|
67
|
-
* thrown away and the verified implementation is kept. Optional: absent in
|
|
68
|
-
* tests; the loop then skips the revert (and warns). Scoped to exclude the
|
|
69
|
-
* .pi-tasks bookkeeping so task front matter is not rolled back.
|
|
70
|
-
*/
|
|
71
|
-
revert?: (cwd: string) => Promise<void>;
|
|
72
11
|
}
|
|
73
12
|
/**
|
|
74
13
|
* Expand any @file references in the feature text by appending each referenced
|
|
@@ -7,25 +7,21 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import * as fsp from 'node:fs/promises';
|
|
9
9
|
import * as path from 'node:path';
|
|
10
|
-
import {
|
|
10
|
+
import { gateRunTask } from './orchestrator.js';
|
|
11
11
|
import { parseClarifyList, deriveTitle } from './parsers.js';
|
|
12
12
|
import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
|
|
13
13
|
import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT } from './auto-prompts.js';
|
|
14
14
|
import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
|
|
15
15
|
import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
|
|
16
16
|
import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
|
|
17
|
-
import { gitCommitAll, gitDropLastCommit } from './auto-commit.js';
|
|
18
|
-
import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
|
|
19
|
-
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
20
|
-
import { researchResolution, resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
|
|
21
|
-
import { runWorker } from '../workers/pi-worker-core.js';
|
|
22
17
|
import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
|
|
23
|
-
import { runPhaseChild, prependHint,
|
|
18
|
+
import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
24
19
|
import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
|
|
25
20
|
import { pushNotify } from '../remote/push.js';
|
|
26
|
-
import { getConfig } from '../config/config.js';
|
|
27
21
|
import { startAutoLoader } from './widget.js';
|
|
28
22
|
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
23
|
+
import { buildGateDeps } from './gate-deps.js';
|
|
24
|
+
import { runGatesForTask } from './task-gates.js';
|
|
29
25
|
// Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
|
|
30
26
|
// when the model emits NONE), but a model that never says NONE would otherwise
|
|
31
27
|
// barrage the user — the real mx5 run asked 10, several of them redundant.
|
|
@@ -340,66 +336,12 @@ const AUTO_PLAN_STEPS = {
|
|
|
340
336
|
};
|
|
341
337
|
const AUTO_PLAN_STEP_TOTAL = 2;
|
|
342
338
|
function defaultDeps(ctx, cwd, signal, title) {
|
|
343
|
-
// Captured by the loader's getState so the widget mirrors the child's
|
|
344
|
-
// output line and context usage, exactly like the single-task phase
|
|
339
|
+
// Captured by the planning loader's getState so the widget mirrors the child's
|
|
340
|
+
// latest output line and context usage, exactly like the single-task phase
|
|
341
|
+
// widget. (The gate children manage their own loaders inside buildGateDeps.)
|
|
345
342
|
let lastLine;
|
|
346
343
|
let contextUsage;
|
|
347
344
|
const parentContextWindow = getParentContextWindow(ctx);
|
|
348
|
-
// Shared runner for the per-task GATE children (verify + post-FAIL recommend).
|
|
349
|
-
// Both are read-only passes of the same local model that must run to
|
|
350
|
-
// completion: unguarded (no wall-clock timeout, exact-match loop guard only,
|
|
351
|
-
// path-revisit disabled because re-running the same check is the job), with a
|
|
352
|
-
// status widget and a per-gate debug log. Returns the closure
|
|
353
|
-
// runWorkVerification / researchResolution expect as `runChild`.
|
|
354
|
-
const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile) => async (tools, prompt, sig) => {
|
|
355
|
-
lastLine = undefined;
|
|
356
|
-
contextUsage = undefined;
|
|
357
|
-
const startedAt = Date.now();
|
|
358
|
-
const logPath = path.join(tasksDir(cwd2), logFile);
|
|
359
|
-
const log = (msg) => {
|
|
360
|
-
void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
|
|
361
|
-
};
|
|
362
|
-
log(`=== ${kind} start: ${taskTitle} ===`);
|
|
363
|
-
const stopLoader = startAutoLoader(gateCtx, () => ({
|
|
364
|
-
title: taskTitle,
|
|
365
|
-
kind,
|
|
366
|
-
step: kind,
|
|
367
|
-
stepNum: 1,
|
|
368
|
-
stepTotal: 1,
|
|
369
|
-
startedAt,
|
|
370
|
-
lastLine,
|
|
371
|
-
contextUsage
|
|
372
|
-
}));
|
|
373
|
-
try {
|
|
374
|
-
const r = await runWorker({
|
|
375
|
-
prompt,
|
|
376
|
-
cwd: cwd2,
|
|
377
|
-
signal: sig,
|
|
378
|
-
tools,
|
|
379
|
-
timeoutMs: 0,
|
|
380
|
-
loop: { pathThreshold: Number.POSITIVE_INFINITY },
|
|
381
|
-
onLine: line => {
|
|
382
|
-
lastLine = line;
|
|
383
|
-
log(line);
|
|
384
|
-
},
|
|
385
|
-
onContextUsage: snapshot => {
|
|
386
|
-
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
387
|
-
}
|
|
388
|
-
});
|
|
389
|
-
if (r.loopHit) {
|
|
390
|
-
log(`=== ${kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
|
|
391
|
-
gateCtx.ui.notify(`${taskTitle}: ${kind} worker looped past the nudges — continuing (not blocked).`, 'warning');
|
|
392
|
-
}
|
|
393
|
-
const failure = classifyEnforceChildFailure(r);
|
|
394
|
-
log(failure ? `=== ${kind} end: FAIL — ${failure} ===` : `=== ${kind} end: ok ===`);
|
|
395
|
-
if (failure)
|
|
396
|
-
throw new Error(failure);
|
|
397
|
-
return r.text;
|
|
398
|
-
}
|
|
399
|
-
finally {
|
|
400
|
-
stopLoader();
|
|
401
|
-
}
|
|
402
|
-
};
|
|
403
345
|
const phaseDeps = {
|
|
404
346
|
cwd,
|
|
405
347
|
taskId: '',
|
|
@@ -412,6 +354,9 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
412
354
|
}
|
|
413
355
|
};
|
|
414
356
|
return {
|
|
357
|
+
// Planning-only seam. The shared gate surface (runTask/commit/verify/
|
|
358
|
+
// enforce/recommend/revert) comes from buildGateDeps below — identical to
|
|
359
|
+
// what /task builds, so both commands gate the same way.
|
|
415
360
|
runChild: async (name, tools, prompt) => {
|
|
416
361
|
// Planning children are slow LLM calls with no UI of their own; show
|
|
417
362
|
// the same status block as /task so this never goes silent until the
|
|
@@ -436,179 +381,10 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
436
381
|
stopLoader();
|
|
437
382
|
}
|
|
438
383
|
},
|
|
439
|
-
|
|
440
|
-
waitForImplementation: true,
|
|
441
|
-
resumeId: opts?.resumeId,
|
|
442
|
-
onStart: opts?.onStart,
|
|
443
|
-
planContext: opts?.planContext,
|
|
444
|
-
fixInstruction: opts?.fixInstruction
|
|
445
|
-
}),
|
|
446
|
-
commit: (cwd2, message) => getConfig().autoCommit ?
|
|
447
|
-
gitCommitAll(cwd2, message, signal)
|
|
448
|
-
: Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
|
|
449
|
-
revert: cwd2 => gitDropLastCommit(cwd2, signal),
|
|
450
|
-
enforce: (enforceCtx, cwd2, taskTitle, mode) => {
|
|
451
|
-
if (!getConfig().enforceGuidelines) {
|
|
452
|
-
return Promise.resolve({ ok: true, reason: 'disabled' });
|
|
453
|
-
}
|
|
454
|
-
return runGuidelineEnforcement({
|
|
455
|
-
cwd: cwd2,
|
|
456
|
-
signal,
|
|
457
|
-
mode,
|
|
458
|
-
// Run the worker child UNGUARDED: no loop detector, no wall-clock
|
|
459
|
-
// timeout. This pass's job is to rework files in place until every
|
|
460
|
-
// violation is fixed, and it legitimately reads and edits the same
|
|
461
|
-
// file many times — the research-worker guards mislabel that as a
|
|
462
|
-
// runaway and kill good work (proven on mx5 TASK_0002). Let it
|
|
463
|
-
// run until the model finishes; classifyEnforceChildFailure still
|
|
464
|
-
// blocks the commit on a real failure (non-zero exit, leaked tool
|
|
465
|
-
// call) or a user cancel.
|
|
466
|
-
runChild: async (tools, prompt, sig) => {
|
|
467
|
-
// The enforcement child is a slow local-model pass with edit
|
|
468
|
-
// tools; show the same /task-auto status block as planning so
|
|
469
|
-
// it isn't silent — head · enforcing guidelines/elapsed ·
|
|
470
|
-
// tokens/window [bar] · ↳ last line. The worker runs the same
|
|
471
|
-
// `--mode json` stream as the phase children, so it emits
|
|
472
|
-
// context_usage; forward it (onContextUsage below) so the bar
|
|
473
|
-
// renders here exactly like every other phase widget.
|
|
474
|
-
lastLine = undefined;
|
|
475
|
-
contextUsage = undefined;
|
|
476
|
-
const startedAt = Date.now();
|
|
477
|
-
// Per-pass debug log. The enforce child is otherwise
|
|
478
|
-
// unobservable (it has no per-task file like the impl runner).
|
|
479
|
-
// One rolling file under .pi-tasks/; each pass brackets its
|
|
480
|
-
// tool/text lines with start/end markers naming the task and
|
|
481
|
-
// the terminal outcome, so a stop (a real failure or a found
|
|
482
|
-
// violation) is diagnosable. Fire-and-forget.
|
|
483
|
-
const enforceLogPath = path.join(tasksDir(cwd2), 'enforce-debug.log');
|
|
484
|
-
const logEnforce = (msg) => {
|
|
485
|
-
void fsp
|
|
486
|
-
.appendFile(enforceLogPath, `${new Date().toISOString()} ${msg}\n`)
|
|
487
|
-
.catch(() => { });
|
|
488
|
-
};
|
|
489
|
-
logEnforce(`=== enforce start: ${taskTitle} ===`);
|
|
490
|
-
const stopLoader = startAutoLoader(enforceCtx, () => ({
|
|
491
|
-
title: taskTitle,
|
|
492
|
-
kind: 'enforce',
|
|
493
|
-
step: 'guidelines',
|
|
494
|
-
stepNum: 1,
|
|
495
|
-
stepTotal: 1,
|
|
496
|
-
startedAt,
|
|
497
|
-
lastLine,
|
|
498
|
-
contextUsage
|
|
499
|
-
}));
|
|
500
|
-
try {
|
|
501
|
-
const r = await runWorker({
|
|
502
|
-
prompt,
|
|
503
|
-
cwd: cwd2,
|
|
504
|
-
signal: sig,
|
|
505
|
-
tools,
|
|
506
|
-
timeoutMs: 0, // no wall-clock timeout — run to completion
|
|
507
|
-
// Exact-match loop guard only: pathThreshold Infinity
|
|
508
|
-
// disables the path-revisit heuristic, so revisiting one
|
|
509
|
-
// file (which IS this pass's job) never trips — only a
|
|
510
|
-
// literally-identical call repeated past threshold does
|
|
511
|
-
// (e.g. the same read or edit repeated hundreds of times).
|
|
512
|
-
// A hit nudges via the normal restart-with-hint; a loop
|
|
513
|
-
// that survives the nudges is WARNED, not blocked (see
|
|
514
|
-
// r.loopHit handling below and classifyEnforceChildFailure).
|
|
515
|
-
loop: { pathThreshold: Number.POSITIVE_INFINITY },
|
|
516
|
-
onLine: line => {
|
|
517
|
-
lastLine = line;
|
|
518
|
-
logEnforce(line);
|
|
519
|
-
},
|
|
520
|
-
onContextUsage: snapshot => {
|
|
521
|
-
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
// A loop that survived the restart-with-hint nudges is a
|
|
525
|
-
// warning, not a failure: log it and tell the user, but let
|
|
526
|
-
// the verdict gate (below) be the only thing that can block.
|
|
527
|
-
if (r.loopHit) {
|
|
528
|
-
logEnforce(`=== enforce LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
|
|
529
|
-
enforceCtx.ui.notify(`${taskTitle}: enforce worker looped past the nudges — continuing (not blocked).`, 'warning');
|
|
530
|
-
}
|
|
531
|
-
const failure = classifyEnforceChildFailure(r);
|
|
532
|
-
logEnforce(failure ?
|
|
533
|
-
`=== enforce end: FAIL — ${failure} ===`
|
|
534
|
-
: '=== enforce end: verdict captured ===');
|
|
535
|
-
if (failure)
|
|
536
|
-
throw new Error(failure);
|
|
537
|
-
return r.text;
|
|
538
|
-
}
|
|
539
|
-
finally {
|
|
540
|
-
stopLoader();
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
});
|
|
544
|
-
},
|
|
545
|
-
verify: async (verifyCtx, cwd2, taskTitle, taskId) => {
|
|
546
|
-
if (!getConfig().verifyWork) {
|
|
547
|
-
return { ok: true, reason: 'disabled' };
|
|
548
|
-
}
|
|
549
|
-
// The spec to verify against is the composed spec committed in the
|
|
550
|
-
// task file. A task that never reached compose has no spec section —
|
|
551
|
-
// runWorkVerification treats a null spec as a no-op pass.
|
|
552
|
-
let spec;
|
|
553
|
-
try {
|
|
554
|
-
const { body } = await readTaskFile(cwd2, taskId);
|
|
555
|
-
spec = extractSpecForVerification(body);
|
|
556
|
-
}
|
|
557
|
-
catch {
|
|
558
|
-
spec = null;
|
|
559
|
-
}
|
|
560
|
-
return runWorkVerification({
|
|
561
|
-
cwd: cwd2,
|
|
562
|
-
signal,
|
|
563
|
-
spec,
|
|
564
|
-
runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log')
|
|
565
|
-
});
|
|
566
|
-
},
|
|
567
|
-
recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
|
|
568
|
-
// Read the same composed spec the verify gate judged against, so the
|
|
569
|
-
// recommendation reasons over the real contract (a missing spec is
|
|
570
|
-
// unusual here — verify already ran — but degrade to the bare title).
|
|
571
|
-
let spec;
|
|
572
|
-
try {
|
|
573
|
-
const { body } = await readTaskFile(cwd2, taskId);
|
|
574
|
-
spec = extractSpecForVerification(body) ?? taskTitle;
|
|
575
|
-
}
|
|
576
|
-
catch {
|
|
577
|
-
spec = taskTitle;
|
|
578
|
-
}
|
|
579
|
-
return researchResolution({
|
|
580
|
-
cwd: cwd2,
|
|
581
|
-
signal,
|
|
582
|
-
spec,
|
|
583
|
-
failReason,
|
|
584
|
-
runChild: makeGateChild(recCtx, cwd2, taskTitle, 'recommend', 'verify-debug.log')
|
|
585
|
-
});
|
|
586
|
-
}
|
|
384
|
+
...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask })
|
|
587
385
|
};
|
|
588
386
|
}
|
|
589
387
|
// ─── Loop ────────────────────────────────────────────────────────────────────
|
|
590
|
-
/**
|
|
591
|
-
* Show the boxed two-choice picker after a verify FAIL and return what the user
|
|
592
|
-
* decided. The model-recommended card is placed first so the renderer tints it
|
|
593
|
-
* green; the user ALWAYS makes the final call (there is no auto-pick). Mirrors the
|
|
594
|
-
* clarify/grill dialog: the same SessionUI.ask races the local boxed picker
|
|
595
|
-
* against a remote answer, with the two actions also surfaced as remote buttons.
|
|
596
|
-
*/
|
|
597
|
-
async function askVerifyResolution(ctx, title, failReason, rec) {
|
|
598
|
-
const options = resolutionOptions(rec.recommend);
|
|
599
|
-
const question = `Verification FAILED for "${title}".\n\n${failReason}\n\n`
|
|
600
|
-
+ `Recommended: ${rec.recommend.toUpperCase()} — ${rec.rationale}`;
|
|
601
|
-
const answer = await new SessionUI(ctx).ask({
|
|
602
|
-
localTitle: 'Verification failed — how should pi proceed?',
|
|
603
|
-
displayQuestion: question,
|
|
604
|
-
question,
|
|
605
|
-
recommended: options[0].label,
|
|
606
|
-
recommended2: options[1].label,
|
|
607
|
-
allowSkip: false,
|
|
608
|
-
options
|
|
609
|
-
});
|
|
610
|
-
return classifyResolutionAnswer(answer);
|
|
611
|
-
}
|
|
612
388
|
let cancelRequested = false;
|
|
613
389
|
let autoRunning = false;
|
|
614
390
|
export function requestAutoCancel() {
|
|
@@ -715,162 +491,46 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
715
491
|
return;
|
|
716
492
|
}
|
|
717
493
|
// GATE: actually RUN the task's verification against the just-finished
|
|
718
|
-
// work
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
// no attempt cap: AUTOFIX loops straight back to the gate as many
|
|
743
|
-
// times as the user keeps choosing it. The run only leaves the loop
|
|
744
|
-
// when the work verifies, the user ACCEPTs, or the user dismisses
|
|
745
|
-
// the picker (which pauses the run, resumable).
|
|
746
|
-
while (!verified.ok) {
|
|
747
|
-
const failReason = verified.reason ?? 'did not verify';
|
|
748
|
-
const rec = deps.recommend ?
|
|
749
|
-
await deps.recommend(active, cwd, next.title, res.taskId, failReason)
|
|
750
|
-
: { recommend: 'autofix', rationale: failReason };
|
|
751
|
-
const choice = await askVerifyResolution(active, next.title, failReason, rec);
|
|
752
|
-
if (choice.action === 'cancel') {
|
|
753
|
-
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
754
|
-
announceDone(active, `${id} paused at "${next.title}" — verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
|
|
755
|
-
return;
|
|
756
|
-
}
|
|
757
|
-
if (choice.action === 'accept') {
|
|
758
|
-
active.ui.notify(`${id}: accepted "${next.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
|
|
759
|
-
break;
|
|
760
|
-
}
|
|
761
|
-
// AUTOFIX: re-run the implementation turn with the failure (and
|
|
762
|
-
// any typed guidance) prepended as a RE-ATTEMPT banner, then
|
|
763
|
-
// re-verify — looping back to the gate. No cap: the user decides
|
|
764
|
-
// when to stop (by accepting or dismissing the next picker).
|
|
765
|
-
active.ui.notify(`${id}: autofixing "${next.title}"…`, 'info');
|
|
766
|
-
const fixInstruction = choice.guidance ?
|
|
767
|
-
`${failReason}\n\nUser guidance: ${choice.guidance}`
|
|
768
|
-
: failReason;
|
|
769
|
-
const fixRes = await deps.runTask(active, cwd, next.title, {
|
|
770
|
-
resumeId: res.taskId,
|
|
771
|
-
planContext: buildScopeFence(entries.map(e => e.title), next.index),
|
|
772
|
-
fixInstruction
|
|
773
|
-
});
|
|
774
|
-
active = fixRes.ctx ?? active;
|
|
775
|
-
if (fixRes.sessionCancelled) {
|
|
776
|
-
announceDone(active, `${id} paused — could not start a session for autofix. Run /task-auto-resume to retry.`, 'warning');
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
if (fixRes.interrupted) {
|
|
780
|
-
announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
if (!fixRes.ok) {
|
|
784
|
-
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
785
|
-
const why = fixRes.reason ? ` — ${fixRes.reason.slice(0, 160)}` : '';
|
|
786
|
-
announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
// Resume reuses the same inner task id, so res.taskId is stable.
|
|
790
|
-
verified = await deps.verify(active, cwd, next.title, res.taskId);
|
|
791
|
-
}
|
|
792
|
-
// Loop exited because the work verified OR the user accepted the
|
|
793
|
-
// artifact — either way fall through to check-off/commit/enforce.
|
|
794
|
-
// A genuine clean pass is ok===true with NO reason; a no-op pass
|
|
795
|
-
// ('no spec to verify') or an accept-override (verified.ok still
|
|
796
|
-
// false at break) does NOT count as a guardable signal.
|
|
797
|
-
verifyCleanPass = verified.ok && !verified.reason;
|
|
494
|
+
// work, then hold the committed result to AGENTS.md / CLAUDE.md. Shared
|
|
495
|
+
// verbatim with /task so both commands gate identically — see
|
|
496
|
+
// runGatesForTask. `done` means the work verified (or was accepted),
|
|
497
|
+
// was checked off + committed, and enforcement ran; every other kind is
|
|
498
|
+
// a terminal stop this loop announces with its own /task-auto-resume
|
|
499
|
+
// wording (the shared gate is command-agnostic).
|
|
500
|
+
const gate = await runGatesForTask(active, deps, {
|
|
501
|
+
cwd,
|
|
502
|
+
taskId: res.taskId,
|
|
503
|
+
title: next.title,
|
|
504
|
+
tag: id,
|
|
505
|
+
// Fence an AUTOFIX re-run against re-expanding the whole spec.
|
|
506
|
+
planContext: buildScopeFence(entries.map(e => e.title), next.index),
|
|
507
|
+
// res.ok === true means runner.run() completed, so res.taskId is the
|
|
508
|
+
// allocated TASK_NNNN id (never empty here). The parent task-list
|
|
509
|
+
// check-off runs after verify passes/accepts and before the commit,
|
|
510
|
+
// so the commit captures the checked box too.
|
|
511
|
+
onVerified: () => checkOffTask(cwd, id, next.index, res.taskId, next.title)
|
|
512
|
+
});
|
|
513
|
+
active = gate.ctx;
|
|
514
|
+
if (gate.kind === 'paused') {
|
|
515
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
516
|
+
announceDone(active, `${id} paused at "${next.title}" — verification failed and you dismissed the choice; resume with /task-auto-resume.`, 'warning');
|
|
517
|
+
return;
|
|
798
518
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
await checkOffTask(cwd, id, next.index, res.taskId, next.title);
|
|
803
|
-
// Commit the task's work (and the just-written check-off) as one
|
|
804
|
-
// snapshot FIRST — before guideline enforcement — so a passing task is
|
|
805
|
-
// durably recorded no matter what enforcement later finds. Best-effort:
|
|
806
|
-
// a failed/empty commit only warns; the task already passed.
|
|
807
|
-
const message = `task: ${next.title} (${res.taskId})`;
|
|
808
|
-
const commit = await deps.commit(cwd, message);
|
|
809
|
-
if (commit.committed) {
|
|
810
|
-
active.ui.notify(`${id}: committed "${next.title}".`, 'info');
|
|
519
|
+
if (gate.kind === 'session-cancelled') {
|
|
520
|
+
announceDone(active, `${id} paused — could not start a session for autofix. Run /task-auto-resume to retry.`, 'warning');
|
|
521
|
+
return;
|
|
811
522
|
}
|
|
812
|
-
|
|
813
|
-
active
|
|
523
|
+
if (gate.kind === 'interrupted') {
|
|
524
|
+
announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
525
|
+
return;
|
|
814
526
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
// verify signal:
|
|
821
|
-
//
|
|
822
|
-
// - 'edit' mode (read,edit — fix in place) runs ONLY when the verify
|
|
823
|
-
// gate just produced a genuine clean pass. That pass is the
|
|
824
|
-
// differential SIGNAL: enforce commits its fixes, then the SAME
|
|
825
|
-
// signal is re-run against the enforced tree. A regression means the
|
|
826
|
-
// pass made the artifact worse than it found it — its commit is
|
|
827
|
-
// dropped and the verified implementation is kept (A/B: 5/5 clean).
|
|
828
|
-
// - 'flag' mode (read-only — report don't fix) runs when there is no
|
|
829
|
-
// such signal (verify off, no spec, or an accept-override): with
|
|
830
|
-
// nothing to revert against, the pass may not rewrite logic, only
|
|
831
|
-
// surface violations as a warning ("no signal ⇒ no license"; A/B:
|
|
832
|
-
// 5/5 clean, 5/5 caught the real violation).
|
|
833
|
-
//
|
|
834
|
-
// Skipped when nothing was committed this round (autoCommit off or an
|
|
835
|
-
// empty commit), when the feature is off, when there are no guideline
|
|
836
|
-
// files, or in tests with no enforce dep.
|
|
837
|
-
if (deps.enforce && commit.committed) {
|
|
838
|
-
const mode = verifyCleanPass ? 'edit' : 'flag';
|
|
839
|
-
active.ui.notify(mode === 'edit' ?
|
|
840
|
-
`${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`
|
|
841
|
-
: `${id}: reviewing "${next.title}" against AGENTS.md/CLAUDE.md (no verify signal — report only)…`, 'info');
|
|
842
|
-
const verdict = await deps.enforce(active, cwd, next.title, mode);
|
|
843
|
-
if (!verdict.ok) {
|
|
844
|
-
active.ui.notify(`${id}: guideline ${mode === 'edit' ? 'enforcement' : 'review'} on "${next.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
|
|
845
|
-
}
|
|
846
|
-
if (mode === 'edit') {
|
|
847
|
-
// Commit whatever the pass fixed as its own snapshot. A no-op
|
|
848
|
-
// when it made no edits (nothing to commit) — and then there is
|
|
849
|
-
// nothing to re-verify or revert, so a clean task skips the
|
|
850
|
-
// second verify entirely.
|
|
851
|
-
const enforceCommit = await deps.commit(cwd, `ENFORCE GUIDELINES: ${next.title} (${res.taskId})`);
|
|
852
|
-
if (enforceCommit.committed) {
|
|
853
|
-
// Differential guard: re-run the verify signal against the
|
|
854
|
-
// enforced tree. verifyCleanPass implies deps.verify exists,
|
|
855
|
-
// so this is a real check. A regression ⇒ drop the enforce
|
|
856
|
-
// commit, keep the verified task commit underneath it.
|
|
857
|
-
const after = deps.verify ?
|
|
858
|
-
await deps.verify(active, cwd, next.title, res.taskId)
|
|
859
|
-
: { ok: true };
|
|
860
|
-
if (!after.ok) {
|
|
861
|
-
// Regression: drop the enforce commit if we can, and
|
|
862
|
-
// never report it as a kept fix.
|
|
863
|
-
if (deps.revert)
|
|
864
|
-
await deps.revert(cwd);
|
|
865
|
-
active.ui.notify(`${id}: guideline fixes regressed verification on "${next.title}" (${(after.reason ?? 'now fails').slice(0, 120)}) — ${deps.revert ? 'reverted them, kept the verified work' : 'left in place (no revert available)'}.`, 'warning');
|
|
866
|
-
}
|
|
867
|
-
else {
|
|
868
|
-
active.ui.notify(`${id}: committed guideline fixes for "${next.title}".`, 'info');
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
// 'flag' mode makes no edits — nothing to commit or revert.
|
|
527
|
+
if (gate.kind === 'failed') {
|
|
528
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
529
|
+
const why = gate.reason ? ` — ${gate.reason.slice(0, 160)}` : '';
|
|
530
|
+
announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
|
|
531
|
+
return;
|
|
873
532
|
}
|
|
533
|
+
// gate.kind === 'done' → fall through to the next task.
|
|
874
534
|
}
|
|
875
535
|
}
|
|
876
536
|
catch (err) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GateDeps } from './task-gates.js';
|
|
2
|
+
/** A function that re-runs a task's implementation turn (AUTOFIX). Injected by the
|
|
3
|
+
* command so this module stays free of the orchestrators (avoids an import cycle). */
|
|
4
|
+
export type RunTaskFn = GateDeps['runTask'];
|
|
5
|
+
/**
|
|
6
|
+
* Build the gate deps for one command run. `runTask` is the orchestrator's
|
|
7
|
+
* implementation re-runner, injected by the caller. The returned object also drives
|
|
8
|
+
* a shared status widget: `getLastLine`/`getContextUsage` expose the children's
|
|
9
|
+
* latest stream line and context usage so the caller can mirror them in its own
|
|
10
|
+
* loader if it wants (the gate closures already render their own loaders).
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildGateDeps(params: {
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
parentContextWindow: number;
|
|
15
|
+
runTask: RunTaskFn;
|
|
16
|
+
}): GateDeps;
|