atris 3.74.3 → 3.74.5

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 (117) hide show
  1. package/FOR_AGENTS.md +12 -12
  2. package/atris/AGENTS.md +7 -7
  3. package/atris/CLAUDE.md +10 -10
  4. package/atris/policies/atris-backend.md +1 -1
  5. package/atris/policies/atris-design.md +71 -71
  6. package/atris/policies/design-seed.md +28 -28
  7. package/atris/policies/writing.md +1 -1
  8. package/atris/skills/apps/SKILL.md +3 -3
  9. package/atris/skills/atris-feedback/SKILL.md +2 -2
  10. package/atris/skills/autopilot/SKILL.md +10 -10
  11. package/atris/skills/blocks/SKILL.md +16 -16
  12. package/atris/skills/calendar/SKILL.md +1 -1
  13. package/atris/skills/clawhub/atris/SKILL.md +4 -4
  14. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
  15. package/atris/skills/create-app/SKILL.md +18 -18
  16. package/atris/skills/create-member/SKILL.md +11 -11
  17. package/atris/skills/design/SKILL.md +1 -1
  18. package/atris/skills/drive/SKILL.md +25 -25
  19. package/atris/skills/email-agent/SKILL.md +11 -11
  20. package/atris/skills/endgame/SKILL.md +33 -33
  21. package/atris/skills/magic-inbox/SKILL.md +15 -15
  22. package/atris/skills/notion/SKILL.md +1 -1
  23. package/atris/skills/ramp/SKILL.md +7 -7
  24. package/atris/skills/research-search/SKILL.md +7 -7
  25. package/atris/skills/slides/SKILL.md +7 -7
  26. package/atris/skills/tidy/SKILL.md +11 -11
  27. package/atris/skills/wake/SKILL.md +11 -11
  28. package/atris/skills/writing/SKILL.md +5 -5
  29. package/atris/team/_template/SOUL.md +3 -3
  30. package/atris/team/brainstormer/MEMBER.md +2 -2
  31. package/atris/team/launcher/MEMBER.md +2 -2
  32. package/atris/team/mission-lead/MISSION.md +7 -7
  33. package/atris/team/opus-overnight/MISSION.md +16 -16
  34. package/atris/team/opus-overnight/STEERING.md +5 -5
  35. package/atris.md +9 -0
  36. package/bin/atris.js +16 -3
  37. package/commands/campaign.js +17 -2
  38. package/commands/card.js +19 -12
  39. package/commands/engine.js +92 -17
  40. package/commands/gm.js +30 -0
  41. package/commands/idea.js +263 -0
  42. package/commands/init.js +11 -2
  43. package/commands/land.js +39 -18
  44. package/commands/mission.js +4 -3
  45. package/commands/ninety-nine.js +288 -0
  46. package/commands/one-lap.js +46 -27
  47. package/commands/reel.js +12 -7
  48. package/commands/sync.js +10 -2
  49. package/lib/campaign-keep.js +23 -8
  50. package/lib/campaign-loop.js +24 -5
  51. package/lib/claude-boot-block.js +7 -4
  52. package/lib/engine-registry.js +139 -1
  53. package/lib/fleet.js +11 -2
  54. package/lib/gm-bet.js +338 -34
  55. package/lib/gm-phone-page.js +325 -33
  56. package/lib/gm-phone.js +5 -1
  57. package/lib/gm-season.js +14 -0
  58. package/lib/headless-chrome.js +39 -0
  59. package/lib/idea-gate.js +33 -0
  60. package/lib/idea-inbox.js +220 -0
  61. package/lib/idea-ledger.js +196 -0
  62. package/lib/idea-tick.js +384 -0
  63. package/lib/known-commands.js +1 -1
  64. package/lib/member-engine.js +1 -0
  65. package/lib/ninety-nine-sync.js +156 -0
  66. package/lib/ninety-nine.js +247 -0
  67. package/lib/opening-lines.js +94 -0
  68. package/lib/roster-markdown.js +39 -1
  69. package/lib/roster-models.js +1 -1
  70. package/lib/roster-swap.js +353 -0
  71. package/lib/spawn-env.js +29 -2
  72. package/lib/wish-audit.js +14 -0
  73. package/lib/yes-lock.js +358 -241
  74. package/lib/zen-apply.js +5 -3
  75. package/lib/zen.js +13 -1
  76. package/package.json +1 -1
  77. package/templates/business-starter/CLAUDE.md +7 -7
  78. package/templates/business-starter/MAP.md +1 -1
  79. package/templates/business-starter/TODO.md +1 -1
  80. package/templates/business-starter/atris.md +2 -2
  81. package/templates/business-starter/context/README.md +6 -6
  82. package/templates/business-starter/context/live-workspace.md +1 -1
  83. package/templates/business-starter/goals.md +1 -1
  84. package/templates/business-starter/instructions.md +1 -1
  85. package/templates/business-starter/memory.md +4 -4
  86. package/templates/business-starter/persona.md +3 -3
  87. package/templates/business-starter/policies/LESSONS.md +1 -1
  88. package/templates/business-starter/reports/README.md +1 -1
  89. package/templates/business-starter/skills/README.md +1 -1
  90. package/templates/business-starter/team/README.md +1 -1
  91. package/templates/business-starter/team/START_HERE.md +1 -1
  92. package/templates/business-starter/team/_template/SOUL.md +3 -3
  93. package/templates/business-starter/team/comms/SOUL.md +2 -2
  94. package/templates/business-starter/team/ops/SOUL.md +4 -4
  95. package/templates/business-starter/team/research/SOUL.md +3 -3
  96. package/templates/business-starter/wiki/index.md +1 -1
  97. package/templates/research-canonical/CLAUDE.md +7 -7
  98. package/templates/research-canonical/MAP.md +1 -1
  99. package/templates/research-canonical/TODO.md +1 -1
  100. package/templates/research-canonical/atris.md +2 -2
  101. package/templates/research-canonical/context/README.md +8 -8
  102. package/templates/research-canonical/context/live-workspace.md +1 -1
  103. package/templates/research-canonical/goals.md +2 -2
  104. package/templates/research-canonical/instructions.md +1 -1
  105. package/templates/research-canonical/memory.md +4 -4
  106. package/templates/research-canonical/persona.md +1 -1
  107. package/templates/research-canonical/policies/LESSONS.md +1 -1
  108. package/templates/research-canonical/reports/README.md +1 -1
  109. package/templates/research-canonical/skills/README.md +1 -1
  110. package/templates/research-canonical/team/README.md +1 -1
  111. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  112. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  113. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  114. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  115. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  116. package/templates/research-canonical/wiki/index.md +1 -1
  117. package/utils/auth.js +1 -0
