@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,111 @@
|
|
|
1
|
+
import { findIds } from "../ids.ts";
|
|
2
|
+
import { findSection, type MarkdownDoc } from "../markdown.ts";
|
|
3
|
+
import { tablesIn } from "../markdown_tables.ts";
|
|
4
|
+
|
|
5
|
+
/** The parts of feature.md and change.md that describe what gets built and how it is proved. */
|
|
6
|
+
|
|
7
|
+
export interface Scope {
|
|
8
|
+
delivers: string[];
|
|
9
|
+
builds: string[];
|
|
10
|
+
removes: string[];
|
|
11
|
+
line: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Task {
|
|
15
|
+
number: string;
|
|
16
|
+
owner: string;
|
|
17
|
+
paths: string[];
|
|
18
|
+
proves: string[];
|
|
19
|
+
line: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TestPlanRow {
|
|
23
|
+
subject: string;
|
|
24
|
+
seam: string;
|
|
25
|
+
file: string;
|
|
26
|
+
kind: string;
|
|
27
|
+
line: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChangeRow {
|
|
31
|
+
change: string;
|
|
32
|
+
delivers: string[];
|
|
33
|
+
builds: string[];
|
|
34
|
+
removes: string[];
|
|
35
|
+
dependsOn: string[];
|
|
36
|
+
line: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const TASK = /^- (T\d+): `([^`]+)`[^`]*? in ((?:`[^`]+`(?:, )?)+), proves (.+)$/;
|
|
40
|
+
|
|
41
|
+
export function readScope(doc: MarkdownDoc): Scope | null {
|
|
42
|
+
const section = findSection(doc, "Scope");
|
|
43
|
+
if (section === null) return null;
|
|
44
|
+
const scope: Scope = { delivers: [], builds: [], removes: [], line: section.heading.line };
|
|
45
|
+
for (let i = section.start; i < section.end; i++) {
|
|
46
|
+
const match = /^- (Delivers|Builds|Removes): (.*)$/.exec(doc.lines[i] ?? "");
|
|
47
|
+
if (match?.[1] === undefined || match[2] === undefined) continue;
|
|
48
|
+
const key = match[1].toLowerCase() as "delivers" | "builds" | "removes";
|
|
49
|
+
scope[key] = findIds(match[2]);
|
|
50
|
+
}
|
|
51
|
+
return scope;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Task lines, and the lines under `## Tasks` that don't parse as tasks. */
|
|
55
|
+
export function readTasks(doc: MarkdownDoc): { tasks: Task[]; malformed: number[] } {
|
|
56
|
+
const section = findSection(doc, "Tasks");
|
|
57
|
+
const tasks: Task[] = [];
|
|
58
|
+
const malformed: number[] = [];
|
|
59
|
+
if (section === null) return { tasks, malformed };
|
|
60
|
+
for (let i = section.start; i < section.end; i++) {
|
|
61
|
+
const line = doc.lines[i] ?? "";
|
|
62
|
+
if (!line.startsWith("- ")) continue;
|
|
63
|
+
const match = TASK.exec(line);
|
|
64
|
+
if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined || match[4] === undefined) {
|
|
65
|
+
malformed.push(i);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const paths = [...match[3].matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "");
|
|
69
|
+
tasks.push({ number: match[1], owner: match[2], paths, proves: findIds(match[4]), line: i });
|
|
70
|
+
}
|
|
71
|
+
return { tasks, malformed };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a change has been planned. A standalone change is designed first, with an
|
|
76
|
+
* empty test plan and no tasks; plan fills both.
|
|
77
|
+
*/
|
|
78
|
+
export function isPlanned(doc: MarkdownDoc): boolean {
|
|
79
|
+
const { tasks, malformed } = readTasks(doc);
|
|
80
|
+
return readTestPlan(doc).length > 0 || tasks.length > 0 || malformed.length > 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function readTestPlan(doc: MarkdownDoc): TestPlanRow[] {
|
|
84
|
+
const section = findSection(doc, "Test plan");
|
|
85
|
+
if (section === null) return [];
|
|
86
|
+
const table = tablesIn(doc.lines, doc.inFence, section.start, section.end)[0];
|
|
87
|
+
return (table?.rows ?? []).map(({ cells, line }) => ({
|
|
88
|
+
subject: cells[0] ?? "",
|
|
89
|
+
seam: cells[1] ?? "",
|
|
90
|
+
file: (cells[2] ?? "").replace(/`/g, ""),
|
|
91
|
+
kind: cells[3] ?? "",
|
|
92
|
+
line,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readChangesTable(doc: MarkdownDoc): ChangeRow[] {
|
|
97
|
+
const section = findSection(doc, "Changes");
|
|
98
|
+
if (section === null) return [];
|
|
99
|
+
const table = tablesIn(doc.lines, doc.inFence, section.start, section.end)[0];
|
|
100
|
+
if (table === undefined) return [];
|
|
101
|
+
const column = (name: string): number => table.header.findIndex((cell) => cell.toLowerCase() === name);
|
|
102
|
+
const cell = (cells: string[], name: string): string => cells[column(name)] ?? "";
|
|
103
|
+
return table.rows.map(({ cells, line }) => ({
|
|
104
|
+
change: cells[0] ?? "",
|
|
105
|
+
delivers: findIds(cell(cells, "delivers")),
|
|
106
|
+
builds: findIds(cell(cells, "builds")),
|
|
107
|
+
removes: findIds(cell(cells, "removes")),
|
|
108
|
+
dependsOn: cell(cells, "depends on").split(",").map((s) => s.trim()).filter((s) => s !== ""),
|
|
109
|
+
line,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { MANAGED_CLOSE, MANAGED_OPEN, type MarkdownDoc } from "../markdown.ts";
|
|
2
|
+
|
|
3
|
+
/** The managed `## Progress` block of a feature or change. */
|
|
4
|
+
export interface Progress {
|
|
5
|
+
branch?: string;
|
|
6
|
+
/** Task number to the commit that completed it, or null while open. */
|
|
7
|
+
tasks: Map<string, string | null>;
|
|
8
|
+
deviations: string[];
|
|
9
|
+
landed?: string;
|
|
10
|
+
/** For a feature: each change's state line. */
|
|
11
|
+
changes: Map<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readProgress(doc: MarkdownDoc): Progress {
|
|
15
|
+
const progress: Progress = { tasks: new Map(), deviations: [], changes: new Map() };
|
|
16
|
+
const range = progressRange(doc);
|
|
17
|
+
if (range === null) return progress;
|
|
18
|
+
for (let i = range.start; i < range.end; i++) {
|
|
19
|
+
const line = doc.lines[i] ?? "";
|
|
20
|
+
let match = /^- Branch: (.+)$/.exec(line);
|
|
21
|
+
if (match?.[1] !== undefined) progress.branch = match[1].trim();
|
|
22
|
+
match = /^- \[([ x])\] (T\d+)(?: \(([0-9a-f]+)\))?/.exec(line);
|
|
23
|
+
if (match?.[2] !== undefined) progress.tasks.set(match[2], match[1] === "x" ? (match[3] ?? "") : null);
|
|
24
|
+
match = /^- Deviation (.+)$/.exec(line);
|
|
25
|
+
if (match?.[1] !== undefined) progress.deviations.push(match[1]);
|
|
26
|
+
match = /^- Landed: (.+)$/.exec(line);
|
|
27
|
+
if (match?.[1] !== undefined) progress.landed = match[1];
|
|
28
|
+
match = /^- (\d\d-[a-z0-9-]+): (.+)$/.exec(line);
|
|
29
|
+
if (match?.[1] !== undefined && match[2] !== undefined) progress.changes.set(match[1], match[2]);
|
|
30
|
+
}
|
|
31
|
+
return progress;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Lines [start, end) inside the managed block that holds `## Progress`, or null. */
|
|
35
|
+
export function progressRange(doc: MarkdownDoc): { open: number; start: number; end: number } | null {
|
|
36
|
+
const heading = doc.headings.find((h) => h.level === 2 && h.text === "Progress");
|
|
37
|
+
if (heading === undefined || !doc.inManaged[heading.line]) return null;
|
|
38
|
+
let open = heading.line;
|
|
39
|
+
while (open > 0 && doc.lines[open]?.trim() !== MANAGED_OPEN) open--;
|
|
40
|
+
let end = heading.line;
|
|
41
|
+
while (end < doc.lines.length && doc.lines[end]?.trim() !== MANAGED_CLOSE) end++;
|
|
42
|
+
return { open, start: heading.line + 1, end };
|
|
43
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { parseApprovals, type ApprovalsFile } from "../approvals/records.ts";
|
|
2
|
+
import { parseConfig, type ConfigResult } from "../config.ts";
|
|
3
|
+
import { journalPath, parseJournal, type JournalEntry } from "../journal.ts";
|
|
4
|
+
import { PATHS, type Snapshot } from "./layout.ts";
|
|
5
|
+
import { buildLivingModel, type LivingModel } from "./living_model.ts";
|
|
6
|
+
import { readDelta } from "./deltas.ts";
|
|
7
|
+
import { listWorkItems, type WorkItem } from "./work_items.ts";
|
|
8
|
+
|
|
9
|
+
/** Everything the rules need, loaded once from a snapshot. */
|
|
10
|
+
export interface ProjectView {
|
|
11
|
+
snapshot: Snapshot;
|
|
12
|
+
living: LivingModel;
|
|
13
|
+
items: WorkItem[];
|
|
14
|
+
config: ConfigResult | null;
|
|
15
|
+
/** Approvals by the folder (or `.cruze` for project documents) that holds them. */
|
|
16
|
+
approvals: Map<string, ApprovalsFile>;
|
|
17
|
+
/** Problems reading approvals files, by path. */
|
|
18
|
+
approvalProblems: Map<string, string>;
|
|
19
|
+
/** IDs retired by archived work: never to be reused. */
|
|
20
|
+
retired: Set<string>;
|
|
21
|
+
/** Every journal entry in the project, oldest first. */
|
|
22
|
+
journal: JournalEntry[];
|
|
23
|
+
/** Journal lines that are not valid entries, by path. */
|
|
24
|
+
journalProblems: Map<string, number[]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildProjectView(snapshot: Snapshot): ProjectView {
|
|
28
|
+
const items = listWorkItems(snapshot);
|
|
29
|
+
const approvals = new Map<string, ApprovalsFile>();
|
|
30
|
+
const approvalProblems = new Map<string, string>();
|
|
31
|
+
for (const folder of [".cruze", ...items.map((item) => item.folder)]) {
|
|
32
|
+
const path = approvalsPath(folder);
|
|
33
|
+
const { file, problem } = parseApprovals(snapshot.get(path));
|
|
34
|
+
approvals.set(folder, file);
|
|
35
|
+
if (problem !== undefined) approvalProblems.set(path, problem);
|
|
36
|
+
}
|
|
37
|
+
const retired = new Set(
|
|
38
|
+
items
|
|
39
|
+
.filter((item) => item.archived)
|
|
40
|
+
.flatMap((item) => readDelta(item.doc).entries.filter((e) => e.element.op === "REMOVED").map((e) => e.element.id)),
|
|
41
|
+
);
|
|
42
|
+
const journal: JournalEntry[] = [];
|
|
43
|
+
const journalProblems = new Map<string, number[]>();
|
|
44
|
+
for (const folder of [".cruze", ...items.map((item) => item.folder)]) {
|
|
45
|
+
const path = journalPath(folder);
|
|
46
|
+
const { entries, badLines } = parseJournal(snapshot.get(path));
|
|
47
|
+
journal.push(...entries);
|
|
48
|
+
if (badLines.length > 0) journalProblems.set(path, badLines);
|
|
49
|
+
}
|
|
50
|
+
journal.sort((a, b) => a.at.localeCompare(b.at));
|
|
51
|
+
const configText = snapshot.get(PATHS.config);
|
|
52
|
+
return {
|
|
53
|
+
snapshot,
|
|
54
|
+
living: buildLivingModel(snapshot),
|
|
55
|
+
items,
|
|
56
|
+
config: configText === undefined ? null : parseConfig(configText),
|
|
57
|
+
approvals,
|
|
58
|
+
approvalProblems,
|
|
59
|
+
retired,
|
|
60
|
+
journal,
|
|
61
|
+
journalProblems,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function approvalsPath(folder: string): string {
|
|
66
|
+
return `${folder}/approvals.json`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The folder that holds an artifact's approval: the work item's folder, or `.cruze` for project documents. */
|
|
70
|
+
export function approvalFolder(view: ProjectView, artifactPath: string): string {
|
|
71
|
+
return view.items.find((item) => item.path === artifactPath)?.folder ?? ".cruze";
|
|
72
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readRows } from "../edits/managed_tables.ts";
|
|
2
|
+
import { parseMarkdown } from "../markdown.ts";
|
|
3
|
+
import { tablesIn } from "../markdown_tables.ts";
|
|
4
|
+
|
|
5
|
+
/** The items of `docs/roadmap.md`, as the cruze-formats skill defines them. */
|
|
6
|
+
|
|
7
|
+
export interface RoadmapItem {
|
|
8
|
+
slug: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
goals: string;
|
|
11
|
+
blockedBy: string[];
|
|
12
|
+
phase: number;
|
|
13
|
+
line: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RoadmapStatusRow {
|
|
17
|
+
slug: string;
|
|
18
|
+
id: string;
|
|
19
|
+
state: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Every item in the roadmap's phases, in the order they appear. */
|
|
23
|
+
export function readRoadmapItems(text: string): RoadmapItem[] {
|
|
24
|
+
const doc = parseMarkdown(text);
|
|
25
|
+
const items: RoadmapItem[] = [];
|
|
26
|
+
for (const phase of doc.headings.filter((h) => h.level === 3 && /^Phase \d+: /.test(h.text))) {
|
|
27
|
+
const number = Number(/^Phase (\d+)/.exec(phase.text)?.[1] ?? 0);
|
|
28
|
+
const end = doc.headings.find((h) => h.line > phase.line && h.level <= 3)?.line ?? doc.lines.length;
|
|
29
|
+
for (const table of tablesIn(doc.lines, doc.inFence, phase.line + 1, end)) {
|
|
30
|
+
for (const { cells, line } of table.rows) {
|
|
31
|
+
const [slug = "", kind = "", goals = "", blocked = ""] = cells;
|
|
32
|
+
const blockedBy = blocked.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
33
|
+
items.push({ slug, kind, goals, blockedBy, phase: number, line });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return items;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The managed `## Status` rows: each started item's folder ID and state. */
|
|
41
|
+
export function readRoadmapStatus(text: string): RoadmapStatusRow[] {
|
|
42
|
+
return readRows(text, "Status").map(([slug = "", id = "", state = ""]) => ({ slug, id, state }));
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { parseMarkdown, type MarkdownDoc } from "../markdown.ts";
|
|
2
|
+
import { PATHS, filesUnder, type Snapshot } from "./layout.ts";
|
|
3
|
+
|
|
4
|
+
export type WorkItemKind = "feature" | "change" | "standalone";
|
|
5
|
+
|
|
6
|
+
/** A feature, a change inside a feature, or a standalone change. */
|
|
7
|
+
export interface WorkItem {
|
|
8
|
+
kind: WorkItemKind;
|
|
9
|
+
/** `<feature id>`, `<feature id>/<change>` or `<standalone id>`. */
|
|
10
|
+
ref: string;
|
|
11
|
+
/** The ID its frontmatter must carry: the folder name. */
|
|
12
|
+
folderName: string;
|
|
13
|
+
folder: string;
|
|
14
|
+
path: string;
|
|
15
|
+
doc: MarkdownDoc;
|
|
16
|
+
archived: boolean;
|
|
17
|
+
/** For a change inside a feature: the feature's ref. */
|
|
18
|
+
featureRef?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FEATURE = /^\.cruze\/(features|archive)\/([^/]+)\/feature\.md$/;
|
|
22
|
+
const FEATURE_CHANGE = /^\.cruze\/(features|archive)\/([^/]+)\/changes\/([^/]+)\/change\.md$/;
|
|
23
|
+
const STANDALONE = /^\.cruze\/(changes|archive)\/([^/]+)\/change\.md$/;
|
|
24
|
+
|
|
25
|
+
export function listWorkItems(snapshot: Snapshot): WorkItem[] {
|
|
26
|
+
const paths = [...filesUnder(snapshot, PATHS.featuresDir), ...filesUnder(snapshot, PATHS.changesDir), ...filesUnder(snapshot, PATHS.archiveDir)];
|
|
27
|
+
return paths.flatMap((path) => {
|
|
28
|
+
const item = classify(path);
|
|
29
|
+
return item === null ? [] : [{ ...item, path, doc: parseMarkdown(snapshot.get(path) ?? "") }];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function findWorkItem(items: WorkItem[], ref: string): WorkItem | undefined {
|
|
34
|
+
return items.find((item) => item.ref === ref || item.path === ref || item.folder === ref);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The feature a change belongs to. */
|
|
38
|
+
export function featureOf(items: WorkItem[], change: WorkItem): WorkItem | undefined {
|
|
39
|
+
return change.featureRef === undefined ? undefined : items.find((item) => item.kind === "feature" && item.ref === change.featureRef);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function changesOf(items: WorkItem[], feature: WorkItem): WorkItem[] {
|
|
43
|
+
return items.filter((item) => item.featureRef === feature.ref).sort((a, b) => a.ref.localeCompare(b.ref));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function classify(path: string): Omit<WorkItem, "path" | "doc"> | null {
|
|
47
|
+
const folder = path.slice(0, path.lastIndexOf("/"));
|
|
48
|
+
let match = FEATURE.exec(path);
|
|
49
|
+
if (match?.[2] !== undefined) {
|
|
50
|
+
return { kind: "feature", ref: match[2], folderName: match[2], folder, archived: match[1] === "archive" };
|
|
51
|
+
}
|
|
52
|
+
match = FEATURE_CHANGE.exec(path);
|
|
53
|
+
if (match?.[2] !== undefined && match[3] !== undefined) {
|
|
54
|
+
return {
|
|
55
|
+
kind: "change",
|
|
56
|
+
ref: `${match[2]}/${match[3]}`,
|
|
57
|
+
folderName: match[3],
|
|
58
|
+
folder,
|
|
59
|
+
archived: match[1] === "archive",
|
|
60
|
+
featureRef: match[2],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
match = STANDALONE.exec(path);
|
|
64
|
+
if (match?.[2] !== undefined) {
|
|
65
|
+
return { kind: "standalone", ref: match[2], folderName: match[2], folder, archived: match[1] === "archive" };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { CruzeError } from "./cruze_error.ts";
|
|
2
|
+
|
|
3
|
+
export type ProjectName = string & { readonly __brand: "ProjectName" };
|
|
4
|
+
|
|
5
|
+
const MAX_LENGTH = 100;
|
|
6
|
+
|
|
7
|
+
/** A project name is a single line of human-readable text, used as the README title. */
|
|
8
|
+
export function parseProjectName(raw: string): ProjectName {
|
|
9
|
+
const name = raw.trim();
|
|
10
|
+
if (name.length === 0) {
|
|
11
|
+
throw new CruzeError("invalid-project-name", "The project name is empty.");
|
|
12
|
+
}
|
|
13
|
+
if (/[\r\n]/.test(name)) {
|
|
14
|
+
throw new CruzeError("invalid-project-name", "The project name must be a single line.");
|
|
15
|
+
}
|
|
16
|
+
if (name.length > MAX_LENGTH) {
|
|
17
|
+
throw new CruzeError("invalid-project-name", `The project name is longer than ${MAX_LENGTH} characters.`);
|
|
18
|
+
}
|
|
19
|
+
return name as ProjectName;
|
|
20
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The files `cruze init` gives a repository, and how their templates are filled. */
|
|
2
|
+
|
|
3
|
+
export interface ScaffoldEntry {
|
|
4
|
+
/** Where the file goes, relative to the project root. */
|
|
5
|
+
path: string;
|
|
6
|
+
/** The template it is rendered from, relative to the bundle's templates directory. */
|
|
7
|
+
template: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const INIT_SCAFFOLD: readonly ScaffoldEntry[] = [
|
|
11
|
+
{ path: "README.md", template: "init/README.md" },
|
|
12
|
+
{ path: "CHANGELOG.md", template: "init/CHANGELOG.md" },
|
|
13
|
+
{ path: "AGENTS.md", template: "init/AGENTS.md" },
|
|
14
|
+
{ path: "CLAUDE.md", template: "init/CLAUDE.md" },
|
|
15
|
+
{ path: ".cruze/config.yaml", template: "init/config.yaml" },
|
|
16
|
+
{ path: ".github/workflows/ci.yml", template: "init/ci.yml" },
|
|
17
|
+
{ path: ".gitattributes", template: "init/gitattributes" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Replaces every `{{key}}` with its value; an unknown key is a template bug. */
|
|
21
|
+
export function renderTemplate(template: string, values: Record<string, string>): string {
|
|
22
|
+
return template.replace(/\{\{([a-z_]+)\}\}/g, (placeholder, key: string) => {
|
|
23
|
+
const value = values[key];
|
|
24
|
+
if (value === undefined) {
|
|
25
|
+
throw new Error(`Template placeholder ${placeholder} has no value`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rules for bundled skills in the Agent Skills format: a folder holding a SKILL.md
|
|
3
|
+
* whose frontmatter carries `name` and `description`, installed as `cruze-<folder>`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SKILL_FILE = "SKILL.md";
|
|
7
|
+
export const SKILL_PREFIX = "cruze-";
|
|
8
|
+
export const MAX_BODY_LINES = 150;
|
|
9
|
+
|
|
10
|
+
const MAX_NAME_LENGTH = 64;
|
|
11
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
12
|
+
const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
13
|
+
|
|
14
|
+
export interface SkillDocument {
|
|
15
|
+
frontmatter: Record<string, string>;
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The directory name a bundled skill folder gets once installed into a project. */
|
|
20
|
+
export function installedSkillName(folder: string): string {
|
|
21
|
+
return `${SKILL_PREFIX}${folder}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isInstalledSkillName(name: string): boolean {
|
|
25
|
+
return name.startsWith(SKILL_PREFIX);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Splits a SKILL.md into frontmatter and body. Frontmatter is read as flat
|
|
30
|
+
* `key: value` lines, which is all Cruze's own skills use; returns null when
|
|
31
|
+
* the file has no frontmatter block.
|
|
32
|
+
*/
|
|
33
|
+
export function parseSkillDocument(text: string): SkillDocument | null {
|
|
34
|
+
const lines = text.split(/\r?\n/);
|
|
35
|
+
if (lines[0] !== "---") return null;
|
|
36
|
+
const end = lines.indexOf("---", 1);
|
|
37
|
+
if (end === -1) return null;
|
|
38
|
+
|
|
39
|
+
const frontmatter: Record<string, string> = {};
|
|
40
|
+
for (const line of lines.slice(1, end)) {
|
|
41
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
42
|
+
if (match?.[1] !== undefined && match[2] !== undefined) {
|
|
43
|
+
frontmatter[match[1]] = unquote(match[2].trim());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { frontmatter, body: lines.slice(end + 1).join("\n") };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every rule a bundled skill breaks; empty when the skill is valid. */
|
|
50
|
+
export function skillProblems(folder: string, skillText: string): string[] {
|
|
51
|
+
const doc = parseSkillDocument(skillText);
|
|
52
|
+
if (doc === null) return [`${folder}: ${SKILL_FILE} has no frontmatter block`];
|
|
53
|
+
|
|
54
|
+
const problems: string[] = [];
|
|
55
|
+
const expectedName = installedSkillName(folder);
|
|
56
|
+
const name = doc.frontmatter["name"] ?? "";
|
|
57
|
+
const description = doc.frontmatter["description"] ?? "";
|
|
58
|
+
|
|
59
|
+
if (name !== expectedName) {
|
|
60
|
+
problems.push(`${folder}: name is "${name}", expected "${expectedName}"`);
|
|
61
|
+
}
|
|
62
|
+
if (name.length > MAX_NAME_LENGTH || !NAME_PATTERN.test(name)) {
|
|
63
|
+
problems.push(`${folder}: name must be lowercase letters, digits and single hyphens, at most ${MAX_NAME_LENGTH} characters`);
|
|
64
|
+
}
|
|
65
|
+
if (description.length === 0) {
|
|
66
|
+
problems.push(`${folder}: description is missing`);
|
|
67
|
+
}
|
|
68
|
+
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
|
69
|
+
problems.push(`${folder}: description is longer than ${MAX_DESCRIPTION_LENGTH} characters`);
|
|
70
|
+
}
|
|
71
|
+
const bodyLines = doc.body.trimEnd().split("\n").length;
|
|
72
|
+
if (bodyLines > MAX_BODY_LINES) {
|
|
73
|
+
problems.push(`${folder}: body is ${bodyLines} lines, the limit is ${MAX_BODY_LINES}; move reference material to sibling files`);
|
|
74
|
+
}
|
|
75
|
+
return problems;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function unquote(value: string): string {
|
|
79
|
+
const quoted = /^"(.*)"$/.exec(value) ?? /^'(.*)'$/.exec(value);
|
|
80
|
+
return quoted?.[1] ?? value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Agent-specific tool names a skill must not use, because skills run under any agent. Plain
|
|
85
|
+
* verbs such as "Read" or "Edit" are fine; these patterns catch tool-shaped uses only.
|
|
86
|
+
*/
|
|
87
|
+
const AGENT_TOOL_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
|
88
|
+
{ pattern: /\b(TodoWrite|WebFetch|WebSearch|AskUserQuestion|NotebookEdit|MultiEdit|ExitPlanMode|SlashCommand|subagent_type)\b/, label: "a Claude Code tool name" },
|
|
89
|
+
{ pattern: /`(Read|Write|Edit|Bash|Grep|Glob|Task|Agent|Skill|LS)`/, label: "a tool name in backticks" },
|
|
90
|
+
{ pattern: /\b(Read|Write|Edit|Bash|Grep|Glob|Task|Agent|Skill)\s+tool\b/, label: "a named tool" },
|
|
91
|
+
{ pattern: /\b(Bash|Read|Write|Edit|WebFetch)\([^)]*\)/, label: "a tool permission pattern" },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Lines of a skill file that name an agent-specific tool. Fenced code blocks are skipped: they
|
|
96
|
+
* hold data such as a config example, which may be written for one agent.
|
|
97
|
+
*/
|
|
98
|
+
export function agentToolProblems(where: string, text: string): string[] {
|
|
99
|
+
let inFence = false;
|
|
100
|
+
return text.split("\n").flatMap((line, i) => {
|
|
101
|
+
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
|
|
102
|
+
if (inFence) return [];
|
|
103
|
+
const hit = AGENT_TOOL_PATTERNS.find(({ pattern }) => pattern.test(line));
|
|
104
|
+
return hit === undefined ? [] : [`${where}:${i + 1}: names ${hit.label}; describe the action instead`];
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import { SKILL_FILE, SKILL_PREFIX } from "./skill.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pointers between bundled skill files. A skill reaches its own sibling files
|
|
6
|
+
* through relative Markdown links, and other Cruze skills through their
|
|
7
|
+
* installed path, `.agents/skills/cruze-<name>/<file>`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface SkillFiles {
|
|
11
|
+
folder: string;
|
|
12
|
+
files: { path: string; text: string }[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface InstalledSkillRef {
|
|
16
|
+
/** The installed skill name, such as `cruze-formats`. */
|
|
17
|
+
skill: string;
|
|
18
|
+
/** The file inside that skill, such as `reference/work-items.md`. */
|
|
19
|
+
path: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const INSTALLED_REF = /\.agents\/skills\/(cruze-[a-z0-9]+(?:-[a-z0-9]+)*)\/([\w./-]*\w)/g;
|
|
23
|
+
const MARKDOWN_LINK = /\]\(([^)\s]+)\)/g;
|
|
24
|
+
|
|
25
|
+
/** Every file of another Cruze skill that a text names by its installed path, in order, without repeats. */
|
|
26
|
+
export function installedSkillRefs(text: string): InstalledSkillRef[] {
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
const refs: InstalledSkillRef[] = [];
|
|
29
|
+
for (const match of text.matchAll(INSTALLED_REF)) {
|
|
30
|
+
const ref = { skill: match[1] ?? "", path: match[2] ?? "" };
|
|
31
|
+
const key = `${ref.skill}/${ref.path}`;
|
|
32
|
+
if (!seen.has(key)) refs.push(ref);
|
|
33
|
+
seen.add(key);
|
|
34
|
+
}
|
|
35
|
+
return refs;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Targets of the relative Markdown links outside code fences, without any `#fragment`. */
|
|
39
|
+
export function relativeLinks(text: string): string[] {
|
|
40
|
+
const targets: string[] = [];
|
|
41
|
+
let inFence = false;
|
|
42
|
+
for (const line of text.split("\n")) {
|
|
43
|
+
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
|
|
44
|
+
if (inFence) continue;
|
|
45
|
+
for (const match of line.matchAll(MARKDOWN_LINK)) {
|
|
46
|
+
const target = (match[1] ?? "").split("#")[0] ?? "";
|
|
47
|
+
if (target !== "" && !/^[a-z][a-z0-9+.-]*:/i.test(target)) targets.push(target);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return targets;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every broken pointer in the bundled skills: a link to a missing sibling, a link
|
|
55
|
+
* that leaves its skill, an installed path to a missing file, and a Markdown file
|
|
56
|
+
* that its skill's SKILL.md can't reach through links.
|
|
57
|
+
*/
|
|
58
|
+
export function skillLinkProblems(skills: SkillFiles[]): string[] {
|
|
59
|
+
const exists = new Set(skills.flatMap((skill) => skill.files.map((file) => `${skill.folder}/${file.path}`)));
|
|
60
|
+
const problems: string[] = [];
|
|
61
|
+
for (const skill of skills) {
|
|
62
|
+
for (const file of skill.files.filter((f) => f.path.endsWith(".md"))) {
|
|
63
|
+
const where = `${skill.folder}/${file.path}`;
|
|
64
|
+
for (const target of relativeLinks(file.text)) {
|
|
65
|
+
const resolved = resolveLink(file.path, target);
|
|
66
|
+
if (resolved === null) problems.push(`${where}: link ${target} leaves the skill; name another skill by its installed path`);
|
|
67
|
+
else if (!exists.has(`${skill.folder}/${resolved}`)) problems.push(`${where}: link ${target} points to a missing file`);
|
|
68
|
+
}
|
|
69
|
+
for (const ref of installedSkillRefs(file.text)) {
|
|
70
|
+
const folder = ref.skill.slice(SKILL_PREFIX.length);
|
|
71
|
+
if (!exists.has(`${folder}/${ref.path}`)) problems.push(`${where}: .agents/skills/${ref.skill}/${ref.path} is not in the bundle`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const reachable = reachableFromSkillFile(skill);
|
|
75
|
+
for (const file of skill.files.filter((f) => f.path.endsWith(".md") && !reachable.has(f.path))) {
|
|
76
|
+
problems.push(`${skill.folder}/${file.path}: no link reaches it from ${SKILL_FILE}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return problems;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A link's path inside the skill folder, or null when it leaves the folder. */
|
|
83
|
+
function resolveLink(fromPath: string, target: string): string | null {
|
|
84
|
+
const resolved = posix.normalize(posix.join(posix.dirname(fromPath), target));
|
|
85
|
+
return resolved.startsWith("../") || resolved === ".." ? null : resolved;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function reachableFromSkillFile(skill: SkillFiles): Set<string> {
|
|
89
|
+
const texts = new Map(skill.files.map((file) => [file.path, file.text]));
|
|
90
|
+
const reached = new Set<string>();
|
|
91
|
+
const queue = [SKILL_FILE];
|
|
92
|
+
while (queue.length > 0) {
|
|
93
|
+
const path = queue.shift() ?? "";
|
|
94
|
+
const text = texts.get(path);
|
|
95
|
+
if (reached.has(path) || text === undefined) continue;
|
|
96
|
+
reached.add(path);
|
|
97
|
+
for (const target of relativeLinks(text)) {
|
|
98
|
+
const resolved = resolveLink(path, target);
|
|
99
|
+
if (resolved !== null) queue.push(resolved);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return reached;
|
|
103
|
+
}
|