@jwilger/pi-development-system 0.52.1 → 0.54.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.
@@ -19,6 +19,7 @@ import { registerRedFirstGuard } from "../src/gates/red-first-guard.ts";
19
19
  import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
20
20
  import { registerTestGuard } from "../src/gates/test-guard.ts";
21
21
  import { createJevHolder } from "../src/jev/holder.ts";
22
+ import { createIntakeTool } from "../src/planning/intake-tool.ts";
22
23
  import { createReviewRecordTool, createReviewStartTool } from "../src/review/review-tools.ts";
23
24
  import { registerCiCommand } from "../src/state/ci-command.ts";
24
25
  import { loadConfig } from "../src/state/config.ts";
@@ -138,6 +139,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
138
139
  pi.registerTool(createRequestApprovalTool({ pi, approvals }));
139
140
  pi.registerTool(createModelsTool());
140
141
  pi.registerTool(createRouteTaskTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
142
+ pi.registerTool(createIntakeTool({ state, jev: (ctx) => jevHolder.forContext(ctx) }));
141
143
  const reviewDeps = {
142
144
  state,
143
145
  jev: (ctx: ExtensionContext) => jevHolder.forContext(ctx),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.52.1",
3
+ "version": "0.54.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"
@@ -0,0 +1,10 @@
1
+ ---
2
+ description: Size new work and get the planning artifacts it needs before building
3
+ argument-hint: "<what you want done>"
4
+ ---
5
+ Start new work with the work-intake-and-slicing skill.
6
+
7
+ 1. Call `devsys_intake` with `request` set to: $ARGUMENTS (if empty, ask the user what they want done first).
8
+ 2. Show the user the proposed size and artifacts. The tool asks them to confirm or change the size.
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.
@@ -0,0 +1,51 @@
1
+ import { type ParseError, parseError, type Sizing } from "./types.ts";
2
+
3
+ export const ARTIFACTS = [
4
+ "task-record",
5
+ "review",
6
+ "adr-if-needed",
7
+ "brief-lite",
8
+ "brief",
9
+ "decision-register",
10
+ "journeys",
11
+ "event-model",
12
+ "architecture",
13
+ "lens-review-optional",
14
+ "lens-review",
15
+ ] as const;
16
+
17
+ export type ArtifactId = (typeof ARTIFACTS)[number];
18
+
19
+ const SIZINGS: readonly Sizing[] = ["fix", "change", "capability", "product"];
20
+
21
+ /** The set the work needs by default; D4: proportional, and skipping any of it is a recorded departure. */
22
+ const BY_SIZING: Readonly<Record<Sizing, readonly ArtifactId[]>> = {
23
+ fix: ["task-record"],
24
+ change: ["task-record", "review", "adr-if-needed"],
25
+ capability: [
26
+ "task-record",
27
+ "review",
28
+ "adr-if-needed",
29
+ "brief-lite",
30
+ "journeys",
31
+ "event-model",
32
+ "lens-review-optional",
33
+ ],
34
+ product: [
35
+ "task-record",
36
+ "review",
37
+ "adr-if-needed",
38
+ "brief",
39
+ "decision-register",
40
+ "journeys",
41
+ "event-model",
42
+ "architecture",
43
+ "lens-review",
44
+ ],
45
+ };
46
+
47
+ export const recommendedArtifacts = (sizing: Sizing): readonly ArtifactId[] => BY_SIZING[sizing];
48
+
49
+ export const parseSizing = (value: string): Sizing | ParseError =>
50
+ SIZINGS.find((s) => s === value) ??
51
+ parseError(`unknown sizing "${value}": expected fix, change, capability or product`);
@@ -0,0 +1,111 @@
1
+ import type { ClassifierBoolQuestion, ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
2
+ import { redactSecrets } from "../../core/redact.ts";
3
+ import { err, ok, type Result } from "../../core/result.ts";
4
+ import type { ArtifactId } from "../../core/sizing.ts";
5
+ import type { Sizing } from "../../core/types.ts";
6
+ import type { Jev, JevError } from "../client.ts";
7
+ import { medianLabel } from "./route.ts";
8
+
9
+ const LEVELS: readonly Sizing[] = ["fix", "change", "capability", "product"];
10
+
11
+ export const SIZING_QUESTION: ClassifierChoiceQuestion = {
12
+ type: "choice",
13
+ instructions:
14
+ "How big is the work in `request`, given `repoSummary`? Judge what must be understood and decided, not how much typing it takes. A bug with a known cause is a fix; a behaviour change inside one existing feature is a change; a new user-facing capability spanning several parts is a capability; a new product or a rethink of one is a product.",
15
+ criteria: {
16
+ fix: "A defect or small correction with a known cause, one place, no design decision",
17
+ change:
18
+ "A modification to existing behaviour inside one feature or module, with a clear shape and a test",
19
+ capability:
20
+ "A new user-visible capability that touches several parts, needs a design, or has more than one user journey",
21
+ product:
22
+ "A new product, a new bounded context, or a rethink of who the users are and what outcome they need",
23
+ },
24
+ };
25
+
26
+ const need = (instructions: string): ClassifierBoolQuestion => ({
27
+ type: "bool",
28
+ instructions,
29
+ criteria: {
30
+ true: "Skipping it would likely cost rework or a wrong build",
31
+ false: "The work is clear enough without it",
32
+ },
33
+ });
34
+
35
+ /** One independent judgement per artifact the sizing table does not settle on its own. */
36
+ export const ARTIFACT_QUESTIONS = {
37
+ "adr-if-needed": need(
38
+ "Does `request` involve an architectural decision that is costly to reverse and that a future reader will ask 'why' about (a new dependency, data shape, boundary, protocol)?",
39
+ ),
40
+ brief: need(
41
+ "Is it unclear who the users are, what outcome they want, or why this work is worth doing, so a written product brief is needed before building?",
42
+ ),
43
+ journeys: need(
44
+ "Does `request` involve a user performing a sequence of actions to reach an outcome, such that listing those journeys would expose missing steps?",
45
+ ),
46
+ "event-model": need(
47
+ "Does `request` change how information flows through a system with commands, state and views, such that modelling that flow before coding would find gaps?",
48
+ ),
49
+ architecture: need(
50
+ "Does `request` need a statement of how the parts of the system fit together and where the boundaries are, because no such statement exists in `repoSummary`?",
51
+ ),
52
+ "lens-review": need(
53
+ "Do the product assumptions in `request` carry enough risk that independent product-lens critique (value, discovery, strategy) is worth running before building?",
54
+ ),
55
+ } as const satisfies Partial<Record<ArtifactId, ClassifierBoolQuestion>>;
56
+
57
+ export type SizingInput = { request: string; repoSummary: string };
58
+ export type SizingJudgement = {
59
+ sizing: Sizing;
60
+ confidence: number;
61
+ artifactNeed: Record<ArtifactId, number>;
62
+ };
63
+
64
+ const REQUEST_MAX = 4000;
65
+ const SUMMARY_MAX = 3000;
66
+ const clip = (text: string, max: number): string =>
67
+ redactSecrets(text.slice(0, max * 2)).slice(0, max);
68
+
69
+ export async function judgeSizing(
70
+ jev: Jev,
71
+ input: SizingInput,
72
+ ): Promise<Result<SizingJudgement, JevError>> {
73
+ const asked = await jev.ask(
74
+ {
75
+ request: clip(input.request, REQUEST_MAX),
76
+ repoSummary: clip(input.repoSummary, SUMMARY_MAX),
77
+ },
78
+ { sizing: SIZING_QUESTION, ...ARTIFACT_QUESTIONS },
79
+ );
80
+ if (!asked.ok) return asked;
81
+ const sizing = asked.value.sizing;
82
+ if (sizing?.type !== "choice") return err({ kind: "provider", message: "missing sizing answer" });
83
+ const label = medianLabel(LEVELS, sizing);
84
+ if (label === undefined) return err({ kind: "provider", message: "unknown sizing label" });
85
+ const probabilities: Partial<Record<string, number>> = {};
86
+ for (const key of Object.keys(ARTIFACT_QUESTIONS)) {
87
+ const answer = asked.value[key];
88
+ if (answer?.type !== "bool" || !Number.isFinite(answer.probability)) {
89
+ return err({ kind: "provider", message: `missing ${key} answer` });
90
+ }
91
+ probabilities[key] = answer.probability;
92
+ }
93
+ const p = (key: keyof typeof ARTIFACT_QUESTIONS): number => probabilities[key] ?? 0;
94
+ return ok({
95
+ sizing: label,
96
+ confidence: sizing.confidence,
97
+ artifactNeed: {
98
+ "task-record": 1,
99
+ review: 1,
100
+ "adr-if-needed": p("adr-if-needed"),
101
+ brief: p("brief"),
102
+ "brief-lite": p("brief"),
103
+ "decision-register": p("brief"),
104
+ journeys: p("journeys"),
105
+ "event-model": p("event-model"),
106
+ architecture: p("architecture"),
107
+ "lens-review": p("lens-review"),
108
+ "lens-review-optional": p("lens-review"),
109
+ },
110
+ });
111
+ }
@@ -0,0 +1,79 @@
1
+ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
+ import { parseSizing } from "../core/sizing.ts";
4
+ import { isParseError, type Sizing, type SliceRef } from "../core/types.ts";
5
+ import type { Jev } from "../jev/client.ts";
6
+ import { judgeSizing } from "../jev/questions/sizing.ts";
7
+ import type { SessionState } from "../state/session-state.ts";
8
+ import { phaseFor, proposeArtifacts, renderProposal, sliceSlug } from "./intake.ts";
9
+
10
+ const Parameters = Type.Object({
11
+ request: Type.String({ description: "What the user asked for, in their words." }),
12
+ repoSummary: Type.Optional(
13
+ Type.String({ description: "One or two sentences on the repository, if known." }),
14
+ ),
15
+ });
16
+
17
+ const SIZES: readonly Sizing[] = ["fix", "change", "capability", "product"];
18
+
19
+ const reply = (text: string, isError = false) => ({
20
+ content: [{ type: "text" as const, text }],
21
+ details: undefined,
22
+ isError,
23
+ });
24
+
25
+ /** Proposed size first, so the default selection is the proposal. */
26
+ const sizeChoices = (proposed: Sizing): string[] => [
27
+ proposed,
28
+ ...SIZES.filter((s) => s !== proposed),
29
+ ];
30
+
31
+ /** `devsys_intake`: Jev proposes a size and artifact set; the user confirms; state moves to the first phase. */
32
+ export function createIntakeTool(deps: {
33
+ state: SessionState;
34
+ jev: (ctx: ExtensionContext) => Jev;
35
+ }): ToolDefinition<typeof Parameters> {
36
+ return {
37
+ name: "devsys_intake",
38
+ label: "Start work",
39
+ description:
40
+ "Size a request (fix, change, capability, product) and list the planning artifacts that size needs. " +
41
+ "The user confirms the size; then phase, sizing and the active slice are set. Call at the start of any non-trivial work.",
42
+ promptSnippet: "Size new work and propose the planning artifacts it needs",
43
+ parameters: Parameters,
44
+ exposure: "direct",
45
+ async execute(_id, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
46
+ const request = params.request.trim();
47
+ if (request === "") return reply("request must not be empty", true);
48
+ const judged = await judgeSizing(deps.jev(ctx), {
49
+ request,
50
+ repoSummary: params.repoSummary ?? "",
51
+ });
52
+ const proposedSize: Sizing = judged.ok ? judged.value.sizing : "change";
53
+ const basis = judged.ok
54
+ ? `Jev judged ${judged.value.sizing} (confidence ${judged.value.confidence.toFixed(2)}).`
55
+ : `Jev unavailable (${judged.error.kind}); defaulted to change.`;
56
+ const artifactNeed = judged.ok ? judged.value.artifactNeed : {};
57
+ const proposalFor = (size: Sizing) => proposeArtifacts(size, artifactNeed);
58
+ const proposal = renderProposal({
59
+ sizing: proposedSize,
60
+ basis,
61
+ proposal: proposalFor(proposedSize),
62
+ });
63
+ if (!ctx.hasUI) return reply(`${proposal}\nHeadless: proposal only, not applied.`);
64
+ const picked = await ctx.ui.select(`Size this work (${basis})`, sizeChoices(proposedSize));
65
+ if (picked === undefined) return reply(`${proposal}\nIntake cancelled; nothing changed.`);
66
+ const sizing = parseSizing(picked);
67
+ if (isParseError(sizing)) return reply(sizing.message, true);
68
+ const slice = sliceSlug(request) as SliceRef;
69
+ deps.state.update((s) => ({
70
+ ...s,
71
+ phase: phaseFor(sizing),
72
+ sizing,
73
+ activeSlice: slice,
74
+ }));
75
+ const final = renderProposal({ sizing, basis, proposal: proposalFor(sizing) });
76
+ return reply(`${final}\nPhase: ${phaseFor(sizing)}. Active slice: ${slice}.`);
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,54 @@
1
+ import { ARTIFACTS, type ArtifactId, recommendedArtifacts } from "../core/sizing.ts";
2
+ import type { Phase, Sizing } from "../core/types.ts";
3
+
4
+ const SLUG_MAX = 40;
5
+ /** Jev need at or above this offers an artifact the sizing table did not list. */
6
+ const CONSIDER_AT = 0.5;
7
+
8
+ export const sliceSlug = (request: string): string => {
9
+ const slug = request
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-+/, "")
13
+ .slice(0, SLUG_MAX)
14
+ .replace(/-+$/, "");
15
+ return slug === "" ? "work" : slug;
16
+ };
17
+
18
+ export type ArtifactProposal = {
19
+ readonly recommended: readonly ArtifactId[];
20
+ readonly consider: readonly ArtifactId[];
21
+ };
22
+
23
+ export const proposeArtifacts = (
24
+ sizing: Sizing,
25
+ artifactNeed: Readonly<Partial<Record<ArtifactId, number>>>,
26
+ ): ArtifactProposal => {
27
+ const recommended = recommendedArtifacts(sizing);
28
+ const consider = ARTIFACTS.filter(
29
+ (id) => !recommended.includes(id) && (artifactNeed[id] ?? 0) >= CONSIDER_AT,
30
+ );
31
+ return { recommended, consider };
32
+ };
33
+
34
+ /** Small work starts building; bigger work plans first (D4: proportional). */
35
+ export const phaseFor = (sizing: Sizing): Phase =>
36
+ sizing === "fix" || sizing === "change" ? "implementing" : "planning";
37
+
38
+ export const renderProposal = (input: {
39
+ sizing: Sizing;
40
+ basis: string;
41
+ proposal: ArtifactProposal;
42
+ }): string => {
43
+ const { sizing, basis, proposal } = input;
44
+ const lines = [
45
+ `Proposed sizing: ${sizing}. ${basis}`,
46
+ `Recommended artifacts: ${proposal.recommended.join(", ")}.`,
47
+ ];
48
+ if (proposal.consider.length > 0) lines.push(`Also consider: ${proposal.consider.join(", ")}.`);
49
+ lines.push(
50
+ "Skipping a recommended artifact is allowed but recorded: call devsys_record_departure with gate artifact.skipped:<artifact>.",
51
+ "The event model and architecture are never blocked by a gate; they are recommended, not enforced.",
52
+ );
53
+ return lines.join("\n");
54
+ };