create-religion 1.3.0 → 1.5.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 +35 -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 +125 -2
  6. package/dist/lib/status.js +21 -4
  7. package/package.json +1 -1
  8. package/template/.agents/skills/audit/SKILL.md +13 -7
  9. package/template/.agents/skills/complete/SKILL.md +22 -6
  10. package/template/.agents/skills/complete/reference/land-on-top.md +174 -0
  11. package/template/.agents/skills/distill/SKILL.md +2 -1
  12. package/template/.agents/skills/feature/SKILL.md +4 -1
  13. package/template/.agents/skills/fix/SKILL.md +3 -2
  14. package/template/.agents/skills/implement/SKILL.md +38 -8
  15. package/template/.agents/skills/implement/reference/item-worktree.md +116 -0
  16. package/template/.agents/skills/implement/reference/worktrees.md +175 -0
  17. package/template/.agents/skills/rollback/SKILL.md +3 -1
  18. package/template/.claude/hooks/write-handoff.mjs +80 -3
  19. package/template/.claude/skills/audit/SKILL.md +13 -7
  20. package/template/.claude/skills/complete/SKILL.md +22 -6
  21. package/template/.claude/skills/complete/reference/land-on-top.md +174 -0
  22. package/template/.claude/skills/distill/SKILL.md +2 -1
  23. package/template/.claude/skills/feature/SKILL.md +4 -1
  24. package/template/.claude/skills/fix/SKILL.md +3 -2
  25. package/template/.claude/skills/implement/SKILL.md +38 -8
  26. package/template/.claude/skills/implement/reference/item-worktree.md +116 -0
  27. package/template/.claude/skills/implement/reference/worktrees.md +175 -0
  28. package/template/.claude/skills/rollback/SKILL.md +3 -1
  29. package/template/AGENTS.md +9 -3
  30. package/template/CLAUDE.md +9 -3
  31. package/template/religion/config.json +2 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # create-religion
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 419f78b: A finding raised while a planned item is active is now headed with the item's number, such as
8
+ `39a/F-1`, so items built side by side cannot hand out the same identifier. `religion status`,
9
+ the handoff and the dashboard read prefixed and bare identifiers alike, so existing ledgers keep
10
+ working, and the dashboard orders findings by their place in the ledger.
11
+ - 9d0c8ba: `religion status --json` gains `inFlight`, the work items active in any worktree of the
12
+ repository, each with its worktree, branch, plan item, title, step counts and whether it is the
13
+ one the command ran in. `religion status` lists items in flight elsewhere and passes over a plan
14
+ item another worktree is building, and the handoff lists them too. Worktrees are read from git's
15
+ own files, so nothing runs git, and a spec in another worktree is read only when it is a plain
16
+ file inside that worktree.
17
+ - 76e2f5b: With items built in their own worktrees, `complete` now lands each one on top of whatever
18
+ landed first: it merges the target without committing, takes the landed copy of the build plan,
19
+ findings ledger, journal, overview and handoff, redoes the item's own tick, findings and journal
20
+ entry, stops on any other conflict, and runs `Verify` again before committing the merge. A merge
21
+ given up half way resets to the item's work commit and names everything it discarded.
22
+ - 4727989: A new setting, `workflow.parallelItems`, off by default, has `implement` start each planned
23
+ feature in its own git worktree beside the project, on the item's branch, moving the spec there
24
+ so the main checkout can spec the next item. `religion doctor` refuses the setting under
25
+ `git.mode: trunk`, and inside a linked worktree names the main checkout and says where to copy a
26
+ missing `.claude/settings.json` from.
27
+
28
+ ## 1.4.0
29
+
30
+ ### Minor Changes
31
+
32
+ - a15784b: With `workflow.parallelSteps` on, `implement` builds each wave step in its own detached git
33
+ worktree outside the project and joins the steps' patches with one all-or-nothing `git apply`,
34
+ stopping for review with the project unchanged when they do not apply. The commands live in the
35
+ implement skill's `reference/worktrees.md`. The authority rules let a run remove the worktrees it
36
+ created for a wave once that wave has joined; removing anything else still asks.
37
+
3
38
  ## 1.3.0
