@printpractical/cruze 0.0.6 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +9 -7
  2. package/dist/adapters/inbound/cli/commands/journal.js +1 -1
  3. package/dist/adapters/inbound/cli/commands/show.js +27 -0
  4. package/dist/adapters/inbound/cli/commands/status.js +19 -3
  5. package/dist/adapters/inbound/cli/commands.js +3 -1
  6. package/dist/app/use_cases/approve_artifact.js +19 -1
  7. package/dist/app/use_cases/check_code.js +10 -3
  8. package/dist/app/use_cases/journal_events.js +8 -5
  9. package/dist/app/use_cases/run_review.js +1 -1
  10. package/dist/app/use_cases/show_scope.js +35 -0
  11. package/dist/domain/check/budget_text.js +24 -0
  12. package/dist/domain/check/check_source.js +6 -3
  13. package/dist/domain/config.js +11 -1
  14. package/dist/domain/journal.js +2 -2
  15. package/dist/domain/project/scope_view.js +118 -0
  16. package/dist/domain/status/work_status.js +13 -2
  17. package/dist/domain/validation/element_rules.js +6 -1
  18. package/dist/domain/validation/size_rules.js +14 -5
  19. package/dist/domain/validation/work_item_rules.js +25 -3
  20. package/dist/domain/versions/rule_versions.js +1 -0
  21. package/dist/main.js +6 -0
  22. package/package.json +1 -1
  23. package/skills/architect/SKILL.md +2 -0
  24. package/skills/architect/feature.md +1 -1
  25. package/skills/architect/project.md +1 -1
  26. package/skills/architect/walkthrough.md +7 -4
  27. package/skills/build/SKILL.md +9 -8
  28. package/skills/dependency-approval/SKILL.md +8 -1
  29. package/skills/envision/SKILL.md +2 -0
  30. package/skills/envision/feature.md +1 -1
  31. package/skills/explore/SKILL.md +1 -1
  32. package/skills/formats/reference/architecture.md +1 -1
  33. package/skills/formats/reference/config.md +5 -1
  34. package/skills/formats/reference/specs.md +1 -1
  35. package/skills/formats/reference/work-items.md +1 -1
  36. package/skills/formats/templates/architecture.md +2 -2
  37. package/skills/grilling/SKILL.md +19 -7
  38. package/skills/grilling/illustrating.md +37 -0
  39. package/skills/hexagonal-design/SKILL.md +10 -3
  40. package/skills/hexagonal-design/languages/rust.md +3 -2
  41. package/skills/hexagonal-design/languages/swift.md +46 -0
  42. package/skills/hexagonal-design/shapes.md +49 -0
  43. package/skills/improve/SKILL.md +51 -0
  44. package/skills/land/SKILL.md +2 -2
  45. package/skills/next/SKILL.md +2 -2
  46. package/skills/plan/SKILL.md +3 -3
  47. package/skills/plan/tasks.md +9 -5
  48. package/skills/realign/SKILL.md +1 -1
  49. package/skills/realign/notes/0.0.7.md +11 -0
  50. package/skills/realign/notes/0.0.8.md +97 -0
  51. package/skills/realign/notes.md +2 -0
  52. package/skills/research/SKILL.md +2 -1
  53. package/skills/rethink/SKILL.md +2 -2
  54. package/skills/roadmap/SKILL.md +2 -0
  55. package/skills/roles/SKILL.md +9 -10
  56. package/skills/roles/code-reviewer.md +8 -8
  57. package/skills/roles/design-reviewer.md +10 -12
  58. package/skills/roles/surveyor.md +40 -0
  59. package/skills/roles/verifier.md +6 -5
  60. package/skills/triage/SKILL.md +2 -0
  61. package/skills/verify/SKILL.md +3 -1
  62. package/src/adapters/inbound/cli/commands/journal.ts +1 -1
  63. package/src/adapters/inbound/cli/commands/show.ts +27 -0
  64. package/src/adapters/inbound/cli/commands/status.ts +19 -3
  65. package/src/adapters/inbound/cli/commands.ts +3 -1
  66. package/src/app/use_cases/approve_artifact.ts +19 -1
  67. package/src/app/use_cases/check_code.ts +10 -3
  68. package/src/app/use_cases/journal_events.ts +8 -5
  69. package/src/app/use_cases/run_review.ts +1 -1
  70. package/src/app/use_cases/show_scope.ts +35 -0
  71. package/src/domain/check/budget_text.ts +24 -0
  72. package/src/domain/check/check_source.ts +6 -3
  73. package/src/domain/config.ts +11 -1
  74. package/src/domain/journal.ts +2 -2
  75. package/src/domain/project/scope_view.ts +118 -0
  76. package/src/domain/status/work_status.ts +13 -2
  77. package/src/domain/validation/element_rules.ts +6 -1
  78. package/src/domain/validation/size_rules.ts +14 -5
  79. package/src/domain/validation/work_item_rules.ts +25 -3
  80. package/src/domain/versions/rule_versions.ts +1 -0
  81. package/src/main.ts +6 -0
  82. package/templates/init/AGENTS.md +1 -1
