diff --git a/.fixtures/seeds/bilal-port-variants/README.md b/.fixtures/seeds/bilal-port-variants/README.md index 5b766d46..a7fad7a9 100644 --- a/.fixtures/seeds/bilal-port-variants/README.md +++ b/.fixtures/seeds/bilal-port-variants/README.md @@ -15,6 +15,6 @@ Deterministic filter, implemented in [`_variant-script.ts`](./_variant-script.ts - keep only nodes whose `source` starts with `stakeholder`, `external-observed`, or `technical-observed` - keep only edges whose endpoints both survive the node filter - rewrite `local_id` and edge endpoint ids densely from 1 in source order -- emit spec slug `macro-view-grounded-intent` at `readiness_grade: elicitation_ready` +- emit spec slug `macro-view-grounded-intent` The variant is curated starting truth for tracer runs. Product-created curation output is not merged back into this reusable seed; mixed-basis evidence belongs under `.fixtures/runs/fixture-curation//`. diff --git a/.fixtures/seeds/bilal-port-variants/_variant-script.ts b/.fixtures/seeds/bilal-port-variants/_variant-script.ts index 0ad0c3f7..353712d5 100644 --- a/.fixtures/seeds/bilal-port-variants/_variant-script.ts +++ b/.fixtures/seeds/bilal-port-variants/_variant-script.ts @@ -6,7 +6,6 @@ interface SeedFixture { spec: { slug: string; name: string; - readiness_grade: string; }; nodes: Array<{ local_id: number; @@ -67,7 +66,6 @@ async function main(): Promise { spec: { slug: VARIANT_SLUG, name: 'Macro View — grounded intent base', - readiness_grade: 'elicitation_ready', }, nodes, edges, diff --git a/.fixtures/seeds/bilal-port-variants/macro-view-grounded-intent.json b/.fixtures/seeds/bilal-port-variants/macro-view-grounded-intent.json index 8a6f73ab..ef6878a8 100644 --- a/.fixtures/seeds/bilal-port-variants/macro-view-grounded-intent.json +++ b/.fixtures/seeds/bilal-port-variants/macro-view-grounded-intent.json @@ -1,8 +1,7 @@ { "spec": { "slug": "macro-view-grounded-intent", - "name": "Macro View — grounded intent base", - "readiness_grade": "elicitation_ready" + "name": "Macro View — grounded intent base" }, "nodes": [ { diff --git a/.fixtures/seeds/bilal-port/README.md b/.fixtures/seeds/bilal-port/README.md index b54e70aa..980e20f0 100644 --- a/.fixtures/seeds/bilal-port/README.md +++ b/.fixtures/seeds/bilal-port/README.md @@ -55,7 +55,7 @@ Each `.json` is the seed contract consumed by the loader: ``` { - "spec": { "slug", "name", "readiness_grade" }, + "spec": { "slug", "name" }, "nodes": [ { "local_id", "plane", "kind", "title", "body?", "basis", "source?", "detail?" } ], "edges": [ { "category", "source_local_id", "target_local_id", "stance?", "basis", "rationale?" } ] } diff --git a/.fixtures/seeds/bilal-port/_port-script.ts b/.fixtures/seeds/bilal-port/_port-script.ts index f1e5d93f..a75919c1 100644 --- a/.fixtures/seeds/bilal-port/_port-script.ts +++ b/.fixtures/seeds/bilal-port/_port-script.ts @@ -701,7 +701,7 @@ function portSpec(sourceName: string, slug: string, displayName: string): SpecPo /** Assemble the consolidated seed contract — one file per spec, atomic seed unit. */ function buildSeed(result: SpecPortResult, displayName: string): SeedFixture { return { - spec: { slug: result.slug, name: displayName, readiness_grade: 'commitments_ready' }, + spec: { slug: result.slug, name: displayName }, nodes: result.brunchNodes, edges: result.brunchEdges, }; @@ -783,7 +783,7 @@ function writeReadme(results: { slug: string; displayName: string; stats: Record '', '```', '{', - ' "spec": { "slug", "name", "readiness_grade" },', + ' "spec": { "slug", "name" },', ' "nodes": [ { "local_id", "plane", "kind", "title", "body?", "basis", "source?", "detail?" } ],', ' "edges": [ { "category", "source_local_id", "target_local_id", "stance?", "basis", "rationale?" } ]', '}', diff --git a/.fixtures/seeds/bilal-port/code-health.json b/.fixtures/seeds/bilal-port/code-health.json index 139f60f9..403b9529 100644 --- a/.fixtures/seeds/bilal-port/code-health.json +++ b/.fixtures/seeds/bilal-port/code-health.json @@ -1,8 +1,7 @@ { "spec": { "slug": "code-health", - "name": "Code Health", - "readiness_grade": "commitments_ready" + "name": "Code Health" }, "nodes": [ { diff --git a/.fixtures/seeds/bilal-port/explorer-ui.json b/.fixtures/seeds/bilal-port/explorer-ui.json index 5fd7aaf4..d8ab2e38 100644 --- a/.fixtures/seeds/bilal-port/explorer-ui.json +++ b/.fixtures/seeds/bilal-port/explorer-ui.json @@ -1,8 +1,7 @@ { "spec": { "slug": "explorer-ui", - "name": "Explorer UI", - "readiness_grade": "commitments_ready" + "name": "Explorer UI" }, "nodes": [ { diff --git a/.fixtures/seeds/bilal-port/macro-view.json b/.fixtures/seeds/bilal-port/macro-view.json index 8020c88d..8a693cf0 100644 --- a/.fixtures/seeds/bilal-port/macro-view.json +++ b/.fixtures/seeds/bilal-port/macro-view.json @@ -1,8 +1,7 @@ { "spec": { "slug": "macro-view", - "name": "Macro View", - "readiness_grade": "commitments_ready" + "name": "Macro View" }, "nodes": [ { diff --git a/.fixtures/seeds/brunch-self/spec-graph.json b/.fixtures/seeds/brunch-self/spec-graph.json index 62ad0be5..b7130ca4 100644 --- a/.fixtures/seeds/brunch-self/spec-graph.json +++ b/.fixtures/seeds/brunch-self/spec-graph.json @@ -1,8 +1,7 @@ { "spec": { "slug": "brunch-self", - "name": "Brunch (self-described spec graph)", - "readiness_grade": "planning_ready" + "name": "Brunch (self-described spec graph)" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Build Brunch as a local spec-elicitation product layered on Pi without forking it", "basis": "explicit", "source": "memory/SPEC.md" }, diff --git a/.fixtures/seeds/dumpchat/README.md b/.fixtures/seeds/dumpchat/README.md index 04bb9e5f..5c296b78 100644 --- a/.fixtures/seeds/dumpchat/README.md +++ b/.fixtures/seeds/dumpchat/README.md @@ -21,7 +21,7 @@ Faithful vs. projected: milestone / frontier / slice nodes are plausible projections from the intent, marked `source: "projected"`. -`readiness_grade` is `commitments_ready`: the source spec commits firmly to +The source spec commits firmly to decisions, invariants, and selector policy, but carries no explicit plan. Coverage (a by-product of being faithful, not the goal): diff --git a/.fixtures/seeds/dumpchat/spec-graph.json b/.fixtures/seeds/dumpchat/spec-graph.json index 847a449c..62aac9b1 100644 --- a/.fixtures/seeds/dumpchat/spec-graph.json +++ b/.fixtures/seeds/dumpchat/spec-graph.json @@ -1,8 +1,7 @@ { "spec": { "slug": "dumpchat", - "name": "Dumpchat (chat-export browser extension spec graph)", - "readiness_grade": "commitments_ready" + "name": "Dumpchat (chat-export browser extension spec graph)" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Export AI chat conversations to a Markdown file from supported platforms", "basis": "explicit", "source": "README.md" }, diff --git a/.fixtures/seeds/edge-spread/category-directions.json b/.fixtures/seeds/edge-spread/category-directions.json index 3cfce16c..8fdff37b 100644 --- a/.fixtures/seeds/edge-spread/category-directions.json +++ b/.fixtures/seeds/edge-spread/category-directions.json @@ -1,8 +1,7 @@ { "spec": { "slug": "category-directions", - "name": "Category Directions", - "readiness_grade": "commitments_ready" + "name": "Category Directions" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "thesis", "title": "Unproven thesis exemplar", "basis": "explicit", "source": "fixture" }, diff --git a/.fixtures/seeds/edge-spread/hub-neighborhood.json b/.fixtures/seeds/edge-spread/hub-neighborhood.json index 977f803e..dff64b51 100644 --- a/.fixtures/seeds/edge-spread/hub-neighborhood.json +++ b/.fixtures/seeds/edge-spread/hub-neighborhood.json @@ -1,8 +1,7 @@ { "spec": { "slug": "hub-neighborhood", - "name": "Hub Neighborhood", - "readiness_grade": "commitments_ready" + "name": "Hub Neighborhood" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "requirement", "title": "Stage 2 configuration-space requirement (hub anchor)", "basis": "explicit", "source": "fixture" }, diff --git a/.fixtures/seeds/fable/README.md b/.fixtures/seeds/fable/README.md index 21153941..b3376326 100644 --- a/.fixtures/seeds/fable/README.md +++ b/.fixtures/seeds/fable/README.md @@ -24,7 +24,7 @@ Faithful vs. projected: and slices map to the real done/pending roadmap slices (config spike, walking skeleton, manifest parity, preview mode, source view, watermark audit). -`readiness_grade` is `planning_ready`: the source carries a committed SPEC plus +The source carries a committed SPEC plus an ordered ROADMAP of done and pending slices. Coverage (a by-product of being faithful, not the goal): diff --git a/.fixtures/seeds/fable/spec-graph.json b/.fixtures/seeds/fable/spec-graph.json index 05362d54..88661582 100644 --- a/.fixtures/seeds/fable/spec-graph.json +++ b/.fixtures/seeds/fable/spec-graph.json @@ -1,8 +1,7 @@ { "spec": { "slug": "fable", - "name": "Fable (Vite-native component workbench)", - "readiness_grade": "planning_ready" + "name": "Fable (Vite-native component workbench)" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Build a Vite-native component workbench with a small deep core and explicit framework adapters", "basis": "explicit", "source": "memory/SPEC.md" }, diff --git a/.fixtures/seeds/kind-band-spread/coverage-matrix.json b/.fixtures/seeds/kind-band-spread/coverage-matrix.json index 2e38ec51..4f948df0 100644 --- a/.fixtures/seeds/kind-band-spread/coverage-matrix.json +++ b/.fixtures/seeds/kind-band-spread/coverage-matrix.json @@ -1,8 +1,7 @@ { "spec": { "slug": "coverage-matrix", - "name": "Coverage Matrix", - "readiness_grade": "elicitation_ready" + "name": "Coverage Matrix" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Anchor the product problem", "basis": "explicit", "source": "fixture" }, diff --git a/.fixtures/seeds/rd-loop/spec-graph.json b/.fixtures/seeds/rd-loop/spec-graph.json index 3ce1409b..53c125c7 100644 --- a/.fixtures/seeds/rd-loop/spec-graph.json +++ b/.fixtures/seeds/rd-loop/spec-graph.json @@ -1,8 +1,7 @@ { "spec": { "slug": "rd-loop", - "name": "RD-Loop (frontier-governance harness for autonomous R&D)", - "readiness_grade": "planning_ready" + "name": "RD-Loop (frontier-governance harness for autonomous R&D)" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Govern autonomous R&D as a controlled epistemic process, not a single agent trying hard", "basis": "explicit", "source": "concept-2-b.md" }, diff --git a/.fixtures/seeds/workspace-spread/alpha-grounding.json b/.fixtures/seeds/workspace-spread/alpha-grounding.json index f939064b..b2c64d1b 100644 --- a/.fixtures/seeds/workspace-spread/alpha-grounding.json +++ b/.fixtures/seeds/workspace-spread/alpha-grounding.json @@ -1,8 +1,7 @@ { "spec": { "slug": "alpha-grounding", - "name": "Alpha Grounding", - "readiness_grade": "grounding_onboarding" + "name": "Alpha Grounding" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Help a user orient inside one workspace", "basis": "explicit", "source": "fixture" }, diff --git a/.fixtures/seeds/workspace-spread/beta-commitments.json b/.fixtures/seeds/workspace-spread/beta-commitments.json index 333c78d2..619b2f2c 100644 --- a/.fixtures/seeds/workspace-spread/beta-commitments.json +++ b/.fixtures/seeds/workspace-spread/beta-commitments.json @@ -1,8 +1,7 @@ { "spec": { "slug": "beta-commitments", - "name": "Beta Commitments", - "readiness_grade": "commitments_ready" + "name": "Beta Commitments" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "requirement", "title": "Workspace overviews should report node counts per spec", "basis": "explicit", "source": "fixture" }, diff --git a/.fixtures/seeds/yamlbase/spec-graph.json b/.fixtures/seeds/yamlbase/spec-graph.json index 7ea93663..d062ff09 100644 --- a/.fixtures/seeds/yamlbase/spec-graph.json +++ b/.fixtures/seeds/yamlbase/spec-graph.json @@ -1,8 +1,7 @@ { "spec": { "slug": "yamlbase", - "name": "Yamlbase (Dogbase — agent-oriented local DB with Git-backed JSON storage)", - "readiness_grade": "planning_ready" + "name": "Yamlbase (Dogbase — agent-oriented local DB with Git-backed JSON storage)" }, "nodes": [ { "local_id": 1, "plane": "intent", "kind": "goal", "title": "Give agents a fast, queryable local DB whose data stays human-auditable and Git-trackable", "basis": "explicit", "source": "memory/SPEC.md" }, diff --git a/drizzle/0004_gaps_node_kind_reference.sql b/drizzle/0004_gaps_node_kind_reference.sql index 695d3b0b..8aff8032 100644 --- a/drizzle/0004_gaps_node_kind_reference.sql +++ b/drizzle/0004_gaps_node_kind_reference.sql @@ -1,3 +1,85 @@ -ALTER TABLE `elicitation_gaps` ADD `refers_to` text NOT NULL;--> statement-breakpoint -ALTER TABLE `elicitation_gaps` ADD `question` text NOT NULL;--> statement-breakpoint -ALTER TABLE `elicitation_gaps` DROP COLUMN `name`; \ No newline at end of file +CREATE TABLE `elicitation_gaps_new` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `spec_id` integer NOT NULL, + `refers_to` text NOT NULL, + `question` text NOT NULL, + `rationale` text NOT NULL, + `disposition` text DEFAULT 'open' NOT NULL, + `basis` text DEFAULT 'explicit' NOT NULL, + `readiness_band` text NOT NULL, + `predicate_kind` text NOT NULL, + `predicate` text NOT NULL, + `importance` integer DEFAULT 1 NOT NULL, + `plane_affinity` text, + `lens_affinity` text, + `arose_from_gap_id` integer, + `resolved_by_node_id` integer, + `created_at_lsn` integer NOT NULL, + `disposition_set_at_lsn` integer, + FOREIGN KEY (`spec_id`) REFERENCES `specs`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`arose_from_gap_id`) REFERENCES `elicitation_gaps_new`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`resolved_by_node_id`) REFERENCES `nodes`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `elicitation_gaps_new` ( + `id`, + `spec_id`, + `refers_to`, + `question`, + `rationale`, + `disposition`, + `basis`, + `readiness_band`, + `predicate_kind`, + `predicate`, + `importance`, + `plane_affinity`, + `lens_affinity`, + `arose_from_gap_id`, + `resolved_by_node_id`, + `created_at_lsn`, + `disposition_set_at_lsn` +) +SELECT + `id`, + `spec_id`, + CASE + WHEN `name` = 'domain' THEN 'context' + WHEN `name` = 'protagonist' THEN 'thesis' + WHEN `name` = 'pain_pull' THEN 'thesis' + WHEN `name` = 'constraint' THEN 'constraint' + WHEN `name` = 'value' THEN 'goal' + WHEN `name` = 'context_of_use' THEN 'context' + WHEN `name` = 'success_sketch' THEN 'criterion' + WHEN `name` = 'solution_boundary' THEN 'constraint' + ELSE COALESCE(json_extract(`predicate`, '$.nodeKind'), json_extract(`predicate`, '$.subjectKind'), 'context') + END, + CASE + WHEN `name` = 'domain' THEN 'What kind of thing is this, and what domain or environment does it live in?' + WHEN `name` = 'protagonist' THEN 'Who is this for?' + WHEN `name` = 'pain_pull' THEN 'What pull or pain makes this worth doing?' + WHEN `name` = 'constraint' THEN 'What binding constraints, non-goals, or boundaries already shape the work?' + WHEN `name` = 'value' THEN 'What outcome or value should this create?' + WHEN `name` = 'context_of_use' THEN 'When, where, or under what conditions will it be used?' + WHEN `name` = 'success_sketch' THEN 'How will we recognize success or good enough?' + WHEN `name` = 'solution_boundary' THEN 'What is explicitly out of scope or off the table?' + ELSE CASE WHEN trim(`name`) = '' THEN 'What needs to be clarified here?' ELSE trim(`name`) END + END, + `rationale`, + `disposition`, + `basis`, + `readiness_band`, + `predicate_kind`, + `predicate`, + `importance`, + `plane_affinity`, + `lens_affinity`, + `arose_from_gap_id`, + `resolved_by_node_id`, + `created_at_lsn`, + `disposition_set_at_lsn` +FROM `elicitation_gaps`; +--> statement-breakpoint +DROP TABLE `elicitation_gaps`; +--> statement-breakpoint +ALTER TABLE `elicitation_gaps_new` RENAME TO `elicitation_gaps`; diff --git a/drizzle/0005_bumpy_vampiro.sql b/drizzle/0005_bumpy_vampiro.sql new file mode 100644 index 00000000..3fd0227d --- /dev/null +++ b/drizzle/0005_bumpy_vampiro.sql @@ -0,0 +1 @@ +ALTER TABLE `specs` DROP COLUMN `readiness_grade`; \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000..537446b2 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,780 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b6ec09f4-c04b-4a39-aa05-f95867c078b2", + "prevId": "4ae1e0f1-9aa2-4188-b2b0-1c9b2b39a320", + "tables": { + "change_log": { + "name": "change_log", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lsn": { + "name": "lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "change_log_spec_id_specs_id_fk": { + "name": "change_log_spec_id_specs_id_fk", + "tableFrom": "change_log", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "change_log_spec_lsn_pk": { + "columns": [ + "spec_id", + "lsn" + ], + "name": "change_log_spec_lsn_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "edges": { + "name": "edges", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stance": { + "name": "stance", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basis": { + "name": "basis", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'explicit'" + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_lsn": { + "name": "created_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_lsn": { + "name": "updated_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "edges_spec_id_specs_id_fk": { + "name": "edges_spec_id_specs_id_fk", + "tableFrom": "edges", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "edges_source_id_nodes_id_fk": { + "name": "edges_source_id_nodes_id_fk", + "tableFrom": "edges", + "tableTo": "nodes", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "edges_target_id_nodes_id_fk": { + "name": "edges_target_id_nodes_id_fk", + "tableFrom": "edges", + "tableTo": "nodes", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "elicitation_gaps": { + "name": "elicitation_gaps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refers_to": { + "name": "refers_to", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "basis": { + "name": "basis", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'explicit'" + }, + "readiness_band": { + "name": "readiness_band", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "predicate_kind": { + "name": "predicate_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "predicate": { + "name": "predicate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "importance": { + "name": "importance", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "plane_affinity": { + "name": "plane_affinity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lens_affinity": { + "name": "lens_affinity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "arose_from_gap_id": { + "name": "arose_from_gap_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by_node_id": { + "name": "resolved_by_node_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_lsn": { + "name": "created_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disposition_set_at_lsn": { + "name": "disposition_set_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "elicitation_gaps_spec_id_specs_id_fk": { + "name": "elicitation_gaps_spec_id_specs_id_fk", + "tableFrom": "elicitation_gaps", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "elicitation_gaps_arose_from_gap_id_elicitation_gaps_id_fk": { + "name": "elicitation_gaps_arose_from_gap_id_elicitation_gaps_id_fk", + "tableFrom": "elicitation_gaps", + "tableTo": "elicitation_gaps", + "columnsFrom": [ + "arose_from_gap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "elicitation_gaps_resolved_by_node_id_nodes_id_fk": { + "name": "elicitation_gaps_resolved_by_node_id_nodes_id_fk", + "tableFrom": "elicitation_gaps", + "tableTo": "nodes", + "columnsFrom": [ + "resolved_by_node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "graph_clock": { + "name": "graph_clock", + "columns": { + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lsn": { + "name": "lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "graph_clock_spec_id_specs_id_fk": { + "name": "graph_clock_spec_id_specs_id_fk", + "tableFrom": "graph_clock", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "node_kind_counters": { + "name": "node_kind_counters", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plane": { + "name": "plane", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_ordinal": { + "name": "next_ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "node_kind_counters_spec_plane_kind_unique": { + "name": "node_kind_counters_spec_plane_kind_unique", + "columns": [ + "spec_id", + "plane", + "kind" + ], + "isUnique": true + } + }, + "foreignKeys": { + "node_kind_counters_spec_id_specs_id_fk": { + "name": "node_kind_counters_spec_id_specs_id_fk", + "tableFrom": "node_kind_counters", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nodes": { + "name": "nodes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plane": { + "name": "plane", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind_ordinal": { + "name": "kind_ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "basis": { + "name": "basis", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'explicit'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_lsn": { + "name": "created_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at_lsn": { + "name": "updated_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "nodes_spec_plane_kind_ordinal_unique": { + "name": "nodes_spec_plane_kind_ordinal_unique", + "columns": [ + "spec_id", + "plane", + "kind", + "kind_ordinal" + ], + "isUnique": true + } + }, + "foreignKeys": { + "nodes_spec_id_specs_id_fk": { + "name": "nodes_spec_id_specs_id_fk", + "tableFrom": "nodes", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reconciliation_need": { + "name": "reconciliation_need", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "spec_id": { + "name": "spec_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_edge_id": { + "name": "target_edge_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_a_id": { + "name": "target_a_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_b_id": { + "name": "target_b_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_lsn": { + "name": "created_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at_lsn": { + "name": "resolved_at_lsn", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "reconciliation_need_spec_id_specs_id_fk": { + "name": "reconciliation_need_spec_id_specs_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "specs", + "columnsFrom": [ + "spec_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reconciliation_need_target_edge_id_edges_id_fk": { + "name": "reconciliation_need_target_edge_id_edges_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "edges", + "columnsFrom": [ + "target_edge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reconciliation_need_target_a_id_nodes_id_fk": { + "name": "reconciliation_need_target_a_id_nodes_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "nodes", + "columnsFrom": [ + "target_a_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reconciliation_need_target_b_id_nodes_id_fk": { + "name": "reconciliation_need_target_b_id_nodes_id_fk", + "tableFrom": "reconciliation_need", + "tableTo": "nodes", + "columnsFrom": [ + "target_b_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "specs": { + "name": "specs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b2834794..2e0777b2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1781108939451, "tag": "0004_gaps_node_kind_reference", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1781168021116, + "tag": "0005_bumpy_vampiro", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/memory/PLAN.md b/memory/PLAN.md index f38e7bcd..0e10804d 100644 --- a/memory/PLAN.md +++ b/memory/PLAN.md @@ -82,14 +82,14 @@ per ledger row: ### Active -- None. +- No active frontier. `capability-readiness` completed the stored-grade deletion sweep on 2026-06-11; see §Next for the next sequenced frontier. ### Readiness & elicitation-gaps remodel (recommended ahead of the trio) Post-`ln-spec` implications that are **upstream** of the context-pipeline trio's readiness/chrome-touching locks (see Context §Readiness / elicitation-gaps remodel). Land the hard chain before stage 1 freezes `workspace/workspace-state` + `session/runtime-state` shapes, or bracket those fields in the trio. 1. `gaps-node-kind-reference` — **done 2026-06-10.** Reshaped the gaps substrate onto node kinds per D75-L: `refersTo: NodeKind` + a free-form `question` replaced the typology `name` enum; reseeded grounding by node kind (floor `context`/`thesis`/`goal`/`constraint` plus `term`/`assumption`); `capability → NodeKind[]` replaced `RelevantGapName`. Absorbed the retired refactor plan (folded into D75-L). -2. `capability-readiness` — **depends on `gaps-node-kind-reference` (done).** Replace the stored-grade gate (`readiness_grade`, `updateReadinessGrade`, `READINESS_GRADES`, `MIN_GRADE` proxies) with JIT capability→relevant-gaps judgment over the node-kind map; add the soft derived `readiness estimate` (UI-only); remove `chrome.phase` / `chrome.chatMode`. +2. `capability-readiness` — **done 2026-06-11 (depends on `gaps-node-kind-reference`, done).** Runtime affordances, method/manifest/tool legality, soft derived readiness estimate projection, agent-prompt display, workspace/chrome display, and the stored-grade deletion sweep now read `ElicitationGap[]` / gap coverage rather than a persisted grade. `specs.readiness_grade`, `updateReadinessGrade`, `READINESS_GRADES`, residual grade prompt carriers, and fixture/probe grade setup are retired. ### Next @@ -114,7 +114,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - `topology-readmes-and-boundaries` — small doc/test hardening when a frontier moves files or exposes a boundary; should remain attached to the frontier when possible rather than becoming an abstract cleanup project. - `dev-seed-fixtures` — rich, real seed data for local dev / manual / observer testing: the consolidated seed contract, the `npm run seed` loader, and growing/enhancing fixture sets (Bilal-port + legacy). Its semantic curation mutation slice is folded into / blocked by `role-safe-graph-mutations`; ongoing seed-data maintenance remains low-conflict. - `dx-introspection-live` — DX follow-on to `dx-feedback-loops`: harden the four-role `.fixtures/` topology + `--cwd` launch (D70-L), unify dev gating under `BRUNCH_DEV` and wire the dormant introspection extension into the real TUI (D71-L), and make introspection conversational (A26-L). Three sequenced slices; ready for a scoping thread. Low-conflict with the product trio; touches `.fixtures/`, `src/app/`, `src/dev/`, `src/.pi/extensions/introspection/`. -- `runtime-vocab-leaf` — establish `src/session/schema/kinds.ts` as the drizzle-free source-of-truth leaf for the session/runtime axis enums (`op_mode`, `strategy`, `lens`, `goal`, `auto` sentinel), mirroring `graph/schema/kinds.ts` (D73-L ownership direction). The decision-3 follow-on; independent of the remodel chain and the trio. Does **not** relocate `READINESS_GRADES` (retired by `capability-readiness`). +- `runtime-vocab-leaf` — establish `src/session/schema/kinds.ts` as the drizzle-free source-of-truth leaf for the session/runtime axis enums (`op_mode`, `strategy`, `lens`, `goal`, `auto` sentinel), mirroring `graph/schema/kinds.ts` (D73-L ownership direction). The decision-3 follow-on; independent of the remodel chain and the trio. Must not recreate `READINESS_GRADES` (retired by `capability-readiness`). ### Horizon @@ -231,7 +231,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Name:** JIT capability-readiness over gaps; retire the stored readiness grade - **Linear:** unassigned — create in FE / brunch when the frontier starts. - **Kind:** structural -- **Status:** next (recommended ahead of the trio) +- **Status:** done — completed 2026-06-11 after the grade-deletion sweep - **Certainty:** proving - **Depends on:** `gaps-node-kind-reference` (hard — the gate reads node-kind-referencing gaps and a `capability → NodeKind[]` map; transitively `elicitation-gaps-remodel`, done). - **Retires:** the stored `readiness_grade` scalar and grade-as-authority (D45-L); A27-L (the `capability → relevant gaps` map carries enough signal to drive proceed / negotiate without a standing grade). @@ -249,7 +249,7 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Cross-cutting obligations:** Readiness never bars graph truth or work (I31-L); `CommandExecutor` must not reject a node for a later-band kind (D64-L). The deferred milestone gate for export/plan/execute op-modes stays deferred (D45-L). Replace grade-gate tests across `compose.test.ts` / `prompting.test.ts` and createSpec/getSpec rather than preserving them. - **Traceability:** D25-L, D30-L, D32-L, D45-L, D57-L, D58-L, D59-L, D64-L, D65-L, D73-L, D74-L, D75-L / A27-L / I25-L, I31-L. Supersedes stored-grade gating and the `chrome.phase` / `chrome.chatMode` fields. - **Design docs:** `memory/SPEC.md` D45-L / D74-L; `src/projections/session/runtime-policy.ts`; `src/projections/workspace/workspace-state.ts`. -- **Current execution pointer:** D74-L JIT gate tracer done 2026-06-10: explicit capability→grounding-gap map, proceed / low-epistemic / negotiate outcome, live presence-coverage flip, no grade-symbol import. Deferred follow-ons remain to re-scope because their shape depends on the gate interface: readiness-estimate projection, consumer rewire off `MIN_GRADE`, stored-grade deletion, `chrome.phase`/`chatMode` removal. +- **Current execution pointer:** Done 2026-06-11. Slices 1–5 moved all legality and display consumers from the old grade/phase-era fields to selected-spec `ElicitationGap[]` / derived readiness estimates. The final grade-deletion sweep removed `specs.readiness_grade`, `updateReadinessGrade`, `READINESS_GRADES`, `ReadinessGrade`, and `AgentPromptSpecContext.readinessGrade`; regenerated migration metadata; stripped readiness grade from seed/export fixture contracts and JSON seed files; and removed probe setup calls that only advanced the legacy grade. `createSpec` / `getSpec` now carry only spec identity (`id`, `name`, `slug`), and readiness remains gap-derived at the consumers. ### runtime-vocab-leaf @@ -259,14 +259,14 @@ The near-term spine has two tracks. The **context-pipeline coverage trio** remai - **Status:** parallel / low-conflict - **Certainty:** proving (low blast radius) - **Stabilizes:** D73-L's ownership direction extended to the runtime/session axes — a drizzle-free `src/session/schema/kinds.ts` leaf owning the closed enum arrays for the runtime axes (`op_mode`, `strategy`, `lens`, `goal`, and the `auto` selection sentinel), mirroring `src/graph/schema/kinds.ts`. -- **Objective:** Establish `src/session/schema/kinds.ts` as the single source of truth for the session/runtime axis vocabulary currently scattered (e.g. `MethodId` in `src/.pi/agents/state.ts`, axis ids in `runtime-policy.ts` / `affordances.ts`). Consumers import the closed arrays from the leaf; the leaf imports nothing (no drizzle, no pi). Does **not** relocate `READINESS_GRADES` (retired by `capability-readiness`). +- **Objective:** Establish `src/session/schema/kinds.ts` as the single source of truth for the session/runtime axis vocabulary currently scattered (e.g. `MethodId` in `src/.pi/agents/state.ts`, axis ids in `runtime-policy.ts` / `affordances.ts`). Consumers import the closed arrays from the leaf; the leaf imports nothing (no drizzle, no pi). Must not recreate `READINESS_GRADES` (retired by `capability-readiness`). - **Why now / unlocks:** The user asked (decision 3) for a runtime-state source-of-truth file parallel to `graph/schema/kinds.ts` so `op_mode` / `strategy` / `lens` / `goal` enums have one home. Independent of the remodel chain and the trio; low conflict. - **Acceptance:** - `src/session/schema/kinds.ts` exists as a pure constants leaf and owns the runtime axis enums; axis-id consumers import from it. - No runtime axis enum is re-declared in `.pi/agents/state.ts`, `runtime-policy.ts`, or `affordances.ts`. - The leaf imports nothing runtime-heavy (drizzle-free, pi-free), matching the D73-L graph-leaf posture. - **Verification:** Inner — import-boundary / architecture test that the leaf imports nothing and that consumers source axis enums from it. -- **Cross-cutting obligations:** Keep the leaf a pure constants module, not a behavior home; do not relocate the retired `READINESS_GRADES`. +- **Cross-cutting obligations:** Keep the leaf a pure constants module, not a behavior home; do not recreate the retired `READINESS_GRADES`. - **Traceability:** D58-L, D59-L, D73-L / I25-L. - **Design docs:** `src/session/README.md`; `src/graph/schema/kinds.ts` (template). @@ -659,14 +659,14 @@ nodes: dx-feedback-loops [done · proving] consolidated src/dev front door (faux/real/introspection loops) + latest-pi source-alias; sealed-profile-safe read-only introspection capture dx-introspection-live [next · proving] wire dormant introspection into real TUI; four-role .fixtures topology + --cwd; unify BRUNCH_DEV; conversational self-report graph-observed-shapes [done · proving] ratified consumer-specific observed-shape ledger + drift guard; no transport shape shipped - runtime-affordances-and-legality [done · proving] shared affordance(resolvedState, grade) derivation + coverage ledger; review-set/turn-mode rows tripwired + runtime-affordances-and-legality [done · proving] shared affordance derivation + coverage ledger; review-set/turn-mode rows tripwired (superseded by gap-based capability-readiness) role-safe-graph-mutations [done · proving] canonical mutateGraph/mutate_graph authored grammar; role-named edges; retire exposed commitGraph/commit_graph projection-shape-coverage [next · coverage] TRIO stage 1 (#project, PROJECT): create projections ledger + no-loss/shape invariants over dark graph/transcript DTOs; invariant-kind, NOT golden renderer-golden-coverage [next · coverage] TRIO stage 2 (#render, RENDER): create renderer ledger + golden-lock every durable renderer; depends on projection-shape-coverage prompt-composition-golden-coverage [next · coverage] TRIO stage 3 (#compose, COMPOSE): composed-prompt preview + golden-lock partials/composition matrix; depends on renderer-golden-coverage elicitation-gaps-remodel [done · proving] remodeled elicitation_gaps obligation register; live presence derivation (grounding typology catalog superseded by gaps-node-kind-reference, D75-L) gaps-node-kind-reference [done · proving] D75-L node-kind gap reference landed; typology name/RelevantGapName retired; same-kind discrimination probe covered - capability-readiness [next · proving] JIT capability->relevant-gaps gate + readiness estimate (UI-only); retire readiness_grade / MIN_GRADE / chrome.phase+chatMode + capability-readiness [done · proving] JIT capability->relevant-gaps gate + readiness estimate (UI-only); stored grade / MIN_GRADE / chrome.phase+chatMode retired runtime-vocab-leaf [parallel · proving] src/session/schema/kinds.ts source-of-truth leaf for op_mode/strategy/lens/goal (D73-L direction); decision-3 follow-on elicitation-driver [after-trio · proving] live per-turn what-to-ask-next driver on remodeled elicitation_gaps; rides COMPOSE oracle; closes cross-cut Seam 3a exchanges-and-generalized-capture [after-trio · proving] bounded feature (NOT coverage): narrow extractive capture + false-commit guard + exchange symmetry audit @@ -687,7 +687,7 @@ edges: elicitation-gaps-remodel -[hard]-> gaps-node-kind-reference (reshape gaps onto node kinds; refersTo NodeKind replaces the typology name enum, D75-L) gaps-node-kind-reference -[hard]-> capability-readiness (gate + readiness estimate read node-kind-referencing gaps and a capability->NodeKind[] map) gaps-node-kind-reference -[hard]-> elicitation-driver (driver ranks/selects over the final gap shape: refersTo NodeKind + question) - capability-readiness -[shape]-> projection-shape-coverage (mutates workspace-state/runtime-state shapes the trio stage 1 would lock; land first or bracket those fields) + capability-readiness -[shape]-> projection-shape-coverage (workspace-state/runtime-state readiness shape is now gap-derived; lock after this completed frontier) gaps-node-kind-reference -[shape]-> projection-shape-coverage (gaps register surfaces through projections; lock upstream shape first) graph-tool-resilience -[hard]-> role-safe-graph-mutations (current graph tool + edge model exist) project-graph-review-cycle -[hard]-> role-safe-graph-mutations (current review-set proposal/accept path exists) diff --git a/memory/SPEC.md b/memory/SPEC.md index eb0b0925..97df7b62 100644 --- a/memory/SPEC.md +++ b/memory/SPEC.md @@ -121,7 +121,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | A24-L | A flat `elicitation_gaps` table (prospective memory) is sufficient to drive elicitor questioning, seed grounding, and feed capability-readiness without graph structure — gaps are typed coverage obligations (typologies), not graph nodes; apparent dependency among gaps is mediated by the claims their resolution produces. | medium | validated | D65-L, D74-L, D75-L | 2026-06-08 FE-823 materialized the flat table (built as `elicitation_backlog`) on the real LSN/change-log seam. 2026-06-10 `elicitation-gaps-remodel` replaced that question-instance shape with the typed obligation register, regenerated the table as `elicitation_gaps`, seeded the grounding typology catalog, and proved live presence-derived coverage/answered read-back without stored structural answers; `gaps-node-kind-reference` then retired the catalog/name vocabulary in favor of `refersTo: NodeKind` + free-form `question`. Remaining downstream proof is capture-reflection spawning; if genuine gap→gap dependency or rich traversal emerges, promote the table to a plane (rows→nodes, FK pointers→edges). | | A25-L | Tracking the latest `pi-coding-agent` release continuously (via source-alias in dev + package dependency bumps) keeps Brunch adaptable without routinely destabilizing it, because Brunch's pi product-behavior surface is concentrated in a few sealed integration seams (the `src/.pi/` extension bundle and the session/runtime adapters) behind the D39-L profile — even though pi *types* are imported across ~25 files, those are mostly type-only and pass through that small set of seams. | medium | partially validated | D67-L | 2026-06-09 FE-825 bumped Brunch to pi 0.79, kept type/default resolution on installed `dist`, added a `PI_SOURCE`-gated vite/vitest runtime alias to sibling `pi-mono` source, preserved product default sealed-profile/offline behavior, and passed `npm run verify`. Each later pi bump that lands without product-behavior regressions raises confidence; a bump that silently breaks sealed-profile assumptions falsifies it. | | A26-L | The refined "conversational introspection" goal can be built as a *read-only session-query-back tool*: under `BRUNCH_DEV`, the agent can call `brunch_session_query` over `ctx.sessionManager.getBranch()`, find entries by predicate, project capped dot/`[n]`/`[*]` paths, and surface exact returned values in chat without weakening D39-L sealing or turning self-reporting into product behavior. | medium | validated | D69-L, D71-L | 2026-06-09 `dx-introspection-live` slice 2 replaced the earlier fixed structured self-report/schema idea with `src/.pi/extensions/session-query/`: a dev-gated read-only tool registered only through `createBrunchPiExtensions(..., { introspection: { enabled } })`, covered by find/project/truncation unit tests, default-off/default-on registration tests, and a faux turn that returns verbatim projected session values. Live-model compliance with "call then echo verbatim" remains outer-loop fitness, not a merge gate. | -| A27-L | Gap satisfaction is expressible band-by-band at acceptable LLM cost: **commitment** typologies are structural `presence`/`field`/`coverage` predicates over the graph; **grounding** typologies are a `presence` floor plus `manual` LLM satisficiency (D57-L); **elicitation** typologies are generatively spawned. The explicit `capability → relevant gaps` map (D74-L) carries enough signal to drive proceed / negotiate without a standing grade. | medium | partially validated | D65-L, D74-L, D75-L | 2026-06-10 `elicitation-gaps-remodel` validated the structural `presence` case: a seeded grounding gap's derived coverage/answered state flips from graph truth with no stored structural answer and sibling-spec isolation holds. 2026-06-10 the `capability-readiness` D74-L gate tracer validated the grounding floor: the explicit capability→gap map drives proceed / proceed_low_epistemic / negotiate, live presence coverage flips a generative capability negotiate→proceed, and the gate imports no grade symbols. 2026-06-10 `gaps-node-kind-reference` collapsed that map onto `NodeKind` (`context`/`thesis`/`goal`/`constraint`), proved required-kind absence fails loud, and proved same-kind gaps discriminate by question+satisfier rather than typology name. Remaining proof: `field`/`coverage` predicate derivation, `manual` LLM satisficiency, elicitation/commitment fixtures, and rewiring consumers off grade thresholds. Falsified if grounding readiness cannot decompose into per-typology presence+manual judgments, or if commitment obligations need logic the predicate union can't express. | +| A27-L | Gap satisfaction is expressible band-by-band at acceptable LLM cost: **commitment** typologies are structural `presence`/`field`/`coverage` predicates over the graph; **grounding** typologies are a `presence` floor plus `manual` LLM satisficiency (D57-L); **elicitation** typologies are generatively spawned. The explicit `capability → relevant gaps` map (D74-L) carries enough signal to drive proceed / negotiate without a standing grade. | medium | partially validated | D65-L, D74-L, D75-L | 2026-06-10 `elicitation-gaps-remodel` validated the structural `presence` case: a seeded grounding gap's derived coverage/answered state flips from graph truth with no stored structural answer and sibling-spec isolation holds. 2026-06-10 the `capability-readiness` D74-L gate tracer validated the grounding floor: the explicit capability→gap map drives proceed / proceed_low_epistemic / negotiate, live presence coverage flips a generative capability negotiate→proceed, and the gate imports no grade symbols. 2026-06-10 `gaps-node-kind-reference` collapsed that map onto `NodeKind` (`context`/`thesis`/`goal`/`constraint`), proved required-kind absence fails loud, and proved same-kind gaps discriminate by question+satisfier rather than typology name. 2026-06-10 the `capability-readiness` affordance-legality slice validated the affordance-path consumer: the runtime affordance projection (`affordances` / `axisOptionsForRuntimeState`) derives goal/strategy/lens menu legality from `evaluateCapabilityReadiness` over gap coverage with no grade symbols, a coverage flip moves a gated option legal, and a required kind absent from the register fails loud (config bug ≠ uncovered) — retiring the affordance-path uncertainty. 2026-06-10 the method/manifest legality slice validated the turn-boundary consumer: `before_agent_start` reads selected-spec gaps through the graph read seam, prompt manifests and active tool names derive gated methods from gap coverage, floor methods/tools remain available at zero coverage, and the `state.ts` grade tables are gone. 2026-06-10 the agent-prompt display slice validated the display consumer: `compose.ts` and `contexts/cwd.ts` render the selected-spec soft per-band estimate from gaps with stable band order/fixed decimals, and `before_agent_start` threads the same selected-spec gaps into the pushed cwd context. Remaining proof: `field`/`coverage` predicate derivation, `manual` LLM satisficiency, elicitation/commitment fixtures, workspace/chrome display rewiring, and stored-grade deletion. Falsified if grounding readiness cannot decompose into per-typology presence+manual judgments, or if commitment obligations need logic the predicate union can't express. | ### Active Decisions @@ -131,11 +131,11 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D2-L — Brunch is an opinionated product, not a pi platform shell.** The POC hardcodes its toolset, system prompt, and policy doctrine; scopes state to `.brunch/`; and hides pi's generic extension surface from end users. Depends on: A1-L. Supersedes: —. - **D39-L — Brunch owns sealed Pi settings plus an explicit Brunch extension bundle around the embedded harness.** Product behavior must come from Brunch-owned programmatic policy, not ambient Pi discovery. `src/.pi/brunch-pi-settings.ts` owns settings policy, resource-loader policy, and offline defaults: it creates an in-memory Brunch-owned `SettingsManager` policy instead of reading ambient global/project `.pi/settings.json`, disables ambient context files, extensions, prompt templates, skills, and themes, and defaults Brunch-launched Pi to offline mode; Pi source confirms extension `resources_discover` can still inject explicit Brunch-owned skill/prompt/theme paths even when `noSkills`/`noPromptTemplates`/`noThemes` disable ambient discovery. `src/.pi/brunch-pi-extensions.ts` owns the explicit Brunch extension factory: it statically imports product extension registrars and registers them from a fixed ordered list rather than ambient discovery. That explicit list must not filesystem-discover or dynamically `import()` extension modules at runtime, because a Brunch-internal discovery layer is itself the discovery this decision rejects. Each product extension exposes one registrar taking explicit dependencies, and the extension bundle wires those dependencies at the call site; the `default` exports under `src/.pi/extensions/*` exist only for dev `/reload` iteration, not as a product load path. Product extension modules live under `src/.pi/extensions/*`, and reusable Pi TUI components live under `src/.pi/components/*`, so they can also be iterated by launching Pi from `src/` and using `/reload`; the root project-local `.pi/` probe runtime files are retired and must not be treated as product configuration. Test files must not live directly under auto-discovered `.pi/extensions` or `.pi/components` resource directories; extension/component tests live under `src/.pi/__tests__/`. The settings boundary owns the audited behavior-shaping settings list in code (`BRUNCH_SETTINGS_POLICY` / `BRUNCH_SETTINGS_AUDITED_GETTERS`), with hostile ambient settings and reload-resilience tests covering shell path/prefix, npm command, ambient resources, skill commands, double-escape behavior, compaction/retry, image/terminal/UI, transport/theme/changelog, and telemetry settings. Remaining sealed-Pi work is runtime-state/prompt/tool posture, not ambient settings file leakage. Depends on: D1-L, D2-L, A19-L. Supersedes: treating `noSkills: true` as full profile isolation, relying on user/project `.pi/` defaults to be harmless, nesting Brunch's product extension modules under `src/.pi/extensions/brunch/`, or replacing the explicit static extension list with a Brunch-internal filesystem-discovery / `brunchExtensionMeta` / `loadOrder` mechanism as the product runtime load path. - Tooling exception: the worktree helper extension now lives outside this repository under the user Pi agent tree (`~/.pi/agent/extensions/worktree/index.ts`) for direct Pi sessions only. It is not a Brunch product extension, is not imported by `src/.pi/brunch-pi-extensions.ts`, and does not weaken the sealed Brunch Pi settings/extensions boundary; Brunch-launched product sessions continue to disable ambient `.pi/` discovery unless deliberately imported. The extension may register direct-Pi `/worktree:switch` / `switch_worktree` and `/worktree:create` / `create_worktree` affordances, but Brunch does not test, package, or document it as a product extension. -- **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the transcript entry facts (`brunch.agent_runtime_state` schema, parser, and init/switch append helpers); `src/projections/session/runtime-state.ts` owns the pure reusable projection, `src/projections/session/runtime-policy.ts` owns operational-mode/role policy plus shared grade legality tables, and `src/projections/session/affordances.ts` owns the pure `(resolvedState, readinessGrade) → legal options + default-on-switch` derivation for goal/strategy/lens. The projection reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Runtime-state entries are Pi JSONL state-change facts, not assistant/user chat content: init and switch entries should render, when visible, as dim non-chat state rows analogous to Pi thinking/model-change rows, and must not enter LLM context as ordinary conversation. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). **Posture switches (durable `reason: "switch"` entries) are a user/system authority: the foreground agent never emits a posture switch.** The agent's only in-axis freedom is `AUTO` (per-turn implicit selection from the D58-L manifest); what it actually chose each turn is legible downstream via per-emission facet stamping (D25-L), not via runtime-state — so runtime-state is the *frame/constraints* while emitted facets carry the agent's per-turn choice. User-mutable axes are `op_mode`, `strategy`, and `lens`; `goal` is internal/grade-derived and not part of the user posture-change surface for now (D59-L). On a parent switch that invalidates a child axis, the child defaults to `AUTO`. The `source: "agent"` entry value is reserved — no current path emits it; it is parked for a future execute-mode orchestrator that might legitimately steer sub-postures. `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/runtime/index.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned entry definitions and projected policy. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. +- **D40-L — Runtime state is transcript-backed Brunch session-agent state, not hidden extension memory.** `src/session/runtime-state.ts` owns the transcript entry facts (`brunch.agent_runtime_state` schema, parser, and init/switch append helpers); `src/projections/session/runtime-state.ts` owns the pure reusable projection, `src/projections/session/runtime-policy.ts` owns operational-mode/role policy plus shared capability-readiness policy, and `src/projections/session/affordances.ts` owns the pure `(resolvedState, gaps) → legal options + default-on-switch` derivation for goal/strategy/lens. The projection reconstructs agent posture from linear `brunch.agent_runtime_state` entries (`reason: "init" | "switch"`), last-writer-wins at turn preparation and over `session.runtimeState`; default/empty slots are explicit when no entry family exists. Runtime-state entries are Pi JSONL state-change facts, not assistant/user chat content: init and switch entries should render, when visible, as dim non-chat state rows analogous to Pi thinking/model-change rows, and must not enter LLM context as ordinary conversation. Its axes are `op_mode` (`elicit`, future `execute`) plus optional, AUTO-able objective axes `strategy`, `lens`, and `goal` (D25-L, D59-L). **Posture switches (durable `reason: "switch"` entries) are a user/system authority: the foreground agent never emits a posture switch.** The agent's only in-axis freedom is `AUTO` (per-turn implicit selection from the D58-L manifest); what it actually chose each turn is legible downstream via per-emission facet stamping (D25-L), not via runtime-state — so runtime-state is the *frame/constraints* while emitted facets carry the agent's per-turn choice. User-mutable axes are `op_mode`, `strategy`, and `lens`; `goal` is internal/readiness-derived and not part of the user posture-change surface for now (D59-L). On a parent switch that invalidates a child axis, the child defaults to `AUTO`. The `source: "agent"` entry value is reserved — no current path emits it; it is parked for a future execute-mode orchestrator that might legitimately steer sub-postures. `session.runtimeState` also exposes shaped mention slots, world-update watermarks (latest graph LSN and optional git head, without raw transcript detail bags), and lifecycle facts when transcript-backed entries make them computable; this is a projection contract, not a mutable state table. The **foreground session agent** (`elicitor` now, future `executor`) is *derived* from `op_mode`, not stored; the other agent roles (`reviewer`, `reconciler`, future `scout`/`researcher`) are async sub-agent/side-chain workers (D29-L, D44-L) invoked out-of-band, never part of the session state machine. `op_mode` gates tool authority, applied by `src/.pi/extensions/runtime/index.ts` (current `elicit` policy denies side-effecting `bash`/`edit`/`write` plus user-shell interception) while `.pi` reuses session-owned entry definitions and projected policy. Prompt composition is a separate concern (D58-L). Depends on: D17-L, D23-L, D25-L, D39-L, D58-L, D59-L. Supersedes: mode-only vocabulary, extension-local mutable state as authority, storing the foreground role as independent session state, the "runtime bundle / role preset" as one knob deriving model/thinking/resources, and binding prompt-resource location to `src/.pi/context/`. - **D34-L — Command containment separates visibility suppression from effect blocking.** Current Pi extension seams can hide unsupported slash suggestions with autocomplete wrapping and can cancel branch/session effects through lifecycle hooks, but they cannot strictly suppress exact interactive built-in commands before `InteractiveMode` dispatches them. Brunch-owned commands must use product-specific names and route writes through Brunch handlers/`CommandExecutor`; extension command collisions are not an override mechanism. Strict built-in command/keybinding policy is a Pi upstream/API ask, while POC safety relies on hiding generic affordances, blocking dangerous effects (`/fork`, `/clone`, `/tree`, raw session replacement), and failing fast on branched transcripts. Brunch's command-policy code should live in `src/.pi/extensions/commands/policy.ts`, merging branch/session-effect blocking with any product command allow/deny behavior instead of preserving a branch-only module. Depends on: D2-L, D24-L, A18-L. Supersedes: treating extension `input` handlers or command-name collisions as built-in command allowlisting. - **D35-L — Dynamic TUI chrome is a Brunch projection wrapper over Pi UI primitives.** Downstream TUI affordances should call a Brunch-owned renderer (`renderBrunchChrome` or its successor) with one activated product-state value rather than scattering raw `ctx.ui.setHeader`, `setFooter`, `setWidget`, title, or working-indicator calls. The wrapper is stateless projection over canonical workspace/session/graph facts, including the discovered project name, selected spec, real activated session id/label, launch activation kind for new-session startup headers, and app-supplied live sidecar URL when present, while its TUI footer compositor may read Pi footer telemetry (`getGitBranch`, foreign `getExtensionStatuses`) at render time. Brunch chrome and startup dialog are project-first shell surfaces with selected-spec context: the project name labels the cwd container, the spec title labels the selected graph, and the session label distinguishes transcript instances. New `newSpec` / `newSession` launches keep Pi `quietStartup` but install a Brunch-owned expandable header through the chrome wrapper; resume/open launches stay quiet. Brunch chrome does not publish a `brunch.chrome` status key; `ctx.ui.setStatus(key, text)` remains a lateral contribution channel for other extensions and future dynamic Brunch state. RPC clients should rely only on surfaces Pi actually emits for the wrapper (currently sidecar/widget-compatible string arrays and title, plus any future explicit status adapter) because header/footer/working-indicator are TUI-only in current Pi RPC mode. Session display names are product projections over Pi session metadata: every Brunch-created session should immediately receive a neutral workspace-global `Untitled Session N` `session_info` label, and later user/generated names may characterize the transcript without replacing spec identity or graph truth. Depends on: D2-L, D21-L, D34-L, A18-L. Supersedes: treating Pi UI methods as direct downstream affordance APIs, rendering placeholder session state such as `unbound` after a session is activated, consuming the status-key namespace for chrome's own static summary, using spec title as the default session label, or allowing two unchanged Brunch-created default names to collide in one cwd. - **D52-L — Source topology targets `src/{app, workspace, scripts, .pi, db, graph, session, projections, renderers, rpc, web}` with directed layer dependencies.** Product entrypoints live under `src/app/`, local executable utility ownership is reserved under `src/scripts/`, package/workspace identity tests live under `src/workspace/`, and reusable projection/rendering modules live under top-level `src/projections/` and `src/renderers/` rather than whichever domain or adapter first needed them. `app/` owns product host entrypoints and wiring. `workspace/` owns cwd/package/workspace identity helpers. `scripts/` owns local executable utilities. `.pi/` is the sealed Pi-harness runtime surface: `agents/` owns runtime prompt assembly, role definitions, legal resource manifests, and agent-context orchestration; `skills/` owns goal/strategy/lens/method markdown resources read on demand; `components/` owns reusable Pi TUI/message components; `extensions/` owns Pi registrars for tools, hooks, commands, chrome, context tools, system-prompt append, exchanges, graph tools, workspace dialogs, runtime policy, and session lifecycle. `graph/` is the domain layer: CommandExecutor, readers, policy, validators, query bucketing, change-log replay, reconciliation-need substrate; it imports from `db/` (Drizzle schema, migrations, connection lifecycle) and no other layer imports `db/` directly. `session/` owns transcript projection, exchange extraction, workspace coordination, session binding, runtime-state transcript entries, and LSN staleness tracking over Pi JSONL. `projections/` owns structured DTOs derived from graph/session/workspace/tool facts; it must not render lossy text and must not import adapters, transports, app entrypoints, or web code. `renderers/` owns lossy text/markdown/toon/tool-content rendering over domain or projection inputs; it may import input types from `graph/`, `session/`, or `projections/` as needed, but must not import adapters, transports, app entrypoints, or web code. `rpc/` owns Brunch JSON-RPC handlers. `web/` owns the React client. Dependency direction: `.pi/`, `rpc/`, and `app/` may import from `graph/`, `session/`, `projections/`, and `renderers/`; `.pi/agents/` may import from `graph/`, `session/`, `projections/`, and `renderers/` to build agent context; `.pi/extensions/` may import from `.pi/agents/` and `.pi/components/`; `projections/` may import from `graph/`, `session/`, and `workspace/`; `renderers/` may import from `projections/`, `graph/`, and `session/`; `graph/` imports from `db/`, and `db/` may import the drizzle-free taxonomy leaf `graph/schema/kinds.ts` — the single sanctioned `db/`→`graph/` edge (D73-L); `web/` is a standalone build target. Depends on: D2-L, D4-L, D39-L, D40-L. Refined by: D73-L. Supersedes: scattering session domain files at `src/` root; treating Pi-only agents as a host-independent top-level `src/.pi/` layer; nesting prompt composition under `src/.pi/context/`; treating reusable `project` / `format` helpers as owned by whichever adapter first needed them. -- **D73-L — Domain enum taxonomy is owned by a drizzle-free `src/graph/schema/kinds.ts` leaf; `db/` is a consumer, not the source.** The closed enum `const` arrays that define graph vocabulary — node kinds (`INTENT_KINDS`, `ORACLE_KINDS`, `DESIGN_KINDS`, `PLAN_KINDS`), `NODE_PLANES` (`intent`/`oracle`/`design`/`plan`), `NODE_BASES`, `EDGE_CATEGORIES`, `EDGE_STANCES`, `READINESS_GRADES`, `READINESS_BANDS`, `LENS_AFFINITIES`, `ELICITATION_BACKLOG_STATUSES` — live in `graph/schema/kinds.ts`, a pure constants leaf that imports nothing (no drizzle, no `graph/atoms`). Both `db/schema.ts` (for `text({ enum })` column constraints, including the previously-inlined `plane` columns) and `graph/` domain modules import the arrays from this leaf; `graph/index.ts` re-exports them from the leaf so non-graph layers still avoid importing `db/` directly (I26-L). Derivations stay where they are read: `NODE_KIND_METADATA`, `formatGraphNodeCode`, `parseGraphNodeCode`, and `intentKindCategory` remain in `graph/schema/nodes.ts` (D62-L). The motivating defect: because `db/schema.ts` eagerly evaluates `sqliteTable(...)` and `verbatimModuleSyntax` emits even type-only imports at runtime, any value-import path from `web/` into the old taxonomy location pulled Drizzle into the browser bundle. Locating taxonomy in a drizzle-free leaf makes the `web/` build target structurally Drizzle-free (I44-L) and corrects the ownership direction so the domain, not the persistence layer, owns its vocabulary. Vocabulary migration (pending the D45-L/D65-L sweep): `READINESS_GRADES` is retired (readiness is no longer a stored grade, D45-L), and `ELICITATION_BACKLOG_STATUSES` is replaced by the `elicitation_gaps` disposition + predicate-shape enums (D65-L); `READINESS_BANDS` stays. Depends on: D16-L, D52-L, D54-L, D62-L, D63-L, D64-L; I26-L. Supersedes: `db/schema.ts` owning the shared enum `const` arrays and the "enum literals flow outward from `db/schema.ts`" posture; the triplicated inline `['intent','oracle','design','plan']` plane literals. +- **D73-L — Domain enum taxonomy is owned by a drizzle-free `src/graph/schema/kinds.ts` leaf; `db/` is a consumer, not the source.** The closed enum `const` arrays that define graph vocabulary — node kinds (`INTENT_KINDS`, `ORACLE_KINDS`, `DESIGN_KINDS`, `PLAN_KINDS`), `NODE_PLANES` (`intent`/`oracle`/`design`/`plan`), `NODE_BASES`, `EDGE_CATEGORIES`, `EDGE_STANCES`, `READINESS_BANDS`, `LENS_AFFINITIES`, `GAP_DISPOSITIONS`, and `GAP_PREDICATE_KINDS` — live in `graph/schema/kinds.ts`, a pure constants leaf that imports nothing (no drizzle, no `graph/atoms`). Both `db/schema.ts` (for `text({ enum })` column constraints, including the previously-inlined `plane` columns) and `graph/` domain modules import the arrays from this leaf; `graph/index.ts` re-exports them from the leaf so non-graph layers still avoid importing `db/` directly (I26-L). Derivations stay where they are read: `NODE_KIND_METADATA`, `formatGraphNodeCode`, `parseGraphNodeCode`, and `intentKindCategory` remain in `graph/schema/nodes.ts` (D62-L). The motivating defect: because `db/schema.ts` eagerly evaluates `sqliteTable(...)` and `verbatimModuleSyntax` emits even type-only imports at runtime, any value-import path from `web/` into the old taxonomy location pulled Drizzle into the browser bundle. Locating taxonomy in a drizzle-free leaf makes the `web/` build target structurally Drizzle-free (I44-L) and corrects the ownership direction so the domain, not the persistence layer, owns its vocabulary. Vocabulary migration status: `READINESS_GRADES` is retired (readiness is no longer a stored grade, D45-L), `ELICITATION_BACKLOG_STATUSES` is replaced by the `elicitation_gaps` disposition + predicate-shape enums (D65-L), and `READINESS_BANDS` stays. Depends on: D16-L, D52-L, D54-L, D62-L, D63-L, D64-L; I26-L. Supersedes: `db/schema.ts` owning the shared enum `const` arrays and the "enum literals flow outward from `db/schema.ts`" posture; the triplicated inline `['intent','oracle','design','plan']` plane literals. #### Data model & vocabulary @@ -153,7 +153,7 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c - **D63-L — Graph `basis` records item-level approval strength, not the mutation pathway.** Accepted nodes and edges use `basis ∈ explicit | implicit`. `explicit` means the user directly stated the graph item or approved the exact node/edge in a review set; `implicit` means the user accepted a concept/proposal and the agent materialized specific graph items to match it without per-item review (the `propose-graph` direct-commit path). The mutation pathway lives in `change_log.operation` and payload (`mutate_graph`, `accept_review_set`, post-exchange capture, etc.), while epistemic attribution lives in `Node.source` and proposal UI metadata may still carry `epistemic_status`. Low-confidence inferred material is still not graph truth; it remains in preface/capture analysis/review drafts/reconciliation needs until clarified or accepted. More abstractly, `basis` is a *provenance-directness* marker — directly from the user (`explicit`) versus agent-materialized from user input (`implicit`) — of which item-level approval strength is the claim-flavored reading; this lets the same `explicit | implicit` distinction apply to non-claim registers such as `elicitation_gaps` (user-raised vs agent-inferred, D65-L). Depends on: D26-L, D27-L, D53-L, D54-L, D55-L. Supersedes: `basis = accepted_review_set` as a persisted graph enum value and any interpretation of `basis` as a provenance/path field. - **D64-L — Readiness bands are the coarse level of one coverage axis; gap typologies (D65-L) are its finer members. Bands are non-exclusive derived node-kind groupings, not structural legality gates.** Bands are `grounding`, `elicitation`, and `commitment`; each `elicitation_gaps` typology carries exactly one band — band and typology are **one axis at two granularities**, so "bands becoming more differentiated over time" means the typology taxonomy growing, not new bands. A node kind may belong to multiple bands (e.g. `constraint` contributes to grounding as the constraint anchor and to elicitation when it bounds solution space). Bands guide what the elicitor is trying to complete, what graph filters and rendered context show, the per-band **readiness estimate** rollup (D45-L), and which gaps a capability-readiness judgment weighs (D74-L). The band's gate-character differs by band: **grounding** is mostly LLM-judged satisficiency with a count floor (D57-L), **elicitation** is generatively spawned (no fixed typology set), **commitment** is more structurally derivable. The `CommandExecutor` must not reject a clear later-band kind merely because of band; readiness controls objectives and capability-judgment, not what graph truth may contain. Depends on: D45-L, D56-L, D57-L, D59-L, D60-L, D65-L. Supersedes: treating the intent `basic | structural | reasoning` category as the readiness taxonomy, treating readiness as a per-kind creation whitelist, or treating bands as a grade rubric for a stored grade. - **D65-L — `elicitation_gaps` are typed coverage *obligations* (typologies) — the elicitor's prospective-memory agenda and the substrate of capability-readiness judgment; they guide and modulate, they never hard-gate.** Renamed and reconceived from `elicitation_backlog`. A gap is a **typology of coverage that must be addressed** (e.g. "the spec must anchor its primary constraint(s)"), **not** a literal queued question and **not** a specific point of unclarity — that would shadow the intent graph, which already owns the content (decisions, assumptions, constraints, …). The original `unknown`/process-vs-domain split still holds: `elicitation_` scopes the term to *process* gaps (knowable by asking), as opposed to the deferred domain-gap `risk` node (Future Direction §Vocabulary evolution). Each gap carries **both** a stable **name** (its typology key — machine identity used for seeding, dedup, and the `capability → relevant gaps` map (D74-L), and a short display label) **and** a **rationale** (the *meta* prose: what coverage this obligation represents, why it matters, and what counts as satisfying it — read by the elicitor to phrase the next question and to make a `manual` satisficiency judgment, D57-L). The two are not redundant: the name is for machine identity/reference, the rationale is for agent reasoning and cannot be compressed into a terse key. In addition each gap carries: a **band** (D64-L — its coarse level, one band per typology); a **predicate shape** — a tagged union of `presence` (≥N nodes of a kind/band present), `field` (a `detail` key present), `coverage` (D60-L `lacksEdge` per-member absence), or `manual` (LLM-judged, the D57-L satisficiency residue) — which routes structural-vs-JIT checking (D74-L); an **importance** (driver-weight / count-floor membership / priority — *not* a hard gate); and a derived **coverage** strength (how well addressed). Importance and coverage are deliberately **two fields, not one ambiguous `rating`**: importance is the pre-answer weight, coverage the post-answer derived strength. **Disposition** (`open | answered | not_applicable | irrelevant | reopened`) is stored *only where it is non-derivable* — scope judgments (`not_applicable` / `irrelevant`, which the agent may set in bulk) and `manual` satisficiency — while `answered` for a structural predicate is derived **live** from the graph and never hand-set; this is the anti-shadowing line: the table holds obligation/disposition/meta only, never domain content. `reopened` is a legitimate disposition (new ambiguity can reopen a typology). Gaps serve three roles: **agenda** (what to ask / propose next), **judgment drivers** for capability-readiness (D74-L), and a **density signal** that scales generative-output epistemic status (D30-L) — the candidate-proposal / disambiguation UX is precisely how open grounding gaps fill progressively, so an open gap must never wall that UX. Seeding is band-correlated. The **grounding** band has a seeded fixed catalog of typologies collated from the D30-L anchor bundle, the D57-L Walter drivers, [`docs/design/ELICITATION_LENSES.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_LENSES.md) §grounding bundle, and the shaping kickoff/framing material — a **floor** of `domain` (what kind of thing is being built), `protagonist` (who it is for / most affected), `pain_pull` (what problem/pain/pull drives it), and `constraint` (what binding non-negotiables already shape it) — the anchor bundle that gates generative capabilities (D30-L) — plus softer **progressive drivers** that enrich and focus elicitation but are *never* floor (the no-moving-the-goalpost line): `value` (what value/benefit), `context_of_use` (when/where used), `success_sketch` (how success is measured / what good looks like), and `solution_boundary` (non-goals / what it is explicitly not). **elicitation** gaps are generatively spawned by capture-reflection as preceding answers raise new coverage obligations (no fixed catalog). **commitment** gaps are derived structural predicates over the graph (e.g. "every requirement has a criterion", "every decision records its rejected options", "every invariant has a proof or check"). It remains a **flat table, not a graph plane/node** — its only relations are filter attributes plus FK pointers (`arose_from`, `resolved_by`), a degenerate bipartite graph promotable later only if genuine gap→gap structure emerges; it is the *prospective* sibling of the *retrospective* `reconciliation_need` register (D8-L). `basis` applies via provenance-directness (D63-L): user-raised `explicit`, agent-inferred `implicit`. The flat-table substrate, `createSpec` seeding, `CommandExecutor`-routed mutations, and shared spec-local LSN + `change_log` boundary are settled from FE-823 (built as `elicitation_backlog`); the obligation/predicate/disposition remodel and the rename are what this decision now locks. Still open: whether the register eventually thins the `goal` axis (D59-L), and live per-turn ranking. Depends on: D8-L, D30-L, D45-L, D57-L, D59-L, D60-L, D63-L, D64-L, D74-L. Refined by: D75-L (gaps reference graph node kinds via `refersTo: NodeKind`; the parallel grounding-typology catalog and the closed gap-`name` enum are retired — substrate, predicate union, disposition, and anti-shadowing line are unchanged). Supersedes: the `elicitation_backlog` name and its question-instance / `open | closed`-status model, treating `unknown` as a graph node kind, and any readiness-grade-projection-over-open-counts as authority. -- **D74-L — Capability-readiness is a just-in-time, capability-relative judgment over relevant gaps — it replaces the standing grade gate.** When a capability is requested (a generative lens, `propose-graph`, `project-graph`, commitment review, eventual export), the agent evaluates readiness *for that capability* against the `elicitation_gaps` (D65-L) declared relevant to it. The `capability → relevant gaps` map is **explicit** and subsumes the retired `STRATEGY_MIN_GRADE` / `GOAL_MIN_GRADE` / `LENS_MIN_GRADE` thresholds in `runtime-policy.ts`, which were a lossy grade-proxy for "enough grounding". Structurally-obvious relevant gaps (`presence` / `field` / `coverage`) are checked **mechanically** (cheap, no LLM); non-obvious (`manual`) ones consume an **LLM satisficiency judgment** (D57-L). The outcome is one of **proceed**, **proceed at low epistemic status** (density-scaled, D30-L), or **negotiate** — surface an `establishment_offer` ("I can, but answer X and Y first", D32-L). Capability-readiness fires **on request, reactive-primary** (proactive nudges are a separate later concern) and is the **only readiness gate**: it never bars attempting work, it scales/negotiates. This resolves the prior "lens is never gated" (`ELICITATION_LENSES.md`) vs `LENS_MIN_GRADE` contradiction (lenses are not grade-gated; readiness is JIT-judged) and dissolves the grade-ratchet / two-value problem (the soft `readiness estimate`, D45-L, gates nothing and may regress honestly). A future structural milestone gate for export/plan/execute op-modes is deferred (D45-L) until such an op-mode exists. Depends on: D25-L, D26-L, D30-L, D32-L, D45-L, D57-L, D59-L, D65-L. Refined by: D75-L (the `capability → relevant gaps` map references node kinds, not a closed typology-name enum). Supersedes: `GRADE_RANK`-based `MIN_GRADE` hard gating of goal/strategy/lens, and a standing readiness scalar as the authority for capability availability. +- **D74-L — Capability-readiness is a just-in-time, capability-relative judgment over relevant gaps — it replaces the standing grade gate.** When a capability is requested (a generative lens, `propose-graph`, `project-graph`, commitment review, eventual export), the agent evaluates readiness *for that capability* against the `elicitation_gaps` (D65-L) declared relevant to it. The `capability → relevant gaps` map is **explicit** and subsumes the retired `STRATEGY_MIN_GRADE` / `GOAL_MIN_GRADE` / `LENS_MIN_GRADE` thresholds in `runtime-policy.ts` plus the retired prompt-manifest/tool `METHOD_MIN_GRADE` thresholds in `.pi/agents/state.ts`, which were lossy grade-proxies for "enough grounding". Structurally-obvious relevant gaps (`presence` / `field` / `coverage`) are checked **mechanically** (cheap, no LLM); non-obvious (`manual`) ones consume an **LLM satisficiency judgment** (D57-L). The outcome is one of **proceed**, **proceed at low epistemic status** (density-scaled, D30-L), or **negotiate** — surface an `establishment_offer` ("I can, but answer X and Y first", D32-L). Capability-readiness fires **on request, reactive-primary** (proactive nudges are a separate later concern) and is the **only readiness gate**: it never bars attempting work, it scales/negotiates. This resolves the prior "lens is never gated" (`ELICITATION_LENSES.md`) vs `LENS_MIN_GRADE` contradiction (lenses are not grade-gated; readiness is JIT-judged) and dissolves the grade-ratchet / two-value problem (the soft `readiness estimate`, D45-L, gates nothing and may regress honestly). A future structural milestone gate for export/plan/execute op-modes is deferred (D45-L) until such an op-mode exists. Depends on: D25-L, D26-L, D30-L, D32-L, D45-L, D57-L, D59-L, D65-L. Refined by: D75-L (the `capability → relevant gaps` map references node kinds, not a closed typology-name enum). Supersedes: `GRADE_RANK`-based `MIN_GRADE` hard gating of goal/strategy/lens/method prompt resources and method-coupled tools, and a standing readiness scalar as the authority for capability availability. - **D75-L — `elicitation_gaps` reference graph node kinds; the parallel grounding-typology vocabulary is retired.** A gap is a **situated question that refers to a graph node kind** (`refersTo: NodeKind`), not an entry in a separate closed "typology" vocabulary. The grounding typology catalog of D65-L (`GROUNDING_GAP_TYPOLOGIES`: floor `domain` / `protagonist` / `pain_pull` / `constraint` + progressive `value` / `context_of_use` / `success_sketch` / `solution_boundary`) was a denormalized, drift-prone copy of the per-kind **source-question rubric** the intent ontology already owns (D56-L; [`docs/design/GRAPH_MODEL.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/GRAPH_MODEL.md#per-plane-node-kinds) §Per-plane node kinds — *"the abstract driver, not a literal question to parrot"*): `domain` / `context_of_use` are facets of `context`; `protagonist` / `pain_pull` of `thesis`; `value` of `goal`; `constraint` / `solution_boundary` of `constraint`; `success_sketch` of `criterion`. Collapsing onto the kind layer yields **one ontology, not two** — the only closed set is `NodeKind` (D54-L/D56-L), already owned by the drizzle-free taxonomy leaf (D73-L). Consequences: (1) the closed gap-`name` typology enum and the `RelevantGapName` union (D74-L) are replaced by `refersTo: NodeKind`; the `capability → relevant gaps` map references node kinds — the grounding floor is grounded `context` + `thesis` + `goal` + `constraint`, a graph query rather than a typology lookup, matching how GRAPH_MODEL already frames the grounding gate ("basic intent nodes are central evidence"). (2) Question text stays **free-form and situated**, projected general→specific by the elicitor per active lens/strategy and grounding density; the presence-aliasing limitation (distinct typologies aliasing one node-kind signal, the deferred finding in the now-retired refactor plan) **dissolves**, because discrimination now lives in the free-form question plus the `manual` / `coverage` satisfier (D57-L), not in a blunt `presence` count or a closed name enum. (3) Coverage extends for free to grounding-band kinds the catalog ignored — `term` (the ubiquitous-language anchor) and `assumption`. The flat-table substrate, `disposition`, `predicate` union, `importance` vs derived `coverage`, the anti-shadowing line (the table holds obligation / disposition / meta only, never domain content), `basis` provenance-directness, and band correlation (D64-L) are all **unchanged** — this decision changes how a gap *names its obligation* (by referring to a kind), not the register substrate. The example phrasings per kind are catalogued in [`docs/design/ELICITATION_QUESTIONS.md`](file:///Users/lunelson/Code/hashintel/brunch-next/docs/design/ELICITATION_QUESTIONS.md) as a **priming / example layer for the elicitor, not a schema**: brainstorming more questions adds facets/phrasings for existing kinds and never adds ontology. The code remodel landed 2026-06-10: `ElicitationGap` and the table now carry `refersTo: NodeKind` + free-form `question`, `createSpec` seeds grounding gaps by node kind (`context`, `thesis`, `goal`, `constraint`, plus `term`/`assumption`), and capability-readiness points at a `capability → NodeKind[]` map with loud failure for a missing required kind. Depends on: D54-L, D56-L, D57-L, D64-L, D65-L, D73-L, D74-L; A24-L, A27-L. Refines: D30-L, D65-L, D74-L. Supersedes: the grounding typology catalog as a parallel closed gap vocabulary; the closed gap-`name` typology enum and the `RelevantGapName` union; and the retired refactor plan to enshrine `GROUNDING_GAP_TYPOLOGIES` as a canonical const. #### Authority & mutation @@ -296,13 +296,13 @@ The POC's purpose is to prove three things: (a) that pi's coding-agent harness c | I22-L | Brunch TUI startup must not render prior session transcript entries or enter an agent loop until the user has explicitly activated a spec/session decision; creating a new spec implicitly creates its first session, creating a new session for an existing spec lands in a binding-only session, resuming a prior transcript is opt-in, and RPC/headless startup exposes structured initial-selection state rather than invoking TUI picker code. | covered (FE-744 coordinator tests; hierarchical spec/session picker model + component tests; `workspace.selectionState` / `workspace.activate` JSON-RPC contract tests with source assertion that RPC does not import TUI picker code; `src/probes/scripts/verify-startup-no-resume.sh` pty/ANSI-stripped TUI probe oracle proving stale transcript text is absent before explicit activation) | D11-L, D21-L, D22-L, D36-L | | I23-L | Every structured elicitation interaction that owns the response surface persists durable semantic display only through Pi `toolResult` rows rendered by `renderResult`; `renderCall` and live `ctx.ui.*` surfaces are transient. A structured-exchange tuple has a recoverable `present_*` result and, when required, exactly one matching terminal `request_*` result before the next agent turn consumes it. The target details model is checked by `schema` + `v`, `exchange_id`, and `tool_meta`; request outcomes are an exactly-one property-presence union; user-authored text is `comment` and runtime-authored text is `message`; present-side status/kind/expected-request aliases and capture graph payloads are invalid in the Zod-authored schema layer. `toolResult.content` is rich markdown suitable for both TUI transcript display and model context; `toolResult.details` carries structured projection/recovery data. | covered for current structured-exchange tools (registered sequential `present_question`, `present_options`, `present_review_set`, `request_answer`, `request_choice`, `request_choices`, and `request_review`; runtime details are emitted from canonical `schema`/`v`/snake_case Zod shapes; tests cover non-semantic `renderCall`, markdown `renderResult`, present/request details, unmatched-present recovery, active-vs-stub registry, JSON-editor fallback for multi-choice, terminal `answered`/`cancelled`/`unavailable` projection closure, option content/rationale parity, review-set `nodes`/`edges` details parity, invalid review proposal non-recovery, review pending-exchange recovery, public-RPC deterministic permutations, capture response-to-graph proof, and same-assistant-message `present_options → request_choice` ordering over a real Pi RPC run. The Zod-authored schema layer is covered by JSON Schema export, drift-rejection, and source-boundary tests for present/request/capture details. `present_candidates` remains a named stub and intentionally unregistered.) | D12-L, D13-L, D17-L, D37-L, D38-L, D41-L | | I24-L | A Brunch-launched Pi runtime does not load ambient user/project Pi context files, extensions, skills, prompt templates, themes, or behavior-shaping settings unless Brunch's sealed Pi settings/extension boundary explicitly allows them; Brunch-owned extension-discovered resources are identified as intentional product resources. | covered for TUI-launch settings/extension boundary by contract tests: ambient resource flags and explicit extension factories are preserved; hostile ambient global/project settings are ignored by the in-memory Brunch settings policy before and after reload; audited Pi settings getters are tracked in `src/.pi/brunch-pi-settings.ts`. Subagent subprocess inheritance remains future coverage under I29-L. | D2-L, D39-L | -| I25-L | The active `op_mode`, `strategy`, `lens`, and `goal` are reconstructable from linear `brunch.agent_runtime_state` entries at turn start and through `session.runtimeState`; concrete axis ids stay separate from the `auto` selection sentinel; the foreground session-agent role is derived from `op_mode`, not separately stored; tool gating follows the reconstructed `op_mode` so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted. Runtime-state projection remains transcript-backed and exposes empty/default mention, world-watermark, and lifecycle slots without inventing hidden extension memory; legal option/default affordances are pure projections over resolved runtime state plus capability-readiness over gaps (D74-L), not persisted state. | covered (`src/session/runtime-state.test.ts` covers default state, cumulative last-writer-wins posture, mention/world/lifecycle slot projection, and non-linear rejection; `src/rpc/handlers.test.ts` covers explicit-target `session.runtimeState` discovery/params/spec validation; `src/.pi/__tests__/operational-mode.test.ts` covers append/project/switch helpers over the reconciled axis vocabulary, AUTO selection for every objective axis, init idempotence, previous-state values, malformed/illegal tuple rejection, role derivation from `op_mode`, and Pi JSONL reload projection; `prompting.test.ts` covers prompt/tool-policy projection from the same transcript-backed runtime state, including selected-spec grade activation for commitment-grade `present_review_set` / `request_review` proposal tools; `src/.pi/extensions/runtime/authority-matrix.test.ts` covers the current POC authority matrix for `elicit-read-only`, blocking `bash`/`edit`/`write`, and structured `needs_human` result representability while leaving A18-L strict built-in suppression as residue; `src/projections/session/affordances.test.ts` covers shared goal/strategy/lens legal options, defaults, AUTO freestyle exclusion, pinned freestyle, and grade-sensitive legality; `src/session/runtime-affordances-coverage.test.ts` guards the required-vs-deferred affordance ledger). | D17-L, D23-L, D40-L, D58-L, D59-L, D66-L | +| I25-L | The active `op_mode`, `strategy`, `lens`, and `goal` are reconstructable from linear `brunch.agent_runtime_state` entries at turn start and through `session.runtimeState`; concrete axis ids stay separate from the `auto` selection sentinel; the foreground session-agent role is derived from `op_mode`, not separately stored; tool gating follows the reconstructed `op_mode` so `elicit` cannot use execute/dangerous tools such as raw `bash`/`write` unless explicitly permitted. Runtime-state projection remains transcript-backed and exposes empty/default mention, world-watermark, and lifecycle slots without inventing hidden extension memory; legal option/default affordances are pure projections over resolved runtime state plus capability-readiness over gaps (D74-L), not persisted state. | covered (`src/session/runtime-state.test.ts` covers default state, cumulative last-writer-wins posture, mention/world/lifecycle slot projection, and non-linear rejection; `src/rpc/handlers.test.ts` covers explicit-target `session.runtimeState` discovery/params/spec validation; `src/.pi/__tests__/operational-mode.test.ts` covers append/project/switch helpers over the reconciled axis vocabulary, AUTO selection for every objective axis, init idempotence, previous-state values, malformed/illegal tuple rejection, role derivation from `op_mode`, and Pi JSONL reload projection; `prompting.test.ts` covers prompt/tool-policy projection from the same transcript-backed runtime state, including selected-spec gap activation for `present_review_set` / `request_review` proposal tools; `src/.pi/extensions/runtime/authority-matrix.test.ts` covers the current POC authority matrix for `elicit-read-only`, blocking `bash`/`edit`/`write`, and structured `needs_human` result representability while leaving A18-L strict built-in suppression as residue; `src/projections/session/affordances.test.ts` covers shared goal/strategy/lens legal options, defaults, AUTO freestyle exclusion, pinned freestyle, gap-driven gated legality, and a live coverage flip; `src/session/runtime-affordances-coverage.test.ts` guards the required-vs-deferred affordance ledger). | D17-L, D23-L, D40-L, D58-L, D59-L, D66-L | | I27-L | Session display names are presentation metadata only: every Brunch-created session gets a neutral workspace-global default `session_info` label (`Untitled Session N`) at creation, unchanged defaults do not collide across specs in one cwd, later user/generated names may replace the default, and no naming path mutates spec identity, session binding, or graph truth. | planned (creation/boundary tests for workspace-global default allocation across specs and replacement sessions; session-lifecycle naming tests with empty transcript/auth failure/success paths; picker/chrome projection tests read session names when present) | D6-L, D21-L, D35-L, D42-L | | I26-L | Runtime schema-library imports stay deliberately scoped: Zod may appear in D41-L-acknowledged product/protocol schema seams — the structured-exchange schemas (`src/.pi/extensions/exchanges/schemas/`) and the dev-gated query-tool params (`src/.pi/extensions/{session-query,introspect-query}/`), each converting to Pi `TSchema` only through a single per-plane `z.toJSONSchema(..., { unrepresentable: 'throw' })` cast adapter (`exchanges/pi-schema.ts`, `shared/pi-tool-schema.ts`); TypeBox remains valid for unrelated Pi tool parameters (e.g. graph tools), small config/frontmatter contracts, and Drizzle-derived row schemas; no boundary may hand-author parallel Zod and TypeBox sources for the same shape. Pi tool parameter schemas authored in Zod must export JSON Schema draft 2020-12 (Zod v4 default), so tuples emit `prefixItems` rather than the draft-07 array-`items`/`additionalItems` form that strict provider validators (Anthropic) reject. Drizzle row/insert/update schemas are not hand-authored alongside their target tables. | covered (structured-exchange schema tests prove Zod parse/export and assert semantic details contracts stay in `src/.pi/extensions/exchanges/schemas/`; the legacy `shared/model.ts` details interface is retired; structured-exchange TypeBox usage is quarantined to the single Pi `TSchema` cast adapter in `src/.pi/extensions/exchanges/pi-schema.ts`, and the dev query tools to `src/.pi/extensions/shared/pi-tool-schema.ts`; `session-query`/`introspect-query` tests assert the advertised parameter schema is draft 2020-12 with no draft-07 tuple form; grep-based architectural boundary test in `architecture.test.ts` enforces no direct `db/` imports outside `graph/`; Drizzle derivation via `drizzle-typebox` in `row-schemas.ts`) | D41-L | | I28-L | Auto-compaction output preserves the configured anchor set byte-stable: every entry kind listed in [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts) is reconstructable post-compaction according to its `select` rule (`first | latest | active-leaves | all-unresolved`); LLM-generated narrative summary never replaces or rephrases preserved-anchor content; extension failure falls through to Pi default compaction rather than dropping anchors silently. | planned (compaction round-trip property tests at M9 plus inner-loop anchor-rendering unit tests and TypeBox schema validation of the anchor contract) | D43-L; R15, R13; I3-L, I4-L, I8-L, I12-L | | I29-L | Subagent subprocesses inherit Brunch Pi Profile sealing: every `subagent` tool invocation spawns `pi --mode json -p --no-session --no-skills --no-extensions` with an explicit per-agent tool allowlist and per-agent model; subagents never load ambient user/project `.pi/` skills, prompts, themes, extensions, context files, or behavior-shaping settings; subagents never gain direct access to the parent's `CommandExecutor`, Brunch RPC handlers, or graph persistence; subagent results return to the main agent only as tool result content (no side-effect transcript writes). | planned (subagent subprocess argv tests; isolation audit asserting absent ambient-resource leakage; tool-allowlist conformance test per starter agent) | D2-L, D39-L, D44-L; I2-L, I11-L, I24-L | | I30-L | Elicitor post-exchange capture only commits high-confidence extractive facts, concrete reconciliation needs, and justified `elicitation_gaps` disposition updates (D65-L); low-confidence implications remain in structured-exchange preface/question material and do not become graph truth until clarified, accepted, or explicitly escalated. | partially covered (`src/graph/capture/structured-response.test.ts` accepts only directly labeled text facts for the current tracer, rejects implication-only prose as `no_capture`, preserves structural diagnostics, `src/probes/capture-response-to-graph-proof.test.ts` proves public RPC response capture into selected-spec graph truth, and `src/probes/submit-message-capture-proof.test.ts` proves the same explicit-text capture path for ordinary `session.submitMessage` turns; reconciliation-needs and gap-disposition capture remain planned) | D18-L, D47-L, D65-L; A22-L | -| I31-L | Readiness never bars graph truth or work; it is just-in-time capability-readiness over relevant gaps, not a stored grade or kind whitelist. There is no `readiness_grade` scalar; capability availability is judged on request against the relevant `elicitation_gaps` (D74-L) and may proceed, proceed at low epistemic status, or negotiate — it never refuses outright. The `CommandExecutor` must not reject a graph node solely because its kind belongs to a later readiness band (D64-L). The soft `readiness estimate` (D45-L) is UI-only and gates nothing. | partially covered (`src/projections/session/capability-readiness.test.ts` covers the D74-L tracer gate, including proceed / proceed_low_epistemic / negotiate, no-refusal, no grade-symbol import, and a live `presence` coverage flip; current `createSpec` / `getSpec` / `updateReadinessGrade`, prompt composition, and affordance tests still predate the full D45-L/D74-L remodel and will be replaced by follow-on consumer rewire / grade deletion / readiness-estimate coverage) | D20-L, D45-L, D64-L, D74-L | +| I31-L | Readiness never bars graph truth or work; it is just-in-time capability-readiness over relevant gaps, not a stored grade or kind whitelist. There is no `readiness_grade` scalar; capability availability is judged on request against the relevant `elicitation_gaps` (D74-L) and may proceed, proceed at low epistemic status, or negotiate — it never refuses outright. The `CommandExecutor` must not reject a graph node solely because its kind belongs to a later readiness band (D64-L). The soft `readiness estimate` (D45-L) is UI-only and gates nothing. | partially covered (`src/projections/session/capability-readiness.test.ts` covers the D74-L tracer gate, including proceed / proceed_low_epistemic / negotiate, no-refusal, no grade-symbol import, and a live `presence` coverage flip; `src/projections/session/affordances.test.ts` covers the first consumer rewire: menu legality omits gated options while relevant gaps negotiate and includes them when coverage rises, with no grade symbols in `runtime-policy.ts` / `affordances.ts`, and a required `NodeKind` absent from the gap register fails loud (config bug ≠ uncovered — readiness omission never masks a seeding error); `src/projections/session/readiness-estimate.test.ts` covers the soft D45-L estimate shape, empty-band zero, importance-weighted per-band coverage, honest regression, no grade imports, and no legality-path imports; `src/.pi/agents/compose.test.ts`, `src/.pi/agents/contexts/cwd.test.ts`, and `src/.pi/__tests__/prompting.test.ts` cover the agent-prompt display swap: selected-spec gaps render as the soft per-band estimate with deterministic formatting, `readiness_grade=` is absent from prompt display, and the turn boundary threads the same gaps into cwd context; `src/session/workspace-session-coordinator.test.ts`, `src/renderers/workspace/workspace-state.test.ts`, `src/session/workspace-context.test.ts`, `src/.pi/__tests__/context-tools.test.ts`, `src/rpc/handlers.test.ts`, and `src/web/app.test.tsx` cover the workspace/chrome display retirement: `chrome.phase` / `chrome.chatMode` no longer project through coordinator/RPC/web/chrome fixtures, and workspace overview session inventory no longer carries or renders `readinessGrade`; `createSpec` / `getSpec` persistence, seed/export fixture contracts, probes, and selected-spec prompt carriers no longer persist or transport a readiness grade) | D20-L, D45-L, D64-L, D74-L | | I32-L | Public RPC structured-exchange driving never requires a client to speak raw Pi RPC: after Brunch method discovery and workspace/spec/session activation, each pending assistant-originated exchange is answered exactly once through `session.submitExchangeResponse`, and the deterministic permutation run produces linear Pi JSONL whose structured exchange projection preserves the same prompt/answer/status/comment artifacts as the equivalent TUI structured-exchange path. | covered for deterministic FE-744 parity under canonical session method names (`session.triggerExchange`, `session.pendingExchange`, `session.submitExchangeResponse`, `session.exchanges`): `rpc.discover` contract tests, pending/respond lifecycle tests, current public-RPC structured-exchange permutations, terminal non-answered status handling, option content/rationale parity, no repeated deterministic prompts, and transcript/exchange parity assertions. | D5-L, D48-L, D49-L; I10-L, I13-L, I21-L, I23-L | | I33-L | `capture_*` analysis entries are transcript evidence only: they persist as Brunch structured-exchange `toolResult` rows, are included by Brunch-semantic transcript renderers, are hidden or collapsed in TUI display, and never mutate graph truth or bypass `CommandExecutor`. | partially covered (minimum capture details schemas parse/export and reject graph payload fields; future runtime capture-analysis schema/rendering tests plus transcript renderer fixtures still need to prove persisted result rendering and TUI hide/collapse behavior; later graph-capture fixtures compare analysis candidates against committed graph mutations) | D17-L, D18-L, D37-L, D47-L, D50-L; I2-L, I11-L, I23-L, I30-L | | I34-L | `mutateGraph` batch validation is all-or-nothing: if any node or edge in the batch is structurally illegal, the entire batch is rejected and no partial state is persisted; the agent receives diagnostics sufficient for bounded self-correction retry. | covered (`command-executor/commit-graph-batch.test.ts` and graph-tool adapter tests cover dry-run/commit diagnostic parity for invalid basis, missing refs/codes, invalid category/stance, self-loop, invalid node kind/detail shape, rollback of nodes/edges/change_log/counters, transaction-local planning before LSN allocation/writes, and structured adapter diagnostics without thrown projected-code errors or fake endpoint refs) | D53-L; I1-L, I11-L | @@ -669,7 +669,7 @@ The first required probe is M0: after manual TUI interaction, a checker proves ` | I22-L | FE-744 coordinator inventory/activation tests plus pty/ANSI-stripped TUI probe assertions: no stale transcript before explicit resume, new-spec path creates an implicit first session, new-session path yields binding-only JSONL, resume path renders the chosen transcript, chrome includes activated session id, and RPC/headless boot exposes structured initial-selection state instead of invoking TUI picker code. | | I23-L | FE-744 structured-exchange tests: `present_*` results persist rich markdown display through `toolResult.content`/`renderResult`; `request_*` tools mount an input-replacing TUI response surface when available; single-choice, multi-choice, freeform, and freeform-plus-choice answers persist as self-contained request result details; RPC/fixture paths submit the same semantic response through JSON-editor fallback or Brunch product handlers; recovery helpers detect unmatched required presents; session exchange projection pairs the prompt-side present with the terminal request result. Structured-exchange schema tests cover the landed target details model: checked `schema`/`v`, `tool_meta`, candidate rubric/graph-ref shapes, review-set pointer shape, request answered/cancelled/unavailable unions, `comment` vs runtime `message`, and capture no-graph-payload minimum. | | I24-L | Sealed-profile tests: resource-loader options disable ambient discovery; inline Brunch extension resources still load intentionally through `resources_discover`; settings/keybinding/tool/prompt policy audit proves no ambient user/project `.pi/` setting changes Brunch product behavior. | -| I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active `op_mode` / `strategy` / `lens` / `goal` (foreground role derived from `op_mode`), and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit` while selected-spec grade gates activate commitment proposal tools. | +| I25-L | Runtime-state tests: append init/switch custom entries, reload the linear transcript, reconstruct the active `op_mode` / `strategy` / `lens` / `goal` (foreground role derived from `op_mode`), and verify before-agent-start/tool-call policy suppresses disallowed tools for `elicit` while selected-spec gap coverage activates commitment proposal tools. | | I26-L | Structured-exchange schema tests prove the acknowledged Zod seam parses and exports JSON Schema; future M4 architectural tests should grep/import-audit schema libraries and Drizzle row-schema derivation boundaries. | | I28-L | Inner — TypeBox schema validation of [src/.pi/extensions/compaction/index.ts](file:///Users/lunelson/Code/hashintel/brunch-next/src/.pi/extensions/compaction/index.ts) shape; deterministic anchor-rendering unit tests (same branch + same config → same header bytes). Middle (M9) — compaction round-trip property tests across all configured anchors and selection rules; fallback-to-Pi-default behavior under simulated auth failure, empty LLM output, and thrown error. Outer (M9) — long-horizon adversarial fixture confirms session binding, latest runtime state, latest establishment offer, in-flight side-task results, and unresolved staleness hints remain agent-intelligible post-compaction. | | I29-L | Inner — argv-shape tests for the `subagent` tool prove every spawned subprocess includes `--no-session --no-skills --no-extensions` plus an explicit per-agent `--tools`/`--extension`/`--models`/`--append-system-prompt` set; TypeBox schema validation of `src/.pi/extensions/subagents/agents/*.md` frontmatter and `src/.pi/extensions/subagents/config.json`. Middle — isolation audit (no ambient `.pi/` resources reachable inside the subprocess; tool-allowlist conformance per starter agent; parent `CommandExecutor`/Brunch RPC handlers absent from subprocess environment). Outer — probe-driven proposal-generation runs invoking scout/researcher/graph-reader confirm grounding inputs flow through subagent outputs into review-set proposals without bypassing primary authority. | diff --git a/src/.pi/README.md b/src/.pi/README.md index ebd0a048..cc4c21fa 100644 --- a/src/.pi/README.md +++ b/src/.pi/README.md @@ -52,4 +52,4 @@ rules: Production Brunch does not rely on ambient discovery from the repository root. The product shell imports extension factories explicitly; tests for extensions/components live in `.pi/__tests__/`. -`SYSTEM.md` / `APPEND_SYSTEM.md` are Pi's static ambient prompt files. Brunch's dynamic selected-spec/runtime prompt contribution is per-turn and therefore uses `before_agent_start` in `extensions/system-prompts/`, appending to the already assembled Pi system prompt by returning `systemPrompt: event.systemPrompt + brunchPrompt`. +`SYSTEM.md` / `APPEND_SYSTEM.md` are Pi's static ambient prompt files. Brunch's dynamic selected-spec/runtime/gap-driven prompt contribution is per-turn and therefore uses `before_agent_start` in `extensions/system-prompts/`, appending to the already assembled Pi system prompt by returning `systemPrompt: event.systemPrompt + brunchPrompt`. diff --git a/src/.pi/__tests__/chrome.test.ts b/src/.pi/__tests__/chrome.test.ts index c889c6e8..e4faf067 100644 --- a/src/.pi/__tests__/chrome.test.ts +++ b/src/.pi/__tests__/chrome.test.ts @@ -15,6 +15,8 @@ describe('Brunch chrome projection', () => { const state = chromeStateForWorkspace(readyWorkspace('/tmp/project', 'session-real')); expect(state.session.id).toBe('session-real'); + expect(state).not.toHaveProperty('phase'); + expect(state).not.toHaveProperty('chatMode'); }); it('populates session.label from workspace session name when available', () => { @@ -39,8 +41,6 @@ describe('Brunch chrome projection', () => { cwd: '/tmp/project', spec: { id: 1, title: 'Spec One' }, session: { id: 'session-1', label: 'Interview #1' }, - phase: 'elicitation' as const, - chatMode: 'responding-to-elicitation' as const, webSidecarUrl: 'http://127.0.0.1:49152/spec/1', }; @@ -58,8 +58,6 @@ describe('Brunch chrome projection', () => { cwd: '/tmp/project', spec: { id: 1, title: 'Spec One' }, session: { id: 'session-1', label: 'Interview #1' }, - phase: 'elicitation' as const, - chatMode: 'responding-to-elicitation' as const, runtime: { bundle: 'elicit-default', role: 'elicitor', @@ -87,8 +85,6 @@ describe('Brunch chrome projection', () => { cwd: '/tmp/project', spec: { id: 1, title: 'Spec One' }, session: { id: 'session-1', label: 'Interview #1' }, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', runtime: { bundle: 'elicit-default', role: 'elicitor', @@ -127,8 +123,6 @@ describe('Brunch chrome projection', () => { cwd: '/tmp/project', spec: { id: 1, title: 'Spec One' }, session: { id: 'session-1' }, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', }); expect(calls.map((call) => call.method)).toEqual(['setFooter', 'setTitle']); @@ -145,8 +139,6 @@ describe('Brunch chrome projection', () => { project: { name: 'Project One', slug: 'project-one' }, spec: { id: 1, title: 'Spec One' }, session: { id: 'session-1', label: 'Spec One — session 1' }, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', webSidecarUrl: 'http://127.0.0.1:49152/spec/1', startupHeader: { decision: 'newSession' }, }); @@ -175,8 +167,6 @@ describe('Brunch chrome projection', () => { cwd: '/tmp/project', spec: { id: 1, title: 'Spec One' }, session: { id: 'session-1' }, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', }); expect(resumedCalls.some((call) => call.method === 'setHeader')).toBe(false); }); @@ -221,8 +211,6 @@ describe('Brunch chrome projection', () => { cwd: '/tmp/project', spec: { id: 1, title: 'Spec One' }, session: { id: 'session-1' }, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', webSidecarUrl: 'http://127.0.0.1:49152/spec/1\nignored', }); @@ -245,8 +233,6 @@ function readyWorkspace(cwd: string, sessionId: string, sessionName?: string): W chrome: { cwd, spec, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', }, }; } diff --git a/src/.pi/__tests__/context-tools.test.ts b/src/.pi/__tests__/context-tools.test.ts index b46b967d..0637e3e4 100644 --- a/src/.pi/__tests__/context-tools.test.ts +++ b/src/.pi/__tests__/context-tools.test.ts @@ -243,6 +243,7 @@ describe('context tools', () => { expect(result.content[0]?.text).toContain('[Workspace overview]'); expect(result.content[0]?.text).toContain('Alpha Grounding'); expect(result.content[0]?.text).toContain('Beta Commitments'); + expect(result.content[0]?.text).not.toContain('readiness_grade='); expect(result.details.mode).toBe('workspace_overview'); expect(result.details.data.specs.map((spec) => spec.title)).toEqual([ 'Alpha Grounding', diff --git a/src/.pi/__tests__/prompting.test.ts b/src/.pi/__tests__/prompting.test.ts index 9dd777a3..6812ba78 100644 --- a/src/.pi/__tests__/prompting.test.ts +++ b/src/.pi/__tests__/prompting.test.ts @@ -4,9 +4,10 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../../graph/schema/nodes.js'; import type { WorkspacePostureState } from '../../session/workspace-session-coordinator.js'; import { composeAgentPrompt } from '../agents/compose.js'; -import type { ReadinessGrade } from '../agents/state.js'; import { createBrunchPiExtensions } from '../brunch-pi-extensions.js'; import { BRUNCH_INTROSPECT_QUERY_TOOL } from '../extensions/introspect-query/index.js'; import { createInMemoryBrunchIntrospectionStore } from '../extensions/introspection/index.js'; @@ -52,6 +53,30 @@ class FakeRuntimeStateSessionManager { } } +function gap(refersTo: NodeKind, coverage = 1): ElicitationGap { + return { + id: `${refersTo}:gap`, + specId: 1, + refersTo, + question: `${refersTo} question`, + rationale: `${refersTo} rationale`, + basis: 'implicit', + band: 'grounding', + predicate: { kind: 'presence', minimum: 1, nodeKind: refersTo }, + importance: 1, + coverage, + answered: coverage >= 1, + disposition: coverage >= 1 ? 'answered' : 'open', + createdAtLsn: 1, + }; +} + +function groundingGaps(coverage: Partial> = {}): ElicitationGap[] { + return ['context', 'thesis', 'goal', 'constraint'].map((kind) => + gap(kind as NodeKind, coverage[kind as NodeKind] ?? 1), + ); +} + const promptContext = { spec: { id: 1, name: 'Spec', readinessGrade: 'commitments_ready' as const }, workspace: { @@ -108,6 +133,7 @@ const promptContext = { }), getNodes: () => [], resolveNodeCode: () => undefined, + getElicitationGaps: () => groundingGaps(), }, }; @@ -130,6 +156,7 @@ describe('Brunch prompt-pack topology', () => { spec: promptContext.spec, workspace: promptContext.workspace, activeTools: ['read', 'grep', 'present_options'], + gaps: groundingGaps(), }); expect(result.prompt).toContain('[Brunch agent control]'); @@ -188,6 +215,14 @@ describe('Brunch prompt-pack topology', () => { expect(result).toMatchObject({ systemPrompt: expect.stringContaining('- active tools: read, grep, present_options, request_answer'), }); + expect(result).toMatchObject({ + systemPrompt: expect.stringContaining( + '- selected spec: Spec (#1); readiness estimate (soft; gates nothing): grounding=1.00, elicitation=0.00, commitment=0.00', + ), + }); + expect(result).toMatchObject({ + systemPrompt: expect.not.stringContaining('readiness_grade='), + }); expect(result).toMatchObject({ systemPrompt: expect.stringContaining('[Selected-spec graph context · design lens]'), }); @@ -207,8 +242,6 @@ describe('Brunch prompt-pack topology', () => { await createBrunchPiExtensions( { cwd: '/tmp/brunch', - chatMode: 'responding-to-elicitation', - phase: 'elicitation', spec: { id: 1, title: 'Launch spec' }, session: { id: 'launch-session', label: 'Launch session' }, }, @@ -244,6 +277,7 @@ describe('Brunch prompt-pack topology', () => { }), getNodes: () => [], resolveNodeCode: () => undefined, + getElicitationGaps: () => groundingGaps(), }, }), }, @@ -435,8 +469,6 @@ describe('Brunch prompt-pack topology', () => { await createBrunchPiExtensions( { cwd: '/tmp/brunch', - chatMode: 'responding-to-elicitation', - phase: 'elicitation', spec: { id: 1, title: 'Spec' }, session: { id: 'session-1', label: 'Session' }, }, @@ -482,8 +514,8 @@ describe('Brunch prompt-pack topology', () => { expect(promptResult?.systemPrompt).toContain(BRUNCH_INTROSPECT_QUERY_TOOL); }); - it('applies the selected-spec grade to mutate_graph tool activation', async () => { - async function activeToolsForGrade(readinessGrade: ReadinessGrade) { + it('applies selected-spec gaps to mutate_graph tool activation', async () => { + async function activeToolsForGaps(gaps: readonly ElicitationGap[]) { const events: Record unknown> = {}; const activeTools: string[][] = []; registerBrunchPrompting( @@ -505,7 +537,7 @@ describe('Brunch prompt-pack topology', () => { } as never, { ...promptContext, - spec: { ...promptContext.spec, readinessGrade }, + graphReads: { ...promptContext.graphReads, getElicitationGaps: () => gaps }, }, ); @@ -518,10 +550,13 @@ describe('Brunch prompt-pack topology', () => { return activeTools.at(-1) ?? []; } - await expect(activeToolsForGrade('grounding_onboarding')).resolves.not.toContain('mutate_graph'); - await expect(activeToolsForGrade('elicitation_ready')).resolves.toContain('mutate_graph'); - await expect(activeToolsForGrade('elicitation_ready')).resolves.not.toContain('present_review_set'); - await expect(activeToolsForGrade('commitments_ready')).resolves.toContain('present_review_set'); + await expect( + activeToolsForGaps(groundingGaps({ context: 0, thesis: 0, goal: 0, constraint: 0 })), + ).resolves.not.toContain('mutate_graph'); + await expect(activeToolsForGaps(groundingGaps({ context: 0.5 }))).resolves.toContain('mutate_graph'); + await expect(activeToolsForGaps(groundingGaps({ context: 0.5 }))).resolves.toContain( + 'present_review_set', + ); }); it('is registered by the explicit shell after operational-mode policy and appends composed manifests', async () => { @@ -531,8 +566,6 @@ describe('Brunch prompt-pack topology', () => { await createBrunchPiExtensions( { cwd: '/tmp/brunch', - chatMode: 'responding-to-elicitation', - phase: 'elicitation', spec: { id: 1, title: 'Spec' }, session: { id: 'session-1', label: 'Session' }, }, @@ -599,8 +632,6 @@ describe('Brunch prompt-pack topology', () => { await createBrunchPiExtensions( { cwd: '/tmp/brunch', - chatMode: 'responding-to-elicitation', - phase: 'elicitation', spec: { id: 1, title: 'Spec' }, session: { id: 'session-1', label: 'Session' }, }, diff --git a/src/.pi/__tests__/tui-lab-style.test.ts b/src/.pi/__tests__/tui-lab-style.test.ts index e78d18b6..8b6f8cc0 100644 --- a/src/.pi/__tests__/tui-lab-style.test.ts +++ b/src/.pi/__tests__/tui-lab-style.test.ts @@ -63,8 +63,6 @@ describe('TUI style lab extension registration', () => { await createBrunchPiExtensions( { cwd: '/tmp/brunch', - chatMode: 'responding-to-elicitation', - phase: 'elicitation', spec: { id: 1, title: 'Spec' }, session: { id: 'session', label: 'Session' }, }, diff --git a/src/.pi/agents/README.md b/src/.pi/agents/README.md index 9dc9736d..ad6a01cd 100644 --- a/src/.pi/agents/README.md +++ b/src/.pi/agents/README.md @@ -10,7 +10,7 @@ The markdown resources the agent reads on demand live beside this layer but are ```text .pi/agents/definitions/ keyed agent role prompts -.pi/skills/goals/ grade-derived objectives +.pi/skills/goals/ capability-readiness-derived objectives .pi/skills/strategies/ interaction shapes .pi/skills/lenses/ topical focus lenses .pi/skills/methods/ tool-routing / sequencing guidance @@ -29,9 +29,9 @@ The markdown resources the agent reads on demand live beside this layer but are ```text agents/ ├── README.md -├── state.ts axis enums + legal (op_mode × goal × strategy × lens) tuple table; -│ also owns each resource's {name, description, location} manifest entry -├── compose.ts projection -> runtime header + gated manifest +├── state.ts resource manifests + gap-driven method/tool legality; +│ reuses runtime-policy for goal/strategy/lens legality +├── compose.ts projection + elicitation gaps -> runtime header + gated manifest ├── index.ts public entry for prompt assembly imports ├── definitions/ keyed Pi session-agent roles; body = system-prompt resource │ ├── elicitor.md @@ -44,11 +44,11 @@ agents/ ## Composition model -`composeAgentPrompt(agentId, sessionState, spec, workspace, context)` emits: +`composeAgentPrompt(agentId, sessionState, spec, workspace, context, gaps)` emits: 1. agent control header — identity, model/thinking expectation, role derived from `op_mode`, tool authority; -2. runtime-state header — current pinned/AUTO `goal`/`strategy`/`lens`, readiness grade, posture; -3. resource manifests — ``, ``, ``, `` entries, filtered by tuple/grade/`op_mode`/allow-list; +2. runtime-state header — current pinned/AUTO `goal`/`strategy`/`lens`, current spec line with the soft per-band readiness estimate, posture; +3. resource manifests — ``, ``, ``, `` entries, filtered by `op_mode`/allow-list plus capability-readiness over selected-spec elicitation gaps; 4. compact pushed context — minimal context handles and rendered context blocks. Detailed goal/strategy/lens/method bodies are markdown resources under `.pi/skills/` and are loaded with `read` when detail matters. Manifest metadata is code-owned in `state.ts`, not filesystem-discovered. @@ -61,7 +61,7 @@ RENDER -> reusable renderers eventually; .pi/agents/contexts chooses audience/d SURFACE -> extensions/system-prompts/ or read_graph / context read tools ``` -`contexts/` is not a `` manifest resource family. It chooses which typed pull to expose, how much detail to include, and how lens/grade/mode shape the prompt-facing string. +`contexts/` is not a `` manifest resource family. It chooses which typed pull to expose, how much detail to include, and how lens/gaps/mode shape the prompt-facing string. ## Imported by diff --git a/src/.pi/agents/compose.test.ts b/src/.pi/agents/compose.test.ts index 637ffa48..ed50a85c 100644 --- a/src/.pi/agents/compose.test.ts +++ b/src/.pi/agents/compose.test.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../../graph/schema/nodes.js'; import { DEFAULT_BRUNCH_AGENT_STATE, projectBrunchAgentState, @@ -17,13 +19,11 @@ const projectRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.ur const groundingSpec = { id: 1, name: 'Grounding Spec', - readinessGrade: 'grounding_onboarding' as const, }; const elicitationSpec = { id: 1, name: 'Elicitation Spec', - readinessGrade: 'elicitation_ready' as const, }; const workspace = { @@ -42,6 +42,32 @@ function workspacePosture(posture: WorkspacePostureState): WorkspacePostureState return posture; } +function gap(refersTo: NodeKind, coverage = 1): ElicitationGap { + return { + id: `${refersTo}:gap`, + specId: 1, + refersTo, + question: `${refersTo} question`, + rationale: `${refersTo} rationale`, + basis: 'implicit', + band: 'grounding', + predicate: { kind: 'presence', minimum: 1, nodeKind: refersTo }, + importance: 1, + coverage, + answered: coverage >= 1, + disposition: coverage >= 1 ? 'answered' : 'open', + createdAtLsn: 1, + }; +} + +const coveredGaps = ['context', 'thesis', 'goal', 'constraint'].map((kind) => gap(kind as NodeKind)); +const zeroCoverageGaps = coveredGaps.map((record) => ({ + ...record, + coverage: 0, + answered: false, + disposition: 'open' as const, +})); + const context = { contextHandles: ['graph-overview: compact selected-spec graph summary available via read tools'], renderedContexts: [ @@ -58,12 +84,16 @@ describe('composeAgentPrompt', () => { workspace, context, activeTools: ['read', 'grep', 'present_options'], + gaps: zeroCoverageGaps, }); expect(result.prompt).toContain('[Brunch agent control]'); expect(result.prompt).toContain('- agent: elicitor'); expect(result.prompt).toContain('[Brunch runtime state]'); - expect(result.prompt).toContain('- spec: Grounding Spec (#1), readiness_grade=grounding_onboarding'); + expect(result.prompt).toContain( + '- spec: Grounding Spec (#1), readiness estimate (soft; gates nothing): grounding=0.00, elicitation=0.00, commitment=0.00', + ); + expect(result.prompt).not.toContain('readiness_grade='); expect(result.prompt).toContain( '- workspace posture: certainty=proving; stakes=high; audience=internal; horizon=current-milestone; migration=free-rewrite; sourcing=strip-or-build', ); @@ -103,6 +133,7 @@ describe('composeAgentPrompt', () => { renderedContexts: ['[Selected-spec graph context · intent lens]\n- emphasis: intent claims'], }, activeTools: ['read'], + gaps: coveredGaps, }); const design = composeAgentPrompt({ agentId: 'elicitor', @@ -127,6 +158,7 @@ describe('composeAgentPrompt', () => { renderedContexts: ['[Selected-spec graph context · design lens]\n- emphasis: design modules'], }, activeTools: ['read'], + gaps: coveredGaps, }); expect(intent.prompt).toContain('[Selected-spec graph context · intent lens]'); @@ -138,7 +170,7 @@ describe('composeAgentPrompt', () => { expect(design.manifests.lenses.map((entry) => entry.name)).toEqual(['design']); }); - it('filters AUTO axes by grade and allow-list, while pinned legal axes point at only the pinned resource', () => { + it('filters AUTO axes by gap coverage and allow-list, while pinned legal axes point at only the pinned resource', () => { const auto = composeAgentPrompt({ agentId: 'elicitor', sessionState: projectBrunchAgentState([ @@ -159,17 +191,20 @@ describe('composeAgentPrompt', () => { spec: elicitationSpec, workspace, activeTools: ['read'], + gaps: coveredGaps, }); expect(auto.manifests.goals.map((entry) => entry.name)).toEqual([ 'grounding-advance', 'elicit-expand', + 'commit-converge', 'capture-posture', ]); expect(auto.manifests.strategies.map((entry) => entry.name)).toEqual([ 'step-wise-decision-tree', 'step-wise-disambiguate', 'propose-graph', + 'project-graph', ]); expect(auto.manifests.lenses.map((entry) => entry.name)).toEqual(['intent', 'design', 'oracle']); @@ -195,6 +230,7 @@ describe('composeAgentPrompt', () => { spec: elicitationSpec, workspace, activeTools: ['read'], + gaps: coveredGaps, }); expect(pinned.manifests.goals.map((entry) => entry.name)).toEqual(['elicit-expand']); @@ -221,14 +257,19 @@ describe('composeAgentPrompt', () => { spec: groundingSpec, workspace, activeTools: ['read'], + gaps: zeroCoverageGaps, }); expect(pinnedFreestyle.manifests.strategies.map((entry) => entry.name)).toEqual(['freestyle']); + expect(auto.prompt).toContain( + '- spec: Elicitation Spec (#1), readiness estimate (soft; gates nothing): grounding=1.00, elicitation=0.00, commitment=0.00', + ); + expect(auto.prompt).not.toContain('readiness_grade='); expect(auto.prompt).not.toContain('name="freestyle"'); expect(pinnedFreestyle.prompt).toContain('name="freestyle"'); }); - it('rejects illegal pinned grade-gated selections loudly', () => { + it('rejects illegal pinned gap-gated selections loudly', () => { expect(() => composeAgentPrompt({ agentId: 'elicitor', @@ -250,9 +291,10 @@ describe('composeAgentPrompt', () => { spec: groundingSpec, workspace, activeTools: ['read'], + gaps: zeroCoverageGaps, }), ).toThrow( - 'Pinned goal "commit-converge" is not legal for elicitor in elicit at readiness grade grounding_onboarding.', + 'Pinned goal "commit-converge" is not legal for elicitor in elicit; capability-readiness returned negotiate for current elicitation gaps.', ); }); @@ -263,6 +305,7 @@ describe('composeAgentPrompt', () => { spec: elicitationSpec, workspace, activeTools: ['read'], + gaps: coveredGaps, }); for (const entry of Object.values(result.manifests).flat()) { diff --git a/src/.pi/agents/compose.ts b/src/.pi/agents/compose.ts index 797d91e6..56d5a304 100644 --- a/src/.pi/agents/compose.ts +++ b/src/.pi/agents/compose.ts @@ -1,16 +1,13 @@ +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; +import { READINESS_BANDS } from '../../graph/schema/kinds.js'; +import { readinessEstimate } from '../../projections/session/readiness-estimate.js'; import type { ResolvedBrunchAgentState } from '../../projections/session/runtime-state.js'; import type { WorkspacePostureState } from '../../session/workspace-session-coordinator.js'; -import { - AGENT_PROMPT_DEFINITIONS, - manifestsForState, - type PromptManifests, - type ReadinessGrade, -} from './state.js'; +import { AGENT_PROMPT_DEFINITIONS, manifestsForState, type PromptManifests } from './state.js'; export interface AgentPromptSpecContext { id: number; name: string; - readinessGrade: ReadinessGrade; } export interface AgentPromptWorkspaceContext { @@ -30,6 +27,7 @@ export interface ComposeAgentPromptInput { workspace: AgentPromptWorkspaceContext; context?: AgentPromptContextBundle; activeTools?: readonly string[]; + gaps: readonly ElicitationGap[]; } export interface ComposeAgentPromptResult { @@ -45,7 +43,7 @@ export function composeAgentPrompt(input: ComposeAgentPromptInput): ComposeAgent } const definition = AGENT_PROMPT_DEFINITIONS[input.agentId]; - const manifests = manifestsForState(input.sessionState, input.spec.readinessGrade); + const manifests = manifestsForState(input.sessionState, input.gaps); const prompt = joinSections([ renderAgentControl(input, definition), renderRuntimeState(input), @@ -82,12 +80,18 @@ function renderRuntimeState(input: ComposeAgentPromptInput): string { `- goal: ${input.sessionState.agentGoal}`, `- strategy: ${input.sessionState.agentStrategy}`, `- lens: ${input.sessionState.agentLens}`, - `- spec: ${input.spec.name} (#${input.spec.id}), readiness_grade=${input.spec.readinessGrade}`, + `- spec: ${input.spec.name} (#${input.spec.id}), ${renderSoftReadinessEstimate(input.gaps)}`, `- workspace: ${input.workspace.cwd}`, `- workspace posture: ${renderPosture(input.workspace.posture)}`, ].join('\n'); } +export function renderSoftReadinessEstimate(gaps: readonly ElicitationGap[]): string { + const estimate = readinessEstimate(gaps); + const coverage = READINESS_BANDS.map((band) => `${band}=${estimate.coverage[band].toFixed(2)}`).join(', '); + return `readiness estimate (soft; gates nothing): ${coverage}`; +} + function renderPosture(posture: AgentPromptWorkspaceContext['posture']): string { if (!posture) return 'unrecorded'; const entries = Object.entries(posture).filter((entry): entry is [string, string] => diff --git a/src/.pi/agents/contexts/cwd.test.ts b/src/.pi/agents/contexts/cwd.test.ts index 46f16428..a7049eae 100644 --- a/src/.pi/agents/contexts/cwd.test.ts +++ b/src/.pi/agents/contexts/cwd.test.ts @@ -1,11 +1,31 @@ import { describe, expect, it } from 'vitest'; +import type { ElicitationGap } from '../../../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../../../graph/schema/nodes.js'; import { renderCwdContext } from './cwd.js'; +function gap(refersTo: NodeKind, coverage: number, band: ElicitationGap['band']): ElicitationGap { + return { + id: `${refersTo}:gap`, + specId: 42, + refersTo, + question: `${refersTo} question`, + rationale: `${refersTo} rationale`, + basis: 'implicit', + band, + predicate: { kind: 'presence', minimum: 1, nodeKind: refersTo }, + importance: 1, + coverage, + answered: coverage >= 1, + disposition: coverage >= 1 ? 'answered' : 'open', + createdAtLsn: 1, + }; +} + describe('renderCwdContext', () => { it('renders selected-spec/session/posture facts without ambient resource discovery', () => { const rendered = renderCwdContext({ - spec: { id: 42, name: 'Payments Spec', readinessGrade: 'elicitation_ready' }, + spec: { id: 42, name: 'Payments Spec' }, workspace: { cwd: '/repo/product', posture: { @@ -15,10 +35,14 @@ describe('renderCwdContext', () => { }, }, session: { id: 'session-7', label: 'Grounding' }, + gaps: [gap('context', 0.5, 'grounding'), gap('requirement', 1, 'elicitation')], }); expect(rendered).toContain('- cwd: /repo/product'); - expect(rendered).toContain('- selected spec: Payments Spec (#42); readiness_grade=elicitation_ready'); + expect(rendered).toContain( + '- selected spec: Payments Spec (#42); readiness estimate (soft; gates nothing): grounding=0.50, elicitation=1.00, commitment=0.00', + ); + expect(rendered).not.toContain('readiness_grade='); expect(rendered).toContain('- selected session: Grounding (session-7)'); expect(rendered).toContain('certainty=proving; stakes=high; migration=free-rewrite'); expect(rendered).toContain('ambient Pi resources: not scanned'); diff --git a/src/.pi/agents/contexts/cwd.ts b/src/.pi/agents/contexts/cwd.ts index 73f6cd88..c0569c59 100644 --- a/src/.pi/agents/contexts/cwd.ts +++ b/src/.pi/agents/contexts/cwd.ts @@ -1,4 +1,9 @@ -import type { AgentPromptSpecContext, AgentPromptWorkspaceContext } from '../compose.js'; +import type { ElicitationGap } from '../../../graph/schema/elicitation-gaps.js'; +import { + renderSoftReadinessEstimate, + type AgentPromptSpecContext, + type AgentPromptWorkspaceContext, +} from '../compose.js'; export interface AgentPromptSessionContext { readonly id?: string; @@ -9,13 +14,14 @@ export interface RenderCwdContextInput { readonly spec: AgentPromptSpecContext; readonly workspace: AgentPromptWorkspaceContext; readonly session?: AgentPromptSessionContext; + readonly gaps: readonly ElicitationGap[]; } export function renderCwdContext(input: RenderCwdContextInput): string { return [ '[Selected workspace context]', `- cwd: ${input.workspace.cwd}`, - `- selected spec: ${input.spec.name} (#${input.spec.id}); readiness_grade=${input.spec.readinessGrade}`, + `- selected spec: ${input.spec.name} (#${input.spec.id}); ${renderSoftReadinessEstimate(input.gaps)}`, `- selected session: ${renderSession(input.session)}`, `- workspace posture: ${renderPosture(input.workspace.posture)}`, '- ambient Pi resources: not scanned; Brunch prompt resources come only from code-owned manifests', diff --git a/src/.pi/agents/state.test.ts b/src/.pi/agents/state.test.ts index e26319f1..17043957 100644 --- a/src/.pi/agents/state.test.ts +++ b/src/.pi/agents/state.test.ts @@ -1,8 +1,37 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + import { describe, expect, it } from 'vitest'; +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../../graph/schema/nodes.js'; import { projectBrunchAgentState } from '../../projections/session/runtime-state.js'; import { activeToolNamesForPosture, manifestsForState } from './state.js'; +function gap(refersTo: NodeKind, coverage: number): ElicitationGap { + return { + id: `${refersTo}:gap`, + specId: 1, + refersTo, + question: `${refersTo} question`, + rationale: `${refersTo} rationale`, + basis: 'implicit', + band: 'grounding', + predicate: { kind: 'presence', minimum: 1, nodeKind: refersTo }, + importance: 1, + coverage, + answered: coverage >= 1, + disposition: coverage >= 1 ? 'answered' : 'open', + createdAtLsn: 1, + }; +} + +function groundingGaps(coverage: Partial> = {}): ElicitationGap[] { + return ['context', 'thesis', 'goal', 'constraint'].map((kind) => + gap(kind as NodeKind, coverage[kind as NodeKind] ?? 1), + ); +} + const registeredToolNames = [ 'read', 'grep', @@ -24,70 +53,74 @@ const registeredToolNames = [ ]; describe('agent posture policy', () => { - it('derives method manifests and active tool names from one grade policy', () => { + it('derives method manifests and active tool names from gap coverage', () => { const state = projectBrunchAgentState([]); + const uncoveredGaps = groundingGaps({ context: 0, thesis: 0, goal: 0, constraint: 0 }); + const coveredGaps = groundingGaps(); - const groundingMethods = manifestsForState(state, 'grounding_onboarding').methods.map( - (entry) => entry.name, - ); - const groundingTools = activeToolNamesForPosture({ - registeredToolNames, - state, - readinessGrade: 'grounding_onboarding', - }); - const elicitationMethods = manifestsForState(state, 'elicitation_ready').methods.map( - (entry) => entry.name, - ); - const elicitationTools = activeToolNamesForPosture({ - registeredToolNames, - state, - readinessGrade: 'elicitation_ready', - }); - const commitmentsMethods = manifestsForState(state, 'commitments_ready').methods.map( - (entry) => entry.name, - ); - const commitmentsTools = activeToolNamesForPosture({ - registeredToolNames, - state, - readinessGrade: 'commitments_ready', - }); + const floorMethods = manifestsForState(state, uncoveredGaps).methods.map((entry) => entry.name); + const floorTools = activeToolNamesForPosture({ registeredToolNames, state, gaps: uncoveredGaps }); + const coveredMethods = manifestsForState(state, coveredGaps).methods.map((entry) => entry.name); + const coveredTools = activeToolNamesForPosture({ registeredToolNames, state, gaps: coveredGaps }); - expect(groundingMethods).not.toContain('commit-graph'); - expect(groundingTools).not.toContain('mutate_graph'); - expect(groundingTools).toContain('read_graph'); - expect(groundingTools).toContain('read_session_context'); - expect(groundingTools).not.toContain('bash'); - expect(groundingTools).toEqual( + expect(floorMethods).toEqual(['run-structured-exchange', 'infer-and-capture', 'read-context']); + expect(floorTools).not.toContain('mutate_graph'); + expect(floorTools).not.toContain('present_review_set'); + expect(floorTools).not.toContain('request_review'); + expect(floorTools).toContain('read_graph'); + expect(floorTools).toContain('read_session_context'); + expect(floorTools).not.toContain('bash'); + expect(floorTools).toEqual( expect.arrayContaining(['present_question', 'present_options', 'request_answer']), ); - expect(elicitationMethods).toContain('commit-graph'); - expect(elicitationTools).toContain('mutate_graph'); - expect(commitmentsMethods).toContain('generate-proposal'); - expect(commitmentsTools).toContain('mutate_graph'); - expect(commitmentsTools).toEqual(expect.arrayContaining(['present_review_set', 'request_review'])); - expect(elicitationTools).not.toContain('present_review_set'); + expect(coveredMethods).toEqual([ + 'run-structured-exchange', + 'infer-and-capture', + 'commit-graph', + 'read-context', + 'generate-proposal', + 'review-for-gaps', + ]); + expect(coveredTools).toContain('mutate_graph'); + expect(coveredTools).toEqual(expect.arrayContaining(['present_review_set', 'request_review'])); + }); + + it('moves a gated method and its tools from absent to present when coverage rises', () => { + const state = projectBrunchAgentState([]); + const uncovered = groundingGaps({ context: 0 }); + const covered = groundingGaps({ context: 0.5 }); + + expect(manifestsForState(state, uncovered).methods.map((entry) => entry.name)).not.toContain( + 'commit-graph', + ); + expect(activeToolNamesForPosture({ registeredToolNames, state, gaps: uncovered })).not.toContain( + 'mutate_graph', + ); + expect(manifestsForState(state, covered).methods.map((entry) => entry.name)).toContain('commit-graph'); + expect(activeToolNamesForPosture({ registeredToolNames, state, gaps: covered })).toContain( + 'mutate_graph', + ); }); it('allows registered dev tool names only through the injected dev allow-list', () => { const state = projectBrunchAgentState([]); + const gaps = groundingGaps({ context: 0, thesis: 0, goal: 0, constraint: 0 }); const productTools = activeToolNamesForPosture({ registeredToolNames: [...registeredToolNames, 'brunch_session_query'], state, - readinessGrade: 'grounding_onboarding', + gaps, }); const devTools = activeToolNamesForPosture({ registeredToolNames: [...registeredToolNames, 'brunch_session_query'], state, - readinessGrade: 'grounding_onboarding', + gaps, devAllowedToolNames: ['brunch_session_query'], }); expect(productTools).not.toContain('brunch_session_query'); expect(devTools).toContain('brunch_session_query'); - expect(productTools).toEqual( - activeToolNamesForPosture({ registeredToolNames, state, readinessGrade: 'grounding_onboarding' }), - ); + expect(productTools).toEqual(activeToolNamesForPosture({ registeredToolNames, state, gaps })); }); it('keeps blocked tools blocked and never advertises unregistered dev tool names', () => { @@ -95,7 +128,7 @@ describe('agent posture policy', () => { const tools = activeToolNamesForPosture({ registeredToolNames, state, - readinessGrade: 'grounding_onboarding', + gaps: groundingGaps({ context: 0, thesis: 0, goal: 0, constraint: 0 }), devAllowedToolNames: ['bash', 'brunch_session_query'], }); @@ -124,26 +157,60 @@ describe('agent posture policy', () => { }, ]); - expect(manifestsForState(autoState, 'elicitation_ready').strategies.map((entry) => entry.name)).toEqual([ + expect(manifestsForState(autoState, groundingGaps()).strategies.map((entry) => entry.name)).toEqual([ 'step-wise-decision-tree', 'step-wise-disambiguate', 'propose-graph', + 'project-graph', ]); expect( - manifestsForState(pinnedFreestyle, 'grounding_onboarding').strategies.map((entry) => entry.name), + manifestsForState( + pinnedFreestyle, + groundingGaps({ context: 0, thesis: 0, goal: 0, constraint: 0 }), + ).strategies.map((entry) => entry.name), ).toEqual(['freestyle']); expect( activeToolNamesForPosture({ registeredToolNames, state: pinnedFreestyle, - readinessGrade: 'elicitation_ready', + gaps: groundingGaps(), }), ).toEqual( activeToolNamesForPosture({ registeredToolNames, state: autoState, - readinessGrade: 'elicitation_ready', + gaps: groundingGaps(), }), ); }); + + it('throws on an illegal pinned axis with a negotiate outcome message, not a grade', () => { + const state = projectBrunchAgentState([ + { + type: 'custom', + customType: 'brunch.agent_runtime_state', + data: { + schemaVersion: 1, + reason: 'switch', + source: 'user', + state: { + schemaVersion: 1, + operationalMode: 'elicit', + agentStrategy: 'auto', + agentLens: 'auto', + agentGoal: 'commit-converge', + }, + }, + }, + ]); + + expect(() => manifestsForState(state, groundingGaps({ thesis: 0 }))).toThrow( + 'Pinned goal "commit-converge" is not legal for elicitor in elicit; capability-readiness returned negotiate for current elicitation gaps.', + ); + }); + + it('keeps state.ts free of grade-gate symbols', () => { + const source = readFileSync(fileURLToPath(new URL('./state.ts', import.meta.url)), 'utf8'); + expect(source).not.toMatch(/ReadinessGrade|GRADE_RANK|MIN_GRADE|isGradeLegal/); + }); }); diff --git a/src/.pi/agents/state.ts b/src/.pi/agents/state.ts index f36792ee..f1ddd6a4 100644 --- a/src/.pi/agents/state.ts +++ b/src/.pi/agents/state.ts @@ -1,18 +1,15 @@ import { fileURLToPath } from 'node:url'; -import type { ReadinessGrade } from '../../graph/index.js'; +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; +import type { CapabilityId } from '../../projections/session/capability-readiness.js'; import { AUTO_EXCLUDED_STRATEGIES, - GOAL_MIN_GRADE, - LENS_MIN_GRADE, - STRATEGY_MIN_GRADE, - isGradeLegal, + axisOptionsForRuntimeState, + isCapabilityLegalForGaps, toolPolicyForRuntimeState, type ResolvedBrunchAgentState, } from '../../projections/session/runtime-policy.js'; import type { AgentGoalId, AgentLensId, AgentRoleId, AgentStrategyId } from '../../session/runtime-state.js'; - -export type { ReadinessGrade }; type PromptResourceFamily = 'goals' | 'strategies' | 'lenses' | 'methods' | 'definitions'; export type MethodId = | 'run-structured-exchange' @@ -50,17 +47,14 @@ export interface PromptManifests { export interface BrunchPostureToolPolicyInput { registeredToolNames: readonly string[]; state: ResolvedBrunchAgentState; - readinessGrade: ReadinessGrade; + gaps: readonly ElicitationGap[]; devAllowedToolNames?: readonly string[] | undefined; } -const METHOD_MIN_GRADE: Record = { - 'run-structured-exchange': 'grounding_onboarding', - 'infer-and-capture': 'grounding_onboarding', - 'read-context': 'grounding_onboarding', - 'commit-graph': 'elicitation_ready', - 'generate-proposal': 'commitments_ready', - 'review-for-gaps': 'commitments_ready', +const METHOD_CAPABILITY: Partial> = { + 'commit-graph': 'propose-graph', + 'generate-proposal': 'project-graph', + 'review-for-gaps': 'commitment-review', }; const METHOD_TOOL_NAMES: Partial> = { @@ -205,7 +199,7 @@ export const METHOD_RESOURCES: Record = { export function manifestsForState( state: ResolvedBrunchAgentState, - readinessGrade: ReadinessGrade, + gaps: readonly ElicitationGap[], ): PromptManifests { const definition = AGENT_PROMPT_DEFINITIONS[state.agentRole]; if (!definition) { @@ -223,8 +217,7 @@ export function manifestsForState( selection: state.agentGoal, allowed: definition.allowedGoals, resources: GOAL_RESOURCES, - minGrades: GOAL_MIN_GRADE, - readinessGrade, + legalIds: axisOptionsForRuntimeState('goal', state, gaps), state, }), strategies: selectAxisResources({ @@ -232,8 +225,7 @@ export function manifestsForState( selection: state.agentStrategy, allowed: definition.allowedStrategies, resources: STRATEGY_RESOURCES, - minGrades: STRATEGY_MIN_GRADE, - readinessGrade, + legalIds: axisOptionsForRuntimeState('strategy', state, gaps), state, autoExcluded: AUTO_EXCLUDED_STRATEGIES, }), @@ -242,32 +234,33 @@ export function manifestsForState( selection: state.agentLens, allowed: definition.allowedLenses, resources: LENS_RESOURCES, - minGrades: LENS_MIN_GRADE, - readinessGrade, + legalIds: axisOptionsForRuntimeState('lens', state, gaps), state, }), - methods: methodIdsForState(state, readinessGrade).map((method) => METHOD_RESOURCES[method]), + methods: methodIdsForState(state, gaps).map((method) => METHOD_RESOURCES[method]), }; } export function methodIdsForState( state: ResolvedBrunchAgentState, - readinessGrade: ReadinessGrade, + gaps: readonly ElicitationGap[], ): readonly MethodId[] { const definition = AGENT_PROMPT_DEFINITIONS[state.agentRole]; if (!definition || definition.id !== state.agentRole || state.operationalMode !== 'elicit') return []; - return definition.allowedMethods.filter((method) => isGradeLegal(method, readinessGrade, METHOD_MIN_GRADE)); + return definition.allowedMethods.filter((method) => + isCapabilityLegalForGaps(METHOD_CAPABILITY[method], gaps), + ); } export function activeToolNamesForPosture({ registeredToolNames, state, - readinessGrade, + gaps, devAllowedToolNames = [], }: BrunchPostureToolPolicyInput): string[] { const toolPolicy = toolPolicyForRuntimeState(state); const legalTools = new Set(toolPolicy.baseAllowedToolNames); - for (const method of methodIdsForState(state, readinessGrade)) { + for (const method of methodIdsForState(state, gaps)) { for (const toolName of METHOD_TOOL_NAMES[method] ?? []) { legalTools.add(toolName); } @@ -286,8 +279,7 @@ function selectAxisResources({ selection, allowed, resources, - minGrades, - readinessGrade, + legalIds, state, autoExcluded, }: { @@ -295,18 +287,17 @@ function selectAxisResources({ selection: 'auto' | TId; allowed: readonly TId[]; resources: Record; - minGrades: Record; - readinessGrade: ReadinessGrade; + legalIds: readonly TId[]; state: ResolvedBrunchAgentState; autoExcluded?: ReadonlySet; }): readonly PromptResourceManifestEntry[] { - const legal = allowed.filter((id) => isGradeLegal(id, readinessGrade, minGrades)); + const legal = allowed.filter((id) => legalIds.includes(id)); if (selection === 'auto') { return legal.filter((id) => !autoExcluded?.has(id)).map((id) => resources[id]); } if (!legal.includes(selection)) { throw new Error( - `Pinned ${label} "${selection}" is not legal for ${state.agentRole} in ${state.operationalMode} at readiness grade ${readinessGrade}.`, + `Pinned ${label} "${selection}" is not legal for ${state.agentRole} in ${state.operationalMode}; capability-readiness returned negotiate for current elicitation gaps.`, ); } return [resources[selection]]; diff --git a/src/.pi/extensions/README.md b/src/.pi/extensions/README.md index d40ca6a7..6f62b7f2 100644 --- a/src/.pi/extensions/README.md +++ b/src/.pi/extensions/README.md @@ -25,7 +25,7 @@ extensions/ ├── compaction/ auto-compaction anchor contract and future hook ├── context/ snapshot/context Pi tools ├── exchanges/ structured-exchange present_* / request_* Pi tools -├── graph/ mutate_graph/read_graph Pi tools +├── graph/ mutate_graph/read_graph Pi tools + selected-spec graph/gap read seam ├── introspection/ dev-gated read-only provider-payload tap + /introspect command ├── introspect-query/ dev-gated read-only brunch_introspect_query tool over captured payloads ├── session-query/ dev-gated read-only brunch_session_query tool over current branch @@ -33,7 +33,7 @@ extensions/ ├── mentions/ #graph mention prompt hint + autocomplete provider ├── runtime/ active-tool policy and tool/user_bash guards ├── session/ session lifecycle hooks -├── system-prompts/ before_agent_start dynamic prompt append +├── system-prompts/ before_agent_start dynamic prompt append + gap-driven active-tool selection ├── workspace/ spec/session picker command adapter └── subagents/ future subagent config/tool surface ``` diff --git a/src/.pi/extensions/chrome/index.ts b/src/.pi/extensions/chrome/index.ts index a25ce4c5..771501cd 100644 --- a/src/.pi/extensions/chrome/index.ts +++ b/src/.pi/extensions/chrome/index.ts @@ -271,8 +271,6 @@ export default function brunchChrome(pi: ExtensionAPI): void { cwd: process.cwd(), spec: null, session: { id: 'direct-pi' }, - phase: 'select_spec', - chatMode: 'select-spec', startupHeader: { decision: 'continue' }, }); } diff --git a/src/.pi/extensions/graph/index.ts b/src/.pi/extensions/graph/index.ts index c9e0df79..7c266244 100644 --- a/src/.pi/extensions/graph/index.ts +++ b/src/.pi/extensions/graph/index.ts @@ -6,6 +6,7 @@ import type { CommandExecutor } from '../../../graph/command-executor.js'; import type { EdgeCategory, EdgeDirection, + ElicitationGap, GraphFilter, GraphSlice, GraphVisibility, @@ -32,6 +33,7 @@ export interface GraphReaders { options?: { hops?: number; visibility?: GraphVisibility }, ) => readonly NodeNeighborhood[]; readonly resolveNodeCode: (code: string) => number | undefined; + readonly getElicitationGaps?: (specId: number) => readonly ElicitationGap[]; } export interface BrunchGraphDeps { diff --git a/src/.pi/extensions/runtime/authority-matrix.test.ts b/src/.pi/extensions/runtime/authority-matrix.test.ts index ec474f78..2496a53d 100644 --- a/src/.pi/extensions/runtime/authority-matrix.test.ts +++ b/src/.pi/extensions/runtime/authority-matrix.test.ts @@ -2,6 +2,8 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; import { describe, expect, it } from 'vitest'; import type { CommandResult } from '../../../graph/command-executor.js'; +import type { ElicitationGap } from '../../../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../../../graph/schema/nodes.js'; import { isToolBlockedForRuntimeState, TOOL_POLICY_DEFINITIONS, @@ -21,6 +23,26 @@ const REGISTERED_POC_TOOLS = [ 'mutate_graph', ] as const; +function gap(refersTo: NodeKind): ElicitationGap { + return { + id: `${refersTo}:gap`, + specId: 1, + refersTo, + question: `${refersTo} question`, + rationale: `${refersTo} rationale`, + basis: 'implicit', + band: 'grounding', + predicate: { kind: 'presence', minimum: 1, nodeKind: refersTo }, + importance: 1, + coverage: 0, + answered: false, + disposition: 'open', + createdAtLsn: 1, + }; +} + +const uncoveredGaps = ['context', 'thesis', 'goal', 'constraint'].map((kind) => gap(kind as NodeKind)); + function piWithRegisteredTools(toolNames: readonly string[]): ExtensionAPI { return { getAllTools: () => toolNames.map((name) => ({ name })), @@ -76,14 +98,9 @@ describe('minimal authority matrix', () => { expect(isToolBlockedForRuntimeState(state, toolName)).toBe(true); } - expect(activeToolNamesForBrunchAgentState(piWithRegisteredTools(REGISTERED_POC_TOOLS), state)).toEqual([ - 'read', - 'grep', - 'find', - 'ls', - 'present_question', - 'request_answer', - ]); + expect( + activeToolNamesForBrunchAgentState(piWithRegisteredTools(REGISTERED_POC_TOOLS), state, uncoveredGaps), + ).toEqual(['read', 'grep', 'find', 'ls', 'present_question', 'request_answer']); }); it('represents needs_human as structured data instead of a TUI-only dialog', () => { diff --git a/src/.pi/extensions/runtime/index.ts b/src/.pi/extensions/runtime/index.ts index a85d4e4d..5a868027 100644 --- a/src/.pi/extensions/runtime/index.ts +++ b/src/.pi/extensions/runtime/index.ts @@ -17,11 +17,13 @@ import { } from '@earendil-works/pi-coding-agent'; import { Text } from '@earendil-works/pi-tui'; +import type { ElicitationGap } from '../../../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../../../graph/schema/nodes.js'; import { isToolBlockedForRuntimeState, toolPolicyForRuntimeState, } from '../../../projections/session/runtime-policy.js'; -import { activeToolNamesForPosture, type ReadinessGrade } from '../../agents/state.js'; +import { activeToolNamesForPosture } from '../../agents/state.js'; export { DEFAULT_BRUNCH_AGENT_STATE, @@ -80,13 +82,13 @@ function supportsBrunchAgentStateEntries( export function activeToolNamesForBrunchAgentState( pi: ExtensionAPI, state: ResolvedBrunchAgentState, - readinessGrade: ReadinessGrade = 'grounding_onboarding', + gaps: readonly ElicitationGap[], devAllowedToolNames?: readonly string[], ): string[] { return activeToolNamesForPosture({ registeredToolNames: pi.getAllTools().map((tool) => tool.name), state, - readinessGrade, + gaps, devAllowedToolNames, }); } @@ -97,10 +99,32 @@ function applyBrunchToolPolicy( devAllowedToolNames?: readonly string[], ): void { pi.setActiveTools( - activeToolNamesForBrunchAgentState(pi, state, 'grounding_onboarding', devAllowedToolNames), + activeToolNamesForBrunchAgentState(pi, state, conservativeUncoveredGaps(), devAllowedToolNames), ); } +function conservativeUncoveredGaps(): readonly ElicitationGap[] { + return (['context', 'thesis', 'goal', 'constraint'] as const).map((kind) => gap(kind)); +} + +function gap(refersTo: NodeKind): ElicitationGap { + return { + id: `${refersTo}:runtime-policy-fallback`, + specId: 0, + refersTo, + question: `${refersTo} question`, + rationale: 'Conservative fallback before selected-spec gaps are available.', + basis: 'implicit', + band: 'grounding', + predicate: { kind: 'presence', minimum: 1, nodeKind: refersTo }, + importance: 1, + coverage: 0, + answered: false, + disposition: 'open', + createdAtLsn: 0, + }; +} + interface TextLikeContent { type: string; text?: string; diff --git a/src/.pi/extensions/system-prompts/index.ts b/src/.pi/extensions/system-prompts/index.ts index ea855b27..82b85094 100644 --- a/src/.pi/extensions/system-prompts/index.ts +++ b/src/.pi/extensions/system-prompts/index.ts @@ -1,5 +1,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'; +import type { ElicitationGap } from '../../../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../../../graph/schema/nodes.js'; import { composeAgentPrompt, renderCwdContext, @@ -57,19 +59,15 @@ export function registerBrunchPrompting( const resolvedPromptContext = await resolvePromptContext(promptContext); const state = projectState(ctx as BeforeAgentStartContextLike | undefined); + const gaps = gapsForPrompt(resolvedPromptContext); const activeTools = typeof (pi as Partial).getAllTools === 'function' - ? activeToolNamesForBrunchAgentState( - pi, - state, - resolvedPromptContext.spec.readinessGrade, - options.devAllowedToolNames, - ) + ? activeToolNamesForBrunchAgentState(pi, state, gaps, options.devAllowedToolNames) : []; if (typeof (pi as Partial).setActiveTools === 'function') { pi.setActiveTools(activeTools); } - const context = contextForPrompt(resolvedPromptContext, state); + const context = contextForPrompt(resolvedPromptContext, state, gaps); const { prompt } = composeAgentPrompt({ agentId: state.agentRole, sessionState: state, @@ -77,6 +75,7 @@ export function registerBrunchPrompting( workspace: resolvedPromptContext.workspace, context, activeTools, + gaps, }); if (prompt.trim().length === 0) return undefined; @@ -88,15 +87,41 @@ export function registerBrunchPrompting( }); } +function gapsForPrompt(context: BrunchPromptContext): readonly ElicitationGap[] { + return ( + context.graphReads?.getElicitationGaps?.(context.spec.id) ?? conservativeUncoveredGaps(context.spec.id) + ); +} + +function conservativeUncoveredGaps(specId: number): readonly ElicitationGap[] { + return (['context', 'thesis', 'goal', 'constraint'] as const).map((kind) => ({ + id: `${kind}:prompt-fallback`, + specId, + refersTo: kind as NodeKind, + question: `${kind} question`, + rationale: 'Conservative fallback when graph gap reads are not wired.', + basis: 'implicit', + band: 'grounding', + predicate: { kind: 'presence', minimum: 1, nodeKind: kind as NodeKind }, + importance: 1, + coverage: 0, + answered: false, + disposition: 'open', + createdAtLsn: 0, + })); +} + function contextForPrompt( context: BrunchPromptContext, state: ReturnType, + gaps: readonly ElicitationGap[], ): AgentPromptContextBundle { const renderedContexts = [ renderCwdContext({ spec: context.spec, workspace: context.workspace, ...(context.session ? { session: context.session } : {}), + gaps, }), ]; if (context.graphReads) { diff --git a/src/app/brunch-tui.test.ts b/src/app/brunch-tui.test.ts index 4bb0f2f4..060a5ac3 100644 --- a/src/app/brunch-tui.test.ts +++ b/src/app/brunch-tui.test.ts @@ -824,7 +824,7 @@ describe('Brunch TUI boot', () => { { coordinator: noOpWorkspaceCoordinator(cwd), promptContext: { - spec: { id: 1, name: 'Spec One', readinessGrade: 'grounding_onboarding' }, + spec: { id: 1, name: 'Spec One' }, workspace: { cwd }, }, }, @@ -1078,8 +1078,6 @@ describe('Brunch TUI boot', () => { chrome: { cwd: '/tmp/project', spec: null, - phase: 'select_spec', - chatMode: 'select-spec', }, }), }); @@ -1108,8 +1106,6 @@ describe('Brunch TUI boot', () => { chrome: { cwd: '/tmp/project', spec: null, - phase: 'select_spec', - chatMode: 'select-spec', }, }), }); @@ -1738,8 +1734,6 @@ function readyWorkspace(cwd: string, sessionId: string): WorkspaceSessionReadySt chrome: { cwd, spec, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', }, }; } diff --git a/src/app/brunch-tui.ts b/src/app/brunch-tui.ts index 5cfea4e8..f6d4430f 100644 --- a/src/app/brunch-tui.ts +++ b/src/app/brunch-tui.ts @@ -354,7 +354,6 @@ export function createBrunchAgentSessionRuntimeFactory( spec: { id: selectedSpec.id, name: selectedSpec.name, - readinessGrade: selectedSpec.readinessGrade, }, workspace: { cwd }, session: { diff --git a/src/app/brunch.test.ts b/src/app/brunch.test.ts index fc7a600d..c1240b84 100644 --- a/src/app/brunch.test.ts +++ b/src/app/brunch.test.ts @@ -30,8 +30,6 @@ function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { chrome: { cwd: '/tmp/brunch-project', spec: { id: 1, title: 'Alpha spec' }, - phase: 'elicitation' as const, - chatMode: 'responding-to-elicitation' as const, }, } : { @@ -39,8 +37,6 @@ function coordinator(sessionFile?: string): WorkspaceSessionCoordinator { chrome: { cwd: '/tmp/brunch-project', spec: null, - phase: 'select_spec' as const, - chatMode: 'select-spec' as const, }, }), cwd: '/tmp/brunch-project', @@ -313,16 +309,13 @@ describe('Brunch CLI dispatch', () => { expect(printOutput).toContain('status: ready'); expect(printOutput).toContain(`cwd: ${rpcState.cwd}`); expect(printOutput).toContain('spec: Parity spec'); - expect(printOutput).toContain(`phase: ${rpcState.chrome.phase}`); - expect(printOutput).toContain(`chatMode: ${rpcState.chrome.chatMode}`); + expect(printOutput).not.toContain('phase:'); + expect(printOutput).not.toContain('chatMode:'); expect(rpcState).toMatchObject({ status: 'ready', cwd, spec: { title: 'Parity spec' }, - chrome: { - phase: 'elicitation', - chatMode: 'responding-to-elicitation', - }, + chrome: {}, }); }); }); diff --git a/src/db/README.md b/src/db/README.md index f0611f48..3ffb12e4 100644 --- a/src/db/README.md +++ b/src/db/README.md @@ -1,6 +1,6 @@ # db/ — Persistence substrate -SPEC decisions: D16-L, D41-L, D52-L, D54-L, D62-L, D75-L +SPEC decisions: D16-L, D41-L, D45-L, D52-L, D54-L, D62-L, D75-L ## Owns @@ -97,7 +97,7 @@ their boundary. ## Current schema posture -The current graph and graph-adjacent tables are spec-scoped: `specs`, `nodes`, +The current graph and graph-adjacent tables are spec-scoped: `specs` (identity only: `id`, `name`, `slug`), `nodes`, `edges`, `node_kind_counters`, `graph_clock`, `change_log`, `reconciliation_need`, and `elicitation_gaps`. `graph_clock` is keyed by `spec_id`; `change_log` carries `spec_id` and is keyed by `(spec_id, lsn)`, so diff --git a/src/db/connection.test.ts b/src/db/connection.test.ts index 997f29f2..77ba5aa9 100644 --- a/src/db/connection.test.ts +++ b/src/db/connection.test.ts @@ -4,10 +4,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; +import { asc } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { createDb } from './connection.js'; -import { changeLog, edges, graphClock, nodeKindCounters, nodes, specs } from './schema.js'; +import { changeLog, edges, elicitationGaps, graphClock, nodeKindCounters, nodes, specs } from './schema.js'; describe('createDb', () => { it('creates a missing database file and can reopen it idempotently', async () => { @@ -16,9 +17,7 @@ describe('createDb', () => { try { const db = createDb(dbPath); - db.insert(specs) - .values({ name: 'Spec A', slug: 'spec-a', readiness_grade: 'grounding_onboarding' }) - .run(); + db.insert(specs).values({ name: 'Spec A', slug: 'spec-a' }).run(); const specId = db.select({ id: specs.id }).from(specs).get()!.id; db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); @@ -99,6 +98,52 @@ describe('createDb', () => { await rm(dir, { recursive: true, force: true }); } }); + + it('migrates a populated pre-node-kind elicitation gap table', async () => { + const dir = await mkdtemp(join(tmpdir(), 'brunch-db-legacy-gaps-')); + const dbPath = join(dir, 'legacy.db'); + + try { + await createLegacy0003ElicitationGapDatabase(dbPath); + + const db = createDb(dbPath); + + expect( + db + .select({ + refersTo: elicitationGaps.refers_to, + question: elicitationGaps.question, + rationale: elicitationGaps.rationale, + }) + .from(elicitationGaps) + .orderBy(asc(elicitationGaps.id)) + .all(), + ).toEqual([ + { + refersTo: 'context', + question: 'What kind of thing is this, and what domain or environment does it live in?', + rationale: 'Anchors the domain.', + }, + { + refersTo: 'thesis', + question: 'Who is this for?', + rationale: 'Identifies the main actor.', + }, + { + refersTo: 'criterion', + question: 'How will we recognize success or good enough?', + rationale: 'Sketches what success looks like.', + }, + { + refersTo: 'goal', + question: 'custom_goal_gap', + rationale: 'Custom legacy row should still migrate.', + }, + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); async function createLegacy0000Database(dbPath: string): Promise { @@ -153,8 +198,8 @@ async function createLegacy0000SpecOnlyHistoryDatabase(dbPath: string): Promise< INSERT INTO change_log (lsn, operation, payload) VALUES - (1, 'create_spec', '{"specId":1,"name":"Spec-only history","slug":"spec-only-history","readinessGrade":"grounding_onboarding"}'), - (4, 'update_spec_readiness_grade', '{"specId":1,"readinessGrade":"elicitation_ready"}'); + (1, 'create_spec', '{"specId":1,"name":"Spec-only history","slug":"spec-only-history"}'), + (4, 'legacy_spec_history', '{"specId":1}'); CREATE TABLE "__drizzle_migrations" ( id SERIAL PRIMARY KEY, @@ -192,3 +237,80 @@ async function createLegacy0000EmptySpecDatabase(dbPath: string): Promise sqlite.close(); } } + +async function createLegacy0003ElicitationGapDatabase(dbPath: string): Promise { + const migrations = await Promise.all([ + readMigration('0000_deep_maria_hill.sql'), + readMigration('0001_aspiring_orphan.sql'), + readMigration('0002_spec_scoped_graph_clock.sql'), + readMigration('0003_outstanding_black_bird.sql'), + ]); + const sqlite = new Database(dbPath); + + try { + for (const migration of migrations) { + sqlite.exec(migration.sql.toString('utf8')); + } + + sqlite.exec(` + INSERT INTO specs (id, name, slug, readiness_grade) + VALUES (1, 'Legacy gap spec', 'legacy-gap-spec', 'grounding_onboarding'); + + INSERT INTO graph_clock (spec_id, lsn) + VALUES (1, 4); + + INSERT INTO elicitation_gaps ( + id, + spec_id, + name, + rationale, + disposition, + basis, + readiness_band, + predicate_kind, + predicate, + importance, + plane_affinity, + lens_affinity, + created_at_lsn + ) + VALUES + (1, 1, 'domain', 'Anchors the domain.', 'open', 'implicit', 'grounding', 'presence', '{"kind":"presence","plane":"intent","nodeKind":"context","minimum":1}', 3, 'intent', 'intent', 1), + (2, 1, 'protagonist', 'Identifies the main actor.', 'open', 'implicit', 'grounding', 'presence', '{"kind":"presence","plane":"intent","nodeKind":"context","minimum":1}', 3, 'intent', 'intent', 2), + (3, 1, 'success_sketch', 'Sketches what success looks like.', 'open', 'implicit', 'grounding', 'presence', '{"kind":"presence","plane":"intent","nodeKind":"criterion","minimum":1}', 1, 'intent', 'intent', 3), + (4, 1, 'custom_goal_gap', 'Custom legacy row should still migrate.', 'open', 'explicit', 'grounding', 'presence', '{"kind":"presence","plane":"intent","nodeKind":"goal","minimum":1}', 1, 'intent', 'intent', 4); + + CREATE TABLE "__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ); + `); + + for (const migration of migrations) { + sqlite + .prepare('INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)') + .run(createHash('sha256').update(migration.sql).digest('hex'), migration.createdAt); + } + } finally { + sqlite.close(); + } +} + +async function readMigration( + name: keyof typeof MIGRATION_CREATED_AT, +): Promise<{ readonly sql: Buffer; readonly createdAt: number }> { + const sql = await readFile(new URL(`../../drizzle/${name}`, import.meta.url)); + + return { + sql, + createdAt: MIGRATION_CREATED_AT[name], + }; +} + +const MIGRATION_CREATED_AT = { + '0000_deep_maria_hill.sql': 1780478757603, + '0001_aspiring_orphan.sql': 1780577981107, + '0002_spec_scoped_graph_clock.sql': 1780668000000, + '0003_outstanding_black_bird.sql': 1780904720280, +} as const; diff --git a/src/db/schema.ts b/src/db/schema.ts index 3412cdd8..463e61e1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -27,7 +27,6 @@ import { NODE_BASES, NODE_PLANES, READINESS_BANDS, - READINESS_GRADES, } from '../graph/schema/kinds.js'; // --------------------------------------------------------------------------- @@ -38,7 +37,6 @@ export const specs = sqliteTable('specs', { id: integer().primaryKey({ autoIncrement: true }), name: text().notNull(), slug: text().notNull(), - readiness_grade: text({ enum: READINESS_GRADES }).notNull().default('grounding_onboarding'), }); export const nodes = sqliteTable( diff --git a/src/graph/README.md b/src/graph/README.md index 1037295f..d7da81b4 100644 --- a/src/graph/README.md +++ b/src/graph/README.md @@ -1,7 +1,7 @@ # graph/ — Graph domain layer Canonical reference: `docs/design/GRAPH_MODEL.md` -SPEC decisions: D4-L, D20-L, D27-L, D51-L, D52-L, D53-L, D54-L, D60-L, D62-L, D63-L, D75-L +SPEC decisions: D4-L, D20-L, D27-L, D45-L, D51-L, D52-L, D53-L, D54-L, D60-L, D62-L, D63-L, D75-L ## Owns @@ -72,7 +72,7 @@ D60-L read-shape ownership is explicit: every durable graph read shape has one c | `gaps` | `getGraphGaps` | required | n/a | n/a | Agent/RPC-only diagnostic shape; not a web observer projection. | | `related` | `getRelatedNodes` | required | n/a | n/a | Agent/RPC-only traversal helper; not a web observer projection. | | `reconciliation_needs` | `getOpenReconciliationNeeds` | deferred | deferred | deferred | Agent-internal register read; no transport consumer yet. | -| `elicitation_gaps` | `getElicitationGaps` | deferred | deferred | deferred | Agent-internal prospective-register read; per-turn driver follow-on owns exposure. | +| `elicitation_gaps` | `getElicitationGaps` | deferred | deferred | deferred | Exposed through the selected-spec graph-read seam for prompt/tool readiness; not a `read_graph`/RPC/web projection until the per-turn driver promotes it. | `observed-shapes-coverage.test.ts` guards the required subsets against accidental drift: the tool mode union must stay at the six required agent shapes, while RPC and web stay at `overview` + `neighborhood` until a scoped feature deliberately promotes another row. @@ -121,9 +121,8 @@ graph/ command-executor.ts CommandExecutor command input/result types - createSpec + createSpec (spec identity only; no stored readiness grade) create/set elicitation-gap disposition - updateReadinessGrade createNode per-kind node ordinal allocation mutateGraph / dryRunMutateGraph @@ -162,6 +161,7 @@ graph/ workspace-store.ts openWorkspaceGraphRuntime(cwd) + bound queryGraph/getNodes/getElicitationGaps readers openWorkspaceCommandExecutor(cwd) schema/ diff --git a/src/graph/architecture.test.ts b/src/graph/architecture.test.ts index 6120abba..559ac735 100644 --- a/src/graph/architecture.test.ts +++ b/src/graph/architecture.test.ts @@ -49,7 +49,7 @@ describe('I26-L architectural boundary', () => { it('db/schema.ts does not own domain enum const arrays', () => { const result = execSync( - `rg "export const (INTENT_KINDS|ORACLE_KINDS|DESIGN_KINDS|PLAN_KINDS|NODE_PLANES|NODE_BASES|EDGE_CATEGORIES|EDGE_STANCES|READINESS_GRADES|READINESS_BANDS|LENS_AFFINITIES|GAP_DISPOSITIONS|GAP_PREDICATE_KINDS)" src/db/schema.ts || true`, + `rg "export const (INTENT_KINDS|ORACLE_KINDS|DESIGN_KINDS|PLAN_KINDS|NODE_PLANES|NODE_BASES|EDGE_CATEGORIES|EDGE_STANCES|READINESS_BANDS|LENS_AFFINITIES|GAP_DISPOSITIONS|GAP_PREDICATE_KINDS)" src/db/schema.ts || true`, { cwd: process.cwd(), encoding: 'utf-8' }, ); diff --git a/src/graph/command-executor.test.ts b/src/graph/command-executor.test.ts index 952774aa..a1b4670b 100644 --- a/src/graph/command-executor.test.ts +++ b/src/graph/command-executor.test.ts @@ -39,9 +39,7 @@ describe('CommandExecutor', () => { beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); - db.insert(specs) - .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) - .run(); + db.insert(specs).values({ name: 'Test Spec', slug: 'test' }).run(); specId = db.select({ id: specs.id }).from(specs).get()!.id; db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); }); @@ -377,7 +375,6 @@ describe('CommandExecutor', () => { const result = executor.createSpec({ name: 'Brunch POC', slug: 'brunch-poc', - readinessGrade: 'grounding_onboarding', }); expect(result.status).toBe('success'); @@ -389,7 +386,6 @@ describe('CommandExecutor', () => { expect(row.id).toBe(result.specId); expect(row.name).toBe('Brunch POC'); expect(row.slug).toBe('brunch-poc'); - expect(row.readiness_grade).toBe('grounding_onboarding'); }); it('creates exactly one graph clock row for a new spec at LSN 1', () => { @@ -500,25 +496,9 @@ describe('CommandExecutor', () => { id: created.specId, name: 'Spec A', slug: 'spec-a', - readinessGrade: 'grounding_onboarding', }); }); - it('updates readiness grade through the command boundary', () => { - const created = executor.createSpec({ name: 'Spec A', slug: 'spec-a' }); - if (created.status !== 'success') throw new Error('unreachable'); - - const result = executor.updateReadinessGrade({ - specId: created.specId, - readinessGrade: 'elicitation_ready', - }); - - expect(result.status).toBe('success'); - if (result.status !== 'success') throw new Error('unreachable'); - expect(result.lsn).toBe(2); - expect(executor.getSpec(created.specId)?.readinessGrade).toBe('elicitation_ready'); - }); - it('fails loud when an existing spec is missing its graph clock row', () => { const created = executor.createSpec({ name: 'Corrupt Spec', slug: 'corrupt-spec' }); if (created.status !== 'success') throw new Error('unreachable'); @@ -533,20 +513,6 @@ describe('CommandExecutor', () => { }), ).toThrow(/graph_clock invariant failed/); }); - - it('rejects an invalid readiness grade without writing', () => { - const created = executor.createSpec({ name: 'Spec A', slug: 'spec-a' }); - if (created.status !== 'success') throw new Error('unreachable'); - - const result = executor.updateReadinessGrade({ - specId: created.specId, - readinessGrade: 'pinning' as never, - }); - - expect(result.status).toBe('structural_illegal'); - expect(executor.getSpec(created.specId)?.readinessGrade).toBe('grounding_onboarding'); - expect(db.select().from(changeLog).all()).toHaveLength(1); - }); }); // --- createReconciliationNeed --- diff --git a/src/graph/command-executor.ts b/src/graph/command-executor.ts index b8c02e5e..0dcccf8f 100644 --- a/src/graph/command-executor.ts +++ b/src/graph/command-executor.ts @@ -45,11 +45,9 @@ import { ORACLE_KINDS, PLAN_KINDS, READINESS_BANDS, - READINESS_GRADES, } from './schema/kinds.js'; import { type NodeBasis, type NodeKind, type NodePlane, type ReadinessBand } from './schema/nodes.js'; -export type ReadinessGrade = (typeof READINESS_GRADES)[number]; export type { Diagnostic, EdgePatch, @@ -124,18 +122,11 @@ interface ElicitationGapDispositionSuccess { readonly lsn: number; } -/** Successful spec readiness-grade update. */ -interface UpdateReadinessGradeSuccess { - readonly status: 'success'; - readonly lsn: number; -} - /** Spec row returned by CommandExecutor reads. */ export interface SpecRecord { readonly id: number; readonly name: string; readonly slug: string; - readonly readinessGrade: ReadinessGrade; } /** Union of all possible command results. */ @@ -148,7 +139,6 @@ export type CommandResult = | CreateSpecSuccess | ElicitationGapSuccess | ElicitationGapDispositionSuccess - | UpdateReadinessGradeSuccess | StructuralIllegal | NeedsHuman | PolicyBlocked @@ -172,9 +162,6 @@ export type CreateElicitationGapResult = ElicitationGapSuccess | StructuralIlleg /** Result of a setElicitationGapDisposition command. */ export type SetElicitationGapDispositionResult = ElicitationGapDispositionSuccess | StructuralIllegal; -/** Result of an updateReadinessGrade command. */ -export type UpdateReadinessGradeResult = UpdateReadinessGradeSuccess | StructuralIllegal; - /** Successful accepted review-set graph batch execution. */ interface AcceptReviewSetSuccess extends MutateGraphSuccess {} @@ -194,13 +181,6 @@ type ExistingNodeRow = typeof schema.nodes.$inferSelect; export interface CreateSpecInput { readonly name: string; readonly slug: string; - readonly readinessGrade?: ReadinessGrade | undefined; -} - -/** Input for updating a spec readiness grade. */ -export interface UpdateReadinessGradeInput { - readonly specId: number; - readonly readinessGrade: ReadinessGrade; } /** Input for accepting an exact user-reviewed graph batch. */ @@ -294,7 +274,6 @@ const VALID_KINDS_BY_PLANE: Record = { }; const KINDS_REQUIRING_DETAIL = new Set(['decision', 'term']); -const VALID_READINESS_GRADES = READINESS_GRADES as unknown as string[]; const VALID_NODE_BASES = NODE_BASES as unknown as string[]; const VALID_READINESS_BANDS = READINESS_BANDS as unknown as string[]; const VALID_NODE_KINDS = [ @@ -386,10 +365,6 @@ const SEEDED_ELICITATION_GAPS: readonly { }, ] as const; -function isReadinessGrade(value: string): value is ReadinessGrade { - return VALID_READINESS_GRADES.includes(value); -} - function isNodeBasis(value: string): value is NodeBasis { return VALID_NODE_BASES.includes(value); } @@ -703,7 +678,6 @@ function specRecordFromRow(row: typeof schema.specs.$inferSelect): SpecRecord { id: row.id, name: row.name, slug: row.slug, - readinessGrade: row.readiness_grade, }; } @@ -797,24 +771,13 @@ export class CommandExecutor { const diagnostics: Diagnostic[] = []; const name = input.name.trim(); const slug = input.slug.trim(); - const readinessGrade = input.readinessGrade ?? 'grounding_onboarding'; if (!name) diagnostics.push({ field: 'name', message: 'name must be non-empty' }); if (!slug) diagnostics.push({ field: 'slug', message: 'slug must be non-empty' }); - if (!isReadinessGrade(readinessGrade)) { - diagnostics.push({ - field: 'readinessGrade', - message: `"${String(readinessGrade)}" is not a valid readiness grade`, - }); - } if (diagnostics.length > 0) return { status: 'structural_illegal', diagnostics }; return this.db.transaction((tx) => { - const row = tx - .insert(schema.specs) - .values({ name, slug, readiness_grade: readinessGrade }) - .returning() - .get(); + const row = tx.insert(schema.specs).values({ name, slug }).returning().get(); const lsn = this.createInitialSpecClock(tx, row!.id); @@ -825,7 +788,7 @@ export class CommandExecutor { spec_id: row!.id, lsn, operation: 'create_spec', - payload: JSON.stringify({ specId: row!.id, name, slug, readinessGrade }), + payload: JSON.stringify({ specId: row!.id, name, slug }), }) .run(); @@ -1032,52 +995,6 @@ export class CommandExecutor { return row ? specRecordFromRow(row) : undefined; } - /** Update a spec's readiness grade through the command boundary. */ - updateReadinessGrade(input: UpdateReadinessGradeInput): UpdateReadinessGradeResult { - if (!isReadinessGrade(input.readinessGrade)) { - return { - status: 'structural_illegal', - diagnostics: [ - { - field: 'readinessGrade', - message: `"${String(input.readinessGrade)}" is not a valid readiness grade`, - }, - ], - }; - } - - return this.db.transaction((tx) => { - const existing = tx - .select({ id: schema.specs.id }) - .from(schema.specs) - .where(eq(schema.specs.id, input.specId)) - .get(); - if (!existing) { - return { - status: 'structural_illegal' as const, - diagnostics: [{ field: 'specId', message: `spec ${input.specId} does not exist` }], - }; - } - - const lsn = this.bumpExistingSpecLsn(tx, input.specId); - tx.update(schema.specs) - .set({ readiness_grade: input.readinessGrade }) - .where(eq(schema.specs.id, input.specId)) - .run(); - - tx.insert(schema.changeLog) - .values({ - spec_id: input.specId, - lsn, - operation: 'update_spec_readiness_grade', - payload: JSON.stringify({ specId: input.specId, readinessGrade: input.readinessGrade }), - }) - .run(); - - return { status: 'success' as const, lsn }; - }); - } - /** * Create a single graph node. * diff --git a/src/graph/command-executor/accept-review-set.test.ts b/src/graph/command-executor/accept-review-set.test.ts index 355ab60f..195ed715 100644 --- a/src/graph/command-executor/accept-review-set.test.ts +++ b/src/graph/command-executor/accept-review-set.test.ts @@ -48,9 +48,7 @@ describe('CommandExecutor.acceptReviewSet', () => { beforeEach(() => { db = createDb(':memory:'); executor = new CommandExecutor(db); - db.insert(specs) - .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) - .run(); + db.insert(specs).values({ name: 'Test Spec', slug: 'test' }).run(); specId = db.select({ id: specs.id }).from(specs).get()!.id; db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); }); diff --git a/src/graph/command-executor/commit-graph-batch.test.ts b/src/graph/command-executor/commit-graph-batch.test.ts index 315a07cc..df26f216 100644 --- a/src/graph/command-executor/commit-graph-batch.test.ts +++ b/src/graph/command-executor/commit-graph-batch.test.ts @@ -38,9 +38,7 @@ describe('CommandExecutor create-only mutateGraph helper', () => { beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); - db.insert(specs) - .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) - .run(); + db.insert(specs).values({ name: 'Test Spec', slug: 'test' }).run(); specId = db.select({ id: specs.id }).from(specs).get()!.id; db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); }); diff --git a/src/graph/export-fixtures.test.ts b/src/graph/export-fixtures.test.ts index a5a307d7..cd7d3165 100644 --- a/src/graph/export-fixtures.test.ts +++ b/src/graph/export-fixtures.test.ts @@ -35,7 +35,6 @@ function makeFixture(): SeedFixture { spec: { slug: 'curation-export', name: 'Curation Export', - readiness_grade: 'elicitation_ready', }, nodes: [ { @@ -92,7 +91,6 @@ describe('exportSeedFixture', () => { const created = executor.createSpec({ slug: 'supersession-capture', name: 'Supersession Capture', - readinessGrade: 'elicitation_ready', }); expect(created.status).toBe('success'); if (created.status !== 'success') return; diff --git a/src/graph/export-fixtures.ts b/src/graph/export-fixtures.ts index 2184389a..e2f645c5 100644 --- a/src/graph/export-fixtures.ts +++ b/src/graph/export-fixtures.ts @@ -68,7 +68,6 @@ export function exportSeedFixture(db: BrunchDb, input: ExportSeedFixtureInput): spec: { slug: spec.slug, name: spec.name, - readiness_grade: spec.readiness_grade, }, nodes, edges, diff --git a/src/graph/index.ts b/src/graph/index.ts index 7171f17f..ab5be2ec 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -19,7 +19,6 @@ export { PLAN_KINDS, NODE_PLANES, NODE_BASES, - READINESS_GRADES, READINESS_BANDS, LENS_AFFINITIES, GAP_DISPOSITIONS, @@ -68,7 +67,6 @@ export type { MutateGraphInput, MutateGraphSuccess, NodePatch, - ReadinessGrade, RoleNamedEdgeDraft, SpecRecord, StructuralIllegal, diff --git a/src/graph/queries.test.ts b/src/graph/queries.test.ts index 20802901..d68ded1f 100644 --- a/src/graph/queries.test.ts +++ b/src/graph/queries.test.ts @@ -34,9 +34,7 @@ describe('getOpenReconciliationNeeds', () => { beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); - db.insert(specs) - .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) - .run(); + db.insert(specs).values({ name: 'Test Spec', slug: 'test' }).run(); specId = db.select({ id: specs.id }).from(specs).get()!.id; db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); }); diff --git a/src/graph/read-api.test.ts b/src/graph/read-api.test.ts index a725b8a7..f22c0360 100644 --- a/src/graph/read-api.test.ts +++ b/src/graph/read-api.test.ts @@ -18,9 +18,7 @@ describe('graph read API', () => { beforeEach(() => { db = createTestDb(); executor = new CommandExecutor(db); - db.insert(specs) - .values({ name: 'Test Spec', slug: 'test', readiness_grade: 'grounding_onboarding' }) - .run(); + db.insert(specs).values({ name: 'Test Spec', slug: 'test' }).run(); specId = db.select({ id: specs.id }).from(specs).get()!.id; db.insert(graphClock).values({ spec_id: specId, lsn: 0 }).run(); }); diff --git a/src/graph/schema/kinds.ts b/src/graph/schema/kinds.ts index 5974eac7..739ff369 100644 --- a/src/graph/schema/kinds.ts +++ b/src/graph/schema/kinds.ts @@ -35,13 +35,6 @@ export const EDGE_CATEGORIES = [ export const EDGE_STANCES = ['for', 'against'] as const; -export const READINESS_GRADES = [ - 'grounding_onboarding', - 'elicitation_ready', - 'commitments_ready', - 'planning_ready', -] as const; - export const READINESS_BANDS = ['grounding', 'elicitation', 'commitment'] as const; export const LENS_AFFINITIES = ['intent', 'design', 'oracle'] as const; diff --git a/src/graph/seed-fixtures.test.ts b/src/graph/seed-fixtures.test.ts index 149fffa1..e8a80ebd 100644 --- a/src/graph/seed-fixtures.test.ts +++ b/src/graph/seed-fixtures.test.ts @@ -48,7 +48,6 @@ describe('seedFixture', () => { expect(specRows).toHaveLength(1); expect(specRows[0]!.id).toBe(result.specId); expect(specRows[0]!.slug).toBe('code-health'); - expect(specRows[0]!.readiness_grade).toBe('commitments_ready'); // Node / edge rows persisted, all scoped to the seeded spec. const nodeRows = db.select().from(nodes).where(eq(nodes.spec_id, result.specId)).all(); @@ -116,18 +115,15 @@ describe('seedFixture', () => { expect(specEdges.some((row) => row.category === 'proof' && row.target_id === thesisId)).toBe(false); }); - it('loads the workspace-spread fixtures into one DB with distinct slugs and readiness grades', () => { + it('loads the workspace-spread fixtures into one DB with distinct slugs', () => { const db: BrunchDb = createDb(':memory:'); const executor = new CommandExecutor(db); const alpha = seedFixture(executor, loadFixture('alpha-grounding', 'workspace-spread')); const beta = seedFixture(executor, loadFixture('beta-commitments', 'workspace-spread')); - const specRows = db.select({ slug: specs.slug, readinessGrade: specs.readiness_grade }).from(specs).all(); + const specRows = db.select({ slug: specs.slug }).from(specs).all(); - expect(specRows).toEqual([ - { slug: 'alpha-grounding', readinessGrade: 'grounding_onboarding' }, - { slug: 'beta-commitments', readinessGrade: 'commitments_ready' }, - ]); + expect(specRows).toEqual([{ slug: 'alpha-grounding' }, { slug: 'beta-commitments' }]); expect(graphClockLsn(db, alpha.specId)).toBe(2); expect(graphClockLsn(db, beta.specId)).toBe(2); }); @@ -157,7 +153,7 @@ describe('seedFixture', () => { const db: BrunchDb = createDb(':memory:'); const executor = new CommandExecutor(db); const fixture: SeedFixture = { - spec: { slug: 'off-basis', name: 'Off Basis', readiness_grade: 'grounding_onboarding' }, + spec: { slug: 'off-basis', name: 'Off Basis' }, nodes: [{ local_id: 1, plane: 'intent', kind: 'goal', title: 'A goal', basis: 'implicit' }], edges: [], }; diff --git a/src/graph/seed-fixtures.ts b/src/graph/seed-fixtures.ts index 23078f2a..c90b9144 100644 --- a/src/graph/seed-fixtures.ts +++ b/src/graph/seed-fixtures.ts @@ -27,7 +27,7 @@ import { readdir, readFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { GraphMutationOp, ReadinessGrade } from './command-executor.js'; +import type { GraphMutationOp } from './command-executor.js'; import { CommandExecutor } from './command-executor.js'; import type { EdgeCategory, EdgeStance } from './schema/edges.js'; import type { NodeBasis, NodePlane } from './schema/nodes.js'; @@ -41,7 +41,6 @@ import { openWorkspaceCommandExecutor } from './workspace-store.js'; export interface SeedFixtureSpec { readonly slug: string; readonly name: string; - readonly readiness_grade: ReadinessGrade; } /** A node row in a consolidated fixture; `local_id` is referenced by edges. */ @@ -111,7 +110,6 @@ export function seedFixture(executor: CommandExecutor, fixture: SeedFixture): Se const specResult = executor.createSpec({ name: fixture.spec.name, slug: fixture.spec.slug, - readinessGrade: fixture.spec.readiness_grade, }); if (specResult.status !== 'success') { throw new Error( diff --git a/src/graph/workspace-store.ts b/src/graph/workspace-store.ts index 13f4ddb4..bbd77e1e 100644 --- a/src/graph/workspace-store.ts +++ b/src/graph/workspace-store.ts @@ -3,7 +3,13 @@ import { join } from 'node:path'; import { createDb } from '../db/connection.js'; import { CommandExecutor } from './command-executor.js'; -import { getNodes, queryGraph, resolveGraphEdgeId, resolveGraphNodeCode } from './queries.js'; +import { + getElicitationGaps, + getNodes, + queryGraph, + resolveGraphEdgeId, + resolveGraphNodeCode, +} from './queries.js'; import type { GetNodesOptions, GraphReadOptions, @@ -29,6 +35,7 @@ interface SpecScopedReaders { ) => readonly NodeNeighborhood[]; readonly resolveNodeCode: (code: string) => number | undefined; readonly resolveEdgeId: (edgeId: number) => number | undefined; + readonly getElicitationGaps: () => ReturnType; } export interface WorkspaceGraphRuntime { @@ -47,6 +54,7 @@ export async function openWorkspaceGraphRuntime(cwd: string): Promise getNodes(db, specId, selectors, options), resolveNodeCode: (code) => resolveGraphNodeCode(db, specId, code), resolveEdgeId: (edgeId) => resolveGraphEdgeId(db, specId, edgeId), + getElicitationGaps: () => getElicitationGaps(db, specId), }; }, }; diff --git a/src/probes/project-graph-review-cycle-proof.ts b/src/probes/project-graph-review-cycle-proof.ts index a57cc9ea..79c36e79 100644 --- a/src/probes/project-graph-review-cycle-proof.ts +++ b/src/probes/project-graph-review-cycle-proof.ts @@ -181,13 +181,6 @@ export async function runProjectGraphReviewCycleProof( const fixture = await readSeedFixture(join(fixtureRoot, 'seeds', seedSet, `${seedSlug}.json`)); const graph = await openWorkspaceGraphRuntime(cwd); const seedResult = seedFixture(graph.commandExecutor, fixture); - const gradeResult = graph.commandExecutor.updateReadinessGrade({ - specId: seedResult.specId, - readinessGrade: 'commitments_ready', - }); - if (gradeResult.status !== 'success') { - throw new Error('failed to advance probe spec to commitments_ready'); - } const baseOverview = graph.forSpec(seedResult.specId).queryGraph(); const coordinator = createWorkspaceSessionCoordinator({ cwd }); await coordinator.openDefaultWorkspace(); diff --git a/src/probes/propose-graph-commit-proof.ts b/src/probes/propose-graph-commit-proof.ts index 754b48cd..9711aca0 100644 --- a/src/probes/propose-graph-commit-proof.ts +++ b/src/probes/propose-graph-commit-proof.ts @@ -143,13 +143,6 @@ export async function runProposeGraphCommitProof( }; appendBrunchAgentRuntimeSwitch(workspace.session.manager, runtimeState, 'extension'); const graph = await openWorkspaceGraphRuntime(cwd); - const gradeResult = graph.commandExecutor.updateReadinessGrade({ - specId: workspace.spec.id, - readinessGrade: 'elicitation_ready', - }); - if (gradeResult.status !== 'success') { - throw new Error('failed to advance probe spec to elicitation_ready'); - } const expectedExistingCode = seedScenarioGraph(graph, workspace.spec.id, scenarioId); const specReads = graph.forSpec(workspace.spec.id); const agentDir = options.agentDir ?? getAgentDir(); diff --git a/src/projections/README.md b/src/projections/README.md index 721b02b6..086de9b7 100644 --- a/src/projections/README.md +++ b/src/projections/README.md @@ -27,9 +27,10 @@ Disposition: `✓` locked · `●` keep + lock (earns place, needs invariant) · | `graph/reconciliation-needs` | — | ○ | `export {}` topology stub. | | `session/transcript-context` | 2 | ● | Real transform: filters session entries + Pi-SDK convert. Invariant: no non-empty transcript entry dropped. Consumes the Pi SDK (external trust boundary), not a PULL surface we own. | | `session/runtime-state` | 13 | ● | Most-consumed projection; flattens runtime state. Direct flattened-shape invariant guards the field set every consumer relies on. | -| `session/affordances` | 1 | ✓ | `affordances.test.ts` — legality + default-on-switch derivation tested directly. | -| `session/capability-readiness` | 0 | ✓ | D74-L/D75-L tracer gate, not a reusable DTO. `capability-readiness.test.ts` locks the explicit capability→node-kind map, proceed / low-epistemic / negotiate outcomes, no-refusal invariant, loud failure when the gap register lacks a required kind, same-kind discrimination through `question`, and live presence-coverage flip. Consumer rewire remains deferred by the next capability-readiness frontier. | -| `session/runtime-policy` | 4 | ○ | Policy/definitions data, not a DTO transform. Legality source already guarded via `affordances.test.ts` + `.pi` state tests. | +| `session/affordances` | 1 | ✓ | `affordances.test.ts` — gap-driven legality + default-on-switch derivation tested directly. Legal options are a menu projection over capability-readiness; omitted options are not capability refusals (I31-L). | +| `session/capability-readiness` | 1 | ✓ | D74-L/D75-L tracer gate, not a reusable DTO. `capability-readiness.test.ts` locks the explicit capability→node-kind map, proceed / low-epistemic / negotiate outcomes, no-refusal invariant, loud failure when the gap register lacks a required kind, same-kind discrimination through `question`, and live presence-coverage flip. `session/affordances` now consumes it for axis-option legality. | +| `session/readiness-estimate` | — | ✓ | D45-L soft per-band coverage rollup over `ElicitationGap[]`; UI-only and gates nothing. `readiness-estimate.test.ts` locks every-band shape, empty-band zero, importance-weighted mean, honest regression, no grade imports, and no legality-path imports. | +| `session/runtime-policy` | 4 | ○ | Policy/definitions data, not a DTO transform. Affordance legality is guarded via `affordances.test.ts`; dormant prompt manifest grade tables are temporarily local to `.pi/agents/state.ts` until the method/manifest follow-on. | | `workspace/workspace-context` | 1 | ✗ | Pure `{ mode, data }` tag wrapper — zero transform, single consumer (`.pi/extensions/context/get-cwd.ts`). Source `session/workspace-context.ts` already exports the shapes + `inspect*` and can feed the consumer directly. Delete / inline. | | `workspace/workspace-state` | 4 | ● | Real flatten of the `WorkspaceSessionState` union to a narrow DTO. Shape invariant across status variants (`ready` / `needs_human` / base). | | `exchanges/request-choice` | 6 | ✓ | `request-choice.test.ts` (direct). | diff --git a/src/projections/session/affordances.test.ts b/src/projections/session/affordances.test.ts index 0947d11f..1d5b8241 100644 --- a/src/projections/session/affordances.test.ts +++ b/src/projections/session/affordances.test.ts @@ -1,16 +1,46 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + import { describe, expect, it } from 'vitest'; +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../../graph/schema/nodes.js'; import { DEFAULT_BRUNCH_AGENT_STATE } from '../../session/runtime-state.js'; import { affordances } from './affordances.js'; +import { axisOptionsForRuntimeState } from './runtime-policy.js'; import { resolveBrunchAgentState } from './runtime-state.js'; function resolved(overrides: Partial = {}) { return resolveBrunchAgentState({ ...DEFAULT_BRUNCH_AGENT_STATE, ...overrides }); } +function gap(refersTo: NodeKind, coverage: number): ElicitationGap { + return { + id: `${refersTo}:gap`, + specId: 1, + refersTo, + question: `${refersTo} question`, + rationale: `${refersTo} rationale`, + basis: 'implicit', + band: 'grounding', + predicate: { kind: 'presence', minimum: 1, nodeKind: refersTo }, + importance: 1, + coverage, + answered: coverage >= 1, + disposition: coverage >= 1 ? 'answered' : 'open', + createdAtLsn: 1, + }; +} + +function groundingGaps(coverage: Partial> = {}): ElicitationGap[] { + return ['context', 'thesis', 'goal', 'constraint'].map((kind) => + gap(kind as NodeKind, coverage[kind as NodeKind] ?? 1), + ); +} + describe('runtime affordances derivation', () => { it('reports legal options and default-on-switch values for every posture axis', () => { - expect(affordances(resolved(), 'commitments_ready')).toEqual({ + expect(affordances(resolved(), groundingGaps())).toEqual({ goal: { selection: 'grounding-advance', legalOptions: ['grounding-advance', 'elicit-expand', 'commit-converge', 'capture-posture'], @@ -29,30 +59,76 @@ describe('runtime affordances derivation', () => { }); }); - it('excludes freestyle from AUTO strategy affordances but reports a pinned legal strategy', () => { - expect(affordances(resolved(), 'planning_ready').strategy.legalOptions).not.toContain('freestyle'); + it('keeps floor options legal when relevant gaps have zero coverage', () => { + const derived = affordances(resolved(), groundingGaps({ context: 0, thesis: 0, goal: 0, constraint: 0 })); + + expect(derived.goal.legalOptions).toEqual(['grounding-advance', 'capture-posture']); + expect(derived.strategy.legalOptions).toEqual(['step-wise-decision-tree', 'step-wise-disambiguate']); + expect(derived.lens.legalOptions).toEqual(['intent']); - expect(affordances(resolved({ agentStrategy: 'freestyle' }), 'grounding_onboarding').strategy).toEqual({ + expect( + affordances(resolved({ agentStrategy: 'freestyle' }), groundingGaps({ context: 0 })).strategy, + ).toEqual({ selection: 'freestyle', legalOptions: ['freestyle', 'step-wise-decision-tree', 'step-wise-disambiguate'], defaultOnSwitch: 'auto', }); }); - it('uses readiness grade as a load-bearing legality input', () => { - const grounding = affordances(resolved(), 'grounding_onboarding'); - const elicitation = affordances(resolved(), 'elicitation_ready'); - const commitments = affordances(resolved(), 'commitments_ready'); + it('excludes gated options until capability-relevant gaps are covered', () => { + const uncovered = affordances( + resolved(), + groundingGaps({ context: 0, thesis: 0, goal: 0, constraint: 0 }), + ); + + expect(uncovered.goal.legalOptions).not.toContain('elicit-expand'); + expect(uncovered.goal.legalOptions).not.toContain('commit-converge'); + expect(uncovered.strategy.legalOptions).not.toContain('propose-graph'); + expect(uncovered.strategy.legalOptions).not.toContain('project-graph'); + expect(uncovered.lens.legalOptions).not.toContain('design'); + expect(uncovered.lens.legalOptions).not.toContain('oracle'); + }); + + it('moves gated options from absent to present when gap coverage rises', () => { + const uncovered = affordances(resolved(), groundingGaps({ context: 0 })).strategy.legalOptions; + const covered = affordances(resolved(), groundingGaps({ context: 0.5 })).strategy.legalOptions; - expect(grounding.goal.legalOptions).toEqual(['grounding-advance', 'capture-posture']); - expect(grounding.strategy.legalOptions).toEqual(['step-wise-decision-tree', 'step-wise-disambiguate']); - expect(grounding.lens.legalOptions).toEqual(['intent']); + expect(uncovered).not.toContain('propose-graph'); + expect(covered).toContain('propose-graph'); + }); + + it('excludes freestyle from AUTO strategy affordances but reports a pinned legal strategy', () => { + expect(affordances(resolved(), groundingGaps()).strategy.legalOptions).not.toContain('freestyle'); + + expect(affordances(resolved({ agentStrategy: 'freestyle' }), groundingGaps()).strategy).toEqual({ + selection: 'freestyle', + legalOptions: [ + 'freestyle', + 'step-wise-decision-tree', + 'step-wise-disambiguate', + 'propose-graph', + 'project-graph', + ], + defaultOnSwitch: 'auto', + }); + }); + + it('fails loud when a gated option requires a kind absent from the register (config bug, not uncovered)', () => { + // A capability-relevant kind missing from the gap register is a seeding/config bug; + // the affordance projection must surface it, not silently omit the option. + const missingThesis = groundingGaps().filter((g) => g.refersTo !== 'thesis'); + expect(() => axisOptionsForRuntimeState('strategy', resolved(), missingThesis)).toThrow( + /no elicitation gap for thesis/, + ); + }); - expect(elicitation.goal.legalOptions).toContain('elicit-expand'); - expect(elicitation.strategy.legalOptions).toContain('propose-graph'); - expect(elicitation.lens.legalOptions).toEqual(['intent', 'design', 'oracle']); + it('derives per-axis legal options without grade-gate symbols', () => { + expect(axisOptionsForRuntimeState('lens', resolved(), groundingGaps({ thesis: 0 }))).toEqual(['intent']); - expect(commitments.goal.legalOptions).toContain('commit-converge'); - expect(commitments.strategy.legalOptions).toContain('project-graph'); + for (const fileName of ['affordances.ts', 'runtime-policy.ts']) { + const sourcePath = fileURLToPath(new URL(`./${fileName}`, import.meta.url)); + const source = readFileSync(sourcePath, 'utf8'); + expect(source).not.toMatch(/ReadinessGrade|GRADE_RANK|MIN_GRADE/); + } }); }); diff --git a/src/projections/session/affordances.ts b/src/projections/session/affordances.ts index 27f07cdc..27a779f9 100644 --- a/src/projections/session/affordances.ts +++ b/src/projections/session/affordances.ts @@ -1,4 +1,4 @@ -import type { ReadinessGrade } from '../../graph/index.js'; +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; import type { AgentGoalId, AgentGoalSelection, @@ -29,22 +29,22 @@ export interface RuntimeAffordances { export function affordances( state: ResolvedBrunchAgentState, - readinessGrade: ReadinessGrade, + gaps: readonly ElicitationGap[], ): RuntimeAffordances { return { goal: { selection: state.agentGoal, - legalOptions: axisOptionsForRuntimeState('goal', state, readinessGrade), + legalOptions: axisOptionsForRuntimeState('goal', state, gaps), defaultOnSwitch: defaultGoalForRuntimeState(state), }, strategy: { selection: state.agentStrategy, - legalOptions: axisOptionsForRuntimeState('strategy', state, readinessGrade), + legalOptions: axisOptionsForRuntimeState('strategy', state, gaps), defaultOnSwitch: defaultStrategyForRuntimeState(state), }, lens: { selection: state.agentLens, - legalOptions: axisOptionsForRuntimeState('lens', state, readinessGrade), + legalOptions: axisOptionsForRuntimeState('lens', state, gaps), defaultOnSwitch: defaultLensForRuntimeState(state), }, }; diff --git a/src/projections/session/readiness-estimate.test.ts b/src/projections/session/readiness-estimate.test.ts new file mode 100644 index 00000000..018f3ee8 --- /dev/null +++ b/src/projections/session/readiness-estimate.test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; +import { READINESS_BANDS } from '../../graph/schema/kinds.js'; +import type { NodeKind, ReadinessBand } from '../../graph/schema/nodes.js'; +import { readinessEstimate } from './readiness-estimate.js'; + +function gap(overrides: { + readonly id?: string; + readonly band: ReadinessBand; + readonly coverage: number; + readonly importance?: number; + readonly refersTo?: NodeKind; +}): ElicitationGap { + return { + id: overrides.id ?? `${overrides.band}:${overrides.refersTo ?? 'context'}:${overrides.coverage}`, + specId: 1, + refersTo: overrides.refersTo ?? 'context', + question: `${overrides.band} question`, + rationale: `${overrides.band} rationale`, + basis: 'implicit', + band: overrides.band, + predicate: { kind: 'presence', minimum: 1, nodeKind: overrides.refersTo ?? 'context' }, + importance: overrides.importance ?? 1, + coverage: overrides.coverage, + answered: overrides.coverage >= 1, + disposition: overrides.coverage >= 1 ? 'answered' : 'open', + createdAtLsn: 1, + }; +} + +describe('readiness estimate projection', () => { + it('returns coverage for every readiness band', () => { + const estimate = readinessEstimate([ + gap({ band: 'grounding', coverage: 1 }), + gap({ band: 'elicitation', coverage: 0.5 }), + gap({ band: 'commitment', coverage: 0.25 }), + ]); + + expect(Object.keys(estimate.coverage)).toEqual([...READINESS_BANDS]); + expect(estimate.coverage).toEqual({ grounding: 1, elicitation: 0.5, commitment: 0.25 }); + }); + + it('reports an empty band as zero coverage', () => { + expect(readinessEstimate([gap({ band: 'grounding', coverage: 0.75 })]).coverage).toEqual({ + grounding: 0.75, + elicitation: 0, + commitment: 0, + }); + }); + + it('uses an importance-weighted mean per band', () => { + const estimate = readinessEstimate([ + gap({ band: 'elicitation', coverage: 1, importance: 3 }), + gap({ band: 'elicitation', coverage: 0, importance: 1 }), + ]); + + expect(estimate.coverage.elicitation).toBe(0.75); + }); + + it('regresses honestly when gap coverage lowers and rises when coverage improves', () => { + const lower = readinessEstimate([ + gap({ id: 'same', band: 'commitment', coverage: 0.25 }), + gap({ id: 'other', band: 'commitment', coverage: 0.75 }), + ]); + const higher = readinessEstimate([ + gap({ id: 'same', band: 'commitment', coverage: 0.75 }), + gap({ id: 'other', band: 'commitment', coverage: 0.75 }), + ]); + + expect(lower.coverage.commitment).toBe(0.5); + expect(higher.coverage.commitment).toBe(0.75); + expect(lower.coverage.commitment).toBeLessThan(higher.coverage.commitment); + }); + + it('does not import grade symbols and is not imported by legality paths', () => { + const estimateSource = readFileSync( + fileURLToPath(new URL('./readiness-estimate.ts', import.meta.url)), + 'utf8', + ); + expect(estimateSource).not.toMatch(/ReadinessGrade|READINESS_GRADES|GRADE_RANK|MIN_GRADE/); + + for (const relativePath of ['./runtime-policy.ts', './affordances.ts', '../../.pi/agents/state.ts']) { + const source = readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); + expect(source).not.toMatch(/readiness-estimate|readinessEstimate/); + } + }); +}); diff --git a/src/projections/session/readiness-estimate.ts b/src/projections/session/readiness-estimate.ts new file mode 100644 index 00000000..94dc94cb --- /dev/null +++ b/src/projections/session/readiness-estimate.ts @@ -0,0 +1,45 @@ +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; +import { READINESS_BANDS } from '../../graph/schema/kinds.js'; +import type { ReadinessBand } from '../../graph/schema/nodes.js'; + +export interface ReadinessEstimate { + readonly coverage: Readonly>; +} + +/** + * Derives the soft D45-L readiness estimate for UI display only. + * + * The estimate reports every D64-L band as an importance-weighted mean of gap + * coverage. Empty bands report 0: no obligations in a band means no established + * coverage yet, not authority to proceed. This projection gates nothing and may + * regress honestly as gap coverage changes. + */ +export function readinessEstimate(gaps: readonly ElicitationGap[]): ReadinessEstimate { + return { + coverage: Object.fromEntries( + READINESS_BANDS.map((band) => [band, estimateBandCoverage(gaps.filter((gap) => gap.band === band))]), + ) as Record, + }; +} + +function estimateBandCoverage(gaps: readonly ElicitationGap[]): number { + if (gaps.length === 0) return 0; + + const totalImportance = gaps.reduce((total, gap) => total + Math.max(0, gap.importance), 0); + if (totalImportance === 0) return average(gaps.map((gap) => clampCoverage(gap.coverage))); + + return ( + gaps.reduce((total, gap) => total + clampCoverage(gap.coverage) * Math.max(0, gap.importance), 0) / + totalImportance + ); +} + +function average(values: readonly number[]): number { + return values.reduce((total, value) => total + value, 0) / values.length; +} + +function clampCoverage(value: number): number { + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} diff --git a/src/projections/session/runtime-policy.ts b/src/projections/session/runtime-policy.ts index dec6ff9f..70ecfdcb 100644 --- a/src/projections/session/runtime-policy.ts +++ b/src/projections/session/runtime-policy.ts @@ -1,4 +1,4 @@ -import type { ReadinessGrade } from '../../graph/index.js'; +import type { ElicitationGap } from '../../graph/schema/elicitation-gaps.js'; import type { AgentGoalId, AgentGoalSelection, @@ -14,6 +14,7 @@ import type { ThinkingLevel, ToolPolicyId, } from '../../session/runtime-state.js'; +import { evaluateCapabilityReadiness, type CapabilityId } from './capability-readiness.js'; export interface ToolPolicyDefinition { id: ToolPolicyId; @@ -87,79 +88,70 @@ export const TOOL_POLICY_DEFINITIONS: Record }, }; -export const GRADE_RANK: Record = { - grounding_onboarding: 0, - elicitation_ready: 1, - commitments_ready: 2, - planning_ready: 3, -}; +export const AUTO_EXCLUDED_STRATEGIES = new Set(['freestyle']); -export const GOAL_MIN_GRADE: Record = { - 'grounding-advance': 'grounding_onboarding', - 'elicit-expand': 'elicitation_ready', - 'commit-converge': 'commitments_ready', - 'capture-posture': 'grounding_onboarding', +const GOAL_CAPABILITY: Partial> = { + 'elicit-expand': 'generative-lens', + 'commit-converge': 'commitment-review', }; -export const STRATEGY_MIN_GRADE: Record = { - freestyle: 'grounding_onboarding', - 'step-wise-decision-tree': 'grounding_onboarding', - 'step-wise-disambiguate': 'grounding_onboarding', - 'propose-graph': 'elicitation_ready', - 'project-graph': 'commitments_ready', +const STRATEGY_CAPABILITY: Partial> = { + 'propose-graph': 'propose-graph', + 'project-graph': 'project-graph', }; -export const AUTO_EXCLUDED_STRATEGIES = new Set(['freestyle']); - -export const LENS_MIN_GRADE: Record = { - intent: 'grounding_onboarding', - design: 'elicitation_ready', - oracle: 'elicitation_ready', +const LENS_CAPABILITY: Partial> = { + design: 'generative-lens', + oracle: 'generative-lens', }; -export type RuntimeAffordanceAxis = 'goal' | 'strategy' | 'lens'; - -export function isGradeLegal( - id: TId, - readinessGrade: ReadinessGrade, - minGrades: Record, +export function isCapabilityLegalForGaps( + capability: CapabilityId | undefined, + gaps: readonly ElicitationGap[], ): boolean { - return GRADE_RANK[readinessGrade] >= GRADE_RANK[minGrades[id]]; + // Floor options carry no capability gate — always legal. + if (!capability) return true; + // A `negotiate` outcome omits the option (readiness, not a refusal — I31-L holds at the + // execution boundary). A missing-register-kind throw is a seeding/config bug and must + // fail loud (gaps-node-kind-reference: config bug ≠ uncovered) — do not swallow it. + return evaluateCapabilityReadiness(capability, gaps).status !== 'negotiate'; } +export type RuntimeAffordanceAxis = 'goal' | 'strategy' | 'lens'; + export function axisOptionsForRuntimeState( axis: 'goal', state: ResolvedBrunchAgentState, - readinessGrade: ReadinessGrade, + gaps: readonly ElicitationGap[], ): readonly AgentGoalId[]; export function axisOptionsForRuntimeState( axis: 'strategy', state: ResolvedBrunchAgentState, - readinessGrade: ReadinessGrade, + gaps: readonly ElicitationGap[], ): readonly AgentStrategyId[]; export function axisOptionsForRuntimeState( axis: 'lens', state: ResolvedBrunchAgentState, - readinessGrade: ReadinessGrade, + gaps: readonly ElicitationGap[], ): readonly AgentLensId[]; export function axisOptionsForRuntimeState( axis: RuntimeAffordanceAxis, state: ResolvedBrunchAgentState, - readinessGrade: ReadinessGrade, + gaps: readonly ElicitationGap[], ): readonly (AgentGoalId | AgentStrategyId | AgentLensId)[] { if (axis === 'goal') { return state.agentRoleDefinition.allowedGoals.filter((id) => - isGradeLegal(id, readinessGrade, GOAL_MIN_GRADE), + isCapabilityLegalForGaps(GOAL_CAPABILITY[id], gaps), ); } if (axis === 'strategy') { const legal = state.agentRoleDefinition.allowedStrategies.filter((id) => - isGradeLegal(id, readinessGrade, STRATEGY_MIN_GRADE), + isCapabilityLegalForGaps(STRATEGY_CAPABILITY[id], gaps), ); return state.agentStrategy === 'auto' ? legal.filter((id) => !AUTO_EXCLUDED_STRATEGIES.has(id)) : legal; } return state.agentRoleDefinition.allowedLenses.filter((id) => - isGradeLegal(id, readinessGrade, LENS_MIN_GRADE), + isCapabilityLegalForGaps(LENS_CAPABILITY[id], gaps), ); } diff --git a/src/projections/workspace/workspace-state.ts b/src/projections/workspace/workspace-state.ts index aa1508fd..46b38567 100644 --- a/src/projections/workspace/workspace-state.ts +++ b/src/projections/workspace/workspace-state.ts @@ -11,10 +11,7 @@ export interface WorkspaceState { id: string; file: string; }; - chrome: { - phase: 'select_spec' | 'elicitation'; - chatMode: 'select-spec' | 'responding-to-elicitation'; - }; + chrome: Record; reason?: string; } @@ -23,10 +20,7 @@ export function projectWorkspaceState(state: WorkspaceSessionState): WorkspaceSt status: state.status, cwd: state.cwd, spec: state.chrome.spec, - chrome: { - phase: state.chrome.phase, - chatMode: state.chrome.chatMode, - }, + chrome: {}, }; if (state.status === 'ready') { diff --git a/src/renderers/workspace/workspace-context.ts b/src/renderers/workspace/workspace-context.ts index d428726f..39aaa4e5 100644 --- a/src/renderers/workspace/workspace-context.ts +++ b/src/renderers/workspace/workspace-context.ts @@ -70,7 +70,7 @@ function renderWorkspaceOverview( lines.push('- session inventory:'); for (const session of overview.sessions) { lines.push( - ` - ${session.file} (${session.id}) → ${session.specTitle} (#${session.specId}), ${session.turnCount} turn(s), readiness_grade=${session.readinessGrade}`, + ` - ${session.file} (${session.id}) → ${session.specTitle} (#${session.specId}), ${session.turnCount} turn(s)`, ); } } diff --git a/src/renderers/workspace/workspace-state.test.ts b/src/renderers/workspace/workspace-state.test.ts index 8c34a67f..eb125e07 100644 --- a/src/renderers/workspace/workspace-state.test.ts +++ b/src/renderers/workspace/workspace-state.test.ts @@ -20,8 +20,6 @@ function readyState(): WorkspaceSessionState { chrome: { cwd, spec: { id: 1, title: 'Alpha spec' }, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', }, }; } @@ -38,15 +36,15 @@ describe('print state', () => { id: 'session-1', file: '/tmp/brunch-project/.brunch/sessions/session-1.jsonl', }, - chrome: { - phase: 'elicitation', - chatMode: 'responding-to-elicitation', - }, + chrome: {}, }); - expect(renderWorkspaceState(state)).toContain('Brunch workspace state'); - expect(renderWorkspaceState(state)).toContain('status: ready'); - expect(renderWorkspaceState(state)).toContain('spec: Alpha spec (1)'); - expect(renderWorkspaceState(state)).toContain('session: session-1'); + const rendered = renderWorkspaceState(state); + expect(rendered).toContain('Brunch workspace state'); + expect(rendered).toContain('status: ready'); + expect(rendered).toContain('spec: Alpha spec (1)'); + expect(rendered).toContain('session: session-1'); + expect(rendered).not.toContain('phase:'); + expect(rendered).not.toContain('chatMode:'); }); it('renders select-spec as state instead of prompting', () => { @@ -56,8 +54,6 @@ describe('print state', () => { chrome: { cwd, spec: null, - phase: 'select_spec', - chatMode: 'select-spec', }, }); diff --git a/src/renderers/workspace/workspace-state.ts b/src/renderers/workspace/workspace-state.ts index b01802c4..9f14f748 100644 --- a/src/renderers/workspace/workspace-state.ts +++ b/src/renderers/workspace/workspace-state.ts @@ -6,8 +6,6 @@ export function renderWorkspaceState(state: WorkspaceState): string { `status: ${state.status}`, `cwd: ${state.cwd}`, `spec: ${state.spec ? `${state.spec.title} (${state.spec.id})` : ''}`, - `phase: ${state.chrome.phase}`, - `chatMode: ${state.chrome.chatMode}`, ]; if (state.session) { diff --git a/src/rpc/handlers.test.ts b/src/rpc/handlers.test.ts index 07a4c410..26b20edf 100644 --- a/src/rpc/handlers.test.ts +++ b/src/rpc/handlers.test.ts @@ -82,8 +82,6 @@ function cancelledState(): WorkspaceActivationState { chrome: { cwd: '/tmp/brunch-project', spec: { id: 1, title: 'Alpha spec' }, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', }, }; } @@ -101,8 +99,6 @@ function readyState(sessionFile: string): WorkspaceSessionReadyState { chrome: { cwd: '/tmp/brunch-project', spec: { id: 1, title: 'Alpha spec' }, - phase: 'elicitation', - chatMode: 'responding-to-elicitation', }, }; } @@ -114,8 +110,6 @@ function selectSpecState(): WorkspaceSessionState { chrome: { cwd: '/tmp/brunch-project', spec: null, - phase: 'select_spec', - chatMode: 'select-spec', }, }; } diff --git a/src/rpc/methods/workspace.ts b/src/rpc/methods/workspace.ts index a7306151..401d7c20 100644 --- a/src/rpc/methods/workspace.ts +++ b/src/rpc/methods/workspace.ts @@ -199,10 +199,7 @@ function workspaceActivationResultFromState(state: WorkspaceActivationState) { status: 'cancelled' as const, cwd: state.cwd, spec: state.chrome.spec, - chrome: { - phase: state.chrome.phase, - chatMode: state.chrome.chatMode, - }, + chrome: {}, }; } diff --git a/src/session/README.md b/src/session/README.md index 25413fcc..6288c3fd 100644 --- a/src/session/README.md +++ b/src/session/README.md @@ -27,9 +27,12 @@ plus the coordination logic for workspace/spec/session lifecycle. `.brunch/workspace.json` management. The `WorkspaceSessionCoordinator` is the only module that creates/opens Pi sessions for Brunch user flows and writes collapsed `brunch.session_binding` entries (`{schemaVersion, - specId}`). Its private `workspace-session-coordinator/` subtree owns - coordinator-shaped boot/probe helpers such as canonical session-file - classification; external callers import only the public root module. + specId}`). Its chrome state is a selection snapshot (`cwd`, optional + project, selected `spec`) and intentionally carries no readiness phase or + chat-mode display fields. Its private `workspace-session-coordinator/` + subtree owns coordinator-shaped boot/probe helpers such as canonical + session-file classification; external callers import only the public root + module. - **Session binding** — session↔spec binding entries in JSONL. @@ -42,10 +45,10 @@ plus the coordination logic for workspace/spec/session lifecycle. ## Runtime affordance coverage ledger Runtime posture affordances are pure derivations over projected runtime state plus -spec readiness grade. `projections/session/affordances.ts` owns legal option sets -and default-on-switch values; `session.runtimeState` currently exposes only the -selected value per axis. Deferred means eligible or known but not currently -transported for that consumer. +capability-readiness over selected-spec gaps. `projections/session/affordances.ts` +owns legal option sets and default-on-switch values; `session.runtimeState` +currently exposes only the selected value per axis. Deferred means eligible or +known but not currently transported for that consumer. | Row | Canonical owner | Agent | RPC | Web | Reason for deferred | | --- | --- | --- | --- | --- | --- | diff --git a/src/session/runtime-affordances-coverage.test.ts b/src/session/runtime-affordances-coverage.test.ts index 0195bc25..c77904cc 100644 --- a/src/session/runtime-affordances-coverage.test.ts +++ b/src/session/runtime-affordances-coverage.test.ts @@ -1,10 +1,30 @@ import { describe, expect, it } from 'vitest'; +import type { ElicitationGap } from '../graph/schema/elicitation-gaps.js'; +import type { NodeKind } from '../graph/schema/nodes.js'; import { affordances } from '../projections/session/affordances.js'; import { resolveBrunchAgentState } from '../projections/session/runtime-state.js'; import { sessionRpcMethods } from '../rpc/methods/session.js'; import { DEFAULT_BRUNCH_AGENT_STATE } from './runtime-state.js'; +function gap(refersTo: NodeKind): ElicitationGap { + return { + id: `${refersTo}:gap`, + specId: 1, + refersTo, + question: `${refersTo} question`, + rationale: `${refersTo} rationale`, + basis: 'implicit', + band: 'grounding', + predicate: { kind: 'presence', minimum: 1, nodeKind: refersTo }, + importance: 1, + coverage: 1, + answered: true, + disposition: 'answered', + createdAtLsn: 1, + }; +} + const runtimeAffordanceLedger = [ { row: 'goal.options', @@ -122,7 +142,12 @@ describe('runtime affordances coverage ledger', () => { }); it('covers all agent-required rows through the shared affordances derivation', () => { - const derived = affordances(resolveBrunchAgentState(DEFAULT_BRUNCH_AGENT_STATE), 'commitments_ready'); + const derived = affordances(resolveBrunchAgentState(DEFAULT_BRUNCH_AGENT_STATE), [ + gap('context'), + gap('thesis'), + gap('goal'), + gap('constraint'), + ]); const derivedRows = Object.entries(derived).flatMap(([axis, axisAffordance]) => { const { selection: _selection, ...derivedFields } = axisAffordance; expect(Object.keys(derivedFields).sort()).toEqual(['defaultOnSwitch', 'legalOptions']); diff --git a/src/session/workspace-context.test.ts b/src/session/workspace-context.test.ts index e8631e0f..2f1b7b92 100644 --- a/src/session/workspace-context.test.ts +++ b/src/session/workspace-context.test.ts @@ -96,7 +96,6 @@ describe('inspectWorkspaceCwdInventory', () => { specId: alpha.specId, specTitle: 'Alpha Grounding', turnCount: 1, - readinessGrade: 'grounding_onboarding', }, { id: 'beta-session', @@ -104,7 +103,6 @@ describe('inspectWorkspaceCwdInventory', () => { specId: beta.specId, specTitle: 'Beta Commitments', turnCount: 2, - readinessGrade: 'commitments_ready', }, ]); }); diff --git a/src/session/workspace-context.ts b/src/session/workspace-context.ts index 4d6e1b87..47e0fb27 100644 --- a/src/session/workspace-context.ts +++ b/src/session/workspace-context.ts @@ -1,7 +1,7 @@ import { readdir, readFile } from 'node:fs/promises'; import { basename, join, relative, resolve, sep } from 'node:path'; -import { openWorkspaceGraphRuntime, type ReadinessGrade } from '../graph/index.js'; +import { openWorkspaceGraphRuntime } from '../graph/index.js'; import { inspectCanonicalSessionFiles } from './workspace-session-coordinator/boot-session-store.js'; interface WorkspaceSessionFileInventory { @@ -44,7 +44,6 @@ interface WorkspaceSessionOverview { readonly specId: number; readonly specTitle: string; readonly turnCount: number; - readonly readinessGrade: ReadinessGrade; } export interface WorkspaceOverview { @@ -89,7 +88,6 @@ export async function inspectWorkspaceOverview(cwd: string): Promise ({ id: spec.id, title: spec.name, - readinessGrade: spec.readinessGrade, nodeCount: graph.forSpec(spec.id).queryGraph().nodes.length, })) .sort((left, right) => left.title.localeCompare(right.title)); @@ -110,7 +108,6 @@ export async function inspectWorkspaceOverview(cwd: string): Promise { expect(result.chrome.cwd).toBe(cwd); expect(result.chrome.spec?.id).toBeTypeOf('number'); expect(result.chrome.spec?.title).toBe('Scratch spec'); - expect(result.chrome.phase).toBe('elicitation'); - expect(result.chrome.chatMode).toBe('responding-to-elicitation'); + expect(result.chrome).not.toHaveProperty('phase'); + expect(result.chrome).not.toHaveProperty('chatMode'); const oracle = await verifyWorkspaceSessionStores({ cwd, diff --git a/src/session/workspace-session-coordinator.ts b/src/session/workspace-session-coordinator.ts index cd954a6a..fc8bbef1 100644 --- a/src/session/workspace-session-coordinator.ts +++ b/src/session/workspace-session-coordinator.ts @@ -56,8 +56,6 @@ export interface WorkspaceSessionChromeState { cwd: string; project?: WorkspaceProjectState; spec: WorkspaceSpecState | null; - phase: 'select_spec' | 'elicitation'; - chatMode: 'select-spec' | 'responding-to-elicitation'; } export interface WorkspaceSessionReadyState { @@ -683,8 +681,6 @@ function chromeState( cwd, project: project ?? projectStateFromCwd(cwd), spec, - phase: spec ? 'elicitation' : 'select_spec', - chatMode: spec ? 'responding-to-elicitation' : 'select-spec', }; } diff --git a/src/web/app.test.tsx b/src/web/app.test.tsx index 9595848a..63d1a6b4 100644 --- a/src/web/app.test.tsx +++ b/src/web/app.test.tsx @@ -22,29 +22,20 @@ const readyState: WorkspaceState = { cwd: '/tmp/brunch-project', spec: { id: 1, title: 'Web spec' }, session: { id: 'session-1', file: '/tmp/session.jsonl' }, - chrome: { - phase: 'elicitation', - chatMode: 'responding-to-elicitation', - }, + chrome: {}, }; const selectSpecState: WorkspaceState = { status: 'select_spec', cwd: '/tmp/brunch-project', spec: null, - chrome: { - phase: 'select_spec', - chatMode: 'select-spec', - }, + chrome: {}, }; const selectedSpecWithoutSessionState: WorkspaceState = { status: 'select_spec', cwd: '/tmp/brunch-project', spec: { id: 2, title: 'Spec without session' }, - chrome: { - phase: 'select_spec', - chatMode: 'select-spec', - }, + chrome: {}, }; const emptySelectionState = {