@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 { headingElements, rowElements, type Element } from "../elements.ts";
2
+ import { VISION_KINDS } from "../ids.ts";
2
3
  import { parseMarkdown, type MarkdownDoc } from "../markdown.ts";
3
4
  import { PATHS, childFiles, type Snapshot } from "./layout.ts";
4
5
 
@@ -49,6 +50,6 @@ function elementsOf(path: string, doc: MarkdownDoc): Element[] {
49
50
  if (path.startsWith(`${PATHS.adrDir}/`)) {
50
51
  return [{ id: adrId(path), kind: "ADR", title: doc.headings[0]?.text ?? "", form: "heading", level: 1, start: 0, end: doc.lines.length }];
51
52
  }
52
- if (path === PATHS.vision) return rowElements(doc).filter((element) => element.kind === "GOAL");
53
+ if (path === PATHS.vision) return rowElements(doc).filter((element) => VISION_KINDS.includes(element.kind));
53
54
  return headingElements(doc);
54
55
  }
@@ -0,0 +1,74 @@
1
+ import { findSection, parseMarkdown } from "../markdown.ts";
2
+ import { tablesIn } from "../markdown_tables.ts";
3
+ import { PATHS } from "./layout.ts";
4
+ import type { Snapshot } from "./layout.ts";
5
+
6
+ /**
7
+ * Notes under `.cruze/notes/`: what the user brought, what explore captured, the evidence research
8
+ * gathered, and the hand-off envision wrote from them. Each says what it is about, so a step reads
9
+ * the notes for its work rather than guessing from file names: one project held 21 notes, and a
10
+ * 9,500-word seed whose design sections no step was told to read.
11
+ */
12
+
13
+ export const NOTE_KINDS = ["input", "intake", "explore", "research"] as const;
14
+ export type NoteKind = (typeof NOTE_KINDS)[number];
15
+
16
+ /** The step an intake row hands a part to, besides a feature on the map. */
17
+ export const HANDOFF_STEPS = ["vision", "architect", "roadmap", "user"] as const;
18
+
19
+ export interface HandoffRow {
20
+ /** The part of the input, by its heading or a short quote. */
21
+ part: string;
22
+ /** Where it is: a note path and heading, or "below" for text saved in the intake note. */
23
+ source: string;
24
+ /** A step from HANDOFF_STEPS, or the slug of the feature that takes it up. */
25
+ goesTo: string;
26
+ /** `open` until the step that takes it up settles it with the user: `settled` or `dropped`. */
27
+ status: string;
28
+ }
29
+
30
+ export interface Note {
31
+ path: string;
32
+ title: string;
33
+ kind?: string;
34
+ /** `project`, feature slugs, or context and capability names. */
35
+ about: string[];
36
+ handoff: HandoffRow[];
37
+ }
38
+
39
+ export function readNotes(snapshot: Snapshot): Note[] {
40
+ return [...snapshot.keys()]
41
+ .filter((path) => path.startsWith(`${PATHS.notesDir}/`) && path.endsWith(".md"))
42
+ .sort()
43
+ .map((path) => readNote(path, snapshot.get(path) ?? ""));
44
+ }
45
+
46
+ function readNote(path: string, text: string): Note {
47
+ const doc = parseMarkdown(text);
48
+ const title = doc.headings.find((h) => h.level === 1)?.text ?? path.slice(path.lastIndexOf("/") + 1, -".md".length);
49
+ const firstSection = doc.headings.find((h) => h.level === 2)?.line ?? doc.lines.length;
50
+ const fact = (key: string): string | undefined =>
51
+ doc.lines.slice(0, firstSection).map((line) => new RegExp(`^- ${key}: (.+)$`).exec(line)?.[1]?.trim()).find((v) => v !== undefined);
52
+ const about = (fact("About") ?? "").split(",").map((a) => a.trim()).filter((a) => a !== "");
53
+ const kind = fact("Kind");
54
+ const section = findSection(doc, "Hand-off");
55
+ const rows = section === null ? [] : (tablesIn(doc.lines, doc.inFence, section.start, section.end)[0]?.rows ?? []);
56
+ const handoff = rows.map(({ cells }) => ({ part: cells[0] ?? "", source: cells[1] ?? "", goesTo: (cells[2] ?? "").replace(/`/g, ""), status: cells[3] ?? "open" }));
57
+ return { path, title, about, handoff, ...(kind === undefined ? {} : { kind }) };
58
+ }
59
+
60
+ export interface NotesFor {
61
+ /** Notes about one of the targets, or about the whole project when it is a target. */
62
+ notes: Note[];
63
+ /** Intake rows handed to one of the targets, with the intake note that holds them. */
64
+ handoffs: Array<HandoffRow & { note: string }>;
65
+ }
66
+
67
+ /** The notes and hand-off rows for a piece of work, named by its targets: `project`, a step, a feature slug or a context. */
68
+ export function notesFor(notes: Note[], targets: string[]): NotesFor {
69
+ const wanted = new Set(targets);
70
+ return {
71
+ notes: notes.filter((note) => note.about.some((a) => wanted.has(a))),
72
+ handoffs: notes.flatMap((note) => note.handoff.filter((row) => wanted.has(row.goesTo)).map((row) => ({ ...row, note: note.path }))),
73
+ };
74
+ }
@@ -36,8 +36,10 @@ export interface ScopeView {
36
36
  scenarios: ShownElement[];
37
37
  /** Elements the shown bodies cite but don't include, with where to read them. */
38
38
  cited: CitedElement[];
39
- /** The settled decisions of the feature, then of the change. */
39
+ /** The settled decisions of the feature, or of a standalone change. */
40
40
  decisions: string[];
41
+ /** A feature's change's own settled decisions, numbered apart from the feature's. */
42
+ changeDecisions: string[];
41
43
  /** Scope IDs defined nowhere: the delta, the living docs or the archive. */
42
44
  missing: string[];
43
45
  }
@@ -66,7 +68,7 @@ export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
66
68
  return [shown];
67
69
  });
68
70
  const elements = show(wanted.elements);
69
- const scenarios = show(wanted.scenarios);
71
+ const scenarios = withoutRepeatedScenarios(show(wanted.scenarios));
70
72
  const shownIds = new Set([...elements, ...scenarios].map((e) => e.id));
71
73
  const cited = [...new Set([...elements, ...scenarios].flatMap((e) => findIds(e.body.join("\n"))))]
72
74
  .filter((id) => !shownIds.has(id))
@@ -74,12 +76,26 @@ export function scopeView(view: ProjectView, item: WorkItem): ScopeView {
74
76
  const found = locate(id);
75
77
  return found === undefined ? [] : [{ id, title: found.element.title, source: `${found.path}:${found.element.start + 1}` }];
76
78
  });
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
+ const decisions = decisionLines((feature ?? item).doc);
80
+ const changeDecisions = feature === undefined ? [] : decisionLines(item.doc);
81
+ const result: ScopeView = { ref: item.ref, kind: item.kind, elements, scenarios, cited, decisions, changeDecisions, missing };
79
82
  if (feature !== undefined) result.feature = feature.ref;
80
83
  return result;
81
84
  }
82
85
 
86
+ /**
87
+ * A requirement shown beside its own scenarios keeps only its own text: its body would repeat
88
+ * each of them, which made up a third of one change's output.
89
+ */
90
+ function withoutRepeatedScenarios(shown: ShownElement[]): ShownElement[] {
91
+ const ids = new Set(shown.map((e) => e.id));
92
+ return shown.map((element) => {
93
+ if (!element.id.startsWith("REQ-")) return element;
94
+ const first = element.body.findIndex((line, i) => i > 0 && /^#{2,6} /.test(line) && findIds(line).some((id) => ids.has(id)));
95
+ return first === -1 ? element : { ...element, body: element.body.slice(0, first) };
96
+ });
97
+ }
98
+
83
99
  /** A change shows what its scope builds, removes and delivers. */
84
100
  function changeIds(change: WorkItem): { elements: string[]; scenarios: string[] } {
85
101
  const scope = readScope(change.doc);
@@ -0,0 +1,36 @@
1
+ import type { SkillFiles } from "./skill_links.ts";
2
+
3
+ /**
4
+ * The words one instruction file may hold. A longer file holds material only some runs need, so it
5
+ * splits by branch, as `docs/contributing/writing-skills.md` says, and each run reads only its part.
6
+ */
7
+ export const MAX_FILE_WORDS = 2100;
8
+
9
+ /**
10
+ * The words agents may read across the whole bundle. Every retro adds rules, and between 0.0.1 and
11
+ * 0.0.10 the skills grew by half while nothing was removed. Lower this when text is cut. Raising it
12
+ * is the maintainer's decision, with the reason in the commit that raises it.
13
+ */
14
+ export const BUNDLE_WORD_BUDGET = 44500;
15
+
16
+ /** Data the CLI reads or renders, rather than instructions an agent reads. */
17
+ const NOT_INSTRUCTIONS = /(^|\/)(templates|notes)\//;
18
+
19
+ export function wordCount(text: string): number {
20
+ return text.split(/\s+/).filter((word) => word !== "").length;
21
+ }
22
+
23
+ /** Instruction files over their limit, and the bundle when it is over its budget. */
24
+ export function budgetProblems(skills: SkillFiles[]): string[] {
25
+ const files = skills.flatMap((skill) =>
26
+ skill.files.filter((f) => f.path.endsWith(".md") && !NOT_INSTRUCTIONS.test(f.path)).map((f) => ({ path: `${skill.folder}/${f.path}`, words: wordCount(f.text) })),
27
+ );
28
+ const problems = files
29
+ .filter((f) => f.words > MAX_FILE_WORDS)
30
+ .map((f) => `${f.path}: ${f.words} words, over ${MAX_FILE_WORDS}; move what only some runs need into a sibling file`);
31
+ const total = files.reduce((sum, f) => sum + f.words, 0);
32
+ if (total > BUNDLE_WORD_BUDGET) {
33
+ problems.push(`the skills hold ${total} words, over the budget of ${BUNDLE_WORD_BUDGET}; cut or merge text to make room, or raise the budget with the reason in the commit`);
34
+ }
35
+ return problems;
36
+ }
@@ -1,4 +1,5 @@
1
- import { evaluateApproval } from "../approvals/evaluate.ts";
1
+ import { evaluateApproval, type ApprovalStatus } from "../approvals/evaluate.ts";
2
+ import { rebaseNeeded } from "../approvals/rebase.ts";
2
3
  import { PATHS } from "../project/layout.ts";
3
4
  import { isPlanned, readChangesTable } from "../project/plan_parts.ts";
4
5
  import { readProgress } from "../project/progress.ts";
@@ -7,7 +8,8 @@ import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.ts";
7
8
  import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
8
9
  import { compareVersions } from "../versions/cruze_version.ts";
9
10
  import { FIRST_RELEASE } from "../versions/rule_versions.ts";
10
- import { isVerified } from "./verification.ts";
11
+ import { pendingReason, pendingRethinks } from "./pending_rethink.ts";
12
+ import { awaitsRethink, isVerified } from "./verification.ts";
11
13
  import { activeChange } from "./work_status.ts";
12
14
 
13
15
  /** The Cruze step to run next, computed from the project's state and the current branch. */
@@ -31,7 +33,7 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
31
33
  const project = projectStep(view);
32
34
  if (project !== null) return { next: project, also: [] };
33
35
  const active = activeChange(view, branch);
34
- const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
36
+ const inFlight = inRoadmapOrder(view, view.items.filter((item) => !item.archived && item.kind !== "change")).flatMap((item) => itemSteps(view, item, branch));
35
37
  const roadmap = roadmapStep(view);
36
38
  const realign = realignStep(view);
37
39
  const candidates = [
@@ -45,6 +47,28 @@ export function nextSteps(view: ProjectView, branch: string | null): NextReport
45
47
  return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
46
48
  }
47
49
 
50
+ /**
51
+ * Work in flight in the roadmap's order, with items whose roadmap blockers haven't all landed
52
+ * last: a feature designed early still waits for the prefactors the user put before it.
53
+ */
54
+ function inRoadmapOrder(view: ProjectView, items: WorkItem[]): WorkItem[] {
55
+ const text = view.snapshot.get(PATHS.roadmap);
56
+ if (text === undefined) return items;
57
+ const rows = readRoadmapStatus(text);
58
+ const state = new Map(rows.map((row) => [row.slug, row.state]));
59
+ const slugOf = new Map(rows.map((row) => [row.id, row.slug]));
60
+ const roadmap = readRoadmapItems(text);
61
+ const rank = (item: WorkItem): [number, number] => {
62
+ const at = roadmap.findIndex((r) => r.slug === slugOf.get(item.ref));
63
+ const blocked = at !== -1 && (roadmap[at]?.blockedBy ?? []).some((b) => state.get(b) !== "landed");
64
+ return [blocked ? 1 : 0, at === -1 ? roadmap.length : at];
65
+ };
66
+ return items
67
+ .map((item, i) => ({ item, key: [...rank(item), i] }))
68
+ .sort((a, b) => (a.key[0] ?? 0) - (b.key[0] ?? 0) || (a.key[1] ?? 0) - (b.key[1] ?? 0) || (a.key[2] ?? 0) - (b.key[2] ?? 0))
69
+ .map(({ item }) => item);
70
+ }
71
+
48
72
  /** Realign comes when nothing else is due: the installed skills expect more than the code meets. */
49
73
  function realignStep(view: ProjectView): NextStep | null {
50
74
  const config = view.config?.config;
@@ -90,7 +114,9 @@ function itemSteps(view: ProjectView, item: WorkItem, branch: string | null): Ne
90
114
  const state = evaluateApproval(view, item.path);
91
115
  if (readChangesTable(item.doc).length === 0) return [{ step: "architect", target: item.ref, reason: "the feature has no design or changes yet" }];
92
116
  if (state.state === "unapproved") return [{ step: "architect", target: item.ref, reason: "review the feature's design and approve it" }];
93
- if (state.state !== "approved") return [{ step: "rethink", target: item.ref, reason: `the feature changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` }];
117
+ if (state.state !== "approved") return [{ step: "rethink", target: item.ref, reason: staleReason(view, item, "the feature", state) }];
118
+ const pending = pendingRethinks(view.journal, item);
119
+ if (pending.length > 0) return [{ step: "rethink", target: item.ref, reason: pendingReason(item, pending) }];
94
120
  const changes = changesOf(view.items, item);
95
121
  const landed = new Set(changes.filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
96
122
  const steps: NextStep[] = [];
@@ -109,14 +135,18 @@ function changeStep(view: ProjectView, change: WorkItem, branch: string | null):
109
135
  const feature = featureOf(view.items, change);
110
136
  const featureState = feature === undefined ? undefined : evaluateApproval(view, feature.path);
111
137
  if (feature !== undefined && featureState !== undefined && featureState.state !== "approved") {
112
- const why = featureState.changed.length > 0 ? ` (${featureState.changed.join(", ")})` : "";
113
- return { step: "rethink", target: feature.ref, reason: `the feature of ${change.ref} changed since its approval${why}` };
138
+ return { step: "rethink", target: feature.ref, reason: staleReason(view, feature, `the feature of ${change.ref}`, featureState) };
139
+ }
140
+ for (const part of [feature, change]) {
141
+ const pending = part === undefined ? [] : pendingRethinks(view.journal, part);
142
+ if (part !== undefined && pending.length > 0) return { step: "rethink", target: part.ref, reason: pendingReason(part, pending) };
114
143
  }
115
144
  const bound = readProgress(change.doc).branch;
116
145
  const state = evaluateApproval(view, change.path);
117
146
  if (!isPlanned(change.doc)) return { step: "plan", target: change.ref, reason: "the change is designed but has no test plan or tasks yet" };
118
147
  if (state.state === "unapproved") return { step: "plan", target: change.ref, reason: "review the plan and approve it on the branch that will build it" };
119
- if (state.state !== "approved") return { step: "rethink", target: change.ref, reason: `the change or something it cites changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` };
148
+ if (state.state !== "approved") return { step: "rethink", target: change.ref, reason: staleReason(view, change, "the change", state) };
149
+ if (awaitsRethink(view.journal, change, feature)) return { step: "rethink", target: feature?.ref ?? change.ref, reason: `verify sent ${change.ref} back for a design change` };
120
150
  if (bound !== undefined && bound !== branch) return { step: "switch-branch", target: bound, reason: `${change.ref} is built on branch ${bound}` };
121
151
  const progress = readProgress(change.doc);
122
152
  if ([...progress.tasks.values()].some((commit) => commit === null)) return { step: "build", target: change.ref, reason: "the change has open tasks" };
@@ -124,6 +154,21 @@ function changeStep(view: ProjectView, change: WorkItem, branch: string | null):
124
154
  return { step: "land", target: change.ref, reason: "the change is verified and accepted" };
125
155
  }
126
156
 
157
+ /**
158
+ * Why work went stale, and what the rethink will take: a rebase when its delta still changes an
159
+ * element whose living text moved, or only a check of the diff and a re-stamp when nothing it cites
160
+ * asks anything of its own text. One project expected a re-stamp and met rebase-required.
161
+ */
162
+ function staleReason(view: ProjectView, item: WorkItem, label: string, state: ApprovalStatus): string {
163
+ if (state.state === "edited") return `${label} changed since its approval`;
164
+ const changed = state.changed.join(", ");
165
+ const rebase = rebaseNeeded(view, item);
166
+ const then = rebase.length > 0
167
+ ? `its delta changes ${rebase.join(", ")}, so rebase it, as cruze diff ${item.ref} shows`
168
+ : `read cruze diff ${item.ref}, and re-stamp it when the work still holds`;
169
+ return `something ${label} cites changed since its approval (${changed}); ${then}`;
170
+ }
171
+
127
172
  /** The first roadmap item that hasn't started and whose blockers have all landed. */
128
173
  function roadmapStep(view: ProjectView): NextStep | null {
129
174
  const text = view.snapshot.get(PATHS.roadmap);
@@ -0,0 +1,22 @@
1
+ import type { JournalEntry } from "../journal.ts";
2
+ import type { WorkItem } from "../project/work_items.ts";
3
+
4
+ /**
5
+ * Findings decided `rethink` for a feature or change that no rethink of it has followed yet.
6
+ * One project's realign recorded seven design findings on a feature as fixed "by a rethink before
7
+ * its next change is planned"; nothing showed them, and they surfaced only in a commit message.
8
+ */
9
+ export function pendingRethinks(journal: JournalEntry[], item: WorkItem): JournalEntry[] {
10
+ const lastRethink = journal
11
+ .filter((e) => e.event === "rethink" && e["item"] === item.ref)
12
+ .map((e) => e.at)
13
+ .sort()
14
+ .at(-1);
15
+ return journal.filter((e) => e.event === "disposition" && e["disposition"] === "rethink" && e["item"] === item.ref && (lastRethink === undefined || e.at > lastRethink));
16
+ }
17
+
18
+ /** Why work waits on a rethink, naming the first finding. */
19
+ export function pendingReason(item: WorkItem, pending: JournalEntry[]): string {
20
+ const more = pending.length > 1 ? ` and ${pending.length - 1} more` : "";
21
+ return `${pending.length} finding(s) on ${item.ref} were decided for a rethink that hasn't happened yet: ${String(pending[0]?.["finding"] ?? "")}${more}`;
22
+ }
@@ -15,3 +15,15 @@ export function isVerified(journal: JournalEntry[], change: WorkItem): boolean {
15
15
  const verifiedHash = approvals.filter((e) => e.at < verification.at).at(-1)?.["hash"];
16
16
  return approvals.filter((e) => e.at > verification.at).every((e) => e["hash"] === verifiedHash);
17
17
  }
18
+
19
+ /**
20
+ * Whether verify sent the change back for a rethink that hasn't been recorded yet. Tasks the
21
+ * agent reopens at verify would otherwise make the change look ready to build again.
22
+ */
23
+ export function awaitsRethink(journal: JournalEntry[], change: WorkItem, feature: WorkItem | undefined): boolean {
24
+ const verification = journal.filter((e) => e.event === "verification" && e["item"] === change.ref).sort((a, b) => a.at.localeCompare(b.at)).at(-1);
25
+ if (verification?.["result"] !== "sent-back" || verification["back_to"] !== "rethink") return false;
26
+ // An architecture-level rethink records no item, and counts too.
27
+ const refs = new Set([change.ref, feature?.ref, undefined]);
28
+ return !journal.some((e) => e.event === "rethink" && e.at > verification.at && refs.has(e["item"] as string | undefined));
29
+ }
@@ -5,6 +5,7 @@ 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";
8
+ import { pendingReason, pendingRethinks } from "./pending_rethink.ts";
8
9
 
9
10
  export interface ChangeStatus {
10
11
  ref: string;
@@ -15,6 +16,8 @@ export interface ChangeStatus {
15
16
  tasksTotal: number;
16
17
  /** When and in which commit the change landed, once it has. */
17
18
  landed?: string;
19
+ /** Findings decided for a rethink of this change that hasn't happened yet. */
20
+ rethinkPending?: string[];
18
21
  }
19
22
 
20
23
  export interface FeatureStatus {
@@ -22,6 +25,8 @@ export interface FeatureStatus {
22
25
  path: string;
23
26
  approval: ApprovalStatus;
24
27
  changes: ChangeStatus[];
28
+ /** Findings decided for a rethink of this feature that hasn't happened yet. */
29
+ rethinkPending?: string[];
25
30
  }
26
31
 
27
32
  export interface ProjectStatus {
@@ -42,6 +47,7 @@ export function projectStatus(view: ProjectView, branch: string | null): Project
42
47
  path: feature.path,
43
48
  approval: evaluateApproval(view, feature.path),
44
49
  changes: changesOf(view.items, feature).map((change) => changeStatus(view, change)),
50
+ ...withPending(view, feature),
45
51
  }));
46
52
  const standalone = active.filter((item) => item.kind === "standalone").map((change) => changeStatus(view, change));
47
53
  const status: ProjectStatus = { documents, features, standalone };
@@ -69,7 +75,12 @@ function changeStatus(view: ProjectView, change: WorkItem): ChangeStatus {
69
75
  };
70
76
  if (progress.branch !== undefined) status.branch = progress.branch;
71
77
  if (progress.landed !== undefined) status.landed = progress.landed;
72
- return status;
78
+ return { ...status, ...withPending(view, change) };
79
+ }
80
+
81
+ function withPending(view: ProjectView, item: WorkItem): { rethinkPending?: string[] } {
82
+ const pending = pendingRethinks(view.journal, item);
83
+ return pending.length === 0 ? {} : { rethinkPending: pending.map((e) => String(e["finding"] ?? "")) };
73
84
  }
74
85
 
75
86
  /** Why building may not start on this branch; empty when the build gate passes. */
@@ -86,7 +97,11 @@ export function buildGateReasons(view: ProjectView, branch: string | null): stri
86
97
  const detail = status.changed.length > 0 ? `: ${status.changed.join(", ")} changed` : status.unjournaled === true ? ": the approval has no journal entry" : "";
87
98
  return [`${path} is ${status.state}${detail}`];
88
99
  });
89
- return [...approvals, ...unlandedDependencies(view, change, feature)];
100
+ const rethinks = [feature, change].flatMap((part) => {
101
+ const pending = part === undefined ? [] : pendingRethinks(view.journal, part);
102
+ return part === undefined || pending.length === 0 ? [] : [`${pendingReason(part, pending)}; run rethink`];
103
+ });
104
+ return [...approvals, ...rethinks, ...unlandedDependencies(view, change, feature)];
90
105
  }
