@mjasnikovs/pi-task 0.14.4 → 0.14.6

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
 
@@ -13,6 +13,8 @@ export interface AutoDeps {
13
13
  resumeId?: string;
14
14
  /** Called with the inner task id once its file exists, before phases. */
15
15
  onStart?: (taskId: string) => void | Promise<void>;
16
+ /** Scope fence naming the sibling steps, forwarded into refine. */
17
+ planContext?: string;
16
18
  }) => Promise<RunSingleTaskResult>;
17
19
  /** Snapshot the working tree into one commit after a task passes. */
18
20
  commit: (cwd: string, message: string) => Promise<CommitResult>;
@@ -62,6 +64,21 @@ export declare function readableMentions(cwd: string, feature: string): Promise<
62
64
  * exactly as before.
63
65
  */
64
66
  export declare function attachSpecRefs(titles: string[], refs: string[]): string[];
67
+ /**
68
+ * Build the refine scope fence for step `currentIndex` of an N-step /task-auto
69
+ * plan. Every per-step pipeline only ever sees its own title, so without this the
70
+ * refine phase — told "the task title is only a pointer into that spec; follow the
71
+ * spec" — re-expands the whole referenced design into one task (a real run
72
+ * implemented all 24 steps under step 1). The fence lists the sibling steps by
73
+ * number and forbids touching anything they own, so refine bounds this step's
74
+ * slice. Validated on the local model: with the fence, refine's CONSTRAINTS gained
75
+ * an explicit per-step deferral list and tool calls dropped 27→11.
76
+ *
77
+ * The plan listing strips the threaded "| decisions … | spec …" tail from each
78
+ * title (keeps the human-readable head) so the model reads clean step names. The
79
+ * authoritative spec ref still rides on THIS step's own title via attachSpecRefs.
80
+ */
81
+ export declare function buildScopeFence(titles: string[], currentIndex: number): string;
65
82
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
66
83
  export declare function planAuto(ctx: ExtensionCommandContext, cwd: string, feature: string, deps: AutoDeps): Promise<string | null>;
67
84
  export declare function requestAutoCancel(): void;
@@ -118,6 +118,40 @@ export function attachSpecRefs(titles, refs) {
118
118
  return out;
119
119
  });
120
120
  }
121
+ /**
122
+ * Build the refine scope fence for step `currentIndex` of an N-step /task-auto
123
+ * plan. Every per-step pipeline only ever sees its own title, so without this the
124
+ * refine phase — told "the task title is only a pointer into that spec; follow the
125
+ * spec" — re-expands the whole referenced design into one task (a real run
126
+ * implemented all 24 steps under step 1). The fence lists the sibling steps by
127
+ * number and forbids touching anything they own, so refine bounds this step's
128
+ * slice. Validated on the local model: with the fence, refine's CONSTRAINTS gained
129
+ * an explicit per-step deferral list and tool calls dropped 27→11.
130
+ *
131
+ * The plan listing strips the threaded "| decisions … | spec …" tail from each
132
+ * title (keeps the human-readable head) so the model reads clean step names. The
133
+ * authoritative spec ref still rides on THIS step's own title via attachSpecRefs.
134
+ */
135
+ export function buildScopeFence(titles, currentIndex) {
136
+ const n = titles.length;
137
+ const listing = titles
138
+ .map((t, i) => {
139
+ const head = t.split(' | ')[0].trim();
140
+ const tag = i === currentIndex ? ' (THIS STEP)' : '';
141
+ return `[${i + 1}]${tag} ${head}`;
142
+ })
143
+ .join('\n');
144
+ return (`PLAN CONTEXT — this task is STEP ${currentIndex + 1} of ${n} in an already-decomposed plan. `
145
+ + `Each step below is implemented by its OWN separate run; the others are NOT your job and `
146
+ + `are done in later runs. Implement ONLY the slice named in "Task" below.\n\n`
147
+ + `The design/spec document the task references describes the WHOLE system across all ${n} `
148
+ + `steps. Read it to get exact names, types, and signatures for YOUR step and to understand `
149
+ + `how your step fits — but DO NOT design, scaffold, schema, route, page, query, component, or `
150
+ + `test anything that belongs to another step listed below. Your GOAL / CONSTRAINTS / `
151
+ + `KNOWN-UNKNOWNS must cover only THIS step's slice. Do not pull in tables, endpoints, pages, `
152
+ + `components, or flows owned by a later step.\n\n`
153
+ + `The full plan (these run separately — do NOT implement them here):\n${listing}`);
154
+ }
121
155
  /** Plan phase: clarify → decompose → write AUTO file. Returns the new id, or null. */
