its-magic 0.1.2-32 → 0.1.2-34

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/README.md CHANGED
@@ -280,7 +280,7 @@ Generated test scaffolding + auto-run behavior (US-0066):
280
280
  - Static baseline test pass does not bypass runtime autopilot; runtime verdict
281
281
  remains mandatory for QA PASS.
282
282
 
283
- ## Workflow
283
+ ## Commands and workflow
284
284
 
285
285
  ### Core commands
286
286
 
@@ -1014,25 +1014,32 @@ the safety cap (`AUTO_LOOP_MAX_CYCLES`) is reached.
1014
1014
 
1015
1015
  #### Layer 2: Local validate-and-push
1016
1016
 
1017
- Run before pushing to catch anything the AI loop missed:
1017
+ Run before pushing to catch anything the AI loop missed. **Merged scratchpad** (see
1018
+ `docs/engineering/runbook.md`, **Executable validate-and-push wiring (DEC-0058)**) gates
1019
+ **`git push`**: default **`SYNC_POLICY_MODE=manual`** and **`ALLOW_AUTO_PUSH=0`** exit early
1020
+ with a **reason code** (no push). Opt-in push requires an eligible mode, **`ALLOW_AUTO_PUSH=1`**,
1021
+ a non-empty **branch allowlist** match, passing **runbook** checks, and bounded **QA** rules.
1018
1022
 
1019
1023
  ```bash
1020
- # Bash (Linux / macOS)
1021
- sh scripts/validate-and-push.sh
1024
+ # Bash (Linux / macOS; bash required for this script)
1025
+ bash scripts/validate-and-push.sh
1022
1026
 
1023
1027
  # PowerShell (Windows)
1024
1028
  powershell scripts/validate-and-push.ps1
1025
1029
  powershell scripts/validate-and-push.ps1 -MaxAttempts 3
1030
+ powershell scripts/validate-and-push.ps1 -DryRun
1026
1031
  ```
1027
1032
 
1028
1033
  The script:
1029
- 1. Runs `FORMAT_COMMAND` and `LINT_FIX_COMMAND` to auto-fix what it can
1030
- 2. Runs `LINT_COMMAND` and `TEST_COMMAND` to verify
1031
- 3. If checks fail, pauses and waits for you to fix
1032
- 4. Re-runs (up to 5 attempts, configurable)
1033
- 5. When green, commits and pushes automatically
1034
+ 1. Evaluates merged scratchpad policy via **`python scripts/sync_push_gates.py`** (Python 3 on PATH)
1035
+ 2. Runs `FORMAT_COMMAND` and `LINT_FIX_COMMAND` to auto-fix what it can
1036
+ 3. Runs `LINT_COMMAND`, optional `TYPECHECK_COMMAND`, and `TEST_COMMAND` to verify (with `TEST_TIMEOUT_SECONDS` when `timeout`/`gtimeout` is available on Unix)
1037
+ 4. If checks fail, pauses and waits for you to fix
1038
+ 5. Re-runs (up to 5 attempts, configurable)
1039
+ 6. When green, re-checks allowlist + QA scan, then commits and pushes automatically (unless dry-run / no-commit)
1034
1040
 
1035
- Use `-NoCommit` (PowerShell) or `false` as third arg (Bash) to skip auto-push.
1041
+ Use `-NoCommit` (PowerShell), **`--dry-run`** first arg (Bash), or `false` as third arg (Bash) to skip **push**.
1042
+ **Policy-only** interpretation of scratchpad sync flags is **deprecated** for these scripts; see **`decisions/DEC-0058.md`** (policy semantics remain **`DEC-0018`** / **`US-0038`**).
1036
1043
 
1037
1044
  #### Layer 3: CI auto-fix (GitHub Actions)
1038
1045
 
@@ -1062,7 +1069,7 @@ push / PR ──> checks ──> PASS ──> done
1062
1069
  Auto-fix commits appear as `ci: auto-fix attempt N/3`. After 3 retries the
1063
1070
  workflow stops and points you to `scripts/validate-and-push` for local fixing.
1064
1071
 
1065
- ## Examples
1072
+ ## Walkthrough examples
1066
1073
 
1067
1074
  ### Example 1: New feature from idea
1068
1075
 
@@ -1354,3 +1361,44 @@ flowchart TD
1354
1361
  - `sprints/Sxxxx/*`: sprint scope, tasks, progress, QA findings, summary.
1355
1362
  - `decisions/*`: decision records.