91
106
 
92
107
  /** The changes this change's row in its feature's Changes table depends on that haven't landed on this branch. */
@@ -0,0 +1,76 @@
1
+ import { definedDecisions, findDecisionCitations, workItemSlugs, type DecisionCitation } from "../decision_citations.ts";
2
+ import type { Element } from "../elements.ts";
3
+ import { findSection, type MarkdownDoc } from "../markdown.ts";
4
+ import { readDelta } from "../project/deltas.ts";
5
+ import { PATHS } from "../project/layout.ts";
6
+ import type { ProjectView } from "../project/project_view.ts";
7
+ import type { WorkItem } from "../project/work_items.ts";
8
+ import { warning, type Problem } from "./problem.ts";
9
+
10
+ const INSTEAD = "write what it decided into this text, or cite the ADR that records it";
11
+
12
+ /**
13
+ * The architecture cites only the decisions its own `## Decisions` defines. The other living docs
14
+ * cite none by number, and no living doc cites a feature's or change's, which is archived when it finishes.
15
+ */
16
+ export function livingDecisionProblems(view: ProjectView): Problem[] {
17
+ const slugs = slugsOf(view);
18
+ const project = projectDecisions(view);
19
+ const problems: Problem[] = [];
20
+ for (const [path, doc] of view.living.docs) {
21
+ for (const citation of citationsIn(doc, slugs)) {
22
+ if (citation.qualifier !== undefined) {
23
+ problems.push(warning(path, citation.line, "decision-citation", `cites ${citation.text}, a decision of ${citation.qualifier}, which the living docs don't hold; ${INSTEAD}`));
24
+ } else if (path !== PATHS.architecture) {
25
+ problems.push(warning(path, citation.line, "decision-citation", `cites ${citation.text}, but only the architecture cites decisions by number, its own; ${INSTEAD}`));
26
+ } else {
27
+ const unknown = citation.numbers.filter((n) => !project.has(n));
28
+ if (unknown.length > 0) {
29
+ problems.push(warning(path, citation.line, "decision-citation", `cites ${unknown.map((n) => `D${n}`).join(", ")}, which "## Decisions" doesn't define; ${INSTEAD}`));
30
+ }
31
+ }
32
+ }
33
+ }
34
+ return problems;
35
+ }
36
+
37
+ /**
38
+ * A delta's elements merge into the living docs, where the work's own decisions don't follow them.
39
+ * They may cite a project decision the work doesn't renumber, or one the living element already cites.
40
+ */
41
+ export function deltaDecisionProblems(view: ProjectView, item: WorkItem): Problem[] {
42
+ const slugs = slugsOf(view);
43
+ const project = projectDecisions(view);
44
+ const local = definedDecisions(sectionLines(item.doc, "Settled decisions"));
45
+ const problems: Problem[] = [];
46
+ for (const { element } of readDelta(item.doc).entries) {
47
+ const living = view.living.elements.get(element.id);
48
+ const carried = new Set(living === undefined ? [] : citationsIn(living.doc, slugs, living.element).flatMap((c) => (c.qualifier === undefined ? c.numbers : [])));
49
+ const allowed = (n: number): boolean => project.has(n) && (!local.has(n) || carried.has(n));
50
+ for (const citation of citationsIn(item.doc, slugs, element)) {
51
+ if (citation.qualifier === undefined && citation.numbers.every(allowed)) continue;
52
+ problems.push(warning(item.path, citation.line, "decision-citation", `${element.id} cites ${citation.text}, which won't mean the same in the living docs once this delta merges; ${INSTEAD}`));
53
+ }
54
+ }
55
+ return problems;
56
+ }
57
+
58
+ function citationsIn(doc: MarkdownDoc, slugs: Set<string>, element?: Element): DecisionCitation[] {
59
+ const from = element === undefined ? doc.bodyStart : element.start + 1;
60
+ const to = element === undefined ? doc.lines.length : element.end;
61
+ return findDecisionCitations(doc.lines, slugs, (line) => line < from || line >= to || doc.inFence[line] === true || doc.inManaged[line] === true);
62
+ }
63
+
64
+ function projectDecisions(view: ProjectView): Set<number> {
65
+ const architecture = view.living.docs.get(PATHS.architecture);
66
+ return architecture === undefined ? new Set() : definedDecisions(sectionLines(architecture, "Decisions"));
67
+ }
68
+
69
+ function sectionLines(doc: MarkdownDoc, title: string): string[] {
70
+ const range = findSection(doc, title);
71
+ return range === null ? [] : doc.lines.slice(range.start, range.end);
72
+ }
73
+
74
+ function slugsOf(view: ProjectView): Set<string> {
75
+ return workItemSlugs(view.items.map((item) => item.folderName));
76
+ }
@@ -4,6 +4,7 @@ import { specPath } from "../project/layout.ts";
4
4
  import { orphanDeltaElements, readDelta, type Delta } from "../project/deltas.ts";