package/README.md CHANGED
@@ -10,7 +10,7 @@ It works with any agent that supports the [Agent Skills](https://agentskills.io)
10
10
 
11
11
  ## Status
12
12
 
13
- Version 0.0.6 is the sixth preview. It cuts the waiting per change: the full test suite runs once per commit, with an optional fast tier for the task loop, the design walkthrough is one overview followed by the documents to read in full, the agent checks a review's round 2 when the fixes follow the proposals, and research checks every claim the design relies on. Early development. The CLI is complete for the V1 lifecycle. The document formats ship as the `cruze-formats` skill, the standards an agent designs and codes to ship as knowledge skills, and the whole workflow, from explore to land, ships as workflow skills. Claude Code is the agent tested so far.
13
+ Version 0.0.8 is the eighth preview. It cuts what each step reads and tightens the gates: every step runs in a new session, plan, build and the reviewers read a change's scope through `cruze show` instead of the living docs whole, round 2 of a review is checked by the agent, `cruze approve` refuses a size warning the user hasn't accepted, build waits for the changes it depends on and runs the same check as CI, and neither a file budget nor avoiding a dependency shapes code. Early development. The CLI is complete for the V1 lifecycle. The document formats ship as the `cruze-formats` skill, the standards an agent designs and codes to ship as knowledge skills, and the whole workflow, from explore to land, ships as workflow skills. Claude Code is the agent tested so far.
14
14
 
15
15
  ## Why
16
16
 
@@ -35,7 +35,7 @@ The skills run `cruze` commands, so the agent needs `cruze` on its path. Install
35
35
  From a GitHub Release, without npm. Each release attaches the packed package:
36
36
 
37
37
  ```sh
38
- npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.6/printpractical-cruze-0.0.6.tgz
38
+ npm install -g https://github.com/PrintPractical/cruze/releases/download/v0.0.8/printpractical-cruze-0.0.8.tgz
39
39
  ```
40
40
 
41
41
  From npm, once a version is published there:
@@ -47,7 +47,7 @@ npm install -g @printpractical/cruze
47
47
  From the git repository, at a tag or `#main`. This needs git and access to the repository. npm 11 needs `--install-links` for a global git install, and the first `cruze` command builds the CLI, which takes a moment:
48
48
 
49
49
  ```sh
50
- npm install -g --install-links github:PrintPractical/cruze#v0.0.6
50
+ npm install -g --install-links github:PrintPractical/cruze#v0.0.8
51
51
  ```
52
52
 
53
53
  ### 2. Set up a repository
@@ -61,7 +61,7 @@ cruze init
61
61
  When you didn't install from npm, tell `init` where Cruze came from, so the CI it writes runs the same source instead of the npm package:
62
62
 
63
63
  ```sh
64
- cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.6/printpractical-cruze-0.0.6.tgz
64
+ cruze init --package https://github.com/PrintPractical/cruze/releases/download/v0.0.8/printpractical-cruze-0.0.8.tgz
65
65
  ```
66
66
 
67
67
  `init` asks for the project name, and which coding agent you use: `claude` for Claude Code, or `other` for any agent that reads `AGENTS.md` and `.agents/skills/`, such as Codex or OpenCode. It doesn't ask when you pass `--agent claude` or `--agent other`, or when the repository already has `CLAUDE.md` or `.claude/`. With `--yes`, or without a terminal, it sets up Claude Code. Then it creates:
@@ -121,6 +121,7 @@ Skills call these at fixed points, and CI runs `check` and `trace` on every push
121
121
  | `cruze validate` | Checks every document against the formats: IDs, elements, deltas, scope rules, task lines, test plans, roadmap and config, and warns when a change falls outside the size band in `changes:` |
122
122
  | `cruze approve <doc>` | Records an approval as a fingerprint: the document's design hash plus the hash of every upstream element it cites. `--replan` sets built elements a rethink changed back to planned; `--rebase` accepts living text a feature must now build on. `--by-agent` approves a change for you once its plan review has closed, and `--restamp-unchanged` re-stamps work a rethink didn't change; both record that the agent gave the approval |
123
123
  | `cruze next` | Names the step to run next on this branch, and why, from computed status |
124
+ | `cruze show <ref>` | Prints the design a change builds, or a feature's delta: each element's full body, the delivered scenarios, what they cite with where to find it, the settled decisions and the item's dispositions, so a step reads its scope instead of the whole architecture |
124
125
  | `cruze status` | Computes each document's state (approved, edited, upstream changed or unapproved) from content, never from stored state |
125
126
  | `cruze status --gate build` | Passes only when this branch's change, its feature and the architecture are approved and current |
126
127
  | `cruze status --overlap` | Lists changes on other branches that touch the same elements as this branch's change |
@@ -135,7 +136,7 @@ Skills call these at fixed points, and CI runs `check` and `trace` on every push
135
136
  | `cruze realign status` | Lists what the installed Cruze expects of existing code since its `standards:` version, with what the CLI rules find now; `--full` covers every release |
136
137
  | `cruze realign done` | Moves `standards:` up once every finding is fixed, waived or scheduled in a change named with `--change` |
137
138
  | `cruze abandon <ref>` | Archives a feature or standalone change that stops for good, with its reason, and puts a feature back on the future list |
138
- | `cruze journal add <event>` | Records a rethink, review round, disposition, verification, override or bug; `cruze feedback export` bundles the journal for improving Cruze itself |
139
+ | `cruze journal add <event>` | Records a rethink, review round, disposition, verification, override or bug; `cruze journal list` reads the work in flight's entries, or landed work's too with `--all`; `cruze feedback export` bundles the journal for improving Cruze itself |
139
140
 
140
141
  Stepping back is editing: change an upstream document and every approval that cites a changed element shows as stale, with the element named. Re-approving is the rewind; there are no phases to reset.
141
142
 
@@ -156,10 +157,11 @@ Stepping back is editing: change an upstream document and every approval that ci
156
157
  | `cruze-triage` | Reproduces a bug, finds its root cause against the living docs, and fixes it test-first or routes it to architect or rethink |
157
158
  | `cruze-rethink` | Steps back to the level a discovery touches, keeps completed work, re-approves what went stale and records why |
158
159
  | `cruze-realign` | Brings existing code up to what a new Cruze expects: audits it against each release's notes, decides every finding with you, and schedules the fixes as prefactor changes |
160
+ | `cruze-improve` | Restructures existing code to meet today's standards: surveys it for structure that departs from them or keeps causing friction, lets you pick any or all of the candidates, and schedules the moves as prefactor changes |
159
161
  | `cruze-next` | Says which step to run next and why |
160
- | `cruze-roles` | The fresh-context design reviewer, code reviewer, verifier, researcher and auditor, and how a review runs to a disposition for every finding |
162
+ | `cruze-roles` | The fresh-context design reviewer, code reviewer, verifier, researcher, auditor and surveyor, and how a review runs to a disposition for every finding |
161
163
  | `cruze-formats` | The format of every Cruze document, with templates |
162
- | `cruze-hexagonal-design` | Ports and adapters with domain-driven design: who owns each rule, dependency direction, many small modules, contracts, runtime ownership, and notes for Rust and C++ |
164
+ | `cruze-hexagonal-design` | Ports and adapters with domain-driven design: who owns each rule, dependency direction, many small modules, contracts, runtime ownership, layouts by kind of system, and notes for Rust, Swift and C++ |
163
165
  | `cruze-behavioural-testing` | Which tests to write and at which seam, fakes instead of mocks, and protecting approved scenarios' tests |
164
166
  | `cruze-grilling` | Interviewing the user in rounds until decisions settle, and challenging once before deferring |
165
167
  | `cruze-domain-language` | Building the glossary and using its words in documents, IDs and code |
@@ -15,7 +15,7 @@ export async function runJournal(context , args , options
15
15
  return { json: entry, human: `Recorded ${entry.event}.` };
16
16
  }
17
17
  if (args[0] === "list") {
18
- const entries = await listEvents(context, options.event, options.item);
18
+ const entries = await listEvents(context, options.event, options.item, options.all);
19
19
  return { json: entries, human: entries.map((e) => `${e.at} ${e.event} ${JSON.stringify({ ...e, at: undefined, event: undefined, by: undefined })}`).join("\n") || "No entries." };
20
20
  }
21
21
  throw new UsageError(`cruze journal takes add or list, not "${args[0]}"`);
@@ -0,0 +1,27 @@
1
+ import { showScope, } from "../../../../app/use_cases/show_scope.js";
2
+
3
+ import { requireArgs, } from "../cli_context.js";
4
+
5
+ export async function runShow(context , args ) {
6
+ requireArgs(args, ["ref"]);
7
+ const report = await showScope(context, args[0] ?? "");
8
+ return { json: report, human: render(report) };
9
+ }
10
+
11
+ /** The scope as a document an agent reads in place of the living docs. */
12
+ function render(report ) {
13
+ const lines = [`# ${report.ref}${report.feature === undefined ? "" : ` (feature ${report.feature})`}`, ""];
14
+ lines.push("## Elements", "", ...blocks(report.elements), "## Scenarios", "", ...blocks(report.scenarios));
15
+ lines.push("## Cited, not shown", "");
16
+ lines.push(...(report.cited.length === 0 ? ["None."] : report.cited.map((c) => `- ${c.id}: ${c.title} (${c.source})`)), "");
17
+ lines.push("## Settled decisions", "", ...(report.decisions.length === 0 ? ["None."] : report.decisions), "");
18
+ lines.push("## Dispositions", "");
19
+ lines.push(...(report.dispositions.length === 0 ? ["None."] : report.dispositions.map((d) => `- [${d.disposition}, ${d.review}] ${d.finding}: ${d.reason}`)), "");
20
+ if (report.missing.length > 0) lines.push("## Missing", "", ...report.missing.map((id) => `- ${id} is defined nowhere`), "");
21
+ return lines.join("\n").trimEnd();
22
+ }
23
+
24
+ function blocks(elements ) {
25
+ if (elements.length === 0) return ["None.", ""];
26
+ return elements.flatMap((element) => [`<!-- ${element.source} -->`, ...element.body.join("\n").trimEnd().split("\n"), ""]);
27
+ }
@@ -32,9 +32,25 @@ export async function runStatus(context , _args , options
32
32
  }
33
33
  }
