@arjunkhera/atlas 0.4.1 → 0.4.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/README.md CHANGED
@@ -4,7 +4,7 @@ Atlas sets how a repository is worked on. It is one Claude Code plugin with:
4
4
 
5
5
  1. **The lead**, `atlas:lead`: the talk rules, the routing and the command
6
6
  list. It loads on its own in each session of a repo with `atlas.yaml`.
7
- No one has to call it.
7
+ Nobody has to call it.
8
8
  2. **The rulebook**, `atlas:sdlc-task`: start, pause, resume, lock and finish
9
9
  one piece of work. Three more skills: `repo-skills` adds a procedure the
10
10
  right way, `learnings` turns a miss into one fix, and `feedback` files an
package/door/lib/gate.mjs CHANGED
@@ -15,8 +15,9 @@
15
15
  // The plan is built by pure functions, so a test reads it without GitHub. The
16
16
  // gh calls sit in one place, behind `gh`, which a test replaces.
17
17
  import { execFileSync } from 'node:child_process';
18
- import { readFileSync, existsSync } from 'node:fs';
19
- import { join } from 'node:path';
18
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { dirname, join } from 'node:path';
20
21
  import { childEnvironment } from '../../work/lib/child-env.mjs';
21
22
  import { parseYaml } from '../../shape/check.mjs';
22
23
 
@@ -195,9 +196,10 @@ const checkLine = (row) => ` ${row.state === 'green' ? '✓' : '✗'} ${row.sta
195
196
 
196
197
  // `atlas gate ready --pr <n>`: may this pull request merge now? Exit 0 when
197
198
  // every required check is green on its head commit, 1 when not.
198
- export function readyCommand({ repo, pr }, line, gh = ghApi) {
199
+ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
199
200
  if (!pr || pr === true || !/^\d+$/.test(String(pr))) throw new GateError('atlas gate ready needs --pr <number>.');
200
201
  const pull = gh('GET', `repos/${repo}/pulls/${pr}`);
202
+ if (answer && typeof pull?.head?.sha === 'string') answer.sha = pull.head.sha;
201
203
  if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
202
204
  const sha = pull.head.sha;
203
205
  const checks = checksAt(gh, repo, pull.base.ref);
@@ -209,6 +211,24 @@ export function readyCommand({ repo, pr }, line, gh = ghApi) {
209
211
  return ready ? 0 : 1;
210
212
  }
211
213
 
214
+ // The merge door hook (packages/atlas/hooks/door.mjs) reads this file before
215
+ // a merge. It holds the last READY of one pull request: the repo, the number,
216
+ // the head commit and the time. Any answer but READY removes the file, so an
217
+ // old READY never outlives a newer NOT READY. The hook keeps its own copy of
218
+ // the path, because its bytes are pinned.
219
+ export const readyFile = (repo, pr, home = homedir()) => join(home, '.config', 'atlas', 'gate-ready', ...repo.toLowerCase().split('/'), String(pr));
220
+
221
+ export function keepReady({ repo, pr, sha, code, home = homedir(), now = new Date() }) {
222
+ if (repo.split('/').some((part) => /^\.+$/.test(part))) return null;
223
+ const path = readyFile(repo, pr, home);
224
+ if (code !== 0 || !/^[0-9a-f]{40}$/i.test(String(sha ?? ''))) { rmSync(path, { force: true }); return null; }
225
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
226
+ const temp = `${path}.${process.pid}.tmp`;
227
+ writeFileSync(temp, `${JSON.stringify({ repo: repo.toLowerCase(), pr: Number(pr), sha: sha.toLowerCase(), at: now.toISOString() })}\n`, { mode: 0o600 });
228
+ renameSync(temp, path);
229
+ return path;
230
+ }
231
+
212
232
  // `atlas gate audit --since <date>`: each pull request merged since the date
213
233
  // whose required checks were not all green at the moment of the merge.
214
234
  export function auditCommand({ repo, since, commit }, line, gh = ghApi) {
@@ -275,10 +295,22 @@ function commitAudit({ repo, commit }, line, gh) {
275
295
  }
276
296
 
277
297
  // `atlas gate apply|ready|audit`. Returns the exit code.
278
- export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit }, line, gh = ghApi) {
298
+ export function gateCommand({ what, repo, root, dryRun = false, off = false, on = false, pr, since, commit, home = homedir() }, line, gh = ghApi) {
279
299
  if (!['apply', 'ready', 'audit'].includes(what)) throw new GateError(`there is no gate verb "${what ?? ''}". Use apply, ready or audit.`);
280
300
  if (!repo || repo === true || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new GateError(`atlas gate ${what} needs --repo <owner/name>.`);
281
- if (what === 'ready') return readyCommand({ repo, pr }, line, gh);
301
+ if (what === 'ready') {
302
+ const answer = {};
303
+ let code = 1;
304
+ try { code = readyCommand({ repo, pr, answer }, line, gh); } finally {
305
+ if (/^\d+$/.test(String(pr ?? ''))) {
306
+ try {
307
+ const kept = keepReady({ repo, pr, sha: answer.sha, code, home });
308
+ if (kept) line(`The merge door may merge #${pr} at this head for ten minutes.`);
309
+ } catch (error) { line(`The merge door could not keep this answer (${error.code ?? error.message}), so it will refuse the merge. Run ready again outside the sandbox.`); }
310
+ }
311
+ }
312
+ return code;
313
+ }
282
314
  if (what === 'audit') return auditCommand({ repo, since, commit }, line, gh);
