@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
@@ -51,6 +51,7 @@
51
51
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
52
52
  import * as path from 'node:path';
53
53
  import { shippedSources, stripCommentLines, SOURCE_HTML_RE, SOURCE_JS_RE } from './shipped-source.js';
54
+ import { blocksOf, parseSpecDoc } from './spec-doc.js';
54
55
  export function emptyProducers() {
55
56
  return {
56
57
  files: new Set(),
@@ -940,10 +941,15 @@ const PROSE_CONSUME_RE = /\b(?:serves?|serving|served|fallback|reads?|loads?|ren
940
941
  * `index.html`, `data/seed.json` and `assets/logo.svg` all extract.
941
942
  * Code-construct refs are not subject to this list. */
942
943
  const PROSE_ASSET_EXT_RE = /\.(?:html?|css|m?js|cjs|json|svg|png|jpe?g|gif|webp|ico|woff2?|ttf|otf|wasm|webmanifest|xml|csv|sql|ya?ml|toml|pdf|mp[34]|db|sqlite)$/i;
944
+ /** The block kinds that can carry consuming PROSE. A fence is code — its
945
+ * `serve`/`read` calls are the code channel's to resolve, not prose's — and a
946
+ * blockquote is quoting something the spec is not itself saying. */
947
+ const PROSE_KINDS = new Set(['para', 'list-item', 'table-row']);
943
948
  /** Backticked, asset-extension, path-shaped tokens on consuming-verb lines. */
944
949
  export function extractSpecProseRefs(spec) {
945
950
  const out = [];
946
- for (const line of spec.split('\n')) {
951
+ const prose = blocksOf(parseSpecDoc(spec)).filter(b => PROSE_KINDS.has(b.kind));
952
+ for (const line of prose.flatMap(b => b.text.split('\n'))) {
947
953
  if (!PROSE_CONSUME_RE.test(line))
948
954
  continue;
949
955
  const tick = /`([^`\n]+)`/g;
@@ -1,10 +1,27 @@
1
1
  import type { TaskState } from './task-types.js';
2
2
  import type { AutoResumeCandidate } from './resume-gap.js';
3
+ import { type RunEndKind } from './run-end.js';
3
4
  export interface TaskEntry {
5
+ /** Position in the list — shifts when a repair step is spliced in. */
4
6
  index: number;
7
+ /**
8
+ * The entry's stable identity, allocated at plan time and never reused.
9
+ * `index` moves and `title` is prose a later pass may rewrite; this is what
10
+ * the owned-requirements ledger joins on. Absent on an AUTO file written
11
+ * before the key existed, where the title join is still the only one there is.
12
+ */
13
+ key?: string;
5
14
  title: string;
6
15
  done: boolean;
7
16
  producedId?: string;
17
+ /** How many implementation attempts this entry has had. */
18
+ attempts?: number;
19
+ /**
20
+ * How the last attempt ENDED, recorded only when it ended abnormally. A
21
+ * resume otherwise cannot tell an entry the user cancelled from one that
22
+ * faulted, because both leave the same unchecked, stamped line.
23
+ */
24
+ lastEnd?: RunEndKind;
8
25
  }
9
26
  export declare function allocateAutoId(cwd: string): Promise<string>;
10
27
  /**
@@ -23,20 +40,40 @@ export declare function allocateAutoId(cwd: string): Promise<string>;
23
40
  */
24
41
  export declare function parseDecomposeList(raw: string): string[];
25
42
  /** Parsed DECOMPOSE_COVERAGE_PROMPT verdict. */
26
- export interface CoverageVerdict {
27
- kind: 'complete' | 'incomplete';
43
+ export type CoverageVerdict = {
44
+ kind: 'complete';
45
+ missing: string[];
46
+ } | {
47
+ kind: 'incomplete';
28
48
  missing: string[];
29
49
  }
50
+ /** The judge DID rule INCOMPLETE but named nothing to reprompt with. */
51
+ | {
52
+ kind: 'unparseable';
53
+ };
54
+ /** What an `unparseable` verdict contributes to the plan's missing-area list, so
55
+ * the judge's own INCOMPLETE cannot be shipped as COMPLETE. */
56
+ export declare const UNNAMED_COVERAGE_GAP = "the coverage judge ruled INCOMPLETE without naming the uncovered area";
30
57
  /**
31
58
  * Parse the coverage-triage child's verdict. Returns null when no COVERAGE tag is
32
- * present (the model wrote prose), and the caller reads a null verdict as an
33
- * empty missing-list, so a malformed judgment can never block planning.
59
+ * present at all (the model wrote prose), and the caller reads a null verdict as
60
+ * an empty missing-list, so a malformed judgment can never block planning.
34
61
  *
35
- * `COVERAGE: INCOMPLETE` with no MISSING lines also returns null, deliberately:
36
- * it names nothing to reprompt with, so treating it as a verdict would loop
37
- * blind. Confirmed by running both shapes.
62
+ * `COVERAGE: INCOMPLETE` with no MISSING lines is NOT null, and that distinction
63
+ * is the whole point: prose is no verdict, while this is a verdict of INCOMPLETE
64
+ * that happens to name nothing. Collapsing the two shipped a plan the judge had
65
+ * just ruled incomplete, logged as COMPLETE.
38
66
  */
39
67
  export declare function parseCoverageVerdict(raw: string): CoverageVerdict | null;
68
+ /** The plan key for the `index`th entry of a freshly planned list. */
69
+ export declare function planKeyAt(index: number): string;
70
+ /**
71
+ * The lowest key number no entry in `entries` holds. Keys are allocated 1:1 with
72
+ * plan entries, so an unkeyed legacy list has implicitly spent its first N
73
+ * numbers — counting it in keeps a spliced repair step from minting a key an
74
+ * eventual migration would hand to an existing entry.
75
+ */
76
+ export declare function nextPlanKey(entries: readonly TaskEntry[]): string;
40
77
  /** Parse the "## tasks" checkbox list. */
41
78
  export declare function parseTaskList(body: string): TaskEntry[];
42
79
  /** Build the initial AUTO-file body. `coverage` is the requirement-level
@@ -51,8 +88,23 @@ export declare function checkOffTask(cwd: string, id: string, index: number, pro
51
88
  * the inner task is allocated. This links the AUTO entry to its in-progress
52
89
  * inner task so /task-auto-resume can continue it from its saved phase instead
53
90
  * of starting a brand-new task — matching how /task-resume behaves.
91
+ *
92
+ * The first stamp is also where the attempt counter is minted: an entry under way
93
+ * has had one attempt.
54
94
  */
55
95
  export declare function stampTaskInProgress(cwd: string, id: string, index: number, producedId: string, title: string): Promise<void>;
96
+ /**
97
+ * Count one more attempt on the `index`th entry and return the new total.
98
+ *
99
+ * Called for a fresh start AND for a resume, because both spend a run on the
100
+ * entry: a task that crashes in refine is re-entered from scratch every time, and
101
+ * a counter that only saw fresh starts would read 1 forever while the loop re-ran
102
+ * it without end. The previous ending is cleared here — it describes the attempt
103
+ * that is now over.
104
+ */
105
+ export declare function beginTaskAttempt(cwd: string, id: string, index: number): Promise<number>;
106
+ /** Record how the `index`th entry's attempt ended. */
107
+ export declare function recordTaskEnd(cwd: string, id: string, index: number, lastEnd: RunEndKind): Promise<void>;
56
108
  /**
57
109
  * Insert a NEW unchecked entry directly after the `afterIndex`th checkbox — the
58
110
  * mid-run plan mutation the root-cause repair channel needs: a repair task must
@@ -75,6 +127,13 @@ export declare function stampTaskInProgress(cwd: string, id: string, index: numb
75
127
  * cached index.
76
128
  */
77
129
  export declare function insertTaskAfter(cwd: string, id: string, afterIndex: number, title: string): Promise<boolean>;
130
+ /**
131
+ * Insert a NEW unchecked entry directly BEFORE the `index`th checkbox — the
132
+ * position a health repair needs: the task about to run must wait until the red
133
+ * it would build on is fixed. Same monotonic, duplicate-refusing splice as
134
+ * {@link insertTaskAfter}.
135
+ */
136
+ export declare function insertTaskBefore(cwd: string, id: string, index: number, title: string): Promise<boolean>;
78
137
  /**
79
138
  * Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
80
139
  * last-write time the resume banner reports (see resume-gap.ts). Null when there
@@ -7,6 +7,8 @@
7
7
  * `## coverage`. The checkbox list under `## tasks` is the resume cursor, and the
8
8
  * loop finds its next step with `entries.find(e => !e.done)` rather than a
9
9
  * remembered position.
10
+ *
11
+ * A checkbox line reads `- [ ] P01 TASK_0006 a2 title` — see {@link ENTRY_RE}.
10
12
  */
11
13
  import * as fsp from 'node:fs/promises';
12
14
  import * as path from 'node:path';
@@ -14,6 +16,7 @@ import { tasksDir, ensureTasksDir, readTaskFile, setTaskSection } from './task-i
14
16
  import { extractSection, parseFrontMatter } from './task-parsers.js';
15
17
  import { readTextFile } from '../shared/fs-text.js';
16
18
  import { RESUMABLE_STATES } from './task-types.js';
19
+ import { RUN_END_POLICY } from './run-end.js';
17
20
  const AUTO_FILE_RE = /^(TASK_AUTO_\d{4,})\.md$/;
18
21
  export async function allocateAutoId(cwd) {
19
22
  await ensureTasksDir(cwd);
@@ -52,14 +55,21 @@ export function parseDecomposeList(raw) {
52
55
  }
53
56
  return out;
54
57
  }
58
+ /** The MISSING bound the coverage prompt states; a longer list is truncated
59
+ * rather than rejected. */
60
+ const MAX_MISSING_AREAS = 8;
61
+ /** What an `unparseable` verdict contributes to the plan's missing-area list, so
62
+ * the judge's own INCOMPLETE cannot be shipped as COMPLETE. */
63
+ export const UNNAMED_COVERAGE_GAP = 'the coverage judge ruled INCOMPLETE without naming the uncovered area';
55
64
  /**
56
65
  * Parse the coverage-triage child's verdict. Returns null when no COVERAGE tag is
57
- * present (the model wrote prose), and the caller reads a null verdict as an
58
- * empty missing-list, so a malformed judgment can never block planning.
66
+ * present at all (the model wrote prose), and the caller reads a null verdict as
67
+ * an empty missing-list, so a malformed judgment can never block planning.
59
68
  *
60
- * `COVERAGE: INCOMPLETE` with no MISSING lines also returns null, deliberately:
61
- * it names nothing to reprompt with, so treating it as a verdict would loop
62
- * blind. Confirmed by running both shapes.
69
+ * `COVERAGE: INCOMPLETE` with no MISSING lines is NOT null, and that distinction
70
+ * is the whole point: prose is no verdict, while this is a verdict of INCOMPLETE
71
+ * that happens to name nothing. Collapsing the two shipped a plan the judge had
72
+ * just ruled incomplete, logged as COMPLETE.
63
73
  */
64
74
  export function parseCoverageVerdict(raw) {
65
75
  const tag = /^\s*COVERAGE:\s*(COMPLETE|INCOMPLETE)\s*$/im.exec(raw);
@@ -72,39 +82,95 @@ export function parseCoverageVerdict(raw) {
72
82
  const m = /^\s*MISSING:\s*(.+?)\s*$/i.exec(line);
73
83
  if (m && m[1].length > 0)
74
84
  missing.push(m[1]);
75
- if (missing.length >= 8)
85
+ if (missing.length >= MAX_MISSING_AREAS)
76
86
  break;
77
87
  }
78
- // INCOMPLETE with no MISSING lines carries no actionable signal to reprompt
79
- // with — treat it like an unparseable verdict rather than looping blind.
80
- return missing.length === 0 ? null : { kind: 'incomplete', missing };
88
+ return missing.length === 0 ? { kind: 'unparseable' } : { kind: 'incomplete', missing };
81
89
  }
82
90
  const CHECKBOX_RE = /^- \[([ xX])\]\s+(.+?)\s*$/;
83
- const PRODUCED_ID_RE = /^(TASK_\d{4,})\s{2,}(.+)$/;
91
+ /**
92
+ * The checkbox line grammar: `- [ ] P01 TASK_0006 a2 title`, where the attempts
93
+ * field may carry how the last attempt ended — `a2:failed`.
94
+ *
95
+ * Every field is optional and each is followed by ONE space; the title is set off
96
+ * by an EXTRA space. That two-space delimiter is what keeps the grammar
97
+ * unambiguous against prose: a title of its own may begin `TASK_0006 is broken`
98
+ * or `(auto) …` and is read whole, because a single space never separates a field
99
+ * from a title. Both legacy forms — `TASK_0006 title` and a bare `title` — are
100
+ * the same grammar with fields missing, so no migration pass is needed on read.
101
+ *
102
+ * The end suffix spells out the endings RUN_END_POLICY registers rather than
103
+ * accepting any word, so `a2:whatever ` stays prose and a renamed ending is a
104
+ * parse miss, never a silently mis-read field.
105
+ */
106
+ const ENTRY_RE = new RegExp('^(?:(?:(P\\d{2,}) )?(?:(TASK_\\d{4,}) )?'
107
+ + `(?:a(\\d+)(?::(${Object.keys(RUN_END_POLICY).join('|')}))? )? )?(.+)$`);
108
+ function parseEntryLine(line, index) {
109
+ const m = CHECKBOX_RE.exec(line.trim());
110
+ if (!m)
111
+ return null;
112
+ const f = ENTRY_RE.exec(m[2].trim());
113
+ if (!f)
114
+ return null;
115
+ const [, key, producedId, attempts, lastEnd, title] = f;
116
+ // Attempts are only ever written alongside an id (they are minted when the
117
+ // inner task starts), so a bare `a7 …` is a title, not a field.
118
+ const keyed = key !== undefined || producedId !== undefined;
119
+ return {
120
+ index,
121
+ done: m[1].toLowerCase() === 'x',
122
+ // A line carries a stamped TASK_NNNN id both when done (the completed
123
+ // inner task) and when merely started — an unchecked, stamped line is an
124
+ // in-progress entry whose inner task can be resumed.
125
+ ...(key !== undefined && { key }),
126
+ ...(producedId !== undefined && { producedId }),
127
+ ...(keyed && attempts !== undefined && { attempts: parseInt(attempts, 10) }),
128
+ ...(keyed && lastEnd !== undefined && { lastEnd: lastEnd }),
129
+ title: keyed || attempts === undefined ? title.trim() : m[2].trim()
130
+ };
131
+ }
132
+ /**
133
+ * Render one entry back into the line grammar.
134
+ *
135
+ * An entry with NEITHER a key nor an id has no field to anchor an attempts count
136
+ * against, and `a2 title` alone re-parses as a title (see {@link ENTRY_RE}) — so
137
+ * the count is dropped rather than written into a line that would read it back as
138
+ * prose. Only pre-key legacy plans are in that state, and they predate the counter.
139
+ */
140
+ function renderEntryLine(e) {
141
+ const anchored = e.key !== undefined || e.producedId !== undefined;
142
+ const attempts = e.attempts === undefined || !anchored ?
143
+ undefined
144
+ : `a${e.attempts}${e.lastEnd === undefined ? '' : `:${e.lastEnd}`}`;
145
+ const fields = [e.key, e.producedId, attempts]
146
+ .filter((f) => f !== undefined && f.length > 0)
147
+ .join(' ');
148
+ return `- [${e.done ? 'x' : ' '}] ${fields.length > 0 ? `${fields} ` : ''}${e.title}`;
149
+ }
150
+ /** The plan key for the `index`th entry of a freshly planned list. */
151
+ export function planKeyAt(index) {
152
+ return `P${String(index + 1).padStart(2, '0')}`;
153
+ }
154
+ /**
155
+ * The lowest key number no entry in `entries` holds. Keys are allocated 1:1 with
156
+ * plan entries, so an unkeyed legacy list has implicitly spent its first N
157
+ * numbers — counting it in keeps a spliced repair step from minting a key an
158
+ * eventual migration would hand to an existing entry.
159
+ */
160
+ export function nextPlanKey(entries) {
161
+ const highest = entries.reduce((max, e) => Math.max(max, e.key ? parseInt(e.key.slice(1), 10) : 0), entries.length);
162
+ return planKeyAt(highest);
163
+ }
84
164
  /** Parse the "## tasks" checkbox list. */
85
165
  export function parseTaskList(body) {
86
166
  const section = extractSection(body, 'tasks');
87
167
  if (section === null)
88
168
  return [];
89
169
  const entries = [];
90
- let index = 0;
91
170
  for (const line of section.split('\n')) {
92
- const m = CHECKBOX_RE.exec(line.trim());
93
- if (!m)
94
- continue;
95
- const done = m[1].toLowerCase() === 'x';
96
- const rest = m[2].trim();
97
- // A line carries a stamped TASK_NNNN id both when done (the completed
98
- // inner task) and when merely started — an unchecked, stamped line is an
99
- // in-progress entry whose inner task can be resumed.
100
- const idm = PRODUCED_ID_RE.exec(rest);
101
- if (idm) {
102
- entries.push({ index, title: idm[2].trim(), done, producedId: idm[1] });
103
- }
104
- else {
105
- entries.push({ index, title: rest, done });
106
- }
107
- index++;
171
+ const entry = parseEntryLine(line, entries.length);
172
+ if (entry)
173
+ entries.push(entry);
108
174
  }
109
175
  return entries;
110
176
  }
@@ -113,24 +179,31 @@ export function parseTaskList(body) {
113
179
  * cross-cutting and what stayed unowned. An empty string omits the section
114
180
  * entirely rather than emitting a blank heading. */
115
181
  export function buildAutoBody(feature, clarifications, titles, coverage = '') {
116
- const tasks = titles.map(t => `- [ ] ${t}`).join('\n');
182
+ const tasks = titles
183
+ .map((t, i) => renderEntryLine({ key: planKeyAt(i), title: t, done: false }))
184
+ .join('\n');
117
185
  return (`\n## feature prompt\n\n${feature.trim() || '(none)'}\n\n`
118
186
  + `## clarifications\n\n${clarifications.trim() || '(none)'}\n\n`
119
187
  + `## tasks\n\n${tasks}\n`
120
188
  + (coverage.trim().length > 0 ? `\n## coverage\n\n${coverage.trim()}\n` : ''));
121
189
  }
122
- /** Rewrite the Nth checkbox line of the "## tasks" section in place. */
190
+ /**
191
+ * Rewrite the Nth checkbox line of the "## tasks" section in place. `render`
192
+ * receives the line as parsed, so a rewrite that only changes one field carries
193
+ * the others — a check-off must not drop the key the ownership join reads.
194
+ */
123
195
  async function rewriteTaskLine(cwd, id, index, render, label) {
124
196
  const { body } = await readTaskFile(cwd, id);
125
197
  const section = extractSection(body, 'tasks') ?? '';
126
198
  const lines = section.split('\n');
127
199
  let seen = -1;
128
200
  for (let i = 0; i < lines.length; i++) {
129
- if (!CHECKBOX_RE.test(lines[i].trim()))
201
+ const entry = parseEntryLine(lines[i], seen + 1);
202
+ if (!entry)
130
203
  continue;
131
204
  seen++;
132
205
  if (seen === index) {
133
- lines[i] = render();
206
+ lines[i] = render(entry);
134
207
  break;
135
208
  }
136
209
  }
@@ -141,25 +214,45 @@ async function rewriteTaskLine(cwd, id, index, render, label) {
141
214
  }
142
215
  /** Check off the Nth checkbox line, stamping the produced TASK_NNNN id. */
143
216
  export async function checkOffTask(cwd, id, index, producedId, title) {
144
- await rewriteTaskLine(cwd, id, index, () => (producedId ? `- [x] ${producedId} ${title}` : `- [x] ${title}`), 'checkOffTask');
217
+ await rewriteTaskLine(cwd, id, index, e => renderEntryLine({ ...e, done: true, title, producedId: producedId || undefined }), 'checkOffTask');
145
218
  }
146
219
  /**
147
220
  * Stamp the inner TASK_NNNN id onto the Nth (still-unchecked) entry the moment
148
221
  * the inner task is allocated. This links the AUTO entry to its in-progress
149
222
  * inner task so /task-auto-resume can continue it from its saved phase instead
150
223
  * of starting a brand-new task — matching how /task-resume behaves.
224
+ *
225
+ * The first stamp is also where the attempt counter is minted: an entry under way
226
+ * has had one attempt.
151
227
  */
152
228
  export async function stampTaskInProgress(cwd, id, index, producedId, title) {
153
- await rewriteTaskLine(cwd, id, index, () => `- [ ] ${producedId} ${title}`, 'stampTaskInProgress');
229
+ await rewriteTaskLine(cwd, id, index, e => renderEntryLine({ ...e, done: false, title, producedId }), 'stampTaskInProgress');
230
+ }
231
+ /**
232
+ * Count one more attempt on the `index`th entry and return the new total.
233
+ *
234
+ * Called for a fresh start AND for a resume, because both spend a run on the
235
+ * entry: a task that crashes in refine is re-entered from scratch every time, and
236
+ * a counter that only saw fresh starts would read 1 forever while the loop re-ran
237
+ * it without end. The previous ending is cleared here — it describes the attempt
238
+ * that is now over.
239
+ */
240
+ export async function beginTaskAttempt(cwd, id, index) {
241
+ let attempts = 1;
242
+ await rewriteTaskLine(cwd, id, index, e => {
243
+ attempts = (e.attempts ?? 0) + 1;
244
+ const { lastEnd: _ended, ...rest } = e;
245
+ return renderEntryLine({ ...rest, attempts });
246
+ }, 'beginTaskAttempt');
247
+ return attempts;
154
248
  }
155
- /** The bare title of a checkbox line (id stamp stripped), or null if not one. */
249
+ /** Record how the `index`th entry's attempt ended. */
250
+ export async function recordTaskEnd(cwd, id, index, lastEnd) {
251
+ await rewriteTaskLine(cwd, id, index, e => renderEntryLine({ ...e, attempts: e.attempts ?? 1, lastEnd }), 'recordTaskEnd');
252
+ }
253
+ /** The bare title of a checkbox line (fields stripped), or null if not one. */
156
254
  function entryTitle(line) {
157
- const m = CHECKBOX_RE.exec(line.trim());
158
- if (!m)
159
- return null;
160
- const rest = m[2].trim();
161
- const idm = PRODUCED_ID_RE.exec(rest);
162
- return idm ? idm[2].trim() : rest;
255
+ return parseEntryLine(line, 0)?.title ?? null;
163
256
  }
164
257
  /**
165
258
  * Insert a NEW unchecked entry directly after the `afterIndex`th checkbox — the
@@ -194,15 +287,18 @@ export async function insertTaskAfter(cwd, id, afterIndex, title) {
194
287
  // sits relative to afterIndex — never add a second one.
195
288
  if (lines.some(l => entryTitle(l) === clean))
196
289
  return false;
197
- let seen = -1;
290
+ const entries = [];
198
291
  let insertAt = -1;
199
292
  for (let i = 0; i < lines.length; i++) {
200
- if (!CHECKBOX_RE.test(lines[i].trim()))
293
+ const entry = parseEntryLine(lines[i], entries.length);
294
+ if (!entry)
201
295
  continue;
202
- seen++;
203
- insertAt = i + 1;
204
- if (seen === afterIndex)
205
- break;
296
+ entries.push(entry);
297
+ // A negative afterIndex means "before the first entry".
298
+ if (entries.length === 1 && afterIndex < 0)
299
+ insertAt = i;
300
+ if (entries.length - 1 <= afterIndex)
301
+ insertAt = i + 1;
206
302
  }
207
303
  // An out-of-range afterIndex is not an error: the loop above leaves `insertAt`
208
304
  // just past the LAST checkbox, so the entry is appended rather than lost — a
@@ -213,10 +309,19 @@ export async function insertTaskAfter(cwd, id, afterIndex, title) {
213
309
  // lines at all, where there is no position to splice into.
214
310
  if (insertAt === -1)
215
311
  return false;
216
- lines.splice(insertAt, 0, `- [ ] ${clean}`);
312
+ lines.splice(insertAt, 0, renderEntryLine({ key: nextPlanKey(entries), title: clean, done: false }));
217
313
  await setTaskSection(cwd, id, 'tasks', lines.join('\n'));
218
314
  return true;
219
315
  }
316
+ /**
317
+ * Insert a NEW unchecked entry directly BEFORE the `index`th checkbox — the
318
+ * position a health repair needs: the task about to run must wait until the red
319
+ * it would build on is fixed. Same monotonic, duplicate-refusing splice as
320
+ * {@link insertTaskAfter}.
321
+ */
322
+ export function insertTaskBefore(cwd, id, index, title) {
323
+ return insertTaskAfter(cwd, id, index - 1, title);
324
+ }
220
325
  /**
221
326
  * Find the most-recently-updated resumable TASK_AUTO_* file, with the state and
222
327
  * last-write time the resume banner reports (see resume-gap.ts). Null when there
@@ -1,4 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
+ import { type TaskEntry } from './auto-io.js';
3
+ import { expandFeatureMentions, readableMentions } from './mentions.js';
4
+ export { expandFeatureMentions, readableMentions };
2
5
  import { type GateDeps } from './task-gates.js';
3
6
  import { type FinalGateStageDeps } from './run-final-gate.js';
4
7
  import { type RequirementEntry } from './requirements.js';
@@ -31,23 +34,6 @@ export interface AutoDeps extends GateDeps, FinalGateStageDeps {
31
34
  }
32
35
  /** Wait for every plan-debug line written so far to reach disk. Tests only. */
33
36
  export declare function flushPlanDebug(): Promise<unknown>;
34
- /**
35
- * Expand any @file references in the feature text by appending each referenced
36
- * file's contents, so the planning children (clarify, decompose) always see the
37
- * real spec inline instead of relying on the model to open the file itself.
38
- * Without this, clarify on a one-line "Implement @spec.md" tends to bail with
39
- * NONE because, to the model, the request looks small and unambiguous.
40
- * Unreadable mentions (typos, non-file @tokens) are left untouched; the feature
41
- * is returned verbatim when nothing readable is referenced.
42
- */
43
- export declare function expandFeatureMentions(cwd: string, feature: string): Promise<string>;
44
- /**
45
- * The @file references in the feature that point at a readable file on disk —
46
- * the bare path tokens, deduped, in first-seen order. Unreadable mentions
47
- * (typos, non-file @tokens) are dropped so we never advertise a missing file as
48
- * an authoritative spec.
49
- */
50
- export declare function readableMentions(cwd: string, feature: string): Promise<string[]>;
51
37
  /**
52
38
  * Thread the feature's spec references AND any per-task decisions into every
53
39
  * decomposed task title. A title is ALL a per-task pipeline ever sees, so both the
@@ -76,11 +62,28 @@ export declare function attachSpecRefs(titles: string[], refs: string[]): string
76
62
  *
77
63
  * The plan listing strips the threaded "| decisions … | spec …" tail from each
78
64
  * title and keeps the human-readable head, so the model reads clean step names,
79
- * and it marks the current one "(THIS STEP)" — both confirmed by building a fence
65
+ * and it marks the current one `[this]` — both confirmed by building a fence
80
66
  * over threaded titles. The authoritative spec ref still rides on THIS step's own
81
67
  * title via attachSpecRefs.
68
+ *
69
+ * Every sibling used to read as unbuilt, which is a lie about the first half of
70
+ * any plan past step one: a step told its predecessors are "done in later runs"
71
+ * re-scaffolds what is already in the tree. The entries carry their own done
72
+ * state, so the listing states it.
73
+ */
74
+ export declare function buildScopeFence(entries: readonly TaskEntry[], currentIndex: number): string;
75
+ /**
76
+ * The scope fence for step `currentIndex`, plus the REPAIR fence when that step is
77
+ * a queued root-cause repair. A repair title ("repair test/teardown.ts: …") reads
78
+ * to refine like any other feature step, and refine's job is to expand a title into
79
+ * a full spec — which is exactly how "repair the teardown" becomes "overhaul the
80
+ * test infrastructure". The extra fence pins the one editable file, and pins VERIFY
81
+ * to the failing command WHEN the title carries one: `extractFailingCommand` reads
82
+ * a backticked runner command out of the defect text, so a defect quoting
83
+ * `bun run test` yields it while a plain-prose defect yields undefined and only the
84
+ * file pin applies.
82
85
  */
83
- export declare function buildScopeFence(titles: string[], currentIndex: number): string;
86
+ export declare function buildStepFence(entries: readonly TaskEntry[], currentIndex: number): string;
84
87
  /**
85
88
  * What ORIENT establishes about the feature before anyone is asked anything: the
86
89
  * spec text the planning children will actually read, and the requirement ledger