@drafthq/draft 3.5.3 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/cli/src/hosts/cursor.js +3 -0
  5. package/cli/src/installer.js +7 -3
  6. package/cli/src/lib/fsx.js +3 -0
  7. package/cli/src/lib/marker.js +8 -3
  8. package/core/shared/condensation.md +1 -1
  9. package/core/shared/git-report-metadata.md +1 -1
  10. package/core/shared/graph-query.md +24 -24
  11. package/core/shared/tool-resolver.md +20 -15
  12. package/core/templates/plan.md +1 -1
  13. package/integrations/agents/AGENTS.md +122 -470
  14. package/integrations/copilot/.github/copilot-instructions.md +122 -470
  15. package/package.json +1 -1
  16. package/scripts/fetch-memory-engine.sh +1 -1
  17. package/scripts/lib.sh +79 -2
  18. package/scripts/tools/_graph_queries.sh +12 -5
  19. package/scripts/tools/_lib.sh +56 -8
  20. package/scripts/tools/adr-index.sh +1 -1
  21. package/scripts/tools/check-skill-line-caps.sh +2 -2
  22. package/scripts/tools/check-track-hygiene.sh +41 -21
  23. package/scripts/tools/classify-files.sh +1 -1
  24. package/scripts/tools/cycle-detect.sh +2 -9
  25. package/scripts/tools/detect-test-framework.sh +1 -1
  26. package/scripts/tools/emit-skill-metrics.sh +7 -2
  27. package/scripts/tools/freshness-check.sh +2 -2
  28. package/scripts/tools/git-metadata.sh +6 -6
  29. package/scripts/tools/graph-arch.sh +4 -10
  30. package/scripts/tools/graph-callers.sh +4 -12
  31. package/scripts/tools/graph-deps.sh +3 -10
  32. package/scripts/tools/graph-errors.sh +4 -11
  33. package/scripts/tools/graph-hierarchy.sh +4 -11
  34. package/scripts/tools/graph-impact.sh +17 -17
  35. package/scripts/tools/graph-init.sh +1 -1
  36. package/scripts/tools/graph-preflight.sh +1 -1
  37. package/scripts/tools/graph-query.sh +5 -12
  38. package/scripts/tools/graph-risk.sh +3 -10
  39. package/scripts/tools/graph-search.sh +4 -11
  40. package/scripts/tools/graph-snapshot.sh +9 -4
  41. package/scripts/tools/graph-snippet.sh +3 -10
  42. package/scripts/tools/graph-tests.sh +3 -10
  43. package/scripts/tools/graph-traces.sh +3 -10
  44. package/scripts/tools/hotspot-rank.sh +4 -11
  45. package/scripts/tools/mermaid-from-graph.sh +3 -10
  46. package/scripts/tools/migrate-track-frontmatter.sh +3 -1
  47. package/scripts/tools/okf-coverage-check.sh +4 -4
  48. package/scripts/tools/okf-emit-catalog.sh +256 -0
  49. package/scripts/tools/okf-fix-links.sh +326 -0
  50. package/scripts/tools/okf-plan-concepts.sh +245 -14
  51. package/scripts/tools/okf-render-views.sh +46 -9
  52. package/scripts/tools/okf-validate-all.sh +20 -9
  53. package/scripts/tools/okf-validate-quality.sh +2 -8
  54. package/scripts/tools/okf-validate.sh +7 -4
  55. package/scripts/tools/parse-git-log.sh +4 -4
  56. package/scripts/tools/parse-reports.sh +1 -1
  57. package/scripts/tools/render-track.sh +1 -1
  58. package/scripts/tools/run-coverage.sh +1 -1
  59. package/scripts/tools/scan-markers.sh +4 -4
  60. package/scripts/tools/validate-frontmatter.sh +2 -2
  61. package/scripts/tools/verify-citations.sh +2 -2
  62. package/scripts/tools/verify-doc-anchors.sh +1 -1
  63. package/scripts/tools/verify-graph-binary.sh +2 -2
  64. package/skills/adr/SKILL.md +1 -1
  65. package/skills/bughunt/SKILL.md +1 -1
  66. package/skills/coverage/SKILL.md +2 -2
  67. package/skills/debug/SKILL.md +1 -1
  68. package/skills/decompose/SKILL.md +3 -3
  69. package/skills/deep-review/SKILL.md +2 -2
  70. package/skills/deploy-checklist/SKILL.md +1 -1
  71. package/skills/graph/SKILL.md +1 -1
  72. package/skills/impact/SKILL.md +1 -1
  73. package/skills/implement/SKILL.md +2 -2
  74. package/skills/init/SKILL.md +10 -7
  75. package/skills/init/references/architecture-spec.md +1 -1
  76. package/skills/init/references/okf-emitter.md +39 -75
  77. package/skills/learn/SKILL.md +1 -1
  78. package/skills/quick-review/SKILL.md +1 -1
  79. package/skills/review/SKILL.md +3 -3
  80. package/skills/standup/SKILL.md +1 -1
  81. package/skills/status/SKILL.md +1 -1
  82. package/skills/tech-debt/SKILL.md +2 -2
  83. package/skills/upload/SKILL.md +1 -1
  84. package/core/templates/track-architecture.md +0 -311
@@ -73,13 +73,13 @@ EOF
73
73
 
