sequant 2.6.1 → 2.7.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +4 -0
  4. package/dist/bin/cli.js +28 -4
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +3 -0
  7. package/dist/marketplace/external_plugins/sequant/skills/clean/SKILL.md +3 -0
  8. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +3 -0
  9. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +3 -0
  10. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +3 -0
  11. package/dist/marketplace/external_plugins/sequant/skills/improve/SKILL.md +4 -1
  12. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +3 -0
  13. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +3 -0
  14. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +3 -0
  15. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +3 -0
  16. package/dist/marketplace/external_plugins/sequant/skills/security-review/SKILL.md +3 -0
  17. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +3 -0
  18. package/dist/marketplace/external_plugins/sequant/skills/solve/SKILL.md +3 -0
  19. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +3 -0
  20. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +3 -0
  21. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +3 -0
  22. package/dist/marketplace/external_plugins/sequant/skills/verify/SKILL.md +3 -0
  23. package/dist/src/commands/ready.js +1 -1
  24. package/dist/src/commands/run.js +1 -1
  25. package/dist/src/commands/sync.d.ts +44 -5
  26. package/dist/src/commands/sync.js +244 -18
  27. package/dist/src/commands/update.d.ts +1 -0
  28. package/dist/src/commands/update.js +80 -68
  29. package/dist/src/lib/templates.d.ts +50 -0
  30. package/dist/src/lib/templates.js +134 -15
  31. package/dist/src/ui/tui/App.js +24 -2
  32. package/dist/src/ui/tui/IssueBox.js +4 -4
  33. package/dist/src/ui/tui/load.d.ts +25 -0
  34. package/dist/src/ui/tui/load.js +41 -0
  35. package/dist/src/ui/tui/theme.d.ts +21 -3
  36. package/dist/src/ui/tui/theme.js +22 -4
  37. package/package.json +1 -1
  38. package/templates/skills/assess/SKILL.md +3 -0
  39. package/templates/skills/clean/SKILL.md +3 -0
  40. package/templates/skills/docs/SKILL.md +3 -0
  41. package/templates/skills/exec/SKILL.md +3 -0
  42. package/templates/skills/fullsolve/SKILL.md +3 -0
  43. package/templates/skills/improve/SKILL.md +4 -1
  44. package/templates/skills/loop/SKILL.md +3 -0
  45. package/templates/skills/merger/SKILL.md +3 -0
  46. package/templates/skills/qa/SKILL.md +3 -0
  47. package/templates/skills/reflect/SKILL.md +3 -0
  48. package/templates/skills/security-review/SKILL.md +3 -0
  49. package/templates/skills/setup/SKILL.md +3 -0
  50. package/templates/skills/solve/SKILL.md +3 -0
  51. package/templates/skills/spec/SKILL.md +3 -0
  52. package/templates/skills/test/SKILL.md +3 -0
  53. package/templates/skills/testgen/SKILL.md +3 -0
  54. package/templates/skills/verify/SKILL.md +3 -0
@@ -5,14 +5,23 @@
5
5
  * Designed for plugin users who need to update after upgrading sequant.
6
6
  */
7
7
  import chalk from "chalk";
8
+ import { join } from "path";
9
+ import { createHash } from "crypto";
8
10
  import { getManifest, updateManifest, getPackageVersion, } from "../lib/manifest.js";
9
- import { copyTemplates } from "../lib/templates.js";
11
+ import { copyTemplates, computeTemplateChanges, listTemplateFiles, getTemplatesDir, } from "../lib/templates.js";
10
12
  import { getConfig } from "../lib/config.js";
11
- import { writeFile, readFile, fileExists } from "../lib/fs.js";
13
+ import { writeFile, readFile, fileExists, getFileStats } from "../lib/fs.js";
12
14
  import { generateAgentsMd, writeAgentsMd, AGENTS_MD_PATH, } from "../lib/agents-md.js";
13
15
  import { getProjectName } from "../lib/project-name.js";
14
16
  import { getStackConfig } from "../lib/stacks.js";
15
17
  const SKILLS_VERSION_PATH = ".claude/skills/.sequant-version";
