entropy-machines 0.1.1

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.
Files changed (51) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +68 -0
  3. package/agents/isolated-worker.md +128 -0
  4. package/agents/verifier.md +158 -0
  5. package/bin/dispatch +700 -0
  6. package/bin/doclint +460 -0
  7. package/bin/drain +507 -0
  8. package/bin/drain-pick.py +168 -0
  9. package/bin/drain-prompt.md +67 -0
  10. package/bin/drain-run.sh +342 -0
  11. package/bin/entropy-machines-init +285 -0
  12. package/bin/handoff +1151 -0
  13. package/bin/init +232 -0
  14. package/bin/post-fold-audit +377 -0
  15. package/bin/serve +724 -0
  16. package/bin/status +208 -0
  17. package/bin/tracker +153 -0
  18. package/docs/AGENT-QUICKSTART.md +86 -0
  19. package/docs/CONFIG.md +68 -0
  20. package/docs/NPM.md +91 -0
  21. package/docs/SERVE.md +74 -0
  22. package/docs/TRACKER-ADAPTER.md +66 -0
  23. package/doctrine/HANDOFF-PROMPT.md +63 -0
  24. package/doctrine/README.md +62 -0
  25. package/doctrine/ROLES.md +27 -0
  26. package/doctrine/WORKFLOW.md +87 -0
  27. package/hooks/commit-msg +24 -0
  28. package/hooks/post-checkout +354 -0
  29. package/hooks/pre-commit +33 -0
  30. package/lib/PRD-001-orientation.html +1180 -0
  31. package/lib/REPORT-TEMPLATE.html +413 -0
  32. package/lib/changelog-collate.mjs +328 -0
  33. package/lib/changelog-guard.sh +157 -0
  34. package/lib/changelog-new.mjs +70 -0
  35. package/lib/config.mjs +283 -0
  36. package/lib/config.py +317 -0
  37. package/lib/doc-template.html +807 -0
  38. package/lib/entropy-drain.plist.in +59 -0
  39. package/lib/entropy-drain.service.in +53 -0
  40. package/lib/entropy-drain.timer.in +36 -0
  41. package/lib/fail-first.mjs +901 -0
  42. package/lib/handoff-guard.sh +623 -0
  43. package/lib/install-hooks.sh +169 -0
  44. package/lib/notes.py +675 -0
  45. package/lib/preflight-tree.mjs +82 -0
  46. package/lib/roots.sh +212 -0
  47. package/lib/themes/daylight.css +84 -0
  48. package/lib/themes/high-contrast.css +36 -0
  49. package/lib/tracker-file +333 -0
  50. package/lib/tracker-view.py +784 -0
  51. package/package.json +38 -0
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * changelog-collate — turn fragment files into changelog sections.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * The naive design has every commit edit the TOP of one shared changelog
8
+ * file. Under concurrent agents editing in parallel worktrees, that file is
9
+ * the one place their otherwise-disjoint changes collide — three parallel
10
+ * merges landing at once is enough to guarantee a conflict there and nowhere
11
+ * else in the diff. The usual mitigation is procedural (one session writes
12
+ * all the entries, everyone else is forbidden to touch the file), which
13
+ * serialises a step that should be parallel. One file per entry removes the
14
+ * shared write instead: two agents produce two adds, and two adds never
15
+ * conflict.
16
+ *
17
+ * ORDER
18
+ * -----
19
+ * Not the filename. A fragment authored earlier on a branch that merges
20
+ * after one authored later belongs *after* it in history, so the order of
21
+ * record is the order the fragments entered git — `git log --diff-filter=A`
22
+ * over the fragment directory, newest first, which is exactly the order the
23
+ * file reads in. Fragments that are not committed yet are the in-flight
24
+ * commit: they sort to the top by filename, descending, and render with a
25
+ * `HEAD` hash. Filename order is the fallback when git is unavailable.
26
+ *
27
+ * STATUS
28
+ * ------
29
+ * The ⏳/✅ marker in the `##` heading comes from the fragment's `status:`
30
+ * line, not from the collated file. A project may wire an external hook to
31
+ * flip the newest pending fragment's status and re-collate — after a QA pass,
32
+ * say, or a CI job — but that integration is optional and lives outside this
33
+ * script; nothing here assumes it exists or names what triggers it. Status is
34
+ * deliberately NOT derived from commit ancestry against some checkpoint file:
35
+ * a repo that cherry-picks onto its main branch rewrites hashes, and an
36
+ * ancestry-derived marker would silently reset.
37
+ *
38
+ * MODES
39
+ * (none) print the collated block to stdout
40
+ * --write splice it into the collated file between the collation markers
41
+ * --init insert the markers if absent, then --write
42
+ * --check validate every fragment; with markers present, also verify the
43
+ * collated region in the collated file is not stale
44
+ *
45
+ * --write refuses to act when the markers are absent. That keeps the change
46
+ * inert until someone runs --init once on purpose: the collated file is the
47
+ * file this whole exercise is about not touching by surprise.
48
+ *
49
+ * Fragment directory, collated-file path and the marker text all come from
50
+ * config.json's `changelog.*` keys — see docs/CONFIG.md.
51
+ */
52
+
53
+ import { execFileSync } from 'node:child_process';
54
+ import fs from 'node:fs';
55
+ import path from 'node:path';
56
+ import { loadConfig } from './config.mjs';
57
+
58
+ const config = loadConfig();
59
+ const REPO_ROOT = config._root;
60
+ const FRAGMENT_DIR = path.join(REPO_ROOT, config.changelog.fragmentDir);
61
+ const FRAGMENT_PREFIX = `${config.changelog.fragmentDir}/`;
62
+ const CHANGELOG = path.join(REPO_ROOT, config.changelog.collatedFile);
63
+
64
+ const CHANGELOG_REL = config.changelog.collatedFile;
65
+ const COLLATE_CMD = config.changelog.collateCmd ?? 'node lib/changelog-collate.mjs';
66
+
67
+ const BEGIN = config.changelog.marker;
68
+ if (!BEGIN.includes('BEGIN')) {
69
+ throw new Error(
70
+ `changelog.marker (${JSON.stringify(BEGIN)}) must contain the literal text "BEGIN" — ` +
71
+ 'the matching end marker is derived by swapping it for "END".',
72
+ );
73
+ }
74
+ const END = BEGIN.replace('BEGIN', 'END');
75
+
76
+ const STATUS_MARKER = {
77
+ pending: '⏳',
78
+ verified: '✅',
79
+ broken: '🚫',
80
+ };
81
+
82
+ /** Fragment files, in plain descending filename order. `_`-prefixed are skipped. */
83
+ function listFragments() {
84
+ if (!fs.existsSync(FRAGMENT_DIR)) return [];
85
+ return fs
86
+ .readdirSync(FRAGMENT_DIR)
87
+ .filter((n) => n.endsWith('.md') && !n.startsWith('_') && !n.startsWith('.'))
88
+ .sort()
89
+ .reverse();
90
+ }
91
+
92
+ /**
93
+ * Parse the `---`-delimited header. Values may be quoted; a `#` inside a value
94
+ * is not a comment (titles contain them). Returns {header, body}.
95
+ */
96
+ function parseFragment(name, text) {
97
+ const lines = text.split('\n');
98
+ let i = 0;
99
+ while (i < lines.length && lines[i].trim() === '') i++;
100
+ if (lines[i]?.trim() !== '---') {
101
+ throw new Error(`${name}: expected a \`---\` header block on the first non-blank line`);
102
+ }
103
+ i++;
104
+ const header = {};
105
+ for (; i < lines.length; i++) {
106
+ const line = lines[i];
107
+ if (line.trim() === '---') {
108
+ i++;
109
+ break;
110
+ }
111
+ const m = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
112
+ if (!m) throw new Error(`${name}: unparseable header line ${i + 1}: ${JSON.stringify(line)}`);
113
+ let value = m[2].trim();
114
+ if (
115
+ (value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
116
+ (value.startsWith("'") && value.endsWith("'") && value.length > 1)
117
+ ) {
118
+ value = value.slice(1, -1);
119
+ }
120
+ header[m[1]] = value;
121
+ }
122
+ return { header, body: lines.slice(i).join('\n').trim() };
123
+ }
124
+
125
+ function validate(name, header, body) {
126
+ const problems = [];
127
+ for (const key of ['status', 'date', 'title']) {
128
+ if (!header[key]) problems.push(`missing \`${key}:\``);
129
+ }
130
+ if (header.status && !STATUS_MARKER[header.status]) {
131
+ problems.push(
132
+ `status \`${header.status}\` is not one of ${Object.keys(STATUS_MARKER).join(' / ')}`,
133
+ );
134
+ }
135
+ if (header.date && !/^\d{4}-\d{2}-\d{2}$/.test(header.date)) {
136
+ problems.push(`date \`${header.date}\` is not YYYY-MM-DD`);
137
+ }
138
+ if (!body) problems.push('empty body');
139
+ return problems.map((p) => `${name}: ${p}`);
140
+ }
141
+
142
+ /**
143
+ * name -> short hash of the commit that ADDED it. Absent for fragments that
144
+ * are not committed yet. Insertion order of the returned Map is the git order:
145
+ * newest commit first.
146
+ */
147
+ function gitAddOrder() {
148
+ const order = new Map();
149
+ let out;
150
+ try {
151
+ out = execFileSync(
152
+ 'git',
153
+ [
154
+ '-C',
155
+ REPO_ROOT,
156
+ 'log',
157
+ '--diff-filter=A',
158
+ '--format=%h',
159
+ '--name-only',
160
+ '--',
161
+ FRAGMENT_PREFIX,
162
+ ],
163
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
164
+ );
165
+ } catch {
166
+ return order; // not a repo, or git missing — filename order is the fallback
167
+ }
168
+ let hash = null;
169
+ for (const raw of out.split('\n')) {
170
+ const line = raw.trim();
171
+ if (!line) continue;
172
+ if (/^[0-9a-f]{7,40}$/.test(line) && !line.endsWith('.md')) {
173
+ hash = line;
174
+ continue;
175
+ }
176
+ if (line.startsWith(FRAGMENT_PREFIX)) {
177
+ const name = line.slice(FRAGMENT_PREFIX.length);
178
+ if (!order.has(name)) order.set(name, hash);
179
+ }
180
+ }
181
+ return order;
182
+ }
183
+
184
+ function collectEntries() {
185
+ const byFilename = listFragments();
186
+ const added = gitAddOrder();
187
+ const problems = [];
188
+ const parsed = new Map();
189
+
190
+ for (const name of byFilename) {
191
+ const text = fs.readFileSync(path.join(FRAGMENT_DIR, name), 'utf8');
192
+ try {
193
+ const { header, body } = parseFragment(name, text);
194
+ problems.push(...validate(name, header, body));
195
+ parsed.set(name, { name, header, body });
196
+ } catch (err) {
197
+ problems.push(String(err.message));
198
+ }
199
+ }
200
+
201
+ // Uncommitted first (filename-descending), then committed in git order.
202
+ const uncommitted = byFilename.filter((n) => !added.has(n));
203
+ const committed = [...added.keys()].filter((n) => parsed.has(n));
204
+
205
+ const entries = [];
206
+ for (const name of uncommitted) {
207
+ if (parsed.has(name)) entries.push({ ...parsed.get(name), hash: 'HEAD' });
208
+ }
209
+ for (const name of committed) {
210
+ entries.push({ ...parsed.get(name), hash: added.get(name) ?? 'HEAD' });
211
+ }
212
+ return { entries, problems };
213
+ }
214
+
215
+ function render(entries) {
216
+ if (entries.length === 0) return '';
217
+ return entries
218
+ .map((e) => {
219
+ const marker = STATUS_MARKER[e.header.status] ?? '⏳';
220
+ const issue = e.header.issue ? ` (${e.header.issue})` : '';
221
+ const title = e.header.title.endsWith(issue) ? e.header.title : `${e.header.title}${issue}`;
222
+ return `## ${marker} ${e.hash} — ${e.header.date} — ${title}\n\n${e.body}\n\n---\n`;
223
+ })
224
+ .join('\n');
225
+ }
226
+
227
+ function readChangelog() {
228
+ return fs.readFileSync(CHANGELOG, 'utf8');
229
+ }
230
+
231
+ function spliceInto(text, block) {
232
+ const b = text.indexOf(BEGIN);
233
+ const e = text.indexOf(END);
234
+ if (b === -1 || e === -1 || e < b) return null;
235
+ const before = text.slice(0, b + BEGIN.length);
236
+ const after = text.slice(e);
237
+ return `${before}\n\n${block}${block ? '\n' : ''}${after}`;
238
+ }
239
+
240
+ /** Insert an empty marker pair immediately above the first `## ` section. */
241
+ function insertMarkers(text) {
242
+ if (text.includes(BEGIN)) return text;
243
+ const lines = text.split('\n');
244
+ let at = lines.findIndex((l) => l.startsWith('## '));
245
+ if (at === -1) at = lines.length;
246
+ const head = lines.slice(0, at);
247
+ // Drop the trailing `---` rule the old header ended with; the last collated
248
+ // entry supplies its own, so keeping both would double the rule.
249
+ while (head.length && head[head.length - 1].trim() === '') head.pop();
250
+ if (head.length && head[head.length - 1].trim() === '---') head.pop();
251
+ while (head.length && head[head.length - 1].trim() === '') head.pop();
252
+ return [...head, '', BEGIN, '', '---', '', END, '', ...lines.slice(at)].join('\n');
253
+ }
254
+
255
+ function main() {
256
+ const args = process.argv.slice(2);
257
+ const mode = args.find((a) => a.startsWith('--')) ?? '';
258
+ const { entries, problems } = collectEntries();
259
+
260
+ if (problems.length) {
261
+ for (const p of problems) console.error(`changelog: ${p}`);
262
+ if (mode === '--check') {
263
+ console.error(`\nchangelog: ${problems.length} malformed fragment(s). See ${config.changelog.fragmentDir}/_template.md.`);
264
+ process.exit(1);
265
+ }
266
+ process.exit(1);
267
+ }
268
+
269
+ const block = render(entries);
270
+
271
+ if (mode === '--check') {
272
+ const text = fs.existsSync(CHANGELOG) ? readChangelog() : '';
273
+ if (!text.includes(BEGIN)) {
274
+ console.log(
275
+ `changelog: ${entries.length} fragment(s) OK. ${CHANGELOG_REL} has no collation markers yet (run --init when you want them).`,
276
+ );
277
+ return;
278
+ }
279
+ // Compare with heading hashes normalised away. A fragment added by the
280
+ // commit under test renders as `HEAD` while it is uncommitted and as its
281
+ // real short hash once it lands, so a literal comparison reports "stale"
282
+ // for every commit that adds an entry — a state the author cannot fix,
283
+ // because the hash does not exist until after the commit does. Found by
284
+ // dogfooding this on its own landing commit. Content drift is the thing
285
+ // worth guarding; hash resolution is bookkeeping that `--write` does at
286
+ // build time.
287
+ const unhash = (s) => s.replace(/^## (✅|⏳|🚫) [0-9a-f]{7,40} —/gm, '## $1 <hash> —')
288
+ .replace(/^## (✅|⏳|🚫) HEAD —/gm, '## $1 <hash> —');
289
+ const want = spliceInto(text, block);
290
+ if (unhash(want) !== unhash(text)) {
291
+ console.error(
292
+ `changelog: ${CHANGELOG_REL} collated region is stale. Run \`${COLLATE_CMD}\`.`,
293
+ );
294
+ process.exit(1);
295
+ }
296
+ console.log(`changelog: ${entries.length} fragment(s) OK, collated region current.`);
297
+ return;
298
+ }
299
+
300
+ if (mode === '--write' || mode === '--init') {
301
+ let text = readChangelog();
302
+ if (!text.includes(BEGIN)) {
303
+ if (mode !== '--init') {
304
+ console.error(
305
+ `changelog: ${CHANGELOG_REL} has no collation markers. Run \`${COLLATE_CMD} --init\` once to add them (this is the only edit that touches the existing file).`,
306
+ );
307
+ process.exit(2);
308
+ }
309
+ text = insertMarkers(text);
310
+ }
311
+ const next = spliceInto(text, block);
312
+ if (next === null) {
313
+ console.error('changelog: markers present but malformed (END before BEGIN?).');
314
+ process.exit(2);
315
+ }
316
+ if (next !== readChangelog()) {
317
+ fs.writeFileSync(CHANGELOG, next);
318
+ console.log(`changelog: wrote ${entries.length} fragment(s) into ${CHANGELOG_REL}.`);
319
+ } else {
320
+ console.log(`changelog: ${CHANGELOG_REL} already current (${entries.length} fragment(s)).`);
321
+ }
322
+ return;
323
+ }
324
+
325
+ process.stdout.write(block);
326
+ }
327
+
328
+ main();
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bash
2
+ # changelog-guard — a commit that touches a watched path must add a fragment
3
+ # under `changelog.fragmentDir`. What counts as "watched" is config.json's
4
+ # `changelog.watchedPathPatterns`; the default is `["**"]`, i.e. every path,
5
+ # until a project narrows it.
6
+ #
7
+ # One implementation, two callers, so the local hook and CI cannot drift:
8
+ #
9
+ # lib/changelog-guard.sh --staged # pre-commit: the index
10
+ # lib/changelog-guard.sh --range A..B # CI: every non-merge commit
11
+ # lib/changelog-guard.sh --commit <sha> # one commit
12
+ #
13
+ # A rule enforced only by a hook is enforced by nothing: hooks live in
14
+ # .git/ and are not version-controlled, so anyone who never runs the hook
15
+ # installer has no hook at all. That is the general shape of a setting that
16
+ # is silently ignored rather than refused — this repo's own hook installer
17
+ # (lib/install-hooks.sh) verifies its own install for exactly that reason.
18
+ # CI is the gate that actually lives in the repo; the hook is the fast local
19
+ # echo of it.
20
+ #
21
+ # Escape hatch, for a genuinely entry-free commit (a revert, a mechanical
22
+ # rename). It takes two forms because the two callers see different things:
23
+ # pre-commit has no commit message yet, and CI has nothing but the message.
24
+ #
25
+ # SKIP_CHANGELOG=1 git commit -m "chore: rename only [skip changelog]"
26
+ #
27
+ # The env var satisfies the hook; the `[skip changelog]` marker is what
28
+ # survives into history and satisfies CI. Use both, or the commit passes
29
+ # locally and fails on push.
30
+ #
31
+ # `git commit --no-verify` is NOT an escape hatch. It skips the hook and
32
+ # leaves nothing behind, so CI fails and there is no record of the intent.
33
+ #
34
+ # The whole feature is optional: `changelog.enabled: false` in config.json
35
+ # turns this script into a no-op. A consuming project may not want a
36
+ # fragment-per-commit changelog at all.
37
+
38
+ set -euo pipefail
39
+
40
+ # ENTROPY_MACHINES_HOME is the harness DIRECTORY — where config.py sits — which may be
41
+ # the repo root or a subdirectory of it. It is not a root and not a separate
42
+ # repo (see lib/roots.sh). The shim installed by lib/install-hooks.sh exports
43
+ # it; the fallback is for direct invocation, by hand or from a test.
44
+ #
45
+ # There is deliberately NO root variable here. Everything this guard reads is
46
+ # `git diff --cached` against whatever working tree git put us in, which is
47
+ # the tree the commit belongs to — the right answer in a linked worktree as
48
+ # well as the main checkout. config.py finds config.json from cwd the same
49
+ # way, and config.json is a TRACKED file, so it is present in every worktree.
50
+ if [ -z "${ENTROPY_MACHINES_HOME:-}" ]; then
51
+ ENTROPY_MACHINES_HOME="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)"
52
+ fi
53
+ CONFIG_PY="$ENTROPY_MACHINES_HOME/lib/config.py"
54
+
55
+ ENABLED="$(python3 "$CONFIG_PY" get changelog.enabled)"
56
+ if [ "$ENABLED" != "true" ]; then
57
+ echo "changelog-guard: changelog.enabled is false in config.json — skipped."
58
+ exit 0
59
+ fi
60
+
61
+ FRAGMENT_DIR="$(python3 "$CONFIG_PY" get changelog.fragmentDir)"
62
+ NEW_FRAGMENT_CMD="$(python3 "$CONFIG_PY" get changelog.newFragmentCmd)"
63
+ WATCHED_RE="$(python3 "$CONFIG_PY" glob-re changelog.watchedPathPatterns)"
64
+ FRAGMENT_RE="$(python3 "$CONFIG_PY" changelog-fragment-re)"
65
+ SKIP_MARKER='[skip changelog]'
66
+
67
+ fail() {
68
+ cat >&2 <<EOF
69
+ changelog-guard: $1 touches a watched path (config.json changelog.watchedPathPatterns) but adds no fragment under ${FRAGMENT_DIR}/.
70
+
71
+ ${NEW_FRAGMENT_CMD} "type(scope): what changed" [issue-id]
72
+
73
+ then fill in the body and stage it. One fragment per commit — that is the
74
+ whole point: your fragment is a file nobody else writes, so it cannot
75
+ conflict with another agent's.
76
+
77
+ Genuinely no entry needed?
78
+ SKIP_CHANGELOG=1 git commit -m "... ${SKIP_MARKER}"
79
+ Both parts matter: the env var clears the hook, the marker clears CI.
80
+ EOF
81
+ exit 1
82
+ }
83
+
84
+ if [ "${SKIP_CHANGELOG:-}" = "1" ]; then
85
+ echo "changelog-guard: SKIP_CHANGELOG=1 — skipped."
86
+ echo "changelog-guard: put '${SKIP_MARKER}' in the commit message too, or CI will fail this commit."
87
+ exit 0
88
+ fi
89
+
90
+ # A commit whose message carries the marker is exempt in CI, the same way the
91
+ # env var exempts it locally.
92
+ skipped_by_message() {
93
+ git log -1 --format='%B' "$1" | grep -qF "$SKIP_MARKER"
94
+ }
95
+
96
+ check_lists() {
97
+ # $1 = all changed paths, $2 = added paths
98
+ local changed="$1" added="$2" label="$3"
99
+ if ! printf '%s\n' "$changed" | grep -Eq "$WATCHED_RE"; then
100
+ return 0
101
+ fi
102
+ if printf '%s\n' "$added" | grep -Eq "$FRAGMENT_RE"; then
103
+ return 0
104
+ fi
105
+ fail "$label"
106
+ }
107
+
108
+ MODE="${1:---staged}"
109
+
110
+ case "$MODE" in
111
+ --staged)
112
+ changed=$(git diff --cached --name-only --diff-filter=ACMRD)
113
+ added=$(git diff --cached --name-only --diff-filter=A)
114
+ check_lists "$changed" "$added" "the staged change"
115
+ echo "changelog-guard: staged change OK."
116
+ ;;
117
+
118
+ --commit)
119
+ sha="${2:?--commit needs a sha}"
120
+ if skipped_by_message "$sha"; then
121
+ echo "changelog-guard: $(git log -1 --format='%h %s' "$sha") — ${SKIP_MARKER}, skipped."
122
+ exit 0
123
+ fi
124
+ changed=$(git show --pretty=format: --name-only --diff-filter=ACMRD "$sha")
125
+ added=$(git show --pretty=format: --name-only --diff-filter=A "$sha")
126
+ check_lists "$changed" "$added" "commit $(git log -1 --format='%h %s' "$sha")"
127
+ echo "changelog-guard: $(git log -1 --format='%h %s' "$sha") OK."
128
+ ;;
129
+
130
+ --range)
131
+ range="${2:?--range needs A..B}"
132
+ # Merge commits are skipped: their content already passed on the branch.
133
+ commits=$(git rev-list --no-merges --reverse "$range")
134
+ if [ -z "$commits" ]; then
135
+ echo "changelog-guard: no non-merge commits in $range."
136
+ exit 0
137
+ fi
138
+ n=0
139
+ for sha in $commits; do
140
+ if skipped_by_message "$sha"; then
141
+ echo "changelog-guard: $(git log -1 --format='%h %s' "$sha") — ${SKIP_MARKER}, skipped."
142
+ n=$((n + 1))
143
+ continue
144
+ fi
145
+ changed=$(git show --pretty=format: --name-only --diff-filter=ACMRD "$sha")
146
+ added=$(git show --pretty=format: --name-only --diff-filter=A "$sha")
147
+ check_lists "$changed" "$added" "commit $(git log -1 --format='%h %s' "$sha")"
148
+ n=$((n + 1))
149
+ done
150
+ echo "changelog-guard: $n commit(s) in $range OK."
151
+ ;;
152
+
153
+ *)
154
+ echo "usage: changelog-guard.sh [--staged | --commit <sha> | --range A..B]" >&2
155
+ exit 2
156
+ ;;
157
+ esac
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * changelog-new — create one changelog fragment, print its path.
4
+ *
5
+ * node lib/changelog-new.mjs -- "fix(scope): what changed" issue-id
6
+ *
7
+ * The filename is <YYYYMMDD>-<HHMMSS>-<slug>-<6 hex>.md.
8
+ *
9
+ * The timestamp is for ordering and for reading the directory by eye. The six
10
+ * random hex characters are what make it collision-proof, and they are the
11
+ * point: two agents in two worktrees cannot see each other, so anything
12
+ * derived from the directory (a sequence number) is a race, and anything
13
+ * derived from the clock alone collides at second resolution. Randomness is
14
+ * the only per-write unique value available without coordination. Ordering
15
+ * does not depend on it — the collator orders by the commit that added the
16
+ * file, and falls back to filename only for fragments not yet committed.
17
+ *
18
+ * Fragment directory is config.json's `changelog.fragmentDir` (default
19
+ * `changelog.d`) — see docs/CONFIG.md.
20
+ */
21
+
22
+ import { randomBytes } from 'node:crypto';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { loadConfig } from './config.mjs';
26
+
27
+ const config = loadConfig();
28
+ const REPO_ROOT = config._root;
29
+ const FRAGMENT_DIR = path.join(REPO_ROOT, config.changelog.fragmentDir);
30
+
31
+ const [, , titleArg, issueArg] = process.argv;
32
+ if (!titleArg) {
33
+ console.error('usage: node lib/changelog-new.mjs -- "type(scope): subject" [issue-id]');
34
+ process.exit(2);
35
+ }
36
+
37
+ const now = new Date();
38
+ const p2 = (n) => String(n).padStart(2, '0');
39
+ const day = `${now.getFullYear()}${p2(now.getMonth() + 1)}${p2(now.getDate())}`;
40
+ const time = `${p2(now.getHours())}${p2(now.getMinutes())}${p2(now.getSeconds())}`;
41
+ const date = `${now.getFullYear()}-${p2(now.getMonth() + 1)}-${p2(now.getDate())}`;
42
+
43
+ const slugSource = issueArg || titleArg;
44
+ const slug =
45
+ slugSource
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, '-')
48
+ .replace(/^-+|-+$/g, '')
49
+ .slice(0, 48) || 'entry';
50
+
51
+ const name = `${day}-${time}-${slug}-${randomBytes(3).toString('hex')}.md`;
52
+ const file = path.join(FRAGMENT_DIR, name);
53
+
54
+ fs.mkdirSync(FRAGMENT_DIR, { recursive: true });
55
+ fs.writeFileSync(
56
+ file,
57
+ `---
58
+ status: pending
59
+ date: ${date}
60
+ issue: ${issueArg ?? ''}
61
+ title: ${JSON.stringify(titleArg)}
62
+ ---
63
+
64
+ TODO: what changed and why.
65
+
66
+ Verification: TODO — what you ran, with the numbers.
67
+ `,
68
+ );
69
+
70
+ console.log(path.relative(REPO_ROOT, file));