@mjasnikovs/pi-task 0.17.10 → 0.17.12

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.
@@ -15,10 +15,11 @@
15
15
  */
16
16
  import * as fsp from 'node:fs/promises';
17
17
  import * as path from 'node:path';
18
- import { tasksDir, readTaskFile } from './task-io.js';
18
+ import { tasksDir, readTaskFile, appendGateRecord } from './task-io.js';
19
19
  import { gitCommitAll, gitDropLastCommit } from './auto-commit.js';
20
20
  import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
21
21
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
22
+ import { runRepoHealthCheck } from './repo-health-check.js';
22
23
  import { researchResolution } from './verify-resolution.js';
23
24
  import { runWorker } from '../workers/pi-worker-core.js';
24
25
  import { formatLoopHint } from './child-runner.js';
@@ -95,6 +96,9 @@ export function buildGateDeps(params) {
95
96
  };
96
97
  return {
97
98
  runTask,
99
+ // Durable per-task gate trail: every verdict/decision lands in the task
100
+ // file's `## gates` section so gate behavior is auditable from artifacts.
101
+ record: (cwd2, taskId, line) => appendGateRecord(cwd2, taskId, line),
98
102
  commit: (cwd2, message) => getConfig().autoCommit ?
99
103
  gitCommitAll(cwd2, message, signal)
100
104
  : Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
@@ -195,7 +199,11 @@ export function buildGateDeps(params) {
195
199
  cwd: cwd2,
196
200
  signal,
197
201
  spec,
198
- runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log')
202
+ runChild: makeGateChild(verifyCtx, cwd2, taskTitle, 'verify', 'verify-debug.log'),
203
+ // Deterministic whole-repo static-analysis gate — runs the project's
204
+ // own lint/typecheck and fails on a real non-zero exit, independent of
205
+ // the model-authored VERIFY block (which may not lint at all).
206
+ repoHealth: () => Promise.resolve(runRepoHealthCheck(cwd2))
199
207
  });
200
208
  },
