@printpractical/cruze 0.0.8 → 0.0.10

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 (152) hide show
  1. package/README.md +17 -13
  2. package/dist/adapters/inbound/cli/cli_context.js +6 -0
  3. package/dist/adapters/inbound/cli/commands/code.js +2 -1
  4. package/dist/adapters/inbound/cli/commands/diff.js +23 -0
  5. package/dist/adapters/inbound/cli/commands/documents.js +1 -1
  6. package/dist/adapters/inbound/cli/commands/show.js +72 -3
  7. package/dist/adapters/inbound/cli/commands/status.js +7 -3
  8. package/dist/adapters/inbound/cli/commands.js +9 -3
  9. package/dist/adapters/inbound/cli/parse_command.js +8 -0
  10. package/dist/adapters/outbound/git_repository.js +49 -7
  11. package/dist/adapters/outbound/project_root.js +13 -0
  12. package/dist/app/ports/repository.js +4 -0
  13. package/dist/app/use_cases/approve_artifact.js +18 -10
  14. package/dist/app/use_cases/check_code.js +5 -3
  15. package/dist/app/use_cases/journal_events.js +6 -2
  16. package/dist/app/use_cases/new_project_doc.js +10 -2
  17. package/dist/app/use_cases/project_status.js +4 -1
  18. package/dist/app/use_cases/run_review.js +10 -2
  19. package/dist/app/use_cases/show_elements.js +18 -0
  20. package/dist/app/use_cases/show_notes.js +11 -0
  21. package/dist/app/use_cases/show_scope.js +17 -1
  22. package/dist/app/use_cases/upstream_diff.js +55 -0
  23. package/dist/domain/approvals/rebase.js +39 -0
  24. package/dist/domain/approvals/upstream_text.js +36 -0
  25. package/dist/domain/check/budget_text.js +43 -6
  26. package/dist/domain/check/check_source.js +20 -3
  27. package/dist/domain/check/decision_comments.js +38 -0
  28. package/dist/domain/config.js +12 -0
  29. package/dist/domain/decision_citations.js +62 -0
  30. package/dist/domain/edits/agents_file.js +15 -1
  31. package/dist/domain/ids.js +3 -1
  32. package/dist/domain/journal.js +16 -3
  33. package/dist/domain/land/merge_plan.js +7 -2
  34. package/dist/domain/line_diff.js +56 -0
  35. package/dist/domain/project/element_lookup.js +124 -0
  36. package/dist/domain/project/living_model.js +2 -1
  37. package/dist/domain/project/notes.js +74 -0
  38. package/dist/domain/project/scope_view.js +20 -4
  39. package/dist/domain/skill_budget.js +36 -0
  40. package/dist/domain/status/next_step.js +52 -7
  41. package/dist/domain/status/pending_rethink.js +22 -0
  42. package/dist/domain/status/verification.js +12 -0
  43. package/dist/domain/status/work_status.js +17 -2
  44. package/dist/domain/validation/decision_rules.js +76 -0
  45. package/dist/domain/validation/delta_rules.js +2 -0
  46. package/dist/domain/validation/future_rules.js +40 -0
  47. package/dist/domain/validation/living_rules.js +2 -0
  48. package/dist/domain/validation/plan_rules.js +1 -1
  49. package/dist/domain/validation/validate_project.js +4 -0
  50. package/dist/domain/validation/vision_rules.js +41 -0
  51. package/dist/domain/versions/rule_versions.js +3 -0
  52. package/dist/main.js +8 -4
  53. package/package.json +1 -1
  54. package/skills/architect/SKILL.md +6 -2
  55. package/skills/architect/feature.md +6 -4
  56. package/skills/architect/project.md +2 -2
  57. package/skills/behavioural-testing/SKILL.md +3 -2
  58. package/skills/behavioural-testing/fakes.md +3 -3
  59. package/skills/build/SKILL.md +3 -2
  60. package/skills/envision/SKILL.md +3 -2
  61. package/skills/envision/feature.md +3 -1
  62. package/skills/envision/intake.md +28 -0
  63. package/skills/envision/possibilities.md +13 -0
  64. package/skills/envision/project.md +5 -3
  65. package/skills/explore/SKILL.md +2 -2
  66. package/skills/formats/SKILL.md +13 -2
  67. package/skills/formats/reference/architecture.md +8 -4
  68. package/skills/formats/reference/config.md +9 -4
  69. package/skills/formats/reference/durable-docs.md +4 -1
  70. package/skills/formats/reference/work-items.md +1 -1
  71. package/skills/formats/templates/vision.md +12 -0
  72. package/skills/grilling/SKILL.md +10 -0
  73. package/skills/hexagonal-design/SKILL.md +2 -2
  74. package/skills/hexagonal-design/languages/rust.md +2 -1
  75. package/skills/hexagonal-design/runtime.md +1 -0
  76. package/skills/land/SKILL.md +6 -2
  77. package/skills/plan/SKILL.md +15 -5
  78. package/skills/plan/tasks.md +4 -4
  79. package/skills/realign/SKILL.md +5 -3
  80. package/skills/realign/notes/0.0.10.md +131 -0
  81. package/skills/realign/notes/0.0.2.md +1 -1
  82. package/skills/realign/notes/0.0.6.md +1 -1
  83. package/skills/realign/notes/0.0.8.md +2 -2
  84. package/skills/realign/notes/0.0.9.md +59 -0
  85. package/skills/realign/notes.md +2 -0
  86. package/skills/research/SKILL.md +3 -2
  87. package/skills/rethink/SKILL.md +6 -4
  88. package/skills/rethink/levels.md +8 -8
  89. package/skills/roadmap/SKILL.md +2 -1
  90. package/skills/roles/SKILL.md +4 -4
  91. package/skills/roles/auditor.md +1 -1
  92. package/skills/roles/code-reviewer.md +5 -4
  93. package/skills/roles/design-reviewer.md +5 -29
  94. package/skills/roles/design-rubric.md +40 -0
  95. package/skills/roles/plan-rubric.md +11 -0
  96. package/skills/roles/surveyor.md +3 -2
  97. package/skills/roles/verifier.md +2 -0
  98. package/skills/triage/SKILL.md +1 -1
  99. package/skills/verify/SKILL.md +3 -3
  100. package/src/adapters/inbound/cli/cli_context.ts +6 -0
  101. package/src/adapters/inbound/cli/commands/code.ts +2 -1
  102. package/src/adapters/inbound/cli/commands/diff.ts +23 -0
  103. package/src/adapters/inbound/cli/commands/documents.ts +1 -1
  104. package/src/adapters/inbound/cli/commands/show.ts +72 -3
  105. package/src/adapters/inbound/cli/commands/status.ts +7 -3
  106. package/src/adapters/inbound/cli/commands.ts +9 -3
  107. package/src/adapters/inbound/cli/parse_command.ts +8 -0
  108. package/src/adapters/outbound/git_repository.ts +49 -7
  109. package/src/adapters/outbound/project_root.ts +13 -0
  110. package/src/app/ports/repository.ts +4 -0
  111. package/src/app/use_cases/approve_artifact.ts +18 -10
  112. package/src/app/use_cases/check_code.ts +5 -3
  113. package/src/app/use_cases/journal_events.ts +6 -2
  114. package/src/app/use_cases/new_project_doc.ts +10 -2
  115. package/src/app/use_cases/project_status.ts +4 -1
  116. package/src/app/use_cases/run_review.ts +10 -2
  117. package/src/app/use_cases/show_elements.ts +18 -0
  118. package/src/app/use_cases/show_notes.ts +11 -0
  119. package/src/app/use_cases/show_scope.ts +17 -1
  120. package/src/app/use_cases/upstream_diff.ts +55 -0
  121. package/src/domain/approvals/rebase.ts +39 -0
  122. package/src/domain/approvals/upstream_text.ts +36 -0
  123. package/src/domain/check/budget_text.ts +43 -6
  124. package/src/domain/check/check_source.ts +20 -3
  125. package/src/domain/check/decision_comments.ts +38 -0
  126. package/src/domain/config.ts +12 -0
  127. package/src/domain/decision_citations.ts +62 -0
  128. package/src/domain/edits/agents_file.ts +15 -1
  129. package/src/domain/ids.ts +3 -1
  130. package/src/domain/journal.ts +16 -3
  131. package/src/domain/land/merge_plan.ts +7 -2
  132. package/src/domain/line_diff.ts +56 -0
  133. package/src/domain/project/element_lookup.ts +124 -0
  134. package/src/domain/project/living_model.ts +2 -1
  135. package/src/domain/project/notes.ts +74 -0
  136. package/src/domain/project/scope_view.ts +20 -4
  137. package/src/domain/skill_budget.ts +36 -0
  138. package/src/domain/status/next_step.ts +52 -7
  139. package/src/domain/status/pending_rethink.ts +22 -0
  140. package/src/domain/status/verification.ts +12 -0
  141. package/src/domain/status/work_status.ts +17 -2
  142. package/src/domain/validation/decision_rules.ts +76 -0
  143. package/src/domain/validation/delta_rules.ts +2 -0
  144. package/src/domain/validation/future_rules.ts +40 -0
  145. package/src/domain/validation/living_rules.ts +2 -0
  146. package/src/domain/validation/plan_rules.ts +1 -1
  147. package/src/domain/validation/validate_project.ts +4 -0
  148. package/src/domain/validation/vision_rules.ts +41 -0
  149. package/src/domain/versions/rule_versions.ts +3 -0
  150. package/src/main.ts +8 -4
  151. package/templates/init/AGENTS.md +1 -1
  152. package/templates/init/config.yaml +5 -4
