@mjasnikovs/pi-task 0.29.1 → 0.29.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/task/auto-orchestrator.js +18 -1
- package/dist/task/command-shrink.d.ts +105 -0
- package/dist/task/command-shrink.js +410 -0
- package/dist/task/final-gate-fix.d.ts +4 -0
- package/dist/task/final-gate-fix.js +29 -2
- package/dist/task/final-gate.d.ts +15 -0
- package/dist/task/final-gate.js +51 -0
- package/dist/task/gate-deps.js +2 -1
- package/dist/task/owned-freeze-conflict.js +11 -1
- package/dist/task/owned-freeze-reassign.d.ts +142 -0
- package/dist/task/owned-freeze-reassign.js +211 -0
- package/dist/task/phases.d.ts +27 -0
- package/dist/task/phases.js +87 -2
- package/dist/task/requirements.d.ts +13 -1
- package/dist/task/requirements.js +14 -5
- package/package.json +1 -1
package/dist/task/final-gate.js
CHANGED
|
@@ -59,6 +59,7 @@ import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js
|
|
|
59
59
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
60
60
|
import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
|
|
61
61
|
import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
|
|
62
|
+
import { makefileRecipe } from './command-shrink.js';
|
|
62
63
|
function packageScripts(cwd) {
|
|
63
64
|
try {
|
|
64
65
|
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -924,6 +925,56 @@ export function discoverGateCommandLabels(cwd) {
|
|
|
924
925
|
].map(([bin, args]) => `${bin} ${args.join(' ')}`);
|
|
925
926
|
return [...new Set(labels)];
|
|
926
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* The RESOLVED BODY of every discoverable gate command, keyed by the same label
|
|
930
|
+
* `discoverGateCommandLabels` produces.
|
|
931
|
+
*
|
|
932
|
+
* The label is what the gate CALLS; the body is what actually runs. mx5 run 19's
|
|
933
|
+
* autofix changed `scripts.test` from `AGENT=1 bun test` to `AGENT=1 bun test
|
|
934
|
+
* ./test` — the label `bun run test` was identical before and after, so the
|
|
935
|
+
* label guard saw nothing while the suite stopped covering the repository. The
|
|
936
|
+
* shrink guard compares these bodies (see command-shrink.ts).
|
|
937
|
+
*
|
|
938
|
+
* A command with no indirection (`cargo test --quiet`, `pytest -q`) resolves to
|
|
939
|
+
* itself: it cannot be narrowed without changing the label, which the label
|
|
940
|
+
* guard already owns.
|
|
941
|
+
*/
|
|
942
|
+
export function discoverGateCommandBodies(cwd) {
|
|
943
|
+
const boot = discoverBootCommand(cwd);
|
|
944
|
+
const cmds = [
|
|
945
|
+
...discoverHealthCommands(cwd).cmds,
|
|
946
|
+
...discoverLockfileChecks(cwd),
|
|
947
|
+
...discoverIntegrationCommands(cwd).cmds,
|
|
948
|
+
...(boot ? [boot] : [])
|
|
949
|
+
];
|
|
950
|
+
const scripts = existsSync(path.join(cwd, 'package.json')) ? packageScripts(cwd) : {};
|
|
951
|
+
let makefile = null;
|
|
952
|
+
if (existsSync(path.join(cwd, 'Makefile'))) {
|
|
953
|
+
try {
|
|
954
|
+
makefile = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
makefile = null;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const out = {};
|
|
961
|
+
for (const [bin, args] of cmds) {
|
|
962
|
+
const label = `${bin} ${args.join(' ')}`;
|
|
963
|
+
if (out[label] !== undefined)
|
|
964
|
+
continue;
|
|
965
|
+
out[label] = resolveCommandBody(bin, args, scripts, makefile) ?? label;
|
|
966
|
+
}
|
|
967
|
+
return out;
|
|
968
|
+
}
|
|
969
|
+
/** `bun run test` → `scripts.test`; `make test` → the target's recipe lines. */
|
|
970
|
+
function resolveCommandBody(bin, args, scripts, makefile) {
|
|
971
|
+
if (args[0] === 'run' && args[1] !== undefined)
|
|
972
|
+
return scripts[args[1]] ?? null;
|
|
973
|
+
if (bin === 'make' && args[0] !== undefined && makefile !== null) {
|
|
974
|
+
return makefileRecipe(makefile, args[0]);
|
|
975
|
+
}
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
927
978
|
/** Last ~`limit` chars of the command's combined output, one line, for the reason. */
|
|
928
979
|
function outputTail(stdout, stderr, limit = 400) {
|
|
929
980
|
const combined = `${stdout}\n${stderr}`.trim();
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -25,7 +25,7 @@ import { readContracts } from './contracts.js';
|
|
|
25
25
|
import { recordAcceptDebt, recordEnforceKeptDebt, recordEnforceRevertDebt, recordFrozenBlockedDebt, recordCrossTaskDeletionDebt, recordYoloAcceptDebt, recordRootCauseDebt } from './accept-debt.js';
|
|
26
26
|
import { recordRepairCandidate } from './root-cause-repair.js';
|
|
27
27
|
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
28
|
-
import { runFinalIntegrationGate, discoverGateCommandLabels } from './final-gate.js';
|
|
28
|
+
import { runFinalIntegrationGate, discoverGateCommandLabels, discoverGateCommandBodies } from './final-gate.js';
|
|
29
29
|
import { runFinalGateAutofix } from './final-gate-fix.js';
|
|
30
30
|
import { researchResolution } from './verify-resolution.js';
|
|
31
31
|
import { extractProhibitions, findProhibitionViolations } from './prohibition-probe.js';
|
|
@@ -815,6 +815,7 @@ export function buildGateDeps(params) {
|
|
|
815
815
|
// shrink guard's discovery is the gate's own (see final-gate.ts).
|
|
816
816
|
gate: c => runFinalIntegrationGate(c),
|
|
817
817
|
discoverLabels: discoverGateCommandLabels,
|
|
818
|
+
discoverBodies: discoverGateCommandBodies,
|
|
818
819
|
discard: discardTreeEdits,
|
|
819
820
|
// WRITE-GUARD STACK (mx5 run 11: this child ran with free bash and
|
|
820
821
|
// none of the run-8 guards — it rm'd a sibling task's verified
|
|
@@ -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:
|
|
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
|
+
}
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -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 {
|
package/dist/task/phases.js
CHANGED
|
@@ -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
|
-
|
|
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
|