@jwilger/pi-development-system 0.75.0 → 0.77.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.
- package/extensions/development-system.ts +5 -1
- package/package.json +1 -1
- package/src/context/devsys-tool.ts +34 -0
- package/src/context/intent-trigger.ts +43 -0
- package/src/context/phase-guide.ts +21 -0
- package/src/context/turn-verifier.ts +17 -1
- package/src/jev/questions/intent.ts +40 -0
- package/src/jev/questions/turn.ts +34 -10
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { cadenceLine, DEFAULT_PUSH_MINUTES } from "../src/context/cadence.ts";
|
|
9
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";
|
|
10
12
|
import { registerModelAdvice } from "../src/context/model-advice.ts";
|
|
11
13
|
import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
|
|
12
14
|
import { applyPromptSection } from "../src/context/system-prompt.ts";
|
|
@@ -87,8 +89,9 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
87
89
|
pi.on("session_start", (_event, ctx) => rebuild(ctx));
|
|
88
90
|
pi.on("session_tree", (_event, ctx) => rebuild(ctx));
|
|
89
91
|
|
|
90
|
-
pi.on("before_agent_start", (event) => {
|
|
92
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
91
93
|
applyPromptSection(event, state.get(), nonNegotiables);
|
|
94
|
+
await applyIntentGuideline(event, state.get(), jevHolder.forContext(ctx));
|
|
92
95
|
});
|
|
93
96
|
|
|
94
97
|
pi.on("context", (event) => {
|
|
@@ -156,6 +159,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
156
159
|
];
|
|
157
160
|
for (const tool of rarelyUsed) pi.registerTool(tool);
|
|
158
161
|
pi.on("session_start", () => declareWithoutCodemode(pi, rarelyUsed));
|
|
162
|
+
pi.registerTool(createPhaseTool({ state }));
|
|
159
163
|
pi.registerTool(createBeginWorkTool({ state }));
|
|
160
164
|
pi.registerTool(createIntakeTool({ pi, state, jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
161
165
|
const reviewDeps = {
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ToolResultEvent,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { redactSecrets } from "../core/redact.ts";
|
|
8
|
+
import { reviewOf } from "../core/review-flow.ts";
|
|
8
9
|
import { exitCodeOf, summarizeOutput } from "../core/test-runner.ts";
|
|
9
10
|
import type { DevsysState, Phase } from "../core/types.ts";
|
|
10
11
|
import type { Jev } from "../jev/client.ts";
|
|
@@ -58,6 +59,9 @@ export function asksUser(text: string): boolean {
|
|
|
58
59
|
return QUESTION_END.test(lead) || (LIST_ITEM.test(last) && CHOICE_LEAD_IN.test(lead));
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
const hasReviewRound = (state: DevsysState, slice: DevsysState["activeSlice"] & string): boolean =>
|
|
63
|
+
(reviewOf(state, slice)?.rounds.length ?? 0) > 0;
|
|
64
|
+
|
|
61
65
|
/** True when a scope.expansion departure already covers the slice: the remedy the note asks for exists. */
|
|
62
66
|
const expansionRecorded = (state: DevsysState, slice: string): boolean =>
|
|
63
67
|
state.openDepartures.some(
|
|
@@ -72,6 +76,9 @@ const claimMessage = (): string =>
|
|
|
72
76
|
const driftMessage = (slice: string): string =>
|
|
73
77
|
`Development system: your last message describes work beyond the active slice "${slice}". Either return to the slice, or call devsys_record_departure (gate scope.expansion) to record why the scope is growing.`;
|
|
74
78
|
|
|
79
|
+
const reviewMessage = (slice: string): string =>
|
|
80
|
+
`Development system: your last message calls the slice "${slice}" finished, but no review round has been recorded for it. Call devsys_review_start for a fresh-context review before committing.`;
|
|
81
|
+
|
|
75
82
|
const TARGET_MAX = 120;
|
|
76
83
|
|
|
77
84
|
const note = (content: string) => ({
|
|
@@ -107,7 +114,7 @@ function evidenceOf(event: ToolResultEvent): ToolEvidence {
|
|
|
107
114
|
|
|
108
115
|
/** The corrective notes a judged turn earns: an unverified claim, and drift not yet covered by a departure. */
|
|
109
116
|
function correctionsFor(
|
|
110
|
-
judged: { unverifiedClaim: number; driftFromSlice: number },
|
|
117
|
+
judged: { unverifiedClaim: number; driftFromSlice: number; sliceDone: number },
|
|
111
118
|
state: DevsysState,
|
|
112
119
|
) {
|
|
113
120
|
const slice = state.activeSlice;
|
|
@@ -118,6 +125,11 @@ function correctionsFor(
|
|
|
118
125
|
return [
|
|
119
126
|
...(judged.unverifiedClaim >= VERIFIER_THRESHOLD ? [note(claimMessage())] : []),
|
|
120
127
|
...(drifted && slice !== undefined ? [note(driftMessage(slice))] : []),
|
|
128
|
+
...(slice !== undefined &&
|
|
129
|
+
judged.sliceDone >= VERIFIER_THRESHOLD &&
|
|
130
|
+
!hasReviewRound(state, slice)
|
|
131
|
+
? [note(reviewMessage(slice))]
|
|
132
|
+
: []),
|
|
121
133
|
];
|
|
122
134
|
}
|
|
123
135
|
|
|
@@ -163,6 +175,10 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
|
|
|
163
175
|
assistantText: parts.text,
|
|
164
176
|
toolEvidence: evidence,
|
|
165
177
|
...(state.activeSlice === undefined ? {} : { activeSlice: state.activeSlice }),
|
|
178
|
+
checkDone:
|
|
179
|
+
state.phase === "implementing" &&
|
|
180
|
+
state.activeSlice !== undefined &&
|
|
181
|
+
!hasReviewRound(state, state.activeSlice),
|
|
166
182
|
});
|
|
167
183
|
if (!judged.ok) return undefined;
|
|
168
184
|
const entries = correctionsFor(judged.value, state);
|
|
@@ -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
|
+
}
|
|
@@ -24,13 +24,31 @@ export const DRIFT_QUESTION: ClassifierBoolQuestion = {
|
|
|
24
24
|
},
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
export const DONE_QUESTION: ClassifierBoolQuestion = {
|
|
28
|
+
type: "bool",
|
|
29
|
+
instructions:
|
|
30
|
+
"`activeSlice` names the one slice of work the agent is doing. Does `assistantText` say that the slice's work is finished and ready to commit or hand over, rather than reporting progress, planning more work or asking something?",
|
|
31
|
+
criteria: {
|
|
32
|
+
true: "The message presents the slice as complete: implemented, tested and ready to commit, push or review",
|
|
33
|
+
false:
|
|
34
|
+
"The message reports partial progress, plans next steps, asks a question or is unrelated to finishing the slice",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
27
38
|
export type ToolEvidence = { tool: string; summary: string; exitCode?: number };
|
|
28
39
|
export type TurnInput = {
|
|
29
40
|
assistantText: string;
|
|
30
41
|
toolEvidence: readonly ToolEvidence[];
|
|
31
42
|
activeSlice?: string;
|
|
43
|
+
/** Also ask whether the message calls the slice finished (only meaningful with an active slice). */
|
|
44
|
+
checkDone?: boolean;
|
|
45
|
+
};
|
|
46
|
+
export type TurnJudgement = {
|
|
47
|
+
unverifiedClaim: number;
|
|
48
|
+
driftFromSlice: number;
|
|
49
|
+
/** 0 unless `checkDone` was set and a slice is active. */
|
|
50
|
+
sliceDone: number;
|
|
32
51
|
};
|
|
33
|
-
export type TurnJudgement = { unverifiedClaim: number; driftFromSlice: number };
|
|
34
52
|
|
|
35
53
|
const TEXT_MAX = 4000;
|
|
36
54
|
const SUMMARY_MAX = 240;
|
|
@@ -54,7 +72,7 @@ const probability = (
|
|
|
54
72
|
: err({ kind: "provider", message: `malformed ${name} answer` });
|
|
55
73
|
};
|
|
56
74
|
|
|
57
|
-
/**
|
|
75
|
+
/** Narrow questions asked together; drift and done only when a slice is active (done only on request). */
|
|
58
76
|
export async function judgeTurn(
|
|
59
77
|
jev: Jev,
|
|
60
78
|
input: TurnInput,
|
|
@@ -65,17 +83,23 @@ export async function judgeTurn(
|
|
|
65
83
|
toolEvidence: input.toolEvidence.slice(-EVIDENCE_MAX).map(evidenceLine),
|
|
66
84
|
...(slice === undefined ? {} : { activeSlice: slice }),
|
|
67
85
|
};
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
);
|
|
86
|
+
const wantDone = slice !== undefined && input.checkDone === true;
|
|
87
|
+
const asked = await jev.ask(state, {
|
|
88
|
+
claim: CLAIM_QUESTION,
|
|
89
|
+
...(slice === undefined ? {} : { drift: DRIFT_QUESTION }),
|
|
90
|
+
...(wantDone ? { done: DONE_QUESTION } : {}),
|
|
91
|
+
});
|
|
74
92
|
if (!asked.ok) return asked;
|
|
75
93
|
const claim = probability(asked.value.claim, "claim");
|
|
76
94
|
if (!claim.ok) return claim;
|
|
77
|
-
if (slice === undefined)
|
|
95
|
+
if (slice === undefined)
|
|
96
|
+
return ok({ unverifiedClaim: claim.value, driftFromSlice: 0, sliceDone: 0 });
|
|
78
97
|
const drift = probability(asked.value.drift, "drift");
|
|
79
98
|
if (!drift.ok) return drift;
|
|
80
|
-
|
|
99
|
+
if (!wantDone) {
|
|
100
|
+
return ok({ unverifiedClaim: claim.value, driftFromSlice: drift.value, sliceDone: 0 });
|
|
101
|
+
}
|
|
102
|
+
const done = probability(asked.value.done, "done");
|
|
103
|
+
if (!done.ok) return done;
|
|
104
|
+
return ok({ unverifiedClaim: claim.value, driftFromSlice: drift.value, sliceDone: done.value });
|
|
81
105
|
}
|