34
34
  for (const change of report.standalone) lines.push(`${describe(`change ${change.ref}`, change.approval)}, tasks ${change.tasksDone}/${change.tasksTotal}`);
35
- return { json: report, human: lines.join("\n") };
35
+ const json = {
36
+ ...report,
37
+ documents: report.documents.map(compact),
38
+ features: report.features.map((f) => ({ ...f, approval: compact(f.approval), changes: f.changes.map((c) => ({ ...c, approval: compact(c.approval) })) })),
39
+ standalone: report.standalone.map((c) => ({ ...c, approval: compact(c.approval) })),
40
+ };
41
+ return { json, human: lines.join("\n") };
36
42
  }
37
43
 
44
+ /** The state without the record's hashes: an agent reads status often, and the hashes were a third of each read. */
45
+ function compact(status ) {
46
+ const { record, ...rest } = status;
47
+ if (record === undefined) return rest;
48
+ return { ...rest, approvedAt: record.approvedAt, by: record.by, ...(record.basis === undefined ? {} : { basis: record.basis }) };
49
+ }
50
+
51
+ /** Every step starts a new session: one session that ran plan, build, verify and land reached 745k tokens of context. */
52
+ const FRESH_SESSION = "Run it in a new session, so its context starts empty.";
53
+
38
54
  const BASIS = { "plan-review": " (by the agent, after its plan review)", rethink: " (re-stamped by the agent after a rethink)" };