@@ -1,4 +1,5 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.js";
2
+ import { PATHS } from "../../domain/project/layout.js";
2
3
  import { readProgress } from "../../domain/project/progress.js";
3
4
  import { buildProjectView } from "../../domain/project/project_view.js";
4
5
  import { findWorkItem, } from "../../domain/project/work_items.js";
@@ -59,7 +60,9 @@ export async function findOverlaps(deps , ref )
59
60
  const mine = touchedIds(view, change);
60
61
  const overlaps = [];
61
62
  for (const other of await deps.repository.otherBranches()) {
62
- const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat([...(await deps.repository.filesOnBranch(other, ".cruze"))])));
63
+ // Landed work is archived, so a branch's work in flight is under features/ and changes/ only.
64
+ const inFlight = [...(await deps.repository.filesOnBranch(other, PATHS.featuresDir)), ...(await deps.repository.filesOnBranch(other, PATHS.changesDir))];
65
+ const theirs = buildProjectView(new Map([...view.snapshot].filter(([p]) => p.startsWith("docs/")).concat(inFlight)));
63
66
  // Work in flight on a branch is the change bound to it that hasn't landed, there or here.
64
67
  const boundThere = theirs.items.filter((i) => {
65
68
  const bound = readProgress(i.doc).branch;
@@ -1,6 +1,7 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.js";
2
2
  import { resolveArtifact } from "../../domain/project/artifact_ref.js";
3
- import { featureOf } from "../../domain/project/work_items.js";
3
+ import { isPlanned } from "../../domain/project/plan_parts.js";
4
+ import { featureOf, } from "../../domain/project/work_items.js";
4
5
 
5
6
  import { loadView, } from "../project_context.js";
6
7
 
@@ -44,7 +45,7 @@ export async function runReview(deps , req
44
45
  context.push(`Under review: ${item?.ref ?? path} (\`${path}\`)`);
45
46
  const feature = item === undefined ? undefined : featureOf(view.items, item);
46
47
  if (feature !== undefined) context.push(`Its feature: \`${feature.path}\``);
47
- if (role === "design-reviewer" && item !== undefined && item.kind !== "feature") context.push("This is a change's plan: review it with the plan rubric.");
48
+ if (role === "design-reviewer" && item !== undefined) context.push(rubricLine(item));
48
49
  } else {
49
50
  context.push("Under review: the project documents the role lists for project scope");
50
51
  }
@@ -83,3 +84,10 @@ async function bundledRole(deps , role ) {
83
84
  if (file === undefined) throw new CruzeError("role-missing", `the ${role} role is not installed; run cruze install`);
84
85
  return file.text;
85
86
  }
87
+
88
+ /** A design reviewer reads one rubric: the plan rubric for a planned change, the design rubric otherwise. */
89
+ function rubricLine(item ) {
90
+ return item.kind !== "feature" && isPlanned(item.doc)
91
+ ? "This is a change's plan: review it against `.agents/skills/cruze-roles/plan-rubric.md`."
92
+ : "This is a design: review it against `.agents/skills/cruze-roles/design-rubric.md`.";
93
+ }
@@ -0,0 +1,18 @@
1
+ import { CruzeError } from "../../domain/cruze_error.js";
2
+ import { elementIndex, lookupElements, } from "../../domain/project/element_lookup.js";
3
+
4
+ import { loadView, } from "../project_context.js";
5
+
6
+ /** The bodies of the named elements, living and in each in-flight delta that changes them. */
7
+ export async function showElements(deps , ids ) {
8
+ const { elements, missing } = lookupElements(await loadView(deps.files), ids);
9
+ if (missing.length > 0) throw new CruzeError("unknown-id", `${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} defined nowhere in the living docs or the work in flight; cruze show --index lists every element`);
10
+ return { elements };
11
+ }
12
+
13
+ /** One line per element of the living architecture and specs, with what work in flight adds or changes. */
14
+ export async function showIndex(deps , scope ) {
15
+ const index = elementIndex(await loadView(deps.files), scope);
16
+ if (scope !== undefined && index.entries.length === 0) throw new CruzeError("unknown-scope", `no element belongs to "${scope}"; name a context or capability, such as the part of an ID before its dot`);
17
+ return scope === undefined ? index : { ...index, scope };
18
+ }
@@ -0,0 +1,11 @@
1
+ import { notesFor, readNotes, } from "../../domain/project/notes.js";
2
+ import { loadView, } from "../project_context.js";
3
+
4
+ /**
5
+ * Every note with what it is about and the hand-off it holds, or, given a target such as
6
+ * `project`, `architect` or a feature slug, the notes and hand-off rows for it.
7
+ */
8
+ export async function showNotes(deps , target ) {
9
+ const notes = readNotes((await loadView(deps.files)).snapshot);
10
+ return target === undefined ? { notes } : { target, ...notesFor(notes, [target]) };
11
+ }
@@ -1,6 +1,9 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.js";
2
2
  import { resolveArtifact } from "../../domain/project/artifact_ref.js";
3
+ import { scopeOf } from "../../domain/ids.js";
4
+ import { notesFor, readNotes, } from "../../domain/project/notes.js";
3
5
  import { scopeView, } from "../../domain/project/scope_view.js";
6
+
4
7
  import { loadView, } from "../project_context.js";
5
8
  import { listEvents } from "./journal_events.js";
6
9
 
@@ -14,6 +17,10 @@ import { listEvents } from "./journal_events.js";
14
17
 
15
18
 
16
19
 
20
+
21
+
22
+
23
+
17
24
 
18
25
 
19
26
  /**
@@ -31,5 +38,14 @@ export async function showScope(deps , ref )
31
38
  reason: String(entry["reason"] ?? ""),
32
39
  review: String(entry["review"] ?? ""),
33
40
  }));
34
- return { ...scope, dispositions };
41
+ const feature = scope.feature === undefined ? item : (view.items.find((i) => i.ref === scope.feature) ?? item);
42
+ const contexts = [...scope.elements, ...scope.scenarios].flatMap((e) => scopeOf(e.id) ?? []);
43
+ const notes = notesFor(readNotes(view.snapshot), [...slugsOf(feature), ...contexts]);
44
+ return { ...scope, dispositions, references: view.config?.config?.references ?? [], notes };
45
+ }
46
+
47
+ /** The names a note or a hand-off may use for a feature or standalone change: its roadmap slug, and its folder name without the date. */
48
+ export function slugsOf(item ) {
49
+ const roadmap = item.doc.frontmatter?.["roadmap"];
50
+ return [...new Set([...(typeof roadmap === "string" ? [roadmap] : []), item.folderName.replace(/^\d{4}-\d{2}-\d{2}-/, "")])];
35
51
  }
@@ -0,0 +1,55 @@
1
+ import { evaluateApproval, } from "../../domain/approvals/evaluate.js";
2
+ import { rebaseNeeded } from "../../domain/approvals/rebase.js";
3
+ import { upstreamLines, upstreamPath } from "../../domain/approvals/upstream_text.js";
4
+ import { lineDiff } from "../../domain/line_diff.js";
5
+ import { resolveArtifact } from "../../domain/project/artifact_ref.js";
6
+ import { approvalFolder, approvalsPath } from "../../domain/project/project_view.js";
7
+ import { loadView, } from "../project_context.js";
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+ /**
28
+ * What changed upstream of a document since its approval, element by element, so a person can
29
+ * judge a re-stamp from the text rather than the IDs. The approved text comes from the commit
30
+ * that recorded the approval. One project went to a rethink for two file-list merges that
31
+ * changed no behaviour, because status named only the IDs.
32
+ */
33
+ export async function upstreamDiff(deps , ref ) {
34
+ const view = await loadView(deps.files);
35
+ const { path, item } = resolveArtifact(view, ref);
36
+ const status = evaluateApproval(view, path);
37
+ const report = { artifact: path, state: status.state, changes: [], rebase: item === undefined ? [] : rebaseNeeded(view, item) };
38
+ if (item !== undefined) report.ref = item.ref;
39
+ const record = status.record;
40
+ if (record === undefined || status.changed.length === 0) return report;
41
+ const commit = await deps.repository.firstCommitAdding(approvalsPath(approvalFolder(view, path)), record.approvedAt);
42
+ for (const key of status.changed) {
43
+ const where = upstreamPath(view, key);
44
+ if (where === undefined) {
45
+ report.changes.push({ key, note: "it no longer exists" });
46
+ continue;
47
+ }
48
+ const now = upstreamLines(key, where, view.snapshot.get(where) ?? "") ?? [];
49
+ const then = commit === null ? null : await deps.repository.fileAt(commit, where);
50
+ const approved = then === null ? undefined : upstreamLines(key, where, then, record.upstream[key]);
51
+ if (approved !== undefined) report.changes.push({ key, path: where, diff: lineDiff(approved, now) });
52
+ else report.changes.push({ key, path: where, note: commit === null ? "the approval isn't committed yet; compare with git diff" : `its approved text isn't in ${where} at ${commit}, the commit that recorded the approval` });
53
+ }
54
+ return report;
55
+ }
@@ -0,0 +1,39 @@
1
+ import { elementHash } from "../hashing.js";
2
+
3
+ import { readDelta } from "../project/deltas.js";
4
+ import { approvalFolder, } from "../project/project_view.js";
5
+
6
+ import { latestApproval } from "./records.js";
7
+
8
+ /**
9
+ * Delta elements a feature or standalone change merged and hasn't changed since. The merge did
10
+ * their job, so the living text is the one to keep: an edit there by a rethink, a realign or
11
+ * another land asks nothing of the delta. One project's delta went stale this way, and every
12
+ * later edit of an element its first change had merged forced a rebase of text with no work left in it.
13
+ */
14
+ export function settledIds(doc , merged ) {
15
+ const settled = new Set ();
16
+ for (const entry of readDelta(doc).entries) {
17
+ for (const element of [entry.element, ...entry.scenarios]) {
18
+ if (merged[element.id] === elementHash(doc, element)) settled.add(element.id);
19
+ }
20
+ }
21
+ return settled;
22
+ }
23
+
24
+ /**
25
+ * Elements the work's delta still changes whose living text moved since its last approval:
26
+ * approving the delta as it stands would overwrite that edit. A change's own approval pins no delta.
27
+ */
28
+ export function rebaseNeeded(view , item ) {
29
+ if (item.kind === "change") return [];
30
+ const file = view.approvals.get(approvalFolder(view, item.path));
31
+ const previous = file === undefined ? undefined : latestApproval(file, item.path);
32
+ if (previous === undefined) return [];
33
+ const settled = settledIds(item.doc, file?.merged ?? {});
34
+ return [...readDelta(item.doc).ids].filter((id) => {
35
+ const living = view.living.elements.get(id);
36
+ const before = previous.upstream[id];
37
+ return !settled.has(id) && living !== undefined && before !== undefined && before !== elementHash(living.doc, living.element);
38
+ });
39
+ }
@@ -0,0 +1,36 @@
1
+ import { isStatusLine } from "../elements.js";
2
+ import { designHash, elementHash } from "../hashing.js";
3
+ import { parseMarkdown, } from "../markdown.js";
4
+ import { buildLivingModel } from "../project/living_model.js";
5
+
6
+
7
+ /** The document that holds an upstream key now: an element's living document, or the path of a `doc:` key. */
8
+ export function upstreamPath(view , key ) {
9
+ if (key.startsWith("doc:")) return view.snapshot.has(key.slice("doc:".length)) ? key.slice("doc:".length) : undefined;
10
+ return view.living.elements.get(key)?.path;
11
+ }
12
+
13
+ /**
14
+ * The design lines behind an upstream key in one version of the document at `path`, without the
15
+ * managed blocks and status lines no hash covers. With `pinned`, only when they hash as pinned.
16
+ */
17
+ export function upstreamLines(key , path , text , pinned ) {
18
+ if (key.startsWith("doc:")) {
19
+ const doc = parseMarkdown(text);
20
+ if (pinned !== undefined && designHash(doc) !== pinned) return undefined;
21
+ return designLines(doc, 0, doc.lines.length);
22
+ }
23
+ const living = buildLivingModel(new Map([[path, text]])).elements.get(key);
24
+ if (living === undefined) return undefined;
25
+ if (pinned !== undefined && elementHash(living.doc, living.element) !== pinned) return undefined;
26
+ return designLines(living.doc, living.element.start, living.element.end);
27
+ }
28
+
29
+ function designLines(doc , start , end ) {
30
+ const lines = [];
31
+ for (let i = start; i < end; i++) {
32
+ const line = doc.lines[i] ?? "";
33
+ if (!doc.inManaged[i] && (doc.inFence[i] || !isStatusLine(line))) lines.push(line);
34
+ }
35
+ return lines;
36
+ }
@@ -1,24 +1,61 @@
1
1
  /**
2
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.
3
+ * file's test-only modules are left out: rustfmt closes a top-level module with `}` in column 0.
4
+ * A module is test-only when its `cfg` needs `test`, as `#[cfg(test)]` or
5
+ * `#[cfg(all(test, feature = "schema"))]` do; other attributes may sit between the two.
4
6
  */
5
7
  export function budgetedText(path , text ) {
6
8
  if (!path.endsWith(".rs")) return text;
7
9
  const lines = text.split("\n");
8
10
  const kept = [];
9
11
  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
+ const start = testModuleStart(lines, i);
13
+ if (start === null) {
12
14
  kept.push(lines[i] ?? "");
13
15
  continue;
14
16
  }
15
- if (item.trimEnd().endsWith(";")) {
16
- i += 1;
17
+ if ((lines[start] ?? "").trimEnd().endsWith(";")) {
18
+ i = start;
17
19
  continue;
18
20
  }
19
- let end = i + 2;
21
+ let end = start + 1;
20
22
  while (end < lines.length && lines[end] !== "}") end++;
21
23
  i = end;
22
24
  }
23
25
  return kept.join("\n");
24
26
  }
27
+
28
+ /** The line of the `mod` a test-only `cfg` at `index` gates, or null when it gates no module. */
29
+ function testModuleStart(lines , index ) {
30
+ if (!needsTest(lines[index]?.trim() ?? "")) return null;
31
+ let next = index + 1;
32
+ while (/^\s*#\[/.test(lines[next] ?? "")) next++;
33
+ return /^(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+\s*[{;]/.test(lines[next] ?? "") ? next : null;
34
+ }
35
+
36
+ function needsTest(attribute ) {
37
+ const predicate = /^#\[cfg\((.*)\)\]$/.exec(attribute)?.[1]?.trim();
38
+ if (predicate === undefined) return false;
39
+ if (predicate === "test") return true;
40
+ const all = /^all\((.*)\)$/.exec(predicate)?.[1];
41
+ return all !== undefined && topLevelArguments(all).includes("test");
42
+ }
43
+
44
+ /** Splits `a, b(c, d), e` at its top-level commas. */
45
+ function topLevelArguments(text ) {
46
+ const parts = [];
47
+ let depth = 0;
48
+ let current = "";
49
+ for (const char of text) {
50
+ if (char === "," && depth === 0) {
51
+ parts.push(current.trim());
52
+ current = "";
53
+ continue;
54
+ }
55
+ if (char === "(") depth++;
56
+ if (char === ")") depth--;
57
+ current += char;
58
+ }
59
+ parts.push(current.trim());
60
+ return parts;
61
+ }
@@ -1,13 +1,14 @@
1
1
 
2
2
  import { matchesAny } from "../glob.js";
3
3
  import { budgetedText } from "./budget_text.js";
4
+ import { decisionCitationsInComments } from "./decision_comments.js";
4
5
  import { importsOf, resolveImport } from "./imports.js";
5
6
  import { countTypes } from "./type_count.js";
6
7
 
7
8
 
8
9
 
9
10
 
10
-
11
+
11
12
 
12
13
 
13
14
 
@@ -21,6 +22,8 @@ import { countTypes } from "./type_count.js";
21
22
 
22
23
 
23
24
 
25
+
26
+
24
27
 
25
28
 
26
29
 
@@ -55,11 +58,11 @@ export function checkSource(input ) {
55
58
  const lines = budgeted.split("\n").length - (budgeted.endsWith("\n") ? 1 : 0);
56
59
  if (!excepted && lines > config.check.maxLines) {
57
60
  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` });
61
+ findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines${measured}, over the budget of ${config.check.maxLines}; split it if it holds a second responsibility, or list it under check.exceptions` });
59
62
  }
60
63
  const types = countTypes(path, budgeted);
61
64
  if (!excepted && types !== null && types > config.check.maxTypes) {
62
- findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
65
+ findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}; split it if it holds a second responsibility, or list it under check.exceptions` });
63
66
  }
64
67
  if (!excepted && isRustTestFileInSource(path)) {
65
68
  findings.push({ path, rule: "test-placement", severity: "warning", message: "Rust tests that leave their inline module go in the crate's tests/ directory, not under src/" });
@@ -67,10 +70,24 @@ export function checkSource(input ) {
67
70
  if (modulePaths.length > 0 && !insideModuleMap(path, modulePaths)) {
68
71
  findings.push({ path, rule: "outside-module-map", severity: "warning", message: "the file is outside every module in the architecture's module map" });
69
72
  }
73
+ findings.push(...decisionFindings(path, text));
74
+ }
75
+ for (const [path, text] of input.tests ?? []) {
76
+ if (!sources.has(path) && (input.only === undefined || input.only.includes(path))) findings.push(...decisionFindings(path, text));
70
77
  }
71
78
  return findings;
72
79
  }
73
80
 
81
+ function decisionFindings(path , text ) {
82
+ return decisionCitationsInComments(path, text).map((citation) => ({
83
+ path,
84
+ line: citation.line + 1,
85
+ rule: "decision-citation",
86
+ severity: "warning",
87
+ message: `the comment cites ${citation.text}, a decision number a reader of the code can't follow; say what was decided, or cite the element or ADR that holds it`,
88
+ }));
89
+ }
90
+
74
91
  /** Whether a module's Path covers the file: a directory path ending in `/` covers everything under it, any other names one file. */
