@jwilger/pi-development-system 0.74.2 → 0.76.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.
@@ -1,8 +1,14 @@
1
1
  // pi-lens-ignore: high-import-coupling -- composition root: it imports every module it wires, by design
2
2
  import { readFileSync } from "node:fs";
3
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ ToolDefinition,
7
+ } from "@earendil-works/pi-coding-agent";
4
8
  import { cadenceLine, DEFAULT_PUSH_MINUTES } from "../src/context/cadence.ts";
5
9
  import { appendContextTail, renderContextTail } from "../src/context/context-tail.ts";
10
+ import { createPhaseTool } from "../src/context/devsys-tool.ts";
11
+ import { applyIntentGuideline } from "../src/context/intent-trigger.ts";
6
12
  import { registerModelAdvice } from "../src/context/model-advice.ts";
7
13
  import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
8
14
  import { applyPromptSection } from "../src/context/system-prompt.ts";
@@ -11,6 +17,7 @@ import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
11
17
  import { defaultMatrix } from "../src/core/models.ts";
12
18
  import { createApprovalStore } from "../src/gates/approvals.ts";
13
19
  import { registerCommitGuard } from "../src/gates/commit-guard.ts";
20
+ import { declareWithoutCodemode } from "../src/gates/exposure.ts";
14
21
  import { registerGitGuard } from "../src/gates/git-guard.ts";
15
22
  import { registerLintSuppressionGuard } from "../src/gates/lint-suppression-guard.ts";
16
23
  import { registerPushGuard } from "../src/gates/push-guard.ts";
@@ -19,6 +26,7 @@ import { registerRedFirstGuard } from "../src/gates/red-first-guard.ts";
19
26
  import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
20
27
  import { registerTestGuard } from "../src/gates/test-guard.ts";
21
28
  import { createJevHolder } from "../src/jev/holder.ts";
29
+ import { createJudgeTools } from "../src/jev/judge-tools.ts";
22
30
  import { createBeginWorkTool } from "../src/planning/begin-tool.ts";
23
31
  import { createIntakeTool } from "../src/planning/intake-tool.ts";
24
32
  import { createTaskCheckTool } from "../src/planning/task-check-tool.ts";
@@ -81,8 +89,9 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
81
89
  pi.on("session_start", (_event, ctx) => rebuild(ctx));
82
90
  pi.on("session_tree", (_event, ctx) => rebuild(ctx));
83
91
 
