sequant 2.9.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +20 -5
  4. package/dist/bin/cli.js +143 -18
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  7. package/dist/marketplace/external_plugins/sequant/README.md +56 -2
  8. package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +20 -7
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +75 -10
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +215 -390
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +1 -1
  14. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
  15. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
  16. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +3 -1
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
  18. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
  25. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
  26. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
  27. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +1 -1
  28. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  29. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  30. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  31. package/dist/src/commands/assess-render.d.ts +23 -0
  32. package/dist/src/commands/assess-render.js +60 -0
  33. package/dist/src/commands/doctor.js +8 -13
  34. package/dist/src/commands/init.js +13 -1
  35. package/dist/src/commands/locks.d.ts +20 -1
  36. package/dist/src/commands/locks.js +208 -4
  37. package/dist/src/commands/logs.js +14 -5
  38. package/dist/src/commands/merge.js +154 -2
  39. package/dist/src/commands/ready-tui-adapter.js +6 -1
  40. package/dist/src/commands/ready.d.ts +31 -3
  41. package/dist/src/commands/ready.js +53 -13
  42. package/dist/src/commands/resume.d.ts +113 -0
  43. package/dist/src/commands/resume.js +193 -0
  44. package/dist/src/commands/run-display.js +38 -4
  45. package/dist/src/commands/run-flags.d.ts +42 -1
  46. package/dist/src/commands/run-flags.js +53 -1
  47. package/dist/src/commands/run-progress.js +26 -1
  48. package/dist/src/commands/run.js +11 -10
  49. package/dist/src/commands/state.js +7 -0
  50. package/dist/src/commands/status.d.ts +9 -0
  51. package/dist/src/commands/status.js +24 -1
  52. package/dist/src/commands/sync.js +90 -19
  53. package/dist/src/commands/update.js +28 -5
  54. package/dist/src/commands/worktree.d.ts +31 -0
  55. package/dist/src/commands/worktree.js +95 -0
  56. package/dist/src/lib/ac-parser.d.ts +14 -0
  57. package/dist/src/lib/ac-parser.js +99 -5
  58. package/dist/src/lib/assess/index.d.ts +10 -0
  59. package/dist/src/lib/assess/index.js +9 -0
  60. package/dist/src/lib/assess/renderer.d.ts +64 -0
  61. package/dist/src/lib/assess/renderer.js +481 -0
  62. package/dist/src/lib/assess/types.d.ts +224 -0
  63. package/dist/src/lib/assess/types.js +241 -0
  64. package/dist/src/lib/ci/outputs.d.ts +15 -2
  65. package/dist/src/lib/ci/outputs.js +17 -5
  66. package/dist/src/lib/cli-flags.d.ts +56 -0
  67. package/dist/src/lib/cli-flags.js +89 -0
  68. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +23 -7
  69. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  70. package/dist/src/lib/cli-ui/run-renderer.js +69 -1
  71. package/dist/src/lib/errors.d.ts +78 -6
  72. package/dist/src/lib/errors.js +133 -15
  73. package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
  74. package/dist/src/lib/locks/checkout-lock.js +389 -0
  75. package/dist/src/lib/locks/index.d.ts +7 -4
  76. package/dist/src/lib/locks/index.js +5 -3
  77. package/dist/src/lib/locks/lock-manager.d.ts +101 -3
  78. package/dist/src/lib/locks/lock-manager.js +277 -8
  79. package/dist/src/lib/locks/types.d.ts +106 -3
  80. package/dist/src/lib/locks/types.js +44 -0
  81. package/dist/src/lib/mcp-config.d.ts +45 -0
  82. package/dist/src/lib/mcp-config.js +77 -1
  83. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  84. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  85. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  86. package/dist/src/lib/merge-check/command-result.js +56 -0
  87. package/dist/src/lib/merge-check/index.d.ts +4 -0
  88. package/dist/src/lib/merge-check/index.js +1 -1
  89. package/dist/src/lib/merge-check/report.js +5 -1
  90. package/dist/src/lib/merge-check/types.d.ts +9 -0
  91. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  92. package/dist/src/lib/merge-check/watch.js +215 -0
  93. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  94. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  95. package/dist/src/lib/settings.d.ts +87 -0
  96. package/dist/src/lib/settings.js +106 -8
  97. package/dist/src/lib/shutdown.d.ts +42 -2
  98. package/dist/src/lib/shutdown.js +75 -5
  99. package/dist/src/lib/skills-check.d.ts +26 -0
  100. package/dist/src/lib/skills-check.js +31 -0
  101. package/dist/src/lib/stacks.d.ts +110 -1
  102. package/dist/src/lib/stacks.js +244 -5
  103. package/dist/src/lib/templates.d.ts +74 -0
  104. package/dist/src/lib/templates.js +171 -16
  105. package/dist/src/lib/test-tautology-detector.d.ts +10 -2
  106. package/dist/src/lib/test-tautology-detector.js +213 -12
  107. package/dist/src/lib/version-check.d.ts +31 -0
  108. package/dist/src/lib/version-check.js +45 -2
  109. package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
  110. package/dist/src/lib/workflow/batch-executor.js +552 -60
  111. package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
  112. package/dist/src/lib/workflow/chain-resume.js +24 -20
  113. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  114. package/dist/src/lib/workflow/completed-status.js +57 -0
  115. package/dist/src/lib/workflow/config-resolver.d.ts +51 -0
  116. package/dist/src/lib/workflow/config-resolver.js +143 -2
  117. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +22 -0
  118. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  119. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  120. package/dist/src/lib/workflow/drivers/claude-code.d.ts +14 -0
  121. package/dist/src/lib/workflow/drivers/claude-code.js +25 -2
  122. package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
  123. package/dist/src/lib/workflow/effort-escalation.js +82 -0
  124. package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
  125. package/dist/src/lib/workflow/error-classifier.js +4 -0
  126. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  127. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  128. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  129. package/dist/src/lib/workflow/heartbeat.js +90 -0
  130. package/dist/src/lib/workflow/log-writer.d.ts +54 -2
  131. package/dist/src/lib/workflow/log-writer.js +95 -6
  132. package/dist/src/lib/workflow/metrics-schema.d.ts +52 -9
  133. package/dist/src/lib/workflow/metrics-schema.js +33 -0
  134. package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
  135. package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
  136. package/dist/src/lib/workflow/phase-detection.js +5 -1
  137. package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
  138. package/dist/src/lib/workflow/phase-executor.js +467 -52
  139. package/dist/src/lib/workflow/phase-registry.js +2 -1
  140. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  141. package/dist/src/lib/workflow/platforms/github.js +152 -0
  142. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  143. package/dist/src/lib/workflow/qa-cache.js +22 -9
  144. package/dist/src/lib/workflow/ready-gate.d.ts +45 -4
  145. package/dist/src/lib/workflow/ready-gate.js +49 -12
  146. package/dist/src/lib/workflow/run-log-schema.d.ts +69 -0
  147. package/dist/src/lib/workflow/run-log-schema.js +78 -2
  148. package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
  149. package/dist/src/lib/workflow/run-orchestrator.js +168 -20
  150. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  151. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  152. package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
  153. package/dist/src/lib/workflow/spec-recommendation.js +142 -0
  154. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  155. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  156. package/dist/src/lib/workflow/state-manager.js +83 -0
  157. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  158. package/dist/src/lib/workflow/state-schema.js +47 -0
  159. package/dist/src/lib/workflow/types.d.ts +190 -4
  160. package/dist/src/lib/workflow/types.js +1 -0
  161. package/dist/src/lib/workflow/worktree-manager.d.ts +36 -3
  162. package/dist/src/lib/workflow/worktree-manager.js +111 -24
  163. package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
  164. package/dist/src/lib/workflow/worktree-resolver.js +126 -0
  165. package/dist/src/mcp/tools/run.d.ts +9 -1
  166. package/dist/src/mcp/tools/run.js +20 -1
  167. package/dist/src/mcp/tools/status.js +6 -0
  168. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  169. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  170. package/dist/src/ui/tui/IssueBox.js +10 -2
  171. package/dist/src/ui/tui/row-cap.js +2 -1
  172. package/dist/src/ui/tui/theme.d.ts +7 -0
  173. package/dist/src/ui/tui/theme.js +9 -0
  174. package/package.json +10 -8
  175. package/templates/hooks/parallel-marker.sh +50 -0
  176. package/templates/hooks/post-tool.sh +20 -7
  177. package/templates/hooks/pre-tool.sh +303 -10
  178. package/templates/scripts/cleanup-worktree.sh +217 -33
  179. package/templates/scripts/new-feature.sh +289 -35
  180. package/templates/skills/_shared/references/subagent-types.md +7 -18
  181. package/templates/skills/_shared/references/trust-model.md +18 -0
  182. package/templates/skills/assess/SKILL.md +220 -391
  183. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  184. package/templates/skills/docs/SKILL.md +4 -4
  185. package/templates/skills/exec/SKILL.md +87 -13
  186. package/templates/skills/fullsolve/SKILL.md +130 -22
  187. package/templates/skills/loop/SKILL.md +94 -13
  188. package/templates/skills/merger/SKILL.md +100 -12
  189. package/templates/skills/qa/SKILL.md +396 -63
  190. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  191. package/templates/skills/qa/references/call-site-review.md +2 -2
  192. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  193. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  194. package/templates/skills/qa/references/quality-gates.md +21 -7
  195. package/templates/skills/qa/references/testing-requirements.md +1 -1
  196. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  197. package/templates/skills/release/SKILL.md +80 -1
  198. package/templates/skills/setup/SKILL.md +5 -5
  199. package/templates/skills/spec/SKILL.md +33 -15
  200. package/templates/skills/spec/references/recommended-workflow.md +14 -1
  201. package/templates/skills/test/SKILL.md +1 -1
  202. package/templates/skills/testgen/SKILL.md +23 -6
  203. package/dist/src/lib/phase-spinner.d.ts +0 -146
  204. package/dist/src/lib/phase-spinner.js +0 -255
  205. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  206. package/dist/src/lib/workflow/pr-operations.js +0 -326
  207. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  208. package/dist/src/lib/workflow/run-summary.js +0 -142
  209. package/templates/agents/sequant-explorer.md +0 -24
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Template management - copy and process templates
3
3
  */
