@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drafthq/draft",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
4
4
  "description": "Context-Driven Development for AI coding agents — install Draft into Claude Code, Cursor, Codex, or opencode.",
5
5
  "bin": {
6
6
  "draft": "cli/bin/draft.js"
@@ -8,7 +8,7 @@
8
8
  # which scripts/tools/_lib.sh:find_memory_bin resolves.
9
9
  #
10
10
  # Pinned by default for reproducibility; override with CMM_VERSION (a tag, e.g.
11
- # "v0.8.1", or "latest").
11
+ # "v0.9.0", or "latest").
12
12
  #
13
13
  # Usage:
14
14
  # scripts/fetch-memory-engine.sh [--dest DIR] [--force]
@@ -16,12 +16,18 @@
16
16
  # Env:
17
17
  # CMM_VERSION Release tag to fetch (default: pinned DEFAULT_VERSION).
18
18
  # CMM_DOWNLOAD_URL Override the release base URL (testing).
19
+ # DRAFT_STRICT_VERIFY 1 = refuse to install when the checksum cannot be
20
+ # verified (missing checksums.txt or unlisted archive).
21
+ # Default is warn-and-continue, which keeps installs working
22
+ # on releases that ship no checksums file — acceptable for
23
+ # individuals, not for anyone who must attest to what runs
24
+ # on their machine.
19
25
  #
20
26
  # Exit codes: 0 installed/already-present, 1 invocation error, 2 fetch/verify failure.
21
27
  set -euo pipefail
22
28
 
23
29
  REPO="DeusData/codebase-memory-mcp"
24
- DEFAULT_VERSION="v0.8.1" # pinned; bump deliberately. NOTE: tag must carry the leading "v" AND have published assets (0.7.0 had none → 404).
30
+ DEFAULT_VERSION="v0.9.0" # pinned; bump deliberately. NOTE: tag must carry the leading "v" AND have published assets (0.7.0 had none → 404).
25
31
  VERSION="${CMM_VERSION:-$DEFAULT_VERSION}"
26
32
  DEST="$HOME/.cache/draft/bin"
27
33
  FORCE=0
@@ -79,7 +85,19 @@ if ! curl -fSL --max-time 300 -o "$TMP/$ARCHIVE" "$BASE/$ARCHIVE"; then
79
85
  exit 2
80
86
  fi
81
87
 
82
- # --- Verify checksum (best-effort: hard-fail only if the archive is listed) ---
88
+ # --- Verify checksum ---
89
+ # A mismatch is always fatal. An *absent* checksum is fatal only under
90
+ # DRAFT_STRICT_VERIFY=1 — otherwise it warns, so that a release without a
91
+ # checksums.txt does not brick the install for everyone.
92
+ STRICT="${DRAFT_STRICT_VERIFY:-0}"
93
+ unverified() {
94
+ if [[ "$STRICT" == "1" ]]; then
95
+ echo "error: $1 (DRAFT_STRICT_VERIFY=1 refuses unverified binaries)" >&2
96
+ exit 2
97
+ fi
98
+ echo " warning: $1 — skipping verification (set DRAFT_STRICT_VERIFY=1 to make this fatal)" >&2
99
+ }
100
+
83
101
  if curl -fsSL --max-time 60 -o "$TMP/checksums.txt" "$BASE/checksums.txt" 2>/dev/null; then
84
102
  expected="$(grep " $ARCHIVE\$" "$TMP/checksums.txt" 2>/dev/null | awk '{print $1}' | head -1 || true)"
85
103
  if [[ -n "$expected" ]]; then
@@ -92,12 +110,12 @@ if curl -fsSL --max-time 60 -o "$TMP/checksums.txt" "$BASE/checksums.txt" 2>/dev
92
110
  echo "error: checksum mismatch for $ARCHIVE (expected $expected, got $actual)" >&2
93
111
  exit 2
94
112
  fi
95
- echo " checksum OK"
113
+ echo " checksum OK ($expected)"
96
114
  else
97
- echo " warning: $ARCHIVE not found in checksums.txt — skipping verification" >&2
115
+ unverified "$ARCHIVE not listed in checksums.txt"
98
116
  fi
99
117
  else
100
- echo " warning: checksums.txt unavailable skipping verification" >&2
118
+ unverified "checksums.txt unavailable at $BASE"
101
119
  fi
102
120
 
103
121
  # --- Extract and install ---
package/scripts/lib.sh CHANGED
@@ -2,21 +2,26 @@
2
2
  #
3
3
  # Shared validation library for Draft skill files.
4
4
  #
5
- # Sourced by test suites. Defines constants and validation functions
6
- # but does not execute anything when sourced.
5
+ # Sourced by test suites and the build script. Defines constants and validation
6
+ # functions only no side effects at source time, shell options included. Every
7
+ # consumer sets its own `set -euo pipefail`; doing it here would silently change
8
+ # the behaviour of whatever sourced us.
7
9
  #
8
10
  # Usage:
9
11
  # source scripts/lib.sh
10
12
  #
11
13
 
12
- set -euo pipefail
13
-
14
14
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
15
  ROOT_DIR="$(dirname "$SCRIPT_DIR")"
16
16
  SKILLS_DIR="$ROOT_DIR/skills"
17
17
  CORE_DIR="$ROOT_DIR/core"
18
18
  TOOLS_DIR="$ROOT_DIR/scripts/tools"
19
19
 
20
+ # Shared shell helpers (json_escape, apply_dest_mode, …). Side-effect free at
21
+ # source time, so this is safe for every consumer of lib.sh.
22
+ # shellcheck source=tools/_lib.sh
23
+ source "$TOOLS_DIR/_lib.sh"
24
+
20
25
  # ─────────────────────────────────────────────────────────
21
26
  # Skill ordering (canonical order for all references)
22
27
  # ─────────────────────────────────────────────────────────
@@ -250,6 +255,7 @@ TOOLS=(
250
255
  "validate-frontmatter.sh"
251
256
  # Foundations hygiene/verification tools
252
257
  "check-graph-usage-report.sh"
258
+ "check-repo-size.sh"
253
259
  "check-scope-conflicts.sh"
254
260
  "check-skill-line-caps.sh"
255
261
  "check-template-noop.sh"
@@ -257,6 +263,7 @@ TOOLS=(
257
263
  "diff-templates-vs-tracks.sh"
258
264
  "emit-skill-metrics.sh"
259
265
  "fix-whitespace.sh"
266
+ "install-smoke-test.sh"
260
267
  "migrate-track-frontmatter.sh"
261
268
  "render-track.sh"
262
269
  "verify-citations.sh"
@@ -12,7 +12,10 @@
12
12
  # `STARTS WITH`, `NOT x STARTS WITH`, `AND`, `OR`, relationship-type
13
13
  # alternation `[:A|B]`, simple `count(x)`.
14
14
  # UNSAFE : coalesce(), `<>` / `!=` / `<=` / `>=`, `NOT EXISTS(...)`,
15
- # `NOT (pattern)`, `WITH`-grouping aggregation, multi-pattern joins.
15
+ # `NOT (pattern)`, `WITH`-grouping aggregation, multi-pattern joins,
16
+ # and comparing one property against another (`a.x < b.x` — the
17
+ # parser wants a literal on the right and fails with "expected value
18
+ # at pos N"). `<` against a literal is fine.
16
19
  # Every builder below stays inside the SAFE set.
17
20
  #
18
21
  # Label-agnostic on name matches: code units are :Method ⪢ :Function in OO repos;
@@ -43,7 +46,12 @@ gq_q_exists() { printf "MATCH (f {name:'%s'}) RETURN f.name AS name L
43
46
  gq_q_callers() { printf "MATCH (c)-[:CALLS]->(f {name:'%s'}) RETURN c.name AS caller, c.file_path AS file LIMIT 200" "$1"; }
44
47
  gq_q_callers_prod() { printf "MATCH (c)-[:CALLS]->(f {name:'%s'}) WHERE c.is_test=false AND NOT c.file_path STARTS WITH 'tests/' RETURN c.name AS caller, c.file_path AS file LIMIT 200" "$1"; }
45
48
  gq_q_callers_qualified() { printf "MATCH (c)-[:CALLS]->(f {qualified_name:'%s'}) RETURN c.name AS caller, c.file_path AS file LIMIT 200" "$1"; }
46
- gq_q_cycles2() { printf "MATCH (a)-[:CALLS]->(b)-[:CALLS]->(a) WHERE a.qualified_name < b.qualified_name RETURN a.qualified_name AS a, b.qualified_name AS b LIMIT 100"; }
49
+ # The `WHERE a.qualified_name < b.qualified_name` this used to carry was doing
50
+ # two jobs — drop self-loops, and emit each mutual pair once instead of twice —
51
+ # but the engine cannot parse a property-to-property comparison, so the query
52
+ # failed on every call and cycle-detect swallowed the error. Both jobs now happen
53
+ # in jq downstream; the limit is doubled because the raw rows come in pairs.
54
+ gq_q_cycles2() { printf "MATCH (a)-[:CALLS]->(b)-[:CALLS]->(a) RETURN a.qualified_name AS a, b.qualified_name AS b LIMIT 200"; }
47
55
  gq_q_cycles3() { printf "MATCH (a)-[:CALLS]->(b)-[:CALLS]->(c)-[:CALLS]->(a) RETURN a.qualified_name AS a, b.qualified_name AS b, c.qualified_name AS c LIMIT 100"; }
48
56
  gq_q_tests() { printf "MATCH (t)-[:TESTS]->(f {name:'%s'}) RETURN t.qualified_name AS test, t.file_path AS file LIMIT 200" "$1"; }
49
57
  gq_q_tested_all() { printf "MATCH (t)-[:TESTS]->(f) RETURN f.qualified_name AS symbol LIMIT 2000"; }
@@ -53,7 +61,6 @@ gq_q_co_change() { printf "MATCH (a:File)-[r:FILE_CHANGES_WITH]->(b:File
53
61
  gq_q_inherits() { printf "MATCH (c)-[:INHERITS]->(p) RETURN c.qualified_name AS child, p.qualified_name AS parent LIMIT 500"; }
54
62
  gq_q_inherits_sym() { printf "MATCH (c)-[:INHERITS]->(p) WHERE c.name='%s' RETURN c.qualified_name AS child, p.qualified_name AS parent LIMIT 200" "$1"; }
55
63
  gq_q_derived_sym() { printf "MATCH (c)-[:INHERITS]->(p) WHERE p.name='%s' RETURN c.qualified_name AS child, p.qualified_name AS parent LIMIT 200" "$1"; }
56
- gq_q_writes() { printf "MATCH (f {name:'%s'})-[:WRITES]->(v) RETURN v.name AS target, v.file_path AS file LIMIT 200" "$1"; }
57
64
  gq_q_raises() { printf "MATCH (f {name:'%s'})-[:RAISES|THROWS]->(e) RETURN e.name AS error, e.qualified_name AS qualified LIMIT 200" "$1"; }
58
65
  gq_q_raisers() { printf "MATCH (f)-[:RAISES|THROWS]->(e {name:'%s'}) RETURN f.qualified_name AS raiser, f.file_path AS file LIMIT 200" "$1"; }
59
66
  gq_q_node_props() { printf "MATCH (f) RETURN f.qualified_name AS q, f.complexity AS c, f.cognitive AS cog, f.is_entry_point AS ep LIMIT 10000"; }
@@ -5,6 +5,21 @@
5
5
 
6
6
  # shellcheck shell=bash
7
7
 
8
+ # Give a mktemp-created replacement file the mode its destination should carry.
9
+ # mktemp creates 0600 and `mv` swaps the inode, so an atomic rewrite silently
10
+ # strips the destination's permissions (a 0644 doc came back 0600). Call this on
11
+ # the temp file BEFORE the mv. Uses the destination's current mode when it
12
+ # exists, else the mode a plain `>` redirect would have produced under the
13
+ # caller's umask.
14
+ apply_dest_mode() {
15
+ local tmp="$1" dest="${2:-}" mode=""
16
+ if [[ -n "$dest" && -e "$dest" ]]; then
17
+ mode="$(stat -c '%a' "$dest" 2>/dev/null || stat -f '%Lp' "$dest" 2>/dev/null || true)"
18
+ fi
19
+ [[ -n "$mode" ]] || mode="$(printf '%o' "$(( 0666 & ~0$(umask) ))")"
20
+ chmod "$mode" "$tmp" 2>/dev/null || true
21
+ }
22
+
8
23
  json_escape() {
9
24
  local s="$1"
10
25
  s="${s//\\/\\\\}"
@@ -15,7 +30,6 @@ json_escape() {
15
30
  printf '%s' "$s"
16
31
  }
17
32
 
18
- # Extract a top-level YAML frontmatter field value from a Markdown file.
19
33
  # Discover track directories under a repo root (default: caller's Draft repo).
20
34
  discover_track_dirs() {
21
35
  local repo_root="${1:-}"
@@ -90,6 +104,7 @@ skill_line_cap() {
90
104
  printf '%s' "$global_cap"
91
105
  }
92
106
 
107
+ # Extract a top-level YAML frontmatter field value from a Markdown file.
93
108
  get_yaml_field() {
94
109
  local file="$1"
95
110
  local key="$2"
@@ -109,11 +124,6 @@ get_yaml_field() {
109
124
  ' "$file"
110
125
  }
111
126
 
112
- # Locate the `codebase-memory-mcp` binary (Draft knowledge-graph engine).
113
- # Sets MEMORY_BIN globally; returns 0 if found, 1 otherwise.
114
- # Preference: PATH > Draft-managed install (~/.cache/draft/bin) > vendored bin/<arch> under known roots.
115
- # No legacy fallbacks: the Aether `graph`/`graph-clang` binaries are retired.
116
-
117
127
  # GitHub-flavored-markdown-ish heading id (shared by TOC, anchors, link fixers).
118
128
  # Keeps underscores; strips other punctuation; collapses whitespace to '-'.
119
129
  gfm_slug() {
@@ -124,7 +134,9 @@ gfm_slug() {
124
134
  printf '%s' "$s"
125
135
  }
126
136
 
127
- # Count entries in an x-grounded-paths frontmatter field.
137
+ # Count entries in an x-grounded-paths frontmatter field. Always prints an
138
+ # integer — a page with no such field counts 0, not "" (callers embed the value
139
+ # in diagnostics, and an empty string rendered as "x-grounded-paths < 2").
128
140
  # Supports both inline (`x-grounded-paths: ["a", "b"]`) and block lists:
129
141
  # x-grounded-paths:
130
142
  # - "a"
@@ -140,20 +152,26 @@ grounded_paths_count() {
140
152
  sub(/^x-grounded-paths:[[:space:]]*\[/, "", line)
141
153
  sub(/\].*$/, "", line)
142
154
  gsub(/[[:space:]]/, "", line)
143
- if (line=="") { print 0; exit }
155
+ if (line=="") { print 0; printed=1; exit }
144
156
  n=split(line, a, ",")
145
157
  print n
158
+ printed=1
146
159
  exit
147
160
  }
148
161
  fm && /^x-grounded-paths:[[:space:]]*$/ { collect=1; next }
149
162
  fm && collect {
150
163
  if ($0 ~ /^[[:space:]]*-[[:space:]]*/) { n++; next }
151
- if ($0 ~ /^[A-Za-z0-9_-]+:/) { print n+0; exit }
164
+ if ($0 ~ /^[A-Za-z0-9_-]+:/) { print n+0; printed=1; exit }
152
165
  }
153
- END { if (collect) print n+0 }
166
+ END { if (!printed) print n+0 }
154
167
  ' "$file"
155
168
  }
156
169
 
170
+ # Locate the `codebase-memory-mcp` binary (Draft knowledge-graph engine).
171
+ # Sets MEMORY_BIN globally; returns 0 if found, 1 otherwise.
172
+ # Precedence: DRAFT_MEMORY_DISABLE (hard off) > DRAFT_MEMORY_BIN > PATH >
173
+ # Draft-managed install (~/.cache/draft/bin) > vendored bin/<arch> under known roots.
174
+ # No legacy fallbacks: the Aether `graph`/`graph-clang` binaries are retired.
157
175
  find_memory_bin() {
158
176
  local repo_abs="$1"
159
177
  local self_repo="$2"
@@ -298,7 +316,11 @@ _can_cgroup_bound() {
298
316
  # Echoes the engine's JSON result on stdout (same contract as memory_cli).
299
317
  memory_index_bounded() {
300
318
  local repo_abs="$1"
301
- local json="{\"repo_path\":\"$repo_abs\"}"
319
+ # Payload built with jq (never string concatenation) so a repo path
320
+ # containing a `"` or `\` can never corrupt the JSON sent to the engine.
321
+ command -v jq >/dev/null 2>&1 || return 1
322
+ local json
323
+ json="$(jq -n --arg r "$repo_abs" '{repo_path:$r}')" || return 1
302
324
  export CBM_WORKERS="${CBM_WORKERS:-4}"
303
325
  local total pct
304
326
  total="$(_total_ram_mb)"
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ # check-repo-size.sh
3
+ #
4
+ # Guards the size of the tree at HEAD.
5
+ #
6
+ # `claude plugin marketplace add <owner>/<repo>` git-clones this repository, so
7
+ # every byte tracked at HEAD is downloaded before the plugin can be installed.
8
+ # In v2.8.3 ~670 MB of audiobook .m4a files in HEAD stalled that first install
9
+ # step with no error message — an install-breaking regression that no test
10
+ # caught. This is that test.
11
+ #
12
+ # The threshold is deliberately loose (default 10 MB against a ~6 MB HEAD): it
13
+ # exists to catch binary assets landing in HEAD, not to police prose growth.
14
+ #
15
+ # Usage:
16
+ # scripts/tools/check-repo-size.sh # default cap, human output
17
+ # scripts/tools/check-repo-size.sh --max-mb 5 # custom cap
18
+ # scripts/tools/check-repo-size.sh --json # machine-readable
19
+ # scripts/tools/check-repo-size.sh --rev v3.6.0 # audit a past revision
20
+ #
21
+ # Exit codes:
22
+ # 0 tree at HEAD is within the cap
23
+ # 1 cap exceeded
24
+ # 2 usage / runtime error
25
+
26
+ set -euo pipefail
27
+
28
+ usage() {
29
+ cat <<'EOF'
30
+ check-repo-size.sh — fail if the tree at HEAD exceeds a size cap
31
+
32
+ Options:
33
+ --max-mb <n> Cap in megabytes (default: 10)
34
+ --rev <rev> Revision to measure (default: HEAD)
35
+ --repo <path> Repository root (default: this repo)
36
+ --top <n> Largest blobs to list (default: 10)
37
+ --json Emit JSON instead of human-readable output
38
+ --help, -h Show this message
39
+ EOF
40
+ }
41
+
42
+ if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
43
+ usage
44
+ exit 0
45
+ fi
46
+
47
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
48
+ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
49
+
50
+ MAX_MB=10
51
+ REV="HEAD"
52
+ TOP=10
53
+ EMIT_JSON=0
54
+
55
+ while [[ $# -gt 0 ]]; do
56
+ case "$1" in
57
+ --max-mb)
58
+ [[ -n "${2:-}" ]] || { echo "check-repo-size: --max-mb requires a value" >&2; exit 2; }
59
+ MAX_MB="$2"; shift 2 ;;
60
+ --rev)
61
+ [[ -n "${2:-}" ]] || { echo "check-repo-size: --rev requires a value" >&2; exit 2; }
62
+ REV="$2"; shift 2 ;;
63
+ --repo)
64
+ [[ -n "${2:-}" ]] || { echo "check-repo-size: --repo requires a value" >&2; exit 2; }
65
+ REPO_ROOT="$2"; shift 2 ;;
66
+ --top)
67
+ [[ -n "${2:-}" ]] || { echo "check-repo-size: --top requires a value" >&2; exit 2; }
68
+ TOP="$2"; shift 2 ;;
69
+ --json) EMIT_JSON=1; shift ;;
70
+ --help|-h) usage; exit 0 ;;
71
+ *) echo "check-repo-size: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
72
+ esac
73
+ done
74
+
75
+ [[ "$MAX_MB" =~ ^[0-9]+$ ]] || { echo "check-repo-size: --max-mb must be an integer, got '$MAX_MB'" >&2; exit 2; }
76
+ [[ "$TOP" =~ ^[0-9]+$ ]] || { echo "check-repo-size: --top must be an integer, got '$TOP'" >&2; exit 2; }
77
+ [[ -d "$REPO_ROOT" ]] || { echo "check-repo-size: no such directory: $REPO_ROOT" >&2; exit 2; }
78
+
79
+ cd "$REPO_ROOT"
80
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "check-repo-size: $REPO_ROOT is not a git repository" >&2; exit 2; }
81
+ git rev-parse --verify --quiet "$REV" >/dev/null || { echo "check-repo-size: unknown revision '$REV'" >&2; exit 2; }
82
+
83
+ # `ls-tree -l` gives per-blob sizes; symlinks/submodules report '-' and are skipped.
84
+ listing="$(git ls-tree -r -l "$REV")"
85
+
86
+ total_bytes=0
87
+ file_count=0
88
+ while read -r _mode _type _sha size _path; do
89
+ [[ "$size" =~ ^[0-9]+$ ]] || continue
90
+ total_bytes=$((total_bytes + size))
91
+ file_count=$((file_count + 1))
92
+ done <<< "$listing"
93
+
94
+ max_bytes=$((MAX_MB * 1024 * 1024))
95
+ status="ok"
96
+ [[ "$total_bytes" -gt "$max_bytes" ]] && status="over"
97
+
98
+ total_mb="$(awk -v b="$total_bytes" 'BEGIN { printf "%.2f", b / 1048576 }')"
99
+
100
+ if [[ "$EMIT_JSON" -eq 1 ]]; then
101
+ printf '{"rev":"%s","status":"%s","total_bytes":%d,"total_mb":%s,"max_mb":%d,"files":%d}\n' \
102
+ "$REV" "$status" "$total_bytes" "$total_mb" "$MAX_MB" "$file_count"
103
+ else
104
+ echo "Tree at $REV: ${total_mb} MB across $file_count files (cap: ${MAX_MB} MB)"
105
+ if [[ "$status" == "over" ]]; then
106
+ echo ""
107
+ echo "Largest tracked blobs:"
108
+ # Materialize the sorted list before slicing it. Piping straight into
109
+ # `head` lets head close the pipe early, which under `pipefail` surfaces
110
+ # sort's SIGPIPE as a hard failure — non-deterministically, depending on
111
+ # whether the output fit in the pipe buffer.
112
+ largest="$(awk '$4 ~ /^[0-9]+$/ { printf " %8.2f MB %s\n", $4 / 1048576, $5 }' <<< "$listing" | sort -rn)"
113
+ head -n "$TOP" <<< "$largest"
114
+ fi
115
+ fi
116
+
117
+ if [[ "$status" == "over" ]]; then
118
+ echo "" >&2
119
+ echo "FAIL: HEAD exceeds ${MAX_MB} MB. 'plugin marketplace add' clones this tree —" >&2
120
+ echo " oversized blobs stall install with no error. Ship large assets as" >&2
121
+ echo " GitHub Release attachments instead of committing them." >&2
122
+ exit 1
123
+ fi
124
+
125
+ exit 0
@@ -64,7 +64,6 @@ declare -a findings=()
64
64
  record() { findings+=("$1|$2|$3"); over_count=$((over_count + 1)); }
65
65
 
66
66
  while IFS= read -r path; do
67
- rel="${path#"$REPO_ROOT/"}"
68
67
  name="$(basename "$(dirname "$path")")"
69
68
  lines="$(wc -l < "$path" | tr -d ' ')"
70
69
  cap="$(skill_line_cap "$name" "$CAPS_CONF" "$GLOBAL_CAP")"
@@ -90,7 +89,7 @@ emit() {
90
89
  printf 'OK: all skills within cap (default %d).\n' "$GLOBAL_CAP"
91
90
  return
92
91
  fi
93
- local mode="warn-only" stream=stderr
92
+ local mode="warn-only"
94
93
  ((ENFORCE)) && mode="enforced"
95
94
  if ((ENFORCE)); then
96
95
  printf 'SKILL-CAPS: %d skill(s) over cap (%s):\n' \
@@ -51,7 +51,6 @@ while IFS= read -r f; do
51
51
  [[ -z "$f" ]] && continue
52
52
  case "$f" in
53
53
  skills/*|scripts/tools/*)
54
- # Skip changes to the templates themselves living under scripts.
55
54
  touches_skills_or_tools=1 ;;
56
55
  core/templates/*)
57
56
  touches_templates=1 ;;
@@ -34,8 +34,8 @@ Output (per file): {path, lang, category, is_test, is_generated, loc, module}
34
34
 
35
35
  Language is inferred from extension and shebang. A file is classified as a
36
36
  test when its path or basename matches common test patterns. It is flagged
37
- generated when the first 5 lines contain "Code generated" or "DO NOT EDIT".
38
- Module is the first path segment under root.
37
+ generated when the first 5 lines contain "Code generated", "DO NOT EDIT",
38
+ "@generated", or "autogenerated". Module is the first path segment under root.
39
39
  EOF
40
40
  }
41
41
 
@@ -58,18 +58,26 @@ graph_bootstrap "$REPO" || unavailable
58
58
  # 2- and 3-node CALLS cycles. Cypher lives in _graph_queries.sh (label-agnostic;
59
59
  # the Phase 0 fix — code units are mostly :Method, and CALLS only connects
60
60
  # callables). LIMIT 100 caps each, so results are a sample, not exhaustive.
61
- R2="$(gq_run "$PROJECT" "$(gq_q_cycles2)" || echo '{}')"
62
- R3="$(gq_run "$PROJECT" "$(gq_q_cycles3)" || echo '{}')"
63
-
64
- # Guard against empty/non-JSON engine output so --argjson never aborts the script.
65
- echo "$R2" | jq -e . >/dev/null 2>&1 || R2='{}'
66
- echo "$R3" | jq -e . >/dev/null 2>&1 || R3='{}'
61
+ #
62
+ # A failed query is not an empty result. Substituting `{}` and carrying on
63
+ # reported `source:"memory-graph"` with zero cycles — a clean bill of health
64
+ # derived from a query that never ran, which is exactly the true-negative
65
+ # confusion Guardrail 4 exists to prevent, and the one failure mode a caller
66
+ # cannot detect. Both queries must land for the sample to mean anything, so
67
+ # either failure routes to `unavailable`. gq_run only ever echoes validated
68
+ # JSON, so nothing downstream needs a second shape guard.
69
+ R2="$(gq_run "$PROJECT" "$(gq_q_cycles2)")" || unavailable
70
+ R3="$(gq_run "$PROJECT" "$(gq_q_cycles3)")" || unavailable
67
71
 
72
+ # Self-loops and duplicate orderings are filtered here rather than in Cypher:
73
+ # the engine rejects `a.x < b.x`, which is what the query used to rely on.
74
+ # A 2-cycle comes back twice (A,B and B,A), hence the doubled LIMIT upstream.
68
75
  jq -n --argjson r2 "$R2" --argjson r3 "$R3" '
69
- ( ((($r2.rows) // []) | length) >= 100
76
+ ( ((($r2.rows) // []) | length) >= 200
70
77
  or ((($r3.rows) // []) | length) >= 100 ) as $trunc
78
+ | ( ($r2.rows // []) | map(select(.[0] != .[1])) | unique_by(sort) ) as $two
71
79
  | {
72
- cycles: (((($r2.rows) // []) + (($r3.rows) // []))),
80
+ cycles: ($two + ($r3.rows // [])),
73
81
  truncated: $trunc,
74
82
  source: "memory-graph"
75
83
  }'
@@ -83,6 +83,9 @@ fix_file() {
83
83
  return 1 # already clean — no change on disk
84
84
  fi
85
85
 
86
+ # mktemp creates 0600 and `mv` swaps the inode — carry the destination's mode
87
+ # across, or an atomic rewrite silently strips the file's permissions.
88
+ apply_dest_mode "$_tmp" "$file"
86
89
  mv -f "$_tmp" "$file"
87
90
  return 0
88
91
  }
@@ -127,6 +127,8 @@ if (( WRITE_PROJECT_METADATA )); then
127
127
  fi
128
128
  CMD="${GENERATED_BY:-draft:init}"
129
129
  _tmp="$(mktemp "${META_FILE}.XXXXXX")"
130
+ # errexit on a failed write would otherwise leave metadata.json.XXXXXX behind.
131
+ trap 'rm -f "$_tmp"' EXIT
130
132
  cat > "$_tmp" <<EOF
131
133
  {
132
134
  "\$schema": "Draft Project Metadata Schema",
@@ -150,7 +152,11 @@ if (( WRITE_PROJECT_METADATA )); then
150
152
  "synced_to_commit": "$FULL_SHA"
151
153
  }
152
154
  EOF
155
+ # mktemp creates 0600 and `mv` swaps the inode — carry the destination's mode
156
+ # across, or an atomic rewrite silently strips the file's permissions.
157
+ apply_dest_mode "$_tmp" "$META_FILE"
153
158
  mv -f "$_tmp" "$META_FILE"
159
+ trap - EXIT
154
160
  echo "Written: $META_FILE (synced_to_commit=$FULL_SHA)"
155
161
  exit 0
156
162
  fi
@@ -57,7 +57,7 @@ unavailable() { echo '{"source":"unavailable"}'; exit 2; }
57
57
 
58
58
  graph_bootstrap "$REPO" || unavailable
59
59
 
60
- ARCH_JSON="$(memory_cli get_architecture "{\"project\":\"$PROJECT\",\"aspects\":[\"all\"]}" || true)"
60
+ ARCH_JSON="$(memory_cli get_architecture "$(jq -n --arg p "$PROJECT" '{project:$p, aspects:["all"]}')" || true)"
61
61
  [[ -n "$ARCH_JSON" ]] || unavailable
62
62
 
63
63
  # Validate it parses and looks like an architecture object before emitting.
@@ -9,7 +9,10 @@
9
9
  # scripts/tools/graph-callers.sh --repo DIR --symbol NAME
10
10
  # [--transitive[=N]] [--prod-only] [--qualified]
11
11
  #
12
- # Output: JSON {symbol, callers:[{name,file[,hop]}], status, source}.
12
+ # Output: JSON {symbol, callers:[{name,file,qualified[,hop]}], status, source}.
13
+ # `file` is always a path (empty when the engine does not carry one — the
14
+ # trace_path expander behind --transitive returns no file_path); the symbol's
15
+ # qualified name is in `qualified`, never smuggled into `file`.
13
16
  # source = "memory-graph" | "unavailable"
14
17
  # status = "ok" | "no-edges" | "no-match" | "unavailable"
15
18
  # (fail-loud: distinguishes node-not-found from node-has-no-callers
@@ -92,7 +95,7 @@ if [[ "$TRANSITIVE" -eq 1 ]]; then
92
95
  fi
93
96
  echo "$RES" | jq --arg s "$SYMBOL" --arg st "$STATUS" '
94
97
  {symbol:$s,
95
- callers: [ (.callers // [])[] | {name:.name, file:(.qualified_name // ""), hop:(.hop // 1)} ],
98
+ callers: [ (.callers // [])[] | {name:.name, file:(.file_path // ""), qualified:(.qualified_name // ""), hop:(.hop // 1)} ],
96
99
  status:$st, source:"memory-graph"}'
97
100
  exit 0
98
101
  fi
@@ -113,5 +116,5 @@ STATUS="$(gq_symbol_status "$PROJECT" "$SYM_ESC" "$RES")"
113
116
 
114
117
  echo "$RES" | jq --arg s "$SYMBOL" --arg st "$STATUS" '
115
118
  {symbol:$s,
116
- callers: [ (.rows // [])[] | {name:.[0], file:.[1]} ],
119
+ callers: [ (.rows // [])[] | {name:.[0], file:(.[1] // ""), qualified:""} ],
117
120
  status:$st, source:"memory-graph"}'
@@ -9,7 +9,9 @@
9
9
  # Usage:
10
10
  # scripts/tools/graph-impact.sh --repo DIR (--file PATH | --symbol NAME) [--depth N]
11
11
  #
12
- # Output: JSON {target, kind, impacted:[{name,file,hop}], source}.
12
+ # Output: JSON {target, kind, impacted:[{name,file,qualified,hop}], source}.
13
+ # `file` is always a path (empty when the engine carries none); the qualified
14
+ # name has its own field rather than being emitted as if it were a path.
13
15
  # source = "memory-graph" | "unavailable"
14
16
  #
15
17
  # Exit codes: 0 OK, 1 invocation error, 2 graph engine unavailable.
@@ -78,7 +80,7 @@ if [[ -n "$SYMBOL" ]]; then
78
80
  echo "$RES" | jq -e . >/dev/null 2>&1 || unavailable "$TARGET" "$KIND"
79
81
  echo "$RES" | jq --arg t "$TARGET" '
80
82
  {target:$t, kind:"symbol",
81
- impacted: [ (.callers // [])[] | {name:.name, file:(.qualified_name // ""), hop:(.hop // 1)} ],
83
+ impacted: [ (.callers // [])[] | {name:.name, file:(.file_path // ""), qualified:(.qualified_name // ""), hop:(.hop // 1)} ],
82
84
  source:"memory-graph"}'
83
85
  else
84
86
  # File impact: detect_changes maps the working-tree diff to impacted symbols.
@@ -93,7 +93,7 @@ ensure_engine() {
93
93
  engine_unavailable() {
94
94
  if [[ "$EMIT_JSON" -eq 1 ]]; then
95
95
  printf '{"status":"unavailable","root":"%s","scope":"%s","is_root":%s}\n' \
96
- "$ROOT_ABS" "$SCOPE_ABS" "$IS_ROOT"
96
+ "$(json_escape "$ROOT_ABS")" "$(json_escape "$SCOPE_ABS")" "$IS_ROOT"
97
97
  else
98
98
  echo "WARNING: knowledge-graph engine (codebase-memory-mcp) is unavailable — no graph built." >&2
99
99
  echo " The engine is Draft's default capability tier. Install it with:" >&2
@@ -115,7 +115,7 @@ build_snapshot() {
115
115
  # Path from <module>/draft/graph back to <root>/draft/graph (module is under root).
116
116
  root_link_relpath() {
117
117
  local sub="${SCOPE_ABS#"$ROOT_ABS"/}"
118
- local ups=2 seg
118
+ local ups seg
119
119
  IFS='/' read -ra seg <<< "$sub"
120
120
  ups=$(( ${#seg[@]} + 2 ))
121
121
  local i out=""
@@ -135,13 +135,16 @@ write_root_link() {
135
135
  fi
136
136
  root_commit="$(git -C "$ROOT_ABS" rev-parse --verify --quiet HEAD 2>/dev/null || echo none)"
137
137
  ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
138
+ # Every interpolated value goes through json_escape: a repo path or engine
139
+ # project name carrying a quote or backslash would otherwise emit a
140
+ # root-link.json that no consumer can parse.
138
141
  cat > "$mod_graph/root-link.json" <<EOF
139
142
  {
140
- "root_graph": "$rel",
141
- "root_abs": "$ROOT_ABS/draft/graph",
142
- "root_project": "${root_project:-unknown}",
143
- "root_commit": "$root_commit",
144
- "status": "$status",
143
+ "root_graph": "$(json_escape "$rel")",
144
+ "root_abs": "$(json_escape "$ROOT_ABS/draft/graph")",
145
+ "root_project": "$(json_escape "${root_project:-unknown}")",
146
+ "root_commit": "$(json_escape "$root_commit")",
147
+ "status": "$(json_escape "$status")",
145
148
  "linked_at": "$ts",
146
149
  "linked_by": "graph-init.sh",
147
150
  "note": "Root is the authoritative whole-repo graph. Follow root_graph for cross-module understanding."
@@ -173,7 +176,8 @@ fi
173
176
 
174
177
  if [[ "$EMIT_JSON" -eq 1 ]]; then
175
178
  printf '{"status":"ok","root":"%s","scope":"%s","is_root":%s,"root_built":%s,"module_built":%s,"link_status":"%s"}\n' \
176
- "$ROOT_ABS" "$SCOPE_ABS" "$IS_ROOT" "$ROOT_BUILT" "$MODULE_BUILT" "$LINK_STATUS"
179
+ "$(json_escape "$ROOT_ABS")" "$(json_escape "$SCOPE_ABS")" \
180
+ "$IS_ROOT" "$ROOT_BUILT" "$MODULE_BUILT" "$(json_escape "$LINK_STATUS")"
177
181
  else
178
182
  echo "--- graph-init ---"
179
183
  echo "Root: $ROOT_ABS$([[ $IS_ROOT -eq 1 ]] && echo ' (this scope is the root)')"
@@ -168,7 +168,10 @@ if find_memory_bin "$REPO_ABS" "$SELF_REPO"; then
168
168
  ENGINE_FOUND=true
169
169
  VER="$("$ENGINE" --version 2>/dev/null | head -1 || echo '?')"
170
170
  ok "Engine: $ENGINE ($VER)"
171
+ # Numeric-only: the value is emitted bare into the --json report, so a
172
+ # non-numeric field (engine output format drift) would produce invalid JSON.
171
173
  LIMIT="$("$ENGINE" config list 2>/dev/null | awk '/auto_index_limit/{print $3}' || true)"
174
+ [[ "$LIMIT" =~ ^[0-9]+$ ]] || LIMIT=""
172
175
  [[ -n "$LIMIT" ]] && info "auto_index_limit: $LIMIT (governs AUTO-index only; explicit index_repository should bypass)" || true
173
176
  if [[ "$GIT_OK" -eq 1 && "${LIMIT:-}" =~ ^[0-9]+$ && "$TRACKED" -gt "$LIMIT" ]]; then
174
177
  warn "Tracked files ($TRACKED) > auto_index_limit ($LIMIT) — confirm the explicit index isn't truncated near $LIMIT."