@printpractical/cruze 0.0.9 → 0.0.11

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 (131) hide show
  1. package/README.md +17 -13
  2. package/dist/adapters/inbound/cli/cli_context.js +4 -0
  3. package/dist/adapters/inbound/cli/commands/diff.js +23 -0
  4. package/dist/adapters/inbound/cli/commands/documents.js +1 -1
  5. package/dist/adapters/inbound/cli/commands/show.js +66 -2
  6. package/dist/adapters/inbound/cli/commands/status.js +7 -3
  7. package/dist/adapters/inbound/cli/commands.js +9 -3
  8. package/dist/adapters/inbound/cli/parse_command.js +8 -0
  9. package/dist/adapters/outbound/git_repository.js +9 -0
  10. package/dist/app/ports/repository.js +4 -0
  11. package/dist/app/use_cases/approve_artifact.js +18 -10
  12. package/dist/app/use_cases/check_code.js +5 -3
  13. package/dist/app/use_cases/journal_events.js +6 -2
  14. package/dist/app/use_cases/new_project_doc.js +10 -2
  15. package/dist/app/use_cases/run_review.js +10 -2
  16. package/dist/app/use_cases/show_elements.js +18 -0
  17. package/dist/app/use_cases/show_notes.js +11 -0
  18. package/dist/app/use_cases/show_scope.js +15 -1
  19. package/dist/app/use_cases/upstream_diff.js +55 -0
  20. package/dist/domain/approvals/rebase.js +39 -0
  21. package/dist/domain/approvals/upstream_text.js +36 -0
  22. package/dist/domain/check/check_source.js +18 -1
  23. package/dist/domain/check/decision_comments.js +38 -0
  24. package/dist/domain/decision_citations.js +62 -0
  25. package/dist/domain/edits/agents_file.js +9 -0
  26. package/dist/domain/ids.js +3 -1
  27. package/dist/domain/journal.js +6 -4
  28. package/dist/domain/land/merge_plan.js +7 -2
  29. package/dist/domain/line_diff.js +56 -0
  30. package/dist/domain/project/element_lookup.js +124 -0
  31. package/dist/domain/project/living_model.js +2 -1
  32. package/dist/domain/project/notes.js +74 -0
  33. package/dist/domain/skill_budget.js +36 -0
  34. package/dist/domain/status/next_step.js +27 -5
  35. package/dist/domain/status/pending_rethink.js +22 -0
  36. package/dist/domain/status/work_status.js +17 -2
  37. package/dist/domain/validation/decision_rules.js +76 -0
  38. package/dist/domain/validation/delta_rules.js +2 -0
  39. package/dist/domain/validation/future_rules.js +40 -0
  40. package/dist/domain/validation/living_rules.js +2 -0
  41. package/dist/domain/validation/validate_project.js +4 -0
  42. package/dist/domain/validation/vision_rules.js +41 -0
  43. package/dist/domain/versions/rule_versions.js +3 -0
  44. package/package.json +1 -1
  45. package/skills/architect/SKILL.md +2 -1
  46. package/skills/architect/feature.md +6 -4
  47. package/skills/architect/project.md +2 -2
  48. package/skills/behavioural-testing/SKILL.md +3 -2
  49. package/skills/behavioural-testing/fakes.md +3 -3
  50. package/skills/build/SKILL.md +6 -6
  51. package/skills/envision/SKILL.md +3 -2
  52. package/skills/envision/feature.md +3 -1
  53. package/skills/envision/intake.md +28 -0
  54. package/skills/envision/possibilities.md +13 -0
  55. package/skills/envision/project.md +5 -3
  56. package/skills/explore/SKILL.md +2 -2
  57. package/skills/formats/SKILL.md +13 -2
  58. package/skills/formats/reference/architecture.md +7 -3
  59. package/skills/formats/reference/durable-docs.md +4 -1
  60. package/skills/formats/reference/work-items.md +1 -1
  61. package/skills/formats/templates/vision.md +12 -0
  62. package/skills/grilling/SKILL.md +10 -0
  63. package/skills/hexagonal-design/languages/rust.md +2 -1
  64. package/skills/hexagonal-design/runtime.md +1 -0
  65. package/skills/land/SKILL.md +6 -2
  66. package/skills/plan/SKILL.md +13 -3
  67. package/skills/plan/tasks.md +3 -3
  68. package/skills/realign/SKILL.md +5 -3
  69. package/skills/realign/notes/0.0.10.md +131 -0
  70. package/skills/realign/notes/0.0.11.md +15 -0
  71. package/skills/realign/notes/0.0.2.md +1 -1
  72. package/skills/realign/notes/0.0.6.md +1 -1
  73. package/skills/realign/notes/0.0.8.md +1 -1
  74. package/skills/realign/notes/0.0.9.md +2 -2
  75. package/skills/realign/notes.md +2 -0
  76. package/skills/research/SKILL.md +3 -2
  77. package/skills/rethink/SKILL.md +7 -4
  78. package/skills/rethink/levels.md +15 -6
  79. package/skills/roadmap/SKILL.md +2 -1
  80. package/skills/roles/SKILL.md +4 -4
  81. package/skills/roles/auditor.md +1 -1
  82. package/skills/roles/code-reviewer.md +6 -5
  83. package/skills/roles/design-reviewer.md +4 -40
  84. package/skills/roles/design-rubric.md +40 -0
  85. package/skills/roles/plan-rubric.md +11 -0
  86. package/skills/roles/surveyor.md +3 -2
  87. package/skills/triage/SKILL.md +1 -1
  88. package/skills/verify/SKILL.md +6 -6
  89. package/src/adapters/inbound/cli/cli_context.ts +4 -0
  90. package/src/adapters/inbound/cli/commands/diff.ts +23 -0
  91. package/src/adapters/inbound/cli/commands/documents.ts +1 -1
  92. package/src/adapters/inbound/cli/commands/show.ts +66 -2
  93. package/src/adapters/inbound/cli/commands/status.ts +7 -3
  94. package/src/adapters/inbound/cli/commands.ts +9 -3
  95. package/src/adapters/inbound/cli/parse_command.ts +8 -0
  96. package/src/adapters/outbound/git_repository.ts +9 -0
  97. package/src/app/ports/repository.ts +4 -0
  98. package/src/app/use_cases/approve_artifact.ts +18 -10
  99. package/src/app/use_cases/check_code.ts +5 -3
  100. package/src/app/use_cases/journal_events.ts +6 -2
  101. package/src/app/use_cases/new_project_doc.ts +10 -2
  102. package/src/app/use_cases/run_review.ts +10 -2
  103. package/src/app/use_cases/show_elements.ts +18 -0
  104. package/src/app/use_cases/show_notes.ts +11 -0
  105. package/src/app/use_cases/show_scope.ts +15 -1
  106. package/src/app/use_cases/upstream_diff.ts +55 -0
  107. package/src/domain/approvals/rebase.ts +39 -0
  108. package/src/domain/approvals/upstream_text.ts +36 -0
  109. package/src/domain/check/check_source.ts +18 -1
  110. package/src/domain/check/decision_comments.ts +38 -0
  111. package/src/domain/decision_citations.ts +62 -0
  112. package/src/domain/edits/agents_file.ts +9 -0
  113. package/src/domain/ids.ts +3 -1
  114. package/src/domain/journal.ts +6 -4
  115. package/src/domain/land/merge_plan.ts +7 -2
  116. package/src/domain/line_diff.ts +56 -0
  117. package/src/domain/project/element_lookup.ts +124 -0
  118. package/src/domain/project/living_model.ts +2 -1
  119. package/src/domain/project/notes.ts +74 -0
  120. package/src/domain/skill_budget.ts +36 -0
  121. package/src/domain/status/next_step.ts +27 -5
  122. package/src/domain/status/pending_rethink.ts +22 -0
  123. package/src/domain/status/work_status.ts +17 -2
  124. package/src/domain/validation/decision_rules.ts +76 -0
  125. package/src/domain/validation/delta_rules.ts +2 -0
  126. package/src/domain/validation/future_rules.ts +40 -0
  127. package/src/domain/validation/living_rules.ts +2 -0
  128. package/src/domain/validation/validate_project.ts +4 -0
  129. package/src/domain/validation/vision_rules.ts +41 -0
  130. package/src/domain/versions/rule_versions.ts +3 -0
  131. package/templates/init/AGENTS.md +1 -1
