@drafthq/draft 3.5.3 → 3.7.1

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 (143) 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/README.md +28 -49
  5. package/bin/README.md +47 -1
  6. package/cli/src/hosts/cursor.js +3 -0
  7. package/cli/src/installer.js +8 -4
  8. package/cli/src/lib/cursor-registry.js +10 -0
  9. package/cli/src/lib/fsx.js +3 -0
  10. package/cli/src/lib/marker.js +8 -3
  11. package/core/agents/architect.md +21 -9
  12. package/core/agents/debugger.md +5 -0
  13. package/core/agents/ops.md +7 -2
  14. package/core/agents/planner.md +7 -0
  15. package/core/agents/rca.md +14 -3
  16. package/core/agents/reviewer.md +1 -0
  17. package/core/agents/writer.md +6 -0
  18. package/core/knowledge-base.md +22 -3
  19. package/core/methodology.md +30 -5
  20. package/core/shared/condensation.md +28 -18
  21. package/core/shared/context-verify.md +3 -1
  22. package/core/shared/cross-skill-dispatch.md +7 -3
  23. package/core/shared/draft-context-loading.md +3 -0
  24. package/core/shared/git-report-metadata.md +2 -1
  25. package/core/shared/graph-query.md +24 -26
  26. package/core/shared/graph-usage-report.md +1 -1
  27. package/core/shared/jira-sync.md +10 -6
  28. package/core/shared/okf-retrieval.md +1 -1
  29. package/core/shared/parallel-analysis.md +14 -7
  30. package/core/shared/pattern-learning.md +2 -0
  31. package/core/shared/template-contract.md +1 -1
  32. package/core/shared/tool-resolver.md +20 -15
  33. package/core/shared/vcs-commands.md +5 -2
  34. package/core/templates/ai-context.md +3 -2
  35. package/core/templates/ai-profile.md +7 -0
  36. package/core/templates/architecture.md +6 -2
  37. package/core/templates/discovery.md +1 -1
  38. package/core/templates/guardrails.md +9 -0
  39. package/core/templates/hld.md +5 -4
  40. package/core/templates/intake-questions.md +45 -0
  41. package/core/templates/jira.md +11 -2
  42. package/core/templates/lld.md +8 -5
  43. package/core/templates/okf/concept.md +0 -1
  44. package/core/templates/plan.md +1 -1
  45. package/core/templates/product.md +11 -2
  46. package/core/templates/rca.md +6 -0
  47. package/core/templates/service-index.md +1 -0
  48. package/core/templates/spec.md +15 -0
  49. package/core/templates/tech-stack.md +12 -0
  50. package/core/templates/workflow.md +20 -0
  51. package/integrations/agents/AGENTS.md +1449 -817
  52. package/integrations/copilot/.github/copilot-instructions.md +1449 -817
  53. package/package.json +1 -1
  54. package/scripts/fetch-memory-engine.sh +25 -7
  55. package/scripts/lib.sh +90 -6
  56. package/scripts/tools/_graph_queries.sh +22 -8
  57. package/scripts/tools/_lib.sh +81 -11
  58. package/scripts/tools/adr-index.sh +1 -1
  59. package/scripts/tools/check-repo-size.sh +125 -0
  60. package/scripts/tools/check-skill-line-caps.sh +3 -4
  61. package/scripts/tools/check-template-noop.sh +0 -1
  62. package/scripts/tools/check-track-hygiene.sh +41 -21
  63. package/scripts/tools/classify-files.sh +3 -3
  64. package/scripts/tools/cycle-detect.sh +18 -17
  65. package/scripts/tools/detect-test-framework.sh +1 -1
  66. package/scripts/tools/emit-skill-metrics.sh +7 -2
  67. package/scripts/tools/fix-whitespace.sh +3 -0
  68. package/scripts/tools/freshness-check.sh +2 -2
  69. package/scripts/tools/git-metadata.sh +12 -6
  70. package/scripts/tools/graph-arch.sh +5 -11
  71. package/scripts/tools/graph-callers.sh +10 -15
  72. package/scripts/tools/graph-deps.sh +3 -10
  73. package/scripts/tools/graph-errors.sh +4 -11
  74. package/scripts/tools/graph-hierarchy.sh +4 -11
  75. package/scripts/tools/graph-impact.sh +21 -19
  76. package/scripts/tools/graph-init.sh +13 -9
  77. package/scripts/tools/graph-preflight.sh +4 -1
  78. package/scripts/tools/graph-query.sh +74 -18
  79. package/scripts/tools/graph-risk.sh +3 -10
  80. package/scripts/tools/graph-search.sh +4 -11
  81. package/scripts/tools/graph-snapshot.sh +38 -9
  82. package/scripts/tools/graph-snippet.sh +3 -10
  83. package/scripts/tools/graph-tests.sh +3 -10
  84. package/scripts/tools/graph-traces.sh +3 -10
  85. package/scripts/tools/hotspot-rank.sh +6 -12
  86. package/scripts/tools/install-smoke-test.sh +223 -0
  87. package/scripts/tools/mermaid-from-graph.sh +5 -11
  88. package/scripts/tools/migrate-track-frontmatter.sh +11 -10
  89. package/scripts/tools/okf-coverage-check.sh +8 -7
  90. package/scripts/tools/okf-emit-catalog.sh +256 -0
  91. package/scripts/tools/okf-fix-links.sh +251 -0
  92. package/scripts/tools/okf-plan-concepts.sh +242 -14
  93. package/scripts/tools/okf-render-views.sh +63 -21
  94. package/scripts/tools/okf-validate-all.sh +20 -9
  95. package/scripts/tools/okf-validate-quality.sh +15 -10
  96. package/scripts/tools/okf-validate.sh +20 -6
  97. package/scripts/tools/parse-git-log.sh +23 -5
  98. package/scripts/tools/parse-reports.sh +1 -1
  99. package/scripts/tools/render-track.sh +1 -1
  100. package/scripts/tools/resolve-tools.sh +24 -8
  101. package/scripts/tools/run-coverage.sh +1 -1
  102. package/scripts/tools/scan-markers.sh +4 -4
  103. package/scripts/tools/validate-frontmatter.sh +2 -2
  104. package/scripts/tools/verify-citations.sh +3 -4
  105. package/scripts/tools/verify-doc-anchors.sh +14 -34
  106. package/scripts/tools/verify-graph-binary.sh +15 -5
  107. package/skills/GRAPH.md +21 -10
  108. package/skills/adr/SKILL.md +25 -7
  109. package/skills/assist-review/SKILL.md +2 -1
  110. package/skills/bughunt/SKILL.md +55 -12
  111. package/skills/bughunt/references/regression-tests.md +14 -4
  112. package/skills/change/SKILL.md +19 -9
  113. package/skills/coverage/SKILL.md +28 -12
  114. package/skills/debug/SKILL.md +13 -3
  115. package/skills/decompose/SKILL.md +42 -12
  116. package/skills/deep-review/SKILL.md +19 -6
  117. package/skills/deploy-checklist/SKILL.md +10 -3
  118. package/skills/documentation/SKILL.md +12 -1
  119. package/skills/draft/SKILL.md +40 -29
  120. package/skills/draft/context-files.md +8 -1
  121. package/skills/draft/quality-guide.md +10 -2
  122. package/skills/graph/SKILL.md +2 -2
  123. package/skills/impact/SKILL.md +4 -2
  124. package/skills/implement/SKILL.md +62 -20
  125. package/skills/incident-response/SKILL.md +5 -2
  126. package/skills/init/SKILL.md +112 -30
  127. package/skills/init/references/architecture-spec.md +32 -8
  128. package/skills/init/references/okf-emitter.md +42 -78
  129. package/skills/jira/SKILL.md +33 -13
  130. package/skills/jira/references/review.md +61 -19
  131. package/skills/learn/SKILL.md +30 -7
  132. package/skills/new-track/SKILL.md +73 -18
  133. package/skills/quick-review/SKILL.md +8 -3
  134. package/skills/revert/SKILL.md +11 -5
  135. package/skills/review/SKILL.md +126 -27
  136. package/skills/review/references/zero-setup-mode.md +117 -0
  137. package/skills/standup/SKILL.md +10 -2
  138. package/skills/status/SKILL.md +7 -7
  139. package/skills/tech-debt/SKILL.md +9 -3
  140. package/skills/testing-strategy/SKILL.md +10 -2
  141. package/skills/tour/SKILL.md +2 -1
  142. package/skills/upload/SKILL.md +4 -3
  143. 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[[:space:]]*=' "$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,27 @@ 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
