@printpractical/cruze 0.0.1
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/LICENSE +201 -0
- package/README.md +167 -0
- package/bin/cruze.mjs +17 -0
- package/dist/adapters/inbound/cli/cli_context.js +75 -0
- package/dist/adapters/inbound/cli/commands/code.js +30 -0
- package/dist/adapters/inbound/cli/commands/documents.js +52 -0
- package/dist/adapters/inbound/cli/commands/init.js +23 -0
- package/dist/adapters/inbound/cli/commands/install.js +15 -0
- package/dist/adapters/inbound/cli/commands/journal.js +31 -0
- package/dist/adapters/inbound/cli/commands/progress.js +51 -0
- package/dist/adapters/inbound/cli/commands/review.js +18 -0
- package/dist/adapters/inbound/cli/commands/status.js +45 -0
- package/dist/adapters/inbound/cli/commands.js +36 -0
- package/dist/adapters/inbound/cli/parse_command.js +90 -0
- package/dist/adapters/inbound/cli/run_cli.js +50 -0
- package/dist/adapters/outbound/git_repository.js +57 -0
- package/dist/adapters/outbound/node_project_files.js +101 -0
- package/dist/adapters/outbound/package_bundle.js +56 -0
- package/dist/adapters/outbound/process_agent_runner.js +26 -0
- package/dist/adapters/outbound/system_clock.js +14 -0
- package/dist/adapters/outbound/terminal_prompter.js +22 -0
- package/dist/app/ports/agent_runner.js +11 -0
- package/dist/app/ports/bundle.js +22 -0
- package/dist/app/ports/clock.js +6 -0
- package/dist/app/ports/project_files.js +20 -0
- package/dist/app/ports/prompter.js +5 -0
- package/dist/app/ports/repository.js +13 -0
- package/dist/app/project_context.js +45 -0
- package/dist/app/use_cases/abandon_work.js +42 -0
- package/dist/app/use_cases/approve_artifact.js +146 -0
- package/dist/app/use_cases/check_code.js +90 -0
- package/dist/app/use_cases/init_project.js +59 -0
- package/dist/app/use_cases/install_skills.js +93 -0
- package/dist/app/use_cases/journal_events.js +48 -0
- package/dist/app/use_cases/land_change.js +113 -0
- package/dist/app/use_cases/new_project_doc.js +40 -0
- package/dist/app/use_cases/new_work_item.js +75 -0
- package/dist/app/use_cases/project_status.js +73 -0
- package/dist/app/use_cases/record_progress.js +101 -0
- package/dist/app/use_cases/run_review.js +71 -0
- package/dist/app/use_cases/validate_project.js +22 -0
- package/dist/domain/agent_skill_homes.js +25 -0
- package/dist/domain/approvals/evaluate.js +36 -0
- package/dist/domain/approvals/fingerprint.js +52 -0
- package/dist/domain/approvals/records.js +50 -0
- package/dist/domain/architecture_sections.js +23 -0
- package/dist/domain/check/check_source.js +64 -0
- package/dist/domain/check/imports.js +81 -0
- package/dist/domain/check/rust_imports.js +84 -0
- package/dist/domain/check/type_count.js +17 -0
- package/dist/domain/config.js +128 -0
- package/dist/domain/cruze_error.js +10 -0
- package/dist/domain/edits/agents_file.js +44 -0
- package/dist/domain/edits/managed_tables.js +60 -0
- package/dist/domain/edits/progress_block.js +33 -0
- package/dist/domain/edits/status_lines.js +52 -0
- package/dist/domain/elements.js +126 -0
- package/dist/domain/glob.js +46 -0
- package/dist/domain/hashing.js +52 -0
- package/dist/domain/ids.js +37 -0
- package/dist/domain/journal.js +62 -0
- package/dist/domain/land/bookkeeping.js +54 -0
- package/dist/domain/land/merge_plan.js +132 -0
- package/dist/domain/land/text_ops.js +66 -0
- package/dist/domain/markdown.js +79 -0
- package/dist/domain/markdown_tables.js +34 -0
- package/dist/domain/project/artifact_ref.js +42 -0
- package/dist/domain/project/deltas.js +49 -0
- package/dist/domain/project/layout.js +40 -0
- package/dist/domain/project/living_model.js +54 -0
- package/dist/domain/project/new_items.js +48 -0
- package/dist/domain/project/plan_parts.js +111 -0
- package/dist/domain/project/progress.js +43 -0
- package/dist/domain/project/project_view.js +72 -0
- package/dist/domain/project/roadmap.js +43 -0
- package/dist/domain/project/work_items.js +68 -0
- package/dist/domain/project_name.js +20 -0
- package/dist/domain/scaffold.js +29 -0
- package/dist/domain/skill.js +106 -0
- package/dist/domain/skill_links.js +103 -0
- package/dist/domain/status/next_step.js +119 -0
- package/dist/domain/status/verification.js +16 -0
- package/dist/domain/status/work_status.js +101 -0
- package/dist/domain/trace.js +44 -0
- package/dist/domain/validation/delta_rules.js +79 -0
- package/dist/domain/validation/element_rules.js +99 -0
- package/dist/domain/validation/living_rules.js +97 -0
- package/dist/domain/validation/plan_rules.js +105 -0
- package/dist/domain/validation/problem.js +21 -0
- package/dist/domain/validation/project_rules.js +99 -0
- package/dist/domain/validation/validate_project.js +31 -0
- package/dist/domain/validation/work_item_rules.js +144 -0
- package/dist/main.js +32 -0
- package/package.json +40 -0
- package/scripts/build.mjs +37 -0
- package/skills/about/SKILL.md +21 -0
- package/skills/architect/SKILL.md +43 -0
- package/skills/architect/feature.md +29 -0
- package/skills/architect/project.md +58 -0
- package/skills/architect/tweak.md +22 -0
- package/skills/architect/walkthrough.md +29 -0
- package/skills/behavioural-testing/SKILL.md +63 -0
- package/skills/behavioural-testing/examples.md +102 -0
- package/skills/behavioural-testing/fakes.md +30 -0
- package/skills/build/SKILL.md +62 -0
- package/skills/dependency-approval/SKILL.md +48 -0
- package/skills/domain-language/SKILL.md +37 -0
- package/skills/envision/SKILL.md +29 -0
- package/skills/envision/feature.md +22 -0
- package/skills/envision/project.md +20 -0
- package/skills/explore/SKILL.md +39 -0
- package/skills/formats/SKILL.md +84 -0
- package/skills/formats/reference/architecture.md +80 -0
- package/skills/formats/reference/config.md +57 -0
- package/skills/formats/reference/durable-docs.md +71 -0
- package/skills/formats/reference/specs.md +65 -0
- package/skills/formats/reference/work-items.md +100 -0
- package/skills/formats/templates/adr.md +7 -0
- package/skills/formats/templates/architecture.md +100 -0
- package/skills/formats/templates/change.md +29 -0
- package/skills/formats/templates/feature.md +49 -0
- package/skills/formats/templates/glossary.md +8 -0
- package/skills/formats/templates/roadmap.md +32 -0
- package/skills/formats/templates/spec.md +11 -0
- package/skills/formats/templates/standalone-change.md +46 -0
- package/skills/formats/templates/vision.md +37 -0
- package/skills/grilling/SKILL.md +66 -0
- package/skills/hexagonal-design/SKILL.md +119 -0
- package/skills/hexagonal-design/contracts.md +68 -0
- package/skills/hexagonal-design/fundamentals.md +50 -0
- package/skills/hexagonal-design/languages/cpp.md +37 -0
- package/skills/hexagonal-design/languages/rust.md +40 -0
- package/skills/hexagonal-design/runtime.md +48 -0
- package/skills/land/SKILL.md +43 -0
- package/skills/next/SKILL.md +30 -0
- package/skills/plan/SKILL.md +52 -0
- package/skills/plan/tasks.md +26 -0
- package/skills/plan/test-plan.md +14 -0
- package/skills/research/SKILL.md +41 -0
- package/skills/research/evaluation.md +27 -0
- package/skills/rethink/SKILL.md +53 -0
- package/skills/rethink/levels.md +45 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roles/SKILL.md +57 -0
- package/skills/roles/code-reviewer.md +55 -0
- package/skills/roles/design-reviewer.md +63 -0
- package/skills/roles/researcher.md +33 -0
- package/skills/roles/verifier.md +38 -0
- package/skills/triage/SKILL.md +35 -0
- package/skills/verify/SKILL.md +39 -0
- package/skills/verify/manual-test.md +23 -0
- package/src/adapters/inbound/cli/cli_context.ts +75 -0
- package/src/adapters/inbound/cli/commands/code.ts +30 -0
- package/src/adapters/inbound/cli/commands/documents.ts +52 -0
- package/src/adapters/inbound/cli/commands/init.ts +23 -0
- package/src/adapters/inbound/cli/commands/install.ts +15 -0
- package/src/adapters/inbound/cli/commands/journal.ts +31 -0
- package/src/adapters/inbound/cli/commands/progress.ts +51 -0
- package/src/adapters/inbound/cli/commands/review.ts +18 -0
- package/src/adapters/inbound/cli/commands/status.ts +45 -0
- package/src/adapters/inbound/cli/commands.ts +36 -0
- package/src/adapters/inbound/cli/parse_command.ts +90 -0
- package/src/adapters/inbound/cli/run_cli.ts +50 -0
- package/src/adapters/outbound/git_repository.ts +57 -0
- package/src/adapters/outbound/node_project_files.ts +101 -0
- package/src/adapters/outbound/package_bundle.ts +56 -0
- package/src/adapters/outbound/process_agent_runner.ts +26 -0
- package/src/adapters/outbound/system_clock.ts +14 -0
- package/src/adapters/outbound/terminal_prompter.ts +22 -0
- package/src/app/ports/agent_runner.ts +11 -0
- package/src/app/ports/bundle.ts +22 -0
- package/src/app/ports/clock.ts +6 -0
- package/src/app/ports/project_files.ts +20 -0
- package/src/app/ports/prompter.ts +5 -0
- package/src/app/ports/repository.ts +13 -0
- package/src/app/project_context.ts +45 -0
- package/src/app/use_cases/abandon_work.ts +42 -0
- package/src/app/use_cases/approve_artifact.ts +146 -0
- package/src/app/use_cases/check_code.ts +90 -0
- package/src/app/use_cases/init_project.ts +59 -0
- package/src/app/use_cases/install_skills.ts +93 -0
- package/src/app/use_cases/journal_events.ts +48 -0
- package/src/app/use_cases/land_change.ts +113 -0
- package/src/app/use_cases/new_project_doc.ts +40 -0
- package/src/app/use_cases/new_work_item.ts +75 -0
- package/src/app/use_cases/project_status.ts +73 -0
- package/src/app/use_cases/record_progress.ts +101 -0
- package/src/app/use_cases/run_review.ts +71 -0
- package/src/app/use_cases/validate_project.ts +22 -0
- package/src/domain/agent_skill_homes.ts +25 -0
- package/src/domain/approvals/evaluate.ts +36 -0
- package/src/domain/approvals/fingerprint.ts +52 -0
- package/src/domain/approvals/records.ts +50 -0
- package/src/domain/architecture_sections.ts +23 -0
- package/src/domain/check/check_source.ts +64 -0
- package/src/domain/check/imports.ts +81 -0
- package/src/domain/check/rust_imports.ts +84 -0
- package/src/domain/check/type_count.ts +17 -0
- package/src/domain/config.ts +128 -0
- package/src/domain/cruze_error.ts +10 -0
- package/src/domain/edits/agents_file.ts +44 -0
- package/src/domain/edits/managed_tables.ts +60 -0
- package/src/domain/edits/progress_block.ts +33 -0
- package/src/domain/edits/status_lines.ts +52 -0
- package/src/domain/elements.ts +126 -0
- package/src/domain/glob.ts +46 -0
- package/src/domain/hashing.ts +52 -0
- package/src/domain/ids.ts +37 -0
- package/src/domain/journal.ts +62 -0
- package/src/domain/land/bookkeeping.ts +54 -0
- package/src/domain/land/merge_plan.ts +132 -0
- package/src/domain/land/text_ops.ts +66 -0
- package/src/domain/markdown.ts +79 -0
- package/src/domain/markdown_tables.ts +34 -0
- package/src/domain/project/artifact_ref.ts +42 -0
- package/src/domain/project/deltas.ts +49 -0
- package/src/domain/project/layout.ts +40 -0
- package/src/domain/project/living_model.ts +54 -0
- package/src/domain/project/new_items.ts +48 -0
- package/src/domain/project/plan_parts.ts +111 -0
- package/src/domain/project/progress.ts +43 -0
- package/src/domain/project/project_view.ts +72 -0
- package/src/domain/project/roadmap.ts +43 -0
- package/src/domain/project/work_items.ts +68 -0
- package/src/domain/project_name.ts +20 -0
- package/src/domain/scaffold.ts +29 -0
- package/src/domain/skill.ts +106 -0
- package/src/domain/skill_links.ts +103 -0
- package/src/domain/status/next_step.ts +119 -0
- package/src/domain/status/verification.ts +16 -0
- package/src/domain/status/work_status.ts +101 -0
- package/src/domain/trace.ts +44 -0
- package/src/domain/validation/delta_rules.ts +79 -0
- package/src/domain/validation/element_rules.ts +99 -0
- package/src/domain/validation/living_rules.ts +97 -0
- package/src/domain/validation/plan_rules.ts +105 -0
- package/src/domain/validation/problem.ts +21 -0
- package/src/domain/validation/project_rules.ts +99 -0
- package/src/domain/validation/validate_project.ts +31 -0
- package/src/domain/validation/work_item_rules.ts +144 -0
- package/src/main.ts +32 -0
- package/templates/init/AGENTS.md +28 -0
- package/templates/init/CHANGELOG.md +8 -0
- package/templates/init/CLAUDE.md +1 -0
- package/templates/init/README.md +1 -0
- package/templates/init/ci.yml +23 -0
- package/templates/init/config.yaml +33 -0
- package/templates/init/gitattributes +2 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import type { Repository } from "../../app/ports/repository.ts";
|
|
4
|
+
|
|
5
|
+
const run = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
/** The git repository at a directory, driven through the git command. */
|
|
8
|
+
export class GitRepository implements Repository {
|
|
9
|
+
private readonly root: string;
|
|
10
|
+
|
|
11
|
+
constructor(root: string) {
|
|
12
|
+
this.root = root;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async currentBranch(): Promise<string | null> {
|
|
16
|
+
const branch = await this.git("rev-parse", "--abbrev-ref", "HEAD");
|
|
17
|
+
return branch === null || branch === "HEAD" ? null : branch;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async headCommit(): Promise<string | null> {
|
|
21
|
+
return this.git("rev-parse", "--short", "HEAD");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async userName(): Promise<string | null> {
|
|
25
|
+
const name = await this.git("config", "user.name");
|
|
26
|
+
const email = await this.git("config", "user.email");
|
|
27
|
+
if (name === null) return email;
|
|
28
|
+
return email === null ? name : `${name} <${email}>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async otherBranches(): Promise<string[]> {
|
|
32
|
+
const current = await this.currentBranch();
|
|
33
|
+
const listing = await this.git("for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes");
|
|
34
|
+
return (listing ?? "")
|
|
35
|
+
.split("\n")
|
|
36
|
+
.filter((ref) => ref !== "" && ref !== current && !ref.endsWith("/HEAD") && ref !== `origin/${current}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async filesOnBranch(branch: string, dir: string): Promise<Map<string, string>> {
|
|
40
|
+
const files = new Map<string, string>();
|
|
41
|
+
const listing = await this.git("ls-tree", "-r", "--name-only", branch, "--", dir);
|
|
42
|
+
for (const path of (listing ?? "").split("\n").filter((p) => p !== "")) {
|
|
43
|
+
const text = await this.git("show", `${branch}:${path}`);
|
|
44
|
+
if (text !== null) files.set(path, text);
|
|
45
|
+
}
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async git(...args: string[]): Promise<string | null> {
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await run("git", args, { cwd: this.root, maxBuffer: 64 * 1024 * 1024 });
|
|
52
|
+
return stdout.trimEnd();
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { appendFile, lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import type { ProjectFiles } from "../../app/ports/project_files.ts";
|
|
4
|
+
|
|
5
|
+
/** Project files on the local disk, confined to one root directory. */
|
|
6
|
+
export class NodeProjectFiles implements ProjectFiles {
|
|
7
|
+
private readonly root: string;
|
|
8
|
+
|
|
9
|
+
constructor(root: string) {
|
|
10
|
+
this.root = resolve(root);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async exists(path: string): Promise<boolean> {
|
|
14
|
+
try {
|
|
15
|
+
await lstat(this.resolve(path));
|
|
16
|
+
return true;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (isNotFound(error)) return false;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async readText(path: string): Promise<string | undefined> {
|
|
24
|
+
try {
|
|
25
|
+
return await readFile(this.resolve(path), "utf8");
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (isNotFound(error)) return undefined;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async writeText(path: string, text: string): Promise<void> {
|
|
33
|
+
const target = this.resolve(path);
|
|
34
|
+
await mkdir(dirname(target), { recursive: true });
|
|
35
|
+
await writeFile(target, text, "utf8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async appendText(path: string, text: string): Promise<void> {
|
|
39
|
+
const target = this.resolve(path);
|
|
40
|
+
await mkdir(dirname(target), { recursive: true });
|
|
41
|
+
await appendFile(target, text, "utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async listFiles(path: string): Promise<string[]> {
|
|
45
|
+
try {
|
|
46
|
+
const entries = await readdir(this.resolve(path), { withFileTypes: true, recursive: true });
|
|
47
|
+
return entries
|
|
48
|
+
.filter((entry) => entry.isFile())
|
|
49
|
+
.map((entry) => relative(this.root, join(entry.parentPath, entry.name)).split(sep).join("/"))
|
|
50
|
+
.sort();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (isNotFound(error)) return [];
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async move(from: string, to: string): Promise<void> {
|
|
58
|
+
const target = this.resolve(to);
|
|
59
|
+
await mkdir(dirname(target), { recursive: true });
|
|
60
|
+
await rename(this.resolve(from), target);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async listEntries(path: string): Promise<string[]> {
|
|
64
|
+
try {
|
|
65
|
+
return (await readdir(this.resolve(path))).sort();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (isNotFound(error)) return [];
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async remove(path: string): Promise<void> {
|
|
73
|
+
await rm(this.resolve(path), { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async link(path: string, target: string): Promise<void> {
|
|
77
|
+
const linkPath = this.resolve(path);
|
|
78
|
+
this.assertInsideRoot(resolve(dirname(linkPath), target));
|
|
79
|
+
await mkdir(dirname(linkPath), { recursive: true });
|
|
80
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
81
|
+
await symlink(target, linkPath, "dir");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolves a project-relative path, refusing anything outside the root. */
|
|
85
|
+
private resolve(path: string): string {
|
|
86
|
+
const absolute = resolve(this.root, path);
|
|
87
|
+
this.assertInsideRoot(absolute);
|
|
88
|
+
return absolute;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private assertInsideRoot(absolute: string): void {
|
|
92
|
+
const fromRoot = relative(this.root, absolute);
|
|
93
|
+
if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
|
|
94
|
+
throw new Error(`Path escapes the project root: ${absolute}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isNotFound(error: unknown): boolean {
|
|
100
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
101
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type { Bundle, BundledFile, BundledSkill } from "../../app/ports/bundle.ts";
|
|
6
|
+
|
|
7
|
+
/** The skills and templates shipped in the installed Cruze package. */
|
|
8
|
+
export class PackageBundle implements Bundle {
|
|
9
|
+
readonly version: string;
|
|
10
|
+
private readonly root: string;
|
|
11
|
+
|
|
12
|
+
private constructor(root: string, version: string) {
|
|
13
|
+
this.root = root;
|
|
14
|
+
this.version = version;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Finds the package root by walking up from this module to the nearest package.json. */
|
|
18
|
+
static async locate(fromUrl: string = import.meta.url): Promise<PackageBundle> {
|
|
19
|
+
let dir = dirname(fileURLToPath(fromUrl));
|
|
20
|
+
while (!existsSync(join(dir, "package.json"))) {
|
|
21
|
+
const parent = dirname(dir);
|
|
22
|
+
if (parent === dir) throw new Error("Cannot find the Cruze package root");
|
|
23
|
+
dir = parent;
|
|
24
|
+
}
|
|
25
|
+
const manifest = JSON.parse(await readFile(join(dir, "package.json"), "utf8")) as { version: string };
|
|
26
|
+
return new PackageBundle(dir, manifest.version);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async skills(): Promise<BundledSkill[]> {
|
|
30
|
+
const skillsDir = join(this.root, "skills");
|
|
31
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
32
|
+
const folders = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
33
|
+
return Promise.all(
|
|
34
|
+
folders.map(async (folder) => ({ folder, files: await readTree(join(skillsDir, folder)) })),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async template(path: string): Promise<string> {
|
|
39
|
+
return readFile(join(this.root, "templates", path), "utf8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async formatTemplate(name: string): Promise<string> {
|
|
43
|
+
return readFile(join(this.root, "skills", "formats", "templates", name), "utf8");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readTree(dir: string): Promise<BundledFile[]> {
|
|
48
|
+
const entries = await readdir(dir, { withFileTypes: true, recursive: true });
|
|
49
|
+
const files = entries.filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name));
|
|
50
|
+
return Promise.all(
|
|
51
|
+
files.sort().map(async (file) => ({
|
|
52
|
+
path: relative(dir, file).split(sep).join("/"),
|
|
53
|
+
text: await readFile(file, "utf8"),
|
|
54
|
+
})),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { AgentRun, AgentRunner } from "../../app/ports/agent_runner.ts";
|
|
3
|
+
|
|
4
|
+
/** Runs an agent's non-interactive mode, such as `claude -p`, as a child process. */
|
|
5
|
+
export class ProcessAgentRunner implements AgentRunner {
|
|
6
|
+
private readonly cwd: string;
|
|
7
|
+
|
|
8
|
+
constructor(cwd: string) {
|
|
9
|
+
this.cwd = cwd;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
run(command: string[], prompt: string): Promise<AgentRun> {
|
|
13
|
+
const [program, ...args] = command;
|
|
14
|
+
if (program === undefined) return Promise.resolve({ exitCode: 127, output: "", error: "no command configured" });
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const child = spawn(program, args, { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
17
|
+
let output = "";
|
|
18
|
+
let error = "";
|
|
19
|
+
child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
|
|
20
|
+
child.stderr.on("data", (chunk: Buffer) => (error += chunk.toString("utf8")));
|
|
21
|
+
child.on("error", (failure) => resolve({ exitCode: 127, output, error: `${error}${failure.message}` }));
|
|
22
|
+
child.on("close", (code) => resolve({ exitCode: code ?? 1, output, error }));
|
|
23
|
+
child.stdin.end(prompt);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Clock } from "../../app/ports/clock.ts";
|
|
2
|
+
|
|
3
|
+
/** The machine's clock, with dates in its local time zone. */
|
|
4
|
+
export class SystemClock implements Clock {
|
|
5
|
+
now(): Date {
|
|
6
|
+
return new Date();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
today(): string {
|
|
10
|
+
const date = new Date();
|
|
11
|
+
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
12
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import type { Prompter } from "../../app/ports/prompter.ts";
|
|
3
|
+
|
|
4
|
+
/** Asks on the terminal. Questions go to stderr so stdout stays machine-readable. */
|
|
5
|
+
export class TerminalPrompter implements Prompter {
|
|
6
|
+
async ask(question: string, fallback: string): Promise<string> {
|
|
7
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
8
|
+
try {
|
|
9
|
+
const answer = await terminal.question(`${question} (${fallback}): `);
|
|
10
|
+
return answer.trim() === "" ? fallback : answer.trim();
|
|
11
|
+
} finally {
|
|
12
|
+
terminal.close();
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Used when nobody can answer: without a terminal, or with --yes. */
|
|
18
|
+
export class DefaultAnswerPrompter implements Prompter {
|
|
19
|
+
async ask(_question: string, fallback: string): Promise<string> {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Starts a coding agent in a fresh context, non-interactively, and collects what it reports. */
|
|
2
|
+
export interface AgentRunner {
|
|
3
|
+
/** Runs `command` in the project root with `prompt` on standard input. */
|
|
4
|
+
run(command: string[], prompt: string): Promise<AgentRun>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface AgentRun {
|
|
8
|
+
exitCode: number;
|
|
9
|
+
output: string;
|
|
10
|
+
error: string;
|
|
11
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** The assets shipped inside the Cruze package: skills and templates. */
|
|
2
|
+
|
|
3
|
+
export interface BundledFile {
|
|
4
|
+
/** Path relative to the skill folder, using forward slashes. */
|
|
5
|
+
path: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface BundledSkill {
|
|
10
|
+
/** The skill's folder name in the bundle, such as `plan`. */
|
|
11
|
+
folder: string;
|
|
12
|
+
files: BundledFile[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Bundle {
|
|
16
|
+
readonly version: string;
|
|
17
|
+
skills(): Promise<BundledSkill[]>;
|
|
18
|
+
/** A template's text, by path relative to the templates directory. */
|
|
19
|
+
template(path: string): Promise<string>;
|
|
20
|
+
/** A document template from the cruze-formats skill, such as `feature.md`. */
|
|
21
|
+
formatTemplate(name: string): Promise<string>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The files of the project Cruze is working in. Paths are relative to the project root. */
|
|
2
|
+
export interface ProjectFiles {
|
|
3
|
+
exists(path: string): Promise<boolean>;
|
|
4
|
+
/** A file's text, or undefined when it doesn't exist. */
|
|
5
|
+
readText(path: string): Promise<string | undefined>;
|
|
6
|
+
/** Writes a file, creating parent directories as needed. */
|
|
7
|
+
writeText(path: string, text: string): Promise<void>;
|
|
8
|
+
/** Appends to a file, creating it and its parent directories as needed. */
|
|
9
|
+
appendText(path: string, text: string): Promise<void>;
|
|
10
|
+
/** Names of the entries directly inside a directory; empty when it doesn't exist. */
|
|
11
|
+
listEntries(path: string): Promise<string[]>;
|
|
12
|
+
/** Every file under a directory, recursively, as project-relative paths; empty when it doesn't exist. */
|
|
13
|
+
listFiles(path: string): Promise<string[]>;
|
|
14
|
+
/** Moves a file or directory, creating the destination's parent directories. */
|
|
15
|
+
move(from: string, to: string): Promise<void>;
|
|
16
|
+
/** Removes a file, a directory tree or a link (never what a link points to). */
|
|
17
|
+
remove(path: string): Promise<void>;
|
|
18
|
+
/** Creates a symbolic link at `path` pointing to `target`, a path relative to the link's directory. */
|
|
19
|
+
link(path: string, target: string): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The version-control repository the project lives in. Every operation tolerates a project outside git. */
|
|
2
|
+
export interface Repository {
|
|
3
|
+
/** The checked-out branch, or null when detached or outside git. */
|
|
4
|
+
currentBranch(): Promise<string | null>;
|
|
5
|
+
/** The short hash of the checked-out commit, or null. */
|
|
6
|
+
headCommit(): Promise<string | null>;
|
|
7
|
+
/** Who is approving: the configured user name and email, or null. */
|
|
8
|
+
userName(): Promise<string | null>;
|
|
9
|
+
/** Local and remote-tracking branches other than the current one. */
|
|
10
|
+
otherBranches(): Promise<string[]>;
|
|
11
|
+
/** Files under a directory as they are on another branch, by project-relative path. */
|
|
12
|
+
filesOnBranch(branch: string, dir: string): Promise<Map<string, string>>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { buildProjectView, type ProjectView } from "../domain/project/project_view.ts";
|
|
2
|
+
import { SNAPSHOT_ROOTS } from "../domain/project/layout.ts";
|
|
3
|
+
import { journalPath, serializeEntry, type JournalEntry } from "../domain/journal.ts";
|
|
4
|
+
import type { Bundle } from "./ports/bundle.ts";
|
|
5
|
+
import type { Clock } from "./ports/clock.ts";
|
|
6
|
+
import type { ProjectFiles } from "./ports/project_files.ts";
|
|
7
|
+
import type { Repository } from "./ports/repository.ts";
|
|
8
|
+
|
|
9
|
+
/** What the project use cases work with. */
|
|
10
|
+
export interface ProjectDeps {
|
|
11
|
+
files: ProjectFiles;
|
|
12
|
+
clock: Clock;
|
|
13
|
+
repository: Repository;
|
|
14
|
+
bundle: Bundle;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Reads every document under docs/ and .cruze/ into a view. */
|
|
18
|
+
export async function loadView(files: ProjectFiles): Promise<ProjectView> {
|
|
19
|
+
const snapshot = new Map<string, string>();
|
|
20
|
+
for (const root of SNAPSHOT_ROOTS) {
|
|
21
|
+
for (const path of await files.listFiles(root)) {
|
|
22
|
+
const text = await files.readText(path);
|
|
23
|
+
if (text !== undefined) snapshot.set(path, text);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return buildProjectView(snapshot);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A new view with some files replaced, for checking edits before writing them. */
|
|
30
|
+
export function withFiles(view: ProjectView, changes: Map<string, string>): ProjectView {
|
|
31
|
+
return buildProjectView(new Map([...view.snapshot, ...changes]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Appends an entry to the journal in `folder`, stamped with the time and the approver. */
|
|
35
|
+
export async function appendJournal(deps: ProjectDeps, folder: string, event: string, fields: Record<string, unknown>): Promise<JournalEntry> {
|
|
36
|
+
const entry: JournalEntry = {
|
|
37
|
+
at: deps.clock.now().toISOString(),
|
|
38
|
+
event,
|
|
39
|
+
by: (await deps.repository.userName()) ?? "unknown",
|
|
40
|
+
cruze: deps.bundle.version,
|
|
41
|
+
...fields,
|
|
42
|
+
};
|
|
43
|
+
await deps.files.appendText(journalPath(folder), serializeEntry(entry));
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
+
import { readRows, upsertRow } from "../../domain/edits/managed_tables.ts";
|
|
3
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
4
|
+
import { PATHS } from "../../domain/project/layout.ts";
|
|
5
|
+
import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
|
|
6
|
+
|
|
7
|
+
export interface AbandonReport {
|
|
8
|
+
ref: string;
|
|
9
|
+
archivedTo: string;
|
|
10
|
+
/** The feature-map slug it went back to, for a feature that may return. */
|
|
11
|
+
futureList?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Stops a feature or standalone change for good: archives its folder with the reason, marks it
|
|
16
|
+
* abandoned on the roadmap, and puts a feature back on the future list. Changes that already
|
|
17
|
+
* landed stay in the living docs.
|
|
18
|
+
*/
|
|
19
|
+
export async function abandonWork(deps: ProjectDeps, ref: string, reason: string): Promise<AbandonReport> {
|
|
20
|
+
if (reason.trim() === "") throw new CruzeError("missing-reason", "abandoning work needs a reason");
|
|
21
|
+
const view = await loadView(deps.files);
|
|
22
|
+
const { item } = resolveArtifact(view, ref);
|
|
23
|
+
if (item === undefined || item.archived) throw new CruzeError("not-found", `"${ref}" is not an active feature or standalone change`);
|
|
24
|
+
if (item.kind === "change") throw new CruzeError("not-abandonable", `${item.ref} belongs to a feature; remove it from the feature's Changes table through rethink instead`);
|
|
25
|
+
|
|
26
|
+
const slug = typeof item.doc.frontmatter?.["roadmap"] === "string" ? item.doc.frontmatter["roadmap"] : undefined;
|
|
27
|
+
const roadmap = view.snapshot.get(PATHS.roadmap);
|
|
28
|
+
if (slug !== undefined && roadmap !== undefined && roadmap.includes("## Status")) {
|
|
29
|
+
await deps.files.writeText(PATHS.roadmap, upsertRow(roadmap, "Status", [slug, item.folderName, "abandoned"]));
|
|
30
|
+
}
|
|
31
|
+
const report: AbandonReport = { ref: item.ref, archivedTo: `${PATHS.archiveDir}/${item.folderName}` };
|
|
32
|
+
const vision = view.snapshot.get(PATHS.vision);
|
|
33
|
+
if (item.kind === "feature" && slug !== undefined && vision !== undefined && vision.includes("### Future")) {
|
|
34
|
+
if (!readRows(vision, "Future").some((row) => row[0] === slug)) {
|
|
35
|
+
await deps.files.writeText(PATHS.vision, upsertRow(vision, "Future", [slug, String(item.doc.frontmatter?.["title"] ?? slug), ""]));
|
|
36
|
+
}
|
|
37
|
+
report.futureList = slug;
|
|
38
|
+
}
|
|
39
|
+
await appendJournal(deps, item.folder, "abandon", { item: item.ref, reason: reason.trim() });
|
|
40
|
+
await deps.files.move(item.folder, report.archivedTo);
|
|
41
|
+
return report;
|
|
42
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { evaluateApproval } from "../../domain/approvals/evaluate.ts";
|
|
2
|
+
import { fingerprint, isApprovable } from "../../domain/approvals/fingerprint.ts";
|
|
3
|
+
import { latestApproval, serializeApprovals, withApproval, type ApprovalRecord } from "../../domain/approvals/records.ts";
|
|
4
|
+
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
5
|
+
import { updateProgress } from "../../domain/edits/progress_block.ts";
|
|
6
|
+
import { setStatus, stampMissingStatuses } from "../../domain/edits/status_lines.ts";
|
|
7
|
+
import { statusOf } from "../../domain/elements.ts";
|
|
8
|
+
import { elementHash } from "../../domain/hashing.ts";
|
|
9
|
+
import { parseMarkdown } from "../../domain/markdown.ts";
|
|
10
|
+
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
11
|
+
import { readDelta } from "../../domain/project/deltas.ts";
|
|
12
|
+
import { PATHS } from "../../domain/project/layout.ts";
|
|
13
|
+
import { isPlanned, readChangesTable } from "../../domain/project/plan_parts.ts";
|
|
14
|
+
import { readProgress } from "../../domain/project/progress.ts";
|
|
15
|
+
import { approvalFolder, approvalsPath, type ProjectView } from "../../domain/project/project_view.ts";
|
|
16
|
+
import { featureOf } from "../../domain/project/work_items.ts";
|
|
17
|
+
import { validateProject } from "../../domain/validation/validate_project.ts";
|
|
18
|
+
import { appendJournal, loadView, withFiles, type ProjectDeps } from "../project_context.ts";
|
|
19
|
+
|
|
20
|
+
export interface ApproveReport {
|
|
21
|
+
artifact: string;
|
|
22
|
+
hash: string;
|
|
23
|
+
upstream: string[];
|
|
24
|
+
/** Elements that received a `planned` status. */
|
|
25
|
+
stamped: string[];
|
|
26
|
+
/** The branch a change was bound to, when this approval bound it. */
|
|
27
|
+
bound?: string;
|
|
28
|
+
/** Built elements this approval set back to planned, because a rethink changed them. */
|
|
29
|
+
replanned: string[];
|
|
30
|
+
/** Elements whose current living text this feature accepted after a land conflict. */
|
|
31
|
+
rebased: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ApproveOptions {
|
|
35
|
+
/** Architecture elements to set back to `planned`: their design changed and their code must follow. */
|
|
36
|
+
replan?: string[];
|
|
37
|
+
/** Elements this work's delta changes whose living text changed since its last approval; the approval accepts the current text. */
|
|
38
|
+
rebase?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Records a person's approval of a document: its design hash and the hashes of everything
|
|
43
|
+
* upstream it relies on. Refuses invalid documents and documents whose upstream isn't approved.
|
|
44
|
+
*/
|
|
45
|
+
export async function approveArtifact(deps: ProjectDeps, ref: string, options: ApproveOptions = {}): Promise<ApproveReport> {
|
|
46
|
+
let view = await loadView(deps.files);
|
|
47
|
+
const { path, item } = resolveArtifact(view, ref);
|
|
48
|
+
if (!isApprovable(view, path)) throw new CruzeError("not-approvable", `${path} is not something you approve; it changes through approved work`);
|
|
49
|
+
requireUpstreamApproved(view, path);
|
|
50
|
+
if (item?.kind === "feature" && readChangesTable(item.doc).length === 0) {
|
|
51
|
+
throw new CruzeError("not-designed", `${path} has no changes yet; architect designs and splits it before approval`);
|
|
52
|
+
}
|
|
53
|
+
if (item !== undefined && item.kind !== "feature" && !isPlanned(item.doc)) {
|
|
54
|
+
throw new CruzeError("not-planned", `${path} has no test plan or tasks yet; plan it before approving`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const edits = new Map<string, string>();
|
|
58
|
+
let text = view.snapshot.get(path) ?? "";
|
|
59
|
+
let stamped: string[] = [];
|
|
60
|
+
if (path === PATHS.architecture) ({ text, stamped } = stampMissingStatuses(text));
|
|
61
|
+
const replanned = options.replan ?? [];
|
|
62
|
+
if (replanned.length > 0 && path !== PATHS.architecture) throw new CruzeError("replan-architecture-only", "--replan applies to the architecture");
|
|
63
|
+
for (const id of replanned) {
|
|
64
|
+
const element = view.living.elements.get(id);
|
|
65
|
+
if (element?.path !== PATHS.architecture || statusOf(element.doc, element.element) !== "built") {
|
|
66
|
+
throw new CruzeError("not-built", `${id} is not a built element of ${PATHS.architecture}`);
|
|
67
|
+
}
|
|
68
|
+
text = setStatus(text, id, "planned");
|
|
69
|
+
}
|
|
70
|
+
let bound: string | undefined;
|
|
71
|
+
if (item !== undefined && item.kind !== "feature" && readProgress(parseMarkdown(text)).branch === undefined) {
|
|
72
|
+
bound = (await deps.repository.currentBranch()) ?? undefined;
|
|
73
|
+
text = updateProgress(text, (progress) => {
|
|
74
|
+
if (bound !== undefined) progress.branch = bound;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (text !== view.snapshot.get(path)) edits.set(path, text);
|
|
78
|
+
view = withFiles(view, edits);
|
|
79
|
+
|
|
80
|
+
const errors = validateProject(view).filter((p) => p.path === path && p.severity === "error");
|
|
81
|
+
if (errors.length > 0) {
|
|
82
|
+
const listing = errors.map((p) => ` ${p.path}:${p.line ?? ""} ${p.message}`).join("\n");
|
|
83
|
+
throw new CruzeError("invalid", `${path} has ${errors.length} problem(s) to fix before approval:\n${listing}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { hash, upstream } = fingerprint(view, path, parseMarkdown(text));
|
|
87
|
+
const folder = approvalFolder(view, path);
|
|
88
|
+
const current = view.approvals.get(folder) ?? { version: 1, approvals: [], merged: {} };
|
|
89
|
+
const rebased = options.rebase ?? [];
|
|
90
|
+
const deltaIds = item === undefined || item.kind === "change" ? new Set<string>() : readDelta(item.doc).ids;
|
|
91
|
+
requireRebased(view, deltaIds, latestApproval(current, path), rebased);
|
|
92
|
+
const merged = { ...current.merged };
|
|
93
|
+
for (const id of rebased) {
|
|
94
|
+
const living = view.living.elements.get(id);
|
|
95
|
+
if (living === undefined || (merged[id] === undefined && !deltaIds.has(id))) {
|
|
96
|
+
throw new CruzeError("not-in-delta", `${id} is not a living element that ${item?.ref ?? path} changes or merged`);
|
|
97
|
+
}
|
|
98
|
+
if (merged[id] !== undefined) merged[id] = elementHash(living.doc, living.element);
|
|
99
|
+
}
|
|
100
|
+
const approvals = withApproval({ ...current, merged }, {
|
|
101
|
+
artifact: path,
|
|
102
|
+
hash,
|
|
103
|
+
upstream,
|
|
104
|
+
approvedAt: deps.clock.now().toISOString(),
|
|
105
|
+
by: (await deps.repository.userName()) ?? "unknown",
|
|
106
|
+
});
|
|
107
|
+
for (const [changed, content] of edits) await deps.files.writeText(changed, content);
|
|
108
|
+
await deps.files.writeText(approvalsPath(folder), serializeApprovals(approvals));
|
|
109
|
+
await appendJournal(deps, folder, "approve", { artifact: path, hash, ...(replanned.length > 0 ? { replanned } : {}), ...(rebased.length > 0 ? { rebased } : {}) });
|
|
110
|
+
const report: ApproveReport = { artifact: path, hash, upstream: Object.keys(upstream), stamped, replanned, rebased };
|
|
111
|
+
if (bound !== undefined) report.bound = bound;
|
|
112
|
+
return report;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A feature or standalone change whose delta changes an element must have been written against
|
|
117
|
+
* that element's current text. When the living element changed since the last approval, through
|
|
118
|
+
* a rethink or another land, the delta would overwrite that edit, so approval waits until the
|
|
119
|
+
* delta is reconciled and the element is named with --rebase.
|
|
120
|
+
*/
|
|
121
|
+
function requireRebased(view: ProjectView, deltaIds: Set<string>, previous: ApprovalRecord | undefined, rebased: string[]): void {
|
|
122
|
+
if (previous === undefined) return;
|
|
123
|
+
const moved = [...deltaIds].filter((id) => {
|
|
124
|
+
const living = view.living.elements.get(id);
|
|
125
|
+
const before = previous.upstream[id];
|
|
126
|
+
return living !== undefined && before !== undefined && before !== elementHash(living.doc, living.element) && !rebased.includes(id);
|
|
127
|
+
});
|
|
128
|
+
if (moved.length > 0) {
|
|
129
|
+
throw new CruzeError(
|
|
130
|
+
"rebase-required",
|
|
131
|
+
`the living text of ${moved.join(", ")} changed since the last approval, and this work's delta changes ${moved.length === 1 ? "it" : "them"}. Rewrite the delta to keep that change, then approve with ${moved.map((id) => `--rebase ${id}`).join(" ")}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A change needs its feature approved; a feature or standalone change needs the architecture approved. */
|
|
137
|
+
function requireUpstreamApproved(view: ProjectView, path: string): void {
|
|
138
|
+
const item = view.items.find((i) => i.path === path);
|
|
139
|
+
if (item === undefined) return;
|
|
140
|
+
const upstream = item.kind === "change" ? featureOf(view.items, item)?.path : view.snapshot.has(PATHS.architecture) ? PATHS.architecture : undefined;
|
|
141
|
+
if (upstream === undefined) return;
|
|
142
|
+
const status = evaluateApproval(view, upstream);
|
|
143
|
+
if (status.state !== "approved") {
|
|
144
|
+
throw new CruzeError("upstream-not-approved", `approve ${upstream} first; it is ${status.state}${status.changed.length > 0 ? ` (${status.changed.join(", ")})` : ""}`);
|
|
145
|
+
}
|
|
146
|
+
}
|