@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,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -1,9 +1,17 @@
1
- import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
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
- return reply(`${final}\nPhase: ${phaseFor(sizing)}. Active slice: ${slice}.`);
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; indented sub-items belong to the step above them. */
32
- const STEP = /^(?:\d+[.)]|[-*])\s+(.*)$/;
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
- const text = new Map([...found].map(([name, body]) => [name, body.join("\n").trim()] as const));
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
- return item === undefined || item === "" ? [] : [item];
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) => get(sections, 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, get(sections, 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"), split);
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 => get(split.text, s);
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("Steps")),
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"),