4
- import { readdir, chmod } from "fs/promises";
4
+ import { readdir, chmod, stat } from "fs/promises";
5
+ import { existsSync, readFileSync } from "fs";
5
6
  import { join, dirname, relative, isAbsolute } from "path";
6
7
  import { fileURLToPath } from "url";
7
8
  import { diffLines } from "diff";
@@ -11,18 +12,122 @@ const SKILLS_VERSION_PATH = ".claude/skills/.sequant-version";
11
12
  import { getStackConfig, getStackNotes, getMultiStackNotes } from "./stacks.js";
12
13
  import { isNativeWindows } from "./system.js";
13
14
  import { getProjectName } from "./project-name.js";
15
+ /**
16
+ * Offsets from this module's directory to the bundled `templates/` root, in
17
+ * probe order. This module lives at `src/lib/templates.ts`, so it sits three
18
+ * levels below the package root once compiled (`dist/src/lib/templates.js`) but
19
+ * only two when executed straight from source under `tsx` (`src/lib/`).
20
+ *
21
+ * The compiled offset is listed first so consumers — who always run the compiled
22
+ * binary — keep byte-identical behavior. See #822.
23
+ */
24
+ const TEMPLATES_DIR_OFFSETS = [
25
+ ["..", "..", "..", "templates"], // compiled: dist/src/lib → <pkg>/templates
26
+ ["..", "..", "templates"], // source (tsx): src/lib → <repo>/templates
27
+ ];
28
+ /**
29
+ * Resolve the templates root relative to a module directory, given a ranking
30
+ * function.
31
+ *
32
+ * Split out from `getTemplatesDir` purely so both layouts are testable: a test
33
+ * cannot relocate `import.meta.url`, so without injection the "works from both
34
+ * layouts" assertion could only ever exercise whichever layout the test runner
35
+ * happens to use.
36
+ *
37
+ * Resolution order: the first `package-root` candidate, else the first that
38
+ * merely `exists` (keeps any layout that works today working, even one whose
39
+ * package root is not where we expect), else the **compiled** offset — so the
40
+ * caller's error message names the canonical expected location rather than a
41
+ * source-tree guess.
42
+ */
43
+ export function resolveTemplatesDirFrom(baseDir, rank) {
44
+ const candidates = TEMPLATES_DIR_OFFSETS.map((offset) => join(baseDir, ...offset));
45
+ const ranked = candidates.map((candidate) => ({
46
+ candidate,
47
+ rank: rank(candidate),
48
+ }));
49
+ return (ranked.find((c) => c.rank === "package-root")?.candidate ??
50
+ ranked.find((c) => c.rank === "exists")?.candidate ??
51
+ candidates[0]);
52
+ }
53
+ /**
54
+ * Rank a candidate by checking whether it exists and whether its parent is the
55
+ * sequant package root. Anchoring on `package.json` mirrors how every other
56
+ * root resolver in this codebase locates the package (`version.ts`,
57
+ * `manifest.ts`, `bin/preflight.ts`).
58
+ */
59
+ function rankTemplatesCandidate(candidate) {
60
+ if (!existsSync(candidate)) {
61
+ return "missing";
62
+ }
63
+ try {
64
+ const pkg = JSON.parse(readFileSync(join(candidate, "..", "package.json"), "utf-8"));
65
+ if (pkg.name === "sequant") {
66
+ return "package-root";
67
+ }
68
+ }
69
+ catch {
70
+ // No readable/parseable package.json beside it — still a usable directory,
71
+ // just not a positively identified package root.
72
+ }
73
+ return "exists";
74
+ }
75
+ /**
76
+ * Memoized bundled-templates path. Resolution is now stat-backed rather than a
77
+ * pure string join, and `getTemplateContent` calls it once per template file
78
+ * during a drift scan — ~2ms per scan uncached, on a pre-flight path #708
79
+ * deliberately keeps in the 2-5ms range. The install layout cannot change
80
+ * within a process, so caching it is safe. Only the *bundled* resolution is
81
+ * cached; the env override is re-read every call, so tests that set and unset
82
+ * `SEQUANT_TEMPLATES_DIR` are unaffected.
83
+ */
84
+ let cachedBundledTemplatesDir;
14
85
  // Get the package templates directory
