@jwilger/pi-development-system 0.85.0 → 0.87.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 +11 -1
- package/extensions/development-system.ts +2 -0
- package/package.json +1 -1
- package/prompts/devsys-event-model.md +11 -0
- package/prompts/devsys-plan.md +1 -1
- package/prompts/devsys-start.md +2 -2
- package/skills/event-modelling/SKILL.md +93 -0
- package/skills/profile-rust/SKILL.md +1 -0
- package/skills/profile-rust/references/gwt-tests.md +14 -0
- package/skills/profile-typescript/SKILL.md +1 -0
- package/skills/profile-typescript/references/gwt-tests.md +15 -0
- package/skills/work-intake-and-slicing/SKILL.md +1 -1
- package/src/context/status.ts +2 -3
- package/src/core/review-flow.ts +2 -1
- package/src/planning/event-model-provider.ts +36 -0
- package/src/planning/event-model-render.ts +81 -0
- package/src/planning/event-model-tool.ts +92 -0
- package/src/planning/intake-tool.ts +72 -25
- package/src/planning/intake.ts +33 -0
- package/src/planning/slice-schema.ts +312 -0
package/README.md
CHANGED
|
@@ -20,13 +20,19 @@ 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
|
+
|
|
30
36
|
A slice has a life cycle: `implementing` → `reviewing` (when a review round starts) → `delivering`
|
|
31
37
|
(review satisfied) → `idle`. Editing production source while delivering reopens `implementing`,
|
|
32
38
|
and red-first stays on throughout. A push of a clean tree closes a delivered slice by itself (a commit
|
|
@@ -34,6 +40,10 @@ in `local-only` mode; CI is not awaited), and `devsys_finish_slice` closes it ex
|
|
|
34
40
|
reason, abandons it. A plan's increments each end in a push, so each increment is its own slice: the
|
|
35
41
|
next one starts with `devsys_intake`.
|
|
36
42
|
|
|
43
|
+
`devsys_intake` does not interrupt autonomous work: Jev's size is used when Jev is at least 50%
|
|
44
|
+
confident, and the user is asked to pick only when it is less sure (or unavailable). A size decided up
|
|
45
|
+
front, such as in a goal's planning, is passed as `size` and skips the sizing question entirely (a `fix` still asks whether to waive review).
|
|
46
|
+
|
|
37
47
|
With `codemode` enabled (`"defaultTools": ["+codemode"]` in pi settings), rarely used tools are
|
|
38
48
|
reached through scripts and the `judge_*` Jev wrappers exist for scripts only; without codemode
|
|
39
49
|
the rarely used tools are declared directly and the wrappers are absent.
|
|
@@ -29,6 +29,7 @@ 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";
|
|
33
34
|
import { createFinishSliceTool, registerSliceClose } from "../src/planning/slice-close.ts";
|
|
34
35
|
import { createTaskCheckTool } from "../src/planning/task-check-tool.ts";
|
|
@@ -179,6 +180,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
179
180
|
pi.registerTool(createPhaseTool({ state }));
|
|
180
181
|
pi.registerTool(createBeginWorkTool({ state }));
|
|
181
182
|
pi.registerTool(createAdrNewTool({ now: () => new Date() }));
|
|
183
|
+
registerEventModelProvider(pi);
|
|
182
184
|
pi.registerTool(createFinishSliceTool({ state, exec }));
|
|
183
185
|
pi.registerTool(
|
|
184
186
|
createLensReviewTool({ state, jev: (ctx) => jevHolder.forContext(ctx), now: () => new Date() }),
|
package/package.json
CHANGED
|
@@ -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`.
|
package/prompts/devsys-plan.md
CHANGED
|
@@ -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 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.
|
|
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 (name each increment's size in it, so every later `devsys_intake` passes `size` and nobody is asked to size it; a `fix` still asks the user whether to waive review) "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.
|
package/prompts/devsys-start.md
CHANGED
|
@@ -5,7 +5,7 @@ argument-hint: "<what you want done>"
|
|
|
5
5
|
Start new work with the work-intake-and-slicing skill.
|
|
6
6
|
|
|
7
7
|
1. Call `devsys_intake` with `request` set to: $ARGUMENTS (if empty, ask the user what they want done first).
|
|
8
|
-
2. Show the user the
|
|
8
|
+
2. Show the user the size and artifacts. Jev sizes the work and intake proceeds without asking when it is at least 50% confident; only a less confident Jev makes the tool ask the user to pick. If the size is already decided (say, in a goal's or plan's up-front planning), pass it as `size` and nobody is asked to size it (a `fix` still asks whether to waive review).
|
|
9
9
|
3. Produce the planning artifacts first (brief, decision register, journeys, event model, architecture, lens review, in that order, whichever are recommended), then the task record. `review` and `adr-if-needed` are not written up front: review happens on the finished slice, and an ADR when a decision needs one. To skip one, call `devsys_record_departure` with gate `artifact.skipped:<artifact>` and the reason; do not skip silently. The event model and architecture are recommendations, never gates.
|
|
10
10
|
4. For `capability` and `product` the phase stays `planning` while you produce the artifacts and the plan. The single point where it changes is the user approving the whole plan: then call `devsys_begin_work` so the review and red-first gates switch on (it does nothing if already implementing).
|
|
11
|
-
5. For a
|
|
11
|
+
5. For a `fix` the user is also asked (when a user is present) whether to skip fresh-context review (it is logged if they say yes; if no, the slice needs its review rounds). For `fix` and `change` the phase is already `implementing`: write the task record, then work it test-first.
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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.
|
|
@@ -16,7 +16,7 @@ Ask these in order and stop at the first yes:
|
|
|
16
16
|
3. Does it change existing behaviour in one area? That is a `change`.
|
|
17
17
|
4. Otherwise it is a `fix`.
|
|
18
18
|
|
|
19
|
-
`/devsys-start`
|
|
19
|
+
`/devsys-start` sizes the work with Jev and proceeds when Jev is at least 50% confident, saying so; below that it asks you to pick. If the size is already decided (a goal's planning, a plan's increment), pass it as `size` and nobody is asked to size it (a `fix` still asks whether to waive review). If you disagree with a size, change it; it is a judgement, not a rule.
|
|
20
20
|
|
|
21
21
|
## Artifacts follow size
|
|
22
22
|
|
package/src/context/status.ts
CHANGED
|
@@ -14,9 +14,8 @@ const ciLabel = (state: DevsysState): string | undefined => {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
const activeReview = (state: DevsysState): string | undefined => {
|
|
17
|
-
//
|
|
18
|
-
const review =
|
|
19
|
-
state.activeSlice === undefined ? state.reviews?.at(-1) : reviewOf(state, state.activeSlice);
|
|
17
|
+
// No active slice, no review: a closed slice's last count would only mislead the next one.
|
|
18
|
+
const review = state.activeSlice === undefined ? undefined : reviewOf(state, state.activeSlice);
|
|
20
19
|
return review === undefined ? undefined : reviewLabel(review);
|
|
21
20
|
};
|
|
22
21
|
|
package/src/core/review-flow.ts
CHANGED
|
@@ -19,8 +19,9 @@ export const upsertReview = (state: DevsysState, review: ReviewState): DevsysSta
|
|
|
19
19
|
return { ...state, reviews: [...others, review] };
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
/** Capped at the requirement: extra clean rounds (after a late change) are not progress past "done". */
|
|
22
23
|
export const reviewLabel = (review: ReviewState): string =>
|
|
23
|
-
`review: ${cleanStreak(review)}/${review.required} clean`;
|
|
24
|
+
`review: ${Math.min(cleanStreak(review), review.required)}/${review.required} clean`;
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Why a commit on this slice lacks a satisfied review, or `undefined` when the review is complete
|
|
@@ -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
|
+
}
|
|
@@ -13,13 +13,27 @@ import type { Jev } from "../jev/client.ts";
|
|
|
13
13
|
import { judgeSizing } from "../jev/questions/sizing.ts";
|
|
14
14
|
import { appendDecision } from "../state/decision-log.ts";
|
|
15
15
|
import type { SessionState } from "../state/session-state.ts";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
decideSize,
|
|
18
|
+
phaseFor,
|
|
19
|
+
proposeArtifacts,
|
|
20
|
+
renderProposal,
|
|
21
|
+
type SizeDecision,
|
|
22
|
+
sliceSlug,
|
|
23
|
+
uniqueSlice,
|
|
24
|
+
} from "./intake.ts";
|
|
17
25
|
|
|
18
26
|
const Parameters = Type.Object({
|
|
19
27
|
request: Type.String({ description: "What the user asked for, in their words." }),
|
|
20
28
|
repoSummary: Type.Optional(
|
|
21
29
|
Type.String({ description: "One or two sentences on the repository, if known." }),
|
|
22
30
|
),
|
|
31
|
+
size: Type.Optional(
|
|
32
|
+
Type.String({
|
|
33
|
+
description:
|
|
34
|
+
"fix, change, capability or product, when the size is already decided (for example during a goal's up-front planning). Skips Jev's sizing question and the user's.",
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
23
37
|
});
|
|
24
38
|
|
|
25
39
|
const SIZES: readonly Sizing[] = ["fix", "change", "capability", "product"];
|
|
@@ -62,6 +76,9 @@ async function declinedToReplace(
|
|
|
62
76
|
const { activeSlice, phase } = before;
|
|
63
77
|
const inFlight = phase === "implementing" || phase === "reviewing" || phase === "delivering";
|
|
64
78
|
if (activeSlice === undefined || !inFlight) return undefined;
|
|
79
|
+
if (!ctx.hasUI) {
|
|
80
|
+
return `Slice "${activeSlice}" is still ${phase}; replacing it needs the user's yes, and there is no UI to ask. Intake cancelled; slice "${activeSlice}" is unchanged.`;
|
|
81
|
+
}
|
|
65
82
|
const go = await ctx.ui.confirm(
|
|
66
83
|
"Slice already in flight",
|
|
67
84
|
`Slice "${activeSlice}" is still ${phase}. Its uncommitted or unpushed changes would ship under the new slice's rules. Start new work anyway?`,
|
|
@@ -69,6 +86,28 @@ async function declinedToReplace(
|
|
|
69
86
|
return go ? undefined : `Intake cancelled; slice "${activeSlice}" is unchanged.`;
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
type Chosen = { readonly sizing: Sizing } | { readonly stop: string; readonly isError: boolean };
|
|
90
|
+
|
|
91
|
+
/** A decided size passes through; an undecided one asks the user (headless: stops with the proposal). */
|
|
92
|
+
async function chooseSize(
|
|
93
|
+
ctx: ExtensionContext,
|
|
94
|
+
decision: SizeDecision,
|
|
95
|
+
proposalFor: (size: Sizing) => ReturnType<typeof proposeArtifacts>,
|
|
96
|
+
): Promise<Chosen> {
|
|
97
|
+
if (decision.kind === "decided") return { sizing: decision.size };
|
|
98
|
+
const { basis, proposed } = decision;
|
|
99
|
+
const proposal = renderProposal({ sizing: proposed, basis, proposal: proposalFor(proposed) });
|
|
100
|
+
if (!ctx.hasUI) {
|
|
101
|
+
return { stop: `${proposal}\nHeadless: proposal only, not applied.`, isError: false };
|
|
102
|
+
}
|
|
103
|
+
const picked = await ctx.ui.select(`Size this work (${basis})`, sizeChoices(proposed));
|
|
104
|
+
if (picked === undefined) {
|
|
105
|
+
return { stop: `${proposal}\nIntake cancelled; nothing changed.`, isError: false };
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseSizing(picked);
|
|
108
|
+
return isParseError(parsed) ? { stop: parsed.message, isError: true } : { sizing: parsed };
|
|
109
|
+
}
|
|
110
|
+
|
|
72
111
|
const waiverNote = (sizing: Sizing, departed: string | undefined): string => {
|
|
73
112
|
if (departed !== undefined)
|
|
74
113
|
return `\nRecorded in ${departed}: you waived fresh-context review for this fix.`;
|
|
@@ -108,7 +147,7 @@ async function fixWithoutReview(
|
|
|
108
147
|
return path;
|
|
109
148
|
}
|
|
110
149
|
|
|
111
|
-
/** `devsys_intake`: Jev
|
|
150
|
+
/** `devsys_intake`: Jev (or the caller) sizes the work; only a doubtful Jev asks the user; state moves to the first phase. */
|
|
112
151
|
export function createIntakeTool(deps: {
|
|
113
152
|
pi: ExtensionAPI;
|
|
114
153
|
state: SessionState;
|
|
@@ -119,36 +158,39 @@ export function createIntakeTool(deps: {
|
|
|
119
158
|
label: "Start work",
|
|
120
159
|
description:
|
|
121
160
|
"Size a request (fix, change, capability, product) and list the planning artifacts that size needs. " +
|
|
122
|
-
"
|
|
161
|
+
"Jev sizes the work and the user is asked only when Jev is under 50% confident; pass `size` when it is already decided. Then phase, sizing and the active slice are set. Call at the start of any non-trivial work.",
|
|
123
162
|
promptSnippet: "Size new work and propose the planning artifacts it needs",
|
|
124
163
|
parameters: Parameters,
|
|
125
164
|
exposure: "model-only",
|
|
126
165
|
async execute(_id, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
|
|
127
166
|
const request = params.request.trim();
|
|
128
167
|
if (request === "") return reply("request must not be empty", true);
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
168
|
+
const given = params.size === undefined ? undefined : parseSizing(params.size.trim());
|
|
169
|
+
if (given !== undefined && isParseError(given)) return reply(given.message, true);
|
|
170
|
+
const judged =
|
|
171
|
+
given === undefined
|
|
172
|
+
? await judgeSizing(deps.jev(ctx), { request, repoSummary: params.repoSummary ?? "" })
|
|
173
|
+
: undefined;
|
|
174
|
+
const decision = decideSize(
|
|
175
|
+
given,
|
|
176
|
+
judged?.ok ? judged.value : undefined,
|
|
177
|
+
judged?.ok === false ? judged.error.kind : "not asked",
|
|
178
|
+
);
|
|
179
|
+
const artifactNeed = judged?.ok ? judged.value.artifactNeed : {};
|
|
138
180
|
const proposalFor = (size: Sizing) => proposeArtifacts(size, artifactNeed);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
proposal: proposalFor(proposedSize),
|
|
143
|
-
});
|
|
144
|
-
if (!ctx.hasUI) return reply(`${proposal}\nHeadless: proposal only, not applied.`);
|
|
145
|
-
const picked = await ctx.ui.select(`Size this work (${basis})`, sizeChoices(proposedSize));
|
|
146
|
-
if (picked === undefined) return reply(`${proposal}\nIntake cancelled; nothing changed.`);
|
|
147
|
-
const sizing = parseSizing(picked);
|
|
148
|
-
if (isParseError(sizing)) return reply(sizing.message, true);
|
|
181
|
+
const chosen = await chooseSize(ctx, decision, proposalFor);
|
|
182
|
+
if ("stop" in chosen) return reply(chosen.stop, chosen.isError);
|
|
183
|
+
const { sizing } = chosen;
|
|
149
184
|
const before = deps.state.get();
|
|
150
185
|
const keep = await declinedToReplace(ctx, before);
|
|
151
|
-
if (keep !== undefined)
|
|
186
|
+
if (keep !== undefined) {
|
|
187
|
+
const proposal = renderProposal({
|
|
188
|
+
sizing,
|
|
189
|
+
basis: decision.basis,
|
|
190
|
+
proposal: proposalFor(sizing),
|
|
191
|
+
});
|
|
192
|
+
return reply(`${proposal}\n${keep}`);
|
|
193
|
+
}
|
|
152
194
|
const slice = uniqueSlice(sliceSlug(request), slicesInUse(before)) as SliceRef;
|
|
153
195
|
deps.state.update((s) => ({
|
|
154
196
|
...s,
|
|
@@ -156,9 +198,14 @@ export function createIntakeTool(deps: {
|
|
|
156
198
|
sizing,
|
|
157
199
|
activeSlice: slice,
|
|
158
200
|
}));
|
|
159
|
-
const waived =
|
|
201
|
+
const waived =
|
|
202
|
+
sizing === "fix" && ctx.hasUI ? await askToWaiveReview(ctx, request, slice) : false;
|
|
160
203
|
const departed = waived ? await fixWithoutReview(deps, ctx, slice) : undefined;
|
|
161
|
-
const final = renderProposal({
|
|
204
|
+
const final = renderProposal({
|
|
205
|
+
sizing,
|
|
206
|
+
basis: decision.basis,
|
|
207
|
+
proposal: proposalFor(sizing),
|
|
208
|
+
});
|
|
162
209
|
const note = waiverNote(sizing, departed);
|
|
163
210
|
return reply(`${final}\nPhase: ${phaseFor(sizing)}. Active slice: ${slice}.${note}`);
|
|
164
211
|
},
|
package/src/planning/intake.ts
CHANGED
|
@@ -71,3 +71,36 @@ export const renderProposal = (input: {
|
|
|
71
71
|
);
|
|
72
72
|
return lines.join("\n");
|
|
73
73
|
};
|
|
74
|
+
|
|
75
|
+
/** Jev confidence below this asks the human; at or above it intake proceeds with Jev's size. */
|
|
76
|
+
const ASK_BELOW = 0.5;
|
|
77
|
+
|
|
78
|
+
export type SizeDecision =
|
|
79
|
+
| { readonly kind: "decided"; readonly size: Sizing; readonly basis: string }
|
|
80
|
+
| { readonly kind: "ask"; readonly proposed: Sizing; readonly basis: string };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pure: who sizes the work. A size given up front (the caller, e.g. a goal's planning, already
|
|
84
|
+
* decided) wins; otherwise Jev decides when it is confident enough; only a doubtful or missing
|
|
85
|
+
* judgement asks the human, with Jev's size (or `change`) as the proposal.
|
|
86
|
+
*/
|
|
87
|
+
export function decideSize(
|
|
88
|
+
given: Sizing | undefined,
|
|
89
|
+
judged: { readonly sizing: Sizing; readonly confidence: number } | undefined,
|
|
90
|
+
unavailable: string,
|
|
91
|
+
): SizeDecision {
|
|
92
|
+
if (given !== undefined) {
|
|
93
|
+
return { kind: "decided", size: given, basis: `Size given up front: ${given}.` };
|
|
94
|
+
}
|
|
95
|
+
if (judged === undefined) {
|
|
96
|
+
return {
|
|
97
|
+
kind: "ask",
|
|
98
|
+
proposed: "change",
|
|
99
|
+
basis: `Jev unavailable (${unavailable}); defaulted to change.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const said = `Jev judged ${judged.sizing} (confidence ${judged.confidence.toFixed(2)})`;
|
|
103
|
+
return judged.confidence >= ASK_BELOW
|
|
104
|
+
? { kind: "decided", size: judged.sizing, basis: `${said}; proceeding without asking.` }
|
|
105
|
+
: { kind: "ask", proposed: judged.sizing, basis: `${said}, too unsure to proceed alone.` };
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|