@mjasnikovs/pi-task 0.13.30 → 0.13.32

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.
@@ -21,8 +21,12 @@ export interface AutoDeps {
21
21
  * CLAUDE.md guidelines (and auto-fix violations) BEFORE it is committed.
22
22
  * Optional: when absent (tests, or the feature disabled), the loop treats it
23
23
  * as a pass. ok === false blocks the commit and fails the task.
24
+ *
25
+ * Takes the loop's CURRENT ctx (not the one captured at defaultDeps time):
26
+ * each task runs in a fresh session, so the captured ctx is stale by the time
27
+ * enforcement runs and using it for the loader widget throws "stale ctx".
24
28
  */
25
- enforce?: (cwd: string, taskTitle: string) => Promise<EnforceOutcome>;
29
+ enforce?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string) => Promise<EnforceOutcome>;
26
30
  }
27
31
  /**
28
32
  * Expand any @file references in the feature text by appending each referenced
@@ -15,7 +15,7 @@ import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './quest
15
15
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
16
16
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath } from './task-io.js';
17
17
  import { gitCommitAll } from './auto-commit.js';
18
- import { runGuidelineEnforcement, ENFORCE_TIMEOUT_MS } from './enforce-guidelines.js';
18
+ import { runGuidelineEnforcement, classifyEnforceChildFailure, ENFORCE_TIMEOUT_MS } from './enforce-guidelines.js';
19
19
  import { runWorker } from '../workers/pi-worker-core.js';
20
20
  import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
21
21
  import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
@@ -306,7 +306,7 @@ function defaultDeps(ctx, cwd, signal, title) {
306
306
  commit: (cwd2, message) => getConfig().autoCommit ?
307
307
  gitCommitAll(cwd2, message, signal)
308
308
  : Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
309
- enforce: (cwd2, _taskTitle) => {
309
+ enforce: (enforceCtx, cwd2, taskTitle) => {
310
310
  if (!getConfig().enforceGuidelines) {
311
311
  return Promise.resolve({ ok: true, reason: 'disabled' });
312
312
  }
@@ -315,30 +315,49 @@ function defaultDeps(ctx, cwd, signal, title) {
315
315
  signal,
316
316
  // Reuse the timeout- and loop-guarded worker child. It runs the
317
317
  // same local model with edit tools so it can fix violations in
318
- // place. Any non-clean exit (loop kill, timeout, leaked tool call,
319
- // non-zero) throws so the pass becomes a blocking outcome rather
320
- // than trusting a partial transcript.
318
+ // place. classifyEnforceChildFailure turns any non-clean exit
319
+ // (loop kill, timeout, leaked tool call, non-zero) into a thrown
320
+ // error so the pass becomes a blocking outcome rather than
321
+ // trusting a partial transcript.
321
322
  runChild: async (tools, prompt, sig) => {
322
- const r = await runWorker({
323
- prompt,
324
- cwd: cwd2,
325
- signal: sig,
326
- tools,
327
- timeoutMs: ENFORCE_TIMEOUT_MS,
328
- onLine: line => phaseDeps.logDebug?.(`enforce: ${line}`)
329
- });
330
- if (r.aborted && !r.timedOut)
331
- throw new Error(USER_CANCELLED);
332
- if (r.timedOut)
333
- throw new Error('enforcement child timed out');
334
- if (r.loopHit)
335
- throw new Error('enforcement child looped');
336
- if (r.leakedToolCall)
337
- throw new Error('enforcement child leaked a tool call');
338
- if (r.exitCode !== 0) {
339
- throw new Error(`enforcement child exited ${r.exitCode}`);
323
+ // The enforcement child is a slow local-model pass with edit
324
+ // tools; show the same /task-auto status block as planning so
325
+ // it isn't silent — head · enforcing guidelines/elapsed · ↳
326
+ // last line. (runWorker surfaces no context usage, so that
327
+ // line is just omitted.)
328
+ lastLine = undefined;
329
+ contextUsage = undefined;
330
+ const startedAt = Date.now();
331
+ const stopLoader = startAutoLoader(enforceCtx, () => ({
332
+ title: taskTitle,
333
+ kind: 'enforce',
334
+ step: 'guidelines',
335
+ stepNum: 1,
336
+ stepTotal: 1,
337
+ startedAt,
338
+ lastLine,
339
+ contextUsage
340
+ }));
341
+ try {
342
+ const r = await runWorker({
343
+ prompt,
344
+ cwd: cwd2,
345
+ signal: sig,
346
+ tools,
347
+ timeoutMs: ENFORCE_TIMEOUT_MS,
348
+ onLine: line => {
349
+ lastLine = line;
350
+ phaseDeps.logDebug?.(`enforce: ${line}`);
351
+ }
352
+ });
353
+ const failure = classifyEnforceChildFailure(r);
354
+ if (failure)
355
+ throw new Error(failure);
356
+ return r.text;
357
+ }
358
+ finally {
359
+ stopLoader();
340
360
  }
341
- return r.text;
342
361
  }
343
362
  });
344
363
  }
@@ -451,7 +470,7 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
451
470
  // dep.) Fixes it makes are folded into this task's snapshot below.
452
471
  if (deps.enforce) {
453
472
  active.ui.notify(`${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`, 'info');
454
- const verdict = await deps.enforce(cwd, next.title);
473
+ const verdict = await deps.enforce(active, cwd, next.title);
455
474
  if (!verdict.ok) {
456
475
  await updateTaskFrontMatter(cwd, id, { state: 'failed' });
457
476
  announceDone(active, `${id} stopped at "${next.title}" — ${verdict.reason ?? 'guideline check failed'} — fix and run /task-auto-resume.`, 'error');
@@ -46,6 +46,26 @@ export declare function parseEnforceVerdict(text: string): {
46
46
  clean: boolean;
47
47
  detail: string;
48
48
  };
49
+ /** The subset of a runWorker result the enforcement-child mapping reads. */
50
+ export interface EnforceChildResult {
51
+ text: string;
52
+ exitCode: number;
53
+ aborted: boolean;
54
+ timedOut?: boolean;
55
+ loopHit?: unknown;
56
+ leakedToolCall?: unknown;
57
+ }
58
+ /**
59
+ * Map the enforcement child's runWorker result to a fatal error message, or null
60
+ * when it finished cleanly enough to parse a verdict from its text.
61
+ *
62
+ * The order is load-bearing. A loop-kill AND a wall-clock timeout BOTH also set
63
+ * `aborted` — killProc flips it on every kill path — so the specific causes
64
+ * (timeout, loop, leaked tool call) must be named BEFORE the generic
65
+ * `aborted → user-cancel` mapping. Checking `aborted` first (as the original
66
+ * inline code did) mislabels a loop-killed enforcement child as a user cancel.
67
+ */
68
+ export declare function classifyEnforceChildFailure(r: EnforceChildResult): string | null;
49
69
  /**
50
70
  * Capture the work to verify as text: the tracked diff against HEAD plus the
51
71
  * names of any new (untracked) files. Non-destructive — it does not touch the
@@ -19,6 +19,7 @@
19
19
  import * as fsp from 'node:fs/promises';
20
20
  import * as path from 'node:path';
21
21
  import { runChildDefault } from '../shared/child-process.js';
22
+ import { USER_CANCELLED } from './child-runner.js';
22
23
  /** Filenames discovered in the working directory (cwd only — no tree walk). */
