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.
- package/CHANGELOG.md +35 -0
- package/dist/lib/dashboard.js +4 -3
- package/dist/lib/doctor.js +21 -7
- package/dist/lib/settings.js +4 -0
- package/dist/lib/state.js +125 -2
- package/dist/lib/status.js +21 -4
- package/package.json +1 -1
- package/template/.agents/skills/audit/SKILL.md +13 -7
- package/template/.agents/skills/complete/SKILL.md +22 -6
- package/template/.agents/skills/complete/reference/land-on-top.md +174 -0
- package/template/.agents/skills/distill/SKILL.md +2 -1
- package/template/.agents/skills/feature/SKILL.md +4 -1
- package/template/.agents/skills/fix/SKILL.md +3 -2
- package/template/.agents/skills/implement/SKILL.md +38 -8
- package/template/.agents/skills/implement/reference/item-worktree.md +116 -0
- package/template/.agents/skills/implement/reference/worktrees.md +175 -0
- package/template/.agents/skills/rollback/SKILL.md +3 -1
- package/template/.claude/hooks/write-handoff.mjs +80 -3
- package/template/.claude/skills/audit/SKILL.md +13 -7
- package/template/.claude/skills/complete/SKILL.md +22 -6
- package/template/.claude/skills/complete/reference/land-on-top.md +174 -0
- package/template/.claude/skills/distill/SKILL.md +2 -1
- package/template/.claude/skills/feature/SKILL.md +4 -1
- package/template/.claude/skills/fix/SKILL.md +3 -2
- package/template/.claude/skills/implement/SKILL.md +38 -8
- package/template/.claude/skills/implement/reference/item-worktree.md +116 -0
- package/template/.claude/skills/implement/reference/worktrees.md +175 -0
- package/template/.claude/skills/rollback/SKILL.md +3 -1
- package/template/AGENTS.md +9 -3
- package/template/CLAUDE.md +9 -3
- 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
|
package/dist/lib/dashboard.js
CHANGED
|
@@ -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
|
-
|
|
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) ||
|
|
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) =>
|
|
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("") +
|
package/dist/lib/doctor.js
CHANGED
|
@@ -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")))
|
|
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,
|
|
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`);
|
package/dist/lib/settings.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/lib/status.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
const
|
|
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
|
+
"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-
|
|
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:**
|
|
109
|
-
it
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
|
97
|
-
|
|
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.
|
|
114
|
-
globally unique
|
|
115
|
-
|
|
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,
|