@jwilger/pi-development-system 0.65.0 → 0.67.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.65.0",
3
+ "version": "0.67.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"
@@ -7,4 +7,4 @@ Start new work with the work-intake-and-slicing skill.
7
7
  1. Call `devsys_intake` with `request` set to: $ARGUMENTS (if empty, ask the user what they want done first).
8
8
  2. Show the user the proposed size and artifacts. The tool asks them to confirm or change the size.
9
9
  3. Follow the recommended artifacts in order. To skip one, call `devsys_record_departure` with gate `artifact.skipped:<artifact>` and the reason; do not skip silently. The event model and architecture are recommendations, never gates.
10
- 4. For `fix` and `change` the phase is already `implementing`: write the task record, then work it test-first.
10
+ 4. For a confirmed `fix` the user is also asked whether to skip fresh-context review (it is logged if they say yes; if no, the slice needs its review rounds). For `fix` and `change` the phase is already `implementing`: write the task record, then work it test-first.
@@ -20,7 +20,7 @@ Ask these in order and stop at the first yes:
20
20
 
21
21
  ## Artifacts follow size
22
22
 
23
- - `fix`: a task record.
23
+ - `fix`: a task record. The user is asked separately whether to waive fresh-context review for it; a yes is logged as a user-approved `review.unsatisfied` departure for that slice, a no keeps the review gate.
24
24
  - `change`: task record, review, an ADR if a decision is hard to reverse.
25
25
  - `capability`: add a brief-lite, journeys, an event model, and optionally lens review.
26
26
  - `product`: the full set, including brief, decision register and architecture.
@@ -125,10 +125,8 @@ async function reviewNeeds(
125
125
  ctx: ExtensionContext,
126
126
  command: string,
127
127
  ): Promise<Need[]> {
128
- const { phase, activeSlice, sizing } = deps.state.get();
128
+ const { phase, activeSlice } = deps.state.get();
129
129
  if ((phase !== "implementing" && phase !== "reviewing") || activeSlice === undefined) return [];
130
- // A fix is sized without a review artifact (I8.1); the gate must not demand rounds the size never asked for.
131
- if (sizing === "fix") return [];
132
130
  const snap = await snapshotDiff(deps.exec, ctx.cwd, "HEAD");
133
131
  // An unreadable diff cannot be compared; the gate asks for a departure rather than guessing.
134
132
  const gap = reviewGap(
@@ -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,75 @@ const slicesInUse = (state: DevsysState): Set<string> =>
35
43
  ...state.openDepartures.flatMap((d) => (d.scope.kind === "slice" ? [d.scope.slice] : [])),
36
44
  ]);
37
45
 
46
+ /** The waiver is its own question, naming the request and what the review gate actually covers. */
47
+ const askToWaiveReview = (
48
+ ctx: ExtensionContext,
49
+ request: string,
50
+ slice: SliceRef,
51
+ ): Promise<boolean> =>
52
+ ctx.ui.confirm(
53
+ "Skip fresh-context review for this fix?",
54
+ `"${request.slice(0, 120)}"\nThe review gate checks the whole uncommitted diff, so yes waives review for everything committed under slice "${slice}". The waiver is logged in docs/decisions.`,
55
+ );
56
+
57
+ /** Starting new work replaces the active slice; while it is in flight that needs the user's yes. */
58
+ async function declinedToReplace(
59
+ ctx: ExtensionContext,
60
+ before: DevsysState,
61
+ ): Promise<string | undefined> {
62
+ const { activeSlice, phase } = before;
63
+ if (activeSlice === undefined || (phase !== "implementing" && phase !== "reviewing")) {
64
+ return undefined;
65
+ }
66
+ const go = await ctx.ui.confirm(
67
+ "Slice already in flight",
68
+ `Slice "${activeSlice}" is still ${phase}. Its uncommitted changes would be committed under the new slice's rules. Start new work anyway?`,
69
+ );
70
+ return go ? undefined : `Intake cancelled; slice "${activeSlice}" is unchanged.`;
71
+ }
72
+
73
+ const waiverNote = (sizing: Sizing, departed: string | undefined): string => {
74
+ if (departed !== undefined)
75
+ return `\nRecorded in ${departed}: you waived fresh-context review for this fix.`;
76
+ return sizing === "fix"
77
+ ? "\nReview is NOT waived: this slice still needs its review rounds before commit."
78
+ : "";
79
+ };
80
+
81
+ /**
82
+ * I8.1 sizes a fix without a review artifact, but the commit gate reviews every slice (I6.4). Only the user's
83
+ * explicit yes to the waiver question is recorded, as a user-approved departure for this slice alone.
84
+ */
85
+ async function fixWithoutReview(
86
+ deps: { pi: ExtensionAPI; state: SessionState },
87
+ ctx: ExtensionContext,
88
+ slice: SliceRef,
89
+ ): Promise<string | undefined> {
90
+ const info = lookupGate("review.unsatisfied");
91
+ const at = new Date();
92
+ const departure = parseDeparture({
93
+ id: `dep-${at.getTime()}-intake`,
94
+ gate: "review.unsatisfied",
95
+ tier: "soft",
96
+ default: info?.default ?? "finish the fresh-context review before release",
97
+ chosen: "no review rounds for this slice",
98
+ why: "the user was asked and agreed: this is a fix, which the sizing table plans without a review artifact",
99
+ costIfWrong: "a defect in a small change ships without a fresh-context look",
100
+ approver: "user",
101
+ scope: { kind: "slice", slice },
102
+ revisitWhen: "the fix turns out to touch more than its task record named",
103
+ recordedAt: at.toISOString().replace(/\.\d{3}Z$/, "Z"),
104
+ });
105
+ if (isParseError(departure)) return undefined;
106
+ const { path } = await appendDecision(ctx.cwd, departure, at);
107
+ deps.pi.appendEntry(DEPARTURE_ENTRY_TYPE, departure);
108
+ deps.state.update((s) => ({ ...s, openDepartures: [...s.openDepartures, departure] }));
109
+ return path;
110
+ }
111
+
38
112
  /** `devsys_intake`: Jev proposes a size and artifact set; the user confirms; state moves to the first phase. */
