diff --git a/.agents/skills/effort-graph/SKILL.md b/.agents/skills/effort-graph/SKILL.md index 41f65698..ac469e99 100644 --- a/.agents/skills/effort-graph/SKILL.md +++ b/.agents/skills/effort-graph/SKILL.md @@ -49,7 +49,7 @@ The write journal is `/.journal/`; read digests cache under ## Writing (journaling) -One command for all 13 mutations — pass the payload as a single JSON argument: +One command for all 15 mutations — pass the payload as a single JSON argument: ```bash flatbread effort write '{"type":"WriteDecision","effort":"","title":"...","body":"...","derives_from":[""]}' @@ -59,18 +59,23 @@ Response: `{"generation":"","artifacts":[{"id","path","operation"}],"touc **Capture `artifacts[0].id`** to wire later edges, and **keep `generation`** for strict read-your-writes. -Full payload shapes for all 13 mutations: read [reference.md](./reference.md). +Full payload shapes for all 15 mutations: read [reference.md](./reference.md). Critical semantics: - Creates always start in the initial lifecycle state: `WriteDecision` → `proposed`, `WriteIssue` → `open`, `WriteRisk` → `open`. You cannot pass a state; use lifecycle mutations (`AcceptDecision`, `ResolveIssue`, - `MitigateRisk`, `SetRiskState`) to transition. + `MitigateRisk`, `SetRiskState`) to transition. `WriteCitation` and + `WriteBlob` have no lifecycle state. - `AcceptDecision` defaults `rejectSiblings: true`, which rejects ALL other proposed Decisions in the same Effort. Pass `"rejectSiblings": false` unless you deliberately want the competing proposals closed. - Edges are forward-only in payloads (`derives_from`, `supersedes`, `invalidates`); back-edges are materialized automatically. +- Cites: `WriteCitation` (body may be a URL; optional `blob` + `role`), then + `cites: [""]` on any epistemic create. Flatbread `refs` link + records → Citation → optional Blob. Bounded digests omit Blob bodies — + use `effort get`. - When superseding, open the new record's body with a short rollup of what changed and why — reads render ancestors only as one-line checkpoints. - For a hard-to-reverse, surprising decision made after a real trade-off, use diff --git a/.agents/skills/effort-graph/glossary.md b/.agents/skills/effort-graph/glossary.md index b3b605bd..2f1e190c 100644 --- a/.agents/skills/effort-graph/glossary.md +++ b/.agents/skills/effort-graph/glossary.md @@ -48,13 +48,37 @@ A prospective negative outcome with likelihood and severity. It is open, mitigated by an accepted Decision, realized with evidence, or explicitly accepted. +### Citation + +A first-class cite target that explains what evidence is and how it relates. +Epistemic records point at Citations via the homogeneous `cites` ref so +Flatbread validates and GraphQL-resolves the edge. A Citation body alone is +valid — often a URL string. An optional `blob` ref attaches a longform/opaque +payload when needed. Optional `role` labels the relationship (e.g. evidence, +context). Citations have no proposed/accepted lifecycle. + +### Blob + +A citeable, non-epistemic payload: opaque content of any format (markdown, +JSON, images, etc.) with no proposed/accepted lifecycle. Blobs are ordinary +Flatbread content (filesystem-first; other sources such as S3/CDN may back +them later). Records do not cite Blobs directly — they cite a Citation that +may optionally ref a Blob. Bounded digests omit Blob bodies by default; use +`effort get ` to read the payload. + ## Edges `derives_from` is causal upstream evidence or context. `supersedes` replaces a record of the same primitive, while `invalidates` says a record was wrong. Those forward edges are authoritative; `superseded_by` and `invalidated_by` are -writer-materialized reverse projections. New edge vocabulary needs a -dogfooded query the existing vocabulary cannot express. +writer-materialized reverse projections. + +`cites` is a homogeneous Flatbread `refs` edge to Citation on every epistemic +record (Effort, Issue, Finding, Decision, Constraint, Risk). Citation may +optionally `blob` → Blob. + +New edge vocabulary needs a dogfooded query the existing vocabulary cannot +express. ## Intentional non-models @@ -62,3 +86,8 @@ Session, Run, Plan, Artifact, Agent, Investigation, Question, Proposal, Retrospective, and Branch are not collections. Use provenance fields for operational data; represent questions as Issues, proposals as proposed Decisions, retrospectives as Findings, and branch history through Git. + +**Citation** carries relationship context (and optional Blob). **Blob** is the +longform/payload collection. **Artifact** remains a non-model for run/build +outputs (CLI write-result `artifacts`, digest `artifact_path`, Proof +transcripts) — do not promote those into graph records. diff --git a/.agents/skills/effort-graph/reference.md b/.agents/skills/effort-graph/reference.md index 1e7bf053..77b8a2d1 100644 --- a/.agents/skills/effort-graph/reference.md +++ b/.agents/skills/effort-graph/reference.md @@ -7,21 +7,26 @@ consumer ground truth. ## IDs Generated as `---<16-char-crockford>` with prefixes `eff`, -`iss`, `fnd`, `dec`, `con`, `rsk`. Filenames never define identity. Let the -writer generate ids; capture them from mutation results (`artifacts[0].id` -for creates). +`iss`, `fnd`, `dec`, `con`, `rsk`, `cit`, `blb`. Filenames never define +identity. Let the writer generate ids; capture them from mutation results +(`artifacts[0].id` for creates). -## The 13 mutations (`flatbread effort write ''`) +## The 15 mutations (`flatbread effort write ''`) Common optional fields on all creates: `id`, `created_at` (ISO with offset), `produced_in`, `created_by` (opaque provenance strings). Forward edge fields -on all creates except `CreateEffort`: `derives_from[]`, `supersedes[]`, -`invalidates[]` (arrays of existing ids; targets are validated). +on all creates except `CreateEffort`, `WriteCitation`, and `WriteBlob`: +`derives_from[]`, `supersedes[]`, `invalidates[]` (arrays of existing ids; +targets are validated). + +Optional `cites[]` on all epistemic creates (`CreateEffort` and every `Write*` +except `WriteCitation` / `WriteBlob`): existing **Citation** ids (homogeneous +Flatbread `refs` → Citation). ### Effort lifecycle ```json -{"type":"CreateEffort","title":"...","body":"...","slug":"optional"} +{"type":"CreateEffort","title":"...","body":"...","slug":"optional","cites":[""]} {"type":"SetEffortStatus","effortId":"","status":"active|paused|completed|abandoned"} ``` @@ -33,10 +38,15 @@ on all creates except `CreateEffort`: `derives_from[]`, `supersedes[]`, {"type":"WriteDecision","effort":"","title":"...","body":"..."} {"type":"WriteConstraint","effort":"","title":"...","body":"...","kind":"hard|soft"} {"type":"WriteRisk","effort":"","title":"...","body":"...","likelihood":"low|medium|high","severity":"low|medium|high"} +{"type":"WriteCitation","effort":"","title":"...","body":"https://example.com/...","role":"evidence|context|","blob":""} +{"type":"WriteBlob","effort":"","title":"...","body":"...","kind":"markdown|json|"} ``` Initial states: Issue `status: open`; Decision `state: proposed`; Risk -`state: open`. +`state: open`. Citation and Blob have no lifecycle state. + +A Citation body alone is valid (commonly a URL). `blob` is optional — attach +a Blob only when you need a longform/opaque payload behind the cite. ### Edge retro-linking (records must already exist) @@ -96,9 +106,12 @@ The digest at `artifact_path` is deterministic markdown: YAML query header, anchor index, per-record sections (selected frontmatter, body, relation lists), one-hop related records, and an edge table. Body policy: -- **`effort get`:** full record body (the normal zoom-in path). +- **`effort get`:** full record body (the normal zoom-in path), including + Blob payloads and Citation bodies (URLs / short notes). - **`list` / `records` / `relations` / `blocking-decisions`:** body excerpt - capped at 600 chars / 12 lines (`[…truncated]`). + capped at 600 chars / 12 lines (`[…truncated]`). **Blob bodies are omitted** + from these digests — use `effort get` for the payload. Citation bodies + (usually short) still excerpt normally. Caps: 25 primary records, one hop, 50 edges, 64 KiB; hitting a cap sets `complete: false` with named `cap_reasons` — narrow the query or page rather @@ -117,8 +130,8 @@ flatbread effort blocking-decisions [consistency flags] flatbread effort cache prune ``` -- `--kinds`: `effort|issue|finding|decision|constraint|risk` (records: - default all non-effort kinds). +- `--kinds`: `effort|issue|finding|decision|constraint|risk|citation|blob` + (records: default all non-effort kinds, including `citation` and `blob`). - `list --status`: defaults to `active`; valid values are exactly `active`, `paused`, `completed`, and `abandoned`. Values are ORed and results are ordered by `created_at` ascending, then `id`. @@ -126,7 +139,7 @@ flatbread effort cache prune `--since`/`--until` bound `created_at` (gte/lte, ISO strings). - `--relations` values: `derives_from`, `supersedes`, `superseded_by`, `invalidates`, `invalidated_by`, `rejected_by`, `mitigated_by`, - `resolved_by`, `evidence` (one hop, explicit only). + `resolved_by`, `evidence`, `cites` (one hop, explicit only). - `--resolve head`: follow `superseded_by` to the current tip; ancestors render as checkpoint lines (max 5, then a count). - `blocking-decisions` membership (frozen): Decision in the effort with @@ -145,14 +158,14 @@ flatbread effort cache prune ## Configuration surface -| Option | Where | Default | Notes | -| ---------------- | --------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| Graph root | `effortGraphContent(root)` in `flatbread.config.js` | `.flatbread-efforts` | All six collection paths + refs derive from it; the preset must appear complete and unmodified for detection. | -| Config discovery | cwd of the CLI invocation | — | Exactly one `flatbread.config.*` must exist in cwd. | -| Digest cache | fixed | `/.flatbread/effort-graph/read-cache/` | Generation-keyed; gitignore it. `cache prune`: >24h old deleted, then oldest-first to ≤100 MiB. | -| Journal | fixed | `/.journal/` | Writer-owned; gitignored. Never edit. | -| Strict timeout | `--timeout-ms` per read | 3000 ms | | -| Page limit | `--limit` per read | 25 | Hard max 25. | +| Option | Where | Default | Notes | +| ---------------- | --------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Graph root | `effortGraphContent(root)` in `flatbread.config.js` | `.flatbread-efforts` | All eight collection paths + refs derive from it; the preset must appear complete and unmodified for detection. | +| Config discovery | cwd of the CLI invocation | — | Exactly one `flatbread.config.*` must exist in cwd. | +| Digest cache | fixed | `/.flatbread/effort-graph/read-cache/` | Generation-keyed; gitignore it. `cache prune`: >24h old deleted, then oldest-first to ≤100 MiB. | +| Journal | fixed | `/.journal/` | Writer-owned; gitignored. Never edit. | +| Strict timeout | `--timeout-ms` per read | 3000 ms | | +| Page limit | `--limit` per read | 25 | Hard max 25. | ## What not to do @@ -164,3 +177,6 @@ flatbread effort cache prune server-side. - Do not model sessions/plans/agents as records — put provenance in `produced_in` / `created_by` fields. +- Do not put citation metadata objects into `cites` — use Citation records + (body/role/optional blob) and bare Citation ids in `cites`. +- Do not expect Blob bodies in list/records digests; zoom with `effort get`. diff --git a/.flatbread-efforts/blobs/blb-blob-cite-design-notes--j3jbgc0cymdb2t9g.md b/.flatbread-efforts/blobs/blb-blob-cite-design-notes--j3jbgc0cymdb2t9g.md new file mode 100644 index 00000000..3574e214 --- /dev/null +++ b/.flatbread-efforts/blobs/blb-blob-cite-design-notes--j3jbgc0cymdb2t9g.md @@ -0,0 +1,15 @@ +--- +id: blb-blob-cite-design-notes--j3jbgc0cymdb2t9g +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Blob cite design notes +kind: markdown +created_at: '2026-07-19T11:21:00.970Z' +--- + +# Blob + Citation dogfood + +Longform notes for the citeable payload slice. + +- Citations are first-class refs targets +- Blob is optional on Citation +- Citation body may be a URL alone diff --git a/.flatbread-efforts/citations/cit-flatbread-refs-docs-intent--ew3x4qpx1x3c1cy2.md b/.flatbread-efforts/citations/cit-flatbread-refs-docs-intent--ew3x4qpx1x3c1cy2.md new file mode 100644 index 00000000..78b43610 --- /dev/null +++ b/.flatbread-efforts/citations/cit-flatbread-refs-docs-intent--ew3x4qpx1x3c1cy2.md @@ -0,0 +1,9 @@ +--- +id: cit-flatbread-refs-docs-intent--ew3x4qpx1x3c1cy2 +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Flatbread refs docs intent +role: context +created_at: '2026-07-19T11:21:02.547Z' +--- + +https://github.com/FlatbreadLabs/flatbread diff --git a/.flatbread-efforts/citations/cit-local-blob-design-dump--pg7g6qy1td7yjqpy.md b/.flatbread-efforts/citations/cit-local-blob-design-dump--pg7g6qy1td7yjqpy.md new file mode 100644 index 00000000..371db75e --- /dev/null +++ b/.flatbread-efforts/citations/cit-local-blob-design-dump--pg7g6qy1td7yjqpy.md @@ -0,0 +1,10 @@ +--- +id: cit-local-blob-design-dump--pg7g6qy1td7yjqpy +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Local blob design dump +role: evidence +blob: blb-blob-cite-design-notes--j3jbgc0cymdb2t9g +created_at: '2026-07-19T11:21:04.044Z' +--- + +Repo dogfood blob capturing the Citation/Blob split. diff --git a/.flatbread-efforts/decisions/dec-ship-citation-collection-with-optional-blob--fyga3x876n7rcnmn.md b/.flatbread-efforts/decisions/dec-ship-citation-collection-with-optional-blob--fyga3x876n7rcnmn.md new file mode 100644 index 00000000..fd1a29b0 --- /dev/null +++ b/.flatbread-efforts/decisions/dec-ship-citation-collection-with-optional-blob--fyga3x876n7rcnmn.md @@ -0,0 +1,33 @@ +--- +id: dec-ship-citation-collection-with-optional-blob--fyga3x876n7rcnmn +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Ship Citation collection with optional Blob +state: accepted +created_at: '2026-07-19T11:21:07.225Z' +derives_from: + - fnd-citation-collection-keeps-flatbread-refs-intact--z33ar5vyjxzr7ys0 + - iss-implement-blob-collection-and-crumb-graph-cites--g2c7m6j39we5xy3z +cites: + - cit-local-blob-design-dump--pg7g6qy1td7yjqpy +--- + +## Context + +Epistemic records need cite metadata without breaking Flatbread homogeneous refs (string ids only). + +## Decision + +Add Citation as a first-class collection. Epistemic records use cites→Citation. Citation body alone is valid (e.g. URL). Optional blob→Blob attaches longform payloads. No cite_meta sidecar. + +## Alternatives considered + +- cite_meta objects alongside cites→Blob: works but bypasses refs for annotations +- cites→Blob only: no place for relationship/URL-only cites + +## Consequences + +WriteCitation + WriteBlob mutations; digests omit Blob bodies by default. + +## Reversal criteria + +Revisit if Citation churn is too heavy for simple URL cites or if polymorphic cite targets become necessary. diff --git a/.flatbread-efforts/findings/fnd-citation-collection-keeps-flatbread-refs-intact--z33ar5vyjxzr7ys0.md b/.flatbread-efforts/findings/fnd-citation-collection-keeps-flatbread-refs-intact--z33ar5vyjxzr7ys0.md new file mode 100644 index 00000000..6003f212 --- /dev/null +++ b/.flatbread-efforts/findings/fnd-citation-collection-keeps-flatbread-refs-intact--z33ar5vyjxzr7ys0.md @@ -0,0 +1,14 @@ +--- +id: fnd-citation-collection-keeps-flatbread-refs-intact--z33ar5vyjxzr7ys0 +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Citation collection keeps Flatbread refs intact +kind: measurement +created_at: '2026-07-19T11:21:05.639Z' +derives_from: + - iss-implement-blob-collection-and-crumb-graph-cites--g2c7m6j39we5xy3z +cites: + - cit-flatbread-refs-docs-intent--ew3x4qpx1x3c1cy2 + - cit-local-blob-design-dump--pg7g6qy1td7yjqpy +--- + +Homogeneous cites→Citation (optional blob; URL body alone is valid) preserves core refs validation and GraphQL resolve without cite_meta sidecars. diff --git a/.flatbread-efforts/issues/iss-implement-blob-collection-and-crumb-graph-cites--g2c7m6j39we5xy3z.md b/.flatbread-efforts/issues/iss-implement-blob-collection-and-crumb-graph-cites--g2c7m6j39we5xy3z.md index c4de60e2..24de4104 100644 --- a/.flatbread-efforts/issues/iss-implement-blob-collection-and-crumb-graph-cites--g2c7m6j39we5xy3z.md +++ b/.flatbread-efforts/issues/iss-implement-blob-collection-and-crumb-graph-cites--g2c7m6j39we5xy3z.md @@ -3,11 +3,14 @@ id: iss-implement-blob-collection-and-crumb-graph-cites--g2c7m6j39we5xy3z effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 title: Implement Blob collection and Crumb Graph cites kind: gap -status: open +status: resolved created_at: '2026-07-19T10:48:48.349Z' derives_from: - con-mutation-enum-stays-deliberately-small--45v1ae3neq26g1rz - dec-name-citeable-longform-payloads-blob--9b11q14fgsx2ytve +resolved_by: + - dec-ship-citation-collection-with-optional-blob--fyga3x876n7rcnmn + - fnd-citation-collection-keeps-flatbread-refs-intact--z33ar5vyjxzr7ys0 --- Implement the accepted Blob decision so Crumb Graph records can cite longform/dynamic payloads. diff --git a/packages/effort-graph/README.md b/packages/effort-graph/README.md index cc2c425e..53dacac6 100644 --- a/packages/effort-graph/README.md +++ b/packages/effort-graph/README.md @@ -6,13 +6,15 @@ finishes completely or is undone. Version 1 supports these actions: `CreateEffort`, `SetEffortStatus`, `WriteIssue`, `WriteFinding`, `WriteDecision`, `WriteConstraint`, `WriteRisk`, -`Supersede`, `Invalidate`, `ResolveIssue`, `AcceptDecision`, `MitigateRisk`, -and `SetRiskState`. +`WriteCitation`, `WriteBlob`, `Supersede`, `Invalidate`, `ResolveIssue`, +`AcceptDecision`, `MitigateRisk`, and `SetRiskState`. The journal is stored in `/.journal` and is ignored by Git. Use `effortGraphContent()` to add Efforts, Issues, Findings, Decisions, -Constraints, and Risks to a Flatbread configuration. The writer checks links -between those record types. +Constraints, Risks, Citations, and Blobs to a Flatbread configuration. The +writer checks links between those record types. Epistemic records may +optionally `cites` Citation ids (Flatbread `refs`). A Citation body alone is +valid (e.g. a URL); an optional `blob` ref attaches a longform payload. Read [`skills/effort-graph/glossary.md`](./skills/effort-graph/glossary.md) for the portable Effort Graph domain model. diff --git a/packages/effort-graph/skills/effort-graph/SKILL.md b/packages/effort-graph/skills/effort-graph/SKILL.md index 41f65698..ac469e99 100644 --- a/packages/effort-graph/skills/effort-graph/SKILL.md +++ b/packages/effort-graph/skills/effort-graph/SKILL.md @@ -49,7 +49,7 @@ The write journal is `/.journal/`; read digests cache under ## Writing (journaling) -One command for all 13 mutations — pass the payload as a single JSON argument: +One command for all 15 mutations — pass the payload as a single JSON argument: ```bash flatbread effort write '{"type":"WriteDecision","effort":"","title":"...","body":"...","derives_from":[""]}' @@ -59,18 +59,23 @@ Response: `{"generation":"","artifacts":[{"id","path","operation"}],"touc **Capture `artifacts[0].id`** to wire later edges, and **keep `generation`** for strict read-your-writes. -Full payload shapes for all 13 mutations: read [reference.md](./reference.md). +Full payload shapes for all 15 mutations: read [reference.md](./reference.md). Critical semantics: - Creates always start in the initial lifecycle state: `WriteDecision` → `proposed`, `WriteIssue` → `open`, `WriteRisk` → `open`. You cannot pass a state; use lifecycle mutations (`AcceptDecision`, `ResolveIssue`, - `MitigateRisk`, `SetRiskState`) to transition. + `MitigateRisk`, `SetRiskState`) to transition. `WriteCitation` and + `WriteBlob` have no lifecycle state. - `AcceptDecision` defaults `rejectSiblings: true`, which rejects ALL other proposed Decisions in the same Effort. Pass `"rejectSiblings": false` unless you deliberately want the competing proposals closed. - Edges are forward-only in payloads (`derives_from`, `supersedes`, `invalidates`); back-edges are materialized automatically. +- Cites: `WriteCitation` (body may be a URL; optional `blob` + `role`), then + `cites: [""]` on any epistemic create. Flatbread `refs` link + records → Citation → optional Blob. Bounded digests omit Blob bodies — + use `effort get`. - When superseding, open the new record's body with a short rollup of what changed and why — reads render ancestors only as one-line checkpoints. - For a hard-to-reverse, surprising decision made after a real trade-off, use diff --git a/packages/effort-graph/skills/effort-graph/glossary.md b/packages/effort-graph/skills/effort-graph/glossary.md index b3b605bd..2f1e190c 100644 --- a/packages/effort-graph/skills/effort-graph/glossary.md +++ b/packages/effort-graph/skills/effort-graph/glossary.md @@ -48,13 +48,37 @@ A prospective negative outcome with likelihood and severity. It is open, mitigated by an accepted Decision, realized with evidence, or explicitly accepted. +### Citation + +A first-class cite target that explains what evidence is and how it relates. +Epistemic records point at Citations via the homogeneous `cites` ref so +Flatbread validates and GraphQL-resolves the edge. A Citation body alone is +valid — often a URL string. An optional `blob` ref attaches a longform/opaque +payload when needed. Optional `role` labels the relationship (e.g. evidence, +context). Citations have no proposed/accepted lifecycle. + +### Blob + +A citeable, non-epistemic payload: opaque content of any format (markdown, +JSON, images, etc.) with no proposed/accepted lifecycle. Blobs are ordinary +Flatbread content (filesystem-first; other sources such as S3/CDN may back +them later). Records do not cite Blobs directly — they cite a Citation that +may optionally ref a Blob. Bounded digests omit Blob bodies by default; use +`effort get ` to read the payload. + ## Edges `derives_from` is causal upstream evidence or context. `supersedes` replaces a record of the same primitive, while `invalidates` says a record was wrong. Those forward edges are authoritative; `superseded_by` and `invalidated_by` are -writer-materialized reverse projections. New edge vocabulary needs a -dogfooded query the existing vocabulary cannot express. +writer-materialized reverse projections. + +`cites` is a homogeneous Flatbread `refs` edge to Citation on every epistemic +record (Effort, Issue, Finding, Decision, Constraint, Risk). Citation may +optionally `blob` → Blob. + +New edge vocabulary needs a dogfooded query the existing vocabulary cannot +express. ## Intentional non-models @@ -62,3 +86,8 @@ Session, Run, Plan, Artifact, Agent, Investigation, Question, Proposal, Retrospective, and Branch are not collections. Use provenance fields for operational data; represent questions as Issues, proposals as proposed Decisions, retrospectives as Findings, and branch history through Git. + +**Citation** carries relationship context (and optional Blob). **Blob** is the +longform/payload collection. **Artifact** remains a non-model for run/build +outputs (CLI write-result `artifacts`, digest `artifact_path`, Proof +transcripts) — do not promote those into graph records. diff --git a/packages/effort-graph/skills/effort-graph/reference.md b/packages/effort-graph/skills/effort-graph/reference.md index 1e7bf053..77b8a2d1 100644 --- a/packages/effort-graph/skills/effort-graph/reference.md +++ b/packages/effort-graph/skills/effort-graph/reference.md @@ -7,21 +7,26 @@ consumer ground truth. ## IDs Generated as `---<16-char-crockford>` with prefixes `eff`, -`iss`, `fnd`, `dec`, `con`, `rsk`. Filenames never define identity. Let the -writer generate ids; capture them from mutation results (`artifacts[0].id` -for creates). +`iss`, `fnd`, `dec`, `con`, `rsk`, `cit`, `blb`. Filenames never define +identity. Let the writer generate ids; capture them from mutation results +(`artifacts[0].id` for creates). -## The 13 mutations (`flatbread effort write ''`) +## The 15 mutations (`flatbread effort write ''`) Common optional fields on all creates: `id`, `created_at` (ISO with offset), `produced_in`, `created_by` (opaque provenance strings). Forward edge fields -on all creates except `CreateEffort`: `derives_from[]`, `supersedes[]`, -`invalidates[]` (arrays of existing ids; targets are validated). +on all creates except `CreateEffort`, `WriteCitation`, and `WriteBlob`: +`derives_from[]`, `supersedes[]`, `invalidates[]` (arrays of existing ids; +targets are validated). + +Optional `cites[]` on all epistemic creates (`CreateEffort` and every `Write*` +except `WriteCitation` / `WriteBlob`): existing **Citation** ids (homogeneous +Flatbread `refs` → Citation). ### Effort lifecycle ```json -{"type":"CreateEffort","title":"...","body":"...","slug":"optional"} +{"type":"CreateEffort","title":"...","body":"...","slug":"optional","cites":[""]} {"type":"SetEffortStatus","effortId":"","status":"active|paused|completed|abandoned"} ``` @@ -33,10 +38,15 @@ on all creates except `CreateEffort`: `derives_from[]`, `supersedes[]`, {"type":"WriteDecision","effort":"","title":"...","body":"..."} {"type":"WriteConstraint","effort":"","title":"...","body":"...","kind":"hard|soft"} {"type":"WriteRisk","effort":"","title":"...","body":"...","likelihood":"low|medium|high","severity":"low|medium|high"} +{"type":"WriteCitation","effort":"","title":"...","body":"https://example.com/...","role":"evidence|context|","blob":""} +{"type":"WriteBlob","effort":"","title":"...","body":"...","kind":"markdown|json|"} ``` Initial states: Issue `status: open`; Decision `state: proposed`; Risk -`state: open`. +`state: open`. Citation and Blob have no lifecycle state. + +A Citation body alone is valid (commonly a URL). `blob` is optional — attach +a Blob only when you need a longform/opaque payload behind the cite. ### Edge retro-linking (records must already exist) @@ -96,9 +106,12 @@ The digest at `artifact_path` is deterministic markdown: YAML query header, anchor index, per-record sections (selected frontmatter, body, relation lists), one-hop related records, and an edge table. Body policy: -- **`effort get`:** full record body (the normal zoom-in path). +- **`effort get`:** full record body (the normal zoom-in path), including + Blob payloads and Citation bodies (URLs / short notes). - **`list` / `records` / `relations` / `blocking-decisions`:** body excerpt - capped at 600 chars / 12 lines (`[…truncated]`). + capped at 600 chars / 12 lines (`[…truncated]`). **Blob bodies are omitted** + from these digests — use `effort get` for the payload. Citation bodies + (usually short) still excerpt normally. Caps: 25 primary records, one hop, 50 edges, 64 KiB; hitting a cap sets `complete: false` with named `cap_reasons` — narrow the query or page rather @@ -117,8 +130,8 @@ flatbread effort blocking-decisions [consistency flags] flatbread effort cache prune ``` -- `--kinds`: `effort|issue|finding|decision|constraint|risk` (records: - default all non-effort kinds). +- `--kinds`: `effort|issue|finding|decision|constraint|risk|citation|blob` + (records: default all non-effort kinds, including `citation` and `blob`). - `list --status`: defaults to `active`; valid values are exactly `active`, `paused`, `completed`, and `abandoned`. Values are ORed and results are ordered by `created_at` ascending, then `id`. @@ -126,7 +139,7 @@ flatbread effort cache prune `--since`/`--until` bound `created_at` (gte/lte, ISO strings). - `--relations` values: `derives_from`, `supersedes`, `superseded_by`, `invalidates`, `invalidated_by`, `rejected_by`, `mitigated_by`, - `resolved_by`, `evidence` (one hop, explicit only). + `resolved_by`, `evidence`, `cites` (one hop, explicit only). - `--resolve head`: follow `superseded_by` to the current tip; ancestors render as checkpoint lines (max 5, then a count). - `blocking-decisions` membership (frozen): Decision in the effort with @@ -145,14 +158,14 @@ flatbread effort cache prune ## Configuration surface -| Option | Where | Default | Notes | -| ---------------- | --------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| Graph root | `effortGraphContent(root)` in `flatbread.config.js` | `.flatbread-efforts` | All six collection paths + refs derive from it; the preset must appear complete and unmodified for detection. | -| Config discovery | cwd of the CLI invocation | — | Exactly one `flatbread.config.*` must exist in cwd. | -| Digest cache | fixed | `/.flatbread/effort-graph/read-cache/` | Generation-keyed; gitignore it. `cache prune`: >24h old deleted, then oldest-first to ≤100 MiB. | -| Journal | fixed | `/.journal/` | Writer-owned; gitignored. Never edit. | -| Strict timeout | `--timeout-ms` per read | 3000 ms | | -| Page limit | `--limit` per read | 25 | Hard max 25. | +| Option | Where | Default | Notes | +| ---------------- | --------------------------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Graph root | `effortGraphContent(root)` in `flatbread.config.js` | `.flatbread-efforts` | All eight collection paths + refs derive from it; the preset must appear complete and unmodified for detection. | +| Config discovery | cwd of the CLI invocation | — | Exactly one `flatbread.config.*` must exist in cwd. | +| Digest cache | fixed | `/.flatbread/effort-graph/read-cache/` | Generation-keyed; gitignore it. `cache prune`: >24h old deleted, then oldest-first to ≤100 MiB. | +| Journal | fixed | `/.journal/` | Writer-owned; gitignored. Never edit. | +| Strict timeout | `--timeout-ms` per read | 3000 ms | | +| Page limit | `--limit` per read | 25 | Hard max 25. | ## What not to do @@ -164,3 +177,6 @@ flatbread effort cache prune server-side. - Do not model sessions/plans/agents as records — put provenance in `produced_in` / `created_by` fields. +- Do not put citation metadata objects into `cites` — use Citation records + (body/role/optional blob) and bare Citation ids in `cites`. +- Do not expect Blob bodies in list/records digests; zoom with `effort get`. diff --git a/packages/effort-graph/src/__tests__/digest.test.ts b/packages/effort-graph/src/__tests__/digest.test.ts index 2ccbd5ac..7d9c9562 100644 --- a/packages/effort-graph/src/__tests__/digest.test.ts +++ b/packages/effort-graph/src/__tests__/digest.test.ts @@ -170,6 +170,67 @@ test('pagination is incomplete without adding a cap reason', async (t) => { t.is(result.page.next_cursor, 'next'); }); +test('renderDigest omits Blob bodies from bounded digests', async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-blob-')); + const secret = 'SECRET_BLOB_PAYLOAD_SHOULD_NOT_APPEAR'; + const result = await renderDigest({ + query: { type: 'listRecords', effort: 'eff-one--0123456789abcdef' }, + queryHash: 'blob-list', + generation: '4', + consistency: { mode: 'eventual' as const, min_generation: null }, + cacheRoot, + records: [ + { + id: 'blb-payload--0123456789abcdef', + kind: 'blob' as const, + path: 'blobs/blb-payload--0123456789abcdef.md', + frontmatter: { + id: 'blb-payload--0123456789abcdef', + title: 'Payload', + kind: 'markdown', + }, + body_excerpt: secret, + relations: {}, + }, + ], + edges: [], + }); + const digest = await readFile(result.artifact_path, 'utf8'); + t.false(digest.includes(secret)); + t.true(digest.includes('Blob body omitted from bounded digests')); + t.true(digest.includes('effort get ')); +}); + +test('renderDigest includes Blob body on fullBody get', async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-blob-full-')); + const secret = 'SECRET_BLOB_PAYLOAD_FULL_GET'; + const result = await renderDigest({ + query: { type: 'getRecord', id: 'blb-payload--0123456789abcdef' }, + queryHash: 'blob-get', + generation: '4', + consistency: { mode: 'eventual' as const, min_generation: null }, + cacheRoot, + fullBody: true, + records: [ + { + id: 'blb-payload--0123456789abcdef', + kind: 'blob' as const, + path: 'blobs/blb-payload--0123456789abcdef.md', + frontmatter: { + id: 'blb-payload--0123456789abcdef', + title: 'Payload', + }, + body_excerpt: secret, + relations: {}, + }, + ], + edges: [], + }); + const digest = await readFile(result.artifact_path, 'utf8'); + t.true(digest.includes(secret)); + t.false(digest.includes('Blob body omitted')); +}); + test('byte caps keep complete unicode sections and rows', async (t) => { const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-bytes-')); const result = await renderDigest({ diff --git a/packages/effort-graph/src/__tests__/ids.test.ts b/packages/effort-graph/src/__tests__/ids.test.ts index 83700c9c..d1a07303 100644 --- a/packages/effort-graph/src/__tests__/ids.test.ts +++ b/packages/effort-graph/src/__tests__/ids.test.ts @@ -14,9 +14,11 @@ const kinds: [PrimitiveKind, string][] = [ ['decision', 'dec'], ['constraint', 'con'], ['risk', 'rsk'], + ['blob', 'blb'], + ['citation', 'cit'], ]; -test('generates valid ids for all six prefixes', (t) => { +test('generates valid ids for all eight prefixes', (t) => { for (const [kind, prefix] of kinds) { const id = generateArtifactId(kind, 'Some Great Title'); t.true(id.startsWith(`${prefix}-some-great-title--`)); diff --git a/packages/effort-graph/src/__tests__/planner.test.ts b/packages/effort-graph/src/__tests__/planner.test.ts index acae80d9..98225e3d 100644 --- a/packages/effort-graph/src/__tests__/planner.test.ts +++ b/packages/effort-graph/src/__tests__/planner.test.ts @@ -434,3 +434,141 @@ test('20 SetRiskState', (t) => { ]); t.deepEqual(w[0].beforeBytes, s.getRawBytes(ids.risk)); }); +test('21 WriteBlob', (t) => { + const id = 'blb-payload--0123456789abcdef'; + const w = planMutation( + { + type: 'WriteBlob', + id, + effort: E, + title: 'Payload', + body: '# longform research\n', + kind: 'markdown', + }, + snap(), + '/root', + now + ); + one(t, w, id, `blobs/${id}.md`, 'create', { + id, + effort: E, + title: 'Payload', + kind: 'markdown', + created_at: now.toISOString(), + }); + t.is( + parseDocument(w[0].afterBytes, 'blob').body.trim(), + '# longform research' + ); +}); +test('22 WriteCitation with URL body and no blob', (t) => { + const id = 'cit-paper--0123456789abcdef'; + const w = planMutation( + { + type: 'WriteCitation', + id, + effort: E, + title: 'Paper', + body: 'https://example.com/paper', + role: 'evidence', + }, + snap(), + '/root', + now + ); + one(t, w, id, `citations/${id}.md`, 'create', { + id, + effort: E, + title: 'Paper', + role: 'evidence', + created_at: now.toISOString(), + }); + t.is( + parseDocument(w[0].afterBytes, 'citation').body.trim(), + 'https://example.com/paper' + ); +}); +test('23 WriteCitation with optional blob', (t) => { + const blobId = 'blb-payload--0123456789abcdef'; + const citId = 'cit-dump--0123456789abcdef'; + const blob = record(blobId, 'blob', { + id: blobId, + effort: E, + title: 'Payload', + created_at: '2025-01-01T00:00:00.000Z', + kind: 'markdown', + }); + const w = planMutation( + { + type: 'WriteCitation', + id: citId, + effort: E, + title: 'Dump cite', + body: 'Local research dump', + blob: blobId, + role: 'evidence', + }, + snap([blob]), + '/root', + now + ); + t.is(parseDocument(w[0].afterBytes, 'citation').frontmatter.blob, blobId); +}); +test('24 WriteFinding cites Citation', (t) => { + const citId = 'cit-paper--0123456789abcdef'; + const citation = record( + citId, + 'citation', + { + id: citId, + effort: E, + title: 'Paper', + created_at: '2025-01-01T00:00:00.000Z', + role: 'evidence', + }, + 'https://example.com/paper' + ); + const w = planMutation( + { + type: 'WriteFinding', + id: ids.finding, + effort: E, + title: 'F', + body: 'short', + kind: 'measurement', + cites: [citId], + }, + snap([citation]), + '/root', + now + ); + t.deepEqual(parseDocument(w[0].afterBytes, 'finding').frontmatter.cites, [ + citId, + ]); +}); +test('25 cites must target Citation', (t) => { + const finding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'F', + created_at: '2025-01-01T00:00:00.000Z', + kind: 'x', + }); + t.throws( + () => + planMutation( + { + type: 'WriteDecision', + id: ids.decision, + effort: E, + title: 'D', + body: '', + cites: [ids.finding], + }, + snap([finding]), + '/root', + now + ), + { message: /cites must target a Citation/ } + ); +}); diff --git a/packages/effort-graph/src/__tests__/preset.test.ts b/packages/effort-graph/src/__tests__/preset.test.ts index 6aae0c76..bfb8e7ac 100644 --- a/packages/effort-graph/src/__tests__/preset.test.ts +++ b/packages/effort-graph/src/__tests__/preset.test.ts @@ -1,14 +1,23 @@ import test from 'ava'; import { effortGraphContent } from '../preset.js'; -test('returns exactly six entries with exact paths and refs', (t) => { +test('returns exactly eight entries with exact paths and refs', (t) => { const entries = effortGraphContent(); t.deepEqual(entries, [ - { collection: 'Effort', path: '.flatbread-efforts/efforts' }, + { + collection: 'Effort', + path: '.flatbread-efforts/efforts', + refs: { cites: 'Citation' }, + }, { collection: 'Issue', path: '.flatbread-efforts/issues', - refs: { effort: 'Effort', supersedes: 'Issue', superseded_by: 'Issue' }, + refs: { + effort: 'Effort', + supersedes: 'Issue', + superseded_by: 'Issue', + cites: 'Citation', + }, }, { collection: 'Finding', @@ -17,6 +26,7 @@ test('returns exactly six entries with exact paths and refs', (t) => { effort: 'Effort', supersedes: 'Finding', superseded_by: 'Finding', + cites: 'Citation', }, }, { @@ -27,6 +37,7 @@ test('returns exactly six entries with exact paths and refs', (t) => { supersedes: 'Decision', superseded_by: 'Decision', rejected_by: 'Decision', + cites: 'Citation', }, }, { @@ -36,6 +47,7 @@ test('returns exactly six entries with exact paths and refs', (t) => { effort: 'Effort', supersedes: 'Constraint', superseded_by: 'Constraint', + cites: 'Citation', }, }, { @@ -46,8 +58,19 @@ test('returns exactly six entries with exact paths and refs', (t) => { supersedes: 'Risk', superseded_by: 'Risk', mitigated_by: 'Decision', + cites: 'Citation', }, }, + { + collection: 'Citation', + path: '.flatbread-efforts/citations', + refs: { effort: 'Effort', blob: 'Blob' }, + }, + { + collection: 'Blob', + path: '.flatbread-efforts/blobs', + refs: { effort: 'Effort' }, + }, ]); }); @@ -62,6 +85,8 @@ test('a custom root is respected in every path', (t) => { 'memory/graph/decisions', 'memory/graph/constraints', 'memory/graph/risks', + 'memory/graph/citations', + 'memory/graph/blobs', ] ); }); @@ -73,6 +98,7 @@ test('polymorphic union fields are absent from every refs map', (t) => { 'invalidated_by', 'resolved_by', 'evidence', + 'cite_meta', ]; for (const entry of effortGraphContent()) { for (const field of polymorphic) { @@ -83,3 +109,13 @@ test('polymorphic union fields are absent from every refs map', (t) => { } } }); + +test('cites is homogeneous Citation ref on every epistemic collection', (t) => { + for (const entry of effortGraphContent()) { + if (entry.collection === 'Blob' || entry.collection === 'Citation') { + t.falsy(entry.refs?.cites); + continue; + } + t.is(entry.refs?.cites, 'Citation', `${entry.collection}.cites → Citation`); + } +}); diff --git a/packages/effort-graph/src/__tests__/schemas.test.ts b/packages/effort-graph/src/__tests__/schemas.test.ts index 930be3e5..ad4d34a8 100644 --- a/packages/effort-graph/src/__tests__/schemas.test.ts +++ b/packages/effort-graph/src/__tests__/schemas.test.ts @@ -56,6 +56,20 @@ const validMutations: Record> = { likelihood: 'low', severity: 'high', }, + WriteCitation: { + type: 'WriteCitation', + effort: eff, + title: 'Paper', + body: 'https://example.com/paper', + role: 'evidence', + }, + WriteBlob: { + type: 'WriteBlob', + effort: eff, + title: 'Payload', + body: '# longform\n', + kind: 'markdown', + }, Supersede: { type: 'Supersede', supersederId: dec, targetId: dec }, Invalidate: { type: 'Invalidate', findingId: fnd, targetId: dec }, ResolveIssue: { @@ -74,9 +88,9 @@ const validMutations: Record> = { }, }; -test('each of the 13 mutation schemas accepts a valid input', (t) => { +test('each of the 15 mutation schemas accepts a valid input', (t) => { const types = Object.keys(validMutations); - t.is(types.length, 13); + t.is(types.length, 15); for (const type of types) { t.notThrows( () => EffortGraphMutationSchema.parse(validMutations[type]), @@ -138,6 +152,30 @@ test('rejects malformed ids', (t) => { ); }); +test('WriteCitation allows URL body without blob; cites accept Citation ids', (t) => { + t.notThrows(() => + EffortGraphMutationSchema.parse({ + type: 'WriteCitation', + effort: eff, + title: 'External link', + body: 'https://example.com/research', + }) + ); + t.notThrows(() => + EffortGraphMutationSchema.parse({ + ...validMutations.WriteCitation, + blob: `blb-payload--${suffix}`, + role: 'context', + }) + ); + t.notThrows(() => + EffortGraphMutationSchema.parse({ + ...validMutations.WriteFinding, + cites: [`cit-paper--${suffix}`], + }) + ); +}); + test('frontmatter schemas passthrough unknown keys', (t) => { const parsed = EffortFrontmatterSchema.parse({ id: eff, diff --git a/packages/effort-graph/src/digest.ts b/packages/effort-graph/src/digest.ts index 5c58d9d0..5b7a76eb 100644 --- a/packages/effort-graph/src/digest.ts +++ b/packages/effort-graph/src/digest.ts @@ -12,7 +12,8 @@ export type ReadRelation = | 'rejected_by' | 'mitigated_by' | 'resolved_by' - | 'evidence'; + | 'evidence' + | 'cites'; export interface ReadRecord { id: string; @@ -82,6 +83,8 @@ const FRONTMATTER_KEYS = [ 'rejected_by', 'mitigated_by', 'evidence', + 'role', + 'blob', ]; function scalar(value: unknown): string { @@ -149,6 +152,14 @@ function renderRecordBody( bodyMode: RecordBodyMode ): string { if (bodyMode === 'byte_cap_miss') return byteCapBodyBanner(record); + // Bounded digests cite Blob id/title/locator — never inline Blob bodies. + if (record.kind === 'blob' && bodyMode !== 'full') { + return [ + '> Blob body omitted from bounded digests.', + `Source path: \`${record.path || '(unknown)'}\`.`, + 'Use `effort get ` to read the payload.', + ].join(' '); + } if (bodyMode === 'full') return normalizeBody(record.body_excerpt); return excerpt(record.body_excerpt); } diff --git a/packages/effort-graph/src/frontmatter.ts b/packages/effort-graph/src/frontmatter.ts index bbc2cc9a..d23ebab7 100644 --- a/packages/effort-graph/src/frontmatter.ts +++ b/packages/effort-graph/src/frontmatter.ts @@ -9,6 +9,8 @@ const order = [ 'kind', 'status', 'state', + 'role', + 'blob', 'created_at', 'produced_in', 'created_by', @@ -21,7 +23,9 @@ const order = [ 'rejected_by', 'mitigated_by', 'evidence', + 'cites', ]; + export function canonicalizeFrontmatter( input: Record ): Record { diff --git a/packages/effort-graph/src/ids.ts b/packages/effort-graph/src/ids.ts index c5922efe..162ce550 100644 --- a/packages/effort-graph/src/ids.ts +++ b/packages/effort-graph/src/ids.ts @@ -8,6 +8,8 @@ export const KIND_PREFIX: Record = { decision: 'dec', constraint: 'con', risk: 'rsk', + blob: 'blb', + citation: 'cit', }; export const KIND_DIRECTORY: Record = { effort: 'efforts', @@ -16,6 +18,8 @@ export const KIND_DIRECTORY: Record = { decision: 'decisions', constraint: 'constraints', risk: 'risks', + blob: 'blobs', + citation: 'citations', }; export const PREFIX_KIND = Object.fromEntries( Object.entries(KIND_PREFIX).map(([k, v]) => [v, k]) diff --git a/packages/effort-graph/src/planner.ts b/packages/effort-graph/src/planner.ts index c155901f..508c5439 100644 --- a/packages/effort-graph/src/planner.ts +++ b/packages/effort-graph/src/planner.ts @@ -20,8 +20,33 @@ const kinds: Record = { WriteDecision: 'decision', WriteConstraint: 'constraint', WriteRisk: 'risk', + WriteCitation: 'citation', + WriteBlob: 'blob', }; +const EPISTEMIC_CREATE = new Set([ + 'issue', + 'finding', + 'decision', + 'constraint', + 'risk', +]); + +function assertCites( + get: ( + id: string + ) => NonNullable>, + cites: string[] | undefined +): void { + for (const citeId of cites ?? []) { + const target = get(citeId); + if (target.kind !== 'citation') + throw new EffortGraphValidationError( + `cites must target a Citation, got ${target.kind} (${citeId})` + ); + } +} + export function planMutation( input: EffortGraphMutation, snapshot: EffortGraphSnapshot, @@ -73,6 +98,7 @@ export function planMutation( throw new EffortGraphValidationError( `Duplicate effort slug ${input.slug}` ); + assertCites(get, input.cites); add( id, 'effort', @@ -88,6 +114,7 @@ export function planMutation( ...(input.created_by !== undefined ? { created_by: input.created_by } : {}), + ...(input.cites !== undefined ? { cites: input.cites } : {}), }, input.body, 'create' @@ -113,6 +140,15 @@ export function planMutation( const effort = get(raw.effort); if (effort.kind !== 'effort') throw new EffortGraphValidationError('Invalid effort'); + if (kind === 'citation' && raw.blob !== undefined) { + const blob = get(raw.blob); + if (blob.kind !== 'blob') + throw new EffortGraphValidationError( + `Citation.blob must target a Blob, got ${blob.kind}` + ); + } + if (EPISTEMIC_CREATE.has(kind) || kind === 'effort') + assertCites(get, raw.cites); const fm: Record = { ...raw, id, @@ -123,6 +159,12 @@ export function planMutation( if (kind === 'issue') fm.status = 'open'; if (kind === 'decision') fm.state = 'proposed'; if (kind === 'risk') fm.state = 'open'; + if (kind === 'blob' || kind === 'citation') { + delete fm.cites; + delete fm.derives_from; + delete fm.supersedes; + delete fm.invalidates; + } add(id, kind, fm, raw.body, 'create'); for (const edge of ['supersedes', 'invalidates'] as const) for (const targetId of (fm[edge] as string[] | undefined) ?? []) { diff --git a/packages/effort-graph/src/preset.ts b/packages/effort-graph/src/preset.ts index 4d1bd79d..d9e4a1a8 100644 --- a/packages/effort-graph/src/preset.ts +++ b/packages/effort-graph/src/preset.ts @@ -1,14 +1,28 @@ import type { ContentEntry } from '@flatbread/core'; + +const CITE_REFS = { cites: 'Citation' } as const; + export function effortGraphContent( root = '.flatbread-efforts' ): ContentEntry[] { // Union refs intentionally stay out of Flatbread refs; they target multiple collections. + // `cites` is homogeneous → Citation so core validates + GraphQL-resolves the edge. + // Citation may optionally ref a Blob; a Citation body alone (e.g. a URL) is valid. return [ - { collection: 'Effort', path: `${root}/efforts` }, + { + collection: 'Effort', + path: `${root}/efforts`, + refs: { ...CITE_REFS }, + }, { collection: 'Issue', path: `${root}/issues`, - refs: { effort: 'Effort', supersedes: 'Issue', superseded_by: 'Issue' }, + refs: { + effort: 'Effort', + supersedes: 'Issue', + superseded_by: 'Issue', + ...CITE_REFS, + }, }, { collection: 'Finding', @@ -17,6 +31,7 @@ export function effortGraphContent( effort: 'Effort', supersedes: 'Finding', superseded_by: 'Finding', + ...CITE_REFS, }, }, { @@ -27,6 +42,7 @@ export function effortGraphContent( supersedes: 'Decision', superseded_by: 'Decision', rejected_by: 'Decision', + ...CITE_REFS, }, }, { @@ -36,6 +52,7 @@ export function effortGraphContent( effort: 'Effort', supersedes: 'Constraint', superseded_by: 'Constraint', + ...CITE_REFS, }, }, { @@ -46,8 +63,19 @@ export function effortGraphContent( supersedes: 'Risk', superseded_by: 'Risk', mitigated_by: 'Decision', + ...CITE_REFS, }, }, + { + collection: 'Citation', + path: `${root}/citations`, + refs: { effort: 'Effort', blob: 'Blob' }, + }, + { + collection: 'Blob', + path: `${root}/blobs`, + refs: { effort: 'Effort' }, + }, ]; } @@ -75,6 +103,8 @@ export function findEffortGraphContentRoot( 'Decision', 'Constraint', 'Risk', + 'Citation', + 'Blob', ]; const effort = content.find( (entry) => entry.collection === 'Effort' && typeof entry.path === 'string' diff --git a/packages/effort-graph/src/read.ts b/packages/effort-graph/src/read.ts index ff35248b..7296f8d1 100644 --- a/packages/effort-graph/src/read.ts +++ b/packages/effort-graph/src/read.ts @@ -11,6 +11,7 @@ export const READ_RELATIONS = [ 'mitigated_by', 'resolved_by', 'evidence', + 'cites', ] as const; export interface ReadOptions { diff --git a/packages/effort-graph/src/schemas.ts b/packages/effort-graph/src/schemas.ts index 48d0172e..8f810811 100644 --- a/packages/effort-graph/src/schemas.ts +++ b/packages/effort-graph/src/schemas.ts @@ -3,6 +3,9 @@ const id = z .string() .regex(/^[a-z]{3}-[a-z0-9-]+--[0123456789abcdefghjkmnpqrstvwxyz]{16}$/); const effort = id; +const cites = { + cites: id.array().optional(), +}; const common = { id: id.optional(), title: z.string().min(1), @@ -20,6 +23,7 @@ const edges = { export const CreateEffortSchema = z.object({ type: z.literal('CreateEffort'), ...common, + ...cites, slug: z.string().optional(), }); export const SetEffortStatusSchema = z.object({ @@ -27,7 +31,7 @@ export const SetEffortStatusSchema = z.object({ effortId: id, status: z.enum(['active', 'paused', 'completed', 'abandoned']), }); -const createBase = { ...common, ...edges, effort: id }; +const createBase = { ...common, ...edges, ...cites, effort: id }; export const WriteIssueSchema = z.object({ type: z.literal('WriteIssue'), ...createBase, @@ -53,6 +57,20 @@ export const WriteRiskSchema = z.object({ likelihood: z.enum(['low', 'medium', 'high']), severity: z.enum(['low', 'medium', 'high']), }); +export const WriteCitationSchema = z.object({ + type: z.literal('WriteCitation'), + ...common, + effort: id, + /** Optional longform/payload target; body alone (e.g. a URL) is valid. */ + blob: id.optional(), + role: z.string().min(1).optional(), +}); +export const WriteBlobSchema = z.object({ + type: z.literal('WriteBlob'), + ...common, + effort: id, + kind: z.string().min(1).optional(), +}); export const SupersedeSchema = z.object({ type: z.literal('Supersede'), supersederId: id, @@ -93,6 +111,8 @@ export const EffortGraphMutationSchema = z.discriminatedUnion('type', [ WriteDecisionSchema, WriteConstraintSchema, WriteRiskSchema, + WriteCitationSchema, + WriteBlobSchema, SupersedeSchema, InvalidateSchema, ResolveIssueSchema, @@ -164,6 +184,25 @@ export const RiskFrontmatterSchema = z severity: z.enum(['low', 'medium', 'high']), }) .passthrough(); +export const CitationFrontmatterSchema = z + .object({ + id, + effort, + title: z.string().min(1), + created_at: z.string(), + blob: id.optional(), + role: z.string().optional(), + }) + .passthrough(); +export const BlobFrontmatterSchema = z + .object({ + id, + effort, + title: z.string().min(1), + created_at: z.string(), + kind: z.string().optional(), + }) + .passthrough(); export const FrontmatterSchemas = { effort: EffortFrontmatterSchema, issue: IssueFrontmatterSchema, @@ -171,4 +210,6 @@ export const FrontmatterSchemas = { decision: DecisionFrontmatterSchema, constraint: ConstraintFrontmatterSchema, risk: RiskFrontmatterSchema, + citation: CitationFrontmatterSchema, + blob: BlobFrontmatterSchema, }; diff --git a/packages/effort-graph/src/types.ts b/packages/effort-graph/src/types.ts index 12846b20..83d542b6 100644 --- a/packages/effort-graph/src/types.ts +++ b/packages/effort-graph/src/types.ts @@ -4,7 +4,9 @@ export type PrimitiveKind = | 'finding' | 'decision' | 'constraint' - | 'risk'; + | 'risk' + | 'blob' + | 'citation'; export type GenerationToken = string; export interface WrittenArtifact { id: string; diff --git a/packages/flatbread/src/cli/effort.test.ts b/packages/flatbread/src/cli/effort.test.ts index 4b2ea42d..a897cf08 100644 --- a/packages/flatbread/src/cli/effort.test.ts +++ b/packages/flatbread/src/cli/effort.test.ts @@ -130,6 +130,8 @@ test.serial( 'decisions', 'constraints', 'risks', + 'citations', + 'blobs', ]) await mkdir(join(cwd, '.flatbread-efforts', directory), { recursive: true, @@ -230,6 +232,8 @@ test.serial( 'decisions', 'constraints', 'risks', + 'citations', + 'blobs', ]) await mkdir(join(cwd, '.flatbread-efforts', directory), { recursive: true, @@ -264,6 +268,8 @@ test.serial( 'decisions', 'constraints', 'risks', + 'citations', + 'blobs', ]) await mkdir(join(cwd, '.flatbread-efforts', directory), { recursive: true, @@ -331,6 +337,8 @@ test.serial( 'decisions', 'constraints', 'risks', + 'citations', + 'blobs', ]) await mkdir(join(cwd, '.flatbread-efforts', directory), { recursive: true, diff --git a/packages/flatbread/src/effort/read.ts b/packages/flatbread/src/effort/read.ts index df6f0bd1..04bde5b8 100644 --- a/packages/flatbread/src/effort/read.ts +++ b/packages/flatbread/src/effort/read.ts @@ -33,7 +33,9 @@ type Collection = | 'Finding' | 'Decision' | 'Constraint' - | 'Risk'; + | 'Risk' + | 'Citation' + | 'Blob'; const COLLECTIONS: readonly Collection[] = [ 'Effort', @@ -42,6 +44,8 @@ const COLLECTIONS: readonly Collection[] = [ 'Decision', 'Constraint', 'Risk', + 'Citation', + 'Blob', ]; const KIND_TO_COLLECTION: Record = { effort: 'Effort', @@ -50,6 +54,8 @@ const KIND_TO_COLLECTION: Record = { decision: 'Decision', constraint: 'Constraint', risk: 'Risk', + citation: 'Citation', + blob: 'Blob', }; const FRONTMATTER_FIELDS = [ 'effort', @@ -57,6 +63,8 @@ const FRONTMATTER_FIELDS = [ 'kind', 'status', 'state', + 'role', + 'blob', 'created_at', 'slug', 'produced_in', @@ -70,6 +78,7 @@ const FRONTMATTER_FIELDS = [ 'rejected_by', 'mitigated_by', 'evidence', + 'cites', ] as const; const RELATION_FIELDS = new Set([ 'derives_from', @@ -81,6 +90,7 @@ const RELATION_FIELDS = new Set([ 'mitigated_by', 'resolved_by', 'evidence', + 'cites', ]); function plural(collection: Collection): string { @@ -90,6 +100,8 @@ function plural(collection: Collection): string { ? 'Findings' : collection === 'Risk' ? 'Risks' + : collection === 'Citation' + ? 'Citations' : `${collection}s`; } @@ -111,6 +123,10 @@ function collectionForId(id: string): Collection | undefined { ? 'Constraint' : prefix === 'rsk' ? 'Risk' + : prefix === 'cit' + ? 'Citation' + : prefix === 'blb' + ? 'Blob' : undefined; } @@ -141,7 +157,7 @@ function toRecord(node: RawNode, collection: Collection): ReadRecord { if (node[field] === undefined) continue; const key = rawKey(field); const ids = relationIds(node[field]); - if (field === 'effort') frontmatter[key] = ids[0]; + if (field === 'effort' || field === 'blob') frontmatter[key] = ids[0]; else if (RELATION_FIELDS.has(field)) relations[key as ReadRelation] = ids; else frontmatter[key] = node[field]; } @@ -344,14 +360,17 @@ class EngineProjection { 'invalidated_by', 'resolved_by', 'evidence', + 'role', ...((available.has('_content') ? ['_content { raw }'] : []) as string[]), ].filter((field) => available.has(field.split(' ', 1)[0])); for (const relation of [ 'effort', + 'blob', 'supersedes', 'superseded_by', 'rejected_by', 'mitigated_by', + 'cites', ]) if (available.has(relation) && !fields.includes(relation)) fields.push(`${relation} { id }`); @@ -529,6 +548,8 @@ export async function effortRecords( 'decision', 'constraint', 'risk', + 'citation', + 'blob', ] as PrimitiveKind[]); const where = options.where ?? {}; // The generated relation field materializes as an object, so its `eq` diff --git a/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts b/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts index b2c20879..6d443cb8 100644 --- a/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts +++ b/packages/flatbread/src/graphql/liveServerEffortGraph.test.ts @@ -19,6 +19,8 @@ async function makeDir() { 'decisions', 'constraints', 'risks', + 'citations', + 'blobs', ]) await mkdir(join(root, path), { recursive: true }); await mkdir(join(root, 'plain'), { recursive: true });