@mjasnikovs/pi-task 0.40.50 → 0.41.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 (123) 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 +15 -1
  8. package/dist/task/accept-debt.js +4 -0
  9. package/dist/task/artifact-closure.js +7 -1
  10. package/dist/task/auto-io.d.ts +59 -7
  11. package/dist/task/auto-io.js +140 -47
  12. package/dist/task/auto-orchestrator.d.ts +22 -19
  13. package/dist/task/auto-orchestrator.js +126 -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 +199 -38
  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/ledger.d.ts +10 -0
  47. package/dist/task/ledger.js +22 -11
  48. package/dist/task/lint-fix.d.ts +11 -0
  49. package/dist/task/lint-fix.js +14 -8
  50. package/dist/task/loop-detector.d.ts +52 -3
  51. package/dist/task/loop-detector.js +81 -5
  52. package/dist/task/mentions.d.ts +17 -0
  53. package/dist/task/mentions.js +86 -0
  54. package/dist/task/orchestrator.d.ts +53 -9
  55. package/dist/task/orchestrator.js +104 -28
  56. package/dist/task/orientation.d.ts +135 -29
  57. package/dist/task/orientation.js +259 -67
  58. package/dist/task/owned-freeze-reassign.d.ts +7 -3
  59. package/dist/task/owned-freeze-reassign.js +13 -5
  60. package/dist/task/parsers.d.ts +6 -1
  61. package/dist/task/parsers.js +21 -4
  62. package/dist/task/phases.d.ts +1 -1
  63. package/dist/task/phases.js +125 -153
  64. package/dist/task/plan-orchestrator.js +5 -6
  65. package/dist/task/plan-rounds.d.ts +1 -0
  66. package/dist/task/plan-rounds.js +8 -1
  67. package/dist/task/prohibition-probe.d.ts +6 -1
  68. package/dist/task/prohibition-probe.js +26 -6
  69. package/dist/task/prompts.js +13 -2
  70. package/dist/task/qa-transcript.d.ts +17 -0
  71. package/dist/task/qa-transcript.js +28 -0
  72. package/dist/task/question-source.d.ts +16 -2
  73. package/dist/task/question-source.js +21 -8
  74. package/dist/task/repo-health-check.d.ts +27 -0
  75. package/dist/task/repo-health-check.js +39 -5
  76. package/dist/task/requirements.d.ts +67 -13
  77. package/dist/task/requirements.js +129 -67
  78. package/dist/task/research-worker.d.ts +43 -2
  79. package/dist/task/research-worker.js +103 -8
  80. package/dist/task/run-bracket.d.ts +9 -4
  81. package/dist/task/run-bracket.js +9 -0
  82. package/dist/task/run-context.d.ts +152 -0
  83. package/dist/task/run-context.js +277 -0
  84. package/dist/task/spec-doc.d.ts +78 -0
  85. package/dist/task/spec-doc.js +205 -0
  86. package/dist/task/spec-model.d.ts +70 -0
  87. package/dist/task/spec-model.js +139 -0
  88. package/dist/task/stall-detector.d.ts +1 -1
  89. package/dist/task/stall-detector.js +9 -5
  90. package/dist/task/state-dir.d.ts +29 -0
  91. package/dist/task/state-dir.js +80 -0
  92. package/dist/task/suppression-probe.d.ts +67 -0
  93. package/dist/task/suppression-probe.js +78 -0
  94. package/dist/task/task-gates.d.ts +39 -57
  95. package/dist/task/task-gates.js +115 -195
  96. package/dist/task/task-io.d.ts +10 -0
  97. package/dist/task/task-io.js +69 -18
  98. package/dist/task/task-parsers.js +6 -6
  99. package/dist/task/task-types.d.ts +7 -0
  100. package/dist/task/timings.d.ts +11 -0
  101. package/dist/task/timings.js +21 -0
  102. package/dist/task/tree-hash.d.ts +8 -0
  103. package/dist/task/tree-hash.js +55 -0
  104. package/dist/task/verify-reconcile.d.ts +4 -3
  105. package/dist/task/verify-reconcile.js +7 -8
  106. package/dist/task/verify-resolution.d.ts +28 -5
  107. package/dist/task/verify-resolution.js +70 -13
  108. package/dist/task/verify-work.d.ts +98 -35
  109. package/dist/task/verify-work.js +227 -60
  110. package/dist/task/yolo.d.ts +0 -13
  111. package/dist/task/yolo.js +9 -21
  112. package/dist/workers/pi-worker-core.d.ts +20 -0
  113. package/dist/workers/pi-worker-core.js +16 -11
  114. package/dist/workers/pi-worker-docs.d.ts +6 -5
  115. package/dist/workers/pi-worker-docs.js +8 -7
  116. package/dist/workers/pi-worker-fetch.d.ts +3 -2
  117. package/dist/workers/pi-worker-fetch.js +5 -4
  118. package/dist/workers/pi-worker-search.js +5 -4
  119. package/dist/workers/research-cache.d.ts +23 -1
  120. package/dist/workers/research-cache.js +63 -3
  121. package/dist/workers/shared.d.ts +18 -0
  122. package/dist/workers/shared.js +38 -22
  123. package/package.json +1 -1
