create-religion 0.4.0 → 1.0.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 CHANGED
@@ -12,7 +12,7 @@ every diff before it lands.
12
12
 
13
13
  ## What it installs
14
14
 
15
- - **22 skills** your AI tool can run: `feature`, `implement`, `check`, `audit`, `complete`,
15
+ - **25 skills** your AI tool can run: `feature`, `implement`, `check`, `audit`, `complete`,
16
16
  and the rest of the loop around them.
17
17
  - **A state directory** holding your plans, the active spec, the findings ledger, and the
18
18
  archive of everything finished.
@@ -42,8 +42,24 @@ Installing the package globally shortens these to `religion status` and the rest
42
42
  `update` will not overwrite a file you have edited. It tells you what conflicts and backs
43
43
  up the original if you choose to replace it.
44
44
 
45
+ ### Exit codes
46
+
47
+ The same for every command, so a script can rely on them:
48
+
49
+ | Code | Meaning |
50
+ | --- | --- |
51
+ | `0` | the command did what it was asked |
52
+ | `1` | it ran and reports failure: no project found, a failing `doctor` check, conflicts, declined merges or linked files left by `install` or `update`, a project installed by a newer version, or an unexpected error |
53
+ | `2` | usage error: nothing was written |
54
+
45
55
  ## Documentation
46
56
 