1356
1363
  - `handoffs/*`: role-to-role transfer notes.
1364
+
1365
+ ## Purpose
1366
+
1367
+ This repository publishes the **its-magic** workflow kit: commands, rules, skills, and
1368
+ documentation templates that teams install into their own repositories. The goal is a
1369
+ repeatable, file-backed lifecycle from intake through release.
1370
+
1371
+ ## Quickstart
1372
+
1373
+ Use [Setup](#setup) for install commands. First-time install:
1374
+
1375
+ ```bash
1376
+ npx its-magic --target . --mode missing --create
1377
+ ```
1378
+
1379
+ ## Examples
1380
+
1381
+ - Upgrade an existing repo: `its-magic --target . --mode upgrade`
1382
+ - Run check-in tests: use `TEST_COMMAND` from `docs/engineering/runbook.md` (often `sh tests/run-tests.sh`).
1383
+
1384
+ ## Related documentation
1385
+
1386
+ - Operator commands and gates: `docs/engineering/runbook.md`
1387
+ - Architecture and story contracts: `docs/engineering/architecture.md`
1388
+ - Product backlog and acceptance: `docs/product/backlog.md`, `docs/product/acceptance.md`
1389
+ - Optional spec-pack mode (`SPEC_PACK_MODE=1`): engineering design artifacts under `docs/engineering/` when your team enables it
1390
+ - Optional user guides (`USER_GUIDE_MODE=1`): `docs/user-guides/` when enabled
1391
+
1392
+ ## Limitations
1393
+
1394
+ - its-magic is a **process and documentation** framework; it does not replace your
1395
+ application runtime, hosting, or product-specific compliance work.
1396
+ - Mixed files such as `README.md` are preserved on upgrade; review notices may appear when
1397
+ the template adds new sections.
1398
+ - Documentation profile validation (`scripts/validate_doc_profile.py`) enforces audience and
1399
+ depth choices from the merged scratchpad (`DOC_AUDIENCE_PROFILE`, `DOC_DETAIL_LEVEL`).
1400
+
1401
+ ## Contributing
1402
+
1403
+ Contributor-focused workflow and guardrails live in
1404
+ [`docs/developer/README.md`](docs/developer/README.md).
package/installer.ps1 CHANGED
@@ -122,6 +122,7 @@ function Classify-File($RelPath) {
122
122
  '.cursor/hooks/',
123
123
  '.github/workflows/',
124
124
  'scripts/validate-and-push',
125
+ 'scripts/sync_push_gates',
125
126
  'docs/engineering/context/',
126
127
  'its_magic/'
127
128
  )
package/installer.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import argparse
2
2
  import filecmp
3
+ import importlib.util
3
4
  import json
4
5
  import os
5
6
  import re
@@ -280,6 +281,47 @@ def materialize_scratchpad_baseline(target_root, source_root, mode, print_ok=Tru
280
281
  return True
281
282
 
282
283
 
284
+ def _load_doc_profile_lib():
285
+ """
286
+ Load doc_profile_lib from scripts/ adjacent to this installer (repo checkout or npm package root).
287
+ Path is derived from __file__ only (no cwd / PYTHONPATH dependency).
288
+ """
289
+ here = os.path.dirname(os.path.abspath(__file__))
290
+ path = os.path.join(here, "scripts", "doc_profile_lib.py")
291
+ if not os.path.isfile(path):
292
+ raise RuntimeError(
293
+ "[DOC_PROFILE_LIB_MISSING] Expected documentation profile library at "
294
+ f"{path} (same directory as installer.py). "
295
+ "Global installs require this file in the published its-magic package; "
296
+ f"reinstall or upgrade its-magic ({REPO_URL})."
297
+ )
298
+ spec = importlib.util.spec_from_file_location("doc_profile_lib", path)
299
+ if spec is None or spec.loader is None:
300
+ raise RuntimeError(
301
+ "[DOC_PROFILE_LIB_LOAD_ERROR] Could not create import spec for "
302
+ f"{path}. Reinstall its-magic ({REPO_URL})."
303
+ )
304
+ mod = importlib.util.module_from_spec(spec)
305
+ sys.modules["doc_profile_lib"] = mod
306
+ try:
307
+ spec.loader.exec_module(mod)
308
+ except Exception as e:
309
+ sys.modules.pop("doc_profile_lib", None)
310
+ raise RuntimeError(
311
+ "[DOC_PROFILE_LIB_LOAD_ERROR] doc_profile_lib failed to load "
312
+ f"({e!r}). Reinstall its-magic ({REPO_URL})."
313
+ ) from e
314
+ return mod
315
+
316
+
317
+ def _doc_profile_sync(target_root, merged, print_ok=True):
318
+ """Append missing normative README/developer doc sections from merged profile (non-destructive)."""
319
+ doc_profile_lib = _load_doc_profile_lib()
320
+ notes = doc_profile_lib.ensure_doc_surfaces_merged(merged, target_root, print_ok=print_ok)
321
+ bad = [ln for ln in notes if ln.startswith("[DOC_PROFILE_INVALID]")]
322
+ return (not bad), notes
323
+
324
+
283
325
  def run_scratchpad_postinstall(target_root, source_root, mode, print_ok=True):
284
326
  if not materialize_scratchpad_example(target_root, source_root, print_ok=print_ok):
285
327
  return False
@@ -288,6 +330,18 @@ def run_scratchpad_postinstall(target_root, source_root, mode, print_ok=True):
288
330
  ok, diagnostics = validate_merged_scratchpad(target_root)
289
331
  for line in diagnostics:
290
332
  print(line)
333
+ if ok:
334
+ merged, _paths = merge_scratchpad_layers(target_root)
335
+ try:
336
+ dp_ok, dp_notes = _doc_profile_sync(target_root, merged, print_ok=print_ok)
337
+ except RuntimeError as e:
338
+ print(str(e))
339
+ ok = False
340
+ else:
341
+ for line in dp_notes:
342
+ print(line)
343
+ if not dp_ok:
344
+ ok = False
291
345
  if ok and print_ok:
292
346
  loc = os.path.join(target_root, SCRATCHPAD_LOCAL_REL)
293
347
  if os.path.isfile(loc):
package/installer.sh CHANGED
@@ -157,7 +157,7 @@ classify_file() {
157
157
  README.md) echo "mixed" ;;
158
158
  .cursor/commands/*|.cursor/rules/*|.cursor/agents/*|.cursor/skills/*) echo "framework" ;;
159
159
  .cursor/hooks/*|.cursor/hooks.json|.cursor/scratchpad.local.example.md) echo "framework" ;;
160
- .github/workflows/*|scripts/validate-and-push*|docs/engineering/context/*|its_magic/*) echo "framework" ;;
160
+ .github/workflows/*|scripts/validate-and-push*|scripts/sync_push_gates.py|docs/engineering/context/*|its_magic/*) echo "framework" ;;
161
161
  .its-magic-version|its_magic/.its-magic-version|its_magic/README.md) echo "framework" ;;
162
162
  docs/product/*|docs/engineering/*|docs/user-guides/*) echo "user-data" ;;
163
163
  sprints/*|handoffs/*|decisions/*) echo "user-data" ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "its-magic",
3
- "version": "0.1.2-32",
3
+ "version": "0.1.2-34",
4
4
  "description": "its-magic - AI dev team workflow for Cursor.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "installer.ps1",
12
12
  "installer.sh",
13
13
  "installer.py",
14
+ "scripts/doc_profile_lib.py",
14
15
  "bin/its-magic.js",
15
16
  "bin/postinstall.js"
16
17
  ],
@@ -0,0 +1,415 @@
1
+ """
2
+ Documentation profile resolution and surface helpers (DEC-0059).
3
+
4
+ Shared by installer (optional surface sync) and scripts/validate_doc_profile.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Dict, List, Optional, Set, Tuple
11
+
12
+ DOC_AUDIENCE_ALLOWED = frozenset({"user", "developer", "both"})
13
+ DOC_DETAIL_ALLOWED = frozenset({"concise", "balanced", "technical-deep"})
14
+
15
+ USER_KEY_TO_H2: Dict[str, str] = {
16
+ "USER_PURPOSE": "Purpose",
17
+ "USER_QUICKSTART": "Quickstart",
18
+ "USER_EXAMPLES": "Examples",
19
+ "USER_TROUBLESHOOTING": "Troubleshooting",
20
+ "USER_LIMITATIONS": "Limitations",
21
+ "USER_RELATED_DOCS": "Related documentation",
22
+ }
23
+
24
+ DEV_KEY_TO_H2: Dict[str, str] = {
25
+ "DEV_PREREQS": "Prerequisites",
26
+ "DEV_WORKFLOW": "Workflow",
27
+ "DEV_QUALITY_GATES": "Quality gates",
28
+ "DEV_ARCHITECTURE": "Architecture notes",
29
+ "DEV_CONTRACTS": "Contracts and interfaces",
30
+ "DEV_DECISIONS": "Engineering decisions",
31
+ }
32
+
33
+ DEV_H2_TITLES = frozenset(DEV_KEY_TO_H2.values())
34
+ USER_H2_TITLES = frozenset(USER_KEY_TO_H2.values())
35
+
36
+ POINTER_H2 = "Contributing"
37
+
38
+ # Root README H2 budget (user-channel headings + optional Contributing pointer only).
39
+ ROOT_BUDGET: Dict[Tuple[str, str], int] = {}
40
+ for aud in ("user", "developer", "both"):
41
+ for det in ("concise", "balanced", "technical-deep"):
42
+ if aud == "user":
43
+ ROOT_BUDGET[(aud, det)] = {"concise": 5, "balanced": 7, "technical-deep": 9}[det]
44
+ elif aud == "developer":
45
+ ROOT_BUDGET[(aud, det)] = {"concise": 4, "balanced": 6, "technical-deep": 8}[det]
46
+ else:
47
+ ROOT_BUDGET[(aud, det)] = {"concise": 6, "balanced": 8, "technical-deep": 6}[det]
48
+
49
+
50
+ def resolve_doc_profile(merged: Dict[str, str]) -> Tuple[Optional[str], Optional[str], List[str]]:
51
+ """Return (audience, detail, errors). Empty profile keys default per DEC-0059 §6."""
52
+ raw_a = (merged.get("DOC_AUDIENCE_PROFILE") or "").strip().lower()
53
+ raw_d = (merged.get("DOC_DETAIL_LEVEL") or "").strip().lower()
54
+ errors: List[str] = []
55
+ if raw_a and raw_a not in DOC_AUDIENCE_ALLOWED:
56
+ errors.append(
57
+ "[DOC_PROFILE_INVALID] DOC_AUDIENCE_PROFILE must be one of: "
58
+ f"user, developer, both (got={raw_a!r})."
59
+ )
60
+ if raw_d and raw_d not in DOC_DETAIL_ALLOWED:
61
+ errors.append(
62
+ "[DOC_PROFILE_INVALID] DOC_DETAIL_LEVEL must be one of: "
63
+ f"concise, balanced, technical-deep (got={raw_d!r})."
64
+ )
65
+ if errors:
66
+ return None, None, errors
67
+ audience = raw_a or "both"
68
+ detail = raw_d or "balanced"
69
+ return audience, detail, []
70
+
71
+
72
+ def required_user_keys(audience: str, detail: str) -> Set[str]:
73
+ if audience not in ("user", "both"):
74
+ return set()
75
+ if detail == "concise":
76
+ return {"USER_PURPOSE", "USER_QUICKSTART", "USER_LIMITATIONS"}
77
+ if detail == "balanced":
78
+ return {
79
+ "USER_PURPOSE",
80
+ "USER_QUICKSTART",
81
+ "USER_LIMITATIONS",
82
+ "USER_EXAMPLES",
83
+ "USER_RELATED_DOCS",
84
+ }
85
+ return {
86
+ "USER_PURPOSE",
87
+ "USER_QUICKSTART",
88
+ "USER_LIMITATIONS",
89
+ "USER_EXAMPLES",
90
+ "USER_RELATED_DOCS",
91
+ "USER_TROUBLESHOOTING",
92
+ }
93
+
94
+
95
+ def required_dev_keys(audience: str, detail: str) -> Set[str]:
96
+ if audience not in ("developer", "both"):
97
+ return set()
98
+ if detail == "concise":
99
+ return {"DEV_PREREQS", "DEV_WORKFLOW"}
100
+ if detail == "balanced":
101
+ return {"DEV_PREREQS", "DEV_WORKFLOW", "DEV_QUALITY_GATES", "DEV_ARCHITECTURE"}
102
+ return {
103
+ "DEV_PREREQS",
104
+ "DEV_WORKFLOW",
105
+ "DEV_QUALITY_GATES",
106
+ "DEV_ARCHITECTURE",
107
+ "DEV_CONTRACTS",
108
+ "DEV_DECISIONS",
109
+ }
110
+
111
+
112
+ def extract_h2_titles(markdown: str) -> List[str]:
113
+ titles: List[str] = []
114
+ for line in markdown.splitlines():
115
+ if line.startswith("## ") and not line.startswith("###"):
116
+ titles.append(line[3:].strip())
117
+ return titles
118
+
119
+
120
+ def has_exact_h2(markdown: str, title: str) -> bool:
121
+ for line in markdown.splitlines():
122
+ if line.startswith("## ") and not line.startswith("###"):
123
+ if line[3:].strip() == title:
124
+ return True
125
+ return False
126
+
127
+
128
+ def count_profile_root_h2s(
129
+ markdown: str,
130
+ audience: str,
131
+ detail: str,
132
+ required_user_keys_set: Set[str],
133
+ ) -> int:
134
+ """
135
+ Count H2 lines in budget scope: required USER_* titles only for user/both;
136
+ Contributing pointer alone for developer-only (DEC-0059 / R-0054 user H2 budgets).
137
+ """
138
+ titles = extract_h2_titles(markdown)
139
+ want: Set[str] = set()
140
+ for key in required_user_keys_set:
141
+ want.add(USER_KEY_TO_H2[key])
142
+ if audience == "developer":
143
+ return sum(1 for t in titles if t == POINTER_H2)
144
+ n = 0
145
+ for t in titles:
146
+ if t in want:
147
+ n += 1
148
+ return n
149
+
150
+
151
+ def dev_h2_forbidden_in_root(markdown: str) -> List[str]:
152
+ """Return DEV_* H2 titles present in root (split layout forbids these in README)."""
153
+ found: List[str] = []
154
+ for t in extract_h2_titles(markdown):
155
+ if t in DEV_H2_TITLES:
156
+ found.append(t)
157
+ return found
158
+
159
+
160
+ def validate_optional_modes(merged: Dict[str, str], readme_text: str) -> List[str]:
161
+ """
162
+ US-0031 / US-0032 compatibility: never require optional artifacts when modes are off.
163
+ When on, ensure profile surfaces mention cross-links (additive, lightweight).
164
+ """
165
+ out: List[str] = []
166
+ sp = (merged.get("SPEC_PACK_MODE") or "0").strip()
167
+ ug = (merged.get("USER_GUIDE_MODE") or "0").strip()
168
+ if sp != "1" and ug != "1":
169
+ return out
170
+ if sp == "1":
171
+ if "docs/engineering" not in readme_text and "spec" not in readme_text.lower():
172
+ out.append(
173
+ "[DOC_OPTIONAL_CROSSLINK_WEAK] SPEC_PACK_MODE=1: root README should mention "
174
+ "engineering docs or spec-pack paths in a user channel section (see Related documentation)."
175
+ )
176
+ if ug == "1":
177
+ if "user-guides" not in readme_text and "user guide" not in readme_text.lower():
178
+ out.append(
179
+ "[DOC_OPTIONAL_CROSSLINK_WEAK] USER_GUIDE_MODE=1: root README should mention "
180
+ "docs/user-guides in a user channel section."
181
+ )
182
+ return out
183
+
184
+
185
+ def ensure_section(path: str, h2_title: str, body: str) -> Tuple[bool, str]:
186
+ """
187
+ Append ## h2_title + body if missing. Non-destructive.
188
+ Returns (changed, message).
189
+ """
190
+ ensure_parent_dir(path)
191
+ if os.path.isfile(path):
192
+ text = _read_utf8(path)
193
+ else:
194
+ text = ""
195
+
196
+ if has_exact_h2(text, h2_title):
197
+ return False, f"[DOC_PROFILE_SYNC] skip existing: ## {h2_title} ({path})"
198
+
199
+ block = f"\n\n## {h2_title}\n\n{body.strip()}\n"
200
+ if not text:
201
+ base = os.path.basename(path)
202
+ if base.lower() == "readme.md":
203
+ text = f"# Documentation\n"
204
+ else:
205
+ text = f"# {base.replace('.md', '').replace('_', ' ')}\n"
206
+ new_text = text.rstrip() + block
207
+ _write_utf8(path, new_text)
208
+ return True, f"[DOC_PROFILE_SYNC] appended: ## {h2_title} ({path})"
209
+
210
+
211
+ def ensure_doc_surfaces_merged(
212
+ merged: Dict[str, str],
213
+ target_root: str,
214
+ print_ok: bool = True,
215
+ ) -> List[str]:
216
+ audience, detail, errors = resolve_doc_profile(merged)
217
+ if errors:
218
+ return list(errors)
219
+ assert audience is not None and detail is not None
220
+ messages: List[str] = []
221
+ uk = required_user_keys(audience, detail)
222
+ dk = required_dev_keys(audience, detail)
223
+ readme = os.path.join(target_root, "README.md")
224
+ dev_readme = os.path.join(target_root, "docs", "developer", "README.md")
225
+
226
+ stubs_user = {
227
+ "USER_PURPOSE": (
228
+ "Describe what this repository is for in plain language. "
229
+ "Replace this placeholder with your product outcome."
230
+ ),
231
+ "USER_QUICKSTART": (
232
+ "Link to your fastest path to success. For its-magic, see [Setup](#setup) above."
233
+ ),
234
+ "USER_EXAMPLES": "Add short, copy-paste friendly examples for common tasks.",
235
+ "USER_TROUBLESHOOTING": (
236
+ "List frequent issues, what to check, and where logs or docs live."
237
+ ),
238
+ "USER_LIMITATIONS": "Call out known limits, unsupported environments, or sharp edges.",
239
+ "USER_RELATED_DOCS": (
240
+ "Link runbooks, architecture notes, and deeper guides. "
241
+ "Operator commands live in `docs/engineering/runbook.md`."
242
+ ),
243
+ }
244
+ stubs_dev = {
245
+ "DEV_PREREQS": "Toolchain, repo layout, and local prerequisites for contributors.",
246
+ "DEV_WORKFLOW": "Branching, phases, and day-to-day contributor workflow.",
247
+ "DEV_QUALITY_GATES": "Tests, lint, typecheck, and review expectations before merge.",
248
+ "DEV_ARCHITECTURE": "High-level modules, boundaries, and extension points.",
249
+ "DEV_CONTRACTS": "Public interfaces, file formats, and compatibility promises.",
250
+ "DEV_DECISIONS": "Pointers to `decisions/` and architecture sections that matter.",
251
+ }
252
+
253
+ for key in sorted(uk):
254
+ h2 = USER_KEY_TO_H2[key]
255
+ changed, msg = ensure_section(readme, h2, stubs_user[key])
256
+ if print_ok or changed:
257
+ messages.append(msg)
258
+
259
+ if dk:
260
+ for key in sorted(dk):
261
+ h2 = DEV_KEY_TO_H2[key]
262
+ changed, msg = ensure_section(dev_readme, h2, stubs_dev[key])
263
+ if print_ok or changed:
264
+ messages.append(msg)
265
+
266
+ if audience in ("developer", "both"):
267
+ contrib_body = (
268
+ "Contributor-focused workflow and guardrails live in "
269
+ "[`docs/developer/README.md`](docs/developer/README.md)."
270
+ )
271
+ changed, msg = ensure_section(readme, POINTER_H2, contrib_body)
272
+ if print_ok or changed:
273
+ messages.append(msg)
274
+
275
+ return messages
276
+
277
+
278
+ def optional_mode_warnings(merged: Dict[str, str], readme_text: str) -> List[str]:
279
+ """Non-blocking hints when optional doc modes are enabled (US-0031 / US-0032)."""
280
+ return validate_optional_modes(merged, readme_text)
281
+
282
+
283
+ def ensure_parent_dir(path: str) -> None:
284
+ parent = os.path.dirname(path)
285
+ if parent and not os.path.isdir(parent):
286
+ os.makedirs(parent, exist_ok=True)
287
+
288
+
289
+ def _read_utf8(path: str) -> str:
290
+ with open(path, "r", encoding="utf-8") as f:
291
+ return f.read()
292
+
293
+
294
+ def _write_utf8(path: str, text: str) -> None:
295
+ ensure_parent_dir(path)
296
+ with open(path, "w", encoding="utf-8", newline="\n") as f:
297
+ f.write(text)
298
+
299
+
300
+ def validate_repo_doc_profile(
301
+ target_root: str,
302
+ merged: Dict[str, str],
303
+ template_root: Optional[str],
304
+ ) -> List[str]:
305
+ """
306
+ Full validation for active target_root; optional template_root for parity.
307
+ Returns error strings (empty => pass).
308
+ """
309
+ errors: List[str] = []
310
+ audience, detail, err = resolve_doc_profile(merged)
311
+ errors.extend(err)
312
+ if errors:
313
+ return errors
314
+ assert audience is not None and detail is not None
315
+
316
+ uk = required_user_keys(audience, detail)
317
+ dk = required_dev_keys(audience, detail)
318
+
319
+ readme_path = os.path.join(target_root, "README.md")
320
+ dev_path = os.path.join(target_root, "docs", "developer", "README.md")
321
+
322
+ if not os.path.isfile(readme_path):
323
+ errors.append("[DOC_PROFILE_MERGE_ERROR] README.md missing (required for user channel checks).")
324
+ return errors
325
+
326
+ readme_text = _read_utf8(readme_path)
327
+
328
+ if audience in ("developer", "both") and dk:
329
+ if not os.path.isfile(dev_path):
330
+ errors.append(
331
+ "[DOC_SECTION_MISSING:shard] docs/developer/README.md missing but profile requires developer sections."
332
+ )
333
+
334
+ if audience in ("developer", "both"):
335
+ if not has_exact_h2(readme_text, POINTER_H2):
336
+ errors.append(
337
+ f"[DOC_SECTION_MISSING:DEV_SHARD_POINTER] Missing H2 ## {POINTER_H2} in README.md "
338
+ "(required pointer to docs/developer/README.md per DEC-0059)."
339
+ )
340
+ bad = dev_h2_forbidden_in_root(readme_text)
341
+ if bad:
342
+ errors.append(
343
+ "[DOC_PROFILE_INVALID] DEV sections must not use root README for split layout; "
344
+ f"found H2 titles {bad!r}. Move them to docs/developer/README.md."
345
+ )
346
+
347
+ for key in sorted(uk):
348
+ h2 = USER_KEY_TO_H2[key]
349
+ if not has_exact_h2(readme_text, h2):
350
+ errors.append(f"[DOC_SECTION_MISSING:{key}] Missing H2 ## {h2} in README.md.")
351
+
352
+ dev_text = ""
353
+ if os.path.isfile(dev_path):
354
+ dev_text = _read_utf8(dev_path)
355
+
356
+ for key in sorted(dk):
357
+ h2 = DEV_KEY_TO_H2[key]
358
+ if not has_exact_h2(dev_text, h2):
359
+ errors.append(f"[DOC_SECTION_MISSING:{key}] Missing H2 ## {h2} in docs/developer/README.md.")
360
+
361
+ budget = ROOT_BUDGET.get((audience, detail), 8)
362
+ counted = count_profile_root_h2s(readme_text, audience, detail, uk)
363
+ if counted > budget:
364
+ errors.append(
365
+ f"[DOC_SECTION_BUDGET_EXCEEDED] Root README profile-scoped H2 count={counted} "
366
+ f"exceeds budget={budget} for audience={audience!r} detail={detail!r}."
367
+ )
368
+
369
+ if template_root:
370
+ tr = os.path.join(template_root, "README.md")
371
+ td = os.path.join(template_root, "docs", "developer", "README.md")
372
+ if not os.path.isfile(tr):
373
+ errors.append("[DOC_TEMPLATE_PARITY_FAIL] template/README.md missing.")
374
+ else:
375
+ tt = _read_utf8(tr)
376
+ for key in sorted(uk):
377
+ h2 = USER_KEY_TO_H2[key]
378
+ if has_exact_h2(readme_text, h2) != has_exact_h2(tt, h2):
379
+ errors.append(
380
+ f"[DOC_TEMPLATE_PARITY_FAIL] USER H2 ## {h2} presence differs active vs template README."
381
+ )
382
+ if audience in ("developer", "both"):
383
+ if has_exact_h2(readme_text, POINTER_H2) != has_exact_h2(tt, POINTER_H2):
384
+ errors.append(
385
+ "[DOC_TEMPLATE_PARITY_FAIL] ## Contributing pointer presence differs active vs template README."
386
+ )
387
+ if dk:
388
+ if not os.path.isfile(td) or not os.path.isfile(dev_path):
389
+ errors.append(
390
+ "[DOC_TEMPLATE_PARITY_FAIL] developer README missing in active or template."
391
+ )
392
+ else:
393
+ tdev = _read_utf8(td)
394
+ for key in sorted(dk):
395
+ h2 = DEV_KEY_TO_H2[key]
396
+ if has_exact_h2(dev_text, h2) != has_exact_h2(tdev, h2):
397
+ errors.append(
398
+ f"[DOC_TEMPLATE_PARITY_FAIL] DEV H2 ## {h2} presence differs active vs template."
399
+ )
400
+
401
+ return errors
402
+
403
+
404
+ def self_test_resolver() -> None:
405
+ """Tier B: assert matrix key sets."""
406
+ assert required_user_keys("user", "concise") == {
407
+ "USER_PURPOSE",
408
+ "USER_QUICKSTART",
409
+ "USER_LIMITATIONS",
410
+ }
411
+ assert required_dev_keys("developer", "technical-deep") == set(DEV_KEY_TO_H2.keys())
412
+ a, d, e = resolve_doc_profile({"DOC_AUDIENCE_PROFILE": "", "DOC_DETAIL_LEVEL": ""})
413
+ assert not e and a == "both" and d == "balanced"
414
+ _, _, e2 = resolve_doc_profile({"DOC_AUDIENCE_PROFILE": "nope"})
415
+ assert e2 and "DOC_PROFILE_INVALID" in e2[0]
@@ -193,7 +193,14 @@ Release gate semantics (US-0039): mandatory gates (check-in test, QA, UAT) and n
193
193
  scan roots in `scripts/check-user-visible-metadata.py` **and** this runbook
194
194
  section together or fail closed with `METADATA_SANITIZATION_SCOPE_AMBIGUOUS`
195
195
  semantics at QA/release.
196
- 21. Triad hot-surface enforcement (DEC-0054):
196
+ 21. Documentation profile validation (US-0077 / DEC-0059):
197
+ - When you change `README.md`, `docs/developer/README.md`, scratchpad profile keys
198
+ (`DOC_AUDIENCE_PROFILE`, `DOC_DETAIL_LEVEL`), or `scripts/doc_profile_lib.py` /
199
+ `scripts/validate_doc_profile.py`, run `python scripts/validate_doc_profile.py --repo <root>`.
200
+ - Fail closed on `DOC_PROFILE_INVALID`, `DOC_PROFILE_MERGE_ERROR`,
201
+ `DOC_SECTION_MISSING:*`, `DOC_SECTION_BUDGET_EXCEEDED`, or `DOC_TEMPLATE_PARITY_FAIL`
202
+ (see `docs/engineering/runbook.md`).
203
+ 22. Triad hot-surface enforcement (DEC-0054):
197
204
  - Before completing `/execute`, run
198
205
  `python scripts/enforce-triad-hot-surface.py --check` from repository root
199
206
  (or `--repo <root>`).
@@ -24,7 +24,7 @@ globs: ["**/*"]
24
24
  (local pre-push) → CI auto-fix (GitHub Actions). Each layer catches issues
25
25
  the previous layer missed.
26
26
  - Before pushing, recommend running `scripts/validate-and-push.ps1` (Windows)
27
- or `scripts/validate-and-push.sh` (Linux/Mac) to catch failures locally.
27
+ or `bash scripts/validate-and-push.sh` (Linux/Mac) to catch failures locally.
28
28
  - `LINT_FIX_COMMAND` and `FORMAT_COMMAND` in the runbook enable automatic
29
29
  formatting/lint fixes both locally and in CI.
30
30
  - Remote config validation errors (when `REMOTE_EXECUTION=1`) must be fail-fast
@@ -196,3 +196,9 @@ SPEC_PACK_MODE=0
196
196
  # - USER_GUIDE_MODE: 0|1 (enable per-feature user guides at docs/user-guides/US-xxxx.md; default 0)
197
197
  # When 0, intake/architecture/sprint-plan/execute/qa/release add no required user-guide steps or blocking checks.
198
198
  USER_GUIDE_MODE=0
199
+
200
+ # Documentation audience profile (DEC-0059)
201
+ # - DOC_AUDIENCE_PROFILE: user|developer|both (empty -> both during transition)
202
+ # - DOC_DETAIL_LEVEL: concise|balanced|technical-deep (empty -> balanced during transition)
203
+ DOC_AUDIENCE_PROFILE=both
204
+ DOC_DETAIL_LEVEL=balanced
@@ -197,3 +197,9 @@ SPEC_PACK_MODE=0
197
197
  # When 0, intake/architecture/sprint-plan/execute/qa/release add no required user-guide steps or blocking checks.
198
198
  USER_GUIDE_MODE=0
199
199
 
200
+ # Documentation audience profile (DEC-0059)
201
+ # - DOC_AUDIENCE_PROFILE: user|developer|both (empty -> both during transition)
202
+ # - DOC_DETAIL_LEVEL: concise|balanced|technical-deep (empty -> balanced during transition)
203
+ DOC_AUDIENCE_PROFILE=both
204
+ DOC_DETAIL_LEVEL=balanced
205
+