@mjasnikovs/pi-task 0.29.0 → 0.29.2

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.
@@ -45,7 +45,8 @@ import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, app
45
45
  import { reconcileTitleSources } from './decompose-fidelity.js';
46
46
  import { granularityFloor, granularitySplitHint, isPlanShapeQuestion, isTooCoarse, planShapeIsHostsToAnswer, PLAN_SHAPE_ANSWER } from './decompose-granularity.js';
47
47
  import { mandatesTestsInSameChange, rewriteBatchTestPlan } from './batch-test-task.js';
48
- import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
48
+ import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, writeOwnedRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger, readOwnedRequirements } from './requirements.js';
49
+ import { unclaimedPendingRequirements } from './owned-freeze-reassign.js';
49
50
  import { decideAdoption, groundedCoverage } from './coverage-loop.js';
50
51
  import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
51
52
  import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
@@ -1239,6 +1240,22 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
1239
1240
  // compared against it rather than blindly re-printed.
1240
1241
  let reportedDebts = fin.openDebts ?? [];
1241
1242
  await surfaceOpenDebts(reportedDebts);
1243
+ // An owned obligation a task DETACHED (its own spec froze the
1244
+ // only file that could satisfy it, nexttask 2) and no later
1245
+ // task claimed. Detach never deletes the quote, so the run
1246
+ // ends holding it — say so, or the resolution would be a
1247
+ // quieter version of the deletion it exists to prevent.
1248
+ const unclaimed = unclaimedPendingRequirements(await readOwnedRequirements(cwd).catch(() => []));
1249
+ for (const o of unclaimed) {
1250
+ await recGate(`owned requirement UNCLAIMED — "${o.quote.slice(0, 200)}"`
1251
+ + ` [frozen in "${o.title.slice(0, 60)}"; no task claimed`
1252
+ + ` ${(o.pending ?? []).join(', ')}]`);
1253
+ }
1254
+ if (unclaimed.length > 0) {
1255
+ active.ui.notify(`${id}: ${unclaimed.length} authoritative design requirement(s) ended the run`
1256
+ + ' owned by NO task — the task they were mapped to could not touch the'
1257
+ + ' file, and nothing else claimed it. See the gate trail.', 'warning');
1258
+ }
1242
1259
  /**
1243
1260
  * nexttask 6 (mx5 run 18). The lines above are emitted from the
1244
1261
  * FIRST gate result; the converged-autofix paths below used to
@@ -32,7 +32,17 @@
32
32
  * outside of X", "any files other than X", "only X may be modified",
33
33
  * "no files outside X").
34
34
  *
35
- * ── STATUS: NOT WIRED. The critique seam FAILED its A/B, 2026-08-04. ─────────
35
+ * ── STATUS, 2026-08-05: WIRED but as a DETACH, not as a rewrite. ──────────
36
+ *
37
+ * `owned-freeze-reassign.ts` consumes this detector at the last spec-producing
38
+ * step (`phases.ts`, right after `appendOwnedConstraints`) and resolves a finding
39
+ * by BOOKKEEPING: the requirement leaves the task that cannot satisfy it and is
40
+ * claimed later by the task whose refined prompt says it writes the frozen file.
41
+ * The quote is never edited, so the deletion failure below cannot recur. A/B-1
42
+ * (scripts/owned-freeze-reassign-ab.ts) PASSes on the corpus with five
43
+ * invariants. The rewrite lever recorded below stays REFUTED and unwired.
44
+ *
45
+ * ── The critique-REWRITE seam FAILED its A/B, 2026-08-04. ────────────────────
36
46
  *
37
47
  * The detector is precise — 1 finding over 58 real composed specs, and it is the
38
48
  * true positive (scripts/owned-freeze-conflict-fp-suite.ts, PASS; STEP 0 in
@@ -0,0 +1,142 @@
1
+ /**
2
+ * owned-freeze-reassign — the DETERMINISTIC resolution for the owned/freeze
3
+ * unsatisfiable pair (nexttask 2): an AUTHORITATIVE owned requirement whose only
4
+ * implementing file the same spec FREEZES. No model, no prose edit. The
5
+ * requirement text is never altered — only which task's ledger entry carries it,
6
+ * so "resolution by deletion" is structurally impossible.
7
+ *
8
+ * WHY NOT A REWRITE. Measured on mx5 run 18 (scripts/live-owned-freeze-conflict
9
+ * -ab.ts, 20 trials/arm): forcing the pair through the critique seam drives
10
+ * pair-present 8/20 → 0/20, but 11 of the 20 resolutions DELETE the
11
+ * AUTHORITATIVE clause instead of moving it, and the delivered VERIFY still
12
+ * greps `package.json` in both arms (6/20). Removal of the pair is not
13
+ * satisfaction of the requirement.
14
+ *
15
+ * ── THE MECHANISM: DETACH, then CLAIM ───────────────────────────────────────
16
+ *
17
+ * Two steps, each run where the information it needs actually exists:
18
+ *
19
+ * DETACH at the conflicting task's own critique (right after the braces
20
+ * stamp the bullet, which is the first moment the pair exists): the
21
+ * entry is marked `pending: [frozen paths]` in the ledger and its
22
+ * stamped bullet is dropped from this spec. It is now owned by
23
+ * nobody; `ownedForTitle` skips it; the quote is still there, byte for
24
+ * byte.
25
+ * CLAIM at every LATER task's compose, before the belt block is built: if
26
+ * that task's REFINED PROMPT shows a write intent on one of the
27
+ * pending paths, the entry becomes that task's own — belt into its
28
+ * compose, braces onto its spec.
29
+ *
30
+ * ── WHY NOT "PICK THE TARGET AT DETACH TIME", WHICH IS WHAT nexttask2.md ASKS ─
31
+ *
32
+ * nexttask2.md's branch 1 is "if exactly one OTHER task in the plan names path
33
+ * `P` — its title or its spec's file list contains `P` — move the requirement
34
+ * there". Measured over all 60 recorded composed specs on disk
35
+ * (scripts/owned-freeze-reassign-baserate.ts), on the one conflict the corpus
36
+ * contains (mx5 run 19 TASK_0015, path `src/server/index.ts`):
37
+ *
38
+ * title-only 3 candidates TASK_0014 TASK_0016 TASK_0017
39
+ * FILES-only 7 candidates TASK_0016 … TASK_0025
40
+ * either 8 candidates → "exactly one" NEVER holds
41
+ *
42
+ * and, decisively, that measurement is against RECORDED SPECS, which production
43
+ * does not have: at the conflicting task's compose the later tasks are still
44
+ * bare plan titles. None of run 19's 26 plan titles contains the string
45
+ * `src/server/index.ts` — TASK_0017's reads "Client API layer — typed
46
+ * hono/client hc<AppType> in api.ts, small fetch/mutation hooks, SPA fallback
47
+ * route on server". A path-lexical target rule at detach time is therefore
48
+ * BLIND IN PRODUCTION (0 candidates), the same way the run-18 critique probe was.
49
+ *
50
+ * The claimant knows what the planner did not. By its own compose, TASK_0017 has
51
+ * a refined prompt that says "add an SPA fallback route to the existing server
52
+ * `src/server/index.ts`". Over run 19's 26 refined prompts, `writeIntent` on that
53
+ * path fires on exactly two: TASK_0014 (which created the file, already run) and
54
+ * TASK_0017. Nothing else in the plan claims it — six other specs mention the
55
+ * path, all of them to fence themselves off it.
56
+ *
57
+ * ── WHY NOT nexttask2.md's BRANCH 2 (CARRY EVERYTHING CROSS-CUTTING) ────────
58
+ *
59
+ * The cross-cutting channel is for prohibitions and product-wide rules
60
+ * (`isCrossCuttingRequirement`; `accountCoverage` only routes those there). The
61
+ * corpus conflict is a task-specific deliverable clause — carrying it would push
62
+ * "the server serves static `dist/`" into the 11 tasks that had not yet run,
63
+ * none of which owns the server file. That is spec inflation dressed as a fix.
64
+ * An entry nobody claims stays `pending` and is surfaced at the end of the run
65
+ * (`unclaimedPendingRequirements`), which is a debt the run can see rather than
66
+ * an obligation that quietly evaporated.
67
+ */
68
+ import { type OwnedFreezeOptions } from './owned-freeze-conflict.js';
69
+ import { type OwnedRequirement } from './requirements.js';
70
+ export type ReassignAction = {
71
+ kind: 'detach';
72
+ quote: string;
73
+ from: string;
74
+ paths: string[];
75
+ } | {
76
+ kind: 'claim';
77
+ quote: string;
78
+ by: string;
79
+ paths: string[];
80
+ } | {
81
+ kind: 'unresolved';
82
+ quote: string;
83
+ from: string;
84
+ paths: string[];
85
+ reason: string;
86
+ };
87
+ export interface DetachResult {
88
+ /** The ledger after the pass — same quotes, one of them now unowned. */
89
+ ledger: OwnedRequirement[];
90
+ /** The spec with the detached requirement's stamped bullet removed.
91
+ * Byte-identical to the input when nothing detached. */
92
+ spec: string;
93
+ actions: ReassignAction[];
94
+ }
95
+ /**
96
+ * Does this text CLAIM a write on `p` — a create/modify verb reaching a mention
97
+ * of the path, not fenced by a negation?
98
+ *
99
+ * The negation half is not decoration. Every sibling task's refined prompt names
100
+ * the files it must not touch ("Do not create or modify any other files outside
101
+ * this slice (e.g., no changes to `src/server/index.ts`, …)" — mx5 run 19
102
+ * TASK_0008). Without the fence check that spec claims the server file; with it,
103
+ * run 19's 26 refined prompts yield exactly the two tasks that do write it.
104
+ */
105
+ export declare function writeIntent(text: string, p: string): boolean;
106
+ /**
107
+ * DETACH every owned requirement this spec makes unsatisfiable: mark it
108
+ * `pending` on the frozen paths and drop its stamped bullet. Pure bookkeeping —
109
+ * no quote leaves the ledger, no prose is touched.
110
+ */
111
+ export declare function detachUnsatisfiableRequirements(args: {
112
+ spec: string;
113
+ /** The executing task's plan title — the ledger's join key. */
114
+ title: string;
115
+ ledger: OwnedRequirement[];
116
+ isSource?: OwnedFreezeOptions['isSource'];
117
+ }): DetachResult;
118
+ export interface ClaimResult {
119
+ ledger: OwnedRequirement[];
120
+ actions: ReassignAction[];
121
+ }
122
+ /**
123
+ * CLAIM the detached requirements this task will actually implement: those whose
124
+ * pending paths its refined prompt writes. Run at the START of compose, so the
125
+ * claimed obligation rides the same belt block every owned requirement does.
126
+ *
127
+ * First claimant wins (`inv-single-owner`): the entry stops being pending the
128
+ * moment it is claimed, so a later task cannot take it as well.
129
+ */
130
+ export declare function claimPendingRequirements(args: {
131
+ /** The claiming task's refined prompt — the text that says what it will write. */
132
+ intent: string;
133
+ /** The claiming task's plan title (the ledger's join key). */
134
+ title: string;
135
+ ledger: OwnedRequirement[];
136
+ }): ClaimResult;
137
+ /** Entries still detached — obligations no task claimed. Surfaced at the end of
138
+ * a run so an unsatisfiable requirement becomes a visible debt instead of a
139
+ * silent drop. */
140
+ export declare function unclaimedPendingRequirements(ledger: OwnedRequirement[]): OwnedRequirement[];
141
+ /** One line per action, for the run's debug log. */
142
+ export declare function formatReassignActions(actions: ReassignAction[]): string;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * owned-freeze-reassign — the DETERMINISTIC resolution for the owned/freeze
3
+ * unsatisfiable pair (nexttask 2): an AUTHORITATIVE owned requirement whose only
4
+ * implementing file the same spec FREEZES. No model, no prose edit. The
5
+ * requirement text is never altered — only which task's ledger entry carries it,
6
+ * so "resolution by deletion" is structurally impossible.
7
+ *
8
+ * WHY NOT A REWRITE. Measured on mx5 run 18 (scripts/live-owned-freeze-conflict
9
+ * -ab.ts, 20 trials/arm): forcing the pair through the critique seam drives
10
+ * pair-present 8/20 → 0/20, but 11 of the 20 resolutions DELETE the
11
+ * AUTHORITATIVE clause instead of moving it, and the delivered VERIFY still
12
+ * greps `package.json` in both arms (6/20). Removal of the pair is not
13
+ * satisfaction of the requirement.
14
+ *
15
+ * ── THE MECHANISM: DETACH, then CLAIM ───────────────────────────────────────
16
+ *
17
+ * Two steps, each run where the information it needs actually exists:
18
+ *
19
+ * DETACH at the conflicting task's own critique (right after the braces
20
+ * stamp the bullet, which is the first moment the pair exists): the
21
+ * entry is marked `pending: [frozen paths]` in the ledger and its
22
+ * stamped bullet is dropped from this spec. It is now owned by
23
+ * nobody; `ownedForTitle` skips it; the quote is still there, byte for
24
+ * byte.
25
+ * CLAIM at every LATER task's compose, before the belt block is built: if
26
+ * that task's REFINED PROMPT shows a write intent on one of the
27
+ * pending paths, the entry becomes that task's own — belt into its
28
+ * compose, braces onto its spec.
29
+ *
30
+ * ── WHY NOT "PICK THE TARGET AT DETACH TIME", WHICH IS WHAT nexttask2.md ASKS ─
31
+ *
32
+ * nexttask2.md's branch 1 is "if exactly one OTHER task in the plan names path
33
+ * `P` — its title or its spec's file list contains `P` — move the requirement
34
+ * there". Measured over all 60 recorded composed specs on disk
35
+ * (scripts/owned-freeze-reassign-baserate.ts), on the one conflict the corpus
36
+ * contains (mx5 run 19 TASK_0015, path `src/server/index.ts`):
37
+ *
38
+ * title-only 3 candidates TASK_0014 TASK_0016 TASK_0017
39
+ * FILES-only 7 candidates TASK_0016 … TASK_0025
40
+ * either 8 candidates → "exactly one" NEVER holds
41
+ *
42
+ * and, decisively, that measurement is against RECORDED SPECS, which production
43
+ * does not have: at the conflicting task's compose the later tasks are still
44
+ * bare plan titles. None of run 19's 26 plan titles contains the string
45
+ * `src/server/index.ts` — TASK_0017's reads "Client API layer — typed
46
+ * hono/client hc<AppType> in api.ts, small fetch/mutation hooks, SPA fallback
47
+ * route on server". A path-lexical target rule at detach time is therefore
48
+ * BLIND IN PRODUCTION (0 candidates), the same way the run-18 critique probe was.
49
+ *
50
+ * The claimant knows what the planner did not. By its own compose, TASK_0017 has
51
+ * a refined prompt that says "add an SPA fallback route to the existing server
52
+ * `src/server/index.ts`". Over run 19's 26 refined prompts, `writeIntent` on that
53
+ * path fires on exactly two: TASK_0014 (which created the file, already run) and
54
+ * TASK_0017. Nothing else in the plan claims it — six other specs mention the
55
+ * path, all of them to fence themselves off it.
56
+ *
57
+ * ── WHY NOT nexttask2.md's BRANCH 2 (CARRY EVERYTHING CROSS-CUTTING) ────────
58
+ *
59
+ * The cross-cutting channel is for prohibitions and product-wide rules
60
+ * (`isCrossCuttingRequirement`; `accountCoverage` only routes those there). The
61
+ * corpus conflict is a task-specific deliverable clause — carrying it would push
62
+ * "the server serves static `dist/`" into the 11 tasks that had not yet run,
63
+ * none of which owns the server file. That is spec inflation dressed as a fix.
64
+ * An entry nobody claims stays `pending` and is surfaced at the end of the run
65
+ * (`unclaimedPendingRequirements`), which is a debt the run can see rather than
66
+ * an obligation that quietly evaporated.
67
+ */
68
+ import { findOwnedFreezeConflicts } from './owned-freeze-conflict.js';
69
+ import { ownedForTitle } from './requirements.js';
70
+ import { PROHIBITION_RE } from './prohibition-probe.js';
71
+ /**
72
+ * Does this text CLAIM a write on `p` — a create/modify verb reaching a mention
73
+ * of the path, not fenced by a negation?
74
+ *
75
+ * The negation half is not decoration. Every sibling task's refined prompt names
76
+ * the files it must not touch ("Do not create or modify any other files outside
77
+ * this slice (e.g., no changes to `src/server/index.ts`, …)" — mx5 run 19
78
+ * TASK_0008). Without the fence check that spec claims the server file; with it,
79
+ * run 19's 26 refined prompts yield exactly the two tasks that do write it.
80
+ */
81
+ export function writeIntent(text, p) {
82
+ const re = new RegExp(String.raw `\b(?:creat|add|implement|modif|updat|extend|wir|writ|mount|chang)\w*\b`
83
+ + String.raw `[^.\n]{0,140}?`
84
+ + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
85
+ for (const m of text.matchAll(re)) {
86
+ // The negation usually sits BEFORE the verb, so the window reaches back
87
+ // past it — matching only the verb→path span misses TASK_0008 entirely.
88
+ const from = Math.max(0, (m.index ?? 0) - 90);
89
+ const window = text.slice(from, (m.index ?? 0) + m[0].length);
90
+ if (NEGATED_RE.test(window) || PROHIBITION_RE.test(window))
91
+ continue;
92
+ return true;
93
+ }
94
+ return false;
95
+ }
96
+ /** Negations that turn a write verb into a fence. Deliberately broader than
97
+ * `PROHIBITION_RE`, which requires "do not"+verb adjacency and so never sees
98
+ * "no changes to `X`" or "any files other than `Y`". */
99
+ const NEGATED_RE = /\b(?:do(?:es)?\s+not|don'?t|must\s+not|may\s+not|never|no|not|other\s+than|outside(?:\s+of)?|except|besides|avoid|without|nor|unchanged|untouched)\b/i;
100
+ /** The machine stamp `appendOwnedConstraints` writes. Only stamped lines are
101
+ * ever removed — a quote compose folded in itself is model prose, and this pass
102
+ * does not edit prose. */
103
+ const STAMP_RE = /owned\s+requirement\s+from\s+the\s+source\s+design/i;
104
+ function stampedLineFor(spec, quote) {
105
+ for (const raw of spec.split('\n')) {
106
+ if (STAMP_RE.test(raw) && raw.includes(quote))
107
+ return raw;
108
+ }
109
+ return null;
110
+ }
111
+ function dropLine(spec, line) {
112
+ const lines = spec.split('\n');
113
+ const i = lines.indexOf(line);
114
+ if (i < 0)
115
+ return spec;
116
+ lines.splice(i, 1);
117
+ return lines.join('\n');
118
+ }
119
+ /**
120
+ * DETACH every owned requirement this spec makes unsatisfiable: mark it
121
+ * `pending` on the frozen paths and drop its stamped bullet. Pure bookkeeping —
122
+ * no quote leaves the ledger, no prose is touched.
123
+ */
124
+ export function detachUnsatisfiableRequirements(args) {
125
+ const mine = ownedForTitle(args.ledger, args.title);
126
+ const conflicts = findOwnedFreezeConflicts(args.spec, {
127
+ owned: mine,
128
+ isSource: args.isSource
129
+ });
130
+ const out = {
131
+ ledger: args.ledger.map(o => ({ ...o })),
132
+ spec: args.spec,
133
+ actions: []
134
+ };
135
+ for (const c of conflicts) {
136
+ const entry = out.ledger.find(o => !(o.pending && o.pending.length > 0)
137
+ && c.requirement.includes(o.quote)
138
+ && normalise(o.title) === normalise(args.title));
139
+ if (!entry) {
140
+ // The conflicting line is not one of THIS task's ledger quotes (a
141
+ // stamp from an earlier plan round, or a quote the ledger no longer
142
+ // holds). Nothing to detach; the spec is left exactly as it is.
143
+ out.actions.push({
144
+ kind: 'unresolved',
145
+ quote: c.requirement,
146
+ from: args.title,
147
+ paths: c.paths,
148
+ reason: 'no ledger entry owns this line'
149
+ });
150
+ continue;
151
+ }
152
+ const line = stampedLineFor(out.spec, entry.quote);
153
+ if (line === null) {
154
+ out.actions.push({
155
+ kind: 'unresolved',
156
+ quote: entry.quote,
157
+ from: args.title,
158
+ paths: c.paths,
159
+ reason: 'the quote is model prose, not a machine-stamped bullet'
160
+ });
161
+ continue;
162
+ }
163
+ entry.pending = [...c.paths];
164
+ out.spec = dropLine(out.spec, line);
165
+ out.actions.push({ kind: 'detach', quote: entry.quote, from: args.title, paths: c.paths });
166
+ }
167
+ return out;
168
+ }
169
+ /**
170
+ * CLAIM the detached requirements this task will actually implement: those whose
171
+ * pending paths its refined prompt writes. Run at the START of compose, so the
172
+ * claimed obligation rides the same belt block every owned requirement does.
173
+ *
174
+ * First claimant wins (`inv-single-owner`): the entry stops being pending the
175
+ * moment it is claimed, so a later task cannot take it as well.
176
+ */
177
+ export function claimPendingRequirements(args) {
178
+ const out = { ledger: args.ledger.map(o => ({ ...o })), actions: [] };
179
+ if (args.title.trim().length === 0)
180
+ return out;
181
+ for (const entry of out.ledger) {
182
+ const paths = entry.pending ?? [];
183
+ if (paths.length === 0)
184
+ continue;
185
+ const claimed = paths.filter(p => writeIntent(args.intent, p));
186
+ if (claimed.length === 0)
187
+ continue;
188
+ delete entry.pending;
189
+ entry.title = args.title;
190
+ out.actions.push({ kind: 'claim', quote: entry.quote, by: args.title, paths: claimed });
191
+ }
192
+ return out;
193
+ }
194
+ /** Entries still detached — obligations no task claimed. Surfaced at the end of
195
+ * a run so an unsatisfiable requirement becomes a visible debt instead of a
196
+ * silent drop. */
197
+ export function unclaimedPendingRequirements(ledger) {
198
+ return ledger.filter(o => (o.pending ?? []).length > 0);
199
+ }
200
+ const normalise = (s) => s.trim().toLowerCase().replace(/\s+/g, ' ');
201
+ /** One line per action, for the run's debug log. */
202
+ export function formatReassignActions(actions) {
203
+ return actions
204
+ .map(a => a.kind === 'detach' ?
205
+ `owned-freeze DETACH: "${a.quote.slice(0, 80)}" — frozen ${a.paths.join(', ')} in this task;`
206
+ + ' released to the task that writes it'
207
+ : a.kind === 'claim' ?
208
+ `owned-freeze CLAIM: "${a.quote.slice(0, 80)}" — this task writes ${a.paths.join(', ')}`
209
+ : `owned-freeze UNRESOLVED: "${a.quote.slice(0, 80)}" — ${a.reason}`)
210
+ .join('\n');
211
+ }
@@ -71,6 +71,33 @@ export declare function phaseContractsBlock(deps: PhaseDeps): Promise<string>;
71
71
  * /task is byte-identical to before.
72
72
  */
73
73
  export declare function phaseCarriedBlocks(deps: PhaseDeps): Promise<string>;
74
+ /**
75
+ * DETACH (nexttask 2) — an owned requirement this task cannot satisfy, because a
76
+ * category freeze in the very spec that carries it covers the only file that
77
+ * could, stops being this task's obligation and is released to whichever later
78
+ * task writes that file.
79
+ *
80
+ * Runs at the LAST spec-producing step, where the pair first exists: the stamped
81
+ * bullet is written one statement earlier by `appendOwnedConstraints`, and a
82
+ * critique-time probe measured 0/40 because the stamp did not exist yet. It
83
+ * never edits prose and never asks a model — the run-18 rewrite lever resolved
84
+ * 11 of 20 pairs by DELETING the authoritative clause. The quote stays in the
85
+ * ledger throughout; only its owner changes.
86
+ */
87
+ export declare function resolveOwnedFreezeForThisTask(deps: PhaseDeps, spec: string): Promise<string>;
88
+ /**
89
+ * CLAIM (nexttask 2) — the other half. A requirement detached by an earlier task
90
+ * becomes THIS task's own when its refined prompt says it writes the frozen
91
+ * file. Run before compose builds its carried blocks, so the claimed obligation
92
+ * rides the same belt every owned requirement does and the braces stamp it onto
93
+ * this spec.
94
+ *
95
+ * The claimant is the only party that knows: at detach time the later tasks are
96
+ * bare plan titles, and none of mx5 run 19's 26 titles contains the path. Over
97
+ * the same run's 26 REFINED prompts, `writeIntent` picks out exactly the two
98
+ * tasks that write the server file.
99
+ */
100
+ export declare function claimOwnedFreezeForThisTask(deps: PhaseDeps, refined: string): Promise<void>;
74
101
  export declare const phaseRefine: (deps: PhaseDeps, raw: string, planContext?: string) => Promise<string>;
75
102
  export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): Promise<string>;
76
103
  export interface PhaseResearchDeps extends ExternalContextDeps {
@@ -24,6 +24,7 @@ import { formatServiceBlock, formatFreshnessSkippedBlock } from './service-block
24
24
  import { gatherExternalContext } from './external-context.js';
25
25
  import { REFINE_PROMPT, RESEARCH_FILES_PROMPT, RESEARCH_APIS_PROMPT, RESEARCH_CONTEXT_PROMPT, RESEARCH_TOOLING_PROMPT, GRILL_GEN_PROMPT, GRILL_AUTO_ANSWER_PROMPT, GRILL_AUTO_FORMAT_HINT, COMPOSE_PROMPT, CRITIQUE_PROMPT, CRITIQUE_TRIAGE_PROMPT, VERIFY_TOOLING_PROMPT, MAX_GRILL_QUESTIONS, appendNoThink } from './prompts.js';
26
26
  import { readSection, removeTaskSection, setTaskSection, updateTaskFrontMatter } from './task-io.js';
27
+ import { spawnSync } from 'node:child_process';
27
28
  import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
28
29
  import { isDuplicateQuestion, MAX_DUP_STRIKES, DUP_REPROMPT_HINT } from './question-dedup.js';
29
30
  import { parseGrillQuestions, parseAutoAnswer, autoAnswerHasTag, parseVerifyToolingOutput, deriveTitle } from './parsers.js';
@@ -38,7 +39,9 @@ import { findSynthesizedApis, synthesizedApiReaskHint } from './api-synthesis.js
38
39
  import { findGrepOnlyVerify, grepOnlyVerifyDefectText, GREP_THEATER_RETRY_HINT } from './verify-quality.js';
39
40
  import { existsSync } from 'node:fs';
40
41
  import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from './contracts.js';
41
- import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
42
+ import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
43
+ import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
44
+ import { trackedSourceOracle } from './owned-freeze-conflict.js';
42
45
  import { runPhaseChild, runPhaseWithLoopGuard, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
43
46
  import { SessionUI } from '../remote/bridge.js';
44
47
  import { isYoloMode, yoloPickAutoAnswer, YOLO_STAMP } from './yolo.js';
@@ -180,6 +183,81 @@ async function ownedForThisTask(deps) {
180
183
  return [];
181
184
  }
182
185
  }
186
+ /** This task's plan title — the owned ledger's join key. */
187
+ async function planTitle(deps) {
188
+ try {
189
+ return ((await readSection(deps.cwd, deps.taskId, 'raw prompt')) ?? '').trim();
190
+ }
191
+ catch {
192
+ return '';
193
+ }
194
+ }
195
+ /** The `isSource` oracle production uses: git tracks the path in this tree. */
196
+ function repoSourceOracle(cwd) {
197
+ return trackedSourceOracle(p => {
198
+ const r = spawnSync('git', ['ls-files', '--', p], { cwd, encoding: 'utf8', timeout: 4000 });
199
+ return { stdout: r.stdout ?? '', exitCode: r.status ?? 1 };
200
+ });
201
+ }
202
+ /**
203
+ * DETACH (nexttask 2) — an owned requirement this task cannot satisfy, because a
204
+ * category freeze in the very spec that carries it covers the only file that
205
+ * could, stops being this task's obligation and is released to whichever later
206
+ * task writes that file.
207
+ *
208
+ * Runs at the LAST spec-producing step, where the pair first exists: the stamped
209
+ * bullet is written one statement earlier by `appendOwnedConstraints`, and a
210
+ * critique-time probe measured 0/40 because the stamp did not exist yet. It
211
+ * never edits prose and never asks a model — the run-18 rewrite lever resolved
212
+ * 11 of 20 pairs by DELETING the authoritative clause. The quote stays in the
213
+ * ledger throughout; only its owner changes.
214
+ */
215
+ export async function resolveOwnedFreezeForThisTask(deps, spec) {
216
+ const ledger = await readOwnedRequirements(deps.cwd).catch(() => []);
217
+ if (ledger.length === 0)
218
+ return spec;
219
+ const title = await planTitle(deps);
220
+ if (title.length === 0)
221
+ return spec;
222
+ const res = detachUnsatisfiableRequirements({
223
+ spec,
224
+ title,
225
+ ledger,
226
+ isSource: repoSourceOracle(deps.cwd)
227
+ });
228
+ if (res.actions.length === 0)
229
+ return spec;
230
+ deps.logDebug?.(formatReassignActions(res.actions));
231
+ if (!res.actions.some(a => a.kind === 'detach'))
232
+ return spec;
233
+ await writeOwnedRequirements(deps.cwd, res.ledger);
234
+ return res.spec;
235
+ }
236
+ /**
237
+ * CLAIM (nexttask 2) — the other half. A requirement detached by an earlier task
238
+ * becomes THIS task's own when its refined prompt says it writes the frozen
239
+ * file. Run before compose builds its carried blocks, so the claimed obligation
240
+ * rides the same belt every owned requirement does and the braces stamp it onto
241
+ * this spec.
242
+ *
243
+ * The claimant is the only party that knows: at detach time the later tasks are
244
+ * bare plan titles, and none of mx5 run 19's 26 titles contains the path. Over
245
+ * the same run's 26 REFINED prompts, `writeIntent` picks out exactly the two
246
+ * tasks that write the server file.
247
+ */
248
+ export async function claimOwnedFreezeForThisTask(deps, refined) {
249
+ const ledger = await readOwnedRequirements(deps.cwd).catch(() => []);
250
+ if (unclaimedPendingRequirements(ledger).length === 0)
251
+ return;
252
+ const title = await planTitle(deps);
253
+ if (title.length === 0)
254
+ return;
255
+ const res = claimPendingRequirements({ intent: refined, title, ledger });
256
+ if (res.actions.length === 0)
257
+ return;
258
+ deps.logDebug?.(formatReassignActions(res.actions));
259
+ await writeOwnedRequirements(deps.cwd, res.ledger);
260
+ }
183
261
  export const phaseRefine = async (deps, raw, planContext) => {
184
262
  const existingFiles = await refineExistingFilesBlock(deps).catch(() => '');
185
263
  const contracts = await phaseCarriedBlocks(deps);
@@ -1247,6 +1325,10 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
1247
1325
  return out.join('\n');
1248
1326
  }
1249
1327
  export async function phaseCompose(deps, refined, research, qa) {
1328
+ // CLAIM before the belt is built: an obligation an earlier task had to
1329
+ // detach (its own spec froze the only file that could satisfy it) becomes
1330
+ // this task's own when this task is the one that writes that file.
1331
+ await claimOwnedFreezeForThisTask(deps, refined).catch(() => { });
1250
1332
  const contracts = await phaseCarriedBlocks(deps);
1251
1333
  return runWithEmphasisRetry(deps, 'compose', 'read', problem => COMPOSE_PROMPT(refined, research, qa, problem, contracts), text => {
1252
1334
  // Trim any "here's the spec:" preamble before validating, so a
@@ -1565,7 +1647,10 @@ export const PHASES = [
1565
1647
  if (out !== spec) {
1566
1648
  d.logDebug?.('owned-requirements braces: appended omitted design obligation(s) to CONSTRAINTS');
1567
1649
  }
1568
- return out;
1650
+ // An owned obligation whose only file this spec also FREEZES is
1651
+ // unsatisfiable here; move it to the pending task that writes that
1652
+ // file rather than shipping a requirement no one can meet.
1653
+ return await resolveOwnedFreezeForThisTask(d, out);
1569
1654
  }
1570
1655
  }
1571
1656
  ];
@@ -132,6 +132,16 @@ export interface OwnedRequirement {
132
132
  * against the executing task's title at phase time (ids don't exist yet at
133
133
  * plan time, and spliced repair tasks shift them). */
134
134
  title: string;
135
+ /**
136
+ * DETACHED (nexttask 2, owned-freeze-reassign.ts): the files this obligation
137
+ * names that its assigned task FROZE, making it unsatisfiable there. While
138
+ * set, the entry is owned by nobody — `ownedForTitle` skips it — and `title`
139
+ * records only where it came from. The next task whose refined prompt shows
140
+ * a write intent on one of these paths claims it (clearing this field), and
141
+ * an entry nobody claims is surfaced at the end of the run rather than
142
+ * silently dropped. Absent on every ordinary entry.
143
+ */
144
+ pending?: string[];
135
145
  }
136
146
  export declare function ownedRequirementsFile(cwd: string): string;
137
147
  /** Persist the task-mapped requirements (host-side, plan time). Overwrites —
@@ -142,7 +152,9 @@ export declare function readOwnedRequirements(cwd: string): Promise<OwnedRequire
142
152
  export declare function parseOwnedRequirements(text: string): OwnedRequirement[];
143
153
  /** The owned entries whose plan title matches THIS task's title (normalised
144
154
  * equality — titles travel verbatim from the plan list into task creation;
145
- * spliced repair tasks simply match nothing). */
155
+ * spliced repair tasks simply match nothing). A DETACHED entry (`pending`) is
156
+ * owned by nobody until a task claims it, so it is never returned here — its
157
+ * `title` is provenance, not ownership. */
146
158
  export declare function ownedForTitle(owned: OwnedRequirement[], title: string): OwnedRequirement[];
147
159
  /** The injection block for a task's OWN mapped obligations. Mirrors
148
160
  * buildRequirementsBlock (the directive pattern that measurably works) but is
@@ -602,7 +602,9 @@ export function ownedRequirementsFile(cwd) {
602
602
  export async function writeOwnedRequirements(cwd, owned) {
603
603
  try {
604
604
  await fsp.mkdir(tasksDir(cwd), { recursive: true });
605
- const lines = owned.map(o => `OWNED: "${o.quote}"${o.anchor ? ` [anchor: ${o.anchor}]` : ''} [title: ${o.title.replace(/\n/g, ' ')}]`);
605
+ const lines = owned.map(o => `OWNED: "${o.quote}"${o.anchor ? ` [anchor: ${o.anchor}]` : ''}`
606
+ + (o.pending && o.pending.length > 0 ? ` [pending: ${o.pending.join(', ')}]` : '')
607
+ + ` [title: ${o.title.replace(/\n/g, ' ')}]`);
606
608
  await fsp.writeFile(ownedRequirementsFile(cwd), lines.join('\n') + '\n', 'utf8');
607
609
  }
608
610
  catch {
@@ -619,23 +621,30 @@ export async function readOwnedRequirements(cwd) {
619
621
  }
620
622
  export function parseOwnedRequirements(text) {
621
623
  const out = [];
622
- for (const m of text.matchAll(/^OWNED:\s*"([^"\n]+)"(?:\s*\[anchor:\s*([^\]]*)\])?\s*\[title:\s*([^\n]+)\]\s*$/gim)) {
624
+ for (const m of text.matchAll(/^OWNED:\s*"([^"\n]+)"(?:\s*\[anchor:\s*([^\]]*)\])?(?:\s*\[pending:\s*([^\]]*)\])?\s*\[title:\s*([^\n]+)\]\s*$/gim)) {
625
+ const pending = (m[3] ?? '')
626
+ .split(',')
627
+ .map(p => p.trim())
628
+ .filter(p => p.length > 0);
623
629
  out.push({
624
630
  quote: m[1].trim(),
625
631
  anchor: (m[2] ?? '').trim(),
626
- title: m[3].replace(/\]\s*$/, '').trim()
632
+ title: m[4].replace(/\]\s*$/, '').trim(),
633
+ ...(pending.length > 0 ? { pending } : {})
627
634
  });
628
635
  }
629
636
  return out;
630
637
  }
631
638
  /** The owned entries whose plan title matches THIS task's title (normalised
632
639
  * equality — titles travel verbatim from the plan list into task creation;
633
- * spliced repair tasks simply match nothing). */
640
+ * spliced repair tasks simply match nothing). A DETACHED entry (`pending`) is
641
+ * owned by nobody until a task claims it, so it is never returned here — its
642
+ * `title` is provenance, not ownership. */
634
643
  export function ownedForTitle(owned, title) {
635
644
  const t = normalise(title);
636
645
  if (t.length === 0)
637
646
  return [];
638
- return owned.filter(o => normalise(o.title) === t);
647
+ return owned.filter(o => normalise(o.title) === t && !(o.pending && o.pending.length > 0));
639
648
  }
