@drafthq/draft 3.6.0 → 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 (117) 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/installer.js +1 -1
  7. package/cli/src/lib/cursor-registry.js +10 -0
  8. package/core/agents/architect.md +21 -9
  9. package/core/agents/debugger.md +5 -0
  10. package/core/agents/ops.md +7 -2
  11. package/core/agents/planner.md +7 -0
  12. package/core/agents/rca.md +14 -3
  13. package/core/agents/reviewer.md +1 -0
  14. package/core/agents/writer.md +6 -0
  15. package/core/knowledge-base.md +22 -3
  16. package/core/methodology.md +30 -5
  17. package/core/shared/condensation.md +27 -17
  18. package/core/shared/context-verify.md +3 -1
  19. package/core/shared/cross-skill-dispatch.md +7 -3
  20. package/core/shared/draft-context-loading.md +3 -0
  21. package/core/shared/git-report-metadata.md +1 -0
  22. package/core/shared/graph-query.md +0 -2
  23. package/core/shared/graph-usage-report.md +1 -1
  24. package/core/shared/jira-sync.md +10 -6
  25. package/core/shared/okf-retrieval.md +1 -1
  26. package/core/shared/parallel-analysis.md +14 -7
  27. package/core/shared/pattern-learning.md +2 -0
  28. package/core/shared/template-contract.md +1 -1
  29. package/core/shared/vcs-commands.md +5 -2
  30. package/core/templates/ai-context.md +3 -2
  31. package/core/templates/ai-profile.md +7 -0
  32. package/core/templates/architecture.md +6 -2
  33. package/core/templates/discovery.md +1 -1
  34. package/core/templates/guardrails.md +9 -0
  35. package/core/templates/hld.md +5 -4
  36. package/core/templates/intake-questions.md +45 -0
  37. package/core/templates/jira.md +11 -2
  38. package/core/templates/lld.md +8 -5
  39. package/core/templates/okf/concept.md +0 -1
  40. package/core/templates/product.md +11 -2
  41. package/core/templates/rca.md +6 -0
  42. package/core/templates/service-index.md +1 -0
  43. package/core/templates/spec.md +15 -0
  44. package/core/templates/tech-stack.md +12 -0
  45. package/core/templates/workflow.md +20 -0
  46. package/integrations/agents/AGENTS.md +1272 -292
  47. package/integrations/copilot/.github/copilot-instructions.md +1272 -292
  48. package/package.json +1 -1
  49. package/scripts/fetch-memory-engine.sh +24 -6
  50. package/scripts/lib.sh +11 -4
  51. package/scripts/tools/_graph_queries.sh +10 -3
  52. package/scripts/tools/_lib.sh +33 -11
  53. package/scripts/tools/check-repo-size.sh +125 -0
  54. package/scripts/tools/check-skill-line-caps.sh +1 -2
  55. package/scripts/tools/check-template-noop.sh +0 -1
  56. package/scripts/tools/classify-files.sh +2 -2
  57. package/scripts/tools/cycle-detect.sh +16 -8
  58. package/scripts/tools/fix-whitespace.sh +3 -0
  59. package/scripts/tools/git-metadata.sh +6 -0
  60. package/scripts/tools/graph-arch.sh +1 -1
  61. package/scripts/tools/graph-callers.sh +6 -3
  62. package/scripts/tools/graph-impact.sh +4 -2
  63. package/scripts/tools/graph-init.sh +12 -8
  64. package/scripts/tools/graph-preflight.sh +3 -0
  65. package/scripts/tools/graph-query.sh +70 -7
  66. package/scripts/tools/graph-snapshot.sh +29 -5
  67. package/scripts/tools/hotspot-rank.sh +2 -1
  68. package/scripts/tools/install-smoke-test.sh +223 -0
  69. package/scripts/tools/mermaid-from-graph.sh +2 -1
  70. package/scripts/tools/migrate-track-frontmatter.sh +9 -10
  71. package/scripts/tools/okf-coverage-check.sh +4 -3
  72. package/scripts/tools/okf-fix-links.sh +18 -93
  73. package/scripts/tools/okf-plan-concepts.sh +5 -8
  74. package/scripts/tools/okf-render-views.sh +19 -14
  75. package/scripts/tools/okf-validate-quality.sh +13 -2
  76. package/scripts/tools/okf-validate.sh +13 -2
  77. package/scripts/tools/parse-git-log.sh +19 -1
  78. package/scripts/tools/resolve-tools.sh +24 -8
  79. package/scripts/tools/verify-citations.sh +1 -2
  80. package/scripts/tools/verify-doc-anchors.sh +13 -33
  81. package/scripts/tools/verify-graph-binary.sh +13 -3
  82. package/skills/GRAPH.md +21 -10
  83. package/skills/adr/SKILL.md +24 -6
  84. package/skills/assist-review/SKILL.md +2 -1
  85. package/skills/bughunt/SKILL.md +54 -11
  86. package/skills/bughunt/references/regression-tests.md +14 -4
  87. package/skills/change/SKILL.md +19 -9
  88. package/skills/coverage/SKILL.md +26 -10
  89. package/skills/debug/SKILL.md +12 -2
  90. package/skills/decompose/SKILL.md +39 -9
  91. package/skills/deep-review/SKILL.md +17 -4
  92. package/skills/deploy-checklist/SKILL.md +9 -2
  93. package/skills/documentation/SKILL.md +12 -1
  94. package/skills/draft/SKILL.md +40 -29
  95. package/skills/draft/context-files.md +8 -1
  96. package/skills/draft/quality-guide.md +10 -2
  97. package/skills/graph/SKILL.md +1 -1
  98. package/skills/impact/SKILL.md +3 -1
  99. package/skills/implement/SKILL.md +60 -18
  100. package/skills/incident-response/SKILL.md +5 -2
  101. package/skills/init/SKILL.md +102 -23
  102. package/skills/init/references/architecture-spec.md +31 -7
  103. package/skills/init/references/okf-emitter.md +3 -3
  104. package/skills/jira/SKILL.md +33 -13
  105. package/skills/jira/references/review.md +61 -19
  106. package/skills/learn/SKILL.md +29 -6
  107. package/skills/new-track/SKILL.md +73 -18
  108. package/skills/quick-review/SKILL.md +7 -2
  109. package/skills/revert/SKILL.md +11 -5
  110. package/skills/review/SKILL.md +123 -24
  111. package/skills/review/references/zero-setup-mode.md +117 -0
  112. package/skills/standup/SKILL.md +9 -1
  113. package/skills/status/SKILL.md +6 -6
  114. package/skills/tech-debt/SKILL.md +7 -1
  115. package/skills/testing-strategy/SKILL.md +10 -2
  116. package/skills/tour/SKILL.md +2 -1
  117. package/skills/upload/SKILL.md +3 -2
