create-religion 1.4.0 → 1.6.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 (31) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/lib/dashboard.js +4 -3
  3. package/dist/lib/doctor.js +21 -7
  4. package/dist/lib/settings.js +4 -0
  5. package/dist/lib/state.js +143 -5
  6. package/dist/lib/status.js +36 -4
  7. package/package.json +1 -1
  8. package/template/.agents/skills/audit/SKILL.md +13 -7
  9. package/template/.agents/skills/auto/SKILL.md +120 -2
  10. package/template/.agents/skills/complete/SKILL.md +22 -6
  11. package/template/.agents/skills/complete/reference/land-on-top.md +188 -0
  12. package/template/.agents/skills/distill/SKILL.md +2 -1
  13. package/template/.agents/skills/feature/SKILL.md +6 -1
  14. package/template/.agents/skills/fix/SKILL.md +3 -2
  15. package/template/.agents/skills/implement/SKILL.md +18 -1
  16. package/template/.agents/skills/implement/reference/item-worktree.md +133 -0
  17. package/template/.agents/skills/rollback/SKILL.md +3 -1
  18. package/template/.claude/hooks/write-handoff.mjs +92 -3
  19. package/template/.claude/skills/audit/SKILL.md +13 -7
  20. package/template/.claude/skills/auto/SKILL.md +120 -2
  21. package/template/.claude/skills/complete/SKILL.md +22 -6
  22. package/template/.claude/skills/complete/reference/land-on-top.md +188 -0
  23. package/template/.claude/skills/distill/SKILL.md +2 -1
  24. package/template/.claude/skills/feature/SKILL.md +6 -1
  25. package/template/.claude/skills/fix/SKILL.md +3 -2
  26. package/template/.claude/skills/implement/SKILL.md +18 -1
  27. package/template/.claude/skills/implement/reference/item-worktree.md +133 -0
  28. package/template/.claude/skills/rollback/SKILL.md +3 -1
  29. package/template/AGENTS.md +12 -1
  30. package/template/CLAUDE.md +12 -1
  31. package/template/religion/config.json +2 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # create-religion
2
2
 
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6834ede: `auto refresh` catches a run up when another run's aggregate pull request merged into the
8
+ default branch first: it brings the default branch into the run's integration branch with a
9
+ merge commit, takes the landed plan, ledger and journal and redoes the run's own share on top,
10
+ runs `Verify`, then pushes the branch and notes the refresh in the aggregate pull request. It asks
11
+ for that push and edit before starting, and never rebases or force-pushes.
12
+ - 6834ede: With `workflow.parallelItems` on under `git.mode: pull-request`, an `auto` run works in its own
13
+ git worktree beside the project, on an integration branch named from the date and its items,
14
+ such as `auto/2026-10-26-6-10`, so two sessions can each run a named set of items at once.
15
+ Before any work a named run stops on an item another run holds, and names the items it will
16
+ pass over because they wait on another run's. A named run now releases its items only as its
17
+ last write, after the aggregate pull request is open.
18
+ - 9e73d9a: `auto` takes an explicit list or range of plan items, such as `1-5` or `6, 7, 8`, and records
19
+ them in its activity record as `items`. `religion status --json` reports them under `activity`
20
+ and under each `inFlight` entry, and lists a worktree whose run sits between items by the items
21
+ it holds. `religion status` and `feature` pass over items a run in another worktree holds,
22
+ including the ones it has not reached yet, and the handoff names them. A held plan item
23
+ holds every sub-item under it.
24
+
25
+ ## 1.5.0
26
+
27
+ ### Minor Changes
28
+
29
+ - 419f78b: A finding raised while a planned item is active is now headed with the item's number, such as
30
+ `39a/F-1`, so items built side by side cannot hand out the same identifier. `religion status`,
31
+ the handoff and the dashboard read prefixed and bare identifiers alike, so existing ledgers keep
32
+ working, and the dashboard orders findings by their place in the ledger.
33
+ - 9d0c8ba: `religion status --json` gains `inFlight`, the work items active in any worktree of the
34
+ repository, each with its worktree, branch, plan item, title, step counts and whether it is the
35
+ one the command ran in. `religion status` lists items in flight elsewhere and passes over a plan
36
+ item another worktree is building, and the handoff lists them too. Worktrees are read from git's
37
+ own files, so nothing runs git, and a spec in another worktree is read only when it is a plain
38
+ file inside that worktree.
39
+ - 76e2f5b: With items built in their own worktrees, `complete` now lands each one on top of whatever
40
+ landed first: it merges the target without committing, takes the landed copy of the build plan,
41
+ findings ledger, journal, overview and handoff, redoes the item's own tick, findings and journal
42
+ entry, stops on any other conflict, and runs `Verify` again before committing the merge. A merge
43
+ given up half way resets to the item's work commit and names everything it discarded.
44
+ - 4727989: A new setting, `workflow.parallelItems`, off by default, has `implement` start each planned
45
+ feature in its own git worktree beside the project, on the item's branch, moving the spec there
46
+ so the main checkout can spec the next item. `religion doctor` refuses the setting under
47
+ `git.mode: trunk`, and inside a linked worktree names the main checkout and says where to copy a
48
+ missing `.claude/settings.json` from.
49
+
3
50
  ## 1.4.0