122
156
  export async function planAuto(ctx, cwd, feature, deps) {
123
157
  // clarify — sequential & adaptive: ask one question at a time, feeding every
@@ -301,7 +335,8 @@ function defaultDeps(ctx, cwd, signal, title) {
301
335
  runTask: (c, cwd2, t, opts) => runSingleTask(c, cwd2, t, {
302
336
  waitForImplementation: true,
303
337
  resumeId: opts?.resumeId,
304
- onStart: opts?.onStart
338
+ onStart: opts?.onStart,
339
+ planContext: opts?.planContext
305
340
  }),
306
341
  commit: (cwd2, message) => getConfig().autoCommit ?
307
342
  gitCommitAll(cwd2, message, signal)
@@ -472,6 +507,12 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
472
507
  }
473
508
  const res = await deps.runTask(active, cwd, next.title, {
474
509
  resumeId,
510
+ // Fence this step against re-expanding the whole referenced spec:
511
+ // name the sibling steps so refine bounds this step's slice. Only
512
+ // matters when refine runs fresh (a resumed task past refine ignores
513
+ // it), but always supplied so a resume that restarts at refine is
514
+ // fenced too.
515
+ planContext: buildScopeFence(entries.map(e => e.title), next.index),
475
516
  onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
476
517
  });
477
518
  active = res.ctx ?? active;
@@ -498,31 +539,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
498
539
  announceDone(active, `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`, 'error');
499
540
  return;
500
541
  }
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
542
  // res.ok === true means runner.run() completed, so res.taskId is the
520
543
  // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
521
544
  // empty id by writing a plain checked line, but that path is unreachable.
522
545
  await checkOffTask(cwd, id, next.index, res.taskId, next.title);
523
546
  // 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.
547
+ // snapshot FIRST before guideline enforcementso a passing task is
548
+ // durably recorded no matter what enforcement later finds. Best-effort:
549
+ // a failed/empty commit only warns; the task already passed.
526
550
  const message = `task: ${next.title} (${res.taskId})`;
527
551
  const commit = await deps.commit(cwd, message);
528
552
  if (commit.committed) {
@@ -531,6 +555,31 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
531
555
  else {
532
556
  active.ui.notify(`${id}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, 'warning');
533
557
  }
558
+ // With the task committed, hold its work to the project's AGENTS.md /
559
+ // CLAUDE.md rules. Local models drift and skip those guidelines, so the
560
+ // enforcement pass re-reads THE LAST COMMIT's diff with the same local
561
+ // model (edit tools enabled) and fixes violations in place; those fixes
562
+ // are then committed SEPARATELY under an "ENFORCE GUIDELINES" commit so
563
+ // they form an independent, auditable diff on top of the task commit.
564
+ // A non-clean verdict only warns — the task commit already landed, so
565
+ // the run continues. Skipped when nothing was committed this round
566
+ // (autoCommit off or an empty commit): there is no "last commit" of this
567
+ // task's work to verify. (Also a no-op when the feature is off, when
568
+ // there are no guideline files, or in tests with no enforce dep.)
569
+ if (deps.enforce && commit.committed) {
570
+ active.ui.notify(`${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`, 'info');
571
+ const verdict = await deps.enforce(active, cwd, next.title);
572
+ if (!verdict.ok) {
573
+ active.ui.notify(`${id}: guideline enforcement on "${next.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
574
+ }
575
+ // Commit whatever the pass fixed as its own snapshot. A no-op when
576
+ // the pass made no edits (gitCommitAll reports nothing to commit),
577
+ // so a clean task adds no empty commit — only announce a real one.
578
+ const enforceCommit = await deps.commit(cwd, `ENFORCE GUIDELINES: ${next.title} (${res.taskId})`);
579
+ if (enforceCommit.committed) {
580
+ active.ui.notify(`${id}: committed guideline fixes for "${next.title}".`, 'info');
581
+ }
582
+ }
534
583
  }
535
584
  }
536
585
  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' };
