@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,3 +1,4 @@
1
+ import { settledIds } from "../approvals/rebase.js";
1
2
  import { sectionForKind } from "../architecture_sections.js";
2
3
  import { CruzeError } from "../cruze_error.js";
3
4
  import { setStatus } from "../edits/status_lines.js";
@@ -26,7 +27,8 @@ import { appendBlock, relevel, removeElement, replaceElement } from "./text_ops.
26
27
  /**
27
28
  * Merges a change's work into the living docs. The first change of a feature to land merges
28
29
  * the whole delta; elements it doesn't build arrive as planned, and later changes flip what
29
- * they build to built. Text is replaced only when nobody else changed it since this work merged it.
30
+ * they build to built. Text is replaced only when nobody else changed it since this work merged it,
31
+ * and an element merged and unchanged in the delta since keeps its living text, whoever edited it.
30
32
  */
31
33
  export function planMerge(view , change , source ) {
32
34
  const scope = readScope(change.doc);
@@ -36,6 +38,7 @@ export function planMerge(view , change , source )
36
38
  const files = new Map ();
37
39
  const text = (path ) => files.get(path) ?? view.snapshot.get(path) ?? "";
38
40
  const plan = { files, merged, mergedIds: [], built: [], removed: [] };
41
+ const settled = settledIds(source.doc, merged);
39
42
 
40
43
  for (const op of delta.capabilities) {
41
44
  const key = capabilityKey(op.capability);
@@ -56,8 +59,10 @@ export function planMerge(view , change , source )
56
59
  }
57
60
  continue;
58
61
  }
