@spexcode/spec-core 0.6.2 → 0.6.3

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.
Files changed (56) hide show
  1. package/dist/anchors.d.ts +94 -0
  2. package/dist/anchors.js +730 -0
  3. package/dist/git.d.ts +166 -0
  4. package/dist/git.js +2736 -0
  5. package/dist/graph.d.ts +34 -0
  6. package/dist/graph.js +237 -0
  7. package/dist/graphDelta.d.ts +45 -0
  8. package/dist/graphDelta.js +84 -0
  9. package/dist/harness-identity.d.ts +31 -0
  10. package/dist/harness-identity.js +20 -0
  11. package/dist/identity-presets.d.ts +152 -0
  12. package/dist/identity-presets.js +132 -0
  13. package/dist/index.d.ts +45 -0
  14. package/dist/index.js +19 -0
  15. package/dist/layout.d.ts +183 -0
  16. package/dist/layout.js +548 -0
  17. package/dist/process-identity.d.ts +37 -0
  18. package/dist/process-identity.js +214 -0
  19. package/dist/project-identity.d.ts +12 -0
  20. package/dist/project-identity.js +71 -0
  21. package/dist/project-store.d.ts +3 -0
  22. package/dist/project-store.js +14 -0
  23. package/dist/resilience.d.ts +2 -0
  24. package/dist/resilience.js +40 -0
  25. package/dist/review/index.d.ts +3 -0
  26. package/{src → dist}/review/index.js +3 -3
  27. package/dist/review/reviewFilters.d.ts +77 -0
  28. package/dist/review/reviewFilters.js +308 -0
  29. package/dist/review/reviewQuery.d.ts +66 -0
  30. package/dist/review/reviewQuery.js +180 -0
  31. package/dist/review/session.d.ts +4 -0
  32. package/dist/review/session.js +8 -0
  33. package/dist/reviewSnapshot.d.ts +15 -0
  34. package/dist/reviewSnapshot.js +12 -0
  35. package/dist/root-lru.d.ts +4 -0
  36. package/{src/root-lru.ts → dist/root-lru.js} +26 -30
  37. package/dist/specs.d.ts +117 -0
  38. package/dist/specs.js +489 -0
  39. package/package.json +19 -8
  40. package/src/anchors.ts +0 -728
  41. package/src/git.ts +0 -2556
  42. package/src/graph.ts +0 -251
  43. package/src/harness-identity.ts +0 -26
  44. package/src/identity-presets.d.ts +0 -13
  45. package/src/identity-presets.js +0 -138
  46. package/src/index.ts +0 -20
  47. package/src/layout.ts +0 -637
  48. package/src/process-identity.ts +0 -207
  49. package/src/project-identity.ts +0 -73
  50. package/src/project-store.ts +0 -17
  51. package/src/resilience.ts +0 -41
  52. package/src/review/reviewFilters.js +0 -324
  53. package/src/review/reviewQuery.js +0 -174
  54. package/src/review/session.js +0 -13
  55. package/src/reviewSnapshot.ts +0 -28
  56. package/src/specs.ts +0 -498
