@leonarto/spec-embryo 0.1.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 +156 -0
- package/package.json +48 -0
- package/src/backends/base.ts +18 -0
- package/src/backends/deterministic.ts +105 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/prompt.ts +169 -0
- package/src/backends/subprocess.ts +198 -0
- package/src/cli.ts +111 -0
- package/src/commands/agents.ts +16 -0
- package/src/commands/current.ts +95 -0
- package/src/commands/doctor.ts +12 -0
- package/src/commands/handoff.ts +64 -0
- package/src/commands/init.ts +101 -0
- package/src/commands/reshape.ts +20 -0
- package/src/commands/resume.ts +19 -0
- package/src/commands/spec.ts +108 -0
- package/src/commands/status.ts +98 -0
- package/src/commands/task.ts +190 -0
- package/src/commands/ui.ts +35 -0
- package/src/domain.ts +357 -0
- package/src/engine.ts +290 -0
- package/src/frontmatter.ts +83 -0
- package/src/index.ts +75 -0
- package/src/paths.ts +32 -0
- package/src/repository.ts +807 -0
- package/src/services/adoption.ts +169 -0
- package/src/services/agents.ts +191 -0
- package/src/services/dashboard.ts +776 -0
- package/src/services/details.ts +453 -0
- package/src/services/doctor.ts +452 -0
- package/src/services/layout.ts +420 -0
- package/src/services/spec-answer-evaluation.ts +103 -0
- package/src/services/spec-import.ts +217 -0
- package/src/services/spec-questions.ts +343 -0
- package/src/services/ui.ts +34 -0
- package/src/storage.ts +57 -0
- package/src/templates.ts +270 -0
- package/tsconfig.json +17 -0
- package/web/package.json +24 -0
- package/web/src/app.css +83 -0
- package/web/src/app.d.ts +6 -0
- package/web/src/app.html +11 -0
- package/web/src/lib/components/AnalysisFilters.svelte +293 -0
- package/web/src/lib/components/DocumentBody.svelte +100 -0
- package/web/src/lib/components/MultiSelectDropdown.svelte +280 -0
- package/web/src/lib/components/SelectDropdown.svelte +265 -0
- package/web/src/lib/server/project-root.ts +34 -0
- package/web/src/lib/task-board.ts +20 -0
- package/web/src/routes/+layout.server.ts +57 -0
- package/web/src/routes/+layout.svelte +421 -0
- package/web/src/routes/+layout.ts +1 -0
- package/web/src/routes/+page.svelte +530 -0
- package/web/src/routes/specs/+page.svelte +416 -0
- package/web/src/routes/specs/[specId]/+page.server.ts +81 -0
- package/web/src/routes/specs/[specId]/+page.svelte +675 -0
- package/web/src/routes/tasks/+page.svelte +341 -0
- package/web/src/routes/tasks/[taskId]/+page.server.ts +12 -0
- package/web/src/routes/tasks/[taskId]/+page.svelte +431 -0
- package/web/src/routes/timeline/+page.svelte +1093 -0
- package/web/svelte.config.js +10 -0
- package/web/tsconfig.json +9 -0
- package/web/vite.config.ts +11 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeUtf8 } from "../storage.ts";
|
|
4
|
+
import type { BackendName, ExecutionBackendConfig, ResumeContext } from "../domain.ts";
|
|
5
|
+
import type { BackendRunOptions, BackendRunResult, ResumeBackend } from "./base.ts";
|
|
6
|
+
import { buildPromptBundle, renderPromptBundleText } from "./prompt.ts";
|
|
7
|
+
|
|
8
|
+
function replaceToken(token: string, replacements: Record<string, string>): string {
|
|
9
|
+
let output = token;
|
|
10
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
11
|
+
output = output.replaceAll(`{${key}}`, value);
|
|
12
|
+
}
|
|
13
|
+
return output;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SubprocessLaunchPlan {
|
|
17
|
+
backend: BackendName;
|
|
18
|
+
mode: "disabled" | "preview" | "spawned";
|
|
19
|
+
command: string | null;
|
|
20
|
+
args: string[];
|
|
21
|
+
promptBundle: ReturnType<typeof buildPromptBundle>;
|
|
22
|
+
promptText: string;
|
|
23
|
+
promptBundleText: string;
|
|
24
|
+
artifactPaths: {
|
|
25
|
+
promptTextFile: string;
|
|
26
|
+
promptBundleFile: string;
|
|
27
|
+
};
|
|
28
|
+
env: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildArtifactPaths(rootDir: string, backend: BackendName): SubprocessLaunchPlan["artifactPaths"] {
|
|
32
|
+
return {
|
|
33
|
+
promptTextFile: join(rootDir, ".specpm", "tmp", `${backend}-resume-prompt.txt`),
|
|
34
|
+
promptBundleFile: join(rootDir, ".specpm", "tmp", `${backend}-resume-prompt.json`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildSubprocessLaunchPlan(
|
|
39
|
+
backend: BackendName,
|
|
40
|
+
backendConfig: ExecutionBackendConfig,
|
|
41
|
+
context: ResumeContext,
|
|
42
|
+
options: BackendRunOptions,
|
|
43
|
+
): SubprocessLaunchPlan {
|
|
44
|
+
const promptBundle = buildPromptBundle(context);
|
|
45
|
+
const promptBundleText = JSON.stringify(promptBundle, null, 2);
|
|
46
|
+
const promptText = renderPromptBundleText(promptBundle);
|
|
47
|
+
const artifactPaths = buildArtifactPaths(options.rootDir, backend);
|
|
48
|
+
|
|
49
|
+
const replacements = {
|
|
50
|
+
prompt: promptText,
|
|
51
|
+
prompt_text: promptText,
|
|
52
|
+
prompt_bundle: promptBundleText,
|
|
53
|
+
prompt_file: artifactPaths.promptTextFile,
|
|
54
|
+
prompt_text_file: artifactPaths.promptTextFile,
|
|
55
|
+
prompt_bundle_file: artifactPaths.promptBundleFile,
|
|
56
|
+
bundle_file: artifactPaths.promptBundleFile,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const command = backendConfig.command ?? null;
|
|
60
|
+
const args = (backendConfig.args ?? []).map((arg) => replaceToken(arg, replacements));
|
|
61
|
+
const env = {
|
|
62
|
+
SPM_BACKEND_NAME: backend,
|
|
63
|
+
SPM_PROJECT_ROOT: options.rootDir,
|
|
64
|
+
SPM_PROMPT_FILE: artifactPaths.promptTextFile,
|
|
65
|
+
SPM_PROMPT_TEXT_FILE: artifactPaths.promptTextFile,
|
|
66
|
+
SPM_PROMPT_BUNDLE_FILE: artifactPaths.promptBundleFile,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
backend,
|
|
71
|
+
mode: !backendConfig.enabled || !command ? "disabled" : options.spawn ? "spawned" : "preview",
|
|
72
|
+
command,
|
|
73
|
+
args,
|
|
74
|
+
promptBundle,
|
|
75
|
+
promptText,
|
|
76
|
+
promptBundleText,
|
|
77
|
+
artifactPaths,
|
|
78
|
+
env,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderPreviewText(plan: SubprocessLaunchPlan): string {
|
|
83
|
+
const commandLine = plan.command ? [plan.command, ...plan.args].join(" ") : "(not configured)";
|
|
84
|
+
|
|
85
|
+
return [
|
|
86
|
+
`${plan.backend} backend is configured but not spawned.`,
|
|
87
|
+
"Re-run with --spawn to invoke the subprocess adapter.",
|
|
88
|
+
"",
|
|
89
|
+
"Command preview:",
|
|
90
|
+
commandLine,
|
|
91
|
+
"",
|
|
92
|
+
"Prompt contract:",
|
|
93
|
+
`- prompt text file: ${plan.artifactPaths.promptTextFile}`,
|
|
94
|
+
`- prompt bundle file: ${plan.artifactPaths.promptBundleFile}`,
|
|
95
|
+
"- token replacements: {prompt}, {prompt_text}, {prompt_bundle}, {prompt_file}, {prompt_text_file}, {prompt_bundle_file}, {bundle_file}",
|
|
96
|
+
"- env vars: SPM_BACKEND_NAME, SPM_PROJECT_ROOT, SPM_PROMPT_FILE, SPM_PROMPT_TEXT_FILE, SPM_PROMPT_BUNDLE_FILE",
|
|
97
|
+
"",
|
|
98
|
+
"Prompt preview:",
|
|
99
|
+
plan.promptText,
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export abstract class SubprocessBackend implements ResumeBackend {
|
|
104
|
+
abstract name: BackendName;
|
|
105
|
+
|
|
106
|
+
constructor(private readonly backendConfig: ExecutionBackendConfig) {}
|
|
107
|
+
|
|
108
|
+
protected get config(): ExecutionBackendConfig {
|
|
109
|
+
return this.backendConfig;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async run(context: ResumeContext, options: BackendRunOptions): Promise<BackendRunResult> {
|
|
113
|
+
const plan = buildSubprocessLaunchPlan(this.name, this.config, context, options);
|
|
114
|
+
|
|
115
|
+
if (plan.mode === "disabled" || !plan.command) {
|
|
116
|
+
const payload = {
|
|
117
|
+
backend: this.name,
|
|
118
|
+
mode: "disabled" as const,
|
|
119
|
+
reason: "Backend is disabled or missing a command.",
|
|
120
|
+
prompt_bundle: plan.promptBundle,
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
text: options.json
|
|
124
|
+
? JSON.stringify(payload, null, 2)
|
|
125
|
+
: `${this.name} backend is disabled. Enable it in .specpm/config.toml or use --backend prompt.`,
|
|
126
|
+
json: payload,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (plan.mode === "preview") {
|
|
131
|
+
const payload = {
|
|
132
|
+
backend: this.name,
|
|
133
|
+
mode: "preview" as const,
|
|
134
|
+
command: plan.command,
|
|
135
|
+
args: plan.args,
|
|
136
|
+
env: plan.env,
|
|
137
|
+
artifact_paths: plan.artifactPaths,
|
|
138
|
+
prompt_bundle: plan.promptBundle,
|
|
139
|
+
prompt_text: plan.promptText,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
text: options.json ? JSON.stringify(payload, null, 2) : renderPreviewText(plan),
|
|
144
|
+
json: payload,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
await writeUtf8(plan.artifactPaths.promptBundleFile, plan.promptBundleText);
|
|
149
|
+
await writeUtf8(plan.artifactPaths.promptTextFile, plan.promptText);
|
|
150
|
+
|
|
151
|
+
const child = spawn(plan.command, plan.args, {
|
|
152
|
+
cwd: options.rootDir,
|
|
153
|
+
env: {
|
|
154
|
+
...process.env,
|
|
155
|
+
...plan.env,
|
|
156
|
+
},
|
|
157
|
+
stdio: "inherit",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await new Promise<void>((resolve, reject) => {
|
|
161
|
+
child.on("exit", (code) => {
|
|
162
|
+
if (code === 0) {
|
|
163
|
+
resolve();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
reject(new Error(`${this.name} backend exited with code ${code ?? -1}`));
|
|
167
|
+
});
|
|
168
|
+
child.on("error", reject);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
text: options.json
|
|
173
|
+
? JSON.stringify(
|
|
174
|
+
{
|
|
175
|
+
backend: this.name,
|
|
176
|
+
mode: "spawned",
|
|
177
|
+
command: plan.command,
|
|
178
|
+
args: plan.args,
|
|
179
|
+
env: plan.env,
|
|
180
|
+
artifact_paths: plan.artifactPaths,
|
|
181
|
+
prompt_bundle: plan.promptBundle,
|
|
182
|
+
},
|
|
183
|
+
null,
|
|
184
|
+
2,
|
|
185
|
+
)
|
|
186
|
+
: `${this.name} backend finished successfully.`,
|
|
187
|
+
json: {
|
|
188
|
+
backend: this.name,
|
|
189
|
+
mode: "spawned" as const,
|
|
190
|
+
command: plan.command,
|
|
191
|
+
args: plan.args,
|
|
192
|
+
env: plan.env,
|
|
193
|
+
artifact_paths: plan.artifactPaths,
|
|
194
|
+
prompt_bundle: plan.promptBundle,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import type { BackendName } from "./domain.ts";
|
|
3
|
+
|
|
4
|
+
export interface ParsedArgs {
|
|
5
|
+
command: string | undefined;
|
|
6
|
+
subcommand: string | undefined;
|
|
7
|
+
positionals: string[];
|
|
8
|
+
flags: Map<string, string | boolean>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseArgs(argv: string[]): ParsedArgs {
|
|
12
|
+
const flags = new Map<string, string | boolean>();
|
|
13
|
+
const positionals: string[] = [];
|
|
14
|
+
let index = 0;
|
|
15
|
+
|
|
16
|
+
while (index < argv.length) {
|
|
17
|
+
const arg = argv[index]!;
|
|
18
|
+
|
|
19
|
+
if (arg.startsWith("--")) {
|
|
20
|
+
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
|
|
21
|
+
if (inlineValue !== undefined) {
|
|
22
|
+
flags.set(rawKey, inlineValue);
|
|
23
|
+
index += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const next = argv[index + 1];
|
|
28
|
+
if (next && !next.startsWith("--")) {
|
|
29
|
+
flags.set(rawKey, next);
|
|
30
|
+
index += 2;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
flags.set(rawKey, true);
|
|
35
|
+
index += 1;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
positionals.push(arg);
|
|
40
|
+
index += 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
command: positionals[0],
|
|
45
|
+
subcommand: positionals[1],
|
|
46
|
+
positionals,
|
|
47
|
+
flags,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function flagValue(parsed: ParsedArgs, name: string): string | undefined {
|
|
52
|
+
const value = parsed.flags.get(name);
|
|
53
|
+
return typeof value === "string" ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hasFlag(parsed: ParsedArgs, name: string): boolean {
|
|
57
|
+
return parsed.flags.has(name);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function backendFlag(parsed: ParsedArgs, fallback: BackendName): BackendName {
|
|
61
|
+
const value = flagValue(parsed, "backend");
|
|
62
|
+
if (value === "prompt" || value === "deterministic" || value === "codex" || value === "claude") {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatPath(rootDir: string, absolutePath: string): string {
|
|
70
|
+
const rel = relative(rootDir, absolutePath);
|
|
71
|
+
return rel.length > 0 ? rel : ".";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function helpText(): string {
|
|
75
|
+
return `spec-embryo
|
|
76
|
+
|
|
77
|
+
Usage:
|
|
78
|
+
spec-embryo init [--force] [--home docs] [--standalone] [--json]
|
|
79
|
+
spm init [--force] [--home docs] [--standalone] [--json]
|
|
80
|
+
spm reshape [--home docs] [--dry-run]
|
|
81
|
+
spm doctor [--json]
|
|
82
|
+
spm status [--json]
|
|
83
|
+
spm current show [--json]
|
|
84
|
+
spm current set [--focus "..."] [--summary "..."] [--active-spec-ids SPEC-001,SPEC-002] [--active-task-ids TASK-006,TASK-013] [--blocked-task-ids TASK-003] [--next-action-hints "hint one || hint two"] [--clear-active-spec-ids] [--clear-active-task-ids] [--clear-blocked-task-ids] [--clear-next-action-hints] [--json]
|
|
85
|
+
spm ui [--host 127.0.0.1] [--port 4173]
|
|
86
|
+
spm resume [--backend deterministic|prompt|codex|claude] [--json] [--spawn]
|
|
87
|
+
spm handoff [--title "Checkpoint"] [--note "extra note"]
|
|
88
|
+
spm agents check [--json]
|
|
89
|
+
spm spec list [--status active]
|
|
90
|
+
spm spec create --title "..." --summary "..." [--status proposed] [--task-ids TASK-001,TASK-002] [--tags foundation,cli] [--owner human-ai] [--json]
|
|
91
|
+
spm spec set-status <SPEC-ID> <proposed|active|paused|done|archived> [--json]
|
|
92
|
+
spm spec import-prompt --source docs/specs [--instructions "..."] [--max-chars-per-file 1800] [--json]
|
|
93
|
+
spm task list [--status todo] [--spec SPEC-001]
|
|
94
|
+
spm task create --title "..." --summary "..." --spec-ids SPEC-001[,SPEC-002] [--status todo|in_progress|blocked|review|done|cancelled] [--priority 2] [--depends-on TASK-001] [--blocked-by TASK-002] [--owner-role builder] [--tags cli,self-hosting] [--json]
|
|
95
|
+
spm task set-status <TASK-ID> <todo|in_progress|blocked|review|done|cancelled> [--json]
|
|
96
|
+
spm task archive <TASK-ID> [--reason "..."] [--json]
|
|
97
|
+
spm task archive-done --older-than 7d [--reason "..."] [--json]
|
|
98
|
+
spm task archive-cancelled --older-than 7d [--reason "..."] [--json]
|
|
99
|
+
spm task restore <TASK-ID> [--json]
|
|
100
|
+
|
|
101
|
+
Aliases:
|
|
102
|
+
- \`spm\` is the primary short command
|
|
103
|
+
- \`spec-embryo\` is the long-form name
|
|
104
|
+
|
|
105
|
+
Core ideas:
|
|
106
|
+
- local-first and file-backed
|
|
107
|
+
- deterministic-first parsing and status derivation
|
|
108
|
+
- prompt-return mode for already-running agents
|
|
109
|
+
- subprocess agent backends as optional adapters
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import { buildAgentsHealthReport, renderAgentsHealthReport } from "../services/agents.ts";
|
|
3
|
+
|
|
4
|
+
export async function runAgentsCommand(rootDir: string, parsed: ParsedArgs): Promise<string> {
|
|
5
|
+
if (parsed.subcommand !== "check") {
|
|
6
|
+
throw new Error("Only `agents check` is supported in v1.");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const report = await buildAgentsHealthReport(rootDir);
|
|
10
|
+
|
|
11
|
+
if (parsed.flags.has("json")) {
|
|
12
|
+
return JSON.stringify(report, null, 2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return renderAgentsHealthReport(report);
|
|
16
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import { flagValue } from "../cli.ts";
|
|
3
|
+
import { loadProjectContext, updateCurrentState } from "../repository.ts";
|
|
4
|
+
|
|
5
|
+
function parseCommaList(value: string | undefined): string[] | undefined {
|
|
6
|
+
if (!value) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return value
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((item) => item.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseHints(value: string | undefined): string[] | undefined {
|
|
17
|
+
if (!value) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return value
|
|
22
|
+
.split("||")
|
|
23
|
+
.map((item) => item.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function runCurrentCommand(rootDir: string, parsed: ParsedArgs): Promise<string> {
|
|
28
|
+
if (!parsed.subcommand || parsed.subcommand === "show") {
|
|
29
|
+
const context = await loadProjectContext(rootDir);
|
|
30
|
+
|
|
31
|
+
if (parsed.flags.has("json")) {
|
|
32
|
+
return JSON.stringify(context.currentState, null, 2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const lines = [
|
|
36
|
+
`Current state: ${context.currentState.title}`,
|
|
37
|
+
`Summary: ${context.currentState.summary}`,
|
|
38
|
+
`Focus: ${context.currentState.focus}`,
|
|
39
|
+
"",
|
|
40
|
+
`Active specs: ${context.currentState.activeSpecIds.join(", ") || "none"}`,
|
|
41
|
+
`Active tasks: ${context.currentState.activeTaskIds.join(", ") || "none"}`,
|
|
42
|
+
`Blocked tasks: ${context.currentState.blockedTaskIds.join(", ") || "none"}`,
|
|
43
|
+
"",
|
|
44
|
+
"Next action hints:",
|
|
45
|
+
...(context.currentState.nextActionHints.length > 0
|
|
46
|
+
? context.currentState.nextActionHints.map((hint) => `- ${hint}`)
|
|
47
|
+
: ["- none"]),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (context.currentState.lastHandoffId) {
|
|
51
|
+
lines.push("", `Last handoff: ${context.currentState.lastHandoffId}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (parsed.subcommand !== "set") {
|
|
58
|
+
throw new Error("Supported current commands in v1 are `current show` and `current set`.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const hasMutation =
|
|
62
|
+
flagValue(parsed, "focus") !== undefined ||
|
|
63
|
+
flagValue(parsed, "summary") !== undefined ||
|
|
64
|
+
flagValue(parsed, "active-spec-ids") !== undefined ||
|
|
65
|
+
flagValue(parsed, "active-task-ids") !== undefined ||
|
|
66
|
+
flagValue(parsed, "blocked-task-ids") !== undefined ||
|
|
67
|
+
flagValue(parsed, "next-action-hints") !== undefined ||
|
|
68
|
+
parsed.flags.has("clear-active-spec-ids") ||
|
|
69
|
+
parsed.flags.has("clear-active-task-ids") ||
|
|
70
|
+
parsed.flags.has("clear-blocked-task-ids") ||
|
|
71
|
+
parsed.flags.has("clear-next-action-hints");
|
|
72
|
+
|
|
73
|
+
if (!hasMutation) {
|
|
74
|
+
throw new Error("`current set` requires at least one field flag or clear flag.");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const updated = await updateCurrentState(rootDir, {
|
|
78
|
+
focus: flagValue(parsed, "focus"),
|
|
79
|
+
summary: flagValue(parsed, "summary"),
|
|
80
|
+
activeSpecIds: parseCommaList(flagValue(parsed, "active-spec-ids")),
|
|
81
|
+
activeTaskIds: parseCommaList(flagValue(parsed, "active-task-ids")),
|
|
82
|
+
blockedTaskIds: parseCommaList(flagValue(parsed, "blocked-task-ids")),
|
|
83
|
+
nextActionHints: parseHints(flagValue(parsed, "next-action-hints")),
|
|
84
|
+
clearActiveSpecIds: parsed.flags.has("clear-active-spec-ids"),
|
|
85
|
+
clearActiveTaskIds: parsed.flags.has("clear-active-task-ids"),
|
|
86
|
+
clearBlockedTaskIds: parsed.flags.has("clear-blocked-task-ids"),
|
|
87
|
+
clearNextActionHints: parsed.flags.has("clear-next-action-hints"),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (parsed.flags.has("json")) {
|
|
91
|
+
return JSON.stringify(updated, null, 2);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return `Updated current state in ${updated.filePath}`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import { buildDoctorReport, renderDoctorReport } from "../services/doctor.ts";
|
|
3
|
+
|
|
4
|
+
export async function runDoctorCommand(rootDir: string, parsed: ParsedArgs): Promise<string> {
|
|
5
|
+
const report = await buildDoctorReport(rootDir);
|
|
6
|
+
|
|
7
|
+
if (parsed.flags.has("json")) {
|
|
8
|
+
return JSON.stringify(report, null, 2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return renderDoctorReport(report);
|
|
12
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import { flagValue } from "../cli.ts";
|
|
3
|
+
import { buildResumeContext } from "../engine.ts";
|
|
4
|
+
import { loadProjectContext, writeHandoff } from "../repository.ts";
|
|
5
|
+
|
|
6
|
+
function compactTitle(title: string): string {
|
|
7
|
+
return title.trim().length > 0 ? title.trim() : "Checkpoint";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function runHandoffCommand(rootDir: string, parsed: ParsedArgs): Promise<string> {
|
|
11
|
+
const context = await loadProjectContext(rootDir);
|
|
12
|
+
const resumeContext = buildResumeContext(context);
|
|
13
|
+
const createdAt = new Date().toISOString();
|
|
14
|
+
const title = compactTitle(flagValue(parsed, "title") ?? `Checkpoint ${createdAt.slice(0, 10)}`);
|
|
15
|
+
const note = flagValue(parsed, "note");
|
|
16
|
+
const handoffId = `HANDOFF-${createdAt.replace(/[-:]/g, "").replace(/\..+/, "")}`;
|
|
17
|
+
const summary = resumeContext.nextActions[0] ?? "Checkpoint created.";
|
|
18
|
+
const bodySections = [
|
|
19
|
+
"# Summary",
|
|
20
|
+
"",
|
|
21
|
+
resumeContext.summary,
|
|
22
|
+
"",
|
|
23
|
+
"## Focus",
|
|
24
|
+
"",
|
|
25
|
+
resumeContext.focus,
|
|
26
|
+
"",
|
|
27
|
+
"## Active Specs",
|
|
28
|
+
"",
|
|
29
|
+
...(resumeContext.activeSpecs.length > 0 ? resumeContext.activeSpecs.map((spec) => `- ${spec.id}: ${spec.title}`) : ["- none"]),
|
|
30
|
+
"",
|
|
31
|
+
"## Active Tasks",
|
|
32
|
+
"",
|
|
33
|
+
...(resumeContext.activeTasks.length > 0
|
|
34
|
+
? resumeContext.activeTasks.map((entry) => `- ${entry.task.id}: ${entry.task.title}`)
|
|
35
|
+
: ["- none"]),
|
|
36
|
+
"",
|
|
37
|
+
"## Next Actions",
|
|
38
|
+
"",
|
|
39
|
+
...resumeContext.nextActions.map((action) => `- ${action}`),
|
|
40
|
+
"",
|
|
41
|
+
"## Blockers",
|
|
42
|
+
"",
|
|
43
|
+
...(resumeContext.blockedTasks.length > 0
|
|
44
|
+
? resumeContext.blockedTasks.map((entry) => `- ${entry.task.id}: ${entry.unmetDependencies.join(", ") || "manually blocked"}`)
|
|
45
|
+
: ["- none"]),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
if (note) {
|
|
49
|
+
bodySections.push("", "## Additional Note", "", note);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const filePath = await writeHandoff(rootDir, context.currentState, {
|
|
53
|
+
id: handoffId,
|
|
54
|
+
title,
|
|
55
|
+
summary,
|
|
56
|
+
createdAt,
|
|
57
|
+
activeTaskIds: resumeContext.activeTasks.map((entry) => entry.task.id),
|
|
58
|
+
relatedSpecIds: resumeContext.activeSpecs.map((spec) => spec.id),
|
|
59
|
+
author: "spec-embryo",
|
|
60
|
+
body: bodySections.join("\n"),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return `Created handoff ${handoffId}\n${filePath}`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin, stdout } from "node:process";
|
|
4
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
5
|
+
import { flagValue, formatPath, hasFlag } from "../cli.ts";
|
|
6
|
+
import { initializeProject, readProjectConfig } from "../repository.ts";
|
|
7
|
+
import { pathExists } from "../storage.ts";
|
|
8
|
+
import { buildAdoptionGuidance, renderAdoptionGuidance } from "../services/adoption.ts";
|
|
9
|
+
import { inspectAdoptionState, normalizeManagedHomeInput } from "../services/layout.ts";
|
|
10
|
+
|
|
11
|
+
async function promptForLayoutChoice(candidates: Array<{ relativePath: string; reason: string }>): Promise<string> {
|
|
12
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
13
|
+
throw new Error("Multiple docs/specs homes found. Re-run with --home <folder> or --standalone.");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
17
|
+
try {
|
|
18
|
+
const options = candidates.map((candidate) => `${candidate.relativePath}/spm`);
|
|
19
|
+
const promptLines = [
|
|
20
|
+
"Multiple documentation/spec surfaces were found.",
|
|
21
|
+
...options.map((option, index) => `${index + 1}. ${option}`),
|
|
22
|
+
`${options.length + 1}. spm`,
|
|
23
|
+
];
|
|
24
|
+
stdout.write(`${promptLines.join("\n")}\n`);
|
|
25
|
+
const answer = await rl.question("Choose the memory home number: ");
|
|
26
|
+
const numeric = Number.parseInt(answer.trim(), 10);
|
|
27
|
+
|
|
28
|
+
if (numeric >= 1 && numeric <= options.length) {
|
|
29
|
+
return options[numeric - 1]!;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (numeric === options.length + 1) {
|
|
33
|
+
return "spm";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
throw new Error("Invalid init selection.");
|
|
37
|
+
} finally {
|
|
38
|
+
rl.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runInitCommand(rootDir: string, parsed: ParsedArgs): Promise<string> {
|
|
43
|
+
const explicitHome = flagValue(parsed, "home");
|
|
44
|
+
const configFile = join(rootDir, ".specpm", "config.toml");
|
|
45
|
+
const existingConfig = (await pathExists(configFile)) ? await readProjectConfig(rootDir) : undefined;
|
|
46
|
+
const inspection = await inspectAdoptionState(rootDir, {
|
|
47
|
+
explicitHome,
|
|
48
|
+
standalone: hasFlag(parsed, "standalone"),
|
|
49
|
+
config: existingConfig,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
let memoryDir = explicitHome ? normalizeManagedHomeInput(explicitHome) : inspection.layoutChoice.memoryDir;
|
|
53
|
+
if (inspection.layoutChoice.requiresChoice) {
|
|
54
|
+
memoryDir = await promptForLayoutChoice(inspection.layoutChoice.candidates);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = await initializeProject(rootDir, {
|
|
58
|
+
force: hasFlag(parsed, "force"),
|
|
59
|
+
memoryDir,
|
|
60
|
+
});
|
|
61
|
+
const createdLines = result.created.map((filePath) => `created ${formatPath(rootDir, filePath)}`);
|
|
62
|
+
const skippedLines = result.skipped.map((filePath) => `kept ${formatPath(rootDir, filePath)}`);
|
|
63
|
+
const guidance = buildAdoptionGuidance({
|
|
64
|
+
adoption: inspection,
|
|
65
|
+
memoryDir: result.memoryDir,
|
|
66
|
+
created: result.created.map((filePath) => formatPath(rootDir, filePath)),
|
|
67
|
+
skipped: result.skipped.map((filePath) => formatPath(rootDir, filePath)),
|
|
68
|
+
commandContext: "init",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (parsed.flags.has("json")) {
|
|
72
|
+
return JSON.stringify(
|
|
73
|
+
{
|
|
74
|
+
memory_dir: result.memoryDir,
|
|
75
|
+
adopted_home: inspection.layoutChoice.adoptedHome,
|
|
76
|
+
adoption_state: inspection.state,
|
|
77
|
+
inspection,
|
|
78
|
+
sequence: result.sequence,
|
|
79
|
+
created: result.created.map((filePath) => formatPath(rootDir, filePath)),
|
|
80
|
+
skipped: result.skipped.map((filePath) => formatPath(rootDir, filePath)),
|
|
81
|
+
guidance,
|
|
82
|
+
},
|
|
83
|
+
null,
|
|
84
|
+
2,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return [
|
|
89
|
+
"Initialized Spec Embryo project memory.",
|
|
90
|
+
`Adoption state: ${inspection.state}`,
|
|
91
|
+
`Memory home: ${result.memoryDir}`,
|
|
92
|
+
"",
|
|
93
|
+
"Init sequence:",
|
|
94
|
+
...result.sequence.map((step, index) => `${index + 1}. ${step}`),
|
|
95
|
+
"",
|
|
96
|
+
...renderAdoptionGuidance(guidance),
|
|
97
|
+
"",
|
|
98
|
+
...createdLines,
|
|
99
|
+
...(skippedLines.length > 0 ? ["", ...skippedLines] : []),
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import { flagValue, hasFlag } from "../cli.ts";
|
|
3
|
+
import { reshapeProject } from "../repository.ts";
|
|
4
|
+
import { normalizeManagedHomeInput } from "../services/layout.ts";
|
|
5
|
+
|
|
6
|
+
export async function runReshapeCommand(rootDir: string, parsed: ParsedArgs): Promise<string> {
|
|
7
|
+
const home = flagValue(parsed, "home");
|
|
8
|
+
const result = await reshapeProject(rootDir, {
|
|
9
|
+
memoryDir: home ? normalizeManagedHomeInput(home) : undefined,
|
|
10
|
+
dryRun: hasFlag(parsed, "dry-run"),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return [
|
|
14
|
+
`${result.applied ? "Reshaped" : "Planned reshape for"} project memory.`,
|
|
15
|
+
`Target memory home: ${result.targetMemoryDir}`,
|
|
16
|
+
"",
|
|
17
|
+
...result.actions.map((action) => `- ${action}`),
|
|
18
|
+
...(result.warnings.length > 0 ? ["", "Warnings:", ...result.warnings.map((warning) => `- ${warning}`)] : []),
|
|
19
|
+
].join("\n");
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ParsedArgs } from "../cli.ts";
|
|
2
|
+
import { backendFlag, hasFlag } from "../cli.ts";
|
|
3
|
+
import { resolveResumeBackend } from "../backends/index.ts";
|
|
4
|
+
import { buildResumeContext } from "../engine.ts";
|
|
5
|
+
import { loadProjectContext } from "../repository.ts";
|
|
6
|
+
|
|
7
|
+
export async function runResumeCommand(rootDir: string, parsed: ParsedArgs): Promise<string> {
|
|
8
|
+
const context = await loadProjectContext(rootDir);
|
|
9
|
+
const backendName = backendFlag(parsed, context.config.defaults.resumeBackend);
|
|
10
|
+
const backend = resolveResumeBackend(backendName, context.config);
|
|
11
|
+
const result = await backend.run(buildResumeContext(context), {
|
|
12
|
+
rootDir,
|
|
13
|
+
config: context.config,
|
|
14
|
+
json: hasFlag(parsed, "json"),
|
|
15
|
+
spawn: hasFlag(parsed, "spawn"),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return result.text;
|
|
19
|
+
}
|