@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.
- package/dist/task/gate-deps.js +10 -2
- package/dist/task/repo-health-check.d.ts +33 -0
- package/dist/task/repo-health-check.js +122 -0
- package/dist/task/task-gates.d.ts +10 -0
- package/dist/task/task-gates.js +36 -2
- package/dist/task/task-io.d.ts +9 -0
- package/dist/task/task-io.js +19 -0
- package/dist/task/verify-work.d.ts +12 -0
- package/dist/task/verify-work.js +44 -6
- package/package.json +1 -1
package/dist/task/gate-deps.js
CHANGED
|
@@ -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 {
|
package/dist/task/task-gates.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/task/task-io.d.ts
CHANGED
|
@@ -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>;
|
package/dist/task/task-io.js
CHANGED
|
@@ -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
|
package/dist/task/verify-work.js
CHANGED
|
@@ -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.
|
|
148
|
-
' service
|
|
149
|
-
'
|
|
150
|
-
'
|
|
151
|
-
'
|
|
152
|
-
' run
|
|
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.
|
|
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",
|