sequant 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +5 -0
  4. package/dist/bin/cli.js +27 -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 +43 -5
  26. package/dist/src/commands/sync.js +188 -17
  27. package/dist/src/commands/update.d.ts +1 -0
  28. package/dist/src/commands/update.js +73 -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
@@ -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,23 @@ 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)"));
159
170
  return;
160
171
  }
161
- // Confirm update
162
- if (!options.force) {
172
+ // Confirm update. --yes and --force both auto-confirm; otherwise we need a
173
+ // prompt, which is impossible without a TTY — bail cleanly instead of crashing.
174
+ if (!options.force && !options.yes) {
175
+ if (isNonInteractive()) {
176
+ refuseNonInteractive();
177
+ return;
178
+ }
163
179
  const { proceed } = await inquirer.prompt([
164
180
  {
165
181
  type: "confirm",
@@ -173,30 +189,19 @@ export async function updateCommand(options) {
173
189
  return;
174
190
  }
175
191
  }
176
- // Apply updates
192
+ // Apply updates — content was already rendered with the shared variable set
193
+ // during change detection, so just write it.
177
194
  console.log(chalk.blue("\nApplying updates..."));
178
195
  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);
196
+ for (const file of applySet) {
197
+ await writeFile(file.path, file.rendered);
193
198
  updated++;
194
199
  }
195
200
  // Update manifest
196
201
  await updateManifest();
197
202
  console.log(chalk.green(`\n✔ Updated ${updated} files`));
198
203
  // 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"));
204
+ const packageJsonUpdated = applySet.some((f) => f.path === "package.json" || f.path.endsWith("/package.json"));
200
205
  if (packageJsonUpdated) {
201
206
  // Use detected package manager or default to npm
202
207
  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
  */
@@ -4,6 +4,7 @@
4
4
  import { readdir, chmod } from "fs/promises";
5
5
  import { join, dirname, relative, isAbsolute } from "path";
6
6
  import { fileURLToPath } from "url";
7
+ import { diffLines } from "diff";
7
8
  import { readFile, writeFile, ensureDir, fileExists, isSymlink, createSymlink, removeFileOrSymlink, } from "./fs.js";
8
9
  import { getPackageVersion } from "./manifest.js";
9
10
  const SKILLS_VERSION_PATH = ".claude/skills/.sequant-version";
@@ -12,6 +13,11 @@ import { isNativeWindows } from "./system.js";
12
13
  import { getProjectName } from "./project-name.js";
13
14
  // Get the package templates directory
14
15
  export function getTemplatesDir() {
16
+ // Allow overriding the templates source (used by tests; also lets the dir be
17
+ // relocated without relying on the compiled-output layout below).
18
+ if (process.env.SEQUANT_TEMPLATES_DIR) {
19
+ return process.env.SEQUANT_TEMPLATES_DIR;
20
+ }
15
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
22
  // Compiled structure: dist/src/lib/templates.js
17
23
  // So we need ../../../templates to reach project root templates/
@@ -64,6 +70,132 @@ export async function getTemplateContent(templatePath) {
64
70
  const fullPath = join(templatesDir, relativePath);
65
71
  return readFile(fullPath);
66
72
  }
73
+ /**
74
+ * Files that are meant to be edited in place per project (e.g. the
75
+ * constitution). When one of these diverges from the rendered template
76
+ * without a parallel `.claude/.local/` file, it is treated as a protected
77
+ * local override rather than a stale "modified" file — so the default
78
+ * (non-`--force`) update/sync path never silently overwrites it.
79
+ */
80
+ export const CUSTOMIZABLE_FILES = [".claude/memory/constitution.md"];
81
+ /**
82
+ * Whether a local path is a customizable file edited in place per project.
83
+ */
84
+ export function isCustomizableFile(localPath) {
85
+ // Normalize OS path separators so the allow-list match holds on Windows,
86
+ // where template paths are assembled with backslashes (#708).
87
+ return CUSTOMIZABLE_FILES.includes(localPath.replace(/\\/g, "/"));
88
+ }
89
+ /**
90
+ * Build the full set of template variables used when rendering templates.
91
+ *
92
+ * This is the single source of truth shared by `copyTemplates` (write time)
93
+ * and `computeTemplateChanges` (diff time) so the two can never drift — a
94
+ * mismatch here is what caused `constitution.md` to read as "modified" on
95
+ * every project (the diff used a different/incomplete variable set than the
96
+ * write). See #708.
97
+ */
98
+ export async function buildTemplateVariables(stack, tokens, options = {}) {
99
+ const stackConfig = getStackConfig(stack);
100
+ // Detect project name from available sources (package.json, Cargo.toml, etc.)
101
+ const projectName = await getProjectName();
102
+ // Get stack-specific notes for constitution template
103
+ // Use multi-stack notes if additional stacks are provided
104
+ const stackNotes = options.additionalStacks && options.additionalStacks.length > 0
105
+ ? getMultiStackNotes(stack, options.additionalStacks)
106
+ : getStackNotes(stack);
107
+ return {
108
+ ...stackConfig.variables,
109
+ ...tokens,
110
+ PROJECT_NAME: projectName,
111
+ STACK: stack,
112
+ STACK_NOTES: stackNotes,
113
+ };
114
+ }
115
+ /**
116
+ * Compare bundled template content against what's installed under `.claude/`.
117
+ *
118
+ * Templates are rendered with the project's variables *before* comparison, so
119
+ * an unmodified file (e.g. a constitution with `{{PROJECT_NAME}}` expanded)
120
+ * reads as `unchanged` rather than `modified`. A file that diverges in place is
121
+ * `local-override` (skip-by-default) when it has a parallel `.claude/.local/`
122
+ * file or is in the customizable allow-list; otherwise it is `modified`.
123
+ */
124
+ export async function computeTemplateChanges(stack, tokens, options = {}) {
125
+ const variables = await buildTemplateVariables(stack, tokens, options);
126
+ const templateFiles = await listTemplateFiles();
127
+ const changes = [];
128
+ 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/");
135
+ // Skip .local files (user customizations are never overwritten)
136
+ if (localPath.includes(".local/")) {
137
+ continue;
138
+ }
139
+ const rendered = processTemplate(await getTemplateContent(templatePath), variables);
140
+ const exists = await fileExists(localPath);
141
+ if (!exists) {
142
+ changes.push({ path: localPath, templatePath, status: "new", rendered });
143
+ continue;
144
+ }
145
+ const localContent = await readFile(localPath);
146
+ if (localContent === rendered) {
147
+ changes.push({
148
+ path: localPath,
149
+ templatePath,
150
+ status: "unchanged",
151
+ rendered,
152
+ });
153
+ continue;
154
+ }
155
+ // Content differs after rendering. Protect in-place customizations:
156
+ // a parallel `.claude/.local/` override, or a known customizable file.
157
+ //
158
+ // Note: this protects a managed file that was *edited in place* (e.g. the
159
+ // constitution) when a parallel `.claude/.local/` twin exists. It is NOT a
160
+ // skill-loading mechanism — the harness never loads `.claude/.local/skills/
161
+ // <name>/SKILL.md`, so a full-file SKILL.md shadow does nothing at runtime
162
+ // (#711). Skills are instead customized via a runtime overlay: each managed
163
+ // SKILL.md opens (before its first heading) with a directive to honor
164
+ // `.claude/.local/skills/<name>/overrides.md`, and that overrides file is
165
+ // auto-skipped above because it lives under `.local/`. The directive sits at
166
+ // the top, not end-of-file, so it fires reliably even in 3000-line skills.
167
+ // See docs/guides/customization.md.
168
+ const localOverridePath = localPath.replace(".claude/", ".claude/.local/");
169
+ const hasLocalOverride = await fileExists(localOverridePath);
170
+ if (hasLocalOverride || isCustomizableFile(localPath)) {
171
+ changes.push({
172
+ path: localPath,
173
+ templatePath,
174
+ status: "local-override",
175
+ rendered,
176
+ });
177
+ continue;
178
+ }
179
+ const diff = diffLines(localContent, rendered)
180
+ .map((part) => {
181
+ const prefix = part.added ? "+" : part.removed ? "-" : " ";
182
+ return part.value
183
+ .split("\n")
184
+ .filter((l) => l)
185
+ .map((l) => `${prefix} ${l}`)
186
+ .join("\n");
187
+ })
188
+ .join("\n");
189
+ changes.push({
190
+ path: localPath,
191
+ templatePath,
192
+ status: "modified",
193
+ rendered,
194
+ diff,
195
+ });
196
+ }
197
+ return changes;
198
+ }
67
199
  /**
68
200
  * Create symlinks for files in a directory, with fallback to copy
69
201
  * @param srcDir Source directory containing template files
@@ -159,21 +291,8 @@ export async function symlinkDir(srcDir, destDir, options = {}) {
159
291
  */
160
292
  export async function copyTemplates(stack, tokens, options = {}) {
161
293
  const templatesDir = getTemplatesDir();
162
- const stackConfig = getStackConfig(stack);
163
- // Detect project name from available sources (package.json, Cargo.toml, etc.)
164
- const projectName = await getProjectName();
165
- // Get stack-specific notes for constitution template
166
- // Use multi-stack notes if additional stacks are provided
167
- const stackNotes = options.additionalStacks && options.additionalStacks.length > 0
168
- ? getMultiStackNotes(stack, options.additionalStacks)
169
- : getStackNotes(stack);
170
- const variables = {
171
- ...stackConfig.variables,
172
- ...tokens,
173
- PROJECT_NAME: projectName,
174
- STACK: stack,
175
- STACK_NOTES: stackNotes,
176
- };
294
+ // Single source of truth for template variables (shared with the diff path)
295
+ const variables = await buildTemplateVariables(stack, tokens, options);
177
296
  async function copyDir(srcDir, destDir) {
178
297
  try {
179
298
  const entries = await readdir(srcDir, { withFileTypes: true });
@@ -19,6 +19,7 @@ export function App({ getSnapshot, onDone, }) {
19
19
  const [now, setNow] = useState(() => Date.now());
20
20
  const doneFired = useRef(false);
21
21
  const { stdout } = useStdout();
22
+ const [columns, setColumns] = useState(() => stdout?.columns ?? 80);
22
23
  // Snapshot poller (drives all state transitions).
23
24
  useEffect(() => {
24
25
  const id = setInterval(() => {
@@ -37,8 +38,29 @@ export function App({ getSnapshot, onDone, }) {
37
38
  const id = setInterval(() => setNow(Date.now()), 1000);
38
39
  return () => clearInterval(id);
39
40
  }, []);
40
- const columns = stdout?.columns ?? 80;
41
- const boxWidth = Math.min(columns - 2, 100);
41
+ // Track the terminal width reactively. ink's own resize handler re-renders
42
+ // the existing React tree but does NOT re-run this component, so a width read
43
+ // imperatively in render goes stale until the next poll. In that window ink
44
+ // repaints boxes at the old (now too-wide) width and the lines wrap, which
45
+ // misaligns the box borders into the duplicate/garbled frames. Updating
46
+ // `columns` from the resize event forces an immediate re-layout at the new
47
+ // width. A 1 Hz fallback poll covers terminals that don't emit `resize`.
48
+ useEffect(() => {
49
+ if (!stdout)
50
+ return;
51
+ const sync = () => setColumns(stdout.columns ?? 80);
52
+ stdout.on("resize", sync);
53
+ sync();
54
+ const id = setInterval(sync, 1000);
55
+ return () => {
56
+ stdout.off("resize", sync);
57
+ clearInterval(id);
58
+ };
59
+ }, [stdout]);
60
+ // Clamp each box to the current terminal width (minus a 2-col safety margin)
61
+ // so a box line can never equal or exceed the terminal width and wrap.
62
+ const safeColumns = columns > 0 ? columns : 80;
63
+ const boxWidth = Math.max(20, Math.min(safeColumns - 2, 100));
42
64
  // #699 AC-4: clamp the number of boxes to the terminal height so a large
43
65
  // batch on a short terminal can't overflow the frame (parity with the plain
44
66
  // renderer's #624 row cap). Older completed issues collapse into `✔ N done`.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { DIVIDER_COLOR, PHASE_GLYPHS, borderColorForIssue, phaseStatusColor, } from "./theme.js";
3
+ import { ACTIVE_PHASE_COLOR, DIVIDER_COLOR, PHASE_GLYPHS, borderColorForIssue, phaseStatusColor, } from "./theme.js";
4
4
  import { Spinner } from "./Spinner.js";
5
5
  import { ElapsedTimer, formatSinceActivity } from "./ElapsedTimer.js";
6
6
  import { truncateToWidth } from "./truncate.js";
@@ -20,7 +20,7 @@ export function IssueBox({ state, slot, width, now, }) {
20
20
  const displayPhaseN = activePhaseIndex >= 0 ? activePhaseIndex + 1 : doneCount;
21
21
  const total = state.phases.length;
22
22
  const headerTitle = truncateToWidth(`#${state.number} ${state.title}`, Math.max(10, innerWidth - 20));
23
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: border, paddingX: 1, marginBottom: 1, width: width, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: border, children: headerTitle }), _jsxs(Text, { color: DIVIDER_COLOR, children: ["phase ", displayPhaseN, "/", total, " \u2022", " ", _jsx(ElapsedTimer, { startedAt: state.startedAt })] })] }), _jsx(Divider, { width: innerWidth }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "branch " }), _jsx(Text, { children: truncateToWidth(state.branch, innerWidth - 8) })] }), _jsx(PhaseProgression, { phases: state.phases, borderColor: border }), state.currentPhase?.logPath ? (_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "log " }), _jsx(Text, { children: truncateToWidth(state.currentPhase.logPath, innerWidth - 8) })] })) : null] }), _jsx(Divider, { width: innerWidth }), _jsx(Box, { flexDirection: "column", children: state.currentPhase ? (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "now " }), _jsx(Spinner, { color: border }), _jsxs(Text, { children: [" ", truncateToWidth(state.currentPhase.nowLine, innerWidth - 12)] })] }), _jsx(Box, { children: _jsxs(Text, { color: DIVIDER_COLOR, children: [" └ last activity ", formatSinceActivity(now, state.currentPhase.lastActivityAt)] }) })] })) : (_jsx(Text, { color: DIVIDER_COLOR, children: statusLine(state) })) })] }));
23
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: border, paddingX: 1, marginBottom: 1, width: width, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: border, children: headerTitle }), _jsxs(Text, { color: DIVIDER_COLOR, children: ["phase ", displayPhaseN, "/", total, " \u2022", " ", _jsx(ElapsedTimer, { startedAt: state.startedAt })] })] }), _jsx(Divider, { width: innerWidth }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "branch " }), _jsx(Text, { children: truncateToWidth(state.branch, innerWidth - 8) })] }), _jsx(PhaseProgression, { phases: state.phases, activeColor: ACTIVE_PHASE_COLOR }), state.currentPhase?.logPath ? (_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "log " }), _jsx(Text, { children: truncateToWidth(state.currentPhase.logPath, innerWidth - 8) })] })) : null] }), _jsx(Divider, { width: innerWidth }), _jsx(Box, { flexDirection: "column", children: state.currentPhase ? (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "now " }), _jsx(Spinner, { color: ACTIVE_PHASE_COLOR }), _jsxs(Text, { children: [" ", truncateToWidth(state.currentPhase.nowLine, innerWidth - 12)] })] }), _jsx(Box, { children: _jsxs(Text, { color: DIVIDER_COLOR, children: [" └ last activity ", formatSinceActivity(now, state.currentPhase.lastActivityAt)] }) })] })) : (_jsx(Text, { color: DIVIDER_COLOR, children: statusLine(state) })) })] }));
24
24
  }
