@mjasnikovs/pi-task 0.14.4 → 0.14.5

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
@@ -148,7 +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
+ | **enforce guidelines** _(off by default)_ | After each `/task-auto` task is committed, re-checks that commit'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 **last commit's** diff, fixes any violations in place, and returns a verdict. Its fixes are committed **separately** as an `ENFORCE GUIDELINES` commit on top of the task commit, so guideline corrections are an independent, auditable diff. A violation it can't clear (or a pass that can't run) only **warns** the task commit already landed, so the run continues. Skipped when nothing was committed for the task. Adds one extra local-model pass per task, so it's opt-in. |
152
152
 
153
153
  ## Configuration
154
154
 
@@ -498,31 +498,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
498
498
  announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
499
499
  return;
500
500
  }
501
- // Before checking the task off or committing, hold the work to the
502
- // project's AGENTS.md / CLAUDE.md rules. Local models drift and skip
503
- // those guidelines, so the enforcement pass re-reads the diff with the
504
- // same local model (edit tools enabled) and fixes violations in place.
505
- // A non-clean verdict — or a pass that could not run — blocks the
506
- // commit and fails the task: non-compliant work is never snapshotted,
507
- // and the run pauses for /task-auto-resume. (No-op when the feature is
508
- // off, when there are no guideline files, or in tests with no enforce
509
- // dep.) Fixes it makes are folded into this task's snapshot below.
510
- if (deps.enforce) {
511
- active.ui.notify(`${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`, 'info');
512
- const verdict = await deps.enforce(active, cwd, next.title);
513
- if (!verdict.ok) {
514
- await updateTaskFrontMatter(cwd, id, { state: 'failed' });
515
- announceDone(active, `${id} stopped at "${next.title}" — ${verdict.reason ?? 'guideline check failed'} — fix and run /task-auto-resume.`, 'error');
516
- return;
517
- }
518
- }
519
501
  // res.ok === true means runner.run() completed, so res.taskId is the
520
502
  // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
521
503
  // empty id by writing a plain checked line, but that path is unreachable.
522
504
  await checkOffTask(cwd, id, next.index, res.taskId, next.title);
523
505
  // Commit the task's work (and the just-written check-off) as one
524
- // snapshot. Best-effort: a failed/empty commit only warns the task
525
- // already passed, so the run continues.
506
+ // snapshot FIRST before guideline enforcementso a passing task is
507
+ // durably recorded no matter what enforcement later finds. Best-effort:
508
+ // a failed/empty commit only warns; the task already passed.
526
509
  const message = `task: ${next.title} (${res.taskId})`;
527
510
  const commit = await deps.commit(cwd, message);
528
511
  if (commit.committed) {
@@ -531,6 +514,31 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
531
514
  else {
532
515
  active.ui.notify(`${id}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, 'warning');
533
516
  }
517
+ // With the task committed, hold its work to the project's AGENTS.md /
518
+ // CLAUDE.md rules. Local models drift and skip those guidelines, so the
519
+ // enforcement pass re-reads THE LAST COMMIT's diff with the same local
520
+ // model (edit tools enabled) and fixes violations in place; those fixes
521
+ // are then committed SEPARATELY under an "ENFORCE GUIDELINES" commit so
522
+ // they form an independent, auditable diff on top of the task commit.
523
+ // A non-clean verdict only warns — the task commit already landed, so
524
+ // the run continues. Skipped when nothing was committed this round
525
+ // (autoCommit off or an empty commit): there is no "last commit" of this
526
+ // task's work to verify. (Also a no-op when the feature is off, when
527
+ // there are no guideline files, or in tests with no enforce dep.)
528
+ if (deps.enforce && commit.committed) {
529
+ active.ui.notify(`${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`, 'info');
530
+ const verdict = await deps.enforce(active, cwd, next.title);
531
+ if (!verdict.ok) {
532
+ active.ui.notify(`${id}: guideline enforcement on "${next.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
533
+ }
534
+ // Commit whatever the pass fixed as its own snapshot. A no-op when
535
+ // the pass made no edits (gitCommitAll reports nothing to commit),
536
+ // so a clean task adds no empty commit — only announce a real one.
537
+ const enforceCommit = await deps.commit(cwd, `ENFORCE GUIDELINES: ${next.title} (${res.taskId})`);
538
+ if (enforceCommit.committed) {
539
+ active.ui.notify(`${id}: committed guideline fixes for "${next.title}".`, 'info');
540
+ }
541
+ }
534
542
  }
535
543
  }