15
86
  export function getTemplatesDir() {
16
87
  // Allow overriding the templates source (used by tests; also lets the dir be
17
- // relocated without relying on the compiled-output layout below).
88
+ // relocated without relying on the compiled-output layout below). Returned
89
+ // verbatim — the override is authoritative and is never probed, so a bad
90
+ // value surfaces at `assertTemplatesDirExists` rather than silently falling
91
+ // back to the bundled tree.
18
92
  if (process.env.SEQUANT_TEMPLATES_DIR) {
19
93
  return process.env.SEQUANT_TEMPLATES_DIR;
20
94
  }
21
- const __dirname = dirname(fileURLToPath(import.meta.url));
22
- // Compiled structure: dist/src/lib/templates.js
23
- // So we need ../../../templates to reach project root templates/
24
- const devPath = join(__dirname, "..", "..", "..", "templates");
25
- return devPath;
95
+ if (cachedBundledTemplatesDir === undefined) {
96
+ const __dirname = dirname(fileURLToPath(import.meta.url));
97
+ cachedBundledTemplatesDir = resolveTemplatesDirFrom(__dirname, rankTemplatesCandidate);
98
+ }
99
+ return cachedBundledTemplatesDir;
100
+ }
101
+ /**
102
+ * Resolve the templates root and fail loudly when it does not exist.
103
+ *
104
+ * A missing templates *root* means the install is broken: every `copyDir` call
105
+ * below would hit `copyDir`'s per-directory ENOENT skip, no-op, and let the
106
+ * caller print a success message over an empty tree (#822). That skip is
107
+ * deliberate for individual subdirectories — a stack may legitimately ship
108
+ * without `memory/` — but it must not absorb the whole source tree.
109
+ *
110
+ * Throws rather than printing so the lib layer stays free of presentation;
111
+ * commands catch and render.
112
+ */
113
+ export async function assertTemplatesDirExists() {
114
+ const templatesDir = getTemplatesDir();
115
+ // A *directory* check, not a bare existence check: a stray file at that path
116
+ // would pass `access()` and then fail deeper in `readdir` with the same silent
117
+ // ENOTDIR-shaped confusion this guard exists to prevent.
118
+ let isDirectory;
119
+ try {
120
+ isDirectory = (await stat(templatesDir)).isDirectory();
121
+ }
122
+ catch {
123
+ isDirectory = false;
124
+ }
125
+ if (isDirectory) {
126
+ return templatesDir;
127
+ }
128
+ throw new Error(`Bundled templates directory not found: ${templatesDir}\n` +
129
+ "This usually means the Sequant install is incomplete or was run from an unexpected layout.\n" +
130
+ "Reinstall sequant, or set SEQUANT_TEMPLATES_DIR to the templates/ directory.");
26
131
  }
