@drafthq/draft 3.6.0 → 3.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +24 -9
  68. package/scripts/tools/install-smoke-test.sh +223 -0
  69. package/scripts/tools/mermaid-from-graph.sh +28 -11
  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,13 +65,26 @@ 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
 
72
73
  # Pre-computed complexity/cognitive/is_entry_point per symbol (merged onto fan-in).
73
- PROPS_JSON="$(gq_run "$PROJECT" "$(gq_q_node_props)" || echo '{"rows":[]}')"
74
- echo "$PROPS_JSON" | jq -e . >/dev/null 2>&1 || PROPS_JSON='{"rows":[]}'
74
+ #
75
+ # The hotspots themselves come from get_architecture above, which is fail-loud,
76
+ # so a failure here costs enrichment rather than the whole result — worth keeping
77
+ # the ranking for. But it must not pass silently: substituting an empty row set
78
+ # gave every symbol complexity 0 and cognitive 0, folded those zeros into `score`,
79
+ # and still reported source:"memory-graph", so a fan-in-only ranking was
80
+ # indistinguishable from a fully measured one. Record the outcome instead, and
81
+ # omit the fields rather than emitting measurements that were never taken.
82
+ if PROPS_JSON="$(gq_run "$PROJECT" "$(gq_q_node_props)")"; then
83
+ ENRICHMENT="ok"
84
+ else
85
+ PROPS_JSON='{"rows":[]}'
86
+ ENRICHMENT="unavailable"
87
+ fi
75
88
 
76
89
  # Merge via temp files (the props row set can exceed argv limits on large repos).
77
90
  TMP_ARCH="$(mktemp)"; TMP_PROPS="$(mktemp)"
@@ -79,7 +92,8 @@ trap 'rm -f "$TMP_ARCH" "$TMP_PROPS"' EXIT
79
92
  printf '%s' "$ARCH_JSON" > "$TMP_ARCH"
80
93
  printf '%s' "$PROPS_JSON" > "$TMP_PROPS"
81
94
 