@@ -11,7 +11,7 @@ Triage starts from a symptom and ends with a fix, or with the right step to fix
11
11
 
12
12
  1. **Capture the symptom.** Get the exact steps, what happened (output, error, exit code), what the user expected, and the environment. Done when you can write the steps as commands.
13
13
  2. **Reproduce it.** Run the steps against the real system. Done when you can make it fail on demand and have quoted the output. When you can't reproduce it, ask the user for what differs in their environment, and stop until you can.
14
- 3. **Place it in the living docs.** Find the requirement and scenarios in `docs/specs/` that cover this behaviour, and the flow in `docs/architecture.md` that runs it. Then classify it:
14
+ 3. **Place it in the living docs.** Find the requirement and scenarios in `docs/specs/` that cover this behaviour, and the flow in `docs/architecture.md` that runs it, with `cruze show --index` and `cruze show <ID>`. Then classify it:
15
15
  - **Code defect:** a scenario says what should happen, and the code does something else.
16
16
  - **Spec gap:** no scenario covers this case.
17
17
  - **Design flaw:** the design as written can't deliver what the scenario says.
@@ -9,14 +9,14 @@ Verify answers one question: does this change work when someone uses it? Green t
9
9
 
10
10
  ## Steps
11
11
 
12
- 1. **Check it is ready.** Run `cruze next`. It must name `verify` for this change, on this branch. Find the base commit the change branched from with `git merge-base main HEAD`.
12
+ 1. **Check it is ready.** Run `cruze next`. It must name `verify` for this change, on this branch. Find the base commit the change branched from with `git merge-base main HEAD`. The code review's base is the same, unless `cruze journal list --event verification --item <ref>` shows an earlier verify: then it is that entry's commit, or `git rev-list -1 --before=<its at> HEAD` for one without, since the code before it was reviewed.
13
13
  2. **Run the mechanical checks.** Run `cruze validate`, `cruze check --ci` and `cruze trace --change <ref>`, and quote each result.
14
14
  - Run `cruze journal list --event checks --item <ref>`. When its last entry's commit has no code changes since, that is when `git diff --quiet <commit> HEAD -- . ':(exclude)docs' ':(exclude).cruze'` succeeds, quote that entry for the format, lint, build and test commands instead of running them again.
15
15
  - Otherwise run the format, lint and build commands and the full suite, as `.agents/skills/cruze-formats/reference/config.md` defines it under `commands`. When they pass, record them with `cruze journal add checks --set commit=<commit> --item <ref>`, where the commit is the output of `git rev-parse --short HEAD`.
16
16
  - When one fails, fix the code in a commit and run them again. When a fix needs design, stop and offer `rethink`.
17
- 3. **Check the dependencies.** Follow "Checking" in `.agents/skills/cruze-dependency-approval/SKILL.md` against the base commit. An unrecorded dependency is a blocker.
17
+ 3. **Check the dependencies.** Follow "Checking" in `.agents/skills/cruze-dependency-approval/SKILL.md` against the merge base. An unrecorded dependency is a blocker.
18
18
  4. **Run the verifier.** Run the verifier role from `.agents/skills/cruze-roles/SKILL.md` for the change, with the mechanical checks' results and the commit they ran at. It must be able to build and run the system. When it runs through `cruze review`, check that `review.command` in `.cruze/config.yaml` allows the project's build and run commands, and add them with the user's agreement. Done when you have its report, with a result for every delivered scenario.