640
649
  /** The injection block for a task's OWN mapped obligations. Mirrors
641
650
  * buildRequirementsBlock (the directive pattern that measurably works) but is
@@ -106,9 +106,24 @@ export async function runAutoInstall(spawn, packageName, signal, versionRange) {
106
106
  // `shell: false` in runChild, so a `^`/`~`/space in the range stays a single
107
107
  // literal arg — no glob/expansion risk from `<pkg>@<range>`.
108
108
  const target = versionRange ? `${packageName}@${versionRange}` : packageName;
109
+ // `--ignore-scripts` is not optional here. The package NAME is model-chosen —
110
+ // it comes out of a worker's question, or out of a `/// <reference types="X" />`
111
+ // line in someone else's declaration file — so a hallucinated or typosquatted
112
+ // name would otherwise run its preinstall/postinstall as the user. This cache
113
+ // has already run install hooks for `node`, `argon2`, `onnxruntime-node` and
114
+ // `sharp`, next to fetched names like `app.ts`, `pkg.json` and `tsconfig.json`.
115
+ // Nothing is lost: the docs worker only ever READS `.d.ts` files and the README
116
+ // out of the installed tree, and those ship in the tarball.
109
117
  const result = await runChild(spawn, {
110
118
  command: 'npm',
111
- args: ['install', '--no-audit', '--no-fund', '--loglevel=error', target]
119
+ args: [
120
+ 'install',
121
+ '--ignore-scripts',
122
+ '--no-audit',
123
+ '--no-fund',
124
+ '--loglevel=error',
125
+ target
126
+ ]
112
127
  }, installDir, signal, { mode: 'text', discardStdout: true });
113
128
  return { success: result.exitCode === 0 && !result.aborted, installDir, stderr: result.stderr };
114
129
  }
@@ -30,9 +30,27 @@ export declare function resolvePackage(moduleName: string, cwd: string): Resolve
30
30
  export declare function typesPackageName(moduleName: string): string | null;
31
31
  /** True if the package ships at least one declaration file. */
