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.
- package/CHANGELOG.md +47 -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 +143 -5
- package/dist/lib/status.js +36 -4
- package/package.json +1 -1
- package/template/.agents/skills/audit/SKILL.md +13 -7
- package/template/.agents/skills/auto/SKILL.md +120 -2
- package/template/.agents/skills/complete/SKILL.md +22 -6
- package/template/.agents/skills/complete/reference/land-on-top.md +188 -0
- package/template/.agents/skills/distill/SKILL.md +2 -1
- package/template/.agents/skills/feature/SKILL.md +6 -1
- package/template/.agents/skills/fix/SKILL.md +3 -2
- package/template/.agents/skills/implement/SKILL.md +18 -1
- package/template/.agents/skills/implement/reference/item-worktree.md +133 -0
- package/template/.agents/skills/rollback/SKILL.md +3 -1
- package/template/.claude/hooks/write-handoff.mjs +92 -3
- package/template/.claude/skills/audit/SKILL.md +13 -7
- package/template/.claude/skills/auto/SKILL.md +120 -2
- package/template/.claude/skills/complete/SKILL.md +22 -6
- package/template/.claude/skills/complete/reference/land-on-top.md +188 -0
- package/template/.claude/skills/distill/SKILL.md +2 -1
- package/template/.claude/skills/feature/SKILL.md +6 -1
- package/template/.claude/skills/fix/SKILL.md +3 -2
- package/template/.claude/skills/implement/SKILL.md +18 -1
- package/template/.claude/skills/implement/reference/item-worktree.md +133 -0
- package/template/.claude/skills/rollback/SKILL.md +3 -1
- package/template/AGENTS.md +12 -1
- package/template/CLAUDE.md +12 -1
- 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
|
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) {
|
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/lib/status.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
const
|
|
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.
|
|
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-
|
|
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
|
| --- | --- | --- |
|