4
51
 
5
52
  ### Minor Changes
@@ -757,7 +757,8 @@ button.chip { font-family: inherit; cursor: pointer; }
757
757
  }
758
758
 
759
759
  const STATUS_ORDER = ["open", "fixed", "unverified", "accepted", "closed", "invalid"];
760
- const idNumber = (f) => Number(String(f.id).slice(2)) || 0;
760
+ // The ledger only appends, so a finding's place in it is its age; identifiers restart per item prefix.
761
+ const age = (f) => data.findings.indexOf(f);
761
762
  const pill = (status) => "<span class='status " + (["open", "fixed", "closed"].indexOf(status) >= 0 ? status : "unverified") + "'>" + esc(status) + "</span>";
762
763
  const stacked = (parts, max) => "<span class='track'>" + parts.map((p) => "<i style='width:" + share(p[0], max) + "%;background:var(--sev-" + p[1].toLowerCase() + ")'></i>").join("") + "</span>";
763
764
 
@@ -822,7 +823,7 @@ button.chip { font-family: inherit; cursor: pointer; }
822
823
  const shown = all
823
824
  .filter((f) => (ui.status === "all" ? true : ui.status === "unresolved" ? UNRESOLVED.indexOf(f.status) >= 0 : f.status === ui.status))
824
825
  .filter((f) => !ui.severity || f.severity === ui.severity)
825
- .sort((a, b) => a.severity.localeCompare(b.severity) || STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || idNumber(a) - idNumber(b));
826
+ .sort((a, b) => a.severity.localeCompare(b.severity) || STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || age(a) - age(b));
826
827
  const picked = shown.find((f) => f.id === ui.finding) || shown[0] || null;
827
828
  $("fd-table").innerHTML = shown.length
828
829
  ? "<table class='rows'><tr><th>ID</th><th>Sev</th><th>Finding</th><th>Status</th><th>File</th><th>Lens</th></tr>" +
@@ -999,7 +1000,7 @@ button.chip { font-family: inherit; cursor: pointer; }
999
1000
  const count = (severity, status) => data.findings.filter((f) => f.severity === severity && (!status || f.status === status)).length;
1000
1001
  const max = Math.max(1, ...SEVERITIES.flatMap((s) => columns.map((c) => count(s, c))));
1001
1002
  const blocking = data.status.findings.blocking.length;
1002
- const oldest = pending.slice().sort((a, b) => idNumber(a) - idNumber(b)).slice(0, 3);
1003
+ const oldest = pending.slice().sort((a, b) => age(a) - age(b)).slice(0, 3);
1003
1004
  return "<table><tr><th>Severity</th><th>Open</th><th>Fixed</th><th>Unverified</th><th>Total</th></tr>" +
