create-agent-rig 0.3.1 → 0.4.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +192 -6
  2. package/README.md +40 -2
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/create.js +40 -10
  5. package/packages/cli/dist/commands/init.js +41 -3
  6. package/packages/cli/dist/commands/upgrade.js +300 -0
  7. package/packages/cli/dist/index.js +100 -13
  8. package/packages/cli/dist/lib/copy-tree.js +9 -1
  9. package/packages/cli/dist/lib/git-env.js +48 -0
  10. package/packages/cli/dist/lib/history.js +49 -0
  11. package/packages/cli/dist/lib/install-set.js +46 -0
  12. package/packages/cli/dist/lib/manifest.js +99 -0
  13. package/packages/cli/dist/lib/prompts.js +20 -0
  14. package/packages/cli/dist/lib/safe-path.js +41 -0
  15. package/packages/cli/dist/lib/substitute.js +32 -0
  16. package/packages/cli/dist/lib/targets.js +15 -1
  17. package/packages/cli/dist/lib/version.js +15 -0
  18. package/scripts/prepare.mjs +54 -17
  19. package/templates/agent-os/init/CLAUDE.md +11 -5
  20. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +15 -0
  21. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +104 -0
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -0
  23. package/templates/agent-os/universal/.claude/rules/workflow.md +4 -0
  24. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +32 -4
  25. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +34 -1
  26. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +125 -0
  27. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +6 -0
  28. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +3 -0
  29. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +6 -0
  30. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +125 -0
  31. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +36 -9
  32. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +12 -2
  33. package/templates/agent-os/universal/CLAUDE.md +12 -3
  34. package/templates/agent-os/universal/PLAN.md +14 -3
  35. package/templates/agent-os/universal/layers.json +2 -0
  36. package/templates/hash-history.json +263 -0
