@drafthq/draft 3.5.0 → 3.5.2

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.
@@ -203,6 +203,73 @@ memory_project_for_repo() {
203
203
  | head -1
204
204
  }
205
205
 
206
+ # Total physical RAM in MB (portable). Echoes a positive integer, or nothing.
207
+ _total_ram_mb() {
208
+ if [[ -r /proc/meminfo ]]; then
209
+ awk '/^MemTotal:/{printf "%d", $2/1024; exit}' /proc/meminfo
210
+ elif command -v sysctl >/dev/null 2>&1; then # macOS / BSD
211
+ local bytes; bytes="$(sysctl -n hw.memsize 2>/dev/null || true)"
212
+ [[ -n "$bytes" ]] && printf '%d' "$(( bytes / 1024 / 1024 ))"
213
+ fi
214
+ }
215
+
216
+ # Cgroup memory args for a transient scope. MemoryHigh throttles (reclaim/swap →
217
+ # slower, never thrashes the host); MemoryMax is the hard ceiling a few % above.
218
+ # Pure (no I/O) so it is unit-testable. Usage: _mem_bound_args <total_mb> <pct>
219
+ _mem_bound_args() {
220
+ local total="$1" pct="$2"
221
+ printf 'MemoryHigh=%dM MemoryMax=%dM' \
222
+ "$(( total * pct / 100 ))" "$(( total * (pct + 5) / 100 ))"
223
+ }
224
+
225
+ # Whether this host can confine a process to a memory-bounded cgroup v2 scope.
226
+ # Probes once (starts a throwaway scope) and caches the verdict for the process.
227
+ _DRAFT_CGROUP_OK=""
228
+ _can_cgroup_bound() {
229
+ if [[ -z "$_DRAFT_CGROUP_OK" ]]; then
230
+ if command -v systemd-run >/dev/null 2>&1 \
231
+ && [[ -e /sys/fs/cgroup/cgroup.controllers ]] \
232
+ && systemd-run --user --scope -q -p MemoryMax=64M -- true >/dev/null 2>&1; then
233
+ _DRAFT_CGROUP_OK=yes
234
+ else
235
+ _DRAFT_CGROUP_OK=no
236
+ fi
237
+ fi
238
+ [[ "$_DRAFT_CGROUP_OK" == yes ]]
239
+ }
240
+
241
+ # Index a repository under a memory bound. The codebase-memory-mcp engine
242
+ # self-budgets ~50% of *physical* RAM and is not cgroup-aware, so a first index
243
+ # of a huge repo can exhaust the host (the original 30 GB hang). On Linux we
244
+ # confine it to a transient cgroup scope sized to DRAFT_INDEX_MEM_PCT (default
245
+ # 25) of total RAM; CBM_WORKERS caps the engine's parallel working set so the
246
+ # throttle has less transient pressure to absorb. Where cgroup v2 + systemd-run
247
+ # are unavailable (e.g. macOS) the worker cap is the only bound. Never falls back
248
+ # from a started scope to an unbounded run — a bounded OOM fails the index
249
+ # cleanly (host stays alive) rather than re-triggering the hang.
250
+ # Echoes the engine's JSON result on stdout (same contract as memory_cli).
251
+ memory_index_bounded() {
252
+ local repo_abs="$1"
253
+ local json="{\"repo_path\":\"$repo_abs\"}"
254
+ export CBM_WORKERS="${CBM_WORKERS:-4}"
255
+ local total pct
256
+ total="$(_total_ram_mb)"
257
+ pct="${DRAFT_INDEX_MEM_PCT:-25}"
258
+ if [[ "${total:-0}" -gt 0 ]] && _can_cgroup_bound; then
259
+ local high_arg max_arg
260
+ read -r high_arg max_arg <<< "$(_mem_bound_args "$total" "$pct")"
261
+ if [[ -n "${DRAFT_MEMORY_DEBUG:-}" ]]; then
262
+ systemd-run --user --scope -q -p "$high_arg" -p "$max_arg" \
263
+ -- "$MEMORY_BIN" cli index_repository "$json"
264
+ else
265
+ systemd-run --user --scope -q -p "$high_arg" -p "$max_arg" \
266
+ -- "$MEMORY_BIN" cli index_repository "$json" 2>/dev/null
267
+ fi
268
+ else
269
+ memory_cli index_repository "$json"
270
+ fi
271
+ }
272
+
206
273
  # Ensure a repository is indexed in the engine; echo its project name.