283
315
  if (off && on) throw new GateError('pick --off or --on, not both.');
284
316
  const mode = off ? 'off' : on ? 'on' : 'apply';
package/hooks/door.mjs ADDED
@@ -0,0 +1,387 @@
1
+ // The merge door: the second hook Atlas ships. Claude Code runs it before a
2
+ // tool call that can merge, push or write a file. On a plan with no
3
+ // rulesets GitHub blocks nothing, so this hook holds the gate in a session
4
+ // (design docs/design-docs/gate-advisory/, done line 8).
5
+ //
6
+ // It refuses five things, and lets every other call through:
7
+ // 1. A merge or an auto-merge, through a GitHub tool or `gh pr merge`, unless
8
+ // `atlas gate ready` said READY for that pull request in the last ten
9
+ // minutes, and the merge names the same head commit. The auto-merge tool
10
+ // cannot name a head, so it is always refused.
11
+ // 2. A merge through another door: `gh api` on a merge path, a GraphQL merge,
12
+ // `gh pr merge --admin`, or a plain HTTP call to a GitHub merge path.
13
+ // 3. A push to the default branch, a force push, and a push of all branches.
14
+ // 4. A file write to the default branch through a GitHub tool.
15
+ // 5. A change to the READY answers, a change to a Claude settings file by a
16
+ // command or to its hook and plugin switches, and turning the plugin off.
17
+ //
18
+ // Its limits:
19
+ // 1. It makes no network call and starts no program. It reads the tool call,
20
+ // the READY answer file, and the git folder of the repo it pushes from.
21
+ // 2. It writes nothing. It never answers "allow": a call it lets through
22
+ // still goes to the person's own permission rules.
23
+ // 3. It matches text. A command it cannot read as a push or a merge goes
24
+ // through. The push audit job and `atlas gate audit` stay the backstop.
25
+ // 4. ATLAS_HOOKS=off, set outside the session, turns it off.
26
+ import { readFileSync, statSync } from 'node:fs';
27
+ import { homedir } from 'node:os';
28
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ export const FRESH_MS = 10 * 60 * 1000;
32
+ export const DEFAULTS = ['main', 'master'];
33
+ const SHA = /^[0-9a-f]{40}$/i;
34
+
35
+ // The folder where `atlas gate ready` keeps each READY answer.
36
+ export const readyFolder = (home = homedir()) => join(home, '.config', 'atlas', 'gate-ready');
37
+ export const readyPath = (repo, pr, home = homedir()) => join(readyFolder(home), ...String(repo).toLowerCase().split('/'), String(pr));
38
+
39
+ // ------------------------------------------------------------------ the door
40
+
41
+ // One tool call in, one answer out: null lets it through, a string refuses it.
42
+ export function judge(call, { home = homedir(), now = Date.now() } = {}) {
43
+ const tool = String(call?.tool_name ?? '');
44
+ const input = call?.tool_input ?? {};
45
+ const cwd = typeof call?.cwd === 'string' ? call.cwd : process.cwd();
46
+ if (tool === 'Bash') return judgeCommand(String(input.command ?? ''), { cwd, home, now });
47
+ if (['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool)) return judgeWrite(input, { cwd, home });
48
+ if (!tool.startsWith('mcp__')) return null;
49
+ const verb = tool.slice(tool.lastIndexOf('__') + 2);
50
+ const repo = `${input.owner ?? ''}/${input.repo ?? ''}`;
51
+ if (verb === 'merge_pull_request') return needReady({ repo, pr: input.pullNumber, sha: input.expectedHeadSha, home, now, how: 'the expectedHeadSha argument' });
52
+ // This tool cannot name the head, so a later push could merge with no READY.
53
+ if (verb === 'enable_pr_auto_merge') return 'the merge door refuses enable_pr_auto_merge: it cannot name the head that ready read. Run "atlas gate ready", then merge with the head SHA, or use "gh pr merge --auto --match-head-commit <sha>".';
54
+ if (['push_files', 'create_or_update_file', 'delete_file'].includes(verb)) {
55
+ const branch = String(input.branch ?? '').replace(/^refs\/heads\//, '');
56
+ if (!branch) return `the merge door refuses ${verb} with no branch: GitHub would write to the default branch. Name a work branch.`;
57
+ if (defaultsFor(repo, cwd).has(branch)) return `the merge door refuses ${verb} on ${branch}, the default branch. Write to a work branch and open a pull request.`;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // A merge waits for a fresh READY at the same head.
63
+ function needReady({ repo, pr, sha, home, now, how }) {
64
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || repo.split('/').some((part) => /^\.+$/.test(part)) || !/^\d+$/.test(String(pr ?? ''))) return 'the merge door refuses a merge that does not name its repo and pull request number.';
65
+ const run = `atlas gate ready --repo ${repo} --pr ${pr}`;
66
+ let answer;
67
+ try { answer = JSON.parse(readFileSync(readyPath(repo, pr, home), 'utf8')); } catch {
68
+ return `the merge door refuses the merge of ${repo}#${pr}: there is no READY answer for it. Run "${run}" right before the merge.`;
69
+ }
70
+ const at = Date.parse(answer?.at ?? '');
71
+ if (String(answer?.repo ?? '').toLowerCase() !== repo.toLowerCase() || String(answer?.pr) !== String(pr) || !SHA.test(String(answer?.sha ?? '')) || Number.isNaN(at)) {
72
+ return `the merge door refuses the merge of ${repo}#${pr}: its READY answer is not well formed. Run "${run}" again.`;
73
+ }
74
+ if (now - at > FRESH_MS || at - now > 60 * 1000) return `the merge door refuses the merge of ${repo}#${pr}: its READY answer is older than ten minutes. Run "${run}" again.`;
75
+ if (!sha) return `the merge door refuses the merge of ${repo}#${pr}: name the head that ready read (${answer.sha}) in ${how}.`;
76
+ if (String(sha).toLowerCase() !== answer.sha.toLowerCase()) return `the merge door refuses the merge of ${repo}#${pr}: the merge names ${String(sha).slice(0, 7)}, but ready read ${answer.sha.slice(0, 7)}. Run "${run}" on the new head.`;
77
+ return null;
78
+ }
79
+
80
+ // ------------------------------------------------------------- file writes
81
+
82
+ const SWITCH = /ATLAS_HOOKS|disableAllHooks|enabledPlugins|atlas@|"hooks"\s*:/;
83
+ // A command with one of these words can write a file.
84
+ const WRITES = /(>|\b(tee|cp|mv|touch|rm|mkdir|ln|chmod|install|rsync|dd|truncate|node|python3?|perl|ruby|jq)\b|\bsed\b[^|;&]*\s-i)/;
85
+ const SETTINGS = /(^|[\\/])\.claude[\\/]settings(\.local)?\.json$/;
86
+
87
+ function judgeWrite(input, { cwd, home }) {
88
+ const raw = String(input.file_path ?? input.notebook_path ?? '');
89
+ if (!raw) return null;
90
+ const path = resolve(cwd, raw.replace(/^~(?=[\\/])/, home));
91
+ const ready = readyFolder(home);
92
+ if (path === ready || path.startsWith(ready + '/') || path.startsWith(ready + '\\')) return 'the merge door refuses a write to the READY answers (~/.config/atlas/gate-ready): only "atlas gate ready" writes them.';
93
+ if (SETTINGS.test(path)) {
94
+ // The old text counts too: an edit of true to false names no switch word.
95
+ const text = [input.content, input.old_string, input.new_string, ...(input.edits ?? []).flatMap((edit) => [edit?.old_string, edit?.new_string])].filter((part) => typeof part === 'string').join('\n');
96
+ if (SWITCH.test(text)) return 'the merge door refuses a change to the hooks or the plugins in a Claude settings file. Only the person turns the Atlas hooks off, outside the session.';
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // ---------------------------------------------------------------- commands
102
+
103
+ // Split a command line into simple commands, and each one into words. Quotes
104
+ // and backslashes are read the way the shell reads them. A `(` ends a command
105
+ // too. judgeCommand reads the inside of each `$(...)` and backtick pair again,
106
+ // since a shell runs it even inside double quotes.
107
+ export function commandsOf(text) {
108
+ const out = [];
109
+ let words = [];
110
+ let word = null;
111
+ const endWord = () => { if (word !== null) words.push(word); word = null; };
112
+ const endCommand = (op) => { endWord(); if (words.length) out.push({ words, op }); words = []; };
113
+ for (let i = 0; i < text.length; i += 1) {
114
+ const c = text[i];
115
+ if (c === "'") { const end = text.indexOf("'", i + 1); word = (word ?? '') + text.slice(i + 1, end < 0 ? text.length : end); i = end < 0 ? text.length : end; continue; }
116
+ if (c === '"') {
117
+ word = word ?? '';
118
+ for (i += 1; i < text.length && text[i] !== '"'; i += 1) word += text[i] === '\\' && i + 1 < text.length ? text[++i] : text[i];
119
+ continue;
120
+ }
121
+ if (c === '\\' && i + 1 < text.length) { if (text[i + 1] !== '\n') word = (word ?? '') + text[i + 1]; i += 1; continue; }
122
+ if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')') { endCommand(c); continue; }
123
+ if (c === ' ' || c === '\t') { endWord(); continue; }
124
+ if (c === '#' && word === null) { const end = text.indexOf('\n', i); i = end < 0 ? text.length : end - 1; continue; }
125
+ word = (word ?? '') + c;
126
+ }
127
+ endCommand('');
128
+ return out;
129
+ }
130
+
131
+ // Leading words that run the next word as the command. `timeout` also takes
132
+ // a time. A command named by its full path counts by its last name.
133
+ const WRAPPERS = new Set(['sudo', 'command', 'exec', 'nohup', 'time', 'env', 'builtin', 'xargs', 'nice', 'timeout', 'stdbuf']);
134
+ const baseName = (word) => String(word ?? '').replace(/^.*\//, '');
135
+ function commandWords(words) {
136
+ let i = 0;
137
+ for (;;) {
138
+ const word = words[i];
139
+ if (word === undefined) break;
140
+ if (WRAPPERS.has(baseName(word))) {
141
+ i += 1;
142
+ while (words[i]?.startsWith('-')) i += 1;
143
+ if (baseName(word) === 'timeout' && /^\d/.test(words[i] ?? '')) i += 1;
144
+ continue;
145
+ }
146
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) { i += 1; continue; }
147
+ break;
148
+ }
149
+ const out = words.slice(i);
150
+ if (out.length) out[0] = baseName(out[0]);
151
+ return out;
152
+ }
153
+
154
+ // The text a shell reads for `$(...)` and backticks: without single-quoted
155
+ // strings and without here-documents whose end word is quoted.
156
+ const unquoted = (text) => text.replace(/<<-?\s*(['"])(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\n|$)/g, ' ').replace(/'[^']*'/g, "''");
157
+ // The command lines only: a here-document body is data for its command.
158
+ const noHereDocs = (text) => text.replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2(?=\n|$)/g, ' $3');
159
+
160
+ const GH_MERGE_PATH = /(^|\/)repos\/[^/\s]+\/[^/\s]+\/(pulls\/\d+\/merge|merges)\b|(^|\/)pulls\/\d+\/merge\b/;
161
+ const GH_REFS_PATH = /(^|\/)git\/refs\b/;
162
+ const GQL_MERGE = /\b(mergePullRequest|enablePullRequestAutoMerge|mergeBranch|updateRefs?|deleteRef)\b/;
163
+ const HTTP_MERGE = /api\.github\.com\/repos\/[^\s'"]+\/(pulls\/\d+\/merge|merges|git\/refs)/;
164
+
165
+ function judgeCommand(text, { cwd, home, now }) {
166
+ // 5. The READY answers, the key and the hook switch are not the session's
167
+ // to change, by any command.
168
+ if (/gate-ready/.test(text) && WRITES.test(text)) return 'the merge door refuses a command that names the READY answers (~/.config/atlas/gate-ready) and can write a file: only "atlas gate ready" writes them.';
169
+ if (/\.claude[\\/]settings(\.local)?\.json/.test(text) && (SWITCH.test(text) || WRITES.test(text))) return 'the merge door refuses a command that changes a Claude settings file. The person changes the hooks and the plugins, outside the session.';
170
+ if (/\bclaude\s+plugins?\s+(disable|uninstall|remove)\b[^|;&]*\batlas\b/.test(text)) return 'the merge door refuses to turn the Atlas plugin off. The person runs that command, outside the session.';
171
+ if (HTTP_MERGE.test(text) && /\b(curl|wget|fetch|http|requests|urllib)\b/.test(text)) return 'the merge door refuses a merge or a branch write through a plain call to the GitHub API. Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.';
172
+ for (const [, inner] of [...unquoted(text).matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)].map((match) => [match[0], match[1] ?? match[2]])) {
173
+ const why = judgeCommand(inner, { cwd, home, now });
174
+ if (why) return why;
175
+ }
176
+ let here = cwd;
177
+ for (const { words: all } of commandsOf(noHereDocs(text))) {
178
+ const words = commandWords(all);
179
+ if (words[0] === 'cd' && words[1] && words[1] !== '-') { here = resolve(here, words[1].replace(/^~(?=\/|$)/, home)); continue; }
180
+ // A command line handed to a shell or to eval is read the same way.
181
+ const flag = words.findIndex((word, k) => k > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
182
+ const inner = ['sh', 'bash', 'zsh', 'dash', 'ksh'].includes(words[0]) && flag > 0 ? words[flag + 1] : words[0] === 'eval' ? words.slice(1).join(' ') : null;
183
+ if (inner) {
184
+ const why = judgeCommand(inner, { cwd: here, home, now });
185
+ if (why) return why;
186
+ }
187
+ if (words[0] === 'git') {
188
+ const why = judgeGit(words, here, home);
189
+ if (why) return why;
190
+ }
191
+ if (words[0] === 'gh') {
192
+ const why = judgeGh(words, { home, now, cwd: here });
193
+ if (why) return why;
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ // -------------------------------------------------------------------- git
200
+
201
+ function judgeGit(words, cwd, home) {
202
+ let i = 1;
203
+ let dir = cwd;
204
+ while (i < words.length && words[i].startsWith('-')) {
205
+ if (words[i] === '-C') { dir = resolve(dir, String(words[i + 1] ?? '').replace(/^~(?=\/|$)/, home)); i += 2; continue; }
206
+ if (words[i] === '-c') { i += 2; continue; }
207
+ i += 1;
208
+ }
209
+ if (words[i] !== 'push') return null;
210
+ const args = words.slice(i + 1);
211
+ const spots = [];
212
+ let deleting = false;
213
+ for (let k = 0; k < args.length; k += 1) {
214
+ const arg = args[k];
215
+ if (/^\d*[<>]/.test(arg) || /^&>/.test(arg)) { if (/^\d*[<>]+&?$|^&>$/.test(arg)) k += 1; continue; }
216
+ if (arg === '--') { spots.push(...args.slice(k + 1)); break; }
217
+ if (/^--(force|force-with-lease|force-if-includes|mirror)(=|$)/.test(arg)) return `the merge door refuses a force push (${arg.split('=')[0]}). Merge the main branch into your branch, and push again.`;
218
+ if (/^--(all|branches)$/.test(arg)) return `the merge door refuses "git push ${arg}": it pushes the default branch too. Push one work branch.`;
219
+ if (arg === '--delete') { deleting = true; continue; }
220
+ if (/^--(repo|receive-pack|exec|push-option|signed)$/.test(arg) || arg === '-o') { k += 1; continue; }
221
+ if (arg.startsWith('--')) continue;
222
+ if (/^-[A-Za-z]+$/.test(arg)) {
223
+ if (arg.includes('f')) return 'the merge door refuses a force push (-f). Merge the main branch into your branch, and push again.';
224
+ if (arg.includes('d')) deleting = true;
225
+ continue;
226
+ }
227
+ spots.push(arg);
228
+ }
229
+ const git = gitFolder(dir);
230
+ const defaults = new Set([...DEFAULTS, ...(git ? remoteDefaults(git.common) : [])]);
231
+ const current = () => (git ? currentBranch(git.dir) : null);
232
+ const refs = spots.slice(1);
233
+ if (!refs.length) {
234
+ const branch = current();
235
+ if (!deleting && branch && defaults.has(branch)) return `the merge door refuses a push from ${branch}, the default branch. Make a work branch and open a pull request.`;
236
+ return null;
237
+ }
238
+ for (const ref of refs) {
239
+ if (ref.startsWith('+')) return `the merge door refuses a force push ("${ref}"). Merge the main branch into your branch, and push again.`;
240
+ const colon = ref.indexOf(':');
241
+ const source = colon >= 0 ? ref.slice(0, colon) : ref;
242
+ let target = colon >= 0 ? ref.slice(colon + 1) || source : ref;
243
+ if (target === 'HEAD' || target === '@') target = current() ?? '';
244
+ target = target.replace(/^refs\/heads\//, '');
245
+ if (!target || !defaults.has(target)) continue;
246
+ if (deleting || source === '') return `the merge door refuses a push that deletes ${target}, the default branch.`;
247
+ return `the merge door refuses a push to ${target}, the default branch. Push a work branch and open a pull request.`;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ // The git folder of a work tree: { dir, common }. A linked work tree keeps
253
+ // HEAD in its own folder and the remotes in the common one.
254
+ export function gitFolder(start) {
255
+ for (let dir = start; ; dir = dirname(dir)) {
256
+ const dot = join(dir, '.git');
257
+ try {
258
+ if (statSync(dot).isDirectory()) return { dir: dot, common: dot };
259
+ const link = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, 'utf8'))?.[1]?.trim();
260
+ if (link) {
261
+ const own = isAbsolute(link) ? link : resolve(dir, link);
262
+ let common = own;
263
+ try { const shared = readFileSync(join(own, 'commondir'), 'utf8').trim(); common = isAbsolute(shared) ? shared : resolve(own, shared); } catch { /* not a linked work tree */ }
264
+ return { dir: own, common };
265
+ }
266
+ } catch { /* no .git here */ }
267
+ if (dirname(dir) === dir) return null;
268
+ }
269
+ }
270
+
271
+ function currentBranch(gitDir) {
272
+ try { return /^ref:\s*refs\/heads\/(.+)$/m.exec(readFileSync(join(gitDir, 'HEAD'), 'utf8'))?.[1]?.trim() ?? null; } catch { return null; }
273
+ }
274
+
275
+ // The default branch names for one repo: main, master, and the remote HEAD of
276
+ // the session's own copy when its origin is that repo.
277
+ function defaultsFor(repo, cwd) {
278
+ const out = new Set(DEFAULTS);
279
+ const git = gitFolder(cwd);
280
+ if (git && String(originRepo(cwd) ?? '').toLowerCase() === String(repo).toLowerCase()) for (const name of remoteDefaults(git.common)) out.add(name);
281
+ return out;
282
+ }
283
+
284
+ // owner/name of the origin remote, from the git config, when it is on GitHub.
285
+ function originRepo(cwd) {
286
+ const git = gitFolder(cwd);
287
+ if (!git) return null;
288
+ try {
289
+ const config = readFileSync(join(git.common, 'config'), 'utf8');
290
+ const block = /\[remote "origin"\]([^[]*)/.exec(config)?.[1] ?? '';
291
+ const url = /^\s*url\s*=\s*(.+)$/m.exec(block)?.[1]?.trim() ?? '';
292
+ return /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(url)?.[1] ?? null;
293
+ } catch { return null; }
294
+ }
295
+
296
+ function remoteDefaults(common) {
297
+ const out = [];
298
+ for (const remote of ['origin', 'upstream']) {
299
+ try {
300
+ const head = /^ref:\s*refs\/remotes\/[^/]+\/(.+)$/m.exec(readFileSync(join(common, 'refs', 'remotes', remote, 'HEAD'), 'utf8'))?.[1]?.trim();
301
+ if (head) out.push(head);
302
+ } catch { /* no such remote */ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ // --------------------------------------------------------------------- gh
308
+
309
+ function judgeGh(words, { home, now, cwd }) {
310
+ const [, group, verb] = words;
311
+ if (group === 'api') {
312
+ let method = null;
313
+ let path = null;
314
+ let fields = false;
315
+ const values = [];
316
+ const rest = words.slice(2);
317
+ for (let k = 0; k < rest.length; k += 1) {
318
+ const arg = rest[k];
319
+ const [name, joined] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, null];
320
+ const value = () => String(joined !== null ? joined : rest[++k] ?? '');
321
+ if (name === '-X' || name === '--method') { method = value().toUpperCase(); continue; }
322
+ if (/^-X[A-Za-z]+$/.test(name)) { method = name.slice(2).toUpperCase(); continue; }
323
+ if (['-f', '-F', '--field', '--raw-field', '--input'].includes(name)) { fields = true; values.push(value()); continue; }
324
+ if (['-H', '--header', '-q', '--jq', '-t', '--template', '--hostname', '--cache', '-p', '--preview'].includes(name)) { value(); continue; }
325
+ if (name.startsWith('-')) continue;
326
+ if (path === null) path = arg;
327
+ }
328
+ method = method ?? (fields ? 'POST' : 'GET');
329
+ path = String(path ?? '').split('?')[0];
330
+ if (path === 'graphql' || /\/graphql$/.test(path)) return GQL_MERGE.test(values.join(' ')) ? 'the merge door refuses a merge or a branch write through GraphQL. Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.' : null;
331
+ if (method === 'GET') return null;
332
+ const newBranch = method === 'POST' && /(^|\/)git\/refs$/.test(path);
333
+ if (GH_MERGE_PATH.test(path) || (GH_REFS_PATH.test(path) && !newBranch)) return `the merge door refuses "gh api --method ${method} ${path}". Merge with the GitHub merge tool or "gh pr merge", after "atlas gate ready" says READY.`;
334
+ if (/\/contents\//.test(path)) {
335
+ const branch = values.map((each) => /^branch=(.+)$/.exec(each)?.[1]).find(Boolean);
336
+ if (!branch || defaultsFor(/^\/?repos\/([^/]+\/[^/]+)\//.exec(path)?.[1] ?? '', cwd).has(branch)) return 'the merge door refuses a file write through "gh api" to the default branch. Name a work branch with -f branch=<name>.';
337
+ }
338
+ return null;
339
+ }
340
+ if (group !== 'pr' || verb !== 'merge') return null;
341
+ const args = words.slice(3);
342
+ let repo = null;
343
+ let pr = null;
344
+ let sha = null;
345
+ let undo = false;
346
+ for (let k = 0; k < args.length; k += 1) {
347
+ const arg = args[k];
348
+ const [name, joined] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, null];
349
+ const value = () => (joined !== null ? joined : args[++k]);
350
+ if (name === '--admin') return 'the merge door refuses "gh pr merge --admin": it skips the checks.';
351
+ if (name === '--disable-auto') { undo = true; continue; }
352
+ if (name === '-R' || name === '--repo') { repo = value(); continue; }
353
+ if (name === '--match-head-commit') { sha = value(); continue; }
354
+ if (['-b', '--body', '-F', '--body-file', '-t', '--subject', '-A', '--author-email'].includes(name)) { value(); continue; }
355
+ if (name.startsWith('-')) continue;
356
+ const url = /github\.com\/([^/\s]+\/[^/\s]+)\/pull\/(\d+)/.exec(arg);
357
+ if (url) { repo = repo ?? url[1]; pr = url[2]; continue; }
358
+ if (/^#?\d+$/.test(arg)) pr = arg.replace('#', '');
359
+ }
360
+ if (undo) return null;
361
+ repo = repo ?? originRepo(cwd);
362
+ if (!repo || !pr) return 'the merge door refuses "gh pr merge" without a pull request number, and a --repo <owner/name> or a GitHub origin. It cannot match the merge to a READY answer otherwise.';
363
+ repo = repo.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
364
+ return needReady({ repo, pr, sha, home, now, how: '--match-head-commit' });
365
+ }
366
+
367
+ // ------------------------------------------------------------------- main
368
+
369
+ function main(input) {
370
+ if (process.env.ATLAS_HOOKS === 'off') return;
371
+ let call;
372
+ try { call = JSON.parse(input); } catch { return; }
373
+ let why;
374
+ try { why = judge(call); } catch (error) {
375
+ // A merge or push that the door cannot read is refused; anything else goes on.
376
+ if (!/merge|push|create_or_update_file|delete_file/.test(String(call?.tool_name ?? '') + JSON.stringify(call?.tool_input ?? {}))) return;
377
+ why = `the merge door could not read this call (${error.message}), so it refuses it.`;
378
+ }
379
+ if (!why) return;
380
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `Atlas: ${why}` } }));
381
+ }
382
+
383
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
384
+ const chunks = [];
385
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
386
+ process.stdin.on('end', () => { main(Buffer.concat(chunks).toString('utf8')); process.exit(0); });
387
+ }
package/hooks/hooks.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "Loads the Atlas lead at the start of each session in a repo that runs Atlas.",
2
+ "description": "Loads the Atlas lead at the start of each session in a repo that runs Atlas, and keeps the merge door: no merge without a fresh READY, no push to the default branch, no force push.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -11,6 +11,18 @@
11
11
  }
12
12
  ]
13
13
  }
14
+ ],
15
+ "PreToolUse": [
16
+ {
17
+ "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit|mcp__.*(merge_pull_request|enable_pr_auto_merge|push_files|create_or_update_file|delete_file)",
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/door.mjs\"",
22
+ "timeout": 5
23
+ }
24
+ ]
25
+ }
14
26
  ]
15
27
  }
16
28
  }
package/hooks/lead.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # The one hook Atlas ships. At the start of a session it prints the lead
2
+ # The first hook Atlas ships. At the start of a session it prints the lead
3
3
  # skill, so a repo that runs Atlas gets the lead with no step from its people.
4
4
  #
5
5
  # Its limits are the reason the package may ship it:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -153,6 +153,11 @@ A lock is the owner's approval of a short design.
153
153
  the merge call, so a later push cannot slip in.
154
154
  4. Where the verb `apply` set the rulesets, the branch rule holds the
155
155
  merge. Turn on auto-merge there, and never press merge.
156
+ 5. The merge door hook holds this step in the session. It refuses a
157
+ merge with no READY from the last ten minutes, or at another head.
158
+ It refuses a push to the main branch and a force push. It refuses the
159
+ auto-merge tool; `gh pr merge --auto --match-head-commit` names the
160
+ head. When it refuses, do what its reason says. Never work around it.
156
161
 
157
162
  ## pause
158
163
 
@@ -94,8 +94,10 @@ If the tracker cannot be reached, skip this half and say so.
94
94
  "Upkeep <date>: <what changed>". The body lists each change on one line.
95
95
  It ends with "no item: the daily upkeep run".
96
96
  7. Wait for every check on the pull request. When all are green and the
97
- diff changes only index lines and link targets, merge it with a merge
98
- commit. Otherwise leave it open and say why in the report.
97
+ diff changes only index lines and link targets, run `atlas gate` with
98
+ the verb `ready`, `--repo` and `--pr`. On READY, merge it with a merge
99
+ commit at the head SHA that ready read. The merge door hook refuses any
100
+ other merge. Otherwise leave it open and say why in the report.
99
101
 
100
102
  ## The run line
101
103