@printpractical/cruze 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/adapters/inbound/cli/argument_files.js +29 -0
- package/dist/adapters/inbound/cli/cli_context.js +1 -0
- package/dist/adapters/inbound/cli/command_options.js +20 -0
- package/dist/adapters/inbound/cli/commands/journal.js +3 -2
- package/dist/adapters/inbound/cli/commands/progress.js +7 -3
- package/dist/adapters/inbound/cli/commands/review.js +3 -2
- package/dist/adapters/inbound/cli/commands/status.js +3 -0
- package/dist/adapters/inbound/cli/commands.js +32 -20
- package/dist/adapters/inbound/cli/parse_command.js +8 -2
- package/dist/adapters/inbound/cli/run_cli.js +9 -5
- package/dist/adapters/outbound/node_project_files.js +2 -1
- package/dist/app/use_cases/journal_events.js +12 -4
- package/dist/app/use_cases/project_status.js +8 -2
- package/dist/app/use_cases/record_progress.js +17 -0
- package/dist/domain/status/work_status.js +1 -3
- package/package.json +1 -1
- package/skills/about/SKILL.md +1 -1
- package/skills/envision/SKILL.md +1 -1
- package/skills/envision/feature.md +1 -0
- package/skills/formats/reference/durable-docs.md +1 -1
- package/skills/land/SKILL.md +1 -0
- package/skills/realign/notes/0.0.4.md +27 -0
- package/skills/realign/notes.md +1 -0
- package/skills/rethink/levels.md +1 -1
- package/src/adapters/inbound/cli/argument_files.ts +29 -0
- package/src/adapters/inbound/cli/cli_context.ts +1 -0
- package/src/adapters/inbound/cli/command_options.ts +20 -0
- package/src/adapters/inbound/cli/commands/journal.ts +3 -2
- package/src/adapters/inbound/cli/commands/progress.ts +7 -3
- package/src/adapters/inbound/cli/commands/review.ts +3 -2
- package/src/adapters/inbound/cli/commands/status.ts +3 -0
- package/src/adapters/inbound/cli/commands.ts +32 -20
- package/src/adapters/inbound/cli/parse_command.ts +8 -2
- package/src/adapters/inbound/cli/run_cli.ts +9 -5
- package/src/adapters/outbound/node_project_files.ts +2 -1
- package/src/app/use_cases/journal_events.ts +12 -4
- package/src/app/use_cases/project_status.ts +8 -2
- package/src/app/use_cases/record_progress.ts +17 -0
- package/src/domain/status/work_status.ts +1 -3
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ It works with any agent that supports the [Agent Skills](https://agentskills.io)
|
|
|
10
10
|
|
|
11
11
|
## Status
|
|
12
12
|
|
|
13
|
-
Version 0.0.
|
|
13
|
+
Version 0.0.4 is the fourth preview. It fixes what the first projects on 0.0.3 ran into: commands print one format on stdout and refuse options they don't take, overlap checks count only work in flight, and the feature map can be corrected when a feature's decisions change it. Early development. The CLI is complete for the V1 lifecycle. The document formats ship as the `cruze-formats` skill, the standards an agent designs and codes to ship as knowledge skills, and the whole workflow, from explore to land, ships as workflow skills. Claude Code is the agent tested so far.
|
|
14
14
|
|
|
15
15
|
## Why
|
|
16
16
|
|
|
@@ -35,7 +35,7 @@ The skills run `cruze` commands, so the agent needs `cruze` on its path. Install
|
|
|
35
35
|
From a GitHub Release, without npm. Each release attaches the packed package:
|
|
36
36
|
|
|
37
37
|
```sh
|
|
38
|
-
npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.
|
|
38
|
+
npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.4/printpractical-cruze-0.0.4.tgz
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
From npm, once a version is published there:
|
|
@@ -47,7 +47,7 @@ npm install -g @printpractical/cruze
|
|
|
47
47
|
From the git repository, at a tag or `#main`. This needs git and access to the repository. npm 11 needs `--install-links` for a global git install, and the first `cruze` command builds the CLI, which takes a moment:
|
|
48
48
|
|
|
49
49
|
```sh
|
|
50
|
-
npm install -g --install-links github:PrintPractical/cruze#v0.0.
|
|
50
|
+
npm install -g --install-links github:PrintPractical/cruze#v0.0.4
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
### 2. Set up a repository
|
|
@@ -61,7 +61,7 @@ cruze init
|
|
|
61
61
|
When you didn't install from npm, tell `init` where Cruze came from, so the CI it writes runs the same source instead of the npm package:
|
|
62
62
|
|
|
63
63
|
```sh
|
|
64
|
-
cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.
|
|
64
|
+
cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.4/printpractical-cruze-0.0.4.tgz
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
`init` asks for the project name, and which coding agent you use: `claude` for Claude Code, or `other` for any agent that reads `AGENTS.md` and `.agents/skills/`, such as Codex or OpenCode. It doesn't ask when you pass `--agent claude` or `--agent other`, or when the repository already has `CLAUDE.md` or `.claude/`. With `--yes`, or without a terminal, it sets up Claude Code. Then it creates:
|
|
@@ -79,7 +79,7 @@ cruze init --package https://github.com/PrintPractical/cruze/releases/download/v
|
|
|
79
79
|
|
|
80
80
|
Existing files are never overwritten, so it is safe to run in an existing repository.
|
|
81
81
|
|
|
82
|
-
The CLI writes
|
|
82
|
+
The CLI writes its result to stdout: JSON when stdout isn't a terminal, and text on a terminal. Pass `--json` to get JSON on a terminal, or `--text` to get text through a pipe, as in `cruze journal list --text | grep review`. Errors and notes go to stderr.
|
|
83
83
|
|
|
84
84
|
## Upgrading
|
|
85
85
|
|
|
@@ -126,7 +126,7 @@ Skills call these at fixed points, and CI runs `check` and `trace` on every push
|
|
|
126
126
|
| `cruze status --overlap` | Lists changes on other branches that touch the same elements as this branch's change |
|
|
127
127
|
| `cruze new <kind>` | Creates a project document (`vision`, `glossary`, `architecture`, `roadmap`) or work (`feature`, `change`, `adr`, `note`) from its template, with a dated ID that is never reused |
|
|
128
128
|
| `cruze task done <T#>` | Ticks a task with its commit; `cruze task reopen` unticks one a rethink changed, and `cruze task deviation` records a small departure from the plan |
|
|
129
|
-
| `cruze features <add\|drop>` | Edits the
|
|
129
|
+
| `cruze features <add\|update\|drop>` | Edits the feature map: adds or drops a future feature, or corrects a summary |
|
|
130
130
|
| `cruze roadmap prune` | Clears landed items from the roadmap's status when a release closes |
|
|
131
131
|
| `cruze trace` | Fails when a delivered scenario has no test carrying its ID; `--all` checks every built scenario |
|
|
132
132
|
| `cruze check` | Enforces the layer rules and the file budgets; `--ci` also fails on budget warnings |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { UsageError } from "./cli_context.js";
|
|
4
|
+
|
|
5
|
+
// Files a person or agent names on the command line, such as a scratch file of review blockers or
|
|
6
|
+
// the path for an exported bundle. They may be anywhere, unlike project files, and relative paths
|
|
7
|
+
// resolve against the working directory.
|
|
8
|
+
|
|
9
|
+
export async function readArgumentFile(path ) {
|
|
10
|
+
try {
|
|
11
|
+
return await readFile(resolve(path), "utf8");
|
|
12
|
+
} catch (error) {
|
|
13
|
+
throw new UsageError(`cannot read ${path}: ${reason(error)}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function writeArgumentFile(path , text ) {
|
|
18
|
+
try {
|
|
19
|
+
await mkdir(dirname(resolve(path)), { recursive: true });
|
|
20
|
+
await writeFile(resolve(path), text, "utf8");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new UsageError(`cannot write ${path}: ${reason(error)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function reason(error ) {
|
|
27
|
+
if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { UsageError } from "./cli_context.js";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
/** Options every command takes. */
|
|
5
|
+
const GLOBAL_OPTIONS = ["json", "text", "help", "version"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Refuses an option the command doesn't take, rather than ignoring it, so a caller never gets
|
|
9
|
+
* wider output than it asked for. An unknown subcommand is left to the handler to report.
|
|
10
|
+
*/
|
|
11
|
+
export function refuseUnknownOptions(name , args , command , given ) {
|
|
12
|
+
const sub = Array.isArray(command.options) ? undefined : args[0];
|
|
13
|
+
const taken = Array.isArray(command.options) ? command.options : sub === undefined ? undefined : (command.options )[sub];
|
|
14
|
+
if (taken === undefined) return;
|
|
15
|
+
const refused = given.filter((option) => !GLOBAL_OPTIONS.includes(option) && !taken.includes(option));
|
|
16
|
+
if (refused.length === 0) return;
|
|
17
|
+
const which = sub === undefined ? `cruze ${name}` : `cruze ${name} ${sub}`;
|
|
18
|
+
const takes = taken.length === 0 ? "it takes no options" : `it takes ${taken.map((o) => `--${o}`).join(", ")}`;
|
|
19
|
+
throw new UsageError(`${which} doesn't take ${refused.map((o) => `--${o}`).join(", ")}; ${takes}`);
|
|
20
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { exportFeedback, listEvents, recordEvent } from "../../../../app/use_cases/journal_events.js";
|
|
2
|
+
import { writeArgumentFile } from "../argument_files.js";
|
|
2
3
|
import { UsageError, requireArgs, } from "../cli_context.js";
|
|
3
4
|
|
|
4
5
|
export async function runJournal(context , args , options ) {
|
|
@@ -14,7 +15,7 @@ export async function runJournal(context , args , options
|
|
|
14
15
|
return { json: entry, human: `Recorded ${entry.event}.` };
|
|
15
16
|
}
|
|
16
17
|
if (args[0] === "list") {
|
|
17
|
-
const entries = await listEvents(context, options.event);
|
|
18
|
+
const entries = await listEvents(context, options.event, options.item);
|
|
18
19
|
return { json: entries, human: entries.map((e) => `${e.at} ${e.event} ${JSON.stringify({ ...e, at: undefined, event: undefined, by: undefined })}`).join("\n") || "No entries." };
|
|
19
20
|
}
|
|
20
21
|
throw new UsageError(`cruze journal takes add or list, not "${args[0]}"`);
|
|
@@ -24,7 +25,7 @@ export async function runFeedback(context , args , options
|
|
|
24
25
|
if (args[0] !== "export") throw new UsageError("usage: cruze feedback export [--out <file>] [--no-redact]");
|
|
25
26
|
const bundle = await exportFeedback(context, options.redact);
|
|
26
27
|
if (options.out !== undefined) {
|
|
27
|
-
await
|
|
28
|
+
await writeArgumentFile(options.out, `${JSON.stringify(bundle, null, 2)}\n`);
|
|
28
29
|
return { json: { out: options.out, entries: bundle.entries.length }, human: `Wrote ${bundle.entries.length} entries to ${options.out}.` };
|
|
29
30
|
}
|
|
30
31
|
return { json: bundle, human: `${bundle.entries.length} journal entries${bundle.redacted ? ", redacted" : ""}.` };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addFutureFeature, completeTask, dropFutureFeature, pruneRoadmap, recordDeviation, reopenTask } from "../../../../app/use_cases/record_progress.js";
|
|
1
|
+
import { addFutureFeature, completeTask, dropFutureFeature, updateFeatureSummary, pruneRoadmap, recordDeviation, reopenTask } from "../../../../app/use_cases/record_progress.js";
|
|
2
2
|
import { abandonWork } from "../../../../app/use_cases/abandon_work.js";
|
|
3
3
|
import { UsageError, requireArgs, requireOption, } from "../cli_context.js";
|
|
4
4
|
|
|
@@ -23,17 +23,21 @@ export async function runTask(context , args , options
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export async function runFeatures(context , args , options ) {
|
|
26
|
-
requireArgs(args, ["add|drop", "slug"]);
|
|
26
|
+
requireArgs(args, ["add|update|drop", "slug"]);
|
|
27
27
|
if (args[0] === "add") {
|
|
28
28
|
const goals = (options.goals ?? "").split(",").map((g) => g.trim()).filter((g) => g !== "");
|
|
29
29
|
const report = await addFutureFeature(context, args[1] ?? "", requireOption(options.summary, "--summary"), goals);
|
|
30
30
|
return { json: report, human: `Added ${report.feature} to the future list.` };
|
|
31
31
|
}
|
|
32
|
+
if (args[0] === "update") {
|
|
33
|
+
const report = await updateFeatureSummary(context, args[1] ?? "", requireOption(options.summary, "--summary"));
|
|
34
|
+
return { json: report, human: `Updated the summary of ${report.feature} on the ${report.list === "Future" ? "future" : "implemented"} list.` };
|
|
35
|
+
}
|
|
32
36
|
if (args[0] === "drop") {
|
|
33
37
|
const report = await dropFutureFeature(context, args[1] ?? "", requireOption(options.reason, "--reason"));
|
|
34
38
|
return { json: report, human: `Dropped ${report.feature} from the future list.` };
|
|
35
39
|
}
|
|
36
|
-
throw new UsageError(`cruze features takes add or drop, not "${args[0]}"`);
|
|
40
|
+
throw new UsageError(`cruze features takes add, update or drop, not "${args[0]}"`);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export async function runRoadmap(context , args ) {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { runReview } from "../../../../app/use_cases/run_review.js";
|
|
2
|
+
import { readArgumentFile } from "../argument_files.js";
|
|
2
3
|
import { UsageError, requireArgs, } from "../cli_context.js";
|
|
3
4
|
|
|
4
5
|
export async function runReviewCommand(context , args , options ) {
|
|
5
6
|
requireArgs(args, ["role"]);
|
|
6
7
|
const round = options.round === undefined ? undefined : Number(options.round);
|
|
7
8
|
if (round !== undefined && round !== 1 && round !== 2) throw new UsageError("--round is 1 or 2");
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
if (options.blockers !== undefined && round !== 2) throw new UsageError("--blockers goes with --round 2");
|
|
10
|
+
const blockers = options.blockers === undefined ? undefined : await readArgumentFile(options.blockers);
|
|
10
11
|
const report = await runReview(context, {
|
|
11
12
|
role: args[0] ?? "",
|
|
12
13
|
...(options.item === undefined ? {} : { item: options.item }),
|
|
@@ -3,6 +3,9 @@ import { checkBuildGate, findOverlaps, showStatus, suggestNext } from "../../../
|
|
|
3
3
|
import { UsageError, } from "../cli_context.js";
|
|
4
4
|
|
|
5
5
|
export async function runStatus(context , _args , options ) {
|
|
6
|
+
if (options.override !== undefined && options.gate === undefined) throw new UsageError("--override goes with --gate build");
|
|
7
|
+
if (options.change !== undefined && !options.overlap) throw new UsageError("--change goes with --overlap");
|
|
8
|
+
if (options.gate !== undefined && options.overlap) throw new UsageError("--gate and --overlap are alternatives");
|
|
6
9
|
if (options.gate !== undefined) {
|
|
7
10
|
if (options.gate !== "build") throw new UsageError(`unknown gate "${options.gate}"; the only gate is build`);
|
|
8
11
|
const report = await checkBuildGate(context, options.override);
|
|
@@ -9,25 +9,36 @@ import { runAbandon, runFeatures, runRoadmap, runTask } from "./commands/progres
|
|
|
9
9
|
import { runReviewCommand } from "./commands/review.js";
|
|
10
10
|
import { runNext, runStatus } from "./commands/status.js";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
12
|
+
/** A command, and the options it takes: one list, or one per subcommand for a command that has them. */
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export const COMMANDS = {
|
|
20
|
+
init: { handler: runInit, options: ["name", "yes", "agent", "package"], usage: "init [--name <name>] [--yes] [--agent claude|other] [--package <npm spec>] set up this repository and install the skills" },
|
|
21
|
+
install: { handler: runInstall, options: ["agent"], usage: "install [--agent <agent>] install or update the Cruze skills" },
|
|
22
|
+
validate: { handler: runValidate, options: [], usage: "validate check every document against the formats" },
|
|
23
|
+
status: { handler: runStatus, options: ["gate", "override", "overlap", "change"], usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
|
|
24
|
+
next: { handler: runNext, options: [], usage: "next the step to run next on this branch, and why" },
|
|
25
|
+
approve: { handler: runApprove, options: ["replan", "rebase"], usage: "approve <vision|architecture|roadmap|ref|path> [--replan <ID>...] [--rebase <ID>...] record your approval of a document" },
|
|
26
|
+
new: {
|
|
27
|
+
handler: runNew,
|
|
28
|
+
options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title"] },
|
|
29
|
+
usage: "new <vision|glossary|architecture|roadmap> | new <feature|change|adr|note> <slug> --title <title> [--feature <ref>] [--roadmap <slug>]",
|
|
30
|
+
},
|
|
31
|
+
task: { handler: runTask, options: { done: ["change", "commit"], deviation: ["change"], reopen: ["change"] }, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
|
|
32
|
+
features: { handler: runFeatures, options: { add: ["summary", "goals"], update: ["summary"], drop: ["reason"] }, usage: "features <add|update|drop> <slug> [--summary <text>] [--goals <ids>] [--reason <text>]" },
|
|
33
|
+
roadmap: { handler: runRoadmap, options: { prune: [] }, usage: "roadmap prune clear landed items from the roadmap status when a release closes" },
|
|
34
|
+
journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>]>" },
|
|
35
|
+
review: { handler: runReviewCommand, options: ["item", "round", "blockers", "base"], usage: "review <role> [--item <ref>] [--round 1|2 --blockers <file>] [--base <commit>] run a role in a fresh agent context" },
|
|
36
|
+
feedback: { handler: runFeedback, options: { export: ["out", "no-redact"] }, usage: "feedback export [--out <file>] [--no-redact]" },
|
|
37
|
+
trace: { handler: runTrace, options: ["all", "change"], usage: "trace [--all] [--change <ref>] link scenarios to the tests that prove them" },
|
|
38
|
+
check: { handler: runCheck, options: ["file", "ci"], usage: "check [<file>...] [--ci] enforce layer rules and file budgets" },
|
|
39
|
+
abandon: { handler: runAbandon, options: ["reason"], usage: "abandon <ref> --reason <text> archive a feature or standalone change that stops for good" },
|
|
40
|
+
land: { handler: runLand, options: ["override"], usage: "land [<ref>] [--override <reason>] merge a verified change into the living docs" },
|
|
41
|
+
realign: { handler: runRealign, options: { status: ["full"], done: ["change"] }, usage: "realign <status [--full]|done [--change <ref>]> what the installed Cruze expects of existing code, and recording that the code meets it" },
|
|
31
42
|
};
|
|
32
43
|
|
|
33
44
|
export const USAGE = `Usage: cruze <command> [options]
|
|
@@ -35,4 +46,5 @@ export const USAGE = `Usage: cruze <command> [options]
|
|
|
35
46
|
Commands:
|
|
36
47
|
${Object.values(COMMANDS).map((c) => ` ${c.usage}`).join("\n")}
|
|
37
48
|
|
|
38
|
-
Every command prints
|
|
49
|
+
Every command prints its result on stdout: JSON when stdout isn't a terminal or with --json,
|
|
50
|
+
and text on a terminal or with --text. Errors and notes go to stderr.`;
|
|
@@ -5,6 +5,8 @@ import { UsageError, } from "./cli_context.js";
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
|
|
@@ -15,11 +17,13 @@ export function parseCommand(argv ) {
|
|
|
15
17
|
parsed = parseArgs({
|
|
16
18
|
args: argv,
|
|
17
19
|
allowPositionals: true,
|
|
20
|
+
tokens: true,
|
|
18
21
|
options: {
|
|
19
22
|
name: { type: "string" },
|
|
20
23
|
yes: { type: "boolean", short: "y", default: false },
|
|
21
24
|
agent: { type: "string", multiple: true, default: [] },
|
|
22
25
|
json: { type: "boolean", default: false },
|
|
26
|
+
text: { type: "boolean", default: false },
|
|
23
27
|
version: { type: "boolean", short: "v", default: false },
|
|
24
28
|
help: { type: "boolean", short: "h", default: false },
|
|
25
29
|
title: { type: "string" },
|
|
@@ -53,13 +57,14 @@ export function parseCommand(argv ) {
|
|
|
53
57
|
} catch (error) {
|
|
54
58
|
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
55
59
|
}
|
|
56
|
-
const { values, positionals } = parsed;
|
|
60
|
+
const { values, positionals, tokens } = parsed;
|
|
57
61
|
const optional = (key , value ) =>
|
|
58
62
|
value === undefined ? {} : ({ [key]: value } );
|
|
59
63
|
const options = {
|
|
60
64
|
yes: values.yes,
|
|
61
65
|
agent: values.agent,
|
|
62
66
|
json: values.json,
|
|
67
|
+
text: values.text,
|
|
63
68
|
set: values.set,
|
|
64
69
|
redact: !values["no-redact"],
|
|
65
70
|
all: values.all,
|
|
@@ -88,5 +93,6 @@ export function parseCommand(argv ) {
|
|
|
88
93
|
...optional("base", values.base),
|
|
89
94
|
...optional("blockers", values.blockers),
|
|
90
95
|
};
|
|
91
|
-
|
|
96
|
+
const given = [...new Set(tokens.flatMap((token) => (token.kind === "option" ? [token.name] : [])))];
|
|
97
|
+
return { words: positionals, options, given, help: values.help, version: values.version };
|
|
92
98
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { versionNotice } from "../../../app/use_cases/record_version.js";
|
|
2
2
|
import { CruzeError } from "../../../domain/cruze_error.js";
|
|
3
3
|
import { UsageError } from "./cli_context.js";
|
|
4
|
+
import { refuseUnknownOptions } from "./command_options.js";
|
|
4
5
|
import { COMMANDS, USAGE } from "./commands.js";
|
|
5
6
|
import { parseCommand } from "./parse_command.js";
|
|
6
7
|
|
|
@@ -16,15 +17,17 @@ const NO_VERSION_NOTICE = new Set(["init", "install", "validate"]);
|
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Runs one CLI invocation and returns the exit code: 0 on success, 1 when a check fails or
|
|
19
|
-
* an expected error occurs, 2 for usage errors.
|
|
20
|
-
*
|
|
20
|
+
* an expected error occurs, 2 for usage errors. The result goes to stdout in one format: JSON for an agent,
|
|
21
|
+
* when stdout isn't a terminal or with --json, and text for a person, on a terminal or with --text.
|
|
22
|
+
* stderr carries errors, and a note when the project's skills don't match this CLI, even when the command fails.
|
|
21
23
|
*/
|
|
22
24
|
export async function runCli(argv , context , terminal ) {
|
|
23
|
-
|
|
25
|
+
// Read from argv too, so a usage error is reported in the format asked for.
|
|
26
|
+
const json = argv.includes("--json") || (!terminal.stdoutIsTerminal && !argv.includes("--text"));
|
|
24
27
|
let notice ;
|
|
25
28
|
try {
|
|
26
29
|
const parsed = parseCommand(argv);
|
|
27
|
-
json
|
|
30
|
+
if (parsed.options.json && parsed.options.text) throw new UsageError("--json and --text are alternatives");
|
|
28
31
|
if (parsed.version) {
|
|
29
32
|
terminal.stdout(context.bundle.version);
|
|
30
33
|
return 0;
|
|
@@ -36,10 +39,11 @@ export async function runCli(argv , context , terminal
|
|
|
36
39
|
}
|
|
37
40
|
const command = COMMANDS[name];
|
|
38
41
|
if (command === undefined) throw new UsageError(`Unknown command: ${name}`);
|
|
42
|
+
refuseUnknownOptions(name, args, command, parsed.given);
|
|
39
43
|
if (!NO_VERSION_NOTICE.has(name)) notice = await versionNotice(context.files, context.bundle.version);
|
|
40
44
|
const result = await command.handler(context, args, parsed.options);
|
|
41
|
-
terminal.stderr(result.human);
|
|
42
45
|
if (json) terminal.stdout(JSON.stringify(result.json, null, 2));
|
|
46
|
+
else if (result.human !== "") terminal.stdout(result.human);
|
|
43
47
|
return result.failed === true ? 1 : 0;
|
|
44
48
|
} catch (error) {
|
|
45
49
|
if (error instanceof UsageError) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFile, lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { CruzeError } from "../../domain/cruze_error.js";
|
|
4
5
|
|
|
5
6
|
/** Project files on the local disk, confined to one root directory. */
|
|
6
7
|
export class NodeProjectFiles {
|
|
@@ -92,7 +93,7 @@ export class NodeProjectFiles {
|
|
|
92
93
|
assertInsideRoot(absolute ) {
|
|
93
94
|
const fromRoot = relative(this.root, absolute);
|
|
94
95
|
if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
|
|
95
|
-
throw new
|
|
96
|
+
throw new CruzeError("outside-project", `${absolute} is outside the project at ${this.root}`);
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
|
-
import { recordedEventProblems, recordedFields, } from "../../domain/journal.js";
|
|
2
|
+
import { journalPath, parseJournal, recordedEventProblems, recordedFields, } from "../../domain/journal.js";
|
|
3
3
|
import { resolveArtifact } from "../../domain/project/artifact_ref.js";
|
|
4
4
|
import { appendJournal, loadView, } from "../project_context.js";
|
|
5
5
|
|
|
@@ -18,9 +18,17 @@ export async function recordEvent(deps , event , fields
|
|
|
18
18
|
return appendJournal(deps, folder, event, { ...recorded, cruze: deps.bundle.version });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/** The journal, or one feature's or change's part of it: its own journal, and a feature's changes' too. */
|
|
22
|
+
export async function listEvents(deps , event , item ) {
|
|
23
|
+
const view = await loadView(deps.files);
|
|
24
|
+
let entries = view.journal;
|
|
25
|
+
if (item !== undefined) {
|
|
26
|
+
const resolved = resolveArtifact(view, item).item;
|
|
27
|
+
if (resolved === undefined) throw new CruzeError("not-found", `"${item}" is not a feature or change`);
|
|
28
|
+
const parts = [resolved, ...view.items.filter((i) => resolved.kind === "feature" && i.featureRef === resolved.ref)];
|
|
29
|
+
entries = parts.flatMap((part) => parseJournal(view.snapshot.get(journalPath(part.folder))).entries).sort((a, b) => a.at.localeCompare(b.at));
|
|
30
|
+
}
|
|
31
|
+
return event === undefined ? entries : entries.filter((entry) => entry.event === event);
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.js";
|
|
2
2
|
import { readProgress } from "../../domain/project/progress.js";
|
|
3
3
|
import { buildProjectView } from "../../domain/project/project_view.js";
|
|
4
|
+
import { findWorkItem, } from "../../domain/project/work_items.js";
|
|
4
5
|
import { nextSteps, } from "../../domain/status/next_step.js";
|
|
5
6
|
import { activeChange, buildGateReasons, projectStatus, touchedIds, } from "../../domain/status/work_status.js";
|
|
6
7
|
import { appendJournal, loadView, } from "../project_context.js";
|
|
@@ -59,10 +60,11 @@ export async function findOverlaps(deps , ref )
|
|
|
59
60
|
const overlaps = [];
|
|
60
61
|
for (const other of await deps.repository.otherBranches()) {
|
|
61
62
|
const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat([...(await deps.repository.filesOnBranch(other, ".cruze"))])));
|
|
62
|
-
// Work in flight on a branch is the change bound to it.
|
|
63
|
+
// Work in flight on a branch is the change bound to it that hasn't landed, there or here.
|
|
63
64
|
const boundThere = theirs.items.filter((i) => {
|
|
64
65
|
const bound = readProgress(i.doc).branch;
|
|
65
|
-
|
|
66
|
+
const here = findWorkItem(view.items, i.ref);
|
|
67
|
+
return !landed(i) && (here === undefined || !landed(here)) && i.ref !== change.ref && bound !== undefined && (other === bound || other.endsWith(`/${bound}`));
|
|
66
68
|
});
|
|
67
69
|
for (const item of boundThere) {
|
|
68
70
|
const shared = [...touchedIds(theirs, item)].filter((id) => mine.has(id));
|
|
@@ -71,3 +73,7 @@ export async function findOverlaps(deps , ref )
|
|
|
71
73
|
}
|
|
72
74
|
return { change: change.ref, overlaps };
|
|
73
75
|
}
|
|
76
|
+
|
|
77
|
+
function landed(item ) {
|
|
78
|
+
return item.archived || readProgress(item.doc).landed !== undefined;
|
|
79
|
+
}
|
|
@@ -58,6 +58,23 @@ export async function addFutureFeature(deps , slug , summary
|
|
|
58
58
|
return { feature: slug };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Replaces a feature's summary on the feature map, in the future list or the implemented list,
|
|
63
|
+
* when a decision changed what the feature delivers. Its goals or release stay as they are.
|
|
64
|
+
*/
|
|
65
|
+
export async function updateFeatureSummary(deps , slug , summary ) {
|
|
66
|
+
if (summary.trim() === "") throw new CruzeError("missing-summary", "a feature needs a summary");
|
|
67
|
+
const vision = await requireVision(deps);
|
|
68
|
+
const future = readRows(vision, "Future").find((row) => row[0] === slug);
|
|
69
|
+
const implemented = readRows(vision, "Implemented").find((row) => row[0] === slug);
|
|
70
|
+
if (future === undefined && implemented === undefined) throw new CruzeError("not-found", `${slug} is not on the feature map`);
|
|
71
|
+
const list = future !== undefined ? "Future" : "Implemented";
|
|
72
|
+
const cells = future !== undefined ? [slug, summary.trim(), future[2] ?? ""] : [slug, implemented?.[1] ?? "", summary.trim()];
|
|
73
|
+
await deps.files.writeText(PATHS.vision, upsertRow(vision, list, cells));
|
|
74
|
+
await appendJournal(deps, ".cruze", "feature-map", { action: "update", feature: slug, summary: summary.trim() });
|
|
75
|
+
return { feature: slug, list };
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
export async function dropFutureFeature(deps , slug , reason ) {
|
|
62
79
|
const vision = await requireVision(deps);
|
|
63
80
|
const { text, removed } = removeRow(vision, "Future", slug);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { evaluateApproval, } from "../approvals/evaluate.js";
|
|
2
|
-
import { findIds } from "../ids.js";
|
|
3
2
|
import { PATHS } from "../project/layout.js";
|
|
4
3
|
import { readDelta } from "../project/deltas.js";
|
|
5
4
|
import { readScope } from "../project/plan_parts.js";
|
|
@@ -89,13 +88,12 @@ export function buildGateReasons(view , branch )
|
|
|
89
88
|
});
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
/** The elements a change touches: its scope
|
|
91
|
+
/** The elements a change touches: its scope and its delta, not the elements it only cites, such as a test seam. */
|
|
93
92
|
export function touchedIds(view , change ) {
|
|
94
93
|
const feature = featureOf(view.items, change);
|
|
95
94
|
const scope = readScope(change.doc);
|
|
96
95
|
const ids = new Set([...(scope?.delivers ?? []), ...(scope?.builds ?? []), ...(scope?.removes ?? [])]);
|
|
97
96
|
const deltaDoc = feature?.doc ?? change.doc;
|
|
98
97
|
for (const id of readDelta(deltaDoc).ids) ids.add(id);
|
|
99
|
-
for (const id of findIds(change.doc.lines.join("\n"))) ids.add(id);
|
|
100
98
|
return new Set([...ids].filter((id) => !id.startsWith("RULE-")));
|
|
101
99
|
}
|
package/package.json
CHANGED
package/skills/about/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ Cruze is a spec-driven development framework. It keeps one living model of the s
|
|
|
9
9
|
|
|
10
10
|
This installation is an early preview. It has:
|
|
11
11
|
|
|
12
|
-
- The `cruze` CLI: `cruze init` sets up a repository, `cruze install` updates the skills after an upgrade, and `cruze help` lists the commands that validate, approve, check, trace and land work.
|
|
12
|
+
- The `cruze` CLI: `cruze init` sets up a repository, `cruze install` updates the skills after an upgrade, and `cruze help` lists the commands that validate, approve, check, trace and land work. Each prints its result on stdout, as JSON when stdout isn't a terminal, or as text with `--text`. A command refuses an option it doesn't take, so check `cruze help` when one is refused.
|
|
13
13
|
- `cruze-formats`, the format of every Cruze document.
|
|
14
14
|
- Knowledge skills holding the standards to design and code to: `cruze-hexagonal-design`, `cruze-behavioural-testing`, `cruze-grilling`, `cruze-domain-language`, `cruze-dependency-approval` and `cruze-research`.
|
|
15
15
|
- The workflow: `explore` (optional), `envision` for the what, `architect` for the how, `roadmap` for the order of work, then `plan`, `build`, `verify` and `land` for each change. `triage` handles bugs, `rethink` steps back from anywhere, and `next` says what to do now.
|
package/skills/envision/SKILL.md
CHANGED
|
@@ -21,7 +21,7 @@ Envision decides the what: the problem, who has it, and the behaviour that solve
|
|
|
21
21
|
- Requirements and scenarios follow `.agents/skills/cruze-formats/reference/specs.md`. Every scenario uses concrete values: real names, real inputs, exact output. The scenarios become the tests, so their values are the expected results.
|
|
22
22
|
- Keep implementation out: no modules, types, libraries or file formats unless the user names one as a constraint. When the user raises one, record it as a constraint or a settled decision and move on.
|
|
23
23
|
- The vision stays within one page, excluding the feature map.
|
|
24
|
-
- Only the CLI writes the feature map. Use `cruze features add` and `cruze features drop`.
|
|
24
|
+
- Only the CLI writes the feature map. Use `cruze features add`, `cruze features update` and `cruze features drop`.
|
|
25
25
|
|
|
26
26
|
## Next step
|
|
27
27
|
|
|
@@ -13,6 +13,7 @@ The result is a feature folder whose `feature.md` holds the intent, the settled
|
|
|
13
13
|
- **Requirements:** each behaviour the feature adds, changes or removes, and the capability it belongs to. It goes in an existing capability when the behaviour fits one; otherwise it starts a new capability.
|
|
14
14
|
- **Scenarios:** for each requirement, the main case, each edge and failure case a user will meet, and what they observe in each. Invent concrete edge cases to test the boundaries.
|
|
15
15
|
6. **Grill in rounds** until every open decision is settled. Record decisions under `## Settled decisions` and terms in the glossary as they settle.
|
|
16
|
+
- When a decision changes something the feature's summary on the feature map promises, such as a policy it names, correct the summary with `cruze features update <slug> --summary <text>`.
|
|
16
17
|
7. **Write the spec delta.** Use `ADDED` for new requirements, `MODIFIED` for a requirement whose text or scenarios change (writing its full new body with every scenario it keeps), `REMOVED` with a `Reason` and `Migration` for retired behaviour, and `ADDED CAPABILITY` for a new capability.
|
|
17
18
|
- Name every ID with glossary terms. An ID is never reused.
|
|
18
19
|
- Done when every requirement has a scenario for its main case and one for each failure a user can meet.
|
|
@@ -16,7 +16,7 @@ What the product is for. At most one page, excluding the feature map.
|
|
|
16
16
|
| `## Feature map` | Managed. `### Implemented` is a table of Feature, Release and Summary. `### Future` is a table of Feature, Summary and Goals |
|
|
17
17
|
|
|
18
18
|
- Feature-map entries are named by slug, such as `jump-hosts`. A future entry gets a folder ID only when its feature starts.
|
|
19
|
-
- Only the CLI writes the feature map. `cruze features add` puts an entry on the future list, `cruze features drop` removes one, and `land` moves a finished feature to implemented, tagged with the release in progress.
|
|
19
|
+
- Only the CLI writes the feature map. `cruze features add` puts an entry on the future list, `cruze features update` corrects an entry's summary on either list, `cruze features drop` removes one from the future list, and `land` moves a finished feature to implemented, tagged with the release in progress.
|
|
20
20
|
|
|
21
21
|
## docs/glossary.md
|
|
22
22
|
|
package/skills/land/SKILL.md
CHANGED
|
@@ -20,6 +20,7 @@ Land makes finished work part of the living model. `cruze land` merges the chang
|
|
|
20
20
|
- When they don't hold, stop and offer `rethink`.
|
|
21
21
|
4. **Run `cruze land`** and quote its report: what it merged, what is now built, and whether the work finished and was archived.
|
|
22
22
|
- A `merge-conflict` means someone changed an element this feature merged earlier. Stop and run `rethink`, which reconciles it.
|
|
23
|
+
- When the work finished, read the feature's row under `### Implemented` in `docs/vision.md` against its settled decisions and spec delta. When the summary no longer describes what landed, correct it with `cruze features update <slug> --summary <text>`.
|
|
23
24
|
- A `merge-invalid` means the merged docs, or the feature and changes being landed, wouldn't validate, such as a decision citing an ID the change removes. Nothing was written. Stop and run `rethink`.
|
|
24
25
|
5. **Update the changelog.** Under `## [Unreleased]` in `CHANGELOG.md`, add one line per user-visible behaviour the change delivers, in the user's words, under `### Added`, `### Changed` or `### Removed`. Name the behaviour, not the code, including any rule a user can now run into that no scenario covers yet.
|
|
25
26
|
6. **Update the README** when the change altered how someone installs, runs or uses the product. Run every command you add, to check it works.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Realign notes: 0.0.4
|
|
2
|
+
|
|
3
|
+
## New work only
|
|
4
|
+
|
|
5
|
+
### argument-files-anywhere: Files named on the command line may be outside the project
|
|
6
|
+
- Applies to: new work only
|
|
7
|
+
- Rule: `.agents/skills/cruze-roles/SKILL.md`, "Running a role"
|
|
8
|
+
|
|
9
|
+
### cli-output-format: The CLI prints one format on stdout, JSON for agents and text with --text
|
|
10
|
+
- Applies to: new work only
|
|
11
|
+
- Rule: `.agents/skills/cruze-about/SKILL.md`
|
|
12
|
+
|
|
13
|
+
### overlap-scope: Overlap counts only in-flight changes, and only the elements their scope and delta name
|
|
14
|
+
- Applies to: new work only
|
|
15
|
+
- Rule: `.agents/skills/cruze-plan/SKILL.md`, "Steps", step 7
|
|
16
|
+
|
|
17
|
+
### refused-options: The CLI refuses an option its command doesn't take
|
|
18
|
+
- Applies to: new work only
|
|
19
|
+
- Rule: `.agents/skills/cruze-about/SKILL.md`
|
|
20
|
+
|
|
21
|
+
### feature-map-summary: A feature's summary on the feature map follows its decisions
|
|
22
|
+
- Applies to: new work only
|
|
23
|
+
- Rule: `.agents/skills/cruze-land/SKILL.md`, "Landing a change", step 4
|
|
24
|
+
|
|
25
|
+
### journal-list-item: Journal list narrows to one feature or change with --item
|
|
26
|
+
- Applies to: new work only
|
|
27
|
+
- Rule: `.agents/skills/cruze-about/SKILL.md`
|
package/skills/realign/notes.md
CHANGED
package/skills/rethink/levels.md
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
## Vision
|
|
32
32
|
|
|
33
|
-
1. Edit `docs/vision.md` with the user: goals, non-goals or constraints. The feature map changes only through `cruze features add` and `cruze features drop`. When the problem, users or goals changed, update the overview under the title of `README.md` to match.
|
|
33
|
+
1. Edit `docs/vision.md` with the user: goals, non-goals or constraints. The feature map changes only through `cruze features add`, `cruze features update` and `cruze features drop`. When the problem, users or goals changed, update the overview under the title of `README.md` to match.
|
|
34
34
|
2. Update `docs/roadmap.md` to match, following `.agents/skills/cruze-roadmap/SKILL.md`.
|
|
35
35
|
3. For each feature in progress that served a dropped goal, the user decides: keep it, or stop it with `cruze abandon <ref> --reason "<why>"`. Abandoning archives the folder and puts the feature back on the future list; changes that already landed stay.
|
|
36
36
|
4. Re-approve with `cruze approve vision`, then `cruze approve roadmap`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { UsageError } from "./cli_context.ts";
|
|
4
|
+
|
|
5
|
+
// Files a person or agent names on the command line, such as a scratch file of review blockers or
|
|
6
|
+
// the path for an exported bundle. They may be anywhere, unlike project files, and relative paths
|
|
7
|
+
// resolve against the working directory.
|
|
8
|
+
|
|
9
|
+
export async function readArgumentFile(path: string): Promise<string> {
|
|
10
|
+
try {
|
|
11
|
+
return await readFile(resolve(path), "utf8");
|
|
12
|
+
} catch (error) {
|
|
13
|
+
throw new UsageError(`cannot read ${path}: ${reason(error)}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function writeArgumentFile(path: string, text: string): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
await mkdir(dirname(resolve(path)), { recursive: true });
|
|
20
|
+
await writeFile(resolve(path), text, "utf8");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new UsageError(`cannot write ${path}: ${reason(error)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function reason(error: unknown): string {
|
|
27
|
+
if (error instanceof Error && "code" in error && typeof error.code === "string") return error.code;
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { UsageError } from "./cli_context.ts";
|
|
2
|
+
import type { Command } from "./commands.ts";
|
|
3
|
+
|
|
4
|
+
/** Options every command takes. */
|
|
5
|
+
const GLOBAL_OPTIONS = ["json", "text", "help", "version"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Refuses an option the command doesn't take, rather than ignoring it, so a caller never gets
|
|
9
|
+
* wider output than it asked for. An unknown subcommand is left to the handler to report.
|
|
10
|
+
*/
|
|
11
|
+
export function refuseUnknownOptions(name: string, args: string[], command: Command, given: string[]): void {
|
|
12
|
+
const sub = Array.isArray(command.options) ? undefined : args[0];
|
|
13
|
+
const taken = Array.isArray(command.options) ? command.options : sub === undefined ? undefined : (command.options as Record<string, readonly string[]>)[sub];
|
|
14
|
+
if (taken === undefined) return;
|
|
15
|
+
const refused = given.filter((option) => !GLOBAL_OPTIONS.includes(option) && !taken.includes(option));
|
|
16
|
+
if (refused.length === 0) return;
|
|
17
|
+
const which = sub === undefined ? `cruze ${name}` : `cruze ${name} ${sub}`;
|
|
18
|
+
const takes = taken.length === 0 ? "it takes no options" : `it takes ${taken.map((o) => `--${o}`).join(", ")}`;
|
|
19
|
+
throw new UsageError(`${which} doesn't take ${refused.map((o) => `--${o}`).join(", ")}; ${takes}`);
|
|
20
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { exportFeedback, listEvents, recordEvent } from "../../../../app/use_cases/journal_events.ts";
|
|
2
|
+
import { writeArgumentFile } from "../argument_files.ts";
|
|
2
3
|
import { UsageError, requireArgs, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
|
|
3
4
|
|
|
4
5
|
export async function runJournal(context: CliContext, args: string[], options: Options): Promise<CommandResult> {
|
|
@@ -14,7 +15,7 @@ export async function runJournal(context: CliContext, args: string[], options: O
|
|
|
14
15
|
return { json: entry, human: `Recorded ${entry.event}.` };
|
|
15
16
|
}
|
|
16
17
|
if (args[0] === "list") {
|
|
17
|
-
const entries = await listEvents(context, options.event);
|
|
18
|
+
const entries = await listEvents(context, options.event, options.item);
|
|
18
19
|
return { json: entries, human: entries.map((e) => `${e.at} ${e.event} ${JSON.stringify({ ...e, at: undefined, event: undefined, by: undefined })}`).join("\n") || "No entries." };
|
|
19
20
|
}
|
|
20
21
|
throw new UsageError(`cruze journal takes add or list, not "${args[0]}"`);
|
|
@@ -24,7 +25,7 @@ export async function runFeedback(context: CliContext, args: string[], options:
|
|
|
24
25
|
if (args[0] !== "export") throw new UsageError("usage: cruze feedback export [--out <file>] [--no-redact]");
|
|
25
26
|
const bundle = await exportFeedback(context, options.redact);
|
|
26
27
|
if (options.out !== undefined) {
|
|
27
|
-
await
|
|
28
|
+
await writeArgumentFile(options.out, `${JSON.stringify(bundle, null, 2)}\n`);
|
|
28
29
|
return { json: { out: options.out, entries: bundle.entries.length }, human: `Wrote ${bundle.entries.length} entries to ${options.out}.` };
|
|
29
30
|
}
|
|
30
31
|
return { json: bundle, human: `${bundle.entries.length} journal entries${bundle.redacted ? ", redacted" : ""}.` };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addFutureFeature, completeTask, dropFutureFeature, pruneRoadmap, recordDeviation, reopenTask } from "../../../../app/use_cases/record_progress.ts";
|
|
1
|
+
import { addFutureFeature, completeTask, dropFutureFeature, updateFeatureSummary, pruneRoadmap, recordDeviation, reopenTask } from "../../../../app/use_cases/record_progress.ts";
|
|
2
2
|
import { abandonWork } from "../../../../app/use_cases/abandon_work.ts";
|
|
3
3
|
import { UsageError, requireArgs, requireOption, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
|
|
4
4
|
|
|
@@ -23,17 +23,21 @@ export async function runTask(context: CliContext, args: string[], options: Opti
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export async function runFeatures(context: CliContext, args: string[], options: Options): Promise<CommandResult> {
|
|
26
|
-
requireArgs(args, ["add|drop", "slug"]);
|
|
26
|
+
requireArgs(args, ["add|update|drop", "slug"]);
|
|
27
27
|
if (args[0] === "add") {
|
|
28
28
|
const goals = (options.goals ?? "").split(",").map((g) => g.trim()).filter((g) => g !== "");
|
|
29
29
|
const report = await addFutureFeature(context, args[1] ?? "", requireOption(options.summary, "--summary"), goals);
|
|
30
30
|
return { json: report, human: `Added ${report.feature} to the future list.` };
|
|
31
31
|
}
|
|
32
|
+
if (args[0] === "update") {
|
|
33
|
+
const report = await updateFeatureSummary(context, args[1] ?? "", requireOption(options.summary, "--summary"));
|
|
34
|
+
return { json: report, human: `Updated the summary of ${report.feature} on the ${report.list === "Future" ? "future" : "implemented"} list.` };
|
|
35
|
+
}
|
|
32
36
|
if (args[0] === "drop") {
|
|
33
37
|
const report = await dropFutureFeature(context, args[1] ?? "", requireOption(options.reason, "--reason"));
|
|
34
38
|
return { json: report, human: `Dropped ${report.feature} from the future list.` };
|
|
35
39
|
}
|
|
36
|
-
throw new UsageError(`cruze features takes add or drop, not "${args[0]}"`);
|
|
40
|
+
throw new UsageError(`cruze features takes add, update or drop, not "${args[0]}"`);
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export async function runRoadmap(context: CliContext, args: string[]): Promise<CommandResult> {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { runReview } from "../../../../app/use_cases/run_review.ts";
|
|
2
|
+
import { readArgumentFile } from "../argument_files.ts";
|
|
2
3
|
import { UsageError, requireArgs, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
|
|
3
4
|
|
|
4
5
|
export async function runReviewCommand(context: CliContext, args: string[], options: Options): Promise<CommandResult> {
|
|
5
6
|
requireArgs(args, ["role"]);
|
|
6
7
|
const round = options.round === undefined ? undefined : Number(options.round);
|
|
7
8
|
if (round !== undefined && round !== 1 && round !== 2) throw new UsageError("--round is 1 or 2");
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
if (options.blockers !== undefined && round !== 2) throw new UsageError("--blockers goes with --round 2");
|
|
10
|
+
const blockers = options.blockers === undefined ? undefined : await readArgumentFile(options.blockers);
|
|
10
11
|
const report = await runReview(context, {
|
|
11
12
|
role: args[0] ?? "",
|
|
12
13
|
...(options.item === undefined ? {} : { item: options.item }),
|
|
@@ -3,6 +3,9 @@ import type { ApprovalStatus } from "../../../../domain/approvals/evaluate.ts";
|
|
|
3
3
|
import { UsageError, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
|
|
4
4
|
|
|
5
5
|
export async function runStatus(context: CliContext, _args: string[], options: Options): Promise<CommandResult> {
|
|
6
|
+
if (options.override !== undefined && options.gate === undefined) throw new UsageError("--override goes with --gate build");
|
|
7
|
+
if (options.change !== undefined && !options.overlap) throw new UsageError("--change goes with --overlap");
|
|
8
|
+
if (options.gate !== undefined && options.overlap) throw new UsageError("--gate and --overlap are alternatives");
|
|
6
9
|
if (options.gate !== undefined) {
|
|
7
10
|
if (options.gate !== "build") throw new UsageError(`unknown gate "${options.gate}"; the only gate is build`);
|
|
8
11
|
const report = await checkBuildGate(context, options.override);
|
|
@@ -9,25 +9,36 @@ import { runAbandon, runFeatures, runRoadmap, runTask } from "./commands/progres
|
|
|
9
9
|
import { runReviewCommand } from "./commands/review.ts";
|
|
10
10
|
import { runNext, runStatus } from "./commands/status.ts";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
12
|
+
/** A command, and the options it takes: one list, or one per subcommand for a command that has them. */
|
|
13
|
+
export interface Command {
|
|
14
|
+
handler: Handler;
|
|
15
|
+
usage: string;
|
|
16
|
+
options: readonly string[] | Readonly<Record<string, readonly string[]>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const COMMANDS: Record<string, Command> = {
|
|
20
|
+
init: { handler: runInit, options: ["name", "yes", "agent", "package"], usage: "init [--name <name>] [--yes] [--agent claude|other] [--package <npm spec>] set up this repository and install the skills" },
|
|
21
|
+
install: { handler: runInstall, options: ["agent"], usage: "install [--agent <agent>] install or update the Cruze skills" },
|
|
22
|
+
validate: { handler: runValidate, options: [], usage: "validate check every document against the formats" },
|
|
23
|
+
status: { handler: runStatus, options: ["gate", "override", "overlap", "change"], usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
|
|
24
|
+
next: { handler: runNext, options: [], usage: "next the step to run next on this branch, and why" },
|
|
25
|
+
approve: { handler: runApprove, options: ["replan", "rebase"], usage: "approve <vision|architecture|roadmap|ref|path> [--replan <ID>...] [--rebase <ID>...] record your approval of a document" },
|
|
26
|
+
new: {
|
|
27
|
+
handler: runNew,
|
|
28
|
+
options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title"] },
|
|
29
|
+
usage: "new <vision|glossary|architecture|roadmap> | new <feature|change|adr|note> <slug> --title <title> [--feature <ref>] [--roadmap <slug>]",
|
|
30
|
+
},
|
|
31
|
+
task: { handler: runTask, options: { done: ["change", "commit"], deviation: ["change"], reopen: ["change"] }, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
|
|
32
|
+
features: { handler: runFeatures, options: { add: ["summary", "goals"], update: ["summary"], drop: ["reason"] }, usage: "features <add|update|drop> <slug> [--summary <text>] [--goals <ids>] [--reason <text>]" },
|
|
33
|
+
roadmap: { handler: runRoadmap, options: { prune: [] }, usage: "roadmap prune clear landed items from the roadmap status when a release closes" },
|
|
34
|
+
journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>]>" },
|
|
35
|
+
review: { handler: runReviewCommand, options: ["item", "round", "blockers", "base"], usage: "review <role> [--item <ref>] [--round 1|2 --blockers <file>] [--base <commit>] run a role in a fresh agent context" },
|
|
36
|
+
feedback: { handler: runFeedback, options: { export: ["out", "no-redact"] }, usage: "feedback export [--out <file>] [--no-redact]" },
|
|
37
|
+
trace: { handler: runTrace, options: ["all", "change"], usage: "trace [--all] [--change <ref>] link scenarios to the tests that prove them" },
|
|
38
|
+
check: { handler: runCheck, options: ["file", "ci"], usage: "check [<file>...] [--ci] enforce layer rules and file budgets" },
|
|
39
|
+
abandon: { handler: runAbandon, options: ["reason"], usage: "abandon <ref> --reason <text> archive a feature or standalone change that stops for good" },
|
|
40
|
+
land: { handler: runLand, options: ["override"], usage: "land [<ref>] [--override <reason>] merge a verified change into the living docs" },
|
|
41
|
+
realign: { handler: runRealign, options: { status: ["full"], done: ["change"] }, usage: "realign <status [--full]|done [--change <ref>]> what the installed Cruze expects of existing code, and recording that the code meets it" },
|
|
31
42
|
};
|
|
32
43
|
|
|
33
44
|
export const USAGE = `Usage: cruze <command> [options]
|
|
@@ -35,4 +46,5 @@ export const USAGE = `Usage: cruze <command> [options]
|
|
|
35
46
|
Commands:
|
|
36
47
|
${Object.values(COMMANDS).map((c) => ` ${c.usage}`).join("\n")}
|
|
37
48
|
|
|
38
|
-
Every command prints
|
|
49
|
+
Every command prints its result on stdout: JSON when stdout isn't a terminal or with --json,
|
|
50
|
+
and text on a terminal or with --text. Errors and notes go to stderr.`;
|
|
@@ -5,6 +5,8 @@ export interface ParsedCommand {
|
|
|
5
5
|
/** The command and, for grouped commands, its subcommand, such as `new feature`. */
|
|
6
6
|
words: string[];
|
|
7
7
|
options: Options;
|
|
8
|
+
/** The options given on the command line, by long name, so a command can refuse ones it doesn't take. */
|
|
9
|
+
given: string[];
|
|
8
10
|
help: boolean;
|
|
9
11
|
version: boolean;
|
|
10
12
|
}
|
|
@@ -15,11 +17,13 @@ export function parseCommand(argv: string[]): ParsedCommand {
|
|
|
15
17
|
parsed = parseArgs({
|
|
16
18
|
args: argv,
|
|
17
19
|
allowPositionals: true,
|
|
20
|
+
tokens: true,
|
|
18
21
|
options: {
|
|
19
22
|
name: { type: "string" },
|
|
20
23
|
yes: { type: "boolean", short: "y", default: false },
|
|
21
24
|
agent: { type: "string", multiple: true, default: [] },
|
|
22
25
|
json: { type: "boolean", default: false },
|
|
26
|
+
text: { type: "boolean", default: false },
|
|
23
27
|
version: { type: "boolean", short: "v", default: false },
|
|
24
28
|
help: { type: "boolean", short: "h", default: false },
|
|
25
29
|
title: { type: "string" },
|
|
@@ -53,13 +57,14 @@ export function parseCommand(argv: string[]): ParsedCommand {
|
|
|
53
57
|
} catch (error) {
|
|
54
58
|
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
55
59
|
}
|
|
56
|
-
const { values, positionals } = parsed;
|
|
60
|
+
const { values, positionals, tokens } = parsed;
|
|
57
61
|
const optional = <K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> =>
|
|
58
62
|
value === undefined ? {} : ({ [key]: value } as Record<K, string>);
|
|
59
63
|
const options: Options = {
|
|
60
64
|
yes: values.yes,
|
|
61
65
|
agent: values.agent,
|
|
62
66
|
json: values.json,
|
|
67
|
+
text: values.text,
|
|
63
68
|
set: values.set,
|
|
64
69
|
redact: !values["no-redact"],
|
|
65
70
|
all: values.all,
|
|
@@ -88,5 +93,6 @@ export function parseCommand(argv: string[]): ParsedCommand {
|
|
|
88
93
|
...optional("base", values.base),
|
|
89
94
|
...optional("blockers", values.blockers),
|
|
90
95
|
};
|
|
91
|
-
|
|
96
|
+
const given = [...new Set(tokens.flatMap((token) => (token.kind === "option" ? [token.name] : [])))];
|
|
97
|
+
return { words: positionals, options, given, help: values.help, version: values.version };
|
|
92
98
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { versionNotice } from "../../../app/use_cases/record_version.ts";
|
|
2
2
|
import { CruzeError } from "../../../domain/cruze_error.ts";
|
|
3
3
|
import { type CliContext, UsageError } from "./cli_context.ts";
|
|
4
|
+
import { refuseUnknownOptions } from "./command_options.ts";
|
|
4
5
|
import { COMMANDS, USAGE } from "./commands.ts";
|
|
5
6
|
import { parseCommand } from "./parse_command.ts";
|
|
6
7
|
|
|
@@ -16,15 +17,17 @@ const NO_VERSION_NOTICE = new Set(["init", "install", "validate"]);
|
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Runs one CLI invocation and returns the exit code: 0 on success, 1 when a check fails or
|
|
19
|
-
* an expected error occurs, 2 for usage errors.
|
|
20
|
-
*
|
|
20
|
+
* an expected error occurs, 2 for usage errors. The result goes to stdout in one format: JSON for an agent,
|
|
21
|
+
* when stdout isn't a terminal or with --json, and text for a person, on a terminal or with --text.
|
|
22
|
+
* stderr carries errors, and a note when the project's skills don't match this CLI, even when the command fails.
|
|
21
23
|
*/
|
|
22
24
|
export async function runCli(argv: string[], context: CliContext, terminal: Terminal): Promise<number> {
|
|
23
|
-
|
|
25
|
+
// Read from argv too, so a usage error is reported in the format asked for.
|
|
26
|
+
const json = argv.includes("--json") || (!terminal.stdoutIsTerminal && !argv.includes("--text"));
|
|
24
27
|
let notice: string | undefined;
|
|
25
28
|
try {
|
|
26
29
|
const parsed = parseCommand(argv);
|
|
27
|
-
json
|
|
30
|
+
if (parsed.options.json && parsed.options.text) throw new UsageError("--json and --text are alternatives");
|
|
28
31
|
if (parsed.version) {
|
|
29
32
|
terminal.stdout(context.bundle.version);
|
|
30
33
|
return 0;
|
|
@@ -36,10 +39,11 @@ export async function runCli(argv: string[], context: CliContext, terminal: Term
|
|
|
36
39
|
}
|
|
37
40
|
const command = COMMANDS[name];
|
|
38
41
|
if (command === undefined) throw new UsageError(`Unknown command: ${name}`);
|
|
42
|
+
refuseUnknownOptions(name, args, command, parsed.given);
|
|
39
43
|
if (!NO_VERSION_NOTICE.has(name)) notice = await versionNotice(context.files, context.bundle.version);
|
|
40
44
|
const result = await command.handler(context, args, parsed.options);
|
|
41
|
-
terminal.stderr(result.human);
|
|
42
45
|
if (json) terminal.stdout(JSON.stringify(result.json, null, 2));
|
|
46
|
+
else if (result.human !== "") terminal.stdout(result.human);
|
|
43
47
|
return result.failed === true ? 1 : 0;
|
|
44
48
|
} catch (error) {
|
|
45
49
|
if (error instanceof UsageError) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFile, lstat, mkdir, readFile, readdir, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import type { ProjectFiles } from "../../app/ports/project_files.ts";
|
|
4
|
+
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
4
5
|
|
|
5
6
|
/** Project files on the local disk, confined to one root directory. */
|
|
6
7
|
export class NodeProjectFiles implements ProjectFiles {
|
|
@@ -92,7 +93,7 @@ export class NodeProjectFiles implements ProjectFiles {
|
|
|
92
93
|
private assertInsideRoot(absolute: string): void {
|
|
93
94
|
const fromRoot = relative(this.root, absolute);
|
|
94
95
|
if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
|
|
95
|
-
throw new
|
|
96
|
+
throw new CruzeError("outside-project", `${absolute} is outside the project at ${this.root}`);
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
|
-
import { recordedEventProblems, recordedFields, type JournalEntry } from "../../domain/journal.ts";
|
|
2
|
+
import { journalPath, parseJournal, recordedEventProblems, recordedFields, type JournalEntry } from "../../domain/journal.ts";
|
|
3
3
|
import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
|
|
4
4
|
import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
|
|
5
5
|
|
|
@@ -18,9 +18,17 @@ export async function recordEvent(deps: ProjectDeps, event: string, fields: Reco
|
|
|
18
18
|
return appendJournal(deps, folder, event, { ...recorded, cruze: deps.bundle.version });
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
/** The journal, or one feature's or change's part of it: its own journal, and a feature's changes' too. */
|
|
22
|
+
export async function listEvents(deps: ProjectDeps, event?: string, item?: string): Promise<JournalEntry[]> {
|
|
23
|
+
const view = await loadView(deps.files);
|
|
24
|
+
let entries = view.journal;
|
|
25
|
+
if (item !== undefined) {
|
|
26
|
+
const resolved = resolveArtifact(view, item).item;
|
|
27
|
+
if (resolved === undefined) throw new CruzeError("not-found", `"${item}" is not a feature or change`);
|
|
28
|
+
const parts = [resolved, ...view.items.filter((i) => resolved.kind === "feature" && i.featureRef === resolved.ref)];
|
|
29
|
+
entries = parts.flatMap((part) => parseJournal(view.snapshot.get(journalPath(part.folder))).entries).sort((a, b) => a.at.localeCompare(b.at));
|
|
30
|
+
}
|
|
31
|
+
return event === undefined ? entries : entries.filter((entry) => entry.event === event);
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
export interface FeedbackExport {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CruzeError } from "../../domain/cruze_error.ts";
|
|
2
2
|
import { readProgress } from "../../domain/project/progress.ts";
|
|
3
3
|
import { buildProjectView } from "../../domain/project/project_view.ts";
|
|
4
|
+
import { findWorkItem, type WorkItem } from "../../domain/project/work_items.ts";
|
|
4
5
|
import { nextSteps, type NextReport } from "../../domain/status/next_step.ts";
|
|
5
6
|
import { activeChange, buildGateReasons, projectStatus, touchedIds, type ProjectStatus } from "../../domain/status/work_status.ts";
|
|
6
7
|
import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
|
|
@@ -59,10 +60,11 @@ export async function findOverlaps(deps: ProjectDeps, ref?: string): Promise<{ c
|
|
|
59
60
|
const overlaps: Overlap[] = [];
|
|
60
61
|
for (const other of await deps.repository.otherBranches()) {
|
|
61
62
|
const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat([...(await deps.repository.filesOnBranch(other, ".cruze"))])));
|
|
62
|
-
// Work in flight on a branch is the change bound to it.
|
|
63
|
+
// Work in flight on a branch is the change bound to it that hasn't landed, there or here.
|
|
63
64
|
const boundThere = theirs.items.filter((i) => {
|
|
64
65
|
const bound = readProgress(i.doc).branch;
|
|
65
|
-
|
|
66
|
+
const here = findWorkItem(view.items, i.ref);
|
|
67
|
+
return !landed(i) && (here === undefined || !landed(here)) && i.ref !== change.ref && bound !== undefined && (other === bound || other.endsWith(`/${bound}`));
|
|
66
68
|
});
|
|
67
69
|
for (const item of boundThere) {
|
|
68
70
|
const shared = [...touchedIds(theirs, item)].filter((id) => mine.has(id));
|
|
@@ -71,3 +73,7 @@ export async function findOverlaps(deps: ProjectDeps, ref?: string): Promise<{ c
|
|
|
71
73
|
}
|
|
72
74
|
return { change: change.ref, overlaps };
|
|
73
75
|
}
|
|
76
|
+
|
|
77
|
+
function landed(item: WorkItem): boolean {
|
|
78
|
+
return item.archived || readProgress(item.doc).landed !== undefined;
|
|
79
|
+
}
|
|
@@ -58,6 +58,23 @@ export async function addFutureFeature(deps: ProjectDeps, slug: string, summary:
|
|
|
58
58
|
return { feature: slug };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Replaces a feature's summary on the feature map, in the future list or the implemented list,
|
|
63
|
+
* when a decision changed what the feature delivers. Its goals or release stay as they are.
|
|
64
|
+
*/
|
|
65
|
+
export async function updateFeatureSummary(deps: ProjectDeps, slug: string, summary: string): Promise<{ feature: string; list: "Future" | "Implemented" }> {
|
|
66
|
+
if (summary.trim() === "") throw new CruzeError("missing-summary", "a feature needs a summary");
|
|
67
|
+
const vision = await requireVision(deps);
|
|
68
|
+
const future = readRows(vision, "Future").find((row) => row[0] === slug);
|
|
69
|
+
const implemented = readRows(vision, "Implemented").find((row) => row[0] === slug);
|
|
70
|
+
if (future === undefined && implemented === undefined) throw new CruzeError("not-found", `${slug} is not on the feature map`);
|
|
71
|
+
const list = future !== undefined ? "Future" : "Implemented";
|
|
72
|
+
const cells = future !== undefined ? [slug, summary.trim(), future[2] ?? ""] : [slug, implemented?.[1] ?? "", summary.trim()];
|
|
73
|
+
await deps.files.writeText(PATHS.vision, upsertRow(vision, list, cells));
|
|
74
|
+
await appendJournal(deps, ".cruze", "feature-map", { action: "update", feature: slug, summary: summary.trim() });
|
|
75
|
+
return { feature: slug, list };
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
export async function dropFutureFeature(deps: ProjectDeps, slug: string, reason: string): Promise<{ feature: string }> {
|
|
62
79
|
const vision = await requireVision(deps);
|
|
63
80
|
const { text, removed } = removeRow(vision, "Future", slug);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { evaluateApproval, type ApprovalStatus } from "../approvals/evaluate.ts";
|
|
2
|
-
import { findIds } from "../ids.ts";
|
|
3
2
|
import { PATHS } from "../project/layout.ts";
|
|
4
3
|
import { readDelta } from "../project/deltas.ts";
|
|
5
4
|
import { readScope } from "../project/plan_parts.ts";
|
|
@@ -89,13 +88,12 @@ export function buildGateReasons(view: ProjectView, branch: string | null): stri
|
|
|
89
88
|
});
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
/** The elements a change touches: its scope
|
|
91
|
+
/** The elements a change touches: its scope and its delta, not the elements it only cites, such as a test seam. */
|
|
93
92
|
export function touchedIds(view: ProjectView, change: WorkItem): Set<string> {
|
|
94
93
|
const feature = featureOf(view.items, change);
|
|
95
94
|
const scope = readScope(change.doc);
|
|
96
95
|
const ids = new Set([...(scope?.delivers ?? []), ...(scope?.builds ?? []), ...(scope?.removes ?? [])]);
|
|
97
96
|
const deltaDoc = feature?.doc ?? change.doc;
|
|
98
97
|
for (const id of readDelta(deltaDoc).ids) ids.add(id);
|
|
99
|
-
for (const id of findIds(change.doc.lines.join("\n"))) ids.add(id);
|
|
100
98
|
return new Set([...ids].filter((id) => !id.startsWith("RULE-")));
|
|
101
99
|
}
|