57
+ - [Getting started](https://github.com/foo-stack/religion/blob/main/docs/getting-started.md)
58
+ - [What the loop actually feels like](https://github.com/foo-stack/religion/blob/main/docs/the-loop.md)
59
+ - [What to do when doctor complains](https://github.com/foo-stack/religion/blob/main/docs/doctor.md)
60
+ - [Updating a project](https://github.com/foo-stack/religion/blob/main/docs/upgrading.md)
61
+ - [Stability](https://github.com/foo-stack/religion/blob/main/docs/stability.md)
62
+
47
63
  [github.com/foo-stack/religion](https://github.com/foo-stack/religion)
48
64
 
49
65
  MIT.
@@ -11,10 +11,11 @@ import fsSync from "node:fs";
11
11
  import path from "node:path";
12
12
  import readline from "node:readline/promises";
13
13
  import { fileURLToPath } from "node:url";
14
+ import { parseArgs } from "../lib/args.js";
14
15
  import { startDashboard } from "../lib/dashboard.js";
15
- import { renderDoctor, runDoctor } from "../lib/doctor.js";
16
- import { ADAPTERS, applyInstall, planInstall, readManifest, wireHooks, writeManifest } from "../lib/install.js";
17
- import { findProjectRoot, STATE_DIR } from "../lib/paths.js";
16
+ import { doctorReport, renderDoctor, runDoctor } from "../lib/doctor.js";
17
+ import { ADAPTERS, applyInstall, BACKUPS, manifestRefusal, planInstall, readManifest, wireHooks, writeManifest } from "../lib/install.js";
18
+ import { findProjectRoot } from "../lib/paths.js";
18
19
  import { readProjectState } from "../lib/state.js";
19
20
  import { computeStatus, renderStatus } from "../lib/status.js";
20
21
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -43,7 +44,14 @@ function findPackageRoot(from) {
43
44
  const packageRoot = findPackageRoot(here);
44
45
  const templateRoot = path.join(packageRoot, "template");
45
46
  async function main(argv) {
46
- const options = parse(argv);
47
+ const parsed = parseArgs(argv, { cwd: process.cwd(), isDirectory });
48
+ if (!parsed.ok) {
49
+ console.error(parsed.error);
50
+ console.error("Run `religion help` for usage.");
51
+ process.exitCode = 2;
52
+ return;
53
+ }
54
+ const options = parsed.options;
47
55
  if (options.command === "help")
48
56
  return printHelp();
49
57
  if (options.command === "install" || options.command === "update")
@@ -60,9 +68,9 @@ async function main(argv) {
60
68
  return;
61
69
  }
62
70
  if (options.command === "doctor") {
63
- const results = await runDoctor(root);
64
- console.log(options.json ? JSON.stringify(results, null, 2) : renderDoctor(results));
65
- if (results.some((r) => !r.ok))
71
+ const report = doctorReport(await runDoctor(root));
72
+ console.log(options.json ? JSON.stringify(report, null, 2) : renderDoctor(report.checks));
73
+ if (!report.healthy)
66
74
  process.exitCode = 1;
67
75
  return;
68
76
  }
@@ -77,6 +85,13 @@ async function runInstall(options) {
77
85
  const target = path.resolve(options.target);
78
86
  const updating = options.command === "update";
79
87
  const previous = await readManifest(target);
88
+ const packageVersion = await version();
89
+ const refusal = manifestRefusal(previous, packageVersion);
90
+ if (refusal) {
91
+ console.error(refusal);
92
+ process.exitCode = 1;
93
+ return;
94
+ }
80
95
  if (updating && !previous) {
81
96
  console.log("No manifest found. Files matching the template will be adopted; others are conflicts.");
82
97
  }
@@ -88,20 +103,35 @@ async function runInstall(options) {
88
103
  conflict: plan.filter((p) => p.action === "conflict").length,
89
104
  kept: plan.filter((p) => p.action === "seed-skip").length,
90
105
  merge: plan.filter((p) => p.action === "merge").length,
91
- remerge: plan.filter((p) => p.action === "remerge").length
106
+ remerge: plan.filter((p) => p.action === "remerge").length,
107
+ rebuild: plan.filter((p) => p.action === "rebuild").length,
108
+ remove: plan.filter((p) => p.action === "remove").length,
109
+ release: plan.filter((p) => p.action === "release").length,
110
+ linked: plan.filter((p) => p.action === "linked").length
92
111
  };
93
112
  console.log(`\nAdapters: ${adapters.map((a) => ADAPTERS[a].label).join(", ")}`);
94
113
  console.log(` create ${counts.create}`);
95
114
  console.log(` update ${counts.update}`);
96
115
  console.log(` keep ${counts.kept} (your files, never overwritten)`);
97
116
  console.log(` conflict ${counts.conflict}`);
117
+ for (const entry of plan.filter((p) => p.action === "conflict"))
118
+ console.log(` ${entry.relative}`);
98
119
  if (counts.merge > 0)
99
120
  console.log(` merge ${counts.merge} (your file, awaiting a decision)`);
100
121
  if (counts.remerge > 0)
101
122
  console.log(` merged ${counts.remerge} (your sections kept)`);
102
- for (const entry of plan.filter((p) => p.action === "conflict")) {
123
+ if (counts.rebuild > 0)
124
+ console.log(` rebuilt ${counts.rebuild} (your sections kept, original backed up)`);
125
+ if (counts.remove > 0)
126
+ console.log(` remove ${counts.remove} (no longer shipped)`);
127
+ if (counts.release > 0)
128
+ console.log(` release ${counts.release} (no longer shipped, but edited by you, so left alone)`);
129
+ for (const entry of plan.filter((p) => p.action === "release"))
130
+ console.log(` ${entry.relative}`);
131
+ if (counts.linked > 0)
132
+ console.log(` linked ${counts.linked} (reached through a symbolic link, never written)`);
133
+ for (const entry of plan.filter((p) => p.action === "linked"))
103
134
  console.log(` ${entry.relative}`);
104
- }
105
135
  const mergeable = plan.filter((p) => p.action === "merge").map((p) => p.relative);
106
136
  let merge = false;
107
137
  if (mergeable.length > 0) {
@@ -110,7 +140,7 @@ async function runInstall(options) {
110
140
  console.log(` ${relative}`);
111
141
  console.log("\nReligion can append its own sections to them inside markers, keeping everything" +
112
142
  "\nyou wrote exactly where it is. Later updates then replace only what is between" +
113
- "\nthose markers. The originals are backed up either way.");
143
+ "\nthose markers. The originals are backed up first.");
114
144
  merge = await confirmMerge(options.yes);
115
145
  }
116
146
  if (options.dryRun) {
@@ -121,14 +151,39 @@ async function runInstall(options) {
121
151
  throw new Error(`No template files found under ${templateRoot}. Nothing would be installed, which is never correct.`);
122
152
  }
123
153
  const result = await applyInstall(templateRoot, target, plan, { force: options.force, merge });
124
- await writeManifest(target, await version(), adapters, templateRoot, previous, result.conflicts);
154
+ await writeManifest(target, packageVersion, adapters, templateRoot, previous, [
155
+ ...result.conflicts,
156
+ ...result.declined,
157
+ ...result.linked
158
+ ]);
125
159
  console.log(`\nWrote ${result.written.length} file(s).`);
126
160
  if (result.merged.length > 0) {
127
161
  console.log(`Merged ${result.merged.length} entry file(s), keeping what you wrote.`);
128
162
  }
129
- if (result.backups.length > 0)
130
- console.log(`Backed up ${result.backups.length} conflicting file(s).`);
163
+ if (result.rebuilt.length > 0) {
164
+ console.log(`Rebuilt ${result.rebuilt.length} entry file(s) around the sections you wrote. The originals are in ` +
165
+ `${BACKUPS}.`);
166
+ }
167
+ if (result.removed.length > 0)
168
+ console.log(`Removed ${result.removed.length} file(s) this version no longer ships.`);
169
+ if (result.released.length > 0) {
170
+ console.log(`Left ${result.released.length} file(s) this version no longer ships, because you edited them. They are yours now.`);
171
+ }
172
+ if (result.backups.length > 0) {
173
+ console.log(`Backed up ${result.backups.length} file(s) to ${BACKUPS} before changing them.`);
174
+ }
175
+ if (result.linked.length > 0) {
176
+ process.exitCode = 1;
177
+ console.log(`\n${result.linked.length} file(s) are reached through a symbolic link and were left alone, since writing` +
178
+ `\nthem would write wherever the link points. Replace the links with real files and run again.`);
179
+ }
180
+ if (result.declined.length > 0) {
181
+ process.exitCode = 1;
182
+ console.log(`\n${result.declined.length} file(s) of yours were not merged, so Religion's instructions are not in them.` +
183
+ `\nRun update again and accept the merge, or pass --yes, to add them.`);
184
+ }
131
185
  if (result.conflicts.length > 0) {
186
+ process.exitCode = 1;
132
187
  console.log(`\n${result.conflicts.length} file(s) were changed locally and left alone.` +
133
188
  `\nReview them, then re-run with --force to replace them (originals are backed up).`);
134
189
  }
@@ -137,9 +192,14 @@ async function runInstall(options) {
137
192
  if (wired === "written") {
138
193
  console.log("Wired the enforcement hooks into .claude/settings.json.");
139
194
  }
195
+ else if (wired === "linked") {
196
+ process.exitCode = 1;
197
+ console.log("\n.claude/settings.json is a symbolic link and was left alone." +
198
+ "\nIf a hook is missing, compare the file it points to with a fresh install's .claude/settings.json.");
199
+ }
140
200
  else if (wired === "exists") {
141
201
  console.log("\n.claude/settings.json already exists and was left alone." +
142
- `\nTo enable the hooks, merge ${path.join(STATE_DIR, ".state", "settings-template.json")} into it.`);
202
+ "\nIf a hook is missing, compare it with a fresh install's .claude/settings.json.");
143
203
  }
144
204
  }
145
205
  if (!updating) {
@@ -187,39 +247,13 @@ async function version() {
187
247
  return "0.0.0";
188
248
  }
189
249
  }
190
- function parse(argv) {
191
- const options = {
192
- command: "install",
193
- adapters: null,
194
- json: false,
195
- dryRun: false,
196
- force: false,
197
- yes: false,
198
- target: process.cwd()
199
- };
200
- const commands = new Set(["install", "update", "status", "doctor", "dashboard", "help"]);
201
- const adapters = [];
202
- for (const arg of argv) {
203
- if (commands.has(arg))
204
- options.command = arg;
205
- else if (arg === "--json")
206
- options.json = true;
207
- else if (arg === "--dry-run")
208
- options.dryRun = true;
209
- else if (arg === "--force")
210
- options.force = true;
211
- else if (arg === "--yes" || arg === "-y")
212
- options.yes = true;
213
- else if (arg === "--help" || arg === "-h")
214
- options.command = "help";
215
- else if (arg.startsWith("--") && arg.slice(2) in ADAPTERS)
216
- adapters.push(arg.slice(2));
217
- else if (!arg.startsWith("-"))
218
- options.target = arg;
219
- }
220
- if (adapters.length > 0)
221
- options.adapters = adapters;
222
- return options;
250
+ function isDirectory(candidate) {
251
+ try {
252
+ return fsSync.statSync(path.resolve(candidate)).isDirectory();
253
+ }
254
+ catch {
255
+ return false;
256
+ }
223
257
  }
224
258
  function printHelp() {
225
259
  console.log(`religion - a file-backed, spec-driven AI development workflow
@@ -239,6 +273,13 @@ Options
239
273
  --force replace locally-changed managed files, backing them up first
240
274
  --json machine-readable output for status and doctor
241
275
  --yes no prompts
276
+
277
+ Exit codes
278
+ 0 the command did what it was asked
279
+ 1 it ran and reports failure: no project found, a failing doctor check,
280
+ conflicts, declined merges or linked files left by install or update,
281
+ a project installed by a newer version, or an unexpected error
282
+ 2 usage error: nothing was written
242
283
  `);
243
284
  }
244
285
  main(process.argv.slice(2)).catch((error) => {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The command-line grammar.
3
+ *
4
+ * Pure, so every rule is testable: the working directory and the one filesystem question it
5
+ * needs, whether a bare word names an existing directory, are passed in. Anything it does
6
+ * not understand is refused rather than guessed at, because a guess here is an install into
7
+ * the wrong place.
8
+ */
9
+ import { ADAPTERS } from "./install.js";
10
+ const COMMANDS = ["install", "update", "status", "doctor", "dashboard", "help"];
11
+ const WRITES = new Set(["install", "update"]);
12
+ const REPORTS = new Set(["status", "doctor"]);
13
+ const EVERY = new Set(COMMANDS);
14
+ /** Every option, the commands it applies to, and what it does. Parsing dispatches on this alone. */
15
+ const FLAGS = {
16
+ "--help": { commands: EVERY, apply: (options) => void (options.command = "help") },
17
+ "-h": { commands: EVERY, apply: (options) => void (options.command = "help") },
18
+ "--dry-run": { commands: WRITES, apply: (options) => void (options.dryRun = true) },
19
+ "--force": { commands: WRITES, apply: (options) => void (options.force = true) },
20
+ "--yes": { commands: WRITES, apply: (options) => void (options.yes = true) },
21
+ "-y": { commands: WRITES, apply: (options) => void (options.yes = true) },
22
+ "--json": { commands: REPORTS, apply: (options) => void (options.json = true) },
23
+ ...Object.fromEntries(Object.keys(ADAPTERS).map((adapter) => [
24
+ `--${adapter}`,
25
+ {
26
+ commands: WRITES,
27
+ apply: (options) => {
28
+ const chosen = options.adapters ?? [];
29
+ if (!chosen.includes(adapter))
30
+ options.adapters = [...chosen, adapter];
31
+ }
32
+ }
33
+ ]))
34
+ };
35
+ /** The options each command accepts, read from the same table parsing uses. */
36
+ export const GRAMMAR = grammar();
37
+ function grammar() {
38
+ const table = {};
39
+ for (const command of COMMANDS)
40
+ table[command] = Object.keys(FLAGS).filter((flag) => FLAGS[flag].commands.has(command));
41
+ return table;
42
+ }
43
+ export function parseArgs(argv, context) {
44
+ const options = {
45
+ command: "install",
46
+ adapters: null,
47
+ json: false,
48
+ dryRun: false,
49
+ force: false,
50
+ yes: false,
51
+ target: context.cwd
52
+ };
53
+ if (argv.some((arg) => arg === "help" || arg === "--help" || arg === "-h")) {
54
+ return { ok: true, options: { ...options, command: "help" } };
55
+ }
56
+ const [first, ...rest] = argv.filter((arg) => !arg.startsWith("-"));
57
+ let extra = [];
58
+ if (first !== undefined && isCommand(first)) {
59
+ options.command = first;
60
+ if (rest[0] !== undefined)
61
+ options.target = rest[0];
62
+ extra = rest.slice(1);
63
+ }
64
+ else if (first !== undefined) {
65
+ if (!context.isDirectory(first))
66
+ return fail(unknownCommand(first));
67
+ options.target = first;
68
+ extra = rest;
69
+ }
70
+ if (extra.length > 0) {
71
+ return fail(`Unexpected argument '${extra[0]}'. ${options.command} takes at most one directory.`);
72
+ }
73
+ for (const flag of argv.filter((arg) => arg.startsWith("-"))) {
74
+ const known = Object.hasOwn(FLAGS, flag) ? FLAGS[flag] : undefined;
75
+ if (!known)
76
+ return fail(`Unknown option '${flag}'.`);
77
+ if (!known.commands.has(options.command))
78
+ return fail(`Option '${flag}' does not apply to ${options.command}.`);
79
+ known.apply(options);
80
+ }
81
+ return { ok: true, options };
82
+ }
83
+ function isCommand(word) {
84
+ return COMMANDS.includes(word);
85
+ }
86
+ function fail(error) {
87
+ return { ok: false, error };
88
+ }
89
+ function unknownCommand(word) {
90
+ const closest = COMMANDS.map((command) => ({ command, distance: editDistance(word, command) }))
91
+ .filter((candidate) => candidate.distance <= 2)
92
+ .sort((a, b) => a.distance - b.distance)[0];
93
+ return closest
94
+ ? `Unknown command '${word}'. Did you mean '${closest.command}'?`
95
+ : `Unknown command '${word}', and no directory of that name exists.`;
96
+ }
97
+ function editDistance(a, b) {
98
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
99
+ for (let i = 1; i <= a.length; i += 1) {
100
+ const current = [i];
101
+ for (let j = 1; j <= b.length; j += 1) {
102
+ const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
103
+ current.push(Math.min(previous[j] + 1, current[j - 1] + 1, substitution));
104
+ }
105
+ previous = current;
106
+ }
107
+ return previous[b.length];
108
+ }
@@ -10,27 +10,50 @@ import { readIfPresent, statePath } from "./paths.js";
10
10
  import { readProjectState } from "./state.js";
11
11
  import { computeStatus } from "./status.js";
12
12
  export async function startDashboard(root) {
13
+ let port = 0;
13
14
  const server = http.createServer((request, response) => {
14
- void handle(root, request, response);
15
+ void handle(root, port, request, response);
15
16
  });
16
17
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
17
18
  const address = server.address();
18
- const port = typeof address === "object" && address ? address.port : 0;
19
+ port = typeof address === "object" && address ? address.port : 0;
19
20
  return {
20
21
  url: `http://127.0.0.1:${port}`,
22
+ address: typeof address === "object" && address ? address.address : "",
21
23
  close: () => new Promise((resolve) => server.close(() => resolve()))
22
24
  };
23
25
  }
24
- async function handle(root, request, response) {
26
+ /**
27
+ * Whether a request was addressed to this server by name, not only by socket.
28
+ *
29
+ * Binding to loopback keeps other machines out, but not a page elsewhere whose domain has
30
+ * been rebound to 127.0.0.1: the browser connects here and sends that domain as the Host.
31
+ * Answering only our own name refuses it.
32
+ */
33
+ export function isOwnHost(host, port) {
34
+ const name = host?.toLowerCase();
35
+ return name === `127.0.0.1:${port}` || name === `localhost:${port}`;
36
+ }
37
+ /** What every response allows a browser to load: inline script and style, and its own server. */
38
+ export const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'";
39
+ async function handle(root, port, request, response) {
40
+ if (!isOwnHost(request.headers.host, port)) {
41
+ send(response, 403, "text/plain; charset=utf-8", "Forbidden: this dashboard only answers requests addressed to itself.");
42
+ return;
43
+ }
25
44
  if (request.url === "/state.json") {
26
45
  const state = await readProjectState(root);
27
46
  const activity = await readActivity(root);
28
- response.writeHead(200, { "content-type": "application/json" });
29
- response.end(JSON.stringify({ status: computeStatus(state), plan: state.plan, findings: state.findings, activity }));
47
+ const body = JSON.stringify({ status: computeStatus(state), plan: state.plan, findings: state.findings, activity });
48
+ send(response, 200, "application/json", body);
30
49
  return;
31
50
  }
32
- response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
33
- response.end(PAGE);
51
+ send(response, 200, "text/html; charset=utf-8", PAGE);
52
+ }
53
+ /** The only way this server answers, so no response can leave out the policy. */
54
+ function send(response, status, type, body) {
55
+ response.writeHead(status, { "content-type": type, "content-security-policy": CONTENT_SECURITY_POLICY });
56
+ response.end(body);
34
57
  }
35
58
  async function readActivity(root) {
36
59
  const raw = await readIfPresent(statePath(root, ".state", "run.json"));
@@ -26,7 +26,8 @@ const REQUIRED = [
26
26
  "history/spikes",
27
27
  "reference"
28
28
  ];
29
- const ALLOWED = {
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 };