clawspec 1.0.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.
- package/README.md +908 -0
- package/README.zh-CN.md +914 -0
- package/index.ts +3 -0
- package/openclaw.plugin.json +129 -0
- package/package.json +52 -0
- package/skills/openspec-apply-change.md +146 -0
- package/skills/openspec-explore.md +75 -0
- package/skills/openspec-propose.md +102 -0
- package/src/acp/client.ts +693 -0
- package/src/config.ts +220 -0
- package/src/control/keywords.ts +72 -0
- package/src/dependencies/acpx.ts +221 -0
- package/src/dependencies/openspec.ts +148 -0
- package/src/execution/session.ts +56 -0
- package/src/execution/state.ts +125 -0
- package/src/index.ts +179 -0
- package/src/memory/store.ts +118 -0
- package/src/openspec/cli.ts +279 -0
- package/src/openspec/tasks.ts +40 -0
- package/src/orchestrator/helpers.ts +312 -0
- package/src/orchestrator/service.ts +2971 -0
- package/src/planning/journal.ts +118 -0
- package/src/rollback/store.ts +173 -0
- package/src/state/locks.ts +133 -0
- package/src/state/store.ts +527 -0
- package/src/types.ts +301 -0
- package/src/utils/args.ts +88 -0
- package/src/utils/channel-key.ts +66 -0
- package/src/utils/env-path.ts +31 -0
- package/src/utils/fs.ts +218 -0
- package/src/utils/markdown.ts +136 -0
- package/src/utils/messages.ts +5 -0
- package/src/utils/paths.ts +127 -0
- package/src/utils/shell-command.ts +227 -0
- package/src/utils/slug.ts +50 -0
- package/src/watchers/manager.ts +3042 -0
- package/src/watchers/notifier.ts +69 -0
- package/src/worker/prompts.ts +484 -0
- package/src/worker/skills.ts +52 -0
- package/src/workspace/store.ts +140 -0
- package/test/acp-client.test.ts +234 -0
- package/test/acpx-dependency.test.ts +112 -0
- package/test/assistant-journal.test.ts +136 -0
- package/test/command-surface.test.ts +23 -0
- package/test/config.test.ts +77 -0
- package/test/detach-attach.test.ts +98 -0
- package/test/file-lock.test.ts +78 -0
- package/test/fs-utils.test.ts +22 -0
- package/test/helpers/harness.ts +241 -0
- package/test/helpers.test.ts +108 -0
- package/test/keywords.test.ts +80 -0
- package/test/notifier.test.ts +29 -0
- package/test/openspec-dependency.test.ts +67 -0
- package/test/pause-cancel.test.ts +55 -0
- package/test/planning-journal.test.ts +69 -0
- package/test/plugin-registration.test.ts +35 -0
- package/test/project-memory.test.ts +42 -0
- package/test/proposal.test.ts +24 -0
- package/test/queue-planning.test.ts +247 -0
- package/test/queue-work.test.ts +110 -0
- package/test/recovery.test.ts +576 -0
- package/test/service-archive.test.ts +82 -0
- package/test/shell-command.test.ts +48 -0
- package/test/state-store.test.ts +74 -0
- package/test/tasks-and-checkpoint.test.ts +60 -0
- package/test/use-project.test.ts +19 -0
- package/test/watcher-planning.test.ts +504 -0
- package/test/watcher-work.test.ts +1741 -0
- package/test/worker-command.test.ts +66 -0
- package/test/worker-skills.test.ts +12 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ExecutionResult, OpenSpecCommandResult, TaskCountSummary } from "../types.ts";
|
|
2
|
+
import { takeLineExcerpt } from "./fs.ts";
|
|
3
|
+
|
|
4
|
+
export function fence(text: string, language = ""): string {
|
|
5
|
+
return `\`\`\`${language}\n${text}\n\`\`\``;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatCommandOutputSection(results: OpenSpecCommandResult<unknown>[]): string {
|
|
9
|
+
if (results.length === 0) {
|
|
10
|
+
return "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const lines = ["## OpenSpec Commands", ""];
|
|
14
|
+
for (const result of results) {
|
|
15
|
+
lines.push(`- \`${result.command}\``);
|
|
16
|
+
lines.push(`- cwd: \`${result.cwd}\``);
|
|
17
|
+
lines.push(`- duration: ${result.durationMs}ms`);
|
|
18
|
+
const parsedSummary = summarizeParsedCommand(result);
|
|
19
|
+
if (parsedSummary) {
|
|
20
|
+
lines.push(`- ${parsedSummary}`);
|
|
21
|
+
}
|
|
22
|
+
const stdoutExcerpt = shouldShowRawStdout(result)
|
|
23
|
+
? takeMeaningfulExcerpt(result.stdout)
|
|
24
|
+
: "";
|
|
25
|
+
if (stdoutExcerpt.length > 0) {
|
|
26
|
+
lines.push(fence(stdoutExcerpt, "text"));
|
|
27
|
+
}
|
|
28
|
+
const stderrExcerpt = takeMeaningfulExcerpt(result.stderr);
|
|
29
|
+
if (stderrExcerpt.length > 0) {
|
|
30
|
+
lines.push(fence(stderrExcerpt, "text"));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatExecutionSummary(result: ExecutionResult): string {
|
|
37
|
+
const lines = [
|
|
38
|
+
`Status: ${result.status}`,
|
|
39
|
+
`Summary: ${result.summary}`,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
if (result.completedTask) {
|
|
43
|
+
lines.push(`Completed Task: ${result.completedTask}`);
|
|
44
|
+
}
|
|
45
|
+
if (result.currentArtifact) {
|
|
46
|
+
lines.push(`Artifact: ${result.currentArtifact}`);
|
|
47
|
+
}
|
|
48
|
+
if (result.changedFiles.length > 0) {
|
|
49
|
+
lines.push("Files Changed:");
|
|
50
|
+
for (const changedFile of result.changedFiles) {
|
|
51
|
+
lines.push(`- ${changedFile}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (result.notes.length > 0) {
|
|
55
|
+
lines.push("Notes:");
|
|
56
|
+
for (const note of result.notes) {
|
|
57
|
+
lines.push(`- ${note}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (typeof result.remainingTasks === "number") {
|
|
61
|
+
lines.push(`Remaining Tasks: ${result.remainingTasks}`);
|
|
62
|
+
}
|
|
63
|
+
if (result.blocker) {
|
|
64
|
+
lines.push(`Blocker: ${result.blocker}`);
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatTaskCounts(counts: TaskCountSummary | undefined): string {
|
|
70
|
+
if (!counts) {
|
|
71
|
+
return "Task counts: unavailable";
|
|
72
|
+
}
|
|
73
|
+
return `Task counts: ${counts.complete}/${counts.total} complete, ${counts.remaining} remaining`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function heading(title: string): string {
|
|
77
|
+
return `## ${title}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shouldShowRawStdout(result: OpenSpecCommandResult<unknown>): boolean {
|
|
81
|
+
return !(result.command.includes("--json") && result.parsed !== undefined);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function takeMeaningfulExcerpt(text: string): string {
|
|
85
|
+
const excerpt = takeLineExcerpt(text);
|
|
86
|
+
if (excerpt.length === 0) {
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lines = excerpt
|
|
91
|
+
.split(/\r?\n/)
|
|
92
|
+
.map((line) => line.trimEnd())
|
|
93
|
+
.filter((line) => line.length > 0 && !/^loading\b/i.test(line));
|
|
94
|
+
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function summarizeParsedCommand(result: OpenSpecCommandResult<unknown>): string | undefined {
|
|
99
|
+
const parsed = result.parsed;
|
|
100
|
+
if (!parsed || typeof parsed !== "object") {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const value = parsed as Record<string, unknown>;
|
|
105
|
+
if (typeof value.schemaName === "string" && Array.isArray(value.artifacts)) {
|
|
106
|
+
const doneCount = value.artifacts.filter((artifact) =>
|
|
107
|
+
artifact && typeof artifact === "object" && (artifact as Record<string, unknown>).status === "done"
|
|
108
|
+
).length;
|
|
109
|
+
return `status summary: schema \`${value.schemaName}\`, ${doneCount}/${value.artifacts.length} artifacts done`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
typeof value.artifactId === "string" &&
|
|
114
|
+
typeof value.outputPath === "string" &&
|
|
115
|
+
typeof value.schemaName === "string"
|
|
116
|
+
) {
|
|
117
|
+
return `artifact summary: \`${value.artifactId}\` -> \`${value.outputPath}\` (${value.schemaName})`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
typeof value.state === "string" &&
|
|
122
|
+
value.progress &&
|
|
123
|
+
typeof value.progress === "object" &&
|
|
124
|
+
typeof (value.progress as Record<string, unknown>).complete === "number" &&
|
|
125
|
+
typeof (value.progress as Record<string, unknown>).total === "number"
|
|
126
|
+
) {
|
|
127
|
+
const progress = value.progress as Record<string, unknown>;
|
|
128
|
+
return `apply summary: ${progress.complete}/${progress.total} tasks complete, state \`${value.state}\``;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (typeof value.valid === "boolean") {
|
|
132
|
+
return `validation summary: ${value.valid ? "valid" : "invalid"}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return result.command.includes("--json") ? "JSON response parsed successfully" : undefined;
|
|
136
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type RepoStatePaths = {
|
|
5
|
+
root: string;
|
|
6
|
+
stateFile: string;
|
|
7
|
+
executionControlFile: string;
|
|
8
|
+
executionResultFile: string;
|
|
9
|
+
workerProgressFile: string;
|
|
10
|
+
progressFile: string;
|
|
11
|
+
changedFilesFile: string;
|
|
12
|
+
decisionLogFile: string;
|
|
13
|
+
latestSummaryFile: string;
|
|
14
|
+
planningJournalFile: string;
|
|
15
|
+
planningJournalSnapshotFile: string;
|
|
16
|
+
rollbackManifestFile: string;
|
|
17
|
+
snapshotsRoot: string;
|
|
18
|
+
archivesRoot: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function getPluginStateRoot(stateDir: string): string {
|
|
22
|
+
return path.join(stateDir, "clawspec");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getDefaultWorkspacePath(): string {
|
|
26
|
+
return path.join(os.homedir(), "clawspec", "workspace");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function expandHomeDir(input: string): string {
|
|
30
|
+
const trimmed = input.trim();
|
|
31
|
+
if (trimmed === "~") {
|
|
32
|
+
return os.homedir();
|
|
33
|
+
}
|
|
34
|
+
if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
35
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
36
|
+
}
|
|
37
|
+
return trimmed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveUserPath(input: string, baseDir = process.cwd()): string {
|
|
41
|
+
const expanded = expandHomeDir(input);
|
|
42
|
+
return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(baseDir, expanded);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getProjectMemoryFilePath(stateDir: string): string {
|
|
46
|
+
return path.join(getPluginStateRoot(stateDir), "project-memory.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getWorkspaceStateFilePath(stateDir: string): string {
|
|
50
|
+
return path.join(getPluginStateRoot(stateDir), "workspace-state.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getActiveProjectMapPath(stateDir: string): string {
|
|
54
|
+
return path.join(getPluginStateRoot(stateDir), "active-projects.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getGlobalProjectStatePath(stateDir: string, projectId: string): string {
|
|
58
|
+
return path.join(getPluginStateRoot(stateDir), "projects", `${projectId}.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getRepoStatePaths(repoPath: string, archiveDirName: string): RepoStatePaths {
|
|
62
|
+
const root = path.join(repoPath, ".openclaw", "clawspec");
|
|
63
|
+
return {
|
|
64
|
+
root,
|
|
65
|
+
stateFile: path.join(root, "state.json"),
|
|
66
|
+
executionControlFile: path.join(root, "execution-control.json"),
|
|
67
|
+
executionResultFile: path.join(root, "execution-result.json"),
|
|
68
|
+
workerProgressFile: path.join(root, "worker-progress.jsonl"),
|
|
69
|
+
progressFile: path.join(root, "progress.md"),
|
|
70
|
+
changedFilesFile: path.join(root, "changed-files.md"),
|
|
71
|
+
decisionLogFile: path.join(root, "decision-log.md"),
|
|
72
|
+
latestSummaryFile: path.join(root, "latest-summary.md"),
|
|
73
|
+
planningJournalFile: path.join(root, "planning-journal.jsonl"),
|
|
74
|
+
planningJournalSnapshotFile: path.join(root, "planning-journal.snapshot.json"),
|
|
75
|
+
rollbackManifestFile: path.join(root, "rollback-manifest.json"),
|
|
76
|
+
snapshotsRoot: path.join(root, "snapshots"),
|
|
77
|
+
archivesRoot: path.join(root, archiveDirName),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getChangeDir(repoPath: string, changeName: string): string {
|
|
82
|
+
return path.join(repoPath, "openspec", "changes", changeName);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getTasksPath(repoPath: string, changeName: string): string {
|
|
86
|
+
return path.join(getChangeDir(repoPath, changeName), "tasks.md");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getChangeSnapshotRoot(repoPath: string, archiveDirName: string, changeName: string): string {
|
|
90
|
+
return path.join(getRepoStatePaths(repoPath, archiveDirName).snapshotsRoot, changeName);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getChangeBaselineRoot(repoPath: string, archiveDirName: string, changeName: string): string {
|
|
94
|
+
return path.join(getChangeSnapshotRoot(repoPath, archiveDirName, changeName), "baseline");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveProjectScopedPath(
|
|
98
|
+
project: { repoPath?: string; changeDir?: string },
|
|
99
|
+
targetPath: string,
|
|
100
|
+
): string {
|
|
101
|
+
if (!targetPath || path.isAbsolute(targetPath)) {
|
|
102
|
+
return targetPath;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const normalized = targetPath.replace(/^[.][\\/]/, "");
|
|
106
|
+
if (/^(openspec|\.openclaw)([\\/]|$)/.test(normalized)) {
|
|
107
|
+
return project.repoPath ? path.join(project.repoPath, normalized) : normalized;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (project.changeDir) {
|
|
111
|
+
return path.join(project.changeDir, normalized);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return project.repoPath ? path.join(project.repoPath, normalized) : normalized;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function sameNormalizedPath(left: string | undefined, right: string | undefined): boolean {
|
|
118
|
+
if (!left || !right) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const normalizedLeft = path.normalize(left);
|
|
122
|
+
const normalizedRight = path.normalize(right);
|
|
123
|
+
if (process.platform === "win32") {
|
|
124
|
+
return normalizedLeft.toLowerCase() === normalizedRight.toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
return normalizedLeft === normalizedRight;
|
|
127
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export type ShellCommandResult = {
|
|
5
|
+
code?: number | null;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
error?: Error;
|
|
9
|
+
signal?: NodeJS.Signals | null;
|
|
10
|
+
killed?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function spawnShellCommand(params: {
|
|
14
|
+
command: string;
|
|
15
|
+
args: string[];
|
|
16
|
+
cwd: string;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
}): ChildProcessWithoutNullStreams {
|
|
19
|
+
if (shouldUseShell(params.command)) {
|
|
20
|
+
const commandLabel = buildShellCommand(params.command, params.args);
|
|
21
|
+
return spawn(commandLabel, {
|
|
22
|
+
cwd: params.cwd,
|
|
23
|
+
env: params.env,
|
|
24
|
+
shell: true,
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return spawn(params.command, params.args, {
|
|
30
|
+
cwd: params.cwd,
|
|
31
|
+
env: params.env,
|
|
32
|
+
shell: false,
|
|
33
|
+
detached: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runShellCommand(params: {
|
|
38
|
+
command: string;
|
|
39
|
+
args: string[];
|
|
40
|
+
cwd: string;
|
|
41
|
+
env?: NodeJS.ProcessEnv;
|
|
42
|
+
input?: string;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}): Promise<ShellCommandResult> {
|
|
45
|
+
return await new Promise((resolve) => {
|
|
46
|
+
const child = spawnShellCommand(params);
|
|
47
|
+
let timeoutError: Error | undefined;
|
|
48
|
+
const timeout = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
|
|
49
|
+
? setTimeout(() => {
|
|
50
|
+
timeoutError = new Error(`${params.command} timed out after ${params.timeoutMs}ms`);
|
|
51
|
+
terminateChildProcess(child);
|
|
52
|
+
}, params.timeoutMs)
|
|
53
|
+
: undefined;
|
|
54
|
+
|
|
55
|
+
let stdout = "";
|
|
56
|
+
let stderr = "";
|
|
57
|
+
|
|
58
|
+
child.stdout.setEncoding("utf8");
|
|
59
|
+
child.stderr.setEncoding("utf8");
|
|
60
|
+
child.stdout.on("data", (chunk) => {
|
|
61
|
+
stdout += chunk;
|
|
62
|
+
});
|
|
63
|
+
child.stderr.on("data", (chunk) => {
|
|
64
|
+
stderr += chunk;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (typeof params.input === "string") {
|
|
68
|
+
child.stdin.end(params.input);
|
|
69
|
+
} else {
|
|
70
|
+
child.stdin.end();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
child.on("error", (error) => {
|
|
74
|
+
if (timeout) {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
}
|
|
77
|
+
resolve({
|
|
78
|
+
code: undefined,
|
|
79
|
+
stdout,
|
|
80
|
+
stderr,
|
|
81
|
+
error,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
child.on("close", (code, signal) => {
|
|
86
|
+
if (timeout) {
|
|
87
|
+
clearTimeout(timeout);
|
|
88
|
+
}
|
|
89
|
+
resolve({
|
|
90
|
+
code,
|
|
91
|
+
stdout,
|
|
92
|
+
stderr,
|
|
93
|
+
signal,
|
|
94
|
+
killed: child.killed,
|
|
95
|
+
error: timeoutError,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function terminateChildProcess(
|
|
102
|
+
child: Pick<ChildProcess, "pid" | "killed" | "kill">,
|
|
103
|
+
options?: { force?: boolean },
|
|
104
|
+
): void {
|
|
105
|
+
if (child.killed) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const force = options?.force === true;
|
|
110
|
+
const pid = typeof child.pid === "number" && Number.isFinite(child.pid) ? child.pid : undefined;
|
|
111
|
+
if (process.platform === "win32") {
|
|
112
|
+
if (pid) {
|
|
113
|
+
try {
|
|
114
|
+
const killer = spawn("taskkill", [
|
|
115
|
+
"/PID",
|
|
116
|
+
String(pid),
|
|
117
|
+
"/T",
|
|
118
|
+
"/F",
|
|
119
|
+
], {
|
|
120
|
+
stdio: "ignore",
|
|
121
|
+
windowsHide: true,
|
|
122
|
+
shell: false,
|
|
123
|
+
});
|
|
124
|
+
killer.unref();
|
|
125
|
+
} catch {
|
|
126
|
+
// Fall back to killing the direct child below.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
child.kill();
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const signal: NodeJS.Signals = force ? "SIGKILL" : "SIGTERM";
|
|
138
|
+
if (pid && pid > 0) {
|
|
139
|
+
try {
|
|
140
|
+
process.kill(-pid, signal);
|
|
141
|
+
} catch {
|
|
142
|
+
try {
|
|
143
|
+
child.kill(signal);
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
try {
|
|
150
|
+
child.kill(signal);
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!force) {
|
|
157
|
+
const escalator = setTimeout(() => {
|
|
158
|
+
terminateChildProcess(child, { force: true });
|
|
159
|
+
}, 1_000);
|
|
160
|
+
escalator.unref?.();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isMissingCommandResult(result: ShellCommandResult, command: string): boolean {
|
|
165
|
+
const combined = `${result.stdout}\n${result.stderr}\n${result.error?.message ?? ""}`.toLowerCase();
|
|
166
|
+
const normalizedCommand = command.toLowerCase();
|
|
167
|
+
return combined.includes("not recognized")
|
|
168
|
+
|| combined.includes("not found")
|
|
169
|
+
|| combined.includes(`'${normalizedCommand}' is not recognized`)
|
|
170
|
+
|| combined.includes(`"${normalizedCommand}" is not recognized`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function describeCommandFailure(result: ShellCommandResult, label: string): string {
|
|
174
|
+
if (result.error) {
|
|
175
|
+
return result.error.message;
|
|
176
|
+
}
|
|
177
|
+
const stderr = result.stderr.trim();
|
|
178
|
+
const stdout = result.stdout.trim();
|
|
179
|
+
return stderr || stdout || `${label} exited with code ${result.code ?? "unknown"}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function buildShellCommand(command: string, args: string[]): string {
|
|
183
|
+
return [command, ...args].map((arg) => quoteShellArg(arg)).join(" ");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function quoteShellArg(arg: string): string {
|
|
187
|
+
if (process.platform === "win32") {
|
|
188
|
+
return quoteWindowsShellArg(arg);
|
|
189
|
+
}
|
|
190
|
+
return quotePosixShellArg(arg);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function shouldUseShell(command: string): boolean {
|
|
194
|
+
if (process.platform !== "win32") {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const extension = path.extname(command).toLowerCase();
|
|
199
|
+
if (extension === ".exe" || extension === ".com") {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function quoteWindowsShellArg(arg: string): string {
|
|
207
|
+
if (arg.length === 0) {
|
|
208
|
+
return "\"\"";
|
|
209
|
+
}
|
|
210
|
+
const escaped = arg
|
|
211
|
+
.replace(/"/g, "\"\"")
|
|
212
|
+
.replace(/%/g, "%%");
|
|
213
|
+
if (!/[\s"&|<>^()!]/.test(arg)) {
|
|
214
|
+
return escaped;
|
|
215
|
+
}
|
|
216
|
+
return `"${escaped}"`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function quotePosixShellArg(arg: string): string {
|
|
220
|
+
if (arg.length === 0) {
|
|
221
|
+
return "''";
|
|
222
|
+
}
|
|
223
|
+
if (/^[A-Za-z0-9_./:=+-]+$/.test(arg)) {
|
|
224
|
+
return arg;
|
|
225
|
+
}
|
|
226
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
227
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function slugify(input: string): string {
|
|
2
|
+
const normalized = input
|
|
3
|
+
.normalize("NFKD")
|
|
4
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
7
|
+
.replace(/^-+|-+$/g, "")
|
|
8
|
+
.replace(/-{2,}/g, "-");
|
|
9
|
+
return normalized.slice(0, 80) || `change-${Date.now()}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function deriveProjectTitle(description: string): string {
|
|
13
|
+
const firstLine = description
|
|
14
|
+
.split(/\r?\n/)
|
|
15
|
+
.map((line) => line.trim())
|
|
16
|
+
.find((line) => line.length > 0);
|
|
17
|
+
if (firstLine && firstLine.length <= 80) {
|
|
18
|
+
return firstLine;
|
|
19
|
+
}
|
|
20
|
+
return slugToTitle(slugify(description));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function deriveChangeName(description: string): string {
|
|
24
|
+
return slugify(description);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function slugToTitle(slug: string): string {
|
|
28
|
+
return slug
|
|
29
|
+
.split("-")
|
|
30
|
+
.filter((part) => part.length > 0)
|
|
31
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
32
|
+
.join(" ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createProjectId(now = new Date()): string {
|
|
36
|
+
const stamp = [
|
|
37
|
+
now.getUTCFullYear(),
|
|
38
|
+
pad(now.getUTCMonth() + 1),
|
|
39
|
+
pad(now.getUTCDate()),
|
|
40
|
+
pad(now.getUTCHours()),
|
|
41
|
+
pad(now.getUTCMinutes()),
|
|
42
|
+
pad(now.getUTCSeconds()),
|
|
43
|
+
].join("");
|
|
44
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
45
|
+
return `clawspec-${stamp}-${random}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pad(value: number): string {
|
|
49
|
+
return value.toString().padStart(2, "0");
|
|
50
|
+
}
|