package/lib/yes-lock.js CHANGED
@@ -64,6 +64,8 @@ function handoffParent(stateDir, missionId) {
64
64
 
65
65
  // Yes cards for held handed-off runs: every lock record under the handoff
66
66
  // seal, asked and not yet answered, with the job it was handed off from.
67
+ // The card says who wants what to go where, in plain words; the raw command
68
+ // stays in the lock record (command).
67
69
  function handoffYesCards(stateDir) {
68
70
  let files = [];
69
71
  try { files = fs.readdirSync(path.join(stateDir, 'yes-locks')).filter(f => f.endsWith('.json')); } catch { return []; }
@@ -73,12 +75,180 @@ function handoffYesCards(stateDir) {
73
75
  try { data = JSON.parse(fs.readFileSync(path.join(stateDir, 'yes-locks', name), 'utf8')); } catch { continue; }
74
76
  if (!data || !data.mission_id || !Array.isArray(data.locks) || !data.locks.length || !data.locks.every(l => l && l.text === HANDOFF_SEAL_TEXT)) continue;
75
77
  for (const lock of askedYesLocks(stateDir, data.mission_id)) {
76
- cards.push({ mission_id: data.mission_id, parent_mission_id: data.parent_mission_id || null, step: lock.step, hash: lock.hash, command: lock.held_command || null, ask: handoffAsk(lock.held_command || 'run a held command') });
78
+ const who = data.owner || 'a handed-off job';
79
+ cards.push({ mission_id: data.mission_id, parent_mission_id: data.parent_mission_id || null, who, step: lock.step, hash: lock.hash, command: lock.held_command || null, ask: `${who} wants to ${plainWhat(lock.held_key, who)}` });
77
80
  }
78
81
  }
79
82
  return cards;
80
83
  }
81
84
 
85
+ // What a held command would do, for the owner's yes card: what goes out and
86
+ // where, with no flags, settings, or paths. key is the request's record of
87
+ // the exact command ({ tool, args, cwd }).
88
+ function plainWhat(key, who = '') {
89
+ let cmd = {};
90
+ try { cmd = JSON.parse(key); } catch { return 'run a command the lock holds'; }
91
+ const name = path.basename(String(cmd.tool || ''));
92
+ const args = (Array.isArray(cmd.args) ? cmd.args : []).map(String);
93
+ const cwd = cmd.cwd || process.cwd();
94
+ if (name === 'git') {
95
+ const p = parseGit(args);
96
+ if (p.sub === 'push') return plainPush(p, cwd, who);
97
+ if (p.sub === 'send-pack' || p.sub === 'http-push') return 'push to another computer';
98
+ return 'use a setting that could let a push skip the lock';
99
+ }
100
+ // gh: said exactly, verb and target, or else where it goes and the words
101
+ // it names; a delete never reads as a release
102
+ if (name === 'gh') return describeGh(args) || `run a command that changes GitHub: "${args.join(' ')}"`;
103
+ if (name === 'npm' || name === 'yarn' || name === 'pnpm') {
104
+ const [sub = '', folder = ''] = packageWords(args).words.map(String);
105
+ // the package it names: a folder given to publish, else this folder
106
+ let pkg = 'a package';
107
+ for (const dir of [folder && path.resolve(cwd, folder), cwd].filter(Boolean)) {
108
+ try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name || pkg; break; } catch { /* not a package folder */ }
109
+ }
110
+ if (PKG_PUBLISH.has(sub)) return sub.startsWith('dist-tag') ? `change which version of ${pkg} npm installs` : `publish ${pkg} to npm`;
111
+ if (sub === 'version') return `make a new version of ${pkg}`;
112
+ return `run ${name} in a way the lock does not know`;
113
+ }
114
+ if (name === 'npx' || name === 'pnpx' || name === 'bunx') return 'run a tool downloaded from npm';
115
+ if (name === 'atris') {
116
+ const [verb = ''] = positionals(args, new Set(['--account', '--as', '--from', '--to', '--channel']));
117
+ const read = readAtris(args);
118
+ if (read.cats.includes('spend')) return `spend Atris credits on ${verb}`;
119
+ // one phrase, the weightiest kind it touches
120
+ const phrase = { deploy: 'deploy a site', publish: 'publish something', merge: 'land work on master', tag: 'push a version tag', post: 'post in public', send: 'send a message' };
121
+ const kind = ['deploy', 'publish', 'merge', 'tag', 'post', 'send'].find(c => read.cats.includes(c));
122
+ if (kind) return phrase[kind];
123
+ if (ATRIS_REFUSED.has(verb)) return 'answer one of your asks for you';
124
+ return `run atris ${verb}`;
125
+ }
126
+ return 'run a command the lock holds';
127
+ }
128
+
129
+ // "push its job deck branch to GitHub": the branch in words, and the host
130
+ function plainPush(p, cwd, who) {
131
+ const words = positionals(p.rest, PUSH_VALUE_FLAGS);
132
+ const remote = words[0] || 'origin';
133
+ let url = '';
134
+ try { url = String(spawnSync('git', [...p.prefix, 'remote', 'get-url', '--push', remote], { cwd, encoding: 'utf8', timeout: 5000 }).stdout || '').trim(); } catch { /* no repo */ }
135
+ const host = (url.match(/^[a-z+]+:\/\/(?:[^@/]+@)?([^/:]+)/i) || url.match(/^[^@/:]+@([^:/]+):/) || [])[1] || '';
136
+ const where = /github\.com$/i.test(host) ? 'GitHub' : /gitlab\.com$/i.test(host) ? 'GitLab' : host ? host : (url ? 'a folder on this computer' : 'its remote');
137
+ if (p.rest.some(a => /^--(tags|follow-tags|mirror|all)/.test(a))) return `push tags or every branch to ${where}`;
138
+ const ref = String(words[1] || '');
139
+ const dst = ref.replace(/^\+/, '').split(':').pop().replace(/^refs\/(heads|tags)\//, '');
140
+ // a delete is said as a delete, with the name it removes
141
+ if (p.rest.some(a => a === '--delete' || a === '-d') || /^\+?:/.test(ref)) return dst ? `delete ${dst} on ${where}` : `delete something on ${where}`;
142
+ const force = /^\+/.test(ref) || p.rest.some(a => /^(--force|-f|--force-with-lease|--force-if-includes)(=|$)/.test(a));
143
+ const said = pushWords(dst, ref, where, who);
144
+ return force ? `force ${said}, replacing what is there` : said;
145
+ }
146
+
147
+ function pushWords(dst, ref, where, who) {
148
+ if (!dst) return `push to ${where}`;
149
+ if (isMainRef(dst)) return `push to master on ${where}`;
150
+ if (/^refs\/tags\//.test(ref.split(':').pop()) || /^v?\d+\.\d+/.test(dst)) return `push the tag ${dst} to ${where}`;
151
+ const first = String(who).toLowerCase();
152
+ const plain = dst.split('/').pop().replace(/[-_]\d{8}(?:[-_]\d{4,6})?$/, '').split(/[-_.]+/).filter((w, i) => w && !(i === 0 && w.toLowerCase() === first)).join(' ');
153
+ return plain ? `push its ${plain} branch to ${where}` : `push a branch to ${where}`;
154
+ }
155
+
156
+ // One command's flags as it takes them: known switches, known options with
157
+ // one value each, and the words left over. Anything else is null.
158
+ function readArgs(words, { switches = [], values = [] } = {}) {
159
+ const out = { flags: new Set(), opts: {}, rest: [] };
160
+ for (let i = 0; i < words.length; i += 1) {
161
+ const w = words[i];
162
+ if (!w.startsWith('-') || w === '-') { out.rest.push(w); continue; }
163
+ const eq = w.indexOf('=');
164
+ const name = eq > 0 ? w.slice(0, eq) : w;
165
+ if (values.includes(name)) {
166
+ const v = eq > 0 ? w.slice(eq + 1) : words[(i += 1)];
167
+ if (!v || v.startsWith('-')) return null;
168
+ out.opts[name] = v;
169
+ } else if (eq < 0 && switches.includes(w)) out.flags.add(w);
170
+ else return null;
171
+ }
172
+ return out;
173
+ }
174
+
175
+ const NAME = /^[\w.@+-]+$/;
176
+ const REPO = /^[\w.-]+\/[\w.-]+$/;
177
+
178
+ // Exact words for a held command, verb and target, or null when it
179
+ // cannot be said exactly. words: the tool, then its arguments.
180
+ function describeCommand(words) {
181
+ if (words[0] === 'gh') return describeGh(words.slice(1));
182
+ if (words[0] === 'git' && words[1] === 'push') return describePush(words.slice(2));
183
+ if ((words[0] === 'npm' || words[0] === 'pnpm') && words[1] === 'publish') return describePublish(words.slice(2));
184
+ return null;
185
+ }
186
+
187
+ function describeGh([group, verb, ...more]) {
188
+ const repo = a => {
189
+ const r = a.opts['-R'] || a.opts['--repo'];
190
+ if (!r) return 'in this repo';
191
+ return REPO.test(r) ? `in ${r}` : null;
192
+ };
193
+ const has = (a, ...names) => names.some(n => a.flags.has(n));
194
+ if (group === 'release' && verb === 'delete') {
195
+ const a = readArgs(more, { switches: ['--yes', '-y', '--cleanup-tag'], values: ['-R', '--repo'] });
196
+ if (!a || a.rest.length !== 1 || !NAME.test(a.rest[0]) || !repo(a)) return null;
197
+ return `delete the GitHub release ${a.rest[0]}${has(a, '--cleanup-tag') ? ' and its tag' : ''} ${repo(a)}`;
198
+ }
199
+ if (group === 'release' && verb === 'create') {
200
+ const a = readArgs(more, { switches: ['--draft', '-d', '--prerelease', '-p', '--generate-notes'], values: ['-R', '--repo', '--title', '-t', '--notes', '-n', '--target'] });
201
+ if (!a || a.rest.length !== 1 || !NAME.test(a.rest[0]) || !repo(a)) return null;
202
+ const target = a.opts['--target'];
203
+ if (target && !NAME.test(target)) return null;
204
+ const kind = [has(a, '--draft', '-d') && ' as a draft', has(a, '--prerelease', '-p') && ' as a prerelease'].filter(Boolean).join(' and');
205
+ return `create the GitHub release ${a.rest[0]}${kind}${target ? ` from ${target}` : ''} ${repo(a)}`;
206
+ }
207
+ if (group === 'pr' && (verb === 'merge' || verb === 'close')) {
208
+ const a = readArgs(more, { switches: verb === 'merge' ? ['--merge', '-m', '--squash', '-s', '--rebase', '-r', '--delete-branch', '-d'] : ['--delete-branch', '-d'], values: ['-R', '--repo'] });
209
+ if (!a || a.rest.length > 1 || !repo(a)) return null;
210
+ const which = a.rest[0];
211
+ if (which && !NAME.test(which)) return null;
212
+ const pr = !which ? 'the pull request for this branch' : /^\d+$/.test(which) ? `pull request ${which}` : `the pull request for ${which}`;
213
+ const how = [has(a, '--merge', '-m') && 'merge', has(a, '--squash', '-s') && 'squash and merge', has(a, '--rebase', '-r') && 'rebase and merge'].filter(Boolean);
214
+ if (how.length > 1) return null;
215
+ const act = verb === 'close' ? 'close' : how[0] || 'merge';
216
+ return `${act} ${pr}${has(a, '--delete-branch', '-d') ? ' and delete its branch' : ''} ${repo(a)}`;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function describePush(args) {
222
+ const a = readArgs(args, { switches: ['--tags', '--force', '-f', '--delete', '-d', '--set-upstream', '-u'] });
223
+ if (!a || a.rest.length > 2) return null;
224
+ const [remote, ref] = a.rest;
225
+ if (remote && !NAME.test(remote)) return null;
226
+ const to = remote || 'the default remote';
227
+ const force = a.flags.has('--force') || a.flags.has('-f');
228
+ const del = a.flags.has('--delete') || a.flags.has('-d');
229
+ if (a.flags.has('--tags')) return ref || force || del ? null : `push every tag to ${to}`;
230
+ if (!ref) return del ? null : `${force ? 'force push' : 'push'} this branch to ${to}${force ? ', replacing what is there' : ''}`;
231
+ const plus = ref.startsWith('+');
232
+ const parts = (plus ? ref.slice(1) : ref).split(':');
233
+ if (parts.length > 2 || parts.some(x => x && !/^[\w./-]+$/.test(x))) return null;
234
+ const [src, dst] = parts;
235
+ if (del || (parts.length === 2 && !src)) return del && parts.length === 2 ? null : plus || force ? null : `delete ${dst || src} on ${to}`;
236
+ if (parts.length === 2 && !dst) return null;
237
+ const strong = force || plus;
238
+ return `${strong ? 'force push' : 'push'} ${dst ? `${src} to ${dst} on ${to}` : `${src} to ${to}`}${strong ? ', replacing what is there' : ''}`;
239
+ }
240
+
241
+ function describePublish(args) {
242
+ const a = readArgs(args, { values: ['--tag', '--access'] });
243
+ if (!a || a.rest.length > 1) return null;
244
+ const access = a.opts['--access'];
245
+ if (access && access !== 'public' && access !== 'restricted') return null;
246
+ const tag = a.opts['--tag'];
247
+ if (tag && !NAME.test(tag)) return null;
248
+ const extra = [tag && `under the ${tag} tag`, access].filter(Boolean).join(', ');
249
+ return `publish ${a.rest.length ? `the package in "${a.rest[0]}"` : 'this package'} to npm${extra ? ` ${extra}` : ''}`;
250
+ }
251
+
82
252
  // ---- what an owner step locks ------------------------------------------------------
83
253
 
84
254
  const STEP_WORDS = [
@@ -133,9 +303,9 @@ function writeState(stateDir, missionId, data) {
133
303
  fs.renameSync(tmp, file);
134
304
  }
135
305
 
136
- function writeYesLocks(stateDir, missionId, { betId = null, seal = [], parentMissionId = null } = {}) {
306
+ function writeYesLocks(stateDir, missionId, { betId = null, seal = [], parentMissionId = null, owner = null } = {}) {
137
307
  const locks = normalizeSeal(seal).map(s => ({ ...s, asked_at: null, approved_at: null, consumed_at: null }));
138
- writeState(stateDir, missionId, { mission_id: missionId, bet_id: betId, ...(parentMissionId ? { parent_mission_id: parentMissionId } : {}), locks });
308
+ writeState(stateDir, missionId, { mission_id: missionId, bet_id: betId, ...(parentMissionId ? { parent_mission_id: parentMissionId } : {}), ...(owner ? { owner } : {}), locks });
139
309
  return locks;
140
310
  }
141
311
 
@@ -190,7 +360,7 @@ function claimFile(stateDir, missionId, lock, request) {
190
360
  function saveLocks(stateDir, missionId, locks, betId) {
191
361
  const prior = readState(stateDir, missionId);
192
362
  const rows = locks.map(l => ({ step: l.step, text: l.text, categories: l.categories, requests: rowRequests(l) }));
193
- writeState(stateDir, missionId, { mission_id: missionId, bet_id: (prior && prior.bet_id) || betId || null, ...(prior && prior.parent_mission_id ? { parent_mission_id: prior.parent_mission_id } : {}), locks: rows });
363
+ writeState(stateDir, missionId, { mission_id: missionId, bet_id: (prior && prior.bet_id) || betId || null, ...(prior && prior.parent_mission_id ? { parent_mission_id: prior.parent_mission_id } : {}), ...(prior && prior.owner ? { owner: prior.owner } : {}), locks: rows });
194
364
  }
195
365
 
196
366
  // The yes covers the one command on the card the owner answered: the waiting
@@ -553,11 +723,15 @@ const ONTO_BRANCH = new Set(['merge', 'commit', 'cherry-pick', 'rebase', 'am', '
553
723
 
554
724
  function readGit(args, ctx, depth = 0) {
555
725
  const p = parseGit(args);
556
- const risky = [...p.configs.map(configKey), ...ctx.envConfigKeys()].find(k => RISKY_GIT_CONFIG.test(k));
557
- if (risky) return refused(`git config ${risky} can change where a push goes, so it is closed while owner steps wait`);
558
726
  const repo = ctx.forRepo(p.prefix);
559
727
  const sub = p.sub;
560
728
  if (!sub) return CLEAR;
729
+ // config that can reroute matters to a push, or a command that reaches a
730
+ // network remote; local work passes whatever config it runs with
731
+ const risky = [...p.configs.map(configKey), ...ctx.envConfigKeys()].find(k => RISKY_GIT_CONFIG.test(k));
732
+ // an alias given with -c can turn any command into a push
733
+ const reaches = ['push', 'send-pack', 'http-push'].includes(sub) || /^alias\./i.test(risky || '') || talksToNetwork(sub, p.rest, repo);
734
+ if (risky && reaches) return refused(`git config ${risky} can change where a push goes, so it is closed while owner steps wait`);
561
735
  // git never applies an alias named after one of its own commands, so
562
736
  // neither does this: a stored alias.push is ignored and push is push
563
737
  const alias = isGitBuiltin(sub, repo) ? null : repo.alias(sub);
@@ -642,6 +816,7 @@ function gitContext(gitBin, cwd, env = process.env, prefix = []) {
642
816
  config: key => out(['config', '--get', key]) || '',
643
817
  configBool: key => out(['config', '--bool', '--get', key]) || '',
644
818
  configAll: key => (out(['config', '--get-all', key]) || '').split('\n').filter(Boolean),
819
+ remoteNames: () => (out(['remote']) || '').split('\n').filter(Boolean),
645
820
  // with insteadOf, pushInsteadOf and pushurl applied, as git push sees them
646
821
  remoteUrls: (remote, { push = false } = {}) => (out(['remote', 'get-url', ...(push ? ['--push'] : []), '--all', remote]) || '').split('\n').filter(Boolean),
647
822
  alias: name => out(['config', '--get', `alias.${name}`]),
@@ -656,47 +831,44 @@ function gitContext(gitBin, cwd, env = process.env, prefix = []) {
656
831
  };
657
832
  }
658
833
 
659
- // ---- the handoff seal: an allowlist --------------------------------------------------
834
+ // ---- the handoff seal --------------------------------------------------------------
660
835
  //
661
- // A run woken by a handoff is trusted least, so it does not get the kind-by-
662
- // kind reading above. Only the commands named here run without a yes;
663
- // anything else, including every tool, verb, or option this code does not
664
- // name, is held as a yes card. Reading commands by kind kept missing outward
665
- // ones (Codex review round 7 on #1483: business deploy, followTags, a mirror
666
- // remote, symbolic-ref); an allowlist cannot miss a command it never names.
667
- // A bet's owner steps still lock by kind (analyze above), since a bet locks
668
- // only the kinds its steps name.
836
+ // A run woken by a handoff guards git push, not every git command. Nothing
837
+ // local publishes anything, and landing goes through origin/master, not
838
+ // local master; the engines and test suites run far more git than any list
839
+ // can name (3.74.0 to 3.74.4 held 546 harmless commands). What stays guarded
840
+ // is what can reach a network remote: a push, and the -c or environment that
841
+ // could run a push past the wrapper, since git runs a program named in its
842
+ // config with its own folder first on PATH. gh and npm, yarn, pnpm keep their
843
+ // lists below, and atris holds its outward and spend verbs. A bet's owner
844
+ // steps still lock by kind (analyze above).
669
845
 
670
846
  const pass = (args = null) => ({ ok: true, args });
671
847
  const hold = why => ({ ok: false, why });
672
848
  const isHandoffSeal = sealed => sealed.some(s => s.text === HANDOFF_SEAL_TEXT);
673
849
  const PUSH_HINT = 'a branch push runs without a yes as: git push origin <branch>';
674
850
 
675
- // git reads, plus local work that cannot move a ref other than the branch it is on
676
- const GIT_SEALED_READS = new Set(['status', 'diff', 'log', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'rev-list', 'merge-base', 'cat-file', 'ls-tree', 'show-ref', 'for-each-ref', 'shortlog', 'check-ignore', 'help', 'version']);
677
- const GIT_SEALED_LOCAL = new Set(['add', 'rm', 'mv', 'restore']);
678
- const GIT_SEALED_GLOBALS = new Set(['--no-pager', '-P', '--no-optional-locks', '--literal-pathspecs', '--version', '--help', '-h']);
679
851
  // the shape of a branch name git accepts, without the corners that read as something else
680
852
  const BRANCH_NAME = /^(?!-)(?!\.)(?!.*\.\.)(?!.*\/\/)(?!.*\/\.)(?!.*@\{)(?!.*\.lock(?:\/|$))[A-Za-z0-9._/-]+(?<![./])$/;
681
853
 
682
- // Only -C and a few output switches go before the command. -c,
683
- // --config-env, and --exec-path are held: config can name a program git runs
684
- // (core.fsmonitor, core.pager), and git puts its own folder first on PATH for
685
- // that program, so a git inside it would skip the wrapper.
686
- function sealedGitPrefix(args) {
687
- const prefix = [];
688
- for (let i = 0; i < args.length; i += 1) {
689
- const a = args[i];
690
- if (a === '-C') {
691
- prefix.push(a, String(args[i + 1] === undefined ? '' : args[i + 1]));
692
- i += 1;
693
- continue;
694
- }
695
- if (GIT_SEALED_GLOBALS.has(a)) { prefix.push(a); continue; }
696
- if (a.startsWith('-')) return { bad: a };
697
- return { prefix, sub: a, rest: args.slice(i + 1) };
698
- }
699
- return { prefix, sub: '', rest: [] };
854
+ // -c a woken run may pass: a value that only turns something off, or a key
855
+ // that only changes how output looks or who a commit is by. The engines
856
+ // harden their own git calls this way. A -c that names a program, path,
857
+ // command, alias, helper, pager, editor, or route is held, and so are the
858
+ // keys whose value is a name, URL, or refspec (alias, remote, url, push,
859
+ // include, credential, branch routing), where even an empty value can
860
+ // reroute (url.<x>.insteadOf=).
861
+ const GIT_DISPLAY_KEYS = new Set(['format.pretty', 'log.showsignature', 'status.showuntrackedfiles', 'core.quotepath', 'core.safecrlf', 'safe.barerepository', 'color.ui', 'user.name', 'user.email']);
862
+ const GIT_ROUTE_KEYS = /^(alias|remote|url|push|include|includeif|credential)\.|^branch\..+\.(remote|pushremote|merge)$/;
863
+
864
+ function sealedGitConfigOk(pair) {
865
+ const eq = String(pair).indexOf('=');
866
+ const key = (eq < 0 ? String(pair) : String(pair).slice(0, eq)).trim().toLowerCase();
867
+ const value = eq < 0 ? 'true' : String(pair).slice(eq + 1).trim();
868
+ if (GIT_DISPLAY_KEYS.has(key)) return true;
869
+ if (key === 'core.hookspath') return value === '/dev/null';
870
+ if (GIT_ROUTE_KEYS.test(key)) return false;
871
+ return /^(|false|never|0|no|off)$/i.test(value);
700
872
  }
701
873
 
702
874
  // The same, set through the environment. GIT_CONFIG_NOSYSTEM only turns the
@@ -706,197 +878,156 @@ function gitEnvProblem(env) {
706
878
  return names.find(k => (/^GIT_CONFIG/.test(k) && k !== 'GIT_CONFIG_NOSYSTEM') || k === 'GIT_EXEC_PATH') || null;
707
879
  }
708
880
 
709
- // A ref a command writes or switches to must be a plain branch name that is
710
- // not master and does not resolve to master: no @{-1}, @, HEAD~, ^, refs/,
711
- // and no name that is a symbolic ref to master.
712
- function targetProblem(name, repo) {
713
- const n = String(name === undefined ? '' : name);
714
- if (n === 'HEAD' || n.startsWith('refs/') || !BRANCH_NAME.test(n)) return `${n || '(no name)'} is not a plain branch name`;
715
- if (isMainRef(n)) return `${n} is master`;
716
- const full = typeof repo.fullRefName === 'function' ? repo.fullRefName(n) : '';
717
- if (full && isMainRef(full)) return `${n} resolves to master`;
718
- return null;
719
- }
881
+ // git prints these and stops without running the command after them
882
+ const GIT_PRINT_AND_EXIT = new Set(['--exec-path', '--version', '--help', '-h', '--html-path', '--man-path', '--info-path']);
720
883
 
721
- function sealedGit(args, ctx) {
722
- const p = sealedGitPrefix(args);
723
- if (p.bad) return hold(`git ${p.bad} is not an option a handed-off job may use`);
724
- const envName = gitEnvProblem(typeof ctx.env === 'function' ? ctx.env() : {});
725
- if (envName) return hold(`git with ${envName} set can run a program that skips the wrapper`);
726
- if (!p.sub) return pass();
727
- if (GIT_SEALED_READS.has(p.sub) || GIT_SEALED_LOCAL.has(p.sub)) return pass();
728
- // the questions below are asked of the repo the command names
729
- const repo = ctx.forRepo(p.prefix.filter((a, i, all) => a === '-C' || all[i - 1] === '-C'));
730
- const rest = p.rest;
731
- switch (p.sub) {
732
- case 'push': return sealedPush(p, repo);
733
- case 'fetch': return sealedFetch(rest, repo);
734
- case 'commit': return isMainRef(repo.currentBranch()) ? hold('a commit here would land on master') : pass();
735
- case 'stash': {
736
- if (rest[0] !== 'branch') return pass();
737
- const why = targetProblem(positionals(rest.slice(1), new Set())[0], repo);
738
- return why ? hold(`git stash branch: ${why}`) : pass();
739
- }
740
- case 'checkout': case 'switch': return sealedSwitch(p.sub, rest, repo);
741
- case 'branch': return sealedBranch(rest, repo);
742
- case 'config': return sealedConfig(rest);
743
- case 'remote': {
744
- const op = rest.filter(a => a !== '-v' && a !== '--verbose')[0] || '';
745
- return op === '' || op === 'get-url' ? pass() : hold(`git remote ${op} changes where git sends things`);
884
+ // The options before the command. -C, --git-dir, --work-tree, and
885
+ // --namespace pick the repo; a -c that fails the reading above, --config-env,
886
+ // and --exec-path=<dir> are noted as bad, which matters only for a command
887
+ // that talks to a network remote.
888
+ function sealedGitPrefix(args) {
889
+ const prefix = [];
890
+ let bad = null;
891
+ for (let i = 0; i < args.length; i += 1) {
892
+ const a = args[i];
893
+ if (['-C', '-c', '--git-dir', '--work-tree', '--namespace'].includes(a)) {
894
+ const value = String(args[i + 1] === undefined ? '' : args[i + 1]);
895
+ if (a === '-c' && !sealedGitConfigOk(value)) bad = bad || `-c ${value}`;
896
+ prefix.push(a, value);
897
+ i += 1;
898
+ continue;
746
899
  }
747
- case 'worktree': return sealedWorktree(rest, repo);
748
- default: return hold(`git ${p.sub} is not on the list a handed-off job may run`);
900
+ if (/^--config-env(=|$)/.test(a) || a.startsWith('--exec-path=')) bad = bad || a;
901
+ if (a.startsWith('-')) { prefix.push(a); continue; }
902
+ return { prefix, bad, sub: a, rest: args.slice(i + 1) };
749
903
  }
904
+ return { prefix, bad, sub: '', rest: [] };
750
905
  }
751
906
 
752
- // One branch, to origin, by name or as HEAD:refs/heads/<branch>, with no
753
- // option but -u and quiet or verbose. It runs with followTags off, origin's
754
- // mirror off, and push options cleared, and with the refspec written out in
755
- // full, so origin's own push lines cannot send it anywhere else.
756
- function sealedPush(p, repo) {
757
- let upstream = false;
758
- const quiet = [];
759
- const words = [];
760
- for (const a of p.rest) {
761
- if (a === '-u' || a === '--set-upstream') { upstream = true; continue; }
762
- if (['-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress'].includes(a)) { quiet.push(a); continue; }
763
- if (a.startsWith('-')) return hold(`git push ${a} is not an option a handed-off job may use; ${PUSH_HINT}`);
764
- words.push(a);
765
- }
766
- if (words.length !== 2 || words[0] !== 'origin') return hold(`a handed-off job pushes one branch to origin; ${PUSH_HINT}`);
767
- const head = words[1].match(/^HEAD:refs\/heads\/(.+)$/);
768
- const branch = head ? head[1] : words[1];
769
- if (!head && (branch.includes(':') || branch.startsWith('refs/'))) return hold(`git push ${words[1]} names more than a branch; ${PUSH_HINT}`);
770
- if (isMainRef(branch)) return hold(`git push to master lands on master; ${PUSH_HINT}`);
771
- const why = targetProblem(branch, repo);
772
- if (why) return hold(`git push ${words[1]}: ${why}; ${PUSH_HINT}`);
773
- if (!head && repo.isTag(branch) && !repo.isBranch(branch)) return hold(`git push ${branch} names a tag; ${PUSH_HINT}`);
774
- const fetchUrls = repo.remoteUrls('origin');
775
- const pushUrls = repo.remoteUrls('origin', { push: true });
776
- if (!fetchUrls.length) return hold('origin is not a remote here');
777
- if (pushUrls.length !== 1 || pushUrls[0] !== fetchUrls[0]) return hold('origin pushes somewhere other than where it fetches from');
778
- if (repo.configBool('remote.origin.mirror') === 'true') return hold('origin is set up as a mirror, which pushes every branch and tag');
779
- const prefix = p.prefix;
780
- return pass([...prefix, '-c', 'push.followTags=false', '-c', 'remote.origin.mirror=false', '-c', 'push.pushOption=', 'push', '--no-follow-tags',
781
- ...(upstream ? ['--set-upstream'] : []), ...quiet, 'origin', `${head ? 'HEAD' : `refs/heads/${branch}`}:refs/heads/${branch}`]);
907
+ // Does this git command talk to a network remote? Pushes, fetches, pulls,
908
+ // ls-remote, and clones to a remote on disk are local work. When the remote
909
+ // cannot be read, it counts as a network one.
910
+ const GIT_REMOTE_SUBS = new Set(['push', 'send-pack', 'http-push', 'fetch', 'pull', 'ls-remote', 'clone']);
911
+ const GIT_REMOTE_VALUE_FLAGS = new Set(['--depth', '--deepen', '--shallow-since', '--shallow-exclude', '-j', '--jobs', '--upload-pack', '--negotiation-tip', '--refmap', '--filter', '-o', '--server-option', '--push-option', '--receive-pack', '--exec', '-s', '--strategy', '-X', '--strategy-option', '-b', '--branch', '--origin', '--template', '--reference', '--separate-git-dir', '-c', '--config']);
912
+
913
+ function talksToNetwork(sub, rest, repo) {
914
+ if (!GIT_REMOTE_SUBS.has(sub)) return false;
915
+ if (sub === 'http-push') return true;
916
+ if (typeof repo.remoteUrls !== 'function') return true;
917
+ const isPush = sub === 'push' || sub === 'send-pack';
918
+ const repoFlag = rest.find(a => /^--repo=/.test(String(a)));
919
+ const words = positionals(rest, GIT_REMOTE_VALUE_FLAGS);
920
+ const branch = repo.currentBranch();
921
+ const targets = sub === 'fetch' && rest.some(a => a === '--all' || a === '--multiple')
922
+ ? (repo.remoteNames ? repo.remoteNames() : ['origin'])
923
+ : [repoFlag ? repoFlag.slice('--repo='.length) : words[0]
924
+ || (isPush && (repo.config(`branch.${branch}.pushRemote`) || repo.config('remote.pushDefault')))
925
+ || repo.config(`branch.${branch}.remote`) || 'origin'];
926
+ return targets.some((target) => {
927
+ const urls = repo.remoteUrls(target, { push: isPush });
928
+ if (urls.length) return !urls.every(isLocalUrl);
929
+ // no URL read: a path is local; a remote name git could not read counts as network
930
+ return !/^(file:\/\/|\/|\.\.?(\/|$)|~)/.test(String(target));
931
+ });
782
932
  }
783
933
 
784
- const FETCH_FLAGS = new Set(['-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress', '-p', '--prune', '-P', '--prune-tags', '-t', '--tags', '-n', '--no-tags', '--dry-run', '--unshallow', '--no-write-fetch-head', '--write-fetch-head']);
785
-
786
- function sealedFetch(rest, repo) {
787
- const words = [];
788
- for (const a of rest) {
789
- if (FETCH_FLAGS.has(a) || /^--(depth|deepen|shallow-since|shallow-exclude|filter|jobs)=/.test(a)) continue;
790
- if (a.startsWith('-')) return hold(`git fetch ${a} is not an option a handed-off job may use`);
791
- words.push(a);
792
- }
793
- const remote = words[0] || repo.config(`branch.${repo.currentBranch()}.remote`) || 'origin';
794
- if (remote !== 'origin') return hold('a handed-off job fetches from origin only');
795
- if (words.slice(1).some(w => /[:+*^]/.test(w))) return hold('a fetch refspec can write a local branch');
796
- // origin's own fetch lines may only fill remote-tracking refs and tags
797
- for (const spec of repo.configAll('remote.origin.fetch')) {
798
- const dest = spec.includes(':') ? spec.split(':').pop() : '';
799
- if (dest && !/^refs\/(remotes|tags)\//.test(dest)) return hold('origin is set up to fetch into local branches');
934
+ function sealedGit(args, ctx, depth = 0) {
935
+ const p = sealedGitPrefix(args);
936
+ if (!p.sub || p.prefix.some(a => GIT_PRINT_AND_EXIT.has(a))) return pass();
937
+ // the lock's own questions leave out options that could break them
938
+ const repo = ctx.forRepo(p.prefix.filter(a => !GIT_PRINT_AND_EXIT.has(a) && !/^--(config-env|exec-path=)/.test(a)));
939
+ // an alias that turns into a push is a push; one that runs a shell could
940
+ // run a git that skips the wrapper
941
+ const alias = isGitBuiltin(p.sub, repo) ? null : repo.alias(p.sub);
942
+ if (alias !== null && alias !== undefined) {
943
+ if (depth >= 5 || alias.startsWith('!')) return hold(`git ${p.sub} is an alias that runs a shell command`);
944
+ return sealedGit([...p.prefix, ...alias.trim().split(/\s+/), ...p.rest], ctx, depth + 1);
800
945
  }
801
- return pass();
946
+ // local work passes in any folder, whatever config the test suite or
947
+ // engine runs git with; only a command that reaches a network remote is read
948
+ if (!talksToNetwork(p.sub, p.rest, repo)) return pass();
949
+ if (p.bad) return hold(`git ${p.bad} can run a program that skips the wrapper`);
950
+ const envName = gitEnvProblem(typeof ctx.env === 'function' ? ctx.env() : {});
951
+ if (envName) return hold(`git with ${envName} set can run a program that skips the wrapper`);
952
+ // the low-level ways to update a remote are push too
953
+ if (['send-pack', 'http-push'].includes(p.sub)) return hold(`git ${p.sub} updates a remote without git push`);
954
+ if (p.sub !== 'push') return pass();
955
+ return sealedPush(p, repo);
802
956
  }
803
957
 
804
- const SWITCH_FLAGS = new Set(['-q', '--quiet', '-f', '--force', '--detach', '-d', '-t', '--track', '--no-track', '--guess', '--no-guess', '-m', '--merge', '--ours', '--theirs', '-p', '--patch', '--progress', '--no-progress', '--overlay', '--no-overlay', '--discard-changes', '--ignore-skip-worktree-bits', '--recurse-submodules', '--no-recurse-submodules', '--ignore-other-worktrees']);
805
-
806
- function sealedSwitch(sub, rest, repo) {
807
- const create = sub === 'checkout' ? new Set(['-b', '-B', '--orphan']) : new Set(['-c', '-C', '--create', '--force-create', '--orphan']);
808
- const words = [];
809
- let creating = false;
810
- let paths = false;
811
- for (let i = 0; i < rest.length; i += 1) {
812
- const a = rest[i];
813
- if (a === '--') { paths = true; break; }
814
- const inline = a.match(/^(--orphan|--create|--force-create)=(.*)$/);
815
- if (create.has(a) || inline) {
816
- const name = inline ? inline[2] : String(rest[i + 1] === undefined ? '' : rest[i + 1]);
817
- if (!inline) i += 1;
818
- const why = targetProblem(name, repo);
819
- if (why) return hold(`git ${sub} ${a}: ${why}`);
820
- creating = true;
821
- continue;
822
- }
823
- if (SWITCH_FLAGS.has(a) || /^--(conflict|track|pathspec-from-file)=/.test(a)) continue;
824
- if (a.startsWith('-') && a !== '-') return hold(`git ${sub} ${a} is not an option a handed-off job may use`);
825
- words.push(a);
826
- }
827
- if (creating || !words.length) return pass();
828
- const target = words[0];
829
- // a checkout with paths restores files from the named commit; it moves no branch
830
- if (sub === 'checkout' && (paths || words.length > 1)) return pass();
831
- const why = targetProblem(target, repo);
832
- return why ? hold(`git ${sub} ${target}: ${why}`) : pass();
958
+ // a remote that is a folder on this computer: a path, a file:// URL, or a
959
+ // remote whose every push URL is one
960
+ function isLocalUrl(url) {
961
+ const u = String(url || '');
962
+ if (/^file:\/\//.test(u) || /^(\/|\.\.?(\/|$)|~)/.test(u)) return true;
963
+ return !u.includes(':');
833
964
  }
834
965
 
835
- function sealedBranch(rest, repo) {
836
- const LIST_VALUE = new Set(['--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--format', '--sort']);
837
- const LIST = new Set(['-a', '--all', '-r', '--remotes', '-v', '-vv', '--verbose', '-l', '--list', '--show-current', '--color', '--no-color', '--column', '--no-column', '-i', '--ignore-case', '--no-abbrev', '-q', '--quiet', '--omit-empty']);
838
- const CREATE = new Set(['-t', '--track', '--no-track', '-f', '--force', '--create-reflog', '--recurse-submodules']);
839
- let listing = false;
966
+ const PUSH_FLAGS_OK = new Set(['-u', '--set-upstream', '-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress', '-f', '--force', '--force-if-includes', '--no-force-if-includes', '--no-verify', '--verify', '-n', '--dry-run', '--porcelain', '--atomic', '--no-atomic', '--thin', '--no-thin', '--no-follow-tags']);
967
+
968
+ // A push to a remote on disk runs as written: tests push tags and master to
969
+ // scratch repos. To a network remote it runs only as branch pushes that are
970
+ // not master, with followTags, the remote's mirror setting, and push options
971
+ // off, and each refspec written out in full, so the remote's own push lines
972
+ // cannot send it anywhere else. Tags, master, mirror, --all, --prune, a
973
+ // matching push (":"), and deleting master are held.
974
+ function sealedPush(p, repo) {
975
+ const words = positionals(p.rest, PUSH_VALUE_FLAGS);
976
+ const repoFlag = p.rest.find(a => /^--repo=/.test(a));
977
+ const named = repoFlag ? repoFlag.slice('--repo='.length) : words[0];
978
+ const remote = named || repo.config(`branch.${repo.currentBranch()}.pushRemote`) || repo.config('remote.pushDefault') || repo.config(`branch.${repo.currentBranch()}.remote`) || 'origin';
979
+ const configured = repo.remoteUrls(remote, { push: true });
980
+ if (configured.length ? configured.every(isLocalUrl) : isLocalUrl(remote)) return pass();
981
+ if (isGitTrue(repo.configBool(`remote.${remote}.mirror`))) return hold(`${remote} is set up as a mirror, which pushes every branch and tag`);
982
+ const kept = [];
840
983
  let deleting = false;
841
- const words = [];
842
- for (let i = 0; i < rest.length; i += 1) {
843
- const a = rest[i];
844
- if (a === '--') { words.push(...rest.slice(i + 1)); break; }
845
- if (LIST_VALUE.has(a)) { listing = true; i += 1; continue; }
846
- if (/^--(contains|no-contains|merged|no-merged|points-at|format|sort)=/.test(a)) { listing = true; continue; }
847
- if (a === '-l' || a === '--list' || a === '--show-current') { listing = true; continue; }
848
- if (LIST.has(a) || CREATE.has(a) || /^--(color|column|abbrev|track)=/.test(a)) continue;
849
- if (a === '-d' || a === '-D' || a === '--delete') { deleting = true; continue; }
850
- if (a.startsWith('-')) return hold(`git branch ${a} is not an option a handed-off job may use`);
851
- words.push(a);
984
+ for (const a of p.rest) {
985
+ if (a === '-d' || a === '--delete') { deleting = true; continue; }
986
+ if (PUSH_FLAGS_OK.has(a) || /^--force-with-lease(=|$)/.test(a)) { kept.push(a); continue; }
987
+ if (a.startsWith('-')) return hold(`git push ${a} to a network remote can move master or tags; ${PUSH_HINT}`);
852
988
  }
853
- if (deleting) {
854
- const why = words.map(w => targetProblem(w, repo)).find(Boolean);
855
- return why ? hold(`git branch -d: ${why}`) : pass();
989
+ const specs = repoFlag ? words : words.slice(1);
990
+ const current = repo.currentBranch();
991
+ const out = [];
992
+ if (!specs.length) {
993
+ // no refspec: the branch's own push target, as one branch
994
+ const dest = String(repo.pushDest() || '').replace(/^[^/]+\//, '');
995
+ if (!dest || deleting) return hold(`git push with no branch named could push more than one; ${PUSH_HINT}`);
996
+ specs.push(`HEAD:${dest}`);
856
997
  }
857
- if (listing || !words.length) return pass();
858
- // git branch <new> [<start>]: the start can be anything, the name it writes cannot
859
- const why = targetProblem(words[0], repo);
860
- return why ? hold(`git branch ${words[0]}: ${why}`) : pass();
861
- }
862
-
863
- // git config reads only
864
- function sealedConfig(rest) {
865
- if (rest.some(a => /^(--add|--unset|--unset-all|--replace-all|--rename-section|--remove-section|-e|--edit)$/.test(String(a).split('=')[0]))) return hold('git config writes change what git does');
866
- if (rest.some(a => /^(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|--get-color|--get-colorbool)$/.test(String(a).split('=')[0]))) return pass();
867
- const words = positionals(rest, new Set(['--file', '-f', '--blob', '--type', '--default']));
868
- if (words[0] === 'get' || words[0] === 'list' || words.length === 1) return pass();
869
- return hold('git config writes change what git does');
870
- }
871
-
872
- function sealedWorktree(rest, repo) {
873
- const [op = '', ...more] = rest;
874
- if (op === 'list') return pass();
875
- if (op !== 'add') return hold(`git worktree ${op} changes another working folder`);
876
- const words = [];
877
- let named = false;
878
- let detached = false;
879
- for (let i = 0; i < more.length; i += 1) {
880
- const a = more[i];
881
- if (a === '-b' || a === '-B') {
882
- const name = String(more[i + 1] === undefined ? '' : more[i + 1]);
883
- i += 1;
884
- const why = targetProblem(name, repo);
885
- if (why) return hold(`git worktree add ${a}: ${why}`);
886
- named = true;
887
- continue;
998
+ for (const spec of specs) {
999
+ const force = spec.startsWith('+') ? '+' : '';
1000
+ const body = spec.replace(/^\+/, '');
1001
+ if (body === ':' || body.includes('*')) return hold(`git push ${spec} pushes many branches at once; ${PUSH_HINT}`);
1002
+ let src;
1003
+ let dst;
1004
+ if (deleting) { src = ''; dst = body; } else if (body.includes(':')) {
1005
+ [src, dst] = [body.slice(0, body.indexOf(':')), body.slice(body.indexOf(':') + 1)];
1006
+ } else if (body === 'HEAD' || body === '@') { src = 'HEAD'; dst = current; } else {
1007
+ if (body.startsWith('refs/tags/') || (repo.isTag(body) && !repo.isBranch(body))) return hold(`git push ${spec} pushes a tag; ${PUSH_HINT}`);
1008
+ dst = body.replace(/^refs\/heads\//, '');
1009
+ src = `refs/heads/${dst}`;
888
1010
  }
889
- if (a === '--detach' || a === '-d') { detached = true; continue; }
890
- if (a === '--reason') { i += 1; continue; }
891
- if (['-f', '--force', '--checkout', '--no-checkout', '--lock', '-q', '--quiet', '--track', '--no-track', '--guess-remote', '--no-guess-remote', '--orphan'].includes(a) || a.startsWith('--reason=')) continue;
892
- if (a.startsWith('-')) return hold(`git worktree add ${a} is not an option a handed-off job may use`);
893
- words.push(a);
1011
+ const name = String(dst || '').replace(/^refs\/heads\/(?=.)/, '');
1012
+ if (String(dst || '').startsWith('refs/') && !String(dst).startsWith('refs/heads/')) return hold(`git push ${spec} does not go to a branch; ${PUSH_HINT}`);
1013
+ if (isMainRef(name)) return hold(`git push to master lands on master; ${PUSH_HINT}`);
1014
+ const why = targetProblem(name, repo);
1015
+ if (why) return hold(`git push ${spec}: ${why}; ${PUSH_HINT}`);
1016
+ out.push(`${force}${src}:refs/heads/${name}`);
894
1017
  }
895
- if (named || detached) return pass();
896
- // with no -b, git checks out the named branch, or one named after the folder
897
- const target = words[1] !== undefined ? words[1] : path.basename(String(words[0] || ''));
898
- const why = targetProblem(target, repo);
899
- return why ? hold(`git worktree add ${target}: ${why}`) : pass();
1018
+ const mirrorOff = /^[A-Za-z0-9._-]+$/.test(remote) ? ['-c', `remote.${remote}.mirror=false`] : [];
1019
+ return pass([...p.prefix, '-c', 'push.followTags=false', ...mirrorOff, '-c', 'push.pushOption=', 'push', '--no-follow-tags', ...kept, remote, ...out]);
1020
+ }
1021
+
1022
+ // A ref a push writes must be a plain branch name that is not master and
1023
+ // does not resolve to master: no @{-1}, @, HEAD~, ^, refs/.
1024
+ function targetProblem(name, repo) {
1025
+ const n = String(name === undefined ? '' : name);
1026
+ if (n === 'HEAD' || n.startsWith('refs/') || !BRANCH_NAME.test(n)) return `${n || '(no name)'} is not a plain branch name`;
1027
+ if (isMainRef(n)) return `${n} is master`;
1028
+ const full = typeof repo.fullRefName === 'function' ? repo.fullRefName(n) : '';
1029
+ if (full && isMainRef(full)) return `${n} resolves to master`;
1030
+ return null;
900
1031
  }
901
1032
 
902
1033
  // gh: reads, plus opening, reading, and commenting on a PR
@@ -942,35 +1073,19 @@ function sealedPackage(name, args) {
942
1073
  return PKG_SEALED[name].includes(sub) ? pass() : hold(`${name} ${sub || '(no command)'} is not on the list a handed-off job may run`);
943
1074
  }
944
1075
 
945
- // atris: the run's own machinery and plain reads. A verb names every sub it
946
- // allows (true: every sub stays local; a function: the subs it allows).
947
- // Anything else is held: business, site, pay, feed, deploy, release, task
948
- // accept, sweep and review runs (they land work), member run and wake,
949
- // mission start and run, and every unknown word (which atris reads as a
950
- // request to start work).
951
- const ATRIS_SEALED = {
952
- task: ['list', 'ls', 'show', 'inspect', 'status', 'events', 'where', 'lineage', 'note', 'say', 'claim', 'start', 'result', 'receipt', 'ready', 'done', 'finish', 'new', 'add'],
953
- member: ['status', 'handoff'],
954
- mission: ['status', 'tick'],
955
- slop: true,
956
- verify: true,
957
- status: true,
958
- log: sub => sub !== 'sync',
959
- youtube: (sub, args) => sub === 'search' && !args.includes('--paid'),
960
- help: true,
961
- version: true,
962
- };
963
-
964
- function sealedAtris(args, missionId) {
965
- const [cmd = '', sub = ''] = args;
966
- if (args.length === 1 && ['--version', '-v', '--help', '-h'].includes(cmd)) return pass();
967
- const allowed = Object.prototype.hasOwnProperty.call(ATRIS_SEALED, cmd) ? ATRIS_SEALED[cmd] : null;
968
- if (!allowed) return hold(`atris ${cmd} is not on the list a handed-off job may run`);
969
- const subOk = allowed === true || ['help', '--help', '-h'].includes(sub)
970
- || (typeof allowed === 'function' ? allowed(sub, args) : allowed.includes(sub));
971
- if (!subOk) return hold(`atris ${cmd} ${sub} is not on the list a handed-off job may run`);
972
- // a tick of another job would run that job's work without this seal
973
- if (cmd === 'mission' && sub === 'tick' && positionals(args.slice(2), new Set())[0] !== missionId) return hold('atris mission tick runs only this job');
1076
+ // atris in a woken run: every verb runs except the outward and spend ones,
1077
+ // read the same way a bet reads them (readAtris), plus these by name and
1078
+ // accepting a task, which lands it.
1079
+ const ATRIS_SEALED_HELD = new Set(['spend', 'publish', 'deploy', 'release', 'land', 'merge', 'accept', 'business', 'site', 'pay', 'feed']);
1080
+ const TASK_ACCEPT = new Set(['accept', 'auto-accept-certified', 'auto-accept', 'accept-group']);
1081
+
1082
+ function sealedAtris(args) {
1083
+ const [cmd = '', sub = ''] = positionals(args, new Set(['--account', '--as', '--from', '--to', '--channel']));
1084
+ if (ATRIS_SEALED_HELD.has(cmd)) return hold(`atris ${cmd} goes out, lands, or spends`);
1085
+ if (cmd === 'task' && (TASK_ACCEPT.has(sub) || args.some(a => a === '--accept' || a === '--auto-accept'))) return hold('accepting a task lands it');
1086
+ const read = readAtris(args);
1087
+ if (read.cats.length) return hold(`atris ${cmd} would ${read.cats.map(c => CATEGORY_WORDS[c]).join(' and ')}`);
1088
+ if (read.refuse && ATRIS_REFUSED.has(cmd)) return hold(read.refuse);
974
1089
  return pass();
975
1090
  }
976
1091
 
@@ -979,7 +1094,7 @@ function sealedVerdict(tool, args, ctx, missionId) {
979
1094
  if (name === 'git') return sealedGit(args, ctx);
980
1095
  if (name === 'gh') return sealedGh(args);
981
1096
  if (name === 'npm' || name === 'yarn' || name === 'pnpm') return sealedPackage(name, args);
982
- if (name === 'atris') return sealedAtris(args, missionId);
1097
+ if (name === 'atris') return sealedAtris(args);
983
1098
  return hold(`${name} is not on the list a handed-off job may run`);
984
1099
  }
985
1100
 
@@ -1065,6 +1180,8 @@ function check({ stateDir, missionId, seal, tool, args = [], gitBin = null, cwd
1065
1180
  function consume() {}
1066
1181
 
1067
1182
  module.exports = {
1183
+ describeCommand,
1184
+ plainWhat,
1068
1185
  HELD_EXIT,
1069
1186
  handoffParent,
1070
1187
  handoffSeal,