@jwilger/pi-development-system 0.84.0 → 0.86.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 CHANGED
@@ -20,13 +20,26 @@ plain words: when a prompt asks for new work, a fix or a review, the system adds
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
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.
23
+ `/devsys-lens-review`, `/devsys-adr`, `/devsys-event-model`) are shortcuts to the same tools.
24
24
 
25
25
  Product planning has a skill (`product-planning`: brief, decision register, follow-ups,
26
26
  terminology, journeys). `devsys_lens_review` plans a review of the brief by five product lenses and
27
27
  writes the packets to `docs/product/reviews/`; `devsys_adr_new` creates the next numbered ADR, and
28
28
  a commit that shapes the architecture without one is stopped by the soft gate `adr.missing`.
29
29
 
30
+ Event modelling has a skill (`event-modelling`: three slice patterns, Given/When/Then, completeness).
31
+ `devsys_event_model_check` validates a directory of slice files (schema v1) and renders the swimlane
32
+ Markdown or a Mermaid diagram; each profile's `gwt-tests` reference turns scenarios into failing tests.
33
+ A dedicated extension that offers `event_model_validate`, or `event_model.provider` in
34
+ `.development-system.toml`, replaces the builtin tool (`docs/event-model-extension-contract.md`).
35
+
36
+ A slice has a life cycle: `implementing` → `reviewing` (when a review round starts) → `delivering`
37
+ (review satisfied) → `idle`. Editing production source while delivering reopens `implementing`,
38
+ and red-first stays on throughout. A push of a clean tree closes a delivered slice by itself (a commit
39
+ in `local-only` mode; CI is not awaited), and `devsys_finish_slice` closes it explicitly or, with a
40
+ reason, abandons it. A plan's increments each end in a push, so each increment is its own slice: the
41
+ next one starts with `devsys_intake`.
42
+
30
43
  With `codemode` enabled (`"defaultTools": ["+codemode"]` in pi settings), rarely used tools are
31
44
  reached through scripts and the `judge_*` Jev wrappers exist for scripts only; without codemode
32
45
  the rarely used tools are declared directly and the wrappers are absent.
@@ -29,7 +29,9 @@ import { createJevHolder } from "../src/jev/holder.ts";
29
29
  import { createJudgeTools } from "../src/jev/judge-tools.ts";
30
30
  import { createAdrNewTool } from "../src/planning/adr-tool.ts";
31
31
  import { createBeginWorkTool } from "../src/planning/begin-tool.ts";
32
+ import { registerEventModelProvider } from "../src/planning/event-model-provider.ts";
32
33
  import { createIntakeTool } from "../src/planning/intake-tool.ts";
34
+ import { createFinishSliceTool, registerSliceClose } from "../src/planning/slice-close.ts";
33
35
  import { createTaskCheckTool } from "../src/planning/task-check-tool.ts";
34
36
  import { createLensReviewTool } from "../src/review/lens-review-tool.ts";
35
37
  import { createReviewRecordTool, createReviewStartTool } from "../src/review/review-tools.ts";
@@ -158,6 +160,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
158
160
  },
159
161
  });
160
162
  registerRedFirstGuard({ pi, state });
163
+ registerSliceClose({ pi, state, exec });
161
164
  registerLintSuppressionGuard({ pi, state });
162
165
  pi.registerTool(createRecordDepartureTool({ pi, state }));
163
166
  pi.registerTool(createRequestApprovalTool({ pi, approvals }));
@@ -177,6 +180,8 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
177
180
  pi.registerTool(createPhaseTool({ state }));
178
181
  pi.registerTool(createBeginWorkTool({ state }));
179
182
  pi.registerTool(createAdrNewTool({ now: () => new Date() }));