207
274
  # Indexes on demand when absent. Returns 1 if the engine is unavailable.
208
275
  memory_ensure_index() {
@@ -212,7 +279,7 @@ memory_ensure_index() {
212
279
  local proj
213
280
  proj="$(memory_project_for_repo "$repo_abs" 2>/dev/null || true)"
214
281
  if [[ -z "$proj" ]]; then
215
- proj="$(memory_cli index_repository "{\"repo_path\":\"$repo_abs\"}" 2>/dev/null \
282
+ proj="$(memory_index_bounded "$repo_abs" \
216
283
  | jq -r '.project // empty' 2>/dev/null || true)"
217
284
  fi
218
285
  [[ -n "$proj" ]] || return 1
@@ -38,6 +38,7 @@ MANIFEST=""
38
38
  MIN_FAN_IN=2
39
39
  OUT=""
40
40
  JSON=0
41
+ DEFER_BELOW_FLOOR=0
41
42
  ALLOW_DEFER=()
42
43
 
43
44
  usage() {
@@ -54,8 +55,12 @@ Flags:
54
55
  --manifest FILE Component list (one component per line; '#' comments; blanks
55
56
  ignored). When present it is authoritative — every entry is
56
57
  required and the graph is not consulted.
57
- --min-fan-in N Package fan-in floor for "required" (default: 2). Packages
58
- below the floor are deferred with a reason.
58
+ --min-fan-in N Fan-in threshold that types a package as a Subsystem (>=N)
59
+ vs a Module (<N) and orders it first (default: 2). By default
60
+ EVERY graph package is required regardless of fan-in — the
61
+ floor no longer exempts anything.
62
+ --defer-below-floor Restore the old behavior: packages with fan_in < --min-fan-in
63
+ are deferred (not required) instead of required-as-Module.
59
64
  --allow-defer GLOB Defer (don't require) components whose name matches GLOB.
60
65
  Repeatable. Deferred entries still appear in the plan.
61
66
  --out FILE Write the plan JSON here (default: stdout).
@@ -72,6 +77,7 @@ while [[ $# -gt 0 ]]; do
72
77
  --scope) SCOPE="$2"; shift 2;;
73
78
  --manifest) MANIFEST="$2"; shift 2;;
74
79
  --min-fan-in) MIN_FAN_IN="$2"; shift 2;;
80
+ --defer-below-floor) DEFER_BELOW_FLOOR=1; shift;;
75
81
  --allow-defer) ALLOW_DEFER+=("$2"); shift 2;;
76
82
  --out) OUT="$2"; shift 2;;
77
83
  --json) JSON=1; shift;;