@@ -8,7 +8,9 @@
8
8
  # are rejected before the engine ever sees them).
9
9
  # --tool NAME --json '{...}' passthrough to any read-only engine tool
10
10
  # (get_code_snippet, search_graph, get_graph_schema,
11
- # trace_path, …). Destructive tools are rejected.
11
+ # trace_path, …). Destructive tools are rejected, and
12
+ # a `query` field in --json is scanned for write
13
+ # verbs exactly like --cypher.
12
14
  #
13
15
  # Dialect limits (engine v0.8.x — see _graph_queries.sh for the full list):
14
16
  # SAFE : fixed-length patterns, `=`, `<`, `STARTS WITH`, `NOT x STARTS WITH`,
@@ -46,7 +48,8 @@ Flags:
46
48
  --cypher STR Read-only openCypher query (write verbs CREATE/MERGE/DELETE/SET/
47
49
  REMOVE/DROP/DETACH are rejected). The {project} is injected.
48
50
  --tool NAME Engine tool to call (read-only allowlist). Combine with --json.
49
- --json STR JSON args for --tool (the project is injected if absent).
51
+ --json STR JSON args for --tool (the project is injected if absent). A
52
+ `query` field is write-verb checked like --cypher.
50
53
  --help Show this help.
51
54
 
52
55
  Dialect: avoid coalesce(), <>, NOT EXISTS, NOT(pattern), WITH-aggregation,
@@ -89,13 +92,74 @@ if [[ -n "$TOOL" ]]; then
89
92
  }
90
93
  fi
91
94
 