201
209
  recommend: async (recCtx, cwd2, taskTitle, taskId, failReason) => {
@@ -0,0 +1,33 @@
1
+ export interface HealthOutcome {
2
+ /** true → every discovered static check passed, or there was nothing to run.
3
+ * false → a discovered command actually ran and exited non-zero. */
4
+ ok: boolean;
5
+ /** Human-readable reason. On a fail, names the exact command and exit code. */
6
+ reason: string;
7
+ /** Which manifest drove discovery, or null when none was found. */
8
+ ecosystem: string | null;
9
+ }
10
+ /** One discovered command: the binary and its args, run from the repo root. */
11
+ export type HealthCommand = [bin: string, args: string[]];
12
+ /**
13
+ * Discover the project's OWN whole-repo static-analysis commands. First manifest
14
+ * that exists wins; returns only the STATIC commands actually available for that
15
+ * ecosystem (never test/build/run). An empty list means "nothing static to run".
16
+ */
17
+ export declare function discoverHealthCommands(cwd: string): {
18
+ ecosystem: string | null;
19
+ cmds: HealthCommand[];
20
+ };
21
+ /**
22
+ * Run the discovered static checks whole-repo and let the real exit codes decide.
23
+ * Deterministic and synchronous under the hood (a wrapper keeps the caller async).
24
+ *
25
+ * - No manifest / no static command → ok (nothing can regress).
26
+ * - A command that CANNOT run (ENOENT / null exit — tool not installed) → skipped,
27
+ * treated as an environment gap, not a fault.
28
+ * - A command that ran and exited non-zero → the first such failure is returned.
29
+ *
30
+ * A generous per-command timeout guards against a wedged tool; a timeout is treated
31
+ * as an inconclusive skip, not a fault (it is an environment problem, not the code's).
32
+ */
33
+ export declare function runRepoHealthCheck(cwd: string, timeoutMs?: number): HealthOutcome;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * repo-health-check — the deterministic, whole-repo half of the verify gate.
3
+ *
4
+ * The failure this closes (proven on the mx5 run): the verify gate ran only the
5
+ * task's OWN composed VERIFY block, and that block is authored per-task by the local
6
+ * model — so it is inconsistent. Some tasks lint the whole repo, some (TASK_0021)
7
+ * ship a VERIFY of `tsc --noEmit` ONLY, with no lint at all. A/B on the live model,
8
+ * 5 runs/arm on the real dirty tree: the tsc-only task false-PASSed 5/5 while
9
+ * `bun run lint` reported 11 errors. The model gate is only ever as good as the
10
+ * VERIFY block it happened to be handed.
11
+ *
12
+ * This check does NOT depend on that block. It discovers the project's OWN
13
+ * whole-repo static-analysis command (the one a fresh checkout / CI would run) and
14
+ * lets its REAL exit code decide — no model, so there is no per-file narrowing and
15
+ * no "those errors aren't in my files" gray area. A non-zero exit is a FAIL that the
16
+ * caller turns into the existing verify-FAIL outcome (→ the AUTOFIX / ACCEPT /
17
+ * dismiss picker; the user decides).
18
+ *
19
+ * Scope is deliberately STATIC ANALYSIS ONLY (lint / typecheck / clippy / vet), never
20
+ * `test`, `build`, `run`, or anything that boots a server or needs a database. Those
21
+ * depend on external services the verify prompt already carves out as an environment
22
+ * gap — running them here would re-introduce the false-FAIL that once wrongly blamed
23
+ * code for a missing DB. Static analysis is hermetic: it needs no network, no service,
24
+ * no fixtures, and it is exactly the class of the reported defect.
25
+ *
26
+ * Absence is a PASS, two ways: (1) no recognised manifest at all (a pure-docs or
27
+ * config-only repo has nothing that can regress); (2) a manifest with no static-check
28
+ * command wired up. A tool that is simply not installed (ENOENT / null exit) is an
29
+ * environment gap, not a code fault, so that command is SKIPPED — only a command that
30
+ * actually ran and returned non-zero fails the check.
31
+ */
32
+ import { spawnSync } from 'node:child_process';
33
+ import { existsSync, readFileSync } from 'node:fs';
34
+ import * as path from 'node:path';
35
+ function packageScripts(cwd) {
36
+ try {
37
+ const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
38
+ return j.scripts ?? {};
39
+ }
40
+ catch {
41
+ return {};
42
+ }
43
+ }
44
+ /** Does the Makefile define a `<target>:` rule? (so `make <target>` won't error out) */
45
+ function makeHasTarget(cwd, target) {
46
+ try {
47
+ const mk = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
48
+ return new RegExp(`^${target}:`, 'm').test(mk);
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Discover the project's OWN whole-repo static-analysis commands. First manifest
56
+ * that exists wins; returns only the STATIC commands actually available for that
57
+ * ecosystem (never test/build/run). An empty list means "nothing static to run".
58
+ */
59
+ export function discoverHealthCommands(cwd) {
60
+ if (existsSync(path.join(cwd, 'package.json'))) {
61
+ const s = packageScripts(cwd);
62
+ const cmds = [];
63
+ // Only static-analysis scripts. `lint` commonly chains tsc (as in mx5's
64
+ // `prettier && eslint && tsc`), so a single `bun run lint` covers both.
65
+ for (const name of ['lint', 'typecheck']) {
66
+ if (s[name])
67
+ cmds.push(['bun', ['run', name]]);
68
+ }
69
+ return { ecosystem: 'package.json', cmds };
70
+ }
71
+ if (existsSync(path.join(cwd, 'Makefile'))) {
72
+ const cmds = [];
73
+ if (makeHasTarget(cwd, 'lint'))
74
+ cmds.push(['make', ['lint']]);
75
+ return { ecosystem: 'Makefile', cmds };
76
+ }
77
+ if (existsSync(path.join(cwd, 'Cargo.toml'))) {
78
+ return { ecosystem: 'Cargo.toml', cmds: [['cargo', ['clippy', '--quiet']]] };
79
+ }
80
+ if (existsSync(path.join(cwd, 'pyproject.toml'))) {
81
+ return { ecosystem: 'pyproject.toml', cmds: [['ruff', ['check', '.']]] };
82
+ }
83
+ if (existsSync(path.join(cwd, 'deno.json')) || existsSync(path.join(cwd, 'deno.jsonc'))) {
84
+ return { ecosystem: 'deno', cmds: [['deno', ['lint']]] };
85
+ }
86
+ if (existsSync(path.join(cwd, 'go.mod'))) {
87
+ return { ecosystem: 'go.mod', cmds: [['go', ['vet', './...']]] };
88
+ }
89
+ return { ecosystem: null, cmds: [] };
90
+ }
91
+ /**
92
+ * Run the discovered static checks whole-repo and let the real exit codes decide.
93
+ * Deterministic and synchronous under the hood (a wrapper keeps the caller async).
94
+ *
95
+ * - No manifest / no static command → ok (nothing can regress).
96
+ * - A command that CANNOT run (ENOENT / null exit — tool not installed) → skipped,
97
+ * treated as an environment gap, not a fault.
98
+ * - A command that ran and exited non-zero → the first such failure is returned.
99
+ *
100
+ * A generous per-command timeout guards against a wedged tool; a timeout is treated
101
+ * as an inconclusive skip, not a fault (it is an environment problem, not the code's).
102
+ */
103
+ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
104
+ const { ecosystem, cmds } = discoverHealthCommands(cwd);
105
+ if (!ecosystem || cmds.length === 0) {
106
+ return { ok: true, reason: 'no repo-wide static-analysis command found', ecosystem };
107
+ }
108
+ for (const [bin, args] of cmds) {
109
+ const r = spawnSync(bin, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
110
+ // Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
111
+ if (r.error || r.status === null)
112
+ continue;
113
+ if (r.status !== 0) {
114
+ return {
115
+ ok: false,
116
+ reason: `\`${bin} ${args.join(' ')}\` exited ${r.status}`,
117
+ ecosystem
118
+ };
119
+ }
120
+ }
121
+ return { ok: true, reason: `${ecosystem}: static checks passed`, ecosystem };
122
+ }
@@ -77,6 +77,16 @@ export interface GateDeps {
77
77
  * skips the revert and warns.
78
78
  */
79
79
  revert?: (cwd: string) => Promise<void>;
80
+ /**
81
+ * Append one line to the task's durable gate trail (`## gates` in the task
82
+ * file). Every gate outcome — each verify verdict, the user's FAIL resolution,
83
+ * the commit result, enforce mode + verdict, the differential guard's decision —
84
+ * is recorded so the sequence is auditable from artifacts alone (the mx5 audit
85
+ * could not tell WHY 10 of 18 tasks show no enforce run: verdicts lived only in
86
+ * terminal notifies). Best-effort: absent in tests → skipped; failures are
87
+ * swallowed by the implementation, never by this sequence.
88
+ */
89
+ record?: (cwd: string, taskId: string, line: string) => Promise<void>;
80
90
  }
81
91
  /** Inputs the sequence needs that vary per caller. */
82
92
  export interface GateParams {
@@ -34,6 +34,21 @@ export async function askVerifyResolution(ctx, title, failReason, rec) {
34
34
  */
35
35
  export async function runGatesForTask(ctxIn, deps, p) {
36
36
  let active = ctxIn;
37
+ // Durable per-task gate trail — every outcome below is also appended to the
38
+ // task file so the sequence is auditable from artifacts alone. Best-effort.
39
+ const rec = async (line) => {
40
+ try {
41
+ await deps.record?.(p.cwd, p.taskId, line);
42
+ }
43
+ catch {
44
+ // recording must never break the gate sequence
45
+ }
46
+ };
47
+ const verdictLine = (v) => v.ok ?
48
+ v.reason ?
49
+ `verify: PASS (${v.reason})`
50
+ : 'verify: PASS'
51
+ : `verify: FAIL — ${v.reason ?? 'did not verify'}`;
37
52
  // GATE: actually RUN the task's verification against the just-finished work
38
53
  // BEFORE it is checked off or committed. Whether this produced a GENUINE clean
39
54
  // pass (a real signal ran and the work met it) also decides how the enforce pass
@@ -44,24 +59,29 @@ export async function runGatesForTask(ctxIn, deps, p) {
44
59
  if (deps.verify) {
45
60
  active.ui.notify(`${p.tag}: verifying "${p.title}"…`, 'info');
46
61
  let verified = await deps.verify(active, p.cwd, p.title, p.taskId);
62
+ await rec(verdictLine(verified));
47
63
  // A FAIL no longer dead-stops: offer the boxed AUTOFIX / ACCEPT / dismiss
48
64
  // picker. The USER always decides; AUTOFIX loops straight back to the gate
49
65
  // as many times as they keep choosing it (no attempt cap).
50
66
  while (!verified.ok) {
51
67
  const failReason = verified.reason ?? 'did not verify';
52
- const rec = deps.recommend ?
68
+ const recOutcome = deps.recommend ?
53
69
  await deps.recommend(active, p.cwd, p.title, p.taskId, failReason)
54
70
  : { recommend: 'autofix', rationale: failReason };
55
- const choice = await askVerifyResolution(active, p.title, failReason, rec);
71
+ await rec(`resolution: recommended ${recOutcome.recommend.toUpperCase()}`);
72
+ const choice = await askVerifyResolution(active, p.title, failReason, recOutcome);
56
73
  if (choice.action === 'cancel') {
74
+ await rec('resolution: user dismissed the verify-FAIL picker — paused');
57
75
  return { kind: 'paused', ctx: active, reason: failReason };
58
76
  }
59
77
  if (choice.action === 'accept') {
78
+ await rec('resolution: user ACCEPTED the work despite verify FAIL');
60
79
  active.ui.notify(`${p.tag}: accepted "${p.title}" despite verify FAIL (${failReason.slice(0, 120)}) — proceeding.`, 'warning');
61
80
  break;
62
81
  }
63
82
  // AUTOFIX: re-run the implementation turn with the failure (and any typed
64
83
  // guidance) prepended as a RE-ATTEMPT banner, then re-verify.
84
+ await rec('resolution: user chose AUTOFIX — re-running the implementation turn');
65
85
  active.ui.notify(`${p.tag}: autofixing "${p.title}"…`, 'info');
66
86
  const fixInstruction = choice.guidance ? `${failReason}\n\nUser guidance: ${choice.guidance}` : failReason;
67
87
  const fixRes = await deps.runTask(active, p.cwd, p.title, {
@@ -78,6 +98,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
78
98
  return { kind: 'failed', ctx: active, reason: fixRes.reason };
79
99
  // Resume reuses the same inner task id, so p.taskId is stable.
80
100
  verified = await deps.verify(active, p.cwd, p.title, p.taskId);
101
+ await rec(verdictLine(verified));
81
102
  }
82
103
  // Loop exited because the work verified OR the user accepted the artifact. A
83
104
  // genuine clean pass is ok===true with NO reason; a no-op pass or an
@@ -91,9 +112,11 @@ export async function runGatesForTask(ctxIn, deps, p) {
91
112
  // so a passing task is durably recorded no matter what enforcement later finds.
92
113
  const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
93
114
  if (commit.committed) {
115
+ await rec('commit: task snapshot committed');
94
116
  active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
95
117
  }
96
118
  else {
119
+ await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
97
120
  active.ui.notify(`${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, 'warning');
98
121
  }
99
122
  // With the task committed, hold its work to AGENTS.md / CLAUDE.md — but as a step
@@ -106,6 +129,7 @@ export async function runGatesForTask(ctxIn, deps, p) {
106
129
  `${p.tag}: enforcing AGENTS.md/CLAUDE.md on "${p.title}"…`
107
130
  : `${p.tag}: reviewing "${p.title}" against AGENTS.md/CLAUDE.md (no verify signal — report only)…`, 'info');
108
131
  const verdict = await deps.enforce(active, p.cwd, p.title, mode);
132
+ await rec(`enforce(${mode}): ${verdict.ok ? `clean${verdict.reason ? ` (${verdict.reason})` : ''}` : (verdict.reason ?? 'not clean')}`);
109
133
  if (!verdict.ok) {
110
134
  active.ui.notify(`${p.tag}: guideline ${mode === 'edit' ? 'enforcement' : 'review'} on "${p.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
111
135
  }
@@ -122,14 +146,24 @@ export async function runGatesForTask(ctxIn, deps, p) {
122
146
  if (!after.ok) {
123
147
  if (deps.revert)
124
148
  await deps.revert(p.cwd);
149
+ await rec(`enforce: fixes committed but re-verify FAILED (${(after.reason ?? 'now fails').slice(0, 200)}) — ${deps.revert ? 'REVERTED' : 'left in place (no revert available)'}`);
125
150
  active.ui.notify(`${p.tag}: guideline fixes regressed verification on "${p.title}" (${(after.reason ?? 'now fails').slice(0, 120)}) — ${deps.revert ? 'reverted them, kept the verified work' : 'left in place (no revert available)'}.`, 'warning');
126
151
  }
127
152
  else {
153
+ await rec('enforce: fixes committed — re-verify PASS, kept');
128
154
  active.ui.notify(`${p.tag}: committed guideline fixes for "${p.title}".`, 'info');
129
155
  }
130
156
  }
157
+ else {
158
+ await rec('enforce(edit): no fixes to commit');
159
+ }
131
160
  }
132
161
  // 'flag' mode makes no edits — nothing to commit or revert.
133
162
  }
163
+ else if (deps.enforce) {
164
+ // deps.enforce wired but nothing was committed this round — record the skip
165
+ // so a missing enforce run is explainable from the trail (mx5 audit gap).
166
+ await rec('enforce: skipped (nothing committed this round)');
167
+ }
134
168
  return { kind: 'done', ctx: active };
135
169
  }
@@ -21,5 +21,14 @@ export declare function writeTaskFile(cwd: string, fm: TaskFrontMatter, body: st
21
21
  export declare function updateTaskFrontMatter(cwd: string, id: string, patch: Partial<TaskFrontMatter>): Promise<void>;
22
22
  export declare function readSection(cwd: string, id: string, heading: string): Promise<string | null>;
23
23
  export declare function setTaskSection(cwd: string, id: string, heading: string, content: string): Promise<void>;
24
+ /**
25
+ * Append one timestamped line to the task's `## gates` section — the durable
26
+ * per-task trail of gate outcomes (verify verdicts, enforce mode/verdict, commit
27
+ * results). Motivated by the mx5 audit: verdict text lived only in memory and
28
+ * terminal notifies, so "did enforce run for this task, and in which mode?" was
29
+ * unanswerable from artifacts. Best-effort by design: a failure to record must
30
+ * never break the gate sequence, so all errors are swallowed.
31
+ */
32
+ export declare function appendGateRecord(cwd: string, id: string, line: string): Promise<void>;
24
33
  /** Remove a section (heading + body) if present; a no-op when it's absent. */
25
34
  export declare function removeTaskSection(cwd: string, id: string, heading: string): Promise<void>;
@@ -112,6 +112,25 @@ export async function setTaskSection(cwd, id, heading, content) {
112
112
  }
113
113
  await writeTaskFile(cwd, { ...frontMatter, updated_at: new Date().toISOString() }, next);
114
114
  }
115
+ /**
116
+ * Append one timestamped line to the task's `## gates` section — the durable
117
+ * per-task trail of gate outcomes (verify verdicts, enforce mode/verdict, commit
118
+ * results). Motivated by the mx5 audit: verdict text lived only in memory and
119
+ * terminal notifies, so "did enforce run for this task, and in which mode?" was
120
+ * unanswerable from artifacts. Best-effort by design: a failure to record must
121
+ * never break the gate sequence, so all errors are swallowed.
122
+ */
123
+ export async function appendGateRecord(cwd, id, line) {
124
+ try {
125
+ const stamp = new Date().toISOString();
126
+ const entry = `- ${stamp} ${line.replace(/\s*\n\s*/g, ' ').trim()}`;
127
+ const existing = await readSection(cwd, id, 'gates');
128
+ await setTaskSection(cwd, id, 'gates', existing ? `${existing}\n${entry}` : entry);
129
+ }
130
+ catch {
131
+ // Recording is observability, not control flow — never propagate.
132
+ }
133
+ }
115
134
  /** Remove a section (heading + body) if present; a no-op when it's absent. */
116
135
  export async function removeTaskSection(cwd, id, heading) {
117
136
  const { frontMatter, body } = await readTaskFile(cwd, id);
@@ -57,6 +57,18 @@ export interface VerificationDeps {
57
57
  /** Runs the verification child and returns its assistant text. Injected so
58
58
  * the orchestrator wires the real child runner and tests use a fake. */
59
59
  runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
60
+ /**
61
+ * DETERMINISTIC whole-repo static-analysis gate (see repo-health-check.ts). Runs
62
+ * the project's OWN lint/typecheck command and lets its real exit code decide —
63
+ * independent of the model-authored VERIFY block, so a repo-wide lint failure can
64
+ * never be narrowed away to "not my files". A non-zero result short-circuits to a
65
+ * FAIL (the existing verify-FAIL outcome → the AUTOFIX/ACCEPT/dismiss picker).
66
+ * Injected so tests fake it; ABSENT → skipped (a pass), keeping the pass path a
67
+ * pure no-op for callers/tests that do not wire it. */
68
+ repoHealth?: () => Promise<{
69
+ ok: boolean;
70
+ reason: string;
71
+ }>;
60
72
  }
61
73
  /**
62
74
  * Run the verification pass for one task. A missing spec is a pass. Otherwise run
@@ -39,6 +39,20 @@
39
39
  * genuinely-broken shipped build FAILs 3/3, and a genuine external-service gap — which
40
40
  * the OLD prompt wrongly blamed on the code 3/3 — now correctly PASSes 3/3.
41
41
  *
42
+ * The sibling failure class is GREP-THEATER (mx5 run 2, TASK_0002): the composed VERIFY
43
+ * block was grep-only, so the child "verified" a schema.sql containing INVALID SQL by
44
+ * grepping for its own broken text — while a live PostgreSQL sat reachable in the same
45
+ * container, never touched. The prompt now (a) says a grep-only VERIFY block does not cap
46
+ * the obligation to execute/apply an executable artifact, and (b) requires PROBING a
47
+ * declared external service before invoking the absent-service exception — reachable ⇒
48
+ * the real verification must run against it. A/B on the faithful fixture (invalid
49
+ * `generate always as` schema, grep-only VERIFY, unadvertised trust-auth PostgreSQL on
50
+ * the default port, DATABASE_URL unset — exactly the mx5 shape): old prompt false-PASSed
51
+ * 4/5; new prompt FAILed 5/5, each naming the real syntax error. Guards: explicit-URL
52
+ * variant old 5/5 / new 5/5 correct-FAIL (no regression), valid schema 3/3 PASS (no
53
+ * paranoia), unreachable-DB 2/3 PASS via the env-gap exception + 1 conservative FAIL
54
+ * that still named the genuine SQL defect (safe direction — only false-PASS trashes work).
55
+ *
42
56
  * It runs as a GATE right after the implementation turn, BEFORE the task is
43
57
  * checked off or committed. A FAIL stops the /task-auto run exactly like an
44
58
  * implementation failure: the task is left unchecked and uncommitted so
@@ -138,18 +152,31 @@ export function buildVerifyPrompt(spec) {
138
152
  '3. Presence of a token / directive / string in a SOURCE file is NOT verification.',
139
153
  ' Judge the produced artifact and the real runtime behavior. A raw build directive',
140
154
  ' that SURVIVES into the built output means the build never ran — that is a FAIL.',
155
+ " The spec's VERIFY block does not cap this obligation: if that block only greps or",
156
+ ' reads source files but the deliverable can be EXECUTED or APPLIED on this machine',
157
+ ' (a script, a schema, a server, a config a tool can load), you must ALSO execute or',
158
+ ' apply it and judge the real result. Grep-only checks passing while the artifact was',
159
+ ' never executed is NOT a PASS — a static match cannot prove an artifact works, only',
160
+ ' running it can.',
141
161
  '',
142
162
  '4. Treat the ACCEPTANCE criteria as the bar. If a command fails, or its real output',
143
163
  ' contradicts an ACCEPTANCE criterion, the work has NOT verified.',
144
164
  '',
145
165
  '5. The ONLY thing you may assume is already provided is a genuinely EXTERNAL running',
146
166
  ' service or network resource (a database server, an API host) that the project',
147
- ' documents as a prerequisite. If a command fails purely because such an external',
148
- ' service is ABSENT from this machine and NOT because the project misconfigures',
149
- ' how it connects note that as an environment gap and judge the rest; do not fail',
150
- ' the code for it. But a command that fails because of how the PROJECT ITSELF is',
151
- ' wired (config it owns but does not load, a wrong default, a command that cannot',
152
- ' run unaided) is a defect, not an environment gap.',
167
+ ' documents as a prerequisite. Before you rely on that assumption, PROBE for the',
168
+ ' service with a cheap real check using the connection details the project/spec',
169
+ ' already declares (attempt the connection: pg_isready, a client one-liner, curl',
170
+ ' with a short timeout). If the probe shows the service IS reachable, the exception',
171
+ ' does NOT apply you must run the real verification against it (apply the schema,',
172
+ ' run the suite, hit the endpoint) and judge the real result; falling back to static',
173
+ ' checks with the service available is NOT verification. Only if a command fails',
174
+ ' purely because such an external service is genuinely ABSENT from this machine —',
175
+ ' and NOT because the project misconfigures how it connects — note that as an',
176
+ ' environment gap and judge the rest; do not fail the code for it. But a command',
177
+ ' that fails because of how the PROJECT ITSELF is wired (config it owns but does',
178
+ ' not load, a wrong default, a command that cannot run unaided) is a defect, not an',
179
+ ' environment gap.',
153
180
  '',
154
181
  '6. If the spec legitimately has no runnable verification (a pure docs / config change',
155
182
  ' with nothing to build or run), validating it cleanly is a PASS.',
@@ -187,6 +214,17 @@ export function parseVerifyVerdict(text) {
187
214
  * USER_CANCELLED handler reports a clean "cancelled — resume").
188
215
  */
189
216
  export async function runWorkVerification(deps) {
217
+ // DETERMINISTIC gate FIRST: run the project's own whole-repo static analysis and
218
+ // let its exit code decide, before spending a model turn. This catches the class
219
+ // the model gate misses — a task whose composed VERIFY block never lints (proven
220
+ // 5/5 false-PASS live) — because it does not depend on that block. A fail is the
221
+ // ordinary verify-FAIL outcome, so it flows into the existing resolution picker.
222
+ // Absent dep, or a no-op result (no tooling to run), falls through to the model.
223
+ if (deps.repoHealth) {
224
+ const h = await deps.repoHealth();
225
+ if (!h.ok)
226
+ return { ok: false, reason: `repo health: ${h.reason}` };
227
+ }
190
228
  if (!deps.spec || deps.spec.trim().length === 0) {
191
229
  return { ok: true, reason: 'no spec to verify' };
192
230
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.17.10",
3
+ "version": "0.17.12",
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",