@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.
- package/CHANGELOG.md +123 -0
- package/README.md +17 -4
- package/dist/{bake-tAb0D6Rc.mjs → bake-BSX4XR_U.mjs} +1 -1
- package/dist/board-status-CWGHIdo_.mjs +1307 -0
- package/dist/boards-BTGNPVMx.mjs +187 -0
- package/dist/{build-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
- package/dist/cli.mjs +67 -8
- package/dist/config-DJxMRVD8.mjs +373 -0
- package/dist/context-DUAENlJ6.mjs +600 -0
- package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
- package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
- package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
- package/dist/managed-write-Bo-oPc-i.mjs +71 -0
- package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
- package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
- package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
- package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
- package/dist/{shot-iw3SEcpn.mjs → shot-C22Ues04.mjs} +2 -2
- package/docs/boards-and-folders.md +161 -0
- package/docs/context.md +117 -0
- package/docs/sharing.md +12 -2
- package/package.json +1 -1
- package/src/client/shell/BoardList.tsx +143 -54
- package/src/client/shell/ContextMenu.tsx +16 -5
- package/src/client/shell/StatusPicker.tsx +91 -0
- package/src/client/shell/board-icons.tsx +75 -0
- package/src/client/shell/store.ts +93 -14
- package/src/client/shell/styles.css +41 -9
- package/src/shared/board-tree.ts +291 -143
- package/src/shared/board-types.ts +103 -0
- package/src/shared/context.ts +265 -0
- package/src/shared/status.ts +157 -0
- package/templates/AGENTS-embedded.md +4 -3
- package/templates/AGENTS-studio.md +4 -3
- package/templates/context/INDEX.md +50 -0
- package/templates/context/map.json +6 -0
- package/templates/context/shipped-knowledge.md +19 -0
- package/templates/context/shipped.md +28 -0
- package/templates/instructions/boards.md +78 -22
- package/templates/instructions/context.md +114 -0
- package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
- package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
- package/templates/playbooks/reorganize-context/eval.md +93 -0
- package/dist/boards-BmxcT3Lc.mjs +0 -290
- 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
|
|
201
|
-
`"folder": "<name>"` on a board file; `design/boards/_folders.json`
|
|
202
|
-
|
|
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
|
|
201
|
-
`"folder": "<name>"` on a board file; `design/boards/_folders.json`
|
|
202
|
-
|
|
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
|
+
|---|---|---|---|
|