@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.
- package/README.md +7 -4
- package/dist/config/config.d.ts +61 -18
- package/dist/config/config.js +50 -6
- package/dist/config/register.js +18 -5
- package/dist/shared/content-tokens.d.ts +16 -0
- package/dist/shared/content-tokens.js +137 -0
- package/dist/task/accept-debt.d.ts +15 -1
- package/dist/task/accept-debt.js +4 -0
- package/dist/task/artifact-closure.js +7 -1
- package/dist/task/auto-io.d.ts +59 -7
- package/dist/task/auto-io.js +140 -47
- package/dist/task/auto-orchestrator.d.ts +22 -19
- package/dist/task/auto-orchestrator.js +126 -112
- package/dist/task/child-runner.d.ts +18 -0
- package/dist/task/child-runner.js +7 -8
- package/dist/task/command-run.d.ts +4 -0
- package/dist/task/command-run.js +4 -2
- package/dist/task/constraint-policy.d.ts +52 -0
- package/dist/task/constraint-policy.js +66 -0
- package/dist/task/context-silence.d.ts +5 -4
- package/dist/task/context-silence.js +13 -5
- package/dist/task/coverage-loop.d.ts +7 -4
- package/dist/task/coverage-loop.js +16 -138
- package/dist/task/debug-log.d.ts +25 -2
- package/dist/task/debug-log.js +64 -10
- package/dist/task/decompose-fidelity.d.ts +17 -4
- package/dist/task/decompose-fidelity.js +95 -55
- package/dist/task/decompose-granularity.d.ts +7 -7
- package/dist/task/decompose-granularity.js +8 -8
- package/dist/task/env-notes.d.ts +65 -19
- package/dist/task/env-notes.js +152 -50
- package/dist/task/external-context.js +25 -5
- package/dist/task/fix-context.d.ts +39 -0
- package/dist/task/fix-context.js +58 -0
- package/dist/task/gate-deps.d.ts +49 -0
- package/dist/task/gate-deps.js +199 -38
- package/dist/task/gate-evidence.d.ts +51 -0
- package/dist/task/gate-evidence.js +90 -0
- package/dist/task/gate-resolution.d.ts +119 -0
- package/dist/task/gate-resolution.js +120 -0
- package/dist/task/git-state-guard.js +3 -26
- package/dist/task/handoff.d.ts +35 -0
- package/dist/task/handoff.js +100 -0
- package/dist/task/health-baseline.d.ts +87 -0
- package/dist/task/health-baseline.js +158 -0
- package/dist/task/ledger.d.ts +10 -0
- package/dist/task/ledger.js +22 -11
- package/dist/task/lint-fix.d.ts +11 -0
- package/dist/task/lint-fix.js +14 -8
- package/dist/task/loop-detector.d.ts +52 -3
- package/dist/task/loop-detector.js +81 -5
- package/dist/task/mentions.d.ts +17 -0
- package/dist/task/mentions.js +86 -0
- package/dist/task/orchestrator.d.ts +53 -9
- package/dist/task/orchestrator.js +104 -28
- package/dist/task/orientation.d.ts +135 -29
- package/dist/task/orientation.js +259 -67
- package/dist/task/owned-freeze-reassign.d.ts +7 -3
- package/dist/task/owned-freeze-reassign.js +13 -5
- package/dist/task/parsers.d.ts +6 -1
- package/dist/task/parsers.js +21 -4
- package/dist/task/phases.d.ts +1 -1
- package/dist/task/phases.js +125 -153
- package/dist/task/plan-orchestrator.js +5 -6
- package/dist/task/plan-rounds.d.ts +1 -0
- package/dist/task/plan-rounds.js +8 -1
- package/dist/task/prohibition-probe.d.ts +6 -1
- package/dist/task/prohibition-probe.js +26 -6
- package/dist/task/prompts.js +13 -2
- package/dist/task/qa-transcript.d.ts +17 -0
- package/dist/task/qa-transcript.js +28 -0
- package/dist/task/question-source.d.ts +16 -2
- package/dist/task/question-source.js +21 -8
- package/dist/task/repo-health-check.d.ts +27 -0
- package/dist/task/repo-health-check.js +39 -5
- package/dist/task/requirements.d.ts +67 -13
- package/dist/task/requirements.js +129 -67
- package/dist/task/research-worker.d.ts +43 -2
- package/dist/task/research-worker.js +103 -8
- package/dist/task/run-bracket.d.ts +9 -4
- package/dist/task/run-bracket.js +9 -0
- package/dist/task/run-context.d.ts +152 -0
- package/dist/task/run-context.js +277 -0
- package/dist/task/spec-doc.d.ts +78 -0
- package/dist/task/spec-doc.js +205 -0
- package/dist/task/spec-model.d.ts +70 -0
- package/dist/task/spec-model.js +139 -0
- package/dist/task/stall-detector.d.ts +1 -1
- package/dist/task/stall-detector.js +9 -5
- package/dist/task/state-dir.d.ts +29 -0
- package/dist/task/state-dir.js +80 -0
- package/dist/task/suppression-probe.d.ts +67 -0
- package/dist/task/suppression-probe.js +78 -0
- package/dist/task/task-gates.d.ts +39 -57
- package/dist/task/task-gates.js +115 -195
- package/dist/task/task-io.d.ts +10 -0
- package/dist/task/task-io.js +69 -18
- package/dist/task/task-parsers.js +6 -6
- package/dist/task/task-types.d.ts +7 -0
- package/dist/task/timings.d.ts +11 -0
- package/dist/task/timings.js +21 -0
- package/dist/task/tree-hash.d.ts +8 -0
- package/dist/task/tree-hash.js +55 -0
- package/dist/task/verify-reconcile.d.ts +4 -3
- package/dist/task/verify-reconcile.js +7 -8
- package/dist/task/verify-resolution.d.ts +28 -5
- package/dist/task/verify-resolution.js +70 -13
- package/dist/task/verify-work.d.ts +98 -35
- package/dist/task/verify-work.js +227 -60
- package/dist/task/yolo.d.ts +0 -13
- package/dist/task/yolo.js +9 -21
- package/dist/workers/pi-worker-core.d.ts +20 -0
- package/dist/workers/pi-worker-core.js +16 -11
- package/dist/workers/pi-worker-docs.d.ts +6 -5
- package/dist/workers/pi-worker-docs.js +8 -7
- package/dist/workers/pi-worker-fetch.d.ts +3 -2
- package/dist/workers/pi-worker-fetch.js +5 -4
- package/dist/workers/pi-worker-search.js +5 -4
- package/dist/workers/research-cache.d.ts +23 -1
- package/dist/workers/research-cache.js +63 -3
- package/dist/workers/shared.d.ts +18 -0
- package/dist/workers/shared.js +38 -22
- package/package.json +1 -1
|
@@ -0,0 +1,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
|
-
|
|
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[];
|