19
- 5. **Review the code.** Run the review in the roles skill with the code reviewer, `review=code`, `--item <ref>` and the base commit. Fix each finding whose disposition is `fixed` in a commit, running the tests each fix touches, and `test` when `commands:` has a `test_full`. Then check the fixes yourself, as round 2 in the roles skill says.
19
+ 5. **Review the code.** Run the review in the roles skill with the code reviewer, `review=code`, `--item <ref>` and the code review's base. Fix each finding whose disposition is `fixed` in a commit, through a detail amendment when it needs one, running the tests each fix touches, and `test` when `commands:` has a `test_full`. Then check the fixes yourself, as round 2 in the roles skill says.
20
20
  6. **Write the manual test script** as `manual-test.md` in the change folder, following [manual-test.md](manual-test.md). Commit it.
21
21
  7. **Check the fixes once.** When code changed since the last `checks` entry, run the format, lint and build commands and the full suite once more, and record them as in step 2. Done when the last `checks` entry is at a commit with no code changes since.
22
22
  8. **Report.** Show the user four things:
@@ -25,7 +25,7 @@ Verify answers one question: does this change work when someone uses it? Green t
25
25
  - The review's outcome, with each finding's disposition.
26
26
  - The path to `manual-test.md`, with a recommendation to run it before accepting.
27
27
  9. **Ask for the decision:** "Do you accept this change, or send it back?"
28
- 10. **Record it** with `cruze journal add verification --set result=accepted --set summary="<one line>" --item <ref>`, or `--set result=sent-back --set back_to=<build|rethink>`. On sent-back, say what goes back:
28
+ 10. **Record it** with `cruze journal add verification --set result=<accepted|sent-back> --set summary="<one line>" --set commit=<commit> --item <ref>`, where the commit is the output of `git rev-parse --short HEAD`, adding `--set back_to=<build|rethink>` when it is sent back. On sent-back, say what goes back:
29
29
  - A defect in code a task already built: `back_to=build`, then `cruze task reopen T<n> "<reason>"`.
30
30
  - A design that can't deliver the scenario: `back_to=rethink`. Leave the tasks to the rethink, which reopens the ones its amendment changes, and `cruze next` names `rethink` until it is recorded.
31
31
 
@@ -34,8 +34,8 @@ Done when the decision is recorded.
34
34
  ## Rules
35
35
 
36
36
  - A scenario the verifier couldn't run isn't passed. Mark it clearly in the report and in `manual-test.md`.
37
- - Findings from the review are dispositions in the journal, never edits to `change.md`.
38
- - Fixes made during verify are code commits that stay within the tasks' files. When a fix needs a new file or owner, it is a deviation or a rethink, exactly as in build.
37
+ - Findings from the review are dispositions in the journal. They edit `change.md` only through a detail amendment.
38
+ - Fixes made during verify are code commits that stay within the tasks' files. When a fix needs a new file or owner, or a detail, handle it exactly as build does.
39
39
 
40
40
  ## Next step
41
41
 