@@ -150,8 +156,13 @@ plan_from_graph() {
150
156
  required=false; reason="allow-defer match"; type=Module
151
157
  elif (( fan_in >= MIN_FAN_IN )); then
152
158
  required=true; reason=""; type=Subsystem
153
- else
159
+ elif [[ $DEFER_BELOW_FLOOR -eq 1 ]]; then
160
+ # Opt-in legacy behavior: low-fan-in packages are exempted.
154
161
  required=false; reason="fan_in $fan_in < floor $MIN_FAN_IN"; type=Module
162
+ else
163
+ # Default: every package the graph knows about is documented. Fan-in
164
+ # below the floor only demotes Subsystem→Module; it never exempts.
165
+ required=true; reason=""; type=Module
155
166
  fi
156
167
  add_concept "$name" systems "$type" "$name" "$fan_in" "$required" "$reason"
157
168
  done < <(echo "$arch" | jq -r '.packages[]? | [.name, (.fan_in // 0)] | @tsv')
@@ -10,6 +10,9 @@
10
10
  # 2. Concept Map — a routing table injected between the
11
11
  # <!-- CONCEPT-MAP:START --> / <!-- CONCEPT-MAP:END --> markers in
12
12
  # wiki/index.md (and optionally another index-root file).
13
+ # 3. Section indexes — (--section-indexes) each <section>/index.md concept
14
+ # table rebuilt from the pages that actually exist in that directory, so its
15
+ # links can never dangle (no more hand-authored, link-rotting indexes).
13
16
  #
14
17
  # Usage:
15
18
  # okf-render-views.sh <BUNDLE_DIR> --arch-out <FILE> [--concept-map-into <FILE>]
@@ -25,6 +28,7 @@ BUNDLE=""
25
28
  ARCH_OUT=""
26
29
  WEB_OUT=""
27
30
  CMAP_INTO=()
31
+ SECTION_INDEXES=0
28
32
  COVERAGE_REPORT=""
29
33
  VALIDATED_AT=""
30
34
 
@@ -39,6 +43,10 @@ Flags:
39
43
  --arch-out FILE Write the rendered linear architecture.md here.
40
44
  --concept-map-into FILE Inject the Concept Map between the CONCEPT-MAP markers
41
45
  in FILE (repeatable: e.g. wiki/index.md and ai-context.md).
46
+ --section-indexes Regenerate each section's <section>/index.md concept
47
+ table (between its CONCEPT-MAP markers) from the pages
48
+ that actually exist in that directory. Eliminates
49
+ hand-authored, link-rotting section indexes.
42
50
  --web FILE Write a self-contained, offline HTML viewer (single file:
43
51
  all pages inlined, built-in markdown renderer, sidebar +
44
52
  search). Double-click to open — no server, no internet.
@@ -56,6 +64,7 @@ while [[ $# -gt 0 ]]; do
56
64
  case "$1" in
57
65
  --arch-out) ARCH_OUT="$2"; shift 2;;
58
66
  --concept-map-into) CMAP_INTO+=("$2"); shift 2;;
67
+ --section-indexes) SECTION_INDEXES=1; shift;;
59
68
  --web) WEB_OUT="$2"; shift 2;;
60
69
  --coverage-report) COVERAGE_REPORT="$2"; shift 2;;
61
70
  --validated-at) VALIDATED_AT="$2"; shift 2;;
