create-agent-rig 0.3.0 → 0.3.2
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 +137 -0
- package/README.md +11 -3
- package/package.json +1 -1
- package/packages/cli/dist/commands/create.js +5 -3
- package/packages/cli/dist/commands/init.js +73 -18
- package/packages/cli/dist/index.js +11 -1
- package/packages/cli/dist/lib/git-env.js +48 -0
- package/packages/cli/dist/lib/init-settings.js +52 -0
- package/packages/cli/dist/lib/targets.js +15 -1
- package/packages/cli/dist/templates.js +8 -0
- package/scripts/prepare.mjs +54 -17
- package/templates/agent-os/init/CLAUDE.md +139 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +16 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +104 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +12 -11
- package/templates/agent-os/universal/.claude/rules/workflow.md +4 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +32 -4
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +34 -1
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +125 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +6 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +3 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +6 -0
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +125 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +19 -7
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +12 -2
- package/templates/agent-os/universal/CLAUDE.md +12 -3
- package/templates/agent-os/universal/layers.json +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,143 @@ the generator.
|
|
|
7
7
|
Versions are published to npm as [`create-agent-rig`](https://www.npmjs.com/package/create-agent-rig);
|
|
8
8
|
`npx github:serhii-baksheiev/create-agent-rig` keeps working for either path.
|
|
9
9
|
|
|
10
|
+
## 0.3.2
|
|
11
|
+
|
|
12
|
+
Numbered as a patch by the owner's call; the content below is additive, so
|
|
13
|
+
nothing that shipped in 0.3.1 changed shape.
|
|
14
|
+
|
|
15
|
+
A generated project gains two review gates it did not have — one before the work
|
|
16
|
+
starts, one over the prose that instructs it — and three more queue-hygiene
|
|
17
|
+
checks.
|
|
18
|
+
|
|
19
|
+
**Upgrading an existing rig: `init` alone is not enough, and here is exactly
|
|
20
|
+
why.** `create-agent-rig init` installs files that are not there and **keeps
|
|
21
|
+
every file that is** — `--force` replaces `CLAUDE.md` and nothing else
|
|
22
|
+
(`packages/cli/src/commands/init.ts`). Re-running it on a 0.3.1 rig therefore
|
|
23
|
+
delivers the two new files, `.claude/agents/prose-reviewer.md` and
|
|
24
|
+
`.claude/skills/check-premises/SKILL.md`, and **none of their wiring**: the
|
|
25
|
+
skill arrives with nothing calling it, and the agent arrives with `pr-ship`
|
|
26
|
+
never launching it. Six files below changed rather than appeared, and `init`
|
|
27
|
+
will not touch them:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
.claude/agents/code-reviewer.md # the sixth blocking item
|
|
31
|
+
.claude/skills/loop/SKILL.md # calls check-premises, and §3/§6/§8
|
|
32
|
+
.claude/skills/pr-ship/SKILL.md # fans out prose-reviewer, passes the item
|
|
33
|
+
.claude/scripts/queue/core.mjs # the three hygiene checks + Ticket.body
|
|
34
|
+
.claude/scripts/detect-missed-gate.mjs # sees a rulebook outside the repo root
|
|
35
|
+
.claude/hooks/gate-stop-dod.mjs # judges the tree it is in
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Delete those six and re-run `init`, or copy them across by hand. A proper
|
|
39
|
+
upgrade command is queued, not shipped — and until it exists this note tells you
|
|
40
|
+
the manual steps rather than an easy sentence that leaves half the release
|
|
41
|
+
inert. That failure mode is the whole subject of 0.3.1, immediately below.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **`check-premises` skill** — a queue item is a _claim about the code_, written
|
|
46
|
+
by someone who was not reading the code at the time, and nothing downstream
|
|
47
|
+
re-checks it: the failing test is written against the item, the implementation
|
|
48
|
+
against the test, and the reviewer compares the diff to the item. A false
|
|
49
|
+
premise therefore produces work that is correct, tested, reviewed and useless.
|
|
50
|
+
The skill runs between taking the item and the Red step, is read-only by
|
|
51
|
+
frontmatter so it cannot start implementing, and returns `PREMISES HOLD` /
|
|
52
|
+
`PREMISE FALSE` / `UNVERIFIABLE`. Its two boundaries are the point: a false
|
|
53
|
+
load-bearing premise is **stop and report**, never a silent re-aim of the task,
|
|
54
|
+
and only load-bearing claims are checked — an audit is what makes the step
|
|
55
|
+
expensive enough to skip. The `loop` skill calls it, and treats `PREMISE FALSE`
|
|
56
|
+
as a per-task escalation rather than a licence to rewrite the item.
|
|
57
|
+
- **`prose-reviewer` agent** — a fourth gate, read-only. In this layer the prose
|
|
58
|
+
_is_ the implementation: a rule that overstates its own enforcement fails
|
|
59
|
+
exactly like broken code, silently and in the direction of false confidence. It
|
|
60
|
+
blocks on five things — enforcement claimed beyond the mechanism, a dead
|
|
61
|
+
reference, two rules that contradict each other, stated limits gone stale in
|
|
62
|
+
either direction, and domain that must not travel (a vendor name, a host path,
|
|
63
|
+
a tracker key or a credential in a layer meant to be neutral) — and its
|
|
64
|
+
boundary comes before its checklist: it is **not
|
|
65
|
+
a literary editor**, and prose that is merely clumsy is not a finding. Wired
|
|
66
|
+
into the `pr-ship` fan-out and named in both maps.
|
|
67
|
+
- **A sixth blocking item for `code-reviewer`** — a change that contradicts the
|
|
68
|
+
queue item it claims to implement. The instruction is to report the mismatch,
|
|
69
|
+
never to decide which side "must have been meant": a reviewer who reconciles
|
|
70
|
+
the two silently turns a visible mismatch into an invisible one. Where no item
|
|
71
|
+
was supplied, it says so rather than reconstructing one from the PR body —
|
|
72
|
+
which is evidence `autonomy.md` refuses by name. `pr-ship` now passes the item.
|
|
73
|
+
- **Three queue-hygiene checks** — a parent that says it was split up and is
|
|
74
|
+
still open; a dependency line naming a blocker no link carries (worse than a
|
|
75
|
+
stale label: selection reads the item as unblocked); and a document link that
|
|
76
|
+
is broken on its face. The neutral `Ticket` shape gains a **nullable `body`**
|
|
77
|
+
so these live in one pure function instead of once per adapter — and `null`
|
|
78
|
+
means "this adapter cannot answer", never "checked, found nothing".
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
|
|
82
|
+
- **The baseline commit of a generated project could land in the caller's
|
|
83
|
+
repository.** Git hands its hooks an absolute `GIT_DIR`, and the CLI spawned
|
|
84
|
+
git with the environment intact — so `git init` re-initialised the caller's
|
|
85
|
+
repo, `add -A` staged its tree, and the commit landed on whatever branch it had
|
|
86
|
+
checked out, while the generated project got no `.git` at all. A redirected
|
|
87
|
+
`git init` can also flip the caller's repository to `core.bare=true`. The path
|
|
88
|
+
that triggers it is a pre-commit hook running a suite that generates projects —
|
|
89
|
+
which is what made the `worktree-task` skill unusable. Every git call site now
|
|
90
|
+
strips the variables that locate a repository, including the shipped
|
|
91
|
+
`gate-stop-dod` hook (which asked git whether _which_ tree was clean) and
|
|
92
|
+
`preflight`.
|
|
93
|
+
- **The Tier-2 gate sweep could not see a rulebook outside the repository root.**
|
|
94
|
+
`detect-missed-gate` exempts the rulebook from its inert-file rule so a merge
|
|
95
|
+
rewriting the autonomy tiers cannot pass as "just prose" — but the exemption
|
|
96
|
+
was anchored at `CLAUDE.md` / `.claude/`. Any project that vendors, templates
|
|
97
|
+
or nests a rig keeps its rulebook elsewhere, and every `.md` there was dropped
|
|
98
|
+
before the elevated-path test ran. It is now recognised wherever it sits, and
|
|
99
|
+
the sweep's verdict vocabulary knows the words `pr-ship` actually emits.
|
|
100
|
+
|
|
101
|
+
### Deferred, and on what condition
|
|
102
|
+
|
|
103
|
+
Two pieces of the source brief did **not** travel, because shipping an unproven
|
|
104
|
+
gate into other people's projects is worse than not having one:
|
|
105
|
+
|
|
106
|
+
- the queue-closing discipline for blocked dependents — enters when it has been
|
|
107
|
+
merged and used in the project it came from;
|
|
108
|
+
- the clarify-gate (`C-0…C-2`) — enters once that gate has fired at least once
|
|
109
|
+
anywhere. Until then there is nothing to copy but an intention.
|
|
110
|
+
|
|
111
|
+
## 0.3.1
|
|
112
|
+
|
|
113
|
+
`create-agent-rig init` shipped a rig that looked installed and enforced
|
|
114
|
+
nothing. Everything below is that one failure, in its four parts — a repo
|
|
115
|
+
`init`ed with 0.3.0 should be re-run with this version (`--force` to replace the
|
|
116
|
+
CLAUDE.md it wrote).
|
|
117
|
+
|
|
118
|
+
### Fixed
|
|
119
|
+
|
|
120
|
+
- **The hooks are wired.** `init` laid the hook files down and stopped there: no
|
|
121
|
+
`.claude/settings.json` meant `guard-bash`, `block-no-verify`, `gate-stop-dod`
|
|
122
|
+
and `inject-rules` were never called, while the installed `CLAUDE.md` claimed
|
|
123
|
+
they were enforced at the tool layer. The wiring is now _derived_ from the
|
|
124
|
+
shipped settings, so it names exactly the hooks that travelled — never one that
|
|
125
|
+
did not. Where the repo already has a `settings.json`, `init` keeps it and
|
|
126
|
+
prints the entries to merge rather than failing silently.
|
|
127
|
+
- **The kill switch works.** `init` copied templates byte-for-byte, leaving
|
|
128
|
+
`__PROJECT_NAME__` in six places — including `stop-flag.mjs`, so the brake
|
|
129
|
+
looked for `~/.claude/__PROJECT_NAME__-loop-STOP` while the operator, following
|
|
130
|
+
the instructions in the same install, created `~/.claude/<repo>-loop-STOP`. It
|
|
131
|
+
never fired, and never said so.
|
|
132
|
+
- **The installed `CLAUDE.md` describes the repo it landed in.** It used to be
|
|
133
|
+
the generated monorepo's map — `packages/core/`, `apps/web/`, links to an
|
|
134
|
+
`architecture.md` and two guards that `init` deliberately does not install. It
|
|
135
|
+
is now its own document: what was installed, what was not, and that the
|
|
136
|
+
architecture rules are yours to write.
|
|
137
|
+
- **The elevated-path block names paths that exist.** It seeded
|
|
138
|
+
`packages/db/src/` into repos that have no such directory, so the Tier-2 gate
|
|
139
|
+
sweep reported "clean" while looking at nothing.
|
|
140
|
+
|
|
141
|
+
### Added
|
|
142
|
+
|
|
143
|
+
- A template test that fails if anything `init` installs references a `.claude`
|
|
144
|
+
file `init` does not install — the drift that produced three of the four
|
|
145
|
+
findings above, now mechanical.
|
|
146
|
+
|
|
10
147
|
## 0.3.0
|
|
11
148
|
|
|
12
149
|
The factory extraction: a scaffolded project now arrives with a working
|
package/README.md
CHANGED
|
@@ -22,7 +22,15 @@ npx create-agent-rig init --dry-run # print the plan, write nothing
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
`init` drops in the autonomy tiers, stop rules, workflow, and the enforcement
|
|
25
|
-
hooks
|
|
25
|
+
hooks — **wired**, in a `.claude/settings.json` that names exactly the hooks it
|
|
26
|
+
installed — plus a `CLAUDE.md` that describes that rig rather than the generated
|
|
27
|
+
monorepo. It refuses to clobber an existing `CLAUDE.md`; if the repo already has
|
|
28
|
+
a `.claude/settings.json`, it keeps it and prints the entries to merge, because a
|
|
29
|
+
hook nothing calls is not enforcement.
|
|
30
|
+
|
|
31
|
+
Two things it deliberately leaves to you, and says so in the installed
|
|
32
|
+
`CLAUDE.md`: the Definition-of-Done gate has no `dod-checks.json` (it cannot know
|
|
33
|
+
your commands), and the elevated-path list names only what every repo has.
|
|
26
34
|
|
|
27
35
|
## What you get
|
|
28
36
|
|
|
@@ -75,8 +83,8 @@ never labels**, and **the agent never files its own work items**.
|
|
|
75
83
|
|
|
76
84
|
Around all of it: **autonomy tiers** (what an agent does alone / after review /
|
|
77
85
|
never), **stop rules** (three strikes, flaky ≠ retry, session staleness),
|
|
78
|
-
**subagent gates** (`test-writer`, `code-reviewer`, `security-scanner`,
|
|
79
|
-
`cdk-diff-reviewer` on the AWS target), **skills** (`pr-ship` pre-merge gate;
|
|
86
|
+
**subagent gates** (`test-writer`, `code-reviewer`, `security-scanner`,
|
|
87
|
+
`prose-reviewer`, and `cdk-diff-reviewer` on the AWS target), **skills** (`pr-ship` pre-merge gate;
|
|
80
88
|
`loop` queue driver; `worktree-task` for concurrent sessions; `new-invariant`, a
|
|
81
89
|
generator for the invariant→hook→test pattern; `post-deploy-verify` and
|
|
82
90
|
`ro-debug` on the AWS target), and a one-page `CLAUDE.md` map a fresh session
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { promisify } from 'node:util';
|
|
|
5
5
|
import { copyTree, listTree } from '../lib/copy-tree.js';
|
|
6
6
|
import { ALLOWED_OVERWRITES, detectCollisions } from '../lib/composition.js';
|
|
7
7
|
import { substituteContent, substituteFileName } from '../lib/substitute.js';
|
|
8
|
+
import { gitEnv } from '../lib/git-env.js';
|
|
8
9
|
import { DEFAULT_TARGET, TARGETS, TARGET_NAMES } from '../lib/targets.js';
|
|
9
10
|
import { agentOsStackDir, agentOsUniversalDir, skeletonDir } from '../templates.js';
|
|
10
11
|
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
@@ -72,9 +73,10 @@ async function initGitBaseline(projectDir) {
|
|
|
72
73
|
// .git/objects/pack after we return — a non-deterministic tail that races any
|
|
73
74
|
// caller cleaning up the directory, and pointless work on a one-commit repo.
|
|
74
75
|
const quiet = ['-c', 'gc.auto=0', '-c', 'maintenance.auto=false'];
|
|
76
|
+
const where = { cwd: projectDir, env: gitEnv() };
|
|
75
77
|
try {
|
|
76
|
-
await run('git', [...quiet, 'init', '--quiet'],
|
|
77
|
-
await run('git', [...quiet, 'add', '-A'],
|
|
78
|
+
await run('git', [...quiet, 'init', '--quiet'], where);
|
|
79
|
+
await run('git', [...quiet, 'add', '-A'], where);
|
|
78
80
|
// Explicit identity: the baseline must commit even where git has no
|
|
79
81
|
// global user configured (fresh machines, CI). --no-verify here shields
|
|
80
82
|
// the baseline from the USER'S global hooks only — the generated
|
|
@@ -90,7 +92,7 @@ async function initGitBaseline(projectDir) {
|
|
|
90
92
|
'--no-verify',
|
|
91
93
|
'-m',
|
|
92
94
|
'Pristine template (create-agent-rig)',
|
|
93
|
-
],
|
|
95
|
+
], where);
|
|
94
96
|
}
|
|
95
97
|
catch {
|
|
96
98
|
// git missing or unusable — generation never fails on this.
|
|
@@ -1,37 +1,93 @@
|
|
|
1
1
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { settingsForInstalledHooks } from '../lib/init-settings.js';
|
|
4
|
+
import { substituteContent } from '../lib/substitute.js';
|
|
5
|
+
import { agentOsInitDir, agentOsUniversalDir } from '../templates.js';
|
|
4
6
|
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
5
7
|
export class InitError extends Error {
|
|
6
8
|
}
|
|
9
|
+
const SETTINGS = '.claude/settings.json';
|
|
7
10
|
async function loadManifest() {
|
|
8
11
|
const raw = await readFile(path.join(agentOsUniversalDir(), 'layers.json'), 'utf8');
|
|
9
12
|
return JSON.parse(raw);
|
|
10
13
|
}
|
|
14
|
+
async function exists(p) {
|
|
15
|
+
try {
|
|
16
|
+
await access(p);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The name this repo is known by inside the rig. It ends up in a *filename* —
|
|
25
|
+
* `~/.claude/<name>-loop-STOP`, the kill switch — so it is reduced to
|
|
26
|
+
* characters an operator can type into a shell without quoting.
|
|
27
|
+
*/
|
|
28
|
+
export function projectNameFor(repoDir) {
|
|
29
|
+
const base = path.basename(path.resolve(repoDir));
|
|
30
|
+
const slug = base
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
33
|
+
.replace(/^[-.]+|[-.]+$/g, '');
|
|
34
|
+
return slug === '' ? 'project' : slug;
|
|
35
|
+
}
|
|
11
36
|
/**
|
|
12
37
|
* `init` installs only the PROCESS layer (hooks-and-reach brief §3/§4): rules
|
|
13
38
|
* that assume nothing about the codebase shape. Architecture rules reference
|
|
14
39
|
* `packages/core` and friends — installing them into an arbitrary repo would
|
|
15
40
|
* describe a structure that does not exist, which is worse than no rule.
|
|
16
41
|
*
|
|
17
|
-
*
|
|
42
|
+
* It also installs two things the process manifest does not name, because both
|
|
43
|
+
* are meaningless in the generated shape and load-bearing here:
|
|
44
|
+
*
|
|
45
|
+
* - `CLAUDE.md` — the map, taken from the init override layer, which describes
|
|
46
|
+
* the rig this command installs rather than the generated monorepo;
|
|
47
|
+
* - `.claude/settings.json` — the wiring, derived from the shipped settings so
|
|
48
|
+
* it names exactly the hooks that travelled.
|
|
18
49
|
*/
|
|
19
|
-
async function
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
export async function initManifest() {
|
|
51
|
+
const manifest = await loadManifest();
|
|
52
|
+
const universal = agentOsUniversalDir();
|
|
53
|
+
const override = agentOsInitDir();
|
|
54
|
+
const files = [];
|
|
55
|
+
for (const rel of [...manifest.process, 'CLAUDE.md']) {
|
|
56
|
+
const overridden = path.join(override, rel);
|
|
57
|
+
files.push({
|
|
58
|
+
rel,
|
|
59
|
+
source: (await exists(overridden)) ? overridden : path.join(universal, rel),
|
|
60
|
+
});
|
|
27
61
|
}
|
|
28
|
-
|
|
29
|
-
|
|
62
|
+
files.push({ rel: SETTINGS, source: null });
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Exactly the bytes `init` would write, keyed by destination path — the single
|
|
67
|
+
* source the plan, the install and the template tests all read.
|
|
68
|
+
*
|
|
69
|
+
* Every file the process layer carries is text (asserted by a template test),
|
|
70
|
+
* so substitution can be applied unconditionally: an unsubstituted
|
|
71
|
+
* `__PROJECT_NAME__` in `stop-flag.mjs` is a kill switch that silently never
|
|
72
|
+
* fires.
|
|
73
|
+
*/
|
|
74
|
+
export async function initFileContents(repoDir) {
|
|
75
|
+
const projectName = projectNameFor(repoDir);
|
|
76
|
+
const ctx = { projectName, projectScope: projectName, region: '' };
|
|
77
|
+
const files = await initManifest();
|
|
78
|
+
const contents = new Map();
|
|
79
|
+
for (const { rel, source } of files) {
|
|
80
|
+
if (source === null)
|
|
81
|
+
continue;
|
|
82
|
+
contents.set(rel, substituteContent(await readFile(source, 'utf8'), ctx));
|
|
30
83
|
}
|
|
84
|
+
const installedHooks = new Set(files.map((f) => f.rel).filter((rel) => rel.startsWith('.claude/hooks/')));
|
|
85
|
+
const shipped = JSON.parse(await readFile(path.join(agentOsUniversalDir(), SETTINGS), 'utf8'));
|
|
86
|
+
contents.set(SETTINGS, `${JSON.stringify(settingsForInstalledHooks(shipped, installedHooks), null, 2)}\n`);
|
|
87
|
+
return contents;
|
|
31
88
|
}
|
|
32
89
|
export async function planInit(repoDir) {
|
|
33
|
-
const
|
|
34
|
-
const files = await processFiles(manifest);
|
|
90
|
+
const files = (await initManifest()).map((f) => f.rel);
|
|
35
91
|
const conflicts = [];
|
|
36
92
|
for (const rel of files) {
|
|
37
93
|
if (await exists(path.join(repoDir, rel)))
|
|
@@ -40,9 +96,7 @@ export async function planInit(repoDir) {
|
|
|
40
96
|
return { files: files.map((p) => ({ path: p })), conflicts };
|
|
41
97
|
}
|
|
42
98
|
export async function initProject(repoDir, options) {
|
|
43
|
-
const
|
|
44
|
-
const files = await processFiles(manifest);
|
|
45
|
-
const universal = agentOsUniversalDir();
|
|
99
|
+
const files = (await initManifest()).map((f) => f.rel);
|
|
46
100
|
// Refuse to clobber an existing CLAUDE.md unless forced — init edits
|
|
47
101
|
// someone's working repository (brief §4, non-negotiable).
|
|
48
102
|
if (!options.force && files.includes('CLAUDE.md')) {
|
|
@@ -51,6 +105,7 @@ export async function initProject(repoDir, options) {
|
|
|
51
105
|
'Merge the agent-os map in by hand, or re-run with --force to replace it.');
|
|
52
106
|
}
|
|
53
107
|
}
|
|
108
|
+
const contents = await initFileContents(repoDir);
|
|
54
109
|
const written = [];
|
|
55
110
|
const skipped = [];
|
|
56
111
|
const plannedCount = files.length;
|
|
@@ -65,7 +120,7 @@ export async function initProject(repoDir, options) {
|
|
|
65
120
|
if (options.dryRun)
|
|
66
121
|
continue;
|
|
67
122
|
await mkdir(path.dirname(dest), { recursive: true });
|
|
68
|
-
await writeFile(dest,
|
|
123
|
+
await writeFile(dest, contents.get(rel) ?? '');
|
|
69
124
|
written.push(rel);
|
|
70
125
|
}
|
|
71
126
|
return { written, skipped, plannedCount };
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { parseArgs } from 'node:util';
|
|
6
6
|
import { CreateError, createProject } from './commands/create.js';
|
|
7
|
-
import { InitError, initProject, planInit } from './commands/init.js';
|
|
7
|
+
import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
|
|
8
8
|
import { makePalette } from './lib/colors.js';
|
|
9
9
|
import { promptTarget } from './lib/prompts.js';
|
|
10
10
|
import { collectGovernance, renderSummary } from './lib/summary.js';
|
|
@@ -65,6 +65,16 @@ async function runInit(rawArgs) {
|
|
|
65
65
|
process.stdout.write(`\nInstalled ${result.written.length} files` +
|
|
66
66
|
(result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
|
|
67
67
|
'.\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` +
|
|
74
|
+
` Until you merge these entries into it, nothing enforces the rules:\n\n` +
|
|
75
|
+
wiring.replace(/^/gm, ' ') +
|
|
76
|
+
'\n');
|
|
77
|
+
}
|
|
68
78
|
return 0;
|
|
69
79
|
}
|
|
70
80
|
async function main() {
|
|
@@ -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,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `init` installs the PROCESS layer only — a subset of the hooks the generated
|
|
3
|
+
* shape gets. The wiring it writes has to match that subset exactly:
|
|
4
|
+
*
|
|
5
|
+
* - wiring a hook file that was not installed makes every matching tool call
|
|
6
|
+
* fail on a missing module;
|
|
7
|
+
* - wiring nothing at all is worse and quieter — the hooks sit on disk, the
|
|
8
|
+
* rules claim they are enforced, and nothing ever calls them.
|
|
9
|
+
*
|
|
10
|
+
* So the wiring is *derived* from the shipped settings.json rather than
|
|
11
|
+
* maintained as a second copy: add a process hook and wire it once, upstream,
|
|
12
|
+
* and init picks it up.
|
|
13
|
+
*/
|
|
14
|
+
/** Matches the hook file a wired command runs, e.g. `.claude/hooks/guard-bash.mjs`. */
|
|
15
|
+
const HOOK_REFERENCE = /\.claude\/hooks\/[A-Za-z0-9._-]+\.mjs/;
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
/** An entry survives unless it names a hook file that is not installed. */
|
|
20
|
+
function keepEntry(entry, installed) {
|
|
21
|
+
if (!isRecord(entry) || typeof entry.command !== 'string')
|
|
22
|
+
return true;
|
|
23
|
+
const referenced = HOOK_REFERENCE.exec(entry.command);
|
|
24
|
+
return referenced === null || installed.has(referenced[0]);
|
|
25
|
+
}
|
|
26
|
+
/** A group survives only with at least one entry left — never as an empty shell. */
|
|
27
|
+
function keepGroup(group, installed) {
|
|
28
|
+
if (!isRecord(group) || !Array.isArray(group.hooks))
|
|
29
|
+
return group;
|
|
30
|
+
const hooks = group.hooks.filter((entry) => keepEntry(entry, installed));
|
|
31
|
+
return hooks.length === 0 ? null : { ...group, hooks };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The shipped settings, narrowed to the hooks actually installed. Shapes this
|
|
35
|
+
* function does not understand are passed through untouched — it filters, it
|
|
36
|
+
* never rewrites.
|
|
37
|
+
*/
|
|
38
|
+
export function settingsForInstalledHooks(settings, installed) {
|
|
39
|
+
if (!isRecord(settings) || !isRecord(settings.hooks))
|
|
40
|
+
return settings;
|
|
41
|
+
const events = {};
|
|
42
|
+
for (const [event, groups] of Object.entries(settings.hooks)) {
|
|
43
|
+
if (!Array.isArray(groups)) {
|
|
44
|
+
events[event] = groups;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const kept = groups.map((group) => keepGroup(group, installed)).filter((g) => g !== null);
|
|
48
|
+
if (kept.length > 0)
|
|
49
|
+
events[event] = kept;
|
|
50
|
+
}
|
|
51
|
+
return { ...settings, hooks: events };
|
|
52
|
+
}
|
|
@@ -10,5 +10,19 @@ export const TARGETS = {
|
|
|
10
10
|
},
|
|
11
11
|
};
|
|
12
12
|
export const TARGET_NAMES = Object.keys(TARGETS);
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* The pre-selected entry in the interactive menu, and the API-level default.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 **Not the fallback for a non-interactive run.** A run with no TTY and no
|
|
17
|
+
* `--target` is *refused* (`index.ts`) — never prompt into a pipe, and never
|
|
18
|
+
* guess a whole project shape for a script that did not say. This constant is
|
|
19
|
+
* what `Enter`, an out-of-range number or an unrecognised name resolve to at the
|
|
20
|
+
* prompt (`prompts.ts`), plus what `createProject` uses when called as a library
|
|
21
|
+
* with no target.
|
|
22
|
+
*
|
|
23
|
+
* The comment this replaces said "one implicit target", which was stale from
|
|
24
|
+
* when there was one. Its first correction claimed the CLI defaults silently in
|
|
25
|
+
* CI — the opposite of what it does, in the file a maintainer would open to
|
|
26
|
+
* check exactly that.
|
|
27
|
+
*/
|
|
14
28
|
export const DEFAULT_TARGET = 'aws-serverless';
|
|
@@ -19,3 +19,11 @@ export function agentOsUniversalDir() {
|
|
|
19
19
|
export function agentOsStackDir(stack) {
|
|
20
20
|
return path.join(templatesRoot(), 'agent-os', 'stack', stack);
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Overrides `init` applies on top of the universal layer. A file here replaces
|
|
24
|
+
* its universal namesake when the rig is installed into an existing repo whose
|
|
25
|
+
* shape the generator knows nothing about. `create` never reads this directory.
|
|
26
|
+
*/
|
|
27
|
+
export function agentOsInitDir() {
|
|
28
|
+
return path.join(templatesRoot(), 'agent-os', 'init');
|
|
29
|
+
}
|
package/scripts/prepare.mjs
CHANGED
|
@@ -5,25 +5,62 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
10
|
const root = path.dirname(fileURLToPath(import.meta.url)) + '/..';
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The environment `git config` runs under, minus anything that could point it
|
|
14
|
+
* at another repository's config file.
|
|
15
|
+
*
|
|
16
|
+
* This script runs from `pnpm install`, and a pre-commit hook can reach an
|
|
17
|
+
* install — a hook-started process inherits an absolute `GIT_DIR`, and
|
|
18
|
+
* `core.hooksPath` would then be written into somebody ELSE's repository.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ The canonical list lives in `packages/cli/src/lib/git-env.ts` and this is a
|
|
21
|
+
* deliberate second copy, because `prepare` step 1 runs *before* step 2 builds
|
|
22
|
+
* the TypeScript that would make it importable. The subset is not a smaller
|
|
23
|
+
* opinion about that list: `git config --local` resolves its target file only
|
|
24
|
+
* through `GIT_DIR`/`GIT_COMMON_DIR`, and `GIT_CONFIG` names a config file
|
|
25
|
+
* outright. `GIT_WORK_TREE` and `GIT_INDEX_FILE` cannot move it, so stripping
|
|
26
|
+
* them here would be noise.
|
|
27
|
+
*
|
|
28
|
+
* Exported so the behaviour is testable — importing this module must not build
|
|
29
|
+
* anything, hence the entry-point guard at the bottom.
|
|
30
|
+
*/
|
|
31
|
+
export const gitConfigEnv = (env = process.env) => {
|
|
32
|
+
const sanitised = { ...env };
|
|
33
|
+
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_CONFIG']) delete sanitised[key];
|
|
34
|
+
return sanitised;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function main() {
|
|
38
|
+
// 1. Wire up the pre-commit hook when working inside the git checkout.
|
|
39
|
+
if (existsSync(path.join(root, '.git'))) {
|
|
40
|
+
spawnSync('git', ['config', 'core.hooksPath', '.husky'], {
|
|
41
|
+
cwd: root,
|
|
42
|
+
env: gitConfigEnv(),
|
|
43
|
+
stdio: 'inherit',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Build the CLI so the `bin` entry exists (required for git/tarball installs).
|
|
48
|
+
const require = createRequire(import.meta.url);
|
|
49
|
+
const tscPath = path.join(
|
|
50
|
+
path.dirname(require.resolve('typescript/package.json')),
|
|
51
|
+
'lib',
|
|
52
|
+
'tsc.js',
|
|
53
|
+
);
|
|
54
|
+
const result = spawnSync(
|
|
55
|
+
process.execPath,
|
|
56
|
+
[tscPath, '-p', path.join(root, 'packages/cli/tsconfig.build.json')],
|
|
57
|
+
{ cwd: root, stdio: 'inherit' },
|
|
58
|
+
);
|
|
59
|
+
process.exit(result.status ?? 1);
|
|
15
60
|
}
|
|
16
61
|
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'tsc.js',
|
|
23
|
-
);
|
|
24
|
-
const result = spawnSync(
|
|
25
|
-
process.execPath,
|
|
26
|
-
[tscPath, '-p', path.join(root, 'packages/cli/tsconfig.build.json')],
|
|
27
|
-
{ cwd: root, stdio: 'inherit' },
|
|
28
|
-
);
|
|
29
|
-
process.exit(result.status ?? 1);
|
|
62
|
+
// Run only when executed, never when imported: a test that imports this module
|
|
63
|
+
// to check one exported function must not trigger a build or touch git config.
|
|
64
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
65
|
+
main();
|
|
66
|
+
}
|