sequant 2.9.0 → 2.10.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 (188) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +14 -3
  4. package/dist/bin/cli.js +97 -17
  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.js +2 -0
  36. package/dist/src/commands/logs.js +14 -5
  37. package/dist/src/commands/merge.js +154 -2
  38. package/dist/src/commands/ready-tui-adapter.js +6 -1
  39. package/dist/src/commands/ready.d.ts +25 -3
  40. package/dist/src/commands/ready.js +39 -13
  41. package/dist/src/commands/resume.d.ts +113 -0
  42. package/dist/src/commands/resume.js +193 -0
  43. package/dist/src/commands/run-display.js +37 -4
  44. package/dist/src/commands/run-flags.d.ts +42 -1
  45. package/dist/src/commands/run-flags.js +53 -1
  46. package/dist/src/commands/run-progress.js +26 -1
  47. package/dist/src/commands/run.js +11 -10
  48. package/dist/src/commands/state.js +7 -0
  49. package/dist/src/commands/status.d.ts +9 -0
  50. package/dist/src/commands/status.js +24 -1
  51. package/dist/src/commands/sync.js +90 -19
  52. package/dist/src/commands/update.js +28 -5
  53. package/dist/src/lib/ac-parser.d.ts +14 -0
  54. package/dist/src/lib/ac-parser.js +99 -5
  55. package/dist/src/lib/assess/index.d.ts +10 -0
  56. package/dist/src/lib/assess/index.js +9 -0
  57. package/dist/src/lib/assess/renderer.d.ts +64 -0
  58. package/dist/src/lib/assess/renderer.js +481 -0
  59. package/dist/src/lib/assess/types.d.ts +224 -0
  60. package/dist/src/lib/assess/types.js +241 -0
  61. package/dist/src/lib/ci/outputs.d.ts +15 -2
  62. package/dist/src/lib/ci/outputs.js +17 -5
  63. package/dist/src/lib/cli-flags.d.ts +33 -0
  64. package/dist/src/lib/cli-flags.js +46 -0
  65. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
  66. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  67. package/dist/src/lib/cli-ui/run-renderer.js +62 -0
  68. package/dist/src/lib/errors.d.ts +78 -6
  69. package/dist/src/lib/errors.js +133 -15
  70. package/dist/src/lib/locks/index.d.ts +3 -3
  71. package/dist/src/lib/locks/index.js +2 -2
  72. package/dist/src/lib/locks/lock-manager.d.ts +20 -2
  73. package/dist/src/lib/locks/lock-manager.js +47 -3
  74. package/dist/src/lib/locks/types.d.ts +34 -3
  75. package/dist/src/lib/locks/types.js +16 -0
  76. package/dist/src/lib/mcp-config.d.ts +45 -0
  77. package/dist/src/lib/mcp-config.js +77 -1
  78. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  79. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  80. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  81. package/dist/src/lib/merge-check/command-result.js +56 -0
  82. package/dist/src/lib/merge-check/index.d.ts +4 -0
  83. package/dist/src/lib/merge-check/index.js +1 -1
  84. package/dist/src/lib/merge-check/report.js +5 -1
  85. package/dist/src/lib/merge-check/types.d.ts +9 -0
  86. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  87. package/dist/src/lib/merge-check/watch.js +215 -0
  88. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  89. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  90. package/dist/src/lib/settings.d.ts +14 -0
  91. package/dist/src/lib/settings.js +61 -8
  92. package/dist/src/lib/shutdown.d.ts +42 -2
  93. package/dist/src/lib/shutdown.js +75 -5
  94. package/dist/src/lib/skills-check.d.ts +26 -0
  95. package/dist/src/lib/skills-check.js +31 -0
  96. package/dist/src/lib/stacks.d.ts +110 -1
  97. package/dist/src/lib/stacks.js +244 -5
  98. package/dist/src/lib/templates.d.ts +74 -0
  99. package/dist/src/lib/templates.js +171 -16
  100. package/dist/src/lib/test-tautology-detector.d.ts +9 -2
  101. package/dist/src/lib/test-tautology-detector.js +153 -12
  102. package/dist/src/lib/version-check.d.ts +31 -0
  103. package/dist/src/lib/version-check.js +45 -2
  104. package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
  105. package/dist/src/lib/workflow/batch-executor.js +477 -44
  106. package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
  107. package/dist/src/lib/workflow/chain-resume.js +24 -20
  108. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  109. package/dist/src/lib/workflow/completed-status.js +57 -0
  110. package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
  111. package/dist/src/lib/workflow/config-resolver.js +54 -2
  112. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  113. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  114. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  115. package/dist/src/lib/workflow/drivers/claude-code.d.ts +14 -0
  116. package/dist/src/lib/workflow/drivers/claude-code.js +20 -2
  117. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  118. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  119. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  120. package/dist/src/lib/workflow/heartbeat.js +90 -0
  121. package/dist/src/lib/workflow/log-writer.d.ts +44 -1
  122. package/dist/src/lib/workflow/log-writer.js +75 -6
  123. package/dist/src/lib/workflow/metrics-schema.d.ts +3 -3
  124. package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
  125. package/dist/src/lib/workflow/phase-executor.js +457 -52
  126. package/dist/src/lib/workflow/phase-registry.js +2 -1
  127. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  128. package/dist/src/lib/workflow/platforms/github.js +152 -0
  129. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  130. package/dist/src/lib/workflow/qa-cache.js +22 -9
  131. package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
  132. package/dist/src/lib/workflow/ready-gate.js +25 -9
  133. package/dist/src/lib/workflow/run-log-schema.d.ts +14 -0
  134. package/dist/src/lib/workflow/run-log-schema.js +47 -1
  135. package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
  136. package/dist/src/lib/workflow/run-orchestrator.js +141 -20
  137. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  138. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  139. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  140. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  141. package/dist/src/lib/workflow/state-manager.js +83 -0
  142. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  143. package/dist/src/lib/workflow/state-schema.js +47 -0
  144. package/dist/src/lib/workflow/types.d.ts +126 -4
  145. package/dist/src/lib/workflow/types.js +1 -0
  146. package/dist/src/lib/workflow/worktree-manager.d.ts +28 -2
  147. package/dist/src/lib/workflow/worktree-manager.js +102 -23
  148. package/dist/src/mcp/tools/run.d.ts +9 -1
  149. package/dist/src/mcp/tools/run.js +20 -1
  150. package/dist/src/mcp/tools/status.js +6 -0
  151. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  152. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  153. package/dist/src/ui/tui/IssueBox.js +10 -2
  154. package/dist/src/ui/tui/row-cap.js +2 -1
  155. package/dist/src/ui/tui/theme.d.ts +7 -0
  156. package/dist/src/ui/tui/theme.js +9 -0
  157. package/package.json +8 -7
  158. package/templates/hooks/parallel-marker.sh +50 -0
  159. package/templates/hooks/post-tool.sh +20 -7
  160. package/templates/hooks/pre-tool.sh +75 -10
  161. package/templates/scripts/cleanup-worktree.sh +182 -19
  162. package/templates/scripts/new-feature.sh +264 -16
  163. package/templates/skills/_shared/references/trust-model.md +18 -0
  164. package/templates/skills/assess/SKILL.md +215 -390
  165. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  166. package/templates/skills/docs/SKILL.md +4 -4
  167. package/templates/skills/exec/SKILL.md +26 -6
  168. package/templates/skills/fullsolve/SKILL.md +3 -1
  169. package/templates/skills/loop/SKILL.md +38 -2
  170. package/templates/skills/merger/SKILL.md +2 -2
  171. package/templates/skills/qa/SKILL.md +337 -57
  172. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  173. package/templates/skills/qa/references/call-site-review.md +2 -2
  174. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  175. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  176. package/templates/skills/qa/references/quality-gates.md +21 -7
  177. package/templates/skills/qa/references/testing-requirements.md +1 -1
  178. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  179. package/templates/skills/release/SKILL.md +1 -1
  180. package/templates/skills/setup/SKILL.md +5 -5
  181. package/templates/skills/spec/SKILL.md +2 -0
  182. package/templates/skills/test/SKILL.md +1 -1
  183. package/dist/src/lib/phase-spinner.d.ts +0 -146
  184. package/dist/src/lib/phase-spinner.js +0 -255
  185. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  186. package/dist/src/lib/workflow/pr-operations.js +0 -326
  187. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  188. package/dist/src/lib/workflow/run-summary.js +0 -142
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Stack detection and configuration
3
3
  */
