create-agent-rig 0.3.2 → 0.5.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 (109) hide show
  1. package/CHANGELOG.md +294 -10
  2. package/README.md +84 -13
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +41 -8
  5. package/packages/cli/dist/commands/init.js +120 -34
  6. package/packages/cli/dist/commands/upgrade.js +382 -0
  7. package/packages/cli/dist/index.js +132 -21
  8. package/packages/cli/dist/lib/copy-tree.js +44 -7
  9. package/packages/cli/dist/lib/history.js +49 -0
  10. package/packages/cli/dist/lib/init-settings.js +12 -0
  11. package/packages/cli/dist/lib/install-set.js +44 -0
  12. package/packages/cli/dist/lib/manifest.js +111 -0
  13. package/packages/cli/dist/lib/prompts.js +20 -0
  14. package/packages/cli/dist/lib/safe-path.js +71 -0
  15. package/packages/cli/dist/lib/substitute.js +32 -0
  16. package/packages/cli/dist/lib/version.js +15 -0
  17. package/templates/agent-os/init/AGENTS.md +191 -0
  18. package/templates/agent-os/init/CLAUDE.md +61 -9
  19. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  20. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  21. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  22. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  23. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  24. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  25. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  26. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  28. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  29. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  30. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  31. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  32. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  33. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  34. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  35. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  36. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  37. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  38. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  39. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  40. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  41. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  42. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  43. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  44. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  45. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  46. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  50. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  51. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  52. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  53. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  55. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  56. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  57. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  58. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  59. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  60. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  61. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  62. package/templates/agent-os/universal/.claude/settings.json +7 -2
  63. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  64. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +395 -33
  65. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  66. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  67. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  68. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  69. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  70. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  71. package/templates/agent-os/universal/AGENTS.md +164 -0
  72. package/templates/agent-os/universal/CLAUDE.md +47 -14
  73. package/templates/agent-os/universal/PLAN.md +7 -29
  74. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  75. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  76. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  77. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  78. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  79. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  80. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  81. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  82. package/templates/agent-os/universal/journal/README.md +101 -0
  83. package/templates/agent-os/universal/layers.json +36 -2
  84. package/templates/hash-history.json +264 -0
  85. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  86. package/templates/skeleton/aws-serverless/README.md +91 -9
  87. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  88. package/templates/skeleton/aws-serverless/gitignore +37 -0
  89. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  90. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  91. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  92. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  93. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  94. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  95. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  96. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  97. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  98. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  99. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  100. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  101. package/templates/skeleton/node-service/README.md +11 -1
  102. package/templates/skeleton/node-service/gitignore +34 -0
  103. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  104. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  105. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  106. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  107. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  108. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  109. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -1,17 +1,17 @@
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 { readManifest } from './lib/manifest.js';
8
+ import { promptConfirm, promptTarget } from './lib/prompts.js';
10
9
  import { collectGovernance, renderSummary } from './lib/summary.js';
11
10
  import { DEFAULT_TARGET, TARGET_NAMES } from './lib/targets.js';
