@marver-design/marver 0.21.0 → 0.22.1

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 +134 -15
  2. package/README.md +17 -4
  3. package/dist/{bake-BID6mo-N.mjs → bake-CvY8QR3L.mjs} +1 -1
  4. package/dist/board-status-CWGHIdo_.mjs +1307 -0
  5. package/dist/boards-BwKI0qWd.mjs +188 -0
  6. package/dist/{build-C7MqQ7hq.mjs → build-B1kavcpc.mjs} +74 -13
  7. package/dist/cli.mjs +69 -9
  8. package/dist/config-DJxMRVD8.mjs +373 -0
  9. package/dist/context-D4t59wDi.mjs +600 -0
  10. package/dist/{daemon-CRZFpl6K.mjs → daemon-PmNqoOAk.mjs} +1 -1
  11. package/dist/{dev-BNZF4Mup.mjs → dev-Cf4wLGe2.mjs} +15 -7
  12. package/dist/{init-C34BY3R4.mjs → init-C_K-cjBQ.mjs} +41 -45
  13. package/dist/managed-write-Bo-oPc-i.mjs +71 -0
  14. package/dist/{manifest-mMfUhPtL.mjs → manifest-CcdWx7ud.mjs} +42 -376
  15. package/dist/{plugin-omHLCn91.mjs → plugin-C5u07Yhv.mjs} +234 -105
  16. package/dist/{poster-BvxiAzy1.mjs → poster-BduzQBYz.mjs} +1 -1
  17. package/dist/{publish-bakes-BqzAAa3w.mjs → publish-bakes-b0jUwvM_.mjs} +2 -2
  18. package/dist/{shot-DswS4iRK.mjs → shot-BAR8hmU9.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 +33 -3
  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 +88 -9
  28. package/src/client/shell/styles.css +34 -3
  29. package/src/shared/board-tree.ts +30 -13
  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 +12 -0
  34. package/templates/AGENTS-studio.md +12 -0
  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 +33 -0
  40. package/templates/instructions/context.md +123 -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-BwiDAmPf.mjs +0 -337
  45. package/dist/boards-DnLewfj8.mjs +0 -71
@@ -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'])
@@ -5,6 +5,17 @@ Never drive or automate the canvas UI; read and write files only. (Starting
5
5
  `npx marver dev` so the human has a live canvas - first session, or on request -
6
6
  is the one allowed touch.)
7
7
 
8
+ ## Before the method: a project with no `context/`
9
+
10
+ This repository has no `context/` directory? Then **every reply in which you made, specced or changed
11
+ a feature or project board ends with this line**, word for word, until the human answers it:
12
+
13
+ > This project has no `context/` yet, so every feature board reads Backlog and nothing records what
14
+ > shipped. Want me to set it up? A few questions, then a draft from the code and the deploy history.
15
+
16
+ A yes: instructions/context.md, "Setting up". A no stands for the session - stop asking. Never set
17
+ it up unasked.
18
+
8
19
  ## The method (binding)
9
20
 
10
21
  Design work moves through phases. BEFORE working in a phase, read its instruction
@@ -22,6 +33,7 @@ file in design/instructions/ - they are short, strict, and part of this contract
22
33
  | Iterate | changing a frame the human has seen, a round of feedback on a scene, or retiring explorations | instructions/iterate.md |
23
34
  | Review | before presenting anything | instructions/review.md |
24
35
  | Boards | creating a board, choosing what ships | instructions/boards.md |
36
+ | Context | any question about what the product does or what is live; a behaviour change; a deploy; no `context/` yet | instructions/context.md |
25
37
  | Slides | a deck is asked for, or `slide: true` frames exist | instructions/slides.md + design/slides.md |
26
38
  | Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
27
39
  | Live Jam | responding to an `@marver` comment (a spawned job); on by default - confirm it names YOUR tool | instructions/jam.md |
@@ -5,6 +5,17 @@ Never drive or automate the canvas UI; read and write files only. (Starting
5
5
  `npx marver dev` so the human has a live canvas - first session, or on request -
6
6
  is the one allowed touch.)
7
7
 
8
+ ## Before the method: a project with no `context/`
9
+
10
+ This repository has no `context/` directory? Then **every reply in which you made, specced or changed
11
+ a feature or project board ends with this line**, word for word, until the human answers it:
12
+
13
+ > This project has no `context/` yet, so every feature board reads Backlog and nothing records what
14
+ > shipped. Want me to set it up? A few questions, then a draft from the code and the deploy history.
15
+
16
+ A yes: instructions/context.md, "Setting up". A no stands for the session - stop asking. Never set
17
+ it up unasked.
18
+
8
19
  ## The method (binding)
9
20
 
10
21
  Design work moves through phases. BEFORE working in a phase, read its instruction
@@ -22,6 +33,7 @@ file in design/instructions/ - they are short, strict, and part of this contract
22
33
  | Iterate | changing a frame the human has seen, a round of feedback on a scene, or retiring explorations | instructions/iterate.md |
23
34
  | Review | before presenting anything | instructions/review.md |
24
35
  | Boards | creating a board, choosing what ships | instructions/boards.md |
36
+ | Context | any question about what the product does or what is live; a behaviour change; a deploy; no `context/` yet | instructions/context.md |
25
37
  | Slides | a deck is asked for, or `slide: true` frames exist | instructions/slides.md + design/slides.md |
26
38
  | Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
27
39
  | Live Jam | responding to an `@marver` comment (a spawned job); on by default - confirm it names YOUR tool | instructions/jam.md |
@@ -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
+ |---|---|---|---|
@@ -0,0 +1,28 @@
1
+ ---
2
+ kind: record
3
+ audience: team
4
+ ---
5
+
6
+ # Shipped - what runs where, and what is available to whom
7
+
8
+ The only file that says what is available. Every evidence cell carries its level, and `confirmed`
9
+ and `reported` carry a citation:
10
+
11
+ - `confirmed` - observed in a system of record: the deploy run whose deploy step succeeded for that
12
+ service, a ci run that executed the suite. An environment marked "deployed" is not proof on its
13
+ own - some pipelines mark it when the run stood down.
14
+ - `reported` - a written claim, cited: a changelog line, an ops note.
15
+ - `unknown` - neither, with the reason. An honest unknown is the right answer.
16
+
17
+ Commit ancestry says a change is inside a revision, not that the revision is running.
18
+
19
+ ## Services × environments
20
+
21
+ | Service | Environment | Revision | Deployed at | How | Evidence |
22
+ |---|---|---|---|---|---|
23
+ | app | production | - | - | - | `unknown` - nothing recorded yet |
24
+
25
+ ## Capabilities
26
+
27
+ | Capability | Implemented | Verified | Available | Contract |
28
+ |---|---|---|---|---|
@@ -158,6 +158,33 @@ you rewrite it. Published canvases show the folders of the published boards only
158
158
  sub-folder's parent included; a folder with nothing published at any depth never reaches
159
159
  the bundle.
160
160
 
161
+ ## Types and status - what a board is for, and where it stands
162
+
163
+ Every canvas shares one sidebar vocabulary. A board's `"type"` - `start`, `feature`, `surface`,
164
+ `project`, `feedback`, `context`, `deck`, `archive` - draws its icon; set it on the board, or on a
165
+ folder's entry in `_folders.json` and every board inside wears it (its own type wins; a sub-folder's
166
+ boards fall back to the parent folder's type). Moving a board changes an inherited type only.
167
+
168
+ - **Make a board in its starting layout:** `npx marver boards new <name> --folder features` (the
169
+ folder's type), or `--type <type>`. A feature gets three phase scenes as three bands -
170
+ `<name>-specs`, `<name>-lofi`, `<name>` - each with a brief; a start board renders
171
+ `context/INDEX.md` and `context/shipped.md`; a deck starts on a title slide. It never overwrites.
172
+ The phase scenes start empty: pin each frame you make in one as a node, or the board never shows it.
173
+ - **Add a typed folder:** `npx marver folders add decks` (or `start`, `features`, `surfaces`,
174
+ `projects`, `feedback`, `context`, `archive`) - appended, never moving one that exists.
175
+ - **Feature and project boards wear a status** read from `context/` (instructions/context.md): the
176
+ capability is the board's name, or `"capability": "<slug>"`. You may decide only three things on
177
+ the board: `"status": "archived"`, `"paused"`, or `"blocked"` with `"reason": "<one sentence>"`.
178
+ **Never write `"status": "done"`** - Done comes from `context/shipped.md`, and the check fails it.
179
+ **No `context/`? Your reply ends with the context line** from design/AGENTS.md ("Before the method")
180
+ - every feature board reads Backlog until there is one. Only when the human declines may a board
181
+ say `"todo"`, `"backlog"` or `"in-progress"` by hand.
182
+ A person sets the same from the sidebar (right-click, Change status…) - it rewrites only `status`
183
+ and `reason`, so re-read a board before you edit it.
184
+ - In progress fills by phase: the phase scenes above, or `phase: spec | lofi | hifi` in a scene's
185
+ `_brief.md` front matter - never by where a row sits.
186
+ - `npx marver boards` prints every board's type and status with what decided it.
187
+
161
188
  ## The default composition: one horizontal band
162
189
 
163
190
  A board reads like a page: left to right first, down only for a reason. The
@@ -249,6 +276,12 @@ be ON a published board - unlisted frames are excluded from the bundle at build
249
276
  time. Deploying the built canvas - gate password, the collaboration volume,
250
277
  accounts and invites - is its own phase: **instructions/publish.md**.
251
278
 
279
+ When you add a board to `publish.json`, write the `type` its board type suggests - `slides` for a
280
+ deck, `refs` for a context board, `doc` for a project (`marver build` notes any row that names none).
281
+ A board's status, reason and capability never ship - unless the row says `"showStatus": true`, and
282
+ then only a status drawn from publishable evidence (`audience: publishable` under `context/`; a scene
283
+ brief counts unless it says otherwise), never Blocked, never a reason.
284
+
252
285
  The published gate page shows the app's identity: `design/logo.svg` + the host
253
286
  package name (overridable via config `share`). If the app has no logo asset yet,
254
287
  create a simple `design/logo.svg`. Leave `share.branding` ON unless the human