arkaik 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,10 +18,10 @@ block below — run `npm run generate`.
18
18
 
19
19
  <!-- GENERATED:SCHEMA:START -->
20
20
  ```typescript
21
- type SpeciesId = "flow" | "view" | "data-model" | "api-endpoint" | "acceptance";
22
- type StatusId = "idea" | "backlog" | "prioritized" | "development" | "releasing" | "live" | "archived" | "blocked";
21
+ type SpeciesId = "flow" | "view" | "data-model" | "api-endpoint" | "acceptance" | "decision";
22
+ type StatusId = "idea" | "discovery" | "backlog" | "development" | "releasing" | "live" | "archived";
23
23
  type PlatformId = "web" | "ios" | "android";
24
- type EdgeTypeId = "composes" | "calls" | "displays" | "queries" | "covers";
24
+ type EdgeTypeId = "composes" | "calls" | "displays" | "queries" | "covers" | "supersedes" | "generates" | "impacts";
25
25
 
26
26
  type PlaylistEntry =
27
27
  | { type: "view"; view_id: string }
@@ -72,6 +72,8 @@ interface Ref {
72
72
 
73
73
  interface NodeMetadata extends Record<string, unknown> {
74
74
  stage?: string;
75
+ /** Non-empty = the node is blocked at its current status. A node id (rendered as a link) or free text. */
76
+ blocked_by?: string;
75
77
  playlist?: FlowPlaylist;
76
78
  platformNotes?: PlatformNotesMap;
77
79
  platformStatuses?: PlatformStatusMap;
@@ -81,6 +83,16 @@ interface NodeMetadata extends Record<string, unknown> {
81
83
  gherkin?: string;
82
84
  /** Acceptance nodes: value elements served — the Why (spec §3.2). */
83
85
  values?: ValueId[];
86
+ /** Product membership; meaningful on flow, view, and acceptance only. */
87
+ product?: string;
88
+ /** Decision nodes: the decision's own status (spec §2). Not a lifecycle status. */
89
+ decision_status?: DecisionStatusId;
90
+ /** Decision nodes: Context — the Why (markdown). */
91
+ context?: string;
92
+ /** Decision nodes: Consequences — the How (markdown). */
93
+ consequences?: string;
94
+ /** Decision nodes: ISO 8601 date the decision was actually made (backfill-friendly; node.created events carry the write date, not this). */
95
+ decided_at?: string;
84
96
  }
85
97
 
86
98
  interface Node {
@@ -103,9 +115,67 @@ interface Edge {
103
115
  metadata?: Record<string, unknown>;
104
116
  }
105
117
 
118
+ type MapKind = "journey" | "system";
119
+
120
+ interface MapLayoutHints extends Record<string, unknown> {
121
+ direction?: "DOWN" | "RIGHT" | (string & {});
122
+ /**
123
+ * Canvas layout algorithm: `"organic"` (force-directed with overlap
124
+ * removal) or `"layered"` (hierarchical tiers). Renderers fall back to the
125
+ * kind's default for unknown values (docs/spec/maps.md § MapDefinition).
126
+ */
127
+ algorithm?: "layered" | "organic" | (string & {});
128
+ }
129
+
130
+ type MapFlowPlatformsMode = "rings" | "bars";
131
+
132
+ type MapViewPlatformsMode = "chips" | "rows";
133
+
134
+ interface MapDisplayOptions extends Record<string, unknown> {
135
+ /** Screenshot (or cover) art on view cards. */
136
+ images?: boolean;
137
+ /** A flow card's platform delivery: the Pyramid's rings, or stacked bars. */
138
+ flow_platforms?: MapFlowPlatformsMode | (string & {});
139
+ /** A view card's platform availability: circular chips, or labelled rows. */
140
+ view_platforms?: MapViewPlatformsMode | (string & {});
141
+ /** What a minimap node's fill encodes: its status, or its species. */
142
+ minimap_color?: MapMinimapColorMode | (string & {});
143
+ }
144
+
145
+ interface MapDefinition extends Record<string, unknown> {
146
+ /** Kebab-case, unique within the project; built-in ids are reserved. */
147
+ id: string;
148
+ title: string;
149
+ description?: string;
150
+ /** Selects the renderer and the selection defaults below. */
151
+ kind: MapKind | (string & {});
152
+ /** Node filter; defaults by kind (docs/spec/maps.md § MapDefinition). */
153
+ species?: (SpeciesId | (string & {}))[];
154
+ /** Edge filter; defaults by kind. */
155
+ edge_types?: (EdgeTypeId | (string & {}))[];
156
+ /** Scope anchor; the journey renderer falls back to `project.root_node_id`. */
157
+ root_node_id?: string;
158
+ /** Product scope; absent = every product (docs/spec/bundle-format.md § Products). */
159
+ product?: string;
160
+ /** Traversal bound from the root; absent = unbounded. */
161
+ depth?: number;
162
+ layout?: MapLayoutHints;
163
+ /** Card rendering; the human twin is `project.metadata.map_display[id]`. */
164
+ display?: MapDisplayOptions;
165
+ }
166
+
106
167
  interface ProjectMetadata extends Record<string, unknown> {
168
+ /**
169
+ * @deprecated Superseded by the per-map `map_display` below. Still parsed,
170
+ * validated, and round-tripped; no renderer reads it.
171
+ */
107
172
  view_card_variant?: "compact" | "large";
108
173
  maps?: MapDefinition[];
174
+ /** Per-map display overrides keyed by map id — built-ins included. */
175
+ map_display?: Record<string, MapDisplayOptions>;
176
+ products?: ProductDefinition[];
177
+ /** Federation (pollen) settings for hosted serving; `plant` is the ariko plant slug this project anchors to. */
178
+ pollen?: { plant?: string } & Record<string, unknown>;
109
179
  }
110
180
 
111
181
  interface Project {
@@ -229,15 +299,22 @@ type KnownJournalEvent =
229
299
  | NodeCreatedEvent
230
300
  | NodeUpdatedEvent
231
301
  | NodeStatusChangedEvent
302
+ | DecisionStatusChangedEvent
232
303
  | NodeDeletedEvent
233
304
  | EdgeAddedEvent
234
305
  | EdgeRemovedEvent
235
306
  | ReleaseTaggedEvent
307
+ | DeliverableShippedEvent
236
308
  | IdeaProposedEvent
237
309
  | RequestFiledEvent
238
310
  | RefAddedEvent
239
311
  | RefRemovedEvent
240
- | RefStatusChangedEvent;
312
+ | RefStatusChangedEvent
313
+ | JournalBaselineEvent
314
+ | QualityAuditCompletedEvent
315
+ | QualityFindingOpenedEvent
316
+ | QualityFindingResolvedEvent
317
+ | QualitySignalTrippedEvent;
241
318
 
242
319
  interface ProjectBundle {
243
320
  /** Bundle Format contract version (docs/spec/bundle-format.md § Schema Versioning). Absent MUST be treated as 1. */
@@ -247,6 +324,8 @@ interface ProjectBundle {
247
324
  edges: Edge[];
248
325
  /** Optional embedded journal — the interchange projection (Level 2). Canonical storage is the JSONL sidecar; see docs/spec/journal.md. */
249
326
  journal?: JournalEvent[];
327
+ /** Optional Kritik quality state — profile, assessments, findings (docs/rfcs/kritik.md § 4.1). Additive; the matrix is derived, never stored. */
328
+ quality?: QualitySection;
250
329
  }
251
330
  ```
252
331
  <!-- GENERATED:SCHEMA:END -->
@@ -282,8 +361,11 @@ exist in the bundle's `nodes` array.
282
361
  | `calls` | view → api-endpoint | View calls this API |
283
362
  | `calls` | flow → api-endpoint | Flow calls this API |
284
363
  | `calls` | api-endpoint → api-endpoint | Endpoint calls another (internal or third-party) API — e.g. a server action / BFF route fanning out to external APIs |
