@mjasnikovs/pi-task 0.14.11 → 0.14.13
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 +1 -0
- package/dist/config/config.js +2 -1
- package/dist/config/register.js +5 -0
- package/dist/remote/bridge.d.ts +9 -6
- package/dist/remote/bridge.js +15 -18
- package/dist/task/auto-orchestrator.d.ts +11 -0
- package/dist/task/auto-orchestrator.js +122 -18
- package/dist/task/phases.js +17 -17
- package/dist/task/question-box.d.ts +85 -0
- package/dist/task/question-box.js +166 -0
- package/dist/task/verify-work.d.ts +68 -0
- package/dist/task/verify-work.js +161 -0
- package/dist/task/widget.d.ts +3 -2
- package/dist/task/widget.js +3 -3
- package/package.json +1 -1
package/dist/config/config.d.ts
CHANGED
package/dist/config/config.js
CHANGED
|
@@ -7,7 +7,8 @@ const DEFAULTS = {
|
|
|
7
7
|
compressReasoning: true,
|
|
8
8
|
autoCommit: true,
|
|
9
9
|
orientation: true,
|
|
10
|
-
enforceGuidelines: false
|
|
10
|
+
enforceGuidelines: false,
|
|
11
|
+
verifyWork: false
|
|
11
12
|
};
|
|
12
13
|
const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
|
|
13
14
|
const _g = globalThis;
|
package/dist/config/register.js
CHANGED
|
@@ -22,6 +22,11 @@ const ITEMS = [
|
|
|
22
22
|
id: 'enforceGuidelines',
|
|
23
23
|
label: 'enforce guidelines',
|
|
24
24
|
description: 'Before each /task-auto commit, re-check the work against AGENTS.md/CLAUDE.md and fix drift'
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'verifyWork',
|
|
28
|
+
label: 'verify work',
|
|
29
|
+
description: "After each /task-auto task, RUN its spec's VERIFY block in the workspace and report a PASS/FAIL verdict"
|
|
25
30
|
}
|
|
26
31
|
];
|
|
27
32
|
function makeTheme(theme) {
|
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export declare function answerPrompt(id: string, value: string | undefined): voi
|
|
|
20
20
|
export interface AskSpec {
|
|
21
21
|
/** Themed, possibly multi-line title for the local TUI input. */
|
|
22
22
|
localTitle: string;
|
|
23
|
+
/** Themed (markdown-rendered) question shown as the boxed picker header.
|
|
24
|
+
* Falls back to `question` when absent. */
|
|
25
|
+
displayQuestion?: string;
|
|
23
26
|
/** Plain question text for the browser card. */
|
|
24
27
|
question: string;
|
|
25
28
|
/** Primary recommended option, prefilled in the local TUI. */
|
|
@@ -52,12 +55,12 @@ export declare class SessionUI {
|
|
|
52
55
|
/** Race the local input against a remote answer; first to settle wins. */
|
|
53
56
|
ask(spec: AskSpec): Promise<string | undefined>;
|
|
54
57
|
/**
|
|
55
|
-
* The local-TUI half of ask(). With `spec.options` it renders
|
|
56
|
-
* picker (each
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* undefined.
|
|
58
|
+
* The local-TUI half of ask(). With `spec.options` it renders the boxed
|
|
59
|
+
* picker (each answer in its own bounding box, the first/recommended one
|
|
60
|
+
* tinted green) plus a trailing "type a different answer" entry that drops to
|
|
61
|
+
* a text input; the chosen entry's `value` is returned. Without options it
|
|
62
|
+
* falls back to a single text input. Cancelling either dialog (or an abort
|
|
63
|
+
* when the remote wins the race) resolves to undefined.
|
|
61
64
|
*/
|
|
62
65
|
private askLocal;
|
|
63
66
|
}
|
package/dist/remote/bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { broadcast as wsBroadcast } from './broadcast.js';
|
|
2
2
|
import { pushNotify } from './push.js';
|
|
3
3
|
import { setPrompt, clearPrompt } from './session-state.js';
|
|
4
|
+
import { askQuestionBox } from '../task/question-box.js';
|
|
4
5
|
const g = globalThis;
|
|
5
6
|
export function getBridge() {
|
|
6
7
|
if (!g.__piBridge) {
|
|
@@ -25,9 +26,6 @@ export function answerPrompt(id, value) {
|
|
|
25
26
|
b.pending.delete(id);
|
|
26
27
|
settle(value);
|
|
27
28
|
}
|
|
28
|
-
/** Trailing picker entry that drops to a free-text input — the local mirror of
|
|
29
|
-
* the remote card's "✎ Manual answer" button. */
|
|
30
|
-
const TYPE_OWN_LABEL = 'Type a different answer…';
|
|
31
29
|
/** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
|
|
32
30
|
export class SessionUI {
|
|
33
31
|
ctx;
|
|
@@ -83,27 +81,26 @@ export class SessionUI {
|
|
|
83
81
|
}
|
|
84
82
|
}
|
|
85
83
|
/**
|
|
86
|
-
* The local-TUI half of ask(). With `spec.options` it renders
|
|
87
|
-
* picker (each
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* undefined.
|
|
84
|
+
* The local-TUI half of ask(). With `spec.options` it renders the boxed
|
|
85
|
+
* picker (each answer in its own bounding box, the first/recommended one
|
|
86
|
+
* tinted green) plus a trailing "type a different answer" entry that drops to
|
|
87
|
+
* a text input; the chosen entry's `value` is returned. Without options it
|
|
88
|
+
* falls back to a single text input. Cancelling either dialog (or an abort
|
|
89
|
+
* when the remote wins the race) resolves to undefined.
|
|
92
90
|
*/
|
|
93
91
|
async askLocal(spec, signal) {
|
|
94
92
|
const opts = spec.options;
|
|
95
93
|
if (opts && opts.length > 0) {
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
return askQuestionBox(this.ctx, {
|
|
95
|
+
question: spec.displayQuestion ?? spec.question,
|
|
96
|
+
inputTitle: spec.localTitle,
|
|
97
|
+
options: opts.map((o, i) => ({
|
|
98
|
+
label: o.label,
|
|
99
|
+
value: o.value,
|
|
100
|
+
recommended: i === 0
|
|
101
|
+
})),
|
|
98
102
|
signal
|
|
99
103
|
});
|
|
100
|
-
if (choice === undefined)
|
|
101
|
-
return undefined; // cancelled / aborted
|
|
102
|
-
if (choice === TYPE_OWN_LABEL) {
|
|
103
|
-
return this.ctx.ui.input(spec.localTitle, undefined, { signal });
|
|
104
|
-
}
|
|
105
|
-
const hit = opts.find(o => o.label === choice);
|
|
106
|
-
return hit ? hit.value : choice;
|
|
107
104
|
}
|
|
108
105
|
return this.ctx.ui.input(spec.localTitle, spec.recommended, { signal });
|
|
109
106
|
}
|
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-c
|
|
|
2
2
|
import type { RunSingleTaskResult } from './orchestrator.js';
|
|
3
3
|
import { type CommitResult } from './auto-commit.js';
|
|
4
4
|
import { type EnforceOutcome } from './enforce-guidelines.js';
|
|
5
|
+
import { type VerifyOutcome } from './verify-work.js';
|
|
5
6
|
/**
|
|
6
7
|
* Injectable seams so the planner and loop are testable without spawning pi.
|
|
7
8
|
* `runChild` is used by planAuto; `runTask` is used by runAutoLoop.
|
|
@@ -29,6 +30,16 @@ export interface AutoDeps {
|
|
|
29
30
|
* enforcement runs and using it for the loader widget throws "stale ctx".
|
|
30
31
|
*/
|
|
31
32
|
enforce?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string) => Promise<EnforceOutcome>;
|
|
33
|
+
/**
|
|
34
|
+
* Verify the just-finished task's work by RUNNING its composed spec's VERIFY
|
|
35
|
+
* block in the real workspace (a fresh child of the same local model, with a
|
|
36
|
+
* `bash` tool), then reporting a PASS/FAIL verdict. Optional: absent in tests
|
|
37
|
+
* or when the `verifyWork` flag is off, in which case the loop treats it as a
|
|
38
|
+
* pass. Runs BEFORE the task is checked off/committed and is a hard GATE: ok
|
|
39
|
+
* === false stops the run and fails the task (left unchecked so resume re-runs
|
|
40
|
+
* it). Needs the inner taskId to read that task's spec.
|
|
41
|
+
*/
|
|
42
|
+
verify?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string) => Promise<VerifyOutcome>;
|
|
32
43
|
}
|
|
33
44
|
/**
|
|
34
45
|
* Expand any @file references in the feature text by appending each referenced
|
|
@@ -16,6 +16,7 @@ import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, check
|
|
|
16
16
|
import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
|
|
17
17
|
import { gitCommitAll } from './auto-commit.js';
|
|
18
18
|
import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
|
|
19
|
+
import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
|
|
19
20
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
20
21
|
import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
|
|
21
22
|
import { runPhaseChild, prependHint, formatLoopHint, USER_CANCELLED } from './child-runner.js';
|
|
@@ -246,30 +247,30 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
246
247
|
askedQuestions.push(plainQ);
|
|
247
248
|
const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
|
|
248
249
|
const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
|
|
249
|
-
// Identical to /task's grill dialog: a
|
|
250
|
-
// picker locally — each
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// "press Enter to accept" scaffolding.
|
|
250
|
+
// Identical to /task's grill dialog: a recommendation (or A/B fork)
|
|
251
|
+
// becomes the boxed picker locally — each answer in its own bounding box,
|
|
252
|
+
// the recommended one tinted green; an open question shows the bare text
|
|
253
|
+
// prompt. No verbose "Recommended:" / "press Enter to accept" scaffolding.
|
|
254
254
|
const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
255
|
+
const options = twoOption ?
|
|
256
|
+
[
|
|
257
|
+
{
|
|
258
|
+
label: `A: ${renderInlineMarkdown(suggested, theme)}`,
|
|
259
|
+
value: plainSuggested
|
|
260
|
+
},
|
|
261
|
+
{ label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
|
|
262
|
+
]
|
|
263
|
+
: plainSuggested !== undefined ?
|
|
264
|
+
[{ label: renderInlineMarkdown(suggested, theme), value: plainSuggested }]
|
|
265
|
+
: undefined;
|
|
258
266
|
const a = await ui.ask({
|
|
259
|
-
localTitle:
|
|
267
|
+
localTitle: shownQ,
|
|
268
|
+
displayQuestion: shownQ,
|
|
260
269
|
question: plainQ,
|
|
261
270
|
recommended: plainSuggested,
|
|
262
271
|
...(plainAlt !== undefined && { recommended2: plainAlt }),
|
|
263
272
|
allowSkip: plainSuggested === undefined && plainAlt === undefined,
|
|
264
|
-
...(
|
|
265
|
-
options: [
|
|
266
|
-
{
|
|
267
|
-
label: `A: ${renderInlineMarkdown(suggested, theme)}`,
|
|
268
|
-
value: plainSuggested
|
|
269
|
-
},
|
|
270
|
-
{ label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
|
|
271
|
-
]
|
|
272
|
-
})
|
|
273
|
+
...(options && { options })
|
|
273
274
|
});
|
|
274
275
|
if (a === undefined) {
|
|
275
276
|
announceDone(ctx, '/task-auto cancelled.', 'warning');
|
|
@@ -292,6 +293,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
292
293
|
else if (twoOption && /^b[.)]?$/i.test(typed)) {
|
|
293
294
|
answer = plainAlt;
|
|
294
295
|
}
|
|
296
|
+
else if (!twoOption && plainSuggested !== undefined && typed === plainSuggested) {
|
|
297
|
+
// Single recommendation accepted by picking its (green) card in the
|
|
298
|
+
// boxed picker — same provenance as an empty-submit accept.
|
|
299
|
+
answer = `${plainSuggested} (accepted recommendation)`;
|
|
300
|
+
}
|
|
295
301
|
else {
|
|
296
302
|
answer = typed;
|
|
297
303
|
}
|
|
@@ -476,6 +482,86 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
476
482
|
}
|
|
477
483
|
}
|
|
478
484
|
});
|
|
485
|
+
},
|
|
486
|
+
verify: async (verifyCtx, cwd2, taskTitle, taskId) => {
|
|
487
|
+
if (!getConfig().verifyWork) {
|
|
488
|
+
return { ok: true, reason: 'disabled' };
|
|
489
|
+
}
|
|
490
|
+
// The spec to verify against is the composed spec committed in the
|
|
491
|
+
// task file. A task that never reached compose has no spec section —
|
|
492
|
+
// runWorkVerification treats a null spec as a no-op pass.
|
|
493
|
+
let spec;
|
|
494
|
+
try {
|
|
495
|
+
const { body } = await readTaskFile(cwd2, taskId);
|
|
496
|
+
spec = extractSpecForVerification(body);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
spec = null;
|
|
500
|
+
}
|
|
501
|
+
return runWorkVerification({
|
|
502
|
+
cwd: cwd2,
|
|
503
|
+
signal,
|
|
504
|
+
spec,
|
|
505
|
+
// Same unguarded child as enforce: no wall-clock timeout (a build
|
|
506
|
+
// or test suite legitimately takes minutes) and exact-match loop
|
|
507
|
+
// guard only. Re-running the same VERIFY command is the job, so the
|
|
508
|
+
// path-revisit heuristic is disabled; only a literally-identical
|
|
509
|
+
// call repeated past threshold trips, and that warns rather than
|
|
510
|
+
// blocks.
|
|
511
|
+
runChild: async (tools, prompt, sig) => {
|
|
512
|
+
lastLine = undefined;
|
|
513
|
+
contextUsage = undefined;
|
|
514
|
+
const startedAt = Date.now();
|
|
515
|
+
const verifyLogPath = path.join(tasksDir(cwd2), 'verify-debug.log');
|
|
516
|
+
const logVerify = (msg) => {
|
|
517
|
+
void fsp
|
|
518
|
+
.appendFile(verifyLogPath, `${new Date().toISOString()} ${msg}\n`)
|
|
519
|
+
.catch(() => { });
|
|
520
|
+
};
|
|
521
|
+
logVerify(`=== verify start: ${taskTitle} ===`);
|
|
522
|
+
const stopLoader = startAutoLoader(verifyCtx, () => ({
|
|
523
|
+
title: taskTitle,
|
|
524
|
+
kind: 'verify',
|
|
525
|
+
step: 'verify',
|
|
526
|
+
stepNum: 1,
|
|
527
|
+
stepTotal: 1,
|
|
528
|
+
startedAt,
|
|
529
|
+
lastLine,
|
|
530
|
+
contextUsage
|
|
531
|
+
}));
|
|
532
|
+
try {
|
|
533
|
+
const r = await runWorker({
|
|
534
|
+
prompt,
|
|
535
|
+
cwd: cwd2,
|
|
536
|
+
signal: sig,
|
|
537
|
+
tools,
|
|
538
|
+
timeoutMs: 0,
|
|
539
|
+
loop: { pathThreshold: Number.POSITIVE_INFINITY },
|
|
540
|
+
onLine: line => {
|
|
541
|
+
lastLine = line;
|
|
542
|
+
logVerify(line);
|
|
543
|
+
},
|
|
544
|
+
onContextUsage: snapshot => {
|
|
545
|
+
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
if (r.loopHit) {
|
|
549
|
+
logVerify(`=== verify LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
|
|
550
|
+
verifyCtx.ui.notify(`${taskTitle}: verify worker looped past the nudges — continuing (not blocked).`, 'warning');
|
|
551
|
+
}
|
|
552
|
+
const failure = classifyEnforceChildFailure(r);
|
|
553
|
+
logVerify(failure ?
|
|
554
|
+
`=== verify end: FAIL — ${failure} ===`
|
|
555
|
+
: '=== verify end: verdict captured ===');
|
|
556
|
+
if (failure)
|
|
557
|
+
throw new Error(failure);
|
|
558
|
+
return r.text;
|
|
559
|
+
}
|
|
560
|
+
finally {
|
|
561
|
+
stopLoader();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
});
|
|
479
565
|
}
|
|
480
566
|
};
|
|
481
567
|
}
|
|
@@ -581,6 +667,24 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
581
667
|
announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
|
|
582
668
|
return;
|
|
583
669
|
}
|
|
670
|
+
// GATE: actually RUN the task's verification against the just-finished
|
|
671
|
+
// work BEFORE it is checked off or committed. The composed spec ships a
|
|
672
|
+
// VERIFY block, but nothing in the pipeline ever executed it — a task
|
|
673
|
+
// that did not work was checked off identically to one that did. Run it
|
|
674
|
+
// now in the real workspace; a FAIL stops the whole run exactly like an
|
|
675
|
+
// implementation failure — the task is left UNCHECKED and UNCOMMITTED so
|
|
676
|
+
// /task-auto-resume re-runs it (rather than blessing broken work). Off
|
|
677
|
+
// by default (deps.verify returns a disabled no-op) and a no-op when the
|
|
678
|
+
// task has no composed spec to verify against.
|
|
679
|
+
if (deps.verify) {
|
|
680
|
+
active.ui.notify(`${id}: verifying "${next.title}"…`, 'info');
|
|
681
|
+
const verified = await deps.verify(active, cwd, next.title, res.taskId);
|
|
682
|
+
if (!verified.ok) {
|
|
683
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
684
|
+
announceDone(active, `${id} stopped at "${next.title}" — ${verified.reason ?? 'did not verify'} — fix and run /task-auto-resume.`, 'error');
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
584
688
|
// res.ok === true means runner.run() completed, so res.taskId is the
|
|
585
689
|
// allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
|
|
586
690
|
// empty id by writing a plain checked line, but that path is unreachable.
|
package/dist/task/phases.js
CHANGED
|
@@ -517,30 +517,30 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
|
517
517
|
else {
|
|
518
518
|
const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
|
|
519
519
|
const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
|
|
520
|
-
// A
|
|
521
|
-
// each
|
|
522
|
-
//
|
|
523
|
-
// question shows the bare prompt.
|
|
520
|
+
// A recommendation (or suggested+alt fork) becomes the boxed picker
|
|
521
|
+
// locally — each answer in its own bounding box, the recommended one
|
|
522
|
+
// tinted green; an open question shows the bare text prompt.
|
|
524
523
|
const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
524
|
+
const options = twoOption ?
|
|
525
|
+
[
|
|
526
|
+
{
|
|
527
|
+
label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
|
|
528
|
+
value: plainSuggested
|
|
529
|
+
},
|
|
530
|
+
{ label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
|
|
531
|
+
]
|
|
532
|
+
: plainSuggested !== undefined ?
|
|
533
|
+
[{ label: renderInlineMarkdown(auto.suggested, theme), value: plainSuggested }]
|
|
534
|
+
: undefined;
|
|
528
535
|
widgetState.lastLine = `awaiting Q${n + 1}`;
|
|
529
536
|
const a = await ui.ask({
|
|
530
|
-
localTitle,
|
|
537
|
+
localTitle: shownQ,
|
|
538
|
+
displayQuestion: shownQ,
|
|
531
539
|
question: plainQ,
|
|
532
540
|
recommended: plainSuggested,
|
|
533
541
|
recommended2: plainAlt,
|
|
534
542
|
allowSkip: plainSuggested === undefined && plainAlt === undefined,
|
|
535
|
-
...(
|
|
536
|
-
options: [
|
|
537
|
-
{
|
|
538
|
-
label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
|
|
539
|
-
value: plainSuggested
|
|
540
|
-
},
|
|
541
|
-
{ label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
|
|
542
|
-
]
|
|
543
|
-
})
|
|
543
|
+
...(options && { options })
|
|
544
544
|
});
|
|
545
545
|
if (a === undefined)
|
|
546
546
|
throw new Error(USER_CANCELLED);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boxed question dialog for the clarify / grill phases.
|
|
3
|
+
*
|
|
4
|
+
* The built-in `ctx.ui.select` renders every option as a bare line under one
|
|
5
|
+
* outer frame — the question, the recommendation, and the choices all read as a
|
|
6
|
+
* single blob. The remote (browser) prompt card instead gives each response its
|
|
7
|
+
* own bounded panel, tints the recommended one green, and separates everything
|
|
8
|
+
* with margins. This module mirrors that remote style in the terminal: a custom
|
|
9
|
+
* `ctx.ui.custom` component that draws each answer in its own bounding box with a
|
|
10
|
+
* green RECOMMENDED tag, an accent-highlighted cursor box, and clear vertical
|
|
11
|
+
* spacing.
|
|
12
|
+
*
|
|
13
|
+
* `renderQuestionBox` is the pure layout — width in, lines out — so the visual
|
|
14
|
+
* structure (borders, tag, cursor arrow, padding) is unit-testable without a
|
|
15
|
+
* live TUI. The component and `askQuestionBox` wire it to keyboard input and the
|
|
16
|
+
* "type a different answer" free-text fallback.
|
|
17
|
+
*/
|
|
18
|
+
import type { Component } from '@earendil-works/pi-tui';
|
|
19
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
20
|
+
/** One answer card. `label` may carry ANSI (rendered markdown); width math is
|
|
21
|
+
* ANSI-aware so the box borders still line up. */
|
|
22
|
+
export interface BoxCard {
|
|
23
|
+
label: string;
|
|
24
|
+
/** Recommended answer → green chrome + RECOMMENDED tag. */
|
|
25
|
+
recommended?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Chrome colourers, injected so tests can pass identity fns and assert on the
|
|
28
|
+
* plain box structure. A real theme is adapted by {@link boxColors}. */
|
|
29
|
+
export interface BoxColors {
|
|
30
|
+
border: (s: string) => string;
|
|
31
|
+
borderRecommended: (s: string) => string;
|
|
32
|
+
borderSelected: (s: string) => string;
|
|
33
|
+
recommendedTag: (s: string) => string;
|
|
34
|
+
questionLabel: (s: string) => string;
|
|
35
|
+
hint: (s: string) => string;
|
|
36
|
+
arrow: (s: string) => string;
|
|
37
|
+
}
|
|
38
|
+
type ThemeLike = ExtensionCommandContext['ui']['theme'];
|
|
39
|
+
export declare const QUESTION_LABEL = "QUESTION";
|
|
40
|
+
export declare const HINT_TEXT = "\u2191\u2193 navigate \u00B7 enter select \u00B7 esc cancel";
|
|
41
|
+
export declare const MANUAL_CARD_LABEL = "\u270E Type a different answer\u2026";
|
|
42
|
+
/**
|
|
43
|
+
* Lay the whole dialog out for `width` columns: a QUESTION label + wrapped
|
|
44
|
+
* question, one bounding box per card (recommended tinted green, the cursor box
|
|
45
|
+
* highlighted with an accent border and "▸" arrow), and a key-hint footer.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderQuestionBox(width: number, question: string, cards: BoxCard[], selected: number, c: BoxColors, hintText?: string): string[];
|
|
48
|
+
export declare function boxColors(theme: ThemeLike): BoxColors;
|
|
49
|
+
/** Focusable picker component; navigation mirrors the built-in select dialog. */
|
|
50
|
+
export declare class QuestionBoxComponent implements Component {
|
|
51
|
+
private readonly question;
|
|
52
|
+
private readonly cards;
|
|
53
|
+
private readonly colors;
|
|
54
|
+
private readonly onChoose;
|
|
55
|
+
private readonly onCancel;
|
|
56
|
+
private selected;
|
|
57
|
+
/** Card labels in display order (recommended first, manual last). Read-only
|
|
58
|
+
* view for tests that drive the picker without parsing rendered output. */
|
|
59
|
+
get cardLabels(): string[];
|
|
60
|
+
/** The question header text. Read-only view for tests. */
|
|
61
|
+
get headerText(): string;
|
|
62
|
+
constructor(question: string, cards: BoxCard[], colors: BoxColors, onChoose: (index: number) => void, onCancel: () => void);
|
|
63
|
+
render(width: number): string[];
|
|
64
|
+
invalidate(): void;
|
|
65
|
+
handleInput(data: string): void;
|
|
66
|
+
}
|
|
67
|
+
export interface BoxOption {
|
|
68
|
+
label: string;
|
|
69
|
+
value: string;
|
|
70
|
+
recommended?: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface AskQuestionBoxSpec {
|
|
73
|
+
/** Display question for the box header (may carry ANSI). */
|
|
74
|
+
question: string;
|
|
75
|
+
/** Plain title for the free-text fallback input dialog. */
|
|
76
|
+
inputTitle: string;
|
|
77
|
+
options: BoxOption[];
|
|
78
|
+
signal: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Show the boxed picker and resolve to the chosen option's `value`, the text the
|
|
82
|
+
* user typed via "type a different answer", or undefined when cancelled/aborted.
|
|
83
|
+
*/
|
|
84
|
+
export declare function askQuestionBox(ctx: ExtensionCommandContext, spec: AskQuestionBoxSpec): Promise<string | undefined>;
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boxed question dialog for the clarify / grill phases.
|
|
3
|
+
*
|
|
4
|
+
* The built-in `ctx.ui.select` renders every option as a bare line under one
|
|
5
|
+
* outer frame — the question, the recommendation, and the choices all read as a
|
|
6
|
+
* single blob. The remote (browser) prompt card instead gives each response its
|
|
7
|
+
* own bounded panel, tints the recommended one green, and separates everything
|
|
8
|
+
* with margins. This module mirrors that remote style in the terminal: a custom
|
|
9
|
+
* `ctx.ui.custom` component that draws each answer in its own bounding box with a
|
|
10
|
+
* green RECOMMENDED tag, an accent-highlighted cursor box, and clear vertical
|
|
11
|
+
* spacing.
|
|
12
|
+
*
|
|
13
|
+
* `renderQuestionBox` is the pure layout — width in, lines out — so the visual
|
|
14
|
+
* structure (borders, tag, cursor arrow, padding) is unit-testable without a
|
|
15
|
+
* live TUI. The component and `askQuestionBox` wire it to keyboard input and the
|
|
16
|
+
* "type a different answer" free-text fallback.
|
|
17
|
+
*/
|
|
18
|
+
import { getKeybindings, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
19
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
20
|
+
/** Left gutter: two columns reserved for the "▸ " cursor so boxes stay aligned. */
|
|
21
|
+
const GUTTER = 2;
|
|
22
|
+
const RECOMMENDED_TAG = 'RECOMMENDED';
|
|
23
|
+
/** Smallest box that still fits "┌─ RECOMMENDED ─┐". Below this we drop the tag. */
|
|
24
|
+
const TAG_MIN_BOX_WIDTH = visibleWidth(`┌─ ${RECOMMENDED_TAG} ─┐`);
|
|
25
|
+
export const QUESTION_LABEL = 'QUESTION';
|
|
26
|
+
export const HINT_TEXT = '↑↓ navigate · enter select · esc cancel';
|
|
27
|
+
export const MANUAL_CARD_LABEL = '✎ Type a different answer…';
|
|
28
|
+
// ─── Pure renderer ─────────────────────────────────────────────────────────────
|
|
29
|
+
function padTo(line, width) {
|
|
30
|
+
const pad = width - visibleWidth(line);
|
|
31
|
+
return pad > 0 ? line + ' '.repeat(pad) : line;
|
|
32
|
+
}
|
|
33
|
+
function renderCard(card, selected, boxWidth, c) {
|
|
34
|
+
const borderFn = selected ? c.borderSelected
|
|
35
|
+
: card.recommended ? c.borderRecommended
|
|
36
|
+
: c.border;
|
|
37
|
+
const innerWidth = Math.max(1, boxWidth - 4); // "│ " + content + " │"
|
|
38
|
+
const dash = (n) => '─'.repeat(Math.max(0, n));
|
|
39
|
+
// Top border, with a RECOMMENDED tag woven in when it fits.
|
|
40
|
+
let top;
|
|
41
|
+
if (card.recommended && boxWidth >= TAG_MIN_BOX_WIDTH) {
|
|
42
|
+
// TAG_MIN_BOX_WIDTH is the width at one trailing dash, so add it back.
|
|
43
|
+
const dashes = boxWidth - TAG_MIN_BOX_WIDTH + 1;
|
|
44
|
+
top = borderFn('┌─ ') + c.recommendedTag(RECOMMENDED_TAG) + borderFn(` ${dash(dashes)}┐`);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
top = borderFn(`┌${dash(boxWidth - 2)}┐`);
|
|
48
|
+
}
|
|
49
|
+
const bodyLines = wrapTextWithAnsi(card.label, innerWidth);
|
|
50
|
+
const body = (bodyLines.length > 0 ? bodyLines : ['']).map(line => borderFn('│ ') + padTo(line, innerWidth) + borderFn(' │'));
|
|
51
|
+
const bottom = borderFn(`└${dash(boxWidth - 2)}┘`);
|
|
52
|
+
const arrow = selected ? c.arrow('▸') + ' ' : ' ';
|
|
53
|
+
const pad = ' ';
|
|
54
|
+
return [arrow + top, ...body.map(b => pad + b), pad + bottom];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Lay the whole dialog out for `width` columns: a QUESTION label + wrapped
|
|
58
|
+
* question, one bounding box per card (recommended tinted green, the cursor box
|
|
59
|
+
* highlighted with an accent border and "▸" arrow), and a key-hint footer.
|
|
60
|
+
*/
|
|
61
|
+
export function renderQuestionBox(width, question, cards, selected, c, hintText = HINT_TEXT) {
|
|
62
|
+
const W = Math.max(width, 12);
|
|
63
|
+
const boxWidth = Math.max(6, W - GUTTER);
|
|
64
|
+
const out = [''];
|
|
65
|
+
out.push(' ' + c.questionLabel(QUESTION_LABEL));
|
|
66
|
+
for (const line of wrapTextWithAnsi(question, W - GUTTER))
|
|
67
|
+
out.push(' ' + line);
|
|
68
|
+
out.push('');
|
|
69
|
+
for (let i = 0; i < cards.length; i++) {
|
|
70
|
+
out.push(...renderCard(cards[i], i === selected, boxWidth, c));
|
|
71
|
+
out.push('');
|
|
72
|
+
}
|
|
73
|
+
for (const line of wrapTextWithAnsi(hintText, W - GUTTER))
|
|
74
|
+
out.push(' ' + c.hint(line));
|
|
75
|
+
out.push('');
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
// ─── Theme adapter ─────────────────────────────────────────────────────────────
|
|
79
|
+
export function boxColors(theme) {
|
|
80
|
+
return {
|
|
81
|
+
border: s => theme.fg('borderMuted', s),
|
|
82
|
+
borderRecommended: s => theme.fg('success', s),
|
|
83
|
+
borderSelected: s => theme.fg('accent', s),
|
|
84
|
+
recommendedTag: s => theme.fg('success', theme.bold(s)),
|
|
85
|
+
questionLabel: s => theme.fg('accent', theme.bold(s)),
|
|
86
|
+
hint: s => theme.fg('muted', s),
|
|
87
|
+
arrow: s => theme.fg('accent', theme.bold(s))
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// ─── Component ─────────────────────────────────────────────────────────────────
|
|
91
|
+
/** Focusable picker component; navigation mirrors the built-in select dialog. */
|
|
92
|
+
export class QuestionBoxComponent {
|
|
93
|
+
question;
|
|
94
|
+
cards;
|
|
95
|
+
colors;
|
|
96
|
+
onChoose;
|
|
97
|
+
onCancel;
|
|
98
|
+
selected = 0;
|
|
99
|
+
/** Card labels in display order (recommended first, manual last). Read-only
|
|
100
|
+
* view for tests that drive the picker without parsing rendered output. */
|
|
101
|
+
get cardLabels() {
|
|
102
|
+
return this.cards.map(c => c.label);
|
|
103
|
+
}
|
|
104
|
+
/** The question header text. Read-only view for tests. */
|
|
105
|
+
get headerText() {
|
|
106
|
+
return this.question;
|
|
107
|
+
}
|
|
108
|
+
constructor(question, cards, colors, onChoose, onCancel) {
|
|
109
|
+
this.question = question;
|
|
110
|
+
this.cards = cards;
|
|
111
|
+
this.colors = colors;
|
|
112
|
+
this.onChoose = onChoose;
|
|
113
|
+
this.onCancel = onCancel;
|
|
114
|
+
}
|
|
115
|
+
render(width) {
|
|
116
|
+
return renderQuestionBox(width, this.question, this.cards, this.selected, this.colors);
|
|
117
|
+
}
|
|
118
|
+
invalidate() { }
|
|
119
|
+
handleInput(data) {
|
|
120
|
+
const kb = getKeybindings();
|
|
121
|
+
if (kb.matches(data, 'tui.select.up') || data === 'k') {
|
|
122
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
123
|
+
}
|
|
124
|
+
else if (kb.matches(data, 'tui.select.down') || data === 'j') {
|
|
125
|
+
this.selected = Math.min(this.cards.length - 1, this.selected + 1);
|
|
126
|
+
}
|
|
127
|
+
else if (kb.matches(data, 'tui.select.confirm') || data === '\n' || data === '\r') {
|
|
128
|
+
this.onChoose(this.selected);
|
|
129
|
+
}
|
|
130
|
+
else if (kb.matches(data, 'tui.select.cancel')) {
|
|
131
|
+
this.onCancel();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Show the boxed picker and resolve to the chosen option's `value`, the text the
|
|
137
|
+
* user typed via "type a different answer", or undefined when cancelled/aborted.
|
|
138
|
+
*/
|
|
139
|
+
export async function askQuestionBox(ctx, spec) {
|
|
140
|
+
const { question, options, inputTitle, signal } = spec;
|
|
141
|
+
const cards = [
|
|
142
|
+
...options.map(o => ({ label: o.label, recommended: o.recommended })),
|
|
143
|
+
{ label: MANUAL_CARD_LABEL }
|
|
144
|
+
];
|
|
145
|
+
const colors = boxColors(ctx.ui.theme);
|
|
146
|
+
const manualIndex = options.length;
|
|
147
|
+
const choice = await ctx.ui.custom((_tui, _theme, _kb, done) => {
|
|
148
|
+
if (signal.aborted) {
|
|
149
|
+
done(undefined);
|
|
150
|
+
return new QuestionBoxComponent(question, cards, colors, () => { }, () => { });
|
|
151
|
+
}
|
|
152
|
+
const onAbort = () => done(undefined);
|
|
153
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
154
|
+
const finish = (result) => {
|
|
155
|
+
signal.removeEventListener('abort', onAbort);
|
|
156
|
+
done(result);
|
|
157
|
+
};
|
|
158
|
+
return new QuestionBoxComponent(question, cards, colors, index => finish(index), () => finish(undefined));
|
|
159
|
+
});
|
|
160
|
+
if (choice === undefined)
|
|
161
|
+
return undefined;
|
|
162
|
+
if (choice === manualIndex) {
|
|
163
|
+
return ctx.ui.input(inputTitle, undefined, { signal });
|
|
164
|
+
}
|
|
165
|
+
return options[choice]?.value;
|
|
166
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verification child gets exactly two tools: `read` and `bash`.
|
|
3
|
+
*
|
|
4
|
+
* - `bash` IS the point: it lets the child actually execute the spec's VERIFY
|
|
5
|
+
* commands (or whatever check the ACCEPTANCE criteria imply) and see the real
|
|
6
|
+
* exit codes and output, rather than reasoning about whether the code "looks"
|
|
7
|
+
* correct.
|
|
8
|
+
* - `read` lets it inspect a file the VERIFY output points at (a build error's
|
|
9
|
+
* source line, a config it just validated) to characterise a failure precisely.
|
|
10
|
+
* - No `edit`/`write`: this pass reports a verdict, it does not change the tree.
|
|
11
|
+
* Keeping it read-only means a verify run can never itself introduce a change
|
|
12
|
+
* that needs re-verifying, and never scaffolds the junk-file runaway that the
|
|
13
|
+
* enforce pass had to drop `write` to stop.
|
|
14
|
+
*/
|
|
15
|
+
declare const VERIFY_TOOLS = "read,bash";
|
|
16
|
+
export interface VerifyOutcome {
|
|
17
|
+
/** true → the work verified (or verification disabled / nothing to verify).
|
|
18
|
+
* false → the child reported the work does NOT satisfy its spec, or the pass
|
|
19
|
+
* could not run / produced no verdict. */
|
|
20
|
+
ok: boolean;
|
|
21
|
+
/** Short, human-readable reason. Always set when ok === false; on the pass
|
|
22
|
+
* path set to the no-op cause ('disabled', 'no spec to verify'). */
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
|
|
27
|
+
* task file body. The composed spec lives under a `## spec` header and runs until
|
|
28
|
+
* the next top-level `## ` section (`## phase timings`). Returns null when no spec
|
|
29
|
+
* section is present (e.g. a task that never reached compose), which the caller
|
|
30
|
+
* treats as a pass — there is nothing to verify against.
|
|
31
|
+
*/
|
|
32
|
+
export declare function extractSpecForVerification(taskBody: string): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Build the verification child's prompt. Kept pure so the wording is unit-tested
|
|
35
|
+
* without spawning pi. The contract: run the spec's own verification in the real
|
|
36
|
+
* workspace, judge against ACCEPTANCE, and end on exactly one verdict line.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildVerifyPrompt(spec: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL` marker
|
|
41
|
+
* (the model discusses before concluding, and bash output may echo the word
|
|
42
|
+
* "VERIFY", so a distinct token and last-match win matter).
|
|
43
|
+
*
|
|
44
|
+
* No marker at all is NOT a pass: a verification that cannot state a verdict is a
|
|
45
|
+
* gray area, and the contract is that unverified work is reported as such.
|
|
46
|
+
*/
|
|
47
|
+
export declare function parseVerifyVerdict(text: string): {
|
|
48
|
+
pass: boolean;
|
|
49
|
+
detail: string;
|
|
50
|
+
};
|
|
51
|
+
export interface VerificationDeps {
|
|
52
|
+
cwd: string;
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
/** The composed spec (GOAL/ACCEPTANCE/VERIFY) of the task just committed. When
|
|
55
|
+
* null/empty the pass is a no-op (ok: true). */
|
|
56
|
+
spec: string | null;
|
|
57
|
+
/** Runs the verification child and returns its assistant text. Injected so
|
|
58
|
+
* the orchestrator wires the real child runner and tests use a fake. */
|
|
59
|
+
runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Run the verification pass for one task. A missing spec is a pass. Otherwise run
|
|
63
|
+
* the child against the real workspace and turn its verdict into an ok/blocked
|
|
64
|
+
* outcome. Never throws (except a user cancel, which propagates so the loop's
|
|
65
|
+
* USER_CANCELLED handler reports a clean "cancelled — resume").
|
|
66
|
+
*/
|
|
67
|
+
export declare function runWorkVerification(deps: VerificationDeps): Promise<VerifyOutcome>;
|
|
68
|
+
export { VERIFY_TOOLS };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work verification for /task-auto.
|
|
3
|
+
*
|
|
4
|
+
* The root failure this addresses: pi-task never *runs* any verification. Each
|
|
5
|
+
* task's composed spec carries a VERIFY block (and ACCEPTANCE criteria), but that
|
|
6
|
+
* block is only authored and presence-checked — never executed. The task is then
|
|
7
|
+
* marked `completed` at handoff. So a task whose implementation does not actually
|
|
8
|
+
* work (a SPA that won't build, a route wired to a dead stub, a CI file nobody
|
|
9
|
+
* runs) is indistinguishable from one that does.
|
|
10
|
+
*
|
|
11
|
+
* This pass closes that gap WITHOUT assuming any particular shape of project. It
|
|
12
|
+
* does NOT hardcode `build`, an HTTP probe, a server boot, or a test command —
|
|
13
|
+
* many tasks have none of those. Instead it hands the just-committed spec (GOAL /
|
|
14
|
+
* ACCEPTANCE / VERIFY) to a fresh child of the SAME local model, gives it a `read`
|
|
15
|
+
* and a `bash` tool in the real workspace, and lets the model do its job: run the
|
|
16
|
+
* verification the spec already declares, observe the REAL output, and report a
|
|
17
|
+
* PASS / FAIL verdict. If the spec's VERIFY is legitimately a no-op (config-only
|
|
18
|
+
* change, re-read of a file), the model says so and that is a PASS.
|
|
19
|
+
*
|
|
20
|
+
* It runs as a GATE right after the implementation turn, BEFORE the task is
|
|
21
|
+
* checked off or committed. A FAIL stops the /task-auto run exactly like an
|
|
22
|
+
* implementation failure: the task is left unchecked and uncommitted so
|
|
23
|
+
* /task-auto-resume re-runs it, rather than blessing work that does not run.
|
|
24
|
+
*
|
|
25
|
+
* Tools: `read` + `bash` only. No `edit`/`write` — verification observes, it does
|
|
26
|
+
* not fix (fixing committed work is the enforcement pass's job, and a verify pass
|
|
27
|
+
* that also edits would blur "did it work" with "make it work"). `bash` is what
|
|
28
|
+
* makes this real: it is the difference between reading the VERIFY block and
|
|
29
|
+
* RUNNING it.
|
|
30
|
+
*
|
|
31
|
+
* Gated by the `verifyWork` config flag. With the flag off, or with no spec to
|
|
32
|
+
* verify, this is a pass (ok: true).
|
|
33
|
+
*/
|
|
34
|
+
import { USER_CANCELLED } from './child-runner.js';
|
|
35
|
+
/**
|
|
36
|
+
* The verification child gets exactly two tools: `read` and `bash`.
|
|
37
|
+
*
|
|
38
|
+
* - `bash` IS the point: it lets the child actually execute the spec's VERIFY
|
|
39
|
+
* commands (or whatever check the ACCEPTANCE criteria imply) and see the real
|
|
40
|
+
* exit codes and output, rather than reasoning about whether the code "looks"
|
|
41
|
+
* correct.
|
|
42
|
+
* - `read` lets it inspect a file the VERIFY output points at (a build error's
|
|
43
|
+
* source line, a config it just validated) to characterise a failure precisely.
|
|
44
|
+
* - No `edit`/`write`: this pass reports a verdict, it does not change the tree.
|
|
45
|
+
* Keeping it read-only means a verify run can never itself introduce a change
|
|
46
|
+
* that needs re-verifying, and never scaffolds the junk-file runaway that the
|
|
47
|
+
* enforce pass had to drop `write` to stop.
|
|
48
|
+
*/
|
|
49
|
+
const VERIFY_TOOLS = 'read,bash';
|
|
50
|
+
/**
|
|
51
|
+
* Slice the delivered spec (GOAL / CONSTRAINTS / ACCEPTANCE / VERIFY) out of a
|
|
52
|
+
* task file body. The composed spec lives under a `## spec` header and runs until
|
|
53
|
+
* the next top-level `## ` section (`## phase timings`). Returns null when no spec
|
|
54
|
+
* section is present (e.g. a task that never reached compose), which the caller
|
|
55
|
+
* treats as a pass — there is nothing to verify against.
|
|
56
|
+
*/
|
|
57
|
+
export function extractSpecForVerification(taskBody) {
|
|
58
|
+
const lines = taskBody.split('\n');
|
|
59
|
+
let start = -1;
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
if (/^##\s+spec\s*$/i.test(lines[i])) {
|
|
62
|
+
start = i + 1;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (start === -1)
|
|
67
|
+
return null;
|
|
68
|
+
let end = lines.length;
|
|
69
|
+
for (let i = start; i < lines.length; i++) {
|
|
70
|
+
if (/^##\s+\S/.test(lines[i])) {
|
|
71
|
+
end = i;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const spec = lines.slice(start, end).join('\n').trim();
|
|
76
|
+
return spec.length > 0 ? spec : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the verification child's prompt. Kept pure so the wording is unit-tested
|
|
80
|
+
* without spawning pi. The contract: run the spec's own verification in the real
|
|
81
|
+
* workspace, judge against ACCEPTANCE, and end on exactly one verdict line.
|
|
82
|
+
*/
|
|
83
|
+
export function buildVerifyPrompt(spec) {
|
|
84
|
+
return [
|
|
85
|
+
'You are a strict verification pass running right after an AI coding agent',
|
|
86
|
+
'finished a task and committed it. The agent is known to mark work "done"',
|
|
87
|
+
'without ever running it, so DO NOT trust that the implementation works —',
|
|
88
|
+
'prove it by running the verification this task ships with.',
|
|
89
|
+
'',
|
|
90
|
+
'You have a `read` tool and a `bash` tool — nothing else. You can run any',
|
|
91
|
+
'command and read any file, but you CANNOT edit or create files. Your job is',
|
|
92
|
+
'to VERIFY, not to fix.',
|
|
93
|
+
'',
|
|
94
|
+
'THE TASK SPEC (its ACCEPTANCE criteria and VERIFY block are the contract):',
|
|
95
|
+
spec.trim(),
|
|
96
|
+
'',
|
|
97
|
+
'How to verify:',
|
|
98
|
+
"1. Run the commands in the spec's VERIFY block, in order, with your `bash`",
|
|
99
|
+
' tool, from the repository root. Observe the REAL exit codes and output —',
|
|
100
|
+
' do not assume success.',
|
|
101
|
+
'2. Treat the ACCEPTANCE criteria as the bar. If a VERIFY command fails, or',
|
|
102
|
+
' its output contradicts an ACCEPTANCE criterion, the work has NOT verified.',
|
|
103
|
+
'3. If a VERIFY command depends on something this environment genuinely lacks',
|
|
104
|
+
' (a service, a network resource) and that is clearly an environment gap',
|
|
105
|
+
' rather than a defect in the code, note it and judge the rest. Do not fail',
|
|
106
|
+
' the task for a missing external service it cannot control.',
|
|
107
|
+
'4. If the spec legitimately has no runnable verification (a config-only or',
|
|
108
|
+
' docs-only change whose VERIFY just re-reads or validates a file), running',
|
|
109
|
+
' that re-read/validation cleanly is a PASS.',
|
|
110
|
+
'5. Do NOT edit anything to make a check pass. Report what you actually saw.',
|
|
111
|
+
'',
|
|
112
|
+
'When you are done, output EXACTLY ONE of these as the final line:',
|
|
113
|
+
' WORK-VERIFIED: PASS (every check ran and the work meets its spec)',
|
|
114
|
+
' WORK-VERIFIED: FAIL <text> (a check failed or the work does not meet its spec; say what failed)',
|
|
115
|
+
'Output the verdict line verbatim — it is parsed mechanically.'
|
|
116
|
+
].join('\n');
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL` marker
|
|
120
|
+
* (the model discusses before concluding, and bash output may echo the word
|
|
121
|
+
* "VERIFY", so a distinct token and last-match win matter).
|
|
122
|
+
*
|
|
123
|
+
* No marker at all is NOT a pass: a verification that cannot state a verdict is a
|
|
124
|
+
* gray area, and the contract is that unverified work is reported as such.
|
|
125
|
+
*/
|
|
126
|
+
export function parseVerifyVerdict(text) {
|
|
127
|
+
const re = /WORK-VERIFIED:\s*(PASS|FAIL)\b[ \t]*(.*)/gi;
|
|
128
|
+
let last = null;
|
|
129
|
+
for (let m = re.exec(text); m !== null; m = re.exec(text))
|
|
130
|
+
last = m;
|
|
131
|
+
if (!last)
|
|
132
|
+
return { pass: false, detail: 'no verdict emitted' };
|
|
133
|
+
const pass = last[1].toUpperCase() === 'PASS';
|
|
134
|
+
return { pass, detail: pass ? '' : last[2].trim() || 'unspecified failure' };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Run the verification pass for one task. A missing spec is a pass. Otherwise run
|
|
138
|
+
* the child against the real workspace and turn its verdict into an ok/blocked
|
|
139
|
+
* outcome. Never throws (except a user cancel, which propagates so the loop's
|
|
140
|
+
* USER_CANCELLED handler reports a clean "cancelled — resume").
|
|
141
|
+
*/
|
|
142
|
+
export async function runWorkVerification(deps) {
|
|
143
|
+
if (!deps.spec || deps.spec.trim().length === 0) {
|
|
144
|
+
return { ok: true, reason: 'no spec to verify' };
|
|
145
|
+
}
|
|
146
|
+
let text;
|
|
147
|
+
try {
|
|
148
|
+
text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec), deps.signal);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err instanceof Error && err.message === USER_CANCELLED)
|
|
152
|
+
throw err;
|
|
153
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
154
|
+
return { ok: false, reason: `verification pass could not run: ${msg}` };
|
|
155
|
+
}
|
|
156
|
+
const verdict = parseVerifyVerdict(text);
|
|
157
|
+
if (verdict.pass)
|
|
158
|
+
return { ok: true };
|
|
159
|
+
return { ok: false, reason: `work did not verify: ${verdict.detail}` };
|
|
160
|
+
}
|
|
161
|
+
export { VERIFY_TOOLS };
|
package/dist/task/widget.d.ts
CHANGED
|
@@ -51,8 +51,9 @@ export interface AutoLoaderState {
|
|
|
51
51
|
contextUsage?: ContextSnapshot;
|
|
52
52
|
/** Which /task-auto stage this loader is for. Defaults to 'planning' (the
|
|
53
53
|
* numbered clarify/decompose steps); 'enforce' is the per-task guideline
|
|
54
|
-
* pass
|
|
55
|
-
|
|
54
|
+
* pass and 'verify' is the per-task work-verification pass, neither of which
|
|
55
|
+
* has step numbering. */
|
|
56
|
+
kind?: 'planning' | 'enforce' | 'verify';
|
|
56
57
|
}
|
|
57
58
|
export declare function buildAutoLoaderLines(s: AutoLoaderState, theme?: WidgetTheme): string[];
|
|
58
59
|
/**
|
package/dist/task/widget.js
CHANGED
|
@@ -121,9 +121,9 @@ export function startWidget(ctx, getState) {
|
|
|
121
121
|
export function buildAutoLoaderLines(s, theme) {
|
|
122
122
|
const elapsed = formatDuration(Date.now() - s.startedAt);
|
|
123
123
|
const head = `/task-auto · ${s.title}`;
|
|
124
|
-
let detail = s.kind === 'enforce' ?
|
|
125
|
-
`
|
|
126
|
-
|
|
124
|
+
let detail = s.kind === 'enforce' ? `enforcing guidelines · ${elapsed}`
|
|
125
|
+
: s.kind === 'verify' ? `verifying work · ${elapsed}`
|
|
126
|
+
: `planning ${s.stepNum}/${s.stepTotal} ${s.step} · ${elapsed}`;
|
|
127
127
|
if (s.contextUsage) {
|
|
128
128
|
const ctxDetail = formatContextDetail(s.contextUsage, theme);
|
|
129
129
|
if (ctxDetail)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.13",
|
|
4
4
|
"description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|