@mjasnikovs/pi-task 0.13.29 → 0.13.30

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, 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,43 @@ 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. 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.
321
+ 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}`);
340
+ }
341
+ return r.text;
342
+ }
343
+ });
344
+ }
307
345
  };
308
346
  }
309
347
  // ─── Loop ────────────────────────────────────────────────────────────────────
@@ -402,6 +440,24 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
402
440
  announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
403
441
  return;
404
442
  }
443
+ // Before checking the task off or committing, hold the work to the
444
+ // project's AGENTS.md / CLAUDE.md rules. Local models drift and skip
445
+ // those guidelines, so the enforcement pass re-reads the diff with the
446
+ // same local model (edit tools enabled) and fixes violations in place.
447
+ // A non-clean verdict — or a pass that could not run — blocks the
448
+ // commit and fails the task: non-compliant work is never snapshotted,
449
+ // and the run pauses for /task-auto-resume. (No-op when the feature is
450
+ // off, when there are no guideline files, or in tests with no enforce
451
+ // dep.) Fixes it makes are folded into this task's snapshot below.
452
+ if (deps.enforce) {
453
+ active.ui.notify(`${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`, 'info');
454
+ const verdict = await deps.enforce(cwd, next.title);
455
+ if (!verdict.ok) {
456
+ await updateTaskFrontMatter(cwd, id, { state: 'failed' });
457
+ announceDone(active, `${id} stopped at "${next.title}" — ${verdict.reason ?? 'guideline check failed'} — fix and run /task-auto-resume.`, 'error');
458
+ return;
459
+ }
460
+ }
405
461
  // res.ok === true means runner.run() completed, so res.taskId is the
406
462
  // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
407
463
  // empty id by writing a plain checked line, but that path is unreachable.
@@ -0,0 +1,74 @@
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
+ /**
50
+ * Capture the work to verify as text: the tracked diff against HEAD plus the
51
+ * names of any new (untracked) files. Non-destructive — it does not touch the
52
+ * index, so the later `git add -A` in gitCommitAll still stages everything
53
+ * (including fixes the enforcement child makes).
54
+ */
55
+ export declare function captureDiff(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string>;
56
+ export interface EnforcementDeps {
57
+ cwd: string;
58
+ signal?: AbortSignal;
59
+ /** Defaults to the real cwd-only file discovery. */
60
+ discover?: (cwd: string) => Promise<GuidelineDoc | null>;
61
+ /** Defaults to the real git diff capture. */
62
+ getDiff?: (cwd: string, signal?: AbortSignal) => Promise<string>;
63
+ /** Runs the enforcement child and returns its assistant text. Injected so
64
+ * the orchestrator wires the real child runner and tests use a fake. */
65
+ runChild: (tools: string, prompt: string, signal?: AbortSignal) => Promise<string>;
66
+ }
67
+ /**
68
+ * Run the full enforcement pass for one task. Discovery is cwd-only; an empty
69
+ * result is a pass. Otherwise capture the diff, run the fix child, and turn its
70
+ * verdict into an ok/blocked outcome. Never throws — a child failure becomes a
71
+ * blocking outcome (ok: false) so an unverifiable task is not committed.
72
+ */
73
+ export declare function runGuidelineEnforcement(deps: EnforcementDeps): Promise<EnforceOutcome>;
74
+ export { ENFORCE_TOOLS, ENFORCE_TIMEOUT_MS };
@@ -0,0 +1,146 @@
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
+ /** Filenames discovered in the working directory (cwd only — no tree walk). */
23
+ export const GUIDELINE_FILENAMES = ['AGENTS.md', 'CLAUDE.md'];
24
+ /** Tools the fix pass is allowed: read/search to inspect, edit/write to fix. */
25
+ const ENFORCE_TOOLS = 'read,grep,find,ls,edit,write';
26
+ /** Wall-clock ceiling for the enforcement child. Local models are slow and the
27
+ * fix pass reads several files and may rewrite a few; give it room but never
28
+ * let a wedged child hang the whole run. */
29
+ const ENFORCE_TIMEOUT_MS = 600_000;
30
+ /**
31
+ * Read the guideline files that live directly in `cwd` (AGENTS.md, CLAUDE.md).
32
+ * Returns null when none exist or all are empty — the caller treats that as a
33
+ * pass. cwd-only by design: no walk up to the git root.
34
+ */
35
+ export async function discoverGuidelines(cwd, readFile = p => fsp.readFile(p, 'utf8')) {
36
+ const files = [];
37
+ const sections = [];
38
+ for (const name of GUIDELINE_FILENAMES) {
39
+ let raw;
40
+ try {
41
+ raw = await readFile(path.join(cwd, name));
42
+ }
43
+ catch {
44
+ continue; // missing file — skip
45
+ }
46
+ if (raw.trim().length === 0)
47
+ continue; // present but empty — nothing to enforce
48
+ files.push(name);
49
+ sections.push(`## ${name}\n\n${raw.trim()}`);
50
+ }
51
+ if (files.length === 0)
52
+ return null;
53
+ return { files, text: sections.join('\n\n') };
54
+ }
55
+ /**
56
+ * Build the enforcement child's prompt: the rules, the diff of the work just
57
+ * done, and the contract for the final verdict line. Kept pure so the wording
58
+ * is unit-tested without spawning pi.
59
+ */
60
+ export function buildEnforcePrompt(rulesText, diff) {
61
+ return [
62
+ 'You are a strict guideline-enforcement pass running after an AI coding agent',
63
+ 'finished a task but before its work is committed. The agent is known to skip',
64
+ 'project rules, so do not trust that it followed them — verify against the diff.',
65
+ '',
66
+ 'PROJECT GUIDELINES (from this repository):',
67
+ rulesText,
68
+ '',
69
+ 'CHANGES JUST MADE (verify these specifically; read any file you need in full):',
70
+ diff.trim().length > 0 ? diff : '(no textual diff captured — inspect the working tree)',
71
+ '',
72
+ 'Your job:',
73
+ '1. Check the changed files against EVERY rule above.',
74
+ '2. For each violation you find, FIX it directly using your edit/write tools.',
75
+ ' Make the minimal change that satisfies the rule; do not rewrite unrelated code.',
76
+ '3. Do not introduce new behavior or features — only bring the work into compliance.',
77
+ '',
78
+ 'When you are done, output EXACTLY ONE of these as the final line:',
79
+ ' ENFORCE: CLEAN (the changes comply with every rule, after your fixes)',
80
+ ' ENFORCE: VIOLATION <text> (a rule is still violated and you could not fix it; say which)',
81
+ 'Output the verdict line verbatim — it is parsed mechanically.'
82
+ ].join('\n');
83
+ }
84
+ /**
85
+ * Parse the child's final verdict. Scans for the LAST `ENFORCE: CLEAN` /
86
+ * `ENFORCE: VIOLATION` marker (the model may discuss before concluding).
87
+ *
88
+ * No marker at all is treated as NOT clean: a pass that cannot state a verdict
89
+ * is a gray area, and the contract is that uncertain work does not get
90
+ * committed.
91
+ */
92
+ export function parseEnforceVerdict(text) {
93
+ const re = /ENFORCE:\s*(CLEAN|VIOLATION)\b[ \t]*(.*)/gi;
94
+ let last = null;
95
+ for (let m = re.exec(text); m !== null; m = re.exec(text))
96
+ last = m;
97
+ if (!last)
98
+ return { clean: false, detail: 'no verdict emitted' };
99
+ const clean = last[1].toUpperCase() === 'CLEAN';
100
+ return { clean, detail: clean ? '' : last[2].trim() || 'unspecified violation' };
101
+ }
102
+ /**
103
+ * Capture the work to verify as text: the tracked diff against HEAD plus the
104
+ * names of any new (untracked) files. Non-destructive — it does not touch the
105
+ * index, so the later `git add -A` in gitCommitAll still stages everything
106
+ * (including fixes the enforcement child makes).
107
+ */
108
+ export async function captureDiff(cwd, signal, spawnFn) {
109
+ const run = (args) => runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
110
+ const tracked = await run(['diff', 'HEAD']);
111
+ const untracked = await run(['ls-files', '--others', '--exclude-standard']);
112
+ const parts = [];
113
+ if (tracked.exitCode === 0 && tracked.stdout.trim().length > 0)
114
+ parts.push(tracked.stdout.trim());
115
+ const newFiles = untracked.exitCode === 0 ? untracked.stdout.trim() : '';
116
+ if (newFiles.length > 0)
117
+ parts.push(`New (untracked) files — read them in full:\n${newFiles}`);
118
+ return parts.join('\n\n');
119
+ }
120
+ /**
121
+ * Run the full enforcement pass for one task. Discovery is cwd-only; an empty
122
+ * result is a pass. Otherwise capture the diff, run the fix child, and turn its
123
+ * verdict into an ok/blocked outcome. Never throws — a child failure becomes a
124
+ * blocking outcome (ok: false) so an unverifiable task is not committed.
125
+ */
126
+ export async function runGuidelineEnforcement(deps) {
127
+ const discover = deps.discover ?? (cwd => discoverGuidelines(cwd));
128
+ const getDiff = deps.getDiff ?? ((cwd, signal) => captureDiff(cwd, signal));
129
+ const doc = await discover(deps.cwd);
130
+ if (!doc)
131
+ return { ok: true, reason: 'no guideline files' };
132
+ const diff = await getDiff(deps.cwd, deps.signal);
133
+ let text;
134
+ try {
135
+ text = await deps.runChild(ENFORCE_TOOLS, buildEnforcePrompt(doc.text, diff), deps.signal);
136
+ }
137
+ catch (err) {
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ return { ok: false, reason: `enforcement pass could not run: ${msg}` };
140
+ }
141
+ const verdict = parseEnforceVerdict(text);
142
+ if (verdict.clean)
143
+ return { ok: true };
144
+ return { ok: false, reason: `guideline violation: ${verdict.detail}` };
145
+ }
146
+ export { ENFORCE_TOOLS, ENFORCE_TIMEOUT_MS };
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.30",
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",