@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 +1 -1
- package/dist/task/auto-orchestrator.js +28 -20
- package/dist/task/enforce-guidelines.d.ts +17 -15
- package/dist/task/enforce-guidelines.js +56 -59
- package/package.json +1 -1
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)_ |
|
|
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
|
|
525
|
-
//
|
|
506
|
+
// snapshot FIRST — before guideline enforcement — so 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
|
|
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
|
|
92
|
-
* the
|
|
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.
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
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.
|
|
6
|
-
*
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
|
82
|
-
'finished a task
|
|
83
|
-
'
|
|
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
|
|
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
|
-
*
|
|
160
|
-
*
|
|
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.
|
|
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
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 (
|
|
197
|
-
parts.push(`UNIFIED DIFF (
|
|
198
|
-
const
|
|
199
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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) =>
|
|
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.
|
|
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",
|