@mjasnikovs/pi-task 0.14.2 → 0.14.4

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.
@@ -313,13 +313,33 @@ export function clientScript(wsUrl) {
313
313
  sendBtn.disabled = !allow;
314
314
  }
315
315
 
316
+ // Pull the human-readable text out of a tool result. Many tools (Read, Bash,
317
+ // MCP tools) return Anthropic content-block shapes — { content: [{type:'text',
318
+ // text:'...'}] } or a bare array of such blocks — which JSON.stringify would
319
+ // render as escaped, unreadable JSON. Extract the text blocks and join them;
320
+ // return null when there's nothing text-shaped so the caller falls back to JSON.
321
+ function contentBlocksText(result) {
322
+ const blocks = Array.isArray(result) ? result
323
+ : (result && Array.isArray(result.content)) ? result.content
324
+ : null;
325
+ if (!blocks) return null;
326
+ const parts = [];
327
+ for (const b of blocks) {
328
+ if (typeof b === 'string') parts.push(b);
329
+ else if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
330
+ }
331
+ return parts.length ? parts.join('\\n') : null;
332
+ }
333
+
316
334
  // Stringify a tool result safely. A null/undefined result (e.g. a tool that
317
335
  // hasn't produced output) must NOT become the JS value undefined, whose
318
336
  // .slice() throws — a throw here aborts the whole snapshot rebuild after the
319
337
  // log was already cleared, blanking the transcript on reconnect.
320
338
  function toolResultText(result) {
321
339
  if (result == null) return '';
322
- const r = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
340
+ if (typeof result === 'string') return result.slice(0, 8000);
341
+ const text = contentBlocksText(result);
342
+ const r = text != null ? text : JSON.stringify(result, null, 2);
323
343
  return (r == null ? '' : r).slice(0, 8000);
324
344
  }
325
345
 
