@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.
@@ -4,6 +4,7 @@ export interface PiTaskConfig {
4
4
  autoCommit: boolean;
5
5
  orientation: boolean;
6
6
  enforceGuidelines: boolean;
7
+ verifyWork: boolean;
7
8
  }
8
9
  export declare function getConfig(): PiTaskConfig;
9
10
  export declare function saveConfig(config: PiTaskConfig): Promise<void>;
@@ -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;
@@ -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) {
@@ -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 a select()
56
- * picker (each option on its own line) plus a trailing "type a different
57
- * answer" entry that drops to a text input; the chosen entry's `value` is
58
- * returned. Without options it falls back to a single text input. Cancelling
59
- * either dialog (or an abort when the remote wins the race) resolves to
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
  }
@@ -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 a select()
87
- * picker (each option on its own line) plus a trailing "type a different
88
- * answer" entry that drops to a text input; the chosen entry's `value` is
89
- * returned. Without options it falls back to a single text input. Cancelling
90
- * either dialog (or an abort when the remote wins the race) resolves to
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
- const labels = opts.map(o => o.label);
97
- const choice = await this.ctx.ui.select(spec.localTitle, [...labels, TYPE_OWN_LABEL], {
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 binary fork becomes a select()
250
- // picker locally — each option on its own line, labelled A/B; a single
251
- // recommendation rides under the question as the input default; an open
252
- // question shows the bare prompt. No verbose "Recommended:" /
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 title = !twoOption && plainSuggested ?
256
- `${shownQ}\n${renderInlineMarkdown(suggested, theme)}`
257
- : shownQ;
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: title,
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
- ...(twoOption && {
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.
@@ -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 binary fork (suggested + alt) becomes a select() picker locally —
521
- // each option on its own line, labelled A/B. A single recommendation
522
- // rides along under the question as the input default; an open
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 localTitle = !twoOption && plainSuggested ?
526
- `${shownQ}\n${renderInlineMarkdown(auto.suggested, theme)}`
527
- : shownQ;
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
- ...(twoOption && {
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 };
@@ -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, which has no step numbering. */
55
- kind?: 'planning' | 'enforce';
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
  /**
@@ -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
- `enforcing guidelines · ${elapsed}`
126
- : `planning ${s.stepNum}/${s.stepTotal} ${s.step} · ${elapsed}`;
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.11",
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",