package/src/graph.ts DELETED
@@ -1,251 +0,0 @@
1
- import { deriveStatus } from './specs.js'
2
- import { resolveProjectIdentity } from './project-identity.js'
3
- import { publishReviewSnapshot } from './reviewSnapshot.js'
4
- import { evalReviewState } from './review/reviewFilters.js'
5
-
6
- // a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
7
- function resolveParent(path: string, byDir: Record<string, string>): string | null {
8
- const dir = path.replace(/\/spec\.md$/, '')
9
- const segs = dir.split('/')
10
- for (let k = segs.length - 1; k > 0; k--) {
11
- const anc = segs.slice(0, k).join('/')
12
- if (byDir[anc]) return byDir[anc]
13
- }
14
- return null
15
- }
16
-
17
- const OVERLAY_TO_PATH = Symbol('overlay-to-path')
18
-
19
- // The graph owns only the composition of these frozen inputs. Session enumeration and forge/issue reads
20
- // belong to the CLI/server adapter, so this module has no hidden runtime-system fallback.
21
- export type BoardSnapshot = {
22
- root: string
23
- specs: any[]
24
- layout: { worktrees: any[] }
25
- sessions: any[]
26
- issues: any[]
27
- issuesStamp: string
28
- forgeRevision: string
29
- evalTimelines: Map<string, any>
30
- sessionEvalProjections: Map<string, any>
31
- }
32
-
33
- // The server-only review snapshot keeps latest readings verbatim. Graph JSON receives only counts.
34
- export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
35
- const seen = new Set<string>()
36
- return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
37
- }
38
-
39
- export function nodeEvalSummary(scenarios: { name: string }[], readings: any[]) {
40
- type State = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty'
41
- const latest = new Map(latestPerScenario(readings).map((reading) => [reading.scenario, reading]))
42
- const summary = { total: scenarios.length, pass: 0, fail: 0, stalePass: 0, staleFail: 0, empty: 0 }
43
- for (const scenario of scenarios) {
44
- const reading = latest.get(scenario.name)
45
- const state = (reading ? evalReviewState(reading) : 'empty') as State
46
- summary[state]++
47
- }
48
- return summary
49
- }
50
-
51
- // @@@ a shelved row carries no delta ([[archive]]) - the board has TWO producers (a full buildBoard and the
52
- // sessions-only spliceSessions), and shelving is a session-scoped write, so it takes the SPLICE path — which
53
- // reuses the previous board's ops by path. Skipping the delta only in layout's row builder would therefore be
54
- // invisible exactly when it fires: the splice would carry the stale ops forward forever. The rule belongs to
55
- // the row, not to one producer, so both decorate through this one function.
56
- const rowOps = (s: { path: string; archived?: boolean }, opsByPath: Record<string, any[]>): any[] =>
57
- (s.archived ? [] : opsByPath[s.path] || [])
58
-
59
- export async function buildBoard({ root, specs, layout, sessions, issues: merged, issuesStamp, forgeRevision, evalTimelines, sessionEvalProjections }: BoardSnapshot) {
60
- const worktrees = layout.worktrees.filter((w) => !w.isMain)
61
- // resolveLayout already zeroed ops for unmanaged worktrees, so this is just "has pending changes".
62
- const opWts = worktrees.filter((w) => w.ops && w.ops.length)
63
-
64
- const sessIdByPath: Record<string, string> = {}
65
- sessions.forEach((s) => { sessIdByPath[s.path] = s.id })
66
- const seedOf = (path: string): string => sessIdByPath[path] || path
67
-
68
- const byId: Record<string, any> = Object.fromEntries(specs.map((n) => [n.id, n]))
69
- const byDir: Record<string, string> = {}
70
- specs.forEach((n: any) => { if (n.path) byDir[n.path.replace(/\/spec\.md$/, '')] = n.id })
71
- // register each added node's own dir in byDir first, so resolveParent can chain a new node to its new
72
- // ghost ancestor (a whole added subtree renders as one tree, not a flat scatter of roots).
73
- for (const w of opWts) for (const op of w.ops) {
74
- if (op.op === 'added') byDir[op.path.replace(/\/spec\.md$/, '')] = op.nodeId
75
- }
76
-
77
- const overlaysByNode: Record<string, any[]> = {}
78
- const ghostById: Record<string, any> = {}
79
- for (const w of opWts) {
80
- const source = w.path, label = w.node || w.branch || w.path, seed = seedOf(w.path)
81
- for (const op of w.ops) {
82
- const ov = {
83
- op: op.op, source, label, branch: w.branch, seed,
84
- committed: op.committed, dirty: op.dirty,
85
- toParent: op.op === 'moved' ? resolveParent(op.toPath || op.path, byDir) : null,
86
- [OVERLAY_TO_PATH]: op.op === 'moved' ? op.toPath || op.path : null,
87
- }
88
- if (op.op === 'added' && !byId[op.nodeId]) {
89
- if (ghostById[op.nodeId]) { ghostById[op.nodeId].overlays.push(ov); continue }
90
- // a ghost is a node being ADDED by a worktree but not yet on main -> it has a pending op,
91
- // so its derived status is `active` (live, in-flight), never `pending`.
92
- ghostById[op.nodeId] = {
93
- id: op.nodeId, parent: resolveParent(op.path, byDir), path: op.path,
94
- title: op.nodeId, status: deriveStatus({ version: 0, drift: 0, hasOverlay: true }),
95
- version: 0, session: null, fmStatus: null,
96
- desc: '', code: [], related: [], body: '', drift: 0, driftFiles: [], ghost: true, overlays: [ov],
97
- }
98
- } else {
99
- (overlaysByNode[op.nodeId] ??= []).push(ov)
100
- }
101
- }
102
- }
103
-
104
- // re-derive status WITH the overlay so a node an unmerged worktree is touching reads `active` — the only
105
- // place in-flight work is known, so the only place `active` is produced.
106
- const nodes = [
107
- ...specs.map((n: any) => {
108
- const overlays = overlaysByNode[n.id] || []
109
- // `body` and its derivation `parts` are DROPPED from the board payload ([[graph-lean]]): together ~56% of
110
- // the bytes, and detail the graph overview never renders. The detail view fetches them per node from
111
- // `/api/specs/:id/content` on open, and the search palette fetches the body corpus from `/api/specs/lite`
112
- // once on open — both off this hot poll. `undefined` makes JSON.stringify omit the keys.
113
- return { ...n, body: undefined, parts: undefined, overlays, status: deriveStatus({ version: n.version, drift: n.drift, hasOverlay: overlays.length > 0, hasCode: (n.code?.length ?? 0) > 0, fmStatus: n.fmStatus ?? undefined }) }
114
- }),
115
- ...Object.values(ghostById),
116
- ]
117
- // The outer adapter reads Issues once. Full rows stay in the server-only review snapshot; graph nodes get
118
- // counts and open identity only, enough for tile/stat/tree glances without reconstructing the list.
119
- const isOpen = (i: { status: string }) => i.status === 'open'
120
- // `issuesStamp` above is that ONE board-level freshness stamp, over EVERY thread — noded or nodeless,
121
- // both stores, BOTH remark hosts. It is folded from the whole store and NOT from the split `merged`: a
122
- // scenario-hosted remark lands on an eval track the issue read splits out ([[eval-issue-split]]), so a
123
- // carrier folded over the issue half alone left an open READING blind to every remark on it — the write
124
- // moved no board byte, [[graph-delta]] correctly suppressed the no-change broadcast, and the push never
125
- // fired at all. The per-node fold below stays [[graph-lean]]-slim (no reply payloads).
126
- const issuesByNode: Record<string, any[]> = {}
127
- for (const issue of merged)
128
- for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
129
- for (const n of nodes) {
130
- const issues = issuesByNode[n.id]
131
- if (!issues || !issues.length) continue
132
- const open = issues.filter(isOpen)
133
- n.reviewSummary = {
134
- ...(n.reviewSummary || {}),
135
- issues: { open: open.length, closed: issues.length - open.length, openIds: open.map((issue) => issue.id) },
136
- }
137
- }
138
-
139
- // The L1 adapter calculates current eval timelines once. Latest rows/declarations stay server-only for
140
- // paged review; graph nodes receive the explicit per-state count projection and nothing row-shaped.
141
- const evalReviewNodes = nodes.flatMap((n) => {
142
- const timeline = evalTimelines.get(n.id)
143
- if (!timeline?.hasEvalFile) return []
144
- const latest = latestPerScenario(timeline.readings)
145
- n.reviewSummary = { ...(n.reviewSummary || {}), evals: nodeEvalSummary(timeline.scenarios, latest) }
146
- return [{ id: n.id, hue: n.hue, scenarios: timeline.scenarios, evals: latest, readings: timeline.readings }]
147
- })
148
-
149
- publishReviewSnapshot({ issues: merged, evalNodes: evalReviewNodes, forgeRevision })
150
-
151
- const opsByPath: Record<string, any[]> = {}
152
- opWts.forEach((w) => { opsByPath[w.path] = w.ops })
153
- const sess = sessions.map((s) => ({
154
- ...s,
155
- source: s.path,
156
- ops: rowOps(s, opsByPath),
157
- evalSummary: sessionEvalProjections.get(s.id),
158
- }))
159
-
160
- // One resolved identity projection feeds title, favicon, rail, and catalog compatibility. A worktree
161
- // backend reads the actual served tree's branch config and identity; endpoint registration uses the
162
- // same git toplevel, so a linked worktree occupies its own host slot instead of replacing main.
163
- const identity = resolveProjectIdentity(root, root)
164
- return { nodes, sessions: sess, identity, issuesStamp }
165
- }
166
-
167
- // @@@ spliceSessions — the SESSIONS-ONLY producer ([[graph-cache]]). A session-scoped change (a lifecycle
168
- // write, a liveness/activity poll flip) reshapes only the board's `sessions` rows — the node/meta units are
169
- // untouched — so the cache re-derives ONLY the sessions and splices them onto the previous board verbatim,
170
- // skipping the whole loadSpecs/layout/eval assembly a full buildBoard() pays. The adapter supplies every
171
- // live input:
172
- // each row is decorated EXACTLY as buildBoard's sess mapping (`{...s, source: s.path, ops}`), and every
173
- // path's `ops` is REUSED from the previous board (a path→ops map). A session path absent in `prev` gets []
174
- // — a brand-new worktree has no pending spec ops yet, and any later ops-CHANGING event (a commit, a
175
- // worktree `.spec` edit) is refs/worktree-scoped, i.e. a FULL rebuild, never a sessions splice. So the
176
- // splice is byte-indistinguishable from a full rebuild whenever only session state moved.
177
- export async function spliceSessions(
178
- prev: Awaited<ReturnType<typeof buildBoard>>,
179
- sessions: any[],
180
- sessionEvalProjections: Map<string, any>,
181
- ): Promise<Awaited<ReturnType<typeof buildBoard>>> {
182
- const activeSources = new Set(sessions.map((session) => session.path))
183
- const opsByPath: Record<string, any[]> = {}
184
- for (const s of prev.sessions) opsByPath[s.source] = s.ops
185
- const sess = sessions.map((s) => ({
186
- ...s,
187
- source: s.path,
188
- ops: rowOps(s, opsByPath),
189
- evalSummary: sessionEvalProjections.get(s.id),
190
- }))
191
- // Archive and close are subtractive topology changes: their worktree leaves the working set, so its
192
- // overlays must leave in the same cheap publication as its row. Filtering the already-built units is exact
193
- // and forks nothing. A newly-active source is intentionally absent here; graphCache keeps a full obligation
194
- // for additions/resume so resolveLayout can discover that worktree's current delta.
195
- let nodeProjectionMoved = false
196
- const projectedNodes = prev.nodes.flatMap((node: any) => {
197
- const overlays = (node.overlays || []).filter((overlay: any) => activeSources.has(overlay.source))
198
- if (node.ghost && overlays.length === 0) { nodeProjectionMoved = true; return [] }
199
- if (overlays.length === (node.overlays || []).length) return [node]
200
- nodeProjectionMoved = true
201
- if (node.ghost) return [{ ...node, overlays, status: deriveStatus({ version: 0, drift: 0, hasOverlay: true }) }]
202
- return [{ ...node, overlays, status: deriveStatus({
203
- version: node.version,
204
- drift: node.drift,
205
- hasOverlay: overlays.length > 0,
206
- hasCode: (node.code?.length ?? 0) > 0,
207
- fmStatus: node.fmStatus ?? undefined,
208
- }) }]
209
- })
210
- const nodes = nodeProjectionMoved ? projectedNodes : prev.nodes
211
- if (nodeProjectionMoved) {
212
- const byDir: Record<string, string> = {}
213
- for (const node of nodes) if (node.path) byDir[node.path.replace(/\/spec\.md$/, '')] = node.id
214
- const retainedIds = new Set(nodes.map((node: any) => node.id))
215
- for (let index = 0; index < nodes.length; index++) {
216
- const node: any = nodes[index]
217
- let changed = false
218
- let parent = node.parent
219
- if (node.ghost) {
220
- const next = resolveParent(node.path, byDir)
221
- if (next !== parent) { parent = next; changed = true }
222
- }
223
- const overlays = (node.overlays || []).map((overlay: any) => {
224
- if (!overlay.toParent || retainedIds.has(overlay.toParent)) return overlay
225
- const toPath = overlay[OVERLAY_TO_PATH]
226
- changed = true
227
- return { ...overlay, toParent: typeof toPath === 'string' ? resolveParent(toPath, byDir) : null }
228
- })
229
- if (changed) nodes[index] = { ...node, parent, overlays }
230
- }
231
- }
232
- return { ...prev, nodes, sessions: sess }
233
- }
234
-
235
- // A full producer may finish after the session lane has already shown a newer row. Reuse that published
236
- // projection on the full topology without another store read: topology owns the current per-path ops, while
237
- // the published row owns lifecycle/eval fields. This is deliberately synchronous so a full completion never
238
- // waits for a quiet session store.
239
- export function rebasePublishedSessions(
240
- topology: Awaited<ReturnType<typeof buildBoard>>,
241
- published: Awaited<ReturnType<typeof buildBoard>>,
242
- ): Awaited<ReturnType<typeof buildBoard>> {
243
- const opsByPath: Record<string, any[]> = {}
244
- for (const session of topology.sessions) opsByPath[session.source] = session.ops
245
- const sessions = published.sessions.map((session) => ({
246
- ...session,
247
- source: session.path,
248
- ops: rowOps(session, opsByPath),
249
- }))
250
- return { ...topology, sessions }
251
- }
@@ -1,26 +0,0 @@
1
- type HarnessIdentityRow = { id: string; sessionEnvVar: string }
2
-
3
- // Adapter-neutral identity facts. Full harness adapters project these rows; consumers that only resolve an
4
- // environment identity must not load launchers, runtime transport, or materialization code.
5
- export const HARNESS_IDENTITIES = [
6
- { id: 'claude', sessionEnvVar: 'CLAUDE_CODE_SESSION_ID' },
7
- { id: 'codex', sessionEnvVar: 'CODEX_THREAD_ID' },
8
- { id: 'opencode', sessionEnvVar: 'OPENCODE_SESSION_ID' },
9
- { id: 'pi', sessionEnvVar: 'PI_SESSION_ID' },
10
- { id: 'zcode', sessionEnvVar: 'ZCODE_SESSION_ID' },
11
- { id: 'claude-headless', sessionEnvVar: 'CLAUDE_CODE_SESSION_ID' },
12
- { id: 'opencode-headless', sessionEnvVar: 'OPENCODE_SESSION_ID' },
13
- { id: 'pi-headless', sessionEnvVar: 'PI_SESSION_ID' },
14
- { id: 'codex-headless', sessionEnvVar: 'CODEX_THREAD_ID' },
15
- ] as const satisfies readonly HarnessIdentityRow[]
16
-
17
- export type HarnessId = typeof HARNESS_IDENTITIES[number]['id']
18
- export type HarnessIdentity = typeof HARNESS_IDENTITIES[number]
19
-
20
- const identityById = new Map(HARNESS_IDENTITIES.map((identity) => [identity.id, identity]))
21
-
22
- export function harnessIdentity(id: HarnessId): HarnessIdentity {
23
- const identity = identityById.get(id)
24
- if (!identity) throw new Error(`unknown harness identity '${id}'`)
25
- return identity
26
- }
@@ -1,13 +0,0 @@
1
- export type IdentityShape = { tag: string; [key: string]: string | number }
2
- export type IdentityPreset = { id: string; label: string; bg: string; fg: string; shapes: IdentityShape[] }
3
-
4
- export const DEFAULT_PROJECT_ICON: string
5
- export const DEFAULT_GATEWAY_ICON: string
6
- export const IDENTITY_PRESETS: readonly IdentityPreset[]
7
- export const IDENTITY_PRESET_IDS: readonly string[]
8
- export function resolvedIdentityIcon(value: unknown, fallback?: string): string
9
- export function identityPreset(value: unknown): IdentityPreset | null
10
- export function isIconifyIcon(value: unknown): boolean
11
- export function requireIdentityChoice(value: unknown): string
12
- export function identitySvg(value: unknown, fallback?: string): string
13
- export function identityFaviconHref(value: unknown, fallback?: string): string
@@ -1,138 +0,0 @@
1
- // One browser-safe identity registry shared by backend validation and every dashboard projection.
2
- // Geometry is data so the React renderer and favicon serializer cannot drift into separate drawings.
3
- //
4
- // @@@ shape keys travel through two renderers - a shape's own attributes override the group's
5
- // fill/stroke, which is how a multi-colour mark fits a single-fg registry. Both consumers pass the keys
6
- // through verbatim: the string serializer spreads them as attributes, React spreads them as JSX props.
7
- // So a key must be valid in BOTH, which rules out hyphenated names (React wants strokeWidth) - hence a
8
- // painted seam outline rather than a stroked line, since per-shape stroke-width cannot be expressed.
9
-
10
- export const DEFAULT_PROJECT_ICON = 'spexcode'
11
- export const DEFAULT_GATEWAY_ICON = 'gateway'
12
-
13
- export const IDENTITY_PRESETS = Object.freeze([
14
- {
15
- // The brand mark: two brackets holding one file, cleft corner to corner and regenerated on the
16
- // upper-right of the cleft. Painted rather than stroked — see the paint note below. Geometry is
17
- // docs/brand/mark-dark.svg scaled by 24/512; the two surface gradients flatten to their midpoints
18
- // because a 24-unit chip has no room for a ramp and the format carries no defs.
19
- id: 'spexcode', label: 'SpexCode', bg: '#12161C', fg: '#EFE8D8',
20
- shapes: [
21
- { tag: 'path', d: 'M 4.500,4.500 L 9.750,4.500 L 9.750,6.656 L 6.656,6.656 L 6.656,17.344 L 9.750,17.344 L 9.750,19.500 L 4.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
22
- { tag: 'path', d: 'M 19.500,4.500 L 14.250,4.500 L 14.250,6.656 L 17.344,6.656 L 17.344,17.344 L 14.250,17.344 L 14.250,19.500 L 19.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
23
- { tag: 'path', d: 'M 15.281,15.281 L 11.731,13.057 L 12.269,10.943 L 8.719,8.719 L 8.719,15.281 Z', fill: '#327D95', stroke: 'none' },
24
- { tag: 'path', d: 'M 8.719,8.719 L 12.269,10.943 L 11.731,13.057 L 15.281,15.281 L 15.281,12.000 L 12.000,8.719 Z', fill: '#82C3D6', stroke: 'none' },
25
- { tag: 'path', d: 'M 8.569,8.957 L 11.946,11.073 L 11.408,13.186 L 15.132,15.520 L 15.431,15.043 L 12.054,12.927 L 12.592,10.814 L 8.868,8.480 Z', fill: '#E4F7FD', stroke: 'none' },
26
- ],
27
- },
28
- {
29
- id: 'gateway', label: 'Gateway', bg: '#155e75', fg: '#ecfeff',
30
- shapes: [
31
- { tag: 'path', d: 'm12 3 8 4.2-8 4.2-8-4.2Z' },
32
- { tag: 'path', d: 'm4 11.2 8 4.2 8-4.2' },
33
- { tag: 'path', d: 'm4 15.2 8 4.2 8-4.2' },
34
- ],
35
- },
36
- {
37
- id: 'mdi:rocket-launch', label: 'Rocket', bg: '#9f1239', fg: '#fff1f2',
38
- shapes: [
39
- { tag: 'path', d: 'M14.5 5.2c2.2-2.2 4.8-2 5.3-1.8.2.5.4 3.1-1.8 5.3l-5.5 5.5-4.2-4.2Z' },
40
- { tag: 'path', d: 'm11.2 6.8-4.1.6-2.5 2.5 4.1.7' },
41
- { tag: 'path', d: 'm16.4 12-1 4.7-2.5 2.5-.7-4.1' },
42
- { tag: 'circle', cx: 16.1, cy: 7.1, r: 1.2 },
43
- { tag: 'path', d: 'M7.6 14.6c-2.2.4-3.4 1.6-3.6 3.8 2.2-.2 3.4-1.4 3.8-3.6' },
44
- ],
45
- },
46
- {
47
- id: 'compass', label: 'Compass', bg: '#1d4ed8', fg: '#eff6ff',
48
- shapes: [
49
- { tag: 'circle', cx: 12, cy: 12, r: 8.5 },
50
- { tag: 'path', d: 'm15.2 8.8-1.8 4.6-4.6 1.8 1.8-4.6Z' },
51
- ],
52
- },
53
- {
54
- id: 'terminal', label: 'Terminal', bg: '#3f3f46', fg: '#fafafa',
55
- shapes: [
56
- { tag: 'rect', x: 3.5, y: 4.5, width: 17, height: 15, rx: 2 },
57
- { tag: 'path', d: 'm7 9 3 3-3 3' },
58
- { tag: 'path', d: 'M12.5 15H17' },
59
- ],
60
- },
61
- {
62
- id: 'package', label: 'Package', bg: '#6d28d9', fg: '#f5f3ff',
63
- shapes: [
64
- { tag: 'path', d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9Z' },
65
- { tag: 'path', d: 'm4.3 7.7 7.7 4.4 7.7-4.4' },
66
- { tag: 'path', d: 'M12 12.1V21' },
67
- ],
68
- },
69
- {
70
- id: 'database', label: 'Database', bg: '#a16207', fg: '#fefce8',
71
- shapes: [
72
- { tag: 'ellipse', cx: 12, cy: 6, rx: 7.5, ry: 3 },
73
- { tag: 'path', d: 'M4.5 6v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3V6' },
74
- { tag: 'path', d: 'M4.5 12v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6' },
75
- ],
76
- },
77
- {
78
- id: 'spark', label: 'Spark', bg: '#c2410c', fg: '#fff7ed',
79
- shapes: [
80
- { tag: 'path', d: 'm12 3 1.5 5.2L19 10l-5.5 1.8L12 17l-1.5-5.2L5 10l5.5-1.8Z' },
81
- { tag: 'path', d: 'm18.5 15 .7 2.2 2.3.8-2.3.8-.7 2.2-.7-2.2-2.3-.8 2.3-.8Z' },
82
- ],
83
- },
84
- ])
85
-
86
- const BY_ID = new Map(IDENTITY_PRESETS.map((preset) => [preset.id, preset]))
87
- const ICONIFY_ID = /^[a-z0-9-]+[:/][a-z0-9-]+$/i
88
- const ALIASES = new Map([
89
- ['rocket', 'mdi:rocket-launch'],
90
- ['mdi/rocket-launch', 'mdi:rocket-launch'],
91
- ['layers', 'gateway'],
92
- ['default', 'spexcode'],
93
- ])
94
-
95
- export const IDENTITY_PRESET_IDS = Object.freeze(IDENTITY_PRESETS.map((preset) => preset.id))
96
-
97
- export function resolvedIdentityIcon(value, fallback = DEFAULT_PROJECT_ICON) {
98
- const raw = typeof value === 'string' ? value.trim() : ''
99
- const id = ALIASES.get(raw) || raw
100
- return id || fallback
101
- }
102
-
103
- export function identityPreset(value) {
104
- const raw = typeof value === 'string' ? value.trim() : ''
105
- return BY_ID.get(ALIASES.get(raw) || raw) || null
106
- }
107
-
108
- export function isIconifyIcon(value) {
109
- return ICONIFY_ID.test(typeof value === 'string' ? value.trim() : '')
110
- }
111
-
112
- export function requireIdentityChoice(value) {
113
- const raw = typeof value === 'string' ? value.trim() : ''
114
- const id = ALIASES.get(raw) || raw
115
- if (BY_ID.has(id)) return id
116
- if (isIconifyIcon(id)) return id.replace('/', ':')
117
- throw new Error(`unknown identity icon '${raw}' (choose a preset or Iconify prefix:name)`)
118
- }
119
-
120
- function attrs(shape) {
121
- return Object.entries(shape).filter(([key]) => key !== 'tag')
122
- .map(([key, value]) => `${key === 'className' ? 'class' : key}="${String(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;')}"`).join(' ')
123
- }
124
-
125
- export function identitySvg(value, fallback = DEFAULT_PROJECT_ICON) {
126
- const preset = identityPreset(value) || identityPreset(fallback)
127
- const geometry = preset.shapes.map((shape) => `<${shape.tag} ${attrs(shape)}/>`).join('')
128
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="5" fill="${preset.bg}"/><g fill="none" stroke="${preset.fg}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${geometry}</g></svg>`
129
- }
130
-
131
- export function identityFaviconHref(value, fallback = DEFAULT_PROJECT_ICON) {
132
- const resolved = resolvedIdentityIcon(value, fallback)
133
- if (identityPreset(resolved)) return `data:image/svg+xml,${encodeURIComponent(identitySvg(resolved, fallback))}`
134
- if (/^https?:\/\//.test(resolved)) return resolved
135
- if (isIconifyIcon(resolved)) return `https://api.iconify.design/${resolved.replace(':', '/')}.svg`
136
- const glyph = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y=".86em" font-size="82" text-anchor="middle">${resolved.replaceAll('&', '&amp;').replaceAll('<', '&lt;')}</text></svg>`
137
- return `data:image/svg+xml,${encodeURIComponent(glyph)}`
138
- }
package/src/index.ts DELETED
@@ -1,20 +0,0 @@
1
- import { sourceIndexesFull } from './git.js'
2
- import { loadSpecs } from './specs.js'
3
-
4
- export async function readSpecs(root: string) {
5
- const [history, drift] = await sourceIndexesFull(root)
6
- return loadSpecs(root, { history, drift })
7
- }
8
-
9
- export * from './anchors.js'
10
- export * from './git.js'
11
- export * from './graph.js'
12
- export * from './harness-identity.js'
13
- export * from './layout.js'
14
- export * from './process-identity.js'
15
- export * from './project-identity.js'
16
- export * from './project-store.js'
17
- export * from './resilience.js'
18
- export * from './reviewSnapshot.js'
19
- export * from './root-lru.js'
20
- export * from './specs.js'