@mjasnikovs/pi-task 0.17.15 → 0.17.17

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.
@@ -11,7 +11,18 @@ export interface CommitResult {
11
11
  committed: boolean;
12
12
  /** Short, human-readable reason when committed === false. */
13
13
  reason?: string;
14
+ /** Set when the commit needed a fallback (e.g. self-supplied identity). */
15
+ note?: string;
14
16
  }
17
+ /**
18
+ * Does this git stderr describe a missing author identity? Seen live (mx5 run 4):
19
+ * the headless docker container has no HOME gitconfig, so EVERY per-task commit
20
+ * failed "Author identity unknown" — which silently disabled enforce and every
21
+ * commit-based differential guard for the whole run.
22
+ */
23
+ export declare function isIdentityFailure(stderr: string): boolean;
24
+ /** Fallback identity for environments with no git config (headless containers). */
25
+ export declare const FALLBACK_IDENTITY_ARGS: readonly ["-c", "user.name=pi-task", "-c", "user.email=pi-task@local"];
15
26
  export declare function git(cwd: string, args: string[], signal: AbortSignal | undefined, spawnFn?: SpawnFn): Promise<{
16
27
  stdout: string;
17
28
  stderr: string;
@@ -7,6 +7,22 @@
7
7
  * reason and let the loop continue (the task already succeeded).
8
8
  */
9
9
  import { runChildDefault } from '../shared/child-process.js';
10
+ /**
11
+ * Does this git stderr describe a missing author identity? Seen live (mx5 run 4):
12
+ * the headless docker container has no HOME gitconfig, so EVERY per-task commit
13
+ * failed "Author identity unknown" — which silently disabled enforce and every
14
+ * commit-based differential guard for the whole run.
15
+ */
16
+ export function isIdentityFailure(stderr) {
17
+ return /identity unknown|unable to auto-detect email|user\.(name|email)/i.test(stderr);
18
+ }
19
+ /** Fallback identity for environments with no git config (headless containers). */
20
+ export const FALLBACK_IDENTITY_ARGS = [
21
+ '-c',
22
+ 'user.name=pi-task',
23
+ '-c',
24
+ 'user.email=pi-task@local'
25
+ ];
10
26
  function firstLine(s) {
11
27
  const line = s.split('\n').find(l => l.trim().length > 0);
12
28
  return (line ?? s).trim();
@@ -42,11 +58,25 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
42
58
  return { committed: false, reason: 'cancelled' };
43
59
  if (diff.exitCode === 0)
44
60
  return { committed: false, reason: 'nothing to commit' };
45
- // 4. Commit. A failure here is usually missing user.name/user.email config.
61
+ // 4. Commit. A failure here is usually missing user.name/user.email config
62
+ // retry once with a self-supplied identity rather than losing the snapshot
63
+ // (and with it enforce + every differential guard) for the whole run.
46
64
  const commit = await git(cwd, ['commit', '-m', message], signal, spawnFn);
47
65
  if (commit.aborted)
48
66
  return { committed: false, reason: 'cancelled' };
49
67
  if (commit.exitCode !== 0) {
68
+ if (isIdentityFailure(commit.stderr || commit.stdout)) {
69
+ const retry = await git(cwd, [...FALLBACK_IDENTITY_ARGS, 'commit', '-m', message], signal, spawnFn);
70
+ if (retry.aborted)
71
+ return { committed: false, reason: 'cancelled' };
72
+ if (retry.exitCode === 0) {
73
+ return { committed: true, note: 'no git identity configured — used pi-task fallback' };
74
+ }
75
+ return {
76
+ committed: false,
77
+ reason: `git commit failed: ${firstLine(retry.stderr || retry.stdout)}`
78
+ };
79
+ }
50
80
  return {
51
81
  committed: false,
52
82
  reason: `git commit failed: ${firstLine(commit.stderr || commit.stdout)}`
@@ -74,10 +74,17 @@ export function revertGuardViolations(preDirty, stillDirty) {
74
74
  return preDirty.filter(f => !stillDirty.has(f));
75
75
  }
76
76
  const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
77
+ /**
78
+ * Files differing from HEAD, or null when git itself failed. The distinction is
79
+ * load-bearing: mx5 run 4 rolled back two GOOD converged fixes because a git
80
+ * failure after the child read as "[] files still dirty" → every pre-existing work
81
+ * file looked reverted → false "discarded work". A git error is INCONCLUSIVE, not
82
+ * evidence of destruction.
83
+ */
77
84
  async function dirtyFiles(deps) {
78
85
  const r = await deps.git(['diff', '--name-only', 'HEAD', '--', '.', EXCLUDE_TASKS_DIR]);
79
86
  if (r.exitCode !== 0)
80
- return [];
87
+ return null;
81
88
  return r.stdout
82
89
  .split('\n')
83
90
  .map(l => l.trim())
@@ -89,12 +96,24 @@ async function dirtyFiles(deps) {
89
96
  */
90
97
  export async function runBoundedLintFix(deps) {
91
98
  // Snapshot the full working state as a tree object (includes untracked files),
92
- // then unstage so the child sees the tree exactly as it was.
93
- const preDirty = await dirtyFiles(deps);
94
- const preUntracked = (await deps.git(['ls-files', '--others', '--exclude-standard', '--', '.', EXCLUDE_TASKS_DIR])).stdout
95
- .split('\n')
96
- .map(l => l.trim())
97
- .filter(l => l.length > 0);
99
+ // then unstage so the child sees the tree exactly as it was. A git failure at
100
+ // snapshot time leaves the guard without a baseline — it then runs vacuously
101
+ // (nothing to compare) rather than inventing violations.
102
+ const preDirty = (await dirtyFiles(deps)) ?? [];
103
+ const preUntrackedRes = await deps.git([
104
+ 'ls-files',
105
+ '--others',
106
+ '--exclude-standard',
107
+ '--',
108
+ '.',
109
+ EXCLUDE_TASKS_DIR
110
+ ]);
111
+ const preUntracked = preUntrackedRes.exitCode !== 0 ?
112
+ []
113
+ : preUntrackedRes.stdout
114
+ .split('\n')
115
+ .map(l => l.trim())
116
+ .filter(l => l.length > 0);
98
117
  let snapshot = null;
99
118
  const add = await deps.git(['add', '-A']);
100
119
  if (add.exitCode === 0) {
@@ -115,15 +134,32 @@ export async function runBoundedLintFix(deps) {
115
134
  }
116
135
  // REVERT-GUARD: every pre-existing work file must still differ from HEAD, and
117
136
  // every pre-existing untracked file must still exist. Trip → restore snapshot.
118
- const stillDirty = new Set(await dirtyFiles(deps));
119
- const violations = revertGuardViolations(preDirty, stillDirty);
120
- for (const f of preUntracked) {
121
- const probe = await deps.git(['ls-files', '--others', '--exclude-standard', '--', f]);
122
- const gone = probe.stdout.trim().length === 0
123
- // ...unless the child legitimately made it tracked-dirty (it edited it).
124
- && !stillDirty.has(f);
125
- if (gone)
126
- violations.push(f);
137
+ // Every comparison requires git to have actually SUCCEEDED: a git error after
138
+ // the child is inconclusive (proven live: it flagged the whole untracked file
139
+ // list as "discarded" while the child had verifiably edited only lint findings)
140
+ // then the guard steps aside and the converge check below still decides.
141
+ const stillDirtyList = await dirtyFiles(deps);
142
+ let guardNote;
143
+ const violations = [];
144
+ if (stillDirtyList === null) {
145
+ guardNote = 'revert-guard inconclusive (git failed after fix) — converge check decides';
146
+ }
147
+ else {
148
+ const stillDirty = new Set(stillDirtyList);
149
+ violations.push(...revertGuardViolations(preDirty, stillDirty));
150
+ for (const f of preUntracked) {
151
+ const probe = await deps.git(['ls-files', '--others', '--exclude-standard', '--', f]);
152
+ if (probe.exitCode !== 0) {
153
+ // git error → unknown, not "gone"; note it once and move on.
154
+ guardNote ??= 'revert-guard partly inconclusive (git probe failed)';
155
+ continue;
156
+ }
157
+ const gone = probe.stdout.trim().length === 0
158
+ // ...unless the child legitimately made it tracked-dirty (it edited it).
159
+ && !stillDirty.has(f);
160
+ if (gone)
161
+ violations.push(f);
162
+ }
127
163
  }
128
164
  if (violations.length > 0) {
129
165
  if (snapshot) {
@@ -142,5 +178,5 @@ export async function runBoundedLintFix(deps) {
142
178
  if (!health.ok) {
143
179
  return { ok: false, reason: `did not converge: ${health.reason}` };
144
180
  }
145
- return { ok: true };
181
+ return { ok: true, reason: guardNote };
146
182
  }
@@ -110,6 +110,11 @@ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
110
110
  // Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
111
111
  if (r.error || r.status === null)
112
112
  continue;
113
+ // Exit 127 = "command not found" INSIDE the script chain (e.g. `bun run lint`
114
+ // before node_modules exists — seen live failing TASK_0001's first verify).
115
+ // Same environment gap as ENOENT, just surfaced through the runner's shell.
116
+ if (r.status === 127)
117
+ continue;
113
118
  if (r.status !== 0) {
114
119
  return {
115
120
  ok: false,
@@ -87,7 +87,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
87
87
  lintFixAttempted = true;
88
88
  active.ui.notify(`${p.tag}: static findings on "${p.title}" — attempting bounded lint fix…`, 'info');
89
89
  const fix = await deps.lintFix(active, p.cwd, p.title, failReason);
90
- await rec(`lint-fix: ${fix.ok ? 'applied — re-verifying' : `not applied (${fix.reason ?? 'failed'})`}`);
90
+ await rec(`lint-fix: ${fix.ok ?
91
+ `applied${fix.reason ? ` (${fix.reason})` : ''} — re-verifying`
92
+ : `not applied (${fix.reason ?? 'failed'})`}`);
91
93
  if (fix.ok) {
92
94
  verified = await deps.verify(active, p.cwd, p.title, p.taskId);
93
95
  await rec(verdictLine(verified));
@@ -157,12 +159,19 @@ export async function runGatesForTask(ctxIn, deps, p) {
157
159
  // so a passing task is durably recorded no matter what enforcement later finds.
158
160
  const commit = await deps.commit(p.cwd, `task: ${p.title} (${p.taskId})`);
159
161
  if (commit.committed) {
160
- await rec('commit: task snapshot committed');
162
+ await rec(`commit: task snapshot committed${commit.note ? ` (${commit.note})` : ''}`);
161
163
  active.ui.notify(`${p.tag}: committed "${p.title}".`, 'info');
162
164
  }
163
165
  else {
164
166
  await rec(`commit: skipped (${commit.reason ?? 'unknown'})`);
165
- active.ui.notify(`${p.tag}: not committed (${commit.reason ?? 'unknown'}) continuing.`, 'warning');
167
+ // A benign skip ("nothing to commit", auto-commit off) is a warning. A real
168
+ // git failure is louder: it silently disables enforce AND every commit-based
169
+ // guard — mx5 run 4 lost all 10 commits (no container git identity) with only
170
+ // per-task warnings to show for it.
171
+ const gitFailure = /^git (commit|add) failed/.test(commit.reason ?? '');
172
+ active.ui.notify(gitFailure ?
173
+ `${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
174
+ : `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
166
175
  }
167
176
  // With the task committed, hold its work to AGENTS.md / CLAUDE.md — but as a step
168
177
  // INSIDE the validation gate, gated by the verify signal (see GateDeps.enforce).
@@ -188,6 +188,19 @@ export function buildVerifyPrompt(spec, probeFindings) {
188
188
  ' shipped artifact directly (the real app / module / entry point). If the real artifact',
189
189
  ' fails where the tests pass, report FAIL and name the bypass.',
190
190
  '',
191
+ '3c. TEST-AUTHORED REPAIR: rule 2 applies INSIDE the shipped test suite too. Before',
192
+ ' trusting a green suite, READ its setup/bootstrap (beforeAll / fixtures / helpers /',
193
+ ' conftest). Seeding ordinary test DATA is fine — but if the setup changes the',
194
+ " product's STRUCTURE or wiring to make the code runnable (ALTER TABLE / adding",
195
+ " columns, tables, or config that the project's own migrations and setup files never",
196
+ ' create; monkey-patching or stubbing shipped code), the suite is repairing the',
197
+ ' product in order to pass: its green result proves the repaired copy, NOT the',
198
+ " shipped artifact. Cross-check against the project's own provisioning: apply ONLY",
199
+ " the project's own setup commands (its migrations / seed scripts) to a scratch",
200
+ ' database and check whether the tested behaviors are possible on what THEY produce.',
201
+ ' If the tests patched a gap, report FAIL and name the missing production-side',
202
+ ' change (e.g. the column no migration creates).',
203
+ '',
191
204
  '4. Treat the ACCEPTANCE criteria as the bar. If a command fails, or its real output',
192
205
  ' contradicts an ACCEPTANCE criterion, the work has NOT verified.',
193
206
  '',
@@ -281,19 +294,30 @@ export async function runWorkVerification(deps) {
281
294
  findings = [];
282
295
  }
283
296
  }
284
- let text;
285
- try {
286
- text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings), deps.signal);
287
- }
288
- catch (err) {
289
- if (err instanceof Error && err.message === USER_CANCELLED)
290
- throw err;
291
- const msg = err instanceof Error ? err.message : String(err);
292
- return { ok: false, reason: `verification pass could not run: ${msg}` };
297
+ // A child that emits NO verdict never judged the work (budget/context death mid-
298
+ // investigation — seen live: an 11-minute verify wandered, died verdict-less, and
299
+ // the resulting FAIL burned a full implementation re-run on an unjudged artifact).
300
+ // That is a verify-side fault, so retry the VERIFY once before reporting a FAIL.
301
+ for (let attempt = 1;; attempt++) {
302
+ let text;
303
+ try {
304
+ text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings), deps.signal);
305
+ }
306
+ catch (err) {
307
+ if (err instanceof Error && err.message === USER_CANCELLED)
308
+ throw err;
309
+ const msg = err instanceof Error ? err.message : String(err);
310
+ return { ok: false, reason: `verification pass could not run: ${msg}` };
311
+ }
312
+ const verdict = parseVerifyVerdict(text);
313
+ if (verdict.pass)
314
+ return { ok: true };
315
+ if (verdict.detail === 'no verdict emitted' && attempt === 1)
316
+ continue;
317
+ return {
318
+ ok: false,
319
+ reason: `work did not verify: ${verdict.detail}${verdict.detail === 'no verdict emitted' ? ' (after verify retry)' : ''}`
320
+ };
293
321
  }
294
- const verdict = parseVerifyVerdict(text);
295
- if (verdict.pass)
296
- return { ok: true };
297
- return { ok: false, reason: `work did not verify: ${verdict.detail}` };
298
322
  }
299
323
  export { VERIFY_TOOLS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.17.15",
3
+ "version": "0.17.17",
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",