18
+ // Where the cheap drift-fingerprint cache lives (gitignored via `**/.sequant/`).
19
+ const DRIFT_CACHE_PATH = ".claude/.sequant/.skills-drift-cache.json";
20
+ // Mirrors config.ts / manifest.ts (those constants are module-private). These
21
+ // install paths are stable; we stat them only to invalidate the drift cache
22
+ // when the project's config tokens or manifest stack change.
23
+ const CONFIG_FILE_PATH = ".claude/.sequant/config.json";
24
+ const MANIFEST_FILE_PATH = ".sequant-manifest.json";
16
25
  /**
17
26
  * Get the version of skills currently installed
18
27
  */
@@ -29,16 +38,141 @@ export async function getSkillsVersion() {
29
38
  }
30
39
  }
31
40
  /**
32
- * Check if skills are outdated compared to package version
41
+ * Cheap stat-only fingerprint of every input that can change the content-drift
42
+ * result: package version plus the mtime (or absence) of each bundled template,
43
+ * its installed counterpart, any `.claude/.local/` override, and the config and
44
+ * manifest. A full read+render+diff scan is ~15ms per command; this fingerprint
45
+ * is ~2-5ms, so the per-command pre-flight can skip the scan when nothing that
46
+ * affects drift has changed (AC-5). A per-file hash (not a max-mtime) is used so
47
+ * editing an *older* file — whose new mtime may still trail another file's —
48
+ * still changes the fingerprint and forces a rescan (no missed warnings).
49
+ *
50
+ * Returns `null` if it cannot be computed; the caller then scans uncached.
33
51
  */