92
- # Reject write verbs in --cypher BEFORE the engine sees the query.
93
- if [[ -n "$CYPHER" ]]; then
94
- UPPER="$(printf '%s' "$CYPHER" | tr '[:lower:]' '[:upper:]')"
95
- if printf '%s' "$UPPER" | grep -Eqw 'CREATE|MERGE|DELETE|SET|REMOVE|DROP|DETACH'; then
95
+ # Drop the contents of every quoted span before scanning for write verbs below.
96
+ # A raw substring grep can't tell a CREATE/SET/DELETE clause from a symbol
97
+ # literally named 'create', 'set', 'delete', etc. — exactly the method names
98
+ # real codebases are full of so quoted spans must be blanked out first or
99
+ # every query about one of them gets wrongly rejected. Cypher quotes strings
100
+ # with either ' or " and identifiers with `, and the engine accepts all three,
101
+ # so all three must be handled. Escaped quotes (`\'`, produced by gq_escape in
102
+ # _graph_queries.sh) must not be read as closing a span early, and an
103
+ # unterminated span fails closed (rejected) rather than letting the rest of the
104
+ # query — write verbs included — go unscanned.
105
+ strip_quoted_spans() {
106
+ local s="$1" out="" i=0 len ch quote
107
+ len="${#s}"
108
+ while (( i < len )); do
109
+ ch="${s:i:1}"
110
+ if [[ "$ch" != "'" && "$ch" != '"' && "$ch" != '`' ]]; then
111
+ out+="$ch"
112
+ i=$((i + 1))
113
+ continue
114
+ fi
115
+ quote="$ch"
116
+ out+="$quote"
117
+ i=$((i + 1))
118
+ while :; do
119
+ (( i < len )) || return 1
120
+ ch="${s:i:1}"
121
+ if [[ "$ch" == '\' ]]; then
122
+ i=$((i + 2))
123
+ continue
124
+ fi
125
+ i=$((i + 1))
126
+ if [[ "$ch" == "$quote" ]]; then
127
+ out+="$quote"
128
+ break
129
+ fi
130
+ done
131
+ done
132
+ printf '%s' "$out"
133
+ }
134
+
135
+ # Reject write verbs BEFORE the engine sees the query.
136
+ reject_write_verbs() {
137
+ local stripped upper
138
+ stripped="$(strip_quoted_spans "$1")" || {
139
+ echo "ERROR: write verbs are not allowed (read-only passthrough)" >&2
140
+ exit 1
141
+ }
142
+ upper="$(printf '%s' "$stripped" | tr '[:lower:]' '[:upper:]')"
143
+ if printf '%s' "$upper" | grep -Eqw 'CREATE|MERGE|DELETE|SET|REMOVE|DROP|DETACH'; then
96
144
  echo "ERROR: write verbs are not allowed (read-only passthrough)" >&2
97
145
  exit 1
98
146
  fi
147
+ }
148
+
149
+ if [[ -n "$CYPHER" ]]; then
150
+ reject_write_verbs "$CYPHER"
151
+ fi
152
+
153
+ # --tool carries Cypher too: query_graph takes it in the payload's `query` field,
154
+ # so guarding only --cypher left the read-only contract fully bypassable via
155
+ # `--tool query_graph --json '{"query":"MATCH (n) DETACH DELETE n"}'`. Scan any
156
+ # `query` a tool payload carries, not just query_graph's, so a future
157
+ # query-bearing tool is covered by construction. jq-gated: without jq nothing can
158
+ # reach the engine anyway (graph_bootstrap fails first).
159
+ if [[ -n "$TOOL" ]] && command -v jq >/dev/null 2>&1; then
160
+ echo "$TOOL_JSON" | jq -e . >/dev/null 2>&1 || { echo "ERROR: --json is not valid JSON" >&2; exit 1; }
161
+ TOOL_QUERY="$(echo "$TOOL_JSON" | jq -r '.query // empty' 2>/dev/null || true)"
162
+ [[ -z "$TOOL_QUERY" ]] || reject_write_verbs "$TOOL_QUERY"
99
163
  fi
100
164
 
101
165
  unavailable() { echo '{"source":"unavailable"}'; exit 2; }
@@ -108,7 +172,6 @@ if [[ -n "$CYPHER" ]]; then
108
172
  printf '%s\n' "$RES"
109
173
  else
110
174
  # Inject the resolved project into the tool args unless the caller set one.
111
- echo "$TOOL_JSON" | jq -e . >/dev/null 2>&1 || { echo "ERROR: --json is not valid JSON" >&2; exit 1; }
112
175
  ARGS="$(echo "$TOOL_JSON" | jq -c --arg p "$PROJECT" 'if has("project") then . else . + {project:$p} end')"
113
176
  RES="$(memory_cli "$TOOL" "$ARGS" 2>/dev/null || true)"
114
177
  [[ -n "$RES" ]] || unavailable
@@ -73,17 +73,41 @@ command -v jq >/dev/null 2>&1 || { echo "jq required" >&2; exit 2; }
73
73
  PROJECT="$(memory_ensure_index "$REPO_ABS" || true)"
74
74
  [[ -n "$PROJECT" ]] || { echo "could not index repo — nothing written" >&2; exit 2; }
75
75
 
76
+ # ...then ALWAYS re-index. memory_ensure_index calls index_repository only when the
77
+ # project is ABSENT — correct for the graph-*.sh query wrappers, which must stay
78
+ # cheap — so on an already-indexed repo this tool used to write a gate marker with a
79
+ # fresh `generated_at` over a frozen index: a deleted symbol stayed resolvable, a new
80
+ # one never appeared, and nothing in the output said so. Refreshing is this tool's
81
+ # entire job. The engine indexes incrementally, so the repeat call is cheap.
82
+ REFRESHED="$(memory_index_bounded "$REPO_ABS" 2>/dev/null | jq -r '.project // empty' 2>/dev/null || true)"
83
+ [[ -n "$REFRESHED" ]] && PROJECT="$REFRESHED"
84
+
76
85
  mkdir -p "$OUT"
77
86
 
78
87
  # Prune any stale fat-snapshot artifacts from a prior (pre-engine-only) run so a
79
88
  # re-index migrates the repo to the thin model.
80
- rm -f "$OUT/architecture.json" "$OUT/hotspots.jsonl" \
81
- "$OUT/module-deps.mermaid" "$OUT/proto-map.mermaid" 2>/dev/null || true
82
- rm -rf "$OUT/okf" 2>/dev/null || true
89
+ #
90
+ # Gated on $OUT carrying positive evidence that Draft owns it. --out is
91
+ # caller-supplied and the mkdir -p above will happily create a typo'd path, so an
92
+ # ungated `rm -rf "$OUT/okf"` turns a mistyped flag into data loss. Evidence is
93
+ # the default location, a marker from a previous run, or a prior fat snapshot
94
+ # (which always carried architecture.json / hotspots.jsonl).
95
+ draft_owns_out_dir() {
96
+ [[ "$OUT" == "$REPO_ABS/draft/graph" ]] && return 0
97
+ [[ -f "$OUT/schema.yaml" ]] && return 0
98
+ [[ -f "$OUT/architecture.json" || -f "$OUT/hotspots.jsonl" ]] && return 0
99
+ return 1
100
+ }
101
+
102
+ if draft_owns_out_dir; then
103
+ rm -f "$OUT/architecture.json" "$OUT/hotspots.jsonl" \
104
+ "$OUT/module-deps.mermaid" "$OUT/proto-map.mermaid" 2>/dev/null || true
105
+ rm -rf "$OUT/okf" 2>/dev/null || true
106
+ fi
83
107
 
84
108
  # schema.yaml — provenance + gate. Counts are point-of-index provenance only;
85
109
  # the live engine is authoritative.
86
- STATUS_JSON="$(memory_cli index_status "{\"project\":\"$PROJECT\"}" || echo '{}')"
110
+ STATUS_JSON="$(memory_cli index_status "$(jq -n --arg p "$PROJECT" '{project:$p}')" || echo '{}')"
87
111
  # Tolerate field-name variation AND non-JSON output across engine versions;
88
112
  # counts are provenance only and must never abort the gate-marker write.
89
113
  NODES="$(echo "$STATUS_JSON" | jq -r '.nodes // .node_count // .total_nodes // 0' 2>/dev/null || echo 0)"
@@ -94,7 +118,7 @@ VER="$("$MEMORY_BIN" --version 2>/dev/null | awk '{print $NF}' || echo unknown)"
94
118
  # incrementally (content-based, git-aware), so re-indexing only touches changed
95
119
  # files. detect_changes reports that working-tree delta — recorded as provenance
96
120
  # and echoed so a refresh shows what moved. Best-effort: never aborts the write.
97
- CHANGES_JSON="$(memory_cli detect_changes "{\"project\":\"$PROJECT\"}" 2>/dev/null || echo '{}')"
121
+ CHANGES_JSON="$(memory_cli detect_changes "$(jq -n --arg p "$PROJECT" '{project:$p}')" 2>/dev/null || echo '{}')"
98
122
  echo "$CHANGES_JSON" | jq -e . >/dev/null 2>&1 || CHANGES_JSON='{}'
99
123
  CHANGED_FILES="$(echo "$CHANGES_JSON" | jq -r '.changed_count // (.changed_files | length?) // 0' 2>/dev/null || echo 0)"
100
124
  IMPACTED="$(echo "$CHANGES_JSON" | jq -r '(.impacted_symbols | length?) // 0' 2>/dev/null || echo 0)"
@@ -65,7 +65,8 @@ unavailable() { echo '{"hotspots":[],"source":"unavailable"}'; exit 2; }
65
65
 
66
66
  graph_bootstrap "$REPO" || unavailable
67
67
 
68
- ARCH_JSON="$(memory_cli get_architecture "{\"project\":\"$PROJECT\",\"aspects\":[\"hotspots\"]}" || true)"
68
+ ARCH_JSON="$(memory_cli get_architecture \
69
+ "$(jq -n --arg p "$PROJECT" '{project:$p, aspects:["hotspots"]}')" || true)"
69
70
  [[ -n "$ARCH_JSON" ]] || unavailable
70
71
  echo "$ARCH_JSON" | jq -e . >/dev/null 2>&1 || unavailable
71
72
 
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env bash
2
+ # install-smoke-test.sh
3
+ #
4
+ # Exercises the real first-install path on a throwaway clone.
5
+ #
6
+ # The v2.8.3 install hang was invisible to `make test` because every suite runs
7
+ # against the working tree, and the working tree is never what a new user gets.
8
+ # A new user gets `git clone --depth 1` followed by manifest discovery and a
9
+ # writer pass over an empty HOME. This reproduces exactly that, in that order,
10
+ # so a regression in any of those steps fails a build instead of a user.
11
+ #
12
+ # Requires: git, node 18+. Does NOT require the `claude` CLI and never touches
13
+ # the network — the graph-engine fetch is stubbed out via --no-graph.
14
+ #
15
+ # Usage:
16
+ # scripts/tools/install-smoke-test.sh # clone this repo, run all checks
17
+ # scripts/tools/install-smoke-test.sh --repo <path> # smoke-test another checkout
18
+ # scripts/tools/install-smoke-test.sh --keep # leave the sandbox for inspection
19
+ # scripts/tools/install-smoke-test.sh --json
20
+ #
21
+ # Exit codes:
22
+ # 0 install path is healthy
23
+ # 1 a check failed
24
+ # 2 usage / runtime error
25
+
26
+ set -euo pipefail
27
+
28
+ usage() {
29
+ cat <<'EOF'
30
+ install-smoke-test.sh — clean-clone install smoke test
31
+
32
+ Options:
33
+ --repo <path> Repository to clone (default: this repo)
34
+ --keep Do not delete the sandbox on exit
35
+ --json Emit JSON instead of human-readable output
36
+ --help, -h Show this message
37
+ EOF
38
+ }
39
+
40
+ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
41
+ usage
42
+ exit 0
43
+ fi
44
+
45
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
46
+ # shellcheck source=_lib.sh
47
+ source "$SCRIPT_DIR/_lib.sh"
48
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
49
+ KEEP=0
50
+ EMIT_JSON=0
51
+
52
+ while [[ $# -gt 0 ]]; do
53
+ case "$1" in
54
+ --repo)
55
+ [[ -n "${2:-}" ]] || { echo "install-smoke-test: --repo requires a value" >&2; exit 2; }
56
+ REPO_ROOT="$2"; shift 2 ;;
57
+ --keep) KEEP=1; shift ;;
58
+ --json) EMIT_JSON=1; shift ;;
59
+ --help|-h) usage; exit 0 ;;
60
+ *) echo "install-smoke-test: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
61
+ esac
62
+ done
63
+
64
+ [[ -d "$REPO_ROOT" ]] || { echo "install-smoke-test: no such directory: $REPO_ROOT" >&2; exit 2; }
65
+ command -v git >/dev/null || { echo "install-smoke-test: git not found" >&2; exit 2; }
66
+ command -v node >/dev/null || { echo "install-smoke-test: node not found" >&2; exit 2; }
67
+
68
+ SANDBOX="$(mktemp -d "${TMPDIR:-/tmp}/draft-install-smoke.XXXXXX")"
69
+ cleanup() { [[ "$KEEP" -eq 1 ]] || rm -rf "$SANDBOX"; }
70
+ trap cleanup EXIT
71
+
72
+ CLONE="$SANDBOX/clone"
73
+ FAKE_HOME="$SANDBOX/home"
74
+ PROJECT="$SANDBOX/project"
75
+ mkdir -p "$FAKE_HOME" "$PROJECT"
76
+
77
+ FAILURES=()
78
+ RESULTS=()
79
+
80
+ # Collapse a file to one truncated line. Done in-shell rather than with
81
+ # `| cut -c1-200` because an early-closing reader plus `pipefail` turns a
82
+ # harmless truncation into a SIGPIPE failure of the whole script.
83
+ oneline() {
84
+ local text
85
+ text="$(tr '\n' ' ' < "$1")"
86
+ printf '%s' "${text:0:200}"
87
+ }
88
+
89
+ lastlines() {
90
+ local text
91
+ text="$(tail -n "$2" "$1" | tr '\n' ' ')"
92
+ printf '%s' "${text:0:200}"
93
+ }
94
+
95
+ record() {
96
+ local name="$1" ok="$2" detail="${3:-}"
97
+ RESULTS+=("$name|$ok|$detail")
98
+ if [[ "$ok" == "true" ]]; then
99
+ [[ "$EMIT_JSON" -eq 1 ]] || echo " PASS: $name${detail:+ — $detail}"
100
+ else
101
+ [[ "$EMIT_JSON" -eq 1 ]] || echo " FAIL: $name${detail:+ — $detail}"
102
+ FAILURES+=("$name")
103
+ fi
104
+ }
105
+
106
+ [[ "$EMIT_JSON" -eq 1 ]] || echo "=== Install smoke test (sandbox: $SANDBOX) ==="
107
+
108
+ # 1. Shallow clone — the literal first step of `plugin marketplace add`.
109
+ # file:// forces the real transfer path; a plain local path would hardlink
110
+ # and hide size problems.
111
+ if git clone --depth 1 --quiet "file://$REPO_ROOT" "$CLONE" 2>"$SANDBOX/clone.err"; then
112
+ record "shallow clone succeeds" "true"
113
+ else
114
+ record "shallow clone succeeds" "false" "$(oneline "$SANDBOX/clone.err")"
115
+ fi
116
+
117
+ if [[ -d "$CLONE" ]]; then
118
+ clone_mb="$(du -sm "$CLONE" | cut -f1)"
119
+ record "clone size recorded" "true" "${clone_mb} MB on disk"
120
+
121
+ # 2. Manifest discovery — install fails here if plugin.json is malformed or
122
+ # points at a skills directory the clone does not contain.
123
+ if node -e '
124
+ const fs = require("fs"), path = require("path");
125
+ const root = process.argv[1];
126
+ const plugin = JSON.parse(fs.readFileSync(path.join(root, ".claude-plugin/plugin.json"), "utf8"));
127
+ const market = JSON.parse(fs.readFileSync(path.join(root, ".claude-plugin/marketplace.json"), "utf8"));
128
+ if (!plugin.name) throw new Error("plugin.json has no name");
129
+ if (!Array.isArray(market.plugins) || market.plugins.length === 0) throw new Error("marketplace.json lists no plugins");
130
+ const skills = typeof plugin.skills === "string" ? plugin.skills : "./skills/";
131
+ const dir = path.join(root, skills);
132
+ if (!fs.existsSync(dir)) throw new Error("skills dir missing from clone: " + skills);
133
+ if (fs.readdirSync(dir).length === 0) throw new Error("skills dir is empty in clone");
134
+ ' "$CLONE" 2>"$SANDBOX/manifest.err"; then
135
+ record "plugin manifests resolve in the clone" "true"
136
+ else
137
+ record "plugin manifests resolve in the clone" "false" "$(oneline "$SANDBOX/manifest.err")"
138
+ fi
139
+
140
+ # 3. Every shipped skill must carry name+description frontmatter, or the
141
+ # host silently drops it at discovery time.
142
+ bad_skills=0
143
+ skill_count=0
144
+ while IFS= read -r skill; do
145
+ skill_count=$((skill_count + 1))
146
+ head -n 1 "$skill" | grep -q '^---$' || { bad_skills=$((bad_skills + 1)); continue; }
147
+ fm="$(sed -n '2,/^---$/p' "$skill")"
148
+ grep -q '^name:' <<< "$fm" || { bad_skills=$((bad_skills + 1)); continue; }
149
+ grep -q '^description:' <<< "$fm" || bad_skills=$((bad_skills + 1))
150
+ done < <(find "$CLONE/skills" -name SKILL.md -type f 2>/dev/null | sort)
151
+
152
+ if [[ "$skill_count" -eq 0 ]]; then
153
+ record "shipped skills discoverable" "false" "no SKILL.md files in clone"
154
+ else
155
+ record "shipped skills discoverable" \
156
+ "$([[ "$bad_skills" -eq 0 ]] && echo true || echo false)" \
157
+ "$skill_count skills, $bad_skills malformed"
158
+ fi
159
+
160
+ # 4. Writer pass against an empty HOME, per host. --dry-run plans every
161
+ # write without touching disk; --no-graph keeps it offline.
162
+ hosts="$(cd "$CLONE" && node -e '
163
+ const { hosts } = require("./cli/src/hosts");
164
+ console.log(hosts.map((h) => h.id).join(" "));
165
+ ' 2>/dev/null || true)"
166
+
167
+ if [[ -z "$hosts" ]]; then
168
+ record "host list enumerable" "false" "cli/src/hosts did not load"
169
+ else
170
+ record "host list enumerable" "true" "$hosts"
171
+ for host in $hosts; do
172
+ if (cd "$PROJECT" && HOME="$FAKE_HOME" node "$CLONE/cli/bin/draft.js" \
173
+ install "$host" --dry-run --no-graph >"$SANDBOX/$host.log" 2>&1); then
174
+ record "install --dry-run: $host" "true"
175
+ else
176
+ record "install --dry-run: $host" "false" "$(lastlines "$SANDBOX/$host.log" 3)"
177
+ fi
178
+ done
179
+ # A dry run that writes is worse than one that fails — it means the real
180
+ # installer's plan/apply split leaks. Both destinations must be checked:
181
+ # codex and opencode default to project scope and write AGENTS.md into the
182
+ # cwd, so a HOME-only assertion misses the likeliest leak.
183
+ record "dry run left HOME untouched" \
184
+ "$([[ -z "$(ls -A "$FAKE_HOME")" ]] && echo true || echo false)" \
185
+ "$(ls -A "$FAKE_HOME" | tr '\n' ' ')"
186
+ record "dry run left the project dir untouched" \
187
+ "$([[ -z "$(ls -A "$PROJECT")" ]] && echo true || echo false)" \
188
+ "$(ls -A "$PROJECT" | tr '\n' ' ')"
189
+ fi
190
+
191
+ # 5. Engine fetcher is present and self-documenting (never invoked here —
192
+ # it downloads).
193
+ if [[ -x "$CLONE/scripts/fetch-memory-engine.sh" ]]; then
194
+ record "graph engine fetcher shipped and executable" "true"
195
+ else
196
+ record "graph engine fetcher shipped and executable" "false" "scripts/fetch-memory-engine.sh missing or not executable"
197
+ fi
198
+ fi
199
+
200
+ if [[ "$EMIT_JSON" -eq 1 ]]; then
201
+ printf '{"sandbox":"%s","failures":%d,"checks":[' "$SANDBOX" "${#FAILURES[@]}"
202
+ sep=""
203
+ for r in "${RESULTS[@]}"; do
204
+ IFS='|' read -r name ok detail <<< "$r"
205
+ # json_escape (from _lib.sh) also handles backslashes and control chars —
206
+ # a Windows path or a stack trace in a captured stderr line used to emit
207
+ # invalid JSON.
208
+ printf '%s{"name":"%s","ok":%s,"detail":"%s"}' "$sep" "$(json_escape "$name")" "$ok" "$(json_escape "$detail")"
209
+ sep=","
210
+ done
211
+ printf ']}\n'
212
+ else
213
+ echo ""
214
+ if [[ "${#FAILURES[@]}" -eq 0 ]]; then
215
+ echo "Install path healthy — ${#RESULTS[@]} checks passed."
216
+ else
217
+ echo "Install path BROKEN — ${#FAILURES[@]} of ${#RESULTS[@]} checks failed:"
218
+ printf ' - %s\n' "${FAILURES[@]}"
219
+ fi
220
+ fi
221
+
222
+ [[ "${#FAILURES[@]}" -eq 0 ]] || exit 1
223
+ exit 0
@@ -92,7 +92,8 @@ render_co_change() {
92
92
  }
