@mjasnikovs/pi-task 0.18.0 → 0.18.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.
@@ -24,6 +24,7 @@ import { runRepoHealthCheck } from './repo-health-check.js';
24
24
  import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
25
25
  import { runFinalGateAutofix } from './final-gate-fix.js';
26
26
  import { researchResolution } from './verify-resolution.js';
27
+ import { extractProhibitions, findProhibitionViolations } from './prohibition-probe.js';
27
28
  import { findSubstitutionSuspects } from './substitution-probe.js';
28
29
  import { runBoundedLintFix } from './lint-fix.js';
29
30
  import { captureGitState, reconcileGitState } from './git-state-guard.js';
@@ -290,6 +291,16 @@ export function buildGateDeps(params) {
290
291
  // authored/changed become prompt-level findings mandating the child
291
292
  // to drive the real artifact before trusting their green result.
292
293
  probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
294
+ // Deterministic prohibition probe: paths the spec forbids modifying
295
+ // that the task's diff modified anyway become prompt-level findings
296
+ // under the no-waiver rule — the child otherwise rarely runs `git
297
+ // diff` and cannot even see the violation.
298
+ prohibitionProbe: () => {
299
+ const banned = spec ? extractProhibitions(spec) : [];
300
+ if (banned.length === 0)
301
+ return Promise.resolve([]);
302
+ return collectChangedFiles(cwd2, signal).then(files => findProhibitionViolations(banned, files));
303
+ },
293
304
  // Git-state guard result of the most recent child run: a verdict
294
305
  // computed on a tree the child itself mutated is discarded (the
295
306
  // guard already restored the state — see git-state-guard.ts).
@@ -0,0 +1,53 @@
1
+ /**
2
+ * prohibition-probe — deterministic detection of VIOLATED SPEC PROHIBITIONS,
3
+ * feeding the verify gate's prompt.
4
+ *
5
+ * The failure class (mx5 run 7, "New Listing page" task): the spec's CONSTRAINTS
6
+ * said "**Do NOT modify** any server-side code: `src/server/index.ts`, …"; the
7
+ * implementation modified `src/server/index.ts` anyway; the verify child SAW it
8
+ * ("VIOLATES 'Do NOT modify server-side code'"), waived it ("BUT: this is
9
+ * additive, tests pass with it"), and PASSed. Worse, the reproduction fixture
10
+ * showed the baseline child usually never LOOKS: 5/5 baseline runs consulted no
11
+ * diff at all and several affirmatively claimed the forbidden file was untouched.
12
+ *
13
+ * So — like the substitution probe (see substitution-probe.ts, whose A/B proved
14
+ * prompt language alone gets ~40% attention while a concrete deterministic
15
+ * finding gets 100%) — the fix is a deterministic pre-check whose finding is
16
+ * injected into the prompt: extract the concrete paths the spec forbids
17
+ * modifying, intersect with the task's changed files (pure git shape, already
18
+ * collected for the substitution probe), and hand the child each hit with the
19
+ * exact constraint wording.
20
+ *
21
+ * The finding is advisory, not an auto-FAIL, for one reason: prohibitions in
22
+ * real specs are prose and can be CONDITIONAL ("Do NOT modify `api.ts` beyond
23
+ * what is needed for the new endpoint") — a hard gate on prose extraction would
24
+ * false-FAIL legitimate work. The finding therefore carries the constraint line
25
+ * verbatim and the prompt's no-waiver rule (4b in verify-work.ts) forbids
26
+ * excusing an ABSOLUTE prohibition while directing conditional ones to be judged
27
+ * against their own stated exception. A violation that was fully reverted before
28
+ * verify produces no diff entry, so it never fires — reverted = not violated.
29
+ */
30
+ import type { ChangedFile } from './substitution-probe.js';
31
+ /** One "do not modify X" constraint extracted from the spec text. */
32
+ export interface Prohibition {
33
+ /** The forbidden path exactly as the spec spells it (file or directory). */
34
+ path: string;
35
+ /** The full spec line carrying the prohibition, so the verify child judges
36
+ * against the EXACT wording — including any exception clause it states. */
37
+ constraint: string;
38
+ }
39
+ /**
40
+ * Extract the concrete paths the spec explicitly forbids modifying: every
41
+ * backtick-quoted path-like token on a line that expresses a modification ban.
42
+ * Prose-only prohibitions ("do not modify server-side code" with no path named)
43
+ * extract nothing — the prompt-level rule still covers them.
44
+ */
45
+ export declare function extractProhibitions(spec: string): Prohibition[];
46
+ /**
47
+ * Intersect the spec's prohibitions with the task's changed files (git shape —
48
+ * the same collector the substitution probe uses). A prohibition matches a
49
+ * changed file exactly, or as a directory prefix (`src/server` covers
50
+ * `src/server/index.ts`). One finding line per violated file, carrying the
51
+ * constraint verbatim; empty array → no block in the prompt.
52
+ */
53
+ export declare function findProhibitionViolations(prohibitions: Prohibition[], files: ChangedFile[]): string[];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Does this line express a modification ban? Matches the active forms ("do not
3
+ * modify", "must not touch", "never edit", "don't change") and the passive form
4
+ * ("must not be modified"). Deliberately verb-scoped to modification — a "do not
5
+ * add a dependency" style rule names no path and is the prompt rule's job.
6
+ */
7
+ const PROHIBITION_RE = /\b(?:do\s+not|don'?t|must\s+not|never)\s+(?:be\s+)?(?:modify|modified|touch|touched|edit|edited|change|changed|alter|altered|rewrite|rewritten|overwrite|overwritten|delete|deleted|remove|removed)\b/i;
8
+ /**
9
+ * A backtick token counts as a path only when it is whitespace-free, uses path
10
+ * characters, and either contains a directory separator, has a file extension,
11
+ * or is a dotfile. Bare identifiers (`getOrder`), API routes with spaces
12
+ * (`POST /api/listings`), and code snippets are rejected — the probe would
13
+ * rather miss a `Makefile` than fire on prose.
14
+ */
15
+ function looksLikePath(token) {
16
+ if (!/^[\w.@~/-]+$/.test(token))
17
+ return false;
18
+ return token.includes('/') || /\.[A-Za-z0-9]+$/.test(token) || token.startsWith('.');
19
+ }
20
+ /**
21
+ * Extract the concrete paths the spec explicitly forbids modifying: every
22
+ * backtick-quoted path-like token on a line that expresses a modification ban.
23
+ * Prose-only prohibitions ("do not modify server-side code" with no path named)
24
+ * extract nothing — the prompt-level rule still covers them.
25
+ */
26
+ export function extractProhibitions(spec) {
27
+ const out = [];
28
+ const seen = new Set();
29
+ for (const line of spec.split('\n')) {
30
+ if (!PROHIBITION_RE.test(line))
31
+ continue;
32
+ for (const m of line.matchAll(/`([^`]+)`/g)) {
33
+ const token = m[1].trim();
34
+ if (!looksLikePath(token) || seen.has(token))
35
+ continue;
36
+ seen.add(token);
37
+ out.push({ path: token, constraint: line.trim() });
38
+ }
39
+ }
40
+ return out;
41
+ }
42
+ /** Normalise a path for comparison: strip leading ./ and trailing /. */
43
+ const norm = (p) => p.replace(/^\.\//, '').replace(/\/+$/, '');
44
+ /**
45
+ * Intersect the spec's prohibitions with the task's changed files (git shape —
46
+ * the same collector the substitution probe uses). A prohibition matches a
47
+ * changed file exactly, or as a directory prefix (`src/server` covers
48
+ * `src/server/index.ts`). One finding line per violated file, carrying the
49
+ * constraint verbatim; empty array → no block in the prompt.
50
+ */
51
+ export function findProhibitionViolations(prohibitions, files) {
52
+ const findings = [];
53
+ for (const f of files) {
54
+ const fp = norm(f.path);
55
+ const hit = prohibitions.find(p => {
56
+ const pp = norm(p.path);
57
+ return fp === pp || fp.startsWith(`${pp}/`);
58
+ });
59
+ if (!hit)
60
+ continue;
61
+ findings.push(`${f.path} — modified by this task, but the spec forbids it: "${hit.constraint}"`);
62
+ }
63
+ return findings;
64
+ }
@@ -38,11 +38,24 @@ export declare function extractSpecForVerification(taskBody: string): string | n
38
38
  * `probeFindings` are the deterministic self-verification probe results (see
39
39
  * substitution-probe.ts): the TEST-THE-COPY class is caught 5/5 only when the
40
40
  * prompt carries both the rule (3b) AND a concrete finding naming the suspect
41
- * file — the rule alone got 2/5 attention on the live model. The findings are
41
+ * file — the rule alone got 2/5 attention on the local model. The findings are
42
42
  * pure git shape (test files the task itself changed), so the mandate is
43
43
  * language- and framework-agnostic.
44
+ *
45
+ * `prohibitionFindings` are the deterministic prohibition probe results (see
46
+ * prohibition-probe.ts): spec-forbidden paths the task's diff modified anyway.
47
+ * Same probe+rule design, same reason: the VIOLATION-EXCUSAL class (mx5 run 7:
48
+ * child saw "Do NOT modify server-side code" violated, waived it as "additive,
49
+ * tests pass", PASSed) needs both the no-waiver rule (4b) AND the concrete diff
50
+ * fact — the baseline child usually never runs `git diff` at all, so without the
51
+ * finding it cannot even SEE the violation. A/B on the live local model
52
+ * (violated-but-working fixture, everything green, forbidden file modified
53
+ * additively): old prompt 5/5 false-PASS (several runs affirmatively claimed the
54
+ * forbidden file was untouched); rule+finding 5/5 FAIL naming the constraint.
55
+ * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
56
+ * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
44
57
  */
45
- export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string): string;
58
+ export declare function buildVerifyPrompt(spec: string, probeFindings?: string[], envNotes?: string, prohibitionFindings?: string[]): string;
46
59
  /**
47
60
  * Parse the child's verdict. Scans for the LAST `WORK-VERIFIED: PASS|FAIL` marker
48
61
  * (the model discusses before concluding, and bash output may echo the word
@@ -82,6 +95,12 @@ export interface VerificationDeps {
82
95
  * into the child's prompt. A/B-proven load-bearing: the prompt rule alone caught
83
96
  * the class 2/5, rule + probe finding 5/5. ABSENT or empty → no probe block. */
84
97
  probe?: () => Promise<string[]>;
98
+ /**
99
+ * DETERMINISTIC prohibition probe (see prohibition-probe.ts): spec-forbidden
100
+ * paths the task's diff modified anyway, injected as prompt findings under the
101
+ * no-waiver rule (4b). Advisory, never auto-FAIL — real prohibitions can be
102
+ * conditional prose. ABSENT or empty → no prohibition block. */
103
+ prohibitionProbe?: () => Promise<string[]>;
85
104
  /**
86
105
  * Result of the git-state guard for the MOST RECENT runChild call (see
87
106
  * git-state-guard.ts): did the child mutate repo state (stash/checkout/file
@@ -120,11 +120,24 @@ export function extractSpecForVerification(taskBody) {
120
120
  * `probeFindings` are the deterministic self-verification probe results (see
121
121
  * substitution-probe.ts): the TEST-THE-COPY class is caught 5/5 only when the
122
122
  * prompt carries both the rule (3b) AND a concrete finding naming the suspect
123
- * file — the rule alone got 2/5 attention on the live model. The findings are
123
+ * file — the rule alone got 2/5 attention on the local model. The findings are
124
124
  * pure git shape (test files the task itself changed), so the mandate is
125
125
  * language- and framework-agnostic.
126
+ *
127
+ * `prohibitionFindings` are the deterministic prohibition probe results (see
128
+ * prohibition-probe.ts): spec-forbidden paths the task's diff modified anyway.
129
+ * Same probe+rule design, same reason: the VIOLATION-EXCUSAL class (mx5 run 7:
130
+ * child saw "Do NOT modify server-side code" violated, waived it as "additive,
131
+ * tests pass", PASSed) needs both the no-waiver rule (4b) AND the concrete diff
132
+ * fact — the baseline child usually never runs `git diff` at all, so without the
133
+ * finding it cannot even SEE the violation. A/B on the live local model
134
+ * (violated-but-working fixture, everything green, forbidden file modified
135
+ * additively): old prompt 5/5 false-PASS (several runs affirmatively claimed the
136
+ * forbidden file was untouched); rule+finding 5/5 FAIL naming the constraint.
137
+ * Guard: honest-clean fixture (prohibition in spec, probe silent) 5/5 PASS — no
138
+ * paranoia. Reverted-violation ≡ clean at the diff level (no entry → no finding).
126
139
  */
127
- export function buildVerifyPrompt(spec, probeFindings, envNotes) {
140
+ export function buildVerifyPrompt(spec, probeFindings, envNotes, prohibitionFindings) {
128
141
  const probeBlock = probeFindings && probeFindings.length > 0 ?
129
142
  [
130
143
  'SELF-VERIFICATION NOTICE (deterministic, computed by the orchestrator from the diff):',
@@ -138,6 +151,19 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes) {
138
151
  ''
139
152
  ]
140
153
  : [];
154
+ const prohibitionBlock = prohibitionFindings && prohibitionFindings.length > 0 ?
155
+ [
156
+ 'PROHIBITION NOTICE (deterministic, computed by the orchestrator from the',
157
+ "spec's own constraint lines and the task's diff): this task MODIFIED paths",
158
+ 'the spec explicitly forbids modifying:',
159
+ ...prohibitionFindings.map(f => `- ${f}`),
160
+ 'Read the exact constraint wording in the spec. Unless that wording itself',
161
+ 'states an exception that covers this change, this is a violated prohibition:',
162
+ 'rule 4b applies and the verdict is FAIL naming the forbidden path — even if',
163
+ 'every test passes and the change looks harmless.',
164
+ ''
165
+ ]
166
+ : [];
141
167
  const envBlock = envNotes && envNotes.trim().length > 0 ? [buildEnvNotesBlock(envNotes)] : [];
142
168
  return [
143
169
  'You are a strict verification pass running right after an AI coding agent',
@@ -154,6 +180,7 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes) {
154
180
  '',
155
181
  ...envBlock,
156
182
  ...probeBlock,
183
+ ...prohibitionBlock,
157
184
  'How to verify — verify the REAL, shipped deliverable exactly as an unaided fresh',
158
185
  'checkout (or CI run) would experience it:',
159
186
  '',
@@ -218,6 +245,19 @@ export function buildVerifyPrompt(spec, probeFindings, envNotes) {
218
245
  '4. Treat the ACCEPTANCE criteria as the bar. If a command fails, or its real output',
219
246
  ' contradicts an ACCEPTANCE criterion, the work has NOT verified.',
220
247
  '',
248
+ '4b. SPEC PROHIBITIONS ARE PART OF THE BAR — YOU HAVE NO WAIVER AUTHORITY: when the',
249
+ ' spec explicitly forbids something ("Do NOT modify X", "MUST NOT touch Y") and the',
250
+ ' shipped work does it anyway, that is a FAIL naming the violated constraint. You',
251
+ ' may not excuse a violation because it is additive, small, harmless, an improvement,',
252
+ ' or because every test still passes — "it works anyway" is exactly the waiver you do',
253
+ " not have; relaxing a constraint is the spec owner's call, not yours. Check the",
254
+ " task's own diff (git) against the spec's prohibitions — a forbidden file can be",
255
+ ' modified without any test noticing. Only two outcomes are not a FAIL: the',
256
+ ' violation was fully REVERTED (the shipped tree no longer violates), or the',
257
+ ' prohibition\'s own wording states an exception ("except…", "beyond what is needed',
258
+ ' for…") that covers the change — judged against that stated exception, not against',
259
+ ' your view of harmlessness.',
260
+ '',
221
261
  '5. The ONLY thing you may assume is already provided is a genuinely EXTERNAL running',
222
262
  ' service or network resource (a database server, an API host) that the project',
223
263
  ' documents as a prerequisite. Before you rely on that assumption, PROBE for the',
@@ -310,6 +350,15 @@ export async function runWorkVerification(deps) {
310
350
  findings = [];
311
351
  }
312
352
  }
353
+ let prohibitions = [];
354
+ if (deps.prohibitionProbe) {
355
+ try {
356
+ prohibitions = await deps.prohibitionProbe();
357
+ }
358
+ catch {
359
+ prohibitions = [];
360
+ }
361
+ }
313
362
  // Environment facts from earlier gate children (best-effort; a cache failure
314
363
  // must never block verification).
315
364
  let envNotes = '';
@@ -328,7 +377,7 @@ export async function runWorkVerification(deps) {
328
377
  for (let attempt = 1;; attempt++) {
329
378
  let text;
330
379
  try {
331
- text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes), deps.signal);
380
+ text = await deps.runChild(VERIFY_TOOLS, buildVerifyPrompt(deps.spec, findings, envNotes, prohibitions), deps.signal);
332
381
  }
333
382
  catch (err) {
334
383
  if (err instanceof Error && err.message === USER_CANCELLED)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",