@printpractical/cruze 0.0.7 → 0.0.8

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.
Files changed (73) hide show
  1. package/README.md +6 -5
  2. package/dist/adapters/inbound/cli/commands/journal.js +1 -1
  3. package/dist/adapters/inbound/cli/commands/show.js +27 -0
  4. package/dist/adapters/inbound/cli/commands/status.js +19 -3
  5. package/dist/adapters/inbound/cli/commands.js +3 -1
  6. package/dist/app/use_cases/approve_artifact.js +19 -1
  7. package/dist/app/use_cases/check_code.js +10 -3
  8. package/dist/app/use_cases/journal_events.js +8 -5
  9. package/dist/app/use_cases/show_scope.js +35 -0
  10. package/dist/domain/check/budget_text.js +24 -0
  11. package/dist/domain/check/check_source.js +6 -3
  12. package/dist/domain/config.js +11 -1
  13. package/dist/domain/project/scope_view.js +118 -0
  14. package/dist/domain/status/work_status.js +13 -2
  15. package/dist/domain/validation/element_rules.js +5 -0
  16. package/dist/domain/validation/size_rules.js +14 -5
  17. package/dist/domain/validation/work_item_rules.js +25 -3
  18. package/dist/domain/versions/rule_versions.js +1 -0
  19. package/dist/main.js +6 -0
  20. package/package.json +1 -1
  21. package/skills/architect/SKILL.md +2 -0
  22. package/skills/architect/feature.md +1 -1
  23. package/skills/architect/walkthrough.md +7 -4
  24. package/skills/build/SKILL.md +9 -8
  25. package/skills/dependency-approval/SKILL.md +8 -1
  26. package/skills/envision/SKILL.md +2 -0
  27. package/skills/envision/feature.md +1 -1
  28. package/skills/explore/SKILL.md +1 -1
  29. package/skills/formats/reference/architecture.md +1 -1
  30. package/skills/formats/reference/config.md +5 -1
  31. package/skills/formats/reference/specs.md +1 -1
  32. package/skills/formats/reference/work-items.md +1 -1
  33. package/skills/grilling/SKILL.md +19 -7
  34. package/skills/grilling/illustrating.md +37 -0
  35. package/skills/hexagonal-design/SKILL.md +1 -0
  36. package/skills/hexagonal-design/languages/rust.md +2 -1
  37. package/skills/improve/SKILL.md +3 -3
  38. package/skills/land/SKILL.md +2 -2
  39. package/skills/next/SKILL.md +1 -1
  40. package/skills/plan/SKILL.md +3 -3
  41. package/skills/plan/tasks.md +9 -5
  42. package/skills/realign/SKILL.md +1 -1
  43. package/skills/realign/notes/0.0.8.md +97 -0
  44. package/skills/realign/notes.md +1 -0
  45. package/skills/research/SKILL.md +2 -1
  46. package/skills/rethink/SKILL.md +1 -1
  47. package/skills/roadmap/SKILL.md +2 -0
  48. package/skills/roles/SKILL.md +7 -9
  49. package/skills/roles/code-reviewer.md +8 -8
  50. package/skills/roles/design-reviewer.md +9 -11
  51. package/skills/roles/surveyor.md +1 -1
  52. package/skills/roles/verifier.md +6 -5
  53. package/skills/triage/SKILL.md +2 -0
  54. package/skills/verify/SKILL.md +3 -1
  55. package/src/adapters/inbound/cli/commands/journal.ts +1 -1
  56. package/src/adapters/inbound/cli/commands/show.ts +27 -0
  57. package/src/adapters/inbound/cli/commands/status.ts +19 -3
  58. package/src/adapters/inbound/cli/commands.ts +3 -1
  59. package/src/app/use_cases/approve_artifact.ts +19 -1
  60. package/src/app/use_cases/check_code.ts +10 -3
  61. package/src/app/use_cases/journal_events.ts +8 -5
  62. package/src/app/use_cases/show_scope.ts +35 -0
  63. package/src/domain/check/budget_text.ts +24 -0
  64. package/src/domain/check/check_source.ts +6 -3
  65. package/src/domain/config.ts +11 -1
  66. package/src/domain/project/scope_view.ts +118 -0
  67. package/src/domain/status/work_status.ts +13 -2
  68. package/src/domain/validation/element_rules.ts +5 -0
  69. package/src/domain/validation/size_rules.ts +14 -5
  70. package/src/domain/validation/work_item_rules.ts +25 -3
  71. package/src/domain/versions/rule_versions.ts +1 -0
  72. package/src/main.ts +6 -0
  73. package/templates/init/AGENTS.md +1 -1
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The part of a source file its budgets measure. Budgets apply to source, not tests, so a Rust
3
+ * file's `#[cfg(test)]` modules are left out: rustfmt closes a top-level module with `}` in column 0.
4
+ */
5
+ export function budgetedText(path: string, text: string): string {
6
+ if (!path.endsWith(".rs")) return text;
7
+ const lines = text.split("\n");
8
+ const kept: string[] = [];
9
+ for (let i = 0; i < lines.length; i++) {
10
+ const item = lines[i + 1] ?? "";
11
+ if (lines[i]?.trim() !== "#[cfg(test)]" || !/^(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+\s*[{;]/.test(item)) {
12
+ kept.push(lines[i] ?? "");
13
+ continue;
14
+ }
15
+ if (item.trimEnd().endsWith(";")) {
16
+ i += 1;
17
+ continue;
18
+ }
19
+ let end = i + 2;
20
+ while (end < lines.length && lines[end] !== "}") end++;
21
+ i = end;
22
+ }
23
+ return kept.join("\n");
24
+ }
@@ -1,5 +1,6 @@
1
1
  import type { CruzeConfig, Layer } from "../config.ts";
2
2
  import { matchesAny } from "../glob.ts";
3
+ import { budgetedText } from "./budget_text.ts";
3
4
  import { importsOf, resolveImport } from "./imports.ts";
4
5
  import { countTypes } from "./type_count.ts";
5
6
 
@@ -50,11 +51,13 @@ export function checkSource(input: CheckInput): CheckFinding[] {
50
51
  }
51
52
  }
52
53
  const excepted = config.check.exceptions.some((exception) => exception.path === path);
53
- const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
54
+ const budgeted = budgetedText(path, text);
55
+ const lines = budgeted.split("\n").length - (budgeted.endsWith("\n") ? 1 : 0);
54
56
  if (!excepted && lines > config.check.maxLines) {
55
- findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines, over the budget of ${config.check.maxLines}; split it by responsibility` });
57
+ const measured = budgeted === text ? "" : ", not counting its inline tests";
58
+ findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines${measured}, over the budget of ${config.check.maxLines}; split it by responsibility` });
56
59
  }
