agent-docs-bootstrap 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follo
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.1] - 2026-10-27
8
+
9
+ ### Fixed
10
+ - A `.DS_Store` or other OS file inside an installed folder counted as a change: the first run skipped the path and dropped its marker line, and later runs called it "not installed by this kit", so updates stopped for the whole team once the marker was committed. Dot files, `Thumbs.db` and `desktop.ini` are now left out of content hashes; a folder without them hashes exactly as in 0.4.0.
11
+ - A path that exists without a marker line but holds content a kit version wrote, byte for byte, is recognised, updated and listed again, so installs stuck on 0.4.0 recover with a normal run.
12
+ - A shipped path the user changed keeps its marker line and old hash, so every run reports the same thing about it instead of "changed" first and "not installed by this kit" afterwards.
13
+ - The installer exits non-zero and lists the paths it could not install when a shipped path is skipped, so an agent or CI notices an incomplete install. A changed path the kit no longer ships is only a warning.
14
+
15
+ ## [0.4.0] - 2026-10-27
16
+
17
+ ### Changed
18
+ - `.agent-docs-bootstrap` lines are `<path> <content hash>`. A path is replaced or removed only if its content still matches what the kit wrote; lines without a hash, from 0.3.0 and earlier, are checked against the hashes of everything those versions installed.
19
+
20
+ ### Fixed
21
+ - An update or `--uninstall` removed a listed path whatever was in it, so a skill the user had replaced with their own version under the same name, such as `grilling` after 0.2.0, was deleted. A changed path is now left in place as the user's, with a warning, and drops out of the marker. Subagent files a 0.3.0 or earlier install rendered with `--scout-model` or `--reader-model` do not match the known hashes and are left in place too: delete them and run the installer again.
22
+
7
23
  ## [0.3.0] - 2026-10-27
8
24
 
9
25
  ### Changed
package/README.md CHANGED
@@ -17,7 +17,7 @@ You need Node.js 18+ and a git repository.
17
17
  1. From your project root, install the skills and subagents:
18
18
 
19
19
  ```bash
20
- npx agent-docs-bootstrap@0.3.0 --target claude
20
+ npx agent-docs-bootstrap@0.4.1 --target claude
21
21
  ```
22
22
 
23
23
  Replace `claude` with `codex`, `opencode`, a list such as `claude,codex`, or `all`.
@@ -103,19 +103,19 @@ All options run the same installer from your project root. Pin the version: the
103
103
  **From npm** (recommended):
104
104
 
105
105
  ```bash
106
- npx agent-docs-bootstrap@0.3.0 --target claude
106
+ npx agent-docs-bootstrap@0.4.1 --target claude
107
107
  ```
108
108
 
109
109
  **From a GitHub release tag**, when the npm registry is blocked or unavailable:
110
110
 
111
111
  ```bash
112
- npx github:TraneDev/agent-docs-bootstrap#v0.3.0 --target claude
112
+ npx github:TraneDev/agent-docs-bootstrap#v0.4.1 --target claude
113
113
  ```
114
114
 
115
115
  **From a clone**, to read or change the kit first:
116
116
 
117
117
  ```bash
118
- git clone --branch v0.3.0 https://github.com/TraneDev/agent-docs-bootstrap.git ~/agent-docs-bootstrap
118
+ git clone --branch v0.4.1 https://github.com/TraneDev/agent-docs-bootstrap.git ~/agent-docs-bootstrap
119
119
  node ~/agent-docs-bootstrap/install.mjs --target claude
120
120
  ```
121
121
 
@@ -131,7 +131,12 @@ Files are copied into the project, not linked, so they get committed with it.
131
131
 
132
132
  Two skills are installed, `docs-bootstrap` and `docs-check`. The guides by other authors that `docs-bootstrap` relies on travel inside it, in `docs-bootstrap/vendor/`, and are not registered as skills, so a skill of yours with the same name, such as `grilling`, never clashes with them.
133
133
 
134
- `.agent-docs-bootstrap` lists every installed path. The installer replaces or removes only those paths and never touches files it did not create: a path that already exists and is not listed there is skipped with a warning, and a line in the marker that is not shaped like a path the kit installs (for example `src` or `../x`, added by a bad merge) is ignored with a warning.
134
+ `.agent-docs-bootstrap` lists every installed path with a hash of what was written. The installer replaces or removes a path only if it is listed there and its content still matches, so it never touches files it did not create or that you changed:
135
+
136
+ - a path that exists but is not listed is skipped with a warning;
137
+ - a listed path whose content you changed is left in place, with a warning, and the installer exits non-zero; a changed path the kit no longer ships, for example a skill you replaced with your own version, becomes yours and drops out of the marker;
138
+ - files the OS adds to the kit's folders, such as `.DS_Store`, `Thumbs.db` or `desktop.ini`, do not count as a change;
139
+ - a marker line not shaped like a path the kit installs (for example `src` or `../x`, added by a bad merge) is ignored with a warning.
135
140
 
