sequant 2.6.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bin/cli.js +27 -4
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/clean/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/improve/SKILL.md +4 -1
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/security-review/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/solve/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +3 -0
- package/dist/marketplace/external_plugins/sequant/skills/verify/SKILL.md +3 -0
- package/dist/src/commands/ready.js +1 -1
- package/dist/src/commands/run.js +1 -1
- package/dist/src/commands/sync.d.ts +43 -5
- package/dist/src/commands/sync.js +188 -17
- package/dist/src/commands/update.d.ts +1 -0
- package/dist/src/commands/update.js +73 -68
- package/dist/src/lib/templates.d.ts +50 -0
- package/dist/src/lib/templates.js +134 -15
- package/dist/src/ui/tui/App.js +24 -2
- package/dist/src/ui/tui/IssueBox.js +4 -4
- package/dist/src/ui/tui/load.d.ts +25 -0
- package/dist/src/ui/tui/load.js +41 -0
- package/dist/src/ui/tui/theme.d.ts +21 -3
- package/dist/src/ui/tui/theme.js +22 -4
- package/package.json +1 -1
- package/templates/skills/assess/SKILL.md +3 -0
- package/templates/skills/clean/SKILL.md +3 -0
- package/templates/skills/docs/SKILL.md +3 -0
- package/templates/skills/exec/SKILL.md +3 -0
- package/templates/skills/fullsolve/SKILL.md +3 -0
- package/templates/skills/improve/SKILL.md +4 -1
- package/templates/skills/loop/SKILL.md +3 -0
- package/templates/skills/merger/SKILL.md +3 -0
- package/templates/skills/qa/SKILL.md +3 -0
- package/templates/skills/reflect/SKILL.md +3 -0
- package/templates/skills/security-review/SKILL.md +3 -0
- package/templates/skills/setup/SKILL.md +3 -0
- package/templates/skills/solve/SKILL.md +3 -0
- package/templates/skills/spec/SKILL.md +3 -0
- package/templates/skills/test/SKILL.md +3 -0
- package/templates/skills/testgen/SKILL.md +3 -0
- 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 {
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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 =
|
|
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
|
-
|
|
163
|
-
|
|
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 });
|
package/dist/src/ui/tui/App.js
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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] |
|
|
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
|
-
/**
|
|
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: "
|
|
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.
|