32
32
  export declare function hasTypeFiles(root: string): boolean;
33
+ /**
34
+ * Count declarations in a declaration file's text, ignoring comments, blank
35
+ * lines, and the pointer lines a redirect stub is made of (`/// <reference .. />`
36
+ * and `export * from "X"`).
37
+ *
38
+ * This is the discriminator `detectTypesRedirect` needs: a redirect stub is a
39
+ * file with essentially nothing in it but the pointer, while an API surface that
40
+ * merely *declares an ambient dependency* on another types package (the
41
+ * `sharp` -> `/// <reference types="node" />` shape) carries its own
42
+ * declarations. Counting `.d.ts` FILES cannot tell those apart — sharp ships one
43
+ * 1971-line file and `@types/bun` ships one 1-line file, and both count as 1.
44
+ *
45
+ * Deliberately lexical, not a TypeScript parse: this runs in the shipped worker,
46
+ * which has no compiler dependency. Over-counting is the safe direction (a
47
+ * declaration found ⇒ not a stub ⇒ keep the package's own types).
48
+ */
49
+ export declare function countEntryDeclarations(content: string): number;
33
50
  /** When a package is a pure pointer to another types package — a single-file
34
51
  * `/// <reference types="X" />` (the `@types/bun -> bun-types` shape) or a lone
35
52
  * `export * from "X"` re-export — return the target package name. Returns null
36
- * for packages that ship their own declarations (more than one .d.ts file, or a
37
- * local `/// <reference path=... />` aggregator entry). */
53
+ * for packages that ship their own declarations (more than one .d.ts file, a
54
+ * local `/// <reference path=... />` aggregator entry, or an entry file that
55
+ * declares anything of its own). */
38
56
  export declare function detectTypesRedirect(pkg: ResolvedPackage): string | null;