93
93
 
94
94
  render_proto_map() {
95
- local res; res="$(memory_cli get_architecture "{\"project\":\"$PROJECT\",\"aspects\":[\"routes\"]}" || echo '{}')"
95
+ local res; res="$(memory_cli get_architecture \
96
+ "$(jq -n --arg p "$PROJECT" '{project:$p, aspects:["routes"]}')" || echo '{}')"
96
97
  local edges; edges="$(echo "${res:-{\}}" | jq -r '(.routes // [])[] | " \"" + ((.method // "")|tostring) + " " + ((.path // "")|tostring) + "\" --> \"" + ((.handler // "?")|tostring) + "\""' 2>/dev/null || true)"
97
98
  if [[ -z "$edges" ]]; then return 1; fi
98
99
  printf '```mermaid\nflowchart LR\n%s\n```\n' "$edges"
@@ -69,15 +69,10 @@ EPHEMERAL_KEYS=(
69
69
  "scope_excludes"
70
70
  )
71
71
 
72
- # Stable keys that survive in markdown frontmatter.
73
- STABLE_KEYS=(
74
- "project"
75
- "module"
76
- "track_id"
77
- "generated_by"
78
- "generated_at"
79
- "links"
80
- )
72
+ # Keys that survive in markdown frontmatter: project, module, track_id,
73
+ # generated_by, generated_at, links. Recorded here for the reader only — the
74
+ # rewrite is driven entirely by EPHEMERAL_KEYS above (a drop-list, not an
75
+ # allow-list), so there is nothing to enumerate in code.
81
76
 
82
77
  # Strip ephemeral blocks from a markdown file's YAML frontmatter.
83
78
  # Approach: read the file; rewrite the frontmatter section so any line whose
@@ -225,7 +220,11 @@ EOF
225
220
  (( BACKUP )) && cp "$path" "$path.bak"
226
221
  # %s\n restores the EOF newline stripped by command substitution
227
222
  # (same pattern as fix-whitespace.sh).
228
- local _tmp; _tmp="$(mktemp "${path}.XXXXXX")"; printf '%s\n' "$after" > "$_tmp" && mv -f "$_tmp" "$path"
223
+ local _tmp; _tmp="$(mktemp "${path}.XXXXXX")"
224
+ printf '%s\n' "$after" > "$_tmp"
225
+ # mktemp creates 0600 and `mv` swaps the inode — carry the mode across.
226
+ apply_dest_mode "$_tmp" "$path"
227
+ mv -f "$_tmp" "$path"
229
228
  printf 'migrate: stripped ephemeral frontmatter from %s\n' "$path"
230
229
  fi
231
230
  fi
@@ -90,7 +90,7 @@ DEFERRED=() # concept_id\treason
90
90
  EXPECTED_TOTAL=0; REQUIRED=0
91
91
 
92
92
  # Iterate expected entries.
93
- while IFS=$'\t' read -r cid required reason ftype fanin; do
93
+ while IFS=$'\t' read -r cid required reason _ftype fanin; do
94
94
  [[ -z "$cid" ]] && continue
95
95
  EXPECTED_TOTAL=$((EXPECTED_TOTAL + 1))
96
96
  if [[ "$required" == "true" ]]; then
@@ -141,8 +141,8 @@ write_coverage_page() {
141
141
  echo ""
142
142
  echo "| Component | Wiki page | Status | Fan-in |"
143
143
  echo "|-----------|-----------|--------|--------|"
144
- local cid status fanin link
145
- while IFS=$'\t' read -r cid required reason ftype fanin; do
144
+ local cid fanin link
145
+ while IFS=$'\t' read -r cid required reason _ftype fanin; do
146
146
  [[ -z "$cid" ]] && continue
147
147
  if [[ "$required" == "true" ]]; then
148
148
  if [[ -f "$BUNDLE/$cid" ]]; then
@@ -167,6 +167,7 @@ write_coverage_page() {
167
167
  fi
168
168
  done < <(jq -r '.expected[] | [.concept_id, (.required|tostring), (.reason_if_deferred // "-"), (.type // "Module"), (.fan_in // 0 | tostring)] | @tsv' "$PLAN")
169
169
  } > "$tmp"
170
+ apply_dest_mode "$tmp" "$out" # mktemp is 0600; mv would strip the dest's mode
170
171
  mv "$tmp" "$out"
171
172
  }
172
173
  [[ $WRITE_PAGE -eq 1 ]] && write_coverage_page
@@ -20,7 +20,6 @@ DRAFT=""
20
20
  FILE=""
21
21
  WIKI=""
22
22
  DO_FIX=0
23
- DO_CHECK=1
24
23
 
25
24
  usage() {
26
25
  cat <<'EOF'
@@ -35,7 +34,7 @@ Flags:
35
34
  --file FILE single markdown file to rewrite/check
36
35
  --wiki DIR wiki bundle (required with --file for basename map)
37
36
  --fix rewrite broken/ambiguous links in place
38
- --check report broken links (default on)
37
+ --check accepted for symmetry with --fix; checking is unconditional
39
38
  --help
40
39
 
41
40
  Exit: 0 clean, 1 broken links remain, 2 bad invocation.
@@ -48,7 +47,7 @@ while [[ $# -gt 0 ]]; do
48
47
  --file) FILE="${2:?--file requires a value}"; shift 2;;
49
48
  --wiki) WIKI="${2:?--wiki requires a value}"; shift 2;;
50
49
  --fix) DO_FIX=1; shift;;
51
- --check) DO_CHECK=1; shift;;
50
+ --check) shift;; # checking always runs; accepted so callers can be explicit
52
51
  --help|-h) usage; exit 0;;