183
+ registerEventModelProvider(pi);
184
+ pi.registerTool(createFinishSliceTool({ state, exec }));
180
185
  pi.registerTool(
181
186
  createLensReviewTool({ state, jev: (ctx) => jevHolder.forContext(ctx), now: () => new Date() }),
182
187
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.84.0",
3
+ "version": "0.86.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: Model a capability as event-model slices and validate them
3
+ argument-hint: "[directory of slice files, default docs/event-model]"
4
+ ---
5
+ Event-model the work with the event-modelling skill.
6
+
7
+ 1. If a tool named `event_model_validate` exists, an event-model extension is installed: use it for validation and rendering instead of the builtin check, and follow its own prompt.
8
+ 2. Otherwise write one slice file per command, view or automation under the directory ($ARGUMENTS, default `docs/event-model`), in the schema the skill describes.
9
+ 3. Call `devsys_event_model_check` with that directory. Fix every error it reports; `render` asks for the derived Markdown or Mermaid view. Do not edit the derived views by hand.
10
+ 4. If the validator wants a fact the model does not have, ask the user. Never invent a policy, field or event to make validation pass.
11
+ 5. When the model is clean, write one failing test per Given/When/Then scenario with the profile's `references/gwt-tests.md`.
@@ -9,4 +9,4 @@ Write the plan for the current work with the work-intake-and-slicing skill.
9
9
  3. Write each task so that someone who sees only that task could finish it. Never write `TBD`.
10
10
  4. Run `devsys_task_check` on every task record you wrote and fix each one until it says ready. Split any that come back too-big.
11
11
  5. Stop and ask the user to review the plan before any implementation starts. Do not start a goal yet: a goal tool may continue on its own and begin implementing before the review.
12
- 6. Only after the user approves the plan: call `devsys_begin_work` (so the review and red-first gates are on while the plan is built; it does nothing if the work is already implementing). Then, if a `create_goal` tool exists in this session, call it with the objective "complete docs/plan/<slug>.md honouring its Constraints and Progress checklist, one increment at a time, stopping after each increment's Release step". Otherwise tell the user the plan path so they can start a goal on it. Do not depend on any goal tool's file format.
12
+ 6. Only after the user approves the plan: call `devsys_begin_work` (so the review and red-first gates are on while the increment is built; it does nothing if the work is already implementing). A push of a clean, reviewed tree closes the slice, so each later increment starts with `devsys_intake`. Then, if a `create_goal` tool exists in this session, call it with the objective "complete docs/plan/<slug>.md honouring its Constraints and Progress checklist, one increment at a time, stopping after each increment's Release step". Otherwise tell the user the plan path so they can start a goal on it. Do not depend on any goal tool's file format.
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: event-modelling
3
+ description: Model a capability as event-model slices (command, events, views, Given/When/Then) before building it, validate them, and turn each scenario into a failing test. Use when a capability or product has state changes, views or automations to design, when asked for an event model, or before writing tests for a command-and-event feature.
4
+ ---
5
+
6
+ # Event modelling (lite)
7
+
8
+ An event model says what happens (events), what causes it (commands), and what people see (views), one thin
9
+ slice at a time. Here it is a set of small YAML or JSON files, one per slice, validated by
10
+ `devsys_event_model_check` and turned into tests. Keep it small: model what the work needs, not the whole
11
+ domain. If a repo has a dedicated event-model extension (a tool named `event_model_validate`), use that tool
12
+ instead.
13
+
14
+ ## The three slice patterns
15
+
16
+ | Pattern | Shape | Has |
17
+ |---|---|---|
18
+ | `state-change` | an actor issues a command and events record the result | `command`, `events`, `gwt` |
19
+ | `state-view` | a view is built from events | `views` (each with `sources`), `gwt` optional |
20
+ | `automation` | the system reacts to events with a command | `actor: system`, `command`, `events`, `gwt` (its `given` names the triggering events) |
21
+
22
+ A slice file:
23
+
24
+ ```yaml
25
+ id: slice.signin.c01
26
+ pattern: state-change
27
+ actor: user
28
+ command: { name: SignIn, fields: { email: string, password: secret } }
29
+ events:
30
+ - { name: SignedIn, fields: { userId: id, at: timestamp } }
31
+ gwt:
32
+ - given: [{ event: UserRegistered, let: { email: "a@b.c" } }]
33
+ when: { command: SignIn, let: { email: "a@b.c", password: "pw" } }
34
+ then: [{ event: SignedIn }]
35
+ ```
36
+
37
+ Views list `{ name, fields, sources: [event names] }`. Ids are unique and stable (`slice.<name>.<c|v|a><nn>`).
38
+
39
+ ## The seven steps
40
+
41
+ 1. **Brainstorm the events** in past tense (`UserRegistered`, `SignedIn`), in the order they happen.
42
+ 2. **Order them** into a story along time, and note which actor causes each.
43
+ 3. **Add the commands** that cause the events, with the fields the actor supplies.
44
+ 4. **Add the views** people read, naming the events each is built from.
45
+ 5. **Cut slices**: one file per command, view or automation, each independently buildable.
46
+ 6. **Write the scenarios**: Given (prior events), When (the one command), Then (events, an error, or a view).
47
+ 7. **Validate and fix**: run `devsys_event_model_check` and resolve every error.
48
+
49
+ Put the files under `docs/event-model/` (or the directory the user names). Ask for the derived swimlane
50
+ Markdown or a Mermaid diagram with `render`; never edit derived views by hand.
51
+
52
+ ## Given / When / Then
53
+
54
+ - `given`: events that already happened, with the values (`let`) that matter. Empty means no history.
55
+ - `when`: exactly one command, with its values. A `state-view` slice has none.
56
+ - `then`: at least one expectation: an `event`, an `error` (kebab-case id), or a `view`.
57
+
58
+ ## Completeness
59
+
60
+ The validator checks that information has an origin and a destination:
61
+
62
+ - every field of a view appears in the fields of one of its source events (`missing-origin`);
63
+ - every field a system-run command needs comes from a view or an earlier event (`missing-origin`);
64
+ - every command produces at least one event (`missing-destination`);
65
+ - every name that a scenario or view mentions exists in some slice (`unknown-ref`);
66
+ - ids and names are unique (`duplicate-id`); scenarios have a `when` and a non-empty `then`
67
+ (`gwt-without-when`, `gwt-then-empty`); a slice's fields match its pattern (`pattern-field-mismatch`);
68
+ - an event nothing reads is a warning (`orphan-event`): a view, a scenario or an automation should use it.
69
+
70
+ ## Never invent policy to satisfy the validator
71
+
72
+ When a check fails because the model lacks a fact (where does this field come from? who sends this
73
+ command?), that is a question for the user, not a gap to fill. Do not add a field, an event or a rule only so
74
+ validation passes. Ask one question, record the answer in the model, and continue.
75
+
76
+ ## From scenarios to tests
77
+
78
+ One failing test per scenario, with the gwt-tests reference of the active language profile
79
+ (`profile-typescript` or `profile-rust`). No spec in the model without an equivalent in code, and no
80
+ behaviour in code that the model does not cover. When they disagree, change the model first.
81
+
82
+ ## Skipping
83
+
84
+ Event modelling is a planning artifact for `capability` and `product` work. Skipping it is a recorded
85
+ departure: `devsys_record_departure` with gate `artifact.skipped:event-model` and why it does not
86
+ fit (for example, no state changes or views to design).
87
+
88
+ ## Checklist
89
+
90
+ - [ ] Every command, view and automation is its own slice file with a stable id
91
+ - [ ] `devsys_event_model_check` reports 0 errors; warnings read and answered
92
+ - [ ] No field, event or rule exists only to satisfy the validator
93
+ - [ ] Each scenario has a failing test, written before the code
@@ -13,6 +13,7 @@ Read the reference you need, not all of them:
13
13
  - Errors: `references/errors.md`
14
14
  - Lints: `references/lints.md`
15
15
  - Tests and mutation testing: `references/tests.md`
16
+ - Event-model scenarios as tests: `references/gwt-tests.md`
16
17
 
17
18
  ## Defaults
18
19
 
@@ -0,0 +1,14 @@
1
+ # GWT scenarios to failing tests (Rust)
2
+
3
+ An event-model slice lists Given/When/Then scenarios. Each is one test; no spec in the model without an
4
+ equivalent in code.
5
+
6
+ 1. Name the test after the slice and scenario: `fn signin_after_registration_emits_signed_in()`, in a module named for the slice.
7
+ 2. **Given** the listed events become the history slice passed to the decide function (`&[Event]`, with the `let` values filled in).
8
+ 3. **When** the command is the input: call the pure `decide(&history, command)`, not a handler with I/O.
9
+ 4. **Then** events assert the returned `Vec<Event>`, `error` asserts the returned error variant (`matches!`), and `view` asserts the projection folded from the events.
10
+ 5. Run it and watch it fail before writing `decide` or the projection (RED), then make it pass.
11
+ 6. A scenario with an empty `given` is a command against no history; keep it as its own test.
12
+
13
+ One failing `#[test]` per scenario. Do not loop over scenarios in one test, which hides which one failed.
14
+ When code and model disagree, change the model first (the model is the spec), then the test.
@@ -13,6 +13,7 @@ Read the reference you need:
13
13
  - Result pattern: `references/errors.md`
14
14
  - tsconfig and biome: `references/lints.md`
15
15
  - Test layout: `references/tests.md`
16
+ - Event-model scenarios as tests: `references/gwt-tests.md`
16
17
 
17
18
  ## Defaults
18
19
 
@@ -0,0 +1,15 @@
1
+ # GWT scenarios to failing tests (TypeScript)
2
+
3
+ An event-model slice lists Given/When/Then scenarios. Each is one test; no spec in the model without an
4
+ equivalent in code.
5
+
6
+ 1. Name the test after the slice and scenario: `test("slice.signin.c01: SignIn after UserRegistered emits SignedIn", ...)`.
7
+ 2. **Given** the listed events become the history the function receives (an array of event objects, with the `let` values filled in).
8
+ 3. **When** the command is the input: call the pure decide function (`decide(history, command)`), not a handler with I/O.
9
+ 4. **Then** events assert the returned events, `error` asserts the returned error `kind`, and `view` asserts the projection built from the events.
10
+ 5. Run it and watch it fail before writing the decide or project function (RED), then make it pass.
11
+ 6. A scenario with an empty `given` is a command against no history; keep it as its own test.
12
+
13
+ One failing test per scenario, one `test(...)` each, in `test/<area>/<slice>.test.ts`. Do not merge scenarios
14
+ into a table that hides which one failed. When code and model disagree, change the model first (the model is
15
+ the spec), then the test.
@@ -10,11 +10,11 @@ export function phaseGuide(phase: Phase): string {
10
10
  case "planning":
11
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
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.";
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 when the slice is complete and reviewed: a push of a clean tree closes it (devsys_finish_slice closes it explicitly).";
14
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.";
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 (red-first still applies), and repeat until the review is satisfied; then the slice moves to delivering.";
16
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.";
17
+ return "Delivering. The review is satisfied. Commit with a rationale, push, and release; a push of a clean tree closes the slice (devsys_finish_slice closes it explicitly, or abandons it with a reason). Editing source reopens implementing. A red trunk (CI) is repaired first.";
18
18
  default:
19
19
  return assertNever(phase);
20
20
  }
@@ -149,17 +149,23 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
149
149
  let evidence: ToolEvidence[] = [];
150
150
  let corrected = 0;
151
151
  let justCorrected = false;
152
+ let inFlightThisRun = false;
152
153
 
153
154
  deps.pi.on("session_start", () => {
154
155
  corrected = 0;
155
156
  justCorrected = false;
157
+ inFlightThisRun = false;
156
158
  evidence = [];
157
159
  });
158
160
  deps.pi.on("agent_start", () => {
161
+ // A push in this run may close the slice; its delivery report is still the claim to check.
162
+ inFlightThisRun = VERIFIED_PHASES.has(deps.state.get().phase);
159
163
  evidence = [];
160
164
  justCorrected = false;
161
165
  });
162
166
  deps.pi.on("tool_result", (event) => {
167
+ // The slice can start inside this run (intake, begin) and be closed by its own push; note it was open.
168
+ if (VERIFIED_PHASES.has(deps.state.get().phase)) inFlightThisRun = true;
163
169
  evidence = [...evidence, evidenceOf(event)].slice(-MAX_EVIDENCE);
164
170
  });
165
171
 
@@ -167,7 +173,7 @@ export function registerTurnVerifier(deps: TurnVerifierDeps): void {
167
173
  // An aborted or errored turn is dropped by pi; judging it would delay the abort and spend a correction.
168
174
  if (event.outcome !== "completed") return undefined;
169
175
  const state = deps.state.get();
170
- if (!VERIFIED_PHASES.has(state.phase)) return undefined;
176
+ if (!(VERIFIED_PHASES.has(state.phase) || inFlightThisRun)) return undefined;
171
177
  const parts = assistantParts(event.message);
172
178
  if (parts === undefined || parts.callsTools || parts.text.trim() === "") return undefined;
173
179
  if (justCorrected) {
@@ -0,0 +1,51 @@
1
+ import type { ReviewAction } from "./review.ts";
2
+ import type { DevsysState, SliceRef } from "./types.ts";
3
+
4
+ /**
5
+ * How a slice moves through the phases once it is implementing. Pure: the tools and guards that observe the
6
+ * events (a review round, an edit, a push) call these and write the result to state.
7
+ *
8
+ * implementing → reviewing a review round starts or is recorded without being satisfied
9
+ * reviewing → delivering the review is satisfied on the current diff
10
+ * delivering → reviewing a later round finds something again
11
+ * delivering → implementing production source is edited (the review no longer covers it)
12
+ * any open → idle the slice is delivered or abandoned (`closeSlice`)
13
+ */
14
+
15
+ const IN_FLIGHT = new Set<DevsysState["phase"]>(["implementing", "reviewing", "delivering"]);
16
+
17
+ /** The state after a review round was started or recorded; `action` is `nextAction` for the diff as it is now. */
18
+ export function afterReviewRound(
19
+ state: DevsysState,
20
+ action: ReviewAction,
21
+ slice: SliceRef | undefined = state.activeSlice,
22
+ ): DevsysState {
23
+ if (state.activeSlice === undefined || slice !== state.activeSlice) return state;
24
+ if (!IN_FLIGHT.has(state.phase)) return state;
25
+ return { ...state, phase: action === "done" ? "delivering" : "reviewing" };
26
+ }
27
+
28
+ /** Editing production source after review reopens the slice, so red-first applies to the edit again. */
29
+ export const afterSourceEdit = (state: DevsysState): DevsysState =>
30
+ state.phase === "delivering" ? { ...state, phase: "implementing" } : state;
31
+
32
+ /** Back to idle: the slice, its sizing and its slice-scoped departures are done. Review history is kept. */
33
+ export function closeSlice(state: DevsysState): DevsysState {
34
+ const { activeSlice, sizing: _sizing, ...rest } = state;
35
+ return {
36
+ ...rest,
37
+ phase: "idle",
38
+ openDepartures: state.openDepartures.filter(
39
+ (d) => d.scope.kind !== "slice" || d.scope.slice !== activeSlice,
40
+ ),
41
+ };
42
+ }
43
+
44
+ /** A recorded `review.unsatisfied` departure covers this slice, so no review is owed before it ships. */
45
+ export const reviewWaived = (state: DevsysState): boolean =>
46
+ state.openDepartures.some(
47
+ (d) =>
48
+ d.gate === "review.unsatisfied" &&
49
+ (d.scope.kind === "session" ||
50
+ (d.scope.kind === "slice" && d.scope.slice === state.activeSlice)),
51
+ );
@@ -194,7 +194,8 @@ async function reviewNeeds(
194
194
  command: string,
195
195
  ): Promise<Need[]> {
196
196
  const { phase, activeSlice } = deps.state.get();
197
- if ((phase !== "implementing" && phase !== "reviewing") || activeSlice === undefined) return [];
197
+ const inFlight = phase === "implementing" || phase === "reviewing" || phase === "delivering";
198
+ if (!inFlight || activeSlice === undefined) return [];
198
199
  const snap = await snapshotDiff(deps.exec, ctx.cwd, "HEAD");
199
200
  // An unreadable diff cannot be compared; the gate asks for a departure rather than guessing.
200
201
  const gap = reviewGap(
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { resolve } from "node:path";
4
4
  import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
5
+ import { afterSourceEdit } from "../core/lifecycle.ts";
5
6
  import { classifyPath } from "../core/path-class.ts";
6
7
  import { normalizeRepoPath } from "../core/test-paths.ts";
7
8
  import type { SessionState } from "../state/session-state.ts";
@@ -57,7 +58,7 @@ const JUDGED_EXEMPTIONS =
57
58
  "straightforward CI scripting, a simple dev-environment utility, or a behaviour-preserving refactor with green coverage";
58
59
 
59
60
  /**
60
- * Soft gate `tdd.red-first`: while implementing, production source is edited only after a failing
61
+ * Soft gate `tdd.red-first`: while a slice is in flight (implementing, reviewing, delivering), production source is edited only after a failing
61
62
  * test run has been observed. Test, docs, config and generated files are exempt by path; the
62
63
  * judged exemptions go through one recorded departure, which covers its whole slice.
63
64
  */
@@ -69,11 +70,13 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
69
70
  deps.pi.on("tool_call", (event, ctx) => {
70
71
  if (!(isToolCallEventType("edit", event) || isToolCallEventType("write", event)))
71
72
  return undefined;
72
- const { phase, lastTestRun, activeSlice } = deps.state.get();
73
- if (phase !== "implementing") return undefined;
74
- if (lastTestRun !== undefined && lastTestRun.exitCode !== 0) return undefined;
73
+ const { phase, activeSlice } = deps.state.get();
74
+ if (phase !== "implementing" && phase !== "reviewing" && phase !== "delivering")
75
+ return undefined;
75
76
  const path = normalizeRepoPath(ctx.cwd, event.input.path, homedir());
76
77
  if (classifyPath(path) !== "source") return undefined;
78
+ const { lastTestRun } = deps.state.get();
79
+ if (lastTestRun !== undefined && lastTestRun.exitCode !== 0) return undefined;
77
80
  if (touchesInlineTest(event.input, () => existing(ctx.cwd, path))) return undefined;
78
81
  if (departure.covers()) return undefined;
79
82
  const seen =
@@ -90,4 +93,17 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
90
93
  `departure covers the rest of the ${scope}.`,
91
94
  };
92
95
  });
96
+
97
+ // The review covered the code as it was: source that was really written puts the slice back to implementing.
98
+ deps.pi.on("tool_result", (event, ctx) => {
99
+ if (event.isError || (event.toolName !== "edit" && event.toolName !== "write"))
100
+ return undefined;
101
+ if (deps.state.get().phase !== "delivering") return undefined;
102
+ const given = (event.input as { path?: unknown }).path;
103
+ if (typeof given !== "string") return undefined;
104
+ if (classifyPath(normalizeRepoPath(ctx.cwd, given, homedir())) === "source") {
105
+ deps.state.update(afterSourceEdit);
106
+ }
107
+ return undefined;
108
+ });
93
109
  }
@@ -0,0 +1,36 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { loadConfig } from "../state/config.ts";
3
+ import { createEventModelCheckTool } from "./event-model-tool.ts";
4
+
5
+ /** The name of the builtin tool; a provider extension offers `event_model_validate` instead. */
6
+ export const EVENT_MODEL_TOOL = "devsys_event_model_check";
7
+ const PROVIDER_TOOL = "event_model_validate";
8
+
9
+ /**
10
+ * The builtin event-model-lite validator is for repos that have nothing better: it steps aside when the config
11
+ * names another provider, or when an extension registers `event_model_validate` (see
12
+ * docs/event-model-extension-contract.md).
13
+ */
14
+ export function usesBuiltinEventModel(provider: string, toolNames: readonly string[]): boolean {
15
+ return provider === "builtin" && !toolNames.includes(PROVIDER_TOOL);
16
+ }
17
+
18
+ /**
19
+ * Registers the builtin tool at session start, when every extension's tools are known and the repo's config
20
+ * can be read: neither is available while the extension loads.
21
+ */
22
+ export function registerEventModelProvider(pi: ExtensionAPI): void {
23
+ pi.on("session_start", async (_event, ctx) => {
24
+ const config = await loadConfig(ctx.cwd);
25
+ const provider = config.ok ? config.value.eventModel.provider : "builtin";
26
+ const names = pi.getAllTools().map((tool) => tool.name);
27
+ if (usesBuiltinEventModel(provider, names)) {
28
+ pi.registerTool(createEventModelCheckTool());
29
+ } else if (!names.includes(PROVIDER_TOOL)) {
30
+ ctx.ui.notify(
31
+ `event_model.provider is "${provider}" but no ${PROVIDER_TOOL} tool is loaded: install that extension, or set provider = "builtin" to use ${EVENT_MODEL_TOOL}.`,
32
+ "warning",
33
+ );
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,81 @@
1
+ import type { Slice } from "./slice-schema.ts";
2
+
3
+ /** Derived views of an event model: a swimlane table with scenarios, and a diagram. Never edited by hand. */
4
+
5
+ const values = (let_: Record<string, unknown> | undefined): string => {
6
+ const entries = Object.entries(let_ ?? {});
7
+ return entries.length === 0
8
+ ? ""
9
+ : ` (${entries.map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ")})`;
10
+ };
11
+
12
+ type Scenario = Slice["gwt"][number];
13
+
14
+ const scenarioLines = (g: Scenario): string[] => [
15
+ ...g.given.map((x) => `- Given ${x.event}${values(x.let)}`),
16
+ ...(g.when ? [`- When ${g.when.command}${values(g.when.let)}`] : []),
17
+ ...g.then.map((t) => {
18
+ if ("error" in t) return `- Then error ${t.error}`;
19
+ if ("view" in t) return `- Then view ${t.view}${values(t.let)}`;
20
+ return `- Then ${t.event}${values(t.let)}`;
21
+ }),
22
+ ];
23
+
24
+ const cell = (text: string): string => (text === "" ? "" : ` ${text} `);
25
+
26
+ const row = (s: Slice): string =>
27
+ `|${[
28
+ s.id,
29
+ s.pattern,
30
+ s.actor,
31
+ s.command?.name ?? "",
32
+ s.events.map((e) => e.name).join(", "),
33
+ s.views.map((v) => v.name).join(", "),
34
+ ]
35
+ .map(cell)
36
+ .join("|")}|`
37
+ .replace(/\|\|/g, "| |")
38
+ .replace(/\| \|\|/g, "| | |");
39
+
40
+ /** The swimlane table (one row per slice), then each slice's scenarios as Given/When/Then lists. */
41
+ export function renderModelMarkdown(slices: readonly Slice[]): string {
42
+ const table = [
43
+ "| Slice | Pattern | Actor | Command | Events | Views |",
44
+ "|---|---|---|---|---|---|",
45
+ ...slices.map(row),
46
+ ];
47
+ const sections = slices.flatMap((s) => [
48
+ `## ${s.id}`,
49
+ "",
50
+ ...s.gwt.flatMap((g, i) => [`Scenario ${i + 1}`, "", ...scenarioLines(g), ""]),
51
+ ]);
52
+ return `${[...table, "", ...sections].join("\n").trimEnd()}\n`;
53
+ }
54
+
55
+ /** Mermaid node ids hold letters, digits and underscores; the label carries the real name. */
56
+ const label = (name: string): string => `"${name.replace(/"/g, "#quot;")}"`;
57
+
58
+ /**
59
+ * Commands to the events they produce, and events to the views that read them. Ids are numbered per name
60
+ * (not derived from it), so names that differ only in punctuation or script are never merged into one node.
61
+ */
62
+ export function renderMermaid(slices: readonly Slice[]): string {
63
+ const ids = new Map<string, string>();
64
+ const node = (prefix: string, name: string): string => {
65
+ const key = `${prefix}:${name}`;
66
+ const id = ids.get(key) ?? `${prefix}${ids.size + 1}`;
67
+ ids.set(key, id);
68
+ return `${id}[${label(name)}]`;
69
+ };
70
+ const lines = ["flowchart LR"];
71
+ for (const s of slices) {
72
+ if (s.command) {
73
+ for (const e of s.events)
74
+ lines.push(` ${node("c", s.command.name)} --> ${node("e", e.name)}`);
75
+ }
76
+ for (const v of s.views) {
77
+ for (const source of v.sources) lines.push(` ${node("e", source)} --> ${node("v", v.name)}`);
78
+ }
79
+ }
80
+ return `${lines.join("\n")}\n`;
81
+ }
@@ -0,0 +1,92 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { type Static, Type } from "typebox";
5
+ import { renderMermaid, renderModelMarkdown } from "./event-model-render.ts";
6
+ import { parseSlice, type Slice, type SliceFormat, validateModel } from "./slice-schema.ts";
7
+
8
+ const Parameters = Type.Object({
9
+ dir: Type.String({
10
+ description: "Directory of slice files (.yaml, .yml, .json), inside the repository.",
11
+ }),
12
+ render: Type.Optional(
13
+ Type.Union([Type.Literal("markdown"), Type.Literal("mermaid")], {
14
+ description: "Also return the derived swimlane Markdown or Mermaid diagram.",
15
+ }),
16
+ ),
17
+ });
18
+
19
+ const reply = (text: string, isError = false) => ({
20
+ content: [{ type: "text" as const, text }],
21
+ details: undefined,
22
+ isError,
23
+ });
24
+
25
+ const FORMATS: Record<string, SliceFormat> = { ".yaml": "yaml", ".yml": "yaml", ".json": "json" };
26
+
27
+ const insideRepo = (cwd: string, target: string): boolean => {
28
+ const rel = relative(cwd, target);
29
+ return !(rel.startsWith("..") || isAbsolute(rel));
30
+ };
31
+
32
+ type Loaded = { slices: Slice[]; problems: string[]; files: number };
33
+
34
+ async function loadSlices(dir: string, names: readonly string[]): Promise<Loaded> {
35
+ const slices: Slice[] = [];
36
+ const problems: string[] = [];
37
+ for (const name of names) {
38
+ const format = FORMATS[extname(name)];
39
+ if (format === undefined) continue;
40
+ const parsed = parseSlice(await readFile(join(dir, name), "utf8"), format);
41
+ if (parsed.ok) slices.push(parsed.value);
42
+ else problems.push(`${name}: ${parsed.error.message}`);
43
+ }
44
+ return { slices, problems, files: slices.length + problems.length };
45
+ }
46
+
47
+ const derived = (
48
+ slices: readonly Slice[],
49
+ render: Static<typeof Parameters>["render"],
50
+ ): string[] => {
51
+ if (render === "markdown") return [renderModelMarkdown(slices)];
52
+ if (render === "mermaid") return [renderMermaid(slices)];
53
+ return [];
54
+ };
55
+
56
+ /**
57
+ * `devsys_event_model_check`: validates a directory of event-model-lite slices (schema v1) and can derive
58
+ * the Markdown or Mermaid view. A dedicated event-model extension replaces it (see
59
+ * docs/event-model-extension-contract.md).
60
+ */
61
+ export function createEventModelCheckTool(): ToolDefinition<typeof Parameters> {
62
+ return {
63
+ name: "devsys_event_model_check",
64
+ label: "Check event model",
65
+ description:
66
+ "Validate a directory of event-model slice files (schema v1: pattern, command, events, views, Given/When/Then) for unknown references, missing origins and destinations, empty scenarios and orphan events; optionally render the swimlane Markdown or a Mermaid diagram.",
67
+ promptSnippet: "Validate event-model slices and render the derived views",
68
+ parameters: Parameters,
69
+ async execute(_id, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
70
+ const dir = resolve(ctx.cwd, params.dir);
71
+ if (!insideRepo(ctx.cwd, dir)) return reply(`${params.dir} is outside the repository`, true);
72
+ let names: string[];
73
+ try {
74
+ names = (await readdir(dir)).sort();
75
+ } catch {
76
+ return reply(`cannot read ${params.dir}: no such directory`, true);
77
+ }
78
+ const { slices, problems, files } = await loadSlices(dir, names);
79
+ if (files === 0) return reply(`no slice files (.yaml, .yml, .json) in ${params.dir}`, true);
80
+ const issues = validateModel(slices);
81
+ const warnings = issues.filter((i) => i.severity === "warning").length;
82
+ const errors = issues.length - warnings + problems.length;
83
+ const lines = [
84
+ `${slices.length} slices, ${errors} errors, ${warnings} warnings`,
85
+ ...problems.map((p) => `- parse-error (error) ${p}`),
86
+ ...issues.map((i) => `- ${i.code} (${i.severity}) ${i.slice}: ${i.message}`),
87
+ ];
88
+ const report = lines.join("\n");
89
+ return reply([report, ...derived(slices, params.render)].join("\n\n"), errors > 0);
90
+ },
91
+ };
92
+ }
@@ -60,12 +60,11 @@ async function declinedToReplace(
60
60
  before: DevsysState,
61
61
  ): Promise<string | undefined> {
62
62
  const { activeSlice, phase } = before;
63
- if (activeSlice === undefined || (phase !== "implementing" && phase !== "reviewing")) {
64
- return undefined;
65
- }
63
+ const inFlight = phase === "implementing" || phase === "reviewing" || phase === "delivering";
64
+ if (activeSlice === undefined || !inFlight) return undefined;
66
65
  const go = await ctx.ui.confirm(
67
66
  "Slice already in flight",
68
- `Slice "${activeSlice}" is still ${phase}. Its uncommitted changes would be committed under the new slice's rules. Start new work anyway?`,
67
+ `Slice "${activeSlice}" is still ${phase}. Its uncommitted or unpushed changes would ship under the new slice's rules. Start new work anyway?`,
69
68
  );
70
69
  return go ? undefined : `Intake cancelled; slice "${activeSlice}" is unchanged.`;
71
70
  }
@@ -0,0 +1,195 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionContext,
4
+ isBashToolResult,
5
+ type ToolDefinition,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { type Static, Type } from "typebox";
8
+ import { extractCommits } from "../core/commit-command.ts";
9
+ import type { Exec } from "../core/exec.ts";
10
+ import { closeSlice, reviewWaived } from "../core/lifecycle.ts";
11
+ import { pushTargets } from "../core/push-command.ts";
12
+ import { isSatisfied } from "../core/review.ts";
13
+ import { reviewOf } from "../core/review-flow.ts";
14
+ import type { DevsysState } from "../core/types.ts";
15
+ import { loadConfig } from "../state/config.ts";
16
+ import type { SessionState } from "../state/session-state.ts";
17
+
18
+ const SLICE_ENTRY_TYPE = "devsys-slice";
19
+
20
+ export type SliceCloseDeps = { pi: ExtensionAPI; state: SessionState; exec: Exec };
21
+
22
+ const IN_FLIGHT = new Set<DevsysState["phase"]>(["implementing", "reviewing", "delivering"]);
23
+
24
+ /** True when `git status` shows nothing to commit; `undefined` when git could not say. */
25
+ async function treeIsClean(exec: Exec, cwd: string): Promise<boolean | undefined> {
26
+ const r = await exec("git", ["status", "--porcelain"], { cwd, timeout: 15_000 });
27
+ return r.code === 0 ? r.stdout.trim() === "" : undefined;
28
+ }
29
+
30
+ /** Commits on HEAD that no branch of `remote` has; works without an upstream, `undefined` when git could not say. */
31
+ async function unpushedCommits(
32
+ exec: Exec,
33
+ cwd: string,
34
+ remote: string,
35
+ ): Promise<number | undefined> {
36
+ const r = await exec("git", ["rev-list", "--count", "HEAD", "--not", `--remotes=${remote}`], {
37
+ cwd,
38
+ timeout: 15_000,
39
+ });
40
+ const n = Number(r.stdout.trim());
41
+ return r.code === 0 && Number.isInteger(n) ? n : undefined;
42
+ }
43
+
44
+ /** No review is owed before this slice ships: its review is satisfied, or a departure waives it. */
45
+ function reviewCleared(state: DevsysState): boolean {
46
+ if (state.activeSlice === undefined) return false;
47
+ const review = reviewOf(state, state.activeSlice);
48
+ return reviewWaived(state) || (review !== undefined && isSatisfied(review));
49
+ }
50
+
51
+ /** What delivered the slice: a commit when nothing leaves the machine, otherwise a push. */
52
+ const shipped = (mode: string, command: string): boolean =>
53
+ mode === "local-only" ? extractCommits(command).length > 0 : pushTargets(command).length > 0;
54
+
55
+ const closedMessage = (slice: string, how: string): string =>
56
+ `Slice ${slice} ${how}. Phase: idle; the next request starts new work with devsys_intake.`;
57
+
58
+ /**
59
+ * Closes the slice when it ships: in any in-flight phase whose review is satisfied (`delivering`, or a session
60
+ * saved before the life cycle existed) or waived by a recorded departure, a successful push (trunk, pull-request) or commit (local-only) that
61
+ * leaves a clean working tree. CI is not awaited; the push guard already refuses to build on a red trunk.
62
+ */
63
+ export function registerSliceClose(deps: SliceCloseDeps): void {
64
+ deps.pi.on("tool_result", async (event, ctx) => {
65
+ if (!isBashToolResult(event) || event.isError) return undefined;
66
+ const state = deps.state.get();
67
+ const { activeSlice, phase } = state;
68
+ if (activeSlice === undefined || !IN_FLIGHT.has(phase)) return undefined;
69
+ if (!reviewCleared(state)) return undefined;
70
+ const config = await loadConfig(ctx.cwd);
71
+ if (!(config.ok && shipped(config.value.delivery.mode, String(event.input.command)))) {
72
+ return undefined;
73
+ }
74
+ if ((await treeIsClean(deps.exec, ctx.cwd)) !== true) return undefined;
75
+ // A tags-only push, or a push of another branch, leaves this slice's commits where they were.
76
+ if (config.value.delivery.mode !== "local-only") {
77
+ if ((await unpushedCommits(deps.exec, ctx.cwd, config.value.delivery.remote)) !== 0)
78
+ return undefined;
79
+ }
80
+ // The checks above awaited git; close only the slice they were about.
81
+ if (deps.state.get().activeSlice !== activeSlice) return undefined;
82
+ deps.state.update(closeSlice);
83
+ deps.pi.sendMessage(
84
+ {
85
+ customType: SLICE_ENTRY_TYPE,
86
+ content: closedMessage(activeSlice, "was delivered"),
87
+ display: true,
88
+ },
89
+ { triggerTurn: false },
90
+ );
91
+ return undefined;
92
+ });
93
+ }
94
+
95
+ const Parameters = Type.Object({
96
+ abandon: Type.Optional(
97
+ Type.Boolean({
98
+ description:
99
+ "Close the slice without delivering it. Needs a reason; the working tree is left as it is.",
100
+ }),
101
+ ),
102
+ reason: Type.Optional(Type.String({ description: "Why the slice is abandoned (with abandon)." })),
103
+ });
104
+
105
+ const reply = (text: string, isError = false) => ({
106
+ content: [{ type: "text" as const, text }],
107
+ details: undefined,
108
+ isError,
109
+ });
110
+
111
+ /** Another slice became active while this one waited on the user or on git: leave it alone. */
112
+ const changedMeanwhile = (slice: string) =>
113
+ reply(`Slice ${slice} is no longer the active slice; nothing was closed.`, true);
114
+
115
+ /** Why a slice cannot be finished yet, or `undefined` when it is reviewed, clean and pushed. */
116
+ async function finishBlocker(deps: Pick<SliceCloseDeps, "state" | "exec">, cwd: string) {
117
+ const state = deps.state.get();
118
+ const { activeSlice } = state;
119
+ if (activeSlice === undefined) return undefined;
120
+ if (!reviewCleared(state)) {
121
+ return `slice ${activeSlice} has no satisfied review: finish the review rounds (devsys_review_start), or record a review.unsatisfied departure`;
122
+ }
123
+ const clean = await treeIsClean(deps.exec, cwd);
124
+ if (clean !== true) {
125
+ return clean === false
126
+ ? "there are uncommitted changes: commit them first"
127
+ : "git could not report the working tree status";
128
+ }
129
+ const config = await loadConfig(cwd);
130
+ if (!config.ok) return `cannot read the delivery mode: ${config.error.message}`;
131
+ if (config.value.delivery.mode === "local-only") return undefined;
132
+ const ahead = await unpushedCommits(deps.exec, cwd, config.value.delivery.remote);
133
+ if (ahead === undefined) {
134
+ return "git could not say whether the commits are pushed";
135
+ }
136
+ return ahead > 0 ? `${ahead} commit(s) are not pushed yet: push first` : undefined;
137
+ }
138
+
139
+ /**
140
+ * `devsys_finish_slice`: the explicit way to close a slice. The push guard closes a delivered slice by itself;
141
+ * this covers what it cannot see (a push made outside pi, a slice that is dropped) and checks the same facts.
142
+ */
143
+ export function createFinishSliceTool(
144
+ deps: Pick<SliceCloseDeps, "state" | "exec">,
145
+ ): ToolDefinition<typeof Parameters> {
146
+ return {
147
+ name: "devsys_finish_slice",
148
+ label: "Finish slice",
149
+ description:
150
+ "Close the active slice and return to idle. Checks that its review is satisfied (or waived by a recorded departure), the tree is clean and the commits are pushed. With abandon and a reason it drops the slice without delivering it, after the user confirms.",
151
+ promptSnippet: "Close the active slice once it is delivered, or abandon it",
152
+ parameters: Parameters,
153
+ exposure: "model-only",
154
+ async execute(
155
+ _id,
156
+ params: Static<typeof Parameters>,
157
+ _signal,
158
+ _onUpdate,
159
+ ctx: ExtensionContext,
160
+ ) {
161
+ const { activeSlice } = deps.state.get();
162
+ if (activeSlice === undefined) {
163
+ return reply("There is no active slice to finish. Start work with devsys_intake.", true);
164
+ }
165
+ if (params.abandon === true) {
166
+ const reason = params.reason?.trim() ?? "";
167
+ if (reason === "") return reply("abandoning a slice needs a reason: pass `reason`.", true);
168
+ // Idle switches the review and red-first gates off, so only the user may drop an unfinished slice.
169
+ if (!ctx.hasUI) {
170
+ return reply(
171
+ `Abandoning slice ${activeSlice} needs the user's confirmation and there is no interactive session; ask the user to run it.`,
172
+ true,
173
+ );
174
+ }
175
+ const go = await ctx.ui.confirm(
176
+ "Abandon slice?",
177
+ `Slice "${activeSlice}" would be closed without being delivered (${reason}). Its review and red-first gates stop applying; the working tree is left as it is.`,
178
+ );
179
+ if (!go) return reply(`Abandon declined; slice ${activeSlice} is unchanged.`, true);
180
+ if (deps.state.get().activeSlice !== activeSlice) return changedMeanwhile(activeSlice);
181
+ deps.state.update(closeSlice);
182
+ return reply(
183
+ `${closedMessage(activeSlice, `was abandoned (${reason})`)} The working tree was not touched.`,
184
+ );
185
+ }
186
+ const blocker = await finishBlocker(deps, ctx.cwd);
187
+ if (blocker !== undefined) {
188
+ return reply(`Slice ${activeSlice} is not finished: ${blocker}.`, true);
189
+ }
190
+ if (deps.state.get().activeSlice !== activeSlice) return changedMeanwhile(activeSlice);
191
+ deps.state.update(closeSlice);
192
+ return reply(closedMessage(activeSlice, "is finished"));
193
+ },
194
+ };
195
+ }
@@ -0,0 +1,312 @@
1
+ import { type Static, Type } from "typebox";
2
+ import Value from "typebox/value";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { err, ok, type Result } from "../core/result.ts";
5
+ import { type ParseError, parseError } from "../core/types.ts";
6
+
7
+ /**
8
+ * Event-model lite, slice schema v1 (plan Appendix D). One slice is one command-to-events step, one
9
+ * view over events, or one automation; Given/When/Then scenarios say what it must do. The schema is
10
+ * deliberately small: a validator that needs more than this to pass is asking for invented policy.
11
+ */
12
+
13
+ const Fields = Type.Record(Type.String(), Type.String());
14
+ const Values = Type.Record(Type.String(), Type.Unknown());
15
+ const Name = Type.String({ minLength: 1 });
16
+
17
+ const SlicePattern = Type.Union([
18
+ Type.Literal("state-change"),
19
+ Type.Literal("state-view"),
20
+ Type.Literal("automation"),
21
+ ]);
22
+
23
+ const EventDecl = Type.Object({ name: Name, fields: Fields }, { additionalProperties: false });
24
+ const ViewDecl = Type.Object(
25
+ { name: Name, fields: Fields, sources: Type.Array(Name) },
26
+ { additionalProperties: false },
27
+ );
28
+ const Given = Type.Object(
29
+ { event: Name, let: Type.Optional(Values) },
30
+ { additionalProperties: false },
31
+ );
32
+ const When = Type.Object(
33
+ { command: Name, let: Type.Optional(Values) },
34
+ { additionalProperties: false },
35
+ );
36
+ const Then = Type.Union([
37
+ Type.Object({ event: Name, let: Type.Optional(Values) }, { additionalProperties: false }),
38
+ Type.Object({ error: Name }, { additionalProperties: false }),
39
+ Type.Object({ view: Name, let: Type.Optional(Values) }, { additionalProperties: false }),
40
+ ]);
41
+ const Scenario = Type.Object(
42
+ // biome-ignore lint/suspicious/noThenProperty: "then" is the Given/When/Then key the slice schema (plan Appendix D) defines; these objects are never awaited.
43
+ { given: Type.Array(Given), when: Type.Optional(When), then: Type.Array(Then) },
44
+ { additionalProperties: false },
45
+ );
46
+
47
+ const SliceSchema = Type.Object(
48
+ {
49
+ id: Name,
50
+ pattern: SlicePattern,
51
+ actor: Name,
52
+ command: Type.Optional(
53
+ Type.Object({ name: Name, fields: Fields }, { additionalProperties: false }),
54
+ ),
55
+ events: Type.Optional(Type.Array(EventDecl)),
56
+ views: Type.Optional(Type.Array(ViewDecl)),
57
+ gwt: Type.Optional(Type.Array(Scenario)),
58
+ },
59
+ { additionalProperties: false },
60
+ );
61
+
62
+ type Raw = Static<typeof SliceSchema>;
63
+
64
+ /** A parsed slice: the optional lists are present, empty when the file left them out. */
65
+ export type Slice = Omit<Raw, "events" | "views" | "gwt"> & {
66
+ events: Static<typeof EventDecl>[];
67
+ views: Static<typeof ViewDecl>[];
68
+ gwt: Static<typeof Scenario>[];
69
+ };
70
+
71
+ export type SliceFormat = "json" | "yaml";
72
+
73
+ /** One slice from JSON or YAML text; a schema violation names the path and the rule it broke. */
74
+ export function parseSlice(text: string, format: SliceFormat): Result<Slice, ParseError> {
75
+ let data: unknown;
76
+ try {
77
+ data = format === "json" ? JSON.parse(text) : parseYaml(text);
78
+ } catch (e) {
79
+ const reason = e instanceof Error ? e.message : String(e);
80
+ // The yaml library appends a code frame; the first line is the reason, and a report is one line per issue.
81
+ return err(parseError(`not valid ${format}: ${reason.split("\n")[0]}`));
82
+ }
83
+ const first = Value.Errors(SliceSchema, data)[0];
84
+ if (first !== undefined) {
85
+ return err(
86
+ parseError(`${first.instancePath === "" ? "/" : first.instancePath}: ${first.message}`),
87
+ );
88
+ }
89
+ const raw = data as Raw;
90
+ return ok({ ...raw, events: raw.events ?? [], views: raw.views ?? [], gwt: raw.gwt ?? [] });
91
+ }
92
+
93
+ type ValidationCode =
94
+ | "duplicate-id"
95
+ | "unknown-ref"
96
+ | "missing-origin"
97
+ | "missing-destination"
98
+ | "gwt-without-when"
99
+ | "gwt-then-empty"
100
+ | "orphan-event"
101
+ | "pattern-field-mismatch";
102
+
103
+ export type ValidationIssue = {
104
+ code: ValidationCode;
105
+ /** An orphan event may be a deliberate end of the line; everything else is a hole in the model. */
106
+ severity: "error" | "warning";
107
+ slice: string;
108
+ message: string;
109
+ };
110
+
111
+ const issue = (
112
+ code: ValidationCode,
113
+ slice: string,
114
+ message: string,
115
+ severity: ValidationIssue["severity"] = "error",
116
+ ): ValidationIssue => ({ code, severity, slice, message });
117
+
118
+ function duplicates(slices: readonly Slice[]): ValidationIssue[] {
119
+ const seen = {
120
+ id: new Set<string>(),
121
+ command: new Set<string>(),
122
+ event: new Set<string>(),
123
+ view: new Set<string>(),
124
+ };
125
+ const out: ValidationIssue[] = [];
126
+ const claim = (kind: keyof typeof seen, name: string, slice: string): void => {
127
+ if (seen[kind].has(name)) {
128
+ out.push(issue("duplicate-id", slice, `the ${kind} "${name}" is declared more than once`));
129
+ }
130
+ seen[kind].add(name);
131
+ };
132
+ for (const s of slices) {
133
+ claim("id", s.id, s.id);
134
+ if (s.command) claim("command", s.command.name, s.id);
135
+ for (const e of s.events) claim("event", e.name, s.id);
136
+ for (const v of s.views) claim("view", v.name, s.id);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ type Model = {
142
+ events: Map<string, Record<string, string>>;
143
+ views: Map<string, Record<string, string>>;
144
+ usedEvents: Set<string>;
145
+ };
146
+
147
+ const indexModel = (slices: readonly Slice[]): Model => {
148
+ const events = new Map<string, Record<string, string>>();
149
+ const views = new Map<string, Record<string, string>>();
150
+ const usedEvents = new Set<string>();
151
+ for (const s of slices) {
152
+ for (const e of s.events) events.set(e.name, e.fields);
153
+ for (const v of s.views) {
154
+ views.set(v.name, v.fields);
155
+ for (const source of v.sources) usedEvents.add(source);
156
+ }
157
+ for (const g of s.gwt) for (const given of g.given) usedEvents.add(given.event);
158
+ }
159
+ return { events, views, usedEvents };
160
+ };
161
+
162
+ function shape(s: Slice): ValidationIssue[] {
163
+ const problems: string[] = [];
164
+ if (s.pattern === "state-view") {
165
+ if (s.views.length === 0) problems.push("a state-view needs at least one view");
166
+ if (s.command) problems.push("a state-view has no command");
167
+ if (s.events.length > 0) problems.push("a state-view declares no events; its sources do");
168
+ } else {
169
+ if (!s.command) problems.push(`a ${s.pattern} needs a command`);
170
+ if (s.views.length > 0) problems.push(`a ${s.pattern} declares no views`);
171
+ if (s.pattern === "automation" && s.actor !== "system") {
172
+ problems.push('an automation is run by the actor "system"');
173
+ }
174
+ }
175
+ return problems.map((p) => issue("pattern-field-mismatch", s.id, p));
176
+ }
177
+
178
+ function commandOrigins(s: Slice, model: Model): ValidationIssue[] {
179
+ if (!s.command || s.actor !== "system") return [];
180
+ const known = new Set<string>();
181
+ for (const fields of model.views.values()) for (const f of Object.keys(fields)) known.add(f);
182
+ for (const g of s.gwt) {
183
+ for (const given of g.given) {
184
+ for (const f of Object.keys(model.events.get(given.event) ?? {})) known.add(f);
185
+ }
186
+ }
187
+ return Object.keys(s.command.fields)
188
+ .filter((f) => !known.has(f))
189
+ .map((f) =>
190
+ issue("missing-origin", s.id, `command field "${f}" comes from no view or triggering event`),
191
+ );
192
+ }
193
+
194
+ function viewIssues(s: Slice, model: Model): ValidationIssue[] {
195
+ const out: ValidationIssue[] = [];
196
+ for (const view of s.views) {
197
+ const carried = new Set<string>();
198
+ for (const source of view.sources) {
199
+ const fields = model.events.get(source);
200
+ if (fields === undefined) {
201
+ out.push(
202
+ issue(
203
+ "unknown-ref",
204
+ s.id,
205
+ `view "${view.name}" reads event "${source}", which no slice declares`,
206
+ ),
207
+ );
208
+ } else {
209
+ for (const f of Object.keys(fields)) carried.add(f);
210
+ }
211
+ }
212
+ for (const f of Object.keys(view.fields)) {
213
+ if (!carried.has(f)) {
214
+ out.push(
215
+ issue(
216
+ "missing-origin",
217
+ s.id,
218
+ `view field "${f}" of "${view.name}" is carried by none of its source events`,
219
+ ),
220
+ );
221
+ }
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+
227
+ function whenIssues(s: Slice, g: Slice["gwt"][number], where: string): ValidationIssue[] {
228
+ if (s.pattern === "state-view") {
229
+ return g.when === undefined
230
+ ? []
231
+ : [issue("pattern-field-mismatch", s.id, `${where} of a state-view has no when`)];
232
+ }
233
+ if (g.when === undefined) return [issue("gwt-without-when", s.id, `${where} has no when`)];
234
+ if (g.when.command === s.command?.name) return [];
235
+ const own = s.command?.name ?? "command";
236
+ return [
237
+ issue(
238
+ "unknown-ref",
239
+ s.id,
240
+ `${where} runs command "${g.when.command}", not this slice's ${own}`,
241
+ ),
242
+ ];
243
+ }
244
+
245
+ function thenIssues(
246
+ s: Slice,
247
+ g: Slice["gwt"][number],
248
+ where: string,
249
+ model: Model,
250
+ ): ValidationIssue[] {
251
+ if (g.then.length === 0) return [issue("gwt-then-empty", s.id, `${where} expects nothing`)];
252
+ return g.then.flatMap((t) => {
253
+ if ("event" in t && !model.events.has(t.event)) {
254
+ return [
255
+ issue("unknown-ref", s.id, `${where} expects event "${t.event}", which no slice declares`),
256
+ ];
257
+ }
258
+ if ("view" in t && !model.views.has(t.view)) {
259
+ return [
260
+ issue("unknown-ref", s.id, `${where} expects view "${t.view}", which no slice declares`),
261
+ ];
262
+ }
263
+ return [];
264
+ });
265
+ }
266
+
267
+ function scenarioIssues(s: Slice, model: Model): ValidationIssue[] {
268
+ return s.gwt.flatMap((g, i) => {
269
+ const where = `scenario ${i + 1}`;
270
+ const given = g.given
271
+ .filter((x) => !model.events.has(x.event))
272
+ .map((x) =>
273
+ issue("unknown-ref", s.id, `${where} gives event "${x.event}", which no slice declares`),
274
+ );
275
+ return [...whenIssues(s, g, where), ...given, ...thenIssues(s, g, where, model)];
276
+ });
277
+ }
278
+
279
+ function ownIssues(s: Slice, model: Model): ValidationIssue[] {
280
+ const missingDestination =
281
+ s.pattern !== "state-view" && s.command && s.events.length === 0
282
+ ? [issue("missing-destination", s.id, `command "${s.command.name}" produces no events`)]
283
+ : [];
284
+ const orphans = s.events
285
+ .filter((e) => !model.usedEvents.has(e.name))
286
+ .map((e) =>
287
+ issue(
288
+ "orphan-event",
289
+ s.id,
290
+ `event "${e.name}" feeds no view and starts no scenario`,
291
+ "warning",
292
+ ),
293
+ );
294
+ return [
295
+ ...shape(s),
296
+ ...missingDestination,
297
+ ...commandOrigins(s, model),
298
+ ...viewIssues(s, model),
299
+ ...scenarioIssues(s, model),
300
+ ...orphans,
301
+ ];
302
+ }
303
+
304
+ /**
305
+ * Checks a whole model: names are unique, every reference resolves, and information is complete (every
306
+ * view field has an event origin, every system command field a source). Judges nothing the model does
307
+ * not say: a hole is reported, never filled.
308
+ */
309
+ export function validateModel(slices: readonly Slice[]): ValidationIssue[] {
310
+ const model = indexModel(slices);
311
+ return [...duplicates(slices), ...slices.flatMap((s) => ownIssues(s, model))];
312
+ }
@@ -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