82
- jq -n --slurpfile arch "$TMP_ARCH" --slurpfile props "$TMP_PROPS" --argjson top "$TOP" '
95
+ jq -n --slurpfile arch "$TMP_ARCH" --slurpfile props "$TMP_PROPS" --argjson top "$TOP" \
96
+ --arg enrichment "$ENRICHMENT" '
83
97
  ((($props[0].rows) // []) | map(select(.[0] != null))) as $prows
84
98
  | (reduce $prows[] as $r ({};
85
99
  .[$r[0]] = {c:((($r[1]) // "0") | tonumber? // 0),
@@ -88,10 +102,11 @@ jq -n --slurpfile arch "$TMP_ARCH" --slurpfile props "$TMP_PROPS" --argjson top
88
102
  | [ (($arch[0].hotspots) // [])[]
89
103
  | (.qualified_name) as $q
90
104
  | ($pmap[$q] // {c:0, cog:0, ep:false}) as $p
91
- | {id:$q, name:.name, fanIn:(.fan_in // 0),
92
- complexity:$p.c, cognitive:$p.cog,
93
- score:((.fan_in // 0) + $p.c + $p.cog),
94
- isEntryPoint:$p.ep} ]
105
+ | ({id:$q, name:.name, fanIn:(.fan_in // 0),
106
+ score:((.fan_in // 0) + $p.c + $p.cog)}
107
+ + (if $enrichment == "ok"
108
+ then {complexity:$p.c, cognitive:$p.cog, isEntryPoint:$p.ep}
109
+ else {} end)) ]
95
110
  | sort_by(-.score)
96
111
  | (if $top > 0 then .[0:$top] else . end) as $h
97
- | {hotspots:$h, source:"memory-graph"}'
112
+ | {hotspots:$h, enrichment:$enrichment, source:"memory-graph"}'
@@ -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
@@ -57,25 +57,39 @@ if [[ ! -d "$REPO" ]]; then
57
57
  exit 1
58
58
  fi
59
59
 
60
+ # $1: "unavailable" (engine/query failed) or "empty" (query ran, no such edges).
61
+ # Swallowing a failed query with `{}` used to make both look identical, so an
62
+ # engine failure and a repo with genuinely no cross-file imports produced the
63
+ # same "graph not built" diagram — the reader could not tell which they had.
60
64
  stub() {
61
- cat <<'EOF'
65
+ if [[ "${1:-unavailable}" == "empty" ]]; then
66
+ cat <<'EOF'
67
+ ```mermaid
68
+ %% graph is built, but holds no edges of this kind — nothing to draw
69
+ flowchart LR
70
+ empty["no edges"]
71
+ ```
72
+ EOF
73
+ else
74
+ cat <<'EOF'
62
75
  ```mermaid
63
76
  %% graph data unavailable — index the repo with the graph engine first
64
77
  flowchart LR
65
78
  empty["graph not built"]
66
79
  ```
67
80
  EOF
81
+ fi
68
82
  exit 2
69
83
  }
70
84
 
71
- graph_bootstrap "$REPO" || stub
85
+ graph_bootstrap "$REPO" || stub unavailable
72
86
 
73
87
  # module-deps: real IMPORTS edges (the auto-derived dependency graph). Self-imports
74
88
  # (src == dst) are dropped so the diagram is a true cross-file graph. Capped at 40
75
89
  # edges for readability.
76
90
  render_module_deps() {
77
- local res; res="$(gq_run "$PROJECT" "$(gq_q_imports)" || echo '{}')"
78
- local edges; edges="$(echo "${res:-{\}}" | jq -r '
91
+ local res; res="$(gq_run "$PROJECT" "$(gq_q_imports)")" || return 2
92
+ local edges; edges="$(echo "$res" | jq -r '
79
93
  [ (.rows // [])[] | {s:(.[0]|tostring), d:(.[1]|tostring)}
80
94
  | select(.s != "" and .d != "" and .s != .d) ]
81
95
  | unique | .[0:40][] | " \"" + .s + "\" --> \"" + .d + "\""' 2>/dev/null || true)"
@@ -85,22 +99,25 @@ render_module_deps() {
85
99
 
86
100
  # co-change: FILE_CHANGES_WITH coupling (the prior module-deps proxy).
87
101
  render_co_change() {
88
- local res; res="$(gq_run "$PROJECT" "$(gq_q_co_change)" || echo '{}')"
89
- local edges; edges="$(echo "${res:-{\}}" | jq -r '(.rows // [])[] | " \"" + (.[0]|tostring) + "\" --> \"" + (.[1]|tostring) + "\""' 2>/dev/null || true)"
102
+ local res; res="$(gq_run "$PROJECT" "$(gq_q_co_change)")" || return 2
103
+ local edges; edges="$(echo "$res" | jq -r '(.rows // [])[] | " \"" + (.[0]|tostring) + "\" --> \"" + (.[1]|tostring) + "\""' 2>/dev/null || true)"
90
104
  if [[ -z "$edges" ]]; then return 1; fi
91
105
  printf '```mermaid\nflowchart LR\n%s\n```\n' "$edges"
92
106
  }
93
107
 
94
108
  render_proto_map() {
95
- local res; res="$(memory_cli get_architecture "{\"project\":\"$PROJECT\",\"aspects\":[\"routes\"]}" || echo '{}')"
96
- local edges; edges="$(echo "${res:-{\}}" | jq -r '(.routes // [])[] | " \"" + ((.method // "")|tostring) + " " + ((.path // "")|tostring) + "\" --> \"" + ((.handler // "?")|tostring) + "\""' 2>/dev/null || true)"
109
+ local res; res="$(memory_cli get_architecture \
110
+ "$(jq -n --arg p "$PROJECT" '{project:$p, aspects:["routes"]}')" || true)"
111
+ [[ -n "$res" ]] && echo "$res" | jq -e . >/dev/null 2>&1 || return 2
112
+ local edges; edges="$(echo "$res" | jq -r '(.routes // [])[] | " \"" + ((.method // "")|tostring) + " " + ((.path // "")|tostring) + "\" --> \"" + ((.handler // "?")|tostring) + "\""' 2>/dev/null || true)"
97
113
  if [[ -z "$edges" ]]; then return 1; fi
98
114
  printf '```mermaid\nflowchart LR\n%s\n```\n' "$edges"
99
115
  }
100
116
 
117
+ # rc 2 = the query failed; rc 1 = it ran and returned nothing. Different stubs.
101
118
  case "$DIAGRAM" in
102
- module-deps) render_module_deps || stub ;;
103
- co-change) render_co_change || stub ;;
104
- proto-map) render_proto_map || stub ;;
119
+ module-deps) render_module_deps || { rc=$?; [[ $rc -eq 1 ]] && stub empty || stub unavailable; } ;;
120
+ co-change) render_co_change || { rc=$?; [[ $rc -eq 1 ]] && stub empty || stub unavailable; } ;;
121
+ proto-map) render_proto_map || { rc=$?; [[ $rc -eq 1 ]] && stub empty || stub unavailable; } ;;
105
122
  *) echo "Unknown --diagram '$DIAGRAM' (expected module-deps|co-change|proto-map)" >&2; exit 1 ;;
106
123
  esac
@@ -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