39
55
 
40
56
  function describe(label , status ) {
@@ -46,6 +62,6 @@ function describe(label , status ) {
46
62
  export async function runNext(context ) {
47
63
  const report = await suggestNext(context);
48
64
  const describe = (s ) => `${s.step}${s.target === undefined ? "" : ` ${s.target}`}: ${s.reason}`;
49
- const lines = [`Next: ${describe(report.next)}`, ...report.also.map((s) => `Also: ${describe(s)}`)];
50
- return { json: report, human: lines.join("\n") };
65
+ const lines = [`Next: ${describe(report.next)}`, ...report.also.map((s) => `Also: ${describe(s)}`), FRESH_SESSION];
66
+ return { json: { ...report, session: FRESH_SESSION }, human: lines.join("\n") };
51
67
  }
@@ -7,6 +7,7 @@ import { runRealign } from "./commands/realign.js";
7
7
  import { runFeedback, runJournal } from "./commands/journal.js";
8
8
  import { runAbandon, runFeatures, runRoadmap, runTask } from "./commands/progress.js";
9
9
  import { runReviewCommand } from "./commands/review.js";
10
+ import { runShow } from "./commands/show.js";
10
11
  import { runNext, runStatus } from "./commands/status.js";
11
12
 
12
13
  /** A command, and the options it takes: one list, or one per subcommand for a command that has them. */
@@ -22,6 +23,7 @@ export const COMMANDS = {
22
23
  validate: { handler: runValidate, options: [], usage: "validate check every document against the formats" },
23
24
  status: { handler: runStatus, options: ["gate", "override", "overlap", "change"], usage: "status [--gate build [--override <reason>]] [--overlap [--change <ref>]]" },
24
25
  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" },
25
27
  approve: {
26
28
  handler: runApprove,
27
29
  options: ["replan", "rebase", "by-agent", "restamp-unchanged"],
@@ -35,7 +37,7 @@ export const COMMANDS = {
35
37
  task: { handler: runTask, options: { done: ["change", "commit"], deviation: ["change"], reopen: ["change"] }, usage: "task <done|deviation|reopen> <T#> [text] [--change <ref>] [--commit <sha>]" },
36
38
  features: { handler: runFeatures, options: { add: ["summary", "goals"], update: ["summary"], drop: ["reason"] }, usage: "features <add|update|drop> <slug> [--summary <text>] [--goals <ids>] [--reason <text>]" },
37
39
  roadmap: { handler: runRoadmap, options: { prune: [] }, usage: "roadmap prune clear landed items from the roadmap status when a release closes" },
38
- journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>]>" },
40
+ journal: { handler: runJournal, options: { add: ["set", "item"], list: ["event", "item", "all"] }, usage: "journal <add <event> --set key=value... [--item <ref>]|list [--event <event>] [--item <ref>] [--all]>" },
39
41
  review: { handler: runReviewCommand, options: ["item", "round", "blockers", "base"], usage: "review <role> [--item <ref>] [--round 1|2 --blockers <file>] [--base <commit>] run a role in a fresh agent context" },
40
42
  feedback: { handler: runFeedback, options: { export: ["out", "no-redact"] }, usage: "feedback export [--out <file>] [--no-redact]" },
41
43
  trace: { handler: runTrace, options: ["all", "change"], usage: "trace [--all] [--change <ref>] link scenarios to the tests that prove them" },
@@ -15,6 +15,8 @@ import { isPlanned, readChangesTable } from "../../domain/project/plan_parts.js"
15
15
  import { readProgress } from "../../domain/project/progress.js";
16
16
  import { approvalFolder, approvalsPath, } from "../../domain/project/project_view.js";
17
17
  import { featureOf } from "../../domain/project/work_items.js";
18
+
19
+ import { SIZE_RULES } from "../../domain/validation/size_rules.js";
18
20
  import { validateProject } from "../../domain/validation/validate_project.js";
19
21
  import { appendJournal, loadView, withFiles, } from "../project_context.js";
20
22
 
@@ -87,11 +89,13 @@ export async function approveArtifact(deps , ref , options
87
89
  if (text !== view.snapshot.get(path)) edits.set(path, text);
88
90
  view = withFiles(view, edits);
89
91
 
90
- const errors = validateProject(view).filter((p) => p.path === path && p.severity === "error");
92
+ const problems = validateProject(view).filter((p) => p.path === path);
93
+ const errors = problems.filter((p) => p.severity === "error");
91
94
  if (errors.length > 0) {
92
95
  const listing = errors.map((p) => ` ${p.path}:${p.line ?? ""} ${p.message}`).join("\n");
93
96
  throw new CruzeError("invalid", `${path} has ${errors.length} problem(s) to fix before approval:\n${listing}`);
94
97
  }
98
+ requireSizesAccepted(problems, path);
95
99
 
96
100
  const { hash, upstream } = fingerprint(view, path, parseMarkdown(text));
97
101
  const folder = approvalFolder(view, path);
@@ -154,6 +158,20 @@ function requireRebased(view , deltaIds , previous
154
158
  }
155
159
  }
156
160
 
161
+ /**
162
+ * A size warning stands until the work is split or the user accepts it under `changes.exceptions`.
163
+ * A 28-task change approved over its warning went on to 26 deviations, so approval waits for one or the other.
164
+ */
165
+ function requireSizesAccepted(problems , path ) {
166
+ const standing = problems.filter((p) => SIZE_RULES.includes(p.rule));
167
+ if (standing.length === 0) return;
168
+ const listing = standing.map((p) => ` ${p.message} [${p.rule}]`).join("\n");
169
+ throw new CruzeError(
170
+ "size-unaccepted",
171
+ `${path} has ${standing.length} size warning(s). Split the work through rethink, or record the user's acceptance under changes.exceptions in .cruze/config.yaml, as the item's ref and their reason:\n${listing}`,
172
+ );
173
+ }
174
+
157
175
  function notWork(path ) {
158
176
  throw new CruzeError("needs-user", `${path} is a project document, so the user approves it`);
159
177
  }
@@ -6,6 +6,7 @@ import { FIRST_RELEASE, isPending } from "../../domain/versions/rule_versions.js
6
6
 
7
7
 
8
8
  import { resolveChange } from "../../domain/project/artifact_ref.js";
9
+ import { readDelta } from "../../domain/project/deltas.js";
9
10
  import { activeChange } from "../../domain/status/work_status.js";
10
11
  import { traceScenarios, } from "../../domain/trace.js";
11
12
  import { loadView, } from "../project_context.js";
@@ -88,8 +89,14 @@ function globBase(glob ) {
88
89
  return fixed.join("/");
89
90
  }
90
91
 
92
+ /**
93
+ * Every module's Path: the living docs' modules, and the modules that work in progress adds or
94
+ * changes in its architecture delta, since their files are written before land merges them.
95
+ */
91
96
  function modulePaths(view ) {
92
- return [...view.living.elements.values()]
93
- .filter((e) => e.id.startsWith("MOD-"))
94
- .flatMap((e) => (factsOf(e.doc, e.element).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")));
97
+ const living = [...view.living.elements.values()].filter((e) => e.id.startsWith("MOD-")).map((e) => ({ doc: e.doc, element: e.element }));
98
+ const inFlight = view.items
99
+ .filter((item) => item.kind !== "change" && !item.archived)
100
+ .flatMap((item) => readDelta(item.doc).entries.filter((entry) => entry.element.id.startsWith("MOD-") && entry.element.op !== "REMOVED").map((entry) => ({ doc: item.doc, element: entry.element })));
101
+ return [...living, ...inFlight].flatMap(({ doc, element }) => (factsOf(doc, element).get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")));
95
102
  }
@@ -18,16 +18,19 @@ export async function recordEvent(deps , event , fields
18
18
  return appendJournal(deps, folder, event, { ...recorded, cruze: deps.bundle.version });
19
19
  }
20
20
 
21
- /** The journal, or one feature's or change's part of it: its own journal, and a feature's changes' too. */
22
- export async function listEvents(deps , event , item ) {
21
+ /**
22
+ * The journal of the work in flight and the project, or of one feature or change (a feature's changes' too).
23
+ * Landed work's entries are history, listed only with `all`, since every entry ever recorded is too much to read.
24
+ */
25
+ export async function listEvents(deps , event , item , all = false) {
23
26
  const view = await loadView(deps.files);
24
- let entries = view.journal;
27
+ let folders = [".cruze", ...view.items.filter((i) => all || !i.archived).map((i) => i.folder)];
25
28
  if (item !== undefined) {
26
29
  const resolved = resolveArtifact(view, item).item;
27
30
  if (resolved === undefined) throw new CruzeError("not-found", `"${item}" is not a feature or change`);
28
- const parts = [resolved, ...view.items.filter((i) => resolved.kind === "feature" && i.featureRef === resolved.ref)];
29
- entries = parts.flatMap((part) => parseJournal(view.snapshot.get(journalPath(part.folder))).entries).sort((a, b) => a.at.localeCompare(b.at));
31
+ folders = [resolved, ...view.items.filter((i) => resolved.kind === "feature" && i.featureRef === resolved.ref)].map((part) => part.folder);
30
32
  }
33
+ const entries = folders.flatMap((folder) => parseJournal(view.snapshot.get(journalPath(folder))).entries).sort((a, b) => a.at.localeCompare(b.at));
31
34
  return event === undefined ? entries : entries.filter((entry) => entry.event === event);
32
35
  }
33
36
 
@@ -4,7 +4,7 @@ import { featureOf } from "../../domain/project/work_items.js";
4
4
 
5
5
  import { loadView, } from "../project_context.js";
6
6
 
7
- export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher", "auditor"] ;
7
+ export const ROLES = ["design-reviewer", "code-reviewer", "verifier", "researcher", "auditor", "surveyor"] ;
8
8
 
9
9
 
10
10
 
@@ -0,0 +1,35 @@
1
+ import { CruzeError } from "../../domain/cruze_error.js";
2
+ import { resolveArtifact } from "../../domain/project/artifact_ref.js";
3
+ import { scopeView, } from "../../domain/project/scope_view.js";
4
+ import { loadView, } from "../project_context.js";
5
+ import { listEvents } from "./journal_events.js";
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+ /**
20
+ * The design a change builds, or a feature's whole delta, with the elements it cites and the
21
+ * item's settled ledger: what plan, build and the reviewers read instead of the whole living docs.
22
+ */
23
+ export async function showScope(deps , ref ) {
24
+ const view = await loadView(deps.files);
25
+ const { item } = resolveArtifact(view, ref);
26
+ if (item === undefined) throw new CruzeError("not-work", `"${ref}" is not a feature or change; show takes a feature or change ID`);
27
+ const scope = scopeView(view, item);
28
+ const dispositions = (await listEvents(deps, "disposition", scope.feature ?? item.ref, true)).map((entry) => ({
29
+ finding: String(entry["finding"] ?? ""),
30
+ disposition: String(entry["disposition"] ?? ""),
31
+ reason: String(entry["reason"] ?? ""),
32
+ review: String(entry["review"] ?? ""),
33
+ }));
34
+ return { ...scope, dispositions };
35
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The part of a source file its budgets measure. Budgets apply to source, not tests, so a Rust
3
+ * file's `#[cfg(test)]` modules are left out: rustfmt closes a top-level module with `}` in column 0.
4
+ */
5
+ export function budgetedText(path , text ) {
6
+ if (!path.endsWith(".rs")) return text;
7
+ const lines = text.split("\n");
8
+ const kept = [];
9
+ for (let i = 0; i < lines.length; i++) {
10
+ const item = lines[i + 1] ?? "";
11
+ if (lines[i]?.trim() !== "#[cfg(test)]" || !/^(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+\s*[{;]/.test(item)) {
12
+ kept.push(lines[i] ?? "");
13
+ continue;
14
+ }
15
+ if (item.trimEnd().endsWith(";")) {
16
+ i += 1;
17
+ continue;
18
+ }
19
+ let end = i + 2;
20
+ while (end < lines.length && lines[end] !== "}") end++;
21
+ i = end;
22
+ }
23
+ return kept.join("\n");
24
+ }
@@ -1,5 +1,6 @@
1
1
 
2
2
  import { matchesAny } from "../glob.js";
3
+ import { budgetedText } from "./budget_text.js";
3
4
  import { importsOf, resolveImport } from "./imports.js";
4
5
  import { countTypes } from "./type_count.js";
5
6
 
@@ -50,11 +51,13 @@ export function checkSource(input ) {
50
51
  }
51
52
  }
52
53
  const excepted = config.check.exceptions.some((exception) => exception.path === path);
53
- const lines = text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
54
+ const budgeted = budgetedText(path, text);
55
+ const lines = budgeted.split("\n").length - (budgeted.endsWith("\n") ? 1 : 0);
54
56
  if (!excepted && lines > config.check.maxLines) {
55
- findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines, over the budget of ${config.check.maxLines}; split it by responsibility` });
57
+ const measured = budgeted === text ? "" : ", not counting its inline tests";
58
+ findings.push({ path, rule: "max-lines", severity: "warning", message: `${lines} lines${measured}, over the budget of ${config.check.maxLines}; split it by responsibility` });
56
59
  }
57
- const types = countTypes(path, text);
60
+ const types = countTypes(path, budgeted);
58
61
  if (!excepted && types !== null && types > config.check.maxTypes) {
59
62
  findings.push({ path, rule: "max-types", severity: "warning", message: `${types} top-level types, over the budget of ${config.check.maxTypes}` });
60
63
  }
@@ -38,9 +38,11 @@ import { parse as parseYaml } from "yaml";
38
38
 
39
39
 
40
40
 
41
+
42
+
41
43
 
42
44
 
43
- export const DEFAULT_CHANGE_LIMITS = { minBuilds: 3, maxScenarios: 30, maxTasks: 25 };
45
+ export const DEFAULT_CHANGE_LIMITS = { minBuilds: 3, maxScenarios: 30, maxTasks: 25, exceptions: [] };
44
46
 
45
47
 
46
48
 
@@ -120,6 +122,14 @@ export function parseConfig(text ) {
120
122
  minBuilds: positiveInt(limits["min_builds"], "changes.min_builds", problems) ?? DEFAULT_CHANGE_LIMITS.minBuilds,
121
123
  maxScenarios: positiveInt(limits["max_scenarios"], "changes.max_scenarios", problems) ?? DEFAULT_CHANGE_LIMITS.maxScenarios,
122
124
  maxTasks: positiveInt(limits["max_tasks"], "changes.max_tasks", problems) ?? DEFAULT_CHANGE_LIMITS.maxTasks,
125
+ exceptions: (Array.isArray(limits["exceptions"]) ? limits["exceptions"] : []).flatMap((entry , i ) => {
126
+ const record = asRecord(entry);
127
+ if (typeof record?.["item"] !== "string" || typeof record["reason"] !== "string" || record["reason"].trim() === "") {
128
+ problems.push(`changes.exceptions[${i}] needs an item and a non-empty reason`);
129
+ return [];
130
+ }
131
+ return [{ item: record["item"], reason: record["reason"] }];
132
+ }),
123
133
  };
124
134
 
125
135
  const config = {
@@ -26,14 +26,14 @@ const OPTIONAL_FIELDS = {
26
26
  };
27
27
 
28
28
  /** The workflow steps and checks that find or miss a problem, so a retro can count them. */
29
- export const STEPS = ["envision", "architect", "research", "design-review", "walkthrough", "plan", "plan-review", "build", "code-review", "verify", "land", "realign", "validate", "check", "user"];
29
+ export const STEPS = ["envision", "architect", "research", "design-review", "walkthrough", "plan", "plan-review", "build", "code-review", "verify", "land", "realign", "improve", "validate", "check", "user"];
30
30
 
31
31
  export const FIELD_VALUES = {
32
32
  level: ["task", "change", "feature", "architecture", "vision"],
33
33
  kind: ["defect", "discovery"],
34
34
  disposition: ["fixed", "waived", "deferred", "rejected"],
35
35
  decided: ["agent", "user"],
36
- review: ["design", "plan", "code", "realign"],
36
+ review: ["design", "plan", "code", "realign", "improve"],
37
37
  result: ["accepted", "sent-back"],
38
38
  checked_by: ["agent", "reviewer"],
39
39
  found_by: STEPS,
@@ -0,0 +1,118 @@
1
+ import { elementLines, } from "../elements.js";
2
+ import { findIds } from "../ids.js";
3
+ import { findSection, } from "../markdown.js";
4
+ import { readDelta } from "./deltas.js";
5
+ import { readScope } from "./plan_parts.js";
6
+
7
+ import { featureOf, } from "./work_items.js";
8
+
9
+ /**
10
+ * The design a piece of work builds, cut out of the documents that hold it, so a step reads
11
+ * the elements in its scope instead of the whole architecture and the whole feature.
12
+ */
13
+
14
+
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
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+ export function scopeView(view , item ) {
52
+ const feature = featureOf(view.items, item);
53
+ const deltaItem = feature ?? item;
54
+ const locate = locator(view, deltaItem);
55
+ const wanted = item.kind === "change" || item.kind === "standalone" ? changeIds(item) : featureIds(deltaItem);
56
+ const missing = [];
57
+ const show = (ids ) =>
58
+ ids.flatMap((id) => {
59
+ const found = locate(id);
60
+ if (found === undefined) {
61
+ missing.push(id);
62
+ return [];
63
+ }
64
+ const shown = { id, title: found.element.title, source: `${found.path}:${found.element.start + 1}`, body: elementLines(found.doc, found.element) };
65
+ if (found.element.op !== undefined) shown.op = found.element.op;
66
+ return [shown];
67
+ });
68
+ const elements = show(wanted.elements);
69
+ const scenarios = show(wanted.scenarios);
70
+ const shownIds = new Set([...elements, ...scenarios].map((e) => e.id));
71
+ const cited = [...new Set([...elements, ...scenarios].flatMap((e) => findIds(e.body.join("\n"))))]
72
+ .filter((id) => !shownIds.has(id))
73
+ .flatMap((id) => {
74
+ const found = locate(id);
75
+ return found === undefined ? [] : [{ id, title: found.element.title, source: `${found.path}:${found.element.start + 1}` }];
76
+ });
77
+ const decisions = feature === undefined ? decisionLines(item.doc) : [...decisionLines(feature.doc), ...decisionLines(item.doc)];
78
+ const result = { ref: item.ref, kind: item.kind, elements, scenarios, cited, decisions, missing };
79
+ if (feature !== undefined) result.feature = feature.ref;
80
+ return result;
81
+ }
82
+
83
+ /** A change shows what its scope builds, removes and delivers. */
84
+ function changeIds(change ) {
85
+ const scope = readScope(change.doc);
86
+ return { elements: [...(scope?.builds ?? []), ...(scope?.removes ?? [])], scenarios: scope?.delivers ?? [] };
87
+ }
88
+
89
+ /** A feature shows its whole delta: every operation, with a requirement's scenarios inside its body. */
90
+ function featureIds(feature ) {
91
+ const entries = readDelta(feature.doc).entries;
92
+ return {
93
+ elements: entries.filter((e) => e.section === "architecture").map((e) => e.element.id),
94
+ scenarios: entries.filter((e) => e.section === "spec").map((e) => e.element.id),
95
+ };
96
+ }
97
+
98
+ /** Finds an element's body in the work's own delta first, since that is the text the work builds, then in the living docs. */
99
+ function locator(view , deltaItem ) {
100
+ const delta = readDelta(deltaItem.doc);
101
+ const inDelta = new Map ();
102
+ for (const entry of delta.entries) {
103
+ inDelta.set(entry.element.id, entry.element);
104
+ for (const scenario of entry.scenarios) inDelta.set(scenario.id, scenario);
105
+ }
106
+ return (id) => {
107
+ const own = inDelta.get(id);
108
+ if (own !== undefined) return { doc: deltaItem.doc, path: deltaItem.path, element: own };
109
+ const living = view.living.elements.get(id);
110
+ return living === undefined ? undefined : { doc: living.doc, path: living.path, element: living.element };
111
+ };
112
+ }
113
+
114
+ function decisionLines(doc ) {
115
+ const section = findSection(doc, "Settled decisions");
116
+ if (section === null) return [];
117
+ return doc.lines.slice(section.start, section.end).filter((line) => line.startsWith("- "));
118
+ }
@@ -1,7 +1,7 @@
1
1
  import { evaluateApproval, } from "../approvals/evaluate.js";
2
2
  import { PATHS } from "../project/layout.js";
3
3
  import { readDelta } from "../project/deltas.js";
4
- import { readScope } from "../project/plan_parts.js";
4
+ 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";
@@ -80,12 +80,23 @@ export function buildGateReasons(view , branch )
80
80
  const feature = featureOf(view.items, change);
81
81
  if (feature !== undefined) required.push(feature.path);
82
82
  if (view.snapshot.has(PATHS.architecture)) required.push(PATHS.architecture);
83
- return required.flatMap((path) => {
83
+ const approvals = required.flatMap((path) => {
84
84
  const status = evaluateApproval(view, path);
85
85
  if (status.state === "approved") return [];
86
86
  const detail = status.changed.length > 0 ? `: ${status.changed.join(", ")} changed` : status.unjournaled === true ? ": the approval has no journal entry" : "";
87
87
  return [`${path} is ${status.state}${detail}`];
88
88
  });
89
+ return [...approvals, ...unlandedDependencies(view, change, feature)];
90
+ }
91
+
92
+ /** The changes this change's row in its feature's Changes table depends on that haven't landed on this branch. */
93
+ function unlandedDependencies(view , change , feature ) {
94
+ if (feature === undefined) return [];
95
+ const row = readChangesTable(feature.doc).find((r) => r.change === change.folderName);
96
+ const landed = new Set(changesOf(view.items, feature).filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
97
+ return (row?.dependsOn ?? [])
98
+ .filter((dependency) => !landed.has(dependency))
99
+ .map((dependency) => `${change.ref} depends on ${dependency}, which hasn't landed here; land it, then merge main into this branch`);
89
100
  }
90
101
 
91
102
  /** The elements a change touches: its scope and its delta, not the elements it only cites, such as a test seam. */
@@ -18,7 +18,7 @@ const REQUIRED_FACTS = {
18
18
  const ALLOWED_VALUES = {
19
19
  Kind: { kinds: ["ENT"], values: ["aggregate", "entity", "value", "service", "policy", "event"] },
20
20
  Direction: { kinds: ["PORT"], values: ["driven", "driving"] },
21
- Layer: { kinds: ["MOD"], values: ["domain", "application", "adapter", "composition"] },
21
+ Layer: { kinds: ["MOD"], values: ["core", "domain", "application", "adapter", "composition"] },
22
22
  };
23
23
 
24
24
  export function elementProblems(path , doc , element ) {
@@ -41,6 +41,11 @@ export function elementProblems(path , doc , element
41
41
  problems.push(error(path, element.start, "fact-value", `${element.id} ${key} must be one of ${rule.values.join(", ")}`));
42
42
  }
43
43
  }
44
+ if (element.kind === "MOD") {
45
+ for (const glob of (facts.get("Path") ?? []).flatMap((value) => [...value.matchAll(/`([^`]+)`/g)].map((m) => m[1] ?? "")).filter((p) => /[*?[{]/.test(p))) {
46
+ problems.push(error(path, element.start, "module-path-glob", `${element.id} Path \`${glob}\` is a glob, which matches no file; name a directory ending in / or a single file, one per Path value`));
47
+ }
48
+ }
44
49
  const module = facts.get("Module")?.[0];
45
50
  if (module !== undefined && !findIds(module).some((id) => id.startsWith("MOD-"))) {
46
51
  problems.push(error(path, element.start, "fact-value", `${element.id} Module must name a MOD element`));