@nurix/etna 0.20.46 → 0.20.48
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/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +1 -1
- package/data/harnesses/code/agents/maintenance.md +5 -1
- package/data/harnesses/code/codex/agents/maintenance.toml +5 -1
- package/data/harnesses/code/docs/structure.md +13 -10
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/compliance.mjs +13 -672
- package/data/harnesses/code/hooks/hooks.json +1 -1
- package/data/harnesses/code/hooks/lib/backlog-detector.mjs +30 -10
- package/data/harnesses/code/hooks/lib/frozen.mjs +28 -11
- package/data/harnesses/code/hooks/lib/gates.mjs +633 -0
- package/data/harnesses/code/hooks/lib/ownership.mjs +4 -14
- package/data/harnesses/code/hooks/lib/protection-gate.mjs +208 -0
- package/data/harnesses/code/hooks/lib/records-door.mjs +12 -21
- package/data/harnesses/code/hooks/lib/repo.mjs +33 -7
- package/data/harnesses/code/hooks/lib/session-log.mjs +1 -2
- package/data/harnesses/code/hooks/lib/state.mjs +4 -10
- package/data/harnesses/code/hooks/lib/walls.mjs +2 -1
- package/data/harnesses/code/hooks/protection.mjs +53 -0
- package/data/harnesses/code/skills/documentation/SKILL.md +11 -1
- package/data/harnesses/code/skills/documentation/references/style-guide.md +2 -1
- package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +3 -3
- package/data/harnesses/code/skills/documentation/templates/Readme.md +3 -2
- package/data/harnesses/code/skills/documentation/templates/doctrine.md +1 -1
- package/data/harnesses/code/skills/documentation/templates/playbook.md +63 -0
- package/data/harnesses/code/skills/documentation/templates/root-readme.md +1 -1
- package/data/harnesses/code/skills/documentation/templates/testing-strategy.md +1 -1
- package/data/harnesses/code/skills/documentation/templates/ux-behavior.md +1 -1
- package/data/harnesses/delivery/agents/maintenance.md +5 -1
- package/data/harnesses/delivery/codex/agents/maintenance.toml +5 -1
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/documentation/SKILL.md +11 -1
- package/data/harnesses/delivery/skills/documentation/references/style-guide.md +2 -1
- package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +3 -3
- package/data/harnesses/delivery/skills/documentation/templates/Readme.md +3 -2
- package/data/harnesses/delivery/skills/documentation/templates/doctrine.md +1 -1
- package/data/harnesses/delivery/skills/documentation/templates/playbook.md +63 -0
- package/data/harnesses/delivery/skills/documentation/templates/root-readme.md +1 -1
- package/data/harnesses/delivery/skills/documentation/templates/testing-strategy.md +1 -1
- package/data/harnesses/delivery/skills/documentation/templates/ux-behavior.md +1 -1
- package/data/harnesses/etna/agents/maintenance.md +5 -1
- package/data/harnesses/etna/codex/agents/maintenance.toml +5 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/documentation/SKILL.md +11 -1
- package/data/harnesses/etna/skills/documentation/references/style-guide.md +2 -1
- package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +3 -3
- package/data/harnesses/etna/skills/documentation/templates/Readme.md +3 -2
- package/data/harnesses/etna/skills/documentation/templates/doctrine.md +1 -1
- package/data/harnesses/etna/skills/documentation/templates/playbook.md +63 -0
- package/data/harnesses/etna/skills/documentation/templates/root-readme.md +1 -1
- package/data/harnesses/etna/skills/documentation/templates/testing-strategy.md +1 -1
- package/data/harnesses/etna/skills/documentation/templates/ux-behavior.md +1 -1
- package/lib/install/plan.mjs +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// harness/code/hooks/lib/protection-gate.mjs — the Protection walls: frozen records and protected files,
|
|
2
|
+
// on the file tools and on the shell write screen. The one module the fail-closed `protection.mjs`
|
|
3
|
+
// loads, so its import graph stays narrow (frozen, repo, session-log, walls, graph, node built-ins): a
|
|
4
|
+
// partial mount missing a compliance-only module cannot crash the hook that blocks on a crash.
|
|
5
|
+
// `protectionVerdict` is the predicate and writes nothing; `protectionGate` adds the logged deny.
|
|
6
|
+
// Compliance runs the same predicate first and defers when `protectionHandled` (docs/harness/code/hooks.md).
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { frozenStatusAtHead } from "./frozen.mjs";
|
|
11
|
+
import { readSafe, relIfInside, commandRoot, backtickTokens, docsRoot, resolveProjectDir } from "./repo.mjs";
|
|
12
|
+
import { appendEvent } from "./session-log.mjs";
|
|
13
|
+
import { wallEnabled } from "./walls.mjs";
|
|
14
|
+
import { readGraph } from "./graph.mjs";
|
|
15
|
+
|
|
16
|
+
// The per-call ceiling of one git call, and the default when a caller carries no deadline.
|
|
17
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
18
|
+
|
|
19
|
+
// Milliseconds a git call may take: the time left before `deadlineAt`, capped at GIT_TIMEOUT_MS.
|
|
20
|
+
function budgetMs(deadlineAt) {
|
|
21
|
+
if (!Number.isFinite(deadlineAt)) return GIT_TIMEOUT_MS;
|
|
22
|
+
return Math.max(0, Math.min(GIT_TIMEOUT_MS, Math.floor(deadlineAt - Date.now())));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function decision(permissionDecision, reason) {
|
|
26
|
+
return {
|
|
27
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason: reason },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A deny is also an event in the session gate log — the record that makes going silent after a
|
|
32
|
+
// block impossible. The log is a dated per-session record (never drained; bookkeeping stays a
|
|
33
|
+
// queue); the Stop and commit walls block on `denied` lines with no later `resolved` pair.
|
|
34
|
+
export function deny(projectDir, sessionId, gate, target, reason) {
|
|
35
|
+
appendEvent(projectDir, sessionId, "denied", gate, target);
|
|
36
|
+
return decision("deny", reason);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- the build graph's switch ------------------------------------------------------------
|
|
40
|
+
// A run handed a build graph (docs/harness/code/build-graph.md) may declare `mode.walls`, an
|
|
41
|
+
// allowlist over lib/walls.mjs. Every wall it does not name is off for that session — because
|
|
42
|
+
// the compliance set assumes a session that can dispatch a bookkeeper and a user who can answer
|
|
43
|
+
// an ask, and a generation box has neither: its walls would be unresolvable blocks, not guard
|
|
44
|
+
// rails. No graph, or a graph with no `mode` block, leaves every wall exactly as it was. With no graph
|
|
45
|
+
// declaring one, an exit set (docs/harness/code/exit-check.md) may carry the same block.
|
|
46
|
+
// Read here rather than through lib/graph-store.mjs and lib/exit-store.mjs, which import the state
|
|
47
|
+
// module; the reading is theirs (`wallsAllowlist`, `exitWallsAllowlist`), field for field.
|
|
48
|
+
function graphWalls(projectDir) {
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = fs.readFileSync(path.join(projectDir, ".claude", "state", "graph.json"), "utf8");
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return null; // absent — dormancy
|
|
54
|
+
}
|
|
55
|
+
const parsed = readGraph(text, projectDir);
|
|
56
|
+
if (!parsed.ok || !parsed.graph.mode) return null; // a refused graph never switches a wall off
|
|
57
|
+
return parsed.graph.mode.walls;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function exitWalls(projectDir) {
|
|
61
|
+
try {
|
|
62
|
+
const set = JSON.parse(fs.readFileSync(path.join(projectDir, ".claude", "state", "exit.json"), "utf8"));
|
|
63
|
+
const attempts = set?.attempts;
|
|
64
|
+
const isExitSet = set && set.v === 1 && Array.isArray(set.owed) && Number.isInteger(attempts?.used) && Number.isInteger(attempts?.max);
|
|
65
|
+
return isExitSet && Array.isArray(set?.mode?.walls) ? set.mode.walls : null;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return null; // absent or unreadable — no allowlist
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Keyed by projectDir, so one importer serving two repositories never reads the other's switch.
|
|
72
|
+
const allowlistCache = new Map();
|
|
73
|
+
export function wallOn(projectDir, name) {
|
|
74
|
+
if (!allowlistCache.has(projectDir)) {
|
|
75
|
+
let allowlist;
|
|
76
|
+
try {
|
|
77
|
+
allowlist = graphWalls(projectDir) ?? exitWalls(projectDir);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
allowlist = null; // a broken graph never switches a wall off
|
|
80
|
+
}
|
|
81
|
+
allowlistCache.set(projectDir, allowlist);
|
|
82
|
+
}
|
|
83
|
+
return wallEnabled(allowlistCache.get(projectDir), name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The protected-list reader: AGENTS.md, the list itself, and every backticked path in
|
|
87
|
+
// <docs>/operations/protected-files.md. Read once per projectDir per process.
|
|
88
|
+
const protectedCache = new Map();
|
|
89
|
+
export function protectedPaths(projectDir) {
|
|
90
|
+
if (protectedCache.has(projectDir)) return protectedCache.get(projectDir);
|
|
91
|
+
const root = docsRoot(projectDir);
|
|
92
|
+
const text = readSafe(path.join(projectDir, root, "operations", "protected-files.md"));
|
|
93
|
+
const list = ["AGENTS.md", `${root}/operations/protected-files.md`, ...backtickTokens(text)];
|
|
94
|
+
protectedCache.set(projectDir, list);
|
|
95
|
+
return list;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A file write arriving by shell hits the same wall the Edit tool does. Only tokens in a
|
|
99
|
+
// WRITING position count — redirect targets, `tee`/`mv`/`cp` destinations, and any path on an
|
|
100
|
+
// in-place editor line (`sed -i`, `perl -i`) — so reading a frozen record into a pipe stays
|
|
101
|
+
// legal. A token that isn't an existing repo path falls through frozenStatus/protected as a
|
|
102
|
+
// no-op, which keeps sed expressions and flags harmless without shell-parsing them.
|
|
103
|
+
export function bashWriteTargets(command) {
|
|
104
|
+
const targets = [];
|
|
105
|
+
let m;
|
|
106
|
+
const redirect = />{1,2}\s*([^\s;|&<>]+)/g;
|
|
107
|
+
while ((m = redirect.exec(command))) targets.push(m[1]);
|
|
108
|
+
const tee = /\btee\s+(?:-\S+\s+)*([^\s;|&<>]+)/g;
|
|
109
|
+
while ((m = tee.exec(command))) targets.push(m[1]);
|
|
110
|
+
const moveCopy = /\b(?:mv|cp)\s+(?:-\S+\s+)*\S+\s+([^\s;|&<>]+)/g;
|
|
111
|
+
while ((m = moveCopy.exec(command))) targets.push(m[1]);
|
|
112
|
+
if (/\b(?:sed|perl)\s+(?:-\S*\s+)*-\S*i/.test(command) || /\bsed\s+--in-place\b/.test(command)) {
|
|
113
|
+
for (const t of command.split(/[\s"']+/)) {
|
|
114
|
+
if (t && !t.startsWith("-") && /[/.]/.test(t)) targets.push(t);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return targets;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The two walls on one repo-relative path. frozenStatusAtHead, not frozenStatus: a record frozen
|
|
121
|
+
// only in the WORKTREE is a freeze born in the current edit — a plan legitimately flipping to
|
|
122
|
+
// Complete — and denying that walls the completion ceremony itself. A spent budget reads as not
|
|
123
|
+
// frozen: a timeout is never a gate. Returns { wall, rel, message, projectDir } or null.
|
|
124
|
+
export function pathVerdict(projectDir, rel, { deadlineAt, shell = false } = {}) {
|
|
125
|
+
if (wallOn(projectDir, "frozen-record")) {
|
|
126
|
+
const timeoutMs = budgetMs(deadlineAt);
|
|
127
|
+
const frozen = timeoutMs > 0 ? frozenStatusAtHead(projectDir, rel, { timeoutMs }) : { frozen: false };
|
|
128
|
+
if (frozen.frozen) {
|
|
129
|
+
return {
|
|
130
|
+
wall: "frozen-record",
|
|
131
|
+
rel,
|
|
132
|
+
projectDir,
|
|
133
|
+
message: shell
|
|
134
|
+
? `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}) — a shell write is still a write. A correction is a new dated entry that links back.`
|
|
135
|
+
: `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}). A correction is a new dated entry that links back.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (wallOn(projectDir, "protected-file") && protectedPaths(projectDir).includes(rel)) {
|
|
140
|
+
return {
|
|
141
|
+
wall: "protected-file",
|
|
142
|
+
rel,
|
|
143
|
+
projectDir,
|
|
144
|
+
message: shell
|
|
145
|
+
? `BLOCKED — protected file. \`${rel}\` is on the protected list — a shell write is still a write.`
|
|
146
|
+
: `BLOCKED — protected file. \`${rel}\` is on the protected list.`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The two walls on every write target of a shell command run in `root`; the first denial wins.
|
|
153
|
+
export function shellVerdict(root, command, { deadlineAt } = {}) {
|
|
154
|
+
for (const target of bashWriteTargets(command || "")) {
|
|
155
|
+
const rel = relIfInside(root, target);
|
|
156
|
+
if (!rel) continue;
|
|
157
|
+
const verdict = pathVerdict(root, rel, { deadlineAt, shell: true });
|
|
158
|
+
if (verdict) return verdict;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The predicate over a PreToolUse payload: { wall, rel, message, projectDir } or null. Writes nothing.
|
|
164
|
+
// A Bash command is judged in the repo it acts on (lib/repo.mjs#commandRoot).
|
|
165
|
+
export function protectionVerdict(projectDir, payload, { deadlineAt } = {}) {
|
|
166
|
+
const input = (payload && payload.tool_input) || {};
|
|
167
|
+
if (payload && payload.tool_name === "Bash") {
|
|
168
|
+
if (!input.command) return null;
|
|
169
|
+
const timeoutMs = budgetMs(deadlineAt);
|
|
170
|
+
const root = timeoutMs > 0 ? commandRoot(projectDir, payload, input.command, { timeoutMs }) : projectDir;
|
|
171
|
+
return shellVerdict(root, input.command, { deadlineAt });
|
|
172
|
+
}
|
|
173
|
+
const rel = relIfInside(projectDir, input.file_path);
|
|
174
|
+
return rel ? pathVerdict(projectDir, rel, { deadlineAt }) : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// The predicate with the logged deny: the hook output object, or null.
|
|
178
|
+
export function protectionGate(projectDir, payload, { deadlineAt } = {}) {
|
|
179
|
+
const verdict = protectionVerdict(projectDir, payload, { deadlineAt });
|
|
180
|
+
if (!verdict) return null;
|
|
181
|
+
return deny(verdict.projectDir, payload.session_id, verdict.wall, verdict.rel, verdict.message);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The dispatcher protection.mjs calls.
|
|
185
|
+
export function protectionPreToolUse(payload, { deadlineAt } = {}) {
|
|
186
|
+
if (!payload || payload.hook_event_name !== "PreToolUse") return null;
|
|
187
|
+
return protectionGate(resolveProjectDir(payload), payload, { deadlineAt });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// True when the project's committed .claude/settings.json registers a PreToolUse handler whose
|
|
191
|
+
// command names .claude/hooks/protection.mjs, and that file exists. A mount rewrites .claude/hooks/**
|
|
192
|
+
// without touching the settings, so either half can be missing; compliance holds both walls then.
|
|
193
|
+
export function protectionHandled(projectDir) {
|
|
194
|
+
if (!fs.existsSync(path.join(projectDir, ".claude", "hooks", "protection.mjs"))) return false;
|
|
195
|
+
let settings;
|
|
196
|
+
try {
|
|
197
|
+
settings = JSON.parse(fs.readFileSync(path.join(projectDir, ".claude", "settings.json"), "utf8"));
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
const groups = settings?.hooks?.PreToolUse;
|
|
202
|
+
if (!Array.isArray(groups)) return false;
|
|
203
|
+
return groups.some(
|
|
204
|
+
(group) =>
|
|
205
|
+
Array.isArray(group?.hooks) &&
|
|
206
|
+
group.hooks.some((handler) => typeof handler?.command === "string" && handler.command.includes(".claude/hooks/protection.mjs"))
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// harness/code/hooks/lib/records-door.mjs — the hooks' side of the records door. A hook never holds a
|
|
2
|
-
// credential: it hands rows to `nustack records emit
|
|
3
|
-
// is
|
|
4
|
-
//
|
|
2
|
+
// credential: it hands rows to `nustack records emit` as a bare JSON array on stdin, and the CLI climbs
|
|
3
|
+
// the door's rungs. There is no fallback file: with the tool absent or its call failing, the caller
|
|
4
|
+
// keeps the rows and offers them again (lib/backlog-detector.mjs). Never throws.
|
|
5
|
+
// Fuller account: docs/harness/code/hooks.md § lib/records-door.mjs.
|
|
5
6
|
|
|
6
7
|
import fs from "node:fs";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { spawnSync } from "node:child_process";
|
|
9
|
-
import { stateDir } from "./state.mjs";
|
|
10
10
|
|
|
11
11
|
// The first executable named `name` on the PATH, or null.
|
|
12
12
|
export function onPath(name, pathEnv = process.env.PATH || "") {
|
|
@@ -22,33 +22,24 @@ export function onPath(name, pathEnv = process.env.PATH || "") {
|
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const at = new Date().toISOString();
|
|
29
|
-
fs.appendFileSync(path.join(dir, `${kind}.jsonl`), rows.map((row) => JSON.stringify({ kind, row, at }) + "\n").join(""));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Emits rows of one kind: through `nustack records emit --kind <kind> --json` when the tool is on the
|
|
33
|
-
// PATH and answers 0, else to the rung-4 file. Returns "door" or "file" (or "none" when nothing landed).
|
|
25
|
+
// Emits rows of one kind through `nustack records emit --kind <kind> --json`, the rows as a bare array
|
|
26
|
+
// on stdin — the shape every published CLI's parseRows accepts. Returns "door" (exit 0), "absent" (no
|
|
27
|
+
// `nustack` on the PATH), "failed" (a non-zero exit, a spawn error or a timeout) or "none" (no rows).
|
|
34
28
|
export function emitRecord(kind, rows, { projectDir, env = process.env, timeoutMs = 5000 } = {}) {
|
|
35
29
|
const list = (Array.isArray(rows) ? rows : [rows]).filter(Boolean);
|
|
36
30
|
if (list.length === 0) return "none";
|
|
37
31
|
const binary = onPath("nustack", env.PATH || "");
|
|
38
|
-
if (binary)
|
|
32
|
+
if (!binary) return "absent";
|
|
33
|
+
try {
|
|
39
34
|
const res = spawnSync(binary, ["records", "emit", "--kind", kind, "--json"], {
|
|
40
35
|
cwd: projectDir,
|
|
41
|
-
input: JSON.stringify(
|
|
36
|
+
input: JSON.stringify(list),
|
|
42
37
|
encoding: "utf8",
|
|
43
38
|
env,
|
|
44
39
|
timeout: timeoutMs,
|
|
45
40
|
});
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
try {
|
|
49
|
-
appendLocal(projectDir, kind, list);
|
|
50
|
-
return "file";
|
|
41
|
+
return !res.error && res.status === 0 ? "door" : "failed";
|
|
51
42
|
} catch (e) {
|
|
52
|
-
return "
|
|
43
|
+
return "failed";
|
|
53
44
|
}
|
|
54
45
|
}
|
|
@@ -1,11 +1,36 @@
|
|
|
1
|
-
// harness/code/hooks/lib/repo.mjs — the shared floor every hook module stands on:
|
|
2
|
-
// path normalization, git, session ids, and the one definition of
|
|
3
|
-
// existed as 2–5 diverging private copies before being hoisted here.
|
|
1
|
+
// harness/code/hooks/lib/repo.mjs — the shared floor every hook module stands on: stdin, the project
|
|
2
|
+
// root, the state folder, safe reads, path normalization, git, session ids, and the one definition of
|
|
3
|
+
// "source file". Each of these existed as 2–5 diverging private copies before being hoisted here. It
|
|
4
|
+
// imports nothing but node built-ins, which keeps the Protection hook's import graph narrow.
|
|
4
5
|
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { spawnSync } from "node:child_process";
|
|
8
9
|
|
|
10
|
+
// Read all of stdin synchronously; returns "" if nothing is piped.
|
|
11
|
+
export function readStdin() {
|
|
12
|
+
try {
|
|
13
|
+
return fs.readFileSync(0, "utf8");
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
|
|
20
|
+
export function resolveProjectDir(payload) {
|
|
21
|
+
return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Ensure .claude/state/ exists and is self-ignoring (a `*` .gitignore inside it),
|
|
25
|
+
// so consumers never see machine-local state as untracked noise. Returns the dir.
|
|
26
|
+
export function stateDir(projectDir) {
|
|
27
|
+
const dir = path.join(projectDir, ".claude", "state");
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
const ignore = path.join(dir, ".gitignore");
|
|
30
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
|
|
31
|
+
return dir;
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
export function readSafe(file) {
|
|
10
35
|
try {
|
|
11
36
|
return fs.readFileSync(file, "utf8");
|
|
@@ -44,8 +69,8 @@ export function relIfInside(projectDir, filePath) {
|
|
|
44
69
|
return rel.split(path.sep).join("/");
|
|
45
70
|
}
|
|
46
71
|
|
|
47
|
-
export function git(cwd, args) {
|
|
48
|
-
const res = spawnSync("git", args, { cwd, encoding: "utf8", timeout:
|
|
72
|
+
export function git(cwd, args, { timeoutMs = 5000 } = {}) {
|
|
73
|
+
const res = spawnSync("git", args, { cwd, encoding: "utf8", timeout: timeoutMs });
|
|
49
74
|
return res.status === 0 ? (res.stdout || "").trim() : null;
|
|
50
75
|
}
|
|
51
76
|
|
|
@@ -75,7 +100,8 @@ export function backtickTokens(text) {
|
|
|
75
100
|
// A candidate is normalized to its git toplevel so a subdirectory cwd still resolves to the
|
|
76
101
|
// repo root. Claim and release both route here — two private copies of this rule once
|
|
77
102
|
// disagreed, and the divergence leaked stale claims.
|
|
78
|
-
|
|
103
|
+
// `timeoutMs` bounds the toplevel lookup, for a caller spending a wall-clock budget.
|
|
104
|
+
export function commandRoot(projectDir, payload, command, { timeoutMs = 5000 } = {}) {
|
|
79
105
|
const cdMatch = /(?:^|\n|&&|;)\s*cd\s+"?([^"\s;&]+)"?/.exec(command || "");
|
|
80
106
|
for (const candidate of [cdMatch && cdMatch[1], payload && payload.cwd]) {
|
|
81
107
|
if (!candidate || !path.isAbsolute(candidate)) continue;
|
|
@@ -84,7 +110,7 @@ export function commandRoot(projectDir, payload, command) {
|
|
|
84
110
|
} catch (e) {
|
|
85
111
|
continue;
|
|
86
112
|
}
|
|
87
|
-
return git(candidate, ["rev-parse", "--show-toplevel"]) || candidate;
|
|
113
|
+
return git(candidate, ["rev-parse", "--show-toplevel"], { timeoutMs }) || candidate;
|
|
88
114
|
}
|
|
89
115
|
return projectDir;
|
|
90
116
|
}
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import { stateDir } from "./
|
|
13
|
-
import { sid8 } from "./repo.mjs";
|
|
12
|
+
import { stateDir, sid8 } from "./repo.mjs";
|
|
14
13
|
|
|
15
14
|
export function sessionLogPath(projectDir, sessionId) {
|
|
16
15
|
const dir = path.join(stateDir(projectDir), "sessions");
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { globToRegExp } from "./ownership.mjs";
|
|
20
|
-
import { docsRoot } from "./repo.mjs";
|
|
20
|
+
import { docsRoot, stateDir } from "./repo.mjs";
|
|
21
21
|
|
|
22
22
|
// Relative to the docs root (lib/repo.mjs#docsRoot) — never a bare "docs/..." literal.
|
|
23
23
|
export const QUEUE_PATH = ["operations", "bookkeeping.md"];
|
|
@@ -326,15 +326,9 @@ export function waiverCovers(rows, kind, subject, today) {
|
|
|
326
326
|
return unexpiredWaivers(rows, kind, today).some((w) => w.body.includes(`\`${subject}\``));
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
export
|
|
332
|
-
const dir = path.join(projectDir, ".claude", "state");
|
|
333
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
334
|
-
const ignore = path.join(dir, ".gitignore");
|
|
335
|
-
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
|
|
336
|
-
return dir;
|
|
337
|
-
}
|
|
329
|
+
// stateDir lives in lib/repo.mjs, so the Protection hook's narrow import graph reaches it without
|
|
330
|
+
// this module; it is re-exported here for every existing importer.
|
|
331
|
+
export { stateDir };
|
|
338
332
|
|
|
339
333
|
// The build graph's two machine-local files. Named here so every reader — the walker's adapters,
|
|
340
334
|
// the compliance gates, a composer writing a graph before any session has run — spells them once.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// the two sets agree, so a rename fails there rather than in a generation box at 3am.
|
|
7
7
|
//
|
|
8
8
|
// The first ten are PreToolUse gate ids as recorded in the session log; the last four name Stop
|
|
9
|
-
// sub-walls that had no id at all until `mode.walls` needed to talk about them.
|
|
9
|
+
// sub-walls that had no id at all until `mode.walls` needed to talk about them; the last is an ask.
|
|
10
10
|
export const WALLS = [
|
|
11
11
|
"frozen-record",
|
|
12
12
|
"protected-file",
|
|
@@ -22,6 +22,7 @@ export const WALLS = [
|
|
|
22
22
|
"open-denials",
|
|
23
23
|
"ledger",
|
|
24
24
|
"changelog-entry",
|
|
25
|
+
"new-standard-doc",
|
|
25
26
|
];
|
|
26
27
|
|
|
27
28
|
// The build graph's own denials are deliberately absent: they are not skippable, because they
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// harness/code/hooks/protection.mjs — the Protection hook: the fail-closed PreToolUse entry over
|
|
2
|
+
// lib/protection-gate.mjs. It denies an edit or a shell write to a frozen record or a protected file,
|
|
3
|
+
// and it is the one hook registered with `onFailure: "block"`: an exception in the gate, or a module
|
|
4
|
+
// that cannot load, exits 1 after a line in .claude/state/hook-failures.log, and Claude Code then
|
|
5
|
+
// blocks the call. A timeout is never a gate: every git call spends one wall-clock budget, a spent
|
|
6
|
+
// budget allows, and a backstop timer exits 0 at the deadline if an asynchronous wait outlives it.
|
|
7
|
+
// A repository with no git, no protected list or no docs root is no failure: nothing is frozen or
|
|
8
|
+
// protected there. Fuller account: docs/harness/code/hooks.md.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
// The wall-clock budget, under the handler's 10 s timeout so process start fits in the gap.
|
|
14
|
+
const PROTECTION_DEADLINE_MS = 7000;
|
|
15
|
+
|
|
16
|
+
const deadlineAt = Date.now() + PROTECTION_DEADLINE_MS;
|
|
17
|
+
setTimeout(() => process.exit(0), PROTECTION_DEADLINE_MS).unref();
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const { protectionPreToolUse } = await import("./lib/protection-gate.mjs");
|
|
21
|
+
let raw = "";
|
|
22
|
+
try {
|
|
23
|
+
raw = fs.readFileSync(0, "utf8");
|
|
24
|
+
} catch (e) {
|
|
25
|
+
raw = "";
|
|
26
|
+
}
|
|
27
|
+
let payload = {};
|
|
28
|
+
try {
|
|
29
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
30
|
+
} catch (e) {
|
|
31
|
+
payload = {}; // no payload names nothing to protect
|
|
32
|
+
}
|
|
33
|
+
const out = protectionPreToolUse(payload, { deadlineAt });
|
|
34
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let code = 0;
|
|
38
|
+
try {
|
|
39
|
+
await main();
|
|
40
|
+
} catch (e) {
|
|
41
|
+
// Fail closed: a Protection wall that cannot run must not let the write through.
|
|
42
|
+
code = 1;
|
|
43
|
+
try {
|
|
44
|
+
const dir = path.join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), ".claude", "state");
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
fs.appendFileSync(path.join(dir, "hook-failures.log"), `${new Date().toISOString()} protection.mjs ${e?.message || e}\n`);
|
|
47
|
+
} catch (e2) {
|
|
48
|
+
/* nowhere to write — the exit status still blocks */
|
|
49
|
+
}
|
|
50
|
+
process.stderr.write(`protection.mjs failed closed: ${e?.message || e}\n`);
|
|
51
|
+
} finally {
|
|
52
|
+
process.exit(code);
|
|
53
|
+
}
|
|
@@ -20,7 +20,7 @@ This skill writes one document at a time: the genre registry says what kind of d
|
|
|
20
20
|
## Writing one document
|
|
21
21
|
|
|
22
22
|
1. **Find the genre.** Match the request against § Routing, then read that genre's registry row: nature, `type:`, location, required keys, required sections. No row fits ⇒ `references/untemplated.md`.
|
|
23
|
-
2. **Find the existing document.** If a document on the topic already exists, extend it; a second document on one topic is drift. If the file is a frozen record (a plan at `Complete` or `Abandoned`, a committed changelog entry, an executed runbook), never edit it: write a new dated record that links back.
|
|
23
|
+
2. **Find the existing document.** If a document on the topic already exists, extend it; a second document on one topic is drift. A new doctrine or playbook document also waits for the user's approval (§ Doctrine and playbooks). If the file is a frozen record (a plan at `Complete` or `Abandoned`, a committed changelog entry, an executed runbook), never edit it: write a new dated record that links back.
|
|
24
24
|
3. **Earn it, and route a set.** A new truth document that states rules (doctrine, a system, an architecture decision) first lists the decisions only it would own, each checked against the documents that exist; under about six, its rules go to their homes and the document is not written. More than one sibling document, or any doctrine document, follows `references/document-sets.md` from here: a term table and a rule ledger before prose, rendered from the ledger, one cold read.
|
|
25
25
|
4. **Place and name it** at the registry's location. `docs/structure.md` is the placement authority for anything the registry leaves open.
|
|
26
26
|
5. **Write it from the template, then run its genre procedure.** A template's procedure section binds that genre only: doctrine's is the reason check (`templates/doctrine.md` § Reason check). Keep the template's section names exactly; omit optional sections with nothing real to say. Write the body from the outside in (style guide § Shape), every heading and bold label a noun phrase, every sentence after a label third person, one rule per sentence in the term engineers use (style guide § Sentences, § Vocabulary). In a truth document, every sentence is present tense (§ The three natures). Every line owns a decision: before stating a rule, search the neighbours for it, and cite the one found rather than restating it (style guide § Net-net).
|
|
@@ -41,6 +41,15 @@ Some genres carry steps beyond writing from the template. Each procedure lives i
|
|
|
41
41
|
|
|
42
42
|
A genre with no row here has no procedure beyond its template.
|
|
43
43
|
|
|
44
|
+
## Doctrine and playbooks
|
|
45
|
+
|
|
46
|
+
Two genres are standards rather than accounts of one repository: a doctrine document settles one concern every product handles, and a playbook settles one interface or workflow shape every product builds. Both are read by the next product as much as by this one, so four rules bind them beyond their templates:
|
|
47
|
+
|
|
48
|
+
- **General form:** each reads correctly copied verbatim into another product's repository. It names nothing only its own repository has: the product, its domain entities, its vendors and stack, its people, its own documents by path. An example uses an object every product has, and the repository's own values stay in its feature documents, which name the standard in their Scope.
|
|
49
|
+
- **Extend first:** before a new document is proposed, the existing set is read (`grep -m1 '^description:' docs/architecture/{doctrine,playbooks}/*.md`), and content whose subject one of them already covers lands there as a section, a principle or a row.
|
|
50
|
+
- **New only on a clear distinction:** a new document is written only for a concern or a shape none of the existing ones owns, with decisions of its own (the template's earn-it test).
|
|
51
|
+
- **Never without approval:** an agent never creates a doctrine or playbook document on its own initiative, a baseline and a harvest included. It names the proposed document, the existing documents it was checked against and the distinction between them, then waits for the user's yes in chat. An unattended session files the request as a stop. An edit to an existing document follows the normal loop.
|
|
52
|
+
|
|
44
53
|
## The three natures
|
|
45
54
|
|
|
46
55
|
Every document is exactly one of three things, and the registry names which for every genre. Decide before writing a word: every other rule follows from the split.
|
|
@@ -105,6 +114,7 @@ A document names the objects it designs in `objects:`, and a source file names t
|
|
|
105
114
|
| Document a feature's data flow or state | The feature doc's Flows (behaviour) and Promises (guarantees); a persisted shape belongs in the API reference (style guide § State at persistence points); component and transient state are not documented |
|
|
106
115
|
| Document a workspace package | [`templates/package.md`](templates/package.md), the residual owner of `packages/{name}/**` |
|
|
107
116
|
| Document one concern the platform handles everywhere (authentication, storage, the API protocol) | [`templates/doctrine.md`](templates/doctrine.md): design and principles in general form, no mechanism, no project link, owns no code; written through [`references/document-sets.md`](references/document-sets.md) |
|
|
117
|
+
| Document one interface or workflow shape every product builds (the shell, a settings area, analytics pages, a record's lifecycle) | [`templates/playbook.md`](templates/playbook.md): the shape and its rules in general form, owns no code; created only with the user's approval (§ Doctrine and playbooks) |
|
|
108
118
|
| Write or rework several sibling documents at once (a doctrine folder, a family of features) | [`references/document-sets.md`](references/document-sets.md): usefulness test, term table, rule ledger, render, one cold read |
|
|
109
119
|
| Document one system spanning packages | [`templates/system.md`](templates/system.md): one machine end to end, never a giant feature doc |
|
|
110
120
|
| Record or revise an architecture decision | [`templates/architecture-decision.md`](templates/architecture-decision.md): one AD per subject, revised in place |
|
|
@@ -28,7 +28,8 @@ Every genre takes the same shape (principle 2) and the same labels (principle 5)
|
|
|
28
28
|
|
|
29
29
|
| Genre | Nature | Deepest layer | Excluded |
|
|
30
30
|
| --- | --- | --- | --- |
|
|
31
|
-
| Doctrine (`docs/architecture/
|
|
31
|
+
| Doctrine (`docs/architecture/doctrine/`) | Truth | The decisions behind each part and the reasons for them | Any mechanism. A doctrine document describes no code |
|
|
32
|
+
| Playbook (`docs/architecture/playbooks/`) | Truth | What a person sees and does in the shape, part by part: its arrangement, each part's behaviour, its states and promises | Any one product's values: its labels, routes, limits and timings, which are its feature documents'. Any mechanism |
|
|
32
33
|
| System (`docs/architecture/systems/`) | Truth | The machine's behaviour across packages: the order it runs in, sequence and state diagrams at role level, the invariant at each place where parts meet, what re-drives a stall. No single file shows a cross-package flow, so this layer earns its place | Per-function steps, tables and columns, routes |
|
|
33
34
|
| Package (`docs/architecture/packages/`) | Truth | The public contract a consumer touches: commands and their flags, exports, what is installed and where. For a CLI the flags *are* the promise | Internal layout, the build's steps, which file holds what |
|
|
34
35
|
| Feature (`docs/features/`) | Truth | The behaviour a person meets, in full: each state the surface can be in, each edge case, what a rejection tells them, the wording where the wording is promised. This genre runs deepest toward its end, in Promises, because a promise is checked by a person or a test and the code cannot state it as intent | Columns, routes, guards, functions, files |
|
|
@@ -255,7 +255,7 @@ function lintFile(path, findings) {
|
|
|
255
255
|
if (/^[a-z]+:\/\//.test(target) || SCAFFOLD.test(target)) continue;
|
|
256
256
|
// The repo-root README routes by intent (templates/root-readme.md): its Map names owning package and system
|
|
257
257
|
// docs, its Destinations rows name procedures and rules. Individual feature docs stay off it — the ownership index finds them.
|
|
258
|
-
if (isRootReadme && /(^|\/)docs\/(architecture\/(packages|systems|
|
|
258
|
+
if (isRootReadme && /(^|\/)docs\/(architecture\/(packages|systems|doctrine|playbooks)\/|operations\/)[^/]+\.md$|(^|\/)\.claude\/rules\/[^/]+\.md$/.test(target)) continue;
|
|
259
259
|
if (isRootReadme && /(^|\/)docs\/features\/[^/]+\.md$/.test(target)) { findings.push({ path, n, severity: 'warn', id: 'root-readme-feature-link', message: `root README links an individual feature doc (\`${target}\`) — point at docs/features/ and let the ownership index find it`, excerpt: line.trim().slice(0, 90) }); continue; }
|
|
260
260
|
findings.push({ path, n, severity: 'block', id: 'index-lists-files', message: `index links an individual document (\`${target}\`) — name the folder and how its files are named; \`ls\` is the roster`, excerpt: line.trim().slice(0, 90) });
|
|
261
261
|
}
|
|
@@ -382,7 +382,7 @@ function lintFile(path, findings) {
|
|
|
382
382
|
// A heading or a bold bullet label names; it never says. A label with a finite verb drags in a tense,
|
|
383
383
|
// a person and a voice ("What was decided", "You already belong to…"), so a label opening with a
|
|
384
384
|
// wh-word or a personal pronoun, or ending in a full stop, is a clause (style-guide § Labels).
|
|
385
|
-
const isLabelledDoc = isOwningDoc || (scope === 'truth' && !isFolderIndex && /(^|\/)docs\/architecture\/
|
|
385
|
+
const isLabelledDoc = isOwningDoc || (scope === 'truth' && !isFolderIndex && /(^|\/)docs\/architecture\/(doctrine|playbooks)\/[^/]+\.md$/.test(path));
|
|
386
386
|
if (isLabelledDoc && !disabled.has('clause-label')) {
|
|
387
387
|
const CLAUSE = /^(how|what|why|where|when|who|which|whose|you|your|we|our|i|it)\b|\b(you|your)\b|\.$/i;
|
|
388
388
|
for (const { n, line } of lines) {
|
|
@@ -413,7 +413,7 @@ function lintFile(path, findings) {
|
|
|
413
413
|
|
|
414
414
|
// A doctrine folder is read as a set: its documents cite each other's principles by number and must not restate each
|
|
415
415
|
// other (templates/doctrine.md, references/document-sets.md), so these checks read the siblings beside the linted file.
|
|
416
|
-
const DOCTRINE_LOCATION = /(^|\/)docs\/architecture\/
|
|
416
|
+
const DOCTRINE_LOCATION = /(^|\/)docs\/architecture\/doctrine\/[^/]+\.md$/;
|
|
417
417
|
// A doctrine document closes on Terms and Related documents; an older one on "For the agent". The body ends at the first.
|
|
418
418
|
const DOCTRINE_CLOSING = /^##\s+(For the agent|Terms|Related documents)\s*$/;
|
|
419
419
|
// The words a doctrine document replaces with the term engineers use (references/style-guide.md § Vocabulary).
|
|
@@ -17,7 +17,8 @@ Every fact about a genre lives in one row of the table below: its template, its
|
|
|
17
17
|
| Feature | [feature.md](feature.md) | truth | `feature` | `docs/features/{feature}.md` | `type` `description` `status` `objects` | Scope · Promises · For the agent |
|
|
18
18
|
| Package | [package.md](package.md) | truth | `package` | `docs/architecture/packages/{package}.md` | `type` `description` `status` `objects` | Scope · Ownership · Usage · Promises · For the agent |
|
|
19
19
|
| System | [system.md](system.md) | truth | `system` | `docs/architecture/systems/{system}.md` | `type` `description` `status` `objects` | Scope · Flow · Promises · For the agent |
|
|
20
|
-
| Doctrine | [doctrine.md](doctrine.md) | truth | `doctrine` | `docs/architecture/
|
|
20
|
+
| Doctrine | [doctrine.md](doctrine.md) | truth | `doctrine` | `docs/architecture/doctrine/{concern}.md` | `type` `description` `status` `objects` | Principles · Terms · Related documents |
|
|
21
|
+
| Playbook | [playbook.md](playbook.md) | truth | `playbook` | `docs/architecture/playbooks/{subject}.md` | `type` `description` `status` `objects` | Purpose · Related documents |
|
|
21
22
|
| Architecture decision | [architecture-decision.md](architecture-decision.md) | truth | `architecture-decision` | `docs/architecture/decisions/AD-NNN-{subject}.md` | `type` `title` `description` `status` `last-updated` | Decision · Context · Consequences |
|
|
22
23
|
| UX behavior | [ux-behavior.md](ux-behavior.md) | truth | `ux` | `docs/features/{surface}-ux.md` | `type` `description` `status` `objects` | Purpose · Anatomy · States · Affordances · Promises · Transitions |
|
|
23
24
|
| System overview | [system-overview.md](system-overview.md) | truth | `system-overview` | `docs/architecture/system-design.md` | `type` `description` | — |
|
|
@@ -25,7 +26,7 @@ Every fact about a genre lives in one row of the table below: its template, its
|
|
|
25
26
|
| Glossary | [glossary.md](glossary.md) | truth | `glossary` | `docs/architecture/glossary.md` | `type` `description` | — |
|
|
26
27
|
| Non-goals | [non-goals.md](non-goals.md) | truth | `non-goals` | `docs/architecture/non-goals.md` | `type` `description` | — |
|
|
27
28
|
| Security overview | [security-overview.md](security-overview.md) | truth | `security-overview` | `docs/architecture/security.md` | `type` `description` | — |
|
|
28
|
-
| Testing strategy | [testing-strategy.md](testing-strategy.md) | truth | `testing-strategy` | `docs/architecture/
|
|
29
|
+
| Testing strategy | [testing-strategy.md](testing-strategy.md) | truth | `testing-strategy` | `docs/architecture/doctrine/testing.md` | `type` `description` | — |
|
|
29
30
|
| API reference | [api-reference.md](api-reference.md) | truth | `api-reference` | `docs/architecture/api.md` | `type` `description` | — |
|
|
30
31
|
| Environment registry | [environment-registry.md](environment-registry.md) | truth | `environment-registry` | `docs/operations/environment.md` | `type` `description` | — |
|
|
31
32
|
| Behavioral checklist | [behavioral-checklist.md](behavioral-checklist.md) | truth | `checklist` | `docs/tests/{feature}.md` | `type` `description` `verifies` | — |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Doctrine documentation (docs/architecture/
|
|
1
|
+
# Doctrine documentation (docs/architecture/doctrine/{concern}.md)
|
|
2
2
|
|
|
3
3
|
Truth document. Use for **one concern the platform handles everywhere**: authentication, authorization, storage, the API protocol, credentials, telemetry. One file per concern, named for the concern. A doctrine document speaks for no code: no source header names it, and it carries no `owns:`. The surfaces bound by it are the documents that design or touch the objects it governs, computed through those objects, and each names it in its Scope.
|
|
4
4
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Playbook documentation (docs/architecture/playbooks/{subject}.md)
|
|
2
|
+
|
|
3
|
+
Truth document. Use for **one interface or workflow shape every product builds**: the shell, a settings area, analytics pages, a record's lifecycle, the interaction patterns, automated agents. One file per shape, named for it. A playbook speaks for no code: no source header names it, and it carries no `owns:`. The product's own feature documents hold its instance of the shape, and each names the playbook in its Scope.
|
|
4
|
+
|
|
5
|
+
**A playbook states the shape, never one product's instance of it.** It says what the parts are, how they are arranged, how each behaves and what the shape promises, so a new instance is new content poured into a known shape rather than a new layout. A product's own values (its labels, routes, limits, timings, the wording of its copy) belong to its feature document. A playbook explains no mechanism, for the reasons a doctrine document explains none (`templates/doctrine.md`).
|
|
6
|
+
|
|
7
|
+
**Doctrine or playbook.** A doctrine document settles the decisions of one concern every product handles, such as how authorization works. A playbook settles what a person sees and does in one recurring shape, such as how a settings page is laid out. A playbook cites the doctrine its shape rests on, and never restates it.
|
|
8
|
+
|
|
9
|
+
**A playbook is written in general form, never linked to the project that holds it.** It reads correctly when copied verbatim into any other product's repository, under the same rule a doctrine document keeps (`templates/doctrine.md`). Where the shape needs a word for the product's own record, the Purpose introduces a generic one once ("the subject: the record a report is read per, such as a job in a hiring product") and the body uses it throughout. Its links reach only sibling playbooks, doctrine documents and published standards.
|
|
10
|
+
|
|
11
|
+
**Earn the document, and ask before writing it.** List the rules only this playbook would own, each checked against the playbooks and doctrine that exist. Fewer than about six owned rules means the shape is a section of a neighbour, not a document. A new playbook is created only with the user's approval (SKILL.md § Doctrine and playbooks).
|
|
12
|
+
|
|
13
|
+
**Required:**
|
|
14
|
+
|
|
15
|
+
- the frontmatter
|
|
16
|
+
- **Purpose:** what the shape is for, what it is not for, and the generic words it introduces
|
|
17
|
+
- **Related documents**
|
|
18
|
+
|
|
19
|
+
**Optional, each only with something real to say:**
|
|
20
|
+
|
|
21
|
+
- **Anatomy:** an ASCII diagram of the arrangement with each region labelled, then one bullet per region
|
|
22
|
+
- **One section per part** that carries rules of its own, named for the part ("Roster", "Readiness", "Side peek")
|
|
23
|
+
- **States:** one table — State · Entered when · The person sees · Can do
|
|
24
|
+
- **Affordances:** what a person can do, one bullet per verb
|
|
25
|
+
- **Principles:** numbered, each a noun-phrase label and its rule
|
|
26
|
+
- **Promises:** what the shape guarantees, grouped under `###` headings where there are several kinds
|
|
27
|
+
- **Transitions:** what moves the shape between its states, and who moves it
|
|
28
|
+
|
|
29
|
+
Sections run Purpose first and Related documents last; the rest follow the order above. Each is named for its concept, never for its layer. Expect one hundred to two hundred lines.
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
---
|
|
33
|
+
type: playbook # required tooling key
|
|
34
|
+
description: "{at most 300 characters: the shape this playbook settles, and that a product's own instance is its feature doc}" # required
|
|
35
|
+
status: active # required — active | draft
|
|
36
|
+
objects: [rail, command palette, account menu] # required — the parts this playbook shapes, by their graph names
|
|
37
|
+
---
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
# {Shape} playbook
|
|
42
|
+
|
|
43
|
+
## Purpose
|
|
44
|
+
|
|
45
|
+
{What the shape is for and what it is not for, in two or three sentences. The generic words it introduces, each in one bullet.}
|
|
46
|
+
|
|
47
|
+
## Anatomy
|
|
48
|
+
|
|
49
|
+
{An ASCII diagram, then one bullet per labelled region.}
|
|
50
|
+
|
|
51
|
+
## {Part}
|
|
52
|
+
|
|
53
|
+
- **{Rule label}:** {the rule, in general form}
|
|
54
|
+
|
|
55
|
+
## Principles
|
|
56
|
+
|
|
57
|
+
1. **{Label}:** {the rule}. {Its reason, as a separate sentence.}
|
|
58
|
+
|
|
59
|
+
## Related documents
|
|
60
|
+
|
|
61
|
+
- [{Sibling playbook}]({file}.md): {what it settles beside this one}
|
|
62
|
+
- [{Doctrine}](../doctrine/{concern}.md): {the decision this shape rests on}
|
|
63
|
+
```
|
|
@@ -130,7 +130,7 @@ The rest of this page is for the team and for coding agents working in this repo
|
|
|
130
130
|
|
|
131
131
|
The baseline **does** scaffold `/README.md` when it is missing, and it fills each section from the repo:
|
|
132
132
|
|
|
133
|
-
- **Identity, Capabilities, Deployment:** from the product charter and vision documents under `docs/architecture/
|
|
133
|
+
- **Identity, Capabilities, Deployment:** from the product charter and vision documents under `docs/architecture/doctrine/` (what the product is, the core loop, who it is for), the package manifests (what publishes where), and the deploy config (addresses). Written in a user's terms; where the charter is missing, the baseline writes Identity only and raises a `note:` row for the rest.
|
|
134
134
|
- **The main path:** from the system overview.
|
|
135
135
|
- **Map:** from the workspace globs and the ownership index.
|
|
136
136
|
- **Rules:** from `docs/operations/protected-files.md` and the architecture doctrine.
|