4
39
 
5
40
  ### 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) {
@@ -425,7 +426,8 @@ export function parseFindings(source) {
425
426
  return [];
426
427
  const entries = source.split(/^(?=###\s)/m);
427
428
  return entries.flatMap((entry) => {
428
- const m = /^###\s+(F-\d+)\s+\[(P[0-3])\]\s+(\w+)\s+-\s+(.+)$/im.exec(entry);
429
+ // A finding raised during a planned item carries the item's prefix, `39a/F-1`; older ones are bare.
430
+ const m = /^###\s+((?:[\w-]+\/)?F-\d+)\s+\[(P[0-3])\]\s+(\w+)\s+-\s+(.+)$/im.exec(entry);
429
431
  if (!m)
430
432
  return [];
431
433
  // A finding raised while building has no lens: "2026-09-26 while writing the guide".
@@ -462,6 +464,126 @@ export function parseOpenQuestions(overview) {
462
464
  return [];
463
465
  return [...section[1].matchAll(/^-\s+\*\*(.+?)\*\*/gim)].map((m) => (m[1] ?? "").trim());
464
466
  }
467
+ function headBranch(head) {
468
+ return /^ref:\s*refs\/heads\/(.+)$/m.exec(head ?? "")?.[1]?.trim() ?? null;
469
+ }
470
+ /** The git directory a `.git` entry stands for: itself, or where its `gitdir:` line points. */
471
+ async function gitDir(dotgit) {
472
+ const stat = await fs.stat(dotgit).catch(() => null);
473
+ if (!stat)
474
+ return null;
475
+ if (stat.isDirectory())
476
+ return dotgit;
477
+ const target = /^gitdir:\s*(.+)$/m.exec((await readIfPresent(dotgit)) ?? "")?.[1]?.trim();
478
+ return target ? path.resolve(path.dirname(dotgit), target) : null;
479
+ }
480
+ /**
481
+ * The repository's worktrees, the one holding `root` and the main checkout, read from git's own records under
482
+ * `worktrees/` rather than by running git, which shipped code may not do. A worktree whose
483
+ * directory is gone is left out, and so is the main entry of a bare repository. Null outside
484
+ * a repository.
485
+ */
486
+ export async function listWorktrees(root) {
487
+ let home = await fs.realpath(root).catch(() => null);
488
+ let own = null;
489
+ while (home && !(own = await gitDir(path.join(home, ".git")))) {
490
+ const up = path.dirname(home);
491
+ home = up === home ? null : up;
492
+ }
493
+ if (!home || !own)
494
+ return null;
495
+ const commondir = await readIfPresent(path.join(own, "commondir"));
496
+ const common = commondir ? path.resolve(own, commondir.trim()) : own;
497
+ const records = [{ dir: path.basename(common) === ".git" ? path.dirname(common) : null, git: common }];
498
+ for (const id of await fs.readdir(path.join(common, "worktrees")).catch(() => [])) {
499
+ const git = path.join(common, "worktrees", id);
500
+ const target = (await readIfPresent(path.join(git, "gitdir")))?.trim();
501
+ // Git calls a record prunable once the `.git` file it points at is gone.
502
+ const dotgit = target ? path.resolve(git, target) : null;
503
+ if (dotgit && (await fs.stat(dotgit).catch(() => null)))
504
+ records.push({ dir: path.dirname(dotgit), git });
505
+ }
506
+ const trees = [];
507
+ for (const { dir, git } of records) {
508
+ const resolved = dir ? await fs.realpath(dir).catch(() => null) : null;
509
+ if (resolved)
510
+ trees.push({ path: resolved, branch: headBranch(await readIfPresent(path.join(git, "HEAD"))) });
511
+ }
512
+ if (!trees.some((t) => t.path === home))
513
+ trees.unshift({ path: home, branch: headBranch(await readIfPresent(path.join(own, "HEAD"))) });
514
+ const main = records[0].dir ? await fs.realpath(records[0].dir).catch(() => null) : null;
515
+ return { home, main, trees };
516
+ }
517
+ /** Far above any real spec; a larger file in another worktree is not read. */
518
+ const SPEC_MAX = 256 * 1024;
519
+ /**
520
+ * A file another worktree's branch controls: a link could point at a device that never ends,
521
+ * or at a file whose first heading would be reported as a title. So it is opened without
522
+ * following a link or blocking, checked through the open handle, and read up to the cap.
523
+ */
524
+ async function readPlain(file, within) {
525
+ // The folder is resolved so a linked directory cannot lead outside that worktree; the spec
526
+ // itself is not, so a spec that is a link is refused below.
527
+ const folder = await fs.realpath(path.dirname(file)).catch(() => null);
528
+ if (!folder || !(folder + path.sep).startsWith(within + path.sep))
529
+ return null;
530
+ const resolved = path.join(folder, path.basename(file));
531
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
532
+ const handle = await fs.open(resolved, flags).catch(() => null);
533
+ if (!handle)
534
+ return null;
535
+ try {
536
+ const stat = await handle.stat();
537
+ if (!stat.isFile() || stat.size > SPEC_MAX)
538
+ return null;
539
+ // Windows has no O_NOFOLLOW: the name must still be a plain file, the one that was opened.
540
+ const named = await fs.lstat(resolved);
541
+ if (!named.isFile() || named.ino !== stat.ino || named.dev !== stat.dev)
542
+ return null;
543
+ const buffer = Buffer.alloc(stat.size);
544
+ const { bytesRead } = await handle.read(buffer, 0, stat.size, 0);
545
+ return buffer.subarray(0, bytesRead).toString("utf8");
546
+ }
547
+ catch {
548
+ return null;
549
+ }
550
+ finally {
551
+ await handle.close().catch(() => undefined);
552
+ }
553
+ }
554
+ /** Active specs across the repository's worktrees; empty outside a repository. */
555
+ export async function readInFlight(root) {
556
+ const listed = await listWorktrees(root);
557
+ const self = await fs.realpath(root).catch(() => null);
558
+ if (!listed || !self)
559
+ return [];
560
+ // Religion may live below the repository's top, so each spec sits at the same offset in its worktree.
561
+ const offset = path.relative(listed.home, self);
562
+ const found = [];
563
+ for (const { path: worktree, branch } of listed.trees) {
564
+ // A wave's step worktree holds a copy of its parent item's spec, not an item of its own.
565
+ if (/^step-\d+$/.test(path.basename(worktree)) && (await readIfPresent(path.join(path.dirname(worktree), "base"))) !== null)
566
+ continue;
567
+ const spec = statePath(path.join(worktree, offset), "context", "current-work.md");
568
+ // This worktree's spec is read as `work` reads it; another's comes from someone else's branch.
569
+ const source = worktree === listed.home ? await readIfPresent(spec) : await readPlain(spec, worktree);
570
+ const work = parseWork(source);
571
+ if (!source || !work.active)
572
+ continue;
573
+ const plan = field(source, "From build plan");
574
+ found.push({
575
+ worktree,
576
+ branch,
577
+ // Plan numbers are compared lowercased, as the build plan's parser stores them.
578
+ item: plan ? /^item\s+([0-9a-z]+)/i.exec(plan)?.[1]?.toLowerCase() ?? null : null,
579
+ title: work.title,
580
+ stepsDone: work.stepsDone,
581
+ stepsTotal: work.stepsTotal,
582
+ here: worktree === listed.home
583
+ });
584
+ }
585
+ return found;
586
+ }
465
587
  /** The stamp the overview carries, and the hash the current plans produce. */
466
588
  export function overviewHash(projectPlan, buildPlan) {
467
589
  return crypto.createHash("sha256").update(projectPlan).update(buildPlan).digest("hex").slice(0, 16);
@@ -522,6 +644,7 @@ export async function readProjectState(root) {
522
644
  inbox: parseInbox(inbox),
523
645
  learning: { lessons: parseLessons(lessons), journal: entries, undecided: entries.filter((e) => !e.decided).length },
524
646
  settledSpikes,
525
- spec: specState(work)
647
+ spec: specState(work),
648
+ inFlight: await readInFlight(root)
526
649
  };
527
650
  }
@@ -12,8 +12,13 @@ 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 is not this checkout's to start.
16
+ const elsewhere = new Map(state.inFlight.filter((i) => !i.here && i.item).map((i) => [i.item, i.branch ?? i.worktree]));
17
+ const busy = (item) => !!item.number && elsewhere.has(item.number);
18
+ const nextItem = leaves.find((i) => !i.done && !waitsOn(i) && !busy(i)) ?? null;
19
+ const before = leaves.slice(0, nextItem ? leaves.indexOf(nextItem) : leaves.length).filter((i) => !i.done);
20
+ const passedOver = before.filter((i) => waitsOn(i));
21
+ const building = before.filter((i) => !waitsOn(i) && busy(i));
17
22
  const byStatus = {};
18
23
  for (const finding of state.findings) {
19
24
  byStatus[finding.status] = (byStatus[finding.status] ?? 0) + 1;
@@ -26,6 +31,10 @@ export function computeStatus(state) {
26
31
  const named = passedOver.map((i) => `${i.number} (${waitsOn(i).join(", ")})`);
27
32
  warnings.push(`passed over plan items that are waiting: ${named.join("; ")}`);
28
33
  }
34
+ if (building.length > 0) {
35
+ const named = building.map((i) => `${i.number} (${elsewhere.get(i.number)})`);
36
+ warnings.push(`passed over plan items in flight in another worktree: ${named.join("; ")}`);
37
+ }
29
38
  warnings.push(...problems.map((p) => `the build plan: ${p}`));
30
39
  if (!state.configValid)
31
40
  warnings.push("config.json does not parse; workflow commands will stop rather than guess");
@@ -46,7 +55,7 @@ export function computeStatus(state) {
46
55
  }
47
56
  return {
48
57
  schemaVersion: 1,
49
- next: nextAction(state, nextItem?.title ?? null, blocking.length > 0, leaves.filter((i) => !i.done && waitsOn(i)), waitsOn),
58
+ next: nextAction(state, nextItem?.title ?? null, blocking.length > 0, leaves.filter((i) => !i.done && waitsOn(i)), waitsOn, building.length > 0),
50
59
  plan: { done, total: leaves.length, nextItem: nextItem?.title ?? null },
51
60
  work: state.work,
52
61
  findings: { total: state.findings.length, blocking, byStatus },
@@ -54,10 +63,11 @@ export function computeStatus(state) {
54
63
  activity: state.activity,
55
64
  inbox: { notes: state.inbox.length },
56
65
  spec: state.spec,
66
+ inFlight: state.inFlight,
57
67
  warnings
58
68
  };
59
69
  }
60
- function nextAction(state, nextItem, blocked, waitingLeaves, waitsOn) {
70
+ function nextAction(state, nextItem, blocked, waitingLeaves, waitsOn, building) {
61
71
  if (!state.configValid)
62
72
  return { command: "doctor", because: "configuration cannot be parsed" };
63
73
  if (!state.overviewPresent)
@@ -82,6 +92,9 @@ function nextAction(state, nextItem, blocked, waitingLeaves, waitsOn) {
82
92
  return { command: "feature", because: `next in the plan: ${nextItem}` };
83
93
  if (state.plan.length === 0)
84
94
  return { command: "overview", because: "the build plan has no items yet" };
95
+ if (building && waitingLeaves.length === 0) {
96
+ return { command: "implement", because: "every remaining item is in flight in another worktree; continue one there" };
97
+ }
85
98
  if (waitingLeaves.length > 0) {
86
99
  const onSpike = waitingLeaves.find((i) => waitsOn(i).includes("a spike"));
87
100
  return onSpike
@@ -98,6 +111,10 @@ export function renderStatus(status, cmd = "/") {
98
111
  ? `Active: ${status.work.title ?? "untitled"} - step ${status.work.stepsDone}/${status.work.stepsTotal}` +
99
112
  (status.work.status ? ` (${status.work.status})` : "")
100
113
  : "Active: nothing in progress");
114
+ for (const item of status.inFlight.filter((i) => !i.here)) {
115
+ out.push(`In flight: ${item.item ? `item ${item.item} ` : ""}${item.title ?? "untitled"} - step ${item.stepsDone}/${item.stepsTotal}` +
116
+ ` on ${item.branch ?? item.worktree}`);
117
+ }
101
118
  out.push(`Plan: ${status.plan.done}/${status.plan.total} complete (${pct}%)`);
102
119
  const run = status.activity;
103
120
  out.push(run.recorded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-religion",
3
- "version": "1.3.0",
3
+ "version": "1.5.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
  | --- | --- | --- |
@@ -93,9 +93,9 @@ Read the spec's type.
93
93
 
94
94
  The number lives in the path. It never appears in the commit message.
95
95
 
96
- **Record what landed.** After the work commit exists in Step 3, append a `## Landed`
97
- section to the archive with the exact commits this item produced, oldest first, and the
98
- commit that preceded them:
96
+ **Record what landed.** After the work commit exists in Step 3, and after Step 4 has landed
97
+ it on top of anything that landed first, append a `## Landed` section to the archive with the
98
+ exact commits this item produced, oldest first, and the commit that preceded them:
99
99
 
100
100
  ## Landed
101
101
 
@@ -110,9 +110,10 @@ commits sit among others on the same branch, so this record is the only reliable
110
110
 
111
111
  **Prune the ledger.** Append every `closed`, `accepted`, and `invalid` entry to a
112
112
  `## Findings` section in the archive just written, at its final status, with the reason
113
- preserved for accepted entries. Prefix each identifier with the archive name so it is
114
- globally unique: item 12's `F-03` becomes `12/F-03`, and that prefixed form is the
115
- permanent reference.
113
+ preserved for accepted entries. Every archived identifier carries a prefix, so it is
114
+ globally unique, and that prefixed form is the permanent reference. One raised under the
115
+ item's prefix already has it and moves across unchanged: item 12's `12/F-3` stays `12/F-3`.
116
+ Prefix only a bare one, with the archive name: item 12's `F-03` becomes `12/F-03`.
116
117
 
117
118
  Then remove only those entries from the ledger. `open`, `fixed`, and `unverified` entries
118
119
  stay, with their identifiers, so nothing is silently dropped. A `fixed` P2 does not block
@@ -195,6 +196,15 @@ is refused outright when any of those commits has already been pushed.
195
196
  Under **`git.mode: trunk`** the work is already on the branch you were on. There is nothing
196
197
  to merge. Go to Step 5.
197
198
 
199
+ **Under either branching mode, land on top of what already landed.** Before the squash merge
200
+ or the push, follow `reference/land-on-top.md`. When the branch the item lands in has moved
201
+ since the item began, because another item built in its own worktree landed first, it brings
202
+ that in, takes the landed copy of each of the plan, ledger, journal, overview and handoff that
203
+ the other item changed, and redoes this item's share of them, so those files are never merged
204
+ as text. Any other conflict stops
205
+ for review. When nothing landed in between, it does nothing. Write the `## Landed` record after
206
+ it, as its last section says, so a later rollback reverses this item's work alone.
207
+
198
208
  Under **`branch-per-item`**:
199
209
 
200
210
  1. Squash-merge into the default branch, only with an explicit yes, so the item lands as
@@ -245,6 +255,12 @@ confirm the work commit is actually in it, and only then delete the branch local
245
255
  the remote. A squash-merge leaves no merge parent, so `git branch -d` will refuse; verify
246
256
  the content landed before reaching for `-D`.
247
257
 
258
+ An item built in its own worktree holds its branch there, so the cleanup runs from the main
259
+ checkout: remove the worktree first, then the branch. Removing the worktree is deleting files,
260
+ so it asks, every time, and only once the work commit is confirmed in the default branch. When
261
+ the state directory is ignored, the item's record is in the worktree alone until the routine's
262
+ last section has copied it to the main checkout; say so when asking.
263
+
248
264
  ## Step 5 - say how to try it
249
265
 
250
266
  Always end with the manual walkthrough for what just landed: what to start, where to go,