74
74
  while [[ $# -gt 0 ]]; do
75
75
  case "$1" in
76
- --repo) REPO="$2"; shift 2;;
77
- --scope) SCOPE="$2"; shift 2;;
78
- --manifest) MANIFEST="$2"; shift 2;;
79
- --min-fan-in) MIN_FAN_IN="$2"; shift 2;;
76
+ --repo) REPO="${2:?--repo requires a value}"; shift 2;;
77
+ --scope) SCOPE="${2:?--scope requires a value}"; shift 2;;
78
+ --manifest) MANIFEST="${2:?--manifest requires a value}"; shift 2;;
79
+ --min-fan-in) MIN_FAN_IN="${2:?--min-fan-in requires a value}"; shift 2;;
80
80
  --defer-below-floor) DEFER_BELOW_FLOOR=1; shift;;
81
81
  --allow-defer) ALLOW_DEFER+=("$2"); shift 2;;
82
- --out) OUT="$2"; shift 2;;
82
+ --out) OUT="${2:?--out requires a value}"; shift 2;;
83
83
  --json) JSON=1; shift;;
84
84
  --help|-h) usage; exit 0;;
85
85
  -*) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
@@ -111,13 +111,38 @@ is_deferred_name() {
111
111
  E_ID=(); E_TYPE=(); E_RES=(); E_FANIN=(); E_REQ=(); E_REASON=()
112
112
  SOURCE="heuristic"
113
113
  DEGRADED="false"
114
+ DISCOVERY_META=() # e.g. cargo, npm, go, graph, heuristic
115
+
116
+ # Graph package names that are almost always tokenizer/type noise, not modules.
117
+ is_noise_package_name() {
118
+ local n="$1"
119
+ case "$n" in
120
+ str|list|dict|int|bool|float|string|type|mod|Cargo|cargo|profile|int64|uint|byte|char|void|null|None|true|false|i32|i64|u32|u64|f32|f64|Option|Result|Error|Self|self)
121
+ return 0;;
122
+ esac
123
+ return 1
124
+ }
125
+
126
+ # True if concept_id already recorded.
127
+ has_concept_id() {
128
+ local want="$1" id
129
+ for id in "${E_ID[@]:-}"; do
130
+ [[ "$id" == "$want" ]] && return 0
131
+ done
132
+ return 1
133
+ }
114
134
 