4
- import { existsSync } from "fs";
4
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from "fs";
5
5
  import { readdir } from "fs/promises";
6
+ import { join } from "path";
6
7
  import { fileExists, readFile } from "./fs.js";
7
8
  /**
8
9
  * Directories to skip during multi-stack detection
@@ -30,6 +31,7 @@ export const PM_CONFIG = {
30
31
  exec: "npx",
31
32
  install: "npm install",
32
33
  installSilent: "npm install --silent",
34
+ ciInstall: "npm ci",
33
35
  addPkg: "npm install",
34
36
  removePkg: "npm uninstall",
35
37
  updatePkg: "npm update",
@@ -39,34 +41,62 @@ export const PM_CONFIG = {
39
41
  exec: "bunx",
40
42
  install: "bun install",
41
43
  installSilent: "bun install --silent",
44
+ ciInstall: "bun install --frozen-lockfile",
42
45
  addPkg: "bun add",
43
46
  removePkg: "bun remove",
44
47
  updatePkg: "bun update",
45
48
  },
49
+ // Yarn 1 (classic) and Yarn 2+ (berry) are different CLIs sharing one
50
+ // lockfile name, so no single table can be right for both. This entry is the
51
+ // BERRY baseline; `resolvePackageManagerConfig(pm, root)` swaps in the classic
52
+ // spelling for the fields that differ. Read yarn commands through that
53
+ // resolver, never off this table, at any site that knows which project
54
+ // directory it is acting on (#871).
55
+ //
56
+ // Fields with no major-specific difference — `run`, `install`, `addPkg`,
57
+ // `removePkg` — are shared, so the resolver leaves them alone.
46
58
  yarn: {
47
59
  run: "yarn",
60
+ // Berry-only: `dlx` does not exist in Yarn 1. Resolver → `npx` for classic.
48
61
  exec: "yarn dlx",
49
62
  install: "yarn install",
63
+ // Left at the classic spelling deliberately. Berry's `yarn install` takes no
64
+ // `--silent`, but this field has NO consumer anywhere in the codebase, and
65
+ // guessing berry's quiet spelling without a yarn binary to check against
66
+ // would trade a dead-config wart for a real one — the same call
67
+ // new-feature.sh's `pm_quiet_flag` already made for pnpm/bun. Known
68
+ // remaining hybrid, and the one field the resolver does not fix.
50
69
  installSilent: "yarn install --silent",
70
+ // Berry-only: Yarn 1 rejects `--immutable` and wants `--frozen-lockfile`.
71
+ // Resolver → YARN_CLASSIC_CI_INSTALL for classic.
72
+ ciInstall: "yarn install --immutable",
51
73
  addPkg: "yarn add",
52
74
  removePkg: "yarn remove",
53
- updatePkg: "yarn upgrade",
75
+ // Berry renamed `yarn upgrade` to `yarn up`. This was the classic spelling
76
+ // until #871's follow-up, which made the whole entry berry-consistent —
77
+ // meaning the upgrade hint in version-check.ts was wrong for berry users.
78
+ // Resolver → `yarn upgrade` for classic.
79
+ updatePkg: "yarn up",
54
80
  },
55
81
  pnpm: {
56
82
  run: "pnpm run",
57
83
  exec: "pnpm dlx",
58
84
  install: "pnpm install",
59
85
  installSilent: "pnpm install --silent",
86
+ ciInstall: "pnpm install --frozen-lockfile",
60
87
  addPkg: "pnpm add",
61
88
  removePkg: "pnpm remove",
62
89
  updatePkg: "pnpm update",
63
90
  },
64
- // Python package managers
91
+ // Python package managers. None of these has a distinct "frozen lockfile"
92
+ // install mode the way npm/pnpm/yarn/bun do (poetry install already honors
93
+ // poetry.lock), so ciInstall mirrors installSilent rather than inventing a flag.
65
94
  pip: {
66
95
  run: "python -m",
67
96
  exec: "python -m",
68
97
  install: "pip install",
69
98
  installSilent: "pip install -q",
99
+ ciInstall: "pip install -q",
70
100
  addPkg: "pip install",
71
101
  removePkg: "pip uninstall",
72
102
  updatePkg: "pip install --upgrade",
@@ -76,6 +106,7 @@ export const PM_CONFIG = {
76
106
  exec: "poetry run",
77
107
  install: "poetry install",
78
108
  installSilent: "poetry install -q",
109
+ ciInstall: "poetry install -q",
79
110
  addPkg: "poetry add",
80
111
  removePkg: "poetry remove",
81
112
  updatePkg: "poetry update",
@@ -85,6 +116,7 @@ export const PM_CONFIG = {
85
116
  exec: "uvx",
86
117
  install: "uv pip install",
87
118
  installSilent: "uv pip install -q",
119
+ ciInstall: "uv pip install -q",
88
120
  addPkg: "uv pip install",
89
121
  removePkg: "uv pip uninstall",
90
122
  updatePkg: "uv pip install --upgrade",
@@ -143,21 +175,228 @@ export async function detectPackageManager() {
143
175
  /**
144
176
  * Synchronous version of detectPackageManager for use in startup code.
145
177
  * Only checks JS lockfiles (not Python) since sequant is a Node.js tool.
178
+ *
179
+ * @param root Directory to look for lockfiles in. Defaults to the process cwd,
180
+ * which is what startup code wants; callers that operate on a specific
181
+ * repository (e.g. merge-check) must pass that repo's root instead.
146
182
  */
