create-religion 1.4.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 +25 -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 +17 -1
- package/template/.agents/skills/implement/reference/item-worktree.md +116 -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 +17 -1
- package/template/.claude/skills/implement/reference/item-worktree.md +116 -0
- package/template/.claude/skills/rollback/SKILL.md +3 -1
- package/template/AGENTS.md +2 -1
- package/template/CLAUDE.md +2 -1
- package/template/religion/config.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
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
|
+
|
|
3
28
|
## 1.4.0
|
|
4
29
|
|
|
5
30
|
### 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,
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Landing on top of what already landed
|
|
2
|
+
|
|
3
|
+
Used before an item's branch is squash-merged or pushed, when the branch it lands in has moved
|
|
4
|
+
since the item began: another item, built in its own worktree, landed first. Both items wrote
|
|
5
|
+
the same state files, and a text merge of those conflicts every time, because each holds
|
|
6
|
+
bookkeeping derived from its own branch. So the landed copy of each is taken, and this item's
|
|
7
|
+
share of it is done again on top.
|
|
8
|
+
|
|
9
|
+
The **target** is the branch the item lands in: the default branch, or an automated run's
|
|
10
|
+
integration branch. Under `pull-request`, fetch it first, which is a network read and asks
|
|
11
|
+
outside an automated run, and use the remote-tracking name, such as `origin/main`.
|
|
12
|
+
|
|
13
|
+
The merge this makes is a commit on the item's own branch, like the work commit, and is covered
|
|
14
|
+
by the same yes; it never writes the target.
|
|
15
|
+
|
|
16
|
+
Run each block as **one** command, from the item's checkout, after the work commit. Each runs in
|
|
17
|
+
a subshell and sets what it needs. A block that stops ends the landing: report what it printed
|
|
18
|
+
and stop. A stop after block 1 leaves the merge open: either put right what the block named and
|
|
19
|
+
run it again, or abandon the merge with the block in "Abandoning the merge" below.
|
|
20
|
+
|
|
21
|
+
## 1. Bring in what landed
|
|
22
|
+
|
|
23
|
+
(
|
|
24
|
+
root=$(git rev-parse --show-toplevel) || exit 1
|
|
25
|
+
target='<the target>'
|
|
26
|
+
[ -z "$(git -C "$root" status --porcelain -- . ':!religion/context/handoff.md')" ] ||
|
|
27
|
+
{ echo "uncommitted changes; make the work commit first"; exit 1; }
|
|
28
|
+
git -C "$root" checkout -q -- religion/context/handoff.md 2>/dev/null
|
|
29
|
+
git -C "$root" rev-parse -q --verify "$target^{commit}" >/dev/null || { echo "no such target: $target"; exit 1; }
|
|
30
|
+
if git -C "$root" merge-base --is-ancestor "$target" HEAD; then echo "nothing landed since"; exit 0; fi
|
|
31
|
+
git -C "$root" merge --no-commit --no-ff "$target"
|
|
32
|
+
git -C "$root" rev-parse -q --verify MERGE_HEAD >/dev/null || { echo "the merge did not start"; exit 1; }
|
|
33
|
+
base=$(git -C "$root" merge-base HEAD MERGE_HEAD)
|
|
34
|
+
for f in build-plan.md context/findings.md learning/journal.md context/project-overview.md context/handoff.md; do
|
|
35
|
+
p="religion/$f"
|
|
36
|
+
git -C "$root" diff --quiet "$base" MERGE_HEAD -- "$p" && continue
|
|
37
|
+
if git -C "$root" cat-file -e "MERGE_HEAD:$p" 2>/dev/null; then
|
|
38
|
+
git -C "$root" checkout MERGE_HEAD -- "$p" || exit 1
|
|
39
|
+
echo "taken as landed: $p"
|
|
40
|
+
else
|
|
41
|
+
git -C "$root" rm -q -f --ignore-unmatch -- "$p" || exit 1
|
|
42
|
+
echo "no longer tracked by the target: $p"
|
|
43
|
+
fi
|
|
44
|
+
done
|
|
45
|
+
left=$(git -C "$root" diff --name-only --diff-filter=U)
|
|
46
|
+
if [ -n "$left" ]; then
|
|
47
|
+
echo "conflicts outside the shared state files:"; echo "$left"
|
|
48
|
+
git -C "$root" merge --abort; exit 1
|
|
49
|
+
fi
|
|
50
|
+
echo "merged; redo this item's share of each file taken as landed"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
`nothing landed since` means the target is already part of the branch: skip the rest. Only a
|
|
54
|
+
shared file the target changed since the item began is taken from it; one it left alone merges
|
|
55
|
+
as usual, carrying this item's changes with nothing to redo. A shared file the target stopped
|
|
56
|
+
tracking is removed, as the target has it; this item's copy is still in `HEAD`. The handoff is generated and rewritten at the end of every turn, so discarding it loses nothing.
|
|
57
|
+
|
|
58
|
+
Taking the target's copy of a shared file resolves that file, conflicted or not. A conflict in
|
|
59
|
+
any other file, product code included, is not this routine's to settle: the merge is abandoned,
|
|
60
|
+
the branch is exactly as it was, and the paths are reported for review. Never resolve them by
|
|
61
|
+
taking either side.
|
|
62
|
+
|
|
63
|
+
While the merge is open, `HEAD` is this item's own work and `MERGE_HEAD` is what landed.
|
|
64
|
+
|
|
65
|
+
## 2. Redo this item's share
|
|
66
|
+
|
|
67
|
+
For each file block 1 took as landed, edit it as it now stands, taking this item's part from
|
|
68
|
+
`HEAD`'s copy, as `git show HEAD:religion/<file>` prints it:
|
|
69
|
+
|
|
70
|
+
| File | This item's share |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `build-plan.md` | a feature ticks its line, and its parent once every sub-item is ticked; a rollback unticks its target and adds its note; a fix or refactor campaign has none |
|
|
73
|
+
| `context/findings.md` | append each entry `HEAD`'s copy holds that the merge base's did not, still `open`, `fixed` or `unverified`, unchanged; one whose identifier the landed copy already holds, two fixes' bare `F-1` say, takes its permanent prefixed form, the archive's name before it as completion would archive it, and the rename is reported; when the landed copy is the stub, replace its placeholder line |
|
|
74
|
+
| `learning/journal.md` | append its entry, the one whose `**Archive:**` line names this item's archive |
|
|
75
|
+
| `context/project-overview.md` | none: left as landed, and stale until $overview runs, as after any completion |
|
|
76
|
+
| `context/handoff.md` | none |
|
|
77
|
+
|
|
78
|
+
Then compare `HEAD`'s ledger with the merge base's,
|
|
79
|
+
`git diff $(git merge-base HEAD MERGE_HEAD) HEAD -- religion/context/findings.md`. A change this
|
|
80
|
+
item made to an entry it did not add is not carried, since the landed copy is the one kept; name
|
|
81
|
+
each such entry in the report so it can be made again by hand.
|
|
82
|
+
|
|
83
|
+
Everything else, this item's archive included, merged as git merges it.
|
|
84
|
+
|
|
85
|
+
## 3. Check, then commit the merge
|
|
86
|
+
|
|
87
|
+
Stage the redone files for the merge commit, then check them:
|
|
88
|
+
|
|
89
|
+
(
|
|
90
|
+
root=$(git rev-parse --show-toplevel) || exit 1
|
|
91
|
+
for f in build-plan.md context/findings.md learning/journal.md context/project-overview.md context/handoff.md; do
|
|
92
|
+
[ -e "$root/religion/$f" ] && git -C "$root" ls-files --error-unmatch -- "religion/$f" >/dev/null 2>&1 &&
|
|
93
|
+
{ git -C "$root" add -- "religion/$f" || exit 1; }
|
|
94
|
+
done
|
|
95
|
+
for f in build-plan.md context/findings.md learning/journal.md; do
|
|
96
|
+
! grep -n -e '^<<<<<<< ' -e '^>>>>>>> ' -- "$root/religion/$f" 2>/dev/null || { echo "conflict markers in religion/$f"; exit 1; }
|
|
97
|
+
done
|
|
98
|
+
[ -z "$(git -C "$root" diff --name-only --diff-filter=U)" ] || { echo "unresolved paths remain"; exit 1; }
|
|
99
|
+
echo "ready for Verify"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
Only the files edited by hand are searched: git leaves no markers in a file it merged cleanly,
|
|
103
|
+
and a document elsewhere may show one on purpose.
|
|
104
|
+
|
|
105
|
+
Run the project's full `Verify` on the result: it is new code, made of both items, and neither
|
|
106
|
+
was checked against the other. A turn that ends before the merge is committed has its handoff
|
|
107
|
+
rewritten by the hook; run block 3 again before committing, which stages it only while the index
|
|
108
|
+
still tracks it.
|
|
109
|
+
|
|
110
|
+
When `Verify` passes, commit the merge with a message that says what was brought in, such as
|
|
111
|
+
`merge: bring in what landed on main`.
|
|
112
|
+
|
|
113
|
+
When it fails, abandon the merge with the block below, which returns the branch to its work
|
|
114
|
+
commit, and stop with the failure. The item is complete on its branch but cannot land as it
|
|
115
|
+
stands: the way forward is $fix here, describing the failure, built on this branch.
|
|
116
|
+
Completing that fix lands both through this routine: block 2 then redoes the share of both, the
|
|
117
|
+
item's and the fix's, every row, each taken from `HEAD`, and the `## Landed` record goes into
|
|
118
|
+
both archives with the one range they share, each saying it is shared.
|
|
119
|
+
|
|
120
|
+
## Abandoning the merge
|
|
121
|
+
|
|
122
|
+
(
|
|
123
|
+
set -o pipefail
|
|
124
|
+
root=$(git rev-parse --show-toplevel) || exit 1
|
|
125
|
+
git -C "$root" rev-parse -q --verify MERGE_HEAD >/dev/null || { echo "no merge open"; exit 0; }
|
|
126
|
+
g="$(git -C "$root" rev-parse --absolute-git-dir)/abandon"
|
|
127
|
+
list() { git -C "$root" -c core.quotePath=false "$@"; }
|
|
128
|
+
list ls-files -o | LC_ALL=C sort >| "$g.before" || exit 1
|
|
129
|
+
{ list diff --name-only --no-renames HEAD && list diff --cached --name-only --no-renames HEAD; } >| "$g.tracked" || exit 1
|
|
130
|
+
git -C "$root" reset -q --hard HEAD; rc=$?
|
|
131
|
+
list ls-files -o | LC_ALL=C sort >| "$g.after" || exit 1
|
|
132
|
+
{ cat "$g.tracked"; LC_ALL=C comm -23 "$g.before" "$g.after"; } | LC_ALL=C sort -u | sed 's/^/discarded: /'
|
|
133
|
+
rm -f "$g.before" "$g.after" "$g.tracked"
|
|
134
|
+
[ "$rc" -eq 0 ] || { echo "the reset failed: the merge is still open, and a path named above may not be set back"; exit 1; }
|
|
135
|
+
list ls-files -o --exclude-standard --directory | sed 's/^/left untracked: /'
|
|
136
|
+
echo "back at the work commit"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
Block 1 started from a clean tree, so the work commit is the whole of this item's work, and
|
|
140
|
+
resetting to it ends the merge and returns the branch exactly as it was, whatever the merge left:
|
|
141
|
+
redone shares, a handoff the hook rewrote, output `Verify` regenerated, paths still in conflict.
|
|
142
|
+
Each path the reset set back or removed is named once: every tracked path whose working or staged copy
|
|
143
|
+
differed from the work commit, an edit made by hand while the merge was open among them, and every untracked or
|
|
144
|
+
ignored file in the way of a path the work commit has, such as one inside a directory the target
|
|
145
|
+
put where a file was, found by listing them with git before the reset and after it. Other
|
|
146
|
+
untracked files are left where they are and named, a whole directory as one line. Names are
|
|
147
|
+
printed as git prints them, quoted where they hold unusual characters. So nothing is lost without
|
|
148
|
+
a word. A reset that fails, such as on a directory it cannot write, still names what it removed,
|
|
149
|
+
then says the merge is still open: put right what git named and run the block again. Never
|
|
150
|
+
abandon with `git merge --abort` alone, which refuses while a file the merge touched has unstaged
|
|
151
|
+
changes. With no merge open, the block does nothing.
|
|
152
|
+
|
|
153
|
+
## 4. Record what landed
|
|
154
|
+
|
|
155
|
+
When a merge was made, the item's `## Landed` record names the last such merge's second parent,
|
|
156
|
+
the target it brought in, as **Base**, and that merge commit and anything after it as **Commits**:
|
|
157
|
+
`git diff` from that base to the last commit is then this item's work alone, which is what a
|
|
158
|
+
rollback reverses. Without a merge, the record is as `$complete` describes. Commit the
|
|
159
|
+
record: in a worktree, an uncommitted file goes when the worktree is removed.
|
|
160
|
+
|
|
161
|
+
## When the state directory is ignored
|
|
162
|
+
|
|
163
|
+
Then none of these files are in git, and the merge carries none of them: this item's archive,
|
|
164
|
+
plan tick, journal entry and ledger exist only in its worktree's copy of `religion/`. Before
|
|
165
|
+
the worktree can be removed, redo this item's share, as the table in block 2 lists it, in the
|
|
166
|
+
main checkout's own `religion/` files, and copy this item's archive there. When this landing
|
|
167
|
+
follows a failed `Verify`, that is the share and the archive of both, the item's and the fix's. With no `HEAD` copy
|
|
168
|
+
or merge base in git, the worktree's own files are this item's part, and the ledger saved when
|
|
169
|
+
the worktree was made, `religion/.state/findings-at-start.md` in it, is the base: an entry the
|
|
170
|
+
worktree's ledger holds that the saved one did not is this item's. Comparing the two also shows
|
|
171
|
+
each entry this item changed or removed, a repair it closed and pruned among them: make the
|
|
172
|
+
same change in the main checkout's ledger, or name the entry in the report, so a finding this
|
|
173
|
+
item closed does not stay open there. Removing the worktree deletes ignored files with it, so
|
|
174
|
+
until then they are the only record.
|