115
135
  add_concept() {
116
136
  # name section type resource fan_in required reason
117
137
  local name="$1" section="$2" type="$3" resource="$4" fan_in="$5" required="$6" reason="$7"
118
138
  local stem; stem="$(slug "$name")"
119
139
  [[ -n "$stem" ]] || stem="component"
120
- E_ID+=("$section/$stem.md")
140
+ local cid="$section/$stem.md"
141
+ # Dedupe by concept_id (cargo + graph may both see the same crate).
142
+ if has_concept_id "$cid"; then
143
+ return 0
144
+ fi
145
+ E_ID+=("$cid")
121
146
  E_TYPE+=("$type")
122
147
  E_RES+=("$resource")
123
148
  E_FANIN+=("$fan_in")
@@ -141,29 +166,195 @@ plan_from_manifest() {
141
166
  SOURCE="manifest"
142
167
  }
143
168
 
144
- # --- 2. Graph path ---
169
+ # --- 2a. Language-aware workspace inventories (Cargo / npm / Go) ---
170
+ # These produce crate/package-level concepts that graph .packages often collapse
171
+ # or mis-label (especially Rust monorepos). Prefer path-grounded resources.
172
+
173
+ plan_from_cargo_workspace() {
174
+ local cargo="$REPO/Cargo.toml"
175
+ [[ -f "$cargo" ]] || return 1
176
+ # Only treat as workspace if [workspace] present with members.
177
+ grep -qE '^\[workspace\]' "$cargo" || return 1
178
+ grep -qE 'members\s*=' "$cargo" || return 1
179
+
180
+ local members=()
181
+ # Extract quoted paths inside the members = [ ... ] array (possibly multi-line).
182
+ local in_members=0 line m
183
+ while IFS= read -r line || [[ -n "$line" ]]; do
184
+ if [[ "$line" =~ members[[:space:]]*=[[:space:]]*\[ ]]; then
185
+ in_members=1
186
+ fi
187
+ if [[ $in_members -eq 1 ]]; then
188
+ while [[ "$line" =~ \"([^\"]+)\" ]]; do
189
+ m="${BASH_REMATCH[1]}"
190
+ members+=("$m")
191
+ line="${line#*\"$m\"}"
192
+ done
193
+ [[ "$line" == *"]"* ]] && in_members=0
194
+ fi
195
+ done < "$cargo"
196
+
197
+ [[ ${#members[@]} -gt 0 ]] || return 1
198
+
199
+ local path name ct required reason type
200
+ local added=0
201
+ for path in "${members[@]}"; do
202
+ [[ -z "$path" ]] && continue
203
+ # Skip globs we can't expand cheaply without bash nullglob walk
204
+ if [[ "$path" == *"*"* ]]; then
205
+ local base="${path%%/\*}"
206
+ [[ -d "$REPO/$base" ]] || continue
207
+ while IFS= read -r ct; do
208
+ name="$(basename "$(dirname "$ct")")"
209
+ [[ -z "$name" ]] && continue
210
+ if is_deferred_name "$name"; then
211
+ required=false; reason="allow-defer match"
212
+ else
213
+ required=true; reason=""
214
+ fi
215
+ type=Module
216
+ # Entrypoint packaging root if only main.rs and no lib.rs (heuristic)
217
+ if [[ -f "$(dirname "$ct")/src/main.rs" && ! -f "$(dirname "$ct")/src/lib.rs" ]]; then
218
+ type=Module
219
+ fi
220
+ add_concept "$name" systems "$type" "$(dirname "$ct" | sed "s|^$REPO/||")" 0 "$required" "$reason"
221
+ added=$((added+1))
222
+ done < <(find "$REPO/$base" -mindepth 1 -maxdepth 3 -type f -name 'Cargo.toml' 2>/dev/null | sort)
223
+ continue
224
+ fi
225
+ [[ -f "$REPO/$path/Cargo.toml" || -f "$REPO/$path" ]] || continue
226
+ local res="$path"
227
+ [[ -f "$REPO/$path" && "$path" == */Cargo.toml ]] && res="$(dirname "$path")"
228
+ name="$(basename "$res")"
229
+ if is_deferred_name "$name"; then
230
+ required=false; reason="allow-defer match"
231
+ else
232
+ required=true; reason=""
233
+ fi
234
+ add_concept "$name" systems Module "$res" 0 "$required" "$reason"
235
+ added=$((added+1))
236
+ done
237
+ [[ $added -gt 0 ]] || return 1
238
+ DISCOVERY_META+=("cargo")
239
+ return 0
240
+ }
241
+
242
+ plan_from_npm_workspaces() {
243
+ local pkg="$REPO/package.json"
244
+ [[ -f "$pkg" ]] || return 1
245
+ command -v jq >/dev/null 2>&1 || return 1
246
+ # workspaces may be array or { packages: [] }
247
+ local names
248
+ names="$(jq -r '
249
+ if .workspaces|type=="array" then .workspaces[]
250
+ elif .workspaces.packages|type=="array" then .workspaces.packages[]
251
+ else empty end
252
+ ' "$pkg" 2>/dev/null || true)"
253
+ [[ -n "$names" ]] || return 1
254
+ local path name required reason added=0
255
+ while IFS= read -r path; do
256
+ [[ -z "$path" || "$path" == *"*"* ]] && continue
257
+ name="$(basename "$path")"
258
+ if is_deferred_name "$name"; then
259
+ required=false; reason="allow-defer match"
260
+ else
261
+ required=true; reason=""
262
+ fi
263
+ add_concept "$name" systems Module "$path" 0 "$required" "$reason"
264
+ added=$((added+1))
265
+ done <<< "$names"
266
+ [[ $added -gt 0 ]] || return 1
267
+ DISCOVERY_META+=("npm")
268
+ return 0
269
+ }
270
+
271
+ plan_from_go_modules() {
272
+ # go.work use directives, else single go.mod at root, else first-level */go.mod
273
+ local added=0 name required reason path
274
+ if [[ -f "$REPO/go.work" ]]; then
275
+ while IFS= read -r path; do
276
+ path="$(printf '%s' "$path" | sed -E 's/^[[:space:]]*//')"
277
+ [[ -z "$path" || "$path" == //* ]] && continue
278
+ [[ -f "$REPO/$path/go.mod" ]] || continue
279
+ name="$(basename "$path")"
280
+ if is_deferred_name "$name"; then
281
+ required=false; reason="allow-defer match"
282
+ else
283
+ required=true; reason=""
284
+ fi
285
+ add_concept "$name" systems Module "$path" 0 "$required" "$reason"
286
+ added=$((added+1))
287
+ done < <(awk '/^use[[:space:]]*\(/,/^\)/ {if ($1!="use" && $1!="(" && $1!=")") print $1}
288
+ /^use[[:space:]]+\./ {print $2}' "$REPO/go.work" | tr -d '"')
289
+ fi
290
+ if [[ $added -eq 0 && -f "$REPO/go.mod" ]]; then
291
+ name="$(basename "$REPO")"
292
+ add_concept "$name" systems Module "." 0 true ""
293
+ added=1
294
+ fi
295
+ if [[ $added -eq 0 ]]; then
296
+ while IFS= read -r path; do
297
+ name="$(basename "$(dirname "$path")")"
298
+ add_concept "$name" systems Module "$(dirname "$path" | sed "s|^$REPO/||")" 0 true ""
299
+ added=$((added+1))
300
+ done < <(find "$REPO" -mindepth 2 -maxdepth 3 -type f -name 'go.mod' 2>/dev/null | sort | head -50)
301
+ fi
302
+ [[ $added -gt 0 ]] || return 1
303
+ DISCOVERY_META+=("go")
304
+ return 0
305
+ }
306
+
307
+ # --- 2b. Graph path ---
145
308
  plan_from_graph() {
146
309
  local arch; arch="$(scripts_graph_arch)" || return 1
147
310
  [[ -n "$arch" ]] || return 1
148
311
  echo "$arch" | jq -e '.packages != null' >/dev/null 2>&1 || return 1
149
312
 
150
- SOURCE="graph"
313
+ local have_lang_inventory=0
314
+ [[ ${#DISCOVERY_META[@]} -gt 0 ]] && have_lang_inventory=1
315
+
151
316
  local name fan_in type required reason
152
317
  # Packages → systems/<pkg>.md
153
318
  while IFS=$'\t' read -r name fan_in; do
154
319
  [[ -z "$name" ]] && continue
320
+ # Drop tokenizer noise when we already have a real inventory (cargo/npm/go).
321
+ if [[ $have_lang_inventory -eq 1 ]] && is_noise_package_name "$name"; then
322
+ continue
323
+ fi
324
+ # Even without inventory, skip pure noise names that do not map to a dir.
325
+ if is_noise_package_name "$name"; then
326
+ local mapped=0
327
+ for d in "$REPO/$name" "$REPO/crates/$name" "$REPO/src/$name" "$REPO/third_party/$name"; do
328
+ [[ -d "$d" ]] && mapped=1 && break
329
+ done
330
+ [[ $mapped -eq 0 ]] && continue
331
+ fi
155
332
  if is_deferred_name "$name"; then
156
333
  required=false; reason="allow-defer match"; type=Module
157
334
  elif (( fan_in >= MIN_FAN_IN )); then
158
335
  required=true; reason=""; type=Subsystem
159
336
  elif [[ $DEFER_BELOW_FLOOR -eq 1 ]]; then
160
- # Opt-in legacy behavior: low-fan-in packages are exempted.
161
337
  required=false; reason="fan_in $fan_in < floor $MIN_FAN_IN"; type=Module
162
338
  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
339
  required=true; reason=""; type=Module
166
340
  fi
341
+ # When language inventory exists, graph packages that are coarse parents
342
+ # (e.g. codegen/) become Subsystems only if a matching directory exists.
343
+ if [[ $have_lang_inventory -eq 1 ]]; then
344
+ if [[ -d "$REPO/$name" || -d "$REPO/crates/$name" || -d "$REPO/third_party/$name" || -d "$REPO/prod/$name" ]]; then
345
+ type=Subsystem
346
+ # Prefer real path as resource when known
347
+ for d in "$name" "crates/$name" "third_party/$name" "prod/$name"; do
348
+ if [[ -d "$REPO/$d" ]]; then
349
+ add_concept "$name" systems "$type" "$d" "$fan_in" "$required" "$reason"
350
+ continue 2
351
+ fi
352
+ done
353
+ else
354
+ # Unmapped graph label with inventory present → skip (not a crate).
355
+ continue
356
+ fi
357
+ fi
167
358
  add_concept "$name" systems "$type" "$name" "$fan_in" "$required" "$reason"
168
359
  done < <(echo "$arch" | jq -r '.packages[]? | [.name, (.fan_in // 0)] | @tsv')
169
360
 
@@ -178,6 +369,8 @@ plan_from_graph() {
178
369
  done < <(echo "$arch" | jq -r '
179
370
  (.entry_points // [])[]? | if type=="object" then (.name // .path // empty) else . end' \
180
371
  | sort -u)
372
+ DISCOVERY_META+=("graph")
373
+ return 0
181
374
  }
182
375
 
183
376
  # graph-arch.sh wrapper that tolerates the "unavailable" sentinel.
@@ -218,13 +411,30 @@ plan_from_heuristic() {
218
411
  }
219
412
 
220
413
  # --- Drive discovery in priority order ---
414
+ # 1. --manifest (authoritative, exclusive)
415
+ # 2. Language inventories (Cargo / npm / Go) — can combine with graph parents
416
+ # 3. Graph packages (filtered when inventory present)
417
+ # 4. Heuristic top-level dirs
221
418
  if [[ -n "$MANIFEST" ]]; then
222
419
  [[ -f "$MANIFEST" ]] || { echo "ERROR: --manifest not found: $MANIFEST" >&2; exit 1; }
223
420
  plan_from_manifest
224
- elif plan_from_graph; then
225
- :
421
+ DISCOVERY_META+=("manifest")
226
422
  else
227
- plan_from_heuristic
423
+ # Language inventories first so cargo crate names win concept_ids.
424
+ plan_from_cargo_workspace || true
425
+ plan_from_npm_workspaces || true
426
+ plan_from_go_modules || true
427
+ if plan_from_graph; then
428
+ :
429
+ elif [[ ${#E_ID[@]} -eq 0 ]]; then
430
+ plan_from_heuristic
431
+ DISCOVERY_META+=("heuristic")
432
+ fi
433
+ # If graph failed but we have cargo/npm/go, still success.
434
+ if [[ ${#E_ID[@]} -eq 0 ]]; then
435
+ plan_from_heuristic
436
+ DISCOVERY_META+=("heuristic")
437
+ fi
228
438
  fi
229
439
 
230
440
  if [[ ${#E_ID[@]} -eq 0 ]]; then
@@ -232,6 +442,19 @@ if [[ ${#E_ID[@]} -eq 0 ]]; then
232
442
  exit 2
233
443
  fi
234
444
 
445
+ # Compose SOURCE label from discovery meta
446
+ if [[ ${#DISCOVERY_META[@]} -gt 0 ]]; then
447
+ # de-dupe meta labels
448
+ SOURCE="$(printf '%s\n' "${DISCOVERY_META[@]}" | awk '!s[$0]++' | paste -sd+ -)"
449
+ [[ -z "$SOURCE" ]] && SOURCE="heuristic"
450
+ # degraded only when pure heuristic with no language/graph
451
+ if [[ "$SOURCE" == "heuristic" ]]; then
452
+ DEGRADED="true"
453
+ else
454
+ DEGRADED="false"
455
+ fi
456
+ fi
457
+
235
458
  # Required-first, then deferred; stable within group (topological-ish: high fan-in
236
459
  # subsystems first so forward cross-links resolve during generation).
237
460
  emit_plan() {
@@ -257,6 +480,14 @@ emit_plan() {
257
480
  printf ' "source": "%s",\n' "$SOURCE"
258
481
  printf ' "degraded": %s,\n' "$DEGRADED"
259
482
  printf ' "min_fan_in": %d,\n' "$MIN_FAN_IN"
483
+ printf ' "discovery": ['
484
+ local di first_d=1
485
+ for di in "${DISCOVERY_META[@]:-}"; do
486
+ [[ -z "$di" ]] && continue
487
+ [[ $first_d -eq 1 ]] && first_d=0 || printf ','
488
+ printf '"%s"' "$(json_escape "$di")"
489
+ done
490
+ printf '],\n'
260
491
  # generated_order
261
492
  printf ' "generated_order": ['
262
493
  local first=1
@@ -62,12 +62,12 @@ EOF
62
62
 
63
63
  while [[ $# -gt 0 ]]; do
64
64
  case "$1" in
65
- --arch-out) ARCH_OUT="$2"; shift 2;;
65
+ --arch-out) ARCH_OUT="${2:?--arch-out requires a value}"; shift 2;;
66
66
  --concept-map-into) CMAP_INTO+=("$2"); shift 2;;
67
67
  --section-indexes) SECTION_INDEXES=1; shift;;
68
- --web) WEB_OUT="$2"; shift 2;;
69
- --coverage-report) COVERAGE_REPORT="$2"; shift 2;;
70
- --validated-at) VALIDATED_AT="$2"; shift 2;;
68
+ --web) WEB_OUT="${2:?--web requires a value}"; shift 2;;
69
+ --coverage-report) COVERAGE_REPORT="${2:?--coverage-report requires a value}"; shift 2;;
70
+ --validated-at) VALIDATED_AT="${2:?--validated-at requires a value}"; shift 2;;
71
71
  --help|-h) usage; exit 0;;
72
72
  -*) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
73
73
  *)
@@ -101,6 +101,38 @@ ordered_pages() {
101
101
  }
102
102
 
103
103
  # Strip YAML frontmatter from a page (leading --- ... --- block on line 1).
104
+
105
+ # Rewrite markdown links so concatenated architecture.md (at draft/) resolves them.
106
+ # stdin body; $1 = bundle-relative path e.g. systems/foo.md
107
+ rewrite_body_links() {
108
+ local rel="${1:-}"
109
+ local sec="${rel%%/*}"
110
+ python3 -c '
111
+ import sys, re
112
+ sec = sys.argv[1]
113
+ text = sys.stdin.read()
114
+ sections = ("systems", "features", "overview", "entrypoints", "reference")
115
+ def repl(m):
116
+ label, href = m.group(1), m.group(2).strip()
117
+ if href.startswith(("http://", "https://", "mailto:", "#", "wiki/")):
118
+ return m.group(0)
119
+ if "::" in href and not href.startswith("../"):
120
+ return "`" + label + "`"
121
+ path, _, frag = href.partition("#")
122
+ frag_s = "#" + frag if frag else ""
123
+ if path.startswith("../"):
124
+ rest = path[3:]
125
+ if rest.split("/")[0] in sections:
126
+ return "[%s](wiki/%s%s)" % (label, rest, frag_s)
127
+ if any(path.startswith(s + "/") for s in sections):
128
+ return "[%s](wiki/%s%s)" % (label, path, frag_s)
129
+ if path.endswith(".md") and "/" not in path and sec in sections:
130
+ return "[%s](wiki/%s/%s%s)" % (label, sec, path, frag_s)
131
+ return m.group(0)
132
+ sys.stdout.write(re.sub(r"\[([^\]]*)\]\(([^)]+)\)", repl, text))
133
+ ' "$sec"
134
+ }
135
+
104
136
  strip_frontmatter() {
105
137
  awk '
106
138
  NR==1 && /^---$/ { fm=1; next }
@@ -172,8 +204,7 @@ render_architecture() {
172
204
  fi
173
205
  title="$(page_title "$BUNDLE/$rel")"
174
206
  [[ -n "$title" ]] || title="$rel"
175
- local anchor; anchor="$(printf '%s' "$title" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-')"
176
- anchor="${anchor#-}"; anchor="${anchor%-}"
207
+ local anchor; anchor="$(gfm_slug "$title")"
177
208
  echo " - [${title}](#${anchor})"
178
209
  done < <(ordered_pages)
179
210
  echo ""
@@ -183,10 +214,16 @@ render_architecture() {
183
214
  echo ""
184
215
  echo "---"
185
216
  echo ""
186
- strip_frontmatter "$BUNDLE/$rel"
217
+ strip_frontmatter "$BUNDLE/$rel" | rewrite_body_links "$rel"
187
218
  done < <(ordered_pages)
188
219
  } >"$tmp"
189
220
  mv "$tmp" "$out"
221
+ if [[ -x "$SCRIPT_DIR/okf-fix-links.sh" ]]; then
222
+ draft_dir="$(cd "$(dirname "$out")" && pwd)"
223
+ if [[ -d "$draft_dir/wiki" || "$(basename "$BUNDLE")" == "wiki" ]]; then
224
+ "$SCRIPT_DIR/okf-fix-links.sh" --file "$out" --wiki "$BUNDLE" --fix >/dev/null 2>&1 || true
225
+ fi
226
+ fi
190
227
  echo "rendered architecture view → $out ($(ordered_pages | grep -c . ) pages)"
191
228
  }
192
229
 
@@ -387,7 +424,7 @@ function render(md, base){
387
424
  var blocks=[], src=md.replace(/```(\w*)\n([\s\S]*?)```/g,function(m,lang,body){
388
425
  var cls = lang==='mermaid' ? ' class="mermaid-src"' : '';
389
426
  blocks.push('<pre'+cls+'><code>'+esc(body.replace(/\n$/,''))+'</code></pre>');
390
- return 'BLOCK'+(blocks.length-1)+'';
427
+ return 'BLOCK'+(blocks.length-1)+'';
391
428
  });
392
429
  var lines=src.split('\n'), out='', i=0, list='', tbl=[];
393
430
  function closeList(){ if(list){ out+='</'+list+'>'; list=''; } }
@@ -410,7 +447,7 @@ function render(md, base){
410
447
  if(/^\s*>\s?/.test(ln)){ closeList(); out+='<blockquote>'+inline(esc(ln.replace(/^\s*>\s?/,'')),base)+'</blockquote>'; continue; }
411
448
  var li=ln.match(/^\s*([-*]|\d+\.)\s+(.*)$/);
412
449
  if(li){ var want=/^\d/.test(li[1])?'ol':'ul'; if(list!==want){ closeList(); list=want; out+='<'+want+'>'; } out+='<li>'+inline(esc(li[2]),base)+'</li>'; continue; }
413
- var b=ln.match(/^BLOCK(\d+)$/);
450
+ var b=ln.match(/^BLOCK(\d+)$/);
414
451
  if(b){ closeList(); out+=blocks[+b[1]]; continue; }
415
452
  if(/^\s*$/.test(ln)){ closeList(); continue; }
416
453
  closeList(); out+='<p>'+inline(esc(ln),base)+'</p>';
@@ -6,9 +6,10 @@
6
6
  # code, so a bundle that is structurally broken, full of stubs, or missing
7
7
  # required components is never promoted.
8
8
  #
9
- # Layer 1 okf-validate.sh structure (frontmatter, types, links, index)
10
9
  # Layer 2 okf-validate-quality.sh per-type anti-stub / depth / mermaid lint
11
10
  # Layer 3 okf-coverage-check.sh every required plan entry has a real page
11
+ # (may rewrite systems/coverage.md)
12
+ # Layer 1 okf-validate.sh structure LAST so coverage rewrite is checked
12
13
  #
13
14
  # Usage:
14
15
  # okf-validate-all.sh <BUNDLE_DIR> [--plan FILE] [--path-index FILE]
@@ -53,10 +54,10 @@ EOF
53
54
 
54
55
  while [[ $# -gt 0 ]]; do
55
56
  case "$1" in
56
- --plan) PLAN="$2"; shift 2;;
57
- --path-index) PATH_INDEX="$2"; shift 2;;
57
+ --plan) PLAN="${2:?--plan requires a value}"; shift 2;;
58
+ --path-index) PATH_INDEX="${2:?--path-index requires a value}"; shift 2;;
58
59
  --strict) STRICT=1; shift;;
59
- --report) REPORT="$2"; shift 2;;
60
+ --report) REPORT="${2:?--report requires a value}"; shift 2;;
60
61
  --json) JSON=1; shift;;
61
62
  --help|-h) usage; exit 0;;
62
63
  -*) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
@@ -79,16 +80,23 @@ run_layer() {
79
80
  return $rc
80
81
  }
81
82
 
82
- # Layer 1: structure.
83
+ # Order matters:
84
+ # 1) quality — page content (does not rewrite files)
85
+ # 2) coverage — may REGENERATE systems/coverage.md (tool-owned)
86
+ # 3) structure — link/frontmatter check LAST so coverage rewrite cannot leave
87
+ # dangling links that structure already approved.
88
+ #
89
+ # Historical bug: structure-before-coverage let init promote a bundle whose
90
+ # coverage.md still had broken relative links (entrypoints/ from systems/).
91
+
83
92
  v1_args=("$BUNDLE")
84
93
  [[ -n "$PATH_INDEX" ]] && v1_args+=(--path-index "$PATH_INDEX")
85
- if run_layer structure "$SCRIPT_DIR/okf-validate.sh" "${v1_args[@]}"; then L1=pass; else L1=fail; OVERALL=1; fi
86
-
87
- # Layer 2: quality.
88
94
  q_args=("$BUNDLE"); [[ $STRICT -eq 1 ]] && q_args+=(--strict)
95
+
96
+ # Layer 2: quality (run first for fast fail on stubs).
89
97
  if run_layer quality "$SCRIPT_DIR/okf-validate-quality.sh" "${q_args[@]}"; then L2=pass; else L2=fail; OVERALL=1; fi
90
98
 
91
- # Layer 3: coverage (only if a plan is supplied).
99
+ # Layer 3: coverage (writes coverage.md when not --no-coverage-page).
92
100
  if [[ -n "$PLAN" ]]; then
93
101
  if run_layer coverage "$SCRIPT_DIR/okf-coverage-check.sh" --plan "$PLAN" --bundle "$BUNDLE"; then
94
102
  L3=pass
@@ -97,6 +105,9 @@ if [[ -n "$PLAN" ]]; then
97
105
  fi
98
106
  fi
99
107
 
108
+ # Layer 1: structure LAST (after coverage page is final).
109
+ if run_layer structure "$SCRIPT_DIR/okf-validate.sh" "${v1_args[@]}"; then L1=pass; else L1=fail; OVERALL=1; fi
110
+
100
111
  REPORT_JSON="$(printf '{"valid":%s,"bundle":"%s","layers":{"structure":"%s","quality":"%s","coverage":"%s"}}\n' \
101
112
  "$([[ $OVERALL -eq 0 ]] && echo true || echo false)" "$(json_escape "$BUNDLE")" "$L1" "$L2" "$L3")"
102
113
 
@@ -76,15 +76,9 @@ body_of() {
76
76
  # Count body lines after the frontmatter close (Q-LEN = "lines after frontmatter").
77
77
  body_lines() { body_of "$1" | wc -l | tr -d ' '; }
78
78
 
79
- # x-grounded-paths array length (entries inside [ ... ]).
79
+ # x-grounded-paths array length inline `[a, b]` or YAML block list (via _lib.sh).
80
80
  grounded_count() {
81
- local arr
82
- arr="$(grep -m1 -E '^x-grounded-paths:' "$1" 2>/dev/null || true)"
83
- [[ -z "$arr" ]] && { echo 0; return; }
84
- arr="${arr#*[}"; arr="${arr%]*}"
85
- arr="$(printf '%s' "$arr" | tr -d ' ')"
86
- [[ -z "$arr" ]] && { echo 0; return; }
87
- awk -F',' '{print NF}' <<< "$arr"
81
+ grounded_paths_count "$1"
88
82
  }
89
83
 
90
84
  has_section() { grep -qE "^##[[:space:]]+$1([[:space:]]|$)" "$2"; }
@@ -66,7 +66,7 @@ EOF
66
66
 
67
67
  while [[ $# -gt 0 ]]; do
68
68
  case "$1" in
69
- --path-index) PATH_INDEX="$2"; shift 2;;
69
+ --path-index) PATH_INDEX="${2:?--path-index requires a value}"; shift 2;;
70
70
  --reverse) REVERSE=1; shift;;
71
71
  --structure-only) STRUCTURE_ONLY=1; shift;;
72
72
  --json) JSON=1; shift;;
@@ -237,12 +237,14 @@ if [[ -n "$PATH_INDEX" ]]; then
237
237
  # themselves end in .md (e.g. grounding to docs/INVARIANTS.md) — those are
238
238
  # not bundle pages, so we extract strings *inside* the [ ... ] value arrays
239
239
  # and ignore keys entirely. Each page must exist in the bundle.
240
+ # Flatten first: the index may be pretty-printed, so value arrays can
241
+ # span lines and a line-oriented match would silently skip them.
240
242
  while IFS= read -r ref; do
241
243
  [[ -z "$ref" ]] && continue
242
244
  if [[ ! -f "$BUNDLE/$ref" ]]; then
243
245
  add_error "path-index references missing concept page: $ref"
244
246
  fi
245
- done < <(grep -oE '\[[^]]*\]' "$PATH_INDEX" 2>/dev/null \
247
+ done < <(tr '\n' ' ' < "$PATH_INDEX" 2>/dev/null | grep -oE '\[[^]]*\]' \
246
248
  | grep -oE '"[^"]+\.md"' | tr -d '"' | sort -u)
247
249
  fi
248
250
  fi
@@ -251,9 +253,10 @@ fi
251
253
  # Forward proves the index doesn't name ghosts; reverse proves no page is an
252
254
  # orphan with no source mapping (a symptom of hand-written / off-plan pages).
253
255
  if [[ $REVERSE -eq 1 && $STRUCTURE_ONLY -eq 0 && -n "$PATH_INDEX" && -f "$PATH_INDEX" ]]; then
254
- # All pages the index maps to (its array values).
256
+ # All pages the index maps to (its array values). Flattened for the same
257
+ # pretty-printed-array reason as the forward check above.
255
258
  INDEXED_FILE="$(mktemp)"
256
- grep -oE '\[[^]]*\]' "$PATH_INDEX" 2>/dev/null \
259
+ tr '\n' ' ' < "$PATH_INDEX" 2>/dev/null | grep -oE '\[[^]]*\]' \
257
260
  | grep -oE '"[^"]+\.md"' | tr -d '"' | sort -u > "$INDEXED_FILE" || true
258
261
  while IFS= read -r -d '' page; do
259
262
  rel="${page#"$BUNDLE/"}"
@@ -49,10 +49,10 @@ EOF
49
49
 
50
50
  while [[ $# -gt 0 ]]; do
51
51
  case "$1" in
52
- --since) SINCE="$2"; shift 2;;
53
- --limit) LIMIT="$2"; shift 2;;
54
- --scope-pattern) SCOPE_PATTERN="$2"; shift 2;;
55
- --branch) BRANCH="$2"; shift 2;;
52
+ --since) SINCE="${2:?--since requires a value}"; shift 2;;
53
+ --limit) LIMIT="${2:?--limit requires a value}"; shift 2;;
54
+ --scope-pattern) SCOPE_PATTERN="${2:?--scope-pattern requires a value}"; shift 2;;
55
+ --branch) BRANCH="${2:?--branch requires a value}"; shift 2;;
56
56
  --help|-h) usage; exit 0;;
57
57
  *) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
58
58
  esac
@@ -35,7 +35,7 @@ EOF
35
35
 
36
36
  while [[ $# -gt 0 ]]; do
37
37
  case "$1" in
38
- --root) ROOT="$2"; shift 2;;
38
+ --root) ROOT="${2:?--root requires a value}"; shift 2;;
39
39
  --help|-h) usage; exit 0;;
40
40
  *) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
41
41
  esac
@@ -48,7 +48,7 @@ TO_STDOUT=0
48
48
  while (($#)); do
49
49
  case "$1" in
50
50
  -h|--help) USAGE_HELP_MODE=1 usage ;;
51
- --out) OUT_PATH="$2"; shift 2 ;;
51
+ --out) OUT_PATH="${2:?--out requires a value}"; shift 2 ;;
52
52
  --stdout) TO_STDOUT=1; shift ;;
53
53
  -*) printf 'Unknown flag: %s\n' "$1" >&2; usage ;;
54
54
  *) TRACK_DIR="$1"; shift ;;
