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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/bin/burgess.js +136 -0
- package/package.json +30 -0
- package/skills/build-brand-platform/SKILL.md +43 -0
- package/skills/burgess/SKILL.md +66 -0
- package/skills/burgess/references/conventions.md +64 -0
- package/skills/burgess/references/frameworks.md +39 -0
- package/skills/burgess/references/schemas.md +216 -0
- package/skills/burgess/scripts/check_stale.py +400 -0
- package/skills/derive-messaging/SKILL.md +41 -0
- package/skills/derive-positioning/SKILL.md +44 -0
- package/skills/derive-themes/SKILL.md +43 -0
- package/skills/generate-assets/SKILL.md +37 -0
- package/skills/generate-vision/SKILL.md +41 -0
- package/skills/ingest-evidence/SKILL.md +44 -0
- package/skills/intake-interview/SKILL.md +43 -0
- package/skills/red-team/SKILL.md +57 -0
|
@@ -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.
|