@jwilger/pi-development-system 0.83.0 → 0.85.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.
@@ -0,0 +1,205 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import { type Static, Type } from "typebox";
6
+ import { resolveSlot } from "../core/models.ts";
7
+ import type { Jev } from "../jev/client.ts";
8
+ import { judgeProductLenses } from "../jev/questions/product-lenses.ts";
9
+ import {
10
+ judgeSolutionDetail,
11
+ SOLUTION_DETAIL_THRESHOLD,
12
+ } from "../jev/questions/solution-detail.ts";
13
+ import { lintBrief } from "../planning/brief-lint.ts";
14
+ import { CONFIG_FILE, loadConfig } from "../state/config.ts";
15
+ import { availableModels } from "../state/models-command.ts";
16
+ import type { SessionState } from "../state/session-state.ts";
17
+ import {
18
+ lensPayloads,
19
+ lensReviewScript,
20
+ PRODUCT_LENSES,
21
+ type ProductLens,
22
+ type ReviewRound,
23
+ reviewPath,
24
+ selectProductLenses,
25
+ synthesisTemplate,
26
+ } from "./lens-review.ts";
27
+
28
+ export type LensReviewDeps = {
29
+ state: SessionState;
30
+ jev: (ctx: ExtensionContext) => Jev;
31
+ now: () => Date;
32
+ };
33
+
34
+ const DEFAULT_BRIEF = "docs/product/brief.md";
35
+
36
+ const Parameters = Type.Object({
37
+ brief: Type.Optional(
38
+ Type.String({ description: `Path of the brief to review; defaults to ${DEFAULT_BRIEF}.` }),
39
+ ),
40
+ round: Type.Optional(
41
+ Type.Number({
42
+ description: "1 = independent review (default); 2 = peer exchange after round 1 was written.",
43
+ }),
44
+ ),
45
+ });
46
+
47
+ const reply = (text: string, isError = false) => ({
48
+ content: [{ type: "text" as const, text }],
49
+ details: undefined,
50
+ isError,
51
+ });
52
+
53
+ type Choice = { lenses: readonly ProductLens[]; basis: string };
54
+
55
+ /** All five for a `product`; for anything else Jev picks, and an offline or empty answer means all five. */
56
+ async function chooseLenses(
57
+ deps: LensReviewDeps,
58
+ ctx: ExtensionContext,
59
+ brief: string,
60
+ ): Promise<Choice> {
61
+ if (deps.state.get().sizing === "product") {
62
+ return { lenses: PRODUCT_LENSES, basis: "sizing is product: all five lenses." };
63
+ }
64
+ const judged = await judgeProductLenses(deps.jev(ctx), { brief });
65
+ if (!judged.ok) {
66
+ return {
67
+ lenses: PRODUCT_LENSES,
68
+ basis: `Jev unavailable (${judged.error.kind}); using all five lenses.`,
69
+ };
70
+ }
71
+ const picked = selectProductLenses(judged.value);
72
+ return picked.length === 0
73
+ ? { lenses: PRODUCT_LENSES, basis: "Jev found no specific lens; using all five lenses." }
74
+ : { lenses: picked, basis: "Jev chose the lenses for this brief." };
75
+ }
76
+
77
+ /** Warnings about solution-level detail in the brief: regex markers plus, when none match, Jev's read. Never an error. */
78
+ async function briefLint(
79
+ deps: LensReviewDeps,
80
+ ctx: ExtensionContext,
81
+ brief: string,
82
+ ): Promise<string[]> {
83
+ const found = lintBrief(brief);
84
+ if (found.length > 0) {
85
+ return found.map((f) => `- line ${f.line}: \`${f.match}\` (${f.kind}) — ${f.message}`);
86
+ }
87
+ const judged = await judgeSolutionDetail(deps.jev(ctx), { brief });
88
+ return judged.ok && judged.value >= SOLUTION_DETAIL_THRESHOLD
89
+ ? [
90
+ "- the brief prescribes the solution (tables, endpoints, classes or libraries) rather than the outcome; record that in an ADR (devsys_adr_new) or the architecture notes",
91
+ ]
92
+ : [];
93
+ }
94
+
95
+ /** The date of the newest `docs/product/reviews/<date>-round1.md`, if any. */
96
+ function latestRound1Date(cwd: string): string | undefined {
97
+ try {
98
+ return readdirSync(join(cwd, "docs/product/reviews"))
99
+ .flatMap((name) => /^(\d{4}-\d{2}-\d{2})-round1\.md$/.exec(name)?.[1] ?? [])
100
+ .sort()
101
+ .pop();
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ /** The lenses that wrote round 1, from its `## <lens> — round 1` headings; empty when none parse. */
108
+ function round1Lenses(cwd: string, date: string): ProductLens[] {
109
+ try {
110
+ const body = readFileSync(join(cwd, reviewPath(date, 1)), "utf8");
111
+ return PRODUCT_LENSES.filter((lens) => `\n${body}`.includes(`\n## ${lens} — round 1`));
112
+ } catch {
113
+ return [];
114
+ }
115
+ }
116
+
117
+ const parseRound = (given: number | undefined): ReviewRound | undefined => {
118
+ const round = given ?? 1;
119
+ return round === 1 || round === 2 ? round : undefined;
120
+ };
121
+
122
+ /**
123
+ * `devsys_lens_review`: the plan for a product-lens review round. Returns a codemode script as the
124
+ * primary form (the packets go to a file, not into the coordinator's context) and the fresh
125
+ * `agent_spawn` payloads as the fallback.
126
+ */
127
+ export function createLensReviewTool(deps: LensReviewDeps): ToolDefinition<typeof Parameters> {
128
+ return {
129
+ name: "devsys_lens_review",
130
+ label: "Lens review",
131
+ description:
132
+ "Plan a product-lens review of a brief: the five Cagan/Torres/Pichler/Perri/Rumelt lens agents, as a codemode script that writes their packets to docs/product/reviews/<date>-round<n>.md and returns only verdict lines, plus the agent_spawn payloads as a fallback. Round 2 is the peer exchange.",
133
+ promptSnippet: "Plan a product-lens review of the brief",
134
+ parameters: Parameters,
135
+ async execute(
136
+ _id,
137
+ params: Static<typeof Parameters>,
138
+ _signal,
139
+ _onUpdate,
140
+ ctx: ExtensionContext,
141
+ ) {
142
+ const round = parseRound(params.round);
143
+ if (round === undefined) {
144
+ return reply("round must be 1 (independent) or 2 (peer exchange)", true);
145
+ }
146
+ const briefPath = params.brief?.trim() || DEFAULT_BRIEF;
147
+ let brief: string;
148
+ try {
149
+ brief = await readFile(resolve(ctx.cwd, briefPath), "utf8");
150
+ } catch {
151
+ return reply(
152
+ `cannot read the brief at ${briefPath}; write it first (product-planning skill)`,
153
+ true,
154
+ );
155
+ }
156
+ const today = deps.now().toISOString().slice(0, 10);
157
+ // Round 2 pairs with the newest round 1 on disk, which may be from an earlier day.
158
+ const date = round === 2 ? latestRound1Date(ctx.cwd) : today;
159
+ if (date === undefined) {
160
+ return reply(
161
+ "round 2 reads a round1 review (docs/product/reviews/<date>-round1.md), and none exists yet; run round 1 first",
162
+ true,
163
+ );
164
+ }
165
+ const config = await loadConfig(ctx.cwd);
166
+ if (!config.ok) return reply(`${CONFIG_FILE}: ${config.error.message}`, true);
167
+ const resolved = resolveSlot(config.value.models, "lens", availableModels(ctx.modelRegistry));
168
+ const earlier = round === 2 ? round1Lenses(ctx.cwd, date) : [];
169
+ // Both ask Jev; run them together so a hung provider costs one timeout, not two.
170
+ const [{ lenses, basis }, lint] = await Promise.all([
171
+ earlier.length > 0
172
+ ? { lenses: earlier, basis: "round 2 asks the lenses that wrote round 1." }
173
+ : chooseLenses(deps, ctx, brief),
174
+ briefLint(deps, ctx, brief),
175
+ ]);
176
+ const payloads = lensPayloads({
177
+ lenses,
178
+ briefPath,
179
+ round,
180
+ date,
181
+ suffix: deps.now().getTime().toString(36),
182
+ model: resolved.ok ? resolved.value.model : undefined,
183
+ });
184
+ const file = reviewPath(date, round);
185
+ const script = lensReviewScript({ payloads, file, round });
186
+ return reply(
187
+ [
188
+ `Lens review, round ${round}, of ${briefPath}. ${basis}`,
189
+ `lenses: ${lenses.join(", ")}; packets go to ${file}`,
190
+ ...(lint.length > 0 ? ["Brief lint (warnings; the review still runs):", ...lint] : []),
191
+ "Run this with the codemode tool, unchanged. It spawns the lens agents, waits, writes the packets and returns only a verdict per lens and the path:",
192
+ "```js",
193
+ script,
194
+ "```",
195
+ `Fallback if codemode is unavailable: call agent_spawn once per payload below (all with wait:false), agent_wait on each, and write the packets to the file yourself, each under a heading '## <lens> — round ${round}' (round 2 reads those headings to find who wrote round 1).`,
196
+ JSON.stringify(payloads),
197
+ round === 1
198
+ ? "After round 2, write the synthesis with this template:"
199
+ : `Then write ${reviewPath(date, "synthesis")} with this template:`,
200
+ synthesisTemplate(date),
201
+ ].join("\n"),
202
+ );
203
+ },
204
+ };
205
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Product-lens review (plan I9.3): five fresh lens agents read the brief independently (round 1),
3
+ * then answer each other (round 2), and the coordinator synthesises. This module is pure: it builds
4
+ * the prompts, the `agent_spawn` payloads and the codemode script; the tool in
5
+ * `lens-review-tool.ts` supplies the date, models and lens choice.
6
+ */
7
+
8
+ export const PRODUCT_LENSES = ["cagan", "torres", "pichler", "perri", "rumelt"] as const;
9
+ export type ProductLens = (typeof PRODUCT_LENSES)[number];
10
+
11
+ /** A lens applies at or above this probability (same cut-off as the code-review lenses). */
12
+ const PRODUCT_LENS_THRESHOLD = 0.5;
13
+
14
+ export const GUARDRAIL =
15
+ "Agreement among agents is useful critique, not customer evidence. A finding that rests on opinion rather than data says so.";
16
+
17
+ export type ReviewRound = 1 | 2;
18
+
19
+ export const reviewPath = (date: string, kind: ReviewRound | "synthesis"): string =>
20
+ `docs/product/reviews/${date}-${kind === "synthesis" ? "synthesis" : `round${kind}`}.md`;
21
+
22
+ export const selectProductLenses = (
23
+ probabilities: Readonly<Record<ProductLens, number>>,
24
+ ): ProductLens[] => PRODUCT_LENSES.filter((lens) => probabilities[lens] >= PRODUCT_LENS_THRESHOLD);
25
+
26
+ const PACKET = [
27
+ "## Review — <artifact> — round <n> — lenses: <lens>",
28
+ "### Sources inspected",
29
+ "- <path:line ranges>",
30
+ "### Findings",
31
+ "- [blocking|should-fix|nit] <lens> `<path>:<line>` — <one sentence> — <why it matters>",
32
+ "### Verdict",
33
+ "no-blocking | blocking",
34
+ ].join("\n");
35
+
36
+ type LensTaskInput = {
37
+ lens: ProductLens;
38
+ briefPath: string;
39
+ round: ReviewRound;
40
+ /** Where round 1 was written; round 2 reads the peers' findings there. */
41
+ round1Path: string;
42
+ };
43
+
44
+ const ROUND_ONE =
45
+ "This is round 1 and it is independent: judge the artifact through your own lens and do not look at other lenses' output.";
46
+
47
+ const roundTwo = (round1Path: string): string =>
48
+ `This is round 2, a peer exchange: read the round 1 packets in \`${round1Path}\`. For each peer finding, say whether you agree, disagree or would reword it, and why, from your own lens. Raise anything the exchange made newly visible. Do not repeat your own round 1 findings.`;
49
+
50
+ /** The task text one lens agent receives. */
51
+ function lensTask(input: LensTaskInput): string {
52
+ return [
53
+ `You are the ${input.lens} lens. Review \`${input.briefPath}\` and any artifact it links (decision register, follow-ups, terminology, journeys, ADRs).`,
54
+ input.round === 1 ? ROUND_ONE : roundTwo(input.round1Path),
55
+ GUARDRAIL,
56
+ "Cite `path:line` for every finding. Do not edit files.",
57
+ `Answer with exactly this packet (round ${input.round}), then a **Not verified** list and a **Route** line saying where each finding should go (decision register, follow-ups, interview question, ignore):`,
58
+ PACKET,
59
+ ].join("\n\n");
60
+ }
61
+
62
+ export type SpawnPayload = {
63
+ path: string;
64
+ type: string;
65
+ task: string;
66
+ thinkingLevel: "high";
67
+ wait: false;
68
+ model?: string;
69
+ };
70
+
71
+ export type PayloadInput = {
72
+ lenses: readonly ProductLens[];
73
+ briefPath: string;
74
+ round: ReviewRound;
75
+ date: string;
76
+ /** Makes the agent paths fresh: a failed spawn must not leave a thread that blocks the retry. */
77
+ suffix: string;
78
+ model?: string | undefined;
79
+ };
80
+
81
+ /** One fresh `agent_spawn` payload per lens, each of its own `lens-*` agent type. */
82
+ export function lensPayloads(input: PayloadInput): SpawnPayload[] {
83
+ const round1Path = reviewPath(input.date, 1);
84
+ return input.lenses.map((lens) => ({
85
+ path: `/lens-r${input.round}-${lens}-${input.suffix}`,
86
+ type: `lens-${lens}`,
87
+ task: lensTask({ lens, briefPath: input.briefPath, round: input.round, round1Path }),
88
+ thinkingLevel: "high" as const,
89
+ wait: false as const,
90
+ ...(input.model === undefined ? {} : { model: input.model }),
91
+ }));
92
+ }
93
+
94
+ export type ScriptInput = {
95
+ payloads: readonly SpawnPayload[];
96
+ file: string;
97
+ round: ReviewRound;
98
+ };
99
+
100
+ /**
101
+ * The codemode script: spawn every lens detached, wait for all, write the packets to one file and
102
+ * return only a verdict line per lens plus the path, so the packets never enter the coordinator's context.
103
+ */
104
+ export function lensReviewScript(input: ScriptInput): string {
105
+ const lenses = input.payloads.map((p) => ({ path: p.path, lens: p.type.replace(/^lens-/, "") }));
106
+ return `// @options: {"timeout_ms": 1800000}
107
+ const payloads = ${JSON.stringify(input.payloads)};
108
+ const lenses = ${JSON.stringify(lenses)};
109
+ const file = ${JSON.stringify(input.file)};
110
+ await Promise.allSettled(payloads.map((p) => tools.agent_spawn({ ...p, wait: false })));
111
+ const packets = await Promise.all(
112
+ lenses.map(async ({ path, lens }) => {
113
+ try {
114
+ await tools.agent_wait({ path, timeoutMs: 1500000 });
115
+ // agent_output returns one page at a time; the verdict is at the end, so read every page.
116
+ let packet = "";
117
+ let offset = 0;
118
+ for (let page = 0; page < 20; page++) {
119
+ const raw = String(await tools.agent_output({ path, offset, limit: 16000 }));
120
+ let text = raw;
121
+ let next = null;
122
+ try {
123
+ const record = JSON.parse(raw);
124
+ if (record && typeof record.text === "string") {
125
+ text = record.text;
126
+ next = typeof record.nextOffset === "number" ? record.nextOffset : null;
127
+ }
128
+ } catch {}
129
+ packet += text;
130
+ if (next === null) break;
131
+ offset = next;
132
+ }
133
+ const found = /###\\s*Verdict\\s*\\n+\\s*(no-blocking|blocking)/i.exec(packet);
134
+ return { lens, packet, verdict: found ? found[1].toLowerCase() : "no verdict" };
135
+ } catch (e) {
136
+ return { lens, packet: "", verdict: "no packet (" + (e && e.message ? e.message : String(e)) + ")" };
137
+ }
138
+ }),
139
+ );
140
+ const body = packets
141
+ .map((r) => "## " + r.lens + " — round ${input.round}\\n\\n" + (r.packet || "_" + r.verdict + "_"))
142
+ .join("\\n\\n");
143
+ await tools.write({ path: file, content: "# Lens review — round ${input.round}\\n\\n" + body + "\\n" });
144
+ return packets.map((r) => r.lens + ": " + r.verdict).join("\\n") + "\\nwritten: " + file;
145
+ `;
146
+ }
147
+
148
+ /** The synthesis the coordinator writes after round 2: dispositions by id, then ONE next question. */
149
+ export function synthesisTemplate(date: string): string {
150
+ return [
151
+ `# Lens review synthesis — ${date}`,
152
+ "",
153
+ `> ${GUARDRAIL}`,
154
+ "",
155
+ "Sources: round 1 and round 2 review files in this directory.",
156
+ "",
157
+ "| R-id | Finding | Lenses | Disposition | Route |",
158
+ "| --- | --- | --- | --- | --- |",
159
+ "| R1 | <finding, one sentence> | <lenses that raised or backed it> | adopt / defer / reject — <why> | decision register / follow-ups / interview / ignore |",
160
+ "",
161
+ "## Interview agenda",
162
+ "",
163
+ "Ask the user one question next — the one whose answer removes the most risk from the R-table: <question>",
164
+ "",
165
+ "Everything else waits in follow-ups until the answer is in.",
166
+ "",
167
+ ].join("\n");
168
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
2
  import { type Static, Type } from "typebox";
3
3
  import type { Exec } from "../core/exec.ts";
4
+ import { afterReviewRound } from "../core/lifecycle.ts";
4
5
  import { resolveSlot } from "../core/models.ts";
5
6
  import type { Lens } from "../core/review.ts";
6
7
  import {
@@ -181,6 +182,7 @@ async function startRound(
181
182
  const existing = reviewOf(deps.state.get(), p.slice) ?? startReview(p.slice, required);
182
183
  const review: ReviewState = { ...existing, required };
183
184
  deps.state.update((s) => upsertReview(s, review));
185
+ deps.state.update((s) => afterReviewRound(s, nextAction(review, p.snap.digest), p.slice));
184
186
  const refusal = startRefusal(review, p);
185
187
  if (refusal !== undefined) return refusal;
186
188
 
@@ -345,6 +347,7 @@ async function recordRound(
345
347
  },
346
348
  );
347
349
  deps.state.update((s) => upsertReview(s, review));
350
+ deps.state.update((s) => afterReviewRound(s, nextAction(review, p.snap.digest), p.slice));
348
351
  return reply(recordSummary(review, findings, notes, p));
349
352
  }
350
353