@jwilger/pi-development-system 0.64.0 → 0.66.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.
|
@@ -141,7 +141,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
141
141
|
pi.registerTool(createRequestApprovalTool({ pi, approvals }));
|
|
142
142
|
pi.registerTool(createModelsTool());
|
|
143
143
|
pi.registerTool(createRouteTaskTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
144
|
-
pi.registerTool(createIntakeTool({ state, jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
144
|
+
pi.registerTool(createIntakeTool({ pi, state, jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
145
145
|
pi.registerTool(createTaskCheckTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
146
146
|
pi.registerTool(createWorkItemTool({ exec }));
|
|
147
147
|
const reviewDeps = {
|
package/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ToolDefinition,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
6
|
import { type Static, Type } from "typebox";
|
|
7
|
+
import { parseDeparture } from "../core/departure.ts";
|
|
8
|
+
import { lookupGate } from "../core/gates.ts";
|
|
3
9
|
import { parseSizing } from "../core/sizing.ts";
|
|
4
10
|
import { type DevsysState, isParseError, type Sizing, type SliceRef } from "../core/types.ts";
|
|
11
|
+
import { DEPARTURE_ENTRY_TYPE } from "../gates/record-departure-tool.ts";
|
|
5
12
|
import type { Jev } from "../jev/client.ts";
|
|
6
13
|
import { judgeSizing } from "../jev/questions/sizing.ts";
|
|
14
|
+
import { appendDecision } from "../state/decision-log.ts";
|
|
7
15
|
import type { SessionState } from "../state/session-state.ts";
|
|
8
16
|
import { phaseFor, proposeArtifacts, renderProposal, sliceSlug, uniqueSlice } from "./intake.ts";
|
|
9
17
|
|
|
@@ -35,8 +43,40 @@ const slicesInUse = (state: DevsysState): Set<string> =>
|
|
|
35
43
|
...state.openDepartures.flatMap((d) => (d.scope.kind === "slice" ? [d.scope.slice] : [])),
|
|
36
44
|
]);
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* I8.1 sizes a fix without a review artifact, but the commit gate reviews every slice (I6.4). The user choosing
|
|
48
|
+
* "fix" is the explicit agreement, so it is recorded as a user-approved departure for this slice only.
|
|
49
|
+
*/
|
|
50
|
+
async function fixWithoutReview(
|
|
51
|
+
deps: { pi: ExtensionAPI; state: SessionState },
|
|
52
|
+
ctx: ExtensionContext,
|
|
53
|
+
slice: SliceRef,
|
|
54
|
+
): Promise<string | undefined> {
|
|
55
|
+
const info = lookupGate("review.unsatisfied");
|
|
56
|
+
const at = new Date();
|
|
57
|
+
const departure = parseDeparture({
|
|
58
|
+
id: `dep-${at.getTime()}-intake`,
|
|
59
|
+
gate: "review.unsatisfied",
|
|
60
|
+
tier: "soft",
|
|
61
|
+
default: info?.default ?? "finish the fresh-context review before release",
|
|
62
|
+
chosen: "no review rounds for this slice",
|
|
63
|
+
why: "the user confirmed this work as a fix, which the sizing table plans without a review artifact",
|
|
64
|
+
costIfWrong: "a defect in a small change ships without a fresh-context look",
|
|
65
|
+
approver: "user",
|
|
66
|
+
scope: { kind: "slice", slice },
|
|
67
|
+
revisitWhen: "the fix turns out to touch more than its task record named",
|
|
68
|
+
recordedAt: at.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
69
|
+
});
|
|
70
|
+
if (isParseError(departure)) return undefined;
|
|
71
|
+
const { path } = await appendDecision(ctx.cwd, departure, at);
|
|
72
|
+
deps.pi.appendEntry(DEPARTURE_ENTRY_TYPE, departure);
|
|
73
|
+
deps.state.update((s) => ({ ...s, openDepartures: [...s.openDepartures, departure] }));
|
|
74
|
+
return path;
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
/** `devsys_intake`: Jev proposes a size and artifact set; the user confirms; state moves to the first phase. */
|
|
39
78
|
export function createIntakeTool(deps: {
|
|
79
|
+
pi: ExtensionAPI;
|
|
40
80
|
state: SessionState;
|
|
41
81
|
jev: (ctx: ExtensionContext) => Jev;
|
|
42
82
|
}): ToolDefinition<typeof Parameters> {
|
|
@@ -79,8 +119,13 @@ export function createIntakeTool(deps: {
|
|
|
79
119
|
sizing,
|
|
80
120
|
activeSlice: slice,
|
|
81
121
|
}));
|
|
122
|
+
const departed = sizing === "fix" ? await fixWithoutReview(deps, ctx, slice) : undefined;
|
|
82
123
|
const final = renderProposal({ sizing, basis, proposal: proposalFor(sizing) });
|
|
83
|
-
|
|
124
|
+
const note =
|
|
125
|
+
departed === undefined
|
|
126
|
+
? ""
|
|
127
|
+
: `\nRecorded in ${departed}: no review rounds for this fix (you sized it fix).`;
|
|
128
|
+
return reply(`${final}\nPhase: ${phaseFor(sizing)}. Active slice: ${slice}.${note}`);
|
|
84
129
|
},
|
|
85
130
|
};
|
|
86
131
|
}
|
|
@@ -26,10 +26,10 @@ const SECTIONS = [
|
|
|
26
26
|
] as const;
|
|
27
27
|
type Section = (typeof SECTIONS)[number];
|
|
28
28
|
|
|
29
|
-
const HEADER = /^## (\S+) [—-] (
|
|
29
|
+
const HEADER = /^## (\S+) [—-] (\S.*?)\s*$/;
|
|
30
30
|
const SECTION_LINE = /^\*\*([^:*]+)(?::\*\*|\*\*:)\s*(.*)$/;
|
|
31
|
-
/** Top-level list items only;
|
|
32
|
-
const STEP = /^(?:\d+[.)]|[
|
|
31
|
+
/** Top-level list items only (CommonMark allows up to three spaces of indent); deeper items belong to the step above. */
|
|
32
|
+
const STEP = /^ {0,3}(?:\d+[.)]|[-*+])\s+(.*)$/;
|
|
33
33
|
const STEPS_MIN = 3;
|
|
34
34
|
const STEPS_MAX = 7;
|
|
35
35
|
/** Words that stand in for a command or an observable result. */
|
|
@@ -88,7 +88,19 @@ function splitSections(lines: readonly string[]): Sections {
|
|
|
88
88
|
found.set(name, current);
|
|
89
89
|
} else current?.push(line);
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
// Leading indentation is kept so the first list item of a section is read like the others.
|
|
92
|
+
const text = new Map(
|
|
93
|
+
[...found].map(
|
|
94
|
+
([name, body]) =>
|
|
95
|
+
[
|
|
96
|
+
name,
|
|
97
|
+
body
|
|
98
|
+
.join("\n")
|
|
99
|
+
.replace(/^\s*\n/, "")
|
|
100
|
+
.trimEnd(),
|
|
101
|
+
] as const,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
92
104
|
return { text, duplicates };
|
|
93
105
|
}
|
|
94
106
|
|
|
@@ -131,9 +143,14 @@ const stripTicks = (text: string): string => {
|
|
|
131
143
|
const stepsOf = (text: string): string[] => {
|
|
132
144
|
const lines = text.split("\n");
|
|
133
145
|
const fenced = fencedFlags(lines);
|
|
146
|
+
// The first item sets the list's indent; an item two or more columns deeper is nested under a step.
|
|
147
|
+
let base: number | undefined;
|
|
134
148
|
return lines.flatMap((line, at) => {
|
|
135
149
|
const item = fenced[at] === true ? undefined : STEP.exec(line)?.[1]?.trim();
|
|
136
|
-
|
|
150
|
+
if (item === undefined || item === "") return [];
|
|
151
|
+
const indent = line.length - line.trimStart().length;
|
|
152
|
+
base ??= indent;
|
|
153
|
+
return indent >= base + 2 ? [] : [item];
|
|
137
154
|
});
|
|
138
155
|
};
|
|
139
156
|
|
|
@@ -148,6 +165,7 @@ function concrete(name: "Run" | "Expected", text: string): string | undefined {
|
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
const get = (sections: ReadonlyMap<Section, string>, s: Section): string => sections.get(s) ?? "";
|
|
168
|
+
const got = (sections: ReadonlyMap<Section, string>, s: Section): string => get(sections, s).trim();
|
|
151
169
|
|
|
152
170
|
/** Every readiness problem in the sections, so the author fixes them in one pass. */
|
|
153
171
|
function problemsOf(markdown: string, split: Sections): string[] {
|
|
@@ -156,7 +174,7 @@ function problemsOf(markdown: string, split: Sections): string[] {
|
|
|
156
174
|
if (split.duplicates.length > 0) {
|
|
157
175
|
problems.push(`duplicate section: ${[...new Set(split.duplicates)].join(", ")}`);
|
|
158
176
|
}
|
|
159
|
-
const missing = SECTIONS.filter((s) =>
|
|
177
|
+
const missing = SECTIONS.filter((s) => got(sections, s) === "");
|
|
160
178
|
if (missing.length > 0) problems.push(`missing section: ${missing.join(", ")}`);
|
|
161
179
|
if (/\bTBD\b/.test(markdown)) problems.push("contains TBD; decide it or split the task");
|
|
162
180
|
const steps = stepsOf(get(sections, "Steps"));
|
|
@@ -164,7 +182,7 @@ function problemsOf(markdown: string, split: Sections): string[] {
|
|
|
164
182
|
problems.push(`Steps must be ${STEPS_MIN}-${STEPS_MAX} steps, found ${steps.length}`);
|
|
165
183
|
}
|
|
166
184
|
for (const name of ["Run", "Expected"] as const) {
|
|
167
|
-
const why = missing.includes(name) ? undefined : concrete(name,
|
|
185
|
+
const why = missing.includes(name) ? undefined : concrete(name, got(sections, name));
|
|
168
186
|
if (why !== undefined) problems.push(why);
|
|
169
187
|
}
|
|
170
188
|
return problems;
|
|
@@ -202,9 +220,9 @@ export function parseTaskRecord(markdown: string, id?: string): TaskRecord | Par
|
|
|
202
220
|
const found = locate(markdown.replace(/\r\n?/g, "\n"), id);
|
|
203
221
|
if (isParseError(found)) return found;
|
|
204
222
|
const split = splitSections(found.lines);
|
|
205
|
-
const problems = problemsOf(found.lines.join("\n")
|
|
223
|
+
const problems = problemsOf(`${found.id} ${found.title}\n${found.lines.join("\n")}`, split);
|
|
206
224
|
if (problems.length > 0) return parseError(problems.join("; "));
|
|
207
|
-
const text = (s: Section): string =>
|
|
225
|
+
const text = (s: Section): string => got(split.text, s);
|
|
208
226
|
return {
|
|
209
227
|
id: found.id,
|
|
210
228
|
title: found.title,
|
|
@@ -212,7 +230,7 @@ export function parseTaskRecord(markdown: string, id?: string): TaskRecord | Par
|
|
|
212
230
|
files: filesOf(text("Files")),
|
|
213
231
|
interfaces: text("Interfaces"),
|
|
214
232
|
firstFailingTest: text("First failing test"),
|
|
215
|
-
steps: stepsOf(text
|
|
233
|
+
steps: stepsOf(get(split.text, "Steps")),
|
|
216
234
|
run: stripTicks(text("Run")),
|
|
217
235
|
expected: text("Expected"),
|
|
218
236
|
outOfScope: text("Out of scope"),
|