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.
- package/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- 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));
|