136
141
  ### Other agents
137
142
 
@@ -144,7 +149,7 @@ What changes without the installer's targets:
144
149
 
145
150
  ### Update or remove
146
151
 
147
- - **Update:** run the installer again with the new version and commit the result. Paths the previous version installed and this one no longer ships are removed.
152
+ - **Update:** run the installer again with the new version and commit the result. Paths the previous version installed and this one no longer ships are removed. Do not install an older version over a newer one: an older installer does not read the newer marker and treats the kit's files as yours. Run `--uninstall` with the newer version first.
148
153
  - **Remove:** run it with `--uninstall`. Everything listed in `.agent-docs-bootstrap` is deleted, along with the marker itself.
149
154
 
150
155
  <details>
package/install.mjs CHANGED
@@ -10,15 +10,18 @@
10
10
  // claude skills → .claude/skills/ agents → .claude/agents/*.md
11
11
  // codex skills → .agents/skills/ agents → .codex/agents/*.toml
12
12
  // opencode skills → .agents/skills/ agents → .opencode/agents/*.md commands → .opencode/commands/*.md
13
- // Installed paths are listed in .agent-docs-bootstrap; only those are ever replaced or removed, and only if a line
14
- // has the shape of a path the kit writes (<layout folder>/<kit name>). On an update, listed paths this version no
15
- // longer ships are removed.
13
+ // Installed paths are listed in .agent-docs-bootstrap with a hash of what was written. A path is replaced or removed
14
+ // only if its line has the shape of a path the kit writes (<layout folder>/<kit name>) and its content still matches
15
+ // that hash or something a version of the kit wrote; OS files such as .DS_Store do not count. A changed path is left
16
+ // in place and the run exits non-zero. On an update, listed paths this version no longer ships are removed under the
17
+ // same rule, or left to the user if changed.
16
18
  // No network access, no changes to harness settings, no admin rights.
17
19
 
18
20
  import fs from 'node:fs';
19
21
  import path from 'node:path';
20
22
  import os from 'node:os';
21
23
  import { fileURLToPath } from 'node:url';
24
+ import { createHash } from 'node:crypto';
22
25
 
23
26
  const KIT = path.dirname(fileURLToPath(import.meta.url));
24
27
  const TARGETS = ['claude', 'codex', 'opencode'];
