@mjasnikovs/pi-task 0.18.15 → 0.18.17

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/dist/task/accept-debt.d.ts +28 -1
  3. package/dist/task/accept-debt.js +61 -3
  4. package/dist/task/auto-io.d.ts +4 -2
  5. package/dist/task/auto-io.js +6 -3
  6. package/dist/task/auto-orchestrator.d.ts +1 -0
  7. package/dist/task/auto-orchestrator.js +135 -19
  8. package/dist/task/auto-prompts.d.ts +5 -5
  9. package/dist/task/auto-prompts.js +9 -2
  10. package/dist/task/contracts.d.ts +8 -0
  11. package/dist/task/contracts.js +4 -2
  12. package/dist/task/decompose-fidelity.d.ts +47 -0
  13. package/dist/task/decompose-fidelity.js +132 -0
  14. package/dist/task/final-gate-fix.d.ts +22 -3
  15. package/dist/task/final-gate-fix.js +72 -7
  16. package/dist/task/final-gate.d.ts +48 -1
  17. package/dist/task/final-gate.js +182 -34
  18. package/dist/task/gate-deps.d.ts +7 -0
  19. package/dist/task/gate-deps.js +37 -1
  20. package/dist/task/launch-contract.d.ts +36 -1
  21. package/dist/task/launch-contract.js +83 -2
  22. package/dist/task/phases.d.ts +13 -1
  23. package/dist/task/phases.js +50 -11
  24. package/dist/task/prompts.js +2 -0
  25. package/dist/task/render-check.d.ts +42 -0
  26. package/dist/task/render-check.js +186 -0
  27. package/dist/task/requirements.d.ts +88 -0
  28. package/dist/task/requirements.js +334 -0
  29. package/dist/task/verify-reconcile.d.ts +36 -0
  30. package/dist/task/verify-reconcile.js +203 -0
  31. package/dist/task/write-guard.d.ts +52 -0
  32. package/dist/task/write-guard.js +112 -0
  33. package/package.json +1 -1
  34. package/dist/task/_ab.d.ts +0 -1
  35. package/dist/task/_ab.js +0 -68
  36. package/dist/task/task-file.d.ts +0 -14
  37. package/dist/task/task-file.js +0 -15
  38. package/dist/think-test/cli.d.ts +0 -1
  39. package/dist/think-test/cli.js +0 -98
  40. package/dist/think-test/client.d.ts +0 -26
  41. package/dist/think-test/client.js +0 -37
  42. package/dist/think-test/compressor.d.ts +0 -5
  43. package/dist/think-test/compressor.js +0 -25
  44. package/dist/think-test/judge.d.ts +0 -4
  45. package/dist/think-test/judge.js +0 -11
  46. package/dist/think-test/score.d.ts +0 -8
  47. package/dist/think-test/score.js +0 -22
  48. package/dist/think-test/serialize.d.ts +0 -19
  49. package/dist/think-test/serialize.js +0 -41
  50. package/dist/think-test/transcript.d.ts +0 -7
  51. package/dist/think-test/transcript.js +0 -41
  52. package/dist/think-test/transform.d.ts +0 -6
  53. package/dist/think-test/transform.js +0 -24
  54. package/dist/think-test/types.d.ts +0 -45
  55. package/dist/think-test/types.js +0 -1
