@mjasnikovs/pi-task 0.40.50 → 0.42.0
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/README.md +7 -4
- package/dist/config/config.d.ts +61 -18
- package/dist/config/config.js +50 -6
- package/dist/config/register.js +18 -5
- package/dist/shared/content-tokens.d.ts +16 -0
- package/dist/shared/content-tokens.js +137 -0
- package/dist/task/accept-debt.d.ts +31 -1
- package/dist/task/accept-debt.js +49 -3
- package/dist/task/artifact-closure.js +7 -1
- package/dist/task/auto-io.d.ts +66 -7
- package/dist/task/auto-io.js +152 -47
- package/dist/task/auto-orchestrator.d.ts +22 -19
- package/dist/task/auto-orchestrator.js +184 -112
- package/dist/task/child-runner.d.ts +18 -0
- package/dist/task/child-runner.js +7 -8
- package/dist/task/command-run.d.ts +4 -0
- package/dist/task/command-run.js +4 -2
- package/dist/task/constraint-policy.d.ts +52 -0
- package/dist/task/constraint-policy.js +66 -0
- package/dist/task/context-silence.d.ts +5 -4
- package/dist/task/context-silence.js +13 -5
- package/dist/task/coverage-loop.d.ts +7 -4
- package/dist/task/coverage-loop.js +16 -138
- package/dist/task/debug-log.d.ts +25 -2
- package/dist/task/debug-log.js +64 -10
- package/dist/task/decompose-fidelity.d.ts +17 -4
- package/dist/task/decompose-fidelity.js +95 -55
- package/dist/task/decompose-granularity.d.ts +7 -7
- package/dist/task/decompose-granularity.js +8 -8
- package/dist/task/env-notes.d.ts +65 -19
- package/dist/task/env-notes.js +152 -50
- package/dist/task/external-context.js +25 -5
- package/dist/task/fix-context.d.ts +39 -0
- package/dist/task/fix-context.js +58 -0
- package/dist/task/gate-deps.d.ts +49 -0
- package/dist/task/gate-deps.js +201 -39
- package/dist/task/gate-evidence.d.ts +51 -0
- package/dist/task/gate-evidence.js +90 -0
- package/dist/task/gate-resolution.d.ts +119 -0
- package/dist/task/gate-resolution.js +120 -0
- package/dist/task/git-state-guard.js +3 -26
- package/dist/task/handoff.d.ts +35 -0
- package/dist/task/handoff.js +100 -0
- package/dist/task/health-baseline.d.ts +87 -0
- package/dist/task/health-baseline.js +158 -0
- package/dist/task/health-repair.d.ts +68 -0
- package/dist/task/health-repair.js +124 -0
- package/dist/task/ledger.d.ts +10 -0
- package/dist/task/ledger.js +22 -11
- package/dist/task/lint-fix.d.ts +11 -0
- package/dist/task/lint-fix.js +14 -8
- package/dist/task/loop-detector.d.ts +52 -3
- package/dist/task/loop-detector.js +81 -5
- package/dist/task/mentions.d.ts +17 -0
- package/dist/task/mentions.js +86 -0
- package/dist/task/orchestrator.d.ts +53 -9
- package/dist/task/orchestrator.js +104 -28
- package/dist/task/orientation.d.ts +135 -29
- package/dist/task/orientation.js +259 -67
- package/dist/task/owned-freeze-reassign.d.ts +7 -3
- package/dist/task/owned-freeze-reassign.js +13 -5
- package/dist/task/parsers.d.ts +6 -1
- package/dist/task/parsers.js +21 -4
- package/dist/task/phases.d.ts +1 -1
- package/dist/task/phases.js +125 -153
- package/dist/task/plan-orchestrator.js +5 -6
- package/dist/task/plan-rounds.d.ts +1 -0
- package/dist/task/plan-rounds.js +8 -1
- package/dist/task/prohibition-probe.d.ts +6 -1
- package/dist/task/prohibition-probe.js +26 -6
- package/dist/task/prompts.js +13 -2
- package/dist/task/qa-transcript.d.ts +17 -0
- package/dist/task/qa-transcript.js +28 -0
- package/dist/task/question-source.d.ts +16 -2
- package/dist/task/question-source.js +21 -8
- package/dist/task/repo-health-check.d.ts +27 -0
- package/dist/task/repo-health-check.js +39 -5
- package/dist/task/requirements.d.ts +67 -13
- package/dist/task/requirements.js +129 -67
- package/dist/task/research-worker.d.ts +43 -2
- package/dist/task/research-worker.js +103 -8
- package/dist/task/run-bracket.d.ts +9 -4
- package/dist/task/run-bracket.js +9 -0
- package/dist/task/run-context.d.ts +152 -0
- package/dist/task/run-context.js +277 -0
- package/dist/task/spec-doc.d.ts +78 -0
- package/dist/task/spec-doc.js +205 -0
- package/dist/task/spec-model.d.ts +70 -0
- package/dist/task/spec-model.js +139 -0
- package/dist/task/stall-detector.d.ts +1 -1
- package/dist/task/stall-detector.js +9 -5
- package/dist/task/state-dir.d.ts +29 -0
- package/dist/task/state-dir.js +80 -0
- package/dist/task/suppression-probe.d.ts +67 -0
- package/dist/task/suppression-probe.js +78 -0
- package/dist/task/task-gates.d.ts +52 -58
- package/dist/task/task-gates.js +161 -199
- package/dist/task/task-io.d.ts +10 -0
- package/dist/task/task-io.js +69 -18
- package/dist/task/task-parsers.js +6 -6
- package/dist/task/task-types.d.ts +7 -0
- package/dist/task/timings.d.ts +11 -0
- package/dist/task/timings.js +21 -0
- package/dist/task/tree-hash.d.ts +8 -0
- package/dist/task/tree-hash.js +55 -0
- package/dist/task/verify-reconcile.d.ts +4 -3
- package/dist/task/verify-reconcile.js +7 -8
- package/dist/task/verify-resolution.d.ts +32 -6
- package/dist/task/verify-resolution.js +78 -15
- package/dist/task/verify-work.d.ts +105 -35
- package/dist/task/verify-work.js +232 -60
- package/dist/task/yolo.d.ts +0 -13
- package/dist/task/yolo.js +9 -21
- package/dist/workers/pi-worker-core.d.ts +20 -0
- package/dist/workers/pi-worker-core.js +16 -11
- package/dist/workers/pi-worker-docs.d.ts +6 -5
- package/dist/workers/pi-worker-docs.js +8 -7
- package/dist/workers/pi-worker-fetch.d.ts +3 -2
- package/dist/workers/pi-worker-fetch.js +5 -4
- package/dist/workers/pi-worker-search.js +5 -4
- package/dist/workers/research-cache.d.ts +23 -1
- package/dist/workers/research-cache.js +63 -3
- package/dist/workers/shared.d.ts +18 -0
- package/dist/workers/shared.js +38 -22
- package/package.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { parseRepairTitleFile } from './root-cause-repair.js';
|
|
2
|
+
/** A path-like token: at least one directory separator, ending in a file name. */
|
|
3
|
+
const PATH_TOKEN_RE = /(?:[\w.@-]+[\\/])+[\w.@-]+\.\w+/g;
|
|
4
|
+
function normalisePath(p) {
|
|
5
|
+
return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').trim();
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a token the output printed to the ONE tracked file it names. Linters
|
|
9
|
+
* print absolute paths, compilers print repo-relative ones, and a stack trace
|
|
10
|
+
* prints `node_modules/...` — the tracked list is what tells a deliverable from
|
|
11
|
+
* noise. An ambiguous suffix (two tracked `src/x.ts`) resolves to nothing rather
|
|
12
|
+
* than to a guess.
|
|
13
|
+
*/
|
|
14
|
+
function resolveTracked(token, cwd, tracked) {
|
|
15
|
+
let t = normalisePath(token);
|
|
16
|
+
const root = normalisePath(cwd);
|
|
17
|
+
if (root.length > 0 && t.startsWith(`${root}/`))
|
|
18
|
+
t = t.slice(root.length + 1);
|
|
19
|
+
if (tracked.includes(t))
|
|
20
|
+
return t;
|
|
21
|
+
const bySuffix = tracked.filter(r => t.endsWith(`/${r}`));
|
|
22
|
+
return bySuffix.length === 1 ? bySuffix[0] : null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* What a red health result is about. Null when the result records no failing
|
|
26
|
+
* command (a legacy baseline, or a signal with no per-command detail) — there is
|
|
27
|
+
* nothing a repair could be pinned to.
|
|
28
|
+
*/
|
|
29
|
+
export function healthRedSubject(health, cwd, tracked) {
|
|
30
|
+
const failing = (health.commands ?? []).find(c => c.outcome === 'fail');
|
|
31
|
+
if (!failing)
|
|
32
|
+
return null;
|
|
33
|
+
const files = [];
|
|
34
|
+
if (tracked) {
|
|
35
|
+
for (const m of (health.output ?? '').matchAll(PATH_TOKEN_RE)) {
|
|
36
|
+
const rel = resolveTracked(m[0], cwd, tracked);
|
|
37
|
+
if (rel !== null && !files.includes(rel))
|
|
38
|
+
files.push(rel);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { command: failing.cmd, exitCode: failing.exitCode, files };
|
|
42
|
+
}
|
|
43
|
+
// ─── Plan entry ──────────────────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* The plan title, in one of two fixed shapes the parser below recovers:
|
|
46
|
+
* `repair src/a.ts, src/b.ts: \`bun run lint\` exits 1 (introduced by TASK_0033)`
|
|
47
|
+
* `repair \`bun run lint\`: exits 1 (no task in this run owns it)`
|
|
48
|
+
* The subject sits right after the prefix so it is both the dedup key and what
|
|
49
|
+
* the scope fence pins.
|
|
50
|
+
*/
|
|
51
|
+
export function buildHealthRepairTitle(red) {
|
|
52
|
+
const exits = `exits ${red.exitCode ?? '?'}`;
|
|
53
|
+
const owner = red.owners.length > 0 ?
|
|
54
|
+
`introduced by ${red.owners.join(', ')}`
|
|
55
|
+
: 'no task in this run owns it';
|
|
56
|
+
return red.files.length > 0 ?
|
|
57
|
+
`repair ${red.files.join(', ')}: \`${red.command}\` ${exits} (${owner})`
|
|
58
|
+
: `repair \`${red.command}\`: ${exits} (${owner})`;
|
|
59
|
+
}
|
|
60
|
+
const PATH = String.raw `(?:[\w.@-]+\/)*[\w.@-]+\.\w+`;
|
|
61
|
+
const HEALTH_REPAIR_TITLE_RE = new RegExp(`^repair\\s+(?:\`([^\`]+)\`\\s*:\\s*exits|(${PATH}(?:,\\s*${PATH})*)\\s*:\\s*\`([^\`]+)\`\\s+exits)\\s`);
|
|
62
|
+
/** The command and files a health-repair title names, or null for any other title. */
|
|
63
|
+
export function parseHealthRepairTitle(title) {
|
|
64
|
+
const m = HEALTH_REPAIR_TITLE_RE.exec(title.trim());
|
|
65
|
+
if (!m)
|
|
66
|
+
return null;
|
|
67
|
+
if (m[1] !== undefined)
|
|
68
|
+
return { command: m[1].trim(), files: [] };
|
|
69
|
+
return { command: m[3].trim(), files: m[2].split(',').map(f => f.trim()) };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Does the plan already carry a repair for this red? Same command, or any of the
|
|
73
|
+
* same files — including a file-scoped root-cause repair (root-cause-repair.ts),
|
|
74
|
+
* which pins the same file. Checked-off entries count: a repair that ran and
|
|
75
|
+
* failed lands in the debt ledger, never in the plan a second time.
|
|
76
|
+
*/
|
|
77
|
+
export function planCoversHealthRed(titles, red) {
|
|
78
|
+
const files = new Set(red.files.map(f => normalisePath(f).toLowerCase()));
|
|
79
|
+
return titles.some(t => {
|
|
80
|
+
const rootCause = parseRepairTitleFile(t);
|
|
81
|
+
if (rootCause !== null && files.has(normalisePath(rootCause).toLowerCase()))
|
|
82
|
+
return true;
|
|
83
|
+
const h = parseHealthRepairTitle(t);
|
|
84
|
+
if (h === null)
|
|
85
|
+
return false;
|
|
86
|
+
if (h.command === red.command)
|
|
87
|
+
return true;
|
|
88
|
+
return h.files.some(f => files.has(normalisePath(f).toLowerCase()));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The extra scope fence a health-repair entry carries into refine. Without it
|
|
93
|
+
* "repair src/a.ts" refines into "overhaul the client", and a fix child left free
|
|
94
|
+
* to choose greens a linter fastest by suppressing it — which is how the red in
|
|
95
|
+
* the run this closes was painted over the first time.
|
|
96
|
+
*/
|
|
97
|
+
export function buildHealthRepairFence(subject) {
|
|
98
|
+
const scope = subject.files.length > 0 ?
|
|
99
|
+
[
|
|
100
|
+
` - ${subject.files.map(f => `\`${f}\``).join(', ')} ${subject.files.length > 1 ? 'are' : 'is'} the ONLY`,
|
|
101
|
+
' file(s) you may modify. Do not refactor, restructure, or "improve" anything else,',
|
|
102
|
+
' and do not create new files.'
|
|
103
|
+
]
|
|
104
|
+
: [
|
|
105
|
+
' - Modify only the files the command reports. Do not refactor, restructure, or',
|
|
106
|
+
' "improve" anything else, and do not create new files.'
|
|
107
|
+
];
|
|
108
|
+
return [
|
|
109
|
+
`REPAIR TASK — this step exists ONLY to make \`${subject.command}\` pass again. It`,
|
|
110
|
+
'was created because that check was red before this step, and every later step',
|
|
111
|
+
'would otherwise build on the red; it is not a feature step and must not grow into one.',
|
|
112
|
+
'',
|
|
113
|
+
'HARD CONSTRAINTS for this step (they override any broader reading of the title):',
|
|
114
|
+
...scope,
|
|
115
|
+
' - Fix the reported findings and nothing more. Do NOT redesign the build, the',
|
|
116
|
+
' lint configuration, the schema, or any shared infrastructure — a wider change',
|
|
117
|
+
" here would silently overwrite sibling tasks' shipped work.",
|
|
118
|
+
' - Do NOT suppress, disable, ignore or weaken the check to make it pass: no',
|
|
119
|
+
' disable comments, no ignore entries, no relaxed rules, no deleted or skipped',
|
|
120
|
+
' tests. A finding is fixed in the code it reports.',
|
|
121
|
+
` - The VERIFY block MUST be exactly: \`${subject.command}\` — the check that was`,
|
|
122
|
+
' red. It passing is the whole acceptance criterion.'
|
|
123
|
+
].join('\n');
|
|
124
|
+
}
|
package/dist/task/ledger.d.ts
CHANGED
|
@@ -22,6 +22,16 @@ export interface LedgerSpec<T> {
|
|
|
22
22
|
* writer produces, so it is an option, not a unification.
|
|
23
23
|
*/
|
|
24
24
|
onNoop?: 'rewrite' | 'skip';
|
|
25
|
+
/**
|
|
26
|
+
* What `append` does when an incoming record's key is already stored:
|
|
27
|
+
* 'keep-first' (default) — the stored record wins, so provenance traces to
|
|
28
|
+
* whoever established it first;
|
|
29
|
+
* 'replace' — the incoming record wins and moves to the end of the file, so
|
|
30
|
+
* the freshest statement of a fact is the one that survives the cap.
|
|
31
|
+
* env-notes is the second kind: a re-measured environment fact SUPERSEDES the
|
|
32
|
+
* earlier one, and keeping the first is how a stale fact becomes permanent.
|
|
33
|
+
*/
|
|
34
|
+
conflict?: 'keep-first' | 'replace';
|
|
25
35
|
}
|
|
26
36
|
export interface Ledger<T> {
|
|
27
37
|
/** Absolute path of the ledger file for this cwd. */
|
package/dist/task/ledger.js
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* requirements, requirements-owned).
|
|
5
5
|
*
|
|
6
6
|
* The ritual is seven steps: read the file (ANY error → ''), parse it into records,
|
|
7
|
-
* key the records,
|
|
8
|
-
* newest MAX (oldest dropped), mkdir the tasks dir, write
|
|
9
|
-
* (`lines.join('\n') + '\n'`, plain `writeFile`, NOT atomic).
|
|
10
|
-
* swallowed — a ledger is a sharpener or an auditing aid, never a
|
|
11
|
-
* phase or gate that calls it.
|
|
7
|
+
* key the records, settle an incoming item whose key is already present (see
|
|
8
|
+
* `conflict`), cap to the newest MAX (oldest dropped), mkdir the tasks dir, write
|
|
9
|
+
* the whole file back (`lines.join('\n') + '\n'`, plain `writeFile`, NOT atomic).
|
|
10
|
+
* Every fault is swallowed — a ledger is a sharpener or an auditing aid, never a
|
|
11
|
+
* blocker of the phase or gate that calls it.
|
|
12
12
|
*
|
|
13
13
|
* What varies per site is the DATA SHAPE (file name, cap, key, line format, parser)
|
|
14
14
|
* and exactly one RULE — what an append does when it adds nothing new (see
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* • `read` is `parse(readRaw)`; every adapter's parser skips blank lines and lines
|
|
22
22
|
* it cannot read, so a corrupt line is dropped, never thrown on.
|
|
23
23
|
* • `append` with an empty batch is a no-op — it does not even create `.pi-tasks/`.
|
|
24
|
-
*
|
|
25
|
-
* the batch
|
|
24
|
+
* Under the default `conflict` the first item with a key wins within a batch,
|
|
25
|
+
* and a key already stored wins over the batch; under `'replace'` the last
|
|
26
|
+
* statement of a key wins and moves to the end.
|
|
26
27
|
* • `write` overwrites with exactly these records; an empty list writes an empty
|
|
27
28
|
* file (this is how a drained queue and a fully-resolved debt ledger look).
|
|
28
29
|
* • Neither `append` nor `write` ever throws.
|
|
@@ -33,6 +34,7 @@ import { tasksDir } from './task-io.js';
|
|
|
33
34
|
export function makeLedger(spec) {
|
|
34
35
|
const { file, max, key, serialize, parse } = spec;
|
|
35
36
|
const onNoop = spec.onNoop ?? 'rewrite';
|
|
37
|
+
const conflict = spec.conflict ?? 'keep-first';
|
|
36
38
|
const filePath = (cwd) => path.join(tasksDir(cwd), file);
|
|
37
39
|
async function readRaw(cwd) {
|
|
38
40
|
try {
|
|
@@ -57,14 +59,23 @@ export function makeLedger(spec) {
|
|
|
57
59
|
const existing = await read(cwd);
|
|
58
60
|
const seen = new Set(existing.map(key));
|
|
59
61
|
const merged = [...existing];
|
|
62
|
+
let added = 0;
|
|
60
63
|
for (const item of items) {
|
|
61
64
|
const k = key(item);
|
|
62
|
-
if (seen.has(k))
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
if (seen.has(k)) {
|
|
66
|
+
if (conflict === 'keep-first')
|
|
67
|
+
continue;
|
|
68
|
+
const at = merged.findIndex(m => key(m) === k);
|
|
69
|
+
if (at !== -1)
|
|
70
|
+
merged.splice(at, 1);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
seen.add(k);
|
|
74
|
+
added++;
|
|
75
|
+
}
|
|
65
76
|
merged.push(item);
|
|
66
77
|
}
|
|
67
|
-
if (
|
|
78
|
+
if (added === 0 && onNoop === 'skip')
|
|
68
79
|
return;
|
|
69
80
|
const kept = max === undefined ? merged : merged.slice(-max);
|
|
70
81
|
await persist(cwd, kept);
|
package/dist/task/lint-fix.d.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
import type { SpecContradiction } from './gate-resolution.js';
|
|
1
2
|
export interface LintFixResult {
|
|
2
3
|
/** true → findings fixed, repo health passes, work preserved. */
|
|
3
4
|
ok: boolean;
|
|
5
|
+
/**
|
|
6
|
+
* WHAT this pass proved, as data. `frozen-path` used to travel as a
|
|
7
|
+
* `frozen-path:` prefix on `reason`, which the gate loop recovered by
|
|
8
|
+
* re-typing the literal — a reword of either side silently disarmed the
|
|
9
|
+
* cross-task-contradiction routing.
|
|
10
|
+
*/
|
|
11
|
+
class: 'converged' | 'frozen-path' | 'not-applied';
|
|
4
12
|
/** why the fix was not applied (guard trip, no convergence, child error). */
|
|
5
13
|
reason?: string;
|
|
14
|
+
/** Set exactly on `frozen-path`: the criterion and the frozen path whose edit
|
|
15
|
+
* is the only way to meet it, for the gate's decision table. */
|
|
16
|
+
contradiction?: SpecContradiction;
|
|
6
17
|
}
|
|
7
18
|
export interface LintFixDeps {
|
|
8
19
|
cwd: string;
|
package/dist/task/lint-fix.js
CHANGED
|
@@ -261,6 +261,7 @@ export async function runBoundedLintFix(deps) {
|
|
|
261
261
|
deps.log?.(`lint-fix REVERT-GUARD — discarded ${violations.length} work file(s)`);
|
|
262
262
|
return {
|
|
263
263
|
ok: false,
|
|
264
|
+
class: 'not-applied',
|
|
264
265
|
reason: `revert-guard: fix pass discarded work (${violations.slice(0, 3).join(', ')}`
|
|
265
266
|
+ `${violations.length > 3 ? ', …' : ''}) — fix ${snapshot ? 'rolled back' : 'REJECTED but no snapshot to restore'}`
|
|
266
267
|
};
|
|
@@ -288,6 +289,7 @@ export async function runBoundedLintFix(deps) {
|
|
|
288
289
|
deps.log?.(`lint-fix CROSS-TASK-DELETION GUARD — ${named[0]}`);
|
|
289
290
|
return {
|
|
290
291
|
ok: false,
|
|
292
|
+
class: 'not-applied',
|
|
291
293
|
reason: `cross-task-deletion: fix child DELETED sibling task deliverable(s) `
|
|
292
294
|
+ `(${named.slice(0, 3).join('; ')}${crossDeletions.length > 3 ? '; …' : ''}) `
|
|
293
295
|
+ `— restored from HEAD; deleting another task's committed work is not a fix`
|
|
@@ -311,6 +313,8 @@ export async function runBoundedLintFix(deps) {
|
|
|
311
313
|
deps.log?.(`lint-fix FROZEN-PATH GUARD — ${frozenViolations.slice(0, 3).join(', ')}`);
|
|
312
314
|
return {
|
|
313
315
|
ok: false,
|
|
316
|
+
class: 'frozen-path',
|
|
317
|
+
contradiction: { criterion: deps.failReason, frozenPath: frozenViolations[0] },
|
|
314
318
|
reason: `frozen-path: fix child modified spec-frozen path(s) `
|
|
315
319
|
+ `(${frozenViolations.slice(0, 3).join(', ')}`
|
|
316
320
|
+ `${frozenViolations.length > 3 ? ', …' : ''}) — `
|
|
@@ -322,7 +326,7 @@ export async function runBoundedLintFix(deps) {
|
|
|
322
326
|
}
|
|
323
327
|
if (end.kind === 'error') {
|
|
324
328
|
deps.log?.(`lint-fix child failed — ${end.msg}`);
|
|
325
|
-
return { ok: false, reason: `fix child failed: ${end.msg}` };
|
|
329
|
+
return { ok: false, class: 'not-applied', reason: `fix child failed: ${end.msg}` };
|
|
326
330
|
}
|
|
327
331
|
if (end.kind === 'blocked')
|
|
328
332
|
deps.log?.(`lint-fix BLOCKED — ${end.note}`);
|
|
@@ -344,28 +348,30 @@ export async function runBoundedLintFix(deps) {
|
|
|
344
348
|
const health = await deps.repoHealth();
|
|
345
349
|
if (!health.ok) {
|
|
346
350
|
if (end.kind === 'blocked') {
|
|
347
|
-
return { ok: false, reason: `fix child blocked: ${end.note}` };
|
|
351
|
+
return { ok: false, class: 'not-applied', reason: `fix child blocked: ${end.note}` };
|
|
348
352
|
}
|
|
349
353
|
// FROZEN-PATH TRACE on non-convergence: when the child was honest — it did
|
|
350
354
|
// NOT touch the frozen path, so the guard above never tripped — but the
|
|
351
355
|
// check is still red and its own output NAMES a frozen path (the typed
|
|
352
356
|
// ESLint message quoted in this file's header), the findings can only be
|
|
353
|
-
// fixed by an edit this task's spec forbids. Report
|
|
354
|
-
// `frozen-path
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
+
// fixed by an edit this task's spec forbids. Report the same
|
|
358
|
+
// `frozen-path` class as the guard trip, so the gate's decision table
|
|
359
|
+
// exits on round one instead of burning unattended AUTOFIX rounds an impl
|
|
360
|
+
// re-run under the same freeze cannot converge out of.
|
|
357
361
|
const implicated = frozen.filter(p => pathNamedIn(`${health.reason}\n${health.output ?? ''}`, p));
|
|
358
362
|
if (implicated.length > 0) {
|
|
359
363
|
deps.log?.(`lint-fix FROZEN-PATH TRACE — ${implicated.slice(0, 3).join(', ')}`);
|
|
360
364
|
return {
|
|
361
365
|
ok: false,
|
|
366
|
+
class: 'frozen-path',
|
|
367
|
+
contradiction: { criterion: health.reason, frozenPath: implicated[0] },
|
|
362
368
|
reason: `frozen-path: static findings implicate spec-frozen path(s) `
|
|
363
369
|
+ `(${implicated.slice(0, 3).join(', ')}`
|
|
364
370
|
+ `${implicated.length > 3 ? ', …' : ''}) — did not converge `
|
|
365
371
|
+ `(${health.reason}); a fix under this task's constraints cannot converge`
|
|
366
372
|
};
|
|
367
373
|
}
|
|
368
|
-
return { ok: false, reason: `did not converge: ${health.reason}` };
|
|
374
|
+
return { ok: false, class: 'not-applied', reason: `did not converge: ${health.reason}` };
|
|
369
375
|
}
|
|
370
|
-
return { ok: true, reason: guardNote };
|
|
376
|
+
return { ok: true, class: 'converged', ...(guardNote === undefined ? {} : { reason: guardNote }) };
|
|
371
377
|
}
|
|
@@ -30,7 +30,15 @@ export interface ToolCall {
|
|
|
30
30
|
export interface LoopHit {
|
|
31
31
|
call: ToolCall;
|
|
32
32
|
count: number;
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* How many recent calls `count` was counted over.
|
|
35
|
+
*
|
|
36
|
+
* OPTIONAL because a stall hit counts a streak or a byte total, not a window
|
|
37
|
+
* — it used to carry 0 there, which every renderer printed as "in the last 0
|
|
38
|
+
* calls". Absent means "this hit has no window"; a renderer must say what the
|
|
39
|
+
* hit actually measured instead.
|
|
40
|
+
*/
|
|
41
|
+
windowSize?: number;
|
|
34
42
|
/**
|
|
35
43
|
* Set when the kill came from the whole-run StallDetector rather than this
|
|
36
44
|
* short-window detector, naming which of its two rules tripped
|
|
@@ -63,11 +71,25 @@ export declare class LoopDetector {
|
|
|
63
71
|
/** Revisits of one path needed to trip; defaults to the exact threshold. */
|
|
64
72
|
private readonly pathThreshold;
|
|
65
73
|
private readonly buf;
|
|
74
|
+
/**
|
|
75
|
+
* Every path the attempt has targeted, with the span it covered — the WHOLE
|
|
76
|
+
* attempt, not the window `buf` holds. It is the only record of what a killed
|
|
77
|
+
* attempt actually read: the kill fires from the tool-call hook, before a byte
|
|
78
|
+
* of answer text streams, so text carry-forward is empty by construction and
|
|
79
|
+
* the re-spawn would otherwise start blind.
|
|
80
|
+
*/
|
|
81
|
+
private readonly visits;
|
|
66
82
|
constructor(window?: number, threshold?: number,
|
|
67
83
|
/** Revisits of one path needed to trip; defaults to the exact threshold. */
|
|
68
84
|
pathThreshold?: number);
|
|
69
85
|
/** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
|
|
70
86
|
record(call: ToolCall): LoopHit | null;
|
|
87
|
+
/**
|
|
88
|
+
* The attempt's READ-SET, in first-seen order: every path it opened, with the
|
|
89
|
+
* line span when the call named one. This is what a restart carries instead of
|
|
90
|
+
* answer text — see {@link visits}.
|
|
91
|
+
*/
|
|
92
|
+
visited(): string[];
|
|
71
93
|
/**
|
|
72
94
|
* Count same-path calls in the window that are "revisits" — accesses that end
|
|
73
95
|
* no further into the file than the furthest line already covered for that
|
|
@@ -84,5 +106,32 @@ export declare class LoopDetector {
|
|
|
84
106
|
*/
|
|
85
107
|
private countRevisits;
|
|
86
108
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
109
|
+
/**
|
|
110
|
+
* What killed the attempt, as prose a human reads in a degraded section or a
|
|
111
|
+
* `loop events` line.
|
|
112
|
+
*
|
|
113
|
+
* ONE renderer because each rule measures something different — calls in a
|
|
114
|
+
* window, a consecutive streak, bytes against a context window — and a shared
|
|
115
|
+
* "×N in the last M calls" template printed the streak's absent window as
|
|
116
|
+
* "in the last 0 calls".
|
|
117
|
+
*/
|
|
118
|
+
export declare function describeLoopHit(hit: LoopHit): string;
|
|
119
|
+
/**
|
|
120
|
+
* The read-set clause a restart hint carries, or '' when the killed attempt
|
|
121
|
+
* opened nothing.
|
|
122
|
+
*
|
|
123
|
+
* It is the only thing a loop restart CAN carry: the kill fires from the
|
|
124
|
+
* tool-call hook, so there is no partial answer to hand forward (which is why
|
|
125
|
+
* `loop` is absent from CARRY_FORWARD_REASONS in pi-worker-core.ts). Without it
|
|
126
|
+
* "do not re-read what you have already read" names no files and the re-spawn
|
|
127
|
+
* re-reads the same ones.
|
|
128
|
+
*
|
|
129
|
+
* Bounded so a rotation over hundreds of files cannot outgrow the prompt it is
|
|
130
|
+
* prepended to; the count keeps the elision honest.
|
|
131
|
+
*/
|
|
132
|
+
export declare function formatReadSet(visited: readonly string[]): string;
|
|
133
|
+
/**
|
|
134
|
+
* The restart hint a re-spawned child gets after a loop kill: names the call, and
|
|
135
|
+
* the read-set the killed attempt built up.
|
|
136
|
+
*/
|
|
137
|
+
export declare function formatLoopHint(hit: LoopHit, visited?: readonly string[]): string;
|
|
@@ -96,6 +96,14 @@ export class LoopDetector {
|
|
|
96
96
|
threshold;
|
|
97
97
|
pathThreshold;
|
|
98
98
|
buf = [];
|
|
99
|
+
/**
|
|
100
|
+
* Every path the attempt has targeted, with the span it covered — the WHOLE
|
|
101
|
+
* attempt, not the window `buf` holds. It is the only record of what a killed
|
|
102
|
+
* attempt actually read: the kill fires from the tool-call hook, before a byte
|
|
103
|
+
* of answer text streams, so text carry-forward is empty by construction and
|
|
104
|
+
* the re-spawn would otherwise start blind.
|
|
105
|
+
*/
|
|
106
|
+
visits = new Map();
|
|
99
107
|
constructor(window = LOOP_WINDOW, threshold = LOOP_THRESHOLD,
|
|
100
108
|
/** Revisits of one path needed to trip; defaults to the exact threshold. */
|
|
101
109
|
pathThreshold = threshold) {
|
|
@@ -107,9 +115,17 @@ export class LoopDetector {
|
|
|
107
115
|
record(call) {
|
|
108
116
|
const key = loopKey(call);
|
|
109
117
|
const offset = readOffset(call.args);
|
|
110
|
-
|
|
118
|
+
const path = primaryPath(call.args);
|
|
119
|
+
const end = readEnd(call.args, offset);
|
|
120
|
+
this.buf.push({ key, path, offset, end });
|
|
111
121
|
if (this.buf.length > this.window)
|
|
112
122
|
this.buf.shift();
|
|
123
|
+
if (path !== null) {
|
|
124
|
+
const seen = this.visits.get(path);
|
|
125
|
+
this.visits.set(path, seen ?
|
|
126
|
+
{ from: Math.min(seen.from, offset), to: Math.max(seen.to, end) }
|
|
127
|
+
: { from: offset, to: end });
|
|
128
|
+
}
|
|
113
129
|
// 1. Exact-match loop: identical (name, args) repeated past threshold.
|
|
114
130
|
let exact = 0;
|
|
115
131
|
for (const e of this.buf)
|
|
@@ -120,7 +136,6 @@ export class LoopDetector {
|
|
|
120
136
|
// 2. Path-aware loop: the same file re-targeted without forward progress.
|
|
121
137
|
// Caught here precisely because varied offset/limit change the exact key on
|
|
122
138
|
// every call, so pattern 1 above can never see it.
|
|
123
|
-
const path = this.buf[this.buf.length - 1].path;
|
|
124
139
|
if (path !== null) {
|
|
125
140
|
const revisits = this.countRevisits(path);
|
|
126
141
|
if (revisits >= this.pathThreshold) {
|
|
@@ -129,6 +144,14 @@ export class LoopDetector {
|
|
|
129
144
|
}
|
|
130
145
|
return null;
|
|
131
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* The attempt's READ-SET, in first-seen order: every path it opened, with the
|
|
149
|
+
* line span when the call named one. This is what a restart carries instead of
|
|
150
|
+
* answer text — see {@link visits}.
|
|
151
|
+
*/
|
|
152
|
+
visited() {
|
|
153
|
+
return [...this.visits].map(([path, span]) => span.to === Infinity ? path : `${path} (lines ${span.from}-${span.to})`);
|
|
154
|
+
}
|
|
132
155
|
/**
|
|
133
156
|
* Count same-path calls in the window that are "revisits" — accesses that end
|
|
134
157
|
* no further into the file than the furthest line already covered for that
|
|
@@ -157,11 +180,64 @@ export class LoopDetector {
|
|
|
157
180
|
return revisits;
|
|
158
181
|
}
|
|
159
182
|
}
|
|
160
|
-
/**
|
|
161
|
-
|
|
183
|
+
/**
|
|
184
|
+
* What killed the attempt, as prose a human reads in a degraded section or a
|
|
185
|
+
* `loop events` line.
|
|
186
|
+
*
|
|
187
|
+
* ONE renderer because each rule measures something different — calls in a
|
|
188
|
+
* window, a consecutive streak, bytes against a context window — and a shared
|
|
189
|
+
* "×N in the last M calls" template printed the streak's absent window as
|
|
190
|
+
* "in the last 0 calls".
|
|
191
|
+
*/
|
|
192
|
+
export function describeLoopHit(hit) {
|
|
193
|
+
const call = `${hit.call.name}(${JSON.stringify(hit.call.args)})`;
|
|
194
|
+
if (hit.stall === 'context-churn') {
|
|
195
|
+
return (`pulled in about ${hit.count} tokens of tool output, more than its `
|
|
196
|
+
+ `${hit.windowSize}-token context window holds (last call ${call})`);
|
|
197
|
+
}
|
|
198
|
+
if (hit.stall === 'no-new-ground') {
|
|
199
|
+
return (`stopped covering new ground — ${hit.count} consecutive tool calls returned `
|
|
200
|
+
+ `nothing it had not already seen (last call ${call})`);
|
|
201
|
+
}
|
|
202
|
+
return `stuck in a loop — called ${call} ×${hit.count} in the last ${hit.windowSize} calls`;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The read-set clause a restart hint carries, or '' when the killed attempt
|
|
206
|
+
* opened nothing.
|
|
207
|
+
*
|
|
208
|
+
* It is the only thing a loop restart CAN carry: the kill fires from the
|
|
209
|
+
* tool-call hook, so there is no partial answer to hand forward (which is why
|
|
210
|
+
* `loop` is absent from CARRY_FORWARD_REASONS in pi-worker-core.ts). Without it
|
|
211
|
+
* "do not re-read what you have already read" names no files and the re-spawn
|
|
212
|
+
* re-reads the same ones.
|
|
213
|
+
*
|
|
214
|
+
* Bounded so a rotation over hundreds of files cannot outgrow the prompt it is
|
|
215
|
+
* prepended to; the count keeps the elision honest.
|
|
216
|
+
*/
|
|
217
|
+
export function formatReadSet(visited) {
|
|
218
|
+
if (visited.length === 0)
|
|
219
|
+
return '';
|
|
220
|
+
const shown = visited.slice(0, MAX_READ_SET_ENTRIES);
|
|
221
|
+
const rest = visited.length - shown.length;
|
|
222
|
+
return (` You have already read, do not re-open: ${shown.join(', ')}`
|
|
223
|
+
+ (rest > 0 ? `, and ${rest} more` : '')
|
|
224
|
+
+ '.');
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* How many paths a restart hint lists. The read-set is a reminder, not an
|
|
228
|
+
* inventory: past a screenful the tail stops being read and only costs prefill,
|
|
229
|
+
* and the elision line still tells the child there was more.
|
|
230
|
+
*/
|
|
231
|
+
const MAX_READ_SET_ENTRIES = 40;
|
|
232
|
+
/**
|
|
233
|
+
* The restart hint a re-spawned child gets after a loop kill: names the call, and
|
|
234
|
+
* the read-set the killed attempt built up.
|
|
235
|
+
*/
|
|
236
|
+
export function formatLoopHint(hit, visited = []) {
|
|
162
237
|
const argsStr = JSON.stringify(hit.call.args);
|
|
163
238
|
return (`[SYSTEM NOTE: Your prior attempt called ${hit.call.name}(${argsStr}) `
|
|
164
239
|
+ `${hit.count} times in the last ${hit.windowSize} tool calls — you appeared to be `
|
|
165
240
|
+ `stuck in a loop. Avoid repeating that exact call; if you've already seen its result, `
|
|
166
|
-
+ `work from memory or pick a different angle
|
|
241
|
+
+ `work from memory or pick a different angle.`
|
|
242
|
+
+ `${formatReadSet(visited)}]`);
|
|
167
243
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expand any @file references in the feature text by appending each referenced
|
|
3
|
+
* file's contents, so the planning children (clarify, decompose) always see the
|
|
4
|
+
* real spec inline instead of relying on the model to open the file itself.
|
|
5
|
+
* Without this, clarify on a one-line "Implement @spec.md" tends to bail with
|
|
6
|
+
* NONE because, to the model, the request looks small and unambiguous.
|
|
7
|
+
* Unreadable mentions (typos, non-file @tokens) are left untouched; the feature
|
|
8
|
+
* is returned verbatim when nothing readable is referenced.
|
|
9
|
+
*/
|
|
10
|
+
export declare function expandFeatureMentions(cwd: string, feature: string): Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* The @file references in the feature that point at a readable file on disk —
|
|
13
|
+
* the bare path tokens, deduped, in first-seen order. Unreadable mentions
|
|
14
|
+
* (typos, non-file @tokens) are dropped so we never advertise a missing file as
|
|
15
|
+
* an authoritative spec.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readableMentions(cwd: string, feature: string): Promise<string[]>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@file` references in a prompt, and the two things done with them: inlining the
|
|
3
|
+
* file's contents for the planning children, and naming the files themselves.
|
|
4
|
+
*
|
|
5
|
+
* Its own module because both the PLANNING side (auto-orchestrator, which threads
|
|
6
|
+
* the spec doc into every task title) and the PHASE side (research, which lets the
|
|
7
|
+
* orientation core select a cited doc) need the same answer, and the phase side
|
|
8
|
+
* cannot import the orchestrator that imports it.
|
|
9
|
+
*/
|
|
10
|
+
import * as fsp from 'node:fs/promises';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { readTextFile } from '../shared/fs-text.js';
|
|
13
|
+
const MENTION_RE = /(?:^|\s)@([^\s]+)/g;
|
|
14
|
+
// Trailing punctuation a user naturally types AFTER an @-mention when it sits in
|
|
15
|
+
// prose — "Implement @design.md, reuse…" or "see @spec.md." — which the greedy
|
|
16
|
+
// [^\s]+ above would otherwise swallow into the path. Left unstripped, the
|
|
17
|
+
// resulting "design.md," resolves to no file, expansion is silently skipped, and
|
|
18
|
+
// the planner reasons over a one-line "Implement @design.md" with NO spec inline
|
|
19
|
+
// → it fabricates generic questions and tasks the spec never called for.
|
|
20
|
+
//
|
|
21
|
+
// Measured against a real file: the greedy token from "Implement @design.md,
|
|
22
|
+
// reuse the parser" is `design.md,`, which does not exist; stripped, `design.md`
|
|
23
|
+
// does. None of these chars are legitimate trailing characters of a doc path.
|
|
24
|
+
const MENTION_TRAILING_PUNCT = /[.,;:!?)\]}>"']+$/;
|
|
25
|
+
/** The cleaned path token of an @-mention: greedy match minus trailing prose punctuation. */
|
|
26
|
+
function mentionPath(token) {
|
|
27
|
+
return token.replace(MENTION_TRAILING_PUNCT, '');
|
|
28
|
+
}
|
|
29
|
+
/** Every distinct @-mention path in `text`, in first-seen order. */
|
|
30
|
+
function mentionPaths(text) {
|
|
31
|
+
const out = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
for (const m of text.matchAll(MENTION_RE)) {
|
|
34
|
+
const rel = mentionPath(m[1]);
|
|
35
|
+
if (rel === '' || seen.has(rel))
|
|
36
|
+
continue;
|
|
37
|
+
seen.add(rel);
|
|
38
|
+
out.push(rel);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Expand any @file references in the feature text by appending each referenced
|
|
44
|
+
* file's contents, so the planning children (clarify, decompose) always see the
|
|
45
|
+
* real spec inline instead of relying on the model to open the file itself.
|
|
46
|
+
* Without this, clarify on a one-line "Implement @spec.md" tends to bail with
|
|
47
|
+
* NONE because, to the model, the request looks small and unambiguous.
|
|
48
|
+
* Unreadable mentions (typos, non-file @tokens) are left untouched; the feature
|
|
49
|
+
* is returned verbatim when nothing readable is referenced.
|
|
50
|
+
*/
|
|
51
|
+
export async function expandFeatureMentions(cwd, feature) {
|
|
52
|
+
const blocks = [];
|
|
53
|
+
for (const rel of mentionPaths(feature)) {
|
|
54
|
+
try {
|
|
55
|
+
// Normalize CRLF/CR so an @-mentioned design doc saved on Windows
|
|
56
|
+
// inlines with LF endings the downstream phase parsers expect.
|
|
57
|
+
const body = await readTextFile(path.resolve(cwd, rel));
|
|
58
|
+
if (body.trim().length > 0) {
|
|
59
|
+
blocks.push(`--- contents of ${rel} ---\n${body.trim()}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// not a readable file — leave the @token in place, skip expansion
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return blocks.length === 0 ? feature : `${feature.trim()}\n\n${blocks.join('\n\n')}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The @file references in the feature that point at a readable file on disk —
|
|
70
|
+
* the bare path tokens, deduped, in first-seen order. Unreadable mentions
|
|
71
|
+
* (typos, non-file @tokens) are dropped so we never advertise a missing file as
|
|
72
|
+
* an authoritative spec.
|
|
73
|
+
*/
|
|
74
|
+
export async function readableMentions(cwd, feature) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const rel of mentionPaths(feature)) {
|
|
77
|
+
try {
|
|
78
|
+
await fsp.access(path.resolve(cwd, rel));
|
|
79
|
+
out.push(rel);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// not a readable file — don't thread it into task titles
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|