@marver-design/marver 0.20.0 → 0.22.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +17 -4
  3. package/dist/{bake-tAb0D6Rc.mjs → bake-BSX4XR_U.mjs} +1 -1
  4. package/dist/board-status-CWGHIdo_.mjs +1307 -0
  5. package/dist/boards-BTGNPVMx.mjs +187 -0
  6. package/dist/{build-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
  7. package/dist/cli.mjs +67 -8
  8. package/dist/config-DJxMRVD8.mjs +373 -0
  9. package/dist/context-DUAENlJ6.mjs +600 -0
  10. package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
  11. package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
  12. package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
  13. package/dist/managed-write-Bo-oPc-i.mjs +71 -0
  14. package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
  15. package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
  16. package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
  17. package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
  18. package/dist/{shot-iw3SEcpn.mjs → shot-C22Ues04.mjs} +2 -2
  19. package/docs/boards-and-folders.md +161 -0
  20. package/docs/context.md +117 -0
  21. package/docs/sharing.md +12 -2
  22. package/package.json +1 -1
  23. package/src/client/shell/BoardList.tsx +143 -54
  24. package/src/client/shell/ContextMenu.tsx +16 -5
  25. package/src/client/shell/StatusPicker.tsx +91 -0
  26. package/src/client/shell/board-icons.tsx +75 -0
  27. package/src/client/shell/store.ts +93 -14
  28. package/src/client/shell/styles.css +41 -9
  29. package/src/shared/board-tree.ts +291 -143
  30. package/src/shared/board-types.ts +103 -0
  31. package/src/shared/context.ts +265 -0
  32. package/src/shared/status.ts +157 -0
  33. package/templates/AGENTS-embedded.md +4 -3
  34. package/templates/AGENTS-studio.md +4 -3
  35. package/templates/context/INDEX.md +50 -0
  36. package/templates/context/map.json +6 -0
  37. package/templates/context/shipped-knowledge.md +19 -0
  38. package/templates/context/shipped.md +28 -0
  39. package/templates/instructions/boards.md +78 -22
  40. package/templates/instructions/context.md +114 -0
  41. package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
  42. package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
  43. package/templates/playbooks/reorganize-context/eval.md +93 -0
  44. package/dist/boards-BmxcT3Lc.mjs +0 -290
  45. package/dist/boards-PuVzw5Wp.mjs +0 -62
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Board types (spec 20) - what a board is FOR in the workspace, one vocabulary every canvas
3
+ * shares so any sidebar reads at a glance. A board states its own `"type"`, or wears the type of
4
+ * the nearest typed folder above it (its folder, then that folder's parent), else it is plain.
5
+ * Moving a board changes an inherited type, never one the board states itself.
6
+ *
7
+ * Pure and shared: the dev API, the manifest, the build, the shell and the CLI all read types
8
+ * through here, so a type means one thing everywhere.
9
+ */
10
+
11
+ export const BOARD_TYPES = ['start', 'feature', 'surface', 'project', 'feedback', 'context', 'deck', 'archive'] as const
12
+ export type KnownType = (typeof BOARD_TYPES)[number]
13
+ export type BoardType = KnownType | 'plain'
14
+
15
+ /** What each type is for - the sidebar's tooltip, the CLI's help, the docs. */
16
+ export const TYPE_ROLES: Record<BoardType, string> = {
17
+ start: 'the way in: the index, the shipped record, the timeline',
18
+ feature: 'one capability: its spec, lo-fi and hi-fi',
19
+ surface: 'the whole product to walk, from frames on feature boards',
20
+ project: 'a deliverable or a question',
21
+ feedback: 'one frame per theme',
22
+ context: 'what came in from outside: meetings, threads, competitors',
23
+ deck: 'slides',
24
+ archive: 'snapshots and retired work',
25
+ plain: 'a board',
26
+ }
27
+
28
+ /** The grammar a stored type keeps to. Any on-grammar word survives a write - a newer Marver's
29
+ * type is never dropped by an older shell - while only the known ones are drawn. */
30
+ export const TYPE_GRAMMAR = /^[a-z][a-z-]{0,31}$/
31
+ /** A type off a file: the word when it is on-grammar, else absent. */
32
+ export const readType = (v: unknown): string | undefined => (typeof v === 'string' && TYPE_GRAMMAR.test(v) ? v : undefined)
33
+ /** A type this Marver draws. */
34
+ export const knownType = (v: unknown): KnownType | undefined =>
35
+ (BOARD_TYPES as readonly string[]).includes(v as string) ? (v as KnownType) : undefined
36
+
37
+ /** A board's type: its own when it states one (an unknown word reads as plain - the board spoke
38
+ * for itself), else its folder's, else that folder's parent's, else plain. */
39
+ export function resolveType(own: unknown, folder?: unknown, parent?: unknown): BoardType {
40
+ if (readType(own)) return knownType(own) ?? 'plain'
41
+ return knownType(folder) ?? knownType(parent) ?? 'plain'
42
+ }
43
+
44
+ /** The publish type a board's type proposes when its publish row names none - how a board
45
+ * PRESENTS when published, a separate question from what it is for. */
46
+ export const PROPOSED_PUBLISH: Partial<Record<BoardType, 'slides' | 'refs' | 'doc' | 'mix'>> = {
47
+ deck: 'slides',
48
+ context: 'refs',
49
+ project: 'doc',
50
+ feature: 'mix',
51
+ surface: 'mix',
52
+ }
53
+
54
+ /** The types that carry a status (spec 20, open question 2: features and projects - the others
55
+ * have no lifecycle of their own). */
56
+ export const HAS_STATUS: readonly BoardType[] = ['feature', 'project']
57
+
58
+ /** The decisions a board may state by hand. `done` is never one: Done comes only from the shipped
59
+ * record. `todo`, `backlog` and `in-progress` count only where there is no `context/`. */
60
+ export const DECISIONS = ['archived', 'paused', 'blocked'] as const
61
+ export const BY_HAND = ['in-progress', 'todo', 'backlog'] as const
62
+ export const STATUS_WORDS = [...DECISIONS, ...BY_HAND, 'done'] as const
63
+ export type StatusWord = (typeof STATUS_WORDS)[number]
64
+ export const readStatusWord = (v: unknown): StatusWord | undefined =>
65
+ (STATUS_WORDS as readonly string[]).includes(v as string) ? (v as StatusWord) : undefined
66
+ /** The statuses a person may set on a feature or project board, in the order a picker lists them:
67
+ * the three decisions always; Backlog, To do and In progress only where there is no `context/`
68
+ * (with it they are read from the evidence). Never Done - that is the shipped record's alone. */
69
+ export const settableStatuses = (contextPresent: boolean): StatusWord[] =>
70
+ contextPresent ? ['blocked', 'paused', 'archived'] : ['backlog', 'todo', 'in-progress', 'blocked', 'paused', 'archived']
71
+
72
+ /** A capability slug - the same grammar as a board name, so a feature board and its contract
73
+ * share it. */
74
+ export const readCapability = (v: unknown): string | undefined =>
75
+ typeof v === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(v) ? v : undefined
76
+ /** A blocked board's reason: one sentence, trimmed and capped. */
77
+ export const REASON_MAX = 300
78
+ export const readReason = (v: unknown): string | undefined => {
79
+ if (typeof v !== 'string') return undefined
80
+ const s = v.trim().replace(/\s+/g, ' ').slice(0, REASON_MAX)
81
+ return s || undefined
82
+ }
83
+
84
+ /** The sidebar every canvas shares (spec 19, The canvas): folder modules, each typed, so a board
85
+ * made inside one wears its type. `marver init --kind` creates a kind's set on a fresh canvas;
86
+ * `marver folders add <module>` adds one later. Names are slugs; only Start here needs a title. */
87
+ export const FOLDER_MODULES: Record<string, { name: string; title?: string; type: KnownType }> = {
88
+ start: { name: 'start-here', title: 'Start here', type: 'start' },
89
+ features: { name: 'features', type: 'feature' },
90
+ surfaces: { name: 'surfaces', type: 'surface' },
91
+ projects: { name: 'projects', type: 'project' },
92
+ feedback: { name: 'feedback', type: 'feedback' },
93
+ context: { name: 'context', type: 'context' },
94
+ decks: { name: 'decks', type: 'deck' },
95
+ archive: { name: 'archive', type: 'archive' },
96
+ }
97
+ /** A kind of work's folders, in sidebar order: the core (Start here, Feedback, Context, Archive)
98
+ * around the kind's own modules. Decks are an add-on for either. */
99
+ export const KIND_FOLDERS = {
100
+ product: ['start', 'features', 'surfaces', 'feedback', 'context', 'archive'],
101
+ knowledge: ['start', 'projects', 'feedback', 'context', 'archive'],
102
+ } as const
103
+ export type Kind = keyof typeof KIND_FOLDERS
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Reading `context/` (spec 19) - pure parsers over file text, shared by the dev server (which
3
+ * reads status off them, spec 20) and `marver context check` (which keeps them true). No fs, no
4
+ * git: callers read the files and hand the text in, so every reader parses one way.
5
+ */
6
+
7
+ /** The evidence levels a shipped-record cell carries. */
8
+ export type Level = 'confirmed' | 'reported' | 'unknown'
9
+ export const LEVEL = /`(confirmed|reported|unknown)`/g
10
+ /** What counts as a citation beside a level: a `path:line`, a run id, a cited file, or a link to a
11
+ * system of record (a deploy run's page). A cited file must also resolve - the check sees to it. */
12
+ export const CITATION = /`[^`\s]+:\d+(-\d+)?`|\bruns? \d{6,}(, \d{6,})*|`[^`\s]+\.(md|json|ts|tsx|js|mjs|yml|yaml|sql)`|\]\(https?:\/\/[^)\s]+\)/
13
+ /** A cited repository file inside a cell, with or without a line: `path/to/x.ts`, `CHANGELOG.md:12`. */
14
+ export const CITED_FILE = /`((?:\.{0,2}[\w@.-]+\/)*[\w@.$-]+\.(?:md|json|ts|tsx|js|jsx|mjs|yml|yaml|sql|txt))(?::(\d+)(?:-(\d+))?)?`/g
15
+
16
+ /** The audiences a context file declares (spec 19, Audiences): `team` when it says nothing. */
17
+ export type Audience = 'publishable' | 'team' | 'restricted'
18
+ export const readAudience = (v: unknown): Audience => (v === 'publishable' || v === 'restricted' ? v : 'team')
19
+ const AUDIENCE_RANK: Record<Audience, number> = { publishable: 0, team: 1, restricted: 2 }
20
+ /** The strictest of several audiences - what a conclusion drawn from them may be shown to. */
21
+ export const strictest = (...a: Audience[]): Audience => a.reduce((x, y) => (AUDIENCE_RANK[y] > AUDIENCE_RANK[x] ? y : x), 'publishable' as Audience)
22
+
23
+ /** Line endings normalized: every reader parses LF, so a CRLF file means the same thing. */
24
+ export const lf = (text: string): string => text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n')
25
+ /** The headers whose cells carry evidence in a shipped table. */
26
+ export const EVIDENCE_COLUMN = /^(evidence|verified|available|delivered)$/i
27
+
28
+ /** The context files' own front matter: a YAML subset - scalars, flow maps `{ a: b }`, flow
29
+ * lists `[a, b]`, block lists. `error` names a block that opens and never closes; a file
30
+ * with no front matter has `data: null`. */
31
+ export interface FrontMatter { data: Record<string, unknown> | null; body: string; offset: number; error?: string }
32
+ /** A Marver-managed file's first line (the playbooks Marver maintains) - front matter follows it. */
33
+ const MANAGED_LINE = /^<!-- marver:managed [^\n]*-->\n/
34
+ export function frontMatter(raw: string): FrontMatter {
35
+ const text = lf(raw)
36
+ const managed = MANAGED_LINE.exec(text)
37
+ if (managed) {
38
+ const r = frontMatter(text.slice(managed[0].length))
39
+ return { ...r, offset: r.offset + 1 }
40
+ }
41
+ if (!text.startsWith('---\n')) return { data: null, body: text, offset: 0 }
42
+ const m = text.match(/^---\n([\s\S]*?)\n---[ \t]*(\n|$)/)
43
+ if (!m) return { data: null, body: text, offset: 0, error: 'front matter opens with --- and never closes' }
44
+ const data: Record<string, unknown> = {}
45
+ let key: string | null = null
46
+ for (const line of m[1].split('\n')) {
47
+ const item = line.match(/^\s+-\s+(.*)$/)
48
+ if (item && key) {
49
+ if (!Array.isArray(data[key])) data[key] = []
50
+ ;(data[key] as unknown[]).push(scalar(item[1]))
51
+ continue
52
+ }
53
+ const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/)
54
+ if (!kv) continue
55
+ key = kv[1]
56
+ data[key] = kv[2] === '' ? [] : value(kv[2])
57
+ }
58
+ return { data, body: text.slice(m[0].length), offset: m[0].split('\n').length - 1 }
59
+ }
60
+ function scalar(s: string): string {
61
+ s = s.trim().replace(/\s+#.*$/, '')
62
+ if (/^".*"$|^'.*'$/.test(s)) return s.slice(1, -1)
63
+ return s
64
+ }
65
+ function splitTop(s: string): string[] {
66
+ const out: string[] = []
67
+ let depth = 0, cur = '', q: string | null = null
68
+ for (const ch of s) {
69
+ if (q) { cur += ch; if (ch === q) q = null; continue }
70
+ if (ch === '"' || ch === "'") { q = ch; cur += ch; continue }
71
+ if (ch === '{' || ch === '[') depth++
72
+ if (ch === '}' || ch === ']') depth--
73
+ if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue }
74
+ cur += ch
75
+ }
76
+ if (cur.trim()) out.push(cur)
77
+ return out
78
+ }
79
+ function value(s: string): unknown {
80
+ s = s.trim().replace(/\s+#[^"'}\]]*$/, '')
81
+ if (s.startsWith('{') && s.endsWith('}')) {
82
+ const o: Record<string, unknown> = {}
83
+ for (const part of splitTop(s.slice(1, -1))) {
84
+ const i = part.indexOf(':')
85
+ if (i > 0) o[part.slice(0, i).trim()] = value(part.slice(i + 1))
86
+ }
87
+ return o
88
+ }
89
+ if (s.startsWith('[') && s.endsWith(']')) return splitTop(s.slice(1, -1)).map((x) => value(x))
90
+ return scalar(s)
91
+ }
92
+
93
+ /** Lines outside fenced code blocks, with their 1-based numbers in the whole file. */
94
+ export function proseLines(text: string, offset = 0): [number, string][] {
95
+ const out: [number, string][] = []
96
+ let fenced = false
97
+ lf(text).split('\n').forEach((line, i) => {
98
+ if (/^\s*```/.test(line)) { fenced = !fenced; return }
99
+ if (!fenced) out.push([i + 1 + offset, line])
100
+ })
101
+ return out
102
+ }
103
+
104
+ /** Markdown tables outside code: each header and its rows, cells trimmed, with line numbers. */
105
+ export interface Table { header: string[]; headerLine: number; rows: { line: number; cells: string[] }[] }
106
+ export function tables(text: string): Table[] {
107
+ const out: Table[] = []
108
+ let cur: Table | null = null
109
+ for (const [n, line] of proseLines(text)) {
110
+ if (!/^\s*\|/.test(line)) { cur = null; continue }
111
+ if (/^\s*\|[\s|:-]+\|\s*$/.test(line)) continue
112
+ const cells = splitRow(line)
113
+ if (!cur) { cur = { header: cells, headerLine: n, rows: [] }; out.push(cur); continue }
114
+ cur.rows.push({ line: n, cells })
115
+ }
116
+ return out
117
+ }
118
+ /** A table row's cells - pipes inside backticks stay in their cell. */
119
+ function splitRow(line: string): string[] {
120
+ const s = line.trim().replace(/^\|/, '').replace(/\|$/, '')
121
+ const out: string[] = []
122
+ let cur = '', code = false
123
+ for (const ch of s) {
124
+ if (ch === '`') code = !code
125
+ if (ch === '|' && !code) { out.push(cur.trim()); cur = ''; continue }
126
+ cur += ch
127
+ }
128
+ out.push(cur.trim())
129
+ return out
130
+ }
131
+
132
+ export const levelsIn = (cell: string): Level[] => [...cell.matchAll(LEVEL)].map((m) => m[1] as Level)
133
+
134
+ /** A table whose header row has no outer pipes - Markdown renders it, the readers here do not. The
135
+ * check rejects it rather than read past it. Returns the separator rows' line numbers. */
136
+ export function looseTables(text: string): number[] {
137
+ return proseLines(text).filter(([, l]) => !/^\s*\|/.test(l) && /^\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(l)).map(([n]) => n)
138
+ }
139
+
140
+ /** The levels an availability cell grants, clause by clause (`;` separates them). A clause counts
141
+ * only when it claims availability: never one carrying a negation anywhere ("nowhere", "not
142
+ * available", "rolled back", "withdrawn") - and, for a product's Available cell, never one scoped to
143
+ * a pre-production place (staging, preview, sandbox) that does not also name production. A
144
+ * knowledge-work Delivered cell has no environments: only negations void it. */
145
+ const NEGATION = /\b(nowhere|none|never|no longer|not (yet )?(available|live|deployed|delivered|on|in|shipped|released|out)|not yet|pending|planned|scheduled|upcoming|awaiting|withdrawn|rolled back|reverted|removed|retired|pulled)\b|^\s*not\b/i
146
+ /** The places a product is NOT yet available to its users, unless the clause also names production. */
147
+ const PRE_PRODUCTION = /\b(staging|preview|sandbox|dev|development|local|locally|testing|test environment|qa)\b/i
148
+ export function availableLevels(cell: string, kind: 'available' | 'delivered' = 'available'): Level[] {
149
+ const out: Level[] = []
150
+ for (const clause of cell.split(';')) {
151
+ const c = clause.replace(/\*\*/g, '').trim()
152
+ if (NEGATION.test(c)) continue
153
+ if (kind === 'available' && PRE_PRODUCTION.test(c) && !/\b(production|prod)\b/i.test(c)) continue
154
+ out.push(...levelsIn(c))
155
+ }
156
+ return out
157
+ }
158
+
159
+ /** The shipped record's capability rows: every table whose first column is "Capability" (a
160
+ * product) or "Project" / "Deliverable" (knowledge work) and that has an "Available" or
161
+ * "Delivered" column. The capability is the first backticked slug in the row's first cell;
162
+ * `levels` are the ones its availability clauses grant (availableLevels). */
163
+ export interface ShippedRow { capability: string; line: number; available: string; levels: Level[] }
164
+ export const RECORD_KEY = /^(capability|project|deliverable)$/i
165
+ export const RECORD_AVAILABLE = /^(available|delivered)$/i
166
+ export const isRecordTable = (t: Table): boolean => RECORD_KEY.test(t.header[0] ?? '') && t.header.some((h) => RECORD_AVAILABLE.test(h))
167
+ export function shippedRows(text: string): ShippedRow[] {
168
+ const out: ShippedRow[] = []
169
+ for (const t of tables(text)) {
170
+ if (!isRecordTable(t)) continue
171
+ const a = t.header.findIndex((h) => RECORD_AVAILABLE.test(h))
172
+ const kind = /^delivered$/i.test(t.header[a]) ? 'delivered' : 'available'
173
+ for (const r of t.rows) {
174
+ const slug = /`([a-z0-9][a-z0-9-]*)`/.exec(r.cells[0] ?? '')?.[1]
175
+ if (!slug) continue
176
+ const available = r.cells[a] ?? ''
177
+ out.push({ capability: slug, line: r.line, available, levels: availableLevels(available, kind) })
178
+ }
179
+ }
180
+ return out
181
+ }
182
+
183
+ /** Is this a glob the matcher reads? Balanced, unnested braces; no character classes. */
184
+ export const validGlob = (g: string): boolean => {
185
+ if (typeof g !== 'string' || !g || /[[\]]/.test(g)) return false
186
+ let depth = 0
187
+ for (const c of g) { if (c === '{') { if (++depth > 1) return false } else if (c === '}') { if (--depth < 0) return false } }
188
+ return depth === 0
189
+ }
190
+ export const hasGlob = (g: string): boolean => /[*?{]/.test(g)
191
+ /** A glob with **, *, ? and {a,b} as a RegExp over repo-relative paths (validGlob first). */
192
+ export function globRe(glob: string): RegExp {
193
+ let re = '', brace = 0
194
+ for (let i = 0; i < glob.length; i++) {
195
+ const c = glob[i]
196
+ if (c === '*') {
197
+ if (glob[i + 1] === '*') {
198
+ i++
199
+ if (glob[i + 1] === '/') { i++; re += '(?:[\\s\\S]*/)?' } else re += '[\\s\\S]*'
200
+ } else re += '[^/]*'
201
+ } else if (c === '?') re += '[^/]'
202
+ else if (c === '{') { brace++; re += '(?:' }
203
+ else if (c === '}' && brace) { brace--; re += ')' }
204
+ else if (c === ',' && brace) re += '|'
205
+ else re += c.replace(/[.+^$()|[\]\\]/g, '\\$&')
206
+ }
207
+ return new RegExp(`^${re}$`)
208
+ }
209
+
210
+ /** `context/map.json`'s shape. */
211
+ export interface CapabilityMap {
212
+ /** `summary` - a few words the index shows beside the slug, so a reader routes without opening the contract */
213
+ capabilities: Record<string, { contract?: string; summary?: string; paths: string[]; tests?: string[] }>
214
+ excluded: { path: string; reason: string }[]
215
+ /** the globs whose files must be mapped or excluded - the check reports any that is neither */
216
+ source?: string[]
217
+ }
218
+ /** Read a map's text: the map, or what is wrong with it - every field checked, every glob valid. */
219
+ export function parseMap(text: string): CapabilityMap | string {
220
+ let raw: unknown
221
+ try { raw = JSON.parse(text) } catch { return 'context/map.json is not valid JSON' }
222
+ const m = raw as Partial<CapabilityMap> | null
223
+ if (!m || typeof m !== 'object' || !m.capabilities || typeof m.capabilities !== 'object' || Array.isArray(m.capabilities)) return 'context/map.json needs a "capabilities" object'
224
+ const globs = (where: string, v: unknown, required: boolean): string | null => {
225
+ if (v === undefined && !required) return null
226
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) return `context/map.json: ${where} must be a list of paths`
227
+ const bad = (v as string[]).findIndex((g) => !validGlob(g))
228
+ return bad >= 0 ? `context/map.json: ${where} has an invalid glob "${(v as string[])[bad]}" (not empty; balanced, unnested {a,b}; no [...])` : null
229
+ }
230
+ for (const [k, c] of Object.entries(m.capabilities)) {
231
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(k)) return `context/map.json: "${k}" is not a capability slug`
232
+ const cc = c as Record<string, unknown> | null
233
+ if (!cc || typeof cc !== 'object') return `context/map.json: "${k}" must be an object`
234
+ const e = globs(`"${k}".paths`, cc.paths, true) ?? globs(`"${k}".tests`, cc.tests, false)
235
+ if (e) return e
236
+ if (cc.contract !== undefined && typeof cc.contract !== 'string') return `context/map.json: "${k}".contract must be a path`
237
+ if (cc.summary !== undefined && typeof cc.summary !== 'string') return `context/map.json: "${k}".summary must be text`
238
+ }
239
+ if (m.excluded !== undefined) {
240
+ if (!Array.isArray(m.excluded)) return 'context/map.json: "excluded" must be a list'
241
+ for (const x of m.excluded as unknown[]) {
242
+ const e = x as { path?: unknown; reason?: unknown } | null
243
+ if (!e || typeof e.path !== 'string' || !validGlob(e.path)) return 'context/map.json: every "excluded" entry needs a valid "path" glob'
244
+ if (typeof e.reason !== 'string' || !e.reason.trim()) return `context/map.json: excluded "${e.path}" needs a "reason"`
245
+ }
246
+ }
247
+ { const e = globs('"source"', (m as { source?: unknown }).source, false); if (e) return e }
248
+ const source = Array.isArray(m.source) ? m.source.filter((x): x is string => typeof x === 'string') : undefined
249
+ return { capabilities: m.capabilities as CapabilityMap['capabilities'], excluded: Array.isArray(m.excluded) ? m.excluded : [], ...(source ? { source } : {}) }
250
+ }
251
+
252
+ /** The index's generated capability table, between `<!-- generated ... -->` fences. */
253
+ export const GENERATED = /<!-- generated[^>]*-->([\s\S]*?)<!-- \/generated -->/
254
+ /** The table the index generates from the map: one row per capability, its contract linked. */
255
+ export function capabilityTable(map: CapabilityMap): string {
256
+ const rows = Object.entries(map.capabilities).map(([k, c]) => {
257
+ const name = `\`${k}\`${typeof c.summary === 'string' && c.summary.trim() ? ` - ${c.summary.trim().replace(/\|/g, '/')}` : ''}`
258
+ const rel = c.contract?.replace(/^context\//, '')
259
+ return rel ? `| ${name} | [\`${rel}\`](${rel}) |` : `| ${name} | none yet - see the map |`
260
+ })
261
+ return ['| Capability | Contract |', '|---|---|', ...rows].join('\n')
262
+ }
263
+
264
+ /** Words in a document's body (front matter excluded) - the index's budget counts these. */
265
+ export const wordCount = (text: string): number => frontMatter(text).body.split(/\s+/).filter(Boolean).length
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Status (spec 20) - one resolver, shared by the dev server and the build: files in, a status and
3
+ * its evidence out, pure. Feature and project boards wear it; read top to bottom, the first row
4
+ * that matches wins:
5
+ *
6
+ * 1-3 the board says archived, paused or blocked (with its reason) - a decision, by hand
7
+ * 4 the evidence it needs cannot be read - Unknown, never a stale Done
8
+ * 5 an open plan names the capability - In progress, filling by phase
9
+ * 6 the shipped record shows it available, `confirmed` - Done
10
+ * 7 ... `reported` only - Done, reported
11
+ * 8 an accepted contract (`state: current`), no availability - To do
12
+ * 9 anything else - Backlog
13
+ *
14
+ * Without `context/`, a board may also say in-progress, todo or backlog by hand. Done is never
15
+ * set by hand: `"status": "done"` is ignored here and reported by `marver context check`.
16
+ */
17
+ import { DECISIONS, HAS_STATUS, type BoardType, type StatusWord } from './board-types.ts'
18
+ import { strictest, type Audience, type Level } from './context.ts'
19
+
20
+ export type Status = 'archived' | 'paused' | 'blocked' | 'unknown' | 'in-progress' | 'done' | 'done-reported' | 'todo' | 'backlog'
21
+ export const STATUS_LABEL: Record<Status, string> = {
22
+ archived: 'Archived', paused: 'Paused', blocked: 'Blocked', unknown: 'Unknown', 'in-progress': 'In progress',
23
+ done: 'Done', 'done-reported': 'Done, reported', todo: 'To do', backlog: 'Backlog',
24
+ }
25
+
26
+ /** A phase, as the fill reads it: 1 spec, 2 lo-fi, 3 hi-fi. */
27
+ export type Phase = 1 | 2 | 3
28
+ export const PHASE_LABEL: Record<Phase, string> = { 1: 'spec', 2: 'lo-fi', 3: 'hi-fi' }
29
+
30
+ /** What the resolver knows about `context/` - read once per pass by the caller. */
31
+ export interface ContextFacts {
32
+ /** a `context/` directory exists */
33
+ present: boolean
34
+ /** evidence that could not be read: `'*'` for the whole record (shipped.md unreadable), else per capability */
35
+ unreadable: Map<string, string>
36
+ /** capability -> the levels its shipped row's availability grants, where, and the record's audience */
37
+ shipped: Map<string, { levels: Level[]; where: string; audience: Audience }>
38
+ /** capability -> its contract's state, where, and its audience */
39
+ contracts: Map<string, { state: string; where: string; audience: Audience }>
40
+ /** capability -> its open plans, each with its audience */
41
+ plans: Map<string, { where: string; audience: Audience }[]>
42
+ }
43
+ export const NO_CONTEXT: ContextFacts = { present: false, unreadable: new Map(), shipped: new Map(), contracts: new Map(), plans: new Map() }
44
+
45
+ export interface BoardInput {
46
+ name: string
47
+ type: BoardType
48
+ capability?: string
49
+ status?: StatusWord
50
+ reason?: string
51
+ /** the scenes the board shows, with any `phase` their brief declares and that brief's audience */
52
+ scenes: { name: string; phase?: string; audience?: Audience }[]
53
+ }
54
+
55
+ export interface StatusResult {
56
+ status: Status
57
+ /** the table row that decided it */
58
+ row: number
59
+ /** In progress: the latest phase present */
60
+ fill?: Phase
61
+ /** Blocked: why */
62
+ reason?: string
63
+ /** the capability the evidence was read for */
64
+ capability: string
65
+ /** one line per piece of evidence, for the tooltip */
66
+ evidence: string[]
67
+ /** the strictest audience of the evidence the status was drawn from - a published canvas shows a
68
+ * status only when this is `publishable` (spec 20, Publishing status) */
69
+ audience: Audience
70
+ }
71
+
72
+ const PHASE_WORDS: Record<string, Phase> = { spec: 1, specs: 1, lofi: 2, 'lo-fi': 2, hifi: 3, 'hi-fi': 3 }
73
+
74
+ /** The latest phase a board's scenes show: a scene named `<cap>-specs` (spec), `<cap>-lofi`
75
+ * (lo-fi) or `<cap>` itself (hi-fi), or a `phase` in a scene's brief. Never geometry. */
76
+ export function phaseOf(capability: string, scenes: BoardInput['scenes']): Phase | undefined {
77
+ return phaseSource(capability, scenes)?.phase
78
+ }
79
+ /** The deciding phase and where it came from - a brief's phase carries that brief's audience. */
80
+ function phaseSource(capability: string, scenes: BoardInput['scenes']): { phase: Phase; scene: string; fromBrief: boolean; audience: Audience } | undefined {
81
+ let best: { phase: Phase; scene: string; fromBrief: boolean; audience: Audience } | undefined
82
+ for (const s of scenes) {
83
+ const declared = s.phase ? PHASE_WORDS[s.phase.toLowerCase()] : undefined
84
+ const named = s.name === `${capability}-specs` || s.name === `${capability}-spec` ? 1 : s.name === `${capability}-lofi` ? 2 : s.name === capability ? 3 : undefined
85
+ const p = (declared ?? named) as Phase | undefined
86
+ if (p && (!best || p > best.phase)) best = { phase: p, scene: s.name, fromBrief: !!declared, audience: declared ? (s.audience ?? 'team') : 'publishable' }
87
+ }
88
+ return best
89
+ }
90
+
91
+ /** A board's status, or null when its type carries none. */
92
+ export function resolveStatus(b: BoardInput, ctx: ContextFacts): StatusResult | null {
93
+ if (!HAS_STATUS.includes(b.type)) return null
94
+ const capability = b.capability ?? b.name
95
+ const out = (status: Status, row: number, evidence: string[], audience: Audience, extra: Partial<StatusResult> = {}): StatusResult => ({ status, row, capability, evidence, audience, ...extra })
96
+
97
+ // 1-3: a decision on the board
98
+ if (b.status && (DECISIONS as readonly string[]).includes(b.status)) {
99
+ const by = `design/boards/${b.name}.json: "status": "${b.status}"`
100
+ return out(b.status as Status, DECISIONS.indexOf(b.status as (typeof DECISIONS)[number]) + 1, [by], 'team', b.status === 'blocked' && b.reason ? { reason: b.reason } : {})
101
+ }
102
+
103
+ if (!ctx.present) {
104
+ // without context/ a project sets To do, Backlog and In progress by hand; Done needs a record
105
+ // the board's own word, and a board ships as written - publishable
106
+ if (b.status === 'in-progress') {
107
+ const f = fillOf(capability, b.scenes)
108
+ return out('in-progress', 5, [`design/boards/${b.name}.json: by hand`, ...f.evidence], strictest('publishable', ...f.audience), f.fill)
109
+ }
110
+ if (b.status === 'todo') return out('todo', 8, [`design/boards/${b.name}.json: by hand`], 'publishable')
111
+ return out('backlog', 9, [b.status === 'backlog' ? `design/boards/${b.name}.json: by hand` : 'no context/ - nothing records it'], 'publishable')
112
+ }
113
+
114
+ // 4: evidence that cannot be read is Unknown - never the last value seen
115
+ const bad = ctx.unreadable.get('*') ?? ctx.unreadable.get(capability)
116
+ if (bad) return out('unknown', 4, [bad], 'team')
117
+
118
+ const row = ctx.shipped.get(capability)
119
+ const live = row?.levels.includes('confirmed') ? 'confirmed' : row?.levels.includes('reported') ? 'reported' : null
120
+
121
+ // 5: an open plan - work on a shipped capability is version two being built, and says so
122
+ const plans = ctx.plans.get(capability)
123
+ if (plans?.length) {
124
+ const f = fillOf(capability, b.scenes)
125
+ const ev = [...plans.map((p) => `${p.where}: an open plan`), ...f.evidence]
126
+ if (live && row) ev.push(`${row.where}: available, \`${live}\` - this is the next version`)
127
+ return out('in-progress', 5, ev, strictest(...plans.map((p) => p.audience), ...f.audience, ...(live && row ? [row.audience] : [])), f.fill)
128
+ }
129
+
130
+ // 6-7: the shipped record
131
+ if (live === 'confirmed') return out('done', 6, [`${row!.where}: available, \`confirmed\``], row!.audience)
132
+ if (live === 'reported') return out('done-reported', 7, [`${row!.where}: available, \`reported\` only`], row!.audience)
133
+
134
+ // 8: an accepted contract
135
+ const c = ctx.contracts.get(capability)
136
+ if (c?.state === 'current') return out('todo', 8, [`${c.where}: state current, nothing available yet`], strictest(c.audience, ...(row ? [row.audience] : [])))
137
+
138
+ // 9: nothing records it - which says nothing private
139
+ return out('backlog', 9, [c ? `${c.where}: state ${c.state}` : `no contract for ${capability}`], c ? c.audience : 'publishable')
140
+ }
141
+
142
+ /** The fill, the line that says where it came from, and the audience of that source. */
143
+ const fillOf = (capability: string, scenes: BoardInput['scenes']): { fill: Partial<StatusResult>; evidence: string[]; audience: Audience[] } => {
144
+ const src = phaseSource(capability, scenes)
145
+ if (!src) return { fill: {}, evidence: [], audience: [] }
146
+ const where = src.fromBrief ? `design/scenes/${src.scene}/_brief.md: phase ${PHASE_LABEL[src.phase]}` : `design/scenes/${src.scene}: the ${PHASE_LABEL[src.phase]} scene`
147
+ return { fill: { fill: src.phase }, evidence: [where], audience: [src.audience] }
148
+ }
149
+
150
+ /** What a published canvas may show of a status (spec 20, Publishing status): rows 5-9 only, drawn
151
+ * from `publishable` evidence only, and never a reason, a record path or other evidence. */
152
+ export function publishableStatus(r: { status: Status; row?: number; fill?: Phase; audience?: Audience } | null | undefined): { status: Status; fill?: Phase } | null {
153
+ if (!r || !PUBLISHABLE.has(r.status) || r.audience !== 'publishable') return null
154
+ return { status: r.status, ...(r.fill ? { fill: r.fill } : {}) }
155
+ }
156
+ /** The statuses rows 5-9 produce - the only ones a published canvas may show. */
157
+ export const PUBLISHABLE: ReadonlySet<Status> = new Set(['in-progress', 'done', 'done-reported', 'todo', 'backlog'])
@@ -22,6 +22,7 @@ file in design/instructions/ - they are short, strict, and part of this contract
22
22
  | Iterate | changing a frame the human has seen, a round of feedback on a scene, or retiring explorations | instructions/iterate.md |
23
23
  | Review | before presenting anything | instructions/review.md |
24
24
  | Boards | creating a board, choosing what ships | instructions/boards.md |
25
+ | Context | any question about what the product does or what is live; a behaviour change; a deploy; no `context/` yet | instructions/context.md |
25
26
  | Slides | a deck is asked for, or `slide: true` frames exist | instructions/slides.md + design/slides.md |
26
27
  | Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
27
28
  | Live Jam | responding to an `@marver` comment (a spawned job); on by default - confirm it names YOUR tool | instructions/jam.md |
@@ -197,9 +198,9 @@ edges; a variant-group name is one indivisible atom). **The default composition
197
198
  ONE horizontal band**: scenes side by side, frames flowing left to right; a second
198
199
  band only when you can say why the eye should move down, and then with generous
199
200
  vertical space. Without a recipe the shell stacks every scene as its own row - so
200
- every curated board carries one. Boards can sit in **folders** (one level): put
201
- `"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
202
- and ranks them - the human creates, renames, and drags folders in the sidebar too, so
201
+ every curated board carries one. Boards can sit in **folders**, two levels deep: put
202
+ `"folder": "<name>"` on a board file (the folder it sits in directly); `design/boards/_folders.json`
203
+ names empty folders, ranks them, and nests a sub-folder with `"parent"` - the human creates, renames, and drags folders in the sidebar too, so
203
204
  run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
204
205
  creating a board, organising boards, or publishing anything, read instructions/boards.md
205
206
  (the layout grammar, file format, folders and their moves, publishing rules).
@@ -22,6 +22,7 @@ file in design/instructions/ - they are short, strict, and part of this contract
22
22
  | Iterate | changing a frame the human has seen, a round of feedback on a scene, or retiring explorations | instructions/iterate.md |
23
23
  | Review | before presenting anything | instructions/review.md |
24
24
  | Boards | creating a board, choosing what ships | instructions/boards.md |
25
+ | Context | any question about what the product does or what is live; a behaviour change; a deploy; no `context/` yet | instructions/context.md |
25
26
  | Slides | a deck is asked for, or `slide: true` frames exist | instructions/slides.md + design/slides.md |
26
27
  | Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
27
28
  | Live Jam | responding to an `@marver` comment (a spawned job); on by default - confirm it names YOUR tool | instructions/jam.md |
@@ -197,9 +198,9 @@ edges; a variant-group name is one indivisible atom). **The default composition
197
198
  ONE horizontal band**: scenes side by side, frames flowing left to right; a second
198
199
  band only when you can say why the eye should move down, and then with generous
199
200
  vertical space. Without a recipe the shell stacks every scene as its own row - so
200
- every curated board carries one. Boards can sit in **folders** (one level): put
201
- `"folder": "<name>"` on a board file; `design/boards/_folders.json` names empty folders
202
- and ranks them - the human creates, renames, and drags folders in the sidebar too, so
201
+ every curated board carries one. Boards can sit in **folders**, two levels deep: put
202
+ `"folder": "<name>"` on a board file (the folder it sits in directly); `design/boards/_folders.json`
203
+ names empty folders, ranks them, and nests a sub-folder with `"parent"` - the human creates, renames, and drags folders in the sidebar too, so
203
204
  run `npx marver boards` (the tree as the files say it is) before you organise. BEFORE
204
205
  creating a board, organising boards, or publishing anything, read instructions/boards.md
205
206
  (the layout grammar, file format, folders and their moves, publishing rules).
@@ -0,0 +1,50 @@
1
+ ---
2
+ audience: team
3
+ ---
4
+
5
+ # {{NAME}} - the index
6
+
7
+ Routing only. Read this, then at most two more files per question. It stays under 800 words -
8
+ `npx marver context check` counts.
9
+
10
+ ## The product
11
+
12
+ <!-- Five lines at most: what it is and for whom; what runs where; what is happening now. -->
13
+
14
+ ## Questions, and where they are answered
15
+
16
+ | Question | Read |
17
+ |---|---|
18
+ | {{RECORD_QUESTION}} | [`shipped.md`](shipped.md) |
19
+ | How does it work today? | its contract below; without one, [`map.json`](map.json) names the code and tests |
20
+ | Why was it decided? | the decision log - say where this project keeps it |
21
+ | What is next, and what counts as done? | the roadmap - say where |
22
+ | Who asked for it, and what happened to it? | `feedback/` |
23
+ | How do we do it? | "How we do things", below |
24
+
25
+ ## Capabilities
26
+
27
+ <!-- generated: `npx marver context index` keeps this table equal to context/map.json -->
28
+ | Capability | Contract |
29
+ |---|---|
30
+ <!-- /generated -->
31
+
32
+ ## Where each role lives
33
+
34
+ <!-- Healthy homes left in place: the changelog, the decision log, research, test records, the canvas. -->
35
+
36
+ ## How we do things
37
+
38
+ - [`reorganize-context`](playbooks/reorganize-context/PLAYBOOK.md) - when context exists but is
39
+ scattered: specs out of date, nobody sure what is live.
40
+ - [`publish-canvas`](playbooks/publish-canvas/PLAYBOOK.md) - when the canvas should be on a URL.
41
+
42
+ ## Rules
43
+
44
+ - **Availability is written only in `shipped.md`,** each cell `confirmed`, `reported` or `unknown`,
45
+ with a citation.
46
+ - **A behaviour change updates its contract,** or the pull request says
47
+ `no-contract-change: <capability> - <why>`.
48
+ - **Every deploy writes the record.**
49
+ - **A plan lives in `plans/` while open** and folds into its contract when it lands.
50
+ - `npx marver context check` checks these; ci runs it.
@@ -0,0 +1,6 @@
1
+ {
2
+ "about": "Which code each capability lives in - routing for agents, and the input of the pull-request rule in `npx marver context check`. A file may map to several capabilities. `source` names what must be mapped or excluded; the check reports any file that is neither. Only capabilities with a contract are checked on pull requests.",
3
+ "source": ["src/**", "app/**", "apps/**", "packages/**", "lib/**"],
4
+ "capabilities": {},
5
+ "excluded": []
6
+ }
@@ -0,0 +1,19 @@
1
+ ---
2
+ kind: record
3
+ audience: team
4
+ ---
5
+
6
+ # Delivered - what was delivered, to whom, and when
7
+
8
+ The only file that says what is delivered. Every evidence cell carries its level, and `confirmed`
9
+ and `reported` carry a citation:
10
+
11
+ - `confirmed` - observed in a system of record: the client's written acceptance, the sent email,
12
+ the signed-off document.
13
+ - `reported` - a written claim, cited: a meeting note, a changelog line.
14
+ - `unknown` - neither, with the reason. An honest unknown is the right answer.
15
+
16
+ ## Projects
17
+
18
+ | Project | Delivered | Evidence | Contract |
19
+ |---|---|---|---|