create-religion 0.5.0 → 1.1.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/README.md +17 -1
- package/dist/bin/religion.js +90 -49
- package/dist/lib/args.js +108 -0
- package/dist/lib/dashboard.js +945 -83
- package/dist/lib/doctor.js +5 -1
- package/dist/lib/install.js +222 -12
- package/dist/lib/merge.js +23 -0
- package/dist/lib/state.js +168 -11
- package/package.json +2 -2
- package/template/.agents/skills/scout/SKILL.md +82 -0
- package/template/.agents/skills/setup/SKILL.md +117 -12
- package/template/.agents/skills/setup/reference/drafted-plans.md +133 -0
- package/template/.agents/skills/status/SKILL.md +1 -1
- package/template/.claude/hooks/write-handoff.mjs +11 -2
- package/template/.claude/skills/scout/SKILL.md +82 -0
- package/template/.claude/skills/setup/SKILL.md +117 -12
- package/template/.claude/skills/setup/reference/drafted-plans.md +133 -0
- package/template/.claude/skills/status/SKILL.md +1 -1
- package/template/AGENTS.md +1 -0
package/dist/lib/doctor.js
CHANGED
|
@@ -26,7 +26,8 @@ const REQUIRED = [
|
|
|
26
26
|
"history/spikes",
|
|
27
27
|
"reference"
|
|
28
28
|
];
|
|
29
|
-
|
|
29
|
+
/** The values each enumerated setting accepts. Other settings are not checked. */
|
|
30
|
+
export const ALLOWED = {
|
|
30
31
|
"workflow.stepReview": ["every", "item"],
|
|
31
32
|
"git.mode": ["trunk", "branch-per-item", "pull-request"],
|
|
32
33
|
"git.checkpoints": ["none", "every-step", "squash"],
|
|
@@ -147,6 +148,9 @@ function invalidSettings(config) {
|
|
|
147
148
|
}
|
|
148
149
|
return problems;
|
|
149
150
|
}
|
|
151
|
+
export function doctorReport(checks) {
|
|
152
|
+
return { schemaVersion: 1, healthy: checks.every((check) => check.ok), checks: [...checks] };
|
|
153
|
+
}
|
|
150
154
|
export function renderDoctor(results) {
|
|
151
155
|
const failures = results.filter((r) => !r.ok);
|
|
152
156
|
const order = { work: 0, completion: 1, nothing: 2 };
|
package/dist/lib/install.js
CHANGED
|
@@ -10,14 +10,17 @@ import crypto from "node:crypto";
|
|
|
10
10
|
import fs from "node:fs/promises";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import { exists, STATE_DIR } from "./paths.js";
|
|
13
|
-
import { hasDamagedMarkers, hasMarkers, replaceManagedBlock, spliceEntry } from "./merge.js";
|
|
13
|
+
import { hasDamagedMarkers, hasMarkers, rebuildEntry, replaceManagedBlock, spliceEntry } from "./merge.js";
|
|
14
14
|
export const ADAPTERS = {
|
|
15
15
|
claude: { label: "Claude Code", trees: [".claude/skills", ".claude/hooks"], entry: ["CLAUDE.md"] },
|
|
16
16
|
codex: { label: "Codex", trees: [".agents/skills"], entry: ["AGENTS.md"] },
|
|
17
17
|
copilot: { label: "GitHub Copilot", trees: [".agents/skills"], entry: ["AGENTS.md"] },
|
|
18
18
|
opencode: { label: "OpenCode", trees: [".agents/skills"], entry: ["AGENTS.md"] }
|
|
19
19
|
};
|
|
20
|
-
const
|
|
20
|
+
const MANIFEST_PATH = `${STATE_DIR}/.state/manifest.json`;
|
|
21
|
+
const MANIFEST = path.join(...MANIFEST_PATH.split("/"));
|
|
22
|
+
/** Where originals are copied before install or update changes them, relative to the project. */
|
|
23
|
+
export const BACKUPS = `${STATE_DIR}/.state/backups`;
|
|
21
24
|
export function hash(contents) {
|
|
22
25
|
return crypto.createHash("sha256").update(contents).digest("hex").slice(0, 16);
|
|
23
26
|
}
|
|
@@ -36,6 +39,12 @@ export async function planInstall(templateRoot, target, adapters, previous) {
|
|
|
36
39
|
for (const relative of await walk(templateRoot)) {
|
|
37
40
|
if (!wanted(relative))
|
|
38
41
|
continue;
|
|
42
|
+
// Checked before anything else, since `exists` follows links: a dangling one would read as
|
|
43
|
+
// absent and be created wherever it points.
|
|
44
|
+
if (await throughLink(target, relative)) {
|
|
45
|
+
plan.push({ relative, action: "linked" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
39
48
|
const destination = path.join(target, relative);
|
|
40
49
|
const source = await fs.readFile(path.join(templateRoot, relative));
|
|
41
50
|
if (!(await exists(destination))) {
|
|
@@ -66,37 +75,124 @@ export async function planInstall(templateRoot, target, adapters, previous) {
|
|
|
66
75
|
if (entries.has(relative)) {
|
|
67
76
|
const text = current.toString("utf8");
|
|
68
77
|
// A damaged marker pair is a conflict, never a fresh merge: splicing a second block
|
|
69
|
-
// into a file that already has one is worse than the damage.
|
|
70
|
-
|
|
78
|
+
// into a file that already has one is worse than the damage. An unmarked file the
|
|
79
|
+
// manifest records is one Religion wrote and setup edited, so it already holds
|
|
80
|
+
// Religion's sections and is rebuilt around the owner's rather than spliced.
|
|
81
|
+
const action = hasMarkers(text)
|
|
82
|
+
? "remerge"
|
|
83
|
+
: hasDamagedMarkers(text)
|
|
84
|
+
? "conflict"
|
|
85
|
+
: recorded
|
|
86
|
+
? await rebuildOrConflict(target, relative, recorded)
|
|
87
|
+
: "merge";
|
|
71
88
|
plan.push({ relative, action });
|
|
72
89
|
continue;
|
|
73
90
|
}
|
|
74
91
|
plan.push({ relative, action: "conflict" });
|
|
75
92
|
}
|
|
93
|
+
return [...plan, ...(await planDropped(templateRoot, target, previous))];
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Files the previous install recorded that the template no longer ships, for any adapter.
|
|
97
|
+
*
|
|
98
|
+
* Unedited ones are removed, which is what lets a skill or a hook be retired at all. Edited
|
|
99
|
+
* ones are released: left where they are and dropped from the manifest, since the edit makes
|
|
100
|
+
* them the owner's. The manifest is a file in someone's repository, so it must not be able to
|
|
101
|
+
* steer a deletion: only a plain file under one of Religion's own trees is ever a candidate.
|
|
102
|
+
*/
|
|
103
|
+
async function planDropped(templateRoot, target, previous) {
|
|
104
|
+
const managed = previous?.managed;
|
|
105
|
+
if (!managed || typeof managed !== "object")
|
|
106
|
+
return [];
|
|
107
|
+
const shipped = new Set(await walk(templateRoot));
|
|
108
|
+
const plan = [];
|
|
109
|
+
for (const [relative, recorded] of Object.entries(managed)) {
|
|
110
|
+
if (shipped.has(relative) || !retiringTree(relative) || !(await isPlainFile(target, relative)))
|
|
111
|
+
continue;
|
|
112
|
+
const current = await fs.readFile(path.join(target, ...relative.split("/")));
|
|
113
|
+
plan.push({ relative, action: hash(current) === recorded ? "remove" : "release", recorded });
|
|
114
|
+
}
|
|
76
115
|
return plan;
|
|
77
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The tree a recorded path can be retired from, or null when it may not be touched.
|
|
119
|
+
*
|
|
120
|
+
* Exact case, forward slashes and no climbing, checked as text before the filesystem is
|
|
121
|
+
* asked anything. A case-insensitive disk would otherwise let `RELIGION/` or `.CLAUDE/`
|
|
122
|
+
* reach files the comparison was meant to exclude.
|
|
123
|
+
*/
|
|
124
|
+
function retiringTree(relative) {
|
|
125
|
+
if (relative.includes("\\") || path.posix.isAbsolute(relative))
|
|
126
|
+
return null;
|
|
127
|
+
if (path.posix.normalize(relative) !== relative || relative.split("/").includes(".."))
|
|
128
|
+
return null;
|
|
129
|
+
const trees = new Set(Object.values(ADAPTERS).flatMap((adapter) => adapter.trees));
|
|
130
|
+
return [...trees].find((tree) => relative.startsWith(`${tree}/`)) ?? null;
|
|
131
|
+
}
|
|
132
|
+
/** Every component a real directory and the last a regular file, with no symbolic link anywhere. */
|
|
133
|
+
async function isPlainFile(target, relative) {
|
|
134
|
+
const parts = relative.split("/");
|
|
135
|
+
let current = target;
|
|
136
|
+
for (const [index, part] of parts.entries()) {
|
|
137
|
+
current = path.join(current, part);
|
|
138
|
+
const stat = await fs.lstat(current).catch(() => null);
|
|
139
|
+
const last = index === parts.length - 1;
|
|
140
|
+
if (!stat || stat.isSymbolicLink() || (last ? !stat.isFile() : !stat.isDirectory()))
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
78
145
|
export async function applyInstall(templateRoot, target, plan, options) {
|
|
79
146
|
const written = [];
|
|
80
147
|
const conflicts = [];
|
|
81
148
|
const backups = [];
|
|
82
149
|
const merged = [];
|
|
150
|
+
const rebuilt = [];
|
|
151
|
+
const removed = [];
|
|
152
|
+
const released = [];
|
|
153
|
+
const declined = [];
|
|
154
|
+
const linked = [];
|
|
155
|
+
await refuseLinkedState(target, plan, options);
|
|
83
156
|
for (const entry of plan) {
|
|
84
157
|
if (entry.action === "unchanged" || entry.action === "seed-skip")
|
|
85
158
|
continue;
|
|
159
|
+
if (entry.action === "release") {
|
|
160
|
+
released.push(entry.relative);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (entry.action === "remove") {
|
|
164
|
+
// Checked again here because a prompt may have waited between planning and now, and a
|
|
165
|
+
// file edited in that time is the owner's.
|
|
166
|
+
const tree = retiringTree(entry.relative);
|
|
167
|
+
const file = path.join(target, ...entry.relative.split("/"));
|
|
168
|
+
const unchanged = tree !== null && (await isPlainFile(target, entry.relative)) && hash(await fs.readFile(file)) === entry.recorded;
|
|
169
|
+
if (!unchanged) {
|
|
170
|
+
released.push(entry.relative);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
await fs.rm(file);
|
|
174
|
+
await pruneEmptyParents(path.join(target, tree), path.dirname(file));
|
|
175
|
+
removed.push(entry.relative);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
86
178
|
if (entry.action === "conflict" && !options.force) {
|
|
87
179
|
conflicts.push(entry.relative);
|
|
88
180
|
continue;
|
|
89
181
|
}
|
|
90
182
|
// Replacing only what is between the markers is the whole point of having merged: the
|
|
91
183
|
// user's own prose sits outside them and is never read, let alone rewritten.
|
|
184
|
+
// Checked again at the write, because a prompt may have waited since planning and a link
|
|
185
|
+
// swapped in meanwhile would carry the write outside the project.
|
|
186
|
+
if (entry.action === "linked" || (await throughLink(target, entry.relative))) {
|
|
187
|
+
linked.push(entry.relative);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
92
190
|
if (entry.action === "remerge" || (entry.action === "merge" && options.merge)) {
|
|
93
191
|
const destination = path.join(target, entry.relative);
|
|
94
192
|
const template = await fs.readFile(path.join(templateRoot, entry.relative), "utf8");
|
|
95
193
|
const current = await fs.readFile(destination, "utf8");
|
|
96
194
|
if (entry.action === "merge") {
|
|
97
|
-
|
|
98
|
-
await fs.mkdir(path.dirname(backup), { recursive: true });
|
|
99
|
-
await fs.copyFile(destination, backup);
|
|
195
|
+
await backUp(target, entry.relative);
|
|
100
196
|
backups.push(entry.relative);
|
|
101
197
|
}
|
|
102
198
|
const next = entry.action === "merge" ? spliceEntry(current, template) : replaceManagedBlock(current, template);
|
|
@@ -111,21 +207,94 @@ export async function applyInstall(templateRoot, target, plan, options) {
|
|
|
111
207
|
continue;
|
|
112
208
|
}
|
|
113
209
|
if (entry.action === "merge") {
|
|
114
|
-
|
|
210
|
+
declined.push(entry.relative);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (entry.action === "rebuild") {
|
|
214
|
+
// A prompt may have waited since planning; a link swapped in meanwhile is not rewritten.
|
|
215
|
+
if (!(await isPlainFile(target, entry.relative))) {
|
|
216
|
+
conflicts.push(entry.relative);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const destination = path.join(target, entry.relative);
|
|
220
|
+
const template = await fs.readFile(path.join(templateRoot, entry.relative), "utf8");
|
|
221
|
+
const current = await fs.readFile(destination, "utf8");
|
|
222
|
+
await backUp(target, entry.relative);
|
|
223
|
+
backups.push(entry.relative);
|
|
224
|
+
await fs.writeFile(destination, rebuildEntry(current, template), "utf8");
|
|
225
|
+
rebuilt.push(entry.relative);
|
|
115
226
|
continue;
|
|
116
227
|
}
|
|
117
228
|
const destination = path.join(target, entry.relative);
|
|
118
229
|
if (entry.action === "conflict") {
|
|
119
|
-
|
|
120
|
-
await fs.mkdir(path.dirname(backup), { recursive: true });
|
|
121
|
-
await fs.copyFile(destination, backup);
|
|
230
|
+
await backUp(target, entry.relative);
|
|
122
231
|
backups.push(entry.relative);
|
|
123
232
|
}
|
|
124
233
|
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
125
234
|
await fs.copyFile(path.join(templateRoot, entry.relative), destination);
|
|
126
235
|
written.push(entry.relative);
|
|
127
236
|
}
|
|
128
|
-
return { written, conflicts, backups, merged };
|
|
237
|
+
return { written, conflicts, backups, merged, rebuilt, removed, released, declined, linked };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Rebuild only a file the manifest genuinely recorded, and only a plain one.
|
|
241
|
+
*
|
|
242
|
+
* A rebuild rewrites without asking, so it must not be reachable by a forged manifest value
|
|
243
|
+
* standing in for a hash, or by an entry file that is a link to somewhere else. Either is a
|
|
244
|
+
* conflict instead, which leaves the file alone and says so.
|
|
245
|
+
*/
|
|
246
|
+
async function rebuildOrConflict(target, relative, recorded) {
|
|
247
|
+
const genuine = typeof recorded === "string" && /^[0-9a-f]{16}$/.test(recorded);
|
|
248
|
+
return genuine && (await isPlainFile(target, relative)) ? "rebuild" : "conflict";
|
|
249
|
+
}
|
|
250
|
+
async function backUp(target, relative) {
|
|
251
|
+
const backup = path.join(target, ...`${BACKUPS}/${relative}`.split("/"));
|
|
252
|
+
await fs.mkdir(path.dirname(backup), { recursive: true });
|
|
253
|
+
await fs.copyFile(path.join(target, relative), backup);
|
|
254
|
+
}
|
|
255
|
+
/** Whether any existing component of a project path is a symbolic link. Missing ones are not. */
|
|
256
|
+
async function throughLink(target, relative) {
|
|
257
|
+
let current = target;
|
|
258
|
+
for (const part of relative.split("/")) {
|
|
259
|
+
current = path.join(current, part);
|
|
260
|
+
const stat = await fs.lstat(current).catch(() => null);
|
|
261
|
+
if (!stat)
|
|
262
|
+
return false;
|
|
263
|
+
if (stat.isSymbolicLink())
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Refuse, before anything is written, a run whose manifest or backups would pass through a link.
|
|
270
|
+
*
|
|
271
|
+
* The state directory is part of someone's repository, and a link at any point on the way
|
|
272
|
+
* would put the manifest, or a copy of their file, wherever the link points.
|
|
273
|
+
*/
|
|
274
|
+
async function refuseLinkedState(target, plan, options) {
|
|
275
|
+
const backedUp = plan.filter((entry) => entry.action === "rebuild" ||
|
|
276
|
+
(entry.action === "merge" && options.merge) ||
|
|
277
|
+
(entry.action === "conflict" && options.force));
|
|
278
|
+
const paths = [MANIFEST_PATH, ...backedUp.map((entry) => `${BACKUPS}/${entry.relative}`)];
|
|
279
|
+
for (const relative of paths) {
|
|
280
|
+
if (await throughLink(target, relative)) {
|
|
281
|
+
throw new Error(`Refusing to write ${relative} because part of that path is a symbolic link. Replace the link with a real directory or file and run again.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Remove directories a removal left empty, walking up and stopping at the tree it came from. */
|
|
286
|
+
async function pruneEmptyParents(tree, dir) {
|
|
287
|
+
const root = path.resolve(tree);
|
|
288
|
+
let current = path.resolve(dir);
|
|
289
|
+
while (current.startsWith(root + path.sep)) {
|
|
290
|
+
try {
|
|
291
|
+
await fs.rmdir(current);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
current = path.dirname(current);
|
|
297
|
+
}
|
|
129
298
|
}
|
|
130
299
|
/**
|
|
131
300
|
* Records what was *installed*, not what is currently on disk.
|
|
@@ -153,6 +322,9 @@ export async function writeManifest(target, version, adapters, templateRoot, pre
|
|
|
153
322
|
}
|
|
154
323
|
managed[relative] = hash(await fs.readFile(path.join(templateRoot, relative)));
|
|
155
324
|
}
|
|
325
|
+
if (await throughLink(target, MANIFEST_PATH)) {
|
|
326
|
+
throw new Error(`Refusing to write ${MANIFEST_PATH} because part of that path is a symbolic link.`);
|
|
327
|
+
}
|
|
156
328
|
const manifest = { schemaVersion: 1, version, adapters: [...adapters], managed };
|
|
157
329
|
const file = path.join(target, MANIFEST);
|
|
158
330
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
@@ -170,12 +342,50 @@ export async function wireHooks(target) {
|
|
|
170
342
|
const settings = path.join(target, ".claude", "settings.json");
|
|
171
343
|
if (!(await exists(template)))
|
|
172
344
|
return "no-template";
|
|
345
|
+
if (await throughLink(target, ".claude/settings.json"))
|
|
346
|
+
return "linked";
|
|
173
347
|
if (await exists(settings))
|
|
174
348
|
return "exists";
|
|
175
349
|
await fs.mkdir(path.dirname(settings), { recursive: true });
|
|
176
350
|
await fs.copyFile(template, settings);
|
|
177
351
|
return "written";
|
|
178
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* Why this package must not act on a project, or null when it may.
|
|
355
|
+
*
|
|
356
|
+
* An older package applying its template over a newer install is a silent downgrade: it
|
|
357
|
+
* rewrites managed files to older versions and records them as installed. A version that
|
|
358
|
+
* cannot be parsed never refuses, since a hand-edited manifest is no evidence of a newer one.
|
|
359
|
+
*/
|
|
360
|
+
export function manifestRefusal(manifest, packageVersion) {
|
|
361
|
+
if (!manifest)
|
|
362
|
+
return null;
|
|
363
|
+
const schema = manifest.schemaVersion;
|
|
364
|
+
if (typeof schema === "number" && schema > 1) {
|
|
365
|
+
return `This project's manifest uses format ${schema}, which create-religion ${packageVersion} does not understand. Run \`npx create-religion@latest update\` instead.`;
|
|
366
|
+
}
|
|
367
|
+
if (isNewer(manifest.version, packageVersion)) {
|
|
368
|
+
return `This project was installed by create-religion ${manifest.version}, newer than this ${packageVersion}. Run \`npx create-religion@latest update\` instead.`;
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
function isNewer(candidate, than) {
|
|
373
|
+
const a = parseVersion(candidate);
|
|
374
|
+
const b = parseVersion(than);
|
|
375
|
+
if (!a || !b)
|
|
376
|
+
return false;
|
|
377
|
+
for (let i = 0; i < 3; i += 1) {
|
|
378
|
+
if (a[i] !== b[i])
|
|
379
|
+
return a[i] > b[i];
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
function parseVersion(version) {
|
|
384
|
+
if (typeof version !== "string")
|
|
385
|
+
return null;
|
|
386
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-.*)?$/.exec(version.trim());
|
|
387
|
+
return match ? match.slice(1, 4).map(Number) : null;
|
|
388
|
+
}
|
|
179
389
|
export async function readManifest(target) {
|
|
180
390
|
try {
|
|
181
391
|
return JSON.parse(await fs.readFile(path.join(target, MANIFEST), "utf8"));
|
package/dist/lib/merge.js
CHANGED
|
@@ -104,6 +104,29 @@ export function spliceEntry(existing, templateEntry) {
|
|
|
104
104
|
parts.push("", commands);
|
|
105
105
|
return parts.join("\n") + "\n";
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Rebuild an unmarked entry file Religion installed, around what its owner wrote.
|
|
109
|
+
*
|
|
110
|
+
* A fresh install writes the entry file without markers and setup then edits it in place,
|
|
111
|
+
* so on the next update it matches neither the template nor the manifest. Splicing a block
|
|
112
|
+
* into it would repeat every section Religion already put there. Instead the file keeps its
|
|
113
|
+
* leading lines and every section Religion does not manage, and Religion's own sections are
|
|
114
|
+
* replaced by the current managed block. A file that already has markers only has its block
|
|
115
|
+
* replaced, so rebuilding twice changes nothing.
|
|
116
|
+
*/
|
|
117
|
+
export function rebuildEntry(existing, templateEntry) {
|
|
118
|
+
if (hasMarkers(existing))
|
|
119
|
+
return replaceManagedBlock(existing, templateEntry) ?? existing;
|
|
120
|
+
const { lead, sections } = parse(existing);
|
|
121
|
+
const managed = new Set(parse(templateEntry).sections.map((s) => s.heading).filter((h) => !USER_OWNED.has(h)));
|
|
122
|
+
const head = lead.filter((line) => !line.startsWith("@")).join("\n");
|
|
123
|
+
const kept = sections.filter((s) => !managed.has(s.heading) && s.heading !== "Commands");
|
|
124
|
+
const commands = sections.filter((s) => s.heading === "Commands");
|
|
125
|
+
return ([head, render(kept), managedBlock(templateEntry), render(commands)]
|
|
126
|
+
.map((part) => part.replace(/\n+$/, ""))
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.join("\n\n") + "\n");
|
|
129
|
+
}
|
|
107
130
|
/**
|
|
108
131
|
* Replace the managed block in a merged file, leaving everything outside it untouched.
|
|
109
132
|
*
|
package/dist/lib/state.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* because the commands that use this are how someone finds out what is wrong.
|
|
7
7
|
*/
|
|
8
8
|
import crypto from "node:crypto";
|
|
9
|
+
import fs from "node:fs/promises";
|
|
9
10
|
import { readIfPresent, statePath } from "./paths.js";
|
|
10
11
|
const CHECKBOX = /^(\s*)- \[( |x|X)\]\s*(.*)$/;
|
|
11
12
|
export function parsePlan(source) {
|
|
@@ -46,28 +47,184 @@ export function parseWork(source) {
|
|
|
46
47
|
};
|
|
47
48
|
if (!source || /_Nothing in progress\./.test(source))
|
|
48
49
|
return empty;
|
|
49
|
-
|
|
50
|
+
// The step's name is its bold text, which itself contains " - " in the template's shape.
|
|
51
|
+
// Spaces and tabs only: `\s` here crosses newlines, which made blank lines quadratic and
|
|
52
|
+
// joined consecutive unbolded steps into one.
|
|
53
|
+
const steps = [...source.matchAll(/^[ \t]*- \[( |x|X)\][ \t]*(?:\*\*(.+?)\*\*|(.+?)(?:[ \t]-[ \t]|$))/gim)];
|
|
50
54
|
const done = steps.filter((s) => (s[1] ?? " ").toLowerCase() === "x").length;
|
|
55
|
+
const next = steps.find((s) => (s[1] ?? " ").toLowerCase() !== "x");
|
|
51
56
|
return {
|
|
52
57
|
active: true,
|
|
53
|
-
title:
|
|
54
|
-
type:
|
|
55
|
-
status:
|
|
58
|
+
title: field(source, "#"),
|
|
59
|
+
type: field(source, "Type"),
|
|
60
|
+
status: field(source, "Status"),
|
|
56
61
|
stepsDone: done,
|
|
57
62
|
stepsTotal: steps.length,
|
|
58
|
-
nextStep:
|
|
63
|
+
nextStep: (next?.[2] ?? next?.[3])?.trim() ?? null
|
|
59
64
|
};
|
|
60
65
|
}
|
|
61
|
-
|
|
66
|
+
function field(source, name) {
|
|
67
|
+
const pattern = name === "#" ? /^#\s+(.+)$/m : new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.+)$`, "m");
|
|
68
|
+
return pattern.exec(source)?.[1]?.trim() ?? null;
|
|
69
|
+
}
|
|
70
|
+
/** The active spec as written, for showing rather than deciding. Null when nothing is in progress. */
|
|
71
|
+
export function parseSpec(source) {
|
|
72
|
+
if (!source || /_Nothing in progress\./.test(source))
|
|
73
|
+
return null;
|
|
74
|
+
const section = sections(source);
|
|
75
|
+
return {
|
|
76
|
+
title: field(source, "#"),
|
|
77
|
+
type: field(source, "Type"),
|
|
78
|
+
planItem: field(source, "From build plan")?.replace(/^item\s+/i, "") ?? null,
|
|
79
|
+
status: field(source, "Status"),
|
|
80
|
+
goal: (section.get("goal") ?? "").split(/\n\s*\n/).map(join).filter(Boolean),
|
|
81
|
+
inScope: bullets(section.get("in scope")),
|
|
82
|
+
outOfScope: bullets(section.get("out of scope")),
|
|
83
|
+
files: bullets(section.get("files and areas")),
|
|
84
|
+
steps: bullets(section.get("build steps"), true).map(parseStep)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Each heading's body, keyed by the lowercased heading; `levels` widens it to `###` too. */
|
|
88
|
+
function sections(source, levels = "##") {
|
|
89
|
+
const found = new Map();
|
|
90
|
+
const parts = source.split(new RegExp(`^(?:${levels})\\s+(.+)$`, "m"));
|
|
91
|
+
for (let index = 1; index < parts.length; index += 2) {
|
|
92
|
+
found.set((parts[index] ?? "").trim().toLowerCase(), parts[index + 1] ?? "");
|
|
93
|
+
}
|
|
94
|
+
return found;
|
|
95
|
+
}
|
|
96
|
+
/** Top-level list items with their wrapped lines joined; with `checkbox`, only `- [ ]` items, tick kept. */
|
|
97
|
+
function bullets(body, checkbox = false) {
|
|
98
|
+
if (!body)
|
|
99
|
+
return [];
|
|
100
|
+
const items = [];
|
|
101
|
+
for (const line of body.split(/\r?\n/)) {
|
|
102
|
+
if (/^- /.test(line))
|
|
103
|
+
items.push(line.slice(2));
|
|
104
|
+
else if (/^\s+\S/.test(line) && items.length > 0)
|
|
105
|
+
items[items.length - 1] += " " + line.trim();
|
|
106
|
+
else if (line.trim() !== "" && items.length > 0)
|
|
107
|
+
items.push("");
|
|
108
|
+
}
|
|
109
|
+
const kept = items.filter(Boolean).map((item) => item.trim());
|
|
110
|
+
return checkbox ? kept.filter((item) => /^\[( |x|X)\]/.test(item)) : kept;
|
|
111
|
+
}
|
|
112
|
+
function parseStep(item) {
|
|
113
|
+
const done = /^\[(x|X)\]/.test(item);
|
|
114
|
+
const text = item.replace(/^\[( |x|X)\]\s*/, "");
|
|
115
|
+
const bold = /^\*\*(.+?)\*\*\s*(?:-\s+)?([\s\S]*)$/.exec(text);
|
|
116
|
+
const name = bold?.[1] ?? text.split(" - ")[0] ?? "";
|
|
117
|
+
const rest = bold ? (bold[2] ?? "") : text.slice(name.length).replace(/^\s*-\s+/, "");
|
|
118
|
+
const [label, ...title] = name.split(" - ");
|
|
119
|
+
const [what, doneWhen] = rest.split(/\*Done when:\*\s*/);
|
|
120
|
+
return {
|
|
121
|
+
label: title.length > 0 ? (label ?? "").trim() : "",
|
|
122
|
+
title: (title.length > 0 ? title.join(" - ") : name).trim(),
|
|
123
|
+
what: (what ?? "").trim(),
|
|
124
|
+
doneWhen: doneWhen?.trim() ?? null,
|
|
125
|
+
done
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export const HISTORY_KINDS = ["features", "fixes", "rollbacks", "refactors", "spikes"];
|
|
129
|
+
/** One archive under `history/<kind>/`, named `NN-slug.md`. */
|
|
130
|
+
export function parseArchive(kind, file, source) {
|
|
131
|
+
const spec = parseSpec(source);
|
|
132
|
+
const section = sections(source, "#{2,3}");
|
|
133
|
+
const repairs = spec?.steps.filter((step) => /^repair\b/i.test(step.label)).length ?? 0;
|
|
134
|
+
return {
|
|
135
|
+
kind,
|
|
136
|
+
file,
|
|
137
|
+
number: /^0*(\d+[a-z]?)-/i.exec(file)?.[1] ?? null,
|
|
138
|
+
title: spec?.title ?? null,
|
|
139
|
+
type: spec?.type ?? null,
|
|
140
|
+
status: spec?.status ?? null,
|
|
141
|
+
steps: (spec?.steps.length ?? 0) - repairs,
|
|
142
|
+
repairs,
|
|
143
|
+
commits: (labelled(source, "Commits") ?? "").split(/[\s,]+/).filter((sha) => /^[0-9a-f]{7,40}$/.test(sha)),
|
|
144
|
+
findings: [...source.matchAll(/^###\s+(?:[\w-]+\/)?(F-\d+)\s+\[(P[0-3])\]\s+(\w+)\s+-\s+(.+)$/gm)].map((m) => ({
|
|
145
|
+
id: m[1],
|
|
146
|
+
severity: m[2],
|
|
147
|
+
status: m[3],
|
|
148
|
+
title: (m[4] ?? "").trim()
|
|
149
|
+
})),
|
|
150
|
+
wentWrong: blocks(section.get("what went wrong on the way")),
|
|
151
|
+
deferred: blocks(section.get("deferred"))
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Every archive, newest first within each kind; an absent folder contributes nothing. */
|
|
155
|
+
export async function readHistory(root) {
|
|
156
|
+
const archives = [];
|
|
157
|
+
for (const kind of HISTORY_KINDS) {
|
|
158
|
+
let names;
|
|
159
|
+
try {
|
|
160
|
+
names = await fs.readdir(statePath(root, "history", kind));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
for (const name of names.filter((n) => n.endsWith(".md") && n !== "README.md").sort().reverse()) {
|
|
166
|
+
const source = await readIfPresent(statePath(root, "history", kind, name));
|
|
167
|
+
if (source !== null)
|
|
168
|
+
archives.push(parseArchive(kind, name, source));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return archives;
|
|
172
|
+
}
|
|
173
|
+
/** The inbox's one-line notes, `- 2026-09-05 - text`, with the date when one leads. */
|
|
174
|
+
export function parseInbox(source) {
|
|
62
175
|
if (!source)
|
|
63
176
|
return [];
|
|
64
|
-
return [...source.matchAll(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
status: m[3],
|
|
68
|
-
title: (m[4] ?? "").trim()
|
|
177
|
+
return [...source.matchAll(/^- (?:(\d{4}-\d{2}-\d{2}) - )?(.+)$/gm)].map((m) => ({
|
|
178
|
+
date: m[1] ?? null,
|
|
179
|
+
text: (m[2] ?? "").trim()
|
|
69
180
|
}));
|
|
70
181
|
}
|
|
182
|
+
/** Paragraphs and list items, each as one line of text. */
|
|
183
|
+
function blocks(body) {
|
|
184
|
+
if (!body)
|
|
185
|
+
return [];
|
|
186
|
+
return body
|
|
187
|
+
.split(/\n\s*\n/)
|
|
188
|
+
.flatMap((paragraph) => (/^\s*- /.test(paragraph) ? bullets(paragraph) : [join(paragraph)]))
|
|
189
|
+
.filter(Boolean);
|
|
190
|
+
}
|
|
191
|
+
function join(paragraph) {
|
|
192
|
+
return paragraph.split(/\r?\n/).map((line) => line.trim()).join(" ").trim();
|
|
193
|
+
}
|
|
194
|
+
export function parseFindings(source) {
|
|
195
|
+
if (!source)
|
|
196
|
+
return [];
|
|
197
|
+
const entries = source.split(/^(?=###\s)/m);
|
|
198
|
+
return entries.flatMap((entry) => {
|
|
199
|
+
const m = /^###\s+(F-\d+)\s+\[(P[0-3])\]\s+(\w+)\s+-\s+(.+)$/im.exec(entry);
|
|
200
|
+
if (!m)
|
|
201
|
+
return [];
|
|
202
|
+
// A finding raised while building has no lens: "2026-09-26 while writing the guide".
|
|
203
|
+
// Matching from the last parenthesis, with no two parts able to take the same spaces, keeps this linear.
|
|
204
|
+
const found = labelled(entry, "Found");
|
|
205
|
+
const paren = found ? found.lastIndexOf("(") : -1;
|
|
206
|
+
const lensed = found && paren >= 0 ? /^\((?:scope:[^;)]*;\s*)?lens:([^)]+)\)\s*$/.exec(found.slice(paren)) : null;
|
|
207
|
+
return [
|
|
208
|
+
{
|
|
209
|
+
id: m[1],
|
|
210
|
+
severity: m[2],
|
|
211
|
+
status: m[3],
|
|
212
|
+
title: (m[4] ?? "").trim(),
|
|
213
|
+
file: labelled(entry, "File"),
|
|
214
|
+
found: lensed && found ? found.slice(0, paren).trim() : found,
|
|
215
|
+
lens: lensed ? (lensed[1] ?? "").trim() : null,
|
|
216
|
+
why: labelled(entry, "Why it matters"),
|
|
217
|
+
fix: labelled(entry, "Suggested fix"),
|
|
218
|
+
resolution: labelled(entry, "Resolution")
|
|
219
|
+
}
|
|
220
|
+
];
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** The text after `**Label:**` on its line, or null when the label is absent or empty. */
|
|
224
|
+
function labelled(entry, label) {
|
|
225
|
+
const value = new RegExp(`^\\*\\*${label}:\\*\\*[ \\t]*(.*)$`, "m").exec(entry)?.[1]?.trim();
|
|
226
|
+
return value ? value : null;
|
|
227
|
+
}
|
|
71
228
|
export function parseOpenQuestions(overview) {
|
|
72
229
|
if (!overview)
|
|
73
230
|
return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-religion",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"religion": "dist/bin/religion.js"
|
|
32
32
|
},
|
|
33
33
|
"files": ["dist/", "template/", "README.md", "LICENSE"],
|
|
34
|
-
"engines": { "node": ">=
|
|
34
|
+
"engines": { "node": ">=22" },
|
|
35
35
|
"scripts": {
|
|
36
36
|
"build": "tsc -p tsconfig.json",
|
|
37
37
|
"prepack": "npm --prefix ../.. run build && npm run build"
|