1004
1005
  SEVERITIES.map((s) =>
1005
1006
  "<tr><td><span class='sev " + s.toLowerCase() + "'>" + s + "</span></td>" + columns.map((c) => "<td class='" + heat(count(s, c), max) + "'>" + count(s, c) + "</td>").join("") +
@@ -9,7 +9,7 @@ import path from "node:path";
9
9
  import { exists, readIfPresent, SKILL_TREES, STATE_DIR, statePath } from "./paths.js";
10
10
  import { editedSinceInstall, isPlainFile } from "./install.js";
11
11
  import { SETTINGS, settingProblems } from "./settings.js";
12
- import { readProjectState } from "./state.js";
12
+ import { listWorktrees, readProjectState } from "./state.js";
13
13
  const REQUIRED = [
14
14
  "config.json",
15
15
  "project-plan.md",
@@ -50,6 +50,10 @@ export async function runDoctor(root, changes) {
50
50
  }
51
51
  else {
52
52
  const bad = settingProblems(state.config);
53
+ const config = (state.config ?? {});
54
+ if (bad.length === 0 && config.workflow?.parallelItems === true && (config.git?.mode ?? "trunk") === "trunk") {
55
+ bad.push("workflow.parallelItems needs a branch per item, which git.mode trunk never makes");
56
+ }
53
57
  results.push({
54
58
  name: "configuration",
55
59
  ok: bad.length === 0,
@@ -121,14 +125,20 @@ export async function runDoctor(root, changes) {
121
125
  detail: ignored ? "state is local only" : "state is committed",
122
126
  blocks: "nothing"
123
127
  });
128
+ const worktrees = await listWorktrees(root);
129
+ const linkedTo = worktrees?.main && worktrees.main !== worktrees.home ? worktrees.main : null;
124
130
  results.push({
125
131
  name: "git",
126
132
  ok: await exists(path.join(root, ".git")),
127
- detail: (await exists(path.join(root, ".git"))) ? "repository present" : "not a git repository",
133
+ detail: !(await exists(path.join(root, ".git")))
134
+ ? "not a git repository"
135
+ : linkedTo
136
+ ? `linked worktree of ${linkedTo}`
137
+ : "repository present",
128
138
  blocks: "completion"
129
139
  });
130
140
  results.push(managedFilesCheck(changes === undefined ? await editedSinceInstall(root) : changes));
131
- results.push(await readHooks(root));
141
+ results.push(await readHooks(root, linkedTo));
132
142
  return results;
133
143
  }
134
144
  const SETTINGS_FILE = ".claude/settings.json";
@@ -143,7 +153,7 @@ export const HOOKS = [
143
153
  { event: "PostToolUse", command: "node .claude/hooks/scan-untrusted-input.mjs" },
144
154
  { event: "PreToolUse", command: "node .claude/hooks/warn-on-drift.mjs" }
145
155
  ];
146
- async function readHooks(root) {
156
+ async function readHooks(root, mainCheckout) {
147
157
  const files = new Set();
148
158
  for (const { command } of HOOKS) {
149
159
  const file = hookFile(command);
@@ -156,7 +166,8 @@ async function readHooks(root) {
156
166
  wanted: HOOKS,
157
167
  settings: await readSettings(root, SETTINGS_FILE),
158
168
  local: await readSettings(root, LOCAL_SETTINGS_FILE),
159
- files
169
+ files,
170
+ mainCheckout
160
171
  });
161
172
  }
162
173
  /** A settings file's text, read only when it is a plain file reached without a link. */
@@ -221,8 +232,11 @@ export function hooksCheck(input) {
221
232
  if (input.settings === "not-file")
222
233
  return result(false, `${SETTINGS_FILE} is not a regular file`);
223
234
  const local = typeof input.local === "string" && input.local !== "link" && input.local !== "not-file" ? input.local : null;
224
- if (input.settings === null && local === null)
225
- return result(false, `${SETTINGS_FILE} is missing; a fresh install writes it`);
235
+ if (input.settings === null && local === null) {
236
+ return result(false, input.mainCheckout
237
+ ? `${SETTINGS_FILE} is missing in this linked worktree; copy the main checkout's from ${input.mainCheckout}`
238
+ : `${SETTINGS_FILE} is missing; a fresh install writes it`);
239
+ }
226
240
  const shared = input.settings === null ? [] : parseHooks(input.settings);
227
241
  if (shared === null)
228
242
  return result(false, `${SETTINGS_FILE} does not parse`);
@@ -16,6 +16,10 @@ export const SETTINGS = {
16
16
  kind: "boolean", default: false,
17
17
  summary: "Whether steps a spec marks as independent build together as one reviewed wave."
18
18
  },
19
+ "workflow.parallelItems": {
20
+ kind: "boolean", default: false,
21
+ summary: "Whether implement starts each planned item in its own git worktree beside the project."
22
+ },
19
23
  "git.mode": {
20
24
  kind: "enum", values: ["trunk", "branch-per-item", "pull-request"], default: "trunk",
21
25
  summary: "Where work lands: the branch you are on, a branch merged per item, or a pull request per item."
package/dist/lib/state.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import crypto from "node:crypto";
9
9
  import fs from "node:fs/promises";
10
+ import path from "node:path";
10
11
  import { installedSkills, readIfPresent, statePath } from "./paths.js";
11
12
  /** Item numbers a spike archive says it answered and settled, from `**For:** item N` and `**Settled:** yes`. */
12
13
  export function parseSettledSpike(source) {
@@ -335,7 +336,7 @@ const CONTROL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
335
336
  * write the record controls every field in it.
336
337
  */
337
338
  export function parseActivity(source, skills) {
338
- const none = { recorded: false, command: null, status: null, summary: null, updatedAt: null, resumeCommand: null };
339
+ const none = { recorded: false, command: null, status: null, summary: null, updatedAt: null, resumeCommand: null, items: [] };
339
340
  if (!source)
340
341
  return none;
341
342
  let record;
@@ -347,7 +348,7 @@ export function parseActivity(source, skills) {
347
348
  }
348
349
  if (!record || typeof record !== "object")
349
350
  return none;
350
- const { command, status, summary, updatedAt, resumeCommand } = record;
351
+ const { command, status, summary, updatedAt, resumeCommand, items } = record;
351
352
  if (typeof command !== "string" || !SKILL_NAME.test(command) || !skills.has(command))
352
353
  return none;
353
354
  if (typeof summary !== "string")
@@ -360,9 +361,18 @@ export function parseActivity(source, skills) {
360
361
  status: status,
361
362
  summary: summary.replace(CONTROL, " "),
362
363
  updatedAt: typeof updatedAt === "string" ? updatedAt.replace(CONTROL, " ") : null,
363
- resumeCommand: resumes(command, resumeCommand) ? resumeCommand : null
364
+ resumeCommand: resumes(command, resumeCommand) ? resumeCommand : null,
365
+ items: command === "auto" ? heldItems(items) : []
364
366
  };
365
367
  }
368
+ const HELD_MAX = 100;
369
+ /** A malformed list degrades to fewer items, never to an unreadable record. */
370
+ function heldItems(items) {
371
+ if (!Array.isArray(items))
372
+ return [];
373
+ const held = items.filter((i) => typeof i === "string" && /^\d+[a-z]?$/i.test(i)).map((i) => i.toLowerCase());
374
+ return [...new Set(held)].slice(0, HELD_MAX);
375
+ }
366
376
  function resumes(command, resumeCommand) {
367
377
  if (typeof resumeCommand !== "string" || resumeCommand.length > RESUME_MAX)
368
378
  return false;
@@ -425,7 +435,8 @@ export function parseFindings(source) {
425
435
  return [];
426
436
  const entries = source.split(/^(?=###\s)/m);
427
437
  return entries.flatMap((entry) => {
428
- const m = /^###\s+(F-\d+)\s+\[(P[0-3])\]\s+(\w+)\s+-\s+(.+)$/im.exec(entry);
438
+ // A finding raised during a planned item carries the item's prefix, `39a/F-1`; older ones are bare.
439
+ const m = /^###\s+((?:[\w-]+\/)?F-\d+)\s+\[(P[0-3])\]\s+(\w+)\s+-\s+(.+)$/im.exec(entry);
429
440
  if (!m)
430
441
  return [];
431
442
  // A finding raised while building has no lens: "2026-09-26 while writing the guide".
@@ -462,6 +473,132 @@ export function parseOpenQuestions(overview) {
462
473
  return [];
463
474
  return [...section[1].matchAll(/^-\s+\*\*(.+?)\*\*/gim)].map((m) => (m[1] ?? "").trim());
464
475
  }
476
+ function headBranch(head) {
477
+ return /^ref:\s*refs\/heads\/(.+)$/m.exec(head ?? "")?.[1]?.trim() ?? null;
478
+ }
479
+ /** The git directory a `.git` entry stands for: itself, or where its `gitdir:` line points. */
480
+ async function gitDir(dotgit) {
481
+ const stat = await fs.stat(dotgit).catch(() => null);
482
+ if (!stat)
483
+ return null;
484
+ if (stat.isDirectory())
485
+ return dotgit;
486
+ const target = /^gitdir:\s*(.+)$/m.exec((await readIfPresent(dotgit)) ?? "")?.[1]?.trim();
487
+ return target ? path.resolve(path.dirname(dotgit), target) : null;
488
+ }
489
+ /**
490
+ * The repository's worktrees, the one holding `root` and the main checkout, read from git's own records under
491
+ * `worktrees/` rather than by running git, which shipped code may not do. A worktree whose
492
+ * directory is gone is left out, and so is the main entry of a bare repository. Null outside
493
+ * a repository.
494
+ */
495
+ export async function listWorktrees(root) {
496
+ let home = await fs.realpath(root).catch(() => null);
497
+ let own = null;
498
+ while (home && !(own = await gitDir(path.join(home, ".git")))) {
499
+ const up = path.dirname(home);
500
+ home = up === home ? null : up;
501
+ }
502
+ if (!home || !own)
503
+ return null;
504
+ const commondir = await readIfPresent(path.join(own, "commondir"));
505
+ const common = commondir ? path.resolve(own, commondir.trim()) : own;
506
+ const records = [{ dir: path.basename(common) === ".git" ? path.dirname(common) : null, git: common }];
507
+ for (const id of await fs.readdir(path.join(common, "worktrees")).catch(() => [])) {
508
+ const git = path.join(common, "worktrees", id);
509
+ const target = (await readIfPresent(path.join(git, "gitdir")))?.trim();
510
+ // Git calls a record prunable once the `.git` file it points at is gone.
511
+ const dotgit = target ? path.resolve(git, target) : null;
512
+ if (dotgit && (await fs.stat(dotgit).catch(() => null)))
513
+ records.push({ dir: path.dirname(dotgit), git });
514
+ }
515
+ const trees = [];
516
+ for (const { dir, git } of records) {
517
+ const resolved = dir ? await fs.realpath(dir).catch(() => null) : null;
518
+ if (resolved)
519
+ trees.push({ path: resolved, branch: headBranch(await readIfPresent(path.join(git, "HEAD"))) });
520
+ }
521
+ if (!trees.some((t) => t.path === home))
522
+ trees.unshift({ path: home, branch: headBranch(await readIfPresent(path.join(own, "HEAD"))) });
523
+ const main = records[0].dir ? await fs.realpath(records[0].dir).catch(() => null) : null;
524
+ return { home, main, trees };
525
+ }
526
+ /** Far above any real spec; a larger file in another worktree is not read. */
527
+ const SPEC_MAX = 256 * 1024;
528
+ /**
529
+ * A file another worktree's branch controls: a link could point at a device that never ends,
530
+ * or at a file whose first heading would be reported as a title. So it is opened without
531
+ * following a link or blocking, checked through the open handle, and read up to the cap.
532
+ */
533
+ async function readPlain(file, within) {
534
+ // The folder is resolved so a linked directory cannot lead outside that worktree; the spec
535
+ // itself is not, so a spec that is a link is refused below.
536
+ const folder = await fs.realpath(path.dirname(file)).catch(() => null);
537
+ if (!folder || !(folder + path.sep).startsWith(within + path.sep))
538
+ return null;
539
+ const resolved = path.join(folder, path.basename(file));
540
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
541
+ const handle = await fs.open(resolved, flags).catch(() => null);
542
+ if (!handle)
543
+ return null;
544
+ try {
545
+ const stat = await handle.stat();
546
+ if (!stat.isFile() || stat.size > SPEC_MAX)
547
+ return null;
548
+ // Windows has no O_NOFOLLOW: the name must still be a plain file, the one that was opened.
549
+ const named = await fs.lstat(resolved);
550
+ if (!named.isFile() || named.ino !== stat.ino || named.dev !== stat.dev)
551
+ return null;
552
+ const buffer = Buffer.alloc(stat.size);
553
+ const { bytesRead } = await handle.read(buffer, 0, stat.size, 0);
554
+ return buffer.subarray(0, bytesRead).toString("utf8");
555
+ }
556
+ catch {
557
+ return null;
558
+ }
559
+ finally {
560
+ await handle.close().catch(() => undefined);
561
+ }
562
+ }
563
+ /** Active specs across the repository's worktrees; empty outside a repository. */
564
+ const AUTO = new Set(["auto"]);
565
+ export async function readInFlight(root) {
566
+ const listed = await listWorktrees(root);
567
+ const self = await fs.realpath(root).catch(() => null);
568
+ if (!listed || !self)
569
+ return [];
570
+ // Religion may live below the repository's top, so each spec sits at the same offset in its worktree.
571
+ const offset = path.relative(listed.home, self);
572
+ const found = [];
573
+ for (const { path: worktree, branch } of listed.trees) {
574
+ // A wave's step worktree holds a copy of its parent item's spec, not an item of its own.
575
+ if (/^step-\d+$/.test(path.basename(worktree)) && (await readIfPresent(path.join(path.dirname(worktree), "base"))) !== null)
576
+ continue;
577
+ const base = path.join(worktree, offset);
578
+ // This worktree's files are read as `work` reads them; another's come from someone else's branch.
579
+ const read = (file) => (worktree === listed.home ? readIfPresent(file) : readPlain(file, worktree));
580
+ const source = await read(statePath(base, "context", "current-work.md"));
581
+ // Only an `auto` run holds items, so no other skill needs to be installed for its record to count.
582
+ const { items } = parseActivity(await read(statePath(base, ".state", "run.json")), AUTO);
583
+ const work = parseWork(source);
584
+ // A run between items has no spec, yet still holds the items it has not reached.
585
+ if (!work.active && items.length === 0)
586
+ continue;
587
+ const plan = work.active ? field(source, "From build plan") : null;
588
+ found.push({
589
+ worktree,
590
+ branch,
591
+ // Plan numbers are compared lowercased, as the build plan's parser stores them.
592
+ item: plan ? /^item\s+([0-9a-z]+)/i.exec(plan)?.[1]?.toLowerCase() ?? null : null,
593
+ title: work.title,
594
+ stepsDone: work.stepsDone,
595
+ stepsTotal: work.stepsTotal,
596
+ here: worktree === listed.home,
597
+ items
598
+ });
599
+ }
600
+ return found;
601
+ }
465
602
  /** The stamp the overview carries, and the hash the current plans produce. */
466
603
  export function overviewHash(projectPlan, buildPlan) {
467
604
  return crypto.createHash("sha256").update(projectPlan).update(buildPlan).digest("hex").slice(0, 16);
@@ -522,6 +659,7 @@ export async function readProjectState(root) {
522
659
  inbox: parseInbox(inbox),
523
660
  learning: { lessons: parseLessons(lessons), journal: entries, undecided: entries.filter((e) => !e.decided).length },
524
661
  settledSpikes,
525
- spec: specState(work)
662
+ spec: specState(work),
663
+ inFlight: await readInFlight(root)
526
664
  };
527
665
  }
@@ -12,8 +12,24 @@ export function computeStatus(state) {
12
12
  const done = leaves.filter((i) => i.done).length;
13
13
  const { waits, problems } = waiting(state.plan, new Set(state.settledSpikes));
14
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));
15
+ // An item another worktree is building, or that a run there holds, is not this checkout's to start.
16
+ const elsewhere = new Map(state.inFlight.filter((i) => !i.here).flatMap((i) => [...i.items, ...(i.item ? [i.item] : [])].map((n) => [n, i.branch ?? i.worktree])));
17
+ // A run holding a parent holds its sub-items, which are what actually get built.
18
+ const holder = new Map();
19
+ const parents = [];
20
+ for (const item of state.plan) {
21
+ while (parents.length && parents[parents.length - 1].depth >= item.depth)
22
+ parents.pop();
23
+ const by = [...parents, item].map((p) => p.number && elsewhere.get(p.number)).find(Boolean);
24
+ if (by)
25
+ holder.set(item, by);
26
+ parents.push(item);
27
+ }
28
+ const busy = (item) => holder.has(item);
29
+ const nextItem = leaves.find((i) => !i.done && !waitsOn(i) && !busy(i)) ?? null;
30
+ const before = leaves.slice(0, nextItem ? leaves.indexOf(nextItem) : leaves.length).filter((i) => !i.done);
31
+ const passedOver = before.filter((i) => waitsOn(i));
32
+ const building = before.filter((i) => !waitsOn(i) && busy(i));
17
33
  const byStatus = {};
18
34
  for (const finding of state.findings) {
19
35
  byStatus[finding.status] = (byStatus[finding.status] ?? 0) + 1;
@@ -26,6 +42,11 @@ export function computeStatus(state) {
26
42
  const named = passedOver.map((i) => `${i.number} (${waitsOn(i).join(", ")})`);
27
43
  warnings.push(`passed over plan items that are waiting: ${named.join("; ")}`);
28
44
  }
45
+ // An unnumbered line under a held parent is passed over too, but has no number to name.
46
+ if (building.some((i) => i.number)) {
47
+ const named = building.filter((i) => i.number).map((i) => `${i.number} (${holder.get(i)})`);
48
+ warnings.push(`passed over plan items in flight in another worktree: ${named.join("; ")}`);
49
+ }
29
50
  warnings.push(...problems.map((p) => `the build plan: ${p}`));
30
51
  if (!state.configValid)
31
52
  warnings.push("config.json does not parse; workflow commands will stop rather than guess");
@@ -46,7 +67,7 @@ export function computeStatus(state) {
46
67
  }
47
68
  return {
48
69
  schemaVersion: 1,
49
- next: nextAction(state, nextItem?.title ?? null, blocking.length > 0, leaves.filter((i) => !i.done && waitsOn(i)), waitsOn),
70
+ next: nextAction(state, nextItem?.title ?? null, blocking.length > 0, leaves.filter((i) => !i.done && waitsOn(i)), waitsOn, building.length > 0),
50
71
  plan: { done, total: leaves.length, nextItem: nextItem?.title ?? null },
51
72
  work: state.work,
52
73
  findings: { total: state.findings.length, blocking, byStatus },
@@ -54,10 +75,11 @@ export function computeStatus(state) {
54
75
  activity: state.activity,
55
76
  inbox: { notes: state.inbox.length },
56
77
  spec: state.spec,
78
+ inFlight: state.inFlight,
57
79
  warnings
58
80
  };
59
81
  }
60
- function nextAction(state, nextItem, blocked, waitingLeaves, waitsOn) {
82
+ function nextAction(state, nextItem, blocked, waitingLeaves, waitsOn, building) {
61
83
  if (!state.configValid)
62
84
  return { command: "doctor", because: "configuration cannot be parsed" };
63
85
  if (!state.overviewPresent)
@@ -82,6 +104,9 @@ function nextAction(state, nextItem, blocked, waitingLeaves, waitsOn) {
82
104
  return { command: "feature", because: `next in the plan: ${nextItem}` };
83
105
  if (state.plan.length === 0)
84
106
  return { command: "overview", because: "the build plan has no items yet" };
107
+ if (building && waitingLeaves.length === 0) {
108
+ return { command: "implement", because: "every remaining item is in flight in another worktree; continue one there" };
109
+ }
85
110
  if (waitingLeaves.length > 0) {
86
111
  const onSpike = waitingLeaves.find((i) => waitsOn(i).includes("a spike"));
87
112
  return onSpike
@@ -98,6 +123,13 @@ export function renderStatus(status, cmd = "/") {
98
123
  ? `Active: ${status.work.title ?? "untitled"} - step ${status.work.stepsDone}/${status.work.stepsTotal}` +
99
124
  (status.work.status ? ` (${status.work.status})` : "")
100
125
  : "Active: nothing in progress");
126
+ for (const item of status.inFlight.filter((i) => !i.here)) {
127
+ const holding = item.items.length > 0 ? `holding items ${item.items.join(", ")}` : "";
128
+ // A run between items has no spec to describe, only the items it holds.
129
+ const between = holding && !item.item && !item.title && item.stepsTotal === 0;
130
+ const work = between ? null : `${item.item ? `item ${item.item} ` : ""}${item.title ?? "untitled"} - step ${item.stepsDone}/${item.stepsTotal}`;
131
+ out.push(`In flight: ${work ? work + (holding ? `, ${holding}` : "") : `auto run ${holding}`} on ${item.branch ?? item.worktree}`);
132
+ }
101
133
  out.push(`Plan: ${status.plan.done}/${status.plan.total} complete (${pct}%)`);
102
134
  const run = status.activity;
103
135
  out.push(run.recorded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-religion",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "A file-backed, spec-driven AI development workflow. Spec before code, small reviewed diffs, and project state in markdown so a cleared context costs nothing.",
5
5
  "license": "MIT",
6
6
  "author": "Nate",
@@ -96,7 +96,7 @@ context; this does. Create it with a `# Findings` heading if it is missing.
96
96
  One block per finding. The header line is the machine-readable part and keeps this exact
97
97
  shape; the prose below it is for people.
98
98
 
99
- ### F-03 [P0] open - Retained auth volumes carry the run label
99
+ ### 12/F-3 [P0] open - Retained auth volumes carry the run label
100
100
 
101
101
  **File:** ops/compose.yaml:86
102
102
  **Found:** 2026-09-04 by audit (scope: current; lens: security)
@@ -105,12 +105,18 @@ shape; the prose below it is for people.
105
105
  **Resolution:**
106
106
 
107
107
  When the pass is re-reviewing a repair and finds a new problem in the code that repair
108
- changed, add `**Surfaced by:** F-NN` after **Found**, naming the finding whose repair exposed
109
- it. That link is what keeps a defect a repair introduced attached to the repair.
110
-
111
- Identifiers are sequential within the ledger, one past the highest present, never reused
112
- and never renumbered while their entries live there. Completion archives resolved entries
113
- under a work-item prefix, and that prefixed form is the permanent reference.
108
+ changed, add `**Surfaced by:** <identifier>` after **Found**, naming the finding whose repair
109
+ exposed it, in full: `**Surfaced by:** 12/F-3`. That link is what keeps a defect a repair
110
+ introduced attached to the repair.
111
+
112
+ **Identifiers carry the item they were raised in.** While the active spec has a
113
+ `**From build plan:** item <n>` line, a new finding is `<n>/F-<k>`: item 12's first is
114
+ `12/F-1`, and `k` is one past the highest present with that prefix. Two items built in
115
+ separate worktrees then never hand out the same identifier. With no active planned item, a
116
+ finding is bare, `F-<k>`, one past the highest bare identifier present, as entries raised
117
+ before prefixes are. Either way an identifier is never reused and never renumbered while its
118
+ entry lives in the ledger. Completion archives resolved entries under the item's prefix, and
119
+ that prefixed form is the permanent reference.
114
120
 
115
121
  | Status | Meaning | Blocks completion at P0/P1 |
116
122
  | --- | --- | --- |