create-religion 1.2.0 → 1.4.0

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 (48) hide show
  1. package/CHANGELOG.md +466 -0
  2. package/README.md +2 -1
  3. package/dist/bin/religion.js +87 -7
  4. package/dist/lib/args.js +15 -5
  5. package/dist/lib/changelog.js +29 -0
  6. package/dist/lib/dashboard.js +102 -22
  7. package/dist/lib/doctor.js +134 -26
  8. package/dist/lib/install.js +43 -6
  9. package/dist/lib/paths.js +18 -0
  10. package/dist/lib/settings.js +156 -0
  11. package/dist/lib/state.js +261 -8
  12. package/dist/lib/status.js +38 -3
  13. package/dist/lib/uninstall.js +216 -0
  14. package/package.json +2 -2
  15. package/template/.agents/skills/auto/SKILL.md +9 -3
  16. package/template/.agents/skills/check/SKILL.md +69 -2
  17. package/template/.agents/skills/ci/SKILL.md +28 -0
  18. package/template/.agents/skills/complete/SKILL.md +76 -5
  19. package/template/.agents/skills/distill/SKILL.md +42 -19
  20. package/template/.agents/skills/feature/SKILL.md +19 -4
  21. package/template/.agents/skills/feature/reference/spec-template.md +1 -0
  22. package/template/.agents/skills/fix/SKILL.md +7 -0
  23. package/template/.agents/skills/implement/SKILL.md +37 -9
  24. package/template/.agents/skills/implement/reference/worktrees.md +175 -0
  25. package/template/.agents/skills/scout/SKILL.md +2 -2
  26. package/template/.agents/skills/spike/SKILL.md +8 -0
  27. package/template/.agents/skills/status/SKILL.md +17 -4
  28. package/template/.agents/skills/tests/SKILL.md +45 -3
  29. package/template/.claude/hooks/warn-on-drift.mjs +74 -0
  30. package/template/.claude/hooks/write-handoff.mjs +54 -2
  31. package/template/.claude/skills/auto/SKILL.md +9 -3
  32. package/template/.claude/skills/check/SKILL.md +69 -2
  33. package/template/.claude/skills/ci/SKILL.md +28 -0
  34. package/template/.claude/skills/complete/SKILL.md +76 -5
  35. package/template/.claude/skills/distill/SKILL.md +42 -19
  36. package/template/.claude/skills/feature/SKILL.md +19 -4
  37. package/template/.claude/skills/feature/reference/spec-template.md +1 -0
  38. package/template/.claude/skills/fix/SKILL.md +7 -0
  39. package/template/.claude/skills/implement/SKILL.md +37 -9
  40. package/template/.claude/skills/implement/reference/worktrees.md +175 -0
  41. package/template/.claude/skills/scout/SKILL.md +2 -2
  42. package/template/.claude/skills/spike/SKILL.md +8 -0
  43. package/template/.claude/skills/status/SKILL.md +17 -4
  44. package/template/.claude/skills/tests/SKILL.md +45 -3
  45. package/template/AGENTS.md +7 -2
  46. package/template/CLAUDE.md +7 -2
  47. package/template/religion/.state/settings-template.json +9 -0
  48. package/template/religion/learning/journal.md +4 -3