@@ -26,6 +26,7 @@ export declare class TaskRunner {
26
26
  private readonly _resumeId;
27
27
  private readonly _sendSpec;
28
28
  private readonly _onStart;
29
+ private readonly _planContext;
29
30
  private readonly _abort;
30
31
  private readonly _startedAt;
31
32
  private readonly _widgetState;
@@ -41,7 +42,7 @@ export declare class TaskRunner {
41
42
  */
42
43
  private readonly _timings;
43
44
  private _currentPhaseChildren;
44
- constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn, onStart?: (taskId: string) => void | Promise<void>);
45
+ constructor(ctx: ExtensionCommandContext, cwd: string, rawPrompt: string, resumeId?: string, sendSpec?: (spec: string) => Promise<void>, spawnFn?: SpawnFn, onStart?: (taskId: string) => void | Promise<void>, planContext?: string);
45
46
  get taskId(): string;
46
47
  get signal(): AbortSignal;
47
48
  /** Return the current widget state, or null if not started. */
@@ -84,6 +85,13 @@ export interface RunSingleTaskOptions {
84
85
  * internal per-task runs, which must stay silent. Default false.
85
86
  */
86
87
  notifyFinish?: boolean;
88
+ /**
89
+ * Scope fence naming the sibling steps of a /task-auto plan. Forwarded into
90
+ * the refine phase so a single decomposed step bounds its slice instead of
91
+ * re-expanding the whole referenced spec doc. Set only by /task-auto's loop;
92
+ * a bare /task leaves it undefined and the refine prompt is unchanged.
93
+ */
94
+ planContext?: string;
87
95
  }
88
96
  export interface RunSingleTaskResult {
89
97
  taskId: string;
@@ -51,6 +51,7 @@ export class TaskRunner {
51
51
  _resumeId;
52
52
  _sendSpec;
53
53
  _onStart;
54
+ _planContext;
54
55
  _abort = new AbortController();
55
56
  _startedAt;
56
57
  _widgetState;
@@ -66,13 +67,14 @@ export class TaskRunner {
66
67
  */
67
68
  _timings = [];
68
69
  _currentPhaseChildren = null;
69
- constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart) {
70
+ constructor(ctx, cwd, rawPrompt, resumeId, sendSpec, spawnFn, onStart, planContext) {
70
71
  this._ctx = ctx;
71
72
  this._cwd = cwd;
72
73
  this._rawPrompt = rawPrompt;
73
74
  this._resumeId = resumeId;
74
75
  this._sendSpec = sendSpec;
75
76
  this._onStart = onStart;
77
+ this._planContext = planContext;
76
78
  this._startedAt = Date.now();
77
79
  // We'll populate id/title/phase lazily in run().
78
80
  // Placeholder — real values set in run().
@@ -109,7 +111,8 @@ export class TaskRunner {
109
111
  refined: '',
110
112
  research: '',
111
113
  qa: '',
112
- spec: ''
114
+ spec: '',
115
+ planContext: this._planContext
113
116
  };
114
117
  }
115
118
  get taskId() {
@@ -372,7 +375,7 @@ export async function runSingleTask(ctx, cwd, rawPrompt, opts = {}) {
372
375
  if (!interrupted)
373
376
  implError = implementationError(newCtx);
374
377
  }
375
- }, opts.spawnFn, opts.onStart);
378
+ }, opts.spawnFn, opts.onStart, opts.planContext);
376
379
  await runner.run();
377
380
  taskId = runner.taskId;
378
381
  }
@@ -23,6 +23,13 @@ export interface PhaseContext {
23
23
  research: string;
24
24
  qa: string;
25
25
  spec: string;
26
+ /**
27
+ * Scope fence for a task that is one step of a /task-auto plan: names the
28
+ * sibling steps so refine bounds this task's slice instead of re-expanding the
29
+ * whole spec doc. Undefined for a bare /task run (prompt unchanged). Threaded
30
+ * only into refine — its scoped output carries the boundary downstream.
31
+ */
32
+ planContext?: string;
26
33
  }
27
34
  export type OutputField = 'refined' | 'research' | 'qa' | 'spec';
