create-religion 1.0.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/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
- const steps = [...source.matchAll(/^\s*- \[( |x|X)\]\s*(?:\*\*)?(.+?)(?:\*\*)?(?:\s+-\s|$)/gim)];
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: /^#\s+(.+)$/m.exec(source)?.[1]?.trim() ?? null,
54
- type: /^\*\*Type:\*\*\s*(.+)$/m.exec(source)?.[1]?.trim() ?? null,
55
- status: /^\*\*Status:\*\*\s*(.+)$/m.exec(source)?.[1]?.trim() ?? null,
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: steps.find((s) => (s[1] ?? " ").toLowerCase() !== "x")?.[2]?.trim() ?? null
63
+ nextStep: (next?.[2] ?? next?.[3])?.trim() ?? null
59
64
  };
60
65
  }
61
- export function parseFindings(source) {
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(/^###\s+(F-\d+)\s+\[(P[0-3])\]\s+(\w+)\s+-\s+(.+)$/gim)].map((m) => ({
65
- id: m[1],
66
- severity: m[2],
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": "1.0.0",
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",