@@ -134,7 +143,7 @@ render_architecture() {
134
143
  echo ""
135
144
  echo "# Architecture (Rendered View)"
136
145
  echo ""
137
- echo "> **Generated** from the \`wiki/\` OKF bundle — do not edit by hand."
146
+ echo "> **Generated** from the \`wiki/\` bundle — do not edit by hand."
138
147
  echo "> The bundle is the source of truth; this is the single-document linear"
139
148
  echo "> view for onboarding. Regenerate with \`okf-render-views.sh\`."
140
149
  echo ""
@@ -192,6 +201,50 @@ build_concept_map() {
192
201
  done < <(find "$BUNDLE" -type f -name '*.md' -print0 | sort -z)
193
202
  }
194
203
 
204
+ # First non-empty line of a page's `description` frontmatter (handles folded `>`).
205
+ page_desc() {
206
+ awk '
207
+ NR==1&&/^---$/{fm=1;next} fm&&/^---$/{exit}
208
+ fm && /^description:/ { collect=1; sub(/^description:[[:space:]]*>?[[:space:]]*/,""); if($0!=""){print; exit} next }
209
+ fm && collect { sub(/^[[:space:]]+/,""); if($0!=""){print; exit} }
210
+ ' "$1"
211
+ }
212
+
213
+ # Build the per-section concept table (stdout) for a single section directory.
214
+ # Links are bundle-section-relative (just the filename) so they resolve from the
215
+ # section's own index.md. Only pages that actually exist are listed — so the
216
+ # table can never point at a missing file.
217
+ build_section_map() {
218
+ local dir="$1" f rel base type title desc
219
+ echo "| Concept | Type | Routing description |"
220
+ echo "|---------|------|---------------------|"
221
+ while IFS= read -r f; do
222
+ base="$(basename "$f")"
223
+ [[ "$base" == "index.md" ]] && continue
224
+ [[ "$base" == "coverage.md" ]] && continue
225
+ grep -q '<!-- okf:coverage-generated -->' "$f" 2>/dev/null && continue
226
+ type="$(get_yaml_field "$f" type)"
227
+ [[ -n "$type" ]] || continue
228
+ title="$(get_yaml_field "$f" title)"; [[ -n "$title" ]] || title="$base"
229
+ desc="$(page_desc "$f")"
230
+ echo "| [${title}](${base}) | ${type} | ${desc} |"
231
+ done < <(find "$dir" -maxdepth 1 -type f -name '*.md' | sort)
232
+ }
233
+
234
+ # Regenerate every <section>/index.md concept table from real pages.
235
+ render_section_indexes() {
236
+ local sec dir idx
237
+ for sec in "${SECTIONS[@]}"; do
238
+ dir="$BUNDLE/$sec"
239
+ idx="$dir/index.md"
240
+ [[ -d "$dir" && -f "$idx" ]] || continue
241
+ local map_tmp; map_tmp="$(mktemp)"
242
+ build_section_map "$dir" >"$map_tmp"
243
+ inject_concept_map "$idx" "$map_tmp"
244
+ rm -f "$map_tmp"
245
+ done
246
+ }
247
+
195
248
  # Inject the Concept Map between markers in a target file (path may be relative
196
249
  # to BUNDLE: links in the map are bundle-relative, so the target should resolve
197
250
  # them — wiki/index.md works directly; an index root above wiki/ should prefix).
@@ -397,7 +450,10 @@ if [[ ${#CMAP_INTO[@]} -gt 0 ]]; then
397
450
  rm -f "$MAP_TMP"
398
451
  fi
399
452
 
453
+ [[ $SECTION_INDEXES -eq 1 ]] && render_section_indexes
454
+
400
455
  [[ -n "$WEB_OUT" ]] && render_web "$WEB_OUT"
401
456
 
402
- [[ -n "$ARCH_OUT" || -n "$WEB_OUT" || ${#CMAP_INTO[@]} -gt 0 ]] || { echo "ERROR: nothing to do (pass --arch-out, --web, and/or --concept-map-into)" >&2; exit 1; }
457
+ [[ -n "$ARCH_OUT" || -n "$WEB_OUT" || ${#CMAP_INTO[@]} -gt 0 || $SECTION_INDEXES -eq 1 ]] \
458
+ || { echo "ERROR: nothing to do (pass --arch-out, --web, --section-indexes, and/or --concept-map-into)" >&2; exit 1; }
403
459
  exit 0
@@ -11,7 +11,8 @@
11
11
  # Pages in scope: any *.md whose frontmatter declares a frozen `type:`. Section
12
12
  # index.md pages, log.md, and the generated coverage page are excluded.
13
13
  #
14
- # Checks (per type): required H2 sections, min body lines, >=1 mermaid block
14
+ # Checks (per type): required H2 sections AND real content beneath each (so a
15
+ # present-but-empty heading fails as "shallow"), min body lines, >=1 mermaid block
15
16
  # (diagram types only), min x-grounded-paths, anti-stub patterns, unreplaced
16
17
  # template tokens, duplicate "What it is" paragraphs, and a syntax-only mermaid
17
18
  # lint (no Node, no headless browser).
@@ -88,6 +89,19 @@ grounded_count() {
88
89
 
89
90
  has_section() { grep -qE "^##[[:space:]]+$1([[:space:]]|$)" "$2"; }
90
91
 
92
+ # Count non-blank content lines under a given H2 section (heading itself
93
+ # excluded), stopping at the next H2. Used to fail a section that is present as a
94
+ # heading but carries no real content — the classic "shallow" page.
95
+ section_content_lines() {
96
+ local sec="$1" file="$2"
97
+ awk -v sec="$sec" '
98
+ $0 ~ "^##[[:space:]]+" sec "([[:space:]]|$)" { grab=1; next }
99
+ grab && /^##[[:space:]]/ { exit }
100
+ grab && /[^[:space:]]/ { n++ }
101
+ END { print n+0 }
102
+ ' "$file"
103
+ }
104
+
91
105
  mermaid_block_count() { grep -cE '^[[:space:]]*```mermaid' "$1" || true; }
92
106
 
93
107
  # Syntax-only mermaid lint: catch the breakers that silently fail previewers
@@ -139,19 +153,22 @@ whatitis_hash() {
139
153
  ANTI_STUB='see architecture\.md|deferred to ref-docs|\bTBD\b|TODO:[[:space:]]*document|stub page|placeholder page'
140
154
  TOKEN_RE='\{[A-Z_]+\}'
141
155
 
142
- # Per-type policy. echoes: sections|min_lines|need_mermaid|min_grounded
156
+ # Per-type policy. echoes: sections|min_lines|need_mermaid|min_grounded|min_section_lines
157
+ # min_section_lines: minimum non-blank content lines required UNDER each named
158
+ # section (0 disables the per-section depth check). This is what turns a
159
+ # present-but-empty heading — the hallmark of a shallow page — into a failure.
143
160
  type_policy() {
144
161
  case "$1" in
145
162
  Subsystem|Module|Feature|Entrypoint)
146
- echo "What it is;How it works;Used by;Blast radius;See also|25|1|2";;
163
+ echo "What it is;How it works;Used by;Blast radius;See also|35|1|2|2";;
147
164
  API|DataModel)
148
- echo "What it is;How it works;See also|18|0|1";;
165
+ echo "What it is;How it works;See also|22|0|1|2";;
149
166
  Dependency)
150
- echo "What it is;Used by|10|0|0";;
167
+ echo "What it is;Used by|12|0|0|1";;
151
168
  ADR|Runbook)
152
- echo "|8|0|0";;
169
+ echo "|8|0|0|0";;
153
170
  *)
154
- echo "|8|0|0";;
171
+ echo "|8|0|0|0";;
155
172
  esac
156
173
  }
157
174
 
@@ -172,13 +189,26 @@ while IFS= read -r -d '' page; do
172
189
  [[ -z "$type_val" ]] && continue # not a concept page
173
190
  CHECKED=$((CHECKED + 1))
174
191
 
175
- IFS='|' read -r sections min_lines need_mermaid min_grounded <<< "$(type_policy "$type_val")"
192
+ IFS='|' read -r sections min_lines need_mermaid min_grounded min_section_lines <<< "$(type_policy "$type_val")"
176
193
 
177
- # Q-SEC: required sections.
194
+ # Q-SEC: required sections must be present. Q-SECLEN: the NARRATIVE sections
195
+ # ("What it is" / "How it works") must carry real prose beneath the heading —
196
+ # a heading with nothing under it is the classic shallow stub. List sections
197
+ # ("Used by", "See also", "Blast radius") are legitimately terse, so the
198
+ # depth bar only applies to the narrative ones.
178
199
  if [[ -n "$sections" ]]; then
179
200
  IFS=';' read -ra secs <<< "$sections"
180
201
  for s in "${secs[@]}"; do
181
- has_section "$s" "$page" || fail "$rel" "Q-SEC" "missing required section '## $s'"
202
+ if ! has_section "$s" "$page"; then
203
+ fail "$rel" "Q-SEC" "missing required section '## $s'"
204
+ elif [[ "${min_section_lines:-0}" -gt 0 ]]; then
205
+ case "$s" in
206
+ "What it is"|"How it works")
207
+ scl="$(section_content_lines "$s" "$page")"
208
+ [[ "$scl" -ge "$min_section_lines" ]] \
209
+ || fail "$rel" "Q-SECLEN" "section '## $s' has $scl content lines < $min_section_lines (shallow)";;
210
+ esac
211
+ fi
182
212
  done
183
213
  fi
184
214
 
@@ -11,6 +11,11 @@
11
11
  # 2. Every concept page (any *.md whose frontmatter declares `type:`) carries
12
12
  # all required OKF frontmatter keys: type, title, description, resource.
13
13
  # 3. Every declared `type` is in the frozen code-repo vocabulary (§4 of HLD).
14
+ # 3a. Every non-meta *.md is a real concept: an empty or frontmatter-less page
15
+ # (a blank placeholder) fails — it would otherwise be invisible to the
16
+ # quality + coverage layers, which both key on `type`.
17
+ # 3b. No page carries an unreplaced {ALL_CAPS} template token (catches leftover
18
+ # placeholders in hand-seeded index pages, which the quality layer skips).
14
19
  # 4. Every relative markdown cross-link ( ](path.md) ) resolves to a file that
15
20
  # exists inside the bundle. External (http/https/mailto) and pure-anchor
16
21
  # (#frag) links are ignored.
@@ -105,14 +110,46 @@ if [[ ! -f "$BUNDLE/index.md" ]]; then
105
110
  add_error "missing bundle root: $BUNDLE/index.md"
106
111
  fi
107
112
 
113
+ # A page is "meta" (not a concept) if it is a section/root index, the change log,
114
+ # or the tool-generated coverage page. Everything else MUST be a real concept —
115
+ # an empty or frontmatter-less .md placeholder is a completeness failure, not an
116
+ # invisible non-concept.
117
+ is_meta_page() {
118
+ local base="$1" page="$2"
119
+ case "$base" in
120
+ index.md|log.md|coverage.md) return 0;;
121
+ esac
122
+ grep -q '<!-- okf:coverage-generated -->' "$page" 2>/dev/null && return 0
123
+ return 1
124
+ }
125
+
126
+ # Non-blank body line count (everything after the frontmatter block).
127
+ nonblank_body_lines() {
128
+ awk 'NR==1&&/^---$/{fm=1;next} fm&&/^---$/{fm=0;next} !fm{print}' "$1" \
129
+ | grep -cE '[^[:space:]]' || true
130
+ }
131
+
108
132
  # --- 2/3. Per-page frontmatter + type vocabulary ---
109
133
  while IFS= read -r -d '' page; do
110
134
  PAGE_COUNT=$((PAGE_COUNT + 1))
111
135
  rel="${page#"$BUNDLE/"}"
136
+ base="$(basename "$rel")"
112
137
 
113
- # A page is a "concept" only if its frontmatter declares a type.
114
138
  type_val="$(get_yaml_field "$page" "type")"
115
- [[ -z "$type_val" ]] && continue
139
+
140
+ # Empty / placeholder pages: a non-meta page with no type (or no body) slips
141
+ # past every downstream check (quality + coverage both key on `type`). Catch
142
+ # it here so a blank or stub file can never ship.
143
+ if [[ -z "$type_val" ]]; then
144
+ if ! is_meta_page "$base" "$page"; then
145
+ if [[ "$(nonblank_body_lines "$page")" -eq 0 ]]; then
146
+ add_error "$rel: empty page (no frontmatter type, no body) — every wiki page must be a real concept"
147
+ else
148
+ add_error "$rel: untyped page (missing frontmatter 'type:') — not a valid concept page"
149
+ fi
150
+ fi
151
+ continue
152
+ fi
116
153
  CONCEPT_COUNT=$((CONCEPT_COUNT + 1))
117
154
 
118
155
  for key in title description resource; do
@@ -124,6 +161,24 @@ while IFS= read -r -d '' page; do
124
161
  if ! is_known_type "$type_val"; then
125
162
  add_error "$rel: unknown concept type '$type_val' (frozen vocab: $OKF_TYPES)"
126
163
  fi
164
+
165
+ # A typed concept page with no body is still a stub — fail it even though the
166
+ # depth/anti-stub bars live in the quality layer (this layer must stand alone).
167
+ if [[ "$(nonblank_body_lines "$page")" -eq 0 ]]; then
168
+ add_error "$rel: concept page has empty body"
169
+ fi
170
+ done < <(find "$BUNDLE" -type f -name '*.md' -print0 | sort -z)
171
+
172
+ # --- 3b. Unreplaced template tokens (any page, including index pages) ---
173
+ # Quality checks skip index.md pages, so a leftover {SECTION_TITLE}/{PROJECT_NAME}
174
+ # placeholder in a hand-seeded index would otherwise survive. Match {ALL_CAPS}
175
+ # tokens (safe: real prose almost never contains them).
176
+ while IFS= read -r -d '' page; do
177
+ prel="${page#"$BUNDLE/"}"
178
+ if grep -qE '\{[A-Z][A-Z0-9_]+\}' "$page" 2>/dev/null; then
179
+ tok="$(grep -oE '\{[A-Z][A-Z0-9_]+\}' "$page" | head -1)"
180
+ add_error "$prel: unreplaced template token '$tok'"
181
+ fi
127
182
  done < <(find "$BUNDLE" -type f -name '*.md' -print0 | sort -z)
128
183
 
129
184
  # --- 4. Cross-link resolution ---
@@ -221,7 +221,7 @@ The tier-gated default rests on **maintainability/readability** (one navigable c
221
221
  1. `okf-plan-concepts.sh` ran and the expected/required/deferred counts were logged **before** any page was written (the concept boundary is a tool output, not an in-context guess).
222
222
  2. Every `required` entry in `concept-plan.json` has a non-stub page.
223
223
  3. `okf-validate-all.sh … --plan … --strict` exits 0 (structure + per-type quality + coverage all pass).
224
- 4. `systems/coverage.md` was generated by tooling (verify the `<!-- okf:coverage-generated -->` marker) — no required package with `fan_in floor` is **MISSING**.
224
+ 4. `systems/coverage.md` was generated by tooling (verify the `<!-- okf:coverage-generated -->` marker) — **every** package/module the graph found is required by default (the fan-in floor only types Subsystem-vs-Module, it does not exempt), so none may be **MISSING**. Section `index.md` tables are regenerated by `okf-render-views.sh --section-indexes`, never hand-authored, so their links cannot dangle.
225
225
  5. On any failure: **do not** atomic-rename; surface `.state/validation-report.json`.
226
226
 
227
227
  > **Red flag:** writing concept pages without first running `okf-plan-concepts.sh`, or finishing generation while any `required` plan entry is unwritten, is a **completeness failure** — not a stylistic one.
@@ -122,17 +122,21 @@ Derive concepts from the graph, not by hand:
122
122
  ```
123
123
  1. Survey → existing /draft:init 5-phase + graph snapshot (graph-snapshot.sh)
124
124
  2. Plan → DETERMINISTIC. okf-plan-concepts.sh derives the expected-concept
125
- set from the graph (every package with fan_in floor → required
126
- Subsystem/Module; entrypointsrequired; below-floor / allow-defer
127
- deferred with a reason) and writes draft.tmp/.state/concept-plan.json.
125
+ set from the graph. EVERY package the graph knows about is
126
+ required (fan_in ≥ floor Subsystem; below floor Module — the
127
+ floor only types/orders, it never exempts); entrypoints required;
128
+ only --allow-defer matches are deferred (with a reason). Writes
129
+ draft.tmp/.state/concept-plan.json.
128
130
  okf-plan-concepts.sh --repo . [--scope PATH] \
129
131
  [--manifest FILE] [--min-fan-in 2] [--allow-defer GLOB]... \
130
132
  --out draft.tmp/.state/concept-plan.json
131
133
  This replaces the old in-context concept enumeration — the boundary
132
134
  of the work is now a tool output, not an LLM judgment, so modules
133
- cannot be silently dropped. LOG the counts (expected/required/
134
- deferred) BEFORE writing any page. `generated_order` is topo-ish
135
- (required + high-fan-in first) so forward cross-links resolve.
135
+ and sub-modules cannot be silently dropped. (Legacy fan-in
136
+ exemption is opt-in via --defer-below-floor.) LOG the counts
137
+ (expected/required/deferred) BEFORE writing any page.
138
+ `generated_order` is topo-ish (required + high-fan-in first) so
139
+ forward cross-links resolve.
136
140
  3. Generate → iterate concept-plan.generated_order; write ONE page per REQUIRED
137
141
  entry, grounding each from the graph:
138
142
  x-callers ← graph-callers.sh --symbol <c>
@@ -152,9 +156,11 @@ Derive concepts from the graph, not by hand:
152
156
  --plan draft.tmp/.state/concept-plan.json \
153
157
  --path-index draft.tmp/.state/path-to-concept.json \
154
158
  --strict --report draft.tmp/.state/validation-report.json
155
- It runs, in order: okf-validate.sh (structure + reverse index),
156
- okf-validate-quality.sh (per-type anti-stub / depth / mermaid lint),
157
- okf-coverage-check.sh (every required plan entry real page).
159
+ It runs, in order: okf-validate.sh (structure + reverse index +
160
+ empty/untyped-page + leftover-template-token + dangling-link checks),
161
+ okf-validate-quality.sh (per-type anti-stub / depth / per-section
162
+ content / mermaid lint), okf-coverage-check.sh (every required plan
163
+ entry → real page).
158
164
  ANY layer failing ⇒ exit non-zero ⇒ DO NOT atomic-rename.
159
165
  coverage.md (systems/coverage.md) is regenerated by the coverage
160
166
  layer; it is tool-owned (marker <!-- okf:coverage-generated -->) —
@@ -193,6 +199,7 @@ regenerated on every init/refresh so they never drift from the bundle:
193
199
  ```bash
194
200
  okf-render-views.sh draft/wiki \
195
201
  --arch-out draft/architecture.md \
202
+ --section-indexes \
196
203
  --concept-map-into draft/wiki/index.md \
197
204
  --concept-map-into draft/.ai-context.md \
198
205
  --web draft/wiki/web/index.html
@@ -200,6 +207,11 @@ okf-render-views.sh draft/wiki \
200
207
 
201
208
  - `--arch-out` renders the linear `architecture.md` (banner + TOC + every concept
202
209
  page in canonical section order, frontmatter stripped, Mermaid preserved).
210
+ - `--section-indexes` rebuilds each `<section>/index.md` concept table (between its
211
+ `CONCEPT-MAP` markers) from the pages that actually exist in that directory. This
212
+ is mandatory: section indexes are NOT hand-authored — building them from real
213
+ files is what makes their links impossible to dangle. Never write a section
214
+ index "Concepts" table by hand.
203
215
  - `--concept-map-into` rebuilds the routing table between the
204
216
  `<!-- CONCEPT-MAP:START -->` / `:END` markers from each concept's `title` +
205
217
  `type` + `description` (section `index.md` pages excluded).