@mjasnikovs/pi-task 0.14.12 → 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) {
@@ -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';
@@ -481,6 +482,86 @@ function defaultDeps(ctx, cwd, signal, title) {
481
482
  }
482
483
  }
483
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
+ });
484
565
  }
485
566
  };
486
567
  }
@@ -586,6 +667,24 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
586
667
  announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
587
668
  return;
588
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
+ }
589
688
  // res.ok === true means runner.run() completed, so res.taskId is the
590
689
  // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
591
690
  // empty id by writing a plain checked line, but that path is unreachable.
@@ -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.12",
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",