@mjasnikovs/pi-task 0.14.22 → 0.15.1

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.
@@ -18,15 +18,15 @@ const ITEMS = [
18
18
  label: 'orientation',
19
19
  description: 'Pre-supply the project core (manifest, types, schema…) to the read-heavy research workers'
20
20
  },
21
- {
22
- id: 'enforceGuidelines',
23
- label: 'enforce guidelines',
24
- description: 'Before each /task-auto commit, re-check the work against AGENTS.md/CLAUDE.md and fix drift'
25
- },
26
21
  {
27
22
  id: 'verifyWork',
28
23
  label: 'verify work',
29
- description: "After each /task-auto task, RUN its spec's VERIFY block in the workspace and report a PASS/FAIL verdict"
24
+ description: "After each /task-auto task, RUN its spec's VERIFY block in the workspace and report a PASS/FAIL verdict (also the signal that lets 'enforce guidelines' fix safely)"
25
+ },
26
+ {
27
+ id: 'enforceGuidelines',
28
+ label: 'enforce guidelines',
29
+ description: "Check each /task-auto commit against AGENTS.md/CLAUDE.md. Needs 'verify work' to FIX drift (fixes are reverted if they regress verification); without it, only reports violations"
30
30
  }
31
31
  ];
32
32
  function makeTheme(theme) {
@@ -18,3 +18,22 @@ export interface CommitResult {
18
18
  * `{committed: false, reason}` so the caller can warn and keep going.
19
19
  */
20
20
  export declare function gitCommitAll(cwd: string, message: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<CommitResult>;
21
+ /**
22
+ * Drop the last commit, restoring the tree to its parent — the differential
23
+ * guard's "revert" when an `'edit'` enforcement pass regressed the verified task
24
+ * commit. The enforcement fixes are committed first (as `ENFORCE GUIDELINES`);
25
+ * when re-running verification against that commit reports a regression, this
26
+ * `git reset --hard HEAD~1` throws the enforce commit away and brings back the
27
+ * verified task commit underneath it.
28
+ *
29
+ * `reset --hard` is safe here precisely because it runs right after the enforce
30
+ * commit: the working tree is clean (everything was just committed), so there is
31
+ * no unrelated uncommitted work for it to destroy. HEAD~1 is the verified task
32
+ * commit. The enforcement child runs `read,edit` with no `write`, so the dropped
33
+ * commit contains only its in-place source edits — nothing else to preserve.
34
+ *
35
+ * Best-effort and never throws: a git failure is swallowed (the caller has
36
+ * already decided to keep the verified work; a failed reset only leaves the
37
+ * enforce commit in place, which is surfaced as a warning).
38
+ */
39
+ export declare function gitDropLastCommit(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<void>;
@@ -54,3 +54,24 @@ export async function gitCommitAll(cwd, message, signal, spawnFn) {
54
54
  }
55
55
  return { committed: true };
56
56
  }
57
+ /**
58
+ * Drop the last commit, restoring the tree to its parent — the differential
59
+ * guard's "revert" when an `'edit'` enforcement pass regressed the verified task
60
+ * commit. The enforcement fixes are committed first (as `ENFORCE GUIDELINES`);
61
+ * when re-running verification against that commit reports a regression, this
62
+ * `git reset --hard HEAD~1` throws the enforce commit away and brings back the
63
+ * verified task commit underneath it.
64
+ *
65
+ * `reset --hard` is safe here precisely because it runs right after the enforce
66
+ * commit: the working tree is clean (everything was just committed), so there is
67
+ * no unrelated uncommitted work for it to destroy. HEAD~1 is the verified task
68
+ * commit. The enforcement child runs `read,edit` with no `write`, so the dropped
69
+ * commit contains only its in-place source edits — nothing else to preserve.
70
+ *
71
+ * Best-effort and never throws: a git failure is swallowed (the caller has
72
+ * already decided to keep the verified work; a failed reset only leaves the
73
+ * enforce commit in place, which is surfaced as a warning).
74
+ */
75
+ export async function gitDropLastCommit(cwd, signal, spawnFn) {
76
+ await git(cwd, ['reset', '--hard', 'HEAD~1'], signal, spawnFn);
77
+ }
@@ -33,7 +33,14 @@ export interface AutoDeps {
33
33
  * each task runs in a fresh session, so the captured ctx is stale by the time
34
34
  * enforcement runs and using it for the loader widget throws "stale ctx".
35
35
  */
36
- enforce?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string) => Promise<EnforceOutcome>;
36
+ enforce?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string,
37
+ /**
38
+ * `'edit'` (read,edit — fix in place) only when there is a clean
39
+ * verification signal to guard the edits against; otherwise `'flag'`
40
+ * (read-only, report don't fix). The loop picks the mode from whether the
41
+ * verify gate produced a genuine clean pass.
42
+ */
43
+ mode: 'edit' | 'flag') => Promise<EnforceOutcome>;
37
44
  /**
38
45
  * Verify the just-finished task's work by RUNNING its composed spec's VERIFY
39
46
  * block in the real workspace (a fresh child of the same local model, with a
@@ -53,6 +60,15 @@ export interface AutoDeps {
53
60
  * taskId (to read the spec) and the gate's failure reason.
54
61
  */
55
62
  recommend?: (ctx: ExtensionCommandContext, cwd: string, taskTitle: string, taskId: string, failReason: string) => Promise<ResolutionOutcome>;
63
+ /**
64
+ * Discard the working-tree edits an `'edit'` enforcement pass made, restoring
65
+ * the verified task commit. The differential guard calls this when re-running
66
+ * verification AFTER enforcement reports a regression: the guideline fixes are
67
+ * thrown away and the verified implementation is kept. Optional: absent in
68
+ * tests; the loop then skips the revert (and warns). Scoped to exclude the
69
+ * .pi-tasks bookkeeping so task front matter is not rolled back.
70
+ */
71
+ revert?: (cwd: string) => Promise<void>;
56
72
  }
57
73
  /**
58
74
  * Expand any @file references in the feature text by appending each referenced
@@ -14,7 +14,7 @@ import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT } from './auto-prompts.js';
14
14
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
15
15
  import { allocateAutoId, buildAutoBody, parseDecomposeList, parseTaskList, checkOffTask, stampTaskInProgress, findResumableAuto } from './auto-io.js';
16
16
  import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
17
- import { gitCommitAll } from './auto-commit.js';
17
+ import { gitCommitAll, gitDropLastCommit } from './auto-commit.js';
18
18
  import { runGuidelineEnforcement, classifyEnforceChildFailure } from './enforce-guidelines.js';
19
19
  import { runWorkVerification, extractSpecForVerification } from './verify-work.js';
20
20
  import { researchResolution, resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
@@ -446,13 +446,15 @@ function defaultDeps(ctx, cwd, signal, title) {
446
446
  commit: (cwd2, message) => getConfig().autoCommit ?
447
447
  gitCommitAll(cwd2, message, signal)
448
448
  : Promise.resolve({ committed: false, reason: 'auto-commit disabled' }),
449
- enforce: (enforceCtx, cwd2, taskTitle) => {
449
+ revert: cwd2 => gitDropLastCommit(cwd2, signal),
450
+ enforce: (enforceCtx, cwd2, taskTitle, mode) => {
450
451
  if (!getConfig().enforceGuidelines) {
451
452
  return Promise.resolve({ ok: true, reason: 'disabled' });
452
453
  }
453
454
  return runGuidelineEnforcement({
454
455
  cwd: cwd2,
455
456
  signal,
457
+ mode,
456
458
  // Run the worker child UNGUARDED: no loop detector, no wall-clock
457
459
  // timeout. This pass's job is to rework files in place until every
458
460
  // violation is fixed, and it legitimately reads and edits the same
@@ -721,6 +723,14 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
721
723
  // /task-auto-resume re-runs it (rather than blessing broken work). Off
722
724
  // by default (deps.verify returns a disabled no-op) and a no-op when the
723
725
  // task has no composed spec to verify against.
726
+ //
727
+ // Whether this gate produced a GENUINE clean pass (a real signal ran
728
+ // and the work met it) also decides how the enforce pass below is
729
+ // allowed to behave: only a genuine pass gives a signal to revert
730
+ // against, so only then may enforce edit in place (see the enforce
731
+ // block). A no-op pass (no spec), a disabled gate, or a user
732
+ // accept-override leaves this false → enforce runs flag-only.
733
+ let verifyCleanPass = false;
724
734
  if (deps.verify) {
725
735
  active.ui.notify(`${id}: verifying "${next.title}"…`, 'info');
726
736
  let verified = await deps.verify(active, cwd, next.title, res.taskId);
@@ -781,6 +791,10 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
781
791
  }
782
792
  // Loop exited because the work verified OR the user accepted the
783
793
  // artifact — either way fall through to check-off/commit/enforce.
794
+ // A genuine clean pass is ok===true with NO reason; a no-op pass
795
+ // ('no spec to verify') or an accept-override (verified.ok still
796
+ // false at break) does NOT count as a guardable signal.
797
+ verifyCleanPass = verified.ok && !verified.reason;
784
798
  }
785
799
  // res.ok === true means runner.run() completed, so res.taskId is the
786
800
  // allocated TASK_NNNN id (never empty here). checkOffTask tolerates an
@@ -799,29 +813,63 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
799
813
  active.ui.notify(`${id}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, 'warning');
800
814
  }
801
815
  // With the task committed, hold its work to the project's AGENTS.md /
802
- // CLAUDE.md rules. Local models drift and skip those guidelines, so the
803
- // enforcement pass re-reads THE LAST COMMIT's diff with the same local
804
- // model (edit tools enabled) and fixes violations in place; those fixes
805
- // are then committed SEPARATELY under an "ENFORCE GUIDELINES" commit so
806
- // they form an independent, auditable diff on top of the task commit.
807
- // A non-clean verdict only warns — the task commit already landed, so
808
- // the run continues. Skipped when nothing was committed this round
809
- // (autoCommit off or an empty commit): there is no "last commit" of this
810
- // task's work to verify. (Also a no-op when the feature is off, when
811
- // there are no guideline files, or in tests with no enforce dep.)
816
+ // CLAUDE.md rules but as a step INSIDE the validation gate, never the
817
+ // blind post-commit write pass it used to be. A bare read,edit enforce
818
+ // pass trashes working code: A/B-proven, it degraded a clean build in
819
+ // 4/5 runs while declaring CLEAN. So enforcement is now gated by the
820
+ // verify signal:
821
+ //
822
+ // - 'edit' mode (read,edit fix in place) runs ONLY when the verify
823
+ // gate just produced a genuine clean pass. That pass is the
824
+ // differential SIGNAL: enforce commits its fixes, then the SAME
825
+ // signal is re-run against the enforced tree. A regression means the
826
+ // pass made the artifact worse than it found it — its commit is
827
+ // dropped and the verified implementation is kept (A/B: 5/5 clean).
828
+ // - 'flag' mode (read-only — report don't fix) runs when there is no
829
+ // such signal (verify off, no spec, or an accept-override): with
830
+ // nothing to revert against, the pass may not rewrite logic, only
831
+ // surface violations as a warning ("no signal ⇒ no license"; A/B:
832
+ // 5/5 clean, 5/5 caught the real violation).
833
+ //
834
+ // Skipped when nothing was committed this round (autoCommit off or an
835
+ // empty commit), when the feature is off, when there are no guideline
836
+ // files, or in tests with no enforce dep.
812
837
  if (deps.enforce && commit.committed) {
813
- active.ui.notify(`${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`, 'info');
814
- const verdict = await deps.enforce(active, cwd, next.title);
838
+ const mode = verifyCleanPass ? 'edit' : 'flag';
839
+ active.ui.notify(mode === 'edit' ?
840
+ `${id}: enforcing AGENTS.md/CLAUDE.md on "${next.title}"…`
841
+ : `${id}: reviewing "${next.title}" against AGENTS.md/CLAUDE.md (no verify signal — report only)…`, 'info');
842
+ const verdict = await deps.enforce(active, cwd, next.title, mode);
815
843
  if (!verdict.ok) {
816
- active.ui.notify(`${id}: guideline enforcement on "${next.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
844
+ active.ui.notify(`${id}: guideline ${mode === 'edit' ? 'enforcement' : 'review'} on "${next.title}" — ${verdict.reason ?? 'not clean'} — continuing.`, 'warning');
817
845
  }
818
- // Commit whatever the pass fixed as its own snapshot. A no-op when
819
- // the pass made no edits (gitCommitAll reports nothing to commit),
820
- // so a clean task adds no empty commit — only announce a real one.
821
- const enforceCommit = await deps.commit(cwd, `ENFORCE GUIDELINES: ${next.title} (${res.taskId})`);
822
- if (enforceCommit.committed) {
823
- active.ui.notify(`${id}: committed guideline fixes for "${next.title}".`, 'info');
846
+ if (mode === 'edit') {
847
+ // Commit whatever the pass fixed as its own snapshot. A no-op
848
+ // when it made no edits (nothing to commit)and then there is
849
+ // nothing to re-verify or revert, so a clean task skips the
850
+ // second verify entirely.
851
+ const enforceCommit = await deps.commit(cwd, `ENFORCE GUIDELINES: ${next.title} (${res.taskId})`);
852
+ if (enforceCommit.committed) {
853
+ // Differential guard: re-run the verify signal against the
854
+ // enforced tree. verifyCleanPass implies deps.verify exists,
855
+ // so this is a real check. A regression ⇒ drop the enforce
856
+ // commit, keep the verified task commit underneath it.
857
+ const after = deps.verify ?
858
+ await deps.verify(active, cwd, next.title, res.taskId)
859
+ : { ok: true };
860
+ if (!after.ok) {
861
+ // Regression: drop the enforce commit if we can, and
862
+ // never report it as a kept fix.
863
+ if (deps.revert)
864
+ await deps.revert(cwd);
865
+ active.ui.notify(`${id}: guideline fixes regressed verification on "${next.title}" (${(after.reason ?? 'now fails').slice(0, 120)}) — ${deps.revert ? 'reverted them, kept the verified work' : 'left in place (no revert available)'}.`, 'warning');
866
+ }
867
+ else {
868
+ active.ui.notify(`${id}: committed guideline fixes for "${next.title}".`, 'info');
869
+ }
870
+ }
824
871
  }
872
+ // 'flag' mode makes no edits — nothing to commit or revert.
825
873
  }
826
874
  }
827
875
  }
@@ -21,6 +21,20 @@ export declare const GUIDELINE_FILENAMES: readonly ["AGENTS.md", "CLAUDE.md"];
21
21
  * (the prompt scopes them) but is a soft instruction for READS.
22
22
  */
23
23
  declare const ENFORCE_TOOLS = "read,edit";
24
+ /**
25
+ * Flag-only enforcement gets exactly ONE tool: `read`. It can inspect the changed
26
+ * files and report violations but CANNOT edit, create, or run anything.
27
+ *
28
+ * This is the "no signal ⇒ no license to rewrite logic" half of the gate. When a
29
+ * task ships no behavioral verification to guard a destructive edit (no runnable
30
+ * VERIFY, or the verify gate did not produce a genuine clean pass), letting the
31
+ * weak model rewrite working code is exactly what trashes the build — A/B-proven:
32
+ * with `read,edit` and no guard the model degraded a clean tree in 4/5 runs while
33
+ * declaring CLEAN. Demoted to `read`, it cannot trash anything (5/5 clean) and
34
+ * still names the real violation (5/5). So with no signal to revert against, the
35
+ * pass reports the violation as a warning instead of editing.
36
+ */
37
+ declare const ENFORCE_FLAG_TOOLS = "read";
24
38
  export interface GuidelineDoc {
25
39
  /** Filenames found, in discovery order (e.g. ['AGENTS.md', 'CLAUDE.md']). */
26
40
  files: string[];
@@ -48,6 +62,13 @@ export declare function discoverGuidelines(cwd: string, readFile?: (p: string) =
48
62
  * is unit-tested without spawning pi.
49
63
  */
50
64
  export declare function buildEnforcePrompt(rulesText: string, diff: string): string;
65
+ /**
66
+ * Build the FLAG-ONLY enforcement prompt: same rules + diff, but the child has a
67
+ * `read` tool only and is told to REPORT violations, not fix them. Kept pure so
68
+ * the wording is unit-tested without spawning pi. Used when there is no
69
+ * verification signal to guard a destructive edit (see ENFORCE_FLAG_TOOLS).
70
+ */
71
+ export declare function buildEnforceFlagPrompt(rulesText: string, diff: string): string;
51
72
  /**
52
73
  * Parse the child's final verdict. Scans for the LAST `ENFORCE: CLEAN` /
53
74
  * `ENFORCE: VIOLATION` marker (the model may discuss before concluding).
@@ -115,6 +136,16 @@ export declare function captureCommitDiff(cwd: string, signal?: AbortSignal, spa
115
136
  export interface EnforcementDeps {
116
137
  cwd: string;
117
138
  signal?: AbortSignal;
139
+ /**
140
+ * Which capability the pass runs with.
141
+ * - `'edit'` (default): `read,edit` — the model fixes violations in place. The
142
+ * caller is responsible for guarding those edits with a differential
143
+ * verification check (revert on regression), because a bare edit pass
144
+ * trashes working code (A/B: 4/5).
145
+ * - `'flag'`: `read` only — the model reports violations but cannot touch the
146
+ * tree. Used when there is no verification signal to guard an edit against.
147
+ */
148
+ mode?: 'edit' | 'flag';
118
149
  /** Defaults to the real cwd-only file discovery. */
119
150
  discover?: (cwd: string) => Promise<GuidelineDoc | null>;
120
151
  /** Defaults to the real git diff capture. */
@@ -130,4 +161,4 @@ export interface EnforcementDeps {
130
161
  * blocking outcome (ok: false) so an unverifiable task is not committed.
131
162
  */
132
163
  export declare function runGuidelineEnforcement(deps: EnforcementDeps): Promise<EnforceOutcome>;
133
- export { ENFORCE_TOOLS };
164
+ export { ENFORCE_TOOLS, ENFORCE_FLAG_TOOLS };
@@ -48,6 +48,20 @@ export const GUIDELINE_FILENAMES = ['AGENTS.md', 'CLAUDE.md'];
48
48
  * (the prompt scopes them) but is a soft instruction for READS.
49
49
  */
50
50
  const ENFORCE_TOOLS = 'read,edit';
51
+ /**
52
+ * Flag-only enforcement gets exactly ONE tool: `read`. It can inspect the changed
53
+ * files and report violations but CANNOT edit, create, or run anything.
54
+ *
55
+ * This is the "no signal ⇒ no license to rewrite logic" half of the gate. When a
56
+ * task ships no behavioral verification to guard a destructive edit (no runnable
57
+ * VERIFY, or the verify gate did not produce a genuine clean pass), letting the
58
+ * weak model rewrite working code is exactly what trashes the build — A/B-proven:
59
+ * with `read,edit` and no guard the model degraded a clean tree in 4/5 runs while
60
+ * declaring CLEAN. Demoted to `read`, it cannot trash anything (5/5 clean) and
61
+ * still names the real violation (5/5). So with no signal to revert against, the
62
+ * pass reports the violation as a warning instead of editing.
63
+ */
64
+ const ENFORCE_FLAG_TOOLS = 'read';
51
65
  /**
52
66
  * Read the guideline files that live directly in `cwd` (AGENTS.md, CLAUDE.md).
53
67
  * Returns null when none exist or all are empty — the caller treats that as a
@@ -110,6 +124,37 @@ export function buildEnforcePrompt(rulesText, diff) {
110
124
  'Output the verdict line verbatim — it is parsed mechanically.'
111
125
  ].join('\n');
112
126
  }
127
+ /**
128
+ * Build the FLAG-ONLY enforcement prompt: same rules + diff, but the child has a
129
+ * `read` tool only and is told to REPORT violations, not fix them. Kept pure so
130
+ * the wording is unit-tested without spawning pi. Used when there is no
131
+ * verification signal to guard a destructive edit (see ENFORCE_FLAG_TOOLS).
132
+ */
133
+ export function buildEnforceFlagPrompt(rulesText, diff) {
134
+ return [
135
+ 'You are a strict guideline-enforcement REVIEW pass running right after an AI',
136
+ 'coding agent finished a task and committed it. The agent is known to skip',
137
+ 'project rules, so do not trust that it followed them — verify against the',
138
+ 'changes.',
139
+ '',
140
+ 'You have a `read` tool — and NOTHING else. You CANNOT edit, create, or run',
141
+ 'anything. Your job is to REVIEW and REPORT, not to fix.',
142
+ '',
143
+ 'PROJECT GUIDELINES (from this repository):',
144
+ rulesText,
145
+ '',
146
+ 'CHANGES IN THE LAST COMMIT (review these specifically against the rules):',
147
+ diff.trim().length > 0 ? diff : '(no textual diff captured — nothing to verify)',
148
+ '',
149
+ 'Read each changed file and check it against EVERY rule above. Do NOT attempt',
150
+ 'to fix anything — only report what you find.',
151
+ '',
152
+ 'When you are done, output EXACTLY ONE of these as the final line:',
153
+ ' ENFORCE: CLEAN (the changes comply with every rule)',
154
+ ' ENFORCE: VIOLATION <text> (a rule is violated; say which — do not fix it)',
155
+ 'Output the verdict line verbatim — it is parsed mechanically.'
156
+ ].join('\n');
157
+ }
113
158
  /**
114
159
  * Parse the child's final verdict. Scans for the LAST `ENFORCE: CLEAN` /
115
160
  * `ENFORCE: VIOLATION` marker (the model may discuss before concluding).
@@ -228,9 +273,12 @@ export async function runGuidelineEnforcement(deps) {
228
273
  if (!doc)
229
274
  return { ok: true, reason: 'no guideline files' };
230
275
  const diff = await getDiff(deps.cwd, deps.signal);
276
+ const flagOnly = deps.mode === 'flag';
277
+ const tools = flagOnly ? ENFORCE_FLAG_TOOLS : ENFORCE_TOOLS;
278
+ const prompt = flagOnly ? buildEnforceFlagPrompt(doc.text, diff) : buildEnforcePrompt(doc.text, diff);
231
279
  let text;
232
280
  try {
233
- text = await deps.runChild(ENFORCE_TOOLS, buildEnforcePrompt(doc.text, diff), deps.signal);
281
+ text = await deps.runChild(tools, prompt, deps.signal);
234
282
  }
235
283
  catch (err) {
236
284
  // A user cancellation is not an enforcement failure — re-throw it so the
@@ -246,4 +294,4 @@ export async function runGuidelineEnforcement(deps) {
246
294
  return { ok: true };
247
295
  return { ok: false, reason: `guideline violation: ${verdict.detail}` };
248
296
  }
249
- export { ENFORCE_TOOLS };
297
+ export { ENFORCE_TOOLS, ENFORCE_FLAG_TOOLS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.14.22",
3
+ "version": "0.15.1",
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",