34
- export async function areSkillsOutdated() {
52
+ async function computeDriftFingerprint(packageVersion) {
53
+ try {
54
+ const templateFiles = await listTemplateFiles();
55
+ const templatesDir = getTemplatesDir();
56
+ const lines = [`v=${packageVersion}`];
57
+ const addPath = async (fsPath, key) => {
58
+ try {
59
+ const stats = await getFileStats(fsPath);
60
+ lines.push(`${key}:${Math.round(stats.mtimeMs)}`);
61
+ }
62
+ catch {
63
+ // Missing file is itself signal: a `.local` override or installed file
64
+ // appearing/disappearing flips this line and invalidates the cache.
65
+ lines.push(`${key}:absent`);
66
+ }
67
+ };
68
+ for (const templatePath of templateFiles) {
69
+ const normalized = templatePath.replace(/\\/g, "/");
70
+ const localPath = normalized.replace("templates/", ".claude/");
71
+ if (localPath.includes(".local/"))
72
+ continue;
73
+ const templateFsPath = join(templatesDir, normalized.replace("templates/", ""));
74
+ const overridePath = localPath.replace(".claude/", ".claude/.local/");
75
+ await addPath(templateFsPath, `t:${normalized}`);
76
+ await addPath(localPath, `l:${localPath}`);
77
+ await addPath(overridePath, `o:${overridePath}`);
78
+ }
79
+ await addPath(CONFIG_FILE_PATH, "config");
80
+ await addPath(MANIFEST_FILE_PATH, "manifest");
81
+ lines.sort();
82
+ return createHash("sha1").update(lines.join("\n")).digest("hex");
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ async function readDriftCache() {
89
+ try {
90
+ if (!(await fileExists(DRIFT_CACHE_PATH)))
91
+ return null;
92
+ const parsed = JSON.parse(await readFile(DRIFT_CACHE_PATH));
93
+ if (typeof parsed?.fingerprint === "string" &&
94
+ typeof parsed?.contentDrift === "number") {
95
+ return parsed;
96
+ }
97
+ return null;
98
+ }
99
+ catch {
100
+ // Corrupt/unreadable cache → treat as a miss; the scan path rebuilds it.
101
+ return null;
102
+ }
103
+ }
104
+ async function writeDriftCache(cache) {
105
+ try {
106
+ await writeFile(DRIFT_CACHE_PATH, JSON.stringify(cache));
107
+ }
108
+ catch {
109
+ // The cache is a pure optimization — never fail a command over a write miss
110
+ // (e.g. the `.claude/.sequant/` dir not existing yet).
111
+ }
112
+ }
113
+ /**
114
+ * Run the content-drift scan (the source-of-truth `computeTemplateChanges` diff),
115
+ * returning the count of `new`+`modified` files. When `useCache` is true (the
116
+ * per-command pre-flight), a stat-only fingerprint short-circuits the scan if no
117
+ * drift-affecting input changed since the last run. Callers that need fresh
118
+ * truth (`doctor`, and `sync` itself) leave caching off — the default.
119
+ */
120
+ async function computeContentDrift(packageVersion, useCache) {
121
+ let fingerprint = null;
122
+ if (useCache) {
123
+ fingerprint = await computeDriftFingerprint(packageVersion);
124
+ if (fingerprint) {
125
+ const cached = await readDriftCache();
126
+ if (cached && cached.fingerprint === fingerprint) {
127
+ return cached.contentDrift;
128
+ }
129
+ }
130
+ }
131
+ try {
132
+ const manifest = await getManifest();
133
+ if (!manifest)
134
+ return 0;
135
+ const config = await getConfig();
136
+ const tokens = config?.tokens || {};
137
+ const changes = await computeTemplateChanges(manifest.stack, tokens);
138
+ const contentDrift = changes.filter((c) => c.status === "new" || c.status === "modified").length;
139
+ if (useCache && fingerprint) {
140
+ await writeDriftCache({ fingerprint, contentDrift });
141
+ }
142
+ return contentDrift;
143
+ }
144
+ catch {
145
+ // The pre-flight must never break the actual command. If the content diff
146
+ // fails (missing templates, read error), treat it as "no detectable drift"
147
+ // and let the command proceed.
148
+ return 0;
149
+ }
150
+ }
151
+ /**
152
+ * Check if skills are outdated compared to package version.
153
+ *
154
+ * The version marker is only a cheap hint: a tree at the matching version can
155
+ * still have drifted bundled content in place (the #708 root cause). So when the
156
+ * marker matches we run the same content diff `sync` uses (`computeTemplateChanges`,
157
+ * the single source of truth from #708/#710) and surface a `contentDrift` count.
158
+ * On a version *mismatch* we skip the diff entirely — the install is already stale
159
+ * and the copy path handles it — keeping the per-command pre-flight cheap (AC-5).
160
+ *
161
+ * `options.cache` opts into a stat-only fingerprint cache for the content scan,
162
+ * so the hot pre-flight path (which runs before most commands, including batched
163
+ * `/assess` dashboard calls) pays the full ~15ms scan only when something that
164
+ * affects drift actually changed. Off by default so diagnostic callers (`doctor`)
165
+ * always see fresh truth.
166
+ */
167
+ export async function areSkillsOutdated(options = {}) {
35
168
  const currentVersion = await getSkillsVersion();
36
169
  const packageVersion = getPackageVersion();
37
- return {
38
- outdated: currentVersion !== packageVersion,
39
- currentVersion,
40
- packageVersion,
41
- };
170
+ const outdated = currentVersion !== packageVersion;
171
+ let contentDrift = 0;
172
+ if (!outdated) {
173
+ contentDrift = await computeContentDrift(packageVersion, options.cache === true);
174
+ }
175
+ return { outdated, currentVersion, packageVersion, contentDrift };
42
176
  }
43
177
  /**
44
178
  * Update the skills version marker
@@ -47,7 +181,7 @@ async function updateSkillsVersion() {
47
181
  await writeFile(SKILLS_VERSION_PATH, getPackageVersion());
48
182
  }
49
183
  export async function syncCommand(options = {}) {
50
- const { force = false, quiet = false } = options;
184
+ const { force = false, quiet = false, dryRun = false } = options;
51
185
  if (!quiet) {
52
186
  console.log(chalk.blue("\nSyncing templates...\n"));
53
187
  console.log(chalk.yellow("Note: For seamless auto-updates, install sequant as a Claude Code plugin:\n" +
@@ -68,16 +202,90 @@ export async function syncCommand(options = {}) {
68
202
  console.log(chalk.gray(`Package version: ${packageVersion}`));
69
203
  console.log(chalk.gray(`Stack: ${manifest.stack}\n`));
70
204
  }
71
- // Check if sync is needed
205
+ // Get config tokens for template processing
206
+ const config = await getConfig();
207
+ const tokens = config?.tokens || {};
208
+ // The version marker is only a fast-path hint — verify actual content before
209
+ // claiming "up to date". On a version match we still diff bundled templates
210
+ // against installed content (rendered with the same variables) so we never
211
+ // declare success while real drift sits in place (#708).
72
212
  if (!force && skillsVersion === packageVersion) {
213
+ const changes = await computeTemplateChanges(manifest.stack, tokens);
214
+ const drifted = changes.filter((c) => c.status === "new" || c.status === "modified");
215
+ if (drifted.length === 0) {
216
+ // Truthful no-op: content is actually identical.
217
+ if (!quiet) {
218
+ console.log(chalk.green("✔ Skills are already up to date!"));
219
+ }
220
+ return;
221
+ }
222
+ // Version current but content differs — report, don't mutate (report-only
223
+ // keeps the fast path from silently overwriting in-place customizations).
73
224
  if (!quiet) {
74
- console.log(chalk.green("✔ Skills are already up to date!"));
225
+ console.log(chalk.yellow(`! Version current, but ${drifted.length} file(s) differ — run \`update\` or \`sync --force\``));
226
+ }
227
+ // Signal drift with a non-zero exit code even under --quiet. The exit code
228
+ // is the machine signal the (suppressible) message is not, so the
229
+ // non-interactive / CI path we recommend can't treat a drifted tree as
230
+ // success — the original failure mode in #708.
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+ // Preview path: report exactly what the apply would write, then stop without
235
+ // mutating (#722). This branch is only reached when `force` is set or the
236
+ // version marker mismatches — i.e. the path that runs `copyTemplates(force:
237
+ // true)` and rewrites the whole tree. (A matching-version, non-force dry-run
238
+ // already returned at the report-only short-circuit above, which never
239
+ // mutates.) `copyTemplates` does NOT protect in-place customizations the way
240
+ // `update` does — the force copy overwrites them — so the preview counts
241
+ // `local-override` files alongside `new`/`modified`. Reporting only
242
+ // new+modified would under-report the write-set, the exact divergence #722
243
+ // is about.
244
+ if (dryRun) {
245
+ const changes = await computeTemplateChanges(manifest.stack, tokens);
246
+ const newFiles = changes.filter((c) => c.status === "new");
247
+ const modifiedFiles = changes.filter((c) => c.status === "modified");
248
+ const localOverrides = changes.filter((c) => c.status === "local-override");
249
+ const toWrite = [...newFiles, ...modifiedFiles, ...localOverrides];
250
+ if (!quiet) {
251
+ console.log(chalk.bold("Summary (dry-run):"));
252
+ console.log(chalk.green(` New files: ${newFiles.length}`));
253
+ console.log(chalk.yellow(` Modified: ${modifiedFiles.length}`));
254
+ console.log(chalk.blue(` Local overrides (overwritten by sync): ${localOverrides.length}`));
255
+ if (modifiedFiles.length > 0) {
256
+ console.log(chalk.bold("\nModified files:"));
257
+ for (const file of modifiedFiles) {
258
+ console.log(chalk.yellow(` ${file.path}`));
259
+ }
260
+ }
261
+ if (newFiles.length > 0) {
262
+ console.log(chalk.bold("\nNew files:"));
263
+ for (const file of newFiles) {
264
+ console.log(chalk.green(` ${file.path}`));
265
+ }
266
+ }
267
+ if (localOverrides.length > 0) {
268
+ console.log(chalk.bold("\nLocal overrides (will be overwritten by sync):"));
269
+ for (const file of localOverrides) {
270
+ console.log(chalk.blue(` ${file.path}`));
271
+ }
272
+ }
273
+ if (toWrite.length === 0) {
274
+ console.log(chalk.green("\n✔ Skills are already up to date!"));
275
+ }
276
+ else {
277
+ console.log(chalk.gray("\n(dry-run mode - no changes made)"));
278
+ }
279
+ }
280
+ // Non-zero exit when work is pending so the documented preview surface can
281
+ // gate CI/automation (the #709 intent): a dry-run reporting nothing must
282
+ // mean nothing to do. The matching-version short-circuit signals drift the
283
+ // same way.
284
+ if (toWrite.length > 0) {
285
+ process.exitCode = 1;
75
286
  }
76
287
  return;
77
288
  }
78
- // Get config tokens for template processing
79
- const config = await getConfig();
80
- const tokens = config?.tokens || {};
81
289
  // Copy templates with force to overwrite existing files
82
290
  const copyOptions = {
83
291
  force: true, // Always overwrite when syncing
@@ -118,14 +326,32 @@ export async function syncCommand(options = {}) {
118
326
  }
119
327
  }
120
328
  /**
121
- * Check and warn if skills are outdated (for use by other commands)
329
+ * Check and warn if skills are outdated (for use by other commands).
330
+ *
331
+ * Warns on either signal: a version-marker mismatch, or in-place content drift at
332
+ * a matching version (#708/#713). The content-drift path is warn-only by design —
333
+ * it never mutates files and never sets `process.exitCode` (this is a pre-flight,
334
+ * not the command itself), so customized installs (#711) are left intact.
335
+ *
336
+ * Callers that have already computed the status (e.g. the `preAction` hook) can
337
+ * pass it in to avoid a second template scan on the hot path (AC-5).
338
+ *
339
+ * @returns `true` if a warning was emitted, `false` if up to date.
122
340
  */
123
- export async function checkAndWarnSkillsOutdated() {
124
- const { outdated, currentVersion, packageVersion } = await areSkillsOutdated();
341
+ export async function checkAndWarnSkillsOutdated(status) {
342
+ const { outdated, currentVersion, packageVersion, contentDrift } = status ?? (await areSkillsOutdated());
125
343
  if (outdated) {
126
344
  console.log(chalk.yellow(`\n! Skills are outdated (${currentVersion || "unknown"} → ${packageVersion})`));
127
345
  console.log(chalk.yellow(" Run: npx sequant sync\n"));
128
346
  return true;
129
347
  }
348
+ if (contentDrift > 0) {
349
+ // Mirror syncCommand's own drift remediation: a bare `sync` at a matching
350
+ // version is report-only (it won't copy), so point at the commands that
351
+ // actually resolve in-place drift — `sync --force` or `update`.
352
+ console.log(chalk.yellow(`\n! Version current, but ${contentDrift} file(s) differ from bundled content`));
353
+ console.log(chalk.yellow(" Run: npx sequant sync --force (or npx sequant update)\n"));
354
+ return true;
355
+ }
130
356
  return false;
131
357
  }
@@ -4,6 +4,7 @@
4
4
  interface UpdateOptions {
5
5
  dryRun?: boolean;
6
6
  force?: boolean;
7
+ yes?: boolean;
7
8
  }
8
9
  export declare function updateCommand(options: UpdateOptions): Promise<void>;
9
10
  export {};
@@ -2,14 +2,36 @@
2
2
  * sequant update - Update templates from the package
3
3
  */
4
4
  import chalk from "chalk";
5
- import { diffLines } from "diff";
6
5
  import inquirer from "inquirer";
7
6
  import { spawnSync } from "child_process";
8
7
  import { getManifest, updateManifest, getPackageVersion, } from "../lib/manifest.js";
9
- import { getTemplateContent, listTemplateFiles, processTemplate, } from "../lib/templates.js";
8
+ import { computeTemplateChanges } from "../lib/templates.js";
10
9
  import { getConfig, saveConfig } from "../lib/config.js";
11
10
  import { getStackConfig, PM_CONFIG, getPackageManagerCommands, } from "../lib/stacks.js";
12
- import { readFile, writeFile, fileExists } from "../lib/fs.js";
11
+ import { writeFile } from "../lib/fs.js";
12
+ import { isStdinTTY, isCI, getNonInteractiveReason } from "../lib/tty.js";
13
+ /**
14
+ * True when `update` must not prompt: stdin is not a terminal (piped input) or
15
+ * we are running in a recognized CI environment. CI is checked explicitly
16
+ * because some runners allocate a pseudo-TTY — `isStdinTTY()` alone would let
17
+ * the prompt render and then hang an unattended job forever.
18
+ */
19
+ function isNonInteractive() {
20
+ return !isStdinTTY() || isCI();
21
+ }
22
+ /**
23
+ * Print an actionable message and set a non-zero exit code when a prompt is
24
+ * required but the shell is non-interactive (piped/CI). Prevents inquirer from
25
+ * throwing a raw ExitPromptError stack trace. Callers should `return`
26
+ * immediately after.
27
+ */
28
+ function refuseNonInteractive() {
29
+ const reason = getNonInteractiveReason() ?? "stdin is not a terminal";
30
+ console.error(chalk.red(`\n❌ non-interactive shell (${reason}): \`update\` needs to prompt to continue.`));
31
+ console.error(chalk.yellow(" Re-run with `--yes` (or `-y`) to apply updates without prompting,"));
32
+ console.error(chalk.yellow(" or use `--dry-run` to preview changes without applying."));
33
+ process.exitCode = 1;
34
+ }
13
35
  export async function updateCommand(options) {
14
36
  console.log(chalk.blue("\nChecking for updates...\n"));
15
37
  console.log(chalk.yellow("Note: For seamless auto-updates, install sequant as a Claude Code plugin:\n" +
@@ -58,10 +80,19 @@ export async function updateCommand(options) {
58
80
  // Get package manager run command
59
81
  const pm = manifest.packageManager || "npm";
60
82
  const pmConfig = getPackageManagerCommands(pm);
61
- if (options.force) {
83
+ if (options.force || options.yes) {
62
84
  tokens = { DEV_URL: defaultDevUrl, PM_RUN: pmConfig.run };
63
85
  console.log(chalk.blue(`Using default dev URL: ${defaultDevUrl}`));
64
86
  }
87
+ else if (options.dryRun) {
88
+ // Dry-run is read-only: preview with defaults, never prompt or persist.
89
+ tokens = { DEV_URL: defaultDevUrl, PM_RUN: pmConfig.run };
90
+ console.log(chalk.blue(`Using default dev URL for preview: ${defaultDevUrl}`));
91
+ }
92
+ else if (isNonInteractive()) {
93
+ refuseNonInteractive();
94
+ return;
95
+ }
65
96
  else {
66
97
  const { inputDevUrl } = await inquirer.prompt([
67
98
  {
@@ -73,57 +104,22 @@ export async function updateCommand(options) {
73
104
  ]);
74
105
  tokens = { DEV_URL: inputDevUrl, PM_RUN: pmConfig.run };
75
106
  }
76
- // Save the new config
107
+ // Persist the new config — but not on a dry-run preview, which must leave
108
+ // the project untouched.
77
109
  config = {
78
110
  tokens,
79
111
  stack: manifest.stack,
80
112
  initialized: manifest.installedAt,
81
113
  };
82
- await saveConfig(config);
83
- console.log(chalk.green("✔ Configuration saved\n"));
84
- }
85
- // Get list of template files
86
- const templateFiles = await listTemplateFiles();
87
- const changes = [];
88
- for (const templatePath of templateFiles) {
89
- const localPath = templatePath.replace("templates/", ".claude/");
90
- // Skip if in .local directory (user customizations)
91
- if (localPath.includes(".local/")) {
92
- continue;
93
- }
94
- const templateContent = await getTemplateContent(templatePath);
95
- const exists = await fileExists(localPath);
96
- if (!exists) {
97
- changes.push({ path: localPath, status: "new" });
98
- }
99
- else {
100
- const localContent = await readFile(localPath);
101
- if (localContent === templateContent) {
102
- changes.push({ path: localPath, status: "unchanged" });
103
- }
104
- else {
105
- // Check if there's a local override
106
- const localOverridePath = localPath.replace(".claude/", ".claude/.local/");
107
- const hasLocalOverride = await fileExists(localOverridePath);
108
- if (hasLocalOverride) {
109
- changes.push({ path: localPath, status: "local-override" });
110
- }
111
- else {
112
- const diff = diffLines(localContent, templateContent)
113
- .map((part) => {
114
- const prefix = part.added ? "+" : part.removed ? "-" : " ";
115
- return part.value
116
- .split("\n")
117
- .filter((l) => l)
118
- .map((l) => `${prefix} ${l}`)
119
- .join("\n");
120
- })
121
- .join("\n");
122
- changes.push({ path: localPath, status: "modified", diff });
123
- }
124
- }
114
+ if (!options.dryRun) {
115
+ await saveConfig(config);
116
+ console.log(chalk.green("✔ Configuration saved\n"));
125
117
  }
126
118
  }
119
+ // Compute changes using the shared, variable-aware comparison.
120
+ // Templates are rendered (PROJECT_NAME, STACK_NOTES, etc.) before diffing,
121
+ // and in-place-customizable files (constitution) are protected as overrides.
122
+ const changes = await computeTemplateChanges(manifest.stack, tokens);
127
123
  // Show summary
128
124
  const newFiles = changes.filter((c) => c.status === "new");
129
125
  const modifiedFiles = changes.filter((c) => c.status === "modified");
@@ -134,8 +130,17 @@ export async function updateCommand(options) {
134
130
  console.log(chalk.yellow(` Modified: ${modifiedFiles.length}`));
135
131
  console.log(chalk.gray(` ✓ Unchanged: ${unchangedFiles.length}`));
136
132
  console.log(chalk.blue(` Local overrides: ${localOverrides.length}`));
137
- if (newFiles.length === 0 && modifiedFiles.length === 0) {
138
- console.log(chalk.green("\n✔ Everything is up to date!"));
133
+ // Local overrides are protected by default only --force overwrites them.
134
+ const applySet = options.force
135
+ ? [...newFiles, ...modifiedFiles, ...localOverrides]
136
+ : [...newFiles, ...modifiedFiles];
137
+ if (applySet.length === 0) {
138
+ if (localOverrides.length > 0) {
139
+ console.log(chalk.blue(`\n✔ No updates to apply. ${localOverrides.length} local override(s) protected (use --force to overwrite).`));
140
+ }
141
+ else {
142
+ console.log(chalk.green("\n✔ Everything is up to date!"));
143
+ }
139
144
  return;
140
145
  }
141
146
  // Show changes
@@ -154,12 +159,30 @@ export async function updateCommand(options) {
154
159
  console.log(chalk.green(` ${file.path}`));
155
160
  }
156
161
  }
162
+ if (options.force && localOverrides.length > 0) {
163
+ console.log(chalk.bold("\nLocal overrides (forced overwrite):"));
164
+ for (const file of localOverrides) {
165
+ console.log(chalk.blue(` ${file.path}`));
166
+ }
167
+ }
157
168
  if (options.dryRun) {
158
169
  console.log(chalk.gray("\n(dry-run mode - no changes made)"));
170
+ // Non-zero exit when work is pending so a CI/automation job can gate on the
171
+ // preview, matching `sync --dry-run` (#724 / #709 intent): a dry-run that
172
+ // reports nothing must mean nothing to do. The no-op case short-circuits at
173
+ // the "Everything is up to date!" return above, so it correctly stays 0.
174
+ if (applySet.length > 0) {
175
+ process.exitCode = 1;
176
+ }
159
177
  return;
160
178
  }
161
- // Confirm update
162
- if (!options.force) {
179
+ // Confirm update. --yes and --force both auto-confirm; otherwise we need a
180
+ // prompt, which is impossible without a TTY — bail cleanly instead of crashing.
181
+ if (!options.force && !options.yes) {
182
+ if (isNonInteractive()) {
183
+ refuseNonInteractive();
184
+ return;
185
+ }
163
186
  const { proceed } = await inquirer.prompt([
164
187
  {
165
188
  type: "confirm",
@@ -173,30 +196,19 @@ export async function updateCommand(options) {
173
196
  return;
174
197
  }
175
198
  }
176
- // Apply updates
199
+ // Apply updates — content was already rendered with the shared variable set
200
+ // during change detection, so just write it.
177
201
  console.log(chalk.blue("\nApplying updates..."));
178
202
  let updated = 0;
179
- // Build complete variables for template processing
180
- const stackConfig = getStackConfig(manifest.stack);
181
- const variables = {
182
- ...stackConfig.variables,
183
- ...tokens,
184
- PROJECT_NAME: process.cwd().split("/").pop() || "project",
185
- STACK: manifest.stack,
186
- };
187
- for (const file of [...newFiles, ...modifiedFiles]) {
188
- const templatePath = file.path.replace(".claude/", "templates/");
189
- let content = await getTemplateContent(templatePath);
190
- // Process templates with tokens to replace {{DEV_URL}} etc.
191
- content = processTemplate(content, variables);
192
- await writeFile(file.path, content);
203
+ for (const file of applySet) {
204
+ await writeFile(file.path, file.rendered);
193
205
  updated++;
194
206
  }
195
207
  // Update manifest
196
208
  await updateManifest();
197
209
  console.log(chalk.green(`\n✔ Updated ${updated} files`));
198
210
  // Check if package.json was updated and run install
199
- const packageJsonUpdated = [...newFiles, ...modifiedFiles].some((f) => f.path === "package.json" || f.path.endsWith("/package.json"));
211
+ const packageJsonUpdated = applySet.some((f) => f.path === "package.json" || f.path.endsWith("/package.json"));
200
212
  if (packageJsonUpdated) {
201
213
  // Use detected package manager or default to npm
202
214
  const pm = manifest.packageManager || "npm";
@@ -14,6 +14,56 @@ export declare function listTemplateFiles(): Promise<string[]>;
14
14
  * Get content of a template file
15
15
  */
16
16
  export declare function getTemplateContent(templatePath: string): Promise<string>;
17
+ /**
18
+ * Files that are meant to be edited in place per project (e.g. the
19
+ * constitution). When one of these diverges from the rendered template
20
+ * without a parallel `.claude/.local/` file, it is treated as a protected
21
+ * local override rather than a stale "modified" file — so the default
22
+ * (non-`--force`) update/sync path never silently overwrites it.
23
+ */
24
+ export declare const CUSTOMIZABLE_FILES: string[];
25
+ /**
26
+ * Whether a local path is a customizable file edited in place per project.
27
+ */
28
+ export declare function isCustomizableFile(localPath: string): boolean;
29
+ /**
30
+ * Build the full set of template variables used when rendering templates.
31
+ *
32
+ * This is the single source of truth shared by `copyTemplates` (write time)
33
+ * and `computeTemplateChanges` (diff time) so the two can never drift — a
34
+ * mismatch here is what caused `constitution.md` to read as "modified" on
35
+ * every project (the diff used a different/incomplete variable set than the
36
+ * write). See #708.
37
+ */
38
+ export declare function buildTemplateVariables(stack: string, tokens?: Record<string, string>, options?: {
39
+ additionalStacks?: string[];
40
+ }): Promise<Record<string, string>>;
41
+ /**
42
+ * A single template file's status relative to the installed copy.
43
+ */
44
+ export interface TemplateChange {
45
+ /** Installed path under `.claude/` */
46
+ path: string;
47
+ /** Source template path under `templates/` */
48
+ templatePath: string;
49
+ status: "new" | "modified" | "unchanged" | "local-override";
50
+ /** Template content rendered with the project's variables */
51
+ rendered: string;
52
+ /** Unified-ish diff (installed → rendered), only set for `modified` */
53
+ diff?: string;
54
+ }
55
+ /**
56
+ * Compare bundled template content against what's installed under `.claude/`.
57
+ *
58
+ * Templates are rendered with the project's variables *before* comparison, so
59
+ * an unmodified file (e.g. a constitution with `{{PROJECT_NAME}}` expanded)
60
+ * reads as `unchanged` rather than `modified`. A file that diverges in place is
61
+ * `local-override` (skip-by-default) when it has a parallel `.claude/.local/`
62
+ * file or is in the customizable allow-list; otherwise it is `modified`.
63
+ */
64
+ export declare function computeTemplateChanges(stack: string, tokens?: Record<string, string>, options?: {
65
+ additionalStacks?: string[];
66
+ }): Promise<TemplateChange[]>;
17
67
  /**
18
68
  * Result of symlink creation attempt
19
69
  */