@@ -56,6 +56,10 @@ export interface Options {
56
56
  rebase: string[];
57
57
  byAgent: boolean;
58
58
  restampUnchanged: boolean;
59
+ index: boolean;
60
+ notes: boolean;
61
+ kind?: string;
62
+ about?: string;
59
63
  round?: string;
60
64
  package?: string;
61
65
  base?: string;
@@ -0,0 +1,23 @@
1
+ import { upstreamDiff, type UpstreamDiffReport } from "../../../../app/use_cases/upstream_diff.ts";
2
+ import { requireArgs, type CliContext, type CommandResult } from "../cli_context.ts";
3
+
4
+ export async function runDiff(context: CliContext, args: string[]): Promise<CommandResult> {
5
+ requireArgs(args, ["ref"]);
6
+ const report = await upstreamDiff(context, args[0] ?? "");
7
+ return { json: report, human: render(report) };
8
+ }
9
+
10
+ function render(report: UpstreamDiffReport): string {
11
+ const lines = [`${report.artifact}: ${report.state}`];
12
+ if (report.changes.length === 0) lines.push("Nothing it cites changed since its approval.");
13
+ for (const change of report.changes) {
14
+ lines.push("", `${change.key}${change.path === undefined ? "" : ` in ${change.path}`}:`);
15
+ lines.push(...(change.diff ?? [change.note ?? ""]).map((line) => ` ${line}`));
16
+ }
17
+ if (report.rebase.length > 0) {
18
+ lines.push("", `Its delta changes ${report.rebase.join(", ")}: rewrite the delta to keep the new text, then approve with ${report.rebase.map((id) => `--rebase ${id}`).join(" ")}.`);
19
+ } else if (report.ref !== undefined && report.state === "upstream-changed") {
20
+ lines.push("", `No rebase needed. When the work still holds against this, re-stamp it with cruze approve ${report.ref} --restamp-unchanged.`);
21
+ }
22
+ return lines.join("\n");
23
+ }
@@ -34,7 +34,7 @@ export async function runNew(context: CliContext, args: string[], options: Optio
34
34
  }
35
35
  requireArgs(args, ["kind", "slug"]);
36
36
  if (args[0] === "note") {
37
- const report = await createNote(context, args[1] ?? "", requireOption(options.title, "--title"));
37
+ const report = await createNote(context, args[1] ?? "", requireOption(options.title, "--title"), requireOption(options.kind, "--kind"), requireOption(options.about, "--about"));
38
38
  return { json: report, human: `Created ${report.path}` };
39
39
  }
40
40
  const kind = args[0] as NewKind;
@@ -1,13 +1,54 @@
1
+ import { showElements, showIndex } from "../../../../app/use_cases/show_elements.ts";
2
+ import { showNotes } from "../../../../app/use_cases/show_notes.ts";
3
+ import type { Note, NotesFor } from "../../../../domain/project/notes.ts";
1
4
  import { showScope, type ScopeReport } from "../../../../app/use_cases/show_scope.ts";
5
+ import { isId } from "../../../../domain/ids.ts";
6
+ import type { ElementIndex } from "../../../../domain/project/element_lookup.ts";
2
7
  import type { ShownElement } from "../../../../domain/project/scope_view.ts";
3
- import { requireArgs, type CliContext, type CommandResult } from "../cli_context.ts";
8
+ import { UsageError, requireArgs, type CliContext, type CommandResult, type Options } from "../cli_context.ts";
4
9
 
5
- export async function runShow(context: CliContext, args: string[]): Promise<CommandResult> {
10
+ export async function runShow(context: CliContext, args: string[], options: Options): Promise<CommandResult> {
11
+ if (options.notes) {
12
+ if (args.length > 1 || options.index) throw new UsageError("--notes takes at most one target: project, a step, a feature slug or a context");
13
+ const report = await showNotes(context, args[0]);
14
+ return { json: report, human: "target" in report ? renderNotesFor(report).join("\n").trimEnd() : renderNotes(report.notes) };
15
+ }
16
+ if (options.index) {
17
+ if (args.length > 1) throw new UsageError("--index takes at most one context or capability");
18
+ const index = await showIndex(context, args[0]);
19
+ return { json: index, human: renderIndex(index) };
20
+ }
6
21
  requireArgs(args, ["ref"]);
22
+ if (args.every(isId)) {
23
+ const report = await showElements(context, args);
24
+ return { json: report, human: blocks(report.elements).join("\n").trimEnd() };
25
+ }
26
+ if (args.length > 1) throw new UsageError("show takes one feature or change, or one or more element IDs");
7
27
  const report = await showScope(context, args[0] ?? "");
8
28
  return { json: report, human: render(report) };
9
29
  }
10
30
 
31
+ /** The index as a list an agent scans for what exists, before fetching bodies with `cruze show <ID>`. */
32
+ function renderIndex(index: ElementIndex): string {
33
+ const { architecture, specs } = index.words;
34
+ const lines = [`# Elements`, "", `docs/architecture.md holds ${architecture} words, and docs/specs/ ${specs}. Read an element's body with \`cruze show <ID>\`.`];
35
+ let source = "";
36
+ for (const entry of [...index.entries].sort((a, b) => Number(a.addedBy !== undefined) - Number(b.addedBy !== undefined))) {
37
+ const heading = entry.addedBy === undefined ? entry.source : "In flight, not yet merged";
38
+ if (heading !== source) lines.push("", `## ${heading}`, "");
39
+ source = heading;
40
+ const facts = [
41
+ entry.status,
42
+ entry.place,
43
+ entry.scenarios === undefined ? undefined : `${entry.scenarios} scenarios`,
44
+ entry.addedBy === undefined ? undefined : `added by ${entry.addedBy}`,
45
+ entry.changedBy.length === 0 ? undefined : `changed by ${entry.changedBy.join(", ")}`,
46
+ ].filter((f) => f !== undefined);
47
+ lines.push(`- ${entry.id}: ${entry.title}${facts.length === 0 ? "" : ` (${facts.join("; ")})`}`);
48
+ }
49
+ return lines.join("\n");
50
+ }
51
+
11
52
  /** The scope as a document an agent reads in place of the living docs. */
12
53
  function render(report: ScopeReport): string {
13
54
  const lines = [`# ${report.ref}${report.feature === undefined ? "" : ` (feature ${report.feature})`}`, ""];
@@ -22,6 +63,7 @@ function render(report: ScopeReport): string {
22
63
  lines.push("## Dispositions", "");
23
64
  lines.push(...(report.dispositions.length === 0 ? ["None."] : report.dispositions.map((d) => `- [${d.disposition}, ${d.review}] ${d.finding}: ${d.reason}`)), "");
24
65
  if (report.references.length > 0) lines.push("## References", "", ...report.references.map((r) => `- \`${r.path}\`: ${r.use}`), "");
66
+ lines.push(...renderNotesFor(report.notes));
25
67
  if (report.missing.length > 0) lines.push("## Missing", "", ...report.missing.map((id) => `- ${id} is defined nowhere`), "");
26
68
  return lines.join("\n").trimEnd();
27
69
  }
@@ -30,3 +72,25 @@ function blocks(elements: ShownElement[]): string[] {
30
72
  if (elements.length === 0) return ["None.", ""];
31
73
  return elements.flatMap((element) => [`<!-- ${element.source} -->`, ...element.body.join("\n").trimEnd().split("\n"), ""]);
32
74
  }
75
+
76
+ /** The notes about a piece of work and the parts of the user's input handed to it; nothing when there are none. */
77
+ function renderNotesFor(found: NotesFor): string[] {
78
+ if (found.notes.length === 0 && found.handoffs.length === 0) return [];
79
+ return [
80
+ "## Notes",
81
+ "",
82
+ ...found.notes.map((n) => `- \`${n.path}\`: ${n.title}${n.kind === undefined ? "" : ` (${n.kind})`}`),
83
+ ...found.handoffs.map((h) => `- ${h.part}, from ${h.source} (${h.status}, handed off in \`${h.note}\`)`),
84
+ "",
85
+ ];
86
+ }
87
+
88
+ function renderNotes(notes: Note[]): string {
89
+ if (notes.length === 0) return "No notes.";
90
+ return notes
91
+ .flatMap((n) => [
92
+ `- \`${n.path}\`: ${n.title} (${n.kind ?? "no kind"}; about ${n.about.length === 0 ? "nothing named" : n.about.join(", ")})`,
93
+ ...n.handoff.map((h) => ` - ${h.part} goes to ${h.goesTo} (${h.status})`),
94
+ ])
95
+ .join("\n");
96
+ }
@@ -25,13 +25,13 @@ export async function runStatus(context: CliContext, _args: string[], options: O
25
25
  const lines = [`Branch: ${report.branch ?? "(none)"}${report.active === undefined ? "" : `, building ${report.active}`}`];
26
26
  for (const doc of report.documents) lines.push(describe(doc.artifact, doc));
27
27
  for (const feature of report.features) {
28
- lines.push(describe(`feature ${feature.ref}`, feature.approval));
28
+ lines.push(`${describe(`feature ${feature.ref}`, feature.approval)}${pendingNote(feature.rethinkPending)}`);
29
29
  for (const change of feature.changes) {
30
30
  const name = change.ref.split("/")[1] ?? change.ref;
31
- lines.push(change.landed !== undefined ? ` ${name}: landed ${change.landed}` : ` ${describe(name, change.approval)}, tasks ${change.tasksDone}/${change.tasksTotal}`);
31
+ lines.push(change.landed !== undefined ? ` ${name}: landed ${change.landed}` : ` ${describe(name, change.approval)}, tasks ${change.tasksDone}/${change.tasksTotal}${pendingNote(change.rethinkPending)}`);
32
32
  }
33
33
  }
34
- for (const change of report.standalone) lines.push(`${describe(`change ${change.ref}`, change.approval)}, tasks ${change.tasksDone}/${change.tasksTotal}`);
34
+ for (const change of report.standalone) lines.push(`${describe(`change ${change.ref}`, change.approval)}, tasks ${change.tasksDone}/${change.tasksTotal}${pendingNote(change.rethinkPending)}`);
35
35
  const json = {
36
36
  ...report,
37
37
  documents: report.documents.map(compact),
@@ -48,6 +48,10 @@ function compact(status: ApprovalStatus): Omit<ApprovalStatus, "record"> & { app
48
48
  return { ...rest, approvedAt: record.approvedAt, by: record.by, ...(record.basis === undefined ? {} : { basis: record.basis }) };
49
49
  }
50
50
 
51
+ function pendingNote(findings: string[] | undefined): string {
52
+ return findings === undefined ? "" : `, ${findings.length} finding(s) waiting on a rethink`;
53
+ }
54
+
51
55
  /** Every step starts a new session: one session that ran plan, build, verify and land reached 745k tokens of context. */
52
56
  const FRESH_SESSION = "Run it in a new session, so its context starts empty.";
53
57
 
@@ -1,5 +1,6 @@
1
1
  import type { Handler } from "./cli_context.ts";
2
2
  import { runCheck, runLand, runTrace } from "./commands/code.ts";
3
+ import { runDiff } from "./commands/diff.ts";
3
4
  import { runApprove, runNew, runValidate } from "./commands/documents.ts";
4
5
  import { runInit } from "./commands/init.ts";
5
6
  import { runInstall } from "./commands/install.ts";
@@ -23,7 +24,12 @@ export const COMMANDS: Record<string, Command> = {
23
24
  validate: { handler: runValidate, options: [], usage: "validate check every document against the formats" },
24
25
  status: { handler: runStatus, options: ["gate", "override", "overlap", "change"], usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
25
26
  next: { handler: runNext, options: [], usage: "next the step to run next on this branch, and why" },
26
- show: { handler: runShow, options: [], usage: "show <ref> the design a change builds, or a feature's delta, with what it cites and its settled ledger" },
27
+ diff: { handler: runDiff, options: [], usage: "diff <ref> what changed upstream of a document since its approval, and whether its delta needs a rebase" },
28
+ show: {
29
+ handler: runShow,
30
+ options: ["index", "notes"],
31
+ usage: "show <ref> | show <ID>... | show --index [<context>] | show --notes [<target>] a change's design, chosen elements, one line per element, or the notes and hand-offs for a target",
32
+ },
27
33
  approve: {
28
34
  handler: runApprove,
29
35
  options: ["replan", "rebase", "by-agent", "restamp-unchanged"],
@@ -31,8 +37,8 @@ export const COMMANDS: Record<string, Command> = {
31
37
  },
32
38
  new: {
33
39
  handler: runNew,
34
- options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title"] },
35
- usage: "new <vision|glossary|architecture|roadmap> | new <feature|change|adr|note> <slug> --title <title> [--feature <ref>] [--roadmap <slug>]",
40
+ options: { vision: [], glossary: [], architecture: [], roadmap: [], feature: ["title", "roadmap"], change: ["title", "feature", "roadmap"], adr: ["title"], note: ["title", "kind", "about"] },
41
+ usage: "new <vision|glossary|architecture|roadmap> | new <feature|change|adr> <slug> --title <title> [--feature <ref>] [--roadmap <slug>] | new note <slug> --title <title> --kind <input|intake|explore|research> --about <subjects>",
36
42
  },
37
43
  task: { handler: runTask, options: { done: ["change", "commit"], deviation: ["change"], reopen: ["change"] }, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
38
44
  features: { handler: runFeatures, options: { add: ["summary", "goals"], update: ["summary"], drop: ["reason"] }, usage: "features <add|update|drop> <slug> [--summary <text>] [--goals <ids>] [--reason <text>]" },
@@ -54,6 +54,10 @@ export function parseCommand(argv: string[]): ParsedCommand {
54
54
  blockers: { type: "string" },
55
55
  "by-agent": { type: "boolean", default: false },
56
56
  "restamp-unchanged": { type: "boolean", default: false },
57
+ index: { type: "boolean", default: false },
58
+ notes: { type: "boolean", default: false },
59
+ kind: { type: "string" },
60
+ about: { type: "string" },
57
61
  },
58
62
  });
59
63
  } catch (error) {
@@ -78,6 +82,8 @@ export function parseCommand(argv: string[]): ParsedCommand {
78
82
  rebase: values.rebase,
79
83
  byAgent: values["by-agent"],
80
84
  restampUnchanged: values["restamp-unchanged"],
85
+ index: values.index,
86
+ notes: values.notes,
81
87
  ...optional("name", values.name),
82
88
  ...optional("title", values.title),
83
89
  ...optional("feature", values.feature),
@@ -96,6 +102,8 @@ export function parseCommand(argv: string[]): ParsedCommand {
96
102
  ...optional("package", values.package),
97
103
  ...optional("base", values.base),
98
104
  ...optional("blockers", values.blockers),
105
+ ...optional("kind", values.kind),
106
+ ...optional("about", values.about),
99
107
  };
100
108
  const given = [...new Set(tokens.flatMap((token) => (token.kind === "option" ? [token.name] : [])))];
101
109
  return { words: positionals, options, given, help: values.help, version: values.version };
@@ -45,6 +45,15 @@ export class GitRepository implements Repository {
45
45
  return new Map(paths.flatMap((path, i) => (blobs[i] === undefined ? [] : [[path, blobs[i]] as [string, string]])));
46
46
  }
47
47
 
48
+ async firstCommitAdding(path: string, text: string): Promise<string | null> {
49
+ const commits = await this.git("log", "--reverse", "--format=%H", `-S${text}`, "--", path);
50
+ return commits?.split("\n").find((c) => c !== "") ?? null;
51
+ }
52
+
53
+ async fileAt(commit: string, path: string): Promise<string | null> {
54
+ return (await this.catFiles([`${commit}:${path}`]))[0] ?? null;
55
+ }
56
+
48
57
  /** The contents of each object, in order, through one `git cat-file --batch`; undefined for one that is missing. */
49
58
  private catFiles(objects: string[]): Promise<Array<string | undefined>> {
50
59
  return new Promise((resolve) => {
@@ -10,4 +10,8 @@ export interface Repository {
10
10
  otherBranches(): Promise<string[]>;
11
11
  /** Files under a directory as they are on another branch, by project-relative path. */
12
12
  filesOnBranch(branch: string, dir: string): Promise<Map<string, string>>;
13
+ /** The oldest commit whose change to a file added the text, or null. */
14
+ firstCommitAdding(path: string, text: string): Promise<string | null>;
15
+ /** A file's contents at a commit, or null when it didn't exist there. */
16
+ fileAt(commit: string, path: string): Promise<string | null>;
13
17
  }
@@ -1,7 +1,8 @@
1
1
  import { requirePlanReviewClosed, requireUnchangedSinceApproval, type AgentBasis } from "../../domain/approvals/agent_approval.ts";
2
2
  import { evaluateApproval } from "../../domain/approvals/evaluate.ts";
3
3
  import { fingerprint, isApprovable } from "../../domain/approvals/fingerprint.ts";
4
- import { latestApproval, serializeApprovals, withApproval, type ApprovalRecord } from "../../domain/approvals/records.ts";
4
+ import { rebaseNeeded } from "../../domain/approvals/rebase.ts";
5
+ import { serializeApprovals, withApproval } from "../../domain/approvals/records.ts";
5
6
  import { CruzeError } from "../../domain/cruze_error.ts";
6
7
  import { updateProgress } from "../../domain/edits/progress_block.ts";
7
8
  import { setStatus, stampMissingStatuses } from "../../domain/edits/status_lines.ts";
@@ -14,7 +15,7 @@ import { PATHS } from "../../domain/project/layout.ts";
14
15
  import { isPlanned, readChangesTable } from "../../domain/project/plan_parts.ts";
15
16
  import { readProgress } from "../../domain/project/progress.ts";
16
17
  import { approvalFolder, approvalsPath, type ProjectView } from "../../domain/project/project_view.ts";
17
- import { featureOf } from "../../domain/project/work_items.ts";
18
+ import { featureOf, type WorkItem } from "../../domain/project/work_items.ts";
18
19
  import type { Problem } from "../../domain/validation/problem.ts";
19
20
  import { SIZE_RULES } from "../../domain/validation/size_rules.ts";
20
21
  import { validateProject } from "../../domain/validation/validate_project.ts";
@@ -96,13 +97,14 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
96
97
  throw new CruzeError("invalid", `${path} has ${errors.length} problem(s) to fix before approval:\n${listing}`);
97
98
  }
98
99
  requireSizesAccepted(problems, path);
100
+ if (item !== undefined) requireNoDecisionCitations(problems, path);
99
101
 
100
102
  const { hash, upstream } = fingerprint(view, path, parseMarkdown(text));
101
103
  const folder = approvalFolder(view, path);
102
104
  const current = view.approvals.get(folder) ?? { version: 1, approvals: [], merged: {} };
103
105
  const rebased = options.rebase ?? [];
104
106
  const deltaIds = item === undefined || item.kind === "change" ? new Set<string>() : readDelta(item.doc).ids;
105
- requireRebased(view, deltaIds, latestApproval(current, path), rebased);
107
+ if (item !== undefined) requireRebased(view, item, rebased);
106
108
  const merged = { ...current.merged };
107
109
  for (const id of rebased) {
108
110
  const living = view.living.elements.get(id);
@@ -143,13 +145,8 @@ export async function approveArtifact(deps: ProjectDeps, ref: string, options: A
143
145
  * a rethink or another land, the delta would overwrite that edit, so approval waits until the
144
146
  * delta is reconciled and the element is named with --rebase.
145
147
  */
146
- function requireRebased(view: ProjectView, deltaIds: Set<string>, previous: ApprovalRecord | undefined, rebased: string[]): void {
147
- if (previous === undefined) return;
148
- const moved = [...deltaIds].filter((id) => {
149
- const living = view.living.elements.get(id);
150
- const before = previous.upstream[id];
151
- return living !== undefined && before !== undefined && before !== elementHash(living.doc, living.element) && !rebased.includes(id);
152
- });
148
+ function requireRebased(view: ProjectView, item: WorkItem, rebased: string[]): void {
149
+ const moved = rebaseNeeded(view, item).filter((id) => !rebased.includes(id));
153
150
  if (moved.length > 0) {
154
151
  throw new CruzeError(
155
152
  "rebase-required",
@@ -172,6 +169,17 @@ function requireSizesAccepted(problems: Problem[], path: string): void {
172
169
  );
173
170
  }
174
171
 
172
+ /**
173
+ * A delta that cites decisions by number would merge them into the living docs, where they dangle once
174
+ * the work is archived. Existing docs only warn, so approval of new or redesigned work is where it stops.
175
+ */
176
+ function requireNoDecisionCitations(problems: Problem[], path: string): void {
177
+ const standing = problems.filter((p) => p.rule === "decision-citation");
178
+ if (standing.length === 0) return;
179
+ const listing = standing.map((p) => ` ${p.path}:${p.line ?? ""} ${p.message}`).join("\n");
180
+ throw new CruzeError("decision-cited", `${path} cites decisions by number in its delta, which merges into the living docs:\n${listing}`);
181
+ }
182
+
175
183
  function notWork(path: string): never {
176
184
  throw new CruzeError("needs-user", `${path} is a project document, so the user approves it`);
177
185
  }
@@ -27,16 +27,18 @@ export async function checkCode(deps: ProjectDeps, options: { files?: string[];
27
27
  const view = await loadView(deps.files);
28
28
  const config = requireConfig(view);
29
29
  const sources = await readMatching(deps.files, config.source);
30
+ const tests = await readMatching(deps.files, config.tests);
30
31
  for (const file of options.files ?? []) {
31
- if (!sources.has(file)) {
32
+ if (!sources.has(file) && !tests.has(file)) {
32
33
  const text = await deps.files.readText(file);
33
34
  if (text === undefined) throw new CruzeError("not-found", `${file} does not exist`);
34
35
  if (matchesAny(file, config.source)) sources.set(file, text);
36
+ else if (matchesAny(file, config.tests)) tests.set(file, text);
35
37
  }
36
38
  }
37
- const only = options.files?.filter((file) => sources.has(file));
39
+ const only = options.files?.filter((file) => sources.has(file) || tests.has(file));
38
40
  const standards = config.standards ?? FIRST_RELEASE;
39
- const findings = checkSource({ config, sources, modulePaths: modulePaths(view), ...(only === undefined ? {} : { only }) }).map((f) =>
41
+ const findings = checkSource({ config, sources, tests, modulePaths: modulePaths(view), ...(only === undefined ? {} : { only }) }).map((f) =>
40
42
  isPending("check", f.rule, standards) ? { ...f, pending: true as const } : f,
41
43
  );
42
44
  const failing = findings.filter((f) => f.pending !== true && (f.severity === "error" || options.ci));
@@ -1,4 +1,5 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.ts";
2
+ import { knownPitfalls } from "../../domain/edits/agents_file.ts";
2
3
  import { journalPath, parseJournal, recordedEventProblems, recordedFields, type JournalEntry } from "../../domain/journal.ts";
3
4
  import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
4
5
  import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
@@ -40,10 +41,12 @@ export interface FeedbackExport {
40
41
  exported_at: string;
41
42
  redacted: boolean;
42
43
  entries: JournalEntry[];
44
+ /** The lines under `## Known pitfalls` in `AGENTS.md`: lessons that may belong in Cruze rather than the project. */
45
+ pitfalls: string[];
43
46
  }
44
47
 
45
48
  /**
46
- * Bundles the journal for the framework feedback loop. By default the project name and
49
+ * Bundles the journal, and the project's known pitfalls, for the framework feedback loop. By default the project name and
47
50
  * the approver's identity are redacted, since the bundle may leave the project.
48
51
  */
49
52
  export async function exportFeedback(deps: ProjectDeps, redact: boolean): Promise<FeedbackExport> {
@@ -56,5 +59,6 @@ export async function exportFeedback(deps: ProjectDeps, redact: boolean): Promis
56
59
  return value;
57
60
  };
58
61
  const entries = redact ? view.journal.map((entry) => ({ ...(scrub(entry) as JournalEntry), by: "<redacted>" })) : view.journal;
59
- return { format: "cruze-feedback/1", cruze: deps.bundle.version, exported_at: deps.clock.now().toISOString(), redacted: redact, entries };
62
+ const pitfalls = knownPitfalls((await deps.files.readText("AGENTS.md")) ?? "").map((line) => (redact ? (scrub(line) as string) : line));
63
+ return { format: "cruze-feedback/1", cruze: deps.bundle.version, exported_at: deps.clock.now().toISOString(), redacted: redact, entries, pitfalls };
60
64
  }
@@ -1,5 +1,6 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.ts";
2
2
  import { PATHS } from "../../domain/project/layout.ts";
3
+ import { NOTE_KINDS } from "../../domain/project/notes.ts";
3
4
  import { requireSlug } from "../../domain/project/new_items.ts";
4
5
  import { renderTemplate } from "../../domain/scaffold.ts";
5
6
  import { appendJournal, loadView, type ProjectDeps } from "../project_context.ts";
@@ -27,14 +28,21 @@ export async function createProjectDoc(deps: ProjectDeps, kind: ProjectDocKind):
27
28
  }
28
29
 
29
30
  /** Creates a dated note in `.cruze/notes/`, such as a captured explore session or research evidence. */
30
- export async function createNote(deps: ProjectDeps, slug: string, title: string): Promise<NewDocReport> {
31
+ /**
32
+ * A dated note, saying what kind it is and what it is about, so `cruze show` finds it for the work it
33
+ * informs: `project`, feature slugs, or context and capability names.
34
+ */
35
+ export async function createNote(deps: ProjectDeps, slug: string, title: string, kind: string, about: string): Promise<NewDocReport> {
31
36
  requireSlug(slug);
32
37
  if (title.trim() === "") throw new CruzeError("missing-title", "a title is required");
38
+ if (!(NOTE_KINDS as readonly string[]).includes(kind)) throw new CruzeError("note-kind", `a note's kind is one of ${NOTE_KINDS.join(", ")}`);
39
+ const subjects = about.split(",").map((a) => a.trim()).filter((a) => a !== "");
40
+ if (subjects.length === 0) throw new CruzeError("note-about", "name what the note is about: project, feature slugs, or contexts, comma-separated");
33
41
  const view = await loadView(deps.files);
34
42
  const date = deps.clock.today();
35
43
  const base = `${PATHS.notesDir}/${date}-${slug}`;
36
44
  let path = `${base}.md`;
37
45
  for (let n = 2; view.snapshot.has(path); n++) path = `${base}-${n}.md`;
38
- await deps.files.writeText(path, `# ${title}\n\n- Date: ${date}\n\n`);
46
+ await deps.files.writeText(path, `# ${title}\n\n- Date: ${date}\n- Kind: ${kind}\n- About: ${subjects.join(", ")}\n\n`);
39
47
  return { kind: "note", path };
40
48
  }
@@ -1,6 +1,7 @@
1
1
  import { CruzeError } from "../../domain/cruze_error.ts";
2
2
  import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
3
- import { featureOf } from "../../domain/project/work_items.ts";
3
+ import { isPlanned } from "../../domain/project/plan_parts.ts";
4
+ import { featureOf, type WorkItem } from "../../domain/project/work_items.ts";
4
5
  import type { AgentRunner } from "../ports/agent_runner.ts";
5
6
  import { loadView, type ProjectDeps } from "../project_context.ts";
6
7
 
@@ -44,7 +45,7 @@ export async function runReview(deps: ProjectDeps & { runner: AgentRunner }, 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: ProjectDeps, role: Role): Promise<string> {
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: WorkItem): string {
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.ts";
2
+ import { elementIndex, lookupElements, type ElementIndex } from "../../domain/project/element_lookup.ts";
3
+ import type { ShownElement } from "../../domain/project/scope_view.ts";
4
+ import { loadView, type ProjectDeps } from "../project_context.ts";
5
+
6
+ /** The bodies of the named elements, living and in each in-flight delta that changes them. */
7
+ export async function showElements(deps: ProjectDeps, ids: string[]): Promise<{ elements: ShownElement[] }> {
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: ProjectDeps, scope?: string): Promise<ElementIndex & { scope?: string }> {
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, type Note, type NotesFor } from "../../domain/project/notes.ts";
2
+ import { loadView, type ProjectDeps } from "../project_context.ts";
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: ProjectDeps, target?: string): Promise<{ notes: Note[] } | (NotesFor & { target: string })> {
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.ts";
2
2
  import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
3
+ import { scopeOf } from "../../domain/ids.ts";
4
+ import { notesFor, readNotes, type NotesFor } from "../../domain/project/notes.ts";
3
5
  import { scopeView, type ScopeView } from "../../domain/project/scope_view.ts";
6
+ import type { WorkItem } from "../../domain/project/work_items.ts";
4
7
  import { loadView, type ProjectDeps } from "../project_context.ts";
5
8
  import { listEvents } from "./journal_events.ts";
6
9
 
@@ -16,6 +19,8 @@ export interface ScopeReport extends ScopeView {
16
19
  dispositions: Disposition[];
17
20
  /** The external documents the design must comply with, from `references:` in `.cruze/config.yaml`. */
18
21
  references: Array<{ path: string; use: string }>;
22
+ /** The notes about this work, and the parts of the user's input handed to it. */
23
+ notes: NotesFor;
19
24
  }
20
25
 
21
26
  /**
@@ -33,5 +38,14 @@ export async function showScope(deps: ProjectDeps, ref: string): Promise<ScopeRe
33
38
  reason: String(entry["reason"] ?? ""),
34
39
  review: String(entry["review"] ?? ""),
35
40
  }));
36
- return { ...scope, dispositions, references: view.config?.config?.references ?? [] };
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: WorkItem): string[] {
49
+ const roadmap = item.doc.frontmatter?.["roadmap"];
50
+ return [...new Set([...(typeof roadmap === "string" ? [roadmap] : []), item.folderName.replace(/^\d{4}-\d{2}-\d{2}-/, "")])];
37
51
  }
@@ -0,0 +1,55 @@
1
+ import { evaluateApproval, type ApprovalState } from "../../domain/approvals/evaluate.ts";
2
+ import { rebaseNeeded } from "../../domain/approvals/rebase.ts";
3
+ import { upstreamLines, upstreamPath } from "../../domain/approvals/upstream_text.ts";
4
+ import { lineDiff } from "../../domain/line_diff.ts";
5
+ import { resolveArtifact } from "../../domain/project/artifact_ref.ts";
6
+ import { approvalFolder, approvalsPath } from "../../domain/project/project_view.ts";
7
+ import { loadView, type ProjectDeps } from "../project_context.ts";
8
+
9
+ export interface UpstreamChange {
10
+ /** The upstream element ID, or `doc:<path>` for a feature's design. */
11
+ key: string;
12
+ path?: string;
13
+ /** The approved text against the current text, when the approved text was found. */
14
+ diff?: string[];
15
+ note?: string;
16
+ }
17
+
18
+ export interface UpstreamDiffReport {
19
+ artifact: string;
20
+ ref?: string;
21
+ state: ApprovalState;
22
+ changes: UpstreamChange[];
23
+ /** Elements the work's delta changes whose living text moved: approval needs --rebase for each. */
24
+ rebase: string[];
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: ProjectDeps, ref: string): Promise<UpstreamDiffReport> {
34
+ const view = await loadView(deps.files);
35
+ const { path, item } = resolveArtifact(view, ref);
36
+ const status = evaluateApproval(view, path);
37
+ const report: UpstreamDiffReport = { 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
+ }