53
52
  -*) echo "Unknown flag: $1" >&2; usage >&2; exit 2;;
54
53
  *) echo "Unexpected arg: $1" >&2; exit 2;;
@@ -65,83 +64,17 @@ fi
65
64
  [[ -n "$FILE" && -f "$FILE" ]] || { echo "ERROR: --file or --draft/architecture.md required" >&2; exit 2; }
66
65
  [[ -n "$WIKI" && -d "$WIKI" ]] || { echo "ERROR: wiki dir required" >&2; exit 2; }
67
66
 
68
- # Build basename → list of wiki-relative paths (relative to draft root as wiki/...)
69
- declare -A BASENAME_MAP_MULTI=()
70
- while IFS= read -r -d '' p; do
71
- rel="wiki/${p#"$WIKI"/}"
72
- base="$(basename "$p")"
73
- if [[ -n "${BASENAME_MAP_MULTI[$base]:-}" ]]; then
74
- BASENAME_MAP_MULTI[$base]="${BASENAME_MAP_MULTI[$base]}|$rel"
75
- else
76
- BASENAME_MAP_MULTI[$base]="$rel"
77
- fi
78
- done < <(find "$WIKI" -type f -name '*.md' -print0)
79
-
80
- pick_target() {
81
- local base="$1" label="$2"
82
- local cands="${BASENAME_MAP_MULTI[$base]:-}"
83
- [[ -n "$cands" ]] || { echo ""; return; }
84
- if [[ "$cands" != *"|"* ]]; then
85
- printf '%s' "$cands"
86
- return
87
- fi
88
- local lab; lab="$(printf '%s' "$label" | tr '[:upper:]' '[:lower:]')"
89
- IFS='|' read -ra arr <<< "$cands"
90
- local pref
91
- if [[ "$lab" == *product* || "$lab" == *feature* || "$lab" == *user*guide* || "$lab" == *install* ]]; then
92
- for pref in "${arr[@]}"; do [[ "$pref" == *"/features/"* ]] && { printf '%s' "$pref"; return; }; done
93
- fi
94
- if [[ "$lab" == *build* || "$lab" == *runbook* || "$lab" == *ops* || "$lab" == *source* ]]; then
95
- for pref in "${arr[@]}"; do [[ "$pref" == *"/overview/"* ]] && { printf '%s' "$pref"; return; }; done
96
- fi
97
- for order in /systems/ /features/ /overview/ /reference/ /entrypoints/; do
98
- for pref in "${arr[@]}"; do
99
- [[ "$pref" == *"$order"* ]] && { printf '%s' "$pref"; return; }
100
- done
101
- done
102
- printf '%s' "${arr[0]}"
67
+ command -v python3 >/dev/null 2>&1 || {
68
+ echo "ERROR: python3 is required by ${0##*/} (link rewriting and validation)" >&2
69
+ exit 2
103
70
  }
104
71
 
105
- # Collect heading slugs from a file for anchor checks
106
- build_slugs() {
107
- local f="$1"
108
- declare -gA SLUGS=()
109
- local counts=()
110
- # shellcheck disable=SC2034
111
- while IFS= read -r line; do
112
- [[ "$line" =~ ^#{1,6}[[:space:]]+(.*)$ ]] || continue
113
- local raw="${BASH_REMATCH[1]}"
114
- local s; s="$(gfm_slug "$raw")"
115
- local n="${SLUGS[_count_$s]:-0}"
116
- SLUGS[_count_$s]=$((n+1))
117
- if [[ $n -eq 0 ]]; then
118
- SLUGS[$s]=1
119
- else
120
- SLUGS["${s}-$n"]=1
121
- fi
122
- done < "$f"
123
- }
72
+ # Link resolution (basename disambiguation, section preference) lives in the
73
+ # Python pass inside fix_file — it builds its own basename map from the same
74
+ # wiki tree. The former bash twin, pick_target(), had no callers.
124
75
 
125
- resolve_ok() {
126
- local src="$1" href="$2"
127
- [[ "$href" =~ ^https?:// || "$href" =~ ^mailto: ]] && return 0
128
- if [[ "$href" == \#* ]]; then
129
- local a="${href#\#}"
130
- [[ -n "${SLUGS[$a]:-}" ]] && return 0
131
- return 1
132
- fi
133
- local path="${href%%\#*}"
134
- [[ -z "$path" ]] && return 0
135
- local dir; dir="$(cd "$(dirname "$src")" && pwd)"
136
- local draft_root=""
137
- if [[ -n "$DRAFT" ]]; then
138
- draft_root="$(cd "$DRAFT" && pwd)"
139
- else
140
- draft_root="$(cd "$(dirname "$src")" && pwd)"
141
- fi
142
- [[ -e "$dir/$path" || -e "$draft_root/$path" || -e "$path" ]] && return 0
143
- return 1
144
- }
76
+ # Anchor and link validation lives in the Python pass at the bottom of this
77
+ # file — it is the only checker, and it computes its own slug table.
145
78
 
146
79
  fix_file() {
147
80
  local src="$1"
@@ -239,11 +172,8 @@ def repl(m):
239
172
  new = re.sub(r"\[([^\]]*)\]\(([^)]+)\)", repl, text)
240
173
  out.write_text(new if new.endswith("\n") else new + "\n", encoding="utf-8")
241
174
  PY
242
- if [[ $DO_FIX -eq 1 ]]; then
243
- mv "$tmp" "$src"
244
- else
245
- rm -f "$tmp"
246
- fi
175
+ apply_dest_mode "$tmp" "$src" # mktemp is 0600; mv would strip the dest's mode
176
+ mv "$tmp" "$src"
247
177
  }
248
178
 
249
179
  if [[ $DO_FIX -eq 1 ]]; then
@@ -254,16 +184,7 @@ if [[ $DO_FIX -eq 1 ]]; then
254
184
  fi
255
185
  fi
256
186
 
257
- # Check
258
- build_slugs "$FILE"
259
- broken=0
260
- total=0
261
- while IFS= read -r line; do
262
- # crude extract — enough for validation
263
- :
264
- done < /dev/null
265
-
266
- # Python check for reliability
187
+ # Check — the single source of link/anchor truth
267
188
  broken_out="$(python3 - "$FILE" "${DRAFT:-$(dirname "$FILE")}" "$WIKI" <<'PY'
268
189
  import sys, re
269
190
  from pathlib import Path
@@ -304,7 +225,11 @@ for m in re.finditer(r"\[([^\]]*)\]\(([^)]+)\)", text):
304
225
  path = href.split("#",1)[0]
305
226
  if not path:
306
227
  ok += 1; continue
307
- cands = [src.parent/path, draft/path, Path(path)]
228
+ # Resolve only against the two bases a link inside this document can mean:
229
+ # the document's own directory and the draft/ root. Never against the
230
+ # process CWD — a same-named file in the caller's cwd used to make a
231
+ # genuinely dangling link report clean (exit flipped 1 -> 0).
232
+ cands = [src.parent/path, draft/path]
308
233
  if any(c.exists() for c in cands):
309
234
  ok += 1
310
235
  else:
@@ -175,7 +175,7 @@ plan_from_cargo_workspace() {
175
175
  [[ -f "$cargo" ]] || return 1
176
176
  # Only treat as workspace if [workspace] present with members.
177
177
  grep -qE '^\[workspace\]' "$cargo" || return 1
178
- grep -qE 'members\s*=' "$cargo" || return 1
178
+ grep -qE 'members[[:space:]]*=' "$cargo" || return 1
179
179
 
180
180
  local members=()
181
181
  # Extract quoted paths inside the members = [ ... ] array (possibly multi-line).
@@ -424,13 +424,10 @@ else
424
424
  plan_from_cargo_workspace || true
425
425
  plan_from_npm_workspaces || true
426
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.
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.
434
431
  if [[ ${#E_ID[@]} -eq 0 ]]; then
435
432
  plan_from_heuristic
436
433
  DISCOVERY_META+=("heuristic")