@mjasnikovs/pi-task 0.13.29 → 0.13.31

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/README.md CHANGED
@@ -49,7 +49,7 @@ pi install npm:@mjasnikovs/pi-task
49
49
  | `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
50
50
  | `/task-auto-resume` | Resume the active `/task-auto` run at the next unfinished task. |
51
51
  | `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
52
- | `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, and orientation. |
52
+ | `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, orientation, and enforce guidelines. |
53
53
  | `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
54
54
 
55
55
  ## The pipeline
@@ -140,7 +140,7 @@ Resolves an installed npm package, indexes its `.d.ts` files and README into a l
140
140
 
141
141
  ## Settings — `/task-config`
142
142
 
143
- Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings persist to `~/.config/pi-task/config.json` and all default to **on**:
143
+ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings persist to `~/.config/pi-task/config.json`. All default to **on** except **enforce guidelines**:
144
144
 
145
145
  | Setting | What it does |
146
146
  | --- | --- |
@@ -148,6 +148,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
148
148
  | **compress reasoning** | After each message, compresses the model's `<think>` blocks down to the decisions/constraints/facts that matter later — keeping long local-model runs from drowning their own context in self-talk. |
149
149
  | **auto-commit** | Snapshots the working tree into one git commit per `/task-auto` sub-task (see above). |
150
150
  | **orientation** | Pre-reads the project's core files (manifest, config, domain types, schema, entrypoints, API surface) once and hands the contents to the read-heavy research workers, so they skip re-discovering the same files cold. Bounded by a hard byte budget; applied only where it helps (FILES/APIS workers). |
151
+ | **enforce guidelines** _(off by default)_ | Before each `/task-auto` commit, re-checks the sub-task's work against the project's `AGENTS.md` / `CLAUDE.md` (in the working directory). Local models drift and skip those rules, so a fresh pass of the same local model — with edit tools — reads the diff, fixes any violations in place, and returns a verdict. A violation it can't clear (or a pass that can't run) **blocks the commit and stops the run** so non-compliant work is never snapshotted; fix and `/task-auto-resume`. Adds one extra local-model pass per task, so it's opt-in. |
151
152
 
152
153
  ## Configuration
153
154
 
@@ -3,6 +3,7 @@ export interface PiTaskConfig {
3
3
  compressReasoning: boolean;
4
4
  autoCommit: boolean;
5
5
  orientation: boolean;
6
+ enforceGuidelines: boolean;
6
7
  }
7
8
  export declare function getConfig(): PiTaskConfig;
8
9
  export declare function saveConfig(config: PiTaskConfig): Promise<void>;
@@ -6,7 +6,8 @@ const DEFAULTS = {
6
6
  remote: true,
7
7
  compressReasoning: true,
8
8
  autoCommit: true,
9
- orientation: true
9
+ orientation: true,
10
+ enforceGuidelines: false
10
11
  };
11
12
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'pi-task', 'config.json');
12
13
  const _g = globalThis;
@@ -17,6 +17,11 @@ const ITEMS = [
17
17
  id: 'orientation',
18
18
  label: 'orientation',
19
19
  description: 'Pre-supply the project core (manifest, types, schema…) to the read-heavy research workers'
20
+ },
21
+ {
22
+ id: 'enforceGuidelines',
23
+ label: 'enforce guidelines',
24
+ description: 'Before each /task-auto commit, re-check the work against AGENTS.md/CLAUDE.md and fix drift'
20
25
  }
21
26
  ];
22
27
  function makeTheme(theme) {
@@ -53,7 +58,7 @@ async function handleTaskConfig(_args, ctx) {
53
58
  }
54
59
  export function registerConfig(pi) {
55
60
  registerBridgeCommand(pi, 'task-config', {
56
- description: 'Configure pi-task settings (remote, compress reasoning, auto-commit, orientation).',
61
+ description: 'Configure pi-task settings (remote, compress reasoning, auto-commit, orientation, enforce guidelines).',
57
62
  handler: handleTaskConfig
58
63
  });
59
64
  }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
2
  import type { RunSingleTaskResult } from './orchestrator.js';
3
3
  import { type CommitResult } from './auto-commit.js';
4
+ import { type EnforceOutcome } from './enforce-guidelines.js';
4
5
  /**
5
6
  * Injectable seams so the planner and loop are testable without spawning pi.
6
7
  * `runChild` is used by planAuto; `runTask` is used by runAutoLoop.
@@ -15,6 +16,13 @@ export interface AutoDeps {
15
16
  }) => Promise<RunSingleTaskResult>;
16
17
  /** Snapshot the working tree into one commit after a task passes. */
17
18
  commit: (cwd: string, message: string) => Promise<CommitResult>;
19
+ /**
20
+ * Verify the just-finished task's work against the project's AGENTS.md /
21
+ * CLAUDE.md guidelines (and auto-fix violations) BEFORE it is committed.
22
+ * Optional: when absent (tests, or the feature disabled), the loop treats it
23
+ * as a pass. ok === false blocks the commit and fails the task.
24
+ */
25
+ enforce?: (cwd: string, taskTitle: string) => Promise<EnforceOutcome>;
18
26
  }
19
27
  /**
20
28
  * Expand any @file references in the feature text by appending each referenced
@@ -15,6 +15,8 @@ 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, classifyEnforceChildFailure, ENFORCE_TIMEOUT_MS } from './enforce-guidelines.js';
19
+ import { runWorker } from '../workers/pi-worker-core.js';
18
20
  import { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
19
21
  import { SessionUI, registerBridgeCommand } from '../remote/bridge.js';
20
22
  import { pushNotify } from '../remote/push.js';
@@ -303,7 +305,62 @@ function defaultDeps(ctx, cwd, signal, title) {
303
305
  }),
304
306
  commit: (cwd2, message) => getConfig().autoCommit ?
305
307
  gitCommitAll(cwd2, message, signal)
306
- : Promise.resolve({ committed: false, reason: 'auto-commit disabled' })
308
+ : Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
309
+ enforce: (cwd2, taskTitle) => {
310
+ if (!getConfig().enforceGuidelines) {
311
+ return Promise.resolve({ ok: true, reason: 'disabled' });
312
+ }
313
+ return runGuidelineEnforcement({
314
+ cwd: cwd2,
315
+ signal,
316
+ // Reuse the timeout- and loop-guarded worker child. It runs the
317
+ // same local model with edit tools so it can fix violations in
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.
322
+ runChild: async (tools, prompt, sig) => {
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(ctx, () => ({
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();
360
+ }
361
+ }
362
+ });
363
+ }
307
364
  };
308
365
  }
309
366
  // ─── Loop ────────────────────────────────────────────────────────────────────
@@ -402,6 +459,24 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
402
459
  announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
403
460
  return;
404
461
  }
462
+ // Before checking the task off or committing, hold the work to the
463
+ // project's AGENTS.md / CLAUDE.md rules. Local models drift and skip
464
+ // those guidelines, so the enforcement pass re-reads the diff with the
465
+ // same local model (edit tools enabled) and fixes violations in place.
466
+ // A non-clean verdict — or a pass that could not run — blocks the
467
+ // commit and fails the task: non-compliant work is never snapshotted,
468
+ // and the run pauses for /task-auto-resume. (No-op when the feature is
469
+ // off, when there are no guideline files, or in tests with no enforce
470
+ // dep.) Fixes it makes are folded into this task's snapshot below.
471
+ if (deps.enforce) {
472
+ active.ui.notify(`${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`, 'info');
473
+ const verdict = await deps.enforce(cwd, next.title);
474
+ if (!verdict.ok) {
475
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
476
+ announceDone(active, `${id} stopped at "${next.title}" — ${verdict.reason ?? 'guideline check failed'} — fix and run /task-auto-resume.`, 'error');
477
+ return;
478
+ }
479
+ }
405
480
  // res.ok === true means runner.run() completed, so res.taskId is the
406
481
  // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
407
482
  // empty id by writing a plain checked line, but that path is unreachable.
@@ -0,0 +1,94 @@
1
+ import { type SpawnFn } from '../shared/child-process.js';
2
+ /** Filenames discovered in the working directory (cwd only — no tree walk). */
3
+ export declare const GUIDELINE_FILENAMES: readonly ["AGENTS.md", "CLAUDE.md"];
4
+ /** Tools the fix pass is allowed: read/search to inspect, edit/write to fix. */
5
+ declare const ENFORCE_TOOLS = "read,grep,find,ls,edit,write";
6
+ /** Wall-clock ceiling for the enforcement child. Local models are slow and the
7
+ * fix pass reads several files and may rewrite a few; give it room but never
8
+ * let a wedged child hang the whole run. */
9
+ declare const ENFORCE_TIMEOUT_MS = 600000;
10
+ export interface GuidelineDoc {
11
+ /** Filenames found, in discovery order (e.g. ['AGENTS.md', 'CLAUDE.md']). */
12
+ files: string[];
13
+ /** The concatenated rules text, each file under a `## <name>` header. */
14
+ text: string;
15
+ }
16
+ export interface EnforceOutcome {
17
+ /** true → compliant (after any fixes), enforcement disabled, or no
18
+ * guideline files. false → a violation the pass could not clear, or the
19
+ * pass could not run / produced no verdict. */
20
+ ok: boolean;
21
+ /** Short, human-readable reason. Always set when ok === false; on the pass
22
+ * path set to the no-op cause ('disabled', 'no guideline files'). */
23
+ reason?: string;
24
+ }
25
+ /**
26
+ * Read the guideline files that live directly in `cwd` (AGENTS.md, CLAUDE.md).
27
+ * Returns null when none exist or all are empty — the caller treats that as a
28
+ * pass. cwd-only by design: no walk up to the git root.
29
+ */
30
+ export declare function discoverGuidelines(cwd: string, readFile?: (p: string) => Promise<string>): Promise<GuidelineDoc | null>;
31
+ /**
32
+ * Build the enforcement child's prompt: the rules, the diff of the work just
33
+ * done, and the contract for the final verdict line. Kept pure so the wording
34
+ * is unit-tested without spawning pi.
35
+ */
36
+ export declare function buildEnforcePrompt(rulesText: string, diff: string): string;
37
+ /**
38
+ * Parse the child's final verdict. Scans for the LAST `ENFORCE: CLEAN` /
39
+ * `ENFORCE: VIOLATION` marker (the model may discuss before concluding).
40
+ *
41
+ * No marker at all is treated as NOT clean: a pass that cannot state a verdict
42
+ * is a gray area, and the contract is that uncertain work does not get
43
+ * committed.
44
+ */
45
+ export declare function parseEnforceVerdict(text: string): {
46
+ clean: boolean;
47
+ detail: string;
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;
69
+ /**
70
+ * Capture the work to verify as text: the tracked diff against HEAD plus the
71
+ * names of any new (untracked) files. Non-destructive — it does not touch the
72
+ * index, so the later `git add -A` in gitCommitAll still stages everything
73
+ * (including fixes the enforcement child makes).
74
+ */
75
+ export declare function captureDiff(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string>;
76
+ export interface EnforcementDeps {
77
+ cwd: string;
78
+ signal?: AbortSignal;
79
+ /** Defaults to the real cwd-only file discovery. */
80
+ discover?: (cwd: string) => Promise<GuidelineDoc | null>;
81
+ /** Defaults to the real git diff capture. */
82
+ getDiff?: (cwd: string, signal?: AbortSignal) => Promise<string>;
83
+ /** Runs the enforcement child and returns its assistant text. Injected so
84
+ * the orchestrator wires the real child runner and tests use a fake. */
85
+ runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
86
+ }
87
+ /**
88
+ * Run the full enforcement pass for one task. Discovery is cwd-only; an empty
89
+ * result is a pass. Otherwise capture the diff, run the fix child, and turn its
90
+ * verdict into an ok/blocked outcome. Never throws — a child failure becomes a
91
+ * blocking outcome (ok: false) so an unverifiable task is not committed.
92
+ */
93
+ export declare function runGuidelineEnforcement(deps: EnforcementDeps): Promise<EnforceOutcome>;
94
+ export { ENFORCE_TOOLS, ENFORCE_TIMEOUT_MS };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Guideline enforcement for /task-auto.
3
+ *
4
+ * Local models drift: they skip the rules written in AGENTS.md / CLAUDE.md even
5
+ * when those files sit right next to the code. After a /task-auto sub-task's
6
+ * implementation turn settles — but BEFORE its per-task commit — this runs a
7
+ * fresh child pi (the same local model that did the work) that is handed the
8
+ * project's guideline files plus the task's diff, fixes any violations in place
9
+ * with its edit tools, and reports a CLEAN / VIOLATION verdict.
10
+ *
11
+ * A VIOLATION (or an enforcement pass that cannot confirm CLEAN) blocks the
12
+ * commit and fails the task, so non-compliant work is never snapshotted — the
13
+ * run pauses for /task-auto-resume. This is deliberately strict: the whole
14
+ * point is that "the model probably followed the rules" is not good enough.
15
+ *
16
+ * Gated by the `enforceGuidelines` config flag. With the flag off, or with no
17
+ * guideline files in the working directory, enforcement is a pass (ok: true).
18
+ */
19
+ import * as fsp from 'node:fs/promises';
20
+ import * as path from 'node:path';
21
+ import { runChildDefault } from '../shared/child-process.js';
22
+ import { USER_CANCELLED } from './child-runner.js';
23
+ /** Filenames discovered in the working directory (cwd only — no tree walk). */
24
+ export const GUIDELINE_FILENAMES = ['AGENTS.md', 'CLAUDE.md'];
25
+ /** Tools the fix pass is allowed: read/search to inspect, edit/write to fix. */
26
+ const ENFORCE_TOOLS = 'read,grep,find,ls,edit,write';
27
+ /** Wall-clock ceiling for the enforcement child. Local models are slow and the
28
+ * fix pass reads several files and may rewrite a few; give it room but never
29
+ * let a wedged child hang the whole run. */
30
+ const ENFORCE_TIMEOUT_MS = 600_000;
31
+ /**
32
+ * Read the guideline files that live directly in `cwd` (AGENTS.md, CLAUDE.md).
33
+ * Returns null when none exist or all are empty — the caller treats that as a
34
+ * pass. cwd-only by design: no walk up to the git root.
35
+ */
36
+ export async function discoverGuidelines(cwd, readFile = p => fsp.readFile(p, 'utf8')) {
37
+ const files = [];
38
+ const sections = [];
39
+ for (const name of GUIDELINE_FILENAMES) {
40
+ let raw;
41
+ try {
42
+ raw = await readFile(path.join(cwd, name));
43
+ }
44
+ catch {
45
+ continue; // missing file — skip
46
+ }
47
+ if (raw.trim().length === 0)
48
+ continue; // present but empty — nothing to enforce
49
+ files.push(name);
50
+ sections.push(`## ${name}\n\n${raw.trim()}`);
51
+ }
52
+ if (files.length === 0)
53
+ return null;
54
+ return { files, text: sections.join('\n\n') };
55
+ }
56
+ /**
57
+ * Build the enforcement child's prompt: the rules, the diff of the work just
58
+ * done, and the contract for the final verdict line. Kept pure so the wording
59
+ * is unit-tested without spawning pi.
60
+ */
61
+ export function buildEnforcePrompt(rulesText, diff) {
62
+ return [
63
+ 'You are a strict guideline-enforcement pass running after an AI coding agent',
64
+ 'finished a task but before its work is committed. The agent is known to skip',
65
+ 'project rules, so do not trust that it followed them — verify against the diff.',
66
+ '',
67
+ 'PROJECT GUIDELINES (from this repository):',
68
+ rulesText,
69
+ '',
70
+ 'CHANGES JUST MADE (verify these specifically; read any file you need in full):',
71
+ diff.trim().length > 0 ? diff : '(no textual diff captured — inspect the working tree)',
72
+ '',
73
+ 'Your job:',
74
+ '1. Check the changed files against EVERY rule above.',
75
+ '2. For each violation you find, FIX it directly using your edit/write tools.',
76
+ ' Make the minimal change that satisfies the rule; do not rewrite unrelated code.',
77
+ '3. Do not introduce new behavior or features — only bring the work into compliance.',
78
+ '',
79
+ 'When you are done, output EXACTLY ONE of these as the final line:',
80
+ ' ENFORCE: CLEAN (the changes comply with every rule, after your fixes)',
81
+ ' ENFORCE: VIOLATION <text> (a rule is still violated and you could not fix it; say which)',
82
+ 'Output the verdict line verbatim — it is parsed mechanically.'
83
+ ].join('\n');
84
+ }
85
+ /**
86
+ * Parse the child's final verdict. Scans for the LAST `ENFORCE: CLEAN` /
87
+ * `ENFORCE: VIOLATION` marker (the model may discuss before concluding).
88
+ *
89
+ * No marker at all is treated as NOT clean: a pass that cannot state a verdict
90
+ * is a gray area, and the contract is that uncertain work does not get
91
+ * committed.
92
+ */
93
+ export function parseEnforceVerdict(text) {
94
+ const re = /ENFORCE:\s*(CLEAN|VIOLATION)\b[ \t]*(.*)/gi;
95
+ let last = null;
96
+ for (let m = re.exec(text); m !== null; m = re.exec(text))
97
+ last = m;
98
+ if (!last)
99
+ return { clean: false, detail: 'no verdict emitted' };
100
+ const clean = last[1].toUpperCase() === 'CLEAN';
101
+ return { clean, detail: clean ? '' : last[2].trim() || 'unspecified violation' };
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
+ }
126
+ /**
127
+ * Capture the work to verify as text: the tracked diff against HEAD plus the
128
+ * names of any new (untracked) files. Non-destructive — it does not touch the
129
+ * index, so the later `git add -A` in gitCommitAll still stages everything
130
+ * (including fixes the enforcement child makes).
131
+ */
132
+ export async function captureDiff(cwd, signal, spawnFn) {
133
+ const run = (args) => runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
134
+ const tracked = await run(['diff', 'HEAD']);
135
+ const untracked = await run(['ls-files', '--others', '--exclude-standard']);
136
+ const parts = [];
137
+ if (tracked.exitCode === 0 && tracked.stdout.trim().length > 0)
138
+ parts.push(tracked.stdout.trim());
139
+ const newFiles = untracked.exitCode === 0 ? untracked.stdout.trim() : '';
140
+ if (newFiles.length > 0)
141
+ parts.push(`New (untracked) files — read them in full:\n${newFiles}`);
142
+ return parts.join('\n\n');
143
+ }
144
+ /**
145
+ * Run the full enforcement pass for one task. Discovery is cwd-only; an empty
146
+ * result is a pass. Otherwise capture the diff, run the fix child, and turn its
147
+ * verdict into an ok/blocked outcome. Never throws — a child failure becomes a
148
+ * blocking outcome (ok: false) so an unverifiable task is not committed.
149
+ */
150
+ export async function runGuidelineEnforcement(deps) {
151
+ const discover = deps.discover ?? (cwd => discoverGuidelines(cwd));
152
+ const getDiff = deps.getDiff ?? ((cwd, signal) => captureDiff(cwd, signal));
153
+ const doc = await discover(deps.cwd);
154
+ if (!doc)
155
+ return { ok: true, reason: 'no guideline files' };
156
+ const diff = await getDiff(deps.cwd, deps.signal);
157
+ let text;
158
+ try {
159
+ text = await deps.runChild(ENFORCE_TOOLS, buildEnforcePrompt(doc.text, diff), deps.signal);
160
+ }
161
+ catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ return { ok: false, reason: `enforcement pass could not run: ${msg}` };
164
+ }
165
+ const verdict = parseEnforceVerdict(text);
166
+ if (verdict.clean)
167
+ return { ok: true };
168
+ return { ok: false, reason: `guideline violation: ${verdict.detail}` };
169
+ }
170
+ export { ENFORCE_TOOLS, ENFORCE_TIMEOUT_MS };
@@ -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.29",
3
+ "version": "0.13.31",
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",