5
5
  import type { ProjectView } from "../project/project_view.ts";
6
6
  import type { WorkItem } from "../project/work_items.ts";
7
+ import { deltaDecisionProblems } from "./decision_rules.ts";
7
8
  import { elementProblems } from "./element_rules.ts";
8
9
  import { error, type Problem } from "./problem.ts";
9
10
 
@@ -44,6 +45,7 @@ export function deltaProblems(view: ProjectView, item: WorkItem): Problem[] {
44
45
  problems.push(error(path, op.start, "capability-exists", `capability ${op.capability} already has ${specPath(op.capability)}`));
45
46
  }
46
47
  }
48
+ problems.push(...deltaDecisionProblems(view, item));
47
49
  return problems;
48
50
  }
49
51
 
@@ -0,0 +1,40 @@
1
+ import { factsOf, headingElements } from "../elements.ts";
2
+ import { readRows } from "../edits/managed_tables.ts";
3
+ import { findIds, kindOf } from "../ids.ts";
4
+ import type { MarkdownDoc } from "../markdown.ts";
5
+ import { PATHS } from "../project/layout.ts";
6
+ import { readDelta } from "../project/deltas.ts";
7
+ import type { ProjectView } from "../project/project_view.ts";
8
+ import { readRoadmapItems } from "../project/roadmap.ts";
9
+ import { warning, type Problem } from "./problem.ts";
10
+
11
+ /**
12
+ * A `Future` fact keeps room for a need someone stated: a `LATER` capability of the vision, or a
13
+ * feature on the feature map or the roadmap. One that names neither reserves room for a need nobody
14
+ * stated, which the design reviewer treats as a finding; this makes it visible without a review.
15
+ */
16
+ export function futureProblems(view: ProjectView): Problem[] {
17
+ const slugs = anchorSlugs(view);
18
+ const unanchored = (text: string): boolean =>
19
+ !findIds(text).some((id) => kindOf(id) === "LATER") && ![...slugs].some((slug) => new RegExp(`(^|[^a-z0-9-])${slug}([^a-z0-9-]|$)`).test(text));
20
+ const check = (path: string, doc: MarkdownDoc, from = 0, to = doc.lines.length): Problem[] =>
21
+ headingElements(doc, from, to).flatMap((element) =>
22
+ (factsOf(doc, element).get("Future") ?? [])
23
+ .filter(unanchored)
24
+ .map(() => warning(path, element.start, "future-unanchored", `${element.id} keeps room for a need its Future fact doesn't name; cite the LATER capability, or the feature on the map or roadmap, that it serves`)),
25
+ );
26
+ const living = view.living.docs.get(PATHS.architecture);
27
+ const deltas = view.items
28
+ .filter((item) => !item.archived && item.kind !== "change")
29
+ .flatMap((item) => readDelta(item.doc).entries.flatMap((entry) => check(item.path, item.doc, entry.element.start, entry.element.end)));
30
+ return [...(living === undefined ? [] : check(PATHS.architecture, living)), ...deltas];
31
+ }
32
+
33
+ /** Feature slugs a Future fact may name: the feature map's, implemented or future, and the roadmap's items. */
34
+ function anchorSlugs(view: ProjectView): Set<string> {
35
+ const vision = view.snapshot.get(PATHS.vision) ?? "";
36
+ const roadmap = view.snapshot.get(PATHS.roadmap);
37
+ const mapped = ["Implemented", "Future"].flatMap((heading) => readRows(vision, heading).map((row) => row[0] ?? ""));
38
+ const planned = roadmap === undefined ? [] : readRoadmapItems(roadmap).map((item) => item.slug);
39
+ return new Set([...mapped, ...planned].map((slug) => slug.trim()).filter((slug) => /^[a-z0-9-]+$/.test(slug)));
40
+ }
@@ -4,6 +4,7 @@ import { findIds, scopeOf, STATUS_KINDS } from "../ids.ts";
4
4
  import type { MarkdownDoc } from "../markdown.ts";