@@ -51,7 +51,7 @@ EOF
51
51
 
52
52
  while [[ $# -gt 0 ]]; do
53
53
  case "$1" in
54
- --path) COVERAGE_PATH="$2"; shift 2;;
54
+ --path) COVERAGE_PATH="${2:?--path requires a value}"; shift 2;;
55
55
  --schema-check) SCHEMA_CHECK="true"; shift;;
56
56
  --help|-h) usage; exit 0;;
57
57
  -*) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
@@ -6,7 +6,7 @@
6
6
  #
7
7
  # Usage:
8
8
  # scripts/tools/scan-markers.sh [--root DIR] [--markers LIST]
9
- # [--min-age-days N] [--include-untracked]
9
+ # [--min-age-days N]
10
10
  #
11
11
  # Exit codes: 0 OK (even with zero hits), 1 invocation error, 2 not a git repo
12
12
  # (emits [] on stdout so consumers can still parse).
@@ -38,9 +38,9 @@ EOF
38
38
 
39
39
  while [[ $# -gt 0 ]]; do
40
40
  case "$1" in
41
- --root) ROOT="$2"; shift 2;;
42
- --markers) MARKERS="$2"; shift 2;;
43
- --min-age-days) MIN_AGE="$2"; shift 2;;
41
+ --root) ROOT="${2:?--root requires a value}"; shift 2;;
42
+ --markers) MARKERS="${2:?--markers requires a value}"; shift 2;;
43
+ --min-age-days) MIN_AGE="${2:?--min-age-days requires a value}"; shift 2;;
44
44
  --help|-h) usage; exit 0;;