@@ -0,0 +1,205 @@
1
+ /**
2
+ * spec-doc — ONE structured read of the source design document.
3
+ *
4
+ * The failure this closes: six host-side planning parsers each split the same
5
+ * markdown their own way, over one flat string. `demark` ran for grounding and
6
+ * was thrown away, so `findDroppedPlusFragments` re-cleaned the raw quote by a
7
+ * different rule; requirement candidacy could not tell a sentence from a SQL
8
+ * column or a router table's guard column. Live, that shipped task titles
9
+ * carrying `check` (a `check (…)` constraint inside a DDL fence) and
10
+ * `password) | public |` (two cells of a routing table) as restored spec
11
+ * constraints, and promoted the document's one-line product description to a
12
+ * requirement no task could ever own.
13
+ *
14
+ * A document is PREAMBLE (whatever sits above the first heading — where a spec
15
+ * says what the thing IS) plus heading-delimited SECTIONS of BLOCKS. A block is
16
+ * the smallest unit a quote can be grounded in and the smallest unit a policy can
17
+ * exclude, and it carries its own `plain`: the text as a model READS it, which is
18
+ * the single normalisation every grounding guard in the pipeline shares.
19
+ *
20
+ * Markdown-shaped but not markdown-dependent: a doc with no headings, no lists
21
+ * and no tables parses to one paragraph block per paragraph, which is exactly the
22
+ * flat-string behavior every consumer had before.
23
+ */
24
+ import { normalise } from './contracts.js';
25
+ /**
26
+ * Markdown MARKUP dropped before grounding — emphasis runs, list and heading
27
+ * markers, blockquote markers, table pipes and CODE BACKTICKS. Not content: no
28
+ * word, number or punctuation inside a sentence is touched, so this cannot make
29
+ * an invented quote match.
30
+ *
31
+ * WHY. A model copies a spec line as it READS, and what it reads is rendered:
32
+ * `2. **Auth** — sessions, login/logout/me, guards + tests.` comes back as
33
+ * `Auth — sessions, login/logout/me, guards + tests.` That is a verbatim copy of
34
+ * the line's TEXT, and the exact-substring test called it fabricated and threw
35
+ * it away — including, as here, the "+ tests" line that is the restoration
36
+ * module's own worked example.
37
+ *
38
+ * BACKTICKS ARE THE SAME CLASS and were the larger half. A code span renders as
39
+ * bare text, so `3. **Invites** — create/validate/redeem, \`/join/:token\` page.`
40
+ * comes back as `Invites — create/validate/redeem, /join/:token page.` Screening
41
+ * every spec line in its RENDERED form is what makes those quotes match at all.
42
+ *
43
+ * The two directions this has to hold in, both run:
44
+ * FLOOR a real spec line with ONE content word altered must NOT be grounded —
45
+ * changing `sessions` to `tokens`, or `redeem` to `revoke`, drops it.
46
+ * CEILING a real spec line quoted without its markup MUST be grounded — both the
47
+ * `2. **Auth** —` numbering-and-bold case and the backticked
48
+ * `` `/join/:token` `` case still match.
49
+ */
50
+ export function demark(s) {
51
+ return s
52
+ .replace(/\*\*|__/g, '')
53
+ .replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
54
+ .replace(/^\s*>\s?/gm, '')
55
+ .replace(/^#+\s*/gm, '')
56
+ .replace(/`/g, '')
57
+ .replace(/\|/g, ' ');
58
+ }
59
+ const HEADING_RE = /^(#{1,6})\s+(\S.*?)\s*$/;
60
+ const FENCE_RE = /^\s*(```+|~~~+)/;
61
+ const TABLE_ROW_RE = /^\s*\|/;
62
+ const BLOCKQUOTE_RE = /^\s*>/;
63
+ const LIST_ITEM_RE = /^\s*(?:[-*+]|\d+[.)])\s+\S/;
64
+ /** A thematic break carries no text, so it is not a block of anything. */
65
+ const THEMATIC_BREAK_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
66
+ /** The kinds a line OPENS on sight. `null` ⇒ prose, which continues whatever
67
+ * block is already open (markdown's lazy continuation) or opens a paragraph. */
68
+ function openingKind(line) {
69
+ if (TABLE_ROW_RE.test(line))
70
+ return 'table-row';
71
+ if (BLOCKQUOTE_RE.test(line))
72
+ return 'quote';
73
+ if (LIST_ITEM_RE.test(line))
74
+ return 'list-item';
75
+ return null;
76
+ }
77
+ export function parseSpecDoc(text) {
78
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
79
+ const doc = { preamble: [], sections: [] };
80
+ let open = null;
81
+ const target = () => doc.sections.length > 0 ? doc.sections[doc.sections.length - 1].blocks : doc.preamble;
82
+ const emit = (kind, body, line) => {
83
+ const t = body.join('\n');
84
+ if (t.trim().length > 0)
85
+ target().push({ kind, text: t, plain: demark(t), line });
86
+ };
87
+ const close = () => {
88
+ if (open)
89
+ emit(open.kind, open.lines, open.line);
90
+ open = null;
91
+ };
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const raw = lines[i];
94
+ const fence = FENCE_RE.exec(raw);
95
+ if (fence) {
96
+ close();
97
+ const start = i + 1;
98
+ const body = [raw];
99
+ // A fence runs to its closing marker or to EOF — an unclosed fence is
100
+ // still one block, never a re-read of its contents as prose.
101
+ while (i + 1 < lines.length) {
102
+ body.push(lines[++i]);
103
+ if (lines[i].trimStart().startsWith(fence[1]))
104
+ break;
105
+ }
106
+ emit('fence', body, start);
107
+ continue;
108
+ }
109
+ const heading = HEADING_RE.exec(raw);
110
+ if (heading) {
111
+ close();
112
+ doc.sections.push({ heading: heading[2], depth: heading[1].length, blocks: [] });
113
+ continue;
114
+ }
115
+ if (raw.trim().length === 0 || THEMATIC_BREAK_RE.test(raw)) {
116
+ close();
117
+ continue;
118
+ }
119
+ const kind = openingKind(raw);
120
+ if (kind === 'table-row') {
121
+ close();
122
+ emit('table-row', [raw], i + 1);
123
+ continue;
124
+ }
125
+ if (kind === 'quote' && open?.kind === 'quote') {
126
+ open.lines.push(raw);
127
+ continue;
128
+ }
129
+ if (kind !== null) {
130
+ close();
131
+ open = { kind, lines: [raw], line: i + 1 };
132
+ continue;
133
+ }
134
+ if (open === null)
135
+ open = { kind: 'para', lines: [raw], line: i + 1 };
136
+ else
137
+ open.lines.push(raw);
138
+ }
139
+ close();
140
+ return doc;
141
+ }
142
+ /** Every block, in document order. */
143
+ export function blocksOf(doc) {
144
+ return [...doc.preamble, ...doc.sections.flatMap(s => s.blocks)];
145
+ }
146
+ /**
147
+ * The blocks above the first CONTENT section — where a spec says what the thing
148
+ * IS rather than what the work must do.
149
+ *
150
+ * Two shapes, both real. A doc can open with prose and then head its sections; or
151
+ * it can open with its TITLE as the only top-level heading and put the same prose
152
+ * under it, which is what `# MX-5 Private — Project Design` does. A lone depth-1
153
+ * heading above deeper ones is a title, not a section, so its body is preamble
154
+ * too.
155
+ *
156
+ * A document with NO sections at all has no preamble: "preamble" means "above the
157
+ * body", so without a body there is nothing to be above — otherwise a heading-less
158
+ * prose spec would have its whole content excluded by any preamble policy.
159
+ */
160
+ export function preambleOf(doc) {
161
+ const [first, ...rest] = doc.sections;
162
+ if (first === undefined)
163
+ return [];
164
+ const titled = first.depth === 1 && rest.length > 0 && rest.every(s => s.depth > 1);
165
+ return titled ? [...doc.preamble, ...first.blocks] : doc.preamble;
166
+ }
167
+ /** A heading in `plain` form. Demarked as the LINE it was, `#` run included:
168
+ * without it demark reads the `11.` of "11. Security notes" as a list marker. */
169
+ function headingPlain(s) {
170
+ return demark(`${'#'.repeat(s.depth)} ${s.heading}`);
171
+ }
172
+ /** Heading-delimited section texts in `plain` form, pre-normalised for
173
+ * containment tests. The preamble is its own leading section. */
174
+ export function sectionPlains(doc) {
175
+ const out = [doc.preamble.map(b => b.plain).join('\n')];
176
+ for (const s of doc.sections)
177
+ out.push([headingPlain(s), ...s.blocks.map(b => b.plain)].join('\n'));
178
+ return out.map(normalise).filter(s => s.length > 0);
179
+ }
180
+ /** The whole doc in `plain` form — the flat fallback for a quote that spans
181
+ * block boundaries (a model quoting two consecutive bullets as one line). */
182
+ export function specPlain(doc) {
183
+ return [
184
+ ...doc.preamble.map(b => b.plain),
185
+ ...doc.sections.flatMap(s => [headingPlain(s), ...s.blocks.map(b => b.plain)])
186
+ ].join('\n');
187
+ }
188
+ /**
189
+ * The first block whose `plain` contains `quote`, both demarked and normalised —
190
+ * the anchor a grounded quote records. `accept` narrows candidacy: a quote whose
191
+ * only match lies outside it is NOT grounded, which is how a requirement policy
192
+ * rejects a DDL column or a table cell the model quoted as an obligation.
193
+ */
194
+ export function groundIn(doc, quote, accept) {
195
+ const q = normalise(demark(quote));
196
+ if (q.length === 0)
197
+ return null;
198
+ for (const b of blocksOf(doc)) {
199
+ if (accept && !accept(b))
200
+ continue;
201
+ if (normalise(b.plain).includes(q))
202
+ return b;
203
+ }
204
+ return null;
205
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * spec-model — the composed spec as a VALUE, and the one parser that produces it.
3
+ *
4
+ * The spec travelled as an opaque blob: the verify child got a string, the
5
+ * resolution judge got the same string, the prohibition probe re-scanned it line
6
+ * by line with its own regex, and the frozen-path guard re-scanned it again
7
+ * through that. Nothing could ask the spec a question — least of all "where did
8
+ * this constraint come from" — so a constraint a grill auto-answer invented was
9
+ * indistinguishable from one the user wrote, and both gates held it unwaivable
10
+ * (0043).
11
+ *
12
+ * So the spec parses once, here, into a value whose constraints carry their
13
+ * PROVENANCE, and `constraint-policy.ts` turns that provenance into the weight
14
+ * both gates render. This module knows nothing about gates, prompts or git: it
15
+ * turns text into a shape.
16
+ *
17
+ * NOT this module's job: `extractCapsSection` (refuted-constraint.ts). That reads
18
+ * the REFINED PROMPT, a different document with a different grammar, written
19
+ * before any spec exists.
20
+ */
21
+ import type { QaKind } from './qa-transcript.js';
22
+ /**
23
+ * Where one constraint came from.
24
+ *
25
+ * A `QaKind` means the composer traced it to that numbered Q&A answer. `spec`
26
+ * means the composer traced it to the refined task itself. `derived` is the
27
+ * honest default for an untagged constraint — the composer wrote it and named no
28
+ * source, which is a weaker claim than either of the other two, and the policy
29
+ * table treats it as such.
30
+ */
31
+ export type ConstraintProvenance = QaKind | 'spec' | 'derived';
32
+ export interface Constraint {
33
+ /** The constraint text, with the provenance tag removed. */
34
+ text: string;
35
+ provenance: ConstraintProvenance;
36
+ }
37
+ export interface Spec {
38
+ goal: string;
39
+ constraints: Constraint[];
40
+ acceptance: string[];
41
+ /** The VERIFY section verbatim, fence and all — `spec-validation.ts` owns
42
+ * what counts as a runnable block inside it. */
43
+ verify: string;
44
+ }
45
+ /** Resolves a `Q<n>` tag to the kind of the answer that numbered question got. */
46
+ export type QaResolver = (questionNumber: number) => QaKind | null;
47
+ /**
48
+ * Parse a composed spec into its value. The SINGLE parser: `extractSpecForVerification`,
49
+ * `extractProhibitions` and (through the latter) `frozenPathsFromSpec` all read
50
+ * this shape, so "what the spec says" cannot mean two things in two gates.
51
+ *
52
+ * Total — any text parses. A spec missing a section yields that section empty,
53
+ * which is what the callers already handle (no constraints ⇒ nothing forbidden).
54
+ */
55
+ export declare function parseSpec(section: string, resolve?: QaResolver): Spec;
56
+ /**
57
+ * Render a parsed spec back to the four-section text the composer emits. Its
58
+ * point is the round-trip: `parseSpec(formatSpec(s))` equals `s`, which is what
59
+ * makes the parser answerable to 21 real specs rather than to its own tests.
60
+ * Provenance rides back out as the same `[from:]` tag, except for `derived`,
61
+ * whose absence IS its spelling.
62
+ */
63
+ export declare function formatSpec(spec: Spec): string;
64
+ /**
65
+ * Slice the delivered spec out of a task file body: everything under `## spec`
66
+ * down to the next `## ` heading (a `### ` subheading stays inside). Null when
67
+ * the section is absent or blank — a task that never reached compose, which the
68
+ * verify gate treats as nothing to verify.
69
+ */
70
+ export declare function sliceSpecSection(taskBody: string): string | null;
@@ -0,0 +1,139 @@
1
+ /** The tag COMPOSE_PROMPT asks for: `[from: Q3]`, or `[from: spec]`. */
2
+ const FROM_TAG_RE = /\[from:\s*(Q(\d+)|spec)\s*\]/i;
3
+ const SECTION_RE = /^\s*(GOAL|CONSTRAINTS|ACCEPTANCE|VERIFY)\b\s*:?\s*$/i;
4
+ const BULLET_RE = /^\s*[-*]\s+(.*)$/;
5
+ /**
6
+ * Split a `## spec` section's text into its four parts. A section the spec never
7
+ * opened is an empty string; the four are independent, so a malformed spec
8
+ * degrades one part rather than the whole value.
9
+ */
10
+ function splitSections(section) {
11
+ const out = {
12
+ GOAL: [],
13
+ CONSTRAINTS: [],
14
+ ACCEPTANCE: [],
15
+ VERIFY: []
16
+ };
17
+ let current = null;
18
+ for (const line of section.split('\n')) {
19
+ const header = SECTION_RE.exec(line);
20
+ if (header) {
21
+ current = header[1].toUpperCase();
22
+ continue;
23
+ }
24
+ if (current)
25
+ out[current].push(line);
26
+ }
27
+ return out;
28
+ }
29
+ /**
30
+ * Bullet list → one entry per bullet, with wrapped continuation lines folded back
31
+ * onto the bullet they belong to.
32
+ *
33
+ * Folding is not cosmetic: real specs wrap a constraint over four lines, and the
34
+ * prohibition probe reads "the line carrying the ban" — split, the ban and the
35
+ * path it names land in different entries and the probe sees neither.
36
+ */
37
+ function parseBullets(lines) {
38
+ const out = [];
39
+ for (const line of lines) {
40
+ const bullet = BULLET_RE.exec(line);
41
+ if (bullet) {
42
+ out.push(bullet[1].trim());
43
+ continue;
44
+ }
45
+ const text = line.trim();
46
+ if (text.length === 0 || out.length === 0)
47
+ continue;
48
+ out[out.length - 1] = `${out[out.length - 1]}\n${text}`;
49
+ }
50
+ return out;
51
+ }
52
+ /**
53
+ * Read one bullet's `[from:]` tag. An unresolvable `Q<n>` — the transcript is
54
+ * shorter than the number, or there is no transcript at all — is `derived`, not
55
+ * an invented kind: a tag naming an answer nobody can produce proves nothing
56
+ * about the constraint's origin.
57
+ */
58
+ function readProvenance(text, resolve) {
59
+ const tag = FROM_TAG_RE.exec(text);
60
+ if (!tag)
61
+ return { text, provenance: 'derived' };
62
+ const stripped = text
63
+ .replace(FROM_TAG_RE, '')
64
+ .replace(/\s{2,}/g, ' ')
65
+ .trim();
66
+ if (tag[2] === undefined)
67
+ return { text: stripped, provenance: 'spec' };
68
+ const kind = resolve?.(Number(tag[2])) ?? null;
69
+ return { text: stripped, provenance: kind ?? 'derived' };
70
+ }
71
+ /**
72
+ * Parse a composed spec into its value. The SINGLE parser: `extractSpecForVerification`,
73
+ * `extractProhibitions` and (through the latter) `frozenPathsFromSpec` all read
74
+ * this shape, so "what the spec says" cannot mean two things in two gates.
75
+ *
76
+ * Total — any text parses. A spec missing a section yields that section empty,
77
+ * which is what the callers already handle (no constraints ⇒ nothing forbidden).
78
+ */
79
+ export function parseSpec(section, resolve) {
80
+ const parts = splitSections(section);
81
+ return {
82
+ goal: parts.GOAL.join('\n').trim(),
83
+ constraints: parseBullets(parts.CONSTRAINTS).map(t => readProvenance(t, resolve)),
84
+ acceptance: parseBullets(parts.ACCEPTANCE),
85
+ verify: parts.VERIFY.join('\n').trim()
86
+ };
87
+ }
88
+ /**
89
+ * Render a parsed spec back to the four-section text the composer emits. Its
90
+ * point is the round-trip: `parseSpec(formatSpec(s))` equals `s`, which is what
91
+ * makes the parser answerable to 21 real specs rather than to its own tests.
92
+ * Provenance rides back out as the same `[from:]` tag, except for `derived`,
93
+ * whose absence IS its spelling.
94
+ */
95
+ export function formatSpec(spec) {
96
+ const tag = (c) => c.provenance === 'derived' ? ''
97
+ : c.provenance === 'spec' ? ' [from: spec]'
98
+ : ` [from: ${c.provenance}]`;
99
+ return [
100
+ 'GOAL',
101
+ spec.goal,
102
+ '',
103
+ 'CONSTRAINTS',
104
+ ...spec.constraints.map(c => `- ${c.text}${tag(c)}`),
105
+ '',
106
+ 'ACCEPTANCE',
107
+ ...spec.acceptance.map(a => `- ${a}`),
108
+ '',
109
+ 'VERIFY:',
110
+ spec.verify
111
+ ].join('\n');
112
+ }
113
+ /**
114
+ * Slice the delivered spec out of a task file body: everything under `## spec`
115
+ * down to the next `## ` heading (a `### ` subheading stays inside). Null when
116
+ * the section is absent or blank — a task that never reached compose, which the
117
+ * verify gate treats as nothing to verify.
118
+ */
119
+ export function sliceSpecSection(taskBody) {
120
+ const lines = taskBody.split('\n');
121
+ let start = -1;
122
+ for (let i = 0; i < lines.length; i++) {
123
+ if (/^##\s+spec\s*$/i.test(lines[i])) {
124
+ start = i + 1;
125
+ break;
126
+ }
127
+ }
128
+ if (start === -1)
129
+ return null;
130
+ let end = lines.length;
131
+ for (let i = start; i < lines.length; i++) {
132
+ if (/^##\s+\S/.test(lines[i])) {
133
+ end = i;
134
+ break;
135
+ }
136
+ }
137
+ const spec = lines.slice(start, end).join('\n').trim();
138
+ return spec.length > 0 ? spec : null;
139
+ }
@@ -105,4 +105,4 @@ export declare class StallDetector {
105
105
  * because "you ran out of time" tells a model that was working correctly but
106
106
  * slowly to truncate its work for no reason.
107
107
  */
108
- export declare function formatStallHint(kind: StallKind): string;
108
+ export declare function formatStallHint(kind: StallKind, visited?: readonly string[]): string;
@@ -46,7 +46,7 @@
46
46
  * short-window sibling that trips FAST on an exact repeat; this is the
47
47
  * whole-run backstop that trips on sustained non-progress.
48
48
  */
49
- import { stableStringify } from './loop-detector.js';
49
+ import { formatReadSet, stableStringify } from './loop-detector.js';
50
50
  /**
51
51
  * Consecutive no-new-ground tool results before the child is killed.
52
52
  *
@@ -107,7 +107,9 @@ export class StallDetector {
107
107
  this.deadStreak++;
108
108
  this.seenCalls.add(key);
109
109
  if (this.deadStreak >= this.limit) {
110
- return { call, count: this.deadStreak, windowSize: 0, stall: 'no-new-ground' };
110
+ // No windowSize: this rule counts a CONSECUTIVE streak over the whole
111
+ // run, so there is no window to report and a renderer must not invent one.
112
+ return { call, count: this.deadStreak, stall: 'no-new-ground' };
111
113
  }
112
114
  return null;
113
115
  }
@@ -142,16 +144,18 @@ export class StallDetector {
142
144
  * because "you ran out of time" tells a model that was working correctly but
143
145
  * slowly to truncate its work for no reason.
144
146
  */
145
- export function formatStallHint(kind) {
147
+ export function formatStallHint(kind, visited = []) {
146
148
  if (kind === 'context-churn') {
147
149
  return ('[SYSTEM NOTE: Your previous attempt pulled in more file content than '
148
150
  + 'its context window can hold, so the earliest material was dropped and '
149
151
  + 'you began re-reading it. Do not re-open files. Read only what you have '
150
- + 'not read yet, and write your answer from what you have.]');
152
+ + 'not read yet, and write your answer from what you have.'
153
+ + `${formatReadSet(visited)}]`);
151
154
  }
152
155
  return ('[SYSTEM NOTE: Your previous attempt made a run of tool calls that returned '
153
156
  + 'nothing you had not already seen — you were re-opening files you had '
154
157
  + 'already read. Read each region of a file AT MOST ONCE, and when a file is '
155
158
  + 'too large to read whole, page FORWARD through it rather than re-opening '
156
- + 'the start. Write your answer from what you have gathered.]');
159
+ + 'the start. Write your answer from what you have gathered.'
160
+ + `${formatReadSet(visited)}]`);
157
161
  }
@@ -0,0 +1,29 @@
1
+ /** How many of a repository's runs keep their logs. */
2
+ export declare const RUN_LOG_KEEP = 20;
3
+ /** The XDG state-home base. Siblings: `dataHome` (shared/data-home.ts). */
4
+ export declare function stateHome(): string;
5
+ /** Stable directory name for a repository: its absolute path, hashed so one flat
6
+ * level holds every repo and no tree layout leaks into a shared directory. */
7
+ export declare function repoHash(cwd: string): string;
8
+ /** Every run's directory for this repository — the unit `pruneRunLogs` walks. */
9
+ export declare function repoStateDir(cwd: string): string;
10
+ /** One run's directory: logs, and (WS9) its gate evidence. */
11
+ export declare function stateDir(cwd: string, runId: string): string;
12
+ /**
13
+ * The id whose directory a line written now belongs in: the open run's (the run
14
+ * bracket owns it — `run-context.ts`). A caller outside any bracket, a direct
15
+ * `runSingleTask` or a test, shares one per-process id so its lines still land
16
+ * together instead of one directory per line.
17
+ */
18
+ export declare function currentRunId(cwd: string): string;
19
+ /** A log file in the current run's directory. */
20
+ export declare function runLogPath(cwd: string, file: string): string;
21
+ /**
22
+ * Drop all but the `keep` most recent runs of this repository, returning the ids
23
+ * removed. Called once when a run completes: retention that needs a timer would
24
+ * outlive the session that owns it, and a repo nobody runs keeps what it had.
25
+ *
26
+ * Best-effort throughout — a state dir that cannot be read or removed must not
27
+ * fail the run that just succeeded.
28
+ */
29
+ export declare function pruneRunLogs(cwd: string, keep?: number): Promise<string[]>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Where a run's logs and gate evidence live: OUTSIDE the repository.
3
+ *
4
+ * `.pi-tasks/` is committed with every task and rewound by the gates'
5
+ * `reset --hard`, and `snapshotTrail` copies every file under it across that
6
+ * rewind. A trail that grows with model chatter therefore costs a re-read, a
7
+ * re-commit and a full-file copy per task — a real run reached 1.7 MB. None of it
8
+ * is an artifact of the project, so it belongs in the user's state home,
9
+ * partitioned per repository and per run so whole generations can be dropped.
10
+ *
11
+ * The task FILES stay where they are: those are the deliverable.
12
+ */
13
+ import * as crypto from 'node:crypto';
14
+ import * as fsp from 'node:fs/promises';
15
+ import * as os from 'node:os';
16
+ import * as path from 'node:path';
17
+ import { newRunToken } from '../workers/research-cache.js';
18
+ import { openRunId } from './run-context.js';
19
+ /** How many of a repository's runs keep their logs. */
20
+ export const RUN_LOG_KEEP = 20;
21
+ /** The XDG state-home base. Siblings: `dataHome` (shared/data-home.ts). */
22
+ export function stateHome() {
23
+ const xdg = process.env.XDG_STATE_HOME?.trim();
24
+ // XDG says a relative value is to be IGNORED, not resolved against the cwd —
25
+ // resolving one would scatter log dirs through the user's projects.
26
+ return xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), '.local', 'state');
27
+ }
28
+ /** Stable directory name for a repository: its absolute path, hashed so one flat
29
+ * level holds every repo and no tree layout leaks into a shared directory. */
30
+ export function repoHash(cwd) {
31
+ return crypto.createHash('sha256').update(path.resolve(cwd)).digest('hex').slice(0, 16);
32
+ }
33
+ /** Every run's directory for this repository — the unit `pruneRunLogs` walks. */
34
+ export function repoStateDir(cwd) {
35
+ return path.join(stateHome(), 'pi-task', repoHash(cwd));
36
+ }
37
+ /** One run's directory: logs, and (WS9) its gate evidence. */
38
+ export function stateDir(cwd, runId) {
39
+ return path.join(repoStateDir(cwd), runId);
40
+ }
41
+ let fallbackRunId;
42
+ /**
43
+ * The id whose directory a line written now belongs in: the open run's (the run
44
+ * bracket owns it — `run-context.ts`). A caller outside any bracket, a direct
45
+ * `runSingleTask` or a test, shares one per-process id so its lines still land
46
+ * together instead of one directory per line.
47
+ */
48
+ export function currentRunId(cwd) {
49
+ return openRunId(cwd) ?? (fallbackRunId ??= newRunToken());
50
+ }
51
+ /** A log file in the current run's directory. */
52
+ export function runLogPath(cwd, file) {
53
+ return path.join(stateDir(cwd, currentRunId(cwd)), file);
54
+ }
55
+ /**
56
+ * Drop all but the `keep` most recent runs of this repository, returning the ids
57
+ * removed. Called once when a run completes: retention that needs a timer would
58
+ * outlive the session that owns it, and a repo nobody runs keeps what it had.
59
+ *
60
+ * Best-effort throughout — a state dir that cannot be read or removed must not
61
+ * fail the run that just succeeded.
62
+ */
63
+ export async function pruneRunLogs(cwd, keep = RUN_LOG_KEEP) {
64
+ const dir = repoStateDir(cwd);
65
+ const kept = Math.max(0, keep);
66
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
67
+ const runs = entries.filter(e => e.isDirectory()).map(e => e.name);
68
+ if (runs.length <= kept)
69
+ return [];
70
+ const dated = await Promise.all(runs.map(async (name) => ({
71
+ name,
72
+ at: await fsp.stat(path.join(dir, name)).then(s => s.mtimeMs, () => 0)
73
+ })));
74
+ // The name breaks an mtime tie: a run token opens with its own base-36
75
+ // minting time, so it already orders the way the clock does.
76
+ dated.sort((a, b) => b.at - a.at || b.name.localeCompare(a.name));
77
+ const drop = dated.slice(kept).map(d => d.name);
78
+ await Promise.all(drop.map(name => fsp.rm(path.join(dir, name), { recursive: true, force: true }).catch(() => { })));
79
+ return drop;
80
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * suppression-probe — deterministic detection of a task that reached green by
3
+ * WIDENING SUPPRESSIONS rather than by fixing what the checker found.
4
+ *
5
+ * TASK_0034 shipped sixteen of them in one file — one blanket `eslint-disable`
6
+ * header and fifteen `@ts-expect-error` lines — and every gate reported success.
7
+ * Nothing named it, because nothing was looking: the checks were green, which is
8
+ * exactly what a suppression buys. The verify child cannot discover this by
9
+ * running the project's commands for the same reason.
10
+ *
11
+ * So it is a diff question, not a check question. The probe counts each pattern's
12
+ * matching lines the task ADDED and subtracts the ones it REMOVED, per file: a
13
+ * refactor that deletes three `@ts-ignore`s and adds one is net -2 and silent,
14
+ * while a file that gained one is a finding. Net, not absolute, because a task
15
+ * that legitimately moves code would otherwise be accused of writing what it only
16
+ * relocated.
17
+ *
18
+ * The registry is DATA, and deliberately cross-ecosystem — a suppression exists
19
+ * for every checker, and a TypeScript-only list would have made this a TypeScript
20
+ * feature. Projects add their own through the `suppressionPatterns` config.
21
+ */
22
+ import type { EcosystemId } from '../workers/docs-ecosystems.js';
23
+ /** One suppression spelling. `ecosystems` narrows a row to the projects where it
24
+ * means what it says; absent ⇒ it applies everywhere. */
25
+ export interface SuppressionPattern {
26
+ id: string;
27
+ re: RegExp;
28
+ ecosystems?: EcosystemId[];
29
+ }
30
+ /**
31
+ * The shipped rows. Each `re` is a LINE test, so a pattern that appears inside a
32
+ * string literal or a comment still counts — the point is the suppression's
33
+ * presence in the shipped file, and deciding what is "really" a suppression would
34
+ * need a parser per language to answer worse.
35
+ */
36
+ export declare const SUPPRESSION_PATTERNS: readonly SuppressionPattern[];
37
+ /** One diff line the probe reads: which side of the diff it is on, and where. */
38
+ export interface DiffLine {
39
+ path: string;
40
+ text: string;
41
+ added: boolean;
42
+ }
43
+ /** A file that gained suppressions, with the net count per pattern. */
44
+ export interface SuppressionHit {
45
+ path: string;
46
+ patternId: string;
47
+ /** Added matching lines minus removed ones. Always positive — a net-zero or
48
+ * net-negative pattern is not a widening and is not reported. */
49
+ net: number;
50
+ }
51
+ /**
52
+ * Compile the extra patterns a project configures. An unparseable source is
53
+ * dropped rather than thrown: a hand-edited config may not break the gate, and a
54
+ * pattern nobody can compile finds nothing either way.
55
+ */
56
+ export declare function compileSuppressionPatterns(sources: readonly string[]): SuppressionPattern[];
57
+ /** The rows in force for a project: the shipped ones its ecosystems claim, plus
58
+ * every configured one (a user pattern is never ecosystem-gated — they wrote it
59
+ * for this repo). */
60
+ export declare function suppressionPatternsFor(ecosystems: readonly EcosystemId[], extra?: readonly SuppressionPattern[]): SuppressionPattern[];
61
+ /**
62
+ * Net-new suppressions per file and pattern, in a stable order (file, then
63
+ * registry order) so two runs over the same diff produce the same findings.
64
+ */
65
+ export declare function findSuppressionWidening(lines: readonly DiffLine[], patterns?: readonly SuppressionPattern[]): SuppressionHit[];
66
+ /** The verify prompt's finding lines for the suppression-widening row. */
67
+ export declare function suppressionVerifyFindings(hits: readonly SuppressionHit[]): string[];