burgess 0.1.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.
@@ -0,0 +1,216 @@
1
+ # Burgess Schemas
2
+
3
+ Canonical formats for everything in a Burgess strategy repo. Every skill reads this file before writing anything. All state is plain markdown + YAML — nothing a human can't read in a diff.
4
+
5
+ ## Repo layout
6
+
7
+ Everything lives in a `strategy/` directory (its own git repo, or inside the product repo):
8
+
9
+ ```
10
+ strategy/
11
+ ├── evidence/ # THE PRODUCT — the evidence base
12
+ │ ├── sources.yaml # registry of every source document/interview
13
+ │ ├── items/ # one file per evidence item (atomized claims)
14
+ │ │ └── ev-0001.md # naming: ev-NNNN, monotonic, never reused
15
+ │ └── intake/ # interview transcripts & answered gap questions
16
+ │ └── intake-2026-07-12.md
17
+ ├── decisions/ # decision log — human judgments, citable
18
+ │ └── dec-001.md # naming: dec-NNN, monotonic
19
+ ├── projections/ # REGENERABLE — never hand-edited
20
+ │ ├── themes.md
21
+ │ ├── positioning.md
22
+ │ ├── messaging.md # Phase 2
23
+ │ ├── vision.md # Phase 3
24
+ │ ├── brand-platform.md # Phase 3
25
+ │ └── assets/ # Phase 3
26
+ ├── reviews/ # red-team outputs, one per run, append-only
27
+ │ └── review-2026-07-12.md
28
+ ├── scripts/
29
+ │ └── check_stale.py # copied from the burgess skill at scaffold time
30
+ ├── gap-report.md # always current; regenerated on every ingest/intake
31
+ └── manifest.yaml # DAG state: hashes, staleness, approval status
32
+ ```
33
+
34
+ ## Evidence item — `evidence/items/ev-NNNN.md`
35
+
36
+ One atomized claim per file. Atomization is what makes staleness tractable: a new document changes *specific items*, not "the context." Granularity rule: prefer more, smaller items; when in doubt, split. A claim that bundles a pain and a capability is two items.
37
+
38
+ ```markdown
39
+ ---
40
+ id: ev-0042
41
+ type: user_pain # user_pain | jtbd | capability | limitation | metric | market |
42
+ # competitive | customer_language | winloss | culture | economic
43
+ claim: "Compliance review is where agent pilots stall; teams cannot answer scope-and-authority questions defensibly."
44
+ source: prd # key into sources.yaml
45
+ location: "§1, §3.1" # structured identifiers cite directly: "FR-18", "SM-5", "§3.1"
46
+ confidence: stated # stated | inferred | assumption | secondhand
47
+ date_observed: 2026-07-10 # when the source asserted it (NOT ingestion date)
48
+ tags: [governance, icp-failed-pilot]
49
+ status: active # active | superseded | disputed
50
+ superseded_by: null # ev-id when superseded
51
+ ---
52
+ Optional prose: verbatim quote from the source, extraction notes, why the
53
+ confidence tag was chosen. Verbatim customer language goes here in full —
54
+ it is the raw material for messaging.
55
+ ```
56
+
57
+ Confidence semantics:
58
+ - `stated` — explicit in an available source.
59
+ - `inferred` — reasonable deduction; the prose says from what.
60
+ - `assumption` — needs human confirmation; downstream use must carry `[A]`.
61
+ - `secondhand` — carried by an available doc on behalf of an unavailable one. May support claims but **never solely** support a differentiator or headline claim.
62
+
63
+ Item type notes: `customer_language` items must preserve the verbatim quote in the prose body. `winloss` items name the deal outcome and what the buyer chose instead. `culture` items record observed behavior (postmortem practice, privacy stances in architecture) — they feed values in Phase 3.
64
+
65
+ ## Source registry — `evidence/sources.yaml`
66
+
67
+ ```yaml
68
+ sources:
69
+ - key: prd
70
+ title: "PRD: Seelray (4795621b-prd.md)"
71
+ kind: prd # prd | architecture | interview | winloss | transcript |
72
+ # support | review | analyst | other
73
+ date: 2026-07-10
74
+ sha256: "<hash of the file if available>"
75
+ availability: available # available | referenced_unavailable
76
+ - key: spec-v0.4
77
+ title: "Product Specification v0.4"
78
+ kind: architecture
79
+ date: null
80
+ sha256: null
81
+ availability: referenced_unavailable # gets an entry so secondhand citations resolve
82
+ ```
83
+
84
+ Referenced-but-unavailable companion docs get registry entries; items citing what an available doc says *on their behalf* use `confidence: secondhand` with `source:` set to the carrying (available) document.
85
+
86
+ ## Intake transcript — `evidence/intake/intake-YYYY-MM-DD.md`
87
+
88
+ Append-only. Registered in `sources.yaml` (`kind: interview`, key `intake-YYYY-MM-DD`). Format: frontmatter (`date`, `participants`, `driven_by: gap-report`), then Q/A pairs verbatim. Answers are then atomized into evidence items with `source: intake-YYYY-MM-DD`, `confidence: stated`.
89
+
90
+ ## Decision record — `decisions/dec-NNN.md`
91
+
92
+ ```markdown
93
+ ---
94
+ id: dec-003
95
+ decides: "v1 market category"
96
+ choice: "Subsegment: agent governance for regulated organisations, from documented authority"
97
+ rejected:
98
+ - "head-on AI governance platform"
99
+ - "new category: authority intelligence"
100
+ rationale: "Budget line already exists; new-category economics unjustifiable at n=1-3."
101
+ decided_by: null # ALWAYS null when an agent proposes; only a human fills it
102
+ date: 2026-07-12
103
+ inputs: [ev-0042, ev-0107, gap-report#q3]
104
+ affects: [projections/positioning.md]
105
+ ---
106
+ Optional prose: fuller argument, tradeoffs, review notes.
107
+ ```
108
+
109
+ Agents PROPOSE decision records, leaving `decided_by: null`. An unfilled decision blocks `status: approved` on any artifact that cites it — but not `draft` generation. The `rejected` list is mandatory when proposing: the rejections are what make the choice reviewable.
110
+
111
+ ## Projection frontmatter
112
+
113
+ Every file in `projections/` (and each file in `projections/assets/`) begins:
114
+
115
+ ```yaml
116
+ ---
117
+ artifact: positioning # themes | positioning | messaging | vision | brand-platform | assets
118
+ status: draft # draft | approved — only a human flips to approved
119
+ date: 2026-07-12
120
+ depends_on: [themes, decisions] # manifest node names
121
+ open_questions: [] # anything needing human input; [A]-tagged claims must appear here
122
+ proposed_decisions: [] # dec-ids proposed by this generation, awaiting decided_by
123
+ ---
124
+ ```
125
+
126
+ ## Citation grammar (used in all projections)
127
+
128
+ - `[E: ev-0042]` — cites an evidence item (the item carries source/location/confidence).
129
+ - `[E: ev-0042, ev-0107]` — multiple items.
130
+ - **Nothing but ids inside the brackets.** Annotations go outside: `[E: ev-0042] (secondhand, supporting only)` — never `[E: ev-0042 — secondhand]`. The checker rejects annotated tokens as malformed.
131
+ - `[D: dec-003]` — cites a human decision.
132
+ - `[I]` — inference by the generating agent; state inline what it is inferred from.
133
+ - `[A]` — assumption awaiting confirmation; must also appear in the artifact's `open_questions`.
134
+
135
+ Rules: projections cite items; items cite sources — two hops, each simple. Items with `confidence: secondhand` never *solely* support a differentiator or headline claim. A projection sentence carrying a factual claim with none of these tags is a defect the red team must flag. `gap-report#qN` anchors may be referenced in prose and in decision `inputs` — they are pointers to open questions, not evidence, and never substitute for an `[E:]` tag.
136
+
137
+ ## Gap report — `gap-report.md`
138
+
139
+ Regenerated (in place) on every ingest and intake; git history is its archive. Structure: frontmatter (`date`, `sources_covered`), then a table per missing evidence type:
140
+
141
+ | Missing evidence | Why it matters downstream | Question the team can answer | Value/effort |
142
+
143
+ Evidence types to check: customer language, competitive alternatives, win/loss, market data, usage/commercial ground truth, culture/values evidence. Each gap gets a stable anchor (`q1`, `q2`, …) so decisions can cite `gap-report#q3`. Tier by value-per-effort — win/loss notes and ~5 sales-call transcripts buy more positioning truth than almost anything else; the report says so explicitly.
144
+
145
+ ## Manifest — `manifest.yaml`
146
+
147
+ ```yaml
148
+ version: 1
149
+ nodes:
150
+ evidence:
151
+ kind: source
152
+ paths: [evidence/items, evidence/sources.yaml]
153
+ decisions:
154
+ kind: source
155
+ paths: [decisions]
156
+ themes:
157
+ kind: projection
158
+ file: projections/themes.md
159
+ depends_on: [evidence]
160
+ input_hash: null # recorded by check_stale.py --record at generation
161
+ output_hash: null # hand-edit detection
162
+ generated: null
163
+ positioning:
164
+ kind: projection
165
+ file: projections/positioning.md
166
+ depends_on: [themes, decisions]
167
+ input_hash: null
168
+ output_hash: null
169
+ generated: null
170
+ messaging:
171
+ kind: projection
172
+ file: projections/messaging.md
173
+ depends_on: [positioning]
174
+ input_hash: null
175
+ output_hash: null
176
+ generated: null
177
+ vision:
178
+ kind: projection
179
+ file: projections/vision.md
180
+ depends_on: [themes, decisions]
181
+ input_hash: null
182
+ output_hash: null
183
+ generated: null
184
+ brand-platform:
185
+ kind: projection
186
+ file: projections/brand-platform.md
187
+ depends_on: [vision, positioning, messaging]
188
+ input_hash: null
189
+ output_hash: null
190
+ generated: null
191
+ assets:
192
+ kind: projection
193
+ file: projections/assets
194
+ depends_on: [messaging, brand-platform]
195
+ input_hash: null
196
+ output_hash: null
197
+ generated: null
198
+ ```
199
+
200
+ This exact content is the scaffold template — write it verbatim on first run (it is valid before anything is generated).
201
+
202
+ ### Hash rules (implemented by `scripts/check_stale.py` — never compute by hand)
203
+
204
+ - File hash: sha256 of raw bytes. Directory hash: sha256 over `relpath\n<file-hash>\n` for files sorted by path.
205
+ - `evidence` node hash: directory hash over `evidence/items/` **excluding items whose `status:` is `superseded`**, plus `sources.yaml`. (Flipping an item to superseded changes membership, so downstream goes stale — correct. Editing an already-superseded item does not — also correct.) `evidence/intake/` is excluded: transcripts affect nothing until atomized into items.
206
+ - `decisions` node hash: directory hash over `decisions/`.
207
+ - Projection node current hash: file (or directory, for `assets`) hash of its output.
208
+ - `input_hash` of a node: sha256 over `"{dep}={current hash of dep}\n"` for its `depends_on`, sorted.
209
+ - **Stale** = recorded `input_hash` ≠ the same computation today. Computed, never asserted.
210
+ - **Hand-edited** = projection's current hash ≠ recorded `output_hash`. Projections are regenerated, not edited; a hand-edit means information is trapped outside the evidence base.
211
+
212
+ After regenerating any projection, run `python scripts/check_stale.py --record <node>` to record its hashes. Regenerating an `approved` artifact demotes it to `draft` in its own frontmatter (the human re-approves the projection of new evidence).
213
+
214
+ ## Review — `reviews/review-YYYY-MM-DD.md`
215
+
216
+ One per red-team run, append-only (suffix `-2`, `-3` for multiple runs in a day). Frontmatter: `date`, `artifacts_reviewed`, `run_reason` (post-regeneration | on-demand). Body per the red-team skill: vertical coherence table (pass/fail with break point), horizontal consistency checks, adversarial personas (severity-rated, no pass/fail), findings table routed to owning artifacts.
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env python3
2
+ """Burgess staleness checker. Stdlib only; suitable for CI.
3
+
4
+ Walks the dependency DAG in manifest.yaml, recomputes content hashes, and reports
5
+ which projections are stale, hand-edited, or missing, plus citation resolution.
6
+ Exit code is non-zero if anything needs attention.
7
+
8
+ Usage:
9
+ python check_stale.py [--repo PATH] [--json]
10
+ python check_stale.py --record NODE[,NODE...] # after regenerating those nodes
11
+ python check_stale.py --record all # record every projection that exists
12
+
13
+ Hash rules (see references/schemas.md):
14
+ file hash = sha256(bytes)
15
+ dir hash = sha256 over "relpath\\n<file-hash>\\n", files sorted by relpath
16
+ evidence node = dir hash over evidence/items (excluding status: superseded) + sources.yaml
17
+ decisions node = dir hash over decisions/
18
+ projection node = file/dir hash of its output
19
+ input_hash(node) = sha256 over "dep=<current hash of dep>\\n" for depends_on, sorted
20
+ stale = recorded input_hash != input_hash computed today
21
+ hand-edited = current output hash != recorded output_hash
22
+ """
23
+
24
+ import argparse
25
+ import datetime
26
+ import hashlib
27
+ import json
28
+ import re
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ # ---------------------------------------------------------------- yaml subset
33
+
34
+ def _scalar(tok):
35
+ tok = tok.strip()
36
+ if tok[:1] in ("'", '"'):
37
+ end = tok.find(tok[0], 1)
38
+ if end != -1:
39
+ return tok[1:end]
40
+ tok = tok.split(" #", 1)[0].rstrip()
41
+ if tok in ("null", "~", ""):
42
+ return None
43
+ return tok
44
+
45
+
46
+ def _flow_list(tok):
47
+ inner = tok.strip()[1:-1].strip()
48
+ if not inner:
49
+ return []
50
+ return [_scalar(p) for p in inner.split(",")]
51
+
52
+
53
+ def parse_yaml(text):
54
+ """Parse the restricted YAML subset Burgess writes: nested mappings by
55
+ 2-space indent, scalar values, flow lists, block lists of scalars."""
56
+ lines = []
57
+ for raw in text.splitlines():
58
+ if not raw.strip() or raw.lstrip().startswith("#"):
59
+ continue
60
+ lines.append(raw.rstrip())
61
+
62
+ def parse_block(i, indent):
63
+ result = None
64
+ while i < len(lines):
65
+ line = lines[i]
66
+ cur = len(line) - len(line.lstrip(" "))
67
+ if cur < indent:
68
+ break
69
+ if cur > indent:
70
+ raise ValueError(f"unexpected indent at line: {line!r}")
71
+ body = line.strip()
72
+ if body.startswith("- "):
73
+ if result is None:
74
+ result = []
75
+ if not isinstance(result, list):
76
+ raise ValueError(f"mixed list/map at: {line!r}")
77
+ result.append(_scalar(body[2:]))
78
+ i += 1
79
+ continue
80
+ if result is None:
81
+ result = {}
82
+ if not isinstance(result, dict):
83
+ raise ValueError(f"mixed list/map at: {line!r}")
84
+ key, _, rest = body.partition(":")
85
+ rest = rest.strip()
86
+ if rest.startswith("#"):
87
+ rest = ""
88
+ elif rest and rest[0] not in ("'", '"'):
89
+ rest = rest.split(" #", 1)[0].rstrip()
90
+ if rest == "":
91
+ child, i2 = parse_block(i + 1, indent + 2)
92
+ result[key.strip()] = child if child is not None else None
93
+ i = i2
94
+ elif rest.startswith("["):
95
+ result[key.strip()] = _flow_list(rest)
96
+ i += 1
97
+ else:
98
+ result[key.strip()] = _scalar(rest)
99
+ i += 1
100
+ return result, i
101
+
102
+ parsed, _ = parse_block(0, 0)
103
+ return parsed or {}
104
+
105
+
106
+ def emit_yaml(node, indent=0):
107
+ pad = " " * indent
108
+ out = []
109
+ if isinstance(node, dict):
110
+ for k, v in node.items():
111
+ if isinstance(v, dict):
112
+ out.append(f"{pad}{k}:")
113
+ out.append(emit_yaml(v, indent + 1))
114
+ elif isinstance(v, list):
115
+ items = ", ".join("null" if x is None else str(x) for x in v)
116
+ out.append(f"{pad}{k}: [{items}]")
117
+ elif v is None:
118
+ out.append(f"{pad}{k}: null")
119
+ else:
120
+ out.append(f"{pad}{k}: {v}")
121
+ return "\n".join(out)
122
+
123
+ # ---------------------------------------------------------------- frontmatter
124
+
125
+ FM_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.S)
126
+
127
+
128
+ def frontmatter_field(path, field):
129
+ """Extract a top-level scalar field from a file's YAML frontmatter."""
130
+ try:
131
+ text = path.read_text(encoding="utf-8", errors="replace")
132
+ except OSError:
133
+ return None
134
+ m = FM_RE.match(text)
135
+ if not m:
136
+ return None
137
+ fm = m.group(1)
138
+ fmatch = re.search(rf"^{re.escape(field)}:\s*(.*)$", fm, re.M)
139
+ if not fmatch:
140
+ return None
141
+ return _scalar(fmatch.group(1).split(" #")[0])
142
+
143
+ # ---------------------------------------------------------------- hashing
144
+
145
+ def file_hash(path):
146
+ return hashlib.sha256(path.read_bytes()).hexdigest()
147
+
148
+
149
+ def dir_hash(paths_and_names):
150
+ h = hashlib.sha256()
151
+ for name, p in sorted(paths_and_names, key=lambda t: t[0]):
152
+ h.update(f"{name}\n{file_hash(p)}\n".encode())
153
+ return h.hexdigest()
154
+
155
+
156
+ def evidence_hash(repo):
157
+ items_dir = repo / "evidence" / "items"
158
+ entries = []
159
+ active = 0
160
+ if items_dir.is_dir():
161
+ for p in sorted(items_dir.glob("*.md")):
162
+ if frontmatter_field(p, "status") == "superseded":
163
+ continue
164
+ active += 1
165
+ entries.append((f"items/{p.name}", p))
166
+ src = repo / "evidence" / "sources.yaml"
167
+ if src.is_file():
168
+ entries.append(("sources.yaml", src))
169
+ return dir_hash(entries), active
170
+
171
+
172
+ def decisions_hash(repo):
173
+ d = repo / "decisions"
174
+ entries = [(p.name, p) for p in sorted(d.glob("*.md"))] if d.is_dir() else []
175
+ return dir_hash(entries), len(entries)
176
+
177
+
178
+ def output_hash(repo, rel):
179
+ p = repo / rel
180
+ if p.is_file():
181
+ return file_hash(p)
182
+ if p.is_dir():
183
+ entries = [(str(q.relative_to(p)).replace("\\", "/"), q)
184
+ for q in sorted(p.rglob("*")) if q.is_file()]
185
+ if entries:
186
+ return dir_hash(entries)
187
+ return None
188
+
189
+ # ---------------------------------------------------------------- citations
190
+
191
+ E_CITE = re.compile(r"\[E:\s*([^\]]+)\]")
192
+ D_CITE = re.compile(r"\[D:\s*([^\]]+)\]")
193
+
194
+
195
+ def check_citations(repo, projection_files):
196
+ resolved, broken, warnings = 0, [], []
197
+ for rel in projection_files:
198
+ p = repo / rel
199
+ files = [p] if p.is_file() else (sorted(p.rglob("*.md")) if p.is_dir() else [])
200
+ for f in files:
201
+ text = f.read_text(encoding="utf-8", errors="replace")
202
+ fname = str(f.relative_to(repo)).replace("\\", "/")
203
+ approved = frontmatter_field(f, "status") == "approved"
204
+ for m in E_CITE.finditer(text):
205
+ for tok in (t.strip() for t in m.group(1).split(",")):
206
+ if not re.fullmatch(r"ev-\d{4}", tok):
207
+ broken.append(f"{fname}: malformed evidence citation '{tok}'")
208
+ continue
209
+ item = repo / "evidence" / "items" / f"{tok}.md"
210
+ if not item.is_file():
211
+ broken.append(f"{fname}: cites {tok} but evidence/items/{tok}.md does not exist")
212
+ else:
213
+ resolved += 1
214
+ if frontmatter_field(item, "status") == "superseded":
215
+ warnings.append(f"{fname}: cites {tok}, which is superseded"
216
+ f" by {frontmatter_field(item, 'superseded_by')}")
217
+ for m in D_CITE.finditer(text):
218
+ for tok in (t.strip() for t in m.group(1).split(",")):
219
+ if not re.fullmatch(r"dec-\d{3}", tok):
220
+ broken.append(f"{fname}: malformed decision citation '{tok}'")
221
+ continue
222
+ dec = repo / "decisions" / f"{tok}.md"
223
+ if not dec.is_file():
224
+ broken.append(f"{fname}: cites {tok} but decisions/{tok}.md does not exist")
225
+ continue
226
+ resolved += 1
227
+ if frontmatter_field(dec, "decided_by") in (None, ""):
228
+ msg = f"{fname}: cites {tok}, which has no decided_by (undecided)"
229
+ if approved:
230
+ broken.append(msg + " -- an approved artifact may not rest on an undecided decision")
231
+ else:
232
+ warnings.append(msg + " -- blocks approval, not drafting")
233
+ return resolved, broken, warnings
234
+
235
+ # ---------------------------------------------------------------- main
236
+
237
+ def find_repo(arg):
238
+ if arg:
239
+ repo = Path(arg)
240
+ if (repo / "manifest.yaml").is_file():
241
+ return repo
242
+ sys.exit(f"error: no manifest.yaml in {repo}")
243
+ for cand in (Path("."), Path("strategy")):
244
+ if (cand / "manifest.yaml").is_file():
245
+ return cand
246
+ sys.exit("error: no manifest.yaml found here or in ./strategy -- run the burgess "
247
+ "orchestrator to scaffold, or pass --repo")
248
+
249
+
250
+ def main():
251
+ ap = argparse.ArgumentParser(description="Burgess staleness checker")
252
+ ap.add_argument("--repo", help="path to the strategy repo (default: . or ./strategy)")
253
+ ap.add_argument("--record", help="comma-separated projection nodes to record after "
254
+ "regeneration, or 'all'")
255
+ ap.add_argument("--json", action="store_true", help="machine-readable output")
256
+ args = ap.parse_args()
257
+
258
+ repo = find_repo(args.repo)
259
+ manifest_path = repo / "manifest.yaml"
260
+ manifest = parse_yaml(manifest_path.read_text(encoding="utf-8"))
261
+ nodes = manifest.get("nodes") or {}
262
+ if not nodes:
263
+ sys.exit(f"error: manifest {manifest_path} has no nodes")
264
+
265
+ ev_hash, n_items = evidence_hash(repo)
266
+ dec_hash, n_decs = decisions_hash(repo)
267
+
268
+ current = {}
269
+ for name, spec in nodes.items():
270
+ if (spec or {}).get("kind") == "source":
271
+ current[name] = ev_hash if name == "evidence" else dec_hash
272
+ else:
273
+ current[name] = output_hash(repo, spec.get("file", ""))
274
+
275
+ def input_hash(spec):
276
+ deps = spec.get("depends_on") or []
277
+ payload = "".join(f"{d}={current.get(d)}\n" for d in sorted(deps))
278
+ return hashlib.sha256(payload.encode()).hexdigest()
279
+
280
+ # ---- record mode
281
+ if args.record:
282
+ wanted = ([n for n, s in nodes.items() if (s or {}).get("kind") != "source"
283
+ and current.get(n)] if args.record == "all"
284
+ else [t.strip() for t in args.record.split(",")])
285
+ today = datetime.date.today().isoformat()
286
+ for name in wanted:
287
+ spec = nodes.get(name)
288
+ if not isinstance(spec, dict) or spec.get("kind") == "source":
289
+ sys.exit(f"error: '{name}' is not a projection node in the manifest")
290
+ if current.get(name) is None:
291
+ sys.exit(f"error: cannot record '{name}' -- {spec.get('file')} does not exist")
292
+ spec["input_hash"] = input_hash(spec)
293
+ spec["output_hash"] = current[name]
294
+ spec["generated"] = today
295
+ with manifest_path.open("w", encoding="utf-8", newline="\n") as fh:
296
+ fh.write(emit_yaml(manifest) + "\n")
297
+ print(f"recorded: {', '.join(wanted)}")
298
+ return 0
299
+
300
+ # ---- report mode
301
+ # Staleness propagates transitively: a projection whose dependency is stale
302
+ # is itself stale, even if that dependency's file hasn't been regenerated yet.
303
+ direct_stale, stale_cache = {}, {}
304
+ for name, spec in nodes.items():
305
+ spec = spec or {}
306
+ if spec.get("kind") != "source" and spec.get("input_hash") not in (None, "") \
307
+ and current.get(name) is not None:
308
+ direct_stale[name] = input_hash(spec) != spec.get("input_hash")
309
+
310
+ def is_stale(name, trail=()):
311
+ if name in trail:
312
+ sys.exit(f"error: dependency cycle in manifest at '{name}'")
313
+ if name in stale_cache:
314
+ return stale_cache[name]
315
+ spec = nodes.get(name) or {}
316
+ result = False
317
+ if name in direct_stale:
318
+ result = direct_stale[name] or any(
319
+ is_stale(d, trail + (name,)) for d in (spec.get("depends_on") or [])
320
+ if (nodes.get(d) or {}).get("kind") != "source")
321
+ stale_cache[name] = result
322
+ return result
323
+
324
+ report, problems = [], []
325
+ for name, spec in nodes.items():
326
+ spec = spec or {}
327
+ if spec.get("kind") == "source":
328
+ count = f"{n_items} active items + sources.yaml" if name == "evidence" \
329
+ else f"{n_decs} records"
330
+ report.append({"node": name, "state": "source", "detail": count,
331
+ "hash": current[name][:12]})
332
+ continue
333
+ rel = spec.get("file", "")
334
+ cur = current.get(name)
335
+ status = None
336
+ p = repo / rel
337
+ if p.is_file():
338
+ status = frontmatter_field(p, "status")
339
+ if spec.get("input_hash") in (None, "") and cur is None:
340
+ report.append({"node": name, "state": "not generated", "detail": rel})
341
+ continue
342
+ if spec.get("input_hash") in (None, "") and cur is not None:
343
+ report.append({"node": name, "state": "UNRECORDED",
344
+ "detail": f"{rel} exists but was never recorded -- "
345
+ f"run --record {name}"})
346
+ problems.append(f"{name}: unrecorded")
347
+ continue
348
+ if cur is None:
349
+ report.append({"node": name, "state": "MISSING",
350
+ "detail": f"recorded in manifest but {rel} is absent"})
351
+ problems.append(f"{name}: missing output")
352
+ continue
353
+ edited = cur != spec.get("output_hash")
354
+ state = "FRESH"
355
+ detail = f"status: {status or '?'}; generated {spec.get('generated')}"
356
+ if edited:
357
+ state = "HAND-EDITED"
358
+ detail = (f"{rel} changed since generation -- projections are regenerated, "
359
+ f"not edited; move the edit into evidence/decisions")
360
+ problems.append(f"{name}: hand-edited")
361
+ if is_stale(name):
362
+ deps = spec.get("depends_on") or []
363
+ via = [d for d in deps if stale_cache.get(d)]
364
+ state = "STALE" if not edited else "STALE+HAND-EDITED"
365
+ detail = (f"stale via {', '.join(via)} (transitive)" if not direct_stale[name]
366
+ else f"dependency changed since generation "
367
+ f"(depends_on: {', '.join(deps)})")
368
+ problems.append(f"{name}: stale")
369
+ report.append({"node": name, "state": state, "detail": detail})
370
+
371
+ proj_files = [s.get("file") for n, s in nodes.items()
372
+ if (s or {}).get("kind") != "source" and current.get(n)]
373
+ resolved, broken, warnings = check_citations(repo, proj_files)
374
+ broken = list(dict.fromkeys(broken))
375
+ warnings = list(dict.fromkeys(warnings))
376
+ problems.extend(broken)
377
+
378
+ if args.json:
379
+ print(json.dumps({"repo": str(repo), "nodes": report,
380
+ "citations": {"resolved": resolved, "broken": broken,
381
+ "warnings": warnings},
382
+ "clean": not problems}, indent=2))
383
+ else:
384
+ print(f"burgess staleness report -- {repo}")
385
+ width = max(len(r["node"]) for r in report)
386
+ for r in report:
387
+ extra = f" [{r['hash']}]" if r.get("hash") else ""
388
+ print(f" {r['node']:<{width}} {r['state']:<16} {r['detail']}{extra}")
389
+ print(f"citations: {resolved} resolved, {len(broken)} broken, "
390
+ f"{len(warnings)} warning(s)")
391
+ for b in broken:
392
+ print(f" BROKEN: {b}")
393
+ for w in warnings:
394
+ print(f" warn: {w}")
395
+ print("clean" if not problems else f"NEEDS ATTENTION: {'; '.join(problems)}")
396
+ return 1 if problems else 0
397
+
398
+
399
+ if __name__ == "__main__":
400
+ sys.exit(main())
@@ -0,0 +1,41 @@
1
+ ---
2
+ name: derive-messaging
3
+ description: Turn Burgess positioning into a message house (master value prop, key messages, proof points), per-ICP benefit bullets sourced from real customer language, investor and customer pitches, and objection responses. Use this skill whenever the user asks for messaging, value propositions, a pitch, benefit statements, sales talking points, or objection handling — after positioning exists, or standalone with a warning that messaging built without settled positioning gets rewritten.
4
+ ---
5
+
6
+ # Derive Messaging
7
+
8
+ Messaging is positioning made usable. The message house exists so every future asset — web page, deck, sales call — draws from the same small set of claims backed by the same proof, instead of each asset inventing its own. Messaging is downstream of positioning, never the reverse: if the positioning feels wrong while writing, stop and flag it; don't paper over it with copy. Formats in the burgess skill's `references/schemas.md`.
9
+
10
+ ## Inputs
11
+
12
+ Read `projections/positioning.md` and the decision records it cites; run `python scripts/check_stale.py` first and surface staleness. Pull every `customer_language` evidence item — verbatim customer words are the scarcest and most valuable input to this skill. **Standalone without positioning:** gather the minimum interactively (target customer, top 2–3 differentiators with proof, main alternative), record via `intake-interview`'s steps, warn that this messaging is provisional, and put "run derive-positioning" in `open_questions`.
13
+
14
+ ## Procedure
15
+
16
+ 1. **Message house.**
17
+ - **Roof — master value proposition.** One sentence combining the ICP, the category frame `[D:]`, and the strongest value theme. Customer language, not architecture language.
18
+ - **Walls — key messages (3–5).** One per differentiation pillar. Each a claim a salesperson could say out loud without flinching — and without violating the pre-launch rule.
19
+ - **Foundation — proof points.** Under each key message, the concrete evidence that makes it credible, each with `[E:]` citations. A key message without a proof point is a wall without a foundation — cut it or flag it.
20
+ 2. **Benefit bullets per ICP (3–5 each).** Written in the customer's own words **where `customer_language` items exist — quote them**. Where they don't, engineer bullets from internal language and flag each one `engineered — validate with customers`. The flag is the honesty mechanism: nobody downstream may mistake engineered copy for validated copy. If every bullet is engineered, say so at the top and point at the gap report.
21
+ 3. **Two pitch variants.**
22
+ - **Investor pitch:** problem size, why now, the vision (from `projections/vision.md` if it exists — otherwise mark the slot open), why this team/approach wins. Market-size claims need `[E:]` or an explicit gap.
23
+ - **Customer pitch:** their pain, the value, the proof, the ask. No vision-speak — customers buy outcomes, not destinations.
24
+ 4. **Objections & responses.** From risk themes, known `limitation` items, and the alternatives map ("why not just use X?"). Evidence-based responses with honest caveats — a credible limitation acknowledged beats a limitation denied. Include the objections the red team's personas raised; they were free market research.
25
+
26
+ ## Output
27
+
28
+ Write `projections/messaging.md`: frontmatter per schema (`artifact: messaging`, `depends_on: [positioning]`), sections: **Message House** (Roof / Walls / Foundation), **Benefit Bullets per ICP** (each bullet marked `customer language [E: ev-NNNN]` or `engineered — validate`), **Investor Pitch**, **Customer Pitch**, **Objections & Responses** (table: objection, response, honest caveat).
29
+
30
+ Then: `python scripts/check_stale.py --record messaging`, commit `regen: messaging`, run `red-team` against it.
31
+
32
+ ## Guardrails
33
+
34
+ - Every claim traces to a proof point; every proof point to evidence. The master value prop must be recognizably the positioning's category + value — a new idea appearing here is positioning drift, not creativity.
35
+ - Nothing may contradict known `limitation` items or claim capabilities positioning didn't.
36
+ - Pre-launch rule applies in full: prospective phrasing, committed-scope labeling, no past-tense results claims — the red team's tense scan treats violations as automatic HIGH.
37
+ - Secondhand evidence never solely supports a headline claim or key message.
38
+
39
+ ## Checkpoint
40
+
41
+ Present the message house first — if the roof and walls are right, the rest is detail. List every `engineered` bullet for validation with real customers (and offer to add the validation questions to the gap report). In a waived run, record and continue.