27
132
  /**
28
133
  * Process template variables in content
@@ -112,6 +217,35 @@ export async function buildTemplateVariables(stack, tokens, options = {}) {
112
217
  STACK_NOTES: stackNotes,
113
218
  };
114
219
  }
220
+ /**
221
+ * Map a bundled template path to the location `copyTemplates` installs it to,
222
+ * or `null` for templates the copy/diff cycle does not manage.
223
+ *
224
+ * This mirrors the routing in `copyTemplates` (write time) so
225
+ * `computeTemplateChanges` (diff time) can never report drift on a file init
226
+ * deliberately places elsewhere — `.claude/mcp.json`, `.claude/relay/*`, and
227
+ * `.claude/scripts/*` read as phantom "new" files on every fresh install when
228
+ * the diff assumed a flat `.claude/<relpath>` layout. #708 gives the same
229
+ * guarantee for the template *variables*; this covers the *destinations*.
230
+ *
231
+ * - `templates/mcp.json` → `null`: the project `.mcp.json` is generated and
232
+ * version-pinned by `mcp-config.ts` (`syncSequantMcpPin`), never copied.
233
+ * - `templates/relay/` → `null`: not installed by `copyTemplates`.
234
+ * - `templates/scripts/` → `scripts/dev/`: symlinked on POSIX, copied on
235
+ * Windows / `--no-symlinks`.
236
+ * - everything else → `.claude/<relpath>`.
237
+ */
238
+ export function templateDestination(templatePath) {
239
+ const normalized = templatePath.replace(/\\/g, "/");
240
+ if (normalized === "templates/mcp.json")
241
+ return null;
242
+ if (normalized.startsWith("templates/relay/"))
243
+ return null;
244
+ if (normalized.startsWith("templates/scripts/")) {
245
+ return normalized.replace("templates/scripts/", "scripts/dev/");
246
+ }
247
+ return normalized.replace("templates/", ".claude/");
248
+ }
115
249
  /**
116
250
  * Compare bundled template content against what's installed under `.claude/`.
117
251
  *
@@ -126,12 +260,13 @@ export async function computeTemplateChanges(stack, tokens, options = {}) {
126
260
  const templateFiles = await listTemplateFiles();
127
261
  const changes = [];
128
262
  for (const templatePath of templateFiles) {
129
- // Normalize separators first: listTemplateFiles builds paths with the OS
130
- // separator (backslashes on Windows), but the prefix swap and the .local/
131
- // and customizable-file checks below all assume forward slashes (#708).
132
- const localPath = templatePath
133
- .replace(/\\/g, "/")
134
- .replace("templates/", ".claude/");
263
+ // templateDestination normalizes separators (listTemplateFiles builds
264
+ // paths with the OS separator, backslashes on Windows #708) and applies
265
+ // the same routing copyTemplates uses at write time.
266
+ const localPath = templateDestination(templatePath);
267
+ if (localPath === null) {
268
+ continue;
269
+ }
135
270
  // Skip .local files (user customizations are never overwritten)
136
271
  if (localPath.includes(".local/")) {
137
272
  continue;
@@ -165,8 +300,11 @@ export async function computeTemplateChanges(stack, tokens, options = {}) {
165
300
  // auto-skipped above because it lives under `.local/`. The directive sits at
166
301
  // the top, not end-of-file, so it fires reliably even in 3000-line skills.
167
302
  // See docs/guides/customization.md.
168
- const localOverridePath = localPath.replace(".claude/", ".claude/.local/");
169
- const hasLocalOverride = await fileExists(localOverridePath);
303
+ // Only `.claude/` files have `.local/` twins; for `scripts/dev/` paths the
304
+ // replace would be a no-op and the file would shadow itself as its own
305
+ // "override".
306
+ const hasLocalOverride = localPath.startsWith(".claude/") &&
307
+ (await fileExists(localPath.replace(".claude/", ".claude/.local/")));
170
308
  if (hasLocalOverride || isCustomizableFile(localPath)) {
171
309
  changes.push({
172
310
  path: localPath,
@@ -293,6 +431,9 @@ export async function copyTemplates(stack, tokens, options = {}) {
293
431
  const templatesDir = getTemplatesDir();
294
432
  // Single source of truth for template variables (shared with the diff path)
295
433
  const variables = await buildTemplateVariables(stack, tokens, options);
434
+ // Customizable files skipped on the write path (see copyDir), surfaced to the
435
+ // caller so it can report them without a second diff pass (#814).
436
+ const preservedCustomizable = [];
296
437
  async function copyDir(srcDir, destDir) {
297
438
  try {
298
439
  const entries = await readdir(srcDir, { withFileTypes: true });
@@ -307,6 +448,20 @@ export async function copyTemplates(stack, tokens, options = {}) {
307
448
  // Read, process, and write
308
449
  let content = await readFile(srcPath);
309
450
  content = processTemplate(content, variables);
451
+ // Protect in-place customizations on the write path. A file in
452
+ // CUSTOMIZABLE_FILES that already exists and differs from the
453
+ // rendered template is preserved unless the caller explicitly opted
454
+ // in via `overwriteCustomizable`. A missing file (fresh install) or
455
+ // an identical one falls through and is written as usual (#814).
456
+ if (!options.overwriteCustomizable &&
457
+ isCustomizableFile(destPath) &&
458
+ (await fileExists(destPath))) {
459
+ const existing = await readFile(destPath);
460
+ if (existing !== content) {
461
+ preservedCustomizable.push(destPath.replace(/\\/g, "/"));
462
+ continue;
463
+ }
464
+ }
310
465
  await writeFile(destPath, content);
311
466
  // Make shell scripts executable
312
467
  if (entry.name.endsWith(".sh")) {
@@ -352,5 +507,5 @@ export async function copyTemplates(stack, tokens, options = {}) {
352
507
  }
353
508
  // Write skills version marker for sync detection
354
509
  await writeFile(SKILLS_VERSION_PATH, getPackageVersion());
355
- return { scriptsSymlinked, symlinkResults };
510
+ return { scriptsSymlinked, symlinkResults, preservedCustomizable };
356
511
  }
@@ -100,9 +100,17 @@ export declare function extractTestBlocks(content: string): Array<{
100
100
  style: "it" | "test";
101
101
  }>;
102
102
  /**
103
- * Check if a test block contains calls to any of the imported production functions
103
+ * Check if a test block calls production code. A block counts as non-tautological
104
+ * when it references an imported production function, directly spawns the
105
+ * project's build output, or calls a helper that (transitively) does so.
106
+ *
107
+ * @param productionHandles Names of describe/module-scope helpers that reach
108
+ * production — by spawning the project's executable code or by calling an
109
+ * imported production function (see {@link collectProductionHandles}).
110
+ * @param buildOutputVars Variable names bound to a build-output path (see
111
+ * {@link collectBuildOutputVars}).
104
112
  */