@@ -223,11 +223,55 @@ function isBareSpecifier(spec) {
223
223
  }
224
224
  const REFERENCE_TYPES_RE = /\/\/\/\s*<reference\s+types=["']([^"']+)["']\s*\/>/;
225
225
  const REEXPORT_ALL_RE = /^\s*export\s+(?:type\s+)?\*\s+from\s+["']([^"']+)["'];?\s*$/m;
226
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
227
+ const DECLARATION_RE = /^\s*(?:export\s+(?:default\s+)?)?(?:declare\s+)?(?:abstract\s+|async\s+)?(?:interface|type|class|function|const|let|var|namespace|module|enum)\b/;
228
+ /**
229
+ * Count declarations in a declaration file's text, ignoring comments, blank
230
+ * lines, and the pointer lines a redirect stub is made of (`/// <reference .. />`
231
+ * and `export * from "X"`).
232
+ *
233
+ * This is the discriminator `detectTypesRedirect` needs: a redirect stub is a
234
+ * file with essentially nothing in it but the pointer, while an API surface that
235
+ * merely *declares an ambient dependency* on another types package (the
236
+ * `sharp` -> `/// <reference types="node" />` shape) carries its own
237
+ * declarations. Counting `.d.ts` FILES cannot tell those apart — sharp ships one
238
+ * 1971-line file and `@types/bun` ships one 1-line file, and both count as 1.
239
+ *
240
+ * Deliberately lexical, not a TypeScript parse: this runs in the shipped worker,
241
+ * which has no compiler dependency. Over-counting is the safe direction (a
242
+ * declaration found ⇒ not a stub ⇒ keep the package's own types).
243
+ */
244
+ export function countEntryDeclarations(content) {
245
+ const stripped = content.replace(BLOCK_COMMENT_RE, '');
246
+ let n = 0;
247
+ for (const raw of stripped.split('\n')) {
248
+ const line = raw.trim();
249
+ if (!line || line.startsWith('//'))
250
+ continue;
251
+ if (REEXPORT_ALL_RE.test(line))
252
+ continue;
253
+ if (DECLARATION_RE.test(line))
254
+ n++;
255
+ }
256
+ return n;
257
+ }
258
+ /** The package a declaration file points at: a triple-slash `<reference types>`
259
+ * or a whole-module `export * from`. Null when the file points nowhere. */
260
+ function pointerTarget(content) {
261
+ const ref = REFERENCE_TYPES_RE.exec(content);
262
+ if (ref && isBareSpecifier(ref[1]))
263
+ return parentPackageName(ref[1]);
264
+ const rex = REEXPORT_ALL_RE.exec(content);
265
+ if (rex && isBareSpecifier(rex[1]))
266
+ return parentPackageName(rex[1]);
267
+ return null;
268
+ }
226
269
  /** When a package is a pure pointer to another types package — a single-file
227
270
  * `/// <reference types="X" />` (the `@types/bun -> bun-types` shape) or a lone
228
271
  * `export * from "X"` re-export — return the target package name. Returns null
229
- * for packages that ship their own declarations (more than one .d.ts file, or a
230
- * local `/// <reference path=... />` aggregator entry). */
272
+ * for packages that ship their own declarations (more than one .d.ts file, a
273
+ * local `/// <reference path=... />` aggregator entry, or an entry file that
274
+ * declares anything of its own). */
231
275
  export function detectTypesRedirect(pkg) {
232
276
  // A package that ships multiple declaration files is an aggregator, not a
233
277
  // redirect stub — use its own types.
@@ -247,11 +291,16 @@ export function detectTypesRedirect(pkg) {
247
291
  // declarations (e.g. bun-types) — not a redirect to another package.
248
292
  if (/\/\/\/\s*<reference\s+path=/.test(content))
249
293
  return null;
250
- const ref = REFERENCE_TYPES_RE.exec(content);
251
- if (ref && isBareSpecifier(ref[1]))
252
- return parentPackageName(ref[1]);
253
- const rex = REEXPORT_ALL_RE.exec(content);
254
- if (rex && isBareSpecifier(rex[1]))
255
- return parentPackageName(rex[1]);
256
- return null;
294
+ const target = pointerTarget(content);
295
+ if (!target)
296
+ return null;
297
+ // A pointer line is not a redirect when the file it sits in also declares an
298
+ // API. `/// <reference types="node" />` in a package like sharp is an AMBIENT
299
+ // DEPENDENCY declaration — "my types need node's" — not "my types ARE node's";
300
+ // following it answered every sharp question out of @types/node (tty.d.ts,
301
+ // zlib.d.ts) while sharp's own 1971-line surface sat one file away. The .d.ts
302
+ // FILE count cannot see this: sharp ships one file and so does @types/bun.
303
+ if (countEntryDeclarations(content) > 0)
304
+ return null;
305
+ return target;
257
306
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.29.0",
3
+ "version": "0.29.2",
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",