@@ -0,0 +1,300 @@
1
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { initInstallSet, projectNameFor } from './init.js';
4
+ import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
5
+ import { agentOsInstallSet, agentOsLayerDirs } from '../lib/install-set.js';
6
+ import { listTree } from '../lib/copy-tree.js';
7
+ import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
8
+ import { resolveInside } from '../lib/safe-path.js';
9
+ import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
10
+ import { TARGETS } from '../lib/targets.js';
11
+ import { packageVersion } from '../lib/version.js';
12
+ /** A user-facing failure: message is printed as-is, no stack trace. */
13
+ export class UpgradeError extends Error {
14
+ }
15
+ const SETTINGS = '.claude/settings.json';
16
+ /** The universal layer's architecture group — installed by `create`, never by `init`. */
17
+ const ARCHITECTURE_ONLY = [
18
+ '.claude/rules/architecture.md',
19
+ '.claude/hooks/guard-core-purity.mjs',
20
+ '.claude/hooks/guard-web-boundary.mjs',
21
+ ];
22
+ async function exists(p) {
23
+ try {
24
+ await access(p);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /**
32
+ * Where `rel` lives inside the rig — refused outright if it lands anywhere
33
+ * else. Nothing should be able to produce such a path once the manifest is
34
+ * validated, which is exactly why this stays: the whole command is writes into
35
+ * somebody's repository, and a containment check is cheap next to the cost of
36
+ * being wrong about that.
37
+ */
38
+ function onDisk(repoDir, rel) {
39
+ const dest = resolveInside(repoDir, rel);
40
+ if (dest === null) {
41
+ throw new UpgradeError(`Refusing to touch "${rel}" — it resolves outside ${repoDir}.`);
42
+ }
43
+ return dest;
44
+ }
45
+ /**
46
+ * The file's bytes, or `null` when it is genuinely **absent**.
47
+ *
48
+ * Only "not there" is absence. Any other failure — a permission, a directory
49
+ * where a file should be, a path this command refuses to touch — is rethrown,
50
+ * because "I could not read your file" must never become "so I wrote mine over
51
+ * it": every caller of this treats `null` as grounds to install.
52
+ */
53
+ async function readIfPresent(repoDir, rel) {
54
+ try {
55
+ return await readFile(onDisk(repoDir, rel), 'utf8');
56
+ }
57
+ catch (error) {
58
+ if (error.code === 'ENOENT')
59
+ return null;
60
+ throw error;
61
+ }
62
+ }
63
+ /** Every stack overlay any target composes — the candidates a rig can carry. */
64
+ function knownStacks() {
65
+ return [...new Set(Object.values(TARGETS).flatMap((t) => t.stacks))];
66
+ }
67
+ /**
68
+ * What a rig with no manifest looks like it is, from the files it has.
69
+ *
70
+ * Two signals, because one file is too thin a thread to hang a project's map
71
+ * on: the architecture rules and hooks, which `create` installs and `init`
72
+ * deliberately does not, **and** any stack-overlay file at all — `init`
73
+ * composes no overlays, so one of those is proof on its own. The region comes
74
+ * from the target whose stack set matches; it is the only value substitution
75
+ * needs that the directory name cannot give.
76
+ *
77
+ * 🔴 Limit: a `create` rig that deleted every architecture file *and* every
78
+ * stack file reads as an `init` rig. It is then offered the `init` flavour of
79
+ * `CLAUDE.md` — a map of a different project shape. Nothing but a manifest
80
+ * distinguishes those two rigs, which is why 0.4.0 writes one.
81
+ */
82
+ async function detectInstall(repoDir) {
83
+ const ctx = { projectName: '', projectScope: '', region: '' };
84
+ const stacks = [];
85
+ for (const stack of knownStacks()) {
86
+ const [layer] = agentOsLayerDirs([stack]).slice(1);
87
+ if (layer === undefined)
88
+ continue;
89
+ const rels = await listTree(layer.dir, {
90
+ transformName: (name) => substituteFileName(name, ctx),
91
+ });
92
+ for (const rel of rels) {
93
+ if (await exists(onDisk(repoDir, rel))) {
94
+ stacks.push(stack);
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ let architectural = stacks.length > 0;
100
+ for (const rel of ARCHITECTURE_ONLY) {
101
+ if (architectural)
102
+ break;
103
+ architectural = await exists(onDisk(repoDir, rel));
104
+ }
105
+ if (!architectural)
106
+ return { kind: 'init', stacks: [], region: '' };
107
+ const target = Object.values(TARGETS).find((t) => t.stacks.length === stacks.length && t.stacks.every((s) => stacks.includes(s)));
108
+ return { kind: 'create', stacks, region: target?.defaultRegion ?? '' };
109
+ }
110
+ async function installSetFor(repoDir, kind, project, stacks) {
111
+ if (kind === 'init')
112
+ return initInstallSet(repoDir, project);
113
+ return agentOsInstallSet(stacks, {
114
+ projectName: project.name,
115
+ projectScope: project.scope,
116
+ region: project.region,
117
+ });
118
+ }
119
+ /**
120
+ * Whether these bytes are a released version of this file.
121
+ *
122
+ * Two candidates are offered to the table: the bytes as they sit, and the
123
+ * bytes with the project's own values turned back into tokens — released
124
+ * template bytes carry `__PROJECT_NAME__`, installed bytes never do.
125
+ */
126
+ function isReleasedVersion(history, rel, content, ctx) {
127
+ const known = history.files[rel];
128
+ if (known === undefined || known.hashes.length === 0)
129
+ return false;
130
+ const candidates = new Set([sha256(content), sha256(detokenizeContent(content, ctx))]);
131
+ return known.hashes.some((hash) => candidates.has(hash));
132
+ }
133
+ /**
134
+ * What an upgrade would do, decided per file, writing nothing.
135
+ *
136
+ * The rule is the whole design: **replace what the rig installed and the user
137
+ * did not touch; report everything else.** There is no three-way merge and no
138
+ * patching — silently merging someone's edits into a file the agent loop obeys
139
+ * is how a rig stops meaning what its owner thinks it means.
140
+ */
141
+ export async function planUpgrade(repoDir, options = {}) {
142
+ const manifest = await readManifest(repoDir);
143
+ // Detection is a whole-tree probe, and it answers a question the manifest
144
+ // has already answered when there is one.
145
+ const detected = manifest === null
146
+ ? await detectInstall(repoDir)
147
+ : { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
148
+ const kind = manifest?.kind ?? detected.kind;
149
+ const name = path.basename(path.resolve(repoDir));
150
+ // `init` slugs the directory name into something an operator can type (it
151
+ // ends up in the kill-switch filename); `create` validated it as an npm name
152
+ // at generation time, so there the basename is already the project name.
153
+ const bootstrapName = kind === 'init' ? projectNameFor(repoDir) : name;
154
+ const project = manifest?.project ?? {
155
+ name: bootstrapName,
156
+ scope: bootstrapName,
157
+ region: detected.region,
158
+ };
159
+ // Only overlays this version actually ships. An unknown name is not input
160
+ // being dropped — there is no layer behind it to install from — and reading
161
+ // a directory a manifest names would be reading a directory a manifest names.
162
+ const shipped = new Set(knownStacks());
163
+ const stacks = (manifest?.stacks ?? detected.stacks).filter((stack) => shipped.has(stack));
164
+ const history = options.history ?? (await loadHashHistory());
165
+ const files = await installSetFor(repoDir, kind, project, stacks);
166
+ const ctx = {
167
+ projectName: project.name,
168
+ projectScope: project.scope,
169
+ region: project.region,
170
+ };
171
+ const actions = [];
172
+ const contents = new Map();
173
+ const nextFiles = {};
174
+ let wiring = null;
175
+ for (const file of files) {
176
+ const current = await readIfPresent(repoDir, file.rel);
177
+ const recorded = manifest?.files[file.rel];
178
+ contents.set(file.rel, file.content);
179
+ if (current === null) {
180
+ // Evidence, not a command. The manifest is the direct evidence; without
181
+ // one, a path that shipped in *every* release the table covers was there
182
+ // to be removed, so its absence is a decision. A path added later is
183
+ // simply missing from an older rig, and that one is delivered.
184
+ if (recorded !== undefined) {
185
+ actions.push({
186
+ rel: file.rel,
187
+ verdict: 'deleted',
188
+ reason: 'installed by the rig, removed since — not restored',
189
+ });
190
+ nextFiles[file.rel] = recorded;
191
+ }
192
+ else if (presentInEveryRelease(history, file.rel)) {
193
+ actions.push({
194
+ rel: file.rel,
195
+ verdict: 'deleted',
196
+ reason: `shipped in every release since ${history.versions[0]}, and is gone — not restored`,
197
+ });
198
+ }
199
+ else {
200
+ actions.push({ rel: file.rel, verdict: 'new', templatePath: file.source });
201
+ nextFiles[file.rel] = sha256(file.content);
202
+ }
203
+ continue;
204
+ }
205
+ if (file.rel === SETTINGS) {
206
+ if (current === file.content) {
207
+ actions.push({ rel: file.rel, verdict: 'unchanged' });
208
+ nextFiles[file.rel] = sha256(file.content);
209
+ }
210
+ else {
211
+ // Same special case `init` makes: this file is a merge target, not a
212
+ // payload — replacing it can unwire hooks the user added themselves.
213
+ wiring = file.content;
214
+ actions.push({
215
+ rel: file.rel,
216
+ verdict: 'wiring',
217
+ reason: 'never replaced — merge the entries below by hand',
218
+ });
219
+ if (recorded !== undefined)
220
+ nextFiles[file.rel] = recorded;
221
+ }
222
+ continue;
223
+ }
224
+ if (current === file.content) {
225
+ actions.push({ rel: file.rel, verdict: 'unchanged' });
226
+ nextFiles[file.rel] = sha256(file.content);
227
+ }
228
+ else if (recorded !== undefined && sha256(current) === recorded) {
229
+ actions.push({ rel: file.rel, verdict: 'update', templatePath: file.source });
230
+ nextFiles[file.rel] = sha256(file.content);
231
+ }
232
+ else if (isReleasedVersion(history, file.rel, current, ctx)) {
233
+ actions.push({ rel: file.rel, verdict: 'update', templatePath: file.source });
234
+ nextFiles[file.rel] = sha256(file.content);
235
+ }
236
+ else {
237
+ actions.push({
238
+ rel: file.rel,
239
+ verdict: 'conflict',
240
+ reason: recorded === undefined
241
+ ? 'not a version this rig ever released — treated as yours'
242
+ : 'edited since it was installed',
243
+ templatePath: file.source,
244
+ });
245
+ // deliberately NOT recorded: the rig does not own these bytes
246
+ }
247
+ }
248
+ // With no manifest, "there is a rig here" has to be *recognised*, not
249
+ // assumed from a file existing: `CLAUDE.md` and `.claude/settings.json` are
250
+ // in the install set and in nearly every repository ever opened by an agent.
251
+ // Recognition means bytes we know — a file already current, or one that
252
+ // matches a released version. Without that this command would silently
253
+ // perform an `init` nobody asked for.
254
+ if (manifest === null &&
255
+ !actions.some((a) => a.verdict === 'unchanged' || a.verdict === 'update'))
256
+ throw new UpgradeError(`No rig found in ${repoDir}. Nothing here is recognisable as a create-agent-rig ` +
257
+ 'install — run `create-agent-rig init` to install the process layer, or upgrade ' +
258
+ 'from the directory that holds the rig.');
259
+ return {
260
+ kind,
261
+ fromVersion: manifest?.version ?? null,
262
+ toVersion: await packageVersion(),
263
+ bootstrapped: manifest === null,
264
+ actions,
265
+ wiring,
266
+ contents,
267
+ manifest: {
268
+ version: await packageVersion(),
269
+ kind,
270
+ project,
271
+ stacks: [...stacks],
272
+ files: nextFiles,
273
+ },
274
+ };
275
+ }
276
+ /**
277
+ * Write the plan: the `update` and `new` files, then the manifest. Everything
278
+ * else in the plan is a sentence for a human, not an edit.
279
+ */
280
+ export async function applyUpgrade(repoDir, plan, options = {}) {
281
+ const written = [];
282
+ if (options.dryRun === true)
283
+ return { written };
284
+ for (const action of plan.actions) {
285
+ if (action.verdict !== 'update' && action.verdict !== 'new')
286
+ continue;
287
+ const content = plan.contents.get(action.rel);
288
+ // Never a silent empty file: a missing entry is a defect in the plan, and
289
+ // truncating somebody's rule file is the worst way to report one.
290
+ if (content === undefined) {
291
+ throw new UpgradeError(`Internal: no content planned for "${action.rel}" — nothing written.`);
292
+ }
293
+ const dest = onDisk(repoDir, action.rel);
294
+ await mkdir(path.dirname(dest), { recursive: true });
295
+ await writeFile(dest, content);
296
+ written.push(action.rel);
297
+ }
298
+ await writeManifest(repoDir, plan.manifest);
299
+ return { written };
300
+ }
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from 'node:fs/promises';
3
- import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
2
  import { parseArgs } from 'node:util';
6
3
  import { CreateError, createProject } from './commands/create.js';
7
4
  import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
5
+ import { UpgradeError, applyUpgrade, planUpgrade } from './commands/upgrade.js';
8
6
  import { makePalette } from './lib/colors.js';
9
- import { promptTarget } from './lib/prompts.js';
7
+ import { promptConfirm, promptTarget } from './lib/prompts.js';
10
8
  import { collectGovernance, renderSummary } from './lib/summary.js';
11
9
  import { DEFAULT_TARGET, TARGET_NAMES } from './lib/targets.js';
10
+ import { packageVersion } from './lib/version.js';
12
11
  const USAGE = `Usage: create-agent-rig <dir> [options]
13
12
 
14
13
  Scaffolds a new project into <dir>: agent operating system (.claude/, CLAUDE.md)
@@ -25,14 +24,11 @@ Options
25
24
 
26
25
  Also: create-agent-rig init [--dry-run] [--force]
27
26
  Install the process layer (rules, gates, stop rules — no architecture
28
- assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md.`;
29
- async function packageVersion() {
30
- // dist/index.js lives three levels under the package root — same walk as
31
- // the templates resolver, valid in the repo and in the published package.
32
- const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'package.json');
33
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
34
- return pkg.version;
35
- }
27
+ assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md.
28
+
29
+ Also: create-agent-rig upgrade [--dry-run] [--yes]
30
+ Bring the rig in the CURRENT repo up to this version. Replaces the files it
31
+ installed and you did not touch; everything else is reported, never merged.`;
36
32
  async function runInit(rawArgs) {
37
33
  let values;
38
34
  try {
@@ -77,10 +73,99 @@ async function runInit(rawArgs) {
77
73
  }
78
74
  return 0;
79
75
  }
76
+ const MARK = {
77
+ update: '~',
78
+ new: '+',
79
+ conflict: '!',
80
+ deleted: '-',
81
+ wiring: '!',
82
+ unchanged: '·',
83
+ };
84
+ function renderUpgradePlan(repoDir, plan) {
85
+ const of = (verdict) => plan.actions.filter((a) => a.verdict === verdict);
86
+ const lines = [
87
+ `agent-rig upgrade — ${plan.kind} rig in ${repoDir}`,
88
+ plan.bootstrapped
89
+ ? ` no manifest here (a pre-0.4.0 rig) — matching files against released versions`
90
+ : ` installed by ${plan.fromVersion}`,
91
+ ` upgrading to ${plan.toVersion}`,
92
+ '',
93
+ ];
94
+ for (const verdict of ['update', 'new', 'deleted', 'conflict', 'wiring']) {
95
+ for (const action of of(verdict)) {
96
+ lines.push(` ${MARK[verdict]} ${action.rel}` + (action.reason ? ` — ${action.reason}` : ''));
97
+ // A conflict is only useful if the new version can be diffed by hand.
98
+ if (verdict === 'conflict' && action.templatePath) {
99
+ lines.push(` new version: ${action.templatePath}`);
100
+ }
101
+ }
102
+ }
103
+ const unchanged = of('unchanged').length;
104
+ lines.push('', ` ${of('update').length} to replace, ${of('new').length} new, ` +
105
+ `${of('conflict').length} yours (kept), ${unchanged} already current`);
106
+ return `${lines.join('\n')}\n`;
107
+ }
108
+ async function runUpgrade(rawArgs) {
109
+ let values;
110
+ try {
111
+ ({ values } = parseArgs({
112
+ args: rawArgs,
113
+ options: { 'dry-run': { type: 'boolean' }, yes: { type: 'boolean' } },
114
+ allowPositionals: false,
115
+ }));
116
+ }
117
+ catch (error) {
118
+ process.stderr.write(`${error.message}\n\n${USAGE}\n`);
119
+ return 1;
120
+ }
121
+ const cwd = process.cwd();
122
+ const plan = await planUpgrade(cwd);
123
+ process.stdout.write(renderUpgradePlan(cwd, plan));
124
+ // The one thing this command will not do for you — printed with the plan,
125
+ // because the dry run is where a reader decides whether there is work here,
126
+ // and a report that mentions entries it never shows is not a plan.
127
+ if (plan.wiring !== null) {
128
+ process.stdout.write(`\n! .claude/settings.json is never replaced — it is where your own hooks live.\n` +
129
+ ` This version wires them like this; merge in what is missing:\n\n` +
130
+ plan.wiring.replace(/^/gm, ' ') +
131
+ '\n');
132
+ }
133
+ if (values['dry-run'] === true) {
134
+ process.stdout.write('\nDry run — nothing written.\n');
135
+ return 0;
136
+ }
137
+ // The plan above is the review step, so it has to be answered before
138
+ // anything is written. On a terminal that is a question; off one it is the
139
+ // same refusal `create` makes without --target — never guess for a run that
140
+ // cannot be asked, least of all when the answer rewrites its repository.
141
+ const isInteractive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
142
+ if (values.yes !== true) {
143
+ if (!isInteractive) {
144
+ process.stderr.write('Refusing to rewrite files in a non-interactive run. ' +
145
+ 'Re-run with --yes once the plan above is what you want (or --dry-run to keep looking).\n');
146
+ return 1;
147
+ }
148
+ const confirmed = await promptConfirm('\nApply this plan?', {
149
+ input: process.stdin,
150
+ output: process.stderr,
151
+ isInteractive,
152
+ });
153
+ if (!confirmed) {
154
+ process.stdout.write('Nothing written.\n');
155
+ return 0;
156
+ }
157
+ }
158
+ const result = await applyUpgrade(cwd, plan);
159
+ process.stdout.write(`\nWrote ${result.written.length} files.\n`);
160
+ return 0;
161
+ }
80
162
  async function main() {
81
163
  if (process.argv[2] === 'init') {
82
164
  return runInit(process.argv.slice(3));
83
165
  }
166
+ if (process.argv[2] === 'upgrade') {
167
+ return runUpgrade(process.argv.slice(3));
168
+ }
84
169
  let positionals;
85
170
  let values;
86
171
  try {
@@ -144,7 +229,9 @@ main()
144
229
  process.exitCode = code;
145
230
  })
146
231
  .catch((error) => {
147
- if (error instanceof CreateError || error instanceof InitError) {
232
+ if (error instanceof CreateError ||
233
+ error instanceof InitError ||
234
+ error instanceof UpgradeError) {
148
235
  process.stderr.write(`${error.message}\n`);
149
236
  }
150
237
  else {
@@ -34,6 +34,14 @@ export async function copyTree(srcDir, destDir, options = {}) {
34
34
  * composition for collisions before anything is copied.
35
35
  */
36
36
  export async function listTree(srcDir, options = {}) {
37
+ return (await listTreeEntries(srcDir, options)).map((entry) => entry.rel);
38
+ }
39
+ /**
40
+ * {@link listTree} with the source path kept alongside each destination — what
41
+ * an upgrade needs to read the new version of a file, and to name where it
42
+ * lives when it must not write it.
43
+ */
44
+ export async function listTreeEntries(srcDir, options = {}) {
37
45
  const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
38
46
  const files = [];
39
47
  const walk = async (dir, relDir) => {
@@ -47,7 +55,7 @@ export async function listTree(srcDir, options = {}) {
47
55
  await walk(path.join(dir, entry.name), relPath);
48
56
  }
49
57
  else if (entry.isFile()) {
50
- files.push(relPath);
58
+ files.push({ rel: relPath, source: path.join(dir, entry.name) });
51
59
  }
52
60
  }
53
61
  };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Variables that point git at a repository other than the one at `cwd`.
3
+ *
4
+ * Inherited, they silently redirect a git command into the CALLER's repository:
5
+ * `git init` re-initialises it, `add -A` stages the caller's tree, and a commit
6
+ * lands on whatever branch the caller has checked out — while the directory the
7
+ * command was aimed at ends up with no `.git` at all.
8
+ *
9
+ * This is not hypothetical. Git sets `GIT_DIR` and `GIT_INDEX_FILE` — absolute —
10
+ * for the hooks it runs, so a `git commit` from a linked worktree whose
11
+ * pre-commit runs a suite that shells out to git writes one junk commit per
12
+ * invocation onto the branch being committed. Observed twice in this repo, from
13
+ * two different call sites, which is why this lives in one module: a second copy
14
+ * of this list is a second chance to fix one and forget the other.
15
+ *
16
+ * The list is explicit rather than a `GIT_*` sweep on purpose — `GIT_SSH_COMMAND`
17
+ * or `GIT_TERMINAL_PROMPT` are the caller's environment and none of our business.
18
+ * Only repository *location* is stripped.
19
+ *
20
+ * 🔴 Limit, stated: this strips repository *location*, not every way git can be
21
+ * redirected. The config-injection family (`GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n`,
22
+ * `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_GLOBAL`/`_SYSTEM`) can carry `core.worktree`
23
+ * or `core.bare` and is deliberately out of scope: git never sets those for the
24
+ * hooks that cause this problem, and stripping a caller's deliberate config
25
+ * overrides would be its own surprise.
26
+ *
27
+ * 🔴 And it protects exactly the call sites that use it. It does not make git
28
+ * safe to call from a hook-invoked process in general, and a spawn that forgets
29
+ * `gitEnv()` is unprotected — nothing in this module can detect that. The sweep
30
+ * in `test/template/git-env.test.ts` is what watches for it.
31
+ */
32
+ export const GIT_LOCATION_VARS = [
33
+ 'GIT_DIR',
34
+ 'GIT_WORK_TREE',
35
+ 'GIT_INDEX_FILE',
36
+ 'GIT_COMMON_DIR',
37
+ 'GIT_OBJECT_DIRECTORY',
38
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
39
+ 'GIT_NAMESPACE',
40
+ 'GIT_PREFIX',
41
+ ];
42
+ /** The caller's environment minus anything that re-points git at another repo. */
43
+ export function gitEnv(env = process.env) {
44
+ const sanitised = { ...env };
45
+ for (const key of GIT_LOCATION_VARS)
46
+ delete sanitised[key];
47
+ return sanitised;
48
+ }
@@ -0,0 +1,49 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { templatesRoot } from '../templates.js';
4
+ export const EMPTY_HISTORY = { versions: [], files: {} };
5
+ /**
6
+ * Whether every release this table knows about carried this path.
7
+ *
8
+ * It is the one question that lets a manifest-less rig keep a **deletion**: if
9
+ * the file shipped in every version the rig could possibly be, then it is gone
10
+ * because somebody removed it — not because the rig predates it. A path added
11
+ * later is genuinely new to an older rig and must still be delivered.
12
+ */
13
+ export function presentInEveryRelease(history, rel) {
14
+ const oldest = history.versions[0];
15
+ const entry = history.files[rel];
16
+ return oldest !== undefined && entry !== undefined && entry.since === oldest;
17
+ }
18
+ export function historyPath() {
19
+ return path.join(templatesRoot(), 'hash-history.json');
20
+ }
21
+ /**
22
+ * Fails open to an empty table: a missing or corrupt history means "nothing is
23
+ * recognised", which downgrades files to conflicts. That is the safe
24
+ * direction — the unsafe one is claiming a file is untouched when it is not.
25
+ */
26
+ export async function loadHashHistory() {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(await readFile(historyPath(), 'utf8'));
30
+ }
31
+ catch {
32
+ return EMPTY_HISTORY;
33
+ }
34
+ if (typeof parsed !== 'object' || parsed === null)
35
+ return EMPTY_HISTORY;
36
+ const { versions, files } = parsed;
37
+ if (!Array.isArray(versions) || typeof files !== 'object' || files === null)
38
+ return EMPTY_HISTORY;
39
+ const clean = {};
40
+ for (const [rel, entry] of Object.entries(files)) {
41
+ const { since, hashes } = (entry ?? {});
42
+ if (typeof since !== 'string')
43
+ continue;
44
+ if (!Array.isArray(hashes) || hashes.some((h) => typeof h !== 'string'))
45
+ continue;
46
+ clean[rel] = { since, hashes };
47
+ }
48
+ return { versions: versions.filter((v) => typeof v === 'string'), files: clean };
49
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { listTreeEntries } from './copy-tree.js';
3
+ import { substituteContent, substituteFileName } from './substitute.js';
4
+ import { agentOsStackDir, agentOsUniversalDir } from '../templates.js';
5
+ /**
6
+ * The agent-os layers of a target, in composition order. One definition, read
7
+ * by `create` when it copies them and by `upgrade` when it refreshes them —
8
+ * two lists would drift, and the one nobody looks at would be the wrong one.
9
+ */
10
+ export function agentOsLayerDirs(stacks) {
11
+ return [
12
+ { name: 'agent-os/universal', dir: agentOsUniversalDir() },
13
+ ...stacks.map((stack) => ({ name: `agent-os/stack/${stack}`, dir: agentOsStackDir(stack) })),
14
+ ];
15
+ }
16
+ /**
17
+ * Exactly the agent-os bytes `create` writes, for a given target and project.
18
+ *
19
+ * The skeleton is deliberately absent: after generation the code is the user's
20
+ * project, not the rig's — `upgrade` refreshes the process layer and nothing
21
+ * else. Layers claim disjoint paths (`composition.ts` refuses otherwise), so
22
+ * this is a plain concatenation.
23
+ *
24
+ * 🔴 Two assumptions, both load-bearing and both true today: the layer is all
25
+ * **text** (this reads every file as UTF-8; a template test pins it for the
26
+ * process half) and none of it is **executable** (`upgrade` writes with a bare
27
+ * `writeFile` and does not carry the mode across the way `copyTree` does —
28
+ * nothing in `templates/agent-os` currently has a mode to carry). Adding the
29
+ * first binary asset or executable script here means teaching both.
30
+ */
31
+ export async function agentOsInstallSet(stacks, ctx) {
32
+ const files = [];
33
+ for (const layer of agentOsLayerDirs(stacks)) {
34
+ const entries = await listTreeEntries(layer.dir, {
35
+ transformName: (name) => substituteFileName(name, ctx),
36
+ });
37
+ for (const entry of entries) {
38
+ files.push({
39
+ rel: entry.rel,
40
+ source: entry.source,
41
+ content: substituteContent(await readFile(entry.source, 'utf8'), ctx),
42
+ });
43
+ }
44
+ }
45
+ return files;
46
+ }