@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.
Files changed (125) hide show
  1. package/README.md +7 -4
  2. package/dist/config/config.d.ts +61 -18
  3. package/dist/config/config.js +50 -6
  4. package/dist/config/register.js +18 -5
  5. package/dist/shared/content-tokens.d.ts +16 -0
  6. package/dist/shared/content-tokens.js +137 -0
  7. package/dist/task/accept-debt.d.ts +31 -1
  8. package/dist/task/accept-debt.js +49 -3
  9. package/dist/task/artifact-closure.js +7 -1
  10. package/dist/task/auto-io.d.ts +66 -7
  11. package/dist/task/auto-io.js +152 -47
  12. package/dist/task/auto-orchestrator.d.ts +22 -19
  13. package/dist/task/auto-orchestrator.js +184 -112
  14. package/dist/task/child-runner.d.ts +18 -0
  15. package/dist/task/child-runner.js +7 -8
  16. package/dist/task/command-run.d.ts +4 -0
  17. package/dist/task/command-run.js +4 -2
  18. package/dist/task/constraint-policy.d.ts +52 -0
  19. package/dist/task/constraint-policy.js +66 -0
  20. package/dist/task/context-silence.d.ts +5 -4
  21. package/dist/task/context-silence.js +13 -5
  22. package/dist/task/coverage-loop.d.ts +7 -4
  23. package/dist/task/coverage-loop.js +16 -138
  24. package/dist/task/debug-log.d.ts +25 -2
  25. package/dist/task/debug-log.js +64 -10
  26. package/dist/task/decompose-fidelity.d.ts +17 -4
  27. package/dist/task/decompose-fidelity.js +95 -55
  28. package/dist/task/decompose-granularity.d.ts +7 -7
  29. package/dist/task/decompose-granularity.js +8 -8
  30. package/dist/task/env-notes.d.ts +65 -19
  31. package/dist/task/env-notes.js +152 -50
  32. package/dist/task/external-context.js +25 -5
  33. package/dist/task/fix-context.d.ts +39 -0
  34. package/dist/task/fix-context.js +58 -0
  35. package/dist/task/gate-deps.d.ts +49 -0
  36. package/dist/task/gate-deps.js +201 -39
  37. package/dist/task/gate-evidence.d.ts +51 -0
  38. package/dist/task/gate-evidence.js +90 -0
  39. package/dist/task/gate-resolution.d.ts +119 -0
  40. package/dist/task/gate-resolution.js +120 -0
  41. package/dist/task/git-state-guard.js +3 -26
  42. package/dist/task/handoff.d.ts +35 -0
  43. package/dist/task/handoff.js +100 -0
  44. package/dist/task/health-baseline.d.ts +87 -0
  45. package/dist/task/health-baseline.js +158 -0
  46. package/dist/task/health-repair.d.ts +68 -0
  47. package/dist/task/health-repair.js +124 -0
  48. package/dist/task/ledger.d.ts +10 -0
  49. package/dist/task/ledger.js +22 -11
  50. package/dist/task/lint-fix.d.ts +11 -0
  51. package/dist/task/lint-fix.js +14 -8
  52. package/dist/task/loop-detector.d.ts +52 -3
  53. package/dist/task/loop-detector.js +81 -5
  54. package/dist/task/mentions.d.ts +17 -0
  55. package/dist/task/mentions.js +86 -0
  56. package/dist/task/orchestrator.d.ts +53 -9
  57. package/dist/task/orchestrator.js +104 -28
  58. package/dist/task/orientation.d.ts +135 -29
  59. package/dist/task/orientation.js +259 -67
  60. package/dist/task/owned-freeze-reassign.d.ts +7 -3
  61. package/dist/task/owned-freeze-reassign.js +13 -5
  62. package/dist/task/parsers.d.ts +6 -1
  63. package/dist/task/parsers.js +21 -4
  64. package/dist/task/phases.d.ts +1 -1
  65. package/dist/task/phases.js +125 -153
  66. package/dist/task/plan-orchestrator.js +5 -6
  67. package/dist/task/plan-rounds.d.ts +1 -0
  68. package/dist/task/plan-rounds.js +8 -1
  69. package/dist/task/prohibition-probe.d.ts +6 -1
  70. package/dist/task/prohibition-probe.js +26 -6
  71. package/dist/task/prompts.js +13 -2
  72. package/dist/task/qa-transcript.d.ts +17 -0
  73. package/dist/task/qa-transcript.js +28 -0
  74. package/dist/task/question-source.d.ts +16 -2
  75. package/dist/task/question-source.js +21 -8
  76. package/dist/task/repo-health-check.d.ts +27 -0
  77. package/dist/task/repo-health-check.js +39 -5
  78. package/dist/task/requirements.d.ts +67 -13
  79. package/dist/task/requirements.js +129 -67
  80. package/dist/task/research-worker.d.ts +43 -2
  81. package/dist/task/research-worker.js +103 -8
  82. package/dist/task/run-bracket.d.ts +9 -4
  83. package/dist/task/run-bracket.js +9 -0
  84. package/dist/task/run-context.d.ts +152 -0
  85. package/dist/task/run-context.js +277 -0
  86. package/dist/task/spec-doc.d.ts +78 -0
  87. package/dist/task/spec-doc.js +205 -0
  88. package/dist/task/spec-model.d.ts +70 -0
  89. package/dist/task/spec-model.js +139 -0
  90. package/dist/task/stall-detector.d.ts +1 -1
  91. package/dist/task/stall-detector.js +9 -5
  92. package/dist/task/state-dir.d.ts +29 -0
  93. package/dist/task/state-dir.js +80 -0
  94. package/dist/task/suppression-probe.d.ts +67 -0
  95. package/dist/task/suppression-probe.js +78 -0
  96. package/dist/task/task-gates.d.ts +52 -58
  97. package/dist/task/task-gates.js +161 -199
  98. package/dist/task/task-io.d.ts +10 -0
  99. package/dist/task/task-io.js +69 -18
  100. package/dist/task/task-parsers.js +6 -6
  101. package/dist/task/task-types.d.ts +7 -0
  102. package/dist/task/timings.d.ts +11 -0
  103. package/dist/task/timings.js +21 -0
  104. package/dist/task/tree-hash.d.ts +8 -0
  105. package/dist/task/tree-hash.js +55 -0
  106. package/dist/task/verify-reconcile.d.ts +4 -3
  107. package/dist/task/verify-reconcile.js +7 -8
  108. package/dist/task/verify-resolution.d.ts +32 -6
  109. package/dist/task/verify-resolution.js +78 -15
  110. package/dist/task/verify-work.d.ts +105 -35
  111. package/dist/task/verify-work.js +232 -60
  112. package/dist/task/yolo.d.ts +0 -13
  113. package/dist/task/yolo.js +9 -21
  114. package/dist/workers/pi-worker-core.d.ts +20 -0
  115. package/dist/workers/pi-worker-core.js +16 -11
  116. package/dist/workers/pi-worker-docs.d.ts +6 -5
  117. package/dist/workers/pi-worker-docs.js +8 -7
  118. package/dist/workers/pi-worker-fetch.d.ts +3 -2
  119. package/dist/workers/pi-worker-fetch.js +5 -4
  120. package/dist/workers/pi-worker-search.js +5 -4
  121. package/dist/workers/research-cache.d.ts +23 -1
  122. package/dist/workers/research-cache.js +63 -3
  123. package/dist/workers/shared.d.ts +18 -0
  124. package/dist/workers/shared.js +38 -22
  125. 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
+ }
@@ -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. */
@@ -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, drop an incoming item whose key is already present, cap to the
8
- * newest MAX (oldest dropped), mkdir the tasks dir, write the whole file back
9
- * (`lines.join('\n') + '\n'`, plain `writeFile`, NOT atomic). Every fault is
10
- * swallowed — a ledger is a sharpener or an auditing aid, never a blocker of the
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
- * Within a batch the first item with a key wins; a key already stored wins over
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
- continue;
64
- seen.add(k);
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 (merged.length === existing.length && onNoop === 'skip')
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);
@@ -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;
@@ -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 it under the same
354
- // `frozen-path:` prefix as the guard trip, so the gate loop can route
355
- // straight to the human picker instead of burning unattended AUTOFIX
356
- // rounds an impl re-run under the same freeze cannot converge out of.
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
- windowSize: number;
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
- /** The restart hint a re-spawned child gets after a loop kill: names the call. */
88
- export declare function formatLoopHint(hit: LoopHit): string;
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
- this.buf.push({ key, path: primaryPath(call.args), offset, end: readEnd(call.args, offset) });
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
- /** The restart hint a re-spawned child gets after a loop kill: names the call. */
161
- export function formatLoopHint(hit) {
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
+ }