147
- export function detectPackageManagerSync() {
183
+ export function detectPackageManagerSync(root = process.cwd()) {
148
184
  for (const { file, pm } of LOCKFILE_PRIORITY) {
149
- if (existsSync(file)) {
185
+ if (existsSync(join(root, file))) {
150
186
  return pm;
151
187
  }
152
188
  }
153
189
  return "npm";
154
190
  }
191
+ /**
192
+ * Resolve the package manager for a directory, preferring a declared value and
193
+ * falling back to live lockfile detection.
194
+ *
195
+ * The declared value is a *snapshot* — the worktree manifest records whatever
196
+ * `sequant init` saw, so it is absent on manifest-less or pre-init trees. The
197
+ * old spelling at those call sites was `(declared as keyof typeof PM_CONFIG)
198
+ * || "npm"`, which assumed npm whenever the snapshot was missing while the
199
+ * shell provisioning path (`templates/scripts/new-feature.sh`, PM-aware since
200
+ * #847) detected live from the lockfile. On a pnpm/yarn/bun project without a
201
+ * manifest the two paths therefore disagreed about the project's package
202
+ * manager — the dual-producer drift class of #833. Detecting here makes the
203
+ * TypeScript path agree with the shell path by construction (#870).
204
+ *
205
+ * The own-property membership test also retires that unchecked cast: a
206
+ * declared value outside `PackageManager` (a hand-edited manifest, a future
207
+ * `"npm@10"` spelling) used to index `PM_CONFIG` to `undefined` and throw on
208
+ * the next property access. Such a value now routes to detection instead.
209
+ * Python managers (`pip`/`poetry`/`uv`) are `PM_CONFIG` keys, so a declared
210
+ * Python manager still wins over the JS-only detector.
211
+ *
212
+ * The test is `hasOwnProperty`, deliberately not `in`: `in` walks the
213
+ * prototype chain, so `"toString"`, `"constructor"`, and `"__proto__"` would
214
+ * all pass it and be returned as if they named a package manager. Indexing
215
+ * `PM_CONFIG` with one yields an inherited function (or `Object.prototype`)
216
+ * whose `ciInstall` is `undefined`, crashing the caller on `.split(" ")` —
217
+ * reintroducing the exact failure this guard exists to prevent.
218
+ *
219
+ * @param declared Package manager recorded in the manifest, if any
220
+ * @param root Directory whose lockfiles decide the fallback
221
+ */
222
+ export function resolvePackageManager(declared, root) {
223
+ if (declared && Object.prototype.hasOwnProperty.call(PM_CONFIG, declared)) {
224
+ return declared;
225
+ }
226
+ return detectPackageManagerSync(root);
227
+ }
155
228
  /**
156
229
  * Get package manager command configuration
157
230
  */
158
231
  export function getPackageManagerCommands(pm) {
159
232
  return PM_CONFIG[pm];
160
233
  }
234
+ /**
235
+ * Frozen install understood by Yarn 1 (classic).
236
+ *
237
+ * Yarn 2+ (berry) renamed this flag to `--immutable`, which is what
238
+ * `PM_CONFIG.yarn.ciInstall` carries. Both yarn spellings therefore exist, and
239
+ * both must appear in `new-feature.sh`'s `pm_ci_install()` table — the drift
240
+ * guard in `__tests__/new-feature-frozen-install.integration.test.ts` asserts
241
+ * exactly that, which is why this is a named export rather than a literal
242
+ * buried in `resolvePackageManagerConfig` (#871).
243
+ */
244
+ export const YARN_CLASSIC_CI_INSTALL = "yarn install --frozen-lockfile";
245
+ /**
246
+ * Yarn 1 (classic) spellings for every `PM_CONFIG.yarn` field whose berry value
247
+ * is wrong on classic. Applied by {@link resolvePackageManagerConfig}.
248
+ *
249
+ * `exec` is `npx` rather than a `yarn` subcommand because Yarn 1 has no
250
+ * `dlx` equivalent at all — npm ships with node, so `npx` is the run-a-binary
251
+ * -without-installing story a Yarn 1 project actually has.
252
+ *
253
+ * `installSilent` is deliberately absent — see the note on `PM_CONFIG.yarn`.
254
+ */
255
+ const YARN_CLASSIC_OVERRIDES = {
256
+ ciInstall: YARN_CLASSIC_CI_INSTALL,
257
+ exec: "npx",
258
+ updatePkg: "yarn upgrade",
259
+ };
260
+ /**
261
+ * Bytes of `yarn.lock` scanned for its version header.
262
+ *
263
+ * Matches the `head -c` byte count in `new-feature.sh`'s `detect_yarn_major`.
264
+ * Keeping the scanned region identical is what makes the two implementations
265
+ * agree by construction rather than by inspection: a whole-file `grep` in the
266
+ * shell against a prefix-only read in TypeScript would silently diverge on a
267
+ * lockfile that happens to mention the header string further down.
268
+ */
269
+ const YARN_LOCK_HEADER_BYTES = 1024;
270
+ /**
271
+ * Read the `packageManager` pin's yarn major from `package.json`, if declared.
272
+ *
273
+ * Deliberately a regex rather than `JSON.parse`: the shell path cannot parse
274
+ * JSON without adding a `jq` dependency, so both paths match on text. A
275
+ * non-numeric pin (`yarn@stable`, `yarn@berry`) does not match and falls
276
+ * through to the next signal.
277
+ *
278
+ * Matching on text does NOT make the two paths agree for free — `\s*` here
279
+ * spans newlines and `.match` returns the first hit, neither of which a
280
+ * line-oriented `sed`/`grep` gives you. `detect_yarn_major` in
281
+ * `new-feature.sh` collapses newlines and takes the first match specifically
282
+ * to line up with this; see its comment for the two inputs that diverged
283
+ * before it did.
284
+ *
285
+ * @returns the declared major, or null when nothing is declared
286
+ */
287
+ function readDeclaredYarnMajor(root) {
288
+ let raw;
289
+ try {
290
+ raw = readFileSync(join(root, "package.json"), "utf8");
291
+ }
292
+ catch {
293
+ return null;
294
+ }
295
+ const match = raw.match(/"packageManager"\s*:\s*"yarn@v?(\d+)/);
296
+ if (!match) {
297
+ return null;
298
+ }
299
+ // Every major above 1 is berry and shares berry's CLI surface, so the return
300
+ // type is the *behavioral* split (classic vs. berry), not the literal major.
301
+ return match[1] === "1" ? 1 : 2;
302
+ }
303
+ /** Read the first {@link YARN_LOCK_HEADER_BYTES} of `yarn.lock` ("" if absent). */
304
+ function readYarnLockHeader(root) {
305
+ let fd;
306
+ try {
307
+ fd = openSync(join(root, "yarn.lock"), "r");
308
+ }
309
+ catch {
310
+ return "";
311
+ }
312
+ try {
313
+ const buffer = Buffer.alloc(YARN_LOCK_HEADER_BYTES);
314
+ const bytesRead = readSync(fd, buffer, 0, YARN_LOCK_HEADER_BYTES, 0);
315
+ return buffer.toString("utf8", 0, bytesRead);
316
+ }
317
+ finally {
318
+ closeSync(fd);
319
+ }
320
+ }
321
+ /**
322
+ * Detect whether a directory is a Yarn 1 (classic) or Yarn 2+ (berry) project.
323
+ *
324
+ * Yarn 1 and berry both use `yarn.lock`, so `detectPackageManagerSync` cannot
325
+ * tell them apart — it answers "yarn", and this answers "which yarn". They are
326
+ * separate axes on purpose: adding a `yarn1` member to `PackageManager` would
327
+ * leak a synthetic name into settings (`pmRun`), doctor output, and templates
328
+ * for what is, today, a one-flag difference.
329
+ *
330
+ * Signals, in order of authority. The ordering follows a single rule: **flag
331
+ * acceptance is decided by the yarn binary that runs, not by the lockfile it
332
+ * reads.** So a Corepack pin outranks the lockfile header — a yarn-1 lockfile
333
+ * under `packageManager: "yarn@4"` still gets berry's `--immutable`, because
334
+ * yarn 4 is what will execute and yarn 4 rejects `--frozen-lockfile`.
335
+ *
336
+ * 1. `packageManager: "yarn@<major>"` in `package.json` — Corepack pins the
337
+ * binary.
338
+ * 2. `.yarnrc.yml` — a berry-only config file; yarn 1 reads `.yarnrc`.
339
+ * 3. `yarn.lock` header — yarn 1 writes `# yarn lockfile v1`; berry writes
340
+ * `__metadata:` instead.
341
+ * 4. Nothing recognizable → berry, the pre-#871 assumption. Staying with it
342
+ * keeps a contentless or unreadable `yarn.lock` behaving exactly as it did
343
+ * before this function existed.
344
+ *
345
+ * Mirrored by `detect_yarn_major` in `templates/scripts/new-feature.sh` (#871).
346
+ *
347
+ * @internal Exported for testing — production code should go through
348
+ * {@link resolvePackageManagerConfig}, whose whole job is to apply this answer.
349
+ * The export exists so the cross-path agreement test can run this and the shell
350
+ * mirror against the same fixtures, which is the gate keeping the two in step.
351
+ *
352
+ * @param root Directory to inspect. Defaults to the process cwd.
353
+ * @returns 1 for Yarn 1 (classic), 2 for Yarn 2+ (berry)
354
+ */
355
+ export function detectYarnMajor(root = process.cwd()) {
356
+ const declared = readDeclaredYarnMajor(root);
357
+ if (declared !== null) {
358
+ return declared;
359
+ }
360
+ if (existsSync(join(root, ".yarnrc.yml"))) {
361
+ return 2;
362
+ }
363
+ if (readYarnLockHeader(root).includes("yarn lockfile v1")) {
364
+ return 1;
365
+ }
366
+ return 2;
367
+ }
368
+ /**
369
+ * Package manager commands for `pm`, with directory-dependent commands resolved
370
+ * against `root`.
371
+ *
372
+ * A drop-in replacement for `PM_CONFIG[pm]` at every call site that knows which
373
+ * project directory it is acting on. `PM_CONFIG.yarn` is the berry baseline, so
374
+ * for a Yarn 1 project this substitutes every field whose berry spelling is
375
+ * wrong on classic — `ciInstall`, `exec`, and `updatePkg` (see
376
+ * {@link YARN_CLASSIC_OVERRIDES}). Every other package manager, and every
377
+ * major-agnostic yarn field, is returned untouched.
378
+ *
379
+ * Returning the whole config rather than one resolved string is what makes that
380
+ * cheap: call sites that thread a `PackageManagerConfig` onward — merge-check's
381
+ * combined-branch test reads `ciInstall` for three commands and two user-facing
382
+ * messages — stay correct with a single-line change, and each newly discovered
383
+ * classic/berry split is one entry in the overrides table rather than a new
384
+ * call-site edit.
385
+ *
386
+ * Resolve against the tree you are about to act on, not against wherever the
387
+ * process started: `root` decides the answer, and in merge-check the combined
388
+ * (post-merge) state can disagree with the pre-merge one.
389
+ *
390
+ * @param pm Package manager identity, e.g. from `detectPackageManagerSync`
391
+ * @param root Directory whose files decide the directory-dependent commands
392
+ */
393
+ export function resolvePackageManagerConfig(pm, root) {
394
+ const config = PM_CONFIG[pm];
395
+ if (pm !== "yarn" || detectYarnMajor(root) !== 1) {
396
+ return config;
397
+ }
398
+ return { ...config, ...YARN_CLASSIC_OVERRIDES };
399
+ }
161
400
  export const STACKS = {
162
401
  nextjs: {
163
402
  name: "nextjs",
@@ -1,7 +1,47 @@
1
1
  /**
2
2
  * Template management - copy and process templates
3
3
  */
4
+ /**
5
+ * How well a candidate path matches "the templates root of *this* package".
6
+ *
7
+ * `package-root` outranks `exists` because the two offsets are not mutually
8
+ * exclusive: from a source tree at `<repo>/src/lib`, the *compiled* offset
9
+ * resolves to `<repo>/../templates` — a directory one level **above** the repo.
10
+ * A bare existence probe would happily bind to an unrelated `templates/` that
11
+ * happens to sit beside the checkout. Requiring the candidate's parent to be
12
+ * the sequant package root removes that ambiguity without walking the tree.
13
+ */
14
+ export type TemplatesCandidateRank = "package-root" | "exists" | "missing";
15
+ /**
16
+ * Resolve the templates root relative to a module directory, given a ranking
17
+ * function.
18
+ *
19
+ * Split out from `getTemplatesDir` purely so both layouts are testable: a test
20
+ * cannot relocate `import.meta.url`, so without injection the "works from both
21
+ * layouts" assertion could only ever exercise whichever layout the test runner
22
+ * happens to use.
23
+ *
24
+ * Resolution order: the first `package-root` candidate, else the first that
25
+ * merely `exists` (keeps any layout that works today working, even one whose
26
+ * package root is not where we expect), else the **compiled** offset — so the
27
+ * caller's error message names the canonical expected location rather than a
28
+ * source-tree guess.
29
+ */
30
+ export declare function resolveTemplatesDirFrom(baseDir: string, rank: (candidate: string) => TemplatesCandidateRank): string;
4
31
  export declare function getTemplatesDir(): string;
32
+ /**
33
+ * Resolve the templates root and fail loudly when it does not exist.
34
+ *
35
+ * A missing templates *root* means the install is broken: every `copyDir` call
36
+ * below would hit `copyDir`'s per-directory ENOENT skip, no-op, and let the
37
+ * caller print a success message over an empty tree (#822). That skip is
38
+ * deliberate for individual subdirectories — a stack may legitimately ship
39
+ * without `memory/` — but it must not absorb the whole source tree.
40
+ *
41
+ * Throws rather than printing so the lib layer stays free of presentation;
42
+ * commands catch and render.
43
+ */
44
+ export declare function assertTemplatesDirExists(): Promise<string>;
5
45
  /**
6
46
  * Process template variables in content
7
47
  */
@@ -52,6 +92,25 @@ export interface TemplateChange {
52
92
  /** Unified-ish diff (installed → rendered), only set for `modified` */
53
93
  diff?: string;
54
94
  }
95
+ /**
96
+ * Map a bundled template path to the location `copyTemplates` installs it to,
97
+ * or `null` for templates the copy/diff cycle does not manage.
98
+ *
99
+ * This mirrors the routing in `copyTemplates` (write time) so
100
+ * `computeTemplateChanges` (diff time) can never report drift on a file init
101
+ * deliberately places elsewhere — `.claude/mcp.json`, `.claude/relay/*`, and
102
+ * `.claude/scripts/*` read as phantom "new" files on every fresh install when
103
+ * the diff assumed a flat `.claude/<relpath>` layout. #708 gives the same
104
+ * guarantee for the template *variables*; this covers the *destinations*.
105
+ *
106
+ * - `templates/mcp.json` → `null`: the project `.mcp.json` is generated and
107
+ * version-pinned by `mcp-config.ts` (`syncSequantMcpPin`), never copied.
108
+ * - `templates/relay/` → `null`: not installed by `copyTemplates`.
109
+ * - `templates/scripts/` → `scripts/dev/`: symlinked on POSIX, copied on
110
+ * Windows / `--no-symlinks`.
111
+ * - everything else → `.claude/<relpath>`.
112
+ */
113
+ export declare function templateDestination(templatePath: string): string | null;
55
114
  /**
56
115
  * Compare bundled template content against what's installed under `.claude/`.
57
116
  *
@@ -83,6 +142,15 @@ export interface CopyTemplatesOptions {
83
142
  noSymlinks?: boolean;
84
143
  /** Force replacement of existing files/symlinks */
85
144
  force?: boolean;
145
+ /**
146
+ * Opt in to overwriting in-place customizations (files in `CUSTOMIZABLE_FILES`,
147
+ * e.g. the constitution) that already exist and differ from the rendered
148
+ * template. Deliberately separate from `force`: `force` refreshes the managed
149
+ * skills/agents/hooks trees, but that always-on tree overwrite must NOT imply
150
+ * consent to clobber user-owned files. Only an explicit user `--force` sets
151
+ * this. A missing or identical customizable file is written regardless (#814).
152
+ */
153
+ overwriteCustomizable?: boolean;
86
154
  /** Additional stacks to include in constitution notes (for multi-stack projects) */
87
155
  additionalStacks?: string[];
88
156
  }
@@ -102,4 +170,10 @@ export declare function symlinkDir(srcDir: string, destDir: string, options?: {
102
170
  export declare function copyTemplates(stack: string, tokens?: Record<string, string>, options?: CopyTemplatesOptions): Promise<{
103
171
  scriptsSymlinked: boolean;
104
172
  symlinkResults?: SymlinkResult[];
173
+ /**
174
+ * Customizable files that already existed, differed from the rendered
175
+ * template, and were left untouched because `overwriteCustomizable` was not
176
+ * set. Normalized to forward slashes so callers can report them verbatim.
177
+ */
178
+ preservedCustomizable: string[];
105
179
  }>;
@@ -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
  }