package/dist/lib/paths.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  export const STATE_DIR = "religion";
4
+ /** Where the adapters install their skills, one directory per skill. */
5
+ export const SKILL_TREES = [".claude/skills", ".agents/skills"];
4
6
  /**
5
7
  * Walks up from `start` to the nearest directory holding the state directory.
6
8
  *
@@ -38,3 +40,19 @@ export async function readIfPresent(file) {
38
40
  return null;
39
41
  }
40
42
  }
43
+ /** The skill names installed in any tree, read from the directories rather than trusted from a file. */
44
+ export async function installedSkills(root) {
45
+ const names = new Set();
46
+ for (const tree of SKILL_TREES) {
47
+ try {
48
+ for (const entry of await fs.readdir(path.join(root, tree), { withFileTypes: true })) {
49
+ if (entry.isDirectory())
50
+ names.add(entry.name);
51
+ }
52
+ }
53
+ catch {
54
+ // A tree that is not installed contributes nothing.
55
+ }
56
+ }
57
+ return names;
58
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Every setting in the configuration reference, with what it accepts.
3
+ *
4
+ * `doctor`, the surface record and the verification suite read this one table, and the
5
+ * verification suite holds it to `docs/architecture/config.md`, so the reference, the shipped
6
+ * defaults and the check cannot disagree without a failing check saying so.
7
+ */
8
+ const prefix = (value, summary) => ({ kind: "prefix", default: value, summary });
9
+ export const SETTINGS = {
10
+ schemaVersion: { kind: "integer", min: 1, max: 1, default: 1, summary: "The format of this file." },
11
+ "workflow.stepReview": {
12
+ kind: "enum", values: ["every", "item"], default: "every",
13
+ summary: "When implementation stops for review: after every step, or once with the whole item."
14
+ },
15
+ "workflow.parallelSteps": {
16
+ kind: "boolean", default: false,
17
+ summary: "Whether steps a spec marks as independent build together as one reviewed wave."
18
+ },
19
+ "git.mode": {
20
+ kind: "enum", values: ["trunk", "branch-per-item", "pull-request"], default: "trunk",
21
+ summary: "Where work lands: the branch you are on, a branch merged per item, or a pull request per item."
22
+ },
23
+ "git.checkpoints": {
24
+ kind: "enum", values: ["none", "every-step", "squash"], default: "every-step",
25
+ summary: "What happens to each step's work: one commit at completion, a commit per step, or per-step commits squashed at completion."
26
+ },
27
+ "git.featureBranchPrefix": prefix("feature/", "The prefix of a feature's branch, outside trunk mode."),
28
+ "git.fixBranchPrefix": prefix("fix/", "The prefix of a fix's branch, outside trunk mode."),
29
+ "git.rollbackBranchPrefix": prefix("rollback/", "The prefix of a rollback's branch, outside trunk mode."),
30
+ "git.refactorBranchPrefix": prefix("refactor/", "The prefix of a refactor campaign's branch, outside trunk mode."),
31
+ "git.integrationBranchPrefix": prefix("auto/", "The prefix of the branch an automated run lands every item on, under pull-request mode."),
32
+ "verification.logicTests": {
33
+ kind: "enum", values: ["when-configured", "required"], default: "when-configured",
34
+ summary: "Whether a logic change needs passing tests only when a test command is declared, or always."
35
+ },
36
+ "verification.uiEvidence": {
37
+ kind: "enum", values: ["when-available", "required"], default: "when-available",
38
+ summary: "Whether an interface outcome needs browser evidence when it can be had, or always."
39
+ },
40
+ "qualityGates.audit": {
41
+ kind: "enum", values: ["manual", "when-sensitive", "always"], default: "when-sensitive",
42
+ summary: "When the read-only review runs on its own: never, for sensitive work, or for every item."
43
+ },
44
+ "qualityGates.check": {
45
+ kind: "enum", values: ["manual", "when-behavioral", "always"], default: "when-behavioral",
46
+ summary: "When the outside proof runs on its own: never, when an outcome needs observed behaviour, or for every item."
47
+ },
48
+ "auto.maxItems": {
49
+ kind: "integer", min: 1, nullable: true, default: null,
50
+ summary: "How many items one automated run may complete; null for no limit."
51
+ },
52
+ "auto.maxRepairAttempts": {
53
+ kind: "integer", min: 0, max: 10, default: 2,
54
+ summary: "How many times an automated run tries to repair one finding before it stops and reports."
55
+ },
56
+ "auto.finalAudit": { kind: "boolean", default: false, summary: "Whether an automated run ends with one review across everything it produced." },
57
+ "security.blockInjection": {
58
+ kind: "boolean", default: false,
59
+ summary: "Whether content carrying three or more injection signatures is blocked, rather than read with a warning."
60
+ },
61
+ "refactor.maxFileLines": { kind: "integer", min: 1, default: 400, summary: "The file length the structure lens of a refactor survey measures against." },
62
+ "refactor.maxFunctionLines": { kind: "integer", min: 1, default: 50, summary: "The function length the structure lens of a refactor survey measures against." }
63
+ };
64
+ const PREFIX = /^[a-z0-9][a-z0-9._/-]*\/$/;
65
+ // What git refuses in a branch name, checked per path component of the prefix.
66
+ const BAD_COMPONENT = (part) => part === "" || part.startsWith(".") || part.endsWith(".lock") || part.includes("..");
67
+ /** The reference's Values and Default cells for a setting, as `docs/architecture/config.md` writes them. */
68
+ export function referenceCells(setting) {
69
+ const code = (value) => "`" + String(value) + "`";
70
+ const values = setting.kind === "enum"
71
+ ? setting.values.map(code).join(", ")
72
+ : setting.kind === "boolean"
73
+ ? "`true`, `false`"
74
+ : setting.kind === "prefix"
75
+ ? "lowercase prefix ending in `/`"
76
+ : setting.min === setting.max
77
+ ? code(setting.min)
78
+ : (setting.min === 1 && setting.max === undefined ? "positive integer" : `integer from ${setting.min} through ${setting.max}`) +
79
+ (setting.nullable ? ", or `null` for no limit" : "");
80
+ return { values, default: code(setting.default) };
81
+ }
82
+ /** What each setting present in `config` gets wrong, one sentence each. Absent settings take their default. */
83
+ export function settingProblems(config) {
84
+ if (config === null || typeof config !== "object" || Array.isArray(config))
85
+ return ["not an object"];
86
+ const problems = [];
87
+ const sections = new Set(Object.keys(SETTINGS).filter((k) => k.includes(".")).map((k) => k.split(".")[0]));
88
+ for (const section of sections) {
89
+ const value = config[section];
90
+ if (value !== undefined && (value === null || typeof value !== "object" || Array.isArray(value))) {
91
+ problems.push(`${section} is ${JSON.stringify(value)}, expected an object of settings`);
92
+ }
93
+ }
94
+ for (const [key, setting] of Object.entries(SETTINGS)) {
95
+ const value = key.split(".").reduce((node, part) => (node && typeof node === "object" && !Array.isArray(node) ? node[part] : undefined), config);
96
+ if (value === undefined)
97
+ continue;
98
+ const wanted = expected(setting, value);
99
+ if (wanted)
100
+ problems.push(`${key} is ${JSON.stringify(value)}, expected ${wanted}`);
101
+ }
102
+ return problems;
103
+ }
104
+ /** What would have been accepted, or null when the value is fine. */
105
+ function expected(setting, value) {
106
+ switch (setting.kind) {
107
+ case "enum":
108
+ return typeof value === "string" && setting.values.includes(value) ? null : `one of ${setting.values.join(", ")}`;
109
+ case "boolean":
110
+ return typeof value === "boolean" ? null : "true or false";
111
+ case "prefix":
112
+ return typeof value === "string" && PREFIX.test(value) && !value.slice(0, -1).split("/").some(BAD_COMPONENT)
113
+ ? null
114
+ : "a lowercase prefix ending in /";
115
+ case "integer": {
116
+ if (value === null && setting.nullable)
117
+ return null;
118
+ const ok = Number.isInteger(value) && value >= setting.min && (setting.max === undefined || value <= setting.max);
119
+ if (ok)
120
+ return null;
121
+ const range = setting.max === undefined ? `${setting.min} or more` : setting.min === setting.max ? `${setting.min}` : `from ${setting.min} through ${setting.max}`;
122
+ return `an integer ${range}` + (setting.nullable ? ", or null" : "");
123
+ }
124
+ }
125
+ }
126
+ /** Every setting, set or not, with what it means and accepts, then any key the schema does not name. */
127
+ export function describeSettings(config) {
128
+ const problems = settingProblems(config);
129
+ const read = (key) => key.split(".").reduce((node, part) => (node && typeof node === "object" && !Array.isArray(node) ? node[part] : undefined), config);
130
+ const rows = Object.entries(SETTINGS).map(([key, setting]) => {
131
+ const value = read(key);
132
+ const cells = referenceCells(setting);
133
+ return {
134
+ key,
135
+ summary: setting.summary,
136
+ accepts: cells.values,
137
+ default: cells.default,
138
+ value: value === undefined ? null : JSON.stringify(value),
139
+ problem: problems.find((p) => p.startsWith(`${key} is `)) ?? null
140
+ };
141
+ });
142
+ if (config && typeof config === "object" && !Array.isArray(config)) {
143
+ for (const [section, value] of Object.entries(config)) {
144
+ const keys = value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value).map((k) => `${section}.${k}`) : [section];
145
+ for (const key of keys) {
146
+ if (!SETTINGS[key] && !(key === section && sectionNamed(section))) {
147
+ rows.push({ key, summary: null, accepts: null, default: null, value: JSON.stringify(read(key)), problem: "not a setting Religion reads" });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return rows;
153
+ }
154
+ function sectionNamed(section) {
155
+ return Object.keys(SETTINGS).some((key) => key.startsWith(`${section}.`));
156
+ }
package/dist/lib/state.js CHANGED
@@ -7,7 +7,71 @@
7
7
  */
8
8
  import crypto from "node:crypto";
9
9
  import fs from "node:fs/promises";
10
- import { readIfPresent, statePath } from "./paths.js";
10
+ import { installedSkills, readIfPresent, statePath } from "./paths.js";
11
+ /** Item numbers a spike archive says it answered and settled, from `**For:** item N` and `**Settled:** yes`. */
12
+ export function parseSettledSpike(source) {
13
+ const item = /^\*\*For:\*\*\s*item\s+(\d+[a-z]?)\b/im.exec(source)?.[1]?.toLowerCase();
14
+ return item && /^\*\*Settled:\*\*\s*yes\b/im.test(source) ? item : null;
15
+ }
16
+ /**
17
+ * What each unticked item still waits on, and the references that cannot be met at all.
18
+ * An item waits on an earlier one until that is ticked, and on its spike until one settles it.
19
+ */
20
+ export function waiting(plan, settled) {
21
+ const byNumber = new Map();
22
+ const problems = [];
23
+ for (const item of plan.filter((i) => i.number)) {
24
+ if (byNumber.has(item.number))
25
+ problems.push(`item ${item.number} appears more than once`);
26
+ else
27
+ byNumber.set(item.number, item);
28
+ }
29
+ const waits = new Map();
30
+ // A sub-item inherits what its parent waits on: splitting an item does not answer its spike.
31
+ const parents = [];
32
+ for (const item of plan) {
33
+ while (parents.length && parents[parents.length - 1].depth >= item.depth)
34
+ parents.pop();
35
+ const inherited = parents.flatMap((p) => (p.number && !p.done ? waits.get(p.number) ?? [] : []));
36
+ parents.push(item);
37
+ if (item.done || !item.number)
38
+ continue;
39
+ const reasons = [...inherited];
40
+ for (const n of item.after) {
41
+ const other = byNumber.get(n);
42
+ if (!other)
43
+ problems.push(`item ${item.number} comes after item ${n}, which the plan does not have`);
44
+ else if (!other.done)
45
+ reasons.push(`item ${n}`);
46
+ }
47
+ if (item.spikeFirst && !settled.has(item.number))
48
+ reasons.push("a spike");
49
+ if (reasons.length)
50
+ waits.set(item.number, [...new Set(reasons)]);
51
+ }
52
+ // A cycle never resolves by ticking anything, so it is named once rather than waited on.
53
+ const seen = new Set();
54
+ for (const start of byNumber.keys()) {
55
+ const path = [];
56
+ const visit = (n) => {
57
+ if (seen.has(n))
58
+ return;
59
+ const at = path.indexOf(n);
60
+ if (at >= 0) {
61
+ problems.push(`items ${[...path.slice(at), n].join(" -> ")} wait on each other`);
62
+ return;
63
+ }
64
+ path.push(n);
65
+ for (const next of byNumber.get(n)?.after ?? [])
66
+ if (byNumber.has(next) && !byNumber.get(next).done)
67
+ visit(next);
68
+ path.pop();
69
+ seen.add(n);
70
+ };
71
+ visit(start);
72
+ }
73
+ return { waits, problems };
74
+ }
11
75
  const CHECKBOX = /^(\s*)- \[( |x|X)\]\s*(.*)$/;
12
76
  export function parsePlan(source) {
13
77
  if (!source)
@@ -26,11 +90,16 @@ export function parsePlan(source) {
26
90
  // The template ships two placeholder items so the shape is visible. They are not work.
27
91
  if (/^\d+\.\s+\*\*(Feature one|Feature two)\*\*/.test(body))
28
92
  continue;
93
+ // A line that only mentions the syntax, in a code span, does not wait on anything.
94
+ const prose = body.replace(/`[^`]*`/g, "");
95
+ const after = [...prose.matchAll(/\bafter items? (\d+[a-z]?(?:(?:,\s*(?:and\s+)?|\s+and\s+)\d+[a-z]?)*)/gi)].map((m) => m[1]);
29
96
  items.push({
30
- number: /^([\dA-Za-z]+)\./.exec(body)?.[1] ?? null,
97
+ number: /^([\dA-Za-z]+)\./.exec(body)?.[1]?.toLowerCase() ?? null,
31
98
  title: body.replace(/^[\dA-Za-z]+\.\s*/, "").replace(/\*\*/g, ""),
32
99
  done: (match[2] ?? " ").toLowerCase() === "x",
33
- depth: Math.floor((match[1] ?? "").length / 2)
100
+ depth: Math.floor((match[1] ?? "").length / 2),
101
+ after: after.flatMap((list) => list.split(/,\s*(?:and\s+)?|\s+and\s+/)).map((n) => n.trim().toLowerCase()).filter(Boolean),
102
+ spikeFirst: /\bspike first\b/i.test(prose)
34
103
  });
35
104
  }
36
105
  return items;
@@ -71,7 +140,8 @@ function field(source, name) {
71
140
  export function parseSpec(source) {
72
141
  if (!source || /_Nothing in progress\./.test(source))
73
142
  return null;
74
- const section = sections(source);
143
+ // A heading inside a code fence is an example, not a section of the spec.
144
+ const section = sections(outsideFences(source));
75
145
  return {
76
146
  title: field(source, "#"),
77
147
  type: field(source, "Type"),
@@ -81,7 +151,77 @@ export function parseSpec(source) {
81
151
  inScope: bullets(section.get("in scope")),
82
152
  outOfScope: bullets(section.get("out of scope")),
83
153
  files: bullets(section.get("files and areas")),
84
- steps: bullets(section.get("build steps"), true).map(parseStep)
154
+ steps: bullets(section.get("build steps"), true).map(parseStep),
155
+ evidence: parseEvidence(section.get("evidence")),
156
+ ...approval(source)
157
+ };
158
+ }
159
+ function approval(source) {
160
+ // An example of the stamp or the section inside a code fence is documentation, not the record.
161
+ const prose = outsideFences(source);
162
+ const line = /^\*\*Approved:\*\*.*$/m.exec(prose)?.[0];
163
+ const stamp = line ? /^\*\*Approved:\*\*\s*(\d{4}-\d{2}-\d{2})\s*,\s*([0-9a-f]{16})\s*$/i.exec(line) : null;
164
+ const approved = stamp ? { date: stamp[1], hash: stamp[2].toLowerCase() } : null;
165
+ return {
166
+ approved,
167
+ amendments: bullets(sections(prose).get("amendments")),
168
+ // A stamp line that cannot be read proves nothing, so it counts as drift rather than hiding it.
169
+ drifted: line !== undefined && (approved === null || approved.hash !== specHash(source))
170
+ };
171
+ }
172
+ function specState(work) {
173
+ const spec = parseSpec(work);
174
+ if (!spec || !work)
175
+ return { hash: null, approved: null, drifted: false };
176
+ return { hash: specHash(work), approved: spec.approved?.hash ?? null, drifted: spec.drifted };
177
+ }
178
+ const FENCE = /^\s*(```|~~~)/;
179
+ /** The source with every line inside a ``` or ~~~ fence blanked, so line structure stays but nothing inside is read. */
180
+ function outsideFences(source) {
181
+ let fenced = false;
182
+ return source
183
+ .split("\n")
184
+ .map((line) => {
185
+ if (FENCE.test(line)) {
186
+ fenced = !fenced;
187
+ return "";
188
+ }
189
+ return fenced ? "" : line;
190
+ })
191
+ .join("\n");
192
+ }
193
+ /**
194
+ * The hash of what a spec asks for. Ticks, the status, the stamp, and the Evidence and
195
+ * Amendments sections change as work proceeds without changing what was approved, so they are
196
+ * left out; anything else that changes is a change to the spec.
197
+ */
198
+ export function specHash(source) {
199
+ const kept = [];
200
+ let fenced = false;
201
+ let skipping = false;
202
+ for (const line of source.replace(/\r\n/g, "\n").split("\n")) {
203
+ if (FENCE.test(line))
204
+ fenced = !fenced;
205
+ else if (!fenced && /^##\s/.test(line))
206
+ skipping = /^##\s+(evidence|amendments)\s*$/i.test(line);
207
+ if (skipping)
208
+ continue;
209
+ if (!fenced && /^\*\*(Status|Approved):\*\*/.test(line))
210
+ continue;
211
+ kept.push((fenced ? line : line.replace(/^(\s*- \[)[xX](\])/, "$1 $2")).replace(/\s+$/, ""));
212
+ }
213
+ const text = kept.join("\n").replace(/\s+$/, "");
214
+ return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
215
+ }
216
+ function parseEvidence(body) {
217
+ if (body === undefined)
218
+ return null;
219
+ return {
220
+ stamp: /^Checked .+$/m.exec(body)?.[0].trim() ?? null,
221
+ entries: bullets(body).map((item) => {
222
+ const m = /^\*\*(.+?)\*\*\s*-\s*(pass|fail|could not check):?\s*([\s\S]*)$/i.exec(item);
223
+ return m ? { outcome: m[1].trim(), verdict: m[2].toLowerCase(), detail: m[3].trim() } : { outcome: item, verdict: "unknown", detail: "" };
224
+ })
85
225
  };
86
226
  }
87
227
  /** Each heading's body, keyed by the lowercased heading; `levels` widens it to `###` too. */
@@ -179,6 +319,95 @@ export function parseInbox(source) {
179
319
  text: (m[2] ?? "").trim()
180
320
  }));
181
321
  }
322
+ const ACTIVITY_STATUSES = new Set(["running", "blocked", "ready", "completed"]);
323
+ const SKILL_NAME = /^[a-z][a-z-]{0,39}$/;
324
+ // The blocked skill's own invocation and plain word arguments, such as `/auto resume`.
325
+ // Anything else in the record could be a shell command dressed up as the next thing to run.
326
+ // The length bound comes first: the repeated group overflows the regex stack on megabytes.
327
+ const RESUME_ARGUMENTS = /^(?: [\w-]+)*$/;
328
+ const RESUME_MAX = 200;
329
+ // A record is written by an agent, so it must not be able to move the cursor, print lines,
330
+ // or reorder the text around it.
331
+ const CONTROL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
332
+ /**
333
+ * The activity record a running skill writes. Skills write it, so only its named fields are
334
+ * read, and a record naming a skill that is not installed is not believed at all: whoever can
335
+ * write the record controls every field in it.
336
+ */
337
+ export function parseActivity(source, skills) {
338
+ const none = { recorded: false, command: null, status: null, summary: null, updatedAt: null, resumeCommand: null };
339
+ if (!source)
340
+ return none;
341
+ let record;
342
+ try {
343
+ record = JSON.parse(source);
344
+ }
345
+ catch {
346
+ return none;
347
+ }
348
+ if (!record || typeof record !== "object")
349
+ return none;
350
+ const { command, status, summary, updatedAt, resumeCommand } = record;
351
+ if (typeof command !== "string" || !SKILL_NAME.test(command) || !skills.has(command))
352
+ return none;
353
+ if (typeof summary !== "string")
354
+ return none;
355
+ if (typeof status !== "string" || !ACTIVITY_STATUSES.has(status))
356
+ return none;
357
+ return {
358
+ recorded: true,
359
+ command,
360
+ status: status,
361
+ summary: summary.replace(CONTROL, " "),
362
+ updatedAt: typeof updatedAt === "string" ? updatedAt.replace(CONTROL, " ") : null,
363
+ resumeCommand: resumes(command, resumeCommand) ? resumeCommand : null
364
+ };
365
+ }
366
+ function resumes(command, resumeCommand) {
367
+ if (typeof resumeCommand !== "string" || resumeCommand.length > RESUME_MAX)
368
+ return false;
369
+ const invocation = resumeCommand.replace(/^[/$]/, "");
370
+ return (invocation === command || invocation.startsWith(command + " ")) &&
371
+ RESUME_ARGUMENTS.test(invocation.slice(command.length));
372
+ }
373
+ /** The lessons after the file's own heading and note, or none while it holds only the stub. */
374
+ export function parseLessons(source) {
375
+ if (!source)
376
+ return [];
377
+ const body = source
378
+ .split(/\r?\n/)
379
+ .filter((line) => !/^#{1,6}[ \t]/.test(line) && !/^>/.test(line) && !/^_Nothing learned yet\._$/.test(line.trim()))
380
+ .join("\n");
381
+ return blocks(body);
382
+ }
383
+ /** One entry per `##` heading, each decided once `journal-archive.md` carries that heading. */
384
+ export function parseJournal(source, archive) {
385
+ if (!source)
386
+ return [];
387
+ const decided = new Set((archive ?? "").split(/\r?\n/).flatMap((line) => /^##[ \t]+(\S.*)$/.exec(line)?.[1]?.trim() ?? []));
388
+ return source.split(/^(?=##[ \t])/m).flatMap((entry) => {
389
+ const heading = /^##[ \t]+(\S.*)$/.exec(entry.split(/\r?\n/, 1)[0] ?? "")?.[1]?.trim();
390
+ if (!heading)
391
+ return [];
392
+ const unlabelled = entry
393
+ .split(/\r?\n/)
394
+ .slice(1)
395
+ .filter((line) => !/^\*\*[^*]+:\*\*/.test(line))
396
+ .join("\n");
397
+ return [
398
+ {
399
+ heading,
400
+ date: /^(\d{4}-\d{2}-\d{2})\b/.exec(heading)?.[1] ?? null,
401
+ archive: labelled(entry, "Archive"),
402
+ surprised: labelled(entry, "Surprised"),
403
+ wentWrong: labelled(entry, "Went wrong"),
404
+ deferred: labelled(entry, "Deferred"),
405
+ summary: blocks(unlabelled)[0] ?? null,
406
+ decided: decided.has(heading)
407
+ }
408
+ ];
409
+ });
410
+ }
182
411
  /** Paragraphs and list items, each as one line of text. */
183
412
  function blocks(body) {
184
413
  if (!body)
@@ -238,14 +467,33 @@ export function overviewHash(projectPlan, buildPlan) {
238
467
  return crypto.createHash("sha256").update(projectPlan).update(buildPlan).digest("hex").slice(0, 16);
239
468
  }
240
469
  export async function readProjectState(root) {
241
- const [configRaw, projectPlan, buildPlan, work, findings, overview] = await Promise.all([
470
+ const [configRaw, projectPlan, buildPlan, work, findings, overview, activity, inbox] = await Promise.all([
242
471
  readIfPresent(statePath(root, "config.json")),
243
472
  readIfPresent(statePath(root, "project-plan.md")),
244
473
  readIfPresent(statePath(root, "build-plan.md")),
245
474
  readIfPresent(statePath(root, "context", "current-work.md")),
246
475
  readIfPresent(statePath(root, "context", "findings.md")),
247
- readIfPresent(statePath(root, "context", "project-overview.md"))
476
+ readIfPresent(statePath(root, "context", "project-overview.md")),
477
+ readIfPresent(statePath(root, ".state", "run.json")),
478
+ readIfPresent(statePath(root, "context", "inbox.md"))
479
+ ]);
480
+ const [lessons, journal, journalArchive] = await Promise.all([
481
+ readIfPresent(statePath(root, "learning", "lessons.md")),
482
+ readIfPresent(statePath(root, "learning", "journal.md")),
483
+ readIfPresent(statePath(root, "learning", "journal-archive.md"))
248
484
  ]);
485
+ const entries = parseJournal(journal, journalArchive);
486
+ const settledSpikes = [];
487
+ try {
488
+ for (const name of (await fs.readdir(statePath(root, "history", "spikes"))).filter((n) => n.endsWith(".md") && n !== "README.md")) {
489
+ const item = parseSettledSpike((await readIfPresent(statePath(root, "history", "spikes", name))) ?? "");
490
+ if (item)
491
+ settledSpikes.push(item);
492
+ }
493
+ }
494
+ catch {
495
+ // No spikes directory: nothing settled.
496
+ }
249
497
  let config = null;
250
498
  let configValid = true;
251
499
  if (configRaw !== null) {
@@ -269,6 +517,11 @@ export async function readProjectState(root) {
269
517
  overviewFresh: !generated || stamped === null
270
518
  ? null
271
519
  : stamped === overviewHash(projectPlan ?? "", buildPlan ?? ""),
272
- openQuestions: parseOpenQuestions(overview)
520
+ openQuestions: parseOpenQuestions(overview),
521
+ activity: parseActivity(activity, await installedSkills(root)),
522
+ inbox: parseInbox(inbox),
523
+ learning: { lessons: parseLessons(lessons), journal: entries, undecided: entries.filter((e) => !e.decided).length },
524
+ settledSpikes,
525
+ spec: specState(work)
273
526
  };
274
527
  }
@@ -4,10 +4,16 @@
4
4
  * The next action is the point. A report that lists five things someone could do has not
5
5
  * answered the question they asked, which is almost always "what now".
6
6
  */
7
+ import { waiting } from "./state.js";
8
+ /** Below this a distill pass has little to group, and recurrence is what it promotes on. */
9
+ export const DISTILL_AFTER = 5;
7
10
  export function computeStatus(state) {
8
11
  const leaves = state.plan.filter((item, index) => state.plan[index + 1] === undefined || state.plan[index + 1].depth <= item.depth);
9
12
  const done = leaves.filter((i) => i.done).length;
10
- const nextItem = leaves.find((i) => !i.done) ?? null;
13
+ const { waits, problems } = waiting(state.plan, new Set(state.settledSpikes));
14
+ const waitsOn = (item) => (item.number ? waits.get(item.number) : undefined);
15
+ const nextItem = leaves.find((i) => !i.done && !waitsOn(i)) ?? null;
16
+ const passedOver = leaves.slice(0, nextItem ? leaves.indexOf(nextItem) : leaves.length).filter((i) => !i.done && waitsOn(i));
11
17
  const byStatus = {};
12
18
  for (const finding of state.findings) {
13
19
  byStatus[finding.status] = (byStatus[finding.status] ?? 0) + 1;
@@ -16,6 +22,11 @@ export function computeStatus(state) {
16
22
  .filter((f) => (f.severity === "P0" || f.severity === "P1") && (f.status === "open" || f.status === "fixed"))
17
23
  .map((f) => `${f.id} [${f.severity}] ${f.status} - ${f.title}`);
18
24
  const warnings = [];
25
+ if (passedOver.length > 0) {
26
+ const named = passedOver.map((i) => `${i.number} (${waitsOn(i).join(", ")})`);
27
+ warnings.push(`passed over plan items that are waiting: ${named.join("; ")}`);
28
+ }
29
+ warnings.push(...problems.map((p) => `the build plan: ${p}`));
19
30
  if (!state.configValid)
20
31
  warnings.push("config.json does not parse; workflow commands will stop rather than guess");
21
32
  if (state.overviewFresh === false)
@@ -23,27 +34,40 @@ export function computeStatus(state) {
23
34
  if (state.openQuestions.length > 0) {
24
35
  warnings.push(`open question(s) in the overview: ${state.openQuestions.join(", ")}`);
25
36
  }
37
+ if (state.spec.drifted) {
38
+ warnings.push("the active spec changed since its approval stamp; record an amendment saying why and renew the stamp");
39
+ }
40
+ if (state.learning.undecided >= DISTILL_AFTER) {
41
+ warnings.push(`${state.learning.undecided} journal entries are waiting for distill`);
42
+ }
26
43
  if (state.work.active && state.work.stepsTotal > 0 && state.work.stepsDone === state.work.stepsTotal &&
27
44
  state.work.status !== "verified") {
28
45
  warnings.push("every step is ticked but the work is not verified");
29
46
  }
30
47
  return {
31
48
  schemaVersion: 1,
32
- next: nextAction(state, nextItem?.title ?? null, blocking.length > 0),
49
+ next: nextAction(state, nextItem?.title ?? null, blocking.length > 0, leaves.filter((i) => !i.done && waitsOn(i)), waitsOn),
33
50
  plan: { done, total: leaves.length, nextItem: nextItem?.title ?? null },
34
51
  work: state.work,
35
52
  findings: { total: state.findings.length, blocking, byStatus },
36
53
  overview: { present: state.overviewPresent, fresh: state.overviewFresh },
54
+ activity: state.activity,
55
+ inbox: { notes: state.inbox.length },
56
+ spec: state.spec,
37
57
  warnings
38
58
  };
39
59
  }
40
- function nextAction(state, nextItem, blocked) {
60
+ function nextAction(state, nextItem, blocked, waitingLeaves, waitsOn) {
41
61
  if (!state.configValid)
42
62
  return { command: "doctor", because: "configuration cannot be parsed" };
43
63
  if (!state.overviewPresent)
44
64
  return { command: "overview", because: "no source of truth has been generated" };
45
65
  if (state.overviewFresh === false)
46
66
  return { command: "overview", because: "the plans have changed since it was generated" };
67
+ const run = state.activity;
68
+ if (run.status === "blocked" && run.resumeCommand) {
69
+ return { command: run.resumeCommand.replace(/^[/$]/, ""), because: `the ${run.command} run is blocked: ${run.summary}` };
70
+ }
47
71
  if (state.work.active) {
48
72
  if (blocked)
49
73
  return { command: "audit", because: "a blocking finding must be re-reviewed before completion" };
@@ -58,6 +82,12 @@ function nextAction(state, nextItem, blocked) {
58
82
  return { command: "feature", because: `next in the plan: ${nextItem}` };
59
83
  if (state.plan.length === 0)
60
84
  return { command: "overview", because: "the build plan has no items yet" };
85
+ if (waitingLeaves.length > 0) {
86
+ const onSpike = waitingLeaves.find((i) => waitsOn(i).includes("a spike"));
87
+ return onSpike
88
+ ? { command: "spike", because: `every remaining item waits, and item ${onSpike.number} waits on a spike` }
89
+ : { command: "overview", because: "every remaining item waits on another; check the plan's after lines" };
90
+ }
61
91
  return { command: "release", because: "the build plan is complete" };
62
92
  }
63
93
  export function renderStatus(status, cmd = "/") {
@@ -69,6 +99,11 @@ export function renderStatus(status, cmd = "/") {
69
99
  (status.work.status ? ` (${status.work.status})` : "")
70
100
  : "Active: nothing in progress");
71
101
  out.push(`Plan: ${status.plan.done}/${status.plan.total} complete (${pct}%)`);
102
+ const run = status.activity;
103
+ out.push(run.recorded
104
+ ? `Run: ${cmd}${run.command} ${run.status} - ${run.summary}` + (run.updatedAt ? ` (updated ${run.updatedAt})` : "")
105
+ : "Run: none recorded");
106
+ out.push(`Inbox: ${status.inbox.notes === 0 ? "empty" : `${status.inbox.notes} note(s)`}`);
72
107
  if (status.findings.total === 0) {
73
108
  out.push("Findings: none");
74
109
  }