28
35
  export interface PhaseConfig {
@@ -35,7 +42,7 @@ export interface PhaseConfig {
35
42
  export declare function extractToolingCommands(research: string): string[] | null;
36
43
  /** Replace the TOOLING section in a research string with a VERIFIED-TOOLING section. */
37
44
  export declare function replaceToolingWithVerified(research: string, verifiedCommands: string[]): string;
38
- export declare const phaseRefine: (deps: PhaseDeps, raw: string) => Promise<string>;
45
+ export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: string) => Promise<string>;
39
46
  export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
40
47
  export interface PhaseResearchDeps extends ExternalContextDeps {
41
48
  getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
@@ -61,7 +61,7 @@ export function replaceToolingWithVerified(research, verifiedCommands) {
61
61
  return replaced;
62
62
  }
63
63
  // ─── Phase functions ─────────────────────────────────────────────────────────
64
- export const phaseRefine = (deps, raw) => runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw))));
64
+ export const phaseRefine = (deps, raw, planContext) => runPhaseWithLoopGuard(deps, 'refine', 'read', hint => prependHint(hint, appendNoThink(REFINE_PROMPT(raw, planContext))));
65
65
  export async function phaseVerifyTooling(deps, research) {
66
66
  const commands = extractToolingCommands(research);
67
67
  if (!commands || commands.length === 0) {
@@ -650,7 +650,7 @@ export const PHASES = [
650
650
  name: 'refine',
651
651
  section: 'refined prompt',
652
652
  field: 'refined',
653
- run: (d, p) => phaseRefine(d, p.rawPrompt)
653
+ run: (d, p) => phaseRefine(d, p.rawPrompt, p.planContext)
654
654
  },
655
655
  {
656
656
  name: 'research',
@@ -35,7 +35,19 @@ export declare function appendNoThink(prompt: string): string;
35
35
  * right *content* (identifying nouns) rather than trusting the model on length.
36
36
  */
37
37
  export declare const COMPRESS_LABEL_PROMPT: (title: string, maxChars: number) => string;
38
- declare const REFINE_PROMPT: (raw: string) => string;
38
+ /**
39
+ * Optional scope fence for a task that is ONE step of a /task-auto plan. Prepended
40
+ * verbatim ahead of the refine body so the model reads the step boundary as
41
+ * authoritative context before it expands the (whole-system) spec doc. Empty for a
42
+ * bare /task run, which keeps that prompt byte-for-byte unchanged. The caller
43
+ * (auto-orchestrator) builds the listing; this prompt only positions it.
44
+ *
45
+ * Without it, refine is told "the task title is only a pointer into that spec —
46
+ * follow the spec" with no signal that the other steps exist, so a one-step
47
+ * "Scaffold …" title re-expands the entire design into one task (validated: a real
48
+ * /task-auto run implemented all 24 steps under step 1).
49
+ */
50
+ declare const REFINE_PROMPT: (raw: string, planContext?: string) => string;
39
51
  declare const RESEARCH_READ_ONLY_CONSTRAINT = "IMPORTANT: You are ONLY allowed to READ. Do NOT create, modify, or delete any files. Use the read, grep, find, and ls tools to inspect the repo.";
40
52
  declare const RESEARCH_FILES_PROMPT: (refined: string) => string;
41
53
  declare const RESEARCH_APIS_PROMPT: (refined: string) => string;
@@ -49,7 +49,19 @@ Rules:
49
49
 
50
50
  TITLE:
51
51
  ${title}`;
52
- const REFINE_PROMPT = (raw) => `You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
52
+ /**
53
+ * Optional scope fence for a task that is ONE step of a /task-auto plan. Prepended
54
+ * verbatim ahead of the refine body so the model reads the step boundary as
55
+ * authoritative context before it expands the (whole-system) spec doc. Empty for a
56
+ * bare /task run, which keeps that prompt byte-for-byte unchanged. The caller
57
+ * (auto-orchestrator) builds the listing; this prompt only positions it.
58
+ *
59
+ * Without it, refine is told "the task title is only a pointer into that spec —
60
+ * follow the spec" with no signal that the other steps exist, so a one-step
61
+ * "Scaffold …" title re-expands the entire design into one task (validated: a real
62
+ * /task-auto run implemented all 24 steps under step 1).
63
+ */
64
+ const REFINE_PROMPT = (raw, planContext) => `${planContext ? planContext + '\n\n---\n\n' : ''}You receive a user's task description for an AI coding agent. Rewrite it to be unambiguous and actionable.
53
65
 
54
66
  Output structure (four sections, exact headings, in this order):
55
67
 
@@ -116,6 +128,8 @@ NPM PACKAGES — use pi-worker-docs, NOT file reads: for any third-party npm pac
116
128
 
117
129
  PROJECT SOURCE — use pi-worker-docs with module ".", NOT file reads: for any function, class, type, or interface defined in THIS project's own .ts/.tsx source (e.g. "what does requireAuth check?", "what does CreateListingSchema look like?", "what does the listings query module export?"), call \`pi-worker-docs(".", query)\` instead of reading the file. The tool indexes all git-tracked source files and returns only the relevant chunks — far cheaper than reading whole files.
118
130
 
131
+ RUNTIME BUILTINS — verify, do NOT echo: a task (or the spec doc it references) may name a runtime/builtin import like \`bun:sql\`, \`bun:sqlite\`, \`node:fs\`, or \`Bun.password\`. A runtime exposes only a small FIXED set of \`<runtime>:<submodule>\` modules, and a spec doc can confidently name one that does not exist. Before you list ANY \`<pkg>:<sub>\` specifier, confirm it with \`pi-worker-docs\` (e.g. \`pi-worker-docs("bun:sql", "sql tagged template and SQL class — the import")\` — the tool resolves the runtime's real types) and emit the CANONICAL import the types actually prove, NOT the string copied from the task. Concretely: Bun's SQL client is \`import { sql } from "bun"\` (or \`Bun.sql\` / \`new SQL()\`) — there is NO \`bun:sql\` module. Never pass an unverified colon-specifier through to the APIS list; a phantom import laundered here becomes fabricated \`declare module\` shims in the implementation.
132
+
119
133
  APIS owns symbols and commands BY NAME ONLY. Do NOT include any file path or path fragment — no \`package.json\`, no \`./src/foo.ts\`, no \`package.json#scripts.lint\`. If the symbol is a script defined in package.json, write the invocation (\`npm run lint\`), not its location. If the symbol is a config file, it does not belong in APIS at all — it belongs in FILES.
120
134
 
121
135
  RELEVANCE — read carefully: list ONLY the symbols the agent will call, implement, modify, or directly depend on for THIS task. Do NOT enumerate the project's entire public surface or dump every exported function in a touched file. A symbol unrelated to the task does not belong here just because it sits in the same module. Keep the smallest sufficient set: include every symbol the task actually exercises and nothing more. There is no fixed limit — list as many as the task truly needs and no padding beyond that.
@@ -4,7 +4,7 @@ import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { openCache as defaultOpenCache } from './docs-cache.js';
6
6
  import { ensureIndexed as defaultEnsureIndexed } from './docs-index.js';
7
- import { resolvePackage as defaultResolvePackage, ResolveError, detectTypesRedirect, typesPackageName, hasTypeFiles, isDtsFile } from './docs-resolve.js';
7
+ import { resolvePackage as defaultResolvePackage, ResolveError, detectTypesRedirect, typesPackageName, hasTypeFiles, isDtsFile, splitRuntimeNamespace } from './docs-resolve.js';
8
8
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
9
9
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
10
10
  import { getPiInvocation } from '../shared/pi-invocation.js';
@@ -97,21 +97,27 @@ export async function docsRaw(input) {
97
97
  const openCache = input.openCache ?? defaultOpenCache;
98
98
  const spawn = input.spawn ?? defaultSpawn;
99
99
  const npmVersionLookup = input.npmVersionLookup ?? defaultNpmVersionLookup;
100
+ // A runtime builtin specifier (`bun:sql`, `node:fs`) is typed by the runtime's
101
+ // own types package, not a literal package of that colon-name — so resolve the
102
+ // runtime instead. This is what turns a `bun:sql` lookup into Bun's real SQL
103
+ // surface (`declare module "bun"` → `const sql: SQL`) rather than an
104
+ // `invalid_name` error, and it lets the docs tool disprove a phantom submodule.
105
+ const requested = splitRuntimeNamespace(input.pkg)?.runtime ?? input.pkg;
100
106
  // Fire the npm registry lookup in parallel with resolve/index/retrieve.
101
107
  // It returns null on any failure, so it never blocks the local pipeline.
102
- const npmVersionPromise = npmVersionLookup(extractParentPackage(input.pkg), {
108
+ const npmVersionPromise = npmVersionLookup(extractParentPackage(requested), {
103
109
  signal: input.signal
104
110
  }).catch(() => null);
105
111
  // Step 1: resolve package
106
112
  let pkg;
107
113
  let autoInstalled = false;
108
114
  try {
109
- pkg = resolvePackage(input.pkg, input.cwd);
115
+ pkg = resolvePackage(requested, input.cwd);
110
116
  }
111
117
  catch (firstErr) {
112
118
  if (firstErr instanceof ResolveError && firstErr.kind === 'not_installed') {
113
119
  // auto-install
114
- const parentPkg = extractParentPackage(input.pkg);
120
+ const parentPkg = extractParentPackage(requested);
115
121
  const installResult = await runAutoInstall(spawn, parentPkg, undefined);
116
122
  if (!installResult.success) {
117
123
  return {
@@ -124,7 +130,7 @@ export async function docsRaw(input) {
124
130
  }
125
131
  autoInstalled = true;
126
132
  try {
127
- pkg = resolvePackage(input.pkg, installResult.installDir);
133
+ pkg = resolvePackage(requested, installResult.installDir);
128
134
  }
129
135
  catch (retryErr) {
130
136
  if (retryErr instanceof ResolveError) {
@@ -166,7 +172,7 @@ export async function docsRaw(input) {
166
172
  // @types/<name> + triple-slash `<reference types>` chain to the package that
167
173
  // actually holds the declarations (e.g. bun -> @types/bun -> bun-types).
168
174
  // Best-effort: any failure leaves the original resolution untouched.
169
- pkg = await resolveTypeSource(pkg, input.pkg, input.cwd, spawn, resolvePackage, input.signal);
175
+ pkg = await resolveTypeSource(pkg, requested, input.cwd, spawn, resolvePackage, input.signal);
170
176
  // Step 2: open cache
171
177
  let cache = null;
172
178
  let cacheError;
@@ -11,6 +11,18 @@ export declare class ResolveError extends Error {
11
11
  readonly kind: 'not_installed' | 'invalid_name';
12
12
  constructor(kind: 'not_installed' | 'invalid_name', message: string);
13
13
  }
14
+ /**
15
+ * Split a runtime builtin specifier (`bun:sql`, `node:fs/promises`) into its
16
+ * runtime and submodule. Returns null for ordinary specifiers (including scoped
17
+ * names, which legitimately contain no colon). The resolver maps the runtime to
18
+ * its types package via the existing @types redirect chain (bun -> bun-types,
19
+ * node -> @types/node), so a `bun:sql` query lands on Bun's real SQL surface
20
+ * (`declare module "bun"` → `const sql: SQL`) instead of erroring `invalid_name`.
21
+ */
22
+ export declare function splitRuntimeNamespace(spec: string): {
23
+ runtime: string;
24
+ sub: string;
25
+ } | null;
14
26
  export declare function resolvePackage(moduleName: string, cwd: string): ResolvedPackage;
15
27
  /** Conventional DefinitelyTyped package for a runtime package that ships no
16
28
  * types of its own. `bun` -> `@types/bun`, `@scope/x` -> `@types/scope__x`.
@@ -20,6 +20,30 @@ function isValidModuleName(name) {
20
20
  return false;
21
21
  return MODULE_NAME_RE.test(name);
22
22
  }
23
+ // Runtimes whose builtin `<runtime>:<sub>` imports are typed by the runtime's own
24
+ // types package, not by a literal package named "<runtime>:<sub>". `node:fs` and
25
+ // `bun:sqlite` are real imports, but their declarations live in @types/node and
26
+ // bun-types — and a phantom like `bun:sql` (no such submodule) is only disprovable
27
+ // by resolving the runtime and finding the symbol absent. Either way the docs
28
+ // lookup target is the runtime, never the colon-name.
29
+ const RUNTIME_NAMESPACES = new Set(['bun', 'node', 'deno']);
30
+ /**
31
+ * Split a runtime builtin specifier (`bun:sql`, `node:fs/promises`) into its
32
+ * runtime and submodule. Returns null for ordinary specifiers (including scoped
33
+ * names, which legitimately contain no colon). The resolver maps the runtime to
34
+ * its types package via the existing @types redirect chain (bun -> bun-types,
35
+ * node -> @types/node), so a `bun:sql` query lands on Bun's real SQL surface
36
+ * (`declare module "bun"` → `const sql: SQL`) instead of erroring `invalid_name`.
37
+ */
38
+ export function splitRuntimeNamespace(spec) {
39
+ const m = /^([a-z]+):([a-z0-9./_-]+)$/i.exec(spec);
40
+ if (!m)
41
+ return null;
42
+ const runtime = m[1].toLowerCase();
43
+ if (!RUNTIME_NAMESPACES.has(runtime))
44
+ return null;
45
+ return { runtime, sub: m[2] };
46
+ }
23
47
  function parentPackageName(moduleName) {
24
48
  if (moduleName.startsWith('@')) {
25
49
  const parts = moduleName.split('/');
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.6",
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",