@@ -54,9 +57,37 @@ const layoutDirs = new Set(Object.values(LAYOUT).flatMap(L => Object.values(L)).
54
57
  // The marker is committed, so a bad merge or PR can add any line to it. Only a line shaped like a path the kit
55
58
  // writes, <layout folder>/<shipped name>[.md|.toml], is trusted; nothing else is ever replaced or removed.
56
59
  const isOwnedPath = (r) => layoutDirs.has(path.normalize(path.dirname(r))) && SHIPPED.has(path.basename(r).replace(/\.(md|toml)$/, ''));
57
- const markerLines = read(MARKER).split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('version='));
58
- for (const l of markerLines) if (!isOwnedPath(l)) warn(`ignored '${l}' in .agent-docs-bootstrap: not a path this kit installs`);
59
- const oldList = markerLines.filter(isOwnedPath);
60
+ // Marker lines are `<path> <hash>`; versions up to 0.3.0 wrote the path alone, so their content is checked
61
+ // against everything those versions installed (LEGACY).
62
+ const old = new Map();
63
+ for (const l of read(MARKER).split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('version='))) {
64
+ const [r, h] = l.split(/\s+/);
65
+ if (isOwnedPath(r)) old.set(r, h);
66
+ else warn(`ignored '${r}' in .agent-docs-bootstrap: not a path this kit installs`);
67
+ }
68
+ // Hashes of every path versions 0.1.0–0.3.0 installed (all targets, no model flags). Fixed: never edit.
69
+ const LEGACY = new Set([
70
+ '00ca80d7077c0e4b', '122ae594f7eb38bd', '185ec7d02d9cf70b', '1f5a0fe496f38dd6', '3798ad63dd954bac',
71
+ '4c36074d70c5a88c', '562d1e8f3a090ffd', '698cadefe39cb46f', '6acf8481d4dd6a1a', '72bcff6d0e5476ba',
72
+ '790d7e6ae5034b3e', '79d9da94af685633', '7cd7eeac11324b51', '855778c0459cea17', '8c5adb3f63300a43',
73
+ '900cf27ab683e5d1', '96288a3e60126264', '9e0e5c30da22c059', 'b45162299d9aa78b', 'c5d47d8811aa61ee',
74
+ 'c87450584ed42c8b', 'cf2f8a40c91f3c45', 'dac09810ceb351f7', 'dc418221c7363caa', 'f741ba5711364c55',
75
+ 'fceff4c3b3159c80', 'fd975138dcda6b38', 'fe412d3c3c312f7a',
76
+ ]);
77
+
78
+ const skills = skillDirs();
79
+ const agents = AGENTS.map(n => parseAgent(path.join(KIT, 'agents', `${n}.md`)));
80
+ const models = { 'docs-scout': a['scout-model'], 'docs-reader': a['reader-model'] };
81
+ const COMMANDS = [['docs-bootstrap', "Build the project's reference documentation"], ['docs-check', "Optional: test the project's documentation with a fresh reader"]];
82
+ const commandBody = (name, what) => `---\ndescription: ${JSON.stringify(`${what} (${name})`)}\n---\nLoad the \`${name}\` skill and follow it. Arguments from the developer, if any: $ARGUMENTS\n`;
83
+ // What this version writes when no model is pinned. Content that matches it or LEGACY byte for byte is the kit's,
84
+ // even where the marker lost its line (0.4.0 counted a .DS_Store as a change and dropped the line).
85
+ const CURRENT = new Set([...skills.map(d => contentHash(d)),
86
+ ...TARGETS.flatMap(t => agents.map(ag => textHash(render(t, ag)))), ...COMMANDS.map(([n, w]) => textHash(commandBody(n, w)))]);
87
+ const isKitContent = (h) => LEGACY.has(h) || CURRENT.has(h);
88
+ // The path is the kit's and nobody changed it since it was installed.
89
+ const isUnchanged = (r) => { const h = contentHash(abs(r)); return h === old.get(r) || isKitContent(h); };
90
+ const changedNote = (r) => `${r} changed since it was installed, so it is left in place as yours`;
60
91
  if (a.uninstall) { uninstall(); process.exit(0); }
61
92
 
62
93
  if (!a.target) usage(1, 'Missing --target.');
@@ -69,23 +100,27 @@ if (!GLOBAL) {
69
100
  die(`No .git here (${BASE}) — is this the project root? Run from the root or add --yes.`);
70
101
  }
71
102
 
72
- const installed = new Set();
73
- const ours = (r) => oldList.includes(r);
103
+ const installed = new Map();
104
+ const released = new Set();
105
+ const shipped = new Set();
106
+ const skipped = [];
107
+ // A shipped path the user changed keeps its marker line and old hash, so every run says the same thing about it.
74
108
  function writeOwned(r, fn) {
75
- if (installed.has(r)) return false;
109
+ if (shipped.has(r)) return false;
110
+ shipped.add(r);
76
111
  const dst = abs(r);
77
- if (exists(dst) && !ours(r)) { warn(`skipped ${r}: exists and was not installed by this kit`); return false; }
112
+ if (exists(dst)) {
113
+ const why = !old.has(r) && !isKitContent(contentHash(dst)) ? 'exists and was not installed by this kit'
114
+ : old.has(r) && !isUnchanged(r) ? 'changed since it was installed' : '';
115
+ if (why) { warn(`skipped ${r}: ${why}`); skipped.push(r); return false; }
116
+ }
78
117
  fs.rmSync(dst, { recursive: true, force: true });
79
118
  fs.mkdirSync(path.dirname(dst), { recursive: true });
80
119
  fn(dst);
81
- installed.add(r);
120
+ installed.set(r, contentHash(dst));
82
121
  return true;
83
122
  }
84
123
 