39
113
  export function createIntakeTool(deps: {
114
+ pi: ExtensionAPI;
40
115
  state: SessionState;
41
116
  jev: (ctx: ExtensionContext) => Jev;
42
117
  }): ToolDefinition<typeof Parameters> {
@@ -72,15 +147,21 @@ export function createIntakeTool(deps: {
72
147
  if (picked === undefined) return reply(`${proposal}\nIntake cancelled; nothing changed.`);
73
148
  const sizing = parseSizing(picked);
74
149
  if (isParseError(sizing)) return reply(sizing.message, true);
75
- const slice = uniqueSlice(sliceSlug(request), slicesInUse(deps.state.get())) as SliceRef;
150
+ const before = deps.state.get();
151
+ const keep = await declinedToReplace(ctx, before);
152
+ if (keep !== undefined) return reply(`${proposal}\n${keep}`);
153
+ const slice = uniqueSlice(sliceSlug(request), slicesInUse(before)) as SliceRef;
76
154
  deps.state.update((s) => ({
77
155
  ...s,
78
156
  phase: phaseFor(sizing),
79
157
  sizing,
80
158
  activeSlice: slice,
81
159
  }));
160
+ const waived = sizing === "fix" ? await askToWaiveReview(ctx, request, slice) : false;
161
+ const departed = waived ? await fixWithoutReview(deps, ctx, slice) : undefined;
82
162
  const final = renderProposal({ sizing, basis, proposal: proposalFor(sizing) });
83
- return reply(`${final}\nPhase: ${phaseFor(sizing)}. Active slice: ${slice}.`);
163
+ const note = waiverNote(sizing, departed);
164
+ return reply(`${final}\nPhase: ${phaseFor(sizing)}. Active slice: ${slice}.${note}`);
84
165
  },
85
166
  };
86
167
  }
@@ -26,7 +26,7 @@ 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
31
  /** Top-level list items only (CommonMark allows up to three spaces of indent); deeper items belong to the step above. */
32
32
  const STEP = /^ {0,3}(?:\d+[.)]|[-*+])\s+(.*)$/;
@@ -226,9 +226,10 @@ const updateIssue = async (
226
226
  const current = toItem(raw.value);
227
227
  const hadMarker = (raw.value.labels ?? []).some((l) => isMarker(l.name));
228
228
  if (patch.status === "in-progress") {
229
- // No --force: that would recolour the team's existing label. "Already exists" is success.
230
- const made = await gh(["label", "create", IN_PROGRESS]);
231
- if (!(made.ok || /already exists/i.test(made.error.message))) return made;
229
+ // No --force: that would recolour the team's existing label. Creating needs push rights, which someone who can
230
+ // only triage lacks even when the label exists, so a failed create is not fatal: the edit below fails if the
231
+ // label is really missing, and says so.
232
+ await gh(["label", "create", IN_PROGRESS]);
232
233
  }
233
234
  const flags = editFlags(current, hadMarker, patch);
234
235
  if (flags.length > 0) {