11
+ import { packageVersion } from './lib/version.js';
12
12
  const USAGE = `Usage: create-agent-rig <dir> [options]
13
13
 
14
- Scaffolds a new project into <dir>: agent operating system (.claude/, CLAUDE.md)
14
+ Scaffolds a new project into <dir>: a Claude Code + Codex agent operating system
15
15
  plus a runnable code skeleton. Refuses to write into a non-empty directory.
16
16
 
17
17
  Options
@@ -23,16 +23,16 @@ Options
23
23
  --version print the version
24
24
  -h, --help this text
25
25
 
26
- Also: create-agent-rig init [--dry-run] [--force]
26
+ Also: create-agent-rig init [--dry-run]
27
27
  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
- }
28
+ assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md
29
+ or AGENTS.md.
30
+ --force is deprecated: it refuses and points at upgrade, which refreshes a
31
+ rig file by file. It is removed in 0.6.
32
+
33
+ Also: create-agent-rig upgrade [--dry-run] [--yes]
34
+ Bring the rig in the CURRENT repo up to this version. Replaces the files it
35
+ installed and you did not touch; everything else is reported, never merged.`;
36
36
  async function runInit(rawArgs) {
37
37
  let values;
38
38
  try {
@@ -48,10 +48,23 @@ async function runInit(rawArgs) {
48
48
  }
49
49
  const cwd = process.cwd();
50
50
  const dryRun = values['dry-run'] === true;
51
+ // `init` adopts a repo the rig knows nothing about. Run inside a rig `create`
52
+ // generated — reachable when its CLAUDE.md was deleted — it is the wrong
53
+ // command: it installs the process layer alone and never refreshes the stack
54
+ // overlays. Say so before anything is written, so it is visible on --dry-run
55
+ // too.
56
+ //
57
+ // It is a manifest read, so a pre-0.4.0 rig with no manifest gets no advisory
58
+ // even when it came from `create` — the same limit `recordInstall` carries,
59
+ // and stated in both places because either one alone reads as wider.
60
+ const existing = await readManifest(cwd);
51
61
  const plan = await planInit(cwd);
52
62
  process.stdout.write(`agent-rig init — process layer into ${cwd}\n\n` +
53
63
  plan.files.map((f) => ` + ${f.path}`).join('\n') +
54
64
  '\n');
65
+ if (existing?.kind === 'create') {
66
+ process.stdout.write(`\n! This rig was created by create-agent-rig — \`init\` only fills gaps here; use \`upgrade\` to refresh.\n`);
67
+ }
55
68
  if (plan.conflicts.length > 0) {
56
69
  process.stdout.write(`\nAlready present (kept, not overwritten):\n` +
57
70
  plan.conflicts.map((c) => ` · ${c}`).join('\n') +
@@ -65,22 +78,118 @@ async function runInit(rawArgs) {
65
78
  process.stdout.write(`\nInstalled ${result.written.length} files` +
66
79
  (result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
67
80
  '.\n');
68
- // The one kept file that silently disables everything else: without this
69
- // wiring the hooks sit on disk and are never called, while the rules claim
70
- // they are enforced. Say so loudly, and hand over the exact entries.
71
- if (result.skipped.includes('.claude/settings.json')) {
72
- const wiring = (await initFileContents(cwd)).get('.claude/settings.json') ?? '';
73
- process.stdout.write(`\n! .claude/settings.json already exists — it was kept, so the rig's hooks are NOT wired.\n` +
81
+ // A kept harness config silently disables that harness's enforcement: the
82
+ // hooks sit on disk and are never called, while the rules claim they are.
83
+ // Say so loudly, and hand over the exact entries for each affected harness.
84
+ const generated = await initFileContents(cwd);
85
+ for (const wiringPath of ['.claude/settings.json', '.codex/hooks.json']) {
86
+ if (!result.skipped.includes(wiringPath))
87
+ continue;
88
+ const wiring = generated.get(wiringPath) ?? '';
89
+ process.stdout.write(`\n! ${wiringPath} already exists — it was kept, so the rig's hooks are NOT wired there.\n` +
74
90
  ` Until you merge these entries into it, nothing enforces the rules:\n\n` +
75
91
  wiring.replace(/^/gm, ' ') +
76
92
  '\n');
77
93
  }
78
94
  return 0;
79
95
  }
96
+ const MARK = {
97
+ update: '~',
98
+ new: '+',
99
+ conflict: '!',
100
+ deleted: '-',
101
+ wiring: '!',
102
+ unchanged: '·',
103
+ };
104
+ function renderUpgradePlan(repoDir, plan) {
105
+ const of = (verdict) => plan.actions.filter((a) => a.verdict === verdict);
106
+ const lines = [
107
+ `agent-rig upgrade — ${plan.kind} rig in ${repoDir}`,
108
+ plan.bootstrapped
109
+ ? ` no manifest here (a pre-0.4.0 rig) — matching files against released versions`
110
+ : ` installed by ${plan.fromVersion}`,
111
+ ` upgrading to ${plan.toVersion}`,
112
+ '',
113
+ ];
114
+ for (const verdict of ['update', 'new', 'deleted', 'conflict', 'wiring']) {
115
+ for (const action of of(verdict)) {
116
+ lines.push(` ${MARK[verdict]} ${action.rel}` + (action.reason ? ` — ${action.reason}` : ''));
117
+ // A conflict is only useful if the new version can be diffed by hand.
118
+ if (verdict === 'conflict' && action.templatePath) {
119
+ lines.push(` new version: ${action.templatePath}`);
120
+ }
121
+ }
122
+ }
123
+ const unchanged = of('unchanged').length;
124
+ lines.push('', ` ${of('update').length} to replace, ${of('new').length} new, ` +
125
+ `${of('conflict').length} yours (kept), ${unchanged} already current`);
126
+ return `${lines.join('\n')}\n`;
127
+ }
128
+ async function runUpgrade(rawArgs) {
129
+ let values;
130
+ try {
131
+ ({ values } = parseArgs({
132
+ args: rawArgs,
133
+ options: { 'dry-run': { type: 'boolean' }, yes: { type: 'boolean' } },
134
+ allowPositionals: false,
135
+ }));
136
+ }
137
+ catch (error) {
138
+ process.stderr.write(`${error.message}\n\n${USAGE}\n`);
139
+ return 1;
140
+ }
141
+ const cwd = process.cwd();
142
+ const plan = await planUpgrade(cwd);
143
+ process.stdout.write(renderUpgradePlan(cwd, plan));
144
+ // The one thing this command will not do for you — printed with the plan,
145
+ // because the dry run is where a reader decides whether there is work here,
146
+ // and a report that mentions entries it never shows is not a plan.
147
+ if (plan.wiringByPath.size > 0) {
148
+ for (const [wiringPath, wiring] of plan.wiringByPath) {
149
+ process.stdout.write(`\n! ${wiringPath} was handed over rather than replaced — the reason is\n` +
150
+ ` on its line above. It is hook wiring, so it is never overwritten\n` +
151
+ ` without proof the rig wrote those exact bytes.\n` +
152
+ ` This version wires it like this; merge in what is missing:\n\n` +
153
+ wiring.replace(/^/gm, ' ') +
154
+ '\n');
155
+ }
156
+ }
157
+ if (values['dry-run'] === true) {
158
+ process.stdout.write('\nDry run — nothing written.\n');
159
+ return 0;
160
+ }
161
+ // The plan above is the review step, so it has to be answered before
162
+ // anything is written. On a terminal that is a question; off one it is the
163
+ // same refusal `create` makes without --target — never guess for a run that
164
+ // cannot be asked, least of all when the answer rewrites its repository.
165
+ const isInteractive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
166
+ if (values.yes !== true) {
167
+ if (!isInteractive) {
168
+ process.stderr.write('Refusing to rewrite files in a non-interactive run. ' +
169
+ 'Re-run with --yes once the plan above is what you want (or --dry-run to keep looking).\n');
170
+ return 1;
171
+ }
172
+ const confirmed = await promptConfirm('\nApply this plan?', {
173
+ input: process.stdin,
174
+ output: process.stderr,
175
+ isInteractive,
176
+ });
177
+ if (!confirmed) {
178
+ process.stdout.write('Nothing written.\n');
179
+ return 0;
180
+ }
181
+ }
182
+ const result = await applyUpgrade(cwd, plan);
183
+ process.stdout.write(`\nWrote ${result.written.length} files.\n`);
184
+ return 0;
185
+ }
80
186
  async function main() {
81
187
  if (process.argv[2] === 'init') {
82
188
  return runInit(process.argv.slice(3));
83
189
  }
190
+ if (process.argv[2] === 'upgrade') {
191
+ return runUpgrade(process.argv.slice(3));
192
+ }
84
193
  let positionals;
85
194
  let values;
86
195
  try {
@@ -144,7 +253,9 @@ main()
144
253
  process.exitCode = code;
145
254
  })
146
255
  .catch((error) => {
147
- if (error instanceof CreateError || error instanceof InitError) {
256
+ if (error instanceof CreateError ||
257
+ error instanceof InitError ||
258
+ error instanceof UpgradeError) {
148
259
  process.stderr.write(`${error.message}\n`);
149
260
  }
150
261
  else {
@@ -25,8 +25,38 @@ function isBinary(buffer) {
25
25
  }
26
26
  export async function copyTree(srcDir, destDir, options = {}) {
27
27
  const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
28
+ const files = [];
28
29
  await mkdir(destDir, { recursive: true });
29
- await copyDir(srcDir, destDir, '', { ...options, ignore });
30
+ await collectCopyTasks(srcDir, destDir, '', { ...options, ignore }, files);
31
+ await mapConcurrent(files, 16, ({ srcPath, destPath, relPath }) => copyFileEntry(srcPath, destPath, relPath, { ...options, ignore }));
32
+ }
33
+ /** Run independent async work through a bounded pool while preserving result order. */
34
+ export async function mapConcurrent(items, limit, worker) {
35
+ if (!Number.isInteger(limit) || limit < 1)
36
+ throw new RangeError('concurrency limit must be positive');
37
+ const results = new Array(items.length);
38
+ let next = 0;
39
+ let failed = false;
40
+ let firstError;
41
+ const run = async () => {
42
+ while (!failed && next < items.length) {
43
+ const index = next;
44
+ next += 1;
45
+ try {
46
+ results[index] = await worker(items[index]);
47
+ }
48
+ catch (error) {
49
+ if (!failed) {
50
+ failed = true;
51
+ firstError = error;
52
+ }
53
+ }
54
+ }
55
+ };
56
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
57
+ if (failed)
58
+ throw firstError;
59
+ return results;
30
60
  }
31
61
  /**
32
62
  * The destination-relative file paths {@link copyTree} would produce — same
@@ -34,6 +64,14 @@ export async function copyTree(srcDir, destDir, options = {}) {
34
64
  * composition for collisions before anything is copied.
35
65
  */
36
66
  export async function listTree(srcDir, options = {}) {
67
+ return (await listTreeEntries(srcDir, options)).map((entry) => entry.rel);
68
+ }
69
+ /**
70
+ * {@link listTree} with the source path kept alongside each destination — what
71
+ * an upgrade needs to read the new version of a file, and to name where it
72
+ * lives when it must not write it.
73
+ */
74
+ export async function listTreeEntries(srcDir, options = {}) {
37
75
  const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
38
76
  const files = [];
39
77
  const walk = async (dir, relDir) => {
@@ -47,14 +85,14 @@ export async function listTree(srcDir, options = {}) {
47
85
  await walk(path.join(dir, entry.name), relPath);
48
86
  }
49
87
  else if (entry.isFile()) {
50
- files.push(relPath);
88
+ files.push({ rel: relPath, source: path.join(dir, entry.name) });
51
89
  }
52
90
  }
53
91
  };
54
92
  await walk(srcDir, '');
55
93
  return files;
56
94
  }
57
- async function copyDir(srcDir, destDir, relDir, options) {
95
+ async function collectCopyTasks(srcDir, destDir, relDir, options, files) {
58
96
  const entries = await readdir(srcDir, { withFileTypes: true });
59
97
  for (const entry of entries) {
60
98
  if (options.ignore.has(entry.name))
@@ -65,10 +103,10 @@ async function copyDir(srcDir, destDir, relDir, options) {
65
103
  const relPath = path.join(relDir, entry.name);
66
104
  if (entry.isDirectory()) {
67
105
  await mkdir(destPath, { recursive: true });
68
- await copyDir(srcPath, destPath, relPath, options);
106
+ await collectCopyTasks(srcPath, destPath, relPath, options, files);
69
107
  }
70
108
  else if (entry.isFile()) {
71
- await copyFileEntry(srcPath, destPath, relPath, options);
109
+ files.push({ srcPath, destPath, relPath });
72
110
  }
73
111
  // Symlinks and other special entries are intentionally not copied:
74
112
  // templates are plain trees.
@@ -79,7 +117,7 @@ async function copyFileEntry(srcPath, destPath, relPath, options) {
79
117
  await copyFile(srcPath, destPath); // copyFile preserves the mode by itself
80
118
  return;
81
119
  }
82
- const buffer = await readFile(srcPath);
120
+ const [buffer, { mode }] = await Promise.all([readFile(srcPath), stat(srcPath)]);
83
121
  if (isBinary(buffer)) {
84
122
  await writeFile(destPath, buffer);
85
123
  }
@@ -88,6 +126,5 @@ async function copyFileEntry(srcPath, destPath, relPath, options) {
88
126
  }
89
127
  // writeFile does NOT preserve permissions — restore them (chmod ignores umask),
90
128
  // otherwise executable template files (scripts, hooks) arrive non-executable.
91
- const { mode } = await stat(srcPath);
92
129
  await chmod(destPath, mode & 0o777);
93
130
  }
@@ -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
+ }
@@ -13,6 +13,18 @@
13
13
  */
14
14
  /** Matches the hook file a wired command runs, e.g. `.claude/hooks/guard-bash.mjs`. */
15
15
  const HOOK_REFERENCE = /\.claude\/hooks\/[A-Za-z0-9._-]+\.mjs/;
16
+ /**
17
+ * Every hook file some command in this settings text would run.
18
+ *
19
+ * Deliberately a scan of the raw text rather than a walk of the parsed shape:
20
+ * the caller compares two settings files to ask whether one stops calling a
21
+ * hook the other called, and a shape this module does not understand must not
22
+ * make that question answer "nothing was wired". The same expression decides
23
+ * both, so the filter and the comparison cannot drift apart.
24
+ */
25
+ export function hookFilesReferencedIn(settingsText) {
26
+ return new Set(settingsText.match(new RegExp(HOOK_REFERENCE, 'g')) ?? []);
27
+ }
16
28
  function isRecord(value) {
17
29
  return typeof value === 'object' && value !== null && !Array.isArray(value);
18
30
  }
@@ -0,0 +1,44 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { listTreeEntries, mapConcurrent } 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
+ files.push(...(await mapConcurrent(entries, 16, async (entry) => ({
38
+ rel: entry.rel,
39
+ source: entry.source,
40
+ content: substituteContent(await readFile(entry.source, 'utf8'), ctx),
41
+ }))));
42
+ }
43
+ return files;
44
+ }
@@ -0,0 +1,111 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { isSafeSubstitutionValue } from './safe-path.js';
5
+ /**
6
+ * The install manifest: what this rig installed, at which version, and the
7
+ * hash each file had when it was written.
8
+ *
9
+ * It exists to answer the one hard question an upgrade has — *did the user
10
+ * edit this file?* — with evidence instead of a guess. It is **evidence, not a
11
+ * command**: a file the manifest names but the disk no longer has is reported,
12
+ * never silently restored.
13
+ *
14
+ * It is meant to be committed. Without it in the repository, an upgrade run on
15
+ * CI or on a colleague's machine is blind and falls back to the hash history.
16
+ */
17
+ export const MANIFEST_REL = '.claude/.rig-manifest.json';
18
+ export function sha256(data) {
19
+ return createHash('sha256').update(data).digest('hex');
20
+ }
21
+ function isStringRecord(value) {
22
+ return (typeof value === 'object' &&
23
+ value !== null &&
24
+ !Array.isArray(value) &&
25
+ Object.values(value).every((v) => typeof v === 'string'));
26
+ }
27
+ /**
28
+ * A manifest, or `null` when there is nothing trustworthy to read.
29
+ *
30
+ * The distinction matters: `null` means "no evidence", which sends the upgrade
31
+ * to the hash history. A half-parsed manifest treated as an empty one would
32
+ * claim every file on disk belongs to the user, and upgrade nothing at all.
33
+ */
34
+ export function parseManifest(raw) {
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(raw);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
43
+ return null;
44
+ const m = parsed;
45
+ if (typeof m.version !== 'string')
46
+ return null;
47
+ if (m.kind !== 'create' && m.kind !== 'init')
48
+ return null;
49
+ const project = m.project;
50
+ if (typeof project !== 'object' ||
51
+ project === null ||
52
+ typeof project.name !== 'string' ||
53
+ typeof project.scope !== 'string' ||
54
+ typeof project.region !== 'string') {
55
+ return null;
56
+ }
57
+ // Values, not just types. This file is committed, so it reaches a
58
+ // maintainer's disk through a pull request. A name of `../..` would send a
59
+ // write out of the repository — and `name` and `region` go further than
60
+ // paths: they are substituted into installed **files**, where a quote closes
61
+ // the string literal `stop-flag.mjs` embeds the name in, which `guard-bash`
62
+ // imports on every Bash call. (`scope` reaches no template today and a
63
+ // `stacks` entry names an overlay *directory*.) An unsafe value invalidates
64
+ // the whole manifest rather than being quietly corrected into something
65
+ // plausible.
66
+ //
67
+ // One check, not two: `isSafeSubstitutionValue` is strictly stronger than
68
+ // `isSafeSegment` here — its first character excludes `.`, and its class
69
+ // admits neither `/` nor `\` nor `\0` — so pairing them would leave a second
70
+ // predicate that can never fire, read as cover, and quietly stop being true
71
+ // if either one moves. `isSafeSegment` still guards every path segment at
72
+ // write time, in `resolveInside`.
73
+ if (!isSafeSubstitutionValue(project.name) || !isSafeSubstitutionValue(project.scope)) {
74
+ return null;
75
+ }
76
+ if (project.region !== '' && !isSafeSubstitutionValue(project.region))
77
+ return null;
78
+ if (!Array.isArray(m.stacks) || m.stacks.some((s) => typeof s !== 'string'))
79
+ return null;
80
+ if (m.stacks.some((s) => !isSafeSubstitutionValue(s)))
81
+ return null;
82
+ if (!isStringRecord(m.files))
83
+ return null;
84
+ return {
85
+ version: m.version,
86
+ kind: m.kind,
87
+ project: { name: project.name, scope: project.scope, region: project.region },
88
+ stacks: [...m.stacks],
89
+ files: { ...m.files },
90
+ };
91
+ }
92
+ /** Stable bytes: sorted paths, so a re-run produces no diff of its own. */
93
+ export function serializeManifest(manifest) {
94
+ const files = {};
95
+ for (const rel of Object.keys(manifest.files).sort())
96
+ files[rel] = manifest.files[rel];
97
+ return `${JSON.stringify({ ...manifest, files }, null, 2)}\n`;
98
+ }
99
+ export async function readManifest(repoDir) {
100
+ try {
101
+ return parseManifest(await readFile(path.join(repoDir, ...MANIFEST_REL.split('/')), 'utf8'));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ export async function writeManifest(repoDir, manifest) {
108
+ const dest = path.join(repoDir, ...MANIFEST_REL.split('/'));
109
+ await mkdir(path.dirname(dest), { recursive: true });
110
+ await writeFile(dest, serializeManifest(manifest));
111
+ }
@@ -1,4 +1,24 @@
1
1
  import { createInterface } from 'node:readline';
2
+ /**
3
+ * A yes/no gate before something irreversible. **The default is no**, and a
4
+ * non-interactive caller gets `false` without being asked — the same rule the
5
+ * rest of this CLI follows: never guess for a run that cannot answer.
6
+ *
7
+ * Unlike the target prompt, an unrecognised answer is *not* forgiving: the
8
+ * question is asked before rewriting files in somebody's repository, and "I
9
+ * did not understand you" must not resolve to "go ahead".
10
+ */
11
+ export function promptConfirm(question, streams) {
12
+ if (!streams.isInteractive)
13
+ return Promise.resolve(false);
14
+ const rl = createInterface({ input: streams.input, output: streams.output });
15
+ return new Promise((resolve) => {
16
+ rl.question(`${question} [y/N] `, (answer) => {
17
+ rl.close();
18
+ resolve(/^y(es)?$/i.test(answer.trim()));
19
+ });
20
+ });
21
+ }
2
22
  /**
3
23
  * Pick a target interactively: by number, by name, or Enter for the default.
4
24
  * Anything unrecognised falls back to the default — generation should never
@@ -0,0 +1,71 @@
1
+ import path from 'node:path';
2
+ /**
3
+ * Path safety for values that came from **outside the CLI** — the install
4
+ * manifest is committed to a repository, so it arrives in pull requests like
5
+ * any other file, and its values are substituted into paths.
6
+ *
7
+ * One module owns both halves so they cannot disagree: what may become a path
8
+ * segment, and where a resolved path is allowed to land.
9
+ */
10
+ /** A value that can be substituted into a path without steering it. */
11
+ export function isSafeSegment(value) {
12
+ return (value !== '' &&
13
+ value !== '.' &&
14
+ value !== '..' &&
15
+ !value.includes('/') &&
16
+ !value.includes('\\') &&
17
+ !value.includes('\0'));
18
+ }
19
+ /**
20
+ * A value that can be substituted into an **installed file** without changing
21
+ * what that file means.
22
+ *
23
+ * 🔴 **Why this is not {@link isSafeSegment}, and why widening that one instead
24
+ * would be wrong.** `isSafeSegment` answers "can this steer a path", and it is
25
+ * what {@link resolveInside} holds every path segment to when an upgrade
26
+ * writes — `CLAUDE.md` and `.claude/rules/workflow.md` have to keep passing it,
27
+ * so it cannot become this whitelist. But `project.name` is substituted into
28
+ * `.claude/scripts/stop-flag.mjs` **inside a single-quoted JavaScript string
29
+ * literal**, and `guard-bash` imports that module on every Bash call. A value
30
+ * that steers no path at all still closes that quote: it reaches code
31
+ * execution in the hook process, and it silently disarms the kill switch,
32
+ * because the paths it computes stop pointing at `~/.claude/<name>-loop-STOP`.
33
+ * A brake that looks installed and is not is the worst of the two.
34
+ *
35
+ * So the rule is a whitelist, not a blacklist of the payloads anyone thought
36
+ * of. It costs nothing real: `create` already refuses anything outside this
37
+ * shape, and `projectNameFor` only ever emits `[a-z0-9._-]` — plus a leading
38
+ * `_`, which is why the first character allows it.
39
+ *
40
+ * One legitimate value it does reject on purpose: the **empty** region, which
41
+ * `init` writes to mean "no region". That carve-out belongs to the caller
42
+ * (`parseManifest`), not here — an empty string is exactly what a whitelist of
43
+ * substitutable characters should refuse, and folding "or empty" into this
44
+ * predicate would hand it to `name` and `scope` as well.
45
+ */
46
+ export function isSafeSubstitutionValue(value) {
47
+ return /^[a-z0-9_][a-z0-9._-]*$/.test(value);
48
+ }
49
+ /**
50
+ * `rel` resolved under `root`, or `null` when it would land anywhere else —
51
+ * including an absolute path, an empty path, and the classic sibling
52
+ * (`/tmp/rig` must not contain `/tmp/rig-evil`).
53
+ *
54
+ * This is the containment behind every write an upgrade makes. It is deliberate
55
+ * belt-and-braces: the values that build `rel` are validated where they are
56
+ * parsed, and this refuses the write anyway.
57
+ */
58
+ export function resolveInside(root, rel) {
59
+ if (rel === '' || path.isAbsolute(rel))
60
+ return null;
61
+ const segments = rel.split('/');
62
+ // Refused, not repaired: joining an absolute or `..`-bearing path onto the
63
+ // root would silently turn hostile input into a plausible-looking write.
64
+ if (segments.some((segment) => !isSafeSegment(segment)))
65
+ return null;
66
+ const base = path.resolve(root);
67
+ const dest = path.resolve(base, ...segments);
68
+ if (dest === base)
69
+ return null;
70
+ return dest.startsWith(base + path.sep) ? dest : null;
71
+ }
@@ -5,6 +5,38 @@ export function substituteContent(content, ctx) {
5
5
  .replaceAll('__REGION__', ctx.region)
6
6
  .replaceAll('@app/', `@${ctx.projectScope}/`);
7
7
  }
8
+ /**
9
+ * The inverse of {@link substituteContent}, used **only to recognise** an
10
+ * installed file as a released version of its template: the released bytes
11
+ * carry tokens, the installed bytes carry the project's own name, and without
12
+ * this every token-carrying file (the kill switch, the loop skill, `CLAUDE.md`)
13
+ * would be a permanent conflict on every rig.
14
+ *
15
+ * 🔴 Limits, and both of them fail in the safe direction — an unrecognised file
16
+ * is reported and left alone, never overwritten:
17
+ *
18
+ * - `__PROJECT_SCOPE__` is not reversed. It substitutes to the same string as
19
+ * `__PROJECT_NAME__`, so the two are indistinguishable after the fact; the
20
+ * agent-os layer (the only layer an upgrade touches) uses neither the scope
21
+ * token nor `@app/` in prose, which a template test pins.
22
+ * - A template that contains the project's name as a *literal* reverses into a
23
+ * token that was never there. That costs nothing on its own — recognition
24
+ * offers the untouched bytes as a candidate too, and those still match. It
25
+ * bites only on a file carrying **both** a token and the literal, which then
26
+ * reads as a conflict for that one project.
27
+ */
28
+ export function detokenizeContent(content, ctx) {
29
+ let out = content;
30
+ // scope first: reversing the name first would rewrite `@name/` into
31
+ // `@__PROJECT_NAME__/` and the scope form could never match afterwards
32
+ if (ctx.projectScope !== '')
33
+ out = out.replaceAll(`@${ctx.projectScope}/`, '@app/');
34
+ if (ctx.region !== '')
35
+ out = out.replaceAll(ctx.region, '__REGION__');
36
+ if (ctx.projectName !== '')
37
+ out = out.replaceAll(ctx.projectName, '__PROJECT_NAME__');
38
+ return out;
39
+ }
8
40
  /**
9
41
  * Files that must exist in the generated project under a dotted name, but are
10
42
  * stored un-dotted in the template because `npm publish` strips the dotted