From fc93c7cd37a04b5a7e763008fc9cb9aba0b84b01 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 25 Aug 2026 14:42:55 +0200 Subject: [PATCH 1/3] Build the controller read path and the SDCPN plugin package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness can now answer "is the elicited model complete?" from the capture store alone, and the SDCPN plugin is code that the harness loads, not a spec the interviewer is told about. Read path (FE-1497), five modules in `packages/core/src`: - `plugin-file.ts` parses an ADR-0006 plugin file: the six fixed headings in order, the version, the three machine-read tables (kinds, must-know rows with typed precision and the not-applicable flag, patterns indexed by the kinds their trigger names), and the static floor as per-kind counts. Contract violations throw `PluginFileError` at load. - `slot-assertion.ts` is the one proposal payload a kind-and-slot plugin needs: `{kind, node, slot, precision?, assertion: value | absence}`, restricted by `createSlotAssertionSchema(file)` to the kinds and slots the file names. It rides inside `content.value`; the envelope-level `{absence}` variant has no address and stays unusable for slots. - `elicited-model.ts` folds active captures into nodes keyed `kind:node` and slots in one of four states (value, absence, conflict, divergence). Unreadable payloads are recorded as unmapped, never interpreted. A prescribed/practiced pair that differs is a divergence; anything else that differs is a conflict; an open conflicting issue pins a slot in conflict even when readings agree. The revision digests the active set. - `completion.ts` is `evaluateCompletion(model, demands)`: pure, no persisted status, refuses foreign plugin versions, checks the floor, then the objective's dependency slice, then every row inside the slice in the diagnostic order the spec fixes. Nodes outside every objective's slice are reported, not demanded. `precisionSatisfies` encodes the ladder (named < number < range < spread; spelled out satisfies itself and named). Accepted statuses default to `explicit`; "inferred and confirmed" is an explicit superseding capture. - `cue.ts` turns a report into a sweep list (failing slots plus the patterns indexed on their kinds) and a completion-cue signal that states the verdict and never decides whether to continue. `definePlugin` gains `file?: PluginFile`; `targetDomain` is renamed `targetFormalism` to match ADR-0006. The Flue binding threads the file through: slot-assertion guidance in the extraction prompt, the cue in the sweep tool's result, the file's prose appended to the instructions. Harness facts still reach the model only through tool results and signals. Plugin package (FE-1482), `packages/plugin-sdcpn`: `docs/specs/sdcpn- plugin.md` moves to `plugin.md` and is imported `?raw`, parsed at module load, and declared as the single `slot-asserted` proposal type. Depends on `@hashintel/brunch-agent` and valibot only; named in kernel §12.2 so the topology gate admits it. Every document that linked the spec is repointed. Tests: the fixture plugin file plus contract-violation cases; the real SDCPN file (version, ten kinds, floor, 24 rows, 13 patterns, no domain words); fold semantics; completion invariants 1–16 mapped one test each (17–19 are session-control and not implemented here); the cue; and an end-to-end fold + completion over the SDCPN rows in the plugin package. Gates: core 232/232, binding-flue 16/16, plugin-gherkin 2/2, plugin-sdcpn 4/4, apps/brunch-agent 32/32; tsgo clean; oxlint 0 errors; oxfmt applied. Dependents resolve the harness through `dist/`, so a stale build fails their tests with the old field name until rebuilt. Co-Authored-By: Claude Fable 5 --- libs/@hashintel/brunch-agent/README.md | 2 + libs/@hashintel/brunch-agent/docs/INDEX.md | 10 +- .../adr/0006-plugins-per-target-formalism.md | 4 +- .../cps-interview-guidance-2026-08-25.md | 2 +- ...n-contract-2026-08-25-declarative-draft.md | 2 +- .../brunch-agent/docs/control/SPEC-LEDGER.md | 4 +- .../brunch-agent/docs/control/STEERING.md | 2 +- .../brunch-agent/docs/control/STRATEGY-LOG.md | 4 +- .../cps-interview-guidance-desk-replay.md | 2 +- .../design/cps-interview-guidance-plain.md | 2 +- .../docs/specs/elicitation-completion.md | 2 +- .../docs/specs/elicitation-kernel.md | 3 +- .../intermediate-representation-plain.md | 2 +- .../docs/specs/plugin-contract.md | 4 +- .../packages/binding-flue/src/index.ts | 45 +- .../packages/core/src/ask-protocol.ts | 4 +- .../packages/core/src/completion.ts | 378 +++++++++++ .../brunch-agent/packages/core/src/cue.ts | 111 ++++ .../packages/core/src/elicited-model.ts | 296 +++++++++ .../brunch-agent/packages/core/src/index.ts | 55 ++ .../packages/core/src/plugin-file.ts | 375 +++++++++++ .../brunch-agent/packages/core/src/plugin.ts | 15 +- .../packages/core/src/slot-assertion.ts | Bin 0 -> 4449 bytes .../packages/core/src/sweep-protocol.ts | 9 +- .../packages/core/src/testing/index.ts | 2 +- .../packages/core/test/completion.test.ts | 401 ++++++++++++ .../packages/core/test/cue.test.ts | 104 +++ .../packages/core/test/elicited-model.test.ts | 268 ++++++++ .../packages/core/test/plugin-file.test.ts | 215 +++++++ .../packages/core/test/slot-fixtures.ts | 196 ++++++ .../packages/core/test/sweep-protocol.test.ts | 2 +- .../packages/plugin-gherkin/src/index.ts | 4 +- .../packages/plugin-sdcpn/.oxlintrc.json | 60 ++ .../packages/plugin-sdcpn/LICENSE.md | 607 ++++++++++++++++++ .../packages/plugin-sdcpn/package.json | 33 + .../plugin-sdcpn/plugin.md} | 6 +- .../packages/plugin-sdcpn/src/index.ts | 62 ++ .../plugin-sdcpn/src/raw-imports.d.ts | 5 + .../packages/plugin-sdcpn/test/plugin.test.ts | 282 ++++++++ .../packages/plugin-sdcpn/tsconfig.json | 19 + .../packages/plugin-sdcpn/turbo.json | 10 + .../packages/plugin-sdcpn/vite.config.ts | 23 + yarn.lock | 15 + 43 files changed, 3609 insertions(+), 38 deletions(-) create mode 100644 libs/@hashintel/brunch-agent/packages/core/src/completion.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/src/cue.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/src/elicited-model.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/src/plugin-file.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/src/slot-assertion.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/test/completion.test.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/test/cue.test.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/test/elicited-model.test.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/test/plugin-file.test.ts create mode 100644 libs/@hashintel/brunch-agent/packages/core/test/slot-fixtures.ts create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/.oxlintrc.json create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/LICENSE.md create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/package.json rename libs/@hashintel/brunch-agent/{docs/specs/sdcpn-plugin.md => packages/plugin-sdcpn/plugin.md} (98%) create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/index.ts create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/raw-imports.d.ts create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/plugin.test.ts create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/tsconfig.json create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/turbo.json create mode 100644 libs/@hashintel/brunch-agent/packages/plugin-sdcpn/vite.config.ts diff --git a/libs/@hashintel/brunch-agent/README.md b/libs/@hashintel/brunch-agent/README.md index 28708deac2a..fe09a825b63 100644 --- a/libs/@hashintel/brunch-agent/README.md +++ b/libs/@hashintel/brunch-agent/README.md @@ -14,6 +14,8 @@ This directory is its context and agent-session root, not a package workspace: - [`packages/binding-flue/`](./packages/binding-flue/) is the Flue binding. - [`packages/transport-aisdk/`](./packages/transport-aisdk/) is the AI SDK transport. - [`packages/plugin-gherkin/`](./packages/plugin-gherkin/) is the Gherkin target plugin. +- [`packages/plugin-sdcpn/`](./packages/plugin-sdcpn/) is the SDCPN target plugin: `plugin.md` and its + slot-assertion proposal type. - [`../../../apps/brunch-agent/`](../../../apps/brunch-agent/) is the remote server and diagnostic application. diff --git a/libs/@hashintel/brunch-agent/docs/INDEX.md b/libs/@hashintel/brunch-agent/docs/INDEX.md index 17bc13c42f2..99322d53cc7 100644 --- a/libs/@hashintel/brunch-agent/docs/INDEX.md +++ b/libs/@hashintel/brunch-agent/docs/INDEX.md @@ -39,7 +39,7 @@ control loop is [`docs/agents/steering.md`](agents/steering.md). | [map.md](archive/elicitation-kernel/map.md) | settled | **mirrored in full**: FE-1366 | Completed wayfinder map | | [issues/](archive/elicitation-kernel/issues) 01–13 | settled | **mirrored in full**: FE-1367–FE-1379 (relations preserved) | 13 resolved tickets | | [notes/consistency-prepass](archive/elicitation-kernel/notes/consistency-prepass-2026-08-10.md) | settled | none | Pre-assembly contradiction audit (7 contradictions, adjudicated in spec Appendix A) | -| [plugin-contract-2026-08-25-declarative-draft](archive/specs/plugin-contract-2026-08-25-declarative-draft.md) | superseded | FE-1431; FE-1405 | Archive copy of the pre-ADR-0006 plugin contract: two schemas and two tables, `ScopeExpr`/`where`/`inSupport`, `firesWhen`, `completionAnchor`, typed fold/demand/variant/loss declarations; replaced by the per-formalism plugin file (`specs/plugin-contract.md`, `specs/sdcpn-plugin.md`) | +| [plugin-contract-2026-08-25-declarative-draft](archive/specs/plugin-contract-2026-08-25-declarative-draft.md) | superseded | FE-1431; FE-1405 | Archive copy of the pre-ADR-0006 plugin contract: two schemas and two tables, `ScopeExpr`/`where`/`inSupport`, `firesWhen`, `completionAnchor`, typed fold/demand/variant/loss declarations; replaced by the per-formalism plugin file (`specs/plugin-contract.md`, `packages/plugin-sdcpn/plugin.md`) | | [elicitation-completion-2026-08-25-full-draft](archive/specs/elicitation-completion-2026-08-25-full-draft.md) | superseded | FE-1402 | Archive copy of the pre-ADR-0006 completion draft: CPS DemandTable, `where`-scoped presence/slot clauses, completion-anchor matching, full deferral-licensing schemas; replaced by the `evaluateCompletion` invariants in `specs/elicitation-completion.md` | ## Process-model elicitation artifacts (FE-1357) @@ -65,20 +65,20 @@ control loop is [`docs/agents/steering.md`](agents/steering.md). | [baseline evaluation evidence](evidence/evaluations/process-model-elicitation/baseline/) | settled | gisted in FE-1361 resolution | Immutable baseline-control evidence: both transcripts, raw snapshots, delivered models, and scored read-out; with the executable cases and protocol under `evaluations/` it is the simulated-expert harness for the walking-skeleton run (reclassified 2026-08-25 as test-bed material) | | [ir-design](specs/intermediate-representation.md) | active | gisted in FE-1364 resolution; amended by FE-1480 | The IR design: Layer A (ratified on worked examples, FE-1397; definition sentence amended by ADR-0003) + the CPS plugin's ten-kind payload, deterministic scaffold and obligation contract (Layer B); executable code is realized downstream under ADR-0005 | | [ir-worked-examples](evidence/proofs/design/intermediate-representation-worked-examples.md) | active | gisted in FE-1397 | Layer-A validation across Gherkin/CPS/BPMN + assurance: property verdicts, amendments, sublimation findings | -| [ir-design-plain](specs/intermediate-representation-plain.md) | active | strain findings on FE-1401; amended by FE-1480 | Plain-prose rendering of the IR design, including ADR-0005's split between deterministic scaffolding and model-assisted executable realization; notes that `sdcpn-plugin.md` is now the concrete rendering of Layer B | +| [ir-design-plain](specs/intermediate-representation-plain.md) | active | strain findings on FE-1401; amended by FE-1480 | Plain-prose rendering of the IR design, including ADR-0005's split between deterministic scaffolding and model-assisted executable realization; notes that `plugin-sdcpn/plugin.md` is now the concrete rendering of Layer B | | [notes/research-patterns-audit](evidence/proofs/audits/research-patterns-audit.md) | active | FE-1401 / card inputs on FE-1403 | Plain-language audit of ~30 research imports in 7 families, evidence-graded, with an 8-point strain appendix | | [notes/penciled-directions-2026-08-14](archive/planning-inputs/penciled-directions-2026-08-14.md) | settled | FE-1401 | Penciled directions from the legibility session: 8 items with firming actions + editorial reflections | | [capture-store-plain](reference/architecture/capture-store.md) | active | strain findings on FE-1401 | STE-leaning rendering of the capture-store semantics (FE-1390/FE-1389) with a load-bearing not-guaranteed section; 8-point strain report incl. two command-reachable unclosable-conflict paths (confirms FE-1419 commits 7/8) and the FE-1405 status-arity answer | | [notes/deep-read-fe-1389](evidence/proofs/audits/deep-read-fe-1389.md) | active | FE-1401 / findings in FE-1420 | Deep-read of the walking skeleton: builder's account, spec-discharge table (issues 10/13 capabilities discharged; markdown floor contradicted in the UI), 12 findings; source of PR #10's backfilled record | | [notes/deep-read-fe-1390](evidence/proofs/audits/deep-read-fe-1390.md) | active | FE-1401 / probes on FE-1419 | Deep-read of the capture store: spec-discharge table, write-time tiering assessment (penciled item 7), the FE-1405 status-arity answer, and live-probed confirmation of FE-1419's capture-store claims plus one new aliasing hole; source of PR #11's backfilled record | -| [plugin-contract-spec](specs/plugin-contract.md) | active | FE-1431 (spec issue); decided on FE-1405; amended by FE-1480; reshaped by ADR-0006 | Per-target-formalism plugin contract: fixed heading set, three machine-read tables (`Kinds`, `Must know`, `Patterns`) with `sdcpn-plugin.md` normative for row/column shape, version binding, `project`/`validate` as code with the ADR-0005 outputs, surviving invariants, open strains, and a Retired 2026-08-25 section pointing to the archived declarative draft | +| [plugin-contract-spec](specs/plugin-contract.md) | active | FE-1431 (spec issue); decided on FE-1405; amended by FE-1480; reshaped by ADR-0006 | Per-target-formalism plugin contract: fixed heading set, three machine-read tables (`Kinds`, `Must know`, `Patterns`) with `plugin-sdcpn/plugin.md` normative for row/column shape, version binding, `project`/`validate` as code with the ADR-0005 outputs, surviving invariants, open strains, and a Retired 2026-08-25 section pointing to the archived declarative draft | | [elicitation-completion](specs/elicitation-completion.md) | active | FE-1402; rewritten under ADR-0006 | Nineteen invariants `evaluateCompletion(model, mustKnowRows)` must satisfy, framed as tests: derived boolean plus evidence report, floor as counts, question-relative demand over objective slices, universal active-objective check, status/precision/confidence separation, conservative conflict and divergence failure, stop/delivery/budget as non-inputs, read-time deferral licensing, no new persistence | | [elicitation-completion-rehearsal](evidence/proofs/design/elicitation-completion-rehearsal.md) | active | FE-1402; inputs FE-1403/FE-1404/FE-1431 | Test-bed material, not authority (reclassified 2026-08-25): manual clause-level replay over all 44 FE-1361 prefixes against the retired domain-keyed CPS DemandTable; golden-fixture candidate for `evaluateCompletion` once re-expressed at kind level | | [elicitation-completion-plain](evidence/proofs/design/elicitation-completion-plain.md) | active | FE-1402 legibility snapshot | Evidence, not authority (reclassified 2026-08-25): plain-language rendering of the pre-ADR-0006 completion draft and its translation strains; the invariants it explains survive in the rewritten spec | -| [cps-interview-guidance](archive/specs/cps-interview-guidance-2026-08-25.md) | superseded | FE-1403; inputs FE-1404/FE-1406/FE-1431 | Archived 2026-08-25 under ADR-0006: the FE-1403 CPS card set (CPS-Q01–Q05, GEN-Q02, two hint fragments) whose cards became kind-indexed patterns P01–P05, P12 and `Moves` steps in `sdcpn-plugin.md`; banner records the card→pattern mapping and the `domain` mis-tag; retained as test-bed material | +| [cps-interview-guidance](archive/specs/cps-interview-guidance-2026-08-25.md) | superseded | FE-1403; inputs FE-1404/FE-1406/FE-1431 | Archived 2026-08-25 under ADR-0006: the FE-1403 CPS card set (CPS-Q01–Q05, GEN-Q02, two hint fragments) whose cards became kind-indexed patterns P01–P05, P12 and `Moves` steps in `plugin-sdcpn/plugin.md`; banner records the card→pattern mapping and the `domain` mis-tag; retained as test-bed material | | [cps-interview-guidance-desk-replay](evidence/proofs/design/cps-interview-guidance-desk-replay.md) | active | FE-1403; inputs FE-1404/FE-1406/FE-1431 | Evidence, not authority (reclassified 2026-08-25): manual two-transcript prefix replay of the archived CPS cards — per-card firings, expected evidence deltas, deactivation boundaries, candidate dispositions, research ledger; desk discrimination only | | [cps-interview-guidance-plain](evidence/proofs/design/cps-interview-guidance-plain.md) | active | FE-1403 legibility snapshot | Evidence, not authority (reclassified 2026-08-25): plain rendering of the archived CPS guidance with translation strains and their dispositions | -| [sdcpn-plugin](specs/sdcpn-plugin.md) | active | FE-1404 (redefined toward the walking skeleton); supersedes the domain-keyed tables of FE-1402/1403 | The SDCPN plugin file: fixed contract headings (Purpose, Kinds, Must know, Patterns, Moves, Deliverable) over the IR spec's ten Layer-B kinds; three machine-read tables, domain-neutral by rule; `Moves` carries two job runbooks (`construct`, `review and revise`) with harness-owned checks and stopping outcomes; moves to `packages/plugin-sdcpn/` with the skeleton | +| [sdcpn-plugin](../packages/plugin-sdcpn/plugin.md) | active | FE-1404 (redefined toward the walking skeleton); supersedes the domain-keyed tables of FE-1402/1403 | The SDCPN plugin file: fixed contract headings (Purpose, Kinds, Must know, Patterns, Moves, Deliverable) over the IR spec's ten Layer-B kinds; three machine-read tables, domain-neutral by rule; `Moves` carries two job runbooks (`construct`, `review and revise`) with harness-owned checks and stopping outcomes; moves to `packages/plugin-sdcpn/` with the skeleton | ## Control, architecture reference, and migration archive diff --git a/libs/@hashintel/brunch-agent/docs/adr/0006-plugins-per-target-formalism.md b/libs/@hashintel/brunch-agent/docs/adr/0006-plugins-per-target-formalism.md index d74da690363..46122baff34 100644 --- a/libs/@hashintel/brunch-agent/docs/adr/0006-plugins-per-target-formalism.md +++ b/libs/@hashintel/brunch-agent/docs/adr/0006-plugins-per-target-formalism.md @@ -38,7 +38,7 @@ streak ≈ controller stopping policy; sealed segments ≈ session archive. The design convergence do not implement SDK surface, projection, …") displaced implementation into `evaluations/`, where it does not compound. -Meanwhile [`docs/specs/sdcpn-plugin.md`](../specs/sdcpn-plugin.md) showed that the whole target +Meanwhile [`packages/plugin-sdcpn/plugin.md`](../../packages/plugin-sdcpn/plugin.md) showed that the whole target fits one file: the twenty domain rows collapse onto kind-level rows instantiated on discovered nodes, and the five domain cards become kind-indexed patterns P01–P05. This record ratifies that file's shape as the plugin contract. @@ -52,7 +52,7 @@ file's shape as the plugin contract. `Purpose · Kinds · Must know · Patterns · Moves · Deliverable`. The headings are the contract and are identical across plugins. The harness parses the `Kinds`, `Must know`, and `Patterns` tables into the model vocabulary, the demand list, and the pattern index; every other section - concatenates into the interviewer's instructions. `docs/specs/sdcpn-plugin.md` is the normative + concatenates into the interviewer's instructions. `packages/plugin-sdcpn/plugin.md` is the normative exemplar; it moves unchanged to `packages/plugin-sdcpn/` with the walking skeleton. 3. **Demand rows are kind-level.** Each row is a slot on a kind with a required precision, an diff --git a/libs/@hashintel/brunch-agent/docs/archive/specs/cps-interview-guidance-2026-08-25.md b/libs/@hashintel/brunch-agent/docs/archive/specs/cps-interview-guidance-2026-08-25.md index e4451979062..795ab6bc449 100644 --- a/libs/@hashintel/brunch-agent/docs/archive/specs/cps-interview-guidance-2026-08-25.md +++ b/libs/@hashintel/brunch-agent/docs/archive/specs/cps-interview-guidance-2026-08-25.md @@ -1,7 +1,7 @@ > **Superseded 2026-08-25.** Moved from `docs/specs/cps-interview-guidance.md` under > [ADR-0006](../../adr/0006-plugins-per-target-formalism.md): interview "cards" are no longer > separate artifacts; they became kind-indexed patterns in the `Patterns` and `Moves` sections of -> [`sdcpn-plugin.md`](../../specs/sdcpn-plugin.md). Mapping: CPS-Q01 → P01 · CPS-Q02 → P02 · +> [`plugin-sdcpn/plugin.md`](../../../packages/plugin-sdcpn/plugin.md). Mapping: CPS-Q01 → P01 · CPS-Q02 → P02 · > CPS-Q03 → P03 · CPS-Q04 → P04 · CPS-Q05 → P05 · GEN-Q02 → Moves "construct" step 3 (the > batching sentence) · HINT-STATUS-GRADE → P12 · HINT-RESPECTFUL-CLOSE → Moves "construct" step 6. > The `domain` tag on the CPS cards was a mis-tag: each card names a model situation that occurs diff --git a/libs/@hashintel/brunch-agent/docs/archive/specs/plugin-contract-2026-08-25-declarative-draft.md b/libs/@hashintel/brunch-agent/docs/archive/specs/plugin-contract-2026-08-25-declarative-draft.md index 7e75a2f492b..9e219bb1f5a 100644 --- a/libs/@hashintel/brunch-agent/docs/archive/specs/plugin-contract-2026-08-25-declarative-draft.md +++ b/libs/@hashintel/brunch-agent/docs/archive/specs/plugin-contract-2026-08-25-declarative-draft.md @@ -4,7 +4,7 @@ > `where` / `inSupport`, `ProposalType.affordance.firesWhen`, `NodeKind.completionAnchor`, the > typed `foldTable` / `demandTable` / `variantDimension` / `lossCategories` keys — has no current > authority; the current contract is the shrunk [`plugin-contract.md`](../../specs/plugin-contract.md) -> and the exemplar [`sdcpn-plugin.md`](../../specs/sdcpn-plugin.md). Content is otherwise +> and the exemplar [`plugin-sdcpn/plugin.md`](../../../packages/plugin-sdcpn/plugin.md). Content is otherwise > verbatim; only relative link targets were re-rooted for the archive location. # Spec: the plugin contract — two schemas, two tables diff --git a/libs/@hashintel/brunch-agent/docs/control/SPEC-LEDGER.md b/libs/@hashintel/brunch-agent/docs/control/SPEC-LEDGER.md index d828051bfe4..1946a4cab18 100644 --- a/libs/@hashintel/brunch-agent/docs/control/SPEC-LEDGER.md +++ b/libs/@hashintel/brunch-agent/docs/control/SPEC-LEDGER.md @@ -104,11 +104,11 @@ states. | Obligation | Spec | Status | Evidence | | -------------------------------------------------------------------------------- | ---------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Plugin ownership: packs, forms, validators | §11.1 | **superseded → partial** | ADR-0006 (2026-08-25) makes a plugin one sectioned Markdown file per target formalism plus `project`/`validate` code ([`plugin-contract.md`](../specs/plugin-contract.md)); cards became kind-indexed `Patterns`, the completion contract became the `Must know` table. `plugin-gherkin` owns its one FE-1392 proposal declaration/schema and target identity; [`sdcpn-plugin.md`](../specs/sdcpn-plugin.md) is authored but unparsed; the file parser, fold, and demand runner remain FE-1393 work | +| Plugin ownership: packs, forms, validators | §11.1 | **superseded → partial** | ADR-0006 (2026-08-25) makes a plugin one sectioned Markdown file per target formalism plus `project`/`validate` code ([`plugin-contract.md`](../specs/plugin-contract.md)); cards became kind-indexed `Patterns`, the completion contract became the `Must know` table. `plugin-gherkin` owns its one FE-1392 proposal declaration/schema and target identity; [`plugin-sdcpn/plugin.md`](../../packages/plugin-sdcpn/plugin.md) is authored but unparsed; the file parser, fold, and demand runner remain FE-1393 work | | Pack form, Principle v2 | §11.2 | **superseded → pending** | ADR-0006 fixes pack form as the heading contract (`Purpose · Kinds · Must know · Patterns · Moves · Deliverable`) with three machine-read tables; Principle v2 still governs the prose sections. No parser or loader exists | | Smallest honest plugin as a standing bar | §11.3 | **partial** | `statement-noted.test.ts` and the core plugin fixture encode the one-type verbatim floor and reject undeclared parsed/pointer shape; the standing bar must grow with FE-1393's operations | | Generic strategy quiver | §11.5 | **pending** (ownership repaired) | was **orphaned** — named-not-designed, carried by no map — now FE-1406 (root issue) | -| Portfolio + hybrid order: both packs authored before the pack interface freezes | §13 | **superseded → partial** | ADR-0006 makes the interface the heading contract and three table grammars; the SDCPN plugin file is authored (`sdcpn-plugin.md`), the Gherkin file is not. Owned by FE-1387 (FE-1383 slice, backlog); current sequencing puts the SDCPN proof before generic freeze (see `STEERING.md`). Gherkin wiring ahead stays legal while FE-1387 holds the freeze | +| Portfolio + hybrid order: both packs authored before the pack interface freezes | §13 | **superseded → partial** | ADR-0006 makes the interface the heading contract and three table grammars; the SDCPN plugin file is authored (`plugin-sdcpn/plugin.md`), the Gherkin file is not. Owned by FE-1387 (FE-1383 slice, backlog); current sequencing puts the SDCPN proof before generic freeze (see `STEERING.md`). Gherkin wiring ahead stays legal while FE-1387 holds the freeze | | Gherkin validation (parse validity, step lexicon) | §13.1 | **pending** | — | | Assurance target (Statement record, four edges, five-stratum derivation, ledger) | §13.2–13.3 | **pending** | — | diff --git a/libs/@hashintel/brunch-agent/docs/control/STEERING.md b/libs/@hashintel/brunch-agent/docs/control/STEERING.md index 80591ff2921..abc645c7fd5 100644 --- a/libs/@hashintel/brunch-agent/docs/control/STEERING.md +++ b/libs/@hashintel/brunch-agent/docs/control/STEERING.md @@ -27,7 +27,7 @@ and [S-007](STRATEGY-LOG.md#s-007). Governing architecture: production path with one formalism-level plugin, not further design. Every design question still open is answered by what the slice forces, and answered in code. The design-convergence frontier is closed: its outputs are test-bed material, and its one durable design result is the plugin file -[`sdcpn-plugin.md`](../specs/sdcpn-plugin.md) ratified by ADR-0006. +[`plugin-sdcpn/plugin.md`](../../packages/plugin-sdcpn/plugin.md) ratified by ADR-0006. The slice has four epicentres, ordered by the size of the gap they close. Work starts at the centre of each and moves outward; edges (SDK generality, affordance catalogues, UI breadth, diff --git a/libs/@hashintel/brunch-agent/docs/control/STRATEGY-LOG.md b/libs/@hashintel/brunch-agent/docs/control/STRATEGY-LOG.md index 0db6adfcae8..e8af617d6a4 100644 --- a/libs/@hashintel/brunch-agent/docs/control/STRATEGY-LOG.md +++ b/libs/@hashintel/brunch-agent/docs/control/STRATEGY-LOG.md @@ -179,7 +179,7 @@ instrument as definition of done". **Decision:** Invert S-005 and S-006: implement the vertical slice and design only what the slice forces. Adopt [ADR-0006](../adr/0006-plugins-per-target-formalism.md): plugins are per target -formalism, authored as one sectioned Markdown file; `docs/specs/sdcpn-plugin.md` is the exemplar. +formalism, authored as one sectioned Markdown file; `packages/plugin-sdcpn/plugin.md` is the exemplar. Close the design-convergence queue: FE-1407, FE-1402, and FE-1403 are reclassified as test-bed material; FE-1404 is redefined as the skeleton run — condition 3 as the protocol originally defined it (kernel harness + real plugin), not the shadow-harness instrument; FE-1406 shrinks to @@ -211,7 +211,7 @@ heading the contract does not have (ADR-0006's condition). **Supersedes:** S-005, S-006 **Evidence links:** [ADR-0006](../adr/0006-plugins-per-target-formalism.md), -[sdcpn plugin file](../specs/sdcpn-plugin.md), +[sdcpn plugin file](../../packages/plugin-sdcpn/plugin.md), [IR spec Layer B](../specs/intermediate-representation.md#layer-b--the-cps-plugins-ir), [archived drafts](../archive/specs/), [baseline situation pack](../../evaluations/cases/process-model-elicitation/baseline/situation-pack.md), diff --git a/libs/@hashintel/brunch-agent/docs/evidence/proofs/design/cps-interview-guidance-desk-replay.md b/libs/@hashintel/brunch-agent/docs/evidence/proofs/design/cps-interview-guidance-desk-replay.md index 9f0eaf95424..0089ce0b3ef 100644 --- a/libs/@hashintel/brunch-agent/docs/evidence/proofs/design/cps-interview-guidance-desk-replay.md +++ b/libs/@hashintel/brunch-agent/docs/evidence/proofs/design/cps-interview-guidance-desk-replay.md @@ -6,7 +6,7 @@ utterance available before condition 2's eleventh interviewer response. ## Fixed inputs and method -- guidance under test: [`cps-interview-guidance.md`](../../../archive/specs/cps-interview-guidance-2026-08-25.md) (archived 2026-08-25; its cards are now patterns in [`sdcpn-plugin.md`](../../../specs/sdcpn-plugin.md)) +- guidance under test: [`cps-interview-guidance.md`](../../../archive/specs/cps-interview-guidance-2026-08-25.md) (archived 2026-08-25; its cards are now patterns in [`plugin-sdcpn/plugin.md`](../../../../packages/plugin-sdcpn/plugin.md)) - completion oracle: `cps-baseline-replay/2026-08-24.3` from the FE-1402 rehearsal - failure signatures: the reviewed FE-1407 catalogue - transcripts: FE-1361 condition 1 and condition 2, one run each diff --git a/libs/@hashintel/brunch-agent/docs/evidence/proofs/design/cps-interview-guidance-plain.md b/libs/@hashintel/brunch-agent/docs/evidence/proofs/design/cps-interview-guidance-plain.md index 9ecb20aac9f..8bc9d43b198 100644 --- a/libs/@hashintel/brunch-agent/docs/evidence/proofs/design/cps-interview-guidance-plain.md +++ b/libs/@hashintel/brunch-agent/docs/evidence/proofs/design/cps-interview-guidance-plain.md @@ -1,7 +1,7 @@ # CPS interview guidance in plain language This is the second-register rendering of the provisional -[CPS interview-guidance contract](../../../archive/specs/cps-interview-guidance-2026-08-25.md) (archived 2026-08-25 under ADR-0006; its cards are now patterns in [`sdcpn-plugin.md`](../../../specs/sdcpn-plugin.md)). A separate renderer +[CPS interview-guidance contract](../../../archive/specs/cps-interview-guidance-2026-08-25.md) (archived 2026-08-25 under ADR-0006; its cards are now patterns in [`plugin-sdcpn/plugin.md`](../../../../packages/plugin-sdcpn/plugin.md)). A separate renderer received the spec and desk replay without the producing trajectory. The rendering is reviewer-facing; the specification remains the required-behavior authority. diff --git a/libs/@hashintel/brunch-agent/docs/specs/elicitation-completion.md b/libs/@hashintel/brunch-agent/docs/specs/elicitation-completion.md index 7c60c92f918..360fe625d10 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/elicitation-completion.md +++ b/libs/@hashintel/brunch-agent/docs/specs/elicitation-completion.md @@ -16,7 +16,7 @@ evaluateCompletion(model, mustKnowRows) -> CompletionReport `model` is the register-2 derived model at one target-document revision ([ADR-0003](../adr/0003-three-register-ir.md)). `mustKnowRows` is the parsed `## Must know` table of one plugin file at one plugin version, with the static floor stated under it -([`sdcpn-plugin.md`](sdcpn-plugin.md) is the exemplar). The function is pure and reads nothing +([`plugin-sdcpn/plugin.md`](../../packages/plugin-sdcpn/plugin.md) is the exemplar). The function is pure and reads nothing else: not the transcript, conversation fluency, turn count, delivery state, session state, or a deferral report. Each numbered statement below is a test the implementation must pass; the [plain rendering](../evidence/proofs/design/elicitation-completion-plain.md) explains the same diff --git a/libs/@hashintel/brunch-agent/docs/specs/elicitation-kernel.md b/libs/@hashintel/brunch-agent/docs/specs/elicitation-kernel.md index 3da1c64e690..64673f46f28 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/elicitation-kernel.md +++ b/libs/@hashintel/brunch-agent/docs/specs/elicitation-kernel.md @@ -21,7 +21,7 @@ carries the operating truth, this map names it; the section itself is not rewrit | §5 envelope, §8 sweep and supersession, §11.1 "own payload structure" | [ADR-0003](../adr/0003-three-register-ir.md): captures are register 1; the elicited model is register 2, derived by a pure fold and never stored; projections are register 3. Envelope semantics unchanged. | | §6.1 `project` for code-bearing targets; §14.1 invariants 3 and 8 | [ADR-0005](../adr/0005-model-assisted-sdcpn-realization.md): the pure projection emits a scaffold, a typed code-obligation sidecar, and the loss report; executable realization is downstream application work. | | §9.5 completion derived, never a gate | [`elicitation-completion.md`](elicitation-completion.md): the invariants of `evaluateCompletion(model, mustKnowRows)` over the plugin file's `Must know` table, under [ADR-0006](../adr/0006-plugins-per-target-formalism.md). | -| §11.1 ElicitationPack (kernel cards, completion contract, clarification hints); §11.2 pack form | [ADR-0006](../adr/0006-plugins-per-target-formalism.md) and [`sdcpn-plugin.md`](sdcpn-plugin.md): a plugin is one sectioned Markdown file per target formalism with fixed headings (`Purpose · Kinds · Must know · Patterns · Moves · Deliverable`); cards became kind-indexed `Patterns`, the completion contract became the `Must know` table, clarification hints became `Moves` steps. Principle v2 still governs the prose sections. `project`/`validate` remain plugin code ([`plugin-contract.md`](plugin-contract.md)). | +| §11.1 ElicitationPack (kernel cards, completion contract, clarification hints); §11.2 pack form | [ADR-0006](../adr/0006-plugins-per-target-formalism.md) and [`plugin-sdcpn/plugin.md`](../../packages/plugin-sdcpn/plugin.md): a plugin is one sectioned Markdown file per target formalism with fixed headings (`Purpose · Kinds · Must know · Patterns · Moves · Deliverable`); cards became kind-indexed `Patterns`, the completion contract became the `Must know` table, clarification hints became `Moves` steps. Principle v2 still governs the prose sections. `project`/`validate` remain plugin code ([`plugin-contract.md`](plugin-contract.md)). | | §11.5 generic strategy cards | Unchanged in principle (guidance ownership follows vocabulary ownership); still named, not designed (FE-1406). Any harness-generic guidance would take the same `Patterns`/`Moves` shape. | | §13 portfolio and hybrid order ("both packs authored before the pack interface freezes") | [ADR-0006](../adr/0006-plugins-per-target-formalism.md): the interface is the heading contract and the three table grammars; the SDCPN file is authored, the Gherkin file is not; sequencing is owned by [STEERING](../control/STEERING.md). §13.1–13.3 target content is unchanged. | @@ -629,6 +629,7 @@ packages/core/testing # (subpath) fixtures, arbitraries, replay driver — packages/binding-flue # the Flue binding (implements §10; owns the storage port impl) packages/transport-aisdk # validated UI ingress + harness replies → AI SDK wire; no binding/substrate imports packages/plugin-gherkin +packages/plugin-sdcpn # the SDCPN process-model plugin file and its slot-assertion proposal type (ADR-0006) packages/plugin-assurance # renamed 2026-08-10 from plugin-proof-obligations (§13.2) apps/dev # owns 'use agent' module, app.ts, db.ts, Vite build ``` diff --git a/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation-plain.md b/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation-plain.md index 240f53fa224..77de1deea42 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation-plain.md +++ b/libs/@hashintel/brunch-agent/docs/specs/intermediate-representation-plain.md @@ -6,7 +6,7 @@ > findings on FE-1401 (third accrual), the load-bearing one being the loss report's unresolved unit > of loss (capture vs. capture-facet). > -> Since 2026-08-25, [`sdcpn-plugin.md`](sdcpn-plugin.md) is the concrete rendering of Layer B: its +> Since 2026-08-25, [`plugin-sdcpn/plugin.md`](../../packages/plugin-sdcpn/plugin.md) is the concrete rendering of Layer B: its > `Kinds` and `Must know` tables carry the ten kinds, the cross-kind attributes, and the > question-relative completion rule described below as the one authored plugin file. diff --git a/libs/@hashintel/brunch-agent/docs/specs/plugin-contract.md b/libs/@hashintel/brunch-agent/docs/specs/plugin-contract.md index 26e694ad5b0..ab0aa83e37a 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/plugin-contract.md +++ b/libs/@hashintel/brunch-agent/docs/specs/plugin-contract.md @@ -5,7 +5,7 @@ Status: **provisional**, reshaped 2026-08-25 by [ADR-0003](../adr/0003-three-register-ir.md)): a worked pass across at least three plugin targets on a real fold. Decided on: FE-1405 (registers), FE-1480 (ADR-0005 outputs), and the 2026-08-25 design-convergence review (per-formalism plugin file). The normative exemplar for -every row and column shape named here is [`sdcpn-plugin.md`](sdcpn-plugin.md); where this +every row and column shape named here is [`plugin-sdcpn/plugin.md`](../../packages/plugin-sdcpn/plugin.md); where this document and that file disagree about shape, the file wins and this document is amended. The retired declarative draft is archived at [`plugin-contract-2026-08-25-declarative-draft.md`](../archive/specs/plugin-contract-2026-08-25-declarative-draft.md). @@ -98,7 +98,7 @@ IR slot, fourth register, or plugin operation. `reconcile` remains optional. name its slot) is adjudicated at the FE-1383 seam, not forked around here. - **Smallest honest plugin.** A file whose `Kinds` table has one row and whose `Must know` table demands one `named` slot must load and run (kernel §11.3). -- **Readability oracle.** Someone who has read `sdcpn-plugin.md` can write the Gherkin plugin +- **Readability oracle.** Someone who has read `plugin-sdcpn/plugin.md` can write the Gherkin plugin file by analogy in a sitting. A harness change that breaks this is a regression even if all tests pass. diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts index f16f4ecdb1d..464f16298bf 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/index.ts @@ -29,23 +29,32 @@ import { SWEEP_RESULT_STATUSES, advanceSweepHighWater, askProtocolInstructionFragments, + buildCompletionCueSignal, buildSettlementCheckSignal, buildReplyBindingSignalPayload, buildSweepExtractionPrompt, + buildSweepList, buildSweepRepairSignal, + completionDemands, + completionProtocolInstructionFragments, computeUnaccountedAskAdvisories, createSweepExtractionResultSchema, createInitialSweepState, decidePendingAffordance, decideSettlementTrigger, + evaluateCompletion, + foldElicitedModel, mintAskAffordance, parseSweepState, pendingSweepRepair, + pluginFileInstructions, reopenSweepAfterRefusal, settlementProtocolInstructionFragments, + slotAssertionExtractionGuidance, sweepableRange, toolName, type CaptureStore, + type CaptureStoreSnapshot, type FreeTextAffordanceValue, type Plugin, type SweepState, @@ -104,6 +113,26 @@ export function useElicitation( let pendingAtFinish = pending; let sweepState = parseSweepState(storedSweepState); const extractionResult = createSweepExtractionResultSchema(plugin); + const { file } = plugin; + const demands = file === undefined ? undefined : completionDemands(file); + // Read-time derivation, never stored: fold the active captures, evaluate + // completion over the objective slices, and render the cue (ADR-0003, + // ADR-0006). Returned as a tool result so the model sees a harness fact + // without any state reaching the instructions. + const completionCue = (snapshot: CaptureStoreSnapshot) => { + if (file === undefined || demands === undefined) return undefined; + const model = foldElicitedModel(snapshot, file); + const report = evaluateCompletion(model, demands); + const sweepList = buildSweepList(model, report, file.patterns); + return { + complete: report.complete, + revision: report.revision, + pluginVersion: report.pluginVersion, + unsatisfied: report.failures.length, + unmapped: model.unmapped, + cue: buildCompletionCueSignal(model, report, sweepList).body, + }; + }; const writeAffordance = useDataWriter("affordance", { schema: FreeTextAffordance, }); @@ -163,10 +192,13 @@ export function useElicitation( await harness.prompt( buildSweepExtractionPrompt( { - targetDomain: plugin.targetDomain, + targetFormalism: plugin.targetFormalism, proposalNames: plugin.proposalCatalog.map( (proposal) => proposal.name, ), + ...(file === undefined + ? {} + : { guidance: slotAssertionExtractionGuidance(file) }), }, range, ), @@ -223,6 +255,9 @@ export function useElicitation( ...("advisories" in applied.value ? applied.value.advisories : []), ...computeUnaccountedAskAdvisories(range, accountedEntryIds), ], + ...(file === undefined + ? {} + : { completion: completionCue(applied.snapshot) }), }, }; }, @@ -258,7 +293,13 @@ export function useElicitation( }); return [ - ...askProtocolInstructionFragments(plugin.targetDomain), + ...askProtocolInstructionFragments(plugin.targetFormalism), ...settlementProtocolInstructionFragments(), + ...(file === undefined + ? [] + : [ + ...completionProtocolInstructionFragments(), + pluginFileInstructions(file), + ]), ].join("\n\n"); } diff --git a/libs/@hashintel/brunch-agent/packages/core/src/ask-protocol.ts b/libs/@hashintel/brunch-agent/packages/core/src/ask-protocol.ts index ffe7d5ddd29..6c76fb73a9b 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/ask-protocol.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/ask-protocol.ts @@ -110,10 +110,10 @@ export function decideAskReplyAdmission( /** Render-invariant instruction fragments for the ask/suspension protocol. */ export function askProtocolInstructionFragments( - targetDomain: string, + targetFormalism: string, ): readonly string[] { return [ - `You are interviewing someone to elicit ${targetDomain}.`, + `You are interviewing someone to elicit ${targetFormalism}.`, `Ask one question at a time with ${toolName("ask")}.`, "Continue the conversation after each reply, using the harness-provided reply binding as a mechanical fact.", ]; diff --git a/libs/@hashintel/brunch-agent/packages/core/src/completion.ts b/libs/@hashintel/brunch-agent/packages/core/src/completion.ts new file mode 100644 index 00000000000..1632980b4da --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/src/completion.ts @@ -0,0 +1,378 @@ +/** + * `evaluateCompletion(model, demands)` — the nineteen invariants of + * `docs/specs/elicitation-completion.md`, as code. + * + * Pure over the register-2 model and the plugin's demand rows. It reads no + * transcript, turn count, session state, delivery, or budget; the same + * `(model, demands)` always yields the same report. The answer is a derived + * boolean plus an evidence-bearing report, never a lifecycle status: nothing + * here is persisted and a later capture can turn `complete` back to `false`. + */ + +import { type EpistemicStatus } from "./capture-store"; +import { + type ElicitedModel, + type ElicitedNode, + type SlotState, +} from "./elicited-model"; +import { + type FloorRow, + type MustKnowRow, + type PluginFile, + type PrecisionDemand, + type PrecisionWord, +} from "./plugin-file"; + +import type { JsonValue } from "./json-value"; + +export const COMPLETION_DIAGNOSTICS = [ + "version-mismatch", + "below-minimum-count", + "unsupported-active-objective", + "unaddressed", + "no-selected-slot", + "inadmissible-status", + "unaccepted-absence", + "below-required-precision", + "open-conflict", + "unresolved-divergence", + "missing-evidence", +] as const; + +export type CompletionDiagnostic = (typeof COMPLETION_DIAGNOSTICS)[number]; + +export interface CompletionFailure { + readonly diagnostic: CompletionDiagnostic; + readonly nodeId?: string; + readonly kind?: string; + readonly slot?: string; + /** What the row demands, in the row's own words. */ + readonly requirement: string; + /** What the model holds. */ + readonly actual: string; + readonly message: string; + /** Supporting captures reached through the slot's support links. */ + readonly captureIds: readonly string[]; +} + +export interface OutsideSliceNode { + readonly nodeId: string; + readonly kind: string; + /** Open issues on nodes no active objective depends on: visible, not blocking. */ + readonly open: readonly CompletionFailure[]; +} + +export interface CompletionReport { + readonly complete: boolean; + readonly pluginVersion: string; + readonly revision: string; + readonly failures: readonly CompletionFailure[]; + /** Nodes some active objective depends on (objectives included). */ + readonly sliceNodeIds: readonly string[]; + readonly outsideSlice: readonly OutsideSliceNode[]; +} + +/** + * Which kind anchors question-relative demand and which of its slots names the + * dependency slice. Derived from the file by convention: the kind named + * `objective` and its single `at least N` row. Absent both, every node is demanded. + */ +export interface CompletionAnchor { + readonly kind: string; + readonly dependencySlot: string; + readonly atLeast: number; +} + +export interface CompletionDemands { + readonly pluginVersion: string; + readonly floor: readonly FloorRow[]; + readonly rows: readonly MustKnowRow[]; + /** Statuses a value may carry and count. Confirmation of an inference is itself an explicit capture. */ + readonly acceptedStatuses: readonly EpistemicStatus[]; + readonly anchor?: CompletionAnchor; +} + +export const ANCHOR_KIND = "objective"; + +/** The demands one plugin file states, with the SDCPN default for accepted statuses. */ +export const completionDemands = ( + file: PluginFile, + options: { readonly acceptedStatuses?: readonly EpistemicStatus[] } = {}, +): CompletionDemands => { + const anchorRow = file.mustKnow.find( + (row) => row.kind === ANCHOR_KIND && row.precision.kind === "at-least", + ); + return { + pluginVersion: file.version, + floor: file.floor, + rows: file.mustKnow, + acceptedStatuses: options.acceptedStatuses ?? ["explicit"], + ...(anchorRow && anchorRow.precision.kind === "at-least" + ? { + anchor: { + kind: anchorRow.kind, + dependencySlot: anchorRow.slot, + atLeast: anchorRow.precision.count, + }, + } + : {}), + }; +}; + +const LADDER: Readonly> = { + named: 0, + number: 1, + range: 2, + spread: 3, + "spelled out": null, +}; + +/** + * Whether a value at `given` precision meets `demanded`. Numeric words form a + * ladder (`spread` ⊃ `range` ⊃ `number` ⊃ `named`); `spelled out` is a + * structure, satisfied only by itself, and it counts as `named`. + */ +export const precisionSatisfies = ( + given: PrecisionWord, + demanded: PrecisionWord, +): boolean => { + if (demanded === "spelled out") return given === "spelled out"; + if (given === "spelled out") return demanded === "named"; + return LADDER[given]! >= LADDER[demanded]!; +}; + +const describeDemand = (precision: PrecisionDemand): string => + precision.kind === "word" ? precision.word : `at least ${precision.count}`; + +const isEmptySelection = (value: JsonValue): boolean => + value === null || + value === "" || + (Array.isArray(value) && value.length === 0) || + (typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 0); + +const describeSlot = (slot: SlotState | undefined): string => { + if (slot === undefined) return "not mentioned"; + switch (slot.state) { + case "value": + return `${slot.precision} value under status ${slot.status}`; + case "absence": + return `absence: ${slot.absence}${slot.pointer ? ` (source: ${slot.pointer})` : ""}`; + case "conflict": + return `${slot.readings.length} competing active readings`; + case "divergence": + return "prescribed and practiced readings diverge"; + } +}; + +const ACCEPTED_ABSENCES = new Set(["not-applicable", "explicitly-absent"]); + +const evaluateRow = ( + node: ElicitedNode, + row: MustKnowRow, + demands: CompletionDemands, + model: ElicitedModel, +): CompletionFailure | null => { + const slot = node.slots[row.slot]; + const base = { + nodeId: node.id, + kind: node.kind, + slot: row.slot, + requirement: describeDemand(row.precision), + actual: describeSlot(slot), + captureIds: slot?.captureIds ?? [], + }; + const fail = ( + diagnostic: CompletionDiagnostic, + message: string, + ): CompletionFailure => ({ ...base, diagnostic, message }); + + if (slot === undefined) { + return fail( + "unaddressed", + `"${row.slot}" has not been addressed on ${node.id}.`, + ); + } + if (slot.state === "conflict") { + return fail( + "open-conflict", + `"${row.slot}" on ${node.id} has competing active captures; an explicit, user-cited resolution must close it.`, + ); + } + if (slot.state === "divergence") { + return fail( + "unresolved-divergence", + `"${row.slot}" on ${node.id} differs between the prescribed and the practiced reading; the expert must resolve which the model follows.`, + ); + } + if (!demands.acceptedStatuses.includes(slot.status)) { + return fail( + "inadmissible-status", + `"${row.slot}" on ${node.id} is held under status ${slot.status}; accepted: ${demands.acceptedStatuses.join(", ")}.`, + ); + } + if ( + !slot.evidenced || + slot.captureIds.some((id) => !model.activeCaptureIds.has(id)) + ) { + return fail( + "missing-evidence", + `"${row.slot}" on ${node.id} is not backed by active, traceable user evidence.`, + ); + } + if (slot.state === "absence") { + if (!ACCEPTED_ABSENCES.has(slot.absence)) { + return fail( + "unaddressed", + `"${row.slot}" on ${node.id} is open: the expert answered "${slot.absence}"${slot.pointer ? `, pointing at ${slot.pointer}` : ""}; that is not a value.`, + ); + } + if (!row.notApplicableAllowed) { + return fail( + "unaccepted-absence", + `"${row.slot}" on ${node.id} was declared ${slot.absence}, but this row does not allow an absence.`, + ); + } + return null; + } + if (isEmptySelection(slot.value)) { + return fail( + "no-selected-slot", + `"${row.slot}" on ${node.id} selects nothing; a demand never passes through an empty selection.`, + ); + } + if (row.precision.kind === "at-least") { + const count = Array.isArray(slot.value) ? slot.value.length : 1; + return count >= row.precision.count + ? null + : fail( + "below-minimum-count", + `"${row.slot}" on ${node.id} lists ${count}; at least ${row.precision.count} needed.`, + ); + } + if (!precisionSatisfies(slot.precision, row.precision.word)) { + return fail( + "below-required-precision", + `"${row.slot}" on ${node.id} is known as a ${slot.precision}; the model needs ${row.precision.word}. Smallest delta: move it from ${slot.precision} to ${row.precision.word}.`, + ); + } + return null; +}; + +const dependencyIds = (slot: SlotState | undefined): readonly string[] => + slot?.state === "value" && Array.isArray(slot.value) + ? slot.value.filter((entry): entry is string => typeof entry === "string") + : []; + +export function evaluateCompletion( + model: ElicitedModel, + demands: CompletionDemands, +): CompletionReport { + const header = { + pluginVersion: demands.pluginVersion, + revision: model.revision, + }; + if (model.pluginVersion !== demands.pluginVersion) { + return { + ...header, + complete: false, + failures: [ + { + diagnostic: "version-mismatch", + requirement: `rows of plugin version ${demands.pluginVersion}`, + actual: `model folded under ${model.pluginVersion}`, + message: + "The model and the demand rows come from different plugin versions; refold and retry.", + captureIds: [], + }, + ], + sliceNodeIds: [], + outsideSlice: [], + }; + } + + const failures: CompletionFailure[] = []; + + for (const floor of demands.floor) { + const count = model.nodes.filter((node) => node.kind === floor.kind).length; + if (count < floor.atLeast) { + failures.push({ + diagnostic: "below-minimum-count", + kind: floor.kind, + requirement: `at least ${floor.atLeast} ${floor.kind}`, + actual: `${count}`, + message: `The model has ${count} ${floor.kind} node(s); the floor needs ${floor.atLeast}.`, + captureIds: [], + }); + } + } + + const byId = new Map(model.nodes.map((node) => [node.id, node])); + const slice = new Set(); + const { anchor } = demands; + if (anchor === undefined) { + for (const node of model.nodes) slice.add(node.id); + } else { + for (const objective of model.nodes.filter( + (node) => node.kind === anchor.kind, + )) { + slice.add(objective.id); + const slot = objective.slots[anchor.dependencySlot]; + const wanted = dependencyIds(slot); + const resolved = wanted.filter((id) => byId.has(id)); + const dangling = wanted.filter((id) => !byId.has(id)); + if (resolved.length < anchor.atLeast) { + failures.push({ + diagnostic: "unsupported-active-objective", + nodeId: objective.id, + kind: objective.kind, + slot: anchor.dependencySlot, + requirement: `at least ${anchor.atLeast} node the objective depends on`, + actual: + slot === undefined + ? "not mentioned" + : `${resolved.length} resolved${dangling.length > 0 ? `, ${dangling.length} naming no node in the model (${dangling.join(", ")})` : ""}`, + message: `${objective.id} depends on nothing the model contains; an objective that depends on nothing is unsupported.`, + captureIds: slot?.captureIds ?? [], + }); + } + for (const id of resolved) slice.add(id); + } + } + + const rowsFor = (kind: string): MustKnowRow[] => + demands.rows.filter( + (row) => + row.kind === kind && + !( + anchor !== undefined && + kind === anchor.kind && + row.slot === anchor.dependencySlot + ), + ); + + const outsideSlice: OutsideSliceNode[] = []; + for (const node of model.nodes) { + const rowFailures = rowsFor(node.kind) + .map((row) => evaluateRow(node, row, demands, model)) + .filter((failure): failure is CompletionFailure => failure !== null); + if (slice.has(node.id)) { + failures.push(...rowFailures); + } else { + outsideSlice.push({ + nodeId: node.id, + kind: node.kind, + open: rowFailures, + }); + } + } + + return { + ...header, + complete: failures.length === 0, + failures, + sliceNodeIds: [...slice].sort(), + outsideSlice, + }; +} diff --git a/libs/@hashintel/brunch-agent/packages/core/src/cue.ts b/libs/@hashintel/brunch-agent/packages/core/src/cue.ts new file mode 100644 index 00000000000..d3de170da75 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/src/cue.ts @@ -0,0 +1,111 @@ +/** + * The cue — what the harness tells the interviewer after it has read the model. + * + * A sweep list is the completion report's failures plus the patterns whose + * kind-index matches a node that still has one. It is a harness fact, so it + * reaches the model as a tool result or a signal entry, never interpolated + * into instructions (Flue routing: "you need the model to see a harness fact"). + * Patterns are surfaced, never mandated; the interviewer decides. + */ + +import { type CompletionFailure, type CompletionReport } from "./completion"; +import { type ElicitedModel } from "./elicited-model"; +import { type PatternRow } from "./plugin-file"; + +export interface PatternCue { + readonly id: string; + readonly nodeId: string; + readonly ask: string; +} + +export interface SweepList { + readonly unsatisfied: readonly CompletionFailure[]; + readonly patterns: readonly PatternCue[]; +} + +export const buildSweepList = ( + model: ElicitedModel, + report: CompletionReport, + patterns: readonly PatternRow[], +): SweepList => { + const failingNodeIds = new Set( + report.failures.flatMap((failure) => + failure.nodeId === undefined ? [] : [failure.nodeId], + ), + ); + const cues: PatternCue[] = []; + for (const node of model.nodes) { + if (!failingNodeIds.has(node.id)) continue; + for (const pattern of patterns) { + if (pattern.kinds.includes(node.kind)) { + cues.push({ id: pattern.id, nodeId: node.id, ask: pattern.ask }); + } + } + } + return { unsatisfied: report.failures, patterns: cues }; +}; + +export interface CompletionCueSignal { + readonly type: "completion-cue"; + readonly tagName: "completion-cue"; + readonly body: string; +} + +const renderFailure = (failure: CompletionFailure): string => + `- [${failure.diagnostic}] ${failure.message}`; + +export const buildCompletionCueSignal = ( + model: ElicitedModel, + report: CompletionReport, + sweepList: SweepList, + options: { readonly maxItems?: number } = {}, +): CompletionCueSignal => { + const maxItems = options.maxItems ?? 12; + const shown = sweepList.unsatisfied.slice(0, maxItems); + const hidden = sweepList.unsatisfied.length - shown.length; + const nodeSummary = `${model.nodes.length} node(s) from ${model.activeCaptureIds.size} active capture(s)${model.unmapped.length > 0 ? `; ${model.unmapped.length} capture(s) could not be mapped to a kind and slot` : ""}`; + const parts = [ + `The harness folded the model at revision ${report.revision} (plugin ${report.pluginVersion}): ${nodeSummary}. Complete: ${report.complete ? "yes" : "no"}.`, + ]; + if (shown.length > 0) { + parts.push( + [ + "Unsatisfied, in file order:", + ...shown.map(renderFailure), + ...(hidden > 0 ? [`- … and ${hidden} more.`] : []), + ].join("\n"), + ); + } + if (sweepList.patterns.length > 0) { + const byPattern = new Map(); + for (const cue of sweepList.patterns) { + if (!byPattern.has(cue.id)) byPattern.set(cue.id, cue); + } + parts.push( + [ + "Patterns whose trigger may apply (discretionary):", + ...[...byPattern.values()] + .slice(0, maxItems) + .map((cue) => `- ${cue.id} on ${cue.nodeId}: ${cue.ask}`), + ].join("\n"), + ); + } + if (report.outsideSlice.length > 0) { + parts.push( + `${report.outsideSlice.length} node(s) lie outside every objective's dependency slice and are recorded but not demanded.`, + ); + } + parts.push( + "Completion is computed from the model, never from the conversation; it does not decide whether to continue. Choose the next question, or none.", + ); + return { + type: "completion-cue", + tagName: "completion-cue", + body: parts.join("\n\n"), + }; +}; + +export const completionProtocolInstructionFragments = (): readonly string[] => [ + "After each applied sweep the harness folds the active captures into the model and reports which demanded slots are unsatisfied and why, with the patterns whose trigger may apply. Read it as a map of what is still unknown, not as an instruction to ask.", + "A slot is satisfied only by what the expert said or confirmed, at the precision the row demands. Never state a value the expert did not give; record what you would assume in the assumption ledger and ask.", +]; diff --git a/libs/@hashintel/brunch-agent/packages/core/src/elicited-model.ts b/libs/@hashintel/brunch-agent/packages/core/src/elicited-model.ts new file mode 100644 index 00000000000..131d2c6130b --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/src/elicited-model.ts @@ -0,0 +1,296 @@ +/** + * Register 2 — the elicited model, derived and never stored (ADR-0003). + * + * `foldElicitedModel` is a pure function of a capture-store snapshot and a + * plugin file. It keeps only active captures, reads each one's slot assertion, + * and groups them into nodes and slots. It is forbidden to interpret: a payload + * it cannot read becomes an `unmapped` entry, two competing readings become a + * `conflict`, a manual-versus-practice split becomes a `divergence`. Every slot + * state answers "which captures made you" through its capture ids. + */ + +import * as v from "valibot"; + +import { + deriveCaptureStatus, + deriveIssueStatus, + type AbsenceState, + type CaptureEnvelope, + type CaptureStoreSnapshot, + type EpistemicStatus, +} from "./capture-store"; +import { type PluginFile, type PrecisionWord } from "./plugin-file"; +import { + createSlotAssertionSchema, + nodeId, + type SlotAssertion, + type SourceRegime, +} from "./slot-assertion"; + +import type { JsonValue } from "./json-value"; + +export interface SlotReading { + readonly captureId: string; + readonly status: EpistemicStatus; + /** Whether user evidence spans back the capture (false for defaults and lookups). */ + readonly evidenced: boolean; + readonly assertion: SlotAssertion; +} + +export type SlotState = + | { + readonly state: "value"; + readonly value: JsonValue; + readonly precision: PrecisionWord; + readonly status: EpistemicStatus; + readonly evidenced: boolean; + readonly sourceRegime?: SourceRegime; + readonly rationale?: string; + readonly captureIds: readonly string[]; + } + | { + readonly state: "absence"; + readonly absence: AbsenceState; + readonly pointer?: string; + readonly status: EpistemicStatus; + readonly evidenced: boolean; + readonly captureIds: readonly string[]; + } + | { + readonly state: "conflict"; + readonly readings: readonly SlotReading[]; + readonly captureIds: readonly string[]; + } + | { + readonly state: "divergence"; + readonly prescribed: SlotReading; + readonly practiced: SlotReading; + readonly captureIds: readonly string[]; + }; + +export interface ElicitedNode { + readonly id: string; + readonly kind: string; + readonly name: string; + readonly slots: Readonly>; +} + +export interface UnmappedCapture { + readonly captureId: string; + readonly reason: string; +} + +export interface ElicitedModel { + readonly pluginVersion: string; + /** Content digest of the active captures and open conflicts this model was folded from. */ + readonly revision: string; + readonly nodes: readonly ElicitedNode[]; + readonly unmapped: readonly UnmappedCapture[]; + readonly activeCaptureIds: ReadonlySet; +} + +const canonical = (value: unknown): string => + JSON.stringify(value, (_key, inner: unknown) => + inner !== null && typeof inner === "object" && !Array.isArray(inner) + ? Object.fromEntries( + Object.entries(inner as Record).sort(([a], [b]) => + a.localeCompare(b), + ), + ) + : inner, + ); + +/** A stable, dependency-free digest; not cryptographic, only a revision label. */ +const digest = (text: string): string => { + const primeA = 1_000_000_007; + const primeB = 998_244_353; + let a = 17; + let b = 31; + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + a = (a * 131 + code) % primeA; + b = (b * 137 + code) % primeB; + } + return `${a.toString(16).padStart(8, "0")}${b.toString(16).padStart(8, "0")}`; +}; + +const readingKey = (reading: SlotReading): string => + canonical({ + assertion: reading.assertion.assertion, + precision: reading.assertion.precision ?? null, + sourceRegime: reading.assertion.sourceRegime ?? null, + }); + +const preferredStatus = (readings: readonly SlotReading[]): EpistemicStatus => + readings.find((reading) => reading.status === "explicit")?.status ?? + readings[0]!.status; + +const settleSlot = ( + readings: readonly SlotReading[], + conflictedCaptureIds: ReadonlySet, +): SlotState => { + const captureIds = readings.map((reading) => reading.captureId); + if (readings.some((reading) => conflictedCaptureIds.has(reading.captureId))) { + return { state: "conflict", readings, captureIds }; + } + const distinct = new Map(); + for (const reading of readings) { + if (!distinct.has(readingKey(reading))) { + distinct.set(readingKey(reading), reading); + } + } + if (distinct.size > 1) { + const prescribed = readings.filter( + (reading) => reading.assertion.sourceRegime === "prescribed", + ); + const practiced = readings.filter( + (reading) => reading.assertion.sourceRegime === "practiced", + ); + if ( + prescribed.length === 1 && + practiced.length === 1 && + readings.length === 2 + ) { + return { + state: "divergence", + prescribed: prescribed[0]!, + practiced: practiced[0]!, + captureIds, + }; + } + return { state: "conflict", readings, captureIds }; + } + const [first] = readings; + const status = preferredStatus(readings); + const evidenced = readings.some((reading) => reading.evidenced); + const { assertion } = first!; + if ("absence" in assertion.assertion) { + return { + state: "absence", + absence: assertion.assertion.absence, + ...(assertion.assertion.pointer === undefined + ? {} + : { pointer: assertion.assertion.pointer }), + status, + evidenced, + captureIds, + }; + } + return { + state: "value", + value: assertion.assertion.value, + // The schema requires a precision word on every value. + precision: assertion.precision!, + status, + evidenced, + ...(assertion.sourceRegime === undefined + ? {} + : { sourceRegime: assertion.sourceRegime }), + ...(assertion.rationale === undefined + ? {} + : { rationale: assertion.rationale }), + captureIds, + }; +}; + +const isEvidenced = (capture: CaptureEnvelope): boolean => + "evidence" in capture && capture.evidence.length > 0; + +/** Fold the active captures of one snapshot into the model a plugin file describes. */ +export function foldElicitedModel( + snapshot: CaptureStoreSnapshot, + file: PluginFile, +): ElicitedModel { + const assertionSchema = createSlotAssertionSchema(file); + const active = snapshot.captures.filter( + (capture) => deriveCaptureStatus(snapshot, capture.id) === "active", + ); + const activeCaptureIds = new Set(active.map((capture) => capture.id)); + const openConflictIssues = snapshot.issues.filter( + (issue) => + issue.type === "conflicting" && + deriveIssueStatus(snapshot, issue.id) === "open", + ); + const conflictedCaptureIds = new Set( + openConflictIssues.flatMap((issue) => issue.references), + ); + + const unmapped: UnmappedCapture[] = []; + const readingsByNode = new Map< + string, + { kind: string; name: string; slots: Map } + >(); + + for (const capture of active) { + if ("absence" in capture.content) { + unmapped.push({ + captureId: capture.id, + reason: + "An envelope-level absence carries no kind, node, or slot; record absences inside a slot assertion.", + }); + continue; + } + const parsed = v.safeParse(assertionSchema, capture.content.value); + if (!parsed.success) { + unmapped.push({ + captureId: capture.id, + reason: parsed.issues.map((issue) => issue.message).join(" "), + }); + continue; + } + const { output: assertion } = parsed; + const id = nodeId(assertion.kind, assertion.node); + const node = readingsByNode.get(id) ?? { + kind: assertion.kind, + name: assertion.node, + slots: new Map(), + }; + readingsByNode.set(id, node); + const readings = node.slots.get(assertion.slot) ?? []; + readings.push({ + captureId: capture.id, + status: capture.epistemicStatus, + evidenced: isEvidenced(capture), + assertion, + }); + node.slots.set(assertion.slot, readings); + } + + const nodes: ElicitedNode[] = [...readingsByNode.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, node]) => ({ + id, + kind: node.kind, + name: node.name, + slots: Object.fromEntries( + [...node.slots.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([slot, readings]) => [ + slot, + settleSlot(readings, conflictedCaptureIds), + ]), + ), + })); + + const revision = digest( + canonical({ + active: [...activeCaptureIds].sort(), + conflicts: openConflictIssues.map((issue) => issue.id).sort(), + plugin: file.version, + }), + ); + + return { + pluginVersion: file.version, + revision, + nodes, + unmapped, + activeCaptureIds, + }; +} + +/** The node with this id, if the model has it. */ +export const findNode = ( + model: ElicitedModel, + id: string, +): ElicitedNode | undefined => model.nodes.find((node) => node.id === id); diff --git a/libs/@hashintel/brunch-agent/packages/core/src/index.ts b/libs/@hashintel/brunch-agent/packages/core/src/index.ts index 76ee8da9f85..81f3ced1001 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/index.ts @@ -47,6 +47,61 @@ export { type Plugin, type PluginProposalType, } from "./plugin"; +export { + mustKnowRowsFor, + parsePluginFile, + PLUGIN_FILE_HEADINGS, + PluginFileError, + pluginFileInstructions, + PRECISION_WORDS, + type FloorRow, + type KindRow, + type MustKnowRow, + type PatternRow, + type PluginFile, + type PluginFileHeading, + type PrecisionDemand, + type PrecisionWord, +} from "./plugin-file"; +export { + createSlotAssertionSchema, + nodeId, + slotAssertionExtractionGuidance, + SlotAssertionSchema, + SOURCE_REGIMES, + type SlotAssertion, + type SourceRegime, +} from "./slot-assertion"; +export { + findNode, + foldElicitedModel, + type ElicitedModel, + type ElicitedNode, + type SlotReading, + type SlotState, + type UnmappedCapture, +} from "./elicited-model"; +export { + ANCHOR_KIND, + COMPLETION_DIAGNOSTICS, + completionDemands, + evaluateCompletion, + precisionSatisfies, + type CompletionAnchor, + type CompletionDemands, + type CompletionDiagnostic, + type CompletionFailure, + type CompletionReport, + type OutsideSliceNode, +} from "./completion"; +export { + buildCompletionCueSignal, + buildSweepList, + completionProtocolInstructionFragments, + type CompletionCueSignal, + type PatternCue, + type SweepList, +} from "./cue"; export { ABSENCE_STATES, CaptureInputProposalSchema, diff --git a/libs/@hashintel/brunch-agent/packages/core/src/plugin-file.ts b/libs/@hashintel/brunch-agent/packages/core/src/plugin-file.ts new file mode 100644 index 00000000000..81d528eb394 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/src/plugin-file.ts @@ -0,0 +1,375 @@ +/** + * The plugin file — one sectioned Markdown document per target formalism + * (ADR-0006; `docs/specs/plugin-contract.md`). + * + * The harness reads three tables by machine: `## Kinds` is the closed node-kind + * catalog, `## Must know` is the demand list (one row per kind and slot, plus + * the static floor stated in prose beneath it), and `## Patterns` is the + * kind-indexed pattern index. Every section's prose, including the sections + * around those tables, is kept verbatim so the binding can hand it to the + * interviewer as instructions. The parser is strict: a missing, renamed, or + * reordered contract heading, an unknown column, an unknown precision word, or + * a demand row for a kind the catalog lacks makes the file fail to load rather + * than load with a hole. + * + * Nothing here knows a domain or a formalism. The SDCPN file is the exemplar + * the column and value vocabularies were fixed against; a second file that + * needs a new heading is a finding for ADR-0006, not a parser feature. + */ + +export const PLUGIN_FILE_HEADINGS = [ + "Purpose", + "Kinds", + "Must know", + "Patterns", + "Moves", + "Deliverable", +] as const; + +export type PluginFileHeading = (typeof PLUGIN_FILE_HEADINGS)[number]; + +/** Precision words a value can carry. `at least N` is a count, not a word. */ +export const PRECISION_WORDS = [ + "named", + "number", + "range", + "spread", + "spelled out", +] as const; + +export type PrecisionWord = (typeof PRECISION_WORDS)[number]; + +export type PrecisionDemand = + | { readonly kind: "word"; readonly word: PrecisionWord } + | { readonly kind: "at-least"; readonly count: number }; + +export interface KindRow { + readonly kind: string; + readonly description: string; + readonly projectsTo: string; +} + +export interface MustKnowRow { + readonly kind: string; + readonly slot: string; + readonly precision: PrecisionDemand; + readonly notApplicableAllowed: boolean; + readonly why: string; +} + +export interface FloorRow { + readonly kind: string; + readonly atLeast: number; +} + +export interface PatternRow { + readonly id: string; + readonly when: string; + readonly ask: string; + /** Kinds named in `when`; the mechanical half of the trigger. */ + readonly kinds: readonly string[]; +} + +export interface PluginFile { + /** Immutable version string from the header, e.g. `sdcpn/2026-08-25.1`. */ + readonly version: string; + readonly kinds: readonly KindRow[]; + readonly mustKnow: readonly MustKnowRow[]; + readonly floor: readonly FloorRow[]; + readonly patterns: readonly PatternRow[]; + /** Each section's Markdown body, heading line excluded, in contract order. */ + readonly sections: Readonly>; +} + +export class PluginFileError extends Error { + constructor(message: string) { + super(message); + this.name = "PluginFileError"; + } +} + +const KINDS_COLUMNS = ["#", "kind", "what it is", "projects to"] as const; +const MUST_KNOW_COLUMNS = [ + "kind", + "slot", + "precision", + '"not applicable" allowed', + "why the model needs it", +] as const; +const PATTERNS_COLUMNS = ["id", "when", "ask"] as const; + +const NUMBER_WORDS: Readonly> = { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + seven: 7, + eight: 8, + nine: 9, + ten: 10, +}; + +const stripCode = (cell: string): string => cell.replace(/^`(.*)`$/u, "$1"); + +interface Table { + readonly header: readonly string[]; + readonly rows: readonly (readonly string[])[]; +} + +/** + * The first GFM table in a block of Markdown: a header line, a separator line, + * then body rows, all starting with `|`. Cells split on bare `|`, which is exact + * for the exemplar and would need revisiting only for an escaped `\|`. + */ +const firstTable = (markdown: string, where: string): Table => { + const lines = markdown.split("\n"); + const start = lines.findIndex((line) => line.trimStart().startsWith("|")); + if (start === -1) { + throw new PluginFileError(`\`## ${where}\` has no table.`); + } + const tableLines: string[] = []; + for (const line of lines.slice(start)) { + if (!line.trimStart().startsWith("|")) break; + tableLines.push(line.trim()); + } + const [headerLine, separator, ...bodyLines] = tableLines; + if ( + headerLine === undefined || + separator === undefined || + !/^\|(?:\s*:?-+:?\s*\|)+$/u.test(separator) + ) { + throw new PluginFileError( + `\`## ${where}\`: the first table lacks a header and separator row.`, + ); + } + const splitCells = (line: string): string[] => + line + .replace(/^\|/u, "") + .replace(/\|$/u, "") + .split("|") + .map((cell) => cell.trim()); + const header = splitCells(headerLine); + const rows = bodyLines.map((line, index) => { + const cells = splitCells(line); + if (cells.length !== header.length) { + throw new PluginFileError( + `\`## ${where}\` row ${index + 1} has ${cells.length} cells; the header has ${header.length}.`, + ); + } + return cells; + }); + return { header, rows }; +}; + +const expectColumns = ( + table: Table, + expected: readonly string[], + where: string, +): void => { + if ( + table.header.length !== expected.length || + table.header.some((column, index) => column !== expected[index]) + ) { + throw new PluginFileError( + `\`## ${where}\` columns must be exactly [${expected.join(", ")}]; found [${table.header.join(", ")}].`, + ); + } +}; + +const parsePrecision = (cell: string, where: string): PrecisionDemand => { + const atLeast = /^at least (\d+)$/u.exec(cell); + if (atLeast) { + return { kind: "at-least", count: Number(atLeast[1]) }; + } + const word = PRECISION_WORDS.find((candidate) => candidate === cell); + if (word === undefined) { + throw new PluginFileError( + `${where}: precision \`${cell}\` is not one of ${PRECISION_WORDS.map((candidate) => `\`${candidate}\``).join(", ")} or \`at least N\`.`, + ); + } + return { kind: "word", word }; +}; + +const parseYesNo = (cell: string, where: string): boolean => { + if (cell === "yes") return true; + if (cell === "no") return false; + throw new PluginFileError( + `${where}: expected \`yes\` or \`no\`, found \`${cell}\`.`, + ); +}; + +const parseSections = ( + markdown: string, +): { header: string; sections: Record } => { + const lines = markdown.split("\n"); + const headings: Array<{ title: string; line: number }> = []; + let inFence = false; + for (const [index, line] of lines.entries()) { + if (line.startsWith("```")) inFence = !inFence; + if (inFence) continue; + const match = /^## (.+?)\s*$/u.exec(line); + if (match) headings.push({ title: match[1]!, line: index }); + } + const found = headings.map((heading) => heading.title); + if ( + found.length !== PLUGIN_FILE_HEADINGS.length || + found.some((title, index) => title !== PLUGIN_FILE_HEADINGS[index]) + ) { + throw new PluginFileError( + `Contract headings must be exactly [${PLUGIN_FILE_HEADINGS.join(" · ")}] in that order; found [${found.join(" · ")}].`, + ); + } + const header = lines.slice(0, headings[0]!.line).join("\n"); + const sections = Object.fromEntries( + headings.map((heading, index) => { + const end = headings[index + 1]?.line ?? lines.length; + return [ + heading.title, + lines + .slice(heading.line + 1, end) + .join("\n") + .trim(), + ]; + }), + ) as Record; + return { header, sections }; +}; + +const parseFloor = ( + mustKnowSection: string, + kinds: ReadonlySet, +): FloorRow[] => { + const paragraph = mustKnowSection + .split(/\n\s*\n/u) + .find((block) => /^\s*Static floor\b/u.test(block)); + if (paragraph === undefined) { + throw new PluginFileError( + "`## Must know` must state the static floor in a paragraph beginning `Static floor`.", + ); + } + const floor: FloorRow[] = []; + for (const match of paragraph.matchAll( + /at least (one|two|three|four|five|six|seven|eight|nine|ten|\d+)\s+`([^`]+)`/gu, + )) { + const count = NUMBER_WORDS[match[1]!] ?? Number(match[1]); + const kind = match[2]!; + if (!kinds.has(kind)) { + throw new PluginFileError( + `Static floor names \`${kind}\`, which is not in \`## Kinds\`.`, + ); + } + floor.push({ kind, atLeast: count }); + } + if (floor.length === 0) { + throw new PluginFileError( + "Static floor names no kind; expected phrases like `at least one `objective``.", + ); + } + return floor; +}; + +/** Parse one plugin file. Throws `PluginFileError` when the contract is violated. */ +export function parsePluginFile(markdown: string): PluginFile { + const { header, sections } = parseSections(markdown); + + const version = /Version:\s*`([^`]+)`/u.exec(header)?.[1]; + if (version === undefined) { + throw new PluginFileError( + "The header must declare an immutable version as `Version: `/.``.", + ); + } + + const kindsTable = firstTable(sections.Kinds, "Kinds"); + expectColumns(kindsTable, KINDS_COLUMNS, "Kinds"); + const kinds: KindRow[] = kindsTable.rows.map((cells) => ({ + kind: stripCode(cells[1]!), + description: cells[2]!, + projectsTo: cells[3]!, + })); + const kindNames = new Set(); + for (const row of kinds) { + if (row.kind === "" || kindNames.has(row.kind)) { + throw new PluginFileError( + `\`## Kinds\` has an empty or repeated kind: \`${row.kind}\`.`, + ); + } + kindNames.add(row.kind); + } + + const mustKnowTable = firstTable(sections["Must know"], "Must know"); + expectColumns(mustKnowTable, MUST_KNOW_COLUMNS, "Must know"); + const slotKeys = new Set(); + const mustKnow: MustKnowRow[] = mustKnowTable.rows.map((cells, index) => { + const where = `\`## Must know\` row ${index + 1}`; + const kind = stripCode(cells[0]!); + const slot = cells[1]!; + if (!kindNames.has(kind)) { + throw new PluginFileError( + `${where} names \`${kind}\`, which is not in \`## Kinds\`.`, + ); + } + if (slot === "") { + throw new PluginFileError(`${where} has an empty slot.`); + } + const key = `${kind}${slot}`; + if (slotKeys.has(key)) { + throw new PluginFileError( + `${where} repeats the slot \`${slot}\` on \`${kind}\`.`, + ); + } + slotKeys.add(key); + return { + kind, + slot, + precision: parsePrecision(cells[2]!, where), + notApplicableAllowed: parseYesNo(cells[3]!, where), + why: cells[4]!, + }; + }); + for (const kind of kindNames) { + if (!mustKnow.some((row) => row.kind === kind)) { + throw new PluginFileError( + `\`## Must know\` has no row for kind \`${kind}\`; every kind needs at least one.`, + ); + } + } + + const floor = parseFloor(sections["Must know"], kindNames); + + const patternsTable = firstTable(sections.Patterns, "Patterns"); + expectColumns(patternsTable, PATTERNS_COLUMNS, "Patterns"); + const patternIds = new Set(); + const patterns: PatternRow[] = patternsTable.rows.map((cells, index) => { + const id = cells[0]!; + if (id === "" || patternIds.has(id)) { + throw new PluginFileError( + `\`## Patterns\` row ${index + 1} has an empty or repeated id: \`${id}\`.`, + ); + } + patternIds.add(id); + const when = cells[1]!; + const named = [...when.matchAll(/`([^`]+)`/gu)].map((match) => match[1]!); + return { + id, + when, + ask: cells[2]!, + kinds: [...new Set(named.filter((name) => kindNames.has(name)))], + }; + }); + + return { version, kinds, mustKnow, floor, patterns, sections }; +} + +/** Every section, in contract order, as one instruction document. */ +export const pluginFileInstructions = (file: PluginFile): string => + PLUGIN_FILE_HEADINGS.map( + (heading) => `## ${heading}\n\n${file.sections[heading]}`, + ).join("\n\n"); + +/** The demand rows for one kind, in file order. */ +export const mustKnowRowsFor = ( + file: PluginFile, + kind: string, +): readonly MustKnowRow[] => file.mustKnow.filter((row) => row.kind === kind); diff --git a/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts b/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts index 4861710b030..d258c003a89 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/plugin.ts @@ -1,6 +1,7 @@ import * as v from "valibot"; import type { CaptureInputProposal } from "./capture-store"; +import type { PluginFile } from "./plugin-file"; /** * The plugin descriptor — identity only, at this stage. @@ -10,8 +11,13 @@ import type { CaptureInputProposal } from "./capture-store"; * says the trivial target must not freeze the plugin contract before the hard * target has stressed it, so nothing in this scaffold ratifies the SDK export * surface. What the descriptor fixes now is only what the topology needs — - * that a plugin declares which target-domain it defines, and does so through + * that a plugin declares which target formalism it defines, and does so through * Valibot like every other boundary in the system (spec §12.4). + * + * ADR-0006 adds the plugin file: a per-formalism sectioned Markdown document + * whose three tables parameterise the harness's fold, completion, and cue. A + * plugin that carries one is a kind-and-slot plugin and proposes slot + * assertions; the tracer plugin still has none. */ export const PluginDescriptor = v.object({ /** Package-level identity, matching the `plugin-*` role prefix (spec §12.2). */ @@ -19,8 +25,8 @@ export const PluginDescriptor = v.object({ v.string(), v.regex(/^plugin-[a-z][a-z0-9-]*$/, "expected a `plugin-` name"), ), - /** The artifact family this plugin elicits — gherkin scenarios, assurance arguments. */ - targetDomain: v.pipe(v.string(), v.nonEmpty()), + /** The target formalism this plugin elicits toward — gherkin, sdcpn — never a domain. */ + targetFormalism: v.pipe(v.string(), v.nonEmpty()), }); export interface PluginProposalType { @@ -32,6 +38,8 @@ export interface PluginProposalType { export type Plugin = v.InferOutput & { /** FE-1392's declared floor; FE-1393 grows the catalog and SDK around it. */ readonly proposalCatalog: readonly [PluginProposalType]; + /** The parsed plugin file (ADR-0006); absent for a plugin without one. */ + readonly file?: PluginFile; }; /** @@ -57,5 +65,6 @@ export function definePlugin(descriptor: Plugin): Plugin { return { ...identity, proposalCatalog: [{ ...proposal, name, description }], + ...(descriptor.file === undefined ? {} : { file: descriptor.file }), }; } diff --git a/libs/@hashintel/brunch-agent/packages/core/src/slot-assertion.ts b/libs/@hashintel/brunch-agent/packages/core/src/slot-assertion.ts new file mode 100644 index 0000000000000000000000000000000000000000..03abcad69bdee6f5c62b1730f4201aea013a805c GIT binary patch literal 4449 zcmb7IU2ogi5$&^o#gs5$$&i$^eUsw=-d#6vgN*|#?LOGS@|wJo7*iz6OGzs@0{SEN z7w#|VIdd;*%Fb@lha?nvXYPEQIWv=2r>949Dxa2CTwXR}oU?V4m4*EFpI@cH>%>&e zy0%i8U0#}0?8eqRdB}=%Z16{=s(ii33aRR{DxGx`sHE15PS_S3o0k=})pcf_6s4Fn zt*vu1xcK2_{QC9lze#NunQLqwtno)NuZBxQ}WVt=XBWHI;77KxQ@)+|-JDl-^V^O*{9 z5Gjy_+1QCDW?ts047%X_b%;QjlU?AtY*+ zSKa32#7Vg=B!29y+s81!&P>rDX9aXq9kaKiGi_9PFN}cYwVjth$+}pXifOptsc&jK zp=4wZ$k0AutMxRXVg!ehvQ8mJMN%6o#3|fVnI>7~5v zn>96qaxR-ml~s1InK*qj0PZ)F_S9fFgd~tb@4bTA^*?Xm;mzf{s~_Rwx!gubu}kXg z-low=@XI7kmf*){hD)W+S{W%&PI;(+UE$mSJ;oeT3aVRSiECRulCl5I|Z}1FGLk)`S<>&VVpCwD> zP(ONsJrHWKW zb?Bh#6HtF+XD=^h{UYkrGwspP=xE3oyz{-(+xKQ&JQSe&ksRoGE9Y;8_3HV>n{tj8 zvXz-_2Gl&!mlII@U;r|d_wi@<-^3Rms$6gWh z#P6A%Sd@k)k(cWV;E(RscuaUV-0jTL>*@5l!lC9i^4z>(cvdkx+nSiwfDMXGWqlQIof?{(bsL-S$yHC0;#t;qV&0wB;6xW zgf!GSK#lMnnh!YbdxbrE=_>r>v*jvO|=<=6F8!X0|#X3t3M>?Mjx}0rLrUDYP z6~|(9`l&3DE>wLtJMjbNWs_4KKG2U2Yzi_EzzyAESJ7dIM_7m)iTS)Vb-@{$JyC~h zT1$0ku#{9#$aAzegD) zndelYXIqL0*qq=cMux#4#Y9%5TE_lSOGnOuz?M9Qt&EaWPauwtAA^uqR}?Ez31&{E zmUt|8NOP-ae}|3W_)#61e8FTsh|VM$GV^Q}{5uHV>WwFtx(~IfydzJ5Kzj=YYnMz< zl~08<<**&Gi9}O*HW6WXV;(ug5q12R!hWa=`v-q?B?=W#=4Vt4o?rQ^t!kVL%`n2J zeLRs59H59Et*LwLV{;_*V4|WMTnKcm(th+nBrDPA{1s*~lhh^P*Lsf$40`}acfg$c z2p;tk%%8o{Y;xl1!V{{h$I-0zO-}})CnB)iv@|^mkMII18cy$ukR*?`kgB_asS4UZ zw!2zbQBWo9y;?AIA2oqR>xqTBc`neAZgI>Uis|_A*@Wi17~`X`jEN!sf^qRMScA|H z_lUPHVRZ?z#Hdo6VgY(WFcBkh;8T<8GTAL_8_cb`FDmat}iks)Mvc~a05VA&ckRm?6v=3dx)?hh>xL=2X={L-hAMO04_g3+X zX++}wY>bv%N{mQ`?Z-RU?LYC0u`$KS4dP#CZVB4tE#Mc-@}6XM(eJ [ - `Extract capture proposals for the ${plugin.targetDomain} target from this settled conversation range.`, - `Use only the declared proposal schema: ${plugin.proposalNames.join(", ")}. Do not add parsed structure or undeclared proposal types.`, + `Extract capture proposals for the ${plugin.targetFormalism} target from this settled conversation range.`, + `Use only the declared proposal schema: ${plugin.proposalNames.join(", ")}. Do not add structure the schema does not declare, or undeclared proposal types.`, + ...(plugin.guidance ?? []), "Every user-grounded proposal must cite one or more exact verbatim quotes from the user lines below. Never supply entry ids, ranges, pointers, or evidence sources; the harness resolves those.", "The declared verbatim interior must preserve what was said without paraphrase or normalization. Return an empty proposal list when no honest capture is available.", renderTail(tail), diff --git a/libs/@hashintel/brunch-agent/packages/core/src/testing/index.ts b/libs/@hashintel/brunch-agent/packages/core/src/testing/index.ts index b4b297966cd..3da1796999e 100644 --- a/libs/@hashintel/brunch-agent/packages/core/src/testing/index.ts +++ b/libs/@hashintel/brunch-agent/packages/core/src/testing/index.ts @@ -33,7 +33,7 @@ const fixtureProposalSchema = v.strictObject({ export function pluginFixture(overrides: Partial = {}): Plugin { return definePlugin({ name: "plugin-fixture", - targetDomain: "fixture", + targetFormalism: "fixture", proposalCatalog: [ { name: "fixture-proposal", diff --git a/libs/@hashintel/brunch-agent/packages/core/test/completion.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/completion.test.ts new file mode 100644 index 00000000000..ac2381df994 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/test/completion.test.ts @@ -0,0 +1,401 @@ +/** + * The numbered invariants of `docs/specs/elicitation-completion.md`, one or more + * tests each. Invariants 17–19 (deferral licensing) describe a session-control + * computation over the report and are not implemented by `evaluateCompletion`; + * invariant 19 is checked here only in that the report carries no persisted + * status. + */ + +import { describe, expect, test } from "vitest"; + +import { + completionDemands, + evaluateCompletion, + precisionSatisfies, + type CompletionDemands, +} from "../src/completion"; +import { foldElicitedModel, type ElicitedModel } from "../src/elicited-model"; +import { + absence, + assertionCapture, + completeCaptures, + fixturePluginFile, + snapshotOf, + value, +} from "./slot-fixtures"; + +import type { CaptureEnvelope } from "../src/capture-store"; + +const file = fixturePluginFile(); +const demands = completionDemands(file); + +const modelOf = (captures: readonly CaptureEnvelope[]): ElicitedModel => + foldElicitedModel(snapshotOf(captures), file); + +const without = (id: string): CaptureEnvelope[] => + completeCaptures().filter((capture) => capture.id !== id); + +const replacing = ( + id: string, + replacement: CaptureEnvelope, +): CaptureEnvelope[] => + completeCaptures().map((capture) => + capture.id === id ? replacement : capture, + ); + +const diagnosticsOf = (captures: readonly CaptureEnvelope[]) => + evaluateCompletion(modelOf(captures), demands).failures.map((failure) => [ + failure.diagnostic, + failure.nodeId ?? failure.kind, + failure.slot, + ]); + +describe("shape of the answer (1–2)", () => { + test("1. a complete model yields true with an empty, evidence-bearing report", () => { + const report = evaluateCompletion(modelOf(completeCaptures()), demands); + expect(report.complete).toBe(true); + expect(report.failures).toEqual([]); + expect(report.sliceNodeIds).toEqual([ + "objective:throughput", + "step:stamp", + "thing:press", + "thing:widget", + ]); + // No lifecycle status, no persisted field: only the derived boolean. + expect(Object.keys(report).sort()).toEqual([ + "complete", + "failures", + "outsideSlice", + "pluginVersion", + "revision", + "sliceNodeIds", + ]); + }); + + test("1. each failure names node, slot, requirement, actual state, diagnostic, and captures", () => { + const report = evaluateCompletion( + modelOf( + replacing( + "c-stamp-duration", + assertionCapture( + "c-stamp-duration", + value("step", "stamp", "how long it takes", "range", { + low: 2, + high: 5, + }), + ), + ), + ), + demands, + ); + expect(report.complete).toBe(false); + expect(report.failures).toHaveLength(1); + const [failure] = report.failures; + expect(failure).toMatchObject({ + diagnostic: "below-required-precision", + nodeId: "step:stamp", + kind: "step", + slot: "how long it takes", + requirement: "spread", + actual: "range value under status explicit", + captureIds: ["c-stamp-duration"], + }); + expect(failure?.message).toContain( + "Smallest delta: move it from range to spread", + ); + }); + + test("2. rows from another plugin version are refused with version-mismatch", () => { + const foreign: CompletionDemands = { + ...demands, + pluginVersion: "fixture/2026-09-01.1", + }; + const report = evaluateCompletion(modelOf(completeCaptures()), foreign); + expect(report.complete).toBe(false); + expect(report.failures.map((failure) => failure.diagnostic)).toEqual([ + "version-mismatch", + ]); + expect(report.pluginVersion).toBe("fixture/2026-09-01.1"); + }); +}); + +describe("the rule (3–7)", () => { + test("3. the floor is a count per kind and fails regardless of slot quality", () => { + expect( + diagnosticsOf( + without("c-press-distinctions").filter((c) => c.id !== "c-press-count"), + ), + ).toEqual( + expect.arrayContaining([["below-minimum-count", "thing", undefined]]), + ); + }); + + test("4. presence and slot quality are separate diagnostics", () => { + const diagnostics = diagnosticsOf([ + ...without("c-press-distinctions").filter( + (c) => c.id !== "c-press-count", + ), + assertionCapture( + "c-widget-distinctions-2", + value("thing", "widget", "distinctions", "named", "kinds"), + { supersedes: "c-widget-distinctions" }, + ), + ]); + expect(diagnostics).toEqual( + expect.arrayContaining([ + ["below-minimum-count", "thing", undefined], + ["below-required-precision", "thing:widget", "distinctions"], + ]), + ); + }); + + test("5. nodes outside every objective's slice are recorded, not demanded", () => { + const report = evaluateCompletion( + modelOf([ + ...completeCaptures(), + assertionCapture( + "c-pack-actor", + value("step", "pack", "who performs it", "named", "nobody"), + ), + ]), + demands, + ); + expect(report.complete).toBe(true); + expect(report.outsideSlice).toEqual([ + { + nodeId: "step:pack", + kind: "step", + open: [ + expect.objectContaining({ + diagnostic: "unaddressed", + slot: "how long it takes", + }), + ], + }, + ]); + }); + + test("6. an objective that depends on nothing in the model is unsupported, and dangling names are reported", () => { + const report = evaluateCompletion( + modelOf( + replacing( + "c-objective-deps", + assertionCapture( + "c-objective-deps", + value( + "objective", + "throughput", + "the nodes it depends on", + "named", + ["step:ship"], + ), + ), + ), + ), + demands, + ); + expect(report.failures).toEqual([ + expect.objectContaining({ + diagnostic: "unsupported-active-objective", + nodeId: "objective:throughput", + actual: "0 resolved, 1 naming no node in the model (step:ship)", + }), + ]); + expect(report.sliceNodeIds).toEqual(["objective:throughput"]); + }); + + test("6. an objective with no dependency slot at all is unsupported; the floor does not substitute", () => { + expect(diagnosticsOf(without("c-objective-deps"))).toEqual([ + [ + "unsupported-active-objective", + "objective:throughput", + "the nodes it depends on", + ], + ]); + }); + + test("7. an empty selection never passes", () => { + expect( + diagnosticsOf( + replacing( + "c-stamp-actor", + assertionCapture( + "c-stamp-actor", + value("step", "stamp", "who performs it", "named", ""), + ), + ), + ), + ).toEqual([["no-selected-slot", "step:stamp", "who performs it"]]); + }); +}); + +describe("what counts as a value (8–14)", () => { + test("8. an inferred value fails under the default accepted statuses, however precise", () => { + const inferred = replacing( + "c-stamp-duration", + assertionCapture( + "c-stamp-duration", + value("step", "stamp", "how long it takes", "spread", { typical: 3 }), + { status: "inferred" }, + ), + ); + expect(diagnosticsOf(inferred)).toEqual([ + ["inadmissible-status", "step:stamp", "how long it takes"], + ]); + const permissive = completionDemands(file, { + acceptedStatuses: ["explicit", "inferred"], + }); + expect(evaluateCompletion(modelOf(inferred), permissive).complete).toBe( + true, + ); + }); + + test("9. a slot never mentioned fails as unaddressed", () => { + expect(diagnosticsOf(without("c-stamp-duration"))).toEqual([ + ["unaddressed", "step:stamp", "how long it takes"], + ]); + }); + + test("10. 'I don't know' and 'later' leave the slot open, with the pointer kept", () => { + const report = evaluateCompletion( + modelOf( + replacing( + "c-stamp-duration", + assertionCapture( + "c-stamp-duration", + absence( + "step", + "stamp", + "how long it takes", + "deferred", + "the MES log", + ), + ), + ), + ), + demands, + ); + expect(report.failures).toHaveLength(1); + expect(report.failures[0]).toMatchObject({ diagnostic: "unaddressed" }); + expect(report.failures[0]?.message).toContain("pointing at the MES log"); + }); + + test("11. an explicit absence passes only on a row that allows it", () => { + expect( + evaluateCompletion(modelOf(completeCaptures()), demands).complete, + ).toBe(true); // widget "how many" is not-applicable on an allowing row + expect( + diagnosticsOf( + replacing( + "c-stamp-duration", + assertionCapture( + "c-stamp-duration", + absence("step", "stamp", "how long it takes", "explicitly-absent"), + ), + ), + ), + ).toEqual([["unaccepted-absence", "step:stamp", "how long it takes"]]); + }); + + test("12. precision is checked against the row's word, not the number's look", () => { + expect(precisionSatisfies("range", "spread")).toBe(false); + expect(precisionSatisfies("number", "range")).toBe(false); + expect(precisionSatisfies("spread", "range")).toBe(true); + expect(precisionSatisfies("spread", "named")).toBe(true); + expect(precisionSatisfies("spelled out", "number")).toBe(false); + expect(precisionSatisfies("number", "spelled out")).toBe(false); + expect(precisionSatisfies("spelled out", "spelled out")).toBe(true); + expect(precisionSatisfies("spelled out", "named")).toBe(true); + }); + + test("13. conflict and divergence fail conservatively", () => { + expect( + diagnosticsOf([ + ...completeCaptures(), + assertionCapture( + "c-stamp-actor-alt", + value("step", "stamp", "who performs it", "named", "the lead"), + { entry: 9 }, + ), + ]), + ).toEqual([["open-conflict", "step:stamp", "who performs it"]]); + expect( + diagnosticsOf([ + ...without("c-stamp-actor"), + assertionCapture( + "c-manual", + value("step", "stamp", "who performs it", "named", "the operator", { + sourceRegime: "prescribed", + }), + ), + assertionCapture( + "c-floor", + value( + "step", + "stamp", + "who performs it", + "named", + "whoever is free", + { sourceRegime: "practiced" }, + ), + { entry: 2 }, + ), + ]), + ).toEqual([["unresolved-divergence", "step:stamp", "who performs it"]]); + }); + + test("14. a value whose support is not active and traceable fails as missing-evidence", () => { + const model = modelOf(completeCaptures()); + const stamp = model.nodes.find((node) => node.id === "step:stamp")!; + const tampered: ElicitedModel = { + ...model, + nodes: model.nodes.map((node) => + node.id === "step:stamp" + ? { + ...node, + slots: { + ...node.slots, + "who performs it": { + ...stamp.slots["who performs it"]!, + evidenced: false, + }, + }, + } + : node, + ), + }; + expect( + evaluateCompletion(tampered, demands).failures.map( + (failure) => failure.diagnostic, + ), + ).toEqual(["missing-evidence"]); + }); +}); + +describe("what leaves the boolean untouched (15–16)", () => { + test("15. the function is pure in (model, demands): the same inputs give the same report", () => { + const model = modelOf(completeCaptures()); + expect(evaluateCompletion(model, demands)).toEqual( + evaluateCompletion(model, demands), + ); + expect(evaluateCompletion.length).toBe(2); + }); + + test("16. a later capture can make a complete document incomplete", () => { + const before = evaluateCompletion(modelOf(completeCaptures()), demands); + const after = evaluateCompletion( + modelOf([ + ...completeCaptures(), + assertionCapture( + "c-widget-count-later", + value("thing", "widget", "how many", "number", 40), + { entry: 11 }, + ), + ]), + demands, + ); + expect(before.complete).toBe(true); + expect(after.complete).toBe(false); + expect(after.failures[0]?.diagnostic).toBe("open-conflict"); + }); +}); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/cue.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/cue.test.ts new file mode 100644 index 00000000000..57280b9c1c2 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/test/cue.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; + +import { completionDemands, evaluateCompletion } from "../src/completion"; +import { + buildCompletionCueSignal, + buildSweepList, + completionProtocolInstructionFragments, +} from "../src/cue"; +import { foldElicitedModel } from "../src/elicited-model"; +import { + assertionCapture, + completeCaptures, + fixturePluginFile, + snapshotOf, + value, +} from "./slot-fixtures"; + +const file = fixturePluginFile(); +const demands = completionDemands(file); + +describe("the sweep list", () => { + test("pairs every failure with the patterns indexed on the failing node's kind", () => { + const model = foldElicitedModel( + snapshotOf(completeCaptures().filter((c) => c.id !== "c-stamp-duration")), + file, + ); + const report = evaluateCompletion(model, demands); + const list = buildSweepList(model, report, file.patterns); + expect(list.unsatisfied.map((f) => f.diagnostic)).toEqual(["unaddressed"]); + expect(list.patterns).toEqual([ + { id: "P01", nodeId: "step:stamp", ask: "ask how often" }, + ]); + }); + + test("surfaces nothing for a complete model", () => { + const model = foldElicitedModel(snapshotOf(completeCaptures()), file); + const list = buildSweepList( + model, + evaluateCompletion(model, demands), + file.patterns, + ); + expect(list).toEqual({ unsatisfied: [], patterns: [] }); + }); +}); + +describe("the cue signal", () => { + test("states the revision, the verdict, each unsatisfied slot, and discretionary patterns", () => { + const model = foldElicitedModel( + snapshotOf([ + ...completeCaptures().filter((c) => c.id !== "c-stamp-duration"), + assertionCapture( + "c-pack-actor", + value("step", "pack", "who performs it", "named", "nobody"), + ), + ]), + file, + ); + const report = evaluateCompletion(model, demands); + const signal = buildCompletionCueSignal( + model, + report, + buildSweepList(model, report, file.patterns), + ); + expect(signal.type).toBe("completion-cue"); + expect(signal.body).toContain(`revision ${report.revision}`); + expect(signal.body).toContain("Complete: no"); + expect(signal.body).toContain("[unaddressed]"); + expect(signal.body).toContain("P01 on step:stamp: ask how often"); + expect(signal.body).toContain( + "1 node(s) lie outside every objective's dependency slice", + ); + expect(signal.body).toContain("does not decide whether to continue"); + }); + + test("truncates long lists and says how many it left out", () => { + const captures = completeCaptures().filter( + (c) => + ![ + "c-stamp-duration", + "c-stamp-actor", + "c-widget-distinctions", + "c-press-distinctions", + ].includes(c.id), + ); + const model = foldElicitedModel(snapshotOf(captures), file); + const report = evaluateCompletion(model, demands); + const signal = buildCompletionCueSignal( + model, + report, + buildSweepList(model, report, file.patterns), + { + maxItems: 2, + }, + ); + expect(report.failures.length).toBeGreaterThan(2); + expect(signal.body).toContain(`and ${report.failures.length - 2} more`); + }); + + test("instruction fragments are render-invariant prose", () => { + for (const fragment of completionProtocolInstructionFragments()) { + expect(fragment).not.toMatch(/\$\{|revision [0-9a-f]/u); + } + }); +}); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/elicited-model.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/elicited-model.test.ts new file mode 100644 index 00000000000..78a41559eec --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/test/elicited-model.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, test } from "vitest"; + +import { findNode, foldElicitedModel } from "../src/elicited-model"; +import { + absence, + assertionCapture, + completeCaptures, + fixturePluginFile, + snapshotOf, + value, +} from "./slot-fixtures"; + +import type { JsonValue } from "../src/json-value"; + +const file = fixturePluginFile(); + +describe("the fold reads only active captures", () => { + test("groups assertions into nodes and slots keyed by kind:node", () => { + const model = foldElicitedModel(snapshotOf(completeCaptures()), file); + expect(model.pluginVersion).toBe("fixture/2026-08-25.1"); + expect(model.nodes.map((node) => node.id)).toEqual([ + "objective:throughput", + "step:stamp", + "thing:press", + "thing:widget", + ]); + expect(findNode(model, "step:stamp")?.slots["who performs it"]).toEqual({ + state: "value", + value: "the press operator", + precision: "named", + status: "explicit", + evidenced: true, + captureIds: ["c-stamp-actor"], + }); + expect(findNode(model, "thing:widget")?.slots["how many"]).toEqual({ + state: "absence", + absence: "not-applicable", + status: "explicit", + evidenced: true, + captureIds: ["c-widget-count"], + }); + expect(model.unmapped).toEqual([]); + }); + + test("a superseding capture replaces its target; a retracted capture disappears", () => { + const captures = [ + ...completeCaptures(), + assertionCapture( + "c-stamp-actor-2", + value("step", "stamp", "who performs it", "named", "the line lead"), + { supersedes: "c-stamp-actor", entry: 7 }, + ), + ]; + const model = foldElicitedModel( + snapshotOf( + captures, + [], + [ + { + type: "retraction", + id: "r-1", + captureId: "c-press-count", + evidence: [ + { + excerpt: "forget the press count", + pointer: { sessionId: "session-1", entryStart: 8, entryEnd: 8 }, + source: "user", + }, + ], + }, + ], + ), + file, + ); + const actor = findNode(model, "step:stamp")?.slots["who performs it"]; + expect(actor?.state).toBe("value"); + expect(actor?.captureIds).toEqual(["c-stamp-actor-2"]); + expect(findNode(model, "thing:press")?.slots["how many"]).toBeUndefined(); + expect(model.activeCaptureIds.has("c-stamp-actor")).toBe(false); + expect(model.activeCaptureIds.has("c-press-count")).toBe(false); + }); + + test("never interprets: an unreadable payload and an envelope-level absence are unmapped", () => { + const stray = { + ...assertionCapture( + "c-stray", + value("thing", "widget", "colour", "named", "blue"), + ), + }; + const envelopeAbsence = assertionCapture( + "c-envelope-absence", + value("thing", "widget", "distinctions", "named", "x"), + ); + const model = foldElicitedModel( + snapshotOf([ + stray, + { + ...envelopeAbsence, + content: { absence: "unknown-to-user" }, + dedupKey: "manual-key", + }, + { + ...assertionCapture( + "c-free", + value("thing", "widget", "how many", "range", 1), + ), + content: { value: { free: "text" } as JsonValue }, + dedupKey: "manual-key-2", + }, + ]), + file, + ); + expect(model.nodes).toEqual([]); + expect(model.unmapped.map((entry) => entry.captureId).sort()).toEqual([ + "c-envelope-absence", + "c-free", + "c-stray", + ]); + expect( + model.unmapped.find((entry) => entry.captureId === "c-stray")?.reason, + ).toMatch(/not a `Must know` row/u); + }); +}); + +describe("competing readings", () => { + test("two different active values on one slot are a conflict, and an open conflict issue pins one", () => { + const captures = [ + assertionCapture( + "c-a", + value("step", "stamp", "who performs it", "named", "Ann"), + ), + assertionCapture( + "c-b", + value("step", "stamp", "who performs it", "named", "Bob"), + { + entry: 2, + }, + ), + assertionCapture( + "c-c", + value("thing", "widget", "distinctions", "spelled out", ["x"]), + ), + assertionCapture( + "c-d", + value("thing", "widget", "distinctions", "spelled out", ["x"]), + { entry: 3 }, + ), + ]; + const model = foldElicitedModel( + snapshotOf(captures, [ + { + id: "issue-1", + type: "conflicting", + origin: { type: "harness" }, + references: ["c-c", "c-d"], + canDefault: false, + }, + ]), + file, + ); + expect(findNode(model, "step:stamp")?.slots["who performs it"]?.state).toBe( + "conflict", + ); + // Identical readings would merge, but the open issue keeps the slot in conflict. + expect(findNode(model, "thing:widget")?.slots.distinctions?.state).toBe( + "conflict", + ); + }); + + test("identical readings merge into one value citing every capture", () => { + const captures = [ + assertionCapture( + "c-a", + value("step", "stamp", "who performs it", "named", "Ann"), + ), + assertionCapture( + "c-b", + value("step", "stamp", "who performs it", "named", "Ann"), + { + entry: 2, + status: "inferred", + }, + ), + ]; + const slot = findNode( + foldElicitedModel(snapshotOf(captures), file), + "step:stamp", + )?.slots["who performs it"]; + expect(slot).toMatchObject({ + state: "value", + status: "explicit", + captureIds: ["c-a", "c-b"], + }); + }); + + test("a prescribed and a practiced reading that differ are a divergence, not a conflict", () => { + const captures = [ + assertionCapture( + "c-manual", + value("step", "stamp", "who performs it", "named", "the operator", { + sourceRegime: "prescribed", + }), + ), + assertionCapture( + "c-floor", + value("step", "stamp", "who performs it", "named", "whoever is free", { + sourceRegime: "practiced", + }), + { entry: 2 }, + ), + ]; + const slot = findNode( + foldElicitedModel(snapshotOf(captures), file), + "step:stamp", + )?.slots["who performs it"]; + expect(slot?.state).toBe("divergence"); + expect(slot?.captureIds).toEqual(["c-manual", "c-floor"]); + }); + + test("an absence is one reading like any other", () => { + const captures = [ + assertionCapture( + "c-a", + absence( + "step", + "stamp", + "how long it takes", + "unknown-to-user", + "the MES log", + ), + ), + ]; + expect( + findNode(foldElicitedModel(snapshotOf(captures), file), "step:stamp") + ?.slots["how long it takes"], + ).toEqual({ + state: "absence", + absence: "unknown-to-user", + pointer: "the MES log", + status: "explicit", + evidenced: true, + captureIds: ["c-a"], + }); + }); +}); + +describe("revision", () => { + test("is stable for the same active set and changes when it changes", () => { + const base = completeCaptures(); + const first = foldElicitedModel(snapshotOf(base), file).revision; + const again = foldElicitedModel( + snapshotOf([...base].reverse()), + file, + ).revision; + const grown = foldElicitedModel( + snapshotOf([ + ...base, + assertionCapture( + "c-extra", + value("step", "pack", "who performs it", "named", "nobody"), + ), + ]), + file, + ).revision; + expect(again).toBe(first); + expect(grown).not.toBe(first); + }); +}); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/plugin-file.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/plugin-file.test.ts new file mode 100644 index 00000000000..987721386b4 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/test/plugin-file.test.ts @@ -0,0 +1,215 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { + parsePluginFile, + PLUGIN_FILE_HEADINGS, + PluginFileError, + pluginFileInstructions, + mustKnowRowsFor, +} from "../src/plugin-file"; +import { CONTEXT_ROOT, contextRootPresent } from "./architecture/workspace"; +import { FIXTURE_PLUGIN_MARKDOWN, fixturePluginFile } from "./slot-fixtures"; + +describe("the synthetic fixture file", () => { + const file = fixturePluginFile(); + + test("reads the version, the kind catalog, and every section", () => { + expect(file.version).toBe("fixture/2026-08-25.1"); + expect(file.kinds.map((row) => row.kind)).toEqual([ + "objective", + "thing", + "step", + ]); + expect(Object.keys(file.sections)).toEqual([...PLUGIN_FILE_HEADINGS]); + expect(file.sections.Purpose).toBe( + "Interview someone about things and steps.", + ); + }); + + test("reads demand rows with typed precision and the not-applicable flag", () => { + expect(mustKnowRowsFor(file, "objective")).toEqual([ + { + kind: "objective", + slot: "the question", + precision: { kind: "word", word: "spelled out" }, + notApplicableAllowed: false, + why: "anchor", + }, + { + kind: "objective", + slot: "the nodes it depends on", + precision: { kind: "at-least", count: 1 }, + notApplicableAllowed: false, + why: "slice", + }, + ]); + expect(mustKnowRowsFor(file, "thing")[1]?.notApplicableAllowed).toBe(true); + }); + + test("reads the static floor as counts from the prose beneath the table", () => { + expect(file.floor).toEqual([ + { kind: "objective", atLeast: 1 }, + { kind: "thing", atLeast: 2 }, + { kind: "step", atLeast: 1 }, + ]); + }); + + test("indexes patterns by the kinds their trigger names", () => { + expect(file.patterns.map((row) => [row.id, row.kinds])).toEqual([ + ["P01", ["step"]], + ["P02", ["thing"]], + ["P03", []], + ]); + }); + + test("renders every section in contract order as instructions", () => { + const instructions = pluginFileInstructions(file); + const positions = PLUGIN_FILE_HEADINGS.map((heading) => + instructions.indexOf(`## ${heading}`), + ); + expect(positions.every((position) => position >= 0)).toBe(true); + expect([...positions].sort((a, b) => a - b)).toEqual(positions); + }); +}); + +describe("contract violations fail to load", () => { + const withoutLinesStarting = (prefix: string): string => + FIXTURE_PLUGIN_MARKDOWN.split("\n") + .filter((line) => !line.startsWith(prefix)) + .join("\n"); + + test.each([ + [ + "a missing heading", + FIXTURE_PLUGIN_MARKDOWN.replace("## Moves\n", ""), + /Contract headings/u, + ], + [ + "a reordered heading", + FIXTURE_PLUGIN_MARKDOWN.replace("## Purpose", "## Kinds").replace( + /## Kinds\n\n\| #/u, + "## Purpose\n\n| #", + ), + /Contract headings/u, + ], + [ + "a renamed heading", + FIXTURE_PLUGIN_MARKDOWN.replace("## Must know", "## Demands"), + /Contract headings/u, + ], + [ + "a missing version", + FIXTURE_PLUGIN_MARKDOWN.replace(/Version: `[^`]+`/u, ""), + /immutable version/u, + ], + [ + "an unknown column", + FIXTURE_PLUGIN_MARKDOWN.replace("| projects to |", "| becomes |"), + /columns must be exactly/u, + ], + [ + "a demand row for an unknown kind", + FIXTURE_PLUGIN_MARKDOWN.replace( + "| `step` | who performs it", + "| `queue` | who performs it", + ), + /not in `## Kinds`/u, + ], + [ + "an unknown precision word", + FIXTURE_PLUGIN_MARKDOWN.replace( + "| spread | no ", + "| roughly | no ", + ), + /precision `roughly`/u, + ], + [ + "a kind with no demand row", + withoutLinesStarting("| `step`"), + /no row for kind `step`/u, + ], + [ + "a floor that names no kind", + FIXTURE_PLUGIN_MARKDOWN.replace( + /Static floor[^\n]*\n[^\n]*\n/u, + "Static floor — none.\n", + ), + /Static floor names no kind/u, + ], + [ + "a not-applicable cell that is not yes or no", + FIXTURE_PLUGIN_MARKDOWN.replace( + "| spelled out | no | anchor", + "| spelled out | maybe | anchor", + ), + /expected `yes` or `no`/u, + ], + ])("%s", (_label, markdown, message) => { + expect(() => parsePluginFile(markdown)).toThrow(PluginFileError); + expect(() => parsePluginFile(markdown)).toThrow(message); + }); +}); + +describe.skipIf(!contextRootPresent)("the SDCPN plugin file", () => { + const file = parsePluginFile( + readFileSync(join(CONTEXT_ROOT, "packages/plugin-sdcpn/plugin.md"), "utf8"), + ); + + test("loads under the contract with Layer B's ten kinds", () => { + expect(file.version).toBe("sdcpn/2026-08-25.1"); + expect(file.kinds.map((row) => row.kind)).toEqual([ + "entity-type", + "boundary-condition", + "activity", + "ordering/flow", + "policy", + "dynamics", + "objective", + "constraint", + "data-binding", + "validation-criterion", + ]); + }); + + test("states the floor and one dependency row on the objective", () => { + expect(file.floor).toEqual([ + { kind: "objective", atLeast: 1 }, + { kind: "entity-type", atLeast: 2 }, + { kind: "activity", atLeast: 1 }, + { kind: "ordering/flow", atLeast: 1 }, + ]); + const anchors = file.mustKnow.filter( + (row) => row.kind === "objective" && row.precision.kind === "at-least", + ); + expect(anchors.map((row) => row.slot)).toEqual(["the nodes it depends on"]); + }); + + test("carries twenty-four demand rows and thirteen patterns, every pattern kind-indexed or generic", () => { + expect(file.mustKnow).toHaveLength(24); + expect(file.patterns.map((row) => row.id)).toEqual( + Array.from( + { length: 13 }, + (_, index) => `P${String(index + 1).padStart(2, "0")}`, + ), + ); + expect(file.patterns.find((row) => row.id === "P01")?.kinds).toEqual([ + "activity", + ]); + }); + + test("names no domain", () => { + const text = pluginFileInstructions(file).toLowerCase(); + for (const domainWord of [ + "coating", + "packaging", + "truck", + "fleet", + "paint", + ]) { + expect(text).not.toContain(domainWord); + } + }); +}); diff --git a/libs/@hashintel/brunch-agent/packages/core/test/slot-fixtures.ts b/libs/@hashintel/brunch-agent/packages/core/test/slot-fixtures.ts new file mode 100644 index 00000000000..45c43be384c --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/core/test/slot-fixtures.ts @@ -0,0 +1,196 @@ +/** + * Fixtures for the read path: a small synthetic plugin file and a capture + * envelope builder. The synthetic file keeps the tests independent of the + * SDCPN file's row set; `plugin-file.test.ts` reads the real file separately. + */ + +import { + captureDedupKey, + type CaptureEnvelope, + type CaptureIssue, + type CaptureStoreEvent, + type CaptureStoreSnapshot, +} from "../src/capture-store"; +import { parsePluginFile, type PluginFile } from "../src/plugin-file"; + +import type { JsonValue } from "../src/json-value"; +import type { SlotAssertion } from "../src/slot-assertion"; + +export const FIXTURE_PLUGIN_MARKDOWN = `# Fixture plugin + +Plugin: \`fixture\` · Target formalism: fixture · Version: \`fixture/2026-08-25.1\` + +## Purpose + +Interview someone about things and steps. + +## Kinds + +| # | kind | what it is | projects to | +| --- | ----------- | ---------- | ----------- | +| 1 | \`objective\` | A question | metrics | +| 2 | \`thing\` | A thing | colours | +| 3 | \`step\` | A step | transitions | + +Attributes apply to every kind. + +## Must know + +A slot is satisfied only by what the expert said. + +| kind | slot | precision | "not applicable" allowed | why the model needs it | +| ----------- | ----------------------- | ----------- | ------------------------ | ---------------------- | +| \`objective\` | the question | spelled out | no | anchor | +| \`objective\` | the nodes it depends on | at least 1 | no | slice | +| \`thing\` | distinctions | spelled out | no | types | +| \`thing\` | how many | range | yes | population | +| \`step\` | how long it takes | spread | no | duration | +| \`step\` | who performs it | named | yes | binding | + +Static floor — the model must contain at least one \`objective\`, at least two \`thing\` nodes, and +at least one \`step\`. + +### Precision words + +| word | means | IR grade | +| ------- | ---------------- | -------- | +| \`named\` | identified in words | verbal | + +## Patterns + +| id | when | ask | +| --- | ------------------------------- | ------------------- | +| P01 | a \`step\` is an event | ask how often | +| P02 | more than one \`thing\` competes | ask which wins | +| P03 | the expert says they do not know | ask for a source | + +## Moves + +Move one. Move two. + +## Deliverable + +The model and its loss report. +`; + +export const fixturePluginFile = (): PluginFile => + parsePluginFile(FIXTURE_PLUGIN_MARKDOWN); + +export interface CaptureOptions { + readonly status?: "explicit" | "inferred" | "tentative"; + readonly excerpt?: string; + readonly supersedes?: string; + readonly entry?: number; +} + +/** A user-evidenced capture envelope whose content is one slot assertion. */ +export const assertionCapture = ( + id: string, + assertion: SlotAssertion, + options: CaptureOptions = {}, +): CaptureEnvelope => { + const entry = options.entry ?? 1; + const fields = { + confidence: "firm", + content: { value: assertion as unknown as JsonValue }, + evidence: [ + { + excerpt: options.excerpt ?? `quote for ${id}`, + pointer: { sessionId: "session-1", entryStart: entry, entryEnd: entry }, + source: "user" as const, + }, + ], + epistemicStatus: options.status ?? ("explicit" as const), + ...(options.supersedes === undefined + ? {} + : { supersedes: options.supersedes }), + }; + return { ...fields, id, dedupKey: captureDedupKey(fields) }; +}; + +export const value = ( + kind: string, + node: string, + slot: string, + precision: SlotAssertion["precision"], + content: JsonValue, + extra: Partial> = {}, +): SlotAssertion => ({ + type: "slot-asserted", + kind, + node, + slot, + precision, + ...extra, + assertion: { value: content }, +}); + +export const absence = ( + kind: string, + node: string, + slot: string, + state: Extract["absence"], + pointer?: string, +): SlotAssertion => ({ + type: "slot-asserted", + kind, + node, + slot, + assertion: { absence: state, ...(pointer === undefined ? {} : { pointer }) }, +}); + +export const snapshotOf = ( + captures: readonly CaptureEnvelope[], + issues: readonly CaptureIssue[] = [], + events: readonly CaptureStoreEvent[] = [], +): CaptureStoreSnapshot => ({ captures, issues, events }); + +/** + * A model that satisfies every fixture row for one objective, two things, and + * one step. Tests perturb one capture at a time from here. + */ +export const completeCaptures = (): CaptureEnvelope[] => [ + assertionCapture( + "c-objective-question", + value("objective", "throughput", "the question", "spelled out", { + question: "How many widgets per day?", + }), + ), + assertionCapture( + "c-objective-deps", + value("objective", "throughput", "the nodes it depends on", "named", [ + "thing:widget", + "thing:press", + "step:stamp", + ]), + ), + assertionCapture( + "c-widget-distinctions", + value("thing", "widget", "distinctions", "spelled out", ["small", "large"]), + ), + assertionCapture( + "c-widget-count", + absence("thing", "widget", "how many", "not-applicable"), + ), + assertionCapture( + "c-press-distinctions", + value("thing", "press", "distinctions", "spelled out", ["one press type"]), + ), + assertionCapture( + "c-press-count", + value("thing", "press", "how many", "range", { low: 2, high: 3 }), + ), + assertionCapture( + "c-stamp-duration", + value("step", "stamp", "how long it takes", "spread", { + typical: 3, + worse1in10: 5, + better1in10: 2, + unit: "minutes", + }), + ), + assertionCapture( + "c-stamp-actor", + value("step", "stamp", "who performs it", "named", "the press operator"), + ), +]; diff --git a/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts b/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts index 2e0f008bbba..f2046b8cf02 100644 --- a/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts +++ b/libs/@hashintel/brunch-agent/packages/core/test/sweep-protocol.test.ts @@ -140,7 +140,7 @@ describe("settlement and sweep protocol", () => { test("keeps extraction quote-only and delegates the interior contract to the plugin", () => { const prompt = buildSweepExtractionPrompt( { - targetDomain: "gherkin", + targetFormalism: "gherkin", proposalNames: ["statement-noted"], }, entries.slice(0, 2), diff --git a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts index 64d9b52375a..4503996a656 100644 --- a/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/plugin-gherkin/src/index.ts @@ -1,5 +1,5 @@ /** - * `@hashintel/brunch-agent-plugin-gherkin` — the gherkin target-domain (spec §13.1). + * `@hashintel/brunch-agent-plugin-gherkin` — the gherkin target formalism (spec §13.1). * * The tracer target: cheap enough to wire end-to-end first, and deliberately * trivial, so it must not be the plugin that freezes the contract (spec §13's @@ -46,7 +46,7 @@ export type StatementNotedProposalInput = v.InferInput< export const gherkin = definePlugin({ name: "plugin-gherkin", - targetDomain: "gherkin", + targetFormalism: "gherkin", proposalCatalog: [ { name: "statement-noted", diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/.oxlintrc.json b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/.oxlintrc.json new file mode 100644 index 00000000000..52c387bca8c --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/.oxlintrc.json @@ -0,0 +1,60 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../../../../.config/oxlint/brunch/base.json"], + "categories": { + "correctness": "error", + "perf": "warn" + }, + "env": { + "builtin": true, + "es2026": true, + "node": true + }, + "options": { + "typeAware": true, + "typeCheck": true + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@hashintel/brunch-agent/storage", + "message": "Plugins receive harness capabilities and must remain storage-blind." + }, + { + "name": "@hashintel/petrinaut", + "message": "Brunch libraries must not depend on Petrinaut implementations." + } + ], + "patterns": [ + { + "group": ["@local/*"], + "message": "Brunch libraries must remain independent of unpublished HASH packages." + }, + { + "group": ["@hashintel/petrinaut/*", "@hashintel/petrinaut-*"], + "message": "Brunch libraries must not depend on Petrinaut implementations." + }, + { + "group": ["@flue/*", "@earendil-works/*"], + "message": "Brunch plugins must remain substrate-independent." + }, + { + "group": ["@hashintel/brunch-agent-*"], + "message": "A plugin may depend inward on the harness, not on Brunch extensions." + } + ] + } + ] + }, + "ignorePatterns": [ + "dist/**", + "build/**", + "coverage/**", + "*.gen.*", + "*.tsbuildinfo", + ".turbo/**" + ] +} diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/LICENSE.md b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/LICENSE.md new file mode 100644 index 00000000000..c7d627721e2 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/LICENSE.md @@ -0,0 +1,607 @@ +GNU Affero General Public License +================================= + +_Version 3, 19 November 2007_ +_Copyright © 2007 Free Software Foundation, Inc. <>_ + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: **(1)** assert copyright on the software, and **(2)** offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions + +“This License” refers to version 3 of the GNU Affero General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this +License. Each licensee is addressed as “you”. “Licensees” and +“recipients” may be individuals or organizations. + +To “modify” a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a “modified version” of the +earlier work or a work “based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based +on the Program. + +To “propagate” a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” +to the extent that it includes a convenient and prominently visible +feature that **(1)** displays an appropriate copyright notice, and **(2)** +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code + +The “source code” for a work means the preferred form of the work +for making modifications to it. “Object code” means any non-source +form of a work. + +A “Standard Interface” means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The “System Libraries” of an executable work include anything, other +than the work as a whole, that **(a)** is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and **(b)** serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +“Major Component”, in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +### 2. Basic Permissions + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +### 4. Conveying Verbatim Copies + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + +* **a)** The work must carry prominent notices stating that you modified +it, and giving a relevant date. +* **b)** The work must carry prominent notices stating that it is +released under this License and any conditions added under section 7. +This requirement modifies the requirement in section 4 to +“keep intact all notices”. +* **c)** You must license the entire work, as a whole, under this +License to anyone who comes into possession of a copy. This +License will therefore apply, along with any applicable section 7 +additional terms, to the whole of the work, and all its parts, +regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not +invalidate such permission if you have separately received it. +* **d)** If the work has interactive user interfaces, each must display +Appropriate Legal Notices; however, if the Program has interactive +interfaces that do not display Appropriate Legal Notices, your +work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +“aggregate” if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + +* **a)** Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by the +Corresponding Source fixed on a durable physical medium +customarily used for software interchange. +* **b)** Convey the object code in, or embodied in, a physical product +(including a physical distribution medium), accompanied by a +written offer, valid for at least three years and valid for as +long as you offer spare parts or customer support for that product +model, to give anyone who possesses the object code either **(1)** a +copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical +medium customarily used for software interchange, for a price no +more than your reasonable cost of physically performing this +conveying of source, or **(2)** access to copy the +Corresponding Source from a network server at no charge. +* **c)** Convey individual copies of the object code with a copy of the +written offer to provide the Corresponding Source. This +alternative is allowed only occasionally and noncommercially, and +only if you received the object code with such an offer, in accord +with subsection 6b. +* **d)** Convey the object code by offering access from a designated +place (gratis or for a charge), and offer equivalent access to the +Corresponding Source in the same way through the same place at no +further charge. You need not require recipients to copy the +Corresponding Source along with the object code. If the place to +copy the object code is a network server, the Corresponding Source +may be on a different server (operated by you or a third party) +that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the +Corresponding Source, you remain obligated to ensure that it is +available for as long as needed to satisfy these requirements. +* **e)** Convey the object code using peer-to-peer transmission, provided +you inform other peers where the object code and Corresponding +Source of the work are being offered to the general public at no +charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A “User Product” is either **(1)** a “consumer product”, which means any +tangible personal property which is normally used for personal, family, +or household purposes, or **(2)** anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, “normally used” refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms + +“Additional permissions” are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + +* **a)** Disclaiming warranty or limiting liability differently from the +terms of sections 15 and 16 of this License; or +* **b)** Requiring preservation of specified reasonable legal notices or +author attributions in that material or in the Appropriate Legal +Notices displayed by works containing it; or +* **c)** Prohibiting misrepresentation of the origin of that material, or +requiring that modified versions of such material be marked in +reasonable ways as different from the original version; or +* **d)** Limiting the use for publicity purposes of names of licensors or +authors of the material; or +* **e)** Declining to grant rights under trademark law for use of some +trade names, trademarks, or service marks; or +* **f)** Requiring indemnification of licensors and authors of that +material by anyone who conveys the material (or modified versions of +it) with contractual assumptions of liability to the recipient, for +any liability that these contractual assumptions directly impose on +those licensors and authors. + +All other non-permissive additional terms are considered “further +restrictions” within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +### 8. Termination + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated **(a)** +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and **(b)** permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An “entity transaction” is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents + +A “contributor” is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, “control” includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a “patent license” is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To “grant” such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either **(1)** cause the Corresponding Source to be so +available, or **(2)** arrange to deprive yourself of the benefit of the +patent license for this particular work, or **(3)** arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. “Knowingly relying” means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is “discriminatory” if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license **(a)** in connection with copies of the covered work +conveyed by you (or copies made from those copies), or **(b)** primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +### 14. Revised Versions of this License + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License “or any later version” applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +### 16. Limitation of Liability + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16 + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/package.json b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/package.json new file mode 100644 index 00000000000..83aa055929b --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/package.json @@ -0,0 +1,33 @@ +{ + "name": "@hashintel/brunch-agent-plugin-sdcpn", + "version": "0.0.0-private", + "private": true, + "description": "The SDCPN target formalism: the process-model plugin file and its slot-assertion proposal type.", + "license": "AGPL-3.0", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "vite build", + "fix:eslint": "oxlint --fix --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit", + "test:unit": "vitest run" + }, + "dependencies": { + "@hashintel/brunch-agent": "workspace:*", + "valibot": "1.4.2" + }, + "devDependencies": { + "@types/node": "22.18.13", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", + "vite": "8.1.0", + "vitest": "4.1.10" + } +} diff --git a/libs/@hashintel/brunch-agent/docs/specs/sdcpn-plugin.md b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/plugin.md similarity index 98% rename from libs/@hashintel/brunch-agent/docs/specs/sdcpn-plugin.md rename to libs/@hashintel/brunch-agent/packages/plugin-sdcpn/plugin.md index 5fd2f24f342..e2491054b7b 100644 --- a/libs/@hashintel/brunch-agent/docs/specs/sdcpn-plugin.md +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/plugin.md @@ -11,7 +11,7 @@ coloured Petri nets (Petrinaut) · Version: `sdcpn/2026-08-25.1` > have a conversation. > > It merges two existing sources into one artifact: the kind vocabulary and completion rule of -> [the IR spec's Layer B](intermediate-representation.md#layer-b--the-cps-plugins-ir), and the +> [the IR spec's Layer B](../../docs/specs/intermediate-representation.md#layer-b--the-cps-plugins-ir), and the > interviewing guidance of the condition-2 v0 prompt. Nothing here is new design; the domain-keyed > demand tables and cards of FE-1402/1403/1404 are the departure this file walks back. > @@ -20,8 +20,8 @@ coloured Petri nets (Petrinaut) · Version: `sdcpn/2026-08-25.1` > unchanged. A new case that seems to need a new row is a finding about the abstraction to be > decided, never content to be added here. > -> It lives under `docs/specs/` until the walking-skeleton branch creates `packages/plugin-sdcpn/` -> with a manifest and the parser; the file then moves there unchanged. +> It lives at `packages/plugin-sdcpn/plugin.md` and is parsed by the harness's `parsePluginFile`; +> the package's `slot-asserted` proposal type is restricted to the kinds and slots below. ## Purpose diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/index.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/index.ts new file mode 100644 index 00000000000..ace73b5f290 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/index.ts @@ -0,0 +1,62 @@ +/** + * `@hashintel/brunch-agent-plugin-sdcpn` — the SDCPN target formalism (ADR-0006). + * + * The plugin is `plugin.md`: one sectioned Markdown file whose three tables the + * harness parses into the model vocabulary, the demand list, and the pattern + * index, and whose prose becomes the interviewer's instructions. This module + * loads that file and declares the one proposal type a kind-and-slot plugin + * needs: a slot assertion addressed to a kind, node, and slot the file names. + * The file names no domain, and neither does this code. + * + * **This package resolves `@hashintel/brunch-agent` and nothing else** — never the + * binding, never Flue, and it is storage-blind (spec §9.6). `project` and + * `validate` (ADR-0005) land with the realization slice. + */ + +import * as v from "valibot"; + +import { + createSlotAssertionSchema, + definePlugin, + parsePluginFile, +} from "@hashintel/brunch-agent"; + +import pluginMarkdown from "../plugin.md?raw"; + +const nonEmptyString = v.pipe(v.string(), v.nonEmpty()); +const evidenceQuote = v.strictObject({ excerpt: nonEmptyString }); + +/** The parsed plugin file; parsing fails loudly at module load if the contract is broken. */ +export const sdcpnPluginFile = parsePluginFile(pluginMarkdown); + +/** + * One slot assertion, quote-anchored, restricted to the file's kinds and slots. + * The harness resolves quotes to evidence spans at apply time; the proposal + * carries excerpts only. + */ +export const SlotAssertedProposal = v.strictObject({ + evidence: v.pipe(v.array(evidenceQuote), v.minLength(1)), + epistemicStatus: v.picklist(["explicit", "inferred", "tentative"]), + confidence: v.picklist(["firm", "hedged", "speculative"]), + content: v.strictObject({ + value: createSlotAssertionSchema(sdcpnPluginFile), + }), +}); + +export type SlotAssertedProposalInput = v.InferInput< + typeof SlotAssertedProposal +>; + +export const sdcpn = definePlugin({ + name: "plugin-sdcpn", + targetFormalism: "sdcpn", + file: sdcpnPluginFile, + proposalCatalog: [ + { + name: "slot-asserted", + description: + "Record what the expert said about one slot of one node: its kind, node, slot, the precision the answer reached, and the value or explicit absence, with verbatim quotes.", + schema: SlotAssertedProposal, + }, + ], +}); diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/raw-imports.d.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/raw-imports.d.ts new file mode 100644 index 00000000000..34796422eb2 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/src/raw-imports.d.ts @@ -0,0 +1,5 @@ +/** Vite's `?raw` import: the plugin file ships inside the bundle as a string. */ +declare module "*.md?raw" { + const markdown: string; + export default markdown; +} diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/plugin.test.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/plugin.test.ts new file mode 100644 index 00000000000..2d6fd7f828a --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/test/plugin.test.ts @@ -0,0 +1,282 @@ +import * as v from "valibot"; +import { describe, expect, test } from "vitest"; + +import { + captureDedupKey, + completionDemands, + createSweepExtractionResultSchema, + evaluateCompletion, + foldElicitedModel, + type CaptureEnvelope, + type JsonValue, + type SlotAssertion, +} from "@hashintel/brunch-agent"; + +import { sdcpn, sdcpnPluginFile } from "../src/index"; + +const proposalOf = (assertion: SlotAssertion) => ({ + evidence: [{ excerpt: "quote" }], + epistemicStatus: "explicit" as const, + confidence: "firm" as const, + content: { value: assertion }, +}); + +const asserted = ( + kind: string, + node: string, + slot: string, + precision: SlotAssertion["precision"], + value: JsonValue, +): SlotAssertion => ({ + type: "slot-asserted", + kind, + node, + slot, + precision, + assertion: { value }, +}); + +const notApplicable = ( + kind: string, + node: string, + slot: string, +): SlotAssertion => ({ + type: "slot-asserted", + kind, + node, + slot, + assertion: { absence: "not-applicable" }, +}); + +let entry = 0; +const capture = (assertion: SlotAssertion): CaptureEnvelope => { + entry += 1; + const fields = { + confidence: "firm" as const, + content: { value: assertion as unknown as JsonValue }, + evidence: [ + { + excerpt: `quote ${entry}`, + pointer: { sessionId: "s", entryStart: entry, entryEnd: entry }, + source: "user" as const, + }, + ], + epistemicStatus: "explicit" as const, + }; + return { ...fields, id: `c-${entry}`, dedupKey: captureDedupKey(fields) }; +}; + +describe("the SDCPN plugin", () => { + test("is the parsed file plus one slot-assertion proposal type", () => { + expect(sdcpn.targetFormalism).toBe("sdcpn"); + expect(sdcpn.file).toBe(sdcpnPluginFile); + expect(sdcpnPluginFile.version).toBe("sdcpn/2026-08-25.1"); + expect(sdcpn.proposalCatalog.map((proposal) => proposal.name)).toEqual([ + "slot-asserted", + ]); + }); + + test("accepts an assertion addressed to a kind and slot the file names", () => { + const schema = createSweepExtractionResultSchema(sdcpn); + const proposal = proposalOf( + asserted("activity", "fill", "how long it takes", "spread", { + typical: 4, + worse1in10: 6, + better1in10: 3, + unit: "minutes", + }), + ); + expect(v.parse(schema, { proposals: [proposal] })).toEqual({ + proposals: [proposal], + }); + }); + + test("refuses kinds and slots the file does not name, and values without a precision", () => { + const schema = createSweepExtractionResultSchema(sdcpn); + const reject = (assertion: SlotAssertion, message: RegExp) => + expect(() => + v.parse(schema, { proposals: [proposalOf(assertion)] }), + ).toThrow(message); + reject(asserted("queue", "q", "how long it takes", "spread", 1), /kind/u); + reject(asserted("activity", "fill", "its colour", "named", "x"), /slot/u); + reject( + { + ...asserted("activity", "fill", "how long it takes", "spread", 1), + precision: undefined, + }, + /precision/u, + ); + }); + + test("folds and completes a minimal model under the file's own rows", () => { + const captures = [ + capture( + asserted( + "objective", + "cycle", + "the question, in the expert's words", + "spelled out", + { + question: "How many items finish per shift?", + }, + ), + ), + capture( + asserted("objective", "cycle", "the nodes it depends on", "named", [ + "entity-type:item", + "entity-type:station", + "activity:fill", + "ordering/flow:main", + ]), + ), + capture( + notApplicable( + "objective", + "cycle", + 'what "better" means, and trade-off weights', + ), + ), + capture( + asserted( + "entity-type", + "item", + "the distinctions the process treats apart", + "spelled out", + ["small", "large"], + ), + ), + capture( + notApplicable( + "entity-type", + "item", + "state that rides along with each instance", + ), + ), + capture( + notApplicable( + "entity-type", + "item", + "how many there are, or the population's shape", + ), + ), + capture( + asserted( + "entity-type", + "station", + "the distinctions the process treats apart", + "spelled out", + ["one station type"], + ), + ), + capture( + notApplicable( + "entity-type", + "station", + "state that rides along with each instance", + ), + ), + capture( + asserted( + "entity-type", + "station", + "how many there are, or the population's shape", + "range", + { + low: 2, + high: 3, + }, + ), + ), + capture( + asserted( + "activity", + "fill", + "what it needs before it can start", + "spelled out", + ["an item", "a free station"], + ), + ), + capture( + asserted( + "activity", + "fill", + "what it produces or changes", + "spelled out", + ["a filled item"], + ), + ), + capture(notApplicable("activity", "fill", "who or what performs it")), + capture( + asserted("activity", "fill", "how long it takes", "spread", { + typical: 4, + worse1in10: 6, + better1in10: 3, + unit: "minutes", + }), + ), + capture( + notApplicable( + "activity", + "fill", + "how often it occurs, if it is an event rather than a step", + ), + ), + capture( + notApplicable( + "activity", + "fill", + "what is lost when it changes the system's mode", + ), + ), + capture( + asserted( + "activity", + "fill", + "whether its quantities vary by type", + "named", + "no", + ), + ), + capture( + asserted( + "ordering/flow", + "main", + "the order things happen in", + "spelled out", + ["fill"], + ), + ), + capture( + notApplicable( + "ordering/flow", + "main", + "how a branch or merge is decided", + ), + ), + ]; + const model = foldElicitedModel( + { captures, issues: [], events: [] }, + sdcpnPluginFile, + ); + expect(model.unmapped).toEqual([]); + const report = evaluateCompletion( + model, + completionDemands(sdcpnPluginFile), + ); + expect(report.failures).toEqual([]); + expect(report.complete).toBe(true); + + const withoutDuration = captures.filter((c) => c.id !== "c-13"); + const partial = evaluateCompletion( + foldElicitedModel( + { captures: withoutDuration, issues: [], events: [] }, + sdcpnPluginFile, + ), + completionDemands(sdcpnPluginFile), + ); + expect(partial.complete).toBe(false); + expect( + partial.failures.map((f) => [f.diagnostic, f.nodeId, f.slot]), + ).toEqual([["unaddressed", "activity:fill", "how long it takes"]]); + }); +}); diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/tsconfig.json b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/tsconfig.json new file mode 100644 index 00000000000..844edbd8e66 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["ESNext"], + "types": ["node"], + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src", "test"] +} diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/turbo.json b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/turbo.json new file mode 100644 index 00000000000..6d9a1d1f9e5 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "cache": false + } + } +} diff --git a/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/vite.config.ts b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/vite.config.ts new file mode 100644 index 00000000000..62daeca5b55 --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/plugin-sdcpn/vite.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vitest/config"; + +const packageRoot = fileURLToPath(new URL(".", import.meta.url)); + +export default defineConfig({ + build: { + lib: { + entry: fileURLToPath(new URL("src/index.ts", import.meta.url)), + fileName: "index", + formats: ["es"], + }, + rolldownOptions: { + external: [/^@hashintel\/brunch-agent(?:\/.*)?$/u, "valibot"], + }, + sourcemap: true, + }, + root: packageRoot, + test: { + include: ["test/**/*.test.ts"], + }, +}); diff --git a/yarn.lock b/yarn.lock index 90fa4284b86..877a431dea5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7568,6 +7568,21 @@ __metadata: languageName: unknown linkType: soft +"@hashintel/brunch-agent-plugin-sdcpn@workspace:libs/@hashintel/brunch-agent/packages/plugin-sdcpn": + version: 0.0.0-use.local + resolution: "@hashintel/brunch-agent-plugin-sdcpn@workspace:libs/@hashintel/brunch-agent/packages/plugin-sdcpn" + dependencies: + "@hashintel/brunch-agent": "workspace:*" + "@types/node": "npm:22.18.13" + "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + oxlint: "npm:1.63.0" + oxlint-tsgolint: "npm:0.22.1" + valibot: "npm:1.4.2" + vite: "npm:8.1.0" + vitest: "npm:4.1.10" + languageName: unknown + linkType: soft + "@hashintel/brunch-agent-transport-aisdk@workspace:*, @hashintel/brunch-agent-transport-aisdk@workspace:libs/@hashintel/brunch-agent/packages/transport-aisdk": version: 0.0.0-use.local resolution: "@hashintel/brunch-agent-transport-aisdk@workspace:libs/@hashintel/brunch-agent/packages/transport-aisdk" From 588ae84164c088ae09828d9bfd8646a9221e2933 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 25 Aug 2026 14:49:35 +0200 Subject: [PATCH 2/3] Mount the SDCPN elicitor in the dev app The target gallery gains its second entry. `src/agents/sdcpn-elicitor.ts` is a thin `'use agent'` module, shaped like the gherkin one: it mounts `useElicitation` with the `sdcpn` plugin and a session whose history reader resolves conversations through the agent's own route. What the interviewer asks, demands, and treats as complete comes from `plugin.md` through the binding; this module holds none of it. `routes.ts` names the routes as a record (`gherkin`, `sdcpn`) and the session factory takes the target, so each agent's reader speaks to its own mount. The browser UI selects the agent with `?target=sdcpn`; gherkin stays the default tracer. Petrinaut's chat transport still drives the gherkin elicitor and is unchanged. `test/build-artifact.test.ts` now reads every module under `src/agents` for pinned identities, so a second agent that silently failed to register would fail the bundle check rather than pass it by omission. Gates: dev app build + 32/32 tests (both identities bound in the bundle); core architecture gates 119/119, including first-statement directive, pinned literal, and no duplicated identity; tsgo clean; oxlint 0 errors; oxfmt clean. Co-Authored-By: Claude Fable 5 --- apps/brunch-agent/package.json | 1 + .../brunch-agent/src/agents/sdcpn-elicitor.ts | 54 +++++++++++++++++++ apps/brunch-agent/src/app.ts | 10 +++- apps/brunch-agent/src/elicitation-session.ts | 24 +++++++-- apps/brunch-agent/src/routes.ts | 14 ++++- apps/brunch-agent/src/ui/chat.tsx | 12 ++++- apps/brunch-agent/test/build-artifact.test.ts | 18 ++++--- yarn.lock | 3 +- 8 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 apps/brunch-agent/src/agents/sdcpn-elicitor.ts diff --git a/apps/brunch-agent/package.json b/apps/brunch-agent/package.json index 2768015dbfa..cf9c5d37003 100644 --- a/apps/brunch-agent/package.json +++ b/apps/brunch-agent/package.json @@ -21,6 +21,7 @@ "@hashintel/brunch-agent": "workspace:*", "@hashintel/brunch-agent-binding-flue": "workspace:*", "@hashintel/brunch-agent-plugin-gherkin": "workspace:*", + "@hashintel/brunch-agent-plugin-sdcpn": "workspace:*", "@hashintel/brunch-agent-transport-aisdk": "workspace:*", "hono": "4.13.2", "react": "19.2.6", diff --git a/apps/brunch-agent/src/agents/sdcpn-elicitor.ts b/apps/brunch-agent/src/agents/sdcpn-elicitor.ts new file mode 100644 index 00000000000..d0858804085 --- /dev/null +++ b/apps/brunch-agent/src/agents/sdcpn-elicitor.ts @@ -0,0 +1,54 @@ +"use agent"; +/** + * The SDCPN elicitor (spec §12.5: one agent per target). + * + * The second entry in the target gallery, and the first whose plugin is a + * file: `@hashintel/brunch-agent-plugin-sdcpn` loads `plugin.md` and the + * harness reads its three tables (ADR-0006). This module is as thin as the + * gherkin one — it mounts harness capability and holds no elicitation + * semantics of its own; what the interviewer asks, demands, and treats as + * complete all comes from the plugin file through the binding. + * + * The same three recorded Flue constraints hold here by construction + * (spec §10): `'use agent'` is the file's first statement; `agentName` is a + * pinned string literal; the tool set is static. + */ + +import { useInitialData, useModel, type AgentProps } from "@flue/runtime"; +import * as v from "valibot"; + +import { useElicitation } from "@hashintel/brunch-agent-binding-flue"; +import { sdcpn } from "@hashintel/brunch-agent-plugin-sdcpn"; + +import { createSdcpnElicitationSession } from "../elicitation-session.ts"; + +/** One definition for the agent and any faux provider alike (see the gherkin elicitor). */ +export const SDCPN_MODEL_ID = "claude-haiku-4-5"; + +const sdcpnElicitorInitialData = v.object({ + targetDocumentId: v.pipe(v.string(), v.nonEmpty()), +}); + +export function SdcpnElicitor(props: AgentProps) { + useModel(`anthropic/${SDCPN_MODEL_ID}`); + const initialData = + useInitialData>(); + return useElicitation( + sdcpn, + createSdcpnElicitationSession(props.id, initialData.targetDocumentId), + ); +} + +/** + * Pinned, and never to be edited: conversation storage keys on this literal, + * so changing it orphans every existing conversation. Product-prefixed for the + * same reason as the gherkin elicitor — agent identities are global per + * application and the demo shell mounts this library beside others. + */ +SdcpnElicitor.agentName = "brunch-sdcpn-elicitor"; + +/** + * Session→document binding (spec §9.1): `initialData` carries the + * target-document id, validated once at creation and immutable thereafter. + */ +SdcpnElicitor.initialData = sdcpnElicitorInitialData; diff --git a/apps/brunch-agent/src/app.ts b/apps/brunch-agent/src/app.ts index 65915d05f8d..43f0315289f 100644 --- a/apps/brunch-agent/src/app.ts +++ b/apps/brunch-agent/src/app.ts @@ -13,9 +13,14 @@ import { createAgentRouter } from "@flue/runtime/routing"; import { Hono } from "hono"; import { GherkinElicitor } from "./agents/gherkin-elicitor.ts"; +import { SdcpnElicitor } from "./agents/sdcpn-elicitor.ts"; import { assetHandler } from "./assets.ts"; import { petrinautChatHandler } from "./petrinaut-chat.ts"; -import { GHERKIN_AGENT_ROUTE, PETRINAUT_CHAT_ROUTE } from "./routes.ts"; +import { + GHERKIN_AGENT_ROUTE, + PETRINAUT_CHAT_ROUTE, + SDCPN_AGENT_ROUTE, +} from "./routes.ts"; const app = new Hono(); @@ -24,6 +29,9 @@ const app = new Hono(); // share the route constant; Flue still keys storage on the agent's independent, // pinned identity. app.route(`/agents/${GHERKIN_AGENT_ROUTE}`, createAgentRouter(GherkinElicitor)); +// The SDCPN elicitor is the process-model target (ADR-0006): the plugin file +// is code the harness loads, and this mount is what FE-1404's run talks to. +app.route(`/agents/${SDCPN_AGENT_ROUTE}`, createAgentRouter(SdcpnElicitor)); // The application owns the HTTP mount; transport-aisdk owns only request validation // and AI SDK stream encoding. No parallel conversation renderer is introduced. diff --git a/apps/brunch-agent/src/elicitation-session.ts b/apps/brunch-agent/src/elicitation-session.ts index 827a91833fd..be16fd92e6c 100644 --- a/apps/brunch-agent/src/elicitation-session.ts +++ b/apps/brunch-agent/src/elicitation-session.ts @@ -7,7 +7,7 @@ import { type FlueHistoryReaderOptions, } from "@hashintel/brunch-agent-binding-flue"; -import { GHERKIN_AGENT_ROUTE } from "./routes.ts"; +import { AGENT_ROUTES, type AgentTarget } from "./routes.ts"; import { targetDocumentPath } from "./target-document-path.ts"; const appTransport: FlueHistoryReaderOptions["transport"] = async ( @@ -18,7 +18,13 @@ const appTransport: FlueHistoryReaderOptions["transport"] = async ( return app.fetch(input instanceof Request ? input : new Request(input, init)); }; -export const createGherkinElicitationSession = ( +/** + * One session factory per target agent. The history reader resolves + * conversations through the agent's own route, so each target gets a + * named creator rather than a shared one that guesses the route. + */ +const createElicitationSession = ( + target: AgentTarget, sessionId: string, targetDocumentId: string, ): ElicitationSession => { @@ -30,9 +36,21 @@ export const createGherkinElicitationSession = ( captureStore, historyReader: createFlueHistoryReader({ resolveConversationUrl: (id) => - `http://brunch.local/agents/${GHERKIN_AGENT_ROUTE}/${id}`, + `http://brunch.local/agents/${AGENT_ROUTES[target]}/${id}`, transport: appTransport, archive: captureStore, }), }; }; + +export const createGherkinElicitationSession = ( + sessionId: string, + targetDocumentId: string, +): ElicitationSession => + createElicitationSession("gherkin", sessionId, targetDocumentId); + +export const createSdcpnElicitationSession = ( + sessionId: string, + targetDocumentId: string, +): ElicitationSession => + createElicitationSession("sdcpn", sessionId, targetDocumentId); diff --git a/apps/brunch-agent/src/routes.ts b/apps/brunch-agent/src/routes.ts index 739c6323023..40aaa363945 100644 --- a/apps/brunch-agent/src/routes.ts +++ b/apps/brunch-agent/src/routes.ts @@ -1,5 +1,17 @@ -/** Browser-facing route segment; conversation identity remains the agent's pinned `agentName`. */ +/** Browser-facing route segments; conversation identity remains each agent's pinned `agentName`. */ export const GHERKIN_AGENT_ROUTE = "gherkin"; +export const SDCPN_AGENT_ROUTE = "sdcpn"; + +/** One route per target agent; the gallery grows an entry per plugin (spec §13). */ +export const AGENT_ROUTES = { + gherkin: GHERKIN_AGENT_ROUTE, + sdcpn: SDCPN_AGENT_ROUTE, +} as const; + +export type AgentTarget = keyof typeof AGENT_ROUTES; + +export const isAgentTarget = (value: string | null): value is AgentTarget => + value !== null && Object.hasOwn(AGENT_ROUTES, value); /** Stock `DefaultChatTransport` endpoint used by Petrinaut's local panel. */ export const PETRINAUT_CHAT_ROUTE = "/api/chat"; diff --git a/apps/brunch-agent/src/ui/chat.tsx b/apps/brunch-agent/src/ui/chat.tsx index 3756e7b11e5..df13a33924b 100644 --- a/apps/brunch-agent/src/ui/chat.tsx +++ b/apps/brunch-agent/src/ui/chat.tsx @@ -5,10 +5,18 @@ import * as v from "valibot"; import { FreeTextAffordance } from "@hashintel/brunch-agent"; -import { GHERKIN_AGENT_ROUTE } from "../routes.ts"; +import { AGENT_ROUTES, isAgentTarget } from "../routes.ts"; const conversationId = crypto.randomUUID(); +// `?target=sdcpn` selects the agent; the gallery is one route per plugin and +// gherkin remains the default tracer. +const requestedTarget = new URLSearchParams(window.location.search).get( + "target", +); +const agentRoute = + AGENT_ROUTES[isAgentTarget(requestedTarget) ? requestedTarget : "gherkin"]; + function VisibleMessage({ message }: { message: FlueConversationMessage }) { if ( message.display !== "visible" || @@ -58,7 +66,7 @@ export function Chat() { const client = useMemo( () => createFlueClient({ - url: `/agents/${GHERKIN_AGENT_ROUTE}/${conversationId}`, + url: `/agents/${agentRoute}/${conversationId}`, }), [], ); diff --git a/apps/brunch-agent/test/build-artifact.test.ts b/apps/brunch-agent/test/build-artifact.test.ts index ad9f51dd136..c617ed2a1d0 100644 --- a/apps/brunch-agent/test/build-artifact.test.ts +++ b/apps/brunch-agent/test/build-artifact.test.ts @@ -40,13 +40,17 @@ beforeAll(() => { /** The pinned identity of every agent module in the app, read from source. */ function declaredAgentIdentities(): string[] { - const agentSource = readFileSync( - join(DEV_APP, "src/agents/gherkin-elicitor.ts"), - "utf8", - ); - return [ - ...agentSource.matchAll(/\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu), - ].map((match) => match[2]!); + const agentsDirectory = join(DEV_APP, "src/agents"); + return readdirSync(agentsDirectory) + .filter((entry) => entry.endsWith(".ts")) + .flatMap((entry) => + Array.from( + readFileSync(join(agentsDirectory, entry), "utf8").matchAll( + /\w+\.agentName\s*=\s*(["'])([^"']+)\1/gu, + ), + ), + ) + .map((match) => match[2]!); } describe("the emitted server bundle", () => { diff --git a/yarn.lock b/yarn.lock index 877a431dea5..68041ed54dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -442,6 +442,7 @@ __metadata: "@hashintel/brunch-agent": "workspace:*" "@hashintel/brunch-agent-binding-flue": "workspace:*" "@hashintel/brunch-agent-plugin-gherkin": "workspace:*" + "@hashintel/brunch-agent-plugin-sdcpn": "workspace:*" "@hashintel/brunch-agent-transport-aisdk": "workspace:*" "@types/node": "npm:22.18.13" "@types/react": "npm:19.2.14" @@ -7568,7 +7569,7 @@ __metadata: languageName: unknown linkType: soft -"@hashintel/brunch-agent-plugin-sdcpn@workspace:libs/@hashintel/brunch-agent/packages/plugin-sdcpn": +"@hashintel/brunch-agent-plugin-sdcpn@workspace:*, @hashintel/brunch-agent-plugin-sdcpn@workspace:libs/@hashintel/brunch-agent/packages/plugin-sdcpn": version: 0.0.0-use.local resolution: "@hashintel/brunch-agent-plugin-sdcpn@workspace:libs/@hashintel/brunch-agent/packages/plugin-sdcpn" dependencies: From fd7e27d1b47454ae44d1366f8adfd6118e4a5645 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Tue, 25 Aug 2026 15:11:59 +0200 Subject: [PATCH 3/3] Record the deferral-licensing gate and the read-path size finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STEERING gains a gate row for completion-spec rules 17–19 (deferral licensing): E1 now supplies the report and revision, but rule 18 makes licensing false until a durable projection delivery exists, so the work is gated on FE-1480 rather than opened as an issue. The belief "the controller read path is small" carried its own tripwire — stop and look if E1 exceeds the plugin file in size. It did. The row now records the measured sizes, splits the parser from the engine, and names the look: whether a stricter plugin-file format would shrink the parser to a schema. Co-Authored-By: Claude Fable 5 --- libs/@hashintel/brunch-agent/docs/control/STEERING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/@hashintel/brunch-agent/docs/control/STEERING.md b/libs/@hashintel/brunch-agent/docs/control/STEERING.md index abc645c7fd5..64c91b4d84c 100644 --- a/libs/@hashintel/brunch-agent/docs/control/STEERING.md +++ b/libs/@hashintel/brunch-agent/docs/control/STEERING.md @@ -104,6 +104,7 @@ The read-only Linear graph supplies mechanical availability, never priority. | --- | --- | --- | --- | --- | | FE-1480 executable realization unavailable | FE-1438; [ADR-0005](../adr/0005-model-assisted-sdcpn-realization.md) | Client tools return code diagnostics to the elicitor. | 2026-08-25 | Scaffold work may proceed; no runnable FE-1480 proof until the gate opens. | | Final use case outstanding | Dora; FE-1476 / September Plan | Dora confirms or changes it. | 2026-08-25 | If creation is required, Proof 1 becomes acceptance-relevant rather than a harness proof; reconcile ADR-0004/proof. | +| Deferral licensing (completion spec rules 17–19) unbuildable | [elicitation-completion](../specs/elicitation-completion.md) rules 17–19; FE-1480 / [ADR-0005](../adr/0005-model-assisted-sdcpn-realization.md) | A durable projection delivery exists for an evaluated revision. | 2026-08-25 | E1 supplies the report and revision (FE-1497, #9325); rule 18 makes licensing `false` without a delivered projection, so no issue is opened. When FE-1480 delivers, it is one read-time function beside `evaluateCompletion` plus a binding hook at settlement; no new persistence. | | Truck-fleet dossier missing from the repository | FE-1382 is Done but its promised `docs/reference/research/` artifact is absent. | Artifact path/branch is supplied or a reviewed replacement is selected. | 2026-08-25 | The generality half of Proof 1 uses a fixture derived from the inbox truck SDCPN and Layer B's worked example; claim no dossier-backed domain provenance. | ## Decision-relevant beliefs and unknowns @@ -112,7 +113,7 @@ The read-only Linear graph supplies mechanical availability, never priority. | --- | --- | --- | | Kind-level rows express the coatings case. | Medium-high; the twenty domain-keyed rows of the FE-1402 rehearsal collapse onto eight kind rows on paper. | Proof 1's first half. | | The truck-fleet case adds zero headings and zero rows. | Medium; Layer B was validated against it, but never through this file. | Proof 1's second half. | -| The controller read path is small. | Medium; `evaluateCompletion` is nineteen invariants over a fold the store already supports. | Build E1; if it exceeds the plugin file in size, stop and look. | +| The controller read path is small. | The tripwire fired: E1 landed on FE-1497 (#9325) at 1055 code lines (excluding comments) against the plugin file's 225 non-blank lines — 378 parse the file and narrow the proposal schema, 677 are the fold, completion, and cue. Rules 17–19 are deferred (see gates). | Look before E3: is the 378-line parser an argument for a stricter plugin-file format (YAML or front matter for the machine-read tables) so the parser shrinks to a schema? Watch whether FE-1479's affected-slice and delta moves fit inside the 677-line engine. | | Field-local code obligations support localized realization and repair. | Low-medium; the corpus and Petrinaut diagnostics are field-addressed, but no Brunch run exists. | Realize one stochastic transition without rewriting an unrelated field. | | Five turns yield a scoped correction. | Low; unrehearsed. The review-and-revise runbook in the plugin file is the first concrete trajectory. | Run two bounded rehearsals against a fixture model. | | Ask carries durable client-tool results. | Medium-low; machine results refused today. | Run one correlated FE-1438 round trip. |