@@ -315,9 +315,9 @@ function defaultDeps(ctx, cwd, signal, title) {
315
315
  signal,
316
316
  // Run the worker child UNGUARDED: no loop detector, no wall-clock
317
317
  // timeout. This pass's job is to rework files in place until every
318
- // violation is fixed, and it legitimately reads/edits/re-greps the
319
- // same file many times — the research-worker guards mislabel that
320
- // as a runaway and kill good work (proven on mx5 TASK_0002). Let it
318
+ // violation is fixed, and it legitimately reads and edits the same
319
+ // file many times — the research-worker guards mislabel that as a
320
+ // runaway and kill good work (proven on mx5 TASK_0002). Let it
321
321
  // run until the model finishes; classifyEnforceChildFailure still
322
322
  // blocks the commit on a real failure (non-zero exit, leaked tool
323
323
  // call) or a user cancel.
@@ -366,7 +366,7 @@ function defaultDeps(ctx, cwd, signal, title) {
366
366
  // disables the path-revisit heuristic, so revisiting one
367
367
  // file (which IS this pass's job) never trips — only a
368
368
  // literally-identical call repeated past threshold does
369
- // (e.g. the same grep fired 900× in enforce-debug.log).
369
+ // (e.g. the same read or edit repeated hundreds of times).
370
370
  // A hit nudges via the normal restart-with-hint; a loop
371
371
  // that survives the nudges is WARNED, not blocked (see
372
372
  // r.loopHit handling below and classifyEnforceChildFailure).
@@ -1,8 +1,26 @@
1
1
  import { type SpawnFn } from '../shared/child-process.js';
2
2
  /** Filenames discovered in the working directory (cwd only — no tree walk). */
3
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";
4
+ /**
5
+ * The fix pass gets exactly two tools: `read` and `edit`. Deliberately no
6
+ * `write`, `grep`, `find`, or `ls`.
7
+ *
8
+ * - No `write` (and `edit` ENOENTs on a missing path) → the pass cannot create
9
+ * new files. Without this the local model, unable to run lint/tsc/tests, wrote
10
+ * hundreds of scratch "runner" scripts hoping to execute them — 864 junk files
11
+ * in one real run (see enforce-debug.log analysis). This is the load-bearing
12
+ * removal: dropping `write` is what stops the runaway file creation.
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
15
+ * told to edit only those.
16
+ * - `read` IS allowed: the model genuinely needs it — to re-read a file after
17
+ * editing to confirm the fix landed, and to read a neighbouring file for the
18
+ * correct import/type when bringing the change into compliance (without it the
19
+ * model fabricates imports — validated against the local model). `read` has no
20
+ * per-path sandbox in pi, so "only the changed files" is enforced for EDITS
21
+ * (the prompt scopes them) but is a soft instruction for READS.
22
+ */
23
+ declare const ENFORCE_TOOLS = "read,edit";
6
24
  export interface GuidelineDoc {
7
25
  /** Filenames found, in discovery order (e.g. ['AGENTS.md', 'CLAUDE.md']). */
8
26
  files: string[];
@@ -70,17 +88,26 @@ export interface EnforceChildResult {
70
88
  */
71
89
  export declare function classifyEnforceChildFailure(r: EnforceChildResult): string | null;
72
90
  /**
73
- * Capture the work to verify as text: the tracked diff against HEAD plus the
74
- * names of any new (untracked) files. Non-destructive it does not touch the
75
- * index, so the later `git add -A` in gitCommitAll still stages everything
76
- * (including fixes the enforcement child makes).
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
93
+ * the child should read and is allowed to edit.
94
+ *
95
+ * The child has a `read` tool, so file contents are NOT inlined here: it reads
96
+ * each listed file itself (and re-reads after editing to confirm). The list is the
97
+ * 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).
77
104
  *
78
- * The `.pi-tasks/` directory is excluded from both git commands. Those task
79
- * files are committable (tracked) by design, so they show up in `git diff HEAD`
80
- * and `git ls-files --others` — git does not honor the fd/ripgrep `.ignore` that
81
- * keeps them out of the worker's find/grep discovery. Without this pathspec the
82
- * enforce child is handed its own TASK_*.md / TASK_AUTO_*.md bookkeeping as
83
- * "changes to verify" and edits them, corrupting the front matter mid-run.
105
+ * 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.
84
111
  */
85
112
  export declare function captureDiff(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<string>;
86
113
  export interface EnforcementDeps {
@@ -5,8 +5,11 @@
5
5
  * when those files sit right next to the code. After a /task-auto sub-task's
6
6
  * implementation turn settles — but BEFORE its per-task commit — 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 plus the task's diff, fixes any violations in place
9
- * with its edit tools, and reports a CLEAN / VIOLATION verdict.
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
11
+ * anything: no `write` (so it can't scaffold junk) and no `grep`/`find`/`ls` (so it
12
+ * can't roam the tree) — see ENFORCE_TOOLS.
10
13
  *
11
14
  * A VIOLATION (or an enforcement pass that cannot confirm CLEAN) blocks the
12
15
  * commit and fails the task, so non-compliant work is never snapshotted — the
@@ -23,8 +26,26 @@ import { USER_CANCELLED } from './child-runner.js';
23
26
  import { TASKS_DIR_NAME } from './task-types.js';
24
27
  /** Filenames discovered in the working directory (cwd only — no tree walk). */
25
28
  export const GUIDELINE_FILENAMES = ['AGENTS.md', 'CLAUDE.md'];
26
- /** Tools the fix pass is allowed: read/search to inspect, edit/write to fix. */
27
- const ENFORCE_TOOLS = 'read,grep,find,ls,edit,write';
29
+ /**
30
+ * The fix pass gets exactly two tools: `read` and `edit`. Deliberately no
31
+ * `write`, `grep`, `find`, or `ls`.
32
+ *
33
+ * - No `write` (and `edit` ENOENTs on a missing path) → the pass cannot create
34
+ * new files. Without this the local model, unable to run lint/tsc/tests, wrote
35
+ * hundreds of scratch "runner" scripts hoping to execute them — 864 junk files
36
+ * in one real run (see enforce-debug.log analysis). This is the load-bearing
37
+ * removal: dropping `write` is what stops the runaway file creation.
38
+ * - 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
40
+ * told to edit only those.
41
+ * - `read` IS allowed: the model genuinely needs it — to re-read a file after
42
+ * editing to confirm the fix landed, and to read a neighbouring file for the
43
+ * correct import/type when bringing the change into compliance (without it the
44
+ * model fabricates imports — validated against the local model). `read` has no
45
+ * per-path sandbox in pi, so "only the changed files" is enforced for EDITS
46
+ * (the prompt scopes them) but is a soft instruction for READS.
47
+ */
48
+ const ENFORCE_TOOLS = 'read,edit';
28
49
  /**
29
50
  * Read the guideline files that live directly in `cwd` (AGENTS.md, CLAUDE.md).
30
51
  * Returns null when none exist or all are empty — the caller treats that as a
@@ -59,18 +80,25 @@ export function buildEnforcePrompt(rulesText, diff) {
59
80
  return [
60
81
  'You are a strict guideline-enforcement pass running after an AI coding agent',
61
82
  'finished a task but before its work is committed. The agent is known to skip',
62
- 'project rules, so do not trust that it followed them — verify against the diff.',
83
+ 'project rules, so do not trust that it followed them — verify against the changes.',
84
+ '',
85
+ 'You have a `read` tool and an `edit` tool — nothing else. You CANNOT run',
86
+ 'commands, run lint/tsc/tests, or create files. Read the changed files listed',
87
+ 'below to inspect them; you may also read a neighbouring file when you need the',
88
+ '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.',
63
90
  '',
64
91
  'PROJECT GUIDELINES (from this repository):',
65
92
  rulesText,
66
93
  '',
67
- 'CHANGES JUST MADE (verify these specifically; read any file you need in full):',
68
- diff.trim().length > 0 ? diff : '(no textual diff captured — inspect the working tree)',
94
+ 'CHANGES JUST MADE (verify these specifically against the rules):',
95
+ diff.trim().length > 0 ? diff : '(no textual diff captured — nothing to verify)',
69
96
  '',
70
97
  'Your job:',
71
- '1. Check the changed files against EVERY rule above.',
72
- '2. For each violation you find, FIX it directly using your edit/write tools.',
73
- ' Make the minimal change that satisfies the rule; do not rewrite unrelated code.',
98
+ '1. Read each changed file and check it against EVERY rule above.',
99
+ '2. For each violation you find, FIX it directly with your `edit` tool, then',
100
+ ' re-read the file to confirm the fix landed. Make the minimal change that',
101
+ ' satisfies the rule; do not rewrite unrelated code.',
74
102
  '3. Do not introduce new behavior or features — only bring the work into compliance.',
75
103
  '',
76
104
  'When you are done, output EXACTLY ONE of these as the final line:',
@@ -128,17 +156,26 @@ export function classifyEnforceChildFailure(r) {
128
156
  return null;
129
157
  }
130
158
  /**
131
- * Capture the work to verify as text: the tracked diff against HEAD plus the
132
- * names of any new (untracked) files. Non-destructive it does not touch the
133
- * index, so the later `git add -A` in gitCommitAll still stages everything
134
- * (including fixes the enforcement child makes).
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
161
+ * the child should read and is allowed to edit.
162
+ *
163
+ * The child has a `read` tool, so file contents are NOT inlined here: it reads
164
+ * each listed file itself (and re-reads after editing to confirm). The list is the
165
+ * 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).
135
172
  *
136
- * The `.pi-tasks/` directory is excluded from both git commands. Those task
137
- * files are committable (tracked) by design, so they show up in `git diff HEAD`
138
- * and `git ls-files --others` — git does not honor the fd/ripgrep `.ignore` that
139
- * keeps them out of the worker's find/grep discovery. Without this pathspec the
140
- * enforce child is handed its own TASK_*.md / TASK_AUTO_*.md bookkeeping as
141
- * "changes to verify" and edits them, corrupting the front matter mid-run.
173
+ * 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.
142
179
  */
143
180
  export async function captureDiff(cwd, signal, spawnFn) {
144
181
  // `:(exclude)<dir>` is a git pathspec that drops everything under the tasks
@@ -146,6 +183,7 @@ export async function captureDiff(cwd, signal, spawnFn) {
146
183
  const excludeTasks = `:(exclude)${TASKS_DIR_NAME}`;
147
184
  const run = (args) => runChildDefault({ command: 'git', args }, cwd, signal, { mode: 'text' }, spawnFn);
148
185
  const tracked = await run(['diff', 'HEAD', '--', '.', excludeTasks]);
186
+ const trackedNames = await run(['diff', 'HEAD', '--name-only', '--', '.', excludeTasks]);
149
187
  const untracked = await run([
150
188
  'ls-files',
151
189
  '--others',
@@ -156,10 +194,28 @@ export async function captureDiff(cwd, signal, spawnFn) {
156
194
  ]);
157
195
  const parts = [];
158
196
  if (tracked.exitCode === 0 && tracked.stdout.trim().length > 0)
159
- parts.push(tracked.stdout.trim());
160
- const newFiles = untracked.exitCode === 0 ? untracked.stdout.trim() : '';
197
+ parts.push(`UNIFIED DIFF (modified files):\n${tracked.stdout.trim()}`);
198
+ const lines = (out) => out.exitCode === 0 ?
199
+ out.stdout
200
+ .split('\n')
201
+ .map(s => s.trim())
202
+ .filter(Boolean)
203
+ : [];
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'));
161
215
  if (newFiles.length > 0)
162
- parts.push(`New (untracked) files read them in full:\n${newFiles}`);
216
+ parts.push('NEW FILEScreated 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'));
163
219
  return parts.join('\n\n');
164
220
  }
165
221
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.14.2",
3
+ "version": "0.14.4",
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",