@jwilger/pi-development-system 0.83.0 → 0.84.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/README.md +7 -2
- package/extensions/development-system.ts +6 -0
- package/package.json +1 -1
- package/prompts/devsys-adr.md +12 -0
- package/prompts/devsys-lens-review.md +12 -0
- package/skills/product-planning/SKILL.md +86 -0
- package/skills/product-planning/references/brief.md +48 -0
- package/skills/product-planning/references/decisions.md +33 -0
- package/skills/product-planning/references/followups.md +16 -0
- package/skills/product-planning/references/journeys.md +14 -0
- package/skills/product-planning/references/terminology.md +10 -0
- package/src/gates/commit-guard.ts +96 -19
- package/src/jev/questions/architecture.ts +43 -0
- package/src/jev/questions/product-lenses.ts +64 -0
- package/src/jev/questions/solution-detail.ts +43 -0
- package/src/planning/adr-tool.ts +70 -0
- package/src/planning/adr.ts +46 -0
- package/src/planning/brief-lint.ts +71 -0
- package/src/review/lens-review-tool.ts +205 -0
- package/src/review/lens-review.ts +168 -0
package/README.md
CHANGED
|
@@ -19,8 +19,13 @@ Everything the system offers is reachable without remembering a command. Describ
|
|
|
19
19
|
plain words: when a prompt asks for new work, a fix or a review, the system adds a guideline
|
|
20
20
|
naming the tool to use (`devsys_intake`, `devsys_review_start`). A `devsys` tool always shows
|
|
21
21
|
what the current phase expects. When you say a slice is finished with no review round recorded,
|
|
22
|
-
it tells you to start one. The slash commands (`/devsys-start`, `/devsys-plan`, `/devsys-review
|
|
23
|
-
are shortcuts to the same tools.
|
|
22
|
+
it tells you to start one. The slash commands (`/devsys-start`, `/devsys-plan`, `/devsys-review`,
|
|
23
|
+
`/devsys-lens-review`, `/devsys-adr`) are shortcuts to the same tools.
|
|
24
|
+
|
|
25
|
+
Product planning has a skill (`product-planning`: brief, decision register, follow-ups,
|
|
26
|
+
terminology, journeys). `devsys_lens_review` plans a review of the brief by five product lenses and
|
|
27
|
+
writes the packets to `docs/product/reviews/`; `devsys_adr_new` creates the next numbered ADR, and
|
|
28
|
+
a commit that shapes the architecture without one is stopped by the soft gate `adr.missing`.
|
|
24
29
|
|
|
25
30
|
With `codemode` enabled (`"defaultTools": ["+codemode"]` in pi settings), rarely used tools are
|
|
26
31
|
reached through scripts and the `judge_*` Jev wrappers exist for scripts only; without codemode
|
|
@@ -27,9 +27,11 @@ import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts
|
|
|
27
27
|
import { registerTestGuard } from "../src/gates/test-guard.ts";
|
|
28
28
|
import { createJevHolder } from "../src/jev/holder.ts";
|
|
29
29
|
import { createJudgeTools } from "../src/jev/judge-tools.ts";
|
|
30
|
+
import { createAdrNewTool } from "../src/planning/adr-tool.ts";
|
|
30
31
|
import { createBeginWorkTool } from "../src/planning/begin-tool.ts";
|
|
31
32
|
import { createIntakeTool } from "../src/planning/intake-tool.ts";
|
|
32
33
|
import { createTaskCheckTool } from "../src/planning/task-check-tool.ts";
|
|
34
|
+
import { createLensReviewTool } from "../src/review/lens-review-tool.ts";
|
|
33
35
|
import { createReviewRecordTool, createReviewStartTool } from "../src/review/review-tools.ts";
|
|
34
36
|
import { registerCiCommand } from "../src/state/ci-command.ts";
|
|
35
37
|
import { loadConfig } from "../src/state/config.ts";
|
|
@@ -174,6 +176,10 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
174
176
|
}
|
|
175
177
|
pi.registerTool(createPhaseTool({ state }));
|
|
176
178
|
pi.registerTool(createBeginWorkTool({ state }));
|
|
179
|
+
pi.registerTool(createAdrNewTool({ now: () => new Date() }));
|
|
180
|
+
pi.registerTool(
|
|
181
|
+
createLensReviewTool({ state, jev: (ctx) => jevHolder.forContext(ctx), now: () => new Date() }),
|
|
182
|
+
);
|
|
177
183
|
pi.registerTool(createIntakeTool({ pi, state, jev: (ctx) => jevHolder.forContext(ctx) }));
|
|
178
184
|
const reviewDeps = {
|
|
179
185
|
state,
|
package/package.json
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Record a hard-to-reverse technical decision as the next numbered ADR
|
|
3
|
+
argument-hint: "<decision title>"
|
|
4
|
+
---
|
|
5
|
+
Record an architecture decision with the product-planning skill.
|
|
6
|
+
|
|
7
|
+
1. Call `devsys_adr_new` with the title: $ARGUMENTS. It creates `docs/adr/NNNN-<slug>.md` from the template and returns the path.
|
|
8
|
+
2. Fill in Context (the forces and the problem), Decision (stated plainly), Consequences (positive and negative), Alternatives (each with why it was rejected) and Revisit when (a concrete condition), then link related ADRs and plan sections.
|
|
9
|
+
3. Set the status to `accepted` only when the user has agreed; leave it `proposed` otherwise.
|
|
10
|
+
4. Report the path and a one-line summary of the decision.
|
|
11
|
+
|
|
12
|
+
Use an ADR for a boundary, dependency, data format or protocol that is hard to reverse. Product decisions belong in the decision register instead.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run the five product lenses over the brief and record the packets
|
|
3
|
+
argument-hint: "[brief path] [round 1|2]"
|
|
4
|
+
---
|
|
5
|
+
Review the product brief with the product-planning skill.
|
|
6
|
+
|
|
7
|
+
1. Call `devsys_lens_review` (brief path and round: $ARGUMENTS; omit either for `docs/product/brief.md` and round 1).
|
|
8
|
+
2. Run the codemode script it returns, unchanged. It spawns the lens agents, waits, writes the packets to `docs/product/reviews/<date>-round<n>.md` and returns only a verdict per lens and the path. If codemode is unavailable, use the `agent_spawn` payloads it lists.
|
|
9
|
+
3. Read the review file, not the agents' full output. Report each lens verdict and the findings with their `path:line`.
|
|
10
|
+
4. After round 2, write the synthesis (R-table and a one-question agenda) from the template in the reply, then ask the user that one question.
|
|
11
|
+
|
|
12
|
+
Agreement among agents is useful critique, not customer evidence.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: product-planning
|
|
3
|
+
description: Plan a product or capability before building it - discovery interview, brief, decision register, follow-ups, terminology, journey inventory, lens review and ADRs. Use when sizing says capability or product, when asked for a brief or a discovery interview, when an answer must be recorded as a decision, or when a plan needs a product review.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Product planning
|
|
7
|
+
|
|
8
|
+
Planning here finds out what is worth building. It is not a spec for code. The artifacts are a small set of
|
|
9
|
+
linked documents; each has one job and a template under `references/`.
|
|
10
|
+
|
|
11
|
+
| Artifact | Job | Template |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Brief | The one narrative: outcome, customers, four risks, assumptions, scope, non-goals | [brief](references/brief.md) |
|
|
14
|
+
| Decision register | The spine: D (decisions), Q (questions), F (scheduling and exclusions) | [decisions](references/decisions.md) |
|
|
15
|
+
| Follow-ups | P items and R review findings: not lost, not now | [followups](references/followups.md) |
|
|
16
|
+
| Terminology | One meaning per word | [terminology](references/terminology.md) |
|
|
17
|
+
| Journeys | J items: a user, actions, an outcome | [journeys](references/journeys.md) |
|
|
18
|
+
|
|
19
|
+
Put them under `docs/product/` (`brief.md`, `decisions.md`, `followups.md`, `terminology.md`, `journeys.md`).
|
|
20
|
+
Copy a template only when the work needs that artifact: `capability` work gets a short brief and journeys,
|
|
21
|
+
`product` work gets the lot. Skipping one is a recorded departure (`artifact.skipped`).
|
|
22
|
+
|
|
23
|
+
## The interview loop
|
|
24
|
+
|
|
25
|
+
Quote this rule and follow it exactly:
|
|
26
|
+
|
|
27
|
+
> Update docs after each answer, ask one next question, yield.
|
|
28
|
+
|
|
29
|
+
1. Ask one question. Make it the most decision-relevant open Q item.
|
|
30
|
+
2. Stop. Wait for the answer. Do not ask a second question or guess ahead.
|
|
31
|
+
3. Record the answer as a D-item in the register, in the owner's words, and add a dated line to the interview log.
|
|
32
|
+
4. Edit the brief **only where the answered question touches it**. Bump its version. Nothing else changes.
|
|
33
|
+
5. Ask the next question and yield again.
|
|
34
|
+
|
|
35
|
+
Every answer becomes a D-item, including "no" and "not now" (a Deferred item with a revisit point).
|
|
36
|
+
|
|
37
|
+
## Do not over-edit the brief
|
|
38
|
+
|
|
39
|
+
The commonest failure is an agent that rewrites the brief between answers. It misframed the team, moved the
|
|
40
|
+
timing boundary, let later-phase ideas leak in and relocated the business case, and the owner had to find and
|
|
41
|
+
undo each one. So:
|
|
42
|
+
|
|
43
|
+
- An edit is limited to the answered question. If you notice something else that is wrong, add a Q item, do not fix it silently.
|
|
44
|
+
- Ideas for later go to follow-ups. Nothing speculative goes in the brief.
|
|
45
|
+
- Show the brief diff after each answer so the owner can see exactly what moved.
|
|
46
|
+
- The owner owns judgements about the domain; you own process and where things are filed.
|
|
47
|
+
|
|
48
|
+
## Outcome, risks, assumptions
|
|
49
|
+
|
|
50
|
+
- Open with an **outcome** (direction plus target), not a feature. State the problem to solve, not the solution to build.
|
|
51
|
+
- Assess the four risks (value, usability, feasibility, viability). "Unassessed" is a legal, recorded state.
|
|
52
|
+
- Write assumptions specifically; the smaller the assumption, the smaller the test. Test the riskiest first
|
|
53
|
+
(critical to success, least evidence). Compare at least two solutions, and record why when you only had one.
|
|
54
|
+
- Say whether the work builds to learn (disposable, fewer gates) or builds to earn (full gates). That is a decision, never an inference.
|
|
55
|
+
|
|
56
|
+
## Deferral is not exclusion
|
|
57
|
+
|
|
58
|
+
Postponing the details of a capability does not remove it from scope. Only an explicit scope decision does.
|
|
59
|
+
A deferred item records who deferred it, why, the interim constraint and when to revisit. A non-goal is
|
|
60
|
+
something we decided not to do.
|
|
61
|
+
|
|
62
|
+
## Journeys
|
|
63
|
+
|
|
64
|
+
Cut the backlog from journeys, not features. A journey passes if it tells the story of a user performing a
|
|
65
|
+
set of actions to achieve an outcome. Decide journeys before generating tasks; feature-shaped backlogs had to
|
|
66
|
+
be thrown away and redone.
|
|
67
|
+
|
|
68
|
+
## Lens review
|
|
69
|
+
|
|
70
|
+
Run `devsys_lens_review` (or `/devsys-lens-review`) on the brief when the outcome, scope or risks are settled
|
|
71
|
+
enough to be wrong in an interesting way. Five fresh agents read it, each through one lens (Cagan, Torres,
|
|
72
|
+
Pichler, Perri, Rumelt). Round one is independent; round two is a peer exchange; you then synthesise an R table
|
|
73
|
+
and a one-question agenda, and the interview loop resumes. **Agreement among agents is useful critique, not
|
|
74
|
+
customer evidence.** Record rejected recommendations in the register instead of dropping them.
|
|
75
|
+
|
|
76
|
+
## Decisions that need an ADR
|
|
77
|
+
|
|
78
|
+
A decision about how the code is built that is hard to reverse (a boundary, a dependency, a data format, a
|
|
79
|
+
protocol) gets an ADR through `devsys_adr_new`. Product decisions stay in the register. Do not write an ADR
|
|
80
|
+
for something the register already holds, or the other way round.
|
|
81
|
+
|
|
82
|
+
## Status words
|
|
83
|
+
|
|
84
|
+
Everything produced here is "proposed" or "advisory" until a person approves a specific revision. Say
|
|
85
|
+
"readiness" for declared checks, "approval" for a person's decision on a named revision, and never let one
|
|
86
|
+
stand in for the other.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Brief: <product or change name>
|
|
2
|
+
|
|
3
|
+
- **Context:** <which product or process this brief is about>
|
|
4
|
+
- **Status:** v0.1 · <date> · links: [decisions](decisions.md), [follow-ups](followups.md)
|
|
5
|
+
- **Not an authorisation:** this brief records what is understood so far. It does not approve building anything.
|
|
6
|
+
|
|
7
|
+
## Outcome
|
|
8
|
+
|
|
9
|
+
One sentence: the change in customer behaviour or business result we want, with a direction and a target
|
|
10
|
+
(for example "first-session success from 22% to 25%"). An output ("ship X") is not an outcome.
|
|
11
|
+
|
|
12
|
+
## Customers and the problem
|
|
13
|
+
|
|
14
|
+
Who has the problem, what they do today, and the evidence for it (link the source, date it). Say plainly what
|
|
15
|
+
is evidence and what is belief.
|
|
16
|
+
|
|
17
|
+
## Four risks
|
|
18
|
+
|
|
19
|
+
| Risk | What could be wrong | Evidence so far | Cheapest test |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| Value | Nobody wants or uses it | unassessed | |
|
|
22
|
+
| Usability | People cannot work it out | unassessed | |
|
|
23
|
+
| Feasibility | We cannot build it | unassessed | |
|
|
24
|
+
| Viability | It does not work for the business | unassessed | |
|
|
25
|
+
|
|
26
|
+
"Unassessed" is a legal state. Writing it is better than guessing.
|
|
27
|
+
|
|
28
|
+
## Assumptions
|
|
29
|
+
|
|
30
|
+
Each assumption is specific, tagged with a risk category and a test status. Riskiest first (critical to
|
|
31
|
+
success, little evidence). Every assumption gets a D-item in the register once answered.
|
|
32
|
+
|
|
33
|
+
| ID | Assumption | Category | Test status |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| A01 | | desirability / viability / feasibility / usability / ethical | untested |
|
|
36
|
+
|
|
37
|
+
## Scope
|
|
38
|
+
|
|
39
|
+
What the first release does, in the customer's words.
|
|
40
|
+
|
|
41
|
+
## Non-goals
|
|
42
|
+
|
|
43
|
+
Things we have decided not to do. Only an explicit scope decision belongs here.
|
|
44
|
+
**Deferral is not exclusion:** an item that is merely later goes to [follow-ups](followups.md), not here.
|
|
45
|
+
|
|
46
|
+
## Open questions
|
|
47
|
+
|
|
48
|
+
Pointers only (Q-ids in [decisions](decisions.md)). The question text lives in the register, not the brief.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Decision register
|
|
2
|
+
|
|
3
|
+
The referential spine: every brief, review and journey cites these ids.
|
|
4
|
+
|
|
5
|
+
## Closure rules
|
|
6
|
+
|
|
7
|
+
- **Resolved:** the owner answered or accepted it.
|
|
8
|
+
- **Deferred:** postponed to a later phase. State who decided, why, the interim constraint and the revisit
|
|
9
|
+
point. Deferring the specification does not defer the capability; only an explicit scope decision does.
|
|
10
|
+
- **Open:** not yet answered.
|
|
11
|
+
- Superseding keeps the old row and points at the new one (`D06 → D56`). Rows are never deleted.
|
|
12
|
+
|
|
13
|
+
## Decisions (D)
|
|
14
|
+
|
|
15
|
+
| ID | Decision | Status | Source |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| D01 | | Resolved | <answer, date> |
|
|
18
|
+
|
|
19
|
+
## Questions (Q)
|
|
20
|
+
|
|
21
|
+
| ID | Question | Status | Needed for closure |
|
|
22
|
+
|---|---|---|---|
|
|
23
|
+
| Q01 | | Open | |
|
|
24
|
+
|
|
25
|
+
## Scheduling, conditional scope and exclusions (F)
|
|
26
|
+
|
|
27
|
+
| ID | Item | Disposition | Rationale | Revisit when |
|
|
28
|
+
|---|---|---|---|---|
|
|
29
|
+
| F01 | | true exclusion / conditional / scheduled | | |
|
|
30
|
+
|
|
31
|
+
## Interview log
|
|
32
|
+
|
|
33
|
+
One dated bullet per answer: the question, the answer in the owner's words, and "Recorded as D<nn>".
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Planning follow-ups
|
|
2
|
+
|
|
3
|
+
Where "do not lose it, do not do it now" goes. **Scheduled planning work is not an MVP exclusion.**
|
|
4
|
+
|
|
5
|
+
| ID | Question or work | Appropriate phase | Revisit when | Decision refs |
|
|
6
|
+
|---|---|---|---|---|
|
|
7
|
+
| P01 | | modelling / design / architecture / pilot / commercial | | D01 |
|
|
8
|
+
|
|
9
|
+
## Review findings (R)
|
|
10
|
+
|
|
11
|
+
Findings from a lens review and where each was routed (decision register, follow-up, interview question,
|
|
12
|
+
rejected). A rejected recommendation is recorded with the reason, never silently dropped.
|
|
13
|
+
|
|
14
|
+
| ID | Finding | Route | Disposition |
|
|
15
|
+
|---|---|---|---|
|
|
16
|
+
| R01 | | | |
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Journey inventory
|
|
2
|
+
|
|
3
|
+
A journey passes this test: **it tells the story of a user performing a set of actions to achieve an outcome.**
|
|
4
|
+
A feature, a screen or a technical layer does not pass it. Do this before cutting any backlog.
|
|
5
|
+
|
|
6
|
+
| ID | User and starting need | Actions | Outcome |
|
|
7
|
+
|---|---|---|---|
|
|
8
|
+
| J01 | | | |
|
|
9
|
+
|
|
10
|
+
## Per journey
|
|
11
|
+
|
|
12
|
+
- Alternative outcomes (what happens when it goes wrong).
|
|
13
|
+
- The decision ids it rests on.
|
|
14
|
+
- Approval is of a specific revision, recorded with person and date; drafting is not approval.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Terminology
|
|
2
|
+
|
|
3
|
+
Words used in the brief, register and journeys, each with one meaning. Label a document with the sense of a
|
|
4
|
+
word it uses when the word has more than one.
|
|
5
|
+
|
|
6
|
+
| Term | Meaning | Not to be confused with |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| Deferred item | Scheduled to a later phase | Exclusion |
|
|
9
|
+
| Readiness | Declared checks pass | Human approval of a specific revision |
|
|
10
|
+
| Lens | An advisory reviewing perspective | A review by the person it is named for |
|
|
@@ -17,6 +17,7 @@ import { resolveGit } from "../core/git-invocations.ts";
|
|
|
17
17
|
import { DECISION_LOG, reviewGap } from "../core/review-flow.ts";
|
|
18
18
|
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
19
19
|
import type { Jev } from "../jev/client.ts";
|
|
20
|
+
import { ARCHITECTURE_THRESHOLD, judgeArchitectureShaping } from "../jev/questions/architecture.ts";
|
|
20
21
|
import { judgeCommit, MIX_THRESHOLD, RATIONALE_FLOOR } from "../jev/questions/commit.ts";
|
|
21
22
|
import { snapshotDiff } from "../review/digest.ts";
|
|
22
23
|
import type { SessionState } from "../state/session-state.ts";
|
|
@@ -40,6 +41,13 @@ const gateId = (id: string): GateId => {
|
|
|
40
41
|
const RATIONALE = gateId("commit.rationale");
|
|
41
42
|
const MIXED = gateId("commit.mixed-change");
|
|
42
43
|
const REVIEW = gateId("review.unsatisfied");
|
|
44
|
+
const ADR_MISSING = gateId("adr.missing");
|
|
45
|
+
|
|
46
|
+
/** A new `docs/adr/NNNN-*.md` among the pending changes; editing an older ADR is not recording a new decision. */
|
|
47
|
+
const addsAdr = (diff: string): boolean =>
|
|
48
|
+
/^diff --git a\/docs\/adr\/\d{4}-\S+\.md b\/\S+\n(?:new file mode|rename from |similarity index)/m.test(
|
|
49
|
+
diff,
|
|
50
|
+
);
|
|
43
51
|
|
|
44
52
|
const readMessageFile = (cwd: string, path: string): string | undefined => {
|
|
45
53
|
try {
|
|
@@ -72,25 +80,85 @@ const stagesEverything = (command: string): boolean =>
|
|
|
72
80
|
command,
|
|
73
81
|
);
|
|
74
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The pathspecs a `git add` in the command stages new files under: `.` for `-A` with no path, else the named
|
|
85
|
+
* paths. Empty when nothing in the command can add an untracked file (`-u` stages only tracked files, `-n` nothing).
|
|
86
|
+
*/
|
|
87
|
+
const addedPathspecs = (command: string): string[] =>
|
|
88
|
+
[...command.matchAll(/\bgit\s+add\b([^;&|\n]*)/g)].flatMap((m) => {
|
|
89
|
+
const words = (m[1] ?? "")
|
|
90
|
+
.trim()
|
|
91
|
+
.split(/\s+/)
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map((w) => w.replace(/^(["'])(.*)\1$/, "$2")); // `git add "docs/adr/0005-x.md"` names the same file
|
|
94
|
+
if (words.some((w) => ["-u", "--update", "-n", "--dry-run"].includes(w))) return [];
|
|
95
|
+
const paths = words.filter((w) => !w.startsWith("-"));
|
|
96
|
+
if (paths.length === 0) return words.some((w) => w === "-A" || w === "--all") ? ["."] : [];
|
|
97
|
+
return paths.map((p) => p.replace(/^\.\//, "").replace(/\/$/, "") || ".");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
type PendingDiff = { stat: string; diff: string; untrackedAdr: boolean };
|
|
101
|
+
|
|
75
102
|
/** What is about to be committed: the staged changes, or every tracked change when the commit stages them itself. */
|
|
76
103
|
async function pendingDiff(
|
|
77
104
|
exec: Exec,
|
|
78
105
|
cwd: string,
|
|
79
106
|
command: string,
|
|
80
|
-
): Promise<
|
|
107
|
+
): Promise<PendingDiff | undefined> {
|
|
81
108
|
try {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
109
|
+
const widened = stagesEverything(command);
|
|
110
|
+
const specs = addedPathspecs(command);
|
|
111
|
+
// Fixed prefixes and no external driver: `addsAdr` reads the headers, and git config can change them.
|
|
112
|
+
const fixed = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
113
|
+
const base = widened ? ["diff", ...fixed, "HEAD"] : ["diff", ...fixed, "--cached"];
|
|
114
|
+
const [stat, diff, untracked] = await Promise.all([
|
|
84
115
|
exec("git", [...base, "--stat"], { cwd, timeout: 10_000 }),
|
|
85
116
|
exec("git", base, { cwd, timeout: 10_000 }),
|
|
117
|
+
// `git diff HEAD` leaves out files git does not track yet, such as a new module or an ADR just created.
|
|
118
|
+
specs.length > 0
|
|
119
|
+
? exec("git", ["ls-files", "--others", "--exclude-standard", "--", ...new Set(specs)], {
|
|
120
|
+
cwd,
|
|
121
|
+
timeout: 10_000,
|
|
122
|
+
})
|
|
123
|
+
: Promise.resolve(undefined),
|
|
86
124
|
]);
|
|
87
|
-
if (stat.code !== 0 || diff.code !== 0
|
|
88
|
-
|
|
125
|
+
if (stat.code !== 0 || diff.code !== 0) return undefined;
|
|
126
|
+
const added = untracked?.code === 0 ? untracked.stdout.trim() : "";
|
|
127
|
+
if (diff.stdout.trim() === "" && added === "") return undefined;
|
|
128
|
+
return {
|
|
129
|
+
stat:
|
|
130
|
+
added === ""
|
|
131
|
+
? stat.stdout
|
|
132
|
+
: // First, because Jev clips the stat: a long list of tracked files must not push new files out of view.
|
|
133
|
+
`New files this commit adds (untracked):\n${added}\n\n${stat.stdout}`,
|
|
134
|
+
diff: diff.stdout,
|
|
135
|
+
untrackedAdr: /^docs\/adr\/\d{4}-\S+\.md$/m.test(added),
|
|
136
|
+
};
|
|
89
137
|
} catch {
|
|
90
138
|
return undefined;
|
|
91
139
|
}
|
|
92
140
|
}
|
|
93
141
|
|
|
142
|
+
/** Non-negotiable 9 as a soft gate: the judgement is probabilistic, so a departure can answer it. */
|
|
143
|
+
async function adrNeeds(
|
|
144
|
+
deps: CommitGuardDeps,
|
|
145
|
+
ctx: ExtensionContext,
|
|
146
|
+
pending: PendingDiff,
|
|
147
|
+
): Promise<Need[]> {
|
|
148
|
+
if (addsAdr(pending.diff) || pending.untrackedAdr) return [];
|
|
149
|
+
const judged = await judgeArchitectureShaping(deps.jev(ctx), {
|
|
150
|
+
diffStat: pending.stat,
|
|
151
|
+
diff: pending.diff,
|
|
152
|
+
});
|
|
153
|
+
if (!judged.ok || judged.value < ARCHITECTURE_THRESHOLD) return [];
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
gate: ADR_MISSING,
|
|
157
|
+
why: "Jev reads this diff as an architecture-shaping decision (a boundary, dependency, data format or protocol) and it adds no ADR",
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
94
162
|
async function jevNeeds(
|
|
95
163
|
deps: CommitGuardDeps,
|
|
96
164
|
ctx: ExtensionContext,
|
|
@@ -100,13 +168,13 @@ async function jevNeeds(
|
|
|
100
168
|
): Promise<Need[]> {
|
|
101
169
|
const pending = await pendingDiff(deps.exec, ctx.cwd, command);
|
|
102
170
|
if (pending === undefined) return [];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
diffStat: pending.stat,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
171
|
+
// Both ask Jev; run them together so a hung provider costs one timeout, not two.
|
|
172
|
+
const [judged, adr] = await Promise.all([
|
|
173
|
+
judgeCommit(deps.jev(ctx), { message, diffStat: pending.stat, diff: pending.diff }),
|
|
174
|
+
adrNeeds(deps, ctx, pending),
|
|
175
|
+
]);
|
|
176
|
+
const needs: Need[] = adr;
|
|
177
|
+
if (!judged.ok) return needs;
|
|
110
178
|
if (bodyPresent && judged.value.rationale < RATIONALE_FLOOR) {
|
|
111
179
|
needs.push({ gate: RATIONALE, why: "Jev reads the body as restating what changed, not why" });
|
|
112
180
|
}
|
|
@@ -247,13 +315,22 @@ const reviewReason = (need: Need): string =>
|
|
|
247
315
|
"and repeat until the review is satisfied. If skipping review is deliberate call devsys_record_departure " +
|
|
248
316
|
`with gate "${need.gate}", what you are doing instead, why, and the cost if wrong; then retry.`;
|
|
249
317
|
|
|
250
|
-
const
|
|
251
|
-
need.gate
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
318
|
+
const adrReason = (need: Need): string =>
|
|
319
|
+
`${need.gate}: ${need.why}. Hard-to-reverse decisions are recorded as an ADR in the same change. ` +
|
|
320
|
+
"Call devsys_adr_new with the decision's title, fill in the ADR and stage it. If this diff is not " +
|
|
321
|
+
`architecture-shaping, call devsys_record_departure with gate "${need.gate}", what you are doing instead, ` +
|
|
322
|
+
"why, and the cost if wrong; then retry.";
|
|
323
|
+
|
|
324
|
+
const commitReason = (need: Need): string =>
|
|
325
|
+
`${need.gate}: ${need.why}. Commit messages carry their rationale and structural and behavioural ` +
|
|
326
|
+
"changes go in separate commits. Fix the commit (split it, or write the why in the body), or if " +
|
|
327
|
+
`departing is deliberate call devsys_record_departure with gate "${need.gate}", what you are doing ` +
|
|
328
|
+
"instead, why, and the cost if wrong; then retry.";
|
|
329
|
+
|
|
330
|
+
function blockReason(need: Need): string {
|
|
331
|
+
if (need.gate === REVIEW) return reviewReason(need);
|
|
332
|
+
return need.gate === ADR_MISSING ? adrReason(need) : commitReason(need);
|
|
333
|
+
}
|
|
257
334
|
|
|
258
335
|
const forbiddenReason = (found: readonly string[]): string =>
|
|
259
336
|
`commit.forbidden-trailer: this commit carries an AI attribution (${found.join("; ")}). ` +
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ClassifierBoolQuestion } 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
|
+
/** At or above this, a diff with no ADR in it is flagged (gate `adr.missing`). */
|
|
7
|
+
export const ARCHITECTURE_THRESHOLD = 0.7;
|
|
8
|
+
|
|
9
|
+
export type ArchitectureInput = { diffStat: string; diff: string };
|
|
10
|
+
|
|
11
|
+
const DIFF_CLIP = 8000;
|
|
12
|
+
|
|
13
|
+
export const ARCHITECTURE_QUESTION: ClassifierBoolQuestion = {
|
|
14
|
+
type: "bool",
|
|
15
|
+
instructions:
|
|
16
|
+
"Read `diffStat` and `diff`. Does this change make an architecture-shaping decision that is hard to reverse: a new module boundary or layer, a new runtime dependency, a persisted data format or schema, a public protocol or interface contract, or a change to how components communicate?",
|
|
17
|
+
criteria: {
|
|
18
|
+
true: "The diff introduces or changes a boundary, dependency, data format or protocol that later code will build on and that would be costly to undo",
|
|
19
|
+
false:
|
|
20
|
+
"The diff is a bug fix, local refactor, test, documentation, formatting or feature work inside existing boundaries that is easy to change later",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Bound the text before redacting (redaction cost grows with run length); keep margin so a secret at the cut is still seen whole.
|
|
25
|
+
const clip = (text: string, max: number): string =>
|
|
26
|
+
redactSecrets(text.slice(0, max * 2)).slice(0, max);
|
|
27
|
+
|
|
28
|
+
/** The probability that the diff shapes the architecture. */
|
|
29
|
+
export async function judgeArchitectureShaping(
|
|
30
|
+
jev: Jev,
|
|
31
|
+
input: ArchitectureInput,
|
|
32
|
+
): Promise<Result<number, JevError>> {
|
|
33
|
+
const asked = await jev.ask(
|
|
34
|
+
{ diffStat: clip(input.diffStat, 2000), diff: clip(input.diff, DIFF_CLIP) },
|
|
35
|
+
{ architecture: ARCHITECTURE_QUESTION },
|
|
36
|
+
);
|
|
37
|
+
if (!asked.ok) return asked;
|
|
38
|
+
const answer = asked.value.architecture;
|
|
39
|
+
if (answer?.type !== "bool") {
|
|
40
|
+
return err({ kind: "provider", message: "missing architecture answer" });
|
|
41
|
+
}
|
|
42
|
+
return ok(answer.probability);
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ClassifierBoolQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import { redactSecrets } from "../../core/redact.ts";
|
|
3
|
+
import { err, ok, type Result } from "../../core/result.ts";
|
|
4
|
+
import { PRODUCT_LENSES, type ProductLens } from "../../review/lens-review.ts";
|
|
5
|
+
import type { Jev, JevError } from "../client.ts";
|
|
6
|
+
|
|
7
|
+
const lensQuestion = (instructions: string, yes: string, no: string): ClassifierBoolQuestion => ({
|
|
8
|
+
type: "bool",
|
|
9
|
+
instructions,
|
|
10
|
+
criteria: { true: yes, false: no },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** One narrow question per product lens: does this lens have something to say about the brief? */
|
|
14
|
+
export const PRODUCT_LENS_QUESTIONS: Readonly<Record<ProductLens, ClassifierBoolQuestion>> = {
|
|
15
|
+
cagan: lensQuestion(
|
|
16
|
+
"Read the `brief`. Does it commit to building a product or feature while leaving a value, usability, feasibility or viability risk unnamed or untested? A trivial copy, rename or maintenance change carries no such risk.",
|
|
17
|
+
"It proposes something to build and names no test or evidence for at least one of those risks",
|
|
18
|
+
"The change is trivial, or the brief names its risks and a test or evidence for them",
|
|
19
|
+
),
|
|
20
|
+
torres: lensQuestion(
|
|
21
|
+
"Read the `brief`. Does it claim what customers need or want without citing discovery evidence (interviews, observed behaviour, usage data)? A brief that makes no customer claim does not count.",
|
|
22
|
+
"It asserts customer needs, demand or behaviour with no discovery evidence cited",
|
|
23
|
+
"Customer claims are backed by cited evidence, or the brief makes no customer claim",
|
|
24
|
+
),
|
|
25
|
+
pichler: lensQuestion(
|
|
26
|
+
"Read the `brief`. Does it lack a measurable goal or metric that ties the work to a product vision or strategy? A trivial copy, rename or maintenance change needs none.",
|
|
27
|
+
"It proposes substantial work with no measurable goal or metric tied to a vision",
|
|
28
|
+
"It names a measurable goal or metric, or the change is trivial",
|
|
29
|
+
),
|
|
30
|
+
perri: lensQuestion(
|
|
31
|
+
"Read the `brief`. Does it define success as shipping features or deliverables rather than as a measurable change for customers or the business? A typo fix, rename, dependency upgrade or other maintenance change is not a product bet and always answers false.",
|
|
32
|
+
"A product bet whose success is framed as delivering a list of features or a launch date, with no outcome measure",
|
|
33
|
+
"Success is a measurable outcome, or the change is maintenance (typo, rename, upgrade, refactor)",
|
|
34
|
+
),
|
|
35
|
+
rumelt: lensQuestion(
|
|
36
|
+
"Read the `brief`. Does it state goals or ambitions without a diagnosis of the real challenge, a guiding policy and coherent actions? A trivial copy, rename or maintenance change needs no strategy.",
|
|
37
|
+
"It lists goals, ambitions or features with no diagnosis of the challenge and no guiding policy",
|
|
38
|
+
"It diagnoses the challenge and sets a policy and actions, or the change is trivial",
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const BRIEF_CLIP = 8000;
|
|
43
|
+
|
|
44
|
+
// Bound the text before redacting (redaction cost grows with run length); keep margin so a secret at the cut is still seen whole.
|
|
45
|
+
const clip = (text: string, max: number): string =>
|
|
46
|
+
redactSecrets(text.slice(0, max * 2)).slice(0, max);
|
|
47
|
+
|
|
48
|
+
/** Probability per product lens that it applies to the brief. */
|
|
49
|
+
export async function judgeProductLenses(
|
|
50
|
+
jev: Jev,
|
|
51
|
+
input: { brief: string },
|
|
52
|
+
): Promise<Result<Record<ProductLens, number>, JevError>> {
|
|
53
|
+
const asked = await jev.ask({ brief: clip(input.brief, BRIEF_CLIP) }, PRODUCT_LENS_QUESTIONS);
|
|
54
|
+
if (!asked.ok) return asked;
|
|
55
|
+
const out = {} as Record<ProductLens, number>;
|
|
56
|
+
for (const lens of PRODUCT_LENSES) {
|
|
57
|
+
const answer = asked.value[lens];
|
|
58
|
+
if (answer?.type !== "bool" || !Number.isFinite(answer.probability)) {
|
|
59
|
+
return err({ kind: "provider", message: `missing or malformed answer for lens ${lens}` });
|
|
60
|
+
}
|
|
61
|
+
out[lens] = answer.probability;
|
|
62
|
+
}
|
|
63
|
+
return ok(out);
|
|
64
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ClassifierBoolQuestion } 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
|
+
/** At or above this, the brief is reported as carrying solution-level detail (a warning only). */
|
|
7
|
+
export const SOLUTION_DETAIL_THRESHOLD = 0.7;
|
|
8
|
+
|
|
9
|
+
export type SolutionDetailInput = { brief: string };
|
|
10
|
+
|
|
11
|
+
const BRIEF_CLIP = 8000;
|
|
12
|
+
|
|
13
|
+
export const SOLUTION_DETAIL_QUESTION: ClassifierBoolQuestion = {
|
|
14
|
+
type: "bool",
|
|
15
|
+
instructions:
|
|
16
|
+
"Read `brief`, a product brief. Does it specify the solution rather than the problem: named database tables or columns, API endpoints, class or module names, framework or library choices, or step-by-step implementation design?",
|
|
17
|
+
criteria: {
|
|
18
|
+
true: "The brief prescribes how the system is built (tables, endpoints, classes, libraries, internal design) instead of, or in addition to, the outcome, users and risks",
|
|
19
|
+
false:
|
|
20
|
+
"The brief describes outcomes, users, journeys, constraints and risks in product terms; any technical mention is a stated constraint or a name the users themselves use",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Bound the text before redacting (redaction cost grows with run length); keep margin so a secret at the cut is still seen whole.
|
|
25
|
+
const clip = (text: string, max: number): string =>
|
|
26
|
+
redactSecrets(text.slice(0, max * 2)).slice(0, max);
|
|
27
|
+
|
|
28
|
+
/** The probability that the brief contains solution-level detail. */
|
|
29
|
+
export async function judgeSolutionDetail(
|
|
30
|
+
jev: Jev,
|
|
31
|
+
input: SolutionDetailInput,
|
|
32
|
+
): Promise<Result<number, JevError>> {
|
|
33
|
+
const asked = await jev.ask(
|
|
34
|
+
{ brief: clip(input.brief, BRIEF_CLIP) },
|
|
35
|
+
{ solution: SOLUTION_DETAIL_QUESTION },
|
|
36
|
+
);
|
|
37
|
+
if (!asked.ok) return asked;
|
|
38
|
+
const answer = asked.value.solution;
|
|
39
|
+
if (answer?.type !== "bool") {
|
|
40
|
+
return err({ kind: "provider", message: "missing solution-detail answer" });
|
|
41
|
+
}
|
|
42
|
+
return ok(answer.probability);
|
|
43
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { type Static, Type } from "typebox";
|
|
5
|
+
import { adrFileName, nextAdrNumber, parseAdrTitle, renderAdr } from "./adr.ts";
|
|
6
|
+
|
|
7
|
+
const Parameters = Type.Object({
|
|
8
|
+
title: Type.String({ description: "The decision, in a few words (one line)." }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const ADR_DIR = "docs/adr";
|
|
12
|
+
const TEMPLATE = "0000-template.md";
|
|
13
|
+
|
|
14
|
+
const reply = (text: string, isError = false) => ({
|
|
15
|
+
content: [{ type: "text" as const, text }],
|
|
16
|
+
details: undefined,
|
|
17
|
+
isError,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const message = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `devsys_adr_new`: the next numbered ADR, filled from `docs/adr/0000-template.md`. Numbering and the
|
|
24
|
+
* file name are mechanical, so the model writes the decision and not the paperwork.
|
|
25
|
+
*/
|
|
26
|
+
export function createAdrNewTool(deps: { now: () => Date }): ToolDefinition<typeof Parameters> {
|
|
27
|
+
return {
|
|
28
|
+
name: "devsys_adr_new",
|
|
29
|
+
// pi runs a turn's tool calls in parallel; two calls would read the same directory and pick the same number.
|
|
30
|
+
executionMode: "sequential",
|
|
31
|
+
label: "New ADR",
|
|
32
|
+
description:
|
|
33
|
+
"Create the next numbered ADR in docs/adr from its template and return the path to fill in. Use for a hard-to-reverse technical decision (a boundary, dependency, data format or protocol); product decisions go in the decision register.",
|
|
34
|
+
promptSnippet: "Create the next ADR from the template",
|
|
35
|
+
parameters: Parameters,
|
|
36
|
+
async execute(_id, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
|
|
37
|
+
const title = parseAdrTitle(params.title);
|
|
38
|
+
if (!title.ok) return reply(title.error.message, true);
|
|
39
|
+
const dir = join(ctx.cwd, ADR_DIR);
|
|
40
|
+
let template: string;
|
|
41
|
+
try {
|
|
42
|
+
template = await readFile(join(dir, TEMPLATE), "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
return reply(
|
|
45
|
+
`${ADR_DIR}/${TEMPLATE} is missing, so there is nothing to start from; add the template first.`,
|
|
46
|
+
true,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const number = nextAdrNumber(await readdir(dir));
|
|
51
|
+
const name = adrFileName(number, title.value);
|
|
52
|
+
const date = deps.now().toISOString().slice(0, 10);
|
|
53
|
+
await mkdir(dir, { recursive: true });
|
|
54
|
+
// `wx`: fail rather than overwrite an ADR with the same file name (calls from other sessions).
|
|
55
|
+
await writeFile(
|
|
56
|
+
join(dir, name),
|
|
57
|
+
renderAdr(template, { number, title: title.value, date }),
|
|
58
|
+
{
|
|
59
|
+
flag: "wx",
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
return reply(
|
|
63
|
+
`Created ${ADR_DIR}/${name} (status proposed). Fill in Context, Decision, Consequences, Alternatives and Revisit when. Leave the status proposed until the user agrees, then set it to accepted.`,
|
|
64
|
+
);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return reply(`could not create the ADR: ${message(e)}`, true);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { err, ok, type Result } from "../core/result.ts";
|
|
2
|
+
import { type ParseError, parseError } from "../core/types.ts";
|
|
3
|
+
|
|
4
|
+
/** `0004-prompt-cache-safe-channels.md` → 4. The template is `0000` and the README has no number. */
|
|
5
|
+
const NUMBERED = /^(\d{4})-.+\.md$/;
|
|
6
|
+
|
|
7
|
+
/** One more than the highest ADR number present; gaps are not filled, so a number is never reused. */
|
|
8
|
+
export function nextAdrNumber(fileNames: readonly string[]): number {
|
|
9
|
+
const numbers = fileNames.flatMap((name) => {
|
|
10
|
+
const digits = NUMBERED.exec(name)?.[1];
|
|
11
|
+
return digits === undefined ? [] : [Number.parseInt(digits, 10)];
|
|
12
|
+
});
|
|
13
|
+
return Math.max(0, ...numbers) + 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const slugify = (title: string): string =>
|
|
17
|
+
title
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-+|-+$/g, "");
|
|
21
|
+
|
|
22
|
+
/** A one-line title with something to slug from; a line break could add lines to the document. */
|
|
23
|
+
export function parseAdrTitle(input: string): Result<string, ParseError> {
|
|
24
|
+
const title = input.trim();
|
|
25
|
+
if (/[\r\n]/.test(title)) return err(parseError("an ADR title is a single line"));
|
|
26
|
+
if (slugify(title) === "") {
|
|
27
|
+
return err(parseError("an ADR title needs at least one letter or digit"));
|
|
28
|
+
}
|
|
29
|
+
return ok(title);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const adrNumberText = (n: number): string => String(n).padStart(4, "0");
|
|
33
|
+
|
|
34
|
+
export const adrFileName = (n: number, title: string): string =>
|
|
35
|
+
`${adrNumberText(n)}-${slugify(title)}.md`;
|
|
36
|
+
|
|
37
|
+
export type AdrInput = { number: number; title: string; date: string };
|
|
38
|
+
|
|
39
|
+
/** The template with its heading, status and date filled in; the sections are left for the author. */
|
|
40
|
+
export function renderAdr(template: string, input: AdrInput): string {
|
|
41
|
+
// Function replacers: a title is data, so `$&` and `$1` in it must not act as replacement patterns.
|
|
42
|
+
return template
|
|
43
|
+
.replace(/^# ADR NNNN: .*$/m, () => `# ADR ${adrNumberText(input.number)}: ${input.title}`)
|
|
44
|
+
.replace(/^- \*\*Status:\*\* .*$/m, () => "- **Status:** proposed")
|
|
45
|
+
.replace(/^- \*\*Date:\*\* .*$/m, () => `- **Date:** ${input.date}`);
|
|
46
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brief lint (plan I9.5): a brief states the outcome, the people and the risks; endpoints, tables and
|
|
3
|
+
* class names are solution detail that belongs in an ADR or the architecture. This is the
|
|
4
|
+
* deterministic half (regex markers); the Jev half lives in `src/jev/questions/solution-detail.ts`.
|
|
5
|
+
* Findings are warnings: a brief may legitimately quote a constraint that looks technical.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
type BriefFindingKind = "endpoint" | "table" | "class" | "path";
|
|
9
|
+
|
|
10
|
+
export type BriefFinding = {
|
|
11
|
+
kind: BriefFindingKind;
|
|
12
|
+
/** 1-based line in the brief. */
|
|
13
|
+
line: number;
|
|
14
|
+
/** The text that matched. */
|
|
15
|
+
match: string;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Rule = { kind: BriefFindingKind; pattern: RegExp; what: string };
|
|
20
|
+
|
|
21
|
+
const RULES: readonly Rule[] = [
|
|
22
|
+
{
|
|
23
|
+
kind: "endpoint",
|
|
24
|
+
pattern: /\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/[\w\-/{}:.]+/g,
|
|
25
|
+
what: "an HTTP endpoint",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
kind: "table",
|
|
29
|
+
pattern: /\bCREATE\s+TABLE\b|`\w+`\s+table\b|\btable\s+`\w+`/gi,
|
|
30
|
+
what: "a database table",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
kind: "class",
|
|
34
|
+
pattern:
|
|
35
|
+
/\b(?:(?:class|interface)\s+[A-Z]\w+|[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)*(?:Service|Repository|Controller|Gateway|Manager|Handler|Factory|Client))\b/g,
|
|
36
|
+
what: "a class or service name",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
kind: "path",
|
|
40
|
+
pattern: /\b(?:src|lib|test|app|packages)\/[\w\-./]+\.\w+/g,
|
|
41
|
+
what: "a source file path",
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const advice = (what: string): string =>
|
|
46
|
+
`${what} is solution detail; keep the brief to outcome, users and risks and record this in an ADR (devsys_adr_new) or the architecture notes`;
|
|
47
|
+
|
|
48
|
+
/** The brief with fenced code blocks blanked, keeping line numbers: an example in a fence is quoted, not specified. */
|
|
49
|
+
function withoutFences(text: string): string[] {
|
|
50
|
+
let inFence = false;
|
|
51
|
+
return text.split("\n").map((line) => {
|
|
52
|
+
if (/^\s*```/.test(line)) {
|
|
53
|
+
inFence = !inFence;
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
return inFence ? "" : line;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function lintBrief(text: string): BriefFinding[] {
|
|
61
|
+
return withoutFences(text).flatMap((line, i) =>
|
|
62
|
+
RULES.flatMap((rule) =>
|
|
63
|
+
[...line.matchAll(rule.pattern)].map((m) => ({
|
|
64
|
+
kind: rule.kind,
|
|
65
|
+
line: i + 1,
|
|
66
|
+
match: m[0],
|
|
67
|
+
message: advice(rule.what),
|
|
68
|
+
})),
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -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
|
+
}
|