59
- mergeEntry(view, source, entry, path, text, files, merged, plan);
62
+ const kept = settled.has(id) && view.living.elements.has(id);
63
+ if (!kept) mergeEntry(view, source, entry, path, text, files, merged, plan);
60
64
  for (const target of [entry.element, ...entry.scenarios].filter((e) => STATUS_KINDS.includes(e.kind))) {
65
+ if (kept && !inScope.has(target.id)) continue;
61
66
  files.set(path, setStatus(text(path), target.id, statusAfterMerge(view, source, target, inScope)));
62
67
  if (inScope.has(target.id)) plan.built.push(target.id);
63
68
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A line diff of two texts as unified-diff lines: " " for a kept line, "- " removed, "+ " added.
3
+ * Only the changes and `context` kept lines around each are shown; "…" marks the lines left out.
4
+ */
5
+ export function lineDiff(before , after , context = 2) {
6
+ const ops = diffOps(before, after);
7
+ const near = new Set ();
8
+ ops.forEach((op, i) => {
9
+ if (op.kind === " ") return;
10
+ for (let j = Math.max(0, i - context); j <= Math.min(ops.length - 1, i + context); j++) near.add(j);
11
+ });
12
+ const lines = [];
13
+ let skipped = false;
14
+ ops.forEach((op, i) => {
15
+ if (!near.has(i)) {
16
+ skipped = true;
17
+ return;
18
+ }
19
+ if (skipped && lines.length > 0) lines.push("…");
20
+ skipped = false;
21
+ lines.push(`${op.kind} ${op.line}`);
22
+ });
23
+ if (skipped && lines.length > 0) lines.push("…");
24
+ return lines;
25
+ }
26
+
27
+
28
+
29
+
30
+
31
+
32
+ /** The edit script of a longest common subsequence: documents are small enough for the quadratic table. */
33
+ function diffOps(a , b ) {
34
+ const width = b.length + 1;
35
+ const table = new Int32Array((a.length + 1) * width);
36
+ const at = (i , j ) => table[i * width + j] ?? 0;
37
+ for (let i = a.length - 1; i >= 0; i--) {
38
+ for (let j = b.length - 1; j >= 0; j--) {
39
+ table[i * width + j] = a[i] === b[j] ? at(i + 1, j + 1) + 1 : Math.max(at(i + 1, j), at(i, j + 1));
40
+ }
41
+ }
42
+ const ops = [];
43
+ let i = 0;
44
+ let j = 0;
45
+ while (i < a.length || j < b.length) {
46
+ if (i < a.length && j < b.length && a[i] === b[j]) {
47
+ ops.push({ kind: " ", line: a[i++] ?? "" });
48
+ j++;
49
+ } else if (j >= b.length || (i < a.length && at(i + 1, j) >= at(i, j + 1))) {
50
+ ops.push({ kind: "-", line: a[i++] ?? "" });
51
+ } else {
52
+ ops.push({ kind: "+", line: b[j++] ?? "" });
53
+ }
54
+ }
55
+ return ops;
56
+ }
@@ -0,0 +1,124 @@
1
+ import { elementLines, factsOf, statusOf, } from "../elements.js";
2
+ import { findIds, kindOf, scopeOf, } from "../ids.js";
3
+
4
+ import { readDelta } from "./deltas.js";
5
+ import { PATHS } from "./layout.js";
6
+
7
+
8
+
9
+
10
+ /**
11
+ * Elements one at a time, and an index of all of them, so a step that needs a few elements of a
12
+ * large architecture fetches those instead of reading the whole file. One project's architecture
13
+ * held 52k words, and a single architect run read all of it to find what it would extend.
14
+ */
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+ const DOCS_INDEXED = (path ) => path === PATHS.architecture || path.startsWith(`${PATHS.specsDir}/`);
40
+
41
+ /**
42
+ * Every element of the living architecture and specs, one entry each, with what in-flight work
43
+ * adds. Without a scope, scenarios are counted under their requirement; with a context or
44
+ * capability name, only its elements are listed, scenarios included.
45
+ */
46
+ export function elementIndex(view , scope ) {
47
+ const inScope = (id ) => scope === undefined || scopeOf(id) === scope || id === `CTX-${scope}`;
48
+ const entries = [];
49
+ const byId = new Map ();
50
+ for (const living of view.living.elements.values()) {
51
+ if (!DOCS_INDEXED(living.path) || !inScope(living.id) || (scope === undefined && kindOf(living.id) === "SCN")) continue;
52
+ const entry = entryOf(living.id, living.element, living.doc, living.path);
53
+ if (kindOf(living.id) === "REQ") entry.scenarios = scenarioCount(view, living.id);
54
+ entries.push(entry);
55
+ byId.set(entry.id, entry);
56
+ }
57
+ for (const item of inFlight(view)) {
58
+ for (const element of deltaElements(item)) {
59
+ if (!inScope(element.element.id) || (scope === undefined && element.element.kind === "SCN")) continue;
60
+ const known = byId.get(element.element.id);
61
+ if (known !== undefined) {
62
+ known.changedBy.push(item.ref);
63
+ continue;
64
+ }
65
+ const added = { ...entryOf(element.element.id, element.element, item.doc, item.ref), addedBy: item.ref };
66
+ entries.push(added);
67
+ byId.set(added.id, added);
68
+ }
69
+ }
70
+ return { words: livingWords(view), entries };
71
+ }
72
+
73
+ /** The bodies of the named elements: the living one, then each in-flight delta that changes it. */
74
+ export function lookupElements(view , ids ) {
75
+ const elements = [];
76
+ const missing = [];
77
+ for (const id of ids) {
78
+ const found = [];
79
+ const living = view.living.elements.get(id);
80
+ if (living !== undefined) found.push(shown(living.doc, living.element, living.path));
81
+ for (const item of inFlight(view)) {
82
+ const delta = deltaElements(item).find((d) => d.element.id === id);
83
+ if (delta !== undefined) found.push({ ...shown(item.doc, delta.element, item.path), ...(delta.op === undefined ? {} : { op: delta.op }) });
84
+ }
85
+ if (found.length === 0) missing.push(id);
86
+ elements.push(...found);
87
+ }
88
+ return { elements, missing };
89
+ }
90
+
91
+ function entryOf(id , element , doc , source ) {
92
+ const facts = factsOf(doc, element);
93
+ const place = (facts.get("Module") ?? []).flatMap(findIds)[0] ?? (facts.get("Path") ?? [])[0];
94
+ const status = statusOf(doc, element);
95
+ return { id, title: element.title, kind: kindOf(id), source, changedBy: [], ...(status === undefined ? {} : { status }), ...(place === undefined ? {} : { place }) };
96
+ }
97
+
98
+ function shown(doc , element , path ) {
99
+ return { id: element.id, title: element.title, source: `${path}:${element.start + 1}`, body: elementLines(doc, element) };
100
+ }
101
+
102
+ function inFlight(view ) {
103
+ return view.items.filter((item) => !item.archived && item.kind !== "change");
104
+ }
105
+
106
+ /** Each element a work item's delta defines or changes, scenarios under a requirement included. */
107
+ function deltaElements(item ) {
108
+ return readDelta(item.doc).entries.flatMap((entry) => [
109
+ { element: entry.element, ...(entry.element.op === undefined ? {} : { op: entry.element.op }) },
110
+ ...entry.scenarios.map((scenario) => ({ element: scenario })),
111
+ ]);
112
+ }
113
+
114
+ function scenarioCount(view , requirement ) {
115
+ const living = view.living.elements.get(requirement);
116
+ if (living === undefined) return 0;
117
+ return [...view.living.elements.values()].filter((e) => e.path === living.path && e.id.startsWith("SCN-") && e.element.start > living.element.start && e.element.start < living.element.end).length;
118
+ }
119
+
120
+ function livingWords(view ) {
121
+ const words = (path ) => (view.snapshot.get(path) ?? "").split(/\s+/).filter((w) => w !== "").length;
122
+ const specs = [...view.living.docs.keys()].filter((path) => path.startsWith(`${PATHS.specsDir}/`));
123
+ return { architecture: words(PATHS.architecture), specs: specs.reduce((sum, path) => sum + words(path), 0) };
124
+ }
@@ -1,4 +1,5 @@
1
1
  import { headingElements, rowElements, } from "../elements.js";
2
+ import { VISION_KINDS } from "../ids.js";
2
3
  import { parseMarkdown, } from "../markdown.js";
3
4
  import { PATHS, childFiles, } from "./layout.js";
4
5
 
@@ -49,6 +50,6 @@ function elementsOf(path , doc ) {
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.js";
2
+ import { tablesIn } from "../markdown_tables.js";
3
+ import { PATHS } from "./layout.js";
4
+
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"] ;
14
+
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"] ;
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+ export function readNotes(snapshot ) {
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 , text ) {
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 ) =>
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
+
61
+
62
+
63
+
64
+
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 , targets ) {
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 @@ import { featureOf, } from "./work_items.js";
36
36
 
37
37
 
38
38
 
39
-
39
+
40
40
 
41
+
42
+
41
43
 
42
44
 
43
45
 
@@ -66,7 +68,7 @@ export function scopeView(view , item ) {
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 , item ) {
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 = { 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 = { 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 ) {
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 ) {
85
101
  const scope = readScope(change.doc);
@@ -0,0 +1,36 @@
1
+
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 ) {
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 ) {
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.js";
1
+ import { evaluateApproval, } from "../approvals/evaluate.js";
2
+ import { rebaseNeeded } from "../approvals/rebase.js";
2
3
  import { PATHS } from "../project/layout.js";
3
4
  import { isPlanned, readChangesTable } from "../project/plan_parts.js";
4
5
  import { readProgress } from "../project/progress.js";
@@ -7,7 +8,8 @@ import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.js";
7
8
  import { changesOf, featureOf, } from "../project/work_items.js";
8
9
  import { compareVersions } from "../versions/cruze_version.js";
9
10
  import { FIRST_RELEASE } from "../versions/rule_versions.js";
10
- import { isVerified } from "./verification.js";
11
+ import { pendingReason, pendingRethinks } from "./pending_rethink.js";
12
+ import { awaitsRethink, isVerified } from "./verification.js";
11
13
  import { activeChange } from "./work_status.js";
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 , branch )
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 , branch )
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 , items ) {
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 ) => {
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 ) {
50
74
  const config = view.config?.config;
@@ -90,7 +114,9 @@ function itemSteps(view , item , branch )
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 = [];
@@ -109,14 +135,18 @@ function changeStep(view , change , branch )
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 , change , branch )
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 , item , label , state ) {
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 ) {
129
174
  const text = view.snapshot.get(PATHS.roadmap);
@@ -0,0 +1,22 @@
1
+
2
+
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 , item ) {
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 , pending ) {
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 , change ) {
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 , change , feature ) {
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"] ));
29
+ }
@@ -5,6 +5,7 @@ import { readChangesTable, readScope } from "../project/plan_parts.js";
5
5
  import { readProgress } from "../project/progress.js";
6
6
 
7
7
  import { changesOf, featureOf, } from "../project/work_items.js";
8
+ import { pendingReason, pendingRethinks } from "./pending_rethink.js";
8
9
 
9
10
 
10
11
 
@@ -15,6 +16,8 @@ import { changesOf, featureOf, } from "../project/work_items.js";
15
16
 
16
17
 
17
18
 
19
+
20
+
18
21
 
19
22
 
20
23
 
@@ -22,6 +25,8 @@ import { changesOf, featureOf, } from "../project/work_items.js";
22
25
 
23
26
 
24
27
 
28
+
29
+
25
30
 
26
31
 
27
32
 
@@ -42,6 +47,7 @@ export function projectStatus(view , branch )
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 = { documents, features, standalone };
@@ -69,7 +75,12 @@ function changeStatus(view , change ) {
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 , item ) {
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 , branch )
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. */