23
24
  export const GUIDELINE_FILENAMES = ['AGENTS.md', 'CLAUDE.md'];
24
25
  /** Tools the fix pass is allowed: read/search to inspect, edit/write to fix. */
@@ -99,6 +100,29 @@ export function parseEnforceVerdict(text) {
99
100
  const clean = last[1].toUpperCase() === 'CLEAN';
100
101
  return { clean, detail: clean ? '' : last[2].trim() || 'unspecified violation' };
101
102
  }
103
+ /**
104
+ * Map the enforcement child's runWorker result to a fatal error message, or null
105
+ * when it finished cleanly enough to parse a verdict from its text.
106
+ *
107
+ * The order is load-bearing. A loop-kill AND a wall-clock timeout BOTH also set
108
+ * `aborted` — killProc flips it on every kill path — so the specific causes
109
+ * (timeout, loop, leaked tool call) must be named BEFORE the generic
110
+ * `aborted → user-cancel` mapping. Checking `aborted` first (as the original
111
+ * inline code did) mislabels a loop-killed enforcement child as a user cancel.
112
+ */
113
+ export function classifyEnforceChildFailure(r) {
114
+ if (r.timedOut)
115
+ return 'enforcement child timed out';
116
+ if (r.loopHit)
117
+ return 'enforcement child looped';
118
+ if (r.leakedToolCall)
119
+ return 'enforcement child leaked a tool call';
120
+ if (r.aborted)
121
+ return USER_CANCELLED;
122
+ if (r.exitCode !== 0)
123
+ return `enforcement child exited ${r.exitCode}`;
124
+ return null;
125
+ }
102
126
  /**
103
127
  * Capture the work to verify as text: the tracked diff against HEAD plus the
104
128
  * names of any new (untracked) files. Non-destructive — it does not touch the
@@ -135,6 +159,11 @@ export async function runGuidelineEnforcement(deps) {
135
159
  text = await deps.runChild(ENFORCE_TOOLS, buildEnforcePrompt(doc.text, diff), deps.signal);
136
160
  }
137
161
  catch (err) {
162
+ // A user cancellation is not an enforcement failure — re-throw it so the
163
+ // /task-auto loop's USER_CANCELLED handler reports a clean "cancelled —
164
+ // resume" instead of wrapping it as "enforcement pass could not run".
165
+ if (err instanceof Error && err.message === USER_CANCELLED)
166
+ throw err;
138
167
  const msg = err instanceof Error ? err.message : String(err);
139
168
  return { ok: false, reason: `enforcement pass could not run: ${msg}` };
140
169
  }
@@ -49,6 +49,10 @@ export interface AutoLoaderState {
49
49
  startedAt: number;
50
50
  lastLine?: string;
51
51
  contextUsage?: ContextSnapshot;
52
+ /** Which /task-auto stage this loader is for. Defaults to 'planning' (the
53
+ * numbered clarify/decompose steps); 'enforce' is the per-task guideline
54
+ * pass, which has no step numbering. */
55
+ kind?: 'planning' | 'enforce';
52
56
  }
53
57
  export declare function buildAutoLoaderLines(s: AutoLoaderState, theme?: WidgetTheme): string[];
54
58
  /**
@@ -121,7 +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 = `planning ${s.stepNum}/${s.stepTotal} ${s.step} · ${elapsed}`;
124
+ let detail = s.kind === 'enforce' ?
125
+ `enforcing guidelines · ${elapsed}`
126
+ : `planning ${s.stepNum}/${s.stepTotal} ${s.step} · ${elapsed}`;
125
127
  if (s.contextUsage) {
126
128
  const ctxDetail = formatContextDetail(s.contextUsage, theme);
127
129
  if (ctxDetail)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.13.30",
3
+ "version": "0.13.32",
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",