@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,128 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
|
|
3
|
+
/** `.cruze/config.yaml`, as described in the cruze-formats skill. */
|
|
4
|
+
export interface CruzeConfig {
|
|
5
|
+
version: 1;
|
|
6
|
+
project: string;
|
|
7
|
+
tracker: "markdown";
|
|
8
|
+
source: string[];
|
|
9
|
+
tests: string[];
|
|
10
|
+
check: {
|
|
11
|
+
maxLines: number;
|
|
12
|
+
maxTypes: number;
|
|
13
|
+
exceptions: Array<{ path: string; reason: string }>;
|
|
14
|
+
};
|
|
15
|
+
layers: Layer[];
|
|
16
|
+
/** Named project commands, such as `test: cargo test`, shown in AGENTS.md. */
|
|
17
|
+
commands: Record<string, string>;
|
|
18
|
+
/** How `cruze review` starts an agent in a fresh context: the program and its arguments. */
|
|
19
|
+
review: { command: string[] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Claude Code in print mode, allowed to read the project, its history and run `cruze`, but not to edit. */
|
|
23
|
+
export const DEFAULT_REVIEW_COMMAND = ["claude", "-p", "--allowedTools", "Read Grep Glob Bash(git diff:*) Bash(git log:*) Bash(git show:*) Bash(cruze:*)"];
|
|
24
|
+
|
|
25
|
+
export interface Layer {
|
|
26
|
+
name: string;
|
|
27
|
+
paths: string[];
|
|
28
|
+
mayImport: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ConfigResult = { config: CruzeConfig; problems: string[] } | { config: null; problems: string[] };
|
|
32
|
+
|
|
33
|
+
/** Parses the config, collecting every problem; returns a config only when it is usable. */
|
|
34
|
+
export function parseConfig(text: string): ConfigResult {
|
|
35
|
+
let raw: unknown;
|
|
36
|
+
try {
|
|
37
|
+
raw = parseYaml(text);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return { config: null, problems: [`not valid YAML: ${error instanceof Error ? error.message : String(error)}`] };
|
|
40
|
+
}
|
|
41
|
+
const problems: string[] = [];
|
|
42
|
+
const root = asRecord(raw);
|
|
43
|
+
if (root === null) return { config: null, problems: ["the file must be a YAML mapping"] };
|
|
44
|
+
|
|
45
|
+
if (root["version"] !== 1) problems.push("version must be 1");
|
|
46
|
+
if (typeof root["project"] !== "string" || root["project"] === "") problems.push("project must be a non-empty string");
|
|
47
|
+
if (root["tracker"] !== "markdown") problems.push("tracker must be markdown");
|
|
48
|
+
const source = stringList(root["source"], "source", problems);
|
|
49
|
+
const tests = stringList(root["tests"], "tests", problems);
|
|
50
|
+
|
|
51
|
+
const check = asRecord(root["check"]) ?? {};
|
|
52
|
+
const maxLines = positiveInt(check["max_lines"], "check.max_lines", problems);
|
|
53
|
+
const maxTypes = positiveInt(check["max_types"], "check.max_types", problems);
|
|
54
|
+
const exceptions = (Array.isArray(check["exceptions"]) ? check["exceptions"] : []).flatMap((entry: unknown, i: number) => {
|
|
55
|
+
const record = asRecord(entry);
|
|
56
|
+
if (typeof record?.["path"] !== "string" || typeof record["reason"] !== "string" || record["reason"].trim() === "") {
|
|
57
|
+
problems.push(`check.exceptions[${i}] needs a path and a non-empty reason`);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return [{ path: record["path"], reason: record["reason"] }];
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const layers = (Array.isArray(root["layers"]) ? root["layers"] : []).flatMap((entry: unknown, i: number) => {
|
|
64
|
+
const record = asRecord(entry);
|
|
65
|
+
if (typeof record?.["name"] !== "string") {
|
|
66
|
+
problems.push(`layers[${i}] needs a name`);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
return [{ name: record["name"], paths: stringList(record["paths"], `layers[${i}].paths`, problems), mayImport: stringList(record["may_import"], `layers[${i}].may_import`, problems) }];
|
|
70
|
+
});
|
|
71
|
+
const names = layers.map((layer) => layer.name);
|
|
72
|
+
names.filter((name, i) => names.indexOf(name) !== i).forEach((name) => problems.push(`layer ${name} is defined twice`));
|
|
73
|
+
for (const layer of layers) {
|
|
74
|
+
for (const target of layer.mayImport.filter((t) => !names.includes(t))) {
|
|
75
|
+
problems.push(`layer ${layer.name} may_import names unknown layer ${target}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const commands: Record<string, string> = {};
|
|
80
|
+
const rawCommands = root["commands"];
|
|
81
|
+
if (rawCommands !== undefined && rawCommands !== null) {
|
|
82
|
+
const record = asRecord(rawCommands);
|
|
83
|
+
if (record === null) problems.push("commands must be a mapping of names to commands");
|
|
84
|
+
for (const [name, command] of Object.entries(record ?? {})) {
|
|
85
|
+
if (typeof command === "string" && command.trim() !== "") commands[name] = command;
|
|
86
|
+
else problems.push(`commands.${name} must be a command string`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const review = asRecord(root["review"]) ?? {};
|
|
90
|
+
const reviewCommand = review["command"] === undefined ? DEFAULT_REVIEW_COMMAND : stringList(review["command"], "review.command", problems);
|
|
91
|
+
if (reviewCommand.length === 0) problems.push("review.command needs at least the program to run");
|
|
92
|
+
|
|
93
|
+
const config: CruzeConfig = {
|
|
94
|
+
version: 1,
|
|
95
|
+
project: typeof root["project"] === "string" ? root["project"] : "",
|
|
96
|
+
tracker: "markdown",
|
|
97
|
+
source,
|
|
98
|
+
tests,
|
|
99
|
+
check: { maxLines: maxLines ?? 250, maxTypes: maxTypes ?? 5, exceptions },
|
|
100
|
+
layers,
|
|
101
|
+
commands,
|
|
102
|
+
review: { command: reviewCommand.length === 0 ? DEFAULT_REVIEW_COMMAND : reviewCommand },
|
|
103
|
+
};
|
|
104
|
+
return { config, problems };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The first layer whose paths match the file, as the formats define. */
|
|
108
|
+
export function layerOf(config: CruzeConfig, path: string, matches: (path: string, globs: string[]) => boolean): Layer | undefined {
|
|
109
|
+
return config.layers.find((layer) => matches(path, layer.paths));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
113
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stringList(value: unknown, key: string, problems: string[]): string[] {
|
|
117
|
+
if (value === undefined || value === null) return [];
|
|
118
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value as string[];
|
|
119
|
+
problems.push(`${key} must be a list of strings`);
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function positiveInt(value: unknown, key: string, problems: string[]): number | undefined {
|
|
124
|
+
if (value === undefined) return undefined;
|
|
125
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
|
126
|
+
problems.push(`${key} must be a positive whole number`);
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** An expected failure the CLI reports to the user, as opposed to a bug. */
|
|
2
|
+
export class CruzeError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
|
|
5
|
+
constructor(code: string, message: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "CruzeError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { factsOf, statusOf } from "../elements.ts";
|
|
2
|
+
import type { ProjectView } from "../project/project_view.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The managed sections of a project's `AGENTS.md`: its commands, from `.cruze/config.yaml`,
|
|
6
|
+
* and its layout, from the built modules in `docs/architecture.md`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const OPEN = "<!-- cruze:managed -->";
|
|
10
|
+
const CLOSE = "<!-- /cruze:managed -->";
|
|
11
|
+
|
|
12
|
+
/** Rewrites the managed blocks under `## Commands` and `## Layout`; other text is left alone. */
|
|
13
|
+
export function renderAgentsFile(text: string, view: ProjectView): string {
|
|
14
|
+
let next = text;
|
|
15
|
+
const commands = Object.entries(view.config?.config?.commands ?? {});
|
|
16
|
+
if (commands.length > 0) next = replaceBlock(next, "Commands", commands.map(([name, command]) => `- ${name}: \`${command}\``));
|
|
17
|
+
const modules = builtModules(view);
|
|
18
|
+
if (modules.length > 0) next = replaceBlock(next, "Layout", ["Modules, from `docs/architecture.md`:", "", ...modules]);
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function builtModules(view: ProjectView): string[] {
|
|
23
|
+
return [...view.living.elements.values()]
|
|
24
|
+
.filter(({ element, doc }) => element.kind === "MOD" && statusOf(doc, element) === "built")
|
|
25
|
+
.map(({ element, doc }) => {
|
|
26
|
+
const facts = factsOf(doc, element);
|
|
27
|
+
const paths = (facts.get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => `\`${m[1] ?? ""}\``));
|
|
28
|
+
return `- ${paths.join(", ")} (${facts.get("Layer")?.[0] ?? "unknown"}): ${element.title}`;
|
|
29
|
+
})
|
|
30
|
+
.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Replaces the body of the first managed block after `## <heading>`, before the next `## ` heading. */
|
|
34
|
+
function replaceBlock(text: string, heading: string, body: string[]): string {
|
|
35
|
+
const lines = text.split("\n");
|
|
36
|
+
const at = lines.findIndex((line) => line === `## ${heading}`);
|
|
37
|
+
if (at === -1) return text;
|
|
38
|
+
const sectionEnd = lines.findIndex((line, i) => i > at && line.startsWith("## "));
|
|
39
|
+
const end = sectionEnd === -1 ? lines.length : sectionEnd;
|
|
40
|
+
const open = lines.findIndex((line, i) => i > at && i < end && line.trim() === OPEN);
|
|
41
|
+
const close = lines.findIndex((line, i) => i > open && i < end && line.trim() === CLOSE);
|
|
42
|
+
if (open === -1 || close === -1) return text;
|
|
43
|
+
return [...lines.slice(0, open + 1), ...body, ...lines.slice(close)].join("\n");
|
|
44
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseMarkdown } from "../markdown.ts";
|
|
2
|
+
import { splitRow, tablesIn } from "../markdown_tables.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Edits to the managed tables: the feature map in vision.md and the status table
|
|
6
|
+
* in roadmap.md. Rows are keyed by their first cell.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Replaces or appends the row keyed by `cells[0]` in the table under a heading. */
|
|
10
|
+
export function upsertRow(text: string, heading: string, cells: string[]): string {
|
|
11
|
+
return editTable(text, heading, (rows) => {
|
|
12
|
+
const index = rows.findIndex((row) => row[0] === cells[0]);
|
|
13
|
+
if (index === -1) rows.push(cells);
|
|
14
|
+
else rows[index] = cells;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function removeRow(text: string, heading: string, key: string): { text: string; removed: string[] | null } {
|
|
19
|
+
let removed: string[] | null = null;
|
|
20
|
+
const next = editTable(text, heading, (rows) => {
|
|
21
|
+
const index = rows.findIndex((row) => row[0] === key);
|
|
22
|
+
if (index !== -1) removed = rows.splice(index, 1)[0] ?? null;
|
|
23
|
+
});
|
|
24
|
+
return { text: next, removed };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readRows(text: string, heading: string): string[][] {
|
|
28
|
+
const doc = parseMarkdown(text);
|
|
29
|
+
const at = doc.headings.find((h) => h.text === heading);
|
|
30
|
+
if (at === undefined) return [];
|
|
31
|
+
const end = doc.headings.find((h) => h.line > at.line && h.level <= at.level)?.line ?? doc.lines.length;
|
|
32
|
+
return tablesIn(doc.lines, doc.inFence, at.line + 1, end)[0]?.rows.map((row) => row.cells) ?? [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function editTable(text: string, heading: string, edit: (rows: string[][]) => void): string {
|
|
36
|
+
const doc = parseMarkdown(text);
|
|
37
|
+
const at = doc.headings.find((h) => h.text === heading);
|
|
38
|
+
if (at === undefined) throw new Error(`No "${heading}" heading to edit`);
|
|
39
|
+
const end = doc.headings.find((h) => h.line > at.line && h.level <= at.level)?.line ?? doc.lines.length;
|
|
40
|
+
let first = -1;
|
|
41
|
+
let last = -1;
|
|
42
|
+
for (let i = at.line + 1; i < end; i++) {
|
|
43
|
+
if (/^\s*\|.*\|\s*$/.test(doc.lines[i] ?? "")) {
|
|
44
|
+
if (first === -1) first = i;
|
|
45
|
+
last = i;
|
|
46
|
+
} else if (first !== -1) break;
|
|
47
|
+
}
|
|
48
|
+
if (first === -1) throw new Error(`No table under "${heading}"`);
|
|
49
|
+
const header = splitRow(doc.lines[first] ?? "");
|
|
50
|
+
const rows = doc.lines.slice(first + 2, last + 1).map(splitRow);
|
|
51
|
+
edit(rows);
|
|
52
|
+
const rendered = [renderRow(header), renderRow(header.map(() => "---")), ...rows.map(renderRow)];
|
|
53
|
+
const lines = [...doc.lines];
|
|
54
|
+
lines.splice(first, last - first + 1, ...rendered);
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderRow(cells: string[]): string {
|
|
59
|
+
return `| ${cells.join(" | ")} |`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MANAGED_CLOSE, MANAGED_OPEN, parseMarkdown } from "../markdown.ts";
|
|
2
|
+
import { readTasks } from "../project/plan_parts.ts";
|
|
3
|
+
import { progressRange, readProgress, type Progress } from "../project/progress.ts";
|
|
4
|
+
|
|
5
|
+
/** Rewrites the managed `## Progress` block of a feature or change from a changed Progress. */
|
|
6
|
+
export function updateProgress(text: string, change: (progress: Progress) => void): string {
|
|
7
|
+
const doc = parseMarkdown(text);
|
|
8
|
+
const progress = readProgress(doc);
|
|
9
|
+
for (const task of readTasks(doc).tasks) if (!progress.tasks.has(task.number)) progress.tasks.set(task.number, null);
|
|
10
|
+
change(progress);
|
|
11
|
+
const block = [MANAGED_OPEN, "## Progress", ...renderProgress(progress), MANAGED_CLOSE];
|
|
12
|
+
const range = progressRange(doc);
|
|
13
|
+
const lines = [...doc.lines];
|
|
14
|
+
if (range === null) {
|
|
15
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
16
|
+
return [...lines, "", ...block, ""].join("\n");
|
|
17
|
+
}
|
|
18
|
+
lines.splice(range.open, range.end - range.open + 1, ...block);
|
|
19
|
+
return lines.join("\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderProgress(progress: Progress): string[] {
|
|
23
|
+
const lines: string[] = [];
|
|
24
|
+
if (progress.branch !== undefined) lines.push(`- Branch: ${progress.branch}`);
|
|
25
|
+
for (const [change, state] of progress.changes) lines.push(`- ${change}: ${state}`);
|
|
26
|
+
const ordered = [...progress.tasks].sort(([a], [b]) => Number(a.slice(1)) - Number(b.slice(1)));
|
|
27
|
+
for (const [task, commit] of ordered) {
|
|
28
|
+
lines.push(commit === null ? `- [ ] ${task}` : commit === "" ? `- [x] ${task}` : `- [x] ${task} (${commit})`);
|
|
29
|
+
}
|
|
30
|
+
for (const deviation of progress.deviations) lines.push(`- Deviation ${deviation}`);
|
|
31
|
+
if (progress.landed !== undefined) lines.push(`- Landed: ${progress.landed}`);
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { headingElements, isStatusLine, statusOf, type Status } from "../elements.ts";
|
|
2
|
+
import { STATUS_KINDS } from "../ids.ts";
|
|
3
|
+
import { parseMarkdown, type MarkdownDoc } from "../markdown.ts";
|
|
4
|
+
|
|
5
|
+
/** Managed `- Status:` lines: only the CLI adds or changes them. */
|
|
6
|
+
|
|
7
|
+
/** Sets one element's status, adding the line after its facts when it has none. Returns the new text. */
|
|
8
|
+
export function setStatus(text: string, id: string, status: Status): string {
|
|
9
|
+
const doc = parseMarkdown(text);
|
|
10
|
+
const element = headingElements(doc).find((e) => e.id === id);
|
|
11
|
+
if (element === undefined) throw new Error(`${id} is not in this document`);
|
|
12
|
+
const lines = [...doc.lines];
|
|
13
|
+
const at = statusLineIndex(doc, element.start, element.end);
|
|
14
|
+
if (at !== null) lines[at] = `- Status: ${status}`;
|
|
15
|
+
else lines.splice(insertionPoint(doc, element.start, element.end), 0, `- Status: ${status}`);
|
|
16
|
+
return lines.join("\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Adds `- Status: planned` to every status-carrying element that has none. */
|
|
20
|
+
export function stampMissingStatuses(text: string): { text: string; stamped: string[] } {
|
|
21
|
+
let current = text;
|
|
22
|
+
const stamped: string[] = [];
|
|
23
|
+
for (const element of headingElements(parseMarkdown(text))) {
|
|
24
|
+
if (!STATUS_KINDS.includes(element.kind)) continue;
|
|
25
|
+
const doc = parseMarkdown(current);
|
|
26
|
+
const fresh = headingElements(doc).find((e) => e.id === element.id);
|
|
27
|
+
if (fresh === undefined || statusOf(doc, fresh) !== undefined) continue;
|
|
28
|
+
current = setStatus(current, element.id, "planned");
|
|
29
|
+
stamped.push(element.id);
|
|
30
|
+
}
|
|
31
|
+
return { text: current, stamped };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statusLineIndex(doc: MarkdownDoc, start: number, end: number): number | null {
|
|
35
|
+
for (let i = start + 1; i < end; i++) {
|
|
36
|
+
if (!doc.inFence[i] && /^#{1,6} /.test(doc.lines[i] ?? "")) return null;
|
|
37
|
+
if (!doc.inFence[i] && isStatusLine(doc.lines[i] ?? "")) return i;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** After the element's last top-level fact or step, before any diagram or nested heading. */
|
|
43
|
+
function insertionPoint(doc: MarkdownDoc, start: number, end: number): number {
|
|
44
|
+
let point = start + 1;
|
|
45
|
+
for (let i = start + 1; i < end; i++) {
|
|
46
|
+
const line = doc.lines[i] ?? "";
|
|
47
|
+
if (doc.inFence[i] || /^#{1,6} /.test(line)) break;
|
|
48
|
+
if (/^(- |\s{2,}- )/.test(line)) point = i + 1;
|
|
49
|
+
else if (line.trim() !== "" && point > start + 1) break;
|
|
50
|
+
}
|
|
51
|
+
return point;
|
|
52
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ID_PATTERN, STATUS_KINDS, kindOf, type Kind } from "./ids.ts";
|
|
2
|
+
import { sectionEnd, type MarkdownDoc } from "./markdown.ts";
|
|
3
|
+
|
|
4
|
+
export type DeltaOp = "ADDED" | "MODIFIED" | "REMOVED";
|
|
5
|
+
export type Status = "planned" | "built";
|
|
6
|
+
|
|
7
|
+
/** One element: a heading `<ID>: <Title>` and its body, or a table row whose first cell is an ID. */
|
|
8
|
+
export interface Element {
|
|
9
|
+
id: string;
|
|
10
|
+
kind: Kind;
|
|
11
|
+
title: string;
|
|
12
|
+
form: "heading" | "row";
|
|
13
|
+
/** Set when the heading is a delta operation, such as `### ADDED PORT-x: Title`. */
|
|
14
|
+
op?: DeltaOp;
|
|
15
|
+
level: number;
|
|
16
|
+
/** Line range [start, end) in the document. */
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A delta heading that creates a spec file: `### ADDED CAPABILITY <name>: <Title>`. */
|
|
22
|
+
export interface CapabilityOp {
|
|
23
|
+
capability: string;
|
|
24
|
+
title: string;
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HEADING = new RegExp(`^(?:(ADDED|MODIFIED|REMOVED) )?(${ID_PATTERN}): (.+)$`);
|
|
30
|
+
const CAPABILITY = /^ADDED CAPABILITY ([a-z0-9]+(?:-[a-z0-9]+)*): (.+)$/;
|
|
31
|
+
const ROW = new RegExp(`^\\|\\s*(${ID_PATTERN})\\s*\\|(.*)\\|\\s*$`);
|
|
32
|
+
const STATUS_LINE = /^- Status: (planned|built)\s*$/;
|
|
33
|
+
|
|
34
|
+
/** Heading elements whose heading lies in [from, to). */
|
|
35
|
+
export function headingElements(doc: MarkdownDoc, from = 0, to = doc.lines.length): Element[] {
|
|
36
|
+
const elements: Element[] = [];
|
|
37
|
+
for (const heading of doc.headings) {
|
|
38
|
+
if (heading.line < from || heading.line >= to) continue;
|
|
39
|
+
const match = HEADING.exec(heading.text);
|
|
40
|
+
if (match?.[2] === undefined || match[3] === undefined) continue;
|
|
41
|
+
const element: Element = {
|
|
42
|
+
id: match[2],
|
|
43
|
+
kind: kindOf(match[2]),
|
|
44
|
+
title: match[3],
|
|
45
|
+
form: "heading",
|
|
46
|
+
level: heading.level,
|
|
47
|
+
start: heading.line,
|
|
48
|
+
end: Math.min(sectionEnd(doc, heading), to),
|
|
49
|
+
};
|
|
50
|
+
if (match[1] !== undefined) element.op = match[1] as DeltaOp;
|
|
51
|
+
elements.push(element);
|
|
52
|
+
}
|
|
53
|
+
return elements;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function capabilityOps(doc: MarkdownDoc, from = 0, to = doc.lines.length): CapabilityOp[] {
|
|
57
|
+
return doc.headings
|
|
58
|
+
.filter((h) => h.line >= from && h.line < to)
|
|
59
|
+
.flatMap((heading) => {
|
|
60
|
+
const match = CAPABILITY.exec(heading.text);
|
|
61
|
+
if (match?.[1] === undefined || match[2] === undefined) return [];
|
|
62
|
+
return [{ capability: match[1], title: match[2], start: heading.line, end: Math.min(sectionEnd(doc, heading), to) }];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Row elements: table rows, outside code fences, whose first cell is an ID. */
|
|
67
|
+
export function rowElements(doc: MarkdownDoc): Element[] {
|
|
68
|
+
return doc.lines.flatMap((line, index) => {
|
|
69
|
+
if (doc.inFence[index]) return [];
|
|
70
|
+
const match = ROW.exec(line);
|
|
71
|
+
if (match?.[1] === undefined) return [];
|
|
72
|
+
const title = match[2]?.split("|")[0]?.trim() ?? "";
|
|
73
|
+
return [{ id: match[1], kind: kindOf(match[1]), title, form: "row" as const, level: 0, start: index, end: index + 1 }];
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function elementLines(doc: MarkdownDoc, element: { start: number; end: number }): string[] {
|
|
78
|
+
return doc.lines.slice(element.start, element.end);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isStatusLine(line: string): boolean {
|
|
82
|
+
return STATUS_LINE.test(line);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The element's own managed status: the first status line before any nested heading. */
|
|
86
|
+
/**
|
|
87
|
+
* Whether an element is still to be built: marked `planned`, or a kind that carries a status
|
|
88
|
+
* but not stamped yet, because the document holding it has never been approved.
|
|
89
|
+
*/
|
|
90
|
+
export function isUnbuilt(doc: MarkdownDoc, element: Element): boolean {
|
|
91
|
+
const status = statusOf(doc, element);
|
|
92
|
+
return status === "planned" || (status === undefined && STATUS_KINDS.includes(element.kind));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function statusOf(doc: MarkdownDoc, element: Element): Status | undefined {
|
|
96
|
+
for (let i = element.start + 1; i < element.end; i++) {
|
|
97
|
+
if (/^#{1,6} /.test(doc.lines[i] ?? "") && !doc.inFence[i]) return undefined;
|
|
98
|
+
const match = STATUS_LINE.exec(doc.lines[i] ?? "");
|
|
99
|
+
if (match?.[1] !== undefined) return match[1] as Status;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The element's top-level facts: `- Key: value` items and their indented sub-items.
|
|
106
|
+
* Nested elements' facts are excluded.
|
|
107
|
+
*/
|
|
108
|
+
export function factsOf(doc: MarkdownDoc, element: Element): Map<string, string[]> {
|
|
109
|
+
const facts = new Map<string, string[]>();
|
|
110
|
+
let current: string[] | null = null;
|
|
111
|
+
for (let i = element.start + 1; i < element.end; i++) {
|
|
112
|
+
const line = doc.lines[i] ?? "";
|
|
113
|
+
if (doc.inFence[i]) continue;
|
|
114
|
+
if (/^#{1,6} /.test(line)) break;
|
|
115
|
+
const fact = /^- ([A-Z][A-Za-z ]*): ?(.*)$/.exec(line);
|
|
116
|
+
if (fact?.[1] !== undefined) {
|
|
117
|
+
current = fact[2] === undefined || fact[2] === "" ? [] : [fact[2]];
|
|
118
|
+
facts.set(fact[1], current);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const sub = /^\s{2,}- (.+)$/.exec(line);
|
|
122
|
+
if (sub?.[1] !== undefined && current !== null) current.push(sub[1]);
|
|
123
|
+
else if (line.trim() !== "") current = null;
|
|
124
|
+
}
|
|
125
|
+
return facts;
|
|
126
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glob matching for config paths: `**` matches any number of directories,
|
|
3
|
+
* `*` anything within one path segment, `?` one character, `{a,b}` either.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const cache = new Map<string, RegExp>();
|
|
7
|
+
|
|
8
|
+
export function matchesGlob(path: string, glob: string): boolean {
|
|
9
|
+
let regex = cache.get(glob);
|
|
10
|
+
if (regex === undefined) {
|
|
11
|
+
regex = new RegExp(`^${globToRegex(glob)}$`);
|
|
12
|
+
cache.set(glob, regex);
|
|
13
|
+
}
|
|
14
|
+
return regex.test(path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function matchesAny(path: string, globs: readonly string[]): boolean {
|
|
18
|
+
return globs.some((glob) => matchesGlob(path, glob));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function globToRegex(glob: string): string {
|
|
22
|
+
let out = "";
|
|
23
|
+
for (let i = 0; i < glob.length; i++) {
|
|
24
|
+
const char = glob[i] ?? "";
|
|
25
|
+
if (char === "*" && glob[i + 1] === "*") {
|
|
26
|
+
const slashAfter = glob[i + 2] === "/";
|
|
27
|
+
out += slashAfter ? "(?:.*/)?" : ".*";
|
|
28
|
+
i += slashAfter ? 2 : 1;
|
|
29
|
+
} else if (char === "*") {
|
|
30
|
+
out += "[^/]*";
|
|
31
|
+
} else if (char === "?") {
|
|
32
|
+
out += "[^/]";
|
|
33
|
+
} else if (char === "{") {
|
|
34
|
+
const close = glob.indexOf("}", i);
|
|
35
|
+
if (close === -1) {
|
|
36
|
+
out += "\\{";
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
out += `(?:${glob.slice(i + 1, close).split(",").map(globToRegex).join("|")})`;
|
|
40
|
+
i = close;
|
|
41
|
+
} else {
|
|
42
|
+
out += char.replace(/[.+^$()|[\]\\]/g, "\\$&");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { elementLines, isStatusLine, type Element } from "./elements.ts";
|
|
3
|
+
import type { MarkdownDoc } from "./markdown.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hashes cover design content only. Managed blocks and status lines are
|
|
7
|
+
* excluded, so bookkeeping by the CLI never makes a design look edited.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function sha256(text: string): string {
|
|
11
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Hash of a whole document's design content. */
|
|
15
|
+
export function designHash(doc: MarkdownDoc): string {
|
|
16
|
+
const kept = doc.lines.filter((line, i) => !doc.inManaged[i] && (doc.inFence[i] || !isStatusLine(line)));
|
|
17
|
+
return sha256(normalize(kept));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hash of one element, independent of where it sits: heading levels are made
|
|
22
|
+
* relative to the element and a delta operation prefix is dropped, so an element
|
|
23
|
+
* hashes the same in a delta and after it is merged into a living document.
|
|
24
|
+
*/
|
|
25
|
+
export function elementHash(doc: MarkdownDoc, element: Element): string {
|
|
26
|
+
if (element.form === "row") return sha256(normalize(elementLines(doc, element)));
|
|
27
|
+
const kept: string[] = [];
|
|
28
|
+
for (let i = element.start; i < element.end; i++) {
|
|
29
|
+
const line = doc.lines[i] ?? "";
|
|
30
|
+
if (doc.inManaged[i] || (!doc.inFence[i] && isStatusLine(line))) continue;
|
|
31
|
+
kept.push(doc.inFence[i] ? line : relativeHeading(line, element.level, i === element.start));
|
|
32
|
+
}
|
|
33
|
+
return sha256(normalize(kept));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function relativeHeading(line: string, baseLevel: number, isOwnHeading: boolean): string {
|
|
37
|
+
const match = /^(#{1,6}) (.*)$/.exec(line);
|
|
38
|
+
if (match?.[1] === undefined || match[2] === undefined) return line;
|
|
39
|
+
const text = isOwnHeading ? match[2].replace(/^(ADDED|MODIFIED|REMOVED) /, "") : match[2];
|
|
40
|
+
return `${"#".repeat(match[1].length - baseLevel + 1)} ${text}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalize(lines: string[]): string {
|
|
44
|
+
const out: string[] = [];
|
|
45
|
+
for (const raw of lines) {
|
|
46
|
+
const line = raw.trimEnd();
|
|
47
|
+
if (line === "" && (out.length === 0 || out[out.length - 1] === "")) continue;
|
|
48
|
+
out.push(line);
|
|
49
|
+
}
|
|
50
|
+
while (out.length > 0 && out[out.length - 1] === "") out.pop();
|
|
51
|
+
return out.join("\n");
|
|
52
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Element IDs: `<KIND>-<name>` or `<KIND>-<scope>.<name>`, named and never reused. */
|
|
2
|
+
|
|
3
|
+
export const KINDS = ["GOAL", "CTX", "ENT", "UC", "PORT", "ADP", "FLOW", "MOD", "RULE", "VIEW", "XC", "REQ", "SCN", "ADR"] as const;
|
|
4
|
+
export type Kind = (typeof KINDS)[number];
|
|
5
|
+
|
|
6
|
+
/** Kinds that describe code, and so carry a managed planned/built status. */
|
|
7
|
+
export const CODE_KINDS: readonly Kind[] = ["ENT", "UC", "PORT", "ADP", "FLOW", "MOD"];
|
|
8
|
+
/** Kinds whose elements carry a managed status: code kinds and scenarios. */
|
|
9
|
+
export const STATUS_KINDS: readonly Kind[] = [...CODE_KINDS, "SCN"];
|
|
10
|
+
/** Kinds that must name their context or capability as a scope. */
|
|
11
|
+
export const SCOPED_KINDS: readonly Kind[] = ["ENT", "UC", "PORT", "ADP", "FLOW", "REQ", "SCN"];
|
|
12
|
+
|
|
13
|
+
const WORD = "[a-z0-9]+(?:-[a-z0-9]+)*";
|
|
14
|
+
export const ID_PATTERN = `(?:${KINDS.join("|")})-${WORD}(?:\\.${WORD})?`;
|
|
15
|
+
|
|
16
|
+
const EXACT = new RegExp(`^${ID_PATTERN}$`);
|
|
17
|
+
const ANYWHERE = new RegExp(`(?<![A-Za-z0-9_-])${ID_PATTERN}(?![A-Za-z0-9_-])`, "g");
|
|
18
|
+
|
|
19
|
+
export function isId(text: string): boolean {
|
|
20
|
+
return EXACT.test(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function kindOf(id: string): Kind {
|
|
24
|
+
return id.slice(0, id.indexOf("-")) as Kind;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The context or capability an ID belongs to, if it has one. */
|
|
28
|
+
export function scopeOf(id: string): string | undefined {
|
|
29
|
+
const rest = id.slice(id.indexOf("-") + 1);
|
|
30
|
+
const dot = rest.indexOf(".");
|
|
31
|
+
return dot === -1 ? undefined : rest.slice(0, dot);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every ID mentioned in the text, once each, in order of first mention. */
|
|
35
|
+
export function findIds(text: string): string[] {
|
|
36
|
+
return [...new Set(text.match(ANYWHERE) ?? [])];
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** The append-only journal: one JSON object per line, written only by the CLI. */
|
|
2
|
+
|
|
3
|
+
export interface JournalEntry {
|
|
4
|
+
at: string;
|
|
5
|
+
event: string;
|
|
6
|
+
by: string;
|
|
7
|
+
[field: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Events other tools and skills record, with the fields each requires. */
|
|
11
|
+
export const RECORDED_EVENTS: Record<string, string[]> = {
|
|
12
|
+
rethink: ["level", "kind", "summary", "wrong", "caught_by"],
|
|
13
|
+
disposition: ["finding", "disposition", "reason", "review"],
|
|
14
|
+
review: ["review", "round", "blockers", "concerns"],
|
|
15
|
+
override: ["gate", "reason"],
|
|
16
|
+
bug: ["summary", "cause"],
|
|
17
|
+
verification: ["result", "summary"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const FIELD_VALUES: Record<string, string[]> = {
|
|
21
|
+
level: ["task", "change", "feature", "architecture", "vision"],
|
|
22
|
+
kind: ["defect", "discovery"],
|
|
23
|
+
disposition: ["fixed", "waived", "deferred", "rejected"],
|
|
24
|
+
review: ["design", "plan", "code"],
|
|
25
|
+
result: ["accepted", "sent-back"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function journalPath(folder: string): string {
|
|
29
|
+
return `${folder}/journal.jsonl`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseJournal(text: string | undefined): { entries: JournalEntry[]; badLines: number[] } {
|
|
33
|
+
const entries: JournalEntry[] = [];
|
|
34
|
+
const badLines: number[] = [];
|
|
35
|
+
(text ?? "").split("\n").forEach((line, index) => {
|
|
36
|
+
if (line.trim() === "") return;
|
|
37
|
+
try {
|
|
38
|
+
const entry = JSON.parse(line) as JournalEntry;
|
|
39
|
+
if (typeof entry.event === "string" && typeof entry.at === "string") entries.push(entry);
|
|
40
|
+
else badLines.push(index + 1);
|
|
41
|
+
} catch {
|
|
42
|
+
badLines.push(index + 1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return { entries, badLines };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function serializeEntry(entry: JournalEntry): string {
|
|
49
|
+
return `${JSON.stringify(entry)}\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Problems with an event a caller asks to record; empty when it can be recorded. */
|
|
53
|
+
export function recordedEventProblems(event: string, fields: Record<string, string>): string[] {
|
|
54
|
+
const required = RECORDED_EVENTS[event];
|
|
55
|
+
if (required === undefined) return [`unknown event "${event}"; recordable events: ${Object.keys(RECORDED_EVENTS).join(", ")}`];
|
|
56
|
+
const problems = required.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}`);
|
|
57
|
+
for (const [field, allowed] of Object.entries(FIELD_VALUES)) {
|
|
58
|
+
const value = fields[field];
|
|
59
|
+
if (value !== undefined && required.includes(field) && !allowed.includes(value)) problems.push(`${field} must be one of ${allowed.join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
return problems;
|
|
62
|
+
}
|