25
25
  function statusLine(state) {
26
26
  switch (state.status) {
@@ -37,10 +37,10 @@ function statusLine(state) {
37
37
  function Divider({ width }) {
38
38
  return _jsx(Text, { color: DIVIDER_COLOR, children: "─".repeat(Math.max(0, width)) });
39
39
  }
40
- function PhaseProgression({ phases, borderColor, }) {
40
+ function PhaseProgression({ phases, activeColor, }) {
41
41
  return (_jsxs(Box, { flexWrap: "wrap", children: [_jsx(Text, { color: DIVIDER_COLOR, children: "phases " }), phases.map((p, i) => {
42
42
  const isLast = i === phases.length - 1;
43
- return (_jsxs(Box, { children: [_jsx(PhaseGlyph, { status: p.status, label: p.name, activeColor: borderColor, elapsedMs: p.elapsedMs }), !isLast ? (_jsxs(Text, { color: DIVIDER_COLOR, children: [" ", PHASE_GLYPHS.separator, " "] })) : null] }, `${p.name}-${i}`));
43
+ return (_jsxs(Box, { children: [_jsx(PhaseGlyph, { status: p.status, label: p.name, activeColor: activeColor, elapsedMs: p.elapsedMs }), !isLast ? (_jsxs(Text, { color: DIVIDER_COLOR, children: [" ", PHASE_GLYPHS.separator, " "] })) : null] }, `${p.name}-${i}`));
44
44
  })] }));
45
45
  }
46
46
  function PhaseGlyph({ status, label, activeColor, elapsedMs, }) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Loader for the Ink TUI module that forces React into its production build.
3
+ *
4
+ * `react-reconciler` (pulled in by `ink`) selects its dev-vs-prod bundle from
5
+ * `process.env.NODE_ENV` **at module-evaluation time**. The development bundle
6
+ * emits a `performance.measure()` per component render and never calls
7
+ * `performance.clearMeasures()`. Driven by the TUI's 10 Hz poll over a long
8
+ * `sequant run`, those entries accumulate in Node's global performance buffer
9
+ * until it overflows its ~1,000,000-entry cap and prints
10
+ * `MaxPerformanceEntryBufferExceededWarning` to stderr — a memory leak that
11
+ * also corrupts the dashboard's in-place redraw (the stderr write scrolls the
12
+ * terminal between log-update frames; see #647/#664).
13
+ *
14
+ * The TUI module is the *only* importer of `react`/`ink`/`react-reconciler`,
15
+ * and it is always reached through a dynamic `import()`, so bracketing that one
16
+ * import with `NODE_ENV=production` caches the production reconciler (which has
17
+ * zero `performance.measure` calls). We restore `NODE_ENV` immediately after so
18
+ * spawned child processes (claude phases, `npm install`, build steps) do NOT
19
+ * inherit `NODE_ENV=production` — which would, e.g., make `npm install` skip
20
+ * devDependencies.
21
+ *
22
+ * Only overrides when `NODE_ENV` is unset/empty: an explicit `development` or
23
+ * `test` (the test runner) is respected so dev warnings remain available there.
24
+ */
25
+ export declare function loadTui(): Promise<typeof import("./index.js")>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Loader for the Ink TUI module that forces React into its production build.
3
+ *
4
+ * `react-reconciler` (pulled in by `ink`) selects its dev-vs-prod bundle from
5
+ * `process.env.NODE_ENV` **at module-evaluation time**. The development bundle
6
+ * emits a `performance.measure()` per component render and never calls
7
+ * `performance.clearMeasures()`. Driven by the TUI's 10 Hz poll over a long
8
+ * `sequant run`, those entries accumulate in Node's global performance buffer
9
+ * until it overflows its ~1,000,000-entry cap and prints
10
+ * `MaxPerformanceEntryBufferExceededWarning` to stderr — a memory leak that
11
+ * also corrupts the dashboard's in-place redraw (the stderr write scrolls the
12
+ * terminal between log-update frames; see #647/#664).
13
+ *
14
+ * The TUI module is the *only* importer of `react`/`ink`/`react-reconciler`,
15
+ * and it is always reached through a dynamic `import()`, so bracketing that one
16
+ * import with `NODE_ENV=production` caches the production reconciler (which has
17
+ * zero `performance.measure` calls). We restore `NODE_ENV` immediately after so
18
+ * spawned child processes (claude phases, `npm install`, build steps) do NOT
19
+ * inherit `NODE_ENV=production` — which would, e.g., make `npm install` skip
20
+ * devDependencies.
21
+ *
22
+ * Only overrides when `NODE_ENV` is unset/empty: an explicit `development` or
23
+ * `test` (the test runner) is respected so dev warnings remain available there.
24
+ */
25
+ export async function loadTui() {
26
+ const prev = process.env.NODE_ENV;
27
+ const override = prev === undefined || prev === "";
28
+ if (override)
29
+ process.env.NODE_ENV = "production";
30
+ try {
31
+ return await import("./index.js");
32
+ }
33
+ finally {
34
+ if (override) {
35
+ if (prev === undefined)
36
+ delete process.env.NODE_ENV;
37
+ else
38
+ process.env.NODE_ENV = prev;
39
+ }
40
+ }
41
+ }
@@ -6,14 +6,32 @@
6
6
  * Respects `NO_COLOR` automatically via `ink`/`chalk`.
7
7
  */
8
8
  import type { IssueStatus, PhaseStatus } from "../../lib/workflow/run-state.js";
9
+ /**
10
+ * Sequant brand accents, sourced from sequant-landing `src/styles/tokens.css`:
11
+ * - `BRAND_ORANGE` is the primary brand color (`--color-primary` dark mode).
12
+ * - `BRAND_GREEN` is the accent/success green (`--color-accent`).
13
+ *
14
+ * Used to brand the two color signals that matter most at a glance — the
15
+ * live/active phase and success — while issue-distinction (border rotation),
16
+ * failure (red), and dividers (gray) stay on robust named ANSI colors.
17
+ *
18
+ * Ink/chalk auto-downsamples hex to the nearest ANSI color on terminals
19
+ * without truecolor, and `NO_COLOR` still strips all color, so these degrade
20
+ * gracefully without a manual capability check.
21
+ */
22
+ export declare const BRAND_ORANGE: "#FF8012";
23
+ export declare const BRAND_GREEN: "#10b981";
9
24
  /** Border-color palette rotated by issue start order. */
10
25
  export declare const BORDER_ROTATION: readonly ["cyan", "magenta", "blue", "yellow"];
11
- export type BorderColor = (typeof BORDER_ROTATION)[number] | "green" | "red" | "gray";
26
+ export type BorderColor = (typeof BORDER_ROTATION)[number] | typeof BRAND_GREEN | typeof BRAND_ORANGE | "red" | "gray";
12
27
  /** Gray used for horizontal dividers inside each box. */
13
28
  export declare const DIVIDER_COLOR: "gray";
14
- /** Green used for the rolled-up `✔ N done` summary line (#699, parity with the
29
+ /** Brand orange for the live/active phase spinner the one element the eye
30
+ * tracks. Border rotation still distinguishes concurrent issues. */
31
+ export declare const ACTIVE_PHASE_COLOR: "#FF8012";
32
+ /** Brand green for the rolled-up `✔ N done` summary line (#699, parity with the
15
33
  * plain renderer's #624 rollup). */
16
- export declare const ROLLUP_COLOR: "green";
34
+ export declare const ROLLUP_COLOR: "#10b981";
17
35
  /**
18
36
  * Pick the border color for an issue.
19
37
  * Failed / passed states win over rotation; otherwise rotate by slot.