536
544
  catch (err) {
@@ -11,7 +11,7 @@ export declare const GUIDELINE_FILENAMES: readonly ["AGENTS.md", "CLAUDE.md"];
11
11
  * in one real run (see enforce-debug.log analysis). This is the load-bearing
12
12
  * removal: dropping `write` is what stops the runaway file creation.
13
13
  * - No `grep`/`find`/`ls` → the pass cannot enumerate or roam the tree; it is
14
- * handed the explicit, closed list of changed files (see captureDiff) and is
14
+ * handed the explicit, closed list of changed files (see captureCommitDiff) and is
15
15
  * told to edit only those.
16
16
  * - `read` IS allowed: the model genuinely needs it — to re-read a file after
17
17
  * editing to confirm the fix landed, and to read a neighbouring file for the
@@ -88,28 +88,30 @@ export interface EnforceChildResult {
88
88
  */
89
89
  export declare function classifyEnforceChildFailure(r: EnforceChildResult): string | null;
90
90
  /**
91
- * Capture the work to verify as text: the tracked diff against HEAD followed by
92
- * the COMPLETE list of changed files tracked-modified new (untracked) — that
91
+ * Capture the LAST COMMIT's work to verify as text: the unified diff of HEAD
92
+ * against its parent followed by the list of files that commit changed the set
93
93
  * the child should read and is allowed to edit.
94
94
  *
95
+ * This runs AFTER the task's auto-commit, so the change set is whatever HEAD
96
+ * introduced (`HEAD~1..HEAD`). New files created by the task appear in that diff
97
+ * as additions, so — unlike the pre-commit working-tree capture this replaced —
98
+ * there is no separate untracked/new-files section to chase. On a root commit
99
+ * (no `HEAD~1`) the diff is taken against the empty tree so the first commit is
100
+ * still fully verifiable.
101
+ *
95
102
  * The child has a `read` tool, so file contents are NOT inlined here: it reads
96
103
  * each listed file itself (and re-reads after editing to confirm). The list is the
97
104
  * scoping signal — it tells the model the closed set of files to touch so it edits
98
- * only the change set rather than roaming. Validated against the local model:
99
- * inlining full bodies bought nothing (it read the files by name regardless).
100
- *
101
- * Non-destructive — it does not touch the index, so the later `git add -A` in
102
- * gitCommitAll still stages everything (including fixes the enforcement child
103
- * makes).
105
+ * only the change set rather than roaming.
104
106
  *
105
107
  * The `.pi-tasks/` directory is excluded from every git command. Those task files
106
- * are committable (tracked) by design, so they show up in `git diff HEAD` and
107
- * `git ls-files --others` — git does not honor the fd/ripgrep `.ignore` that keeps
108
- * them out of the worker's find/grep discovery. Without this pathspec the enforce
109
- * child is handed its own TASK_*.md / TASK_AUTO_*.md bookkeeping as "changes to
110
- * verify" and edits them, corrupting the front matter mid-run.
108
+ * are committable (tracked) by design, so they ride into the commit's diff git
109
+ * does not honor the fd/ripgrep `.ignore` that keeps them out of the worker's
110
+ * find/grep discovery. Without this pathspec the enforce child is handed its own
111
+ * TASK_*.md / TASK_AUTO_*.md bookkeeping as "changes to verify" and edits them,
112
+ * corrupting the front matter mid-run.
111
113
  */
112
- export declare function captureDiff(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string>;
114
+ export declare function captureCommitDiff(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string>;
113
115
  export interface EnforcementDeps {
114
116
  cwd: string;
115
117
  signal?: AbortSignal;
@@ -2,19 +2,21 @@
2
2
  * Guideline enforcement for /task-auto.
3
3
  *
4
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
5
+ * when those files sit right next to the code. A /task-auto sub-task's work is
6
+ * auto-committed AS SOON AS the implementation turn passes; THEN this runs a
7
7
  * fresh child pi (the same local model that did the work) that is handed the
8
- * project's guideline files, the task's diff, and the list of changed files. With
9
- * read + edit tools (and nothing else) it reads those files, fixes any violations
10
- * in place, and reports a CLEAN / VIOLATION verdict. It cannot create files or run
8
+ * project's guideline files and the diff of THAT LAST COMMIT. With read + edit
9
+ * tools (and nothing else) it reads the committed files, fixes any violations in
10
+ * place, and reports a CLEAN / VIOLATION verdict. It cannot create files or run
11
11
  * anything: no `write` (so it can't scaffold junk) and no `grep`/`find`/`ls` (so it
12
12
  * can't roam the tree) — see ENFORCE_TOOLS.
13
13
  *
14
- * A VIOLATION (or an enforcement pass that cannot confirm CLEAN) blocks the
15
- * commit and fails the task, so non-compliant work is never snapshotted — the
16
- * run pauses for /task-auto-resume. This is deliberately strict: the whole
17
- * point is that "the model probably followed the rules" is not good enough.
14
+ * The fixes it makes are committed SEPARATELY by the orchestrator under an
15
+ * "ENFORCE GUIDELINES" commit, so guideline corrections are an independent,
16
+ * auditable diff that sits on top of the task commit. A VIOLATION it could not
17
+ * fix (or a pass that cannot confirm CLEAN) is surfaced as a warning the task
18
+ * commit already landed, so the run continues rather than pausing. The outcome's
19
+ * `ok` flag therefore drives a warn-or-not decision, not a hard gate.
18
20
  *
19
21
  * Gated by the `enforceGuidelines` config flag. With the flag off, or with no
20
22
  * guideline files in the working directory, enforcement is a pass (ok: true).
@@ -36,7 +38,7 @@ export const GUIDELINE_FILENAMES = ['AGENTS.md', 'CLAUDE.md'];
36
38
  * in one real run (see enforce-debug.log analysis). This is the load-bearing
37
39
  * removal: dropping `write` is what stops the runaway file creation.
38
40
  * - No `grep`/`find`/`ls` → the pass cannot enumerate or roam the tree; it is
39
- * handed the explicit, closed list of changed files (see captureDiff) and is
41
+ * handed the explicit, closed list of changed files (see captureCommitDiff) and is
40
42
  * told to edit only those.
41
43
  * - `read` IS allowed: the model genuinely needs it — to re-read a file after
42
44
  * editing to confirm the fix landed, and to read a neighbouring file for the
@@ -78,20 +80,21 @@ export async function discoverGuidelines(cwd, readFile = p => fsp.readFile(p, 'u
78
80
  */
79
81
  export function buildEnforcePrompt(rulesText, diff) {
80
82
  return [
81
- 'You are a strict guideline-enforcement pass running after an AI coding agent',
82
- 'finished a task but before its work is committed. The agent is known to skip',
83
- 'project rules, so do not trust that it followed them — verify against the changes.',
83
+ 'You are a strict guideline-enforcement pass running right after an AI coding',
84
+ 'agent finished a task and committed it. The agent is known to skip project',
85
+ 'rules, so do not trust that it followed them — verify against the changes.',
84
86
  '',
85
87
  'You have a `read` tool and an `edit` tool — nothing else. You CANNOT run',
86
88
  'commands, run lint/tsc/tests, or create files. Read the changed files listed',
87
89
  'below to inspect them; you may also read a neighbouring file when you need the',
88
90
  'correct import or type. But EDIT ONLY the changed files listed below — do not',
89
- 'create new files and do not modify anything outside that set.',
91
+ 'create new files and do not modify anything outside that set. Your fixes will',
92
+ 'be committed separately, so make them count.',
90
93
  '',
91
94
  'PROJECT GUIDELINES (from this repository):',
92
95
  rulesText,
93
96
  '',
94
- 'CHANGES JUST MADE (verify these specifically against the rules):',
97
+ 'CHANGES IN THE LAST COMMIT (verify these specifically against the rules):',
95
98
  diff.trim().length > 0 ? diff : '(no textual diff captured — nothing to verify)',
96
99
  '',
97
100
  'Your job:',
@@ -156,66 +159,60 @@ export function classifyEnforceChildFailure(r) {
156
159
  return null;
157
160
  }
158
161
  /**
159
- * Capture the work to verify as text: the tracked diff against HEAD followed by
160
- * the COMPLETE list of changed files tracked-modified new (untracked) that
162
+ * The git "empty tree" object the canonical base for diffing a ROOT commit
163
+ * (one with no parent). Diffing `HEAD` against it renders the whole first commit
164
+ * as additions, so a root commit still produces a verifiable diff.
165
+ */
166
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
167
+ /**
168
+ * Capture the LAST COMMIT's work to verify as text: the unified diff of HEAD
169
+ * against its parent followed by the list of files that commit changed — the set
161
170
  * the child should read and is allowed to edit.
162
171
  *
172
+ * This runs AFTER the task's auto-commit, so the change set is whatever HEAD
173
+ * introduced (`HEAD~1..HEAD`). New files created by the task appear in that diff
174
+ * as additions, so — unlike the pre-commit working-tree capture this replaced —
175
+ * there is no separate untracked/new-files section to chase. On a root commit
176
+ * (no `HEAD~1`) the diff is taken against the empty tree so the first commit is
177
+ * still fully verifiable.
178
+ *
163
179
  * The child has a `read` tool, so file contents are NOT inlined here: it reads
164
180
  * each listed file itself (and re-reads after editing to confirm). The list is the
165
181
  * scoping signal — it tells the model the closed set of files to touch so it edits
166
- * only the change set rather than roaming. Validated against the local model:
167
- * inlining full bodies bought nothing (it read the files by name regardless).
168
- *
169
- * Non-destructive — it does not touch the index, so the later `git add -A` in
170
- * gitCommitAll still stages everything (including fixes the enforcement child
171
- * makes).
182
+ * only the change set rather than roaming.
172
183
  *
173
184
  * The `.pi-tasks/` directory is excluded from every git command. Those task files
174
- * are committable (tracked) by design, so they show up in `git diff HEAD` and
175
- * `git ls-files --others` — git does not honor the fd/ripgrep `.ignore` that keeps
176
- * them out of the worker's find/grep discovery. Without this pathspec the enforce
177
- * child is handed its own TASK_*.md / TASK_AUTO_*.md bookkeeping as "changes to
178
- * verify" and edits them, corrupting the front matter mid-run.
185
+ * are committable (tracked) by design, so they ride into the commit's diff git
186
+ * does not honor the fd/ripgrep `.ignore` that keeps them out of the worker's
187
+ * find/grep discovery. Without this pathspec the enforce child is handed its own
188
+ * TASK_*.md / TASK_AUTO_*.md bookkeeping as "changes to verify" and edits them,
189
+ * corrupting the front matter mid-run.
179
190
  */
180
- export async function captureDiff(cwd, signal, spawnFn) {
191
+ export async function captureCommitDiff(cwd, signal, spawnFn) {
181
192
  // `:(exclude)<dir>` is a git pathspec that drops everything under the tasks
182
193
  // directory from the result, leaving only real source changes to verify.
183
194
  const excludeTasks = `:(exclude)${TASKS_DIR_NAME}`;
184
195
  const run = (args) => runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
185
- const tracked = await run(['diff', 'HEAD', '--', '.', excludeTasks]);
186
- const trackedNames = await run(['diff', 'HEAD', '--name-only', '--', '.', excludeTasks]);
187
- const untracked = await run([
188
- 'ls-files',
189
- '--others',
190
- '--exclude-standard',
191
- '--',
192
- '.',
193
- excludeTasks
194
- ]);
196
+ // Diff the last commit against its parent. On a root commit there is no
197
+ // HEAD~1 (rev-parse exits non-zero), so fall back to the empty tree.
198
+ const parent = await run(['rev-parse', '--verify', '--quiet', 'HEAD~1']);
199
+ const base = parent.exitCode === 0 ? 'HEAD~1' : EMPTY_TREE;
200
+ const diff = await run(['diff', base, 'HEAD', '--', '.', excludeTasks]);
201
+ const names = await run(['diff', base, 'HEAD', '--name-only', '--', '.', excludeTasks]);
195
202
  const parts = [];
196
- if (tracked.exitCode === 0 && tracked.stdout.trim().length > 0)
197
- parts.push(`UNIFIED DIFF (modified files):\n${tracked.stdout.trim()}`);
198
- const lines = (out) => out.exitCode === 0 ?
199
- out.stdout
203
+ if (diff.exitCode === 0 && diff.stdout.trim().length > 0)
204
+ parts.push(`UNIFIED DIFF (last commit):\n${diff.stdout.trim()}`);
205
+ const changed = names.exitCode === 0 ?
206
+ names.stdout
200
207
  .split('\n')
201
208
  .map(s => s.trim())
202
209
  .filter(Boolean)
210
+ .sort()
203
211
  : [];
204
- const modified = lines(trackedNames).sort();
205
- // Untracked-NEW files do NOT appear in the diff above, so they are listed in
206
- // their own emphatic section: in a flat list the model overlooks them and then
207
- // emits a false CLEAN while a new file still violates the rules (observed on the
208
- // local model). A diff-only file (already in `modified`) is not repeated here.
209
- const newFiles = lines(untracked)
210
- .filter(n => !modified.includes(n))
211
- .sort();
212
- if (modified.length > 0)
213
- parts.push('MODIFIED FILES — read each in full and check it against the rules:\n'
214
- + modified.map(n => `- ${n}`).join('\n'));
215
- if (newFiles.length > 0)
216
- parts.push('NEW FILES — created by this task and NOT shown in the diff above. You MUST'
217
- + ' read and check each one against EVERY rule too:\n'
218
- + newFiles.map(n => `- ${n}`).join('\n'));
212
+ if (changed.length > 0)
213
+ parts.push('FILES CHANGED IN THE LAST COMMIT read each in full and check it against'
214
+ + ' EVERY rule:\n'
215
+ + changed.map(n => `- ${n}`).join('\n'));
219
216
  return parts.join('\n\n');
220
217
  }
221
218
  /**
@@ -226,7 +223,7 @@ export async function captureDiff(cwd, signal, spawnFn) {
226
223
  */
227
224
  export async function runGuidelineEnforcement(deps) {
228
225
  const discover = deps.discover ?? (cwd => discoverGuidelines(cwd));
229
- const getDiff = deps.getDiff ?? ((cwd, signal) => captureDiff(cwd, signal));
226
+ const getDiff = deps.getDiff ?? ((cwd, signal) => captureCommitDiff(cwd, signal));
230
227
  const doc = await discover(deps.cwd);
231
228
  if (!doc)
232
229
  return { ok: true, reason: 'no guideline files' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.14.4",
3
+ "version": "0.14.5",
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",