105
- export declare function testBlockCallsProductionCode(body: string, importedFunctions: ImportedFunction[]): boolean;
113
+ export declare function testBlockCallsProductionCode(body: string, importedFunctions: ImportedFunction[], productionHandles?: string[], buildOutputVars?: string[]): boolean;
106
114
  /**
107
115
  * Check if a file opts out of tautology detection via pragma comment.
108
116
  *
@@ -341,20 +341,219 @@ function escapeRegex(str) {
341
341
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
342
342
  }
343
343
  /**
344
- * Check if a test block contains calls to any of the imported production functions
344
+ * Build a whole-identifier reference matcher for `name`, bounded by
345
+ * non-identifier chars ([\w$]). Catches direct calls, method calls, callback
346
+ * references, and assignments while rejecting substring matches.
345
347
  */
346
- export function testBlockCallsProductionCode(body, importedFunctions) {
347
- if (importedFunctions.length === 0) {
348
- return false;
348
+ function referenceMatcher(name) {
349
+ return new RegExp(`(?<![\\w$])${escapeRegex(name)}(?![\\w$])`);
350
+ }
351
+ /**
352
+ * Child-process spawn functions. A test that spawns the project's *build
353
+ * output* is exercising production code across a process boundary that static
354
+ * import analysis cannot see through — issue #885.
355
+ *
356
+ * Two alternates by name ambiguity: the long names are unambiguous
357
+ * child-process API and match anywhere, including method-style calls from a
358
+ * namespace import (`cp.execSync(...)`). The short names (`exec`, `spawn`,
359
+ * `fork`) collide with unrelated methods — `RegExp.prototype.exec` most of
360
+ * all — so they must not be preceded by `.` (or an identifier char). The
361
+ * cost is that method-style callback `cp.exec(...)` no longer counts; tests
362
+ * that spawn build output overwhelmingly use the sync variants, and a false
363
+ * tautology report is loud where the `.exec()` collision was silent.
364
+ */
365
+ const SPAWN_PATTERN = /(?:\b(?:execFileSync|spawnSync|execSync|execFile)\s*\(|(?<![\w$.])(?:exec|fork|spawn)\s*\()/;
366
+ /**
367
+ * Marker for the project's build-output directory. A spawn whose arguments
368
+ * reach this path is running compiled production code.
369
+ */
370
+ const BUILD_OUTPUT_PATTERN = /\bdist\//;
371
+ /**
372
+ * Non-compiled production code this project ships and executes: the hook
373
+ * scripts and the `scripts/` + `templates/scripts/` trees (#906).
374
+ *
375
+ * `dist/` alone was too narrow. `checkout-lock.integration.test.ts` drives the
376
+ * real `.claude/hooks/pre-tool.sh` as a subprocess — that hook IS the
377
+ * enforcement half of the feature under test — yet every block in the file
378
+ * read as tautological because the path is not under `dist/`.
379
+ */
380
+ const PROJECT_SCRIPT_PATTERN = /\b(?:hooks\/[\w.-]+\.sh|scripts\/[\w./-]+)/;
381
+ /**
382
+ * Collect names of variables bound to a path into the project's own executable
383
+ * code, e.g.
384
+ * const cliPath = resolve(projectRoot, "dist/bin/cli.js");
385
+ * const HOOK = join(REPO_ROOT, ".claude/hooks/pre-tool.sh");
386
+ * captures `cliPath` / `HOOK`. Tests almost always spawn via such a handle
387
+ * rather than an inline string, so these names stand in for the literal path.
388
+ *
389
+ * The right-hand side is statement-bounded (`[^;]`) so a match cannot bleed
390
+ * across declarations, and must reach one of the two markers. Spawning a
391
+ * *system* binary (`git`, `bash` with a temp fixture) matches neither, which
392
+ * is the intended exclusion — those are not this project's code.
393
+ */
394
+ function collectBuildOutputVars(content) {
395
+ const names = new Set();
396
+ const patterns = [
397
+ /(?:const|let|var)\s+(\w+)\s*=\s*[^;]*?\bdist\//g,
398
+ /(?:const|let|var)\s+(\w+)\s*=\s*[^;]*?\b(?:hooks\/[\w.-]+\.sh|scripts\/[\w./-]+)/g,
399
+ ];
400
+ for (const pattern of patterns) {
401
+ let match;
402
+ while ((match = pattern.exec(content)) !== null) {
403
+ names.add(match[1]);
404
+ }
349
405
  }
406
+ return [...names];
407
+ }
408
+ /**
409
+ * Whether a code body references a build-output token: either the literal
410
+ * `dist/` marker or one of the collected build-path variable names.
411
+ */
412
+ function referencesBuildOutput(body, buildOutputVars) {
413
+ if (BUILD_OUTPUT_PATTERN.test(body) || PROJECT_SCRIPT_PATTERN.test(body)) {
414
+ return true;
415
+ }
416
+ return buildOutputVars.some((name) => referenceMatcher(name).test(body));
417
+ }
418
+ /**
419
+ * Whether a code body itself spawns the build output: it must contain BOTH a
420
+ * child-process spawn call AND a build-output token. Requiring co-occurrence
421
+ * keeps a helper that merely mentions `dist/` in a string (but never spawns)
422
+ * from counting as production (#885 AC-5).
423
+ */
424
+ function spawnsBuildOutput(body, buildOutputVars) {
425
+ return (SPAWN_PATTERN.test(body) && referencesBuildOutput(body, buildOutputVars));
426
+ }
427
+ /**
428
+ * Extract module/describe-scope helper definitions as { name, body } pairs.
429
+ *
430
+ * Two shapes, because both are idiomatic and a detector that saw only one
431
+ * produced large-scale false positives (#906): a test calling a helper the
432
+ * detector cannot see reads as import-less, hence tautological. Measured on
433
+ * `checkout-lock.integration.test.ts` (helpers written as `function`
434
+ * declarations): 17 of 19 blocks flagged, every one of them real.
435
+ *
436
+ * Params are matched with `[^()]*` (no nested parens) to keep the scan from
437
+ * running away across the file. Expression-bodied arrows are skipped — they
438
+ * have no `{` body to extract.
439
+ */
440
+ function extractHelperDefinitions(content) {
441
+ const helpers = [];
442
+ // Arrow consts anchor on `=> {`, so the body brace is unambiguous.
443
+ const arrowPattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^()]*\)\s*(?::[^=]*?)?=>\s*\{/g;
444
+ let match;
445
+ while ((match = arrowPattern.exec(content)) !== null) {
446
+ if (isInsideString(content, match.index))
447
+ continue;
448
+ const braceIndex = match.index + match[0].length - 1;
449
+ helpers.push({
450
+ name: match[1],
451
+ body: extractBlockBody(content.substring(braceIndex)),
452
+ });
453
+ }
454
+ // Declarations have no `=>` marker, and the return-type annotation may open
455
+ // a brace group of its own:
456
+ // function runHook(o): { status: number; stderr: string } { ... }
457
+ // so the body is NOT simply the first `{` after the parameters. Do not try
458
+ // to express that in the regex — a greedy annotation subpattern silently ran
459
+ // past the body and anchored on the NEXT declaration's brace, yielding a
460
+ // "helper" whose body was somebody else's code (caught by the object
461
+ // return-type test below). Match only to the closing paren, then walk: take
462
+ // the first brace group; if another `{` follows it, that group was the
463
+ // return type and the body is the next one.
464
+ const declPattern = /(?:async\s+)?function\s*\*?\s*(\w+)\s*\([^()]*\)/g;
465
+ while ((match = declPattern.exec(content)) !== null) {
466
+ if (isInsideString(content, match.index))
467
+ continue;
468
+ const rest = content.substring(match.index + match[0].length);
469
+ const firstBrace = rest.indexOf("{");
470
+ if (firstBrace === -1)
471
+ continue;
472
+ // Anything between `)` and the first `{` must be an annotation, not code.
473
+ if (/[;)=]/.test(rest.substring(0, firstBrace)))
474
+ continue;
475
+ let body = extractBlockBody(rest.substring(firstBrace));
476
+ const after = rest.substring(firstBrace + body.length);
477
+ if (/^\s*\{/.test(after)) {
478
+ body = extractBlockBody(after);
479
+ }
480
+ helpers.push({ name: match[1], body });
481
+ }
482
+ return helpers;
483
+ }
484
+ /**
485
+ * Collect the names of helper functions that reach production code, resolving
486
+ * indirection transitively: a helper that calls an already-known handle is
487
+ * itself a handle. This lets a test that only calls `expectFlagAccepted(...)`
488
+ * (which calls `runInUninitializedDir`, which spawns the CLI) count as
489
+ * exercising production code.
490
+ *
491
+ * A helper qualifies two ways:
492
+ * - it spawns the project's own executable code (subprocess integration
493
+ * tests), or
494
+ * - it references an imported production function (#906). `makeLock()`
495
+ * returning `new CheckoutLock({...})` is production code by any reading,
496
+ * but seeding on spawns alone missed it, so every test that built its
497
+ * subject through a factory read as tautological.
498
+ */
499
+ function collectProductionHandles(content, buildOutputVars, importedFunctions = []) {
500
+ const helpers = extractHelperDefinitions(content);
501
+ const handles = new Set();
502
+ // Seed: helpers that directly reach production.
503
+ for (const helper of helpers) {
504
+ if (spawnsBuildOutput(helper.body, buildOutputVars) ||
505
+ importedFunctions.some((fn) => referenceMatcher(fn.name).test(helper.body))) {
506
+ handles.add(helper.name);
507
+ }
508
+ }
509
+ // Transitive closure: a helper referencing a known spawn helper is one too.
510
+ let changed = true;
511
+ while (changed) {
512
+ changed = false;
513
+ for (const helper of helpers) {
514
+ if (handles.has(helper.name)) {
515
+ continue;
516
+ }
517
+ for (const known of handles) {
518
+ if (referenceMatcher(known).test(helper.body)) {
519
+ handles.add(helper.name);
520
+ changed = true;
521
+ break;
522
+ }
523
+ }
524
+ }
525
+ }
526
+ return [...handles];
527
+ }
528
+ /**
529
+ * Check if a test block calls production code. A block counts as non-tautological
530
+ * when it references an imported production function, directly spawns the
531
+ * project's build output, or calls a helper that (transitively) does so.
532
+ *
533
+ * @param productionHandles Names of describe/module-scope helpers that reach
534
+ * production — by spawning the project's executable code or by calling an
535
+ * imported production function (see {@link collectProductionHandles}).
536
+ * @param buildOutputVars Variable names bound to a build-output path (see
537
+ * {@link collectBuildOutputVars}).
538
+ */
539
+ export function testBlockCallsProductionCode(body, importedFunctions, productionHandles = [], buildOutputVars = []) {
540
+ // 1. References an imported production function.
350
541
  for (const fn of importedFunctions) {
351
- // Check for any reference to the imported name bounded by non-identifier chars.
352
- // Uses [\w$] to match JS identifier characters (letters, digits, _, $).
353
- // This catches direct calls (fn()), method calls (ns.method()),
354
- // callback references (arr.map(fn)), and assignments (const x = fn).
355
- const escaped = escapeRegex(fn.name);
356
- const referencePattern = new RegExp(`(?<![\\w$])${escaped}(?![\\w$])`);
357
- if (referencePattern.test(body)) {
542
+ if (referenceMatcher(fn.name).test(body)) {
543
+ return true;
544
+ }
545
+ }
546
+ // 2. Directly spawns the project's build output (#885). Static import
547
+ // analysis can't see through a subprocess boundary, so a test that runs
548
+ // `dist/bin/cli.js` looks import-less but exercises production code.
549
+ if (spawnsBuildOutput(body, buildOutputVars)) {
550
+ return true;
551
+ }
552
+ // 3. Calls a describe/module-scope helper that (transitively) reaches
553
+ // production — spawns the project's executable code, or calls an imported
554
+ // production function (#906). Covers arrow-const and `function` helpers.
555
+ for (const handle of productionHandles) {
556
+ if (referenceMatcher(handle).test(body)) {
358
557
  return true;
359
558
  }
360
559
  }
@@ -389,12 +588,14 @@ export function analyzeTestFile(content, filePath) {
389
588
  }
390
589
  try {
391
590
  const importedFunctions = extractImports(content);
591
+ const buildOutputVars = collectBuildOutputVars(content);
592
+ const productionHandles = collectProductionHandles(content, buildOutputVars, importedFunctions);
392
593
  const testBlocks = extractTestBlocks(content);
393
594
  const analyzedBlocks = testBlocks.map((block) => ({
394
595
  description: block.description,
395
596
  lineNumber: block.lineNumber,
396
597
  style: block.style,
397
- isTautological: !testBlockCallsProductionCode(block.body, importedFunctions),
598
+ isTautological: !testBlockCallsProductionCode(block.body, importedFunctions, productionHandles, buildOutputVars),
398
599
  }));
399
600
  const tautologicalCount = analyzedBlocks.filter((b) => b.isTautological).length;
400
601
  const totalTests = analyzedBlocks.length;
@@ -53,6 +53,37 @@ export declare function isGlobalInstall(installPath?: string): boolean;
53
53
  * neither receives the project-local "use npm update sequant" warning.
54
54
  */
55
55
  export declare function isLocalNodeModulesInstall(installPath?: string): boolean;
56
+ /**
57
+ * Check if running from the npx cache (~/.npm/_npx/<hash>/node_modules/sequant).
58
+ *
59
+ * Separated from `isLocalNodeModulesInstall`, which deliberately excludes the
60
+ * cache: that predicate answers "is this a project dependency the user can
61
+ * `npm update`", while this one answers "did the user reach us through npx".
62
+ * Backslashes are normalized first, so the single forward-slash form also
63
+ * matches a Windows `…\.npm\_npx\…` path.
64
+ */
65
+ export declare function isNpxCacheInstall(installPath?: string): boolean;
66
+ /**
67
+ * Resolve the invocation to name in user-facing "run this next" messages.
68
+ *
69
+ * A hardcoded `npx sequant` is right for the documented install path (README:
70
+ * `npm install sequant` then `npx sequant init`) — npx resolves the project's
71
+ * `node_modules/.bin/sequant`, and a bare `sequant` would not be on PATH. It is
72
+ * wrong for a global or `npm link`ed install with no local dependency: there,
73
+ * `npx sequant` cannot resolve locally and falls through to the npx cache,
74
+ * which can hold an older *published* copy than the one currently running. The
75
+ * observed symptom is a remediation that fights itself — the recommended
76
+ * command reinstalls the very content whose staleness triggered the message.
77
+ *
78
+ * So recommend the form that actually reaches *this* build:
79
+ * - project-local dependency or npx cache → `npx sequant`
80
+ * - global, `npm link`ed, or a dev checkout → `sequant`
81
+ *
82
+ * A linked checkout resolves through its realpath (Node resolves symlinks
83
+ * unless `--preserve-symlinks` is set), so it looks like neither a global nor a
84
+ * local install and correctly lands on the bare form.
85
+ */
86
+ export declare function resolveCliInvocation(installPath?: string): "sequant" | "npx sequant";
56
87
  /**
57
88
  * Walk up from the given directory to find the directory containing
58
89
  * sequant's package.json. Returns null if not found.
@@ -8,7 +8,7 @@ import fs from "fs";
8
8
  import os from "os";
9
9
  import path from "path";
10
10
  import { fileURLToPath } from "url";
11
- import { detectPackageManagerSync, getPackageManagerCommands, } from "./stacks.js";
11
+ import { detectPackageManagerSync, resolvePackageManagerConfig, } from "./stacks.js";
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = path.dirname(__filename);
14
14
  const PACKAGE_NAME = "sequant";
@@ -92,6 +92,44 @@ export function isLocalNodeModulesInstall(installPath = __dirname) {
92
92
  normalizedPath.includes("\\.npm\\_npx\\");
93
93
  return inNodeModules && !inNpxCache && !isGlobalInstall(normalizedPath);
94
94
  }
95
+ /**
96
+ * Check if running from the npx cache (~/.npm/_npx/<hash>/node_modules/sequant).
97
+ *
98
+ * Separated from `isLocalNodeModulesInstall`, which deliberately excludes the
99
+ * cache: that predicate answers "is this a project dependency the user can
100
+ * `npm update`", while this one answers "did the user reach us through npx".
101
+ * Backslashes are normalized first, so the single forward-slash form also
102
+ * matches a Windows `…\.npm\_npx\…` path.
103
+ */
104
+ export function isNpxCacheInstall(installPath = __dirname) {
105
+ return installPath.replace(/\\/g, "/").includes("/.npm/_npx/");
106
+ }
107
+ /**
108
+ * Resolve the invocation to name in user-facing "run this next" messages.
109
+ *
110
+ * A hardcoded `npx sequant` is right for the documented install path (README:
111
+ * `npm install sequant` then `npx sequant init`) — npx resolves the project's
112
+ * `node_modules/.bin/sequant`, and a bare `sequant` would not be on PATH. It is
113
+ * wrong for a global or `npm link`ed install with no local dependency: there,
114
+ * `npx sequant` cannot resolve locally and falls through to the npx cache,
115
+ * which can hold an older *published* copy than the one currently running. The
116
+ * observed symptom is a remediation that fights itself — the recommended
117
+ * command reinstalls the very content whose staleness triggered the message.
118
+ *
119
+ * So recommend the form that actually reaches *this* build:
120
+ * - project-local dependency or npx cache → `npx sequant`
121
+ * - global, `npm link`ed, or a dev checkout → `sequant`
122
+ *
123
+ * A linked checkout resolves through its realpath (Node resolves symlinks
124
+ * unless `--preserve-symlinks` is set), so it looks like neither a global nor a
125
+ * local install and correctly lands on the bare form.
126
+ */
127
+ export function resolveCliInvocation(installPath = __dirname) {
128
+ return isLocalNodeModulesInstall(installPath) ||
129
+ isNpxCacheInstall(installPath)
130
+ ? "npx sequant"
131
+ : "sequant";
132
+ }
95
133
  /**
96
134
  * Walk up from the given directory to find the directory containing
97
135
  * sequant's package.json. Returns null if not found.
@@ -314,7 +352,12 @@ export function getVersionWarning(currentVersion, latestVersion, isLocal) {
314
352
  const isLocalInstall = isLocal ?? isLocalNodeModulesInstall();
315
353
  if (isLocalInstall) {
316
354
  const pm = detectPackageManagerSync();
317
- const pmConfig = getPackageManagerCommands(pm);
355
+ // Resolved, not read off PM_CONFIG: `updatePkg` is one of the fields whose
356
+ // yarn spelling depends on the major (berry `yarn up` vs classic
357
+ // `yarn upgrade`), and this string is a command we are telling the user to
358
+ // run. Both `detectPackageManagerSync` and the resolver read the cwd, which
359
+ // is the project this local install belongs to (#871).
360
+ const pmConfig = resolvePackageManagerConfig(pm, process.cwd());
318
361
  return `sequant ${latestVersion} is available (you have ${currentVersion})
319
362
  Run: ${pmConfig.updatePkg} sequant
320
363
  Note: You have sequant as a local dependency. npx uses your node_modules version.`;