84
- pi.on("before_agent_start", (event) => {
92
+ pi.on("before_agent_start", async (event, ctx) => {
85
93
  applyPromptSection(event, state.get(), nonNegotiables);
94
+ await applyIntentGuideline(event, state.get(), jevHolder.forContext(ctx));
86
95
  });
87
96
 
88
97
  pi.on("context", (event) => {
@@ -140,12 +149,19 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
140
149
  registerLintSuppressionGuard({ pi, state });
141
150
  pi.registerTool(createRecordDepartureTool({ pi, state }));
142
151
  pi.registerTool(createRequestApprovalTool({ pi, approvals }));
143
- pi.registerTool(createModelsTool());
144
- pi.registerTool(createRouteTaskTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
152
+ // Rarely used tools: reached through codemode when it is on, declared directly when it is not.
153
+ const rarelyUsed: ToolDefinition[] = [
154
+ createModelsTool(),
155
+ createRouteTaskTool({ jev: (ctx) => jevHolder.forContext(ctx) }),
156
+ createTaskCheckTool({ jev: (ctx) => jevHolder.forContext(ctx) }),
157
+ createWorkItemTool({ exec }),
158
+ ...createJudgeTools({ jev: (ctx) => jevHolder.forContext(ctx) }),
159
+ ];
160
+ for (const tool of rarelyUsed) pi.registerTool(tool);
161
+ pi.on("session_start", () => declareWithoutCodemode(pi, rarelyUsed));
162
+ pi.registerTool(createPhaseTool({ state }));
145
163
  pi.registerTool(createBeginWorkTool({ state }));
146
164
  pi.registerTool(createIntakeTool({ pi, state, jev: (ctx) => jevHolder.forContext(ctx) }));
147
- pi.registerTool(createTaskCheckTool({ jev: (ctx) => jevHolder.forContext(ctx) }));
148
- pi.registerTool(createWorkItemTool({ exec }));
149
165
  const reviewDeps = {
150
166
  state,
151
167
  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.74.2",
3
+ "version": "0.76.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,34 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { SessionState } from "../state/session-state.ts";
4
+ import { phaseGuide } from "./phase-guide.ts";
5
+
6
+ const BASE =
7
+ "What the development system expects in the current workflow phase, and which tools to use. Call it when unsure what comes next.";
8
+
9
+ /** `devsys`: a model-only tool whose description is rewritten each turn with the current phase's guidance. */
10
+ export function createPhaseTool(deps: { state: SessionState }): ToolDefinition {
11
+ return {
12
+ name: "devsys",
13
+ label: "Workflow guide",
14
+ description: BASE,
15
+ promptSnippet: "Show what the workflow expects in the current phase",
16
+ parameters: Type.Object({}),
17
+ exposure: "model-only",
18
+ prepareLoadout: () => ({
19
+ descriptions: {
20
+ devsys: `${BASE} Current phase (${deps.state.get().phase}): ${phaseGuide(deps.state.get().phase)}`,
21
+ },
22
+ }),
23
+ execute() {
24
+ const { phase, sizing, activeSlice } = deps.state.get();
25
+ const text = [
26
+ `phase: ${phase}`,
27
+ `sizing: ${sizing ?? "none"}`,
28
+ `slice: ${activeSlice ?? "none"}`,
29
+ phaseGuide(phase),
30
+ ].join("\n");
31
+ return Promise.resolve({ content: [{ type: "text" as const, text }], details: undefined });
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,43 @@
1
+ import type { BeforeAgentStartEvent } from "@earendil-works/pi-coding-agent";
2
+ import type { DevsysState } from "../core/types.ts";
3
+ import type { Jev } from "../jev/client.ts";
4
+ import { type Intent, judgeIntent } from "../jev/questions/intent.ts";
5
+
6
+ /** Probability at or above which an intent is acted on. */
7
+ export const INTENT_AT = 0.7;
8
+
9
+ /** Pure: the one guideline line an intent earns in a phase, or undefined. Questions and continuations earn none. */
10
+ export function intentGuideline(
11
+ intent: Intent,
12
+ confidence: number,
13
+ phase: DevsysState["phase"],
14
+ ): string | undefined {
15
+ if (confidence < INTENT_AT) return undefined;
16
+ const idle = phase === "idle";
17
+ if ((intent === "new-work" || intent === "fix") && (idle || intent === "new-work")) {
18
+ return "This prompt asks for new work: call devsys_intake with the request before editing anything, so it is sized and the planning artifacts it needs are named.";
19
+ }
20
+ if (intent === "review" && idle) {
21
+ return "This prompt asks for a review: call devsys_review_start for a fresh-context review instead of reviewing in this conversation.";
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /** Slash commands and empty prompts are not judged. */
27
+ const judgeable = (prompt: string): boolean => {
28
+ const trimmed = prompt.trim();
29
+ return trimmed !== "" && !trimmed.startsWith("/");
30
+ };
31
+
32
+ /** Adapter: judge the prompt, and add the line to this run's guidelines. Jev trouble adds nothing. */
33
+ export async function applyIntentGuideline(
34
+ event: BeforeAgentStartEvent,
35
+ state: DevsysState,
36
+ jev: Jev,
37
+ ): Promise<void> {
38
+ if (!judgeable(event.prompt)) return;
39
+ const judged = await judgeIntent(jev, { prompt: event.prompt, phase: state.phase });
40
+ if (!judged.ok) return;
41
+ const line = intentGuideline(judged.value.intent, judged.value.confidence, state.phase);
42
+ if (line !== undefined) event.systemPromptOptions.promptGuidelines.push(line);
43
+ }
@@ -0,0 +1,21 @@
1
+ import { assertNever } from "../core/exhaustive.ts";
2
+ import type { Phase } from "../core/types.ts";
3
+
4
+ /** What to do in each phase, in one paragraph. Pure; the `devsys` tool and its description both use it. */
5
+ export function phaseGuide(phase: Phase): string {
6
+ switch (phase) {
7
+ case "idle":
8
+ return "Nothing is in flight. For new work or a fix, call devsys_intake with the user's request before editing: it sizes the work and names the planning artifacts it needs.";
9
+ case "intake":
10
+ case "planning":
11
+ return "Planning. Produce the artifacts the sizing asked for, write task records (check each with devsys_task_check), and when the user approves the plan call devsys_begin_work.";
12
+ case "implementing":
13
+ return "Implementing one slice. Write a failing test first, make it pass with the least code, run the tests and read the result before claiming anything. Push small, verified increments.";
14
+ case "reviewing":
15
+ return "Reviewing. Call devsys_review_start for a fresh-context review, pass its packet and diffDigest to devsys_review_record, fix every blocking and should-fix finding, and repeat until the round is clean.";
16
+ case "delivering":
17
+ return "Delivering. Commit with a rationale, push to trunk, wait for CI to go green, and release before starting the next slice. A red trunk is repaired first.";
18
+ default:
19
+ return assertNever(phase);
20
+ }
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Tools registered with `exposure: "codemode"` are reachable only through the `codemode` tool.
5
+ * When that tool is not active, the model could not reach them at all, so declare them directly.
6
+ * Runs at session start: pi's tool state cannot be read while an extension loads.
7
+ */
8
+ export function declareWithoutCodemode(pi: ExtensionAPI, tools: readonly ToolDefinition[]): void {
9
+ if (pi.getActiveTools().includes("codemode")) return;
10
+ for (const tool of tools) pi.registerTool({ ...tool, exposure: "direct" });
11
+ }
@@ -30,7 +30,7 @@ export function createRequestApprovalTool(
30
30
  "only valid for the exact command text. Unavailable when running headless.",
31
31
  promptSnippet: "Ask the user to approve an irreversible git command (hard stop)",
32
32
  parameters: Parameters,
33
- exposure: "direct",
33
+ exposure: "model-only",
34
34
  async execute(toolCallId, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
35
35
  const gate = parseGateId(params.gate);
36
36
  if (params.gate === "commit.forbidden-trailer") {
@@ -0,0 +1,122 @@
1
+ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { type Static, type TSchema, Type } from "typebox";
3
+ import type { Result } from "../core/result.ts";
4
+ import { isParseError } from "../core/types.ts";
5
+ import { parseTaskRecord } from "../planning/task-record.ts";
6
+ import type { Jev, JevError } from "./client.ts";
7
+ import { judgeTaskReadiness } from "./questions/readiness.ts";
8
+ import { judgeLenses } from "./questions/review.ts";
9
+ import { judgeSizing } from "./questions/sizing.ts";
10
+ import { judgeTestChange } from "./questions/test-change.ts";
11
+
12
+ /** Tool namespace that groups the Jev judgements for codemode scripts. */
13
+ const JUDGE_NAMESPACE = {
14
+ name: "devsys-judge",
15
+ description:
16
+ "Jev judgements as data: probabilities and labels a script can branch on (sizing, test-change, lenses, task readiness).",
17
+ instructions:
18
+ "Each tool returns JSON text: the judgement, or {error} when Jev is unavailable. Inputs are redacted and clipped before they reach Jev. Use these inside codemode scripts; they change no state.",
19
+ } as const;
20
+
21
+ type Deps = { jev: (ctx: ExtensionContext) => Jev };
22
+
23
+ const reply = (payload: unknown, isError = false) => ({
24
+ content: [{ type: "text" as const, text: JSON.stringify(payload) }],
25
+ details: undefined,
26
+ isError,
27
+ });
28
+
29
+ const fromResult = (result: Result<unknown, JevError>) =>
30
+ result.ok ? reply(result.value) : reply({ error: result.error }, true);
31
+
32
+ function judgeTool<P extends TSchema>(
33
+ spec: {
34
+ name: string;
35
+ label: string;
36
+ description: string;
37
+ parameters: P;
38
+ },
39
+ run: (deps: Deps, params: Static<P>, ctx: ExtensionContext) => Promise<ReturnType<typeof reply>>,
40
+ deps: Deps,
41
+ ): ToolDefinition<P> {
42
+ return {
43
+ ...spec,
44
+ exposure: "codemode",
45
+ namespace: JUDGE_NAMESPACE,
46
+ execute: (_id, params, _signal, _onUpdate, ctx) => run(deps, params, ctx),
47
+ };
48
+ }
49
+
50
+ export function createJudgeTools(deps: Deps): ToolDefinition[] {
51
+ const sizing = judgeTool(
52
+ {
53
+ name: "judge_sizing",
54
+ label: "Judge sizing",
55
+ description:
56
+ "Size a request as fix, change, capability or product, with the probability each planning artifact is needed.",
57
+ parameters: Type.Object({
58
+ request: Type.String(),
59
+ repoSummary: Type.Optional(Type.String()),
60
+ }),
61
+ },
62
+ async (d, p, ctx) =>
63
+ fromResult(
64
+ await judgeSizing(d.jev(ctx), { request: p.request, repoSummary: p.repoSummary ?? "" }),
65
+ ),
66
+ deps,
67
+ );
68
+ const testChange = judgeTool(
69
+ {
70
+ name: "judge_test_change",
71
+ label: "Judge test change",
72
+ description:
73
+ "Judge whether a change to a test file weakens what the tests verify. Omit `after` for a deleted file.",
74
+ parameters: Type.Object({
75
+ path: Type.String(),
76
+ before: Type.Optional(Type.String()),
77
+ after: Type.Optional(Type.String()),
78
+ recentFailure: Type.Optional(Type.String()),
79
+ }),
80
+ },
81
+ async (d, p, ctx) => fromResult(await judgeTestChange(d.jev(ctx), p)),
82
+ deps,
83
+ );
84
+ const lenses = judgeTool(
85
+ {
86
+ name: "judge_lenses",
87
+ label: "Judge review lenses",
88
+ description: "Probability per review lens that it applies to a diff.",
89
+ parameters: Type.Object({
90
+ diffStat: Type.String(),
91
+ diffSample: Type.String(),
92
+ profiles: Type.Optional(Type.Array(Type.String())),
93
+ }),
94
+ },
95
+ async (d, p, ctx) =>
96
+ fromResult(
97
+ await judgeLenses(d.jev(ctx), {
98
+ diffStat: p.diffStat,
99
+ diffSample: p.diffSample,
100
+ profiles: p.profiles ?? [],
101
+ }),
102
+ ),
103
+ deps,
104
+ );
105
+ const readiness = judgeTool(
106
+ {
107
+ name: "judge_task_readiness",
108
+ label: "Judge task readiness",
109
+ description:
110
+ "Judge whether a task record (markdown, optionally one id out of a plan) is ready for an implementer: ready, needs-detail or too-big.",
111
+ parameters: Type.Object({ markdown: Type.String(), id: Type.Optional(Type.String()) }),
112
+ },
113
+ async (d, p, ctx) => {
114
+ const record = parseTaskRecord(p.markdown, p.id);
115
+ if (isParseError(record)) return reply({ error: record.message }, true);
116
+ return fromResult(await judgeTaskReadiness(d.jev(ctx), record));
117
+ },
118
+ deps,
119
+ );
120
+ // SAFETY: pi stores tools as ToolDefinition<TSchema>; each tool here only reads its own validated params.
121
+ return [sizing, testChange, lenses, readiness] as unknown as ToolDefinition[];
122
+ }
@@ -0,0 +1,40 @@
1
+ import type { 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 { Jev, JevError } from "../client.ts";
5
+
6
+ export const INTENTS = ["new-work", "fix", "review", "question", "continuation"] as const;
7
+ export type Intent = (typeof INTENTS)[number];
8
+
9
+ export const INTENT_QUESTION: ClassifierChoiceQuestion = {
10
+ type: "choice",
11
+ instructions:
12
+ "What does the user's `prompt` ask the assistant to do, given the workflow `phase` the session is in? Judge the request itself, not its tone. A short reply that answers or approves what the assistant just said is a continuation.",
13
+ criteria: {
14
+ "new-work":
15
+ "Asks to build, add, change, remove, rename or restructure something in the code, docs or configuration",
16
+ fix: "Reports something broken, failing or wrong and wants it repaired",
17
+ review: "Asks for existing work, a diff, a plan or a document to be reviewed or checked",
18
+ question:
19
+ "Asks for an explanation, information or an opinion and does not ask for any change to be made",
20
+ continuation:
21
+ "A short reply to the assistant's last message: a confirmation, a choice, 'continue', 'yes', 'go ahead', or an answer to its question",
22
+ },
23
+ };
24
+
25
+ const PROMPT_MAX = 1500;
26
+ const isIntent = (value: string): value is Intent => INTENTS.some((i) => i === value);
27
+
28
+ export async function judgeIntent(
29
+ jev: Jev,
30
+ input: { prompt: string; phase: string },
31
+ ): Promise<Result<{ intent: Intent; confidence: number }, JevError>> {
32
+ const prompt = redactSecrets(input.prompt.slice(0, PROMPT_MAX * 2)).slice(0, PROMPT_MAX);
33
+ const asked = await jev.ask({ prompt, phase: input.phase }, { intent: INTENT_QUESTION });
34
+ if (!asked.ok) return asked;
35
+ const answer = asked.value.intent;
36
+ if (answer?.type !== "choice" || !isIntent(answer.choice)) {
37
+ return err({ kind: "provider", message: "missing or unknown intent answer" });
38
+ }
39
+ return ok({ intent: answer.choice, confidence: answer.confidence });
40
+ }
@@ -25,7 +25,7 @@ export function createBeginWorkTool(deps: {
25
25
  "Move from planning to implementing once the user has approved the plan. This switches on the review and red-first gates for the active slice.",
26
26
  promptSnippet: "Start implementing after the plan is approved",
27
27
  parameters: Parameters,
28
- exposure: "direct",
28
+ exposure: "model-only",
29
29
  execute(_id, _params: Static<typeof Parameters>) {
30
30
  const { phase, activeSlice } = deps.state.get();
31
31
  if (phase === "implementing" && activeSlice !== undefined) {
@@ -123,7 +123,7 @@ export function createIntakeTool(deps: {
123
123
  "The user confirms the size; then phase, sizing and the active slice are set. Call at the start of any non-trivial work.",
124
124
  promptSnippet: "Size new work and propose the planning artifacts it needs",
125
125
  parameters: Parameters,
126
- exposure: "direct",
126
+ exposure: "model-only",
127
127
  async execute(_id, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
128
128
  const request = params.request.trim();
129
129
  if (request === "") return reply("request must not be empty", true);
@@ -50,7 +50,7 @@ export function createTaskCheckTool(deps: {
50
50
  "Run before handing a task record to an implementer subagent.",
51
51
  promptSnippet: "Check a task record is ready for an implementer",
52
52
  parameters: Parameters,
53
- exposure: "direct",
53
+ exposure: "codemode",
54
54
  async execute(_id, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
55
55
  const target = resolve(ctx.cwd, params.path);
56
56
  if (!insideRepo(ctx.cwd, target))
@@ -215,7 +215,7 @@ export function createReviewStartTool(
215
215
  "Begin a review round for a slice: computes the diff digest, chooses review lenses, and returns the agent_spawn payload for a fresh-context reviewer. Run that spawn, then pass the reviewer's packet to devsys_review_record.",
216
216
  promptSnippet: "Start a fresh-context review round for a slice",
217
217
  parameters: StartParameters,
218
- exposure: "direct",
218
+ exposure: "model-only",
219
219
  async execute(
220
220
  _id,
221
221
  params: Static<typeof StartParameters>,
@@ -199,7 +199,7 @@ export function createModelsTool(): ToolDefinition<typeof Parameters> {
199
199
  "Return the model this project's matrix resolves each slot to (frontier, strong, fast, planning, advisor, implementer, reviewer, lens, researcher, jev) given the credentials on this machine.",
200
200
  promptSnippet: "Look up which model a development-system slot resolves to",
201
201
  parameters: Parameters,
202
- exposure: "direct",
202
+ exposure: "codemode",
203
203
  async execute(
204
204
  _id,
205
205
  params: Static<typeof Parameters>,
@@ -36,7 +36,7 @@ export function createRouteTaskTool(deps: {
36
36
  "Recommend which model and thinking level a subagent should use for a task. Call before agent_spawn for implementer or reviewer work and pass the returned model and thinkingLevel.",
37
37
  promptSnippet: "Recommend model and thinking level for a subagent task",
38
38
  parameters: Parameters,
39
- exposure: "direct",
39
+ exposure: "codemode",
40
40
  async execute(
41
41
  _id,
42
42
  params: Static<typeof Parameters>,
@@ -120,7 +120,7 @@ export function createWorkItemTool(deps: { exec: Exec }): ToolDefinition<typeof
120
120
  "Use it for the backlog instead of editing tracker files or running gh by hand.",
121
121
  promptSnippet: "Read and write the project's work items",
122
122
  parameters: Parameters,
123
- exposure: "direct",
123
+ exposure: "codemode",
124
124
  async execute(
125
125
  _id,
126
126
  params: Static<typeof Parameters>,