@mjasnikovs/pi-task 0.17.11 → 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,7 +15,7 @@
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';
@@ -96,6 +96,9 @@ export function buildGateDeps(params) {
96
96
  };
97
97
  return {
98
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),
99
102
  commit: (cwd2, message) => getConfig().autoCommit ?
100
103
  gitCommitAll(cwd2, message, signal)
101
104
  : Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
@@ -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);
@@ -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.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.17.11",
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",