+ plan_from_graph || true
428
+ # Last resort only: any of cargo/npm/go/graph succeeding leaves E_ID non-empty
429
+ # and skips this. Re-running the heuristic a second time can never add what
430
+ # the first pass didn't find, so there is one call site, not two.
431
+ if [[ ${#E_ID[@]} -eq 0 ]]; then
432
+ plan_from_heuristic
433
+ DISCOVERY_META+=("heuristic")
434
+ fi
228
435
  fi
229
436
 
230
437
  if [[ ${#E_ID[@]} -eq 0 ]]; then
@@ -232,6 +439,19 @@ if [[ ${#E_ID[@]} -eq 0 ]]; then
232
439
  exit 2
233
440
  fi
234
441
 
442
+ # Compose SOURCE label from discovery meta
443
+ if [[ ${#DISCOVERY_META[@]} -gt 0 ]]; then
444
+ # de-dupe meta labels
445
+ SOURCE="$(printf '%s\n' "${DISCOVERY_META[@]}" | awk '!s[$0]++' | paste -sd+ -)"
446
+ [[ -z "$SOURCE" ]] && SOURCE="heuristic"
447
+ # degraded only when pure heuristic with no language/graph
448
+ if [[ "$SOURCE" == "heuristic" ]]; then
449
+ DEGRADED="true"
450
+ else
451
+ DEGRADED="false"
452
+ fi
453
+ fi
454
+
235
455
  # Required-first, then deferred; stable within group (topological-ish: high fan-in
236
456
  # subsystems first so forward cross-links resolve during generation).
237
457
  emit_plan() {
@@ -257,6 +477,14 @@ emit_plan() {
257
477
  printf ' "source": "%s",\n' "$SOURCE"
258
478
  printf ' "degraded": %s,\n' "$DEGRADED"
259
479
  printf ' "min_fan_in": %d,\n' "$MIN_FAN_IN"
480
+ printf ' "discovery": ['
481
+ local di first_d=1
482
+ for di in "${DISCOVERY_META[@]:-}"; do
483
+ [[ -z "$di" ]] && continue
484
+ [[ $first_d -eq 1 ]] && first_d=0 || printf ','
485
+ printf '"%s"' "$(json_escape "$di")"
486
+ done
487
+ printf '],\n'
260
488
  # generated_order
261
489
  printf ' "generated_order": ['
262
490
  local first=1
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # okf-render-views.sh — render the demoted views from an OKF taxonomy bundle.
3
3
  #
4
- # The wiki/ bundle is the source of truth. This produces the two derived,
4
+ # The wiki/ bundle is the source of truth. This produces the derived,
5
5
  # human-facing views deterministically (so they never drift from the bundle and
6
6
  # carry zero extra maintenance):
7
7
  # 1. architecture.md — a single linear concatenation of every concept page,
@@ -24,6 +24,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
24
  # shellcheck source=scripts/tools/_lib.sh
25
25
  source "$SCRIPT_DIR/_lib.sh"
26
26
 
27
+ command -v python3 >/dev/null 2>&1 || {
28
+ echo "ERROR: python3 is required by ${0##*/} (link rewriting in rendered views)" >&2
29
+ exit 2
30
+ }
31
+
27
32
  BUNDLE=""
28
33
  ARCH_OUT=""
29
34
  WEB_OUT=""
@@ -56,18 +61,18 @@ Flags:
56
61
  this tool has no clock dependency).
57
62
  --help Show this help.
58
63
 
59
- Requires jq (already a Draft prereq) for --web. Exit 0 ok, 1 error, 2 bundle not found.
64
+ Requires jq (for --web) and python3 (link rewriting). Exit 0 ok, 1 error, 2 bundle not found.
60
65
  EOF
61
66
  }
62
67
 
63
68
  while [[ $# -gt 0 ]]; do
64
69
  case "$1" in
65
- --arch-out) ARCH_OUT="$2"; shift 2;;
70
+ --arch-out) ARCH_OUT="${2:?--arch-out requires a value}"; shift 2;;
66
71
  --concept-map-into) CMAP_INTO+=("$2"); shift 2;;
67
72
  --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;;
73
+ --web) WEB_OUT="${2:?--web requires a value}"; shift 2;;
74
+ --coverage-report) COVERAGE_REPORT="${2:?--coverage-report requires a value}"; shift 2;;
75
+ --validated-at) VALIDATED_AT="${2:?--validated-at requires a value}"; shift 2;;
71
76
  --help|-h) usage; exit 0;;
72
77
  -*) echo "Unknown flag: $1" >&2; usage >&2; exit 1;;
73
78
  *)
@@ -100,6 +105,37 @@ ordered_pages() {
100
105
  done
101
106
  }
102
107
 
108
+ # Rewrite markdown links so concatenated architecture.md (at draft/) resolves them.
109
+ # stdin body; $1 = bundle-relative path e.g. systems/foo.md
110
+ rewrite_body_links() {
111
+ local rel="${1:-}"
112
+ local sec="${rel%%/*}"
113
+ python3 -c '
114
+ import sys, re
115
+ sec = sys.argv[1]
116
+ text = sys.stdin.read()
117
+ sections = ("systems", "features", "overview", "entrypoints", "reference")
118
+ def repl(m):
119
+ label, href = m.group(1), m.group(2).strip()
120
+ if href.startswith(("http://", "https://", "mailto:", "#", "wiki/")):
121
+ return m.group(0)
122
+ if "::" in href and not href.startswith("../"):
123
+ return "`" + label + "`"
124
+ path, _, frag = href.partition("#")
125
+ frag_s = "#" + frag if frag else ""
126
+ if path.startswith("../"):
127
+ rest = path[3:]
128
+ if rest.split("/")[0] in sections:
129
+ return "[%s](wiki/%s%s)" % (label, rest, frag_s)
130
+ if any(path.startswith(s + "/") for s in sections):
131
+ return "[%s](wiki/%s%s)" % (label, path, frag_s)
132
+ if path.endswith(".md") and "/" not in path and sec in sections:
133
+ return "[%s](wiki/%s/%s%s)" % (label, sec, path, frag_s)
134
+ return m.group(0)
135
+ sys.stdout.write(re.sub(r"\[([^\]]*)\]\(([^)]+)\)", repl, text))
136
+ ' "$sec"
137
+ }
138
+
103
139
  # Strip YAML frontmatter from a page (leading --- ... --- block on line 1).
104
140
  strip_frontmatter() {
105
141
  awk '
@@ -172,8 +208,7 @@ render_architecture() {
172
208
  fi
173
209
  title="$(page_title "$BUNDLE/$rel")"
174
210
  [[ -n "$title" ]] || title="$rel"
175
- local anchor; anchor="$(printf '%s' "$title" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-')"
176
- anchor="${anchor#-}"; anchor="${anchor%-}"
211
+ local anchor; anchor="$(gfm_slug "$title")"
177
212
  echo " - [${title}](#${anchor})"
178
213
  done < <(ordered_pages)
179
214
  echo ""
@@ -183,10 +218,17 @@ render_architecture() {
183
218
  echo ""
184
219
  echo "---"
185
220
  echo ""
186
- strip_frontmatter "$BUNDLE/$rel"
221
+ strip_frontmatter "$BUNDLE/$rel" | rewrite_body_links "$rel"
187
222
  done < <(ordered_pages)
188
223
  } >"$tmp"
224
+ apply_dest_mode "$tmp" "$out" # mktemp is 0600; mv would strip the dest's mode
189
225
  mv "$tmp" "$out"
226
+ if [[ -x "$SCRIPT_DIR/okf-fix-links.sh" ]]; then
227
+ draft_dir="$(cd "$(dirname "$out")" && pwd)"
228
+ if [[ -d "$draft_dir/wiki" || "$(basename "$BUNDLE")" == "wiki" ]]; then
229
+ "$SCRIPT_DIR/okf-fix-links.sh" --file "$out" --wiki "$BUNDLE" --fix >/dev/null 2>&1 || true
230
+ fi
231
+ fi
190
232
  echo "rendered architecture view → $out ($(ordered_pages | grep -c . ) pages)"
191
233
  }
192
234
 
@@ -206,12 +248,7 @@ build_concept_map() {
206
248
  [[ -n "$type" ]] || continue
207
249
  title="$(get_yaml_field "$page" title)"
208
250
  [[ -n "$title" ]] || title="$rel"
209
- # description may be a folded (>) block — take the first non-empty body line.
210
- desc="$(awk '
211
- NR==1&&/^---$/{fm=1;next} fm&&/^---$/{exit}
212
- fm && /^description:/ { collect=1; sub(/^description:[[:space:]]*>?[[:space:]]*/,""); if($0!=""){print; exit} next }
213
- fm && collect { sub(/^[[:space:]]+/,""); if($0!=""){print; exit} }
214
- ' "$page")"
251
+ desc="$(page_desc "$page")"
215
252
  echo "| [${title}](${rel}) | ${type} | ${desc} |"
216
253
  done < <(find "$BUNDLE" -type f -name '*.md' -print0 | sort -z)
217
254
  }
@@ -276,14 +313,18 @@ inject_concept_map() {
276
313
  /<!-- CONCEPT-MAP:END -->/ { skip=0 }
277
314
  !skip { print }
278
315
  ' "$target" >"$tmp"
316
+ apply_dest_mode "$tmp" "$target" # mktemp is 0600; mv would strip the dest's mode
279
317
  mv "$tmp" "$target"
280
318
  echo "injected Concept Map → $target"
281
319
  }
282
320
 
283
321
  # --- 3. Render a self-contained offline HTML viewer (single file) ---
284
322
  # All pages are inlined as JSON; a small built-in markdown renderer draws them in
285
- # the browser. No server, no internet, no CDN. jq encodes page content safely
286
- # (and we neutralize any literal </ so embedded "</script>" can't break parsing).
323
+ # the browser. No server, no internet, no CDN. jq encodes page content safely,
324
+ # and the whole emitted data block is passed through a `</` → `<\/` filter: JSON
325
+ # escaping does NOT stop a literal "</script>" from closing the <script> element,
326
+ # and page titles/types/paths come from repo-derived frontmatter, so every field
327
+ # has to be neutralized, not just the markdown body.
287
328
  render_web() {
288
329
  local out="$1"
289
330
  command -v jq >/dev/null 2>&1 || { echo "ERROR: --web requires jq" >&2; return 1; }
@@ -351,14 +392,14 @@ HTML_HEAD
351
392
  "$(jq -Rn --arg v "$rel" '$v')" \
352
393
  "$(jq -Rn --arg v "$title" '$v')" \
353
394
  "$(jq -Rn --arg v "$type" '$v')" \
354
- "$(strip_frontmatter "$page" | jq -Rs . | sed 's#</#<\\/#g')"
395
+ "$(strip_frontmatter "$page" | jq -Rs .)"
355
396
  done < <(find "$BUNDLE" -type f -name '*.md' -print0 | sort -z)
356
397
  echo "};"
357
398
  # ORDER: bundle root index.md first, then everything else sorted.
358
399
  echo "const ORDER = Object.keys(PAGES).sort(function(a,b){"
359
400
  echo " if(a==='index.md') return -1; if(b==='index.md') return 1;"
360
401
  echo " return a<b?-1:a>b?1:0; });"
361
- } >>"$tmp"
402
+ } | sed 's#</#<\\/#g' >>"$tmp"
362
403
 
363
404
  cat >>"$tmp" <<'HTML_TAIL'
364
405
  function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
@@ -387,7 +428,7 @@ function render(md, base){
387
428
  var blocks=[], src=md.replace(/```(\w*)\n([\s\S]*?)```/g,function(m,lang,body){
388
429
  var cls = lang==='mermaid' ? ' class="mermaid-src"' : '';
389
430
  blocks.push('<pre'+cls+'><code>'+esc(body.replace(/\n$/,''))+'</code></pre>');
390
- return 'BLOCK'+(blocks.length-1)+'';
431
+ return 'BLOCK'+(blocks.length-1)+'';
391
432
  });
392
433
  var lines=src.split('\n'), out='', i=0, list='', tbl=[];
393
434
  function closeList(){ if(list){ out+='</'+list+'>'; list=''; } }
@@ -410,7 +451,7 @@ function render(md, base){
410
451
  if(/^\s*>\s?/.test(ln)){ closeList(); out+='<blockquote>'+inline(esc(ln.replace(/^\s*>\s?/,'')),base)+'</blockquote>'; continue; }
411
452
  var li=ln.match(/^\s*([-*]|\d+\.)\s+(.*)$/);
412
453
  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+)$/);
454
+ var b=ln.match(/^BLOCK(\d+)$/);
414
455
  if(b){ closeList(); out+=blocks[+b[1]]; continue; }
415
456
  if(/^\s*$/.test(ln)){ closeList(); continue; }
416
457
  closeList(); out+='<p>'+inline(esc(ln),base)+'</p>';
@@ -450,6 +491,7 @@ show(decodeURIComponent(location.hash.slice(1)) || ORDER[0]);
450
491
  HTML_TAIL
451
492
 
452
493
  mkdir -p "$(dirname "$out")"
494
+ apply_dest_mode "$tmp" "$out" # mktemp is 0600; mv would strip the dest's mode
453
495
  mv "$tmp" "$out"
454
496
  echo "rendered offline HTML viewer → $out ($(find "$BUNDLE" -type f -name '*.md' | grep -c .) pages)"
455
497
  }
@@ -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
 
@@ -73,18 +73,23 @@ body_of() {
73
73
  awk 'NR==1&&/^---$/{fm=1;next} fm&&/^---$/{fm=0;next} !fm{print}' "$1"
74
74
  }
75
75
 
76
+ # Body text the Q-TEMPLATE scan is allowed to see: fenced code blocks and
77
+ # shell-style ${VAR} expansions removed. Both legitimately carry {ALL_CAPS}
78
+ # spans that are not unreplaced placeholders (a page quoting `${HOME}` or a CI
79
+ # snippet would otherwise fail).
80
+ template_scan_body() {
81
+ body_of "$1" | awk '
82
+ /^[[:space:]]*```/ { fence = !fence; next }
83
+ !fence { gsub(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/, ""); print }
84
+ '
85
+ }
86
+
76
87
  # Count body lines after the frontmatter close (Q-LEN = "lines after frontmatter").
77
88
  body_lines() { body_of "$1" | wc -l | tr -d ' '; }
78
89
 
79
- # x-grounded-paths array length (entries inside [ ... ]).
90
+ # x-grounded-paths array length inline `[a, b]` or YAML block list (via _lib.sh).
80
91
  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"
92
+ grounded_paths_count "$1"
88
93
  }
89
94
 
90
95
  has_section() { grep -qE "^##[[:space:]]+$1([[:space:]]|$)" "$2"; }
@@ -120,7 +125,7 @@ mermaid_lint() {
120
125
  echo "unicode arrow in mermaid (use --> not →)"; return 1
121
126
  fi
122
127
  # '&' node chaining (common breaker).
123
- if printf '%s' "$blocks" | grep -qE '[A-Za-z0-9_]\s*&\s*[A-Za-z0-9_]'; then
128
+ if printf '%s' "$blocks" | grep -qE '[A-Za-z0-9_][[:space:]]*&[[:space:]]*[A-Za-z0-9_]'; then
124
129
  echo "'&' node chaining in mermaid"; return 1
125
130
  fi
126
131
  # Reserved bareword node ids.
@@ -241,7 +246,7 @@ while IFS= read -r -d '' page; do
241
246
  fi
242
247
 
243
248
  # Q-TEMPLATE: unreplaced {TOKEN} placeholders.
244
- if body_of "$page" | grep -qE "$TOKEN_RE"; then
249
+ if template_scan_body "$page" | grep -qE "$TOKEN_RE"; then
245
250
  fail "$rel" "Q-TEMPLATE" "unreplaced template token {PLACEHOLDER}"
246
251
  fi
247
252