@@ -0,0 +1,334 @@
1
+ /**
2
+ * requirements — requirement-level coverage accounting for /task-auto planning
3
+ * (mx5 run 11, goal A).
4
+ *
5
+ * The failure this closes: the design's §10 Testing section REQUIRES test-first
6
+ * cadence, Playwright CT with screenshot baselines, a `test:ct` script, a
7
+ * separate test DB, and a `test/` dir — and got ZERO tasks and ZERO per-task
8
+ * injection. The coverage gate asked one holistic question ("do these tasks
9
+ * cover the whole feature?"), and a task list that mirrors the spec's own
10
+ * milestone list is structurally parity-complete, so the judge said COMPLETE in
11
+ * round 1. Milestone-parity coverage is structurally blind to sections that
12
+ * aren't milestones.
13
+ *
14
+ * Mechanism (spec-shape-agnostic, contracts.ts pattern):
15
+ * 1. EXTRACT requirement units as VERBATIM quotes from whatever structure the
16
+ * spec has (headings, tables, bullets, prose) — each host-GROUNDED by the
17
+ * normalised-substring guard, so a fabricated requirement can never enter.
18
+ * 2. MAP each grounded requirement against the task list (a per-requirement
19
+ * verdict: TASK n / CROSS-CUTTING / NONE). Completeness is then computed
20
+ * HOST-SIDE from the map — a blanket "COMPLETE" is structurally impossible
21
+ * because the model must commit to a falsifiable claim per requirement.
22
+ * 3. CARRY what tasks don't own: cross-cutting requirements (methodology,
23
+ * quality bars) are appended to `.pi-tasks/requirements.md` and injected
24
+ * VERBATIM into every task's refine/compose (the REFINE_PRESERVE_DIRECTIVE
25
+ * pattern: content travels, not a pointer); requirements still unmapped
26
+ * after the retry rounds are recorded user-visibly, never silently dropped.
27
+ *
28
+ * Goal C rides the same channel: when the spec mandates a verification
29
+ * methodology ("a test lands in the same change as each new route"), that quote
30
+ * is exactly what gets injected, and compose's VERIFY rules fold it into every
31
+ * applicable task's runnable verification.
32
+ */
33
+ import * as fsp from 'node:fs/promises';
34
+ import * as path from 'node:path';
35
+ import { tasksDir } from './task-io.js';
36
+ import { normalise } from './contracts.js';
37
+ const REQUIREMENTS_FILE = 'requirements.md';
38
+ /** Cap kept entries so the injected block stays bounded on a large design. */
39
+ const MAX_REQUIREMENTS = 40;
40
+ /** One line each; longer is prose, not a requirement statement. */
41
+ const MAX_REQUIREMENT_LENGTH = 300;
42
+ /** Too short to state an obligation (and to ground unambiguously). */
43
+ const MIN_QUOTE_LENGTH = 6;
44
+ export function requirementsFile(cwd) {
45
+ return path.join(tasksDir(cwd), REQUIREMENTS_FILE);
46
+ }
47
+ /** Parse `REQUIREMENT: "<quote>" [anchor: …]` lines (mirrors parseContractLines). */
48
+ export function parseRequirementLines(text) {
49
+ const entries = [];
50
+ for (const m of text.matchAll(/^[ \t]*REQUIREMENT:[ \t]*(.+)$/gim)) {
51
+ const body = m[1].trim();
52
+ const q = /"([^"]+)"/.exec(body);
53
+ if (!q)
54
+ continue;
55
+ const quote = q[1].trim();
56
+ if (quote.length < MIN_QUOTE_LENGTH || quote.length > MAX_REQUIREMENT_LENGTH)
57
+ continue;
58
+ const a = /\[anchor:\s*([^\]]+)\]/i.exec(body);
59
+ entries.push({ quote, anchor: a ? a[1].trim() : '' });
60
+ }
61
+ return entries;
62
+ }
63
+ /** THE ANTI-SYNTHESIS GUARD: keep only entries whose quote is a normalised
64
+ * substring of the source doc (same rule as keepGroundedContracts). Does NOT
65
+ * cap — capping is capRequirements' job, which protects obligation-marked
66
+ * passages from doc-order truncation. */
67
+ export function keepGroundedRequirements(entries, sourceDoc) {
68
+ const haystack = normalise(sourceDoc);
69
+ const seen = new Set();
70
+ const kept = [];
71
+ for (const e of entries) {
72
+ const key = normalise(e.quote);
73
+ if (key.length === 0 || seen.has(key))
74
+ continue;
75
+ if (!haystack.includes(key))
76
+ continue;
77
+ seen.add(key);
78
+ kept.push(e);
79
+ }
80
+ return kept;
81
+ }
82
+ /**
83
+ * Bound the list WITHOUT doc-order truncation: measured live, an eager model
84
+ * extracts 40+ items top-down (every §1 decision row), so a plain first-N cap
85
+ * systematically drops the TAIL sections — exactly where mx5 keeps its testing
86
+ * obligations. Rule (deterministic priority, not a knob): entries quoting an
87
+ * obligation-marked passage survive first; the remainder fills in given order.
88
+ */
89
+ export function capRequirements(entries, passages) {
90
+ if (entries.length <= MAX_REQUIREMENTS)
91
+ return entries;
92
+ const norms = passages.map(normalise);
93
+ const covers = (e) => {
94
+ const q = normalise(e.quote);
95
+ return norms.some(p => p.includes(q));
96
+ };
97
+ const marked = entries.filter(covers);
98
+ const rest = entries.filter(e => !covers(e));
99
+ return [...marked, ...rest].slice(0, MAX_REQUIREMENTS);
100
+ }
101
+ /**
102
+ * DETERMINISTIC RECALL FLOOR (same medicine as the launch-contract checklist):
103
+ * paragraphs carrying an obligation marker (word-bounded "required"/"must").
104
+ * Extraction recall over a 20KB doc is the weak model's, and it is variance-
105
+ * prone — measured live, 1 of 5 runs kept 16 quotes with ZERO §10 items. The
106
+ * host enumerates the marked passages; the prompt lists their head lines as a
107
+ * checklist, and uncoveredPassages() below turns "a marked passage produced no
108
+ * quote" into hard evidence for one forced re-extraction.
109
+ */
110
+ export function enumerateObligationPassages(doc) {
111
+ const out = [];
112
+ // Normalise CRLF/CR → LF: a Windows-authored spec would otherwise collapse
113
+ // into one giant paragraph (the split marker never matches `\r\n\r\n`) and
114
+ // the per-obligation recall floor would enumerate nothing.
115
+ for (const para of doc.replace(/\r\n?/g, '\n').split(/\n[ \t]*\n/)) {
116
+ const p = para.trim();
117
+ if (p.length < MIN_QUOTE_LENGTH)
118
+ continue;
119
+ if (!/\b(required|must)\b/i.test(p))
120
+ continue;
121
+ out.push(p);
122
+ if (out.length >= 20)
123
+ break;
124
+ }
125
+ return out;
126
+ }
127
+ /** The head line of a passage, for compact checklist rendering. */
128
+ function passageHead(p) {
129
+ const first = p.split('\n')[0].trim();
130
+ return first.length > 140 ? first.slice(0, 140) + '…' : first;
131
+ }
132
+ /** Marked passages none of the kept quotes came from — the hard evidence that
133
+ * extraction recall failed there (a kept quote "covers" a passage when the
134
+ * passage contains it, normalised). */
135
+ export function uncoveredPassages(passages, kept) {
136
+ const keptNorm = kept.map(e => normalise(e.quote));
137
+ return passages.filter(p => {
138
+ const pn = normalise(p);
139
+ return !keptNorm.some(q => pn.includes(q));
140
+ });
141
+ }
142
+ /** Reprompt hint for the forced re-extraction over uncovered passages. */
143
+ export function extractionRetryHint(uncovered) {
144
+ return ('[SYSTEM NOTE: Your previous answer produced NO requirement from these passages, '
145
+ + 'although each carries an explicit obligation marker. Re-extract the FULL '
146
+ + 'requirement list, making sure every obligation in each passage below is quoted '
147
+ + 'verbatim:\n'
148
+ + uncovered.map(p => ` - ${passageHead(p)}`).join('\n')
149
+ + ']');
150
+ }
151
+ /** The plan-time extraction prompt. Runs with --no-tools; every quote is
152
+ * re-grounded host-side, so guessing wastes effort. Spec-shape-agnostic.
153
+ * `passages` is enumerateObligationPassages' checklist ([] ⇒ prompt unchanged). */
154
+ export const REQUIREMENT_EXTRACT_PROMPT = (feature, passages = []) => [
155
+ 'You are recording the REQUIREMENTS of the feature/design below as VERBATIM quotes.',
156
+ 'A requirement is anything the text OBLIGATES the finished work to have, do, or obey:',
157
+ 'functional behavior, constraints, quality bars, security/accessibility rules, and any',
158
+ 'MANDATED METHODOLOGY (testing cadence, verification practice, required scripts, files,',
159
+ 'directory structures, databases). Extract from WHATEVER structure the text has —',
160
+ 'numbered sections, tables, bullet lists, or flowing prose with no headings at all.',
161
+ "Pay particular attention to obligations that are NOT part of the text's main",
162
+ 'feature/milestone structure (a "required" testing or security section, an obligation',
163
+ 'buried mid-prose) — those are the ones downstream planning loses.',
164
+ '',
165
+ 'FEATURE/DESIGN (the ONLY source — quote from it, never from your own knowledge):',
166
+ feature.trim(),
167
+ '',
168
+ ...(passages.length > 0 ?
169
+ [
170
+ 'OBLIGATION-MARKED PASSAGES — found mechanically (they contain "required"/"must").',
171
+ 'This checklist exists ONLY so you do not MISS one: every obligation in each of',
172
+ 'these passages must appear among your REQUIREMENT lines. It is a floor, not a',
173
+ 'ceiling — obligations outside these passages must be extracted too.',
174
+ ...passages.map(p => ` - ${p.split('\n')[0].trim().slice(0, 140)}`),
175
+ ''
176
+ ]
177
+ : []),
178
+ 'For each requirement, emit exactly:',
179
+ ' REQUIREMENT: "<verbatim quote copied EXACTLY from the text>" [anchor: <section/heading, or prose>]',
180
+ 'one per line. RULES: (1) the quote MUST be a literal substring of the text — do NOT',
181
+ 'paraphrase, merge, normalise, or complete it; ungrounded quotes are DISCARDED',
182
+ 'host-side. (2) Prefer the single sentence or line that states the obligation most',
183
+ 'directly. (3) One obligation per line. (4) Do NOT quote examples, rationale, or',
184
+ 'reference links. (5) Never invent a requirement the text does not state.',
185
+ '',
186
+ 'Output the REQUIREMENT: lines and nothing else. If the text states no requirements,',
187
+ 'output nothing.'
188
+ ].join('\n');
189
+ /** Per-requirement coverage verdicts against a task list. Runs with --no-tools. */
190
+ export const COVERAGE_MAP_PROMPT = (requirements, titles) => [
191
+ 'Below are the REQUIRED CONTENTS of a feature (verbatim quotes mechanically grounded',
192
+ 'in its design) and the planned TASK LIST. For EACH requirement, judge which task will',
193
+ 'deliver it. Judge coverage, not wording — a task covers a requirement when its stated',
194
+ 'scope would naturally include it.',
195
+ '',
196
+ 'REQUIREMENTS:',
197
+ ...requirements.map((r, i) => `${i + 1}. "${r.quote}"${r.anchor ? ` [${r.anchor}]` : ''}`),
198
+ '',
199
+ 'TASK LIST:',
200
+ ...titles.map((t, i) => `${i + 1}. ${t}`),
201
+ '',
202
+ 'For EVERY requirement, in order, output exactly one line:',
203
+ ' MAP: <requirement#> -> TASK <task#> (one task clearly owns it)',
204
+ ' MAP: <requirement#> -> CROSS-CUTTING (a rule/methodology MANY tasks must each fold into their own work)',
205
+ ' MAP: <requirement#> -> NONE (no task plausibly covers it)',
206
+ 'Every requirement number must appear exactly once. Output the MAP: lines and nothing else.'
207
+ ].join('\n');
208
+ /**
209
+ * Parse the mapping output. Index-aligned with `requirements` (0-based); a
210
+ * requirement the model skipped, or mapped to an out-of-range task, is `none` —
211
+ * distrust by default: an unaccounted requirement is exactly what this gate
212
+ * exists to surface, so parsing leniency must never manufacture coverage.
213
+ */
214
+ export function parseCoverageMap(text, reqCount, taskCount) {
215
+ const out = Array.from({ length: reqCount }, () => ({ kind: 'none' }));
216
+ for (const m of text.matchAll(/^[ \t]*MAP:[ \t]*(\d+)[ \t]*(?:->|→)[ \t]*(TASK[ \t]*(\d+)|CROSS[- ]?CUTTING|NONE)/gim)) {
217
+ const reqIdx = parseInt(m[1], 10) - 1;
218
+ if (reqIdx < 0 || reqIdx >= reqCount)
219
+ continue;
220
+ const verdict = m[2].toUpperCase();
221
+ if (verdict.startsWith('TASK')) {
222
+ const t = parseInt(m[3], 10);
223
+ out[reqIdx] = t >= 1 && t <= taskCount ? { kind: 'task', task: t } : { kind: 'none' };
224
+ }
225
+ else if (verdict.startsWith('CROSS')) {
226
+ out[reqIdx] = { kind: 'cross' };
227
+ }
228
+ else {
229
+ out[reqIdx] = { kind: 'none' };
230
+ }
231
+ }
232
+ return out;
233
+ }
234
+ /** Deterministic accounting over the parsed map — the host, not the model,
235
+ * decides completeness. */
236
+ export function accountCoverage(requirements, mappings) {
237
+ const acc = { mapped: [], crossCutting: [], unmapped: [] };
238
+ for (let i = 0; i < requirements.length; i++) {
239
+ const m = mappings[i] ?? { kind: 'none' };
240
+ if (m.kind === 'task')
241
+ acc.mapped.push({ req: requirements[i], task: m.task });
242
+ else if (m.kind === 'cross')
243
+ acc.crossCutting.push(requirements[i]);
244
+ else
245
+ acc.unmapped.push(requirements[i]);
246
+ }
247
+ return acc;
248
+ }
249
+ // ─── The carried-requirements artifact + injection block ────────────────────
250
+ /** The stored carried-requirements text ('' when none recorded). */
251
+ export async function readRequirements(cwd) {
252
+ try {
253
+ return (await fsp.readFile(requirementsFile(cwd), 'utf8')).trim();
254
+ }
255
+ catch {
256
+ return '';
257
+ }
258
+ }
259
+ function formatEntry(e, marker) {
260
+ const anchor = e.anchor ? ` [anchor: ${e.anchor}]` : '';
261
+ return `"${e.quote}"${anchor}${marker ? ` [${marker}]` : ''}`;
262
+ }
263
+ /**
264
+ * Append carried requirements (cross-cutting, plus any left unmapped after the
265
+ * retry rounds — better carried into every task than silently lost), deduped
266
+ * against what is stored. Host-side only; children never write it. Best-effort.
267
+ */
268
+ export async function appendCarriedRequirements(cwd, crossCutting, unresolved = []) {
269
+ if (crossCutting.length === 0 && unresolved.length === 0)
270
+ return;
271
+ try {
272
+ const existing = (await readRequirements(cwd)).split('\n').filter(l => l.trim().length > 0);
273
+ const seen = new Set(existing.map(l => {
274
+ const q = /"([^"]+)"/.exec(l);
275
+ return normalise(q ? q[1] : l);
276
+ }));
277
+ const merged = [...existing];
278
+ for (const [entries, marker] of [
279
+ [crossCutting, undefined],
280
+ [unresolved, 'no task owns this — surfaced at plan time']
281
+ ]) {
282
+ for (const e of entries) {
283
+ const key = normalise(e.quote);
284
+ if (seen.has(key))
285
+ continue;
286
+ seen.add(key);
287
+ merged.push(formatEntry(e, marker));
288
+ }
289
+ }
290
+ await fsp.mkdir(tasksDir(cwd), { recursive: true });
291
+ await fsp.writeFile(requirementsFile(cwd), merged.slice(-MAX_REQUIREMENTS).join('\n') + '\n', 'utf8');
292
+ }
293
+ catch {
294
+ // best-effort artifact
295
+ }
296
+ }
297
+ /**
298
+ * The read-only block refine/compose receive when carried requirements exist.
299
+ * Verbatim content travels with every task (the directive pattern that works),
300
+ * and the VERIFY mandate is explicit — goal C rides here.
301
+ */
302
+ export function buildRequirementsBlock(requirements) {
303
+ if (requirements.trim().length === 0)
304
+ return '';
305
+ return [
306
+ 'CROSS-CUTTING REQUIREMENTS — obligations the SOURCE design states that apply across',
307
+ 'tasks (verbatim quotes; AUTHORITATIVE). No single task owns them, so EVERY task must',
308
+ 'fold them into its own slice wherever they touch it:',
309
+ ...requirements
310
+ .trim()
311
+ .split('\n')
312
+ .map(l => `- ${l}`),
313
+ 'For each entry that touches artifacts THIS task creates or changes (its routes,',
314
+ 'components, pages, commands, files): deliver it IN THIS TASK as part of the same',
315
+ 'change — e.g. a mandated test/check for a new artifact lands with that artifact —',
316
+ 'and make ACCEPTANCE/VERIFY exercise it with runnable commands. These are never',
317
+ '"a later task\'s job" unless the plan names a task that owns them. Entries that do',
318
+ "not touch this task's slice are ignored, not restated.",
319
+ ''
320
+ ].join('\n');
321
+ }
322
+ /** The decompose-prompt ledger block (goal E's belt): the grounded requirement
323
+ * list rides into decompose so structure-mirroring can't discharge it. */
324
+ export function buildRequirementsLedger(requirements) {
325
+ if (requirements.length === 0)
326
+ return '';
327
+ return [
328
+ 'REQUIRED CONTENT LEDGER (verbatim from the spec, mechanically grounded — the task',
329
+ 'list must collectively carry EVERY entry, whatever structure you follow; mirroring',
330
+ "the spec's own milestone/section list does NOT by itself discharge these):",
331
+ ...requirements.map((r, i) => `${i + 1}. "${r.quote}"${r.anchor ? ` [${r.anchor}]` : ''}`),
332
+ ''
333
+ ].join('\n');
334
+ }
@@ -0,0 +1,36 @@
1
+ export interface AbsenceAssertion {
2
+ /** The verify line carrying the assertion (trimmed). */
3
+ line: string;
4
+ /** The path or grep pattern asserted absent (quotes stripped). */
5
+ target: string;
6
+ kind: 'path' | 'pattern';
7
+ }
8
+ /**
9
+ * All absence assertions in the spec's VERIFY block. Scans the fenced commands:
10
+ * `if` blocks whose body reaches `exit <nonzero>` contribute their condition's
11
+ * POSITIVE probes; other lines contribute their NEGATED probes.
12
+ */
13
+ export declare function findAbsenceAssertions(spec: string): AbsenceAssertion[];
14
+ export interface AbsenceConflict {
15
+ assertion: AbsenceAssertion;
16
+ against: 'disk' | 'sibling' | 'contract';
17
+ /** What it collided with (the sibling title / contract line / the path). */
18
+ detail: string;
19
+ }
20
+ export interface AbsenceConflictContext {
21
+ /** Does this (repo-relative) path exist in the worktree right now? */
22
+ fileExists: (path: string) => boolean;
23
+ /** SIBLING task titles only — never this task's own title. */
24
+ siblingTitles: string[];
25
+ /** The cross-slice contract registry text ('' when absent). */
26
+ contracts: string;
27
+ }
28
+ /** Absence assertions that collide with the plan: the deterministic D lever. */
29
+ export declare function findAbsenceConflicts(spec: string, ctx: AbsenceConflictContext): AbsenceConflict[];
30
+ /** The forced critique-rewrite defect text (skip-escape pattern: MANDATORY,
31
+ * self-contained, names the reconciliation). */
32
+ export declare function absenceProbeText(conflicts: AbsenceConflict[]): string;
33
+ /** Parse SIBLING titles out of the scope-fence text (buildScopeFence's listing),
34
+ * excluding the "(THIS STEP)" line — a task may legitimately assert about its
35
+ * own deliverables. '' / undefined (bare /task, no plan) yields none. */
36
+ export declare function siblingTitlesFromPlanContext(planContext: string | undefined): string[];
@@ -0,0 +1,203 @@
1
+ /**
2
+ * verify-reconcile — deterministic reconciliation of a spec's VERIFY assertions
3
+ * against the PLAN (mx5 run 11, goal D).
4
+ *
5
+ * The failure this closes: TASK_0009's composed spec carried
6
+ * `if [ -f src/client/pages/admin.tsx ] || grep -rn 'Admin' src/client/pages/ …; then … exit 1`
7
+ * — a NEGATIVE EXISTENCE assertion on a SIBLING task's pinned deliverable
8
+ * (TASK_0008 shipped the admin page; the design pins the admin routes). The
9
+ * scope fence ("do NOT build other steps' work") leaked into the verify script
10
+ * as "other steps' work must be ABSENT". Verify then correctly FAILed at run
11
+ * end, the user accepted it as debt, and the final-gate autofix treated the
12
+ * debt as an instruction and `rm`'d the sibling's deliverable.
13
+ *
14
+ * A sibling's deliverable is a CLAIM about the tree this task runs in — never a
15
+ * valid absence target. The defect must die at SPEC time (a forced critique
16
+ * rewrite, the skip-escape pattern), not surface as a run-end verify-FAIL.
17
+ *
18
+ * Detection is deterministic and shape-aware for how these specs actually write
19
+ * absence checks (measured on the real run-11 artifacts):
20
+ * - `if <cond>; then … exit 1; fi` where <cond> contains a POSITIVE existence
21
+ * probe (`[ -f P ]`, `test -e P`, `grep PAT …`): P/PAT must be absent or the
22
+ * verify fails.
23
+ * - a standalone / `|| exit`-guarded NEGATED probe (`[ ! -f P ]`,
24
+ * `test ! -f P`, `! grep PAT …`): same meaning outside an if-header.
25
+ * `grep -v` occurrences are filters, not probes, and are skipped.
26
+ *
27
+ * A detected absence target is a CONFLICT when the plan pins it elsewhere:
28
+ * - 'disk' — the path already exists in the worktree at spec time (a
29
+ * sibling shipped it; asserting its absence is a guaranteed
30
+ * future FAIL);
31
+ * - 'sibling' — a SIBLING task's title names it;
32
+ * - 'contract' — the cross-slice contract registry quotes it.
33
+ * Matching: pattern targets by case-insensitive substring (all-words matching
34
+ * false-fires on `bun:sql` vs a "Bun SQL connection" title); path targets by
35
+ * full-path substring or a word-bounded basename. No similarity thresholds.
36
+ *
37
+ * The finding is a PROBE, not a gate: it forces the critique rewrite and names
38
+ * the reconciliation (delete-tasks legitimately assert absence — the rewrite
39
+ * keeps the check when the GOAL explicitly deletes the artifact).
40
+ */
41
+ import { parseVerifyBlock } from './spec-validation.js';
42
+ /** A quoted or bare shell word. */
43
+ const SHELL_WORD = `"[^"]+"|'[^']+'|[^\\s\\]&|;)]+`;
44
+ /** Positive existence probes: `-f P` / `-e P` / `-d P` not preceded by `!`. */
45
+ const EXIST_RE = new RegExp(`(!?)\\s*(?:\\[\\[?\\s*)?(!?)\\s*(?:test\\s+)?(!?)\\s*-[efd]\\s+(${SHELL_WORD})`, 'g');
46
+ /** grep probes: optional leading `!`, flags, then the first pattern argument. */
47
+ const GREP_RE = new RegExp(`(!\\s+)?\\bgrep\\s+((?:-{1,2}[\\w=,.-]+\\s+)*)(${SHELL_WORD})`, 'g');
48
+ function stripQuotes(w) {
49
+ const m = /^"(.*)"$|^'(.*)'$/.exec(w);
50
+ return m ? (m[1] ?? m[2] ?? '') : w;
51
+ }
52
+ /** Extract absence assertions from one shell fragment.
53
+ * `positiveMeansAbsent` is true inside an `if …; then … exit 1` condition. */
54
+ function assertionsInFragment(fragment, positiveMeansAbsent) {
55
+ const out = [];
56
+ const line = fragment.trim();
57
+ for (const m of line.matchAll(EXIST_RE)) {
58
+ const negated = Boolean(m[1] || m[2] || m[3]);
59
+ const wantsAbsent = positiveMeansAbsent ? !negated : negated;
60
+ if (!wantsAbsent)
61
+ continue;
62
+ const target = stripQuotes(m[4]).trim();
63
+ if (target.length === 0 || target.startsWith('-') || target.startsWith('$'))
64
+ continue;
65
+ out.push({ line, target, kind: 'path' });
66
+ }
67
+ for (const m of line.matchAll(GREP_RE)) {
68
+ const flags = m[2] ?? '';
69
+ if (/(?:^|\s)-\w*v|--invert-match/.test(flags))
70
+ continue; // a filter, not a probe
71
+ const negated = Boolean(m[1]);
72
+ const wantsAbsent = positiveMeansAbsent ? !negated : negated;
73
+ if (!wantsAbsent)
74
+ continue;
75
+ const target = stripQuotes(m[3]).trim();
76
+ if (target.length === 0 || target.startsWith('-') || target.startsWith('$'))
77
+ continue;
78
+ out.push({ line, target, kind: 'pattern' });
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * All absence assertions in the spec's VERIFY block. Scans the fenced commands:
84
+ * `if` blocks whose body reaches `exit <nonzero>` contribute their condition's
85
+ * POSITIVE probes; other lines contribute their NEGATED probes.
86
+ */
87
+ export function findAbsenceAssertions(spec) {
88
+ const cmds = parseVerifyBlock(spec);
89
+ if (cmds === null)
90
+ return [];
91
+ const lines = cmds.map(c => c.raw);
92
+ const out = [];
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const ifm = /^if\s+(.+?)(?:;\s*then\b.*)?$/.exec(lines[i]);
95
+ if (ifm) {
96
+ // Collect the block body up to the matching fi (flat scan — generated
97
+ // VERIFY blocks don't nest ifs; a nested one just extends the body).
98
+ let depth = 1;
99
+ let body = '';
100
+ let j = i + 1;
101
+ for (; j < lines.length && depth > 0; j++) {
102
+ if (/^if\b/.test(lines[j]))
103
+ depth++;
104
+ if (/^fi\b/.test(lines[j]))
105
+ depth--;
106
+ if (depth > 0)
107
+ body += lines[j] + '\n';
108
+ }
109
+ if (/\bexit\s+[1-9]/.test(body)) {
110
+ out.push(...assertionsInFragment(ifm[1], true));
111
+ }
112
+ i = j - 1;
113
+ continue;
114
+ }
115
+ out.push(...assertionsInFragment(lines[i], false));
116
+ }
117
+ return out;
118
+ }
119
+ function escapeRe(s) {
120
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
121
+ }
122
+ /** basename without extension: src/client/pages/admin.tsx → admin */
123
+ function basenameSansExt(p) {
124
+ const base = p.split('/').pop() ?? p;
125
+ const dot = base.lastIndexOf('.');
126
+ return dot > 0 ? base.slice(0, dot) : base;
127
+ }
128
+ /** Does `haystack` name this target? Patterns match by ci-substring; paths by
129
+ * full-path substring or word-bounded basename. */
130
+ function namesTarget(haystack, a) {
131
+ const h = haystack.toLowerCase();
132
+ if (a.kind === 'pattern')
133
+ return h.includes(a.target.toLowerCase());
134
+ if (h.includes(a.target.toLowerCase()))
135
+ return true;
136
+ const base = basenameSansExt(a.target);
137
+ if (base.length === 0)
138
+ return false;
139
+ return new RegExp(`\\b${escapeRe(base)}\\b`, 'i').test(haystack);
140
+ }
141
+ /** Absence assertions that collide with the plan: the deterministic D lever. */
142
+ export function findAbsenceConflicts(spec, ctx) {
143
+ const out = [];
144
+ for (const a of findAbsenceAssertions(spec)) {
145
+ if (a.kind === 'path' && ctx.fileExists(a.target)) {
146
+ out.push({ assertion: a, against: 'disk', detail: a.target });
147
+ }
148
+ for (const title of ctx.siblingTitles) {
149
+ if (namesTarget(title, a)) {
150
+ out.push({ assertion: a, against: 'sibling', detail: title });
151
+ break;
152
+ }
153
+ }
154
+ if (ctx.contracts.trim().length > 0) {
155
+ const line = ctx.contracts.split('\n').find(l => namesTarget(l, a));
156
+ if (line !== undefined) {
157
+ out.push({ assertion: a, against: 'contract', detail: line.trim() });
158
+ }
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+ /** The forced critique-rewrite defect text (skip-escape pattern: MANDATORY,
164
+ * self-contained, names the reconciliation). */
165
+ export function absenceProbeText(conflicts) {
166
+ const what = {
167
+ disk: 'ALREADY EXISTS in the worktree (a prior task shipped it)',
168
+ sibling: "is a SIBLING task's deliverable",
169
+ contract: 'is pinned by a cross-slice contract'
170
+ };
171
+ const items = conflicts.map(c => `- VERIFY asserts \`${c.assertion.target}\` must be ABSENT (\`${c.assertion.line.slice(0, 120)}\`), `
172
+ + `but it ${what[c.against]}: ${c.detail.slice(0, 160)}`);
173
+ return [
174
+ 'PLAN-CONTRADICTION FINDING (deterministic; MUST be resolved, it overrides a CLEAN triage):',
175
+ ...items,
176
+ "A sibling step's deliverable is a FACT about the tree this task runs in, never a",
177
+ "violation. The plan's scope fence forbids THIS task from BUILDING sibling work; it",
178
+ 'does not make sibling work absent. A check like this is guaranteed to FAIL at run',
179
+ "end and misleads automated fixers into DELETING the sibling's shipped work.",
180
+ "REWRITE each flagged check to assert THIS task's own deliverables (what it adds or",
181
+ "changes). Keep an absence check ONLY if this task's GOAL explicitly deletes that",
182
+ "exact artifact — then say so in the check's comment."
183
+ ].join('\n');
184
+ }
185
+ /** Parse SIBLING titles out of the scope-fence text (buildScopeFence's listing),
186
+ * excluding the "(THIS STEP)" line — a task may legitimately assert about its
187
+ * own deliverables. '' / undefined (bare /task, no plan) yields none. */
188
+ export function siblingTitlesFromPlanContext(planContext) {
189
+ if (!planContext)
190
+ return [];
191
+ const out = [];
192
+ for (const line of planContext.split('\n')) {
193
+ const m = /^\[\d+\](.*)$/.exec(line.trim());
194
+ if (!m)
195
+ continue;
196
+ if (m[1].startsWith(' (THIS STEP)'))
197
+ continue;
198
+ const title = m[1].trim();
199
+ if (title.length > 0)
200
+ out.push(title);
201
+ }
202
+ return out;
203
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * write-guard — deterministic tree-change accounting for WRITE-CAPABLE gate
3
+ * children (mx5 run 11).
4
+ *
5
+ * The failure class: the final-gate autofix child (read,edit,bash) was added after
6
+ * the run-8 guard generation and inherited NONE of the guards the other
7
+ * write-capable passes carry — no diff capture, no frozen-path deny, no probe
8
+ * scans, free `rm`. Run 11 it deleted `src/client/pages/admin.tsx` (TASK_0008's
9
+ * verified deliverable) to satisfy a recorded debt claim, and the deletion was
10
+ * invisible: nothing even logged what the pass changed.
11
+ *
12
+ * This module is the pure half of the guard stack: parse `git status --porcelain`
13
+ * into a change summary (the diff-capture log line every write-capable child now
14
+ * gets at the gate-deps seam), and classify tracked-file DELETIONS. A fix pass
15
+ * exists to repair the assembled repository, not to shrink it: every tracked file
16
+ * is a committed task's deliverable, so deleting one is rejected outright — with
17
+ * one allowance, a RELOCATION (the same file name reappears as an added file
18
+ * elsewhere, e.g. moving a test the runner was never meant to pick up out of its
19
+ * glob — the legitimate fix shape from run 7). Pure text/path analysis; no git
20
+ * execution, no stack assumptions.
21
+ */
22
+ /** What a write-capable pass changed, from `git status --porcelain`. */
23
+ export interface TreeChangeSummary {
24
+ /** Tracked files modified in place (includes rename targets). */
25
+ modified: string[];
26
+ /** Tracked files deleted from the worktree/index (includes rename sources). */
27
+ deleted: string[];
28
+ /** New files: untracked (`??`) or staged adds (includes rename targets). */
29
+ added: string[];
30
+ }
31
+ /**
32
+ * Parse `git status --porcelain` output into the change summary. A rename entry
33
+ * contributes its source to `deleted` and its target to `added` (unstaged child
34
+ * edits show the same reality as separate ` D old` + `?? new` lines, so both
35
+ * shapes classify identically). Deterministic and pure so it is unit-tested
36
+ * without a repo.
37
+ */
38
+ export declare function parseTreeChanges(porcelain: string): TreeChangeSummary;
39
+ /**
40
+ * The deletions a fix pass may NOT make: every deleted tracked file whose name does
41
+ * not reappear among the added files (a relocation keeps the file, under the same
42
+ * name, somewhere in the tree). Anything returned here rejects the whole fix
43
+ * attempt — run 11's `rm src/client/pages/admin.tsx` had no corresponding add and
44
+ * destroyed a sibling task's verified deliverable.
45
+ */
46
+ export declare function findForbiddenDeletions(changes: TreeChangeSummary): string[];
47
+ /**
48
+ * One-line summary for the gate debug log — the diff capture every write-capable
49
+ * child gets so "what did this pass change" is answerable from artifacts (the
50
+ * run-11 `rm` left no trace outside the bash stream).
51
+ */
52
+ export declare function formatTreeChanges(changes: TreeChangeSummary): string;