57
- const types = countTypes(path, text);
60
+ const types = countTypes(path, budgeted);
58
61
  if (!excepted && types !== null && types > config.check.maxTypes) {
59
62
  findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
60
63
  }
@@ -38,9 +38,11 @@ export interface ChangeLimits {
38
38
  maxScenarios: number;
39
39
  /** A plan with more tasks than this splits. */
40
40
  maxTasks: number;
41
+ /** Features and changes the user accepted outside the band, by ref, each with the reason. */
42
+ exceptions: Array<{ item: string; reason: string }>;
41
43
  }
42
44
 
43
- export const DEFAULT_CHANGE_LIMITS: ChangeLimits = { minBuilds: 3, maxScenarios: 30, maxTasks: 25 };
45
+ export const DEFAULT_CHANGE_LIMITS: ChangeLimits = { minBuilds: 3, maxScenarios: 30, maxTasks: 25, exceptions: [] };
44
46
 
45
47
  export interface Layer {
46
48
  name: string;
@@ -120,6 +122,14 @@ export function parseConfig(text: string): ConfigResult {
120
122
  minBuilds: positiveInt(limits["min_builds"], "changes.min_builds", problems) ?? DEFAULT_CHANGE_LIMITS.minBuilds,
121
123
  maxScenarios: positiveInt(limits["max_scenarios"], "changes.max_scenarios", problems) ?? DEFAULT_CHANGE_LIMITS.maxScenarios,
122
124
  maxTasks: positiveInt(limits["max_tasks"], "changes.max_tasks", problems) ?? DEFAULT_CHANGE_LIMITS.maxTasks,
125
+ exceptions: (Array.isArray(limits["exceptions"]) ? limits["exceptions"] : []).flatMap((entry: unknown, i: number) => {
126
+ const record = asRecord(entry);
127
+ if (typeof record?.["item"] !== "string" || typeof record["reason"] !== "string" || record["reason"].trim() === "") {
128
+ problems.push(`changes.exceptions[${i}] needs an item and a non-empty reason`);
129
+ return [];
130
+ }
131
+ return [{ item: record["item"], reason: record["reason"] }];
132
+ }),
123
133
  };
124
134
 
125
135
  const config: CruzeConfig = {
@@ -0,0 +1,118 @@
1
+ import { elementLines, type DeltaOp, type Element } from "../elements.ts";
2
+ import { findIds } from "../ids.ts";
3
+ import { findSection, type MarkdownDoc } from "../markdown.ts";
4
+ import { readDelta } from "./deltas.ts";
5
+ import { readScope } from "./plan_parts.ts";
6
+ import type { ProjectView } from "./project_view.ts";
7
+ import { featureOf, type WorkItem, type WorkItemKind } from "./work_items.ts";
8
+
9
+ /**
10
+ * The design a piece of work builds, cut out of the documents that hold it, so a step reads
11
+ * the elements in its scope instead of the whole architecture and the whole feature.
12
+ */
13
+
14
+ export interface ShownElement {
15
+ id: string;
16
+ title: string;
17
+ op?: DeltaOp;
18
+ /** Where the body comes from, as `<path>:<line>`. */
19
+ source: string;
20
+ body: string[];
21
+ }
22
+
23
+ export interface CitedElement {
24
+ id: string;
25
+ title: string;
26
+ source: string;
27
+ }
28
+
29
+ export interface ScopeView {
30
+ ref: string;
31
+ kind: WorkItemKind;
32
+ feature?: string;
33
+ /** The architecture elements the work builds or removes; for a feature, its whole architecture delta. */
34
+ elements: ShownElement[];
35
+ /** The scenarios and requirements the work delivers; for a feature, its whole spec delta. */
36
+ scenarios: ShownElement[];
37
+ /** Elements the shown bodies cite but don't include, with where to read them. */
38
+ cited: CitedElement[];
39
+ /** The settled decisions of the feature, then of the change. */
40
+ decisions: string[];
41
+ /** Scope IDs defined nowhere: the delta, the living docs or the archive. */
42
+ missing: string[];
43
+ }
44
+
45
+ interface Located {
46
+ doc: MarkdownDoc;
47
+ path: string;
48
+ element: Element;
49
+ }
50
+
51
+ export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
52
+ const feature = featureOf(view.items, item);
53
+ const deltaItem = feature ?? item;
54
+ const locate = locator(view, deltaItem);
55
+ const wanted = item.kind === "change" || item.kind === "standalone" ? changeIds(item) : featureIds(deltaItem);
56
+ const missing: string[] = [];
57
+ const show = (ids: string[]): ShownElement[] =>
58
+ ids.flatMap((id) => {
59
+ const found = locate(id);
60
+ if (found === undefined) {
61
+ missing.push(id);
62
+ return [];
63
+ }
64
+ const shown: ShownElement = { id, title: found.element.title, source: `${found.path}:${found.element.start + 1}`, body: elementLines(found.doc, found.element) };
65
+ if (found.element.op !== undefined) shown.op = found.element.op;
66
+ return [shown];
67
+ });
68
+ const elements = show(wanted.elements);
69
+ const scenarios = show(wanted.scenarios);
70
+ const shownIds = new Set([...elements, ...scenarios].map((e) => e.id));
71
+ const cited = [...new Set([...elements, ...scenarios].flatMap((e) => findIds(e.body.join("\n"))))]
72
+ .filter((id) => !shownIds.has(id))
73
+ .flatMap((id) => {
74
+ const found = locate(id);
75
+ return found === undefined ? [] : [{ id, title: found.element.title, source: `${found.path}:${found.element.start + 1}` }];
76
+ });
77
+ const decisions = feature === undefined ? decisionLines(item.doc) : [...decisionLines(feature.doc), ...decisionLines(item.doc)];
78
+ const result: ScopeView = { ref: item.ref, kind: item.kind, elements, scenarios, cited, decisions, missing };
79
+ if (feature !== undefined) result.feature = feature.ref;
80
+ return result;
81
+ }
82
+
83
+ /** A change shows what its scope builds, removes and delivers. */
84
+ function changeIds(change: WorkItem): { elements: string[]; scenarios: string[] } {
85
+ const scope = readScope(change.doc);
86
+ return { elements: [...(scope?.builds ?? []), ...(scope?.removes ?? [])], scenarios: scope?.delivers ?? [] };
87
+ }
88
+
89
+ /** A feature shows its whole delta: every operation, with a requirement's scenarios inside its body. */
90
+ function featureIds(feature: WorkItem): { elements: string[]; scenarios: string[] } {
91
+ const entries = readDelta(feature.doc).entries;
92
+ return {
93
+ elements: entries.filter((e) => e.section === "architecture").map((e) => e.element.id),
94
+ scenarios: entries.filter((e) => e.section === "spec").map((e) => e.element.id),
95
+ };
96
+ }
97
+
98
+ /** Finds an element's body in the work's own delta first, since that is the text the work builds, then in the living docs. */
99
+ function locator(view: ProjectView, deltaItem: WorkItem): (id: string) => Located | undefined {
100
+ const delta = readDelta(deltaItem.doc);
101
+ const inDelta = new Map<string, Element>();
102
+ for (const entry of delta.entries) {
103
+ inDelta.set(entry.element.id, entry.element);
104
+ for (const scenario of entry.scenarios) inDelta.set(scenario.id, scenario);
105
+ }
106
+ return (id) => {
107
+ const own = inDelta.get(id);
108
+ if (own !== undefined) return { doc: deltaItem.doc, path: deltaItem.path, element: own };
109
+ const living = view.living.elements.get(id);
110
+ return living === undefined ? undefined : { doc: living.doc, path: living.path, element: living.element };
111
+ };
112
+ }
113
+
114
+ function decisionLines(doc: MarkdownDoc): string[] {
115
+ const section = findSection(doc, "Settled decisions");
116
+ if (section === null) return [];
117
+ return doc.lines.slice(section.start, section.end).filter((line) => line.startsWith("- "));
118
+ }
@@ -1,7 +1,7 @@
1
1
  import { evaluateApproval, type ApprovalStatus } from "../approvals/evaluate.ts";
2
2
  import { PATHS } from "../project/layout.ts";
3
3
  import { readDelta } from "../project/deltas.ts";
4
- import { readScope } from "../project/plan_parts.ts";
4
+ import { readChangesTable, readScope } from "../project/plan_parts.ts";
5
5
  import { readProgress } from "../project/progress.ts";
6
6
  import type { ProjectView } from "../project/project_view.ts";
7
7
  import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
@@ -80,12 +80,23 @@ export function buildGateReasons(view: ProjectView, branch: string | null): stri
80
80
  const feature = featureOf(view.items, change);
81
81
  if (feature !== undefined) required.push(feature.path);
82
82
  if (view.snapshot.has(PATHS.architecture)) required.push(PATHS.architecture);
83
- return required.flatMap((path) => {
83
+ const approvals = required.flatMap((path) => {
84
84
  const status = evaluateApproval(view, path);
85
85
  if (status.state === "approved") return [];
86
86
  const detail = status.changed.length > 0 ? `: ${status.changed.join(", ")} changed` : status.unjournaled === true ? ": the approval has no journal entry" : "";
87
87
  return [`${path} is ${status.state}${detail}`];
88
88
  });
89
+ return [...approvals, ...unlandedDependencies(view, change, feature)];
90
+ }
91
+
92
+ /** The changes this change's row in its feature's Changes table depends on that haven't landed on this branch. */
93
+ function unlandedDependencies(view: ProjectView, change: WorkItem, feature: WorkItem | undefined): string[] {
94
+ if (feature === undefined) return [];
95
+ const row = readChangesTable(feature.doc).find((r) => r.change === change.folderName);
96
+ const landed = new Set(changesOf(view.items, feature).filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
97
+ return (row?.dependsOn ?? [])
98
+ .filter((dependency) => !landed.has(dependency))
99
+ .map((dependency) => `${change.ref} depends on ${dependency}, which hasn't landed here; land it, then merge main into this branch`);
89
100
  }
90
101
 
91
102
  /** The elements a change touches: its scope and its delta, not the elements it only cites, such as a test seam. */
@@ -41,6 +41,11 @@ export function elementProblems(path: string, doc: MarkdownDoc, element: Element
41
41
  problems.push(error(path, element.start, "fact-value", `${element.id} ${key} must be one of ${rule.values.join(", ")}`));
42
42
  }
43
43
  }
44
+ if (element.kind === "MOD") {
45
+ for (const glob of (facts.get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")).filter((p) => /[*?[{]/.test(p))) {
46
+ problems.push(error(path, element.start, "module-path-glob", `${element.id} Path \`${glob}\` is a glob, which matches no file; name a directory ending in / or a single file, one per Path value`));
47
+ }
48
+ }
44
49
  const module = facts.get("Module")?.[0];
45
50
  if (module !== undefined && !findIds(module).some((id) => id.startsWith("MOD-"))) {
46
51
  problems.push(error(path, element.start, "fact-value", `${element.id} Module must name a MOD element`));
@@ -12,17 +12,25 @@ export function changeLimits(view: ProjectView): ChangeLimits {
12
12
  return view.config?.config?.changes ?? DEFAULT_CHANGE_LIMITS;
13
13
  }
14
14
 
15
+ /** The rules a size warning comes from. An item stays outside the band only with the user's recorded acceptance. */
16
+ export const SIZE_RULES = ["feature-size", "change-too-small", "change-too-large", "plan-too-large"];
17
+
18
+ /** Whether the user accepted this feature or change outside the band, under `changes.exceptions`. */
19
+ export function sizeAccepted(view: ProjectView, ref: string): boolean {
20
+ return changeLimits(view).exceptions.some((exception) => exception.item === ref);
21
+ }
22
+
15
23
  /** A feature's changes not yet landed that fall outside the size band. A feature of one change has nothing to merge into. */
16
- export function changeRowSizeProblems(view: ProjectView, path: string, rows: ChangeRow[], landed: Set<string>): Problem[] {
24
+ export function changeRowSizeProblems(view: ProjectView, feature: { path: string; ref: string }, rows: ChangeRow[], landed: Set<string>): Problem[] {
17
25
  const limits = changeLimits(view);
18
26
  const problems: Problem[] = [];
19
- for (const row of rows.filter((r) => !landed.has(r.change))) {
27
+ for (const row of rows.filter((r) => !landed.has(r.change) && !sizeAccepted(view, `${feature.ref}/${r.change}`))) {
20
28
  if (rows.length > 1 && row.builds.length < limits.minBuilds) {
21
- problems.push(warning(path, row.line, "change-too-small", `${row.change} builds ${row.builds.length} element(s), under ${limits.minBuilds}; merge it into the change it continues, since each change pays for its own plan, review and verify`));
29
+ problems.push(warning(feature.path, row.line, "change-too-small", `${row.change} builds ${row.builds.length} element(s), under ${limits.minBuilds}; merge it into the change it continues, since each change pays for its own plan, review and verify`));
22
30
  }
23
31
  const scenarios = row.delivers.filter((id) => kindOf(id) === "SCN").length;
24
32
  if (scenarios > limits.maxScenarios) {
25
- problems.push(warning(path, row.line, "change-too-large", `${row.change} delivers ${scenarios} scenarios, over ${limits.maxScenarios}; split it, since one verifier run has to go through them all`));
33
+ problems.push(warning(feature.path, row.line, "change-too-large", `${row.change} delivers ${scenarios} scenarios, over ${limits.maxScenarios}; split it, since one verifier run has to go through them all`));
26
34
  }
27
35
  }
28
36
  return problems;
@@ -31,6 +39,7 @@ export function changeRowSizeProblems(view: ProjectView, path: string, rows: Cha
31
39
  /** A plan with more tasks than one code review and one verify can hold. */
32
40
  export function planSizeProblems(view: ProjectView, path: string, tasks: number): Problem[] {
33
41
  const { maxTasks } = changeLimits(view);
34
- if (tasks <= maxTasks) return [];
42
+ const ref = view.items.find((item) => item.path === path)?.ref ?? path;
43
+ if (tasks <= maxTasks || sizeAccepted(view, ref)) return [];
35
44
  return [warning(path, undefined, "plan-too-large", `the plan has ${tasks} tasks, over ${maxTasks}; split the change through rethink at feature level`)];
36
45
  }
@@ -11,7 +11,7 @@ import { deltaProblems } from "./delta_rules.ts";
11
11
  import { malformedIdHeadings, lineOf } from "./living_rules.ts";
12
12
  import { changePlanProblems } from "./plan_rules.ts";
13
13
  import { error, warning, type Problem } from "./problem.ts";
14
- import { changeRowSizeProblems } from "./size_rules.ts";
14
+ import { changeRowSizeProblems, sizeAccepted } from "./size_rules.ts";
15
15
 
16
16
  const REQUIRED_SECTIONS: Record<WorkItem["kind"], string[]> = {
17
17
  feature: ["Intent", "Spec delta", "Architecture delta", "Changes"],
@@ -45,6 +45,10 @@ function itemProblems(view: ProjectView, item: WorkItem): Problem[] {
45
45
  for (const cited of findIds(text).filter((cited) => !available.has(cited))) {
46
46
  problems.push(error(path, lineOf(text, cited), "unknown-id", `${cited} is cited but defined neither in the living docs nor in this work's deltas`));
47
47
  }
48
+ const dropped = droppedScenarios(view, delta);
49
+ for (const cited of findIds(text).filter((cited) => dropped.has(cited))) {
50
+ problems.push(error(path, lineOf(text, cited), "cites-dropped", `${cited} is cited, but ${dropped.get(cited)} in this work's delta leaves it out, so it won't exist once the work lands; describe it in words instead`));
51
+ }
48
52
  if (item.kind !== "change") problems.push(...deltaProblems(view, item));
49
53
  if (item.kind === "feature") problems.push(...featureScopeProblems(view, item, delta));
50
54
  // A landed change is history: its scope named what it built, which is now built.
@@ -69,10 +73,10 @@ function featureScopeProblems(view: ProjectView, feature: WorkItem, delta: Delta
69
73
  );
70
74
  const names = rows.map((row) => row.change);
71
75
  const extra = rows[MAX_CHANGES];
72
- if (extra !== undefined) {
76
+ if (extra !== undefined && !sizeAccepted(view, feature.ref)) {
73
77
  problems.push(warning(feature.path, extra.line, "feature-size", `the feature has ${rows.length} changes, over ${MAX_CHANGES}; split it into features a single design review can hold`));
74
78
  }
75
- problems.push(...changeRowSizeProblems(view, feature.path, rows, landedChanges));
79
+ problems.push(...changeRowSizeProblems(view, feature, rows, landedChanges));
76
80
  for (const row of rows) {
77
81
  if (!/^\d\d-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(row.change)) problems.push(error(feature.path, row.line, "change-name", `change "${row.change}" must be named NN-slug`));
78
82
  if (names.indexOf(row.change) !== names.lastIndexOf(row.change)) problems.push(error(feature.path, row.line, "change-name", `change ${row.change} is listed twice`));
@@ -151,3 +155,21 @@ function scopeIds(item: WorkItem): string[] {
151
155
  const scope = readScope(item.doc);
152
156
  return scope === null ? [] : [...scope.delivers, ...scope.builds, ...scope.removes];
153
157
  }
158
+
159
+ /**
160
+ * Living scenarios that a MODIFIED requirement in the delta leaves out, each with that requirement.
161
+ * Land drops them, so a citation of one passes validation now and breaks only at land.
162
+ */
163
+ function droppedScenarios(view: ProjectView, delta: Delta): Map<string, string> {
164
+ const dropped = new Map<string, string>();
165
+ for (const entry of delta.entries.filter((e) => e.element.op === "MODIFIED" && kindOf(e.element.id) === "REQ")) {
166
+ const living = view.living.elements.get(entry.element.id);
167
+ if (living === undefined) continue;
168
+ const kept = new Set(entry.scenarios.map((s) => s.id));
169
+ for (const scenario of view.living.elements.values()) {
170
+ const nested = scenario.doc === living.doc && scenario.element.start > living.element.start && scenario.element.start < living.element.end;
171
+ if (nested && kindOf(scenario.id) === "SCN" && !kept.has(scenario.id) && !delta.ids.has(scenario.id)) dropped.set(scenario.id, entry.element.id);
172
+ }
173
+ }
174
+ return dropped;
175
+ }
@@ -16,6 +16,7 @@ export const RULE_SINCE: Record<string, string> = {
16
16
  "validate:change-too-small": "0.0.5",
17
17
  "validate:change-too-large": "0.0.5",
18
18
  "validate:plan-too-large": "0.0.5",
19
+ "validate:module-path-glob": "0.0.8",
19
20
  };
20
21
 
21
22
  /** Whether a rule arrived after the version the project's code meets, so it waits for `realign`. */
package/src/main.ts CHANGED
@@ -9,6 +9,12 @@ import { ProcessAgentRunner } from "./adapters/outbound/process_agent_runner.ts"
9
9
  import { SystemClock } from "./adapters/outbound/system_clock.ts";
10
10
  import { DefaultAnswerPrompter, TerminalPrompter } from "./adapters/outbound/terminal_prompter.ts";
11
11
 
12
+ // A reader that stops early, such as `cruze journal list | head`, closes the pipe; that is not an error worth a stack trace.
13
+ process.stdout.on("error", (error: NodeJS.ErrnoException) => {
14
+ if (error.code === "EPIPE") process.exit(0);
15
+ throw error;
16
+ });
17
+
12
18
  const cwd = process.cwd();
13
19
  const exitCode = await runCli(
14
20
  process.argv.slice(2),
@@ -4,7 +4,7 @@ Instructions for coding agents working in this repository. Cruze keeps the manag
4
4
 
5
5
  ## Workflow
6
6
 
7
- This project uses [Cruze](https://github.com/PrintPractical/cruze) for spec-driven development. Durable documents live in `docs/`: vision, glossary, architecture, ADRs, specs and roadmap. Work in progress lives in `.cruze/`. Read `docs/architecture.md` before changing code, once it exists. The Cruze skills run the `cruze` command; when it is missing, install it as https://github.com/PrintPractical/cruze#quick-start describes.
7
+ This project uses [Cruze](https://github.com/PrintPractical/cruze) for spec-driven development. Durable documents live in `docs/`: vision, glossary, architecture, ADRs, specs and roadmap. Work in progress lives in `.cruze/`. Before changing code, read the design the change builds with `cruze show <change>`, once it exists. Each Cruze step runs in a new session, so its context holds what the step reads and nothing else. The Cruze skills run the `cruze` command; when it is missing, install it as https://github.com/PrintPractical/cruze#quick-start describes.
8
8
 
9
9
  ## Commands
10
10