5
5
  import { PATHS } from "../project/layout.ts";
6
6
  import type { ProjectView } from "../project/project_view.ts";
7
+ import { livingDecisionProblems } from "./decision_rules.ts";
7
8
  import { elementProblems } from "./element_rules.ts";
8
9
  import { error, warning, type Problem } from "./problem.ts";
9
10
 
@@ -26,6 +27,7 @@ export function livingProblems(view: ProjectView): Problem[] {
26
27
  if (!view.living.elements.has(id)) problems.push(error(path, lineOf(text, id), "unknown-id", `${id} is cited but not defined in the living docs`));
27
28
  }
28
29
  }
30
+ problems.push(...livingDecisionProblems(view));
29
31
  return problems;
30
32
  }
31
33
 
@@ -46,7 +46,7 @@ export function changePlanProblems(
46
46
  if (kindOf(id) === "MOD") {
47
47
  const modulePaths = modulePathsOf(view, delta.doc, delta.delta, id);
48
48
  if (!taskPaths.some((p) => modulePaths.some((m) => p.startsWith(m)))) {
49
- problems.push(error(path, scope.line, "unproved-scope", `${id} is in scope but no task writes inside ${modulePaths.join(" or ") || "its path"}`));
49
+ problems.push(error(path, scope.line, "unproved-scope", `${id} is in scope but no task writes inside ${modulePaths.join(" or ") || "its path"}; a module that owns other files, such as a build script, lists each under its Path`));
50
50
  }
51
51
  } else if (kindOf(id) === "REQ") {
52
52
  const scenarios = delta.delta.entries.find((e) => e.element.id === id)?.scenarios.map((s) => s.id) ?? [];
@@ -1,7 +1,9 @@
1
1
  import type { ProjectView } from "../project/project_view.ts";
2
+ import { futureProblems } from "./future_rules.ts";
2
3
  import { livingProblems } from "./living_rules.ts";
3
4
  import { error, sortProblems, type Problem } from "./problem.ts";
4
5
  import { configProblems, readmeProblems, roadmapProblems, templateLeftovers } from "./project_rules.ts";
6
+ import { visionProblems } from "./vision_rules.ts";
5
7
  import { workItemProblems } from "./work_item_rules.ts";
6
8
 
7
9
  /** Every rule in the cruze-formats skill, applied to the whole project. */
@@ -13,6 +15,8 @@ export function validateProject(view: ProjectView): Problem[] {
13
15
  return sortProblems([
14
16
  ...configProblems(view),
15
17
  ...livingProblems(view),
18
+ ...visionProblems(view),
19
+ ...futureProblems(view),
16
20
  ...roadmapProblems(view),
17
21
  ...workItemProblems(view),
18
22
  ...templateLeftovers(view),
@@ -0,0 +1,41 @@
1
+ import { rowElements } from "../elements.ts";
2
+ import { findSection } from "../markdown.ts";
3
+ import { splitRow } from "../markdown_tables.ts";
4
+ import { PATHS } from "../project/layout.ts";
5
+ import type { ProjectView } from "../project/project_view.ts";
6
+ import { error, type Problem } from "./problem.ts";
7
+
8
+ /** The section and the cells after the ID that each long-term row of the vision holds. */
9
+ const INTENT_ROWS: Record<string, { section: string; cells: string[] }> = {
10
+ PRINCIPLE: { section: "Principles", cells: ["Principle", "Why"] },
11
+ LATER: { section: "Must stay possible", cells: ["Capability", "Likelihood", "Why", "Leave room for"] },
12
+ };
13
+
14
+ export const LIKELIHOODS = ["likely", "possible"];
15
+
16
+ /**
17
+ * The vision's principles and the capabilities that must stay possible are rows, so designs cite
18
+ * them and approvals pin them. One project kept its principles in a seed note and its long-term
19
+ * plans in the user's head: a feature reversed a principle unnoticed, and a later review proposed
20
+ * removing what those plans needed.
21
+ */
22
+ export function visionProblems(view: ProjectView): Problem[] {
23
+ const doc = view.living.docs.get(PATHS.vision);
24
+ if (doc === undefined) return [];
25
+ return rowElements(doc).flatMap((row) => {
26
+ const shape = INTENT_ROWS[row.kind];
27
+ if (shape === undefined) return [];
28
+ const section = findSection(doc, shape.section);
29
+ if (section === null || row.start < section.start || row.start >= section.end) {
30
+ return [error(PATHS.vision, row.start, "vision-row", `${row.id} belongs in the table under ## ${shape.section}`)];
31
+ }
32
+ const cells = splitRow(doc.lines[row.start] ?? "").slice(1);
33
+ if (cells.length !== shape.cells.length || cells.some((cell) => cell === "")) {
34
+ return [error(PATHS.vision, row.start, "vision-row", `${row.id} needs ${shape.cells.join(", ")}, each filled`)];
35
+ }
36
+ if (row.kind === "LATER" && !LIKELIHOODS.includes(cells[1] ?? "")) {
37
+ return [error(PATHS.vision, row.start, "vision-row", `${row.id}'s likelihood must be ${LIKELIHOODS.join(" or ")}`)];
38
+ }
39
+ return [];
40
+ });
41
+ }
@@ -17,6 +17,9 @@ export const RULE_SINCE: Record<string, string> = {
17
17
  "validate:change-too-large": "0.0.5",
18
18
  "validate:plan-too-large": "0.0.5",
19
19
  "validate:module-path-glob": "0.0.8",
20
+ "validate:decision-citation": "0.0.10",
21
+ "check:decision-citation": "0.0.10",
22
+ "validate:future-unanchored": "0.0.10",
20
23
  };
21
24
 
22
25
  /** Whether a rule arrived after the version the project's code meets, so it waits for `realign`. */