364
+ | `calls` | api-endpoint → view | The server initiates: a webhook, an SSE stream, a push landing on this view (the View card's inbound/read affordance) |
285
365
  | `displays` | view → data-model | View displays data from this model |
286
366
  | `queries` | api-endpoint → data-model | API reads or writes this model |
367
+ | `covers` | acceptance → view | Acceptance anchors a testable promise to this view |
368
+ | `covers` | acceptance → flow | Acceptance anchors a testable promise to this flow |
287
369
 
288
370
  Any other source → target combination for a given edge type is invalid.
289
371
 
@@ -10,9 +10,9 @@
10
10
  *
11
11
  * Usage: node validate-bundle.js <path-to-bundle.json>
12
12
  */
13
- "use strict";var P=require("node:fs"),B=require("node:path");var G=["flow","view","data-model","api-endpoint","acceptance"],C=["idea","backlog","prioritized","development","releasing","live","archived","blocked"],q=["web","ios","android"],K=["composes","calls","displays","queries","covers"];var Q=["saves-time","simplifies","makes-money","reduces-risk","organizes","integrates","connects","reduces-effort","avoids-hassles","reduces-cost","quality","variety","sensory-appeal","informs","reduces-anxiety","rewards-me","nostalgia","design-aesthetics","badge-value","wellness","therapeutic-value","fun-entertainment","attractiveness","provides-access","provides-hope","self-actualization","motivation","heirloom","affiliation-belonging","self-transcendence"];var Z={flow:"F-",view:"V-","data-model":"DM-","api-endpoint":"API-",acceptance:"AC-"};function de(f){let a=e=>typeof e=="string"?e:"";return[...f].sort((e,r)=>{let $=a(e.ts),v=a(r.ts);if($<v)return-1;if($>v)return 1;let p=a(e.id),y=a(r.id);return p<y?-1:p>y?1:0})}function ee(f){let a=[],e=[];return f.split(/\r?\n/).forEach(($,v)=>{let p=v+1;if($.trim()==="")return;let y;try{y=JSON.parse($)}catch(h){e.push({line:p,rule:"journal-line-parse",message:`Line ${p}: not valid JSON \u2014 ${h.message}`,severity:"error"});return}if(typeof y!="object"||y===null||Array.isArray(y)){e.push({line:p,rule:"journal-line-shape",message:`Line ${p}: each journal line must be a single JSON event object.`,severity:"error"});return}let _=y,E=[];if(typeof _.id!="string"&&E.push("id"),typeof _.ts!="string"&&E.push("ts"),typeof _.type!="string"&&E.push("type"),E.length>0){e.push({line:p,rule:"journal-line-shape",message:`Line ${p}: event is missing required envelope field(s): ${E.join(", ")}.`,severity:"error"});return}a.push(_)}),{events:a,findings:e}}var ce={"node.updated":["node_id"],"node.status_changed":["node_id"],"node.deleted":["node_id"],"edge.added":["source_id","target_id"],"ref.added":["node_id"],"ref.removed":["node_id"],"ref.status_changed":["node_id"],"idea.proposed":["node_id"],"request.filed":["node_id"]};function te(f){let a=[],e=f.journal;if(e===void 0)return a;if(!Array.isArray(e))return a.push({path:"journal",rule:"journal-shape",message:"journal must be an array of events when present.",severity:"error"}),a;if(e.length===0)return a;let r=o=>typeof o=="string"?o:void 0,$=Array.isArray(f.nodes)?f.nodes:[],v=Array.isArray(f.edges)?f.edges:[],p=new Map;for(let o of $){let d=r(o?.id);d!==void 0&&p.set(d,o.status)}let y=new Set;for(let o of v){let d=r(o?.id);d!==void 0&&y.add(d)}let _=[];e.forEach((o,d)=>{let w=`journal[${d}]`;if(typeof o!="object"||o===null||Array.isArray(o)){a.push({path:w,rule:"journal-event-shape",message:`journal[${d}]: each event must be a JSON object.`,severity:"error"});return}let S=o,j=[];if(r(S.id)===void 0&&j.push("id"),r(S.ts)===void 0&&j.push("ts"),r(S.type)===void 0&&j.push("type"),j.length>0){a.push({path:w,rule:"journal-event-envelope",message:`journal[${d}]: event is missing required envelope field(s): ${j.join(", ")}.`,severity:"error"});return}_.push({ev:S,index:d})});let E=new Set(p.keys()),h=new Set(y);for(let{ev:o}of _)if(o.type==="node.created"){let d=r(o.node_id);d&&E.add(d)}else if(o.type==="edge.added"){let d=r(o.edge_id);d&&h.add(d)}let R=de(_.map(o=>o.ev)),I=new Set,b=new Map;for(let o of R)if(o.type==="node.created"){let d=r(o.node_id);d&&I.add(d)}else if(o.type==="node.status_changed"){let d=r(o.node_id);if(d&&o.platform===void 0){let w=r(o.to);w!==void 0&&b.set(d,w)}}for(let{ev:o,index:d}of _){let w=ce[o.type];if(w)for(let S of w){let j=r(o[S]);j!==void 0&&!E.has(j)&&a.push({path:`journal[${d}].${S}`,rule:"journal-dangling-node-ref",message:`journal[${d}] (${o.type}): references node "${j}" that never existed in the snapshot or journal.`,severity:"error"})}if(o.type==="edge.removed"){let S=r(o.edge_id);S!==void 0&&!h.has(S)&&a.push({path:`journal[${d}].edge_id`,rule:"journal-dangling-edge-ref",message:`journal[${d}] (edge.removed): references edge "${S}" that never existed in the snapshot or journal.`,severity:"error"})}}for(let[o,d]of p){I.has(o)||a.push({path:"journal",rule:"journal-missing-node-created",message:`Node "${o}" is present in the snapshot but has no node.created event in the journal.`,severity:"error"});let w=b.get(o);w!==void 0&&w!==d&&a.push({path:"journal",rule:"journal-status-mismatch",message:`Node "${o}": journal's last node.status_changed.to "${w}" disagrees with snapshot status "${String(d)}".`,severity:"error"})}return a}var le=["journey","system"];function ne(f){return le.includes(f)}var pe=["beta","monitoring","deprecated"],se=["compact","large"],ie=2*1024*1024;function fe(f){let a=f.indexOf(",");if(a===-1)return 0;let e=f.slice(a+1),r=e.endsWith("==")?2:e.endsWith("=")?1:0;return Math.floor(e.length*3/4)-r}var ue={composes:[["flow","view"],["flow","flow"],["view","flow"],["view","view"]],calls:[["view","api-endpoint"],["flow","api-endpoint"],["api-endpoint","api-endpoint"]],displays:[["view","data-model"]],queries:[["api-endpoint","data-model"]],covers:[["acceptance","view"],["acceptance","flow"]]};function oe(f){return typeof f=="string"&&!Number.isNaN(Date.parse(f))}function re(f){let a=[],e=(s,u,n)=>a.push({path:s,rule:u,message:n,severity:"error"}),r=(s,u,n)=>a.push({path:s,rule:u,message:n,severity:"warning"}),$=()=>{let s=a.filter(n=>n.severity==="error"),u=a.filter(n=>n.severity==="warning");return{valid:s.length===0,findings:a,errors:s,warnings:u}};if(typeof f!="object"||f===null||Array.isArray(f))return e("","bundle-shape","Bundle must be an object with project, nodes, and edges."),$();let v=f,p=v.project,y=v.nodes,_=v.edges;if(!p||!y||!_)return e("","bundle-shape","Missing top-level keys (project, nodes, edges)."),$();if(!Array.isArray(y))return e("nodes","bundle-shape","nodes must be an array."),$();if(!Array.isArray(_))return e("edges","bundle-shape","edges must be an array."),$();let E=y,h=_,R=new Map,I=new Set;p.id||e("project.id","project-id-required","project.id is missing"),(typeof p.title!="string"||!p.title.trim())&&e("project.title","title-non-empty","project.title is missing or empty"),p.created_at||e("project.created_at","timestamp-required","project.created_at is missing"),p.updated_at||e("project.updated_at","timestamp-required","project.updated_at is missing"),p.created_at&&!oe(p.created_at)&&r("project.created_at","iso-timestamp","project.created_at is not a valid ISO 8601 timestamp"),p.updated_at&&!oe(p.updated_at)&&r("project.updated_at","iso-timestamp","project.updated_at is not a valid ISO 8601 timestamp");let b=p.metadata;b&&b.view_card_variant!==void 0&&!se.includes(b.view_card_variant)&&e("project.metadata.view_card_variant","view-card-variant",`project.metadata.view_card_variant must be one of ${se.join(", ")} (import rejects other values)`),E.forEach((s,u)=>{let n=`nodes[${u}]`,t=s.id;I.has(t)&&e(`${n}.id`,"duplicate-node-id",`Duplicate node ID: ${t}`),I.add(t),R.set(t,s),(typeof s.title!="string"||!s.title.trim())&&e(`${n}.title`,"title-non-empty",`Node ${t}: title is missing or empty`);let c=s.species,i=Z[c];i&&!(typeof t=="string"&&t.startsWith(i))&&e(`${n}.id`,"species-prefix",`Node ${t}: species "${c}" should have prefix "${i}"`),G.includes(c)||e(`${n}.species`,"valid-species",`Node ${t}: invalid species "${c}"`),s.project_id!==p.id&&e(`${n}.project_id`,"project-id-match",`Node ${t}: project_id "${s.project_id}" does not match project.id "${p.id}"`),C.includes(s.status)||e(`${n}.status`,"valid-status",`Node ${t}: invalid status "${s.status}"`);let m=s.platforms;if(!m||m.length===0)e(`${n}.platforms`,"platforms-non-empty",`Node ${t}: platforms array is empty or missing`);else for(let g of m)q.includes(g)||e(`${n}.platforms`,"valid-platform",`Node ${t}: invalid platform "${g}"`);let l=s.metadata||{};l.stage!==void 0&&!pe.includes(l.stage)&&e(`${n}.metadata.stage`,"valid-stage",`Node ${t}: invalid metadata.stage "${l.stage}"`);let x=l.platformNotes;if(x)for(let g of Object.keys(x))q.includes(g)||e(`${n}.metadata.platformNotes`,"valid-platform",`Node ${t}: platformNotes has invalid platform "${g}"`);let N=l.platformStatuses;if(N)for(let[g,A]of Object.entries(N))q.includes(g)?Array.isArray(m)&&!m.includes(g)&&r(`${n}.metadata.platformStatuses`,"platform-statuses-subset",`Node ${t}: platformStatuses covers platform "${g}" not in node.platforms`):e(`${n}.metadata.platformStatuses`,"valid-platform",`Node ${t}: platformStatuses has invalid platform "${g}"`),C.includes(A)||e(`${n}.metadata.platformStatuses`,"valid-status",`Node ${t}: platformStatuses has invalid status "${A}" for platform "${g}"`);let D=l.platformScreenshots;if(D){for(let[g,A]of Object.entries(D))if(typeof A=="string"&&A.startsWith("data:")){let O=fe(A);O>ie&&r(`${n}.metadata.platformScreenshots`,"screenshot-data-uri-size",`Node ${t}: platformScreenshots.${g} is a ${(O/(1024*1024)).toFixed(1)}MB data URI, above the ${ie/(1024*1024)}MB guidance (docs/spec/bundle-format.md \xA7 Asset Values) \u2014 consider a relative path or hosted URL instead`)}}let T=l.gherkin,M=l.values;if(c==="acceptance"?((typeof T!="string"||!T.trim())&&r(`${n}.metadata.gherkin`,"acceptance-gherkin-missing",`Acceptance ${t}: metadata.gherkin is missing or empty (one Given/When/Then scenario expected)`),(!Array.isArray(M)||M.length===0)&&r(`${n}.metadata.values`,"acceptance-values-missing",`Acceptance ${t}: metadata.values is missing or empty (assign 1-3 value elements)`)):(T!==void 0&&r(`${n}.metadata.gherkin`,"gherkin-species",`Node ${t}: metadata.gherkin is only meaningful on acceptance nodes`),M!==void 0&&r(`${n}.metadata.values`,"values-species",`Node ${t}: metadata.values is only meaningful on acceptance nodes`)),Array.isArray(M))for(let g of M)Q.includes(g)||e(`${n}.metadata.values`,"valid-value",`Node ${t}: invalid value element "${g}"`);let V=l.refs;if(Array.isArray(V)){let g=new Set;V.forEach((A,O)=>{let J=A??{},U=`${n}.metadata.refs[${O}]`,k=J.id;typeof k!="string"||!k.trim()?e(`${U}.id`,"ref-id-required",`Node ${t}: ref is missing an id`):g.has(k)?e(`${U}.id`,"duplicate-ref-id",`Node ${t}: duplicate ref id "${k}"`):g.add(k),(typeof J.url!="string"||!J.url.trim())&&e(`${U}.url`,"ref-url-required",`Node ${t}: ref "${k}" is missing a url`),J.status_mapped!==void 0&&!C.includes(J.status_mapped)&&e(`${U}.status_mapped`,"valid-status",`Node ${t}: ref "${k}" has invalid status_mapped "${J.status_mapped}"`)})}if(c==="flow"){let g=l.playlist;!s.metadata||!g||!g.entries?e(`${n}.metadata.playlist`,"flow-playlist-required",`Flow ${t}: missing metadata.playlist.entries`):g.entries.length===0&&r(`${n}.metadata.playlist`,"flow-playlist-empty",`Flow ${t}: playlist is empty`)}});let o=p.root_node_id;o&&!I.has(o)&&e("project.root_node_id","root-node-exists",`project.root_node_id "${o}" does not reference an existing node`);let d=b?.maps;if(Array.isArray(d)){let s=new Set;d.forEach((u,n)=>{if(typeof u!="object"||u===null||Array.isArray(u))return;let t=u,c=`project.metadata.maps[${n}]`,i=typeof t.id=="string"?t.id:void 0;i!==void 0&&(s.has(i)&&r(`${c}.id`,"map-duplicate-id",`Duplicate map id "${i}"`),s.add(i),ne(i)&&r(`${c}.id`,"map-shadows-built-in",`Map id "${i}" shadows a built-in map and will be ignored`));let m=t.root_node_id;typeof m=="string"&&!I.has(m)&&r(`${c}.root_node_id`,"map-unknown-root",`Map "${i??n}" anchors on "${m}" which does not reference an existing node`),Array.isArray(t.species)&&t.species.forEach((l,x)=>{typeof l=="string"&&!G.includes(l)&&r(`${c}.species[${x}]`,"map-unknown-species",`Map "${i??n}" filters on unknown species "${l}"`)}),Array.isArray(t.edge_types)&&t.edge_types.forEach((l,x)=>{typeof l=="string"&&!K.includes(l)&&r(`${c}.edge_types[${x}]`,"map-unknown-edge-type",`Map "${i??n}" filters on unknown edge type "${l}"`)})})}let w=new Set,S=new Set,j=new Set;h.forEach((s,u)=>{let n=`edges[${u}]`,t=s.id,c=s.source_id,i=s.target_id,m=s.edge_type;w.has(t)&&e(`${n}.id`,"duplicate-edge-id",`Duplicate edge ID: ${t}`),w.add(t);let l=`e-${c}-${i}`;t!==l&&r(`${n}.id`,"edge-id-convention",`Edge ${t}: id does not match convention "${l}" (stale after a rename?)`);let x=`${c}->${i}:${m}`;S.has(x)&&r(`${n}`,"duplicate-edge-relationship",`Duplicate edge relationship: ${x}`),S.add(x),s.project_id!==p.id&&e(`${n}.project_id`,"project-id-match",`Edge ${t}: project_id does not match project.id`),I.has(c)||e(`${n}.source_id`,"dangling-edge",`Edge ${t}: source_id "${c}" not found in nodes`),I.has(i)||e(`${n}.target_id`,"dangling-edge",`Edge ${t}: target_id "${i}" not found in nodes`),K.includes(m)||e(`${n}.edge_type`,"valid-edge-type",`Edge ${t}: invalid edge_type "${m}"`);let N=R.get(c),D=R.get(i),T=ue[m];N&&D&&T&&(T.some(([V,g])=>V===N.species&&g===D.species)||e(`${n}.edge_type`,"edge-semantics",`Edge ${t}: "${m}" not valid from ${N.species} to ${D.species}`)),m==="composes"&&j.add(`${c}->${i}`)});let F=(s,u,n,t=0)=>{if(t>50)return e(n,"playlist-depth",`Flow ${u}: playlist nesting too deep (possible cycle)`),[];let c=[];for(let i of s)if(i.type==="view")I.has(i.view_id)||e(n,"playlist-ref-exists",`Flow ${u}: playlist references non-existent view "${i.view_id}"`),c.push(i.view_id);else if(i.type==="flow")I.has(i.flow_id)||e(n,"playlist-ref-exists",`Flow ${u}: playlist references non-existent flow "${i.flow_id}"`),i.flow_id===u&&e(n,"playlist-self-cycle",`Flow ${u}: playlist contains itself (direct cycle)`),c.push(i.flow_id);else if(i.type==="condition")i.if_true&&c.push(...F(i.if_true,u,n,t+1)),i.if_false&&c.push(...F(i.if_false,u,n,t+1));else if(i.type==="junction"&&i.cases)for(let m of i.cases)c.push(...F(m.entries||[],u,n,t+1));return c};E.forEach((s,u)=>{let n=s.metadata;if(s.species==="flow"&&n?.playlist?.entries){let t=`nodes[${u}].metadata.playlist`,c=s.id,i=F(n.playlist.entries,c,t);for(let m of i)j.has(`${c}->${m}`)||e(t,"playlist-composes-coherence",`Flow ${c}: playlist references "${m}" but no composes edge exists`)}});let W=new Map,H=new Map;E.forEach((s,u)=>{let n=s.metadata;if(s.species==="flow"&&n?.playlist?.entries){let t=s.id,c=[],i=m=>{for(let l of m)if(l.type==="flow"&&c.push(l.flow_id),l.type==="condition"&&(l.if_true&&i(l.if_true),l.if_false&&i(l.if_false)),l.type==="junction"&&l.cases)for(let x of l.cases)i(x.entries||[])};i(n.playlist.entries),W.set(t,c),H.set(t,`nodes[${u}].metadata.playlist`)}});let Y=new Set,z=new Set,X=s=>{if(z.has(s))return!0;if(Y.has(s))return!1;Y.add(s),z.add(s);for(let u of W.get(s)||[])if(X(u))return e(H.get(s)??"","flow-cycle",`Cycle detected: flow "${s}" -> "${u}"`),!0;return z.delete(s),!1};for(let s of W.keys())X(s);for(let s of te(v))a.push(s);return $()}var ae="journal.jsonl";function L(f,a){return f.filter(e=>e?.species===a).length}function ge(){let f=process.argv[2];f||(console.error("Usage: node validate-bundle.js <path-to-bundle.json>"),process.exit(1)),(0,P.existsSync)(f)||(console.error(`File not found: ${f}`),process.exit(1));let a;try{a=JSON.parse((0,P.readFileSync)(f,"utf8"))}catch(h){console.error(`FATAL: Cannot parse JSON \u2014 ${h.message}`),process.exit(1)}let e=a;(typeof e!="object"||e===null||!e.project||!e.nodes||!e.edges)&&(console.error("FATAL: Missing top-level keys (project, nodes, edges)."),process.exit(1));let r=[],$=!1;if(e.journal===void 0){let h=(0,B.join)((0,B.dirname)(f),ae);if((0,P.existsSync)(h)){let{events:R,findings:I}=ee((0,P.readFileSync)(h,"utf8"));r=I,$=!0,e.journal=R}}let v=Array.isArray(e.nodes)?e.nodes:[],p=Array.isArray(e.edges)?e.edges:[],y=Array.isArray(e.journal)?e.journal:[],_=re(a);console.log(`
13
+ "use strict";var U=require("node:fs"),Q=require("node:path");var H=["flow","view","data-model","api-endpoint","acceptance","decision"],K=["idea","discovery","backlog","development","releasing","live","archived"],B=["web","ios","android"],ne=["composes","calls","displays","queries","covers","supersedes","generates","impacts"],me={composes:[["flow","view"],["flow","flow"],["view","flow"],["view","view"]],calls:[["view","api-endpoint"],["flow","api-endpoint"],["api-endpoint","api-endpoint"],["api-endpoint","view"]],displays:[["view","data-model"]],queries:[["api-endpoint","data-model"]],covers:[["acceptance","view"],["acceptance","flow"]],supersedes:[["decision","decision"]],generates:[["decision","acceptance"]],impacts:[["decision","flow"],["decision","view"],["decision","data-model"],["decision","api-endpoint"]]};var he=["saves-time","simplifies","makes-money","reduces-risk","organizes","integrates","connects","reduces-effort","avoids-hassles","reduces-cost","quality","variety","sensory-appeal","informs","reduces-anxiety","rewards-me","nostalgia","design-aesthetics","badge-value","wellness","therapeutic-value","fun-entertainment","attractiveness","provides-access","provides-hope","self-actualization","motivation","heirloom","affiliation-belonging","self-transcendence"];var _e={flow:"F-",view:"V-","data-model":"DM-","api-endpoint":"API-",acceptance:"AC-",decision:"DEC-"};var De=["proposed","approved","enacted","rejected","deprecated","superseded"];function ve(f){switch(f){case"proposed":return"discovery";case"approved":return"backlog";case"enacted":return"live";case"rejected":case"deprecated":case"superseded":return"archived"}}function we(f){let p=f.metadata?.decision_status;return De.includes(p)?p:"proposed"}var ie={prioritized:"backlog",blocked:"development"};function se(f){return K.includes(f)?f:ie[f]}var Be={...ie},Ye={backlog:"idea",...ie};function Ne(f){let p=t=>typeof t=="string"?t:"";return[...f].sort((t,s)=>{let $=p(t.ts),w=p(s.ts);if($<w)return-1;if($>w)return 1;let m=p(t.id),I=p(s.id);return m<I?-1:m>I?1:0})}function re(f){let p=[],t=[];return f.split(/\r?\n/).forEach(($,w)=>{let m=w+1;if($.trim()==="")return;let I;try{I=JSON.parse($)}catch(L){t.push({line:m,rule:"journal-line-parse",message:`Line ${m}: not valid JSON \u2014 ${L.message}`,severity:"error"});return}if(typeof I!="object"||I===null||Array.isArray(I)){t.push({line:m,rule:"journal-line-shape",message:`Line ${m}: each journal line must be a single JSON event object.`,severity:"error"});return}let T=I,E=[];if(typeof T.id!="string"&&E.push("id"),typeof T.ts!="string"&&E.push("ts"),typeof T.type!="string"&&E.push("type"),E.length>0){t.push({line:m,rule:"journal-line-shape",message:`Line ${m}: event is missing required envelope field(s): ${E.join(", ")}.`,severity:"error"});return}p.push(T)}),{events:p,findings:t}}var Te={"node.updated":["node_id"],"node.status_changed":["node_id"],"decision.status_changed":["node_id"],"node.deleted":["node_id"],"edge.added":["source_id","target_id"],"ref.added":["node_id"],"ref.removed":["node_id"],"ref.status_changed":["node_id"],"idea.proposed":["node_id"],"request.filed":["node_id"]};function Fe(f,p){return f===p?!0:typeof p!="string"?!1:f==="backlog"&&p==="idea"?!0:(se(f)??f)===(se(p)??p)}function Se(f){let p=[],t=f.journal;if(t===void 0)return p;if(!Array.isArray(t))return p.push({path:"journal",rule:"journal-shape",message:"journal must be an array of events when present.",severity:"error"}),p;if(t.length===0)return p;let s=c=>typeof c=="string"?c:void 0,$=Array.isArray(f.nodes)?f.nodes:[],w=Array.isArray(f.edges)?f.edges:[],m=new Map,I=new Map;for(let c of $){let g=s(c?.id);if(g!==void 0){m.set(g,c.status);let _=c.metadata,x=_&&typeof _=="object"&&!Array.isArray(_)?_.decision_status:void 0;I.set(g,x??"proposed")}}let T=new Set;for(let c of w){let g=s(c?.id);g!==void 0&&T.add(g)}let E=[];t.forEach((c,g)=>{let _=`journal[${g}]`;if(typeof c!="object"||c===null||Array.isArray(c)){p.push({path:_,rule:"journal-event-shape",message:`journal[${g}]: each event must be a JSON object.`,severity:"error"});return}let x=c,j=[];if(s(x.id)===void 0&&j.push("id"),s(x.ts)===void 0&&j.push("ts"),s(x.type)===void 0&&j.push("type"),j.length>0){p.push({path:_,rule:"journal-event-envelope",message:`journal[${g}]: event is missing required envelope field(s): ${j.join(", ")}.`,severity:"error"});return}E.push({ev:x,index:g})});let L=new Set(m.keys()),k=new Set(T),F=new Set;for(let{ev:c}of E)if(c.type==="node.created"){let g=s(c.node_id);g&&L.add(g)}else if(c.type==="edge.added"){let g=s(c.edge_id);g&&k.add(g)}else if(c.type==="journal.baseline"&&Array.isArray(c.node_ids))for(let g of c.node_ids){let _=s(g);_&&(F.add(_),L.add(_))}let O=Ne(E.map(c=>c.ev)),J=new Set,G=new Map,q=new Map;for(let c of O)if(c.type==="node.created"){let g=s(c.node_id);g&&J.add(g)}else if(c.type==="node.status_changed"){let g=s(c.node_id);if(g&&c.platform===void 0){let _=s(c.to);_!==void 0&&G.set(g,_)}}else if(c.type==="decision.status_changed"){let g=s(c.node_id);if(g){let _=s(c.to);_!==void 0&&q.set(g,_)}}for(let{ev:c,index:g}of E){let _=Te[c.type];if(_)for(let x of _){let j=s(c[x]);j!==void 0&&!L.has(j)&&p.push({path:`journal[${g}].${x}`,rule:"journal-dangling-node-ref",message:`journal[${g}] (${c.type}): references node "${j}" that never existed in the snapshot or journal.`,severity:"error"})}if(c.type==="deliverable.shipped"&&Array.isArray(c.node_ids)&&c.node_ids.forEach((x,j)=>{let V=s(x);V!==void 0&&!L.has(V)&&p.push({path:`journal[${g}].node_ids[${j}]`,rule:"journal-dangling-node-ref",message:`journal[${g}] (${c.type}): references node "${V}" that never existed in the snapshot or journal.`,severity:"error"})}),c.type==="edge.removed"){let x=s(c.edge_id);x!==void 0&&!k.has(x)&&p.push({path:`journal[${g}].edge_id`,rule:"journal-dangling-edge-ref",message:`journal[${g}] (edge.removed): references edge "${x}" that never existed in the snapshot or journal.`,severity:"error"})}}for(let[c,g]of m){!J.has(c)&&!F.has(c)&&p.push({path:"journal",rule:"journal-missing-node-created",message:`Node "${c}" is present in the snapshot but has no node.created event in the journal.`,severity:"error"});let _=G.get(c);_!==void 0&&!Fe(_,g)&&p.push({path:"journal",rule:"journal-status-mismatch",message:`Node "${c}": journal's last node.status_changed.to "${_}" disagrees with snapshot status "${String(g)}".`,severity:"error"})}for(let[c,g]of q){if(!I.has(c))continue;let _=I.get(c)??"proposed";g!==_&&p.push({path:"journal",rule:"journal-decision-status-mismatch",message:`Node "${c}": journal's last decision.status_changed.to "${g}" disagrees with snapshot decision_status "${String(_)}".`,severity:"error"})}return p}var $e=["flow","view","acceptance"];function be(f){let p=f?.metadata?.products;if(!Array.isArray(p))return[];let t=new Set,s=[];for(let $ of p){if(typeof $!="object"||$===null||Array.isArray($))continue;let w=$;typeof w.id!="string"||w.id.trim()===""||t.has(w.id)||(t.add(w.id),s.push(w))}return s}var Ee=["rings","bars"],Ae=["chips","rows"],ke=["status","species"];var Le=["journey","system"];function xe(f){return Le.includes(f)}var X="cross-surface";var Oe=["beta","monitoring","deprecated"],Ie=["compact","large"],Re=2*1024*1024;function Ce(f){let p=f.indexOf(",");if(p===-1)return 0;let t=f.slice(p+1),s=t.endsWith("==")?2:t.endsWith("=")?1:0;return Math.floor(t.length*3/4)-s}function Me(f){return typeof f=="string"&&!Number.isNaN(Date.parse(f))}function Pe(f){let p=[],t=(e,r,n)=>p.push({path:e,rule:r,message:n,severity:"error"}),s=(e,r,n)=>p.push({path:e,rule:r,message:n,severity:"warning"}),$=()=>{let e=p.filter(n=>n.severity==="error"),r=p.filter(n=>n.severity==="warning");return{valid:e.length===0,findings:p,errors:e,warnings:r}};if(typeof f!="object"||f===null||Array.isArray(f))return t("","bundle-shape","Bundle must be an object with project, nodes, and edges."),$();let w=f,m=w.project,I=w.nodes,T=w.edges;if(!m||!I||!T)return t("","bundle-shape","Missing top-level keys (project, nodes, edges)."),$();if(!Array.isArray(I))return t("nodes","bundle-shape","nodes must be an array."),$();if(!Array.isArray(T))return t("edges","bundle-shape","edges must be an array."),$();let E=I,L=T,k=new Map,F=new Set;m.id||t("project.id","project-id-required","project.id is missing"),(typeof m.title!="string"||!m.title.trim())&&t("project.title","title-non-empty","project.title is missing or empty"),m.created_at||t("project.created_at","timestamp-required","project.created_at is missing"),m.updated_at||t("project.updated_at","timestamp-required","project.updated_at is missing"),m.created_at&&!Me(m.created_at)&&s("project.created_at","iso-timestamp","project.created_at is not a valid ISO 8601 timestamp"),m.updated_at&&!Me(m.updated_at)&&s("project.updated_at","iso-timestamp","project.updated_at is not a valid ISO 8601 timestamp");let O=m.metadata;O&&O.view_card_variant!==void 0&&!Ie.includes(O.view_card_variant)&&t("project.metadata.view_card_variant","view-card-variant",`project.metadata.view_card_variant must be one of ${Ie.join(", ")} (import rejects other values)`),E.forEach((e,r)=>{let n=`nodes[${r}]`,i=e.id;F.has(i)&&t(`${n}.id`,"duplicate-node-id",`Duplicate node ID: ${i}`),F.add(i),k.set(i,e),(typeof e.title!="string"||!e.title.trim())&&t(`${n}.title`,"title-non-empty",`Node ${i}: title is missing or empty`);let a=e.species,o=_e[a];o&&!(typeof i=="string"&&i.startsWith(o))&&t(`${n}.id`,"species-prefix",`Node ${i}: species "${a}" should have prefix "${o}"`),H.includes(a)||t(`${n}.species`,"valid-species",`Node ${i}: invalid species "${a}"`),e.project_id!==m.id&&t(`${n}.project_id`,"project-id-match",`Node ${i}: project_id "${e.project_id}" does not match project.id "${m.id}"`),K.includes(e.status)||t(`${n}.status`,"valid-status",`Node ${i}: invalid status "${e.status}"`);let l=e.platforms;if(!l||l.length===0)a!=="decision"&&t(`${n}.platforms`,"platforms-non-empty",`Node ${i}: platforms array is empty or missing`);else for(let d of l)B.includes(d)||t(`${n}.platforms`,"valid-platform",`Node ${i}: invalid platform "${d}"`);let u=e.metadata||{};u.stage!==void 0&&!Oe.includes(u.stage)&&t(`${n}.metadata.stage`,"valid-stage",`Node ${i}: invalid metadata.stage "${u.stage}"`);let S=u.platformNotes;if(S)for(let d of Object.keys(S))B.includes(d)||t(`${n}.metadata.platformNotes`,"valid-platform",`Node ${i}: platformNotes has invalid platform "${d}"`);let b=u.platformStatuses;if(b)for(let[d,y]of Object.entries(b))B.includes(d)?Array.isArray(l)&&!l.includes(d)&&s(`${n}.metadata.platformStatuses`,"platform-statuses-subset",`Node ${i}: platformStatuses covers platform "${d}" not in node.platforms`):t(`${n}.metadata.platformStatuses`,"valid-platform",`Node ${i}: platformStatuses has invalid platform "${d}"`),K.includes(y)||t(`${n}.metadata.platformStatuses`,"valid-status",`Node ${i}: platformStatuses has invalid status "${y}" for platform "${d}"`);let R=u.platformScreenshots;if(R){for(let[d,y]of Object.entries(R))if(typeof y=="string"&&y.startsWith("data:")){let M=Ce(y);M>Re&&s(`${n}.metadata.platformScreenshots`,"screenshot-data-uri-size",`Node ${i}: platformScreenshots.${d} is a ${(M/(1024*1024)).toFixed(1)}MB data URI, above the ${Re/(1024*1024)}MB guidance (docs/spec/bundle-format.md \xA7 Asset Values) \u2014 consider a relative path or hosted URL instead`)}}let P=u.gherkin,C=u.values;if(a==="acceptance"?((typeof P!="string"||!P.trim())&&s(`${n}.metadata.gherkin`,"acceptance-gherkin-missing",`Acceptance ${i}: metadata.gherkin is missing or empty (one Given/When/Then scenario expected)`),(!Array.isArray(C)||C.length===0)&&s(`${n}.metadata.values`,"acceptance-values-missing",`Acceptance ${i}: metadata.values is missing or empty (assign 1-3 value elements)`)):(P!==void 0&&s(`${n}.metadata.gherkin`,"gherkin-species",`Node ${i}: metadata.gherkin is only meaningful on acceptance nodes`),C!==void 0&&s(`${n}.metadata.values`,"values-species",`Node ${i}: metadata.values is only meaningful on acceptance nodes`)),Array.isArray(C))for(let d of C)he.includes(d)||t(`${n}.metadata.values`,"valid-value",`Node ${i}: invalid value element "${d}"`);let h=u.refs;if(Array.isArray(h)){let d=new Set;h.forEach((y,M)=>{let A=y??{},D=`${n}.metadata.refs[${M}]`,N=A.id;typeof N!="string"||!N.trim()?t(`${D}.id`,"ref-id-required",`Node ${i}: ref is missing an id`):d.has(N)?t(`${D}.id`,"duplicate-ref-id",`Node ${i}: duplicate ref id "${N}"`):d.add(N),(typeof A.url!="string"||!A.url.trim())&&t(`${D}.url`,"ref-url-required",`Node ${i}: ref "${N}" is missing a url`),A.status_mapped!==void 0&&!K.includes(A.status_mapped)&&t(`${D}.status_mapped`,"valid-status",`Node ${i}: ref "${N}" has invalid status_mapped "${A.status_mapped}"`)})}if(u.decision_status!==void 0&&a!=="decision"&&s(`${n}.metadata.decision_status`,"decision-status-wrong-species",`decision_status is meaningful on decision nodes only; "${i}" is a ${a}.`),a==="decision"){let d=we({metadata:e.metadata}),y=ve(d);e.status!==y&&s(`${n}.status`,"decision-lifecycle-mismatch",`Decision "${i}" is ${d}, whose lifecycle status should be "${y}", but status is "${e.status}" (spec \xA72).`)}if(a==="flow"){let d=u.playlist;!e.metadata||!d||!d.entries?t(`${n}.metadata.playlist`,"flow-playlist-required",`Flow ${i}: missing metadata.playlist.entries`):d.entries.length===0&&s(`${n}.metadata.playlist`,"flow-playlist-empty",`Flow ${i}: playlist is empty`)}});let J=m.root_node_id;J&&!F.has(J)&&t("project.root_node_id","root-node-exists",`project.root_node_id "${J}" does not reference an existing node`);let G=(e,r,n)=>{if(typeof e!="object"||e===null||Array.isArray(e))return;let i=e,a=[["flow_platforms",Ee],["view_platforms",Ae],["minimap_color",ke]];for(let[o,l]of a){let u=i[o];u!==void 0&&(typeof u!="string"||!l.includes(u))&&s(`${r}.${o}`,"map-unknown-display",`${n} sets ${o} to "${String(u)}"; expected one of ${l.join(", ")} (renderers fall back to the default)`)}i.images!==void 0&&typeof i.images!="boolean"&&s(`${r}.images`,"map-unknown-display",`${n} sets images to a non-boolean value (renderers fall back to the default)`)},q=O?.map_display;if(typeof q=="object"&&q!==null&&!Array.isArray(q))for(let[e,r]of Object.entries(q))G(r,`project.metadata.map_display.${e}`,`Map "${e}"`);let c=O?.maps;if(Array.isArray(c)){let e=new Set;c.forEach((r,n)=>{if(typeof r!="object"||r===null||Array.isArray(r))return;let i=r,a=`project.metadata.maps[${n}]`,o=typeof i.id=="string"?i.id:void 0;o!==void 0&&(e.has(o)&&s(`${a}.id`,"map-duplicate-id",`Duplicate map id "${o}"`),e.add(o),xe(o)&&s(`${a}.id`,"map-shadows-built-in",`Map id "${o}" shadows a built-in map and will be ignored`));let l=i.root_node_id;typeof l=="string"&&!F.has(l)&&s(`${a}.root_node_id`,"map-unknown-root",`Map "${o??n}" anchors on "${l}" which does not reference an existing node`),Array.isArray(i.species)&&i.species.forEach((u,S)=>{typeof u=="string"&&!H.includes(u)&&s(`${a}.species[${S}]`,"map-unknown-species",`Map "${o??n}" filters on unknown species "${u}"`)}),Array.isArray(i.edge_types)&&i.edge_types.forEach((u,S)=>{typeof u=="string"&&!ne.includes(u)&&s(`${a}.edge_types[${S}]`,"map-unknown-edge-type",`Map "${o??n}" filters on unknown edge type "${u}"`)}),G(i.display,`${a}.display`,`Map "${o??n}"`)})}let g=O?.products,_=new Set;if(Array.isArray(g)){let e=new Set;g.forEach((r,n)=>{if(typeof r!="object"||r===null||Array.isArray(r))return;let i=r,a=`project.metadata.products[${n}]`,o=typeof i.id=="string"?i.id:void 0;o!==void 0&&(e.has(o)?s(`${a}.id`,"product-duplicate-id",`Duplicate product id "${o}" \u2014 the first wins`):(e.add(o),o.trim()!==""&&_.add(o)),/^[a-z0-9]+(-[a-z0-9]+)*$/.test(o)||s(`${a}.id`,"product-invalid-id",`Product id "${o}" is not kebab-case`))})}let x=_.size>0,j=new Map;for(let e of L){if(e.edge_type!=="covers")continue;let r=typeof e.source_id=="string"?e.source_id:void 0,n=typeof e.target_id=="string"?e.target_id:void 0;if(r===void 0||n===void 0)continue;let i=j.get(r)??[];i.push(n),j.set(r,i)}let V=new Map;if(x)for(let e of be({metadata:O}))V.set(e.id,new Set(Array.isArray(e.platforms)?e.platforms:[]));let oe=new Map,ae=new Map;if(E.forEach((e,r)=>{let n=typeof e.id=="string"?e.id:`#${r}`,i=e.species,a=`nodes[${r}]`;ae.set(n,r);let o=e.metadata??{},l=typeof o.product=="string"?o.product:void 0,u=i!==void 0&&$e.includes(i);if(l!==void 0&&!u){let R=H.includes(i)?`${i} membership is derived from consumers and must not be stored`:"metadata.product is only meaningful on flow, view, and acceptance nodes";s(`${a}.metadata.product`,"product-membership-wrong-species",`Node ${n}: ${R}`);return}if(!u)return;if(l===void 0){if(!x)return;i==="acceptance"?j.has(n)||s(`${a}.metadata.product`,"acceptance-product-unassigned",`Acceptance ${n} covers nothing and names no product \u2014 it will show only under "All products"`):s(`${a}.metadata.product`,"unassigned-membership",`Node ${n}: no product membership \u2014 it will show only under "All products"`);return}if(oe.set(n,l),!_.has(l)){s(`${a}.metadata.product`,"product-unknown-reference",`Node ${n}: product "${l}" is not declared on the project`);return}let S=V.get(l),b=Array.isArray(e.platforms)?e.platforms:[];if(S)for(let R of b)typeof R=="string"&&!S.has(R)&&s(`${a}.platforms`,"product-platform-not-in-menu",`Node ${n}: platform "${R}" is not in product "${l}"'s menu`)}),x)for(let[e,r]of j){let n=ae.get(e);if(n===void 0)continue;let i=new Set(r.map(a=>oe.get(a)).filter(a=>!!a));i.size>1&&s(`nodes[${n}].metadata.product`,"acceptance-covers-span-products",`Acceptance ${e} covers anchors in ${[...i].sort().join(" and ")} \u2014 statuses may conflate products`)}let de=new Set,ce=new Set,le=new Set;L.forEach((e,r)=>{let n=`edges[${r}]`,i=e.id,a=e.source_id,o=e.target_id,l=e.edge_type;de.has(i)&&t(`${n}.id`,"duplicate-edge-id",`Duplicate edge ID: ${i}`),de.add(i);let u=`e-${a}-${o}`;i!==u&&s(`${n}.id`,"edge-id-convention",`Edge ${i}: id does not match convention "${u}" (stale after a rename?)`);let S=`${a}->${o}:${l}`;ce.has(S)&&s(`${n}`,"duplicate-edge-relationship",`Duplicate edge relationship: ${S}`),ce.add(S),e.project_id!==m.id&&t(`${n}.project_id`,"project-id-match",`Edge ${i}: project_id does not match project.id`),F.has(a)||t(`${n}.source_id`,"dangling-edge",`Edge ${i}: source_id "${a}" not found in nodes`),F.has(o)||t(`${n}.target_id`,"dangling-edge",`Edge ${i}: target_id "${o}" not found in nodes`),ne.includes(l)||t(`${n}.edge_type`,"valid-edge-type",`Edge ${i}: invalid edge_type "${l}"`);let b=k.get(a),R=k.get(o),P=me[l];b&&R&&P&&(P.some(([h,v])=>h===b.species&&v===R.species)||t(`${n}.edge_type`,"edge-semantics",`Edge ${i}: "${l}" not valid from ${b.species} to ${R.species}`)),l==="composes"&&le.add(`${a}->${o}`)});let ue=["view","flow","condition","junction"],Z=(e,r,n,i=0)=>{if(!(i>50)){if(!Array.isArray(e)){t(n,"playlist-entry-shape",`Flow ${r}: ${n} must be an array of playlist entries`);return}e.forEach((a,o)=>{let l=`${n}[${o}]`;if(typeof a!="object"||a===null||Array.isArray(a)){t(l,"playlist-entry-shape",`Flow ${r}: ${l} must be a playlist entry object`);return}let u=a,S=u.type;if(typeof S!="string"||!ue.includes(S)){t(`${l}.type`,"playlist-entry-shape",`Flow ${r}: ${l}.type must be one of ${ue.join(", ")} (got ${JSON.stringify(S)})`);return}if(S==="view")typeof u.view_id!="string"&&t(`${l}.view_id`,"playlist-entry-shape",`Flow ${r}: ${l} is a view entry with no view_id string`);else if(S==="flow")typeof u.flow_id!="string"&&t(`${l}.flow_id`,"playlist-entry-shape",`Flow ${r}: ${l} is a flow entry with no flow_id string`);else if(S==="condition"){typeof u.label!="string"&&t(`${l}.label`,"playlist-entry-shape",`Flow ${r}: ${l} is a condition with no label string`);for(let b of["if_true","if_false"])Array.isArray(u[b])?Z(u[b],r,`${l}.${b}`,i+1):t(`${l}.${b}`,"playlist-entry-shape",`Flow ${r}: ${l}.${b} must be an array of playlist entries (both branches are required, use [] for an empty one)`)}else{if(typeof u.label!="string"&&t(`${l}.label`,"playlist-entry-shape",`Flow ${r}: ${l} is a junction with no label string`),!Array.isArray(u.cases)){t(`${l}.cases`,"playlist-entry-shape",`Flow ${r}: ${l}.cases must be an array of junction cases`);return}u.cases.forEach((b,R)=>{let P=`${l}.cases[${R}]`;if(typeof b!="object"||b===null||Array.isArray(b)){t(P,"playlist-entry-shape",`Flow ${r}: ${P} must be a junction case object`);return}let C=b;typeof C.label!="string"&&t(`${P}.label`,"playlist-entry-shape",`Flow ${r}: ${P} has no label string`),Array.isArray(C.entries)?Z(C.entries,r,`${P}.entries`,i+1):t(`${P}.entries`,"playlist-entry-shape",`Flow ${r}: ${P}.entries must be an array of playlist entries \u2014 a junction case holds its own entries, not a bare view_id/flow_id`)})}})}};E.forEach((e,r)=>{let n=e.metadata;e.species==="flow"&&n?.playlist?.entries&&Z(n.playlist.entries,e.id,`nodes[${r}].metadata.playlist.entries`)});let z=(e,r,n,i=0)=>{if(i>50)return t(n,"playlist-depth",`Flow ${r}: playlist nesting too deep (possible cycle)`),[];let a=[];if(!Array.isArray(e))return a;for(let o of e)if(!(typeof o!="object"||o===null)){if(o.type==="view")F.has(o.view_id)||t(n,"playlist-ref-exists",`Flow ${r}: playlist references non-existent view "${o.view_id}"`),a.push(o.view_id);else if(o.type==="flow")F.has(o.flow_id)||t(n,"playlist-ref-exists",`Flow ${r}: playlist references non-existent flow "${o.flow_id}"`),o.flow_id===r&&t(n,"playlist-self-cycle",`Flow ${r}: playlist contains itself (direct cycle)`),a.push(o.flow_id);else if(o.type==="condition")o.if_true&&a.push(...z(o.if_true,r,n,i+1)),o.if_false&&a.push(...z(o.if_false,r,n,i+1));else if(o.type==="junction"&&o.cases)for(let l of o.cases)a.push(...z(l.entries||[],r,n,i+1))}return a};E.forEach((e,r)=>{let n=e.metadata;if(e.species==="flow"&&n?.playlist?.entries){let i=`nodes[${r}].metadata.playlist`,a=e.id,o=z(n.playlist.entries,a,i);for(let l of o)le.has(`${a}->${l}`)||t(i,"playlist-composes-coherence",`Flow ${a}: playlist references "${l}" but no composes edge exists`)}});let ee=new Map,pe=new Map;E.forEach((e,r)=>{let n=e.metadata;if(e.species==="flow"&&n?.playlist?.entries){let i=e.id,a=[],o=l=>{if(Array.isArray(l)){for(let u of l)if(!(typeof u!="object"||u===null)&&(u.type==="flow"&&a.push(u.flow_id),u.type==="condition"&&(u.if_true&&o(u.if_true),u.if_false&&o(u.if_false)),u.type==="junction"&&u.cases))for(let S of u.cases)o(S.entries||[])}};o(n.playlist.entries),ee.set(i,a),pe.set(i,`nodes[${r}].metadata.playlist`)}});let fe=new Set,te=new Set,ge=e=>{if(te.has(e))return!0;if(fe.has(e))return!1;fe.add(e),te.add(e);for(let r of ee.get(e)||[])if(ge(r))return t(pe.get(e)??"","flow-cycle",`Cycle detected: flow "${e}" -> "${r}"`),!0;return te.delete(e),!1};for(let e of ee.keys())ge(e);let W=w.quality;if(typeof W=="object"&&W!==null&&!Array.isArray(W)){let e=W,r=Array.isArray(e.assessments)?e.assessments:[],n=Array.isArray(e.findings)?e.findings:[],i=typeof e.profile=="object"&&e.profile!==null?e.profile:void 0,a=new Set;Array.isArray(i?.surfaces)&&i.surfaces.forEach((h,v)=>{if(typeof h!="object"||h===null||Array.isArray(h))return;let d=h,y=typeof d.id=="string"?d.id:void 0;y===void 0||y.trim()===""||(a.has(y)&&s(`quality.profile.surfaces[${v}].id`,"quality-duplicate-surface",`Duplicate surface id "${y}" \u2014 the first wins`),a.add(y))}),a.size===0&&(r.length>0||n.length>0)&&s("quality.profile.surfaces","quality-no-surfaces","Quality data is stored but the profile declares no surfaces \u2014 the matrix falls back to the surfaces the assessments name");let o=typeof e.library=="object"&&e.library!==null?e.library:void 0,l=new Set,u=new Map;if(Array.isArray(o?.criteria))for(let h of o.criteria){if(typeof h!="object"||h===null)continue;let v=h;typeof v.id=="string"&&(l.add(v.id),typeof v.superseded_by=="string"&&v.superseded_by!==""&&u.set(v.id,v.superseded_by))}else(r.length>0||n.length>0)&&s("quality.library","quality-library-missing","No criteria pack is embedded \u2014 criterion ids cannot be resolved to domains or weights; the matrix uses a synthesized library");(typeof e.framework_version!="string"||e.framework_version==="")&&s("quality.framework_version","quality-framework-version-missing","Quality section has no framework_version \u2014 scores are not comparable across audits without one");let S=(h,v,d)=>{let y=typeof h.criterion_id=="string"?h.criterion_id:void 0;y!==void 0&&l.size>0&&!l.has(y)&&s(`${v}.criterion_id`,"quality-unknown-criterion",`${d}: criterion "${y}" is not in the pinned library`);let M=y!==void 0?u.get(y):void 0;M!==void 0&&s(`${v}.criterion_id`,"quality-retired-criterion",`${d}: criterion "${y}" is retired \u2014 superseded by "${M}"`);let A=typeof h.surface=="string"?h.surface:void 0;A!==void 0&&A!==X&&a.size>0&&!a.has(A)&&s(`${v}.surface`,"quality-unknown-surface",`${d}: surface "${A}" is not declared in the profile`)},b=new Set;r.forEach((h,v)=>{if(typeof h!="object"||h===null||Array.isArray(h))return;let d=h,y=`quality.assessments[${v}]`,M=`Assessment ${v}`;S(d,y,M);let A=d.level;(typeof A!="number"||!Number.isInteger(A)||A<0||A>4)&&s(`${y}.level`,"quality-level-range",`${M}: level must be an integer 0-4 (N/A is the absence of the row, not a level)`),d.surface===X&&s(`${y}.surface`,"quality-cross-surface-assessment",`${M}: "${X}" is a findings-only lens \u2014 it carries no matrix column, so this score would render nowhere`),(typeof d.evidence!="string"||d.evidence.trim()==="")&&s(`${y}.evidence`,"quality-assessment-no-evidence",`${M}: scored with no evidence \u2014 a score without a citation is an opinion, not an assessment`);let D=typeof d.criterion_id=="string"?d.criterion_id:void 0,N=typeof d.surface=="string"?d.surface:void 0;if(D!==void 0&&N!==void 0){let ye=`${D}\0${N}`;b.has(ye)&&s(`${y}`,"quality-duplicate-assessment",`${M}: a second score for (${D} x ${N}) \u2014 the section stores latest-per-cell, so this cell is ambiguous`),b.add(ye)}});let R=new Set;n.forEach((h,v)=>{if(typeof h!="object"||h===null||Array.isArray(h))return;let d=h,y=`quality.findings[${v}]`,M=`Finding ${v}`;S(d,y,M);let A=typeof d.id=="string"?d.id:void 0;A!==void 0&&(R.has(A)&&s(`${y}.id`,"quality-duplicate-finding-id",`Duplicate finding id "${A}"`),R.add(A));for(let D of["impact","likelihood"]){let N=d[D];(typeof N!="number"||!Number.isInteger(N)||N<1||N>5)&&s(`${y}.${D}`,"quality-risk-range",`${M}: ${D} must be an integer 1-5 \u2014 severity is derived from impact x likelihood`)}for(let D of["severity","priority"])D in d&&s(`${y}.${D}`,"quality-derived-field-stored",`${M}: ${D} is derived from impact, likelihood and cost \u2014 storing it lets it drift from the numbers behind it`);d.status==="accepted-risk"&&(typeof d.detail!="string"||d.detail.trim()==="")&&s(`${y}.detail`,"quality-accepted-risk-no-note",`${M}: accepted-risk with no note \u2014 an accepted risk is a decision and reads like one`)});let P=new Set,C=Array.isArray(w.journal)?w.journal:[];C.forEach(h=>{if(typeof h!="object"||h===null)return;let v=h;v.type==="quality.finding.opened"&&typeof v.finding_id=="string"&&P.add(v.finding_id)}),C.forEach((h,v)=>{if(typeof h!="object"||h===null)return;let d=h,y=typeof d.type=="string"?d.type:"";y.startsWith("quality.")&&((typeof d.actor!="string"||d.actor.trim()==="")&&s(`journal[${v}].actor`,"quality-event-no-actor",`Journal event ${v}: ${y} has no actor \u2014 human, agent and CI scores become indistinguishable`),y==="quality.finding.resolved"&&typeof d.finding_id=="string"&&!P.has(d.finding_id)&&!R.has(d.finding_id)&&s(`journal[${v}].finding_id`,"quality-resolved-never-opened",`Journal event ${v}: resolves finding "${d.finding_id}", which no quality.finding.opened event or stored finding ever declared`))})}for(let e of Se(w))p.push(e);return $()}var je="journal.jsonl",Je="journal";function qe(f){let p=(0,Q.join)(f,Je);return(0,U.existsSync)(p)?(0,U.readdirSync)(p).filter(t=>t.startsWith("archive-")&&t.endsWith(".jsonl")).sort().map(t=>(0,Q.join)(p,t)):[]}function Y(f,p){return f.filter(t=>t?.species===p).length}function Ge(){let f=process.argv[2];f||(console.error("Usage: node validate-bundle.js <path-to-bundle.json>"),process.exit(1)),(0,U.existsSync)(f)||(console.error(`File not found: ${f}`),process.exit(1));let p;try{p=JSON.parse((0,U.readFileSync)(f,"utf8"))}catch(k){console.error(`FATAL: Cannot parse JSON \u2014 ${k.message}`),process.exit(1)}let t=p;(typeof t!="object"||t===null||!t.project||!t.nodes||!t.edges)&&(console.error("FATAL: Missing top-level keys (project, nodes, edges)."),process.exit(1));let s=[],$=!1,w=[];if(t.journal===void 0){let k=(0,Q.dirname)(f),F=(0,Q.join)(k,je),O=[];if((0,U.existsSync)(F)){let{events:J,findings:G}=re((0,U.readFileSync)(F,"utf8"));for(let q of G)s.push(q.message);$=!0,O.push(...J)}for(let J of qe(k)){let G=(0,Q.basename)(J),{events:q,findings:c}=re((0,U.readFileSync)(J,"utf8"));for(let g of c)s.push(`${G} \u2014 ${g.message}`);w.push(G),O.push(...q)}($||w.length>0)&&(t.journal=O)}let m=Array.isArray(t.nodes)?t.nodes:[],I=Array.isArray(t.edges)?t.edges:[],T=Array.isArray(t.journal)?t.journal:[],E=Pe(p);if(console.log(`
14
14
  Arkaik Bundle Validation`),console.log(` =======================
15
- `),console.log(` Nodes: ${v.length} (${L(v,"view")} views, ${L(v,"flow")} flows, ${L(v,"data-model")} data-models, ${L(v,"api-endpoint")} api-endpoints, ${L(v,"acceptance")} acceptances)`),console.log(` Edges: ${p.length}`),$?console.log(` Journal: ${y.length} event(s) from ${ae} sidecar`):y.length>0&&console.log(` Journal: ${y.length} embedded event(s)`),console.log(""),_.warnings.length>0&&(console.log(` Warnings: ${_.warnings.length}`),_.warnings.forEach(h=>console.log(` WARN: ${h.message}`)),console.log(""));let E=[...r.map(h=>h.message),..._.errors.map(h=>h.message)];E.length===0&&(console.log(` Result: VALID
16
- `),process.exit(0)),console.log(` Errors: ${E.length}`),E.forEach(h=>console.log(` ERROR: ${h}`)),console.log(`
15
+ `),console.log(` Nodes: ${m.length} (${Y(m,"view")} views, ${Y(m,"flow")} flows, ${Y(m,"data-model")} data-models, ${Y(m,"api-endpoint")} api-endpoints, ${Y(m,"acceptance")} acceptances)`),console.log(` Edges: ${I.length}`),$||w.length>0){let k=[...$?[`${je} sidecar`]:[],...w.length>0?[`${w.length} archive(s)`]:[]].join(" + ");console.log(` Journal: ${T.length} event(s) from ${k}`)}else T.length>0&&console.log(` Journal: ${T.length} embedded event(s)`);console.log(""),E.warnings.length>0&&(console.log(` Warnings: ${E.warnings.length}`),E.warnings.forEach(k=>console.log(` WARN: ${k.message}`)),console.log(""));let L=[...s,...E.errors.map(k=>k.message)];L.length===0&&(console.log(` Result: VALID
16
+ `),process.exit(0)),console.log(` Errors: ${L.length}`),L.forEach(k=>console.log(` ERROR: ${k}`)),console.log(`
17
17
  Result: INVALID
18
- `),process.exit(1)}ge();
18
+ `),process.exit(1)}Ge();
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: arkaik
3
- version: 3.0.0
3
+ version: 3.2.0
4
4
  description: >
5
5
  Maintain the Arkaik product graph map for {{PRODUCT_NAME}} — add, update, or
6
6
  remove nodes and edges in the ProjectBundle JSON that describes its screens,
@@ -174,6 +174,9 @@ larger restructuring. Follow these rules strictly:
174
174
  capitalized words; physical tables/views use the exact DB identifier verbatim.
175
175
  - Every `node.project_id` must match `project.id`
176
176
  - `platforms` must contain at least one of: `"web"`, `"ios"`, `"android"`
177
+ - If the project declares products (`project.metadata.products`), flows and views
178
+ also carry `metadata.product` and their `platforms` stay inside that product's
179
+ menu — see [Products](#products--which-app-does-this-node-belong-to)
177
180
  - Flow nodes must have `metadata.playlist` with at least one entry
178
181
 
179
182
  **Edge rules:**
@@ -181,7 +184,7 @@ larger restructuring. Follow these rules strictly:
181
184
  - Every `source_id` and `target_id` must reference existing node IDs
182
185
  - Edge type semantics:
183
186
  - `composes`: flow -> view, flow -> flow (sub-flow), view -> flow (triggers)
184
- - `calls`: view -> api-endpoint, flow -> api-endpoint, api-endpoint -> api-endpoint (endpoint fan-out to internal/external APIs)
187
+ - `calls`: view -> api-endpoint, flow -> api-endpoint, api-endpoint -> api-endpoint (endpoint fan-out to internal/external APIs), api-endpoint -> view (the server initiates: webhook, SSE, push)
185
188
  - `displays`: view -> data-model
186
189
  - `queries`: api-endpoint -> data-model
187
190
  - `covers`: acceptance -> view, acceptance -> flow
@@ -271,8 +274,10 @@ Creating a new acceptance + covers edge is itself a dual-write: append
271
274
  - `Given` encodes render variants ("Given the pebble has a picture attached…").
272
275
  - `platforms` lists only the platforms where the behavior is *expected* — a
273
276
  mobile-only behavior is `["ios", "android"]`, not backlog-on-web.
274
- - `covers` edges: acceptance → view or acceptance → flow. Zero edges = a
275
- product-level acceptance (legal). Several = the behavior spans surfaces.
277
+ - `covers` edges: acceptance → view or acceptance → flow. Zero edges = an
278
+ anchorless acceptance in **intake** (legal): an idea filed before its flows and
279
+ views exist, carrying `metadata.product` until it has anchors to derive
280
+ membership from. Several = the behavior spans surfaces.
276
281
  - Statuses reuse the standard lifecycle; "shipped" = `live`.
277
282
 
278
283
  **Example** — iOS ships the draw-in animation:
@@ -304,6 +309,72 @@ worse than a missing one. Consult `references/values.md` (one-line definitions
304
309
  per element) only when actually mapping — do not load it otherwise.
305
310
  <!-- values:end -->
306
311
 
312
+ ## Products — which app does this node belong to?
313
+
314
+ A project may describe a **family** of apps sharing one graph: an end-user app, a
315
+ web-only back office, a public API. Each is a **product**, declared once in
316
+ `project.metadata.products` with an `id`, a `title`, and the `platforms` it may
317
+ ship on. Most projects declare none, and a project with no `products` key behaves
318
+ exactly as it always has — do not invent products for one.
319
+
320
+ But when a project *does* declare them, **you are the only author of membership**:
321
+ no form, panel, or dialog in the app writes any of the fields below. If you don't
322
+ write them, nobody does.
323
+
324
+ **Where membership is stored — and where it must never be:**
325
+
326
+ | Species | What you write |
327
+ |---|---|
328
+ | `flow`, `view` | `metadata.product` — exactly one declared product id |
329
+ | `acceptance` | Usually **nothing**. Membership comes from the views and flows its `covers` edges reach. Write `metadata.product` only when the acceptance covers nothing (below) |
330
+ | `data-model`, `api-endpoint` | **Never.** Membership is derived from whoever consumes them, walking `calls` / `displays` / `queries` inward from the flows and views. Writing `metadata.product` here is a validator warning (`product-membership-wrong-species`) |
331
+
332
+ That last row is the one to get right. The system layer is shared substrate: a
333
+ data model both the end-user app and the admin touch belongs to both, and a
334
+ stored key could only ever claim one of them.
335
+
336
+ **Keep `platforms` inside the product's menu.** `node.platforms` stays
337
+ authoritative and unchanged in meaning, but it *should* be a subset of its
338
+ product's `platforms`. A view in a web-only admin product is `["web"]`, not
339
+ `["web", "ios", "android"]`. Readers intersect the two lists, so an out-of-menu
340
+ platform is dropped from every display anyway and only earns you a
341
+ `product-platform-not-in-menu` warning.
342
+
343
+ **An anchorless acceptance should name its product.** An acceptance with zero
344
+ `covers` edges is legal — a product-level promise — but it has no anchor to
345
+ derive membership from, so it sits under "All products" only until you say which
346
+ app it is about (`acceptance-product-unassigned`):
347
+
348
+ ```json
349
+ {
350
+ "id": "AC-audit-log-retention",
351
+ "project_id": "{{PROJECT_ID}}",
352
+ "species": "acceptance",
353
+ "title": "Audit log retention",
354
+ "status": "backlog",
355
+ "platforms": ["web"],
356
+ "metadata": {
357
+ "gherkin": "Given an admin action older than 90 days, When I open the audit log, Then it is no longer listed.",
358
+ "values": ["reduces-risk"],
359
+ "product": "admin"
360
+ }
361
+ }
362
+ ```
363
+
364
+ This is the one rule no example project can teach you by imitation: in the
365
+ Pebbles seed all three acceptances anchor on a view or flow via `covers`, so
366
+ every one of them derives its product and stores nothing. The anchorless case has
367
+ no worked example anywhere — write the key yourself.
368
+
369
+ **Stored maps take a product too.** A `MapDefinition` in `project.metadata.maps`
370
+ has an optional `product`, which makes "the admin systems map" data rather than a
371
+ feature request. As with everything above, there is no UI control for it: a map's
372
+ `product` is set by writing it.
373
+
374
+ When in doubt, leave membership off. An unassigned flow or view is a visible
375
+ triage state the validator names (`unassigned-membership`); a *wrongly* assigned
376
+ one is invisible, and quietly wrong in every rollup that reads it.
377
+
307
378
  ## Full Schema Reference
308
379
 
309
380
  For the complete TypeScript types, allowed values for `status`, `species`,
@@ -341,6 +412,12 @@ known type whenever one fits.
341
412
 
342
413
  ## Bootstrap: Generating a Map from Scratch
343
414
 
415
+ **Check for the bootstrap skill first.** If the `arkaik-bootstrap` skill is
416
+ installed beside this one — or can be installed with `arkaik init --bootstrap` —
417
+ the bootstrap method supersedes this section for from-scratch and
418
+ retro-population runs. The steps below remain the fallback when the bootstrap
419
+ tooling is unavailable.
420
+
344
421
  If the project doesn't have a map yet and the user asks you to create one:
345
422
 
346
423
  1. Scan the codebase for routes/pages, models, and API endpoints
@@ -360,5 +437,6 @@ If the project doesn't have a map yet and the user asks you to create one:
360
437
  6. Save the snapshot to `{{BUNDLE_PATH}}` and the journal to `{{JOURNAL_PATH}}`
361
438
  (or ask the user where they want them)
362
439
 
363
- Full generation is the **only** sanctioned non-surgical case. For every
364
- subsequent change, use a surgical patch paired with an appended event.
440
+ Full generation via this section or the bootstrap method is the **only**
441
+ sanctioned non-surgical case. For every subsequent change, use a surgical patch
442
+ paired with an appended event.