85
- const skills = skillDirs();
86
- const agents = AGENTS.map(n => parseAgent(path.join(KIT, 'agents', `${n}.md`)));
87
- const models = { 'docs-scout': a['scout-model'], 'docs-reader': a['reader-model'] };
88
-
89
124
  for (const t of targets) {
90
125
  const L = LAYOUT[t];
91
126
  console.log(`\n${t}`);
@@ -98,26 +133,36 @@ for (const t of targets) {
98
133
  const r = `${L.agents}/${ag.name}.${ext}`;
99
134
  if (writeOwned(r, dst => fs.writeFileSync(dst, render(t, ag, models[ag.name])))) log(`agent ${r}`);
100
135
  }
101
- if (L.commands) for (const [name, what] of [['docs-bootstrap', "Build the project's reference documentation"], ['docs-check', "Optional: test the project's documentation with a fresh reader"]]) {
136
+ if (L.commands) for (const [name, what] of COMMANDS) {
102
137
  const r = `${L.commands}/${name}.md`;
103
- const body = `---\ndescription: ${JSON.stringify(`${what} (${name})`)}\n---\nLoad the \`${name}\` skill and follow it. Arguments from the developer, if any: $ARGUMENTS\n`;
104
- if (writeOwned(r, dst => fs.writeFileSync(dst, body))) log(`command /${name}`);
138
+ if (writeOwned(r, dst => fs.writeFileSync(dst, commandBody(name, what)))) log(`command /${name}`);
105
139
  }
106
140
  }
107
141
 
108
142
  // Paths an earlier version installed into these targets' folders and this version no longer ships.
109
143
  const ownDirs = new Set(targets.flatMap(t => Object.values(LAYOUT[t])));
110
- for (const r of oldList)
111
- if (!installed.has(r) && ownDirs.has(path.dirname(r)) && exists(abs(r))) { fs.rmSync(abs(r), { recursive: true, force: true }); log(`removed ${r} (no longer shipped)`); }
144
+ for (const r of old.keys()) {
145
+ if (shipped.has(r) || !ownDirs.has(path.dirname(r)) || !exists(abs(r))) continue;
146
+ if (isUnchanged(r)) { fs.rmSync(abs(r), { recursive: true, force: true }); log(`removed ${r} (no longer shipped)`); }
147
+ else { warn(`not removed: ${changedNote(r)}`); released.add(r); }
148
+ }
112
149
 
113
- // Keep entries of earlier installs (e.g. another target) that are still on disk.
114
- const keep = oldList.filter(r => !installed.has(r) && exists(abs(r)));
115
- fs.writeFileSync(MARKER, [`version=${VERSION}`, ...keep, ...installed].join('\n') + '\n');
150
+ // Keep entries of earlier installs (e.g. another target) and skipped shipped paths that are still on disk; a changed
151
+ // path this version no longer ships is the user's and is no longer listed.
152
+ const keep = [...old].filter(([r]) => !installed.has(r) && !released.has(r) && exists(abs(r)));
153
+ fs.writeFileSync(MARKER, [`version=${VERSION}`, ...[...keep, ...installed].map(([r, h]) => h ? `${r} ${h}` : r)].join('\n') + '\n');
116
154
 
117
- console.log(`\nDone: agent-docs-bootstrap ${VERSION}, ${targets.join(', ')}${GLOBAL ? ' (global)' : ''}.`);
118
- if (!GLOBAL) log(`commit the installed folders and .agent-docs-bootstrap so the whole team gets the same version`);
119
- log('restart the agent session so it picks up the new skills and subagents');
120
- log('start with the docs-bootstrap skill (Claude Code /docs-bootstrap, Codex $docs-bootstrap, OpenCode /docs-bootstrap)');
155
+ if (skipped.length) {
156
+ console.error(`\nIncomplete: agent-docs-bootstrap ${VERSION} could not install ${skipped.length} path(s):`);
157
+ for (const r of skipped) console.error(` ${r}`);
158
+ console.error('They hold content this kit did not write. To get this version, move or delete them, then run the installer again.');
159
+ process.exitCode = 1;
160
+ } else {
161
+ console.log(`\nDone: agent-docs-bootstrap ${VERSION}, ${targets.join(', ')}${GLOBAL ? ' (global)' : ''}.`);
162
+ if (!GLOBAL) log(`commit the installed folders and .agent-docs-bootstrap so the whole team gets the same version`);
163
+ log('restart the agent session so it picks up the new skills and subagents');
164
+ log('start with the docs-bootstrap skill (Claude Code /docs-bootstrap, Codex $docs-bootstrap, OpenCode /docs-bootstrap)');
165
+ }
121
166
 
122
167
  // ---------- rendering
123
168
  function render(target, ag, model) {
@@ -166,11 +211,15 @@ function skillDirs() {
166
211
  return out;
167
212
  }
168
213
  function uninstall() {
169
- if (!oldList.length) die(`Nothing to remove: no ${MARKER}.`);
214
+ if (!old.size) die(`Nothing to remove: no ${MARKER}.`);
170
215
  console.log(`Removing agent-docs-bootstrap from ${BASE}`);
171
- for (const r of oldList) if (exists(abs(r))) { fs.rmSync(abs(r), { recursive: true, force: true }); log(`removed ${r}`); }
216
+ for (const r of old.keys()) {
217
+ if (!exists(abs(r))) continue;
218
+ if (isUnchanged(r)) { fs.rmSync(abs(r), { recursive: true, force: true }); log(`removed ${r}`); }
219
+ else warn(`not removed: ${changedNote(r)}`);
220
+ }
172
221
  fs.rmSync(MARKER, { force: true });
173
- const dirs = new Set(oldList.flatMap(r => [path.dirname(r), path.dirname(path.dirname(r))]));
222
+ const dirs = new Set([...old.keys()].flatMap(r => [path.dirname(r), path.dirname(path.dirname(r))]));
174
223
  for (const d of [...dirs].sort((x, y) => y.length - x.length)) try { fs.rmdirSync(abs(d)); } catch {}
175
224
  }
176
225
  function parseArgs(argv) {
@@ -192,6 +241,27 @@ function usage(code, msg) {
192
241
  console.log(src.slice(0, src.findIndex(l => !l.startsWith('//'))).map(l => l.replace(/^\/\/ ?/, '')).join('\n'));
193
242
  process.exit(code);
194
243
  }
244
+ // Content hash of an installed file or folder: relative names and text with line endings normalised, so a
245
+ // checkout with CRLF line endings still matches. Files the OS adds (dot files such as .DS_Store, Thumbs.db,
246
+ // desktop.ini) are left out; the kit ships none (check.mjs makes sure). The format must not change: markers and
247
+ // LEGACY depend on it.
248
+ function contentHash(p) {
249
+ const h = createHash('sha256');
250
+ const files = fs.lstatSync(p).isDirectory() ? listFiles(p).sort() : [''];
251
+ for (const f of files) h.update(`${f}\0${read(path.join(p, f)).replace(/\r\n/g, '\n')}\0`);
252
+ return h.digest('hex').slice(0, 16);
253
+ }
254
+ function isOsFile(name) { return name.startsWith('.') || /^(thumbs\.db|desktop\.ini)$/i.test(name); }
255
+ // The same hash for text about to be written as a file.
256
+ function textHash(text) {
257
+ return createHash('sha256').update(`\0${text.replace(/\r\n/g, '\n')}\0`).digest('hex').slice(0, 16);
258
+ }
259
+ function listFiles(dir, base = dir) {
260
+ return fs.readdirSync(dir, { withFileTypes: true }).filter(e => !isOsFile(e.name)).flatMap(e => {
261
+ const p = path.join(dir, e.name);
262
+ return e.isDirectory() ? listFiles(p, base) : [path.relative(base, p).split(path.sep).join('/')];
263
+ });
264
+ }
195
265
  function read(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } }
196
266
  function log(s) { console.log(` ${s}`); }
197
267
  function warn(s) { console.error(`WARNING: ${s}`); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-docs-bootstrap",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Agent skills that build reference documentation for a codebase (glossary, ADRs, architecture, how-tos, AGENTS.md) through interviews with the team, with an optional check by a fresh agent. Works with any agent that runs skills; the installer sets up Claude Code, Codex and OpenCode.",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/TraneDev/agent-docs-bootstrap.git" },
@@ -38,7 +38,7 @@ const win = process.platform === 'win32';
38
38
  // Documents the kit builds; kit-installed paths and its run files never reach the reader. The marker is committed
39
39
  // and could hold any line, so only lines shaped like an installed path count (a line `docs` would hide every document).
40
40
  const KIT_DIRS = ['.claude/skills', '.claude/agents', '.agents/skills', '.codex/agents', '.opencode/agents', '.opencode/commands'];
41
- const kitPaths = read(path.join(ROOT, '.agent-docs-bootstrap'), '', true).split('\n').map(l => l.trim())
41
+ const kitPaths = read(path.join(ROOT, '.agent-docs-bootstrap'), '', true).split('\n').map(l => l.trim().split(/\s+/)[0])
42
42
  .filter(l => KIT_DIRS.includes(path.posix.dirname(l)) && /^[a-z0-9-]+(\.md|\.toml)?$/.test(path.posix.basename(l)));
43
43
  const isKit = (p) => p === '.agent-docs-bootstrap' || p.startsWith('docs/agents/') || kitPaths.some(k => p === k || p.startsWith(k + '/'));
44
44
  // README and other files stay as they were in the parent commit, like the code: they are the project as it was,