45
45
  *) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
46
46
  esac
@@ -43,8 +43,8 @@ REQUIRED="name,description"
43
43
 
44
44
  while [[ $# -gt 0 ]]; do
45
45
  case "$1" in
46
- --require) REQUIRED="$2"; shift 2;;
47
- --mode) MODE="$2"; shift 2;;
46
+ --require) REQUIRED="${2:?--require requires a value}"; shift 2;;
47
+ --mode) MODE="${2:?--mode requires a value}"; shift 2;;
48
48
  --help|-h) usage; exit 0;;
49
49
  -*) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
50
50
  *)
@@ -48,7 +48,7 @@ while (($#)); do
48
48
  case "$1" in
49
49
  -h|--help) USAGE_HELP_MODE=1 usage ;;
50
50
  --json) EMIT_JSON=1; shift ;;
51
- --tolerance) TOLERANCE="$2"; shift 2 ;;
51
+ --tolerance) TOLERANCE="${2:?--tolerance requires a value}"; shift 2 ;;
52
52
  -*) printf 'Unknown flag: %s\n' "$1" >&2; usage ;;
53
53
  *) TRACK_PATHS+=("$1"); shift ;;
54
54
  esac
@@ -83,7 +83,7 @@ extract_citations() {
83
83
  # bracket expression prematurely terminates a regex literal in
84
84
  # POSIX/BSD awk ("nonterminated character class"), though gawk is
85
85
  # lenient. The string form is portable across awk implementations.
86
- while (match(s, "[A-Za-z0-9_][A-Za-z0-9_./-]*\\.[A-Za-z0-9]+:[0-9]+")) {
86
+ while (match(s, "[A-Za-z0-9_][A-Za-z0-9_./-]*\\.[A-Za-z0-9]+:[0-9]+(-[0-9]+)?")) {
87
87
  cite = substr(s, RSTART, RLENGTH)
88
88
  printf("%s\t%d\t%s\n", FILENAME, NR, cite)
89
89
  s = substr(s, RSTART + RLENGTH)