75
92
  export function insideModuleMap(path , modulePaths ) {
76
93
  return modulePaths.some((module) => (module.endsWith("/") ? path.startsWith(module) : path === module));
@@ -0,0 +1,38 @@
1
+ import { findDecisionCitations, } from "../decision_citations.js";
2
+
3
+ const HASH_COMMENTS = new Set(["py", "rb", "sh", "bash", "zsh", "pl", "r", "ex", "exs", "jl", "nim", "cr", "ps1"]);
4
+ const DASH_COMMENTS = new Set(["sql", "lua", "hs", "elm", "ada"]);
5
+
6
+ /**
7
+ * Decisions a source file's comments cite by number, such as `(D22)` or `(events D23)`. Each work
8
+ * item numbers its decisions from D1 and is archived when it finishes, so a number in code points
9
+ * at nothing a reader can find.
10
+ */
11
+ export function decisionCitationsInComments(path , text ) {
12
+ const comments = commentText(path, text.split("\n"));
13
+ return findDecisionCitations(comments, new Set());
14
+ }
15
+
16
+ /** Each line with everything but its comment blanked, so a citation's line number stays right. */
17
+ function commentText(path , lines ) {
18
+ const extension = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
19
+ if (HASH_COMMENTS.has(extension)) return lines.map((line) => after(line, /(^|\s)#/));
20
+ if (DASH_COMMENTS.has(extension)) return lines.map((line) => after(line, /--/));
21
+ let inBlock = false;
22
+ return lines.map((line) => {
23
+ if (inBlock) {
24
+ if (line.includes("*/")) inBlock = false;
25
+ return line;
26
+ }
27
+ const open = line.indexOf("/*");
28
+ if (open !== -1 && !line.includes("*/", open + 2)) inBlock = true;
29
+ const slash = line.indexOf("//");
30
+ const start = [open, slash].filter((at) => at !== -1).sort((a, b) => a - b)[0];
31
+ return start === undefined ? "" : line.slice(start);
32
+ });
33
+ }
34
+
35
+ function after(line , marker ) {
36
+ const match = marker.exec(line);
37
+ return match === null ? "" : line.slice(match.index);
38
+ }
@@ -26,6 +26,8 @@ import { parse as parseYaml } from "yaml";
26
26
 
27
27
 
28
28
 
29
+
30
+
29
31
 
30
32
 
31
33
  /** Which review findings the user decides: only the exceptions, or every one. */
@@ -132,6 +134,15 @@ export function parseConfig(text ) {
132
134
  }),
133
135
  };
134
136
 
137
+ const references = (Array.isArray(root["references"]) ? root["references"] : root["references"] === undefined ? [] : [null]).flatMap((entry , i ) => {
138
+ const record = asRecord(entry);
139
+ if (typeof record?.["path"] !== "string" || record["path"].trim() === "" || typeof record["use"] !== "string" || record["use"].trim() === "") {
140
+ problems.push(`references[${i}] needs a path and a non-empty use`);
141
+ return [];
142
+ }
143
+ return [{ path: record["path"], use: record["use"] }];
144
+ });
145
+
135
146
  const config = {
136
147
  version: 1,
137
148
  ...(typeof cruze === "string" && cruze.trim() !== "" ? { cruze } : {}),
@@ -148,6 +159,7 @@ export function parseConfig(text ) {
148
159
  decide: decide === "all" ? "all" : "exceptions",
149
160
  },
150
161
  changes,
162
+ references,
151
163
  };
152
164
  return { config, problems };
153
165
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Settled decisions are numbered `D<n>` within the document that records them: the architecture's
3
+ * `## Decisions`, or one feature or change, which is archived when it finishes. A number cited
4
+ * anywhere else either dangles or silently means another document's decision.
5
+ */
6
+
7
+ /** One citation of decisions by number, such as `D5`, `D5, D19` or `events D17`. */
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+ const SLUG = "[a-z0-9]+(?:-[a-z0-9]+)*";
18
+ const CITATION = new RegExp(`(?:(?<![\\w-])(${SLUG})\\s+)?(?<![\\w-])(D\\d+(?:\\s*(?:,|and)\\s*D\\d+)*)(?![\\w-])(?:\\s+of\\s+(${SLUG}))?`, "g");
19
+ const DEFINITION = /^\s*- D(\d+):/;
20
+
21
+ /**
22
+ * Every citation of decisions by number in the lines given, skipping the `- D<n>:` that defines one
23
+ * and anything inside backticks. `slugs` are the work items a citation may name.
24
+ */
25
+ export function findDecisionCitations(lines , slugs , skip = () => false) {
26
+ const citations = [];
27
+ lines.forEach((raw, line) => {
28
+ if (skip(line)) return;
29
+ const text = raw.replace(DEFINITION, (m) => " ".repeat(m.length)).replace(/`[^`]*`/g, (m) => " ".repeat(m.length));
30
+ for (const match of text.matchAll(CITATION)) {
31
+ const before = match[1] !== undefined && slugs.has(match[1]) ? match[1] : undefined;
32
+ const after = match[3] !== undefined && slugs.has(match[3]) ? match[3] : undefined;
33
+ const numbers = [...(match[2] ?? "").matchAll(/D(\d+)/g)].map((m) => Number(m[1]));
34
+ const qualifier = before ?? after;
35
+ const cited = qualifier === undefined ? (match[2] ?? "") : (match[0] ?? "").trim();
36
+ citations.push(qualifier === undefined ? { line, text: cited, numbers } : { line, text: cited, numbers, qualifier });
37
+ }
38
+ });
39
+ return citations;
40
+ }
41
+
42
+ /** The decisions a `## Decisions` section, or any lines, define as `- D<n>: ...`. */
43
+ export function definedDecisions(lines ) {
44
+ return new Set(lines.flatMap((line) => {
45
+ const match = DEFINITION.exec(line);
46
+ return match?.[1] === undefined ? [] : [Number(match[1])];
47
+ }));
48
+ }
49
+
50
+ /**
51
+ * The names a citation may give a work item: a feature's slug with and without its date, and a
52
+ * change's folder name, such as `events`, `2026-10-08-events` and `02-links`.
53
+ */
54
+ export function workItemSlugs(folderNames ) {
55
+ const slugs = new Set ();
56
+ for (const name of folderNames) {
57
+ slugs.add(name);
58
+ const undated = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
59
+ if (undated !== "") slugs.add(undated);
60
+ }
61
+ return slugs;
62
+ }
@@ -1,4 +1,5 @@
1
1
  import { factsOf, statusOf } from "../elements.js";
2
+ import { findSection, parseMarkdown } from "../markdown.js";
2
3
 
3
4
 
4
5
  /**
@@ -19,9 +20,22 @@ export function renderAgentsFile(text , view ) {
19
20
  return next;
20
21
  }
21
22
 
23
+ /** The lines under `## Known pitfalls`, one per pitfall, which the feedback export carries back to Cruze. */
24
+ export function knownPitfalls(text ) {
25
+ const doc = parseMarkdown(text);
26
+ const range = findSection(doc, "Known pitfalls");
27
+ if (range === null) return [];
28
+ return doc.lines.slice(range.start, range.end).filter((line) => line.startsWith("- ")).map((line) => line.slice(2).trim());
29
+ }
30
+
31
+ /**
32
+ * The modules whose code exists: those built, and those a land built before a rethink set them
33
+ * back to `planned` for a change to rebuild, since their directories are still there.
34
+ */
22
35
  function builtModules(view ) {
36
+ const everBuilt = new Set(view.journal.filter((e) => e.event === "land").flatMap((e) => (Array.isArray(e["built"]) ? (e["built"] ).map(String) : [])));
23
37
  return [...view.living.elements.values()]
24
- .filter(({ element, doc }) => element.kind === "MOD" && statusOf(doc, element) === "built")
38
+ .filter(({ element, doc }) => element.kind === "MOD" && (statusOf(doc, element) === "built" || everBuilt.has(element.id)))
25
39
  .map(({ element, doc }) => {
26
40
  const facts = factsOf(doc, element);
27
41
  const paths = (facts.get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => `\`${m[1] ?? ""}\``));
@@ -1,8 +1,10 @@
1
1
  /** Element IDs: `<KIND>-<name>` or `<KIND>-<scope>.<name>`, named and never reused. */
2
2
 
3
- export const KINDS = ["GOAL", "CTX", "ENT", "UC", "PORT", "ADP", "FLOW", "MOD", "RULE", "VIEW", "XC", "REQ", "SCN", "ADR"] ;
3
+ export const KINDS = ["GOAL", "PRINCIPLE", "LATER", "CTX", "ENT", "UC", "PORT", "ADP", "FLOW", "MOD", "RULE", "VIEW", "XC", "REQ", "SCN", "ADR"] ;
4
4
 
5
5
 
6
+ /** Kinds of the vision's row elements: goals, the principles every design follows, and what must stay possible later. */
7
+ export const VISION_KINDS = ["GOAL", "PRINCIPLE", "LATER"];
6
8
  /** Kinds that describe code, and so carry a managed planned/built status. */
7
9
  export const CODE_KINDS = ["ENT", "UC", "PORT", "ADP", "FLOW", "MOD"];
8
10
  /** Kinds whose elements carry a managed status: code kinds and scenarios. */
@@ -16,6 +16,7 @@ export const RECORDED_EVENTS = {
16
16
  bug: ["summary", "cause"],
17
17
  verification: ["result", "summary"],
18
18
  checks: ["commit"],
19
+ lesson: ["summary", "instead", "step"],
19
20
  };
20
21
 
21
22
  /** Fields an event may carry beyond its required ones, checked when present. A defect rethink must say which step missed it. */
@@ -23,6 +24,7 @@ const OPTIONAL_FIELDS = {
23
24
  rethink: ["missed_by"],
24
25
  review: ["nits", "checked_by"],
25
26
  disposition: ["decided"],
27
+ verification: ["back_to"],
26
28
  };
27
29
 
28
30
  /** The workflow steps and checks that find or miss a problem, so a retro can count them. */
@@ -31,15 +33,26 @@ export const STEPS = ["envision", "architect", "research", "design-review", "wal
31
33
  export const FIELD_VALUES = {
32
34
  level: ["task", "change", "feature", "architecture", "vision"],
33
35
  kind: ["defect", "discovery"],
34
- disposition: ["fixed", "waived", "deferred", "rejected"],
36
+ disposition: ["fixed", "waived", "deferred", "rejected", "rethink"],
35
37
  decided: ["agent", "user"],
36
38
  review: ["design", "plan", "code", "realign", "improve"],
37
39
  result: ["accepted", "sent-back"],
40
+ back_to: ["build", "rethink"],
38
41
  checked_by: ["agent", "reviewer"],
39
42
  found_by: STEPS,
40
43
  missed_by: STEPS,
44
+ step: STEPS,
41
45
  };
42
46
 
47
+ /** A defect rethink must say which step missed it, and a sent-back verification where the change goes back to. */
48
+ function conditionallyRequired(event , fields ) {
49
+ if (event === "rethink" && fields["kind"] === "defect") return ["missed_by"];
50
+ if (event === "verification" && fields["result"] === "sent-back") return ["back_to"];
51
+ return [];
52
+ }
53
+
54
+ const CONDITION_NOTES = { missed_by: " for a defect", back_to: " when the change is sent back" };
55
+
43
56
  /** Fields recorded as numbers rather than text. */
44
57
  const COUNT_FIELDS = ["round", "blockers", "concerns", "nits"];
45
58
 
@@ -71,8 +84,8 @@ export function serializeEntry(entry ) {
71
84
  export function recordedEventProblems(event , fields ) {
72
85
  const required = RECORDED_EVENTS[event];
73
86
  if (required === undefined) return [`unknown event "${event}"; recordable events: ${Object.keys(RECORDED_EVENTS).join(", ")}`];
74
- const needed = event === "rethink" && fields["kind"] === "defect" ? [...required, "missed_by"] : required;
75
- const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${field === "missed_by" ? " for a defect" : ""}`);
87
+ const needed = [...required, ...conditionallyRequired(event, fields)];
88
+ const problems = needed.filter((field) => (fields[field] ?? "").trim() === "").map((field) => `${event} needs ${field}${CONDITION_NOTES[field] ?? ""}`);
76
89
  const known = [...required, ...(OPTIONAL_FIELDS[event] ?? [])];
77
90
  for (const field of known) {
78
91
  const value = fields[field];