atris 3.74.4 → 3.74.6

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 (124) 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/commands/task.js +4 -2
  50. package/commands/xp.js +68 -15
  51. package/commands/zen.js +10 -3
  52. package/lib/campaign-keep.js +23 -8
  53. package/lib/campaign-loop.js +24 -5
  54. package/lib/career-xp-lock.js +61 -0
  55. package/lib/career-xp-root.js +61 -0
  56. package/lib/claude-boot-block.js +7 -4
  57. package/lib/engine-registry.js +139 -1
  58. package/lib/fleet.js +11 -2
  59. package/lib/gm-bet.js +734 -42
  60. package/lib/gm-card.js +431 -0
  61. package/lib/gm-phone-page.js +486 -717
  62. package/lib/gm-phone.js +33 -34
  63. package/lib/gm-season.js +14 -0
  64. package/lib/headless-chrome.js +39 -0
  65. package/lib/idea-gate.js +33 -0
  66. package/lib/idea-inbox.js +220 -0
  67. package/lib/idea-ledger.js +196 -0
  68. package/lib/idea-tick.js +384 -0
  69. package/lib/known-commands.js +1 -1
  70. package/lib/member-engine.js +1 -0
  71. package/lib/ninety-nine-sync.js +156 -0
  72. package/lib/ninety-nine.js +247 -0
  73. package/lib/opening-lines.js +94 -0
  74. package/lib/roster-markdown.js +39 -1
  75. package/lib/roster-models.js +1 -1
  76. package/lib/roster-swap.js +353 -0
  77. package/lib/spawn-env.js +29 -2
  78. package/lib/task-db.js +8 -4
  79. package/lib/wish-audit.js +14 -0
  80. package/lib/yes-lock.js +365 -256
  81. package/lib/zen-apply.js +6 -3
  82. package/lib/zen.js +28 -1
  83. package/package.json +1 -1
  84. package/templates/business-starter/CLAUDE.md +7 -7
  85. package/templates/business-starter/MAP.md +1 -1
  86. package/templates/business-starter/TODO.md +1 -1
  87. package/templates/business-starter/atris.md +2 -2
  88. package/templates/business-starter/context/README.md +6 -6
  89. package/templates/business-starter/context/live-workspace.md +1 -1
  90. package/templates/business-starter/goals.md +1 -1
  91. package/templates/business-starter/instructions.md +1 -1
  92. package/templates/business-starter/memory.md +4 -4
  93. package/templates/business-starter/persona.md +3 -3
  94. package/templates/business-starter/policies/LESSONS.md +1 -1
  95. package/templates/business-starter/reports/README.md +1 -1
  96. package/templates/business-starter/skills/README.md +1 -1
  97. package/templates/business-starter/team/README.md +1 -1
  98. package/templates/business-starter/team/START_HERE.md +1 -1
  99. package/templates/business-starter/team/_template/SOUL.md +3 -3
  100. package/templates/business-starter/team/comms/SOUL.md +2 -2
  101. package/templates/business-starter/team/ops/SOUL.md +4 -4
  102. package/templates/business-starter/team/research/SOUL.md +3 -3
  103. package/templates/business-starter/wiki/index.md +1 -1
  104. package/templates/research-canonical/CLAUDE.md +7 -7
  105. package/templates/research-canonical/MAP.md +1 -1
  106. package/templates/research-canonical/TODO.md +1 -1
  107. package/templates/research-canonical/atris.md +2 -2
  108. package/templates/research-canonical/context/README.md +8 -8
  109. package/templates/research-canonical/context/live-workspace.md +1 -1
  110. package/templates/research-canonical/goals.md +2 -2
  111. package/templates/research-canonical/instructions.md +1 -1
  112. package/templates/research-canonical/memory.md +4 -4
  113. package/templates/research-canonical/persona.md +1 -1
  114. package/templates/research-canonical/policies/LESSONS.md +1 -1
  115. package/templates/research-canonical/reports/README.md +1 -1
  116. package/templates/research-canonical/skills/README.md +1 -1
  117. package/templates/research-canonical/team/README.md +1 -1
  118. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  119. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  120. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  121. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  122. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  123. package/templates/research-canonical/wiki/index.md +1 -1
  124. 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
 
@@ -152,7 +322,7 @@ function rowRequests(row) {
152
322
  // What a step looks like from outside: its newest waiting command, and its
153
323
  // yes that is given and not used yet.
154
324
  function stepSummary(requests) {
155
- const pending = requests.filter(r => !r.approved_at && !r.used_at);
325
+ const pending = requests.filter(r => !r.approved_at && !r.used_at && !r.denied_at);
156
326
  const open = requests.find(r => r.approved_at && !r.used_at) || null;
157
327
  const used = requests.filter(r => r.used_at);
158
328
  const last = pending[pending.length - 1] || null;
@@ -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
@@ -200,7 +370,7 @@ function approveYesLock(stateDir, missionId, step, seal, at = new Date().toISOSt
200
370
  const { locks } = effectiveLocks(stateDir, missionId, seal || sealFromState(stateDir, missionId));
201
371
  const lock = locks.find(l => l.step === step);
202
372
  if (!lock) return null;
203
- const waiting = lock.requests.filter(r => !r.approved_at && !r.used_at);
373
+ const waiting = lock.requests.filter(r => !r.approved_at && !r.used_at && !r.denied_at);
204
374
  const target = hash ? waiting.find(r => r.hash === hash) : waiting[waiting.length - 1];
205
375
  if (!target) return null;
206
376
  target.approved_at = at || new Date().toISOString();
@@ -208,6 +378,20 @@ function approveYesLock(stateDir, missionId, step, seal, at = new Date().toISOSt
208
378
  return { ...lock, ...stepSummary(lock.requests), approved: target };
209
379
  }
210
380
 
381
+ // The owner's no: the one waiting request with that hash is told no for good.
382
+ // It never runs, its card is gone, and asking again only hears the no.
383
+ // Without a hash (an older caller), the newest waiting request on the step.
384
+ function denyYesLock(stateDir, missionId, step, { hash = null, at = new Date().toISOString() } = {}) {
385
+ const { locks } = effectiveLocks(stateDir, missionId, sealFromState(stateDir, missionId));
386
+ const lock = locks.find(l => l.step === step);
387
+ const waiting = lock ? lock.requests.filter(r => !r.approved_at && !r.used_at && !r.denied_at) : [];
388
+ const target = hash ? waiting.find(r => r.hash === hash) : waiting[waiting.length - 1];
389
+ if (!target) return null;
390
+ target.denied_at = at;
391
+ saveLocks(stateDir, missionId, locks);
392
+ return { ...lock, denied: target };
393
+ }
394
+
211
395
  // For readers that have no mission record at hand (the desk), the state file
212
396
  // is its own seal: it is only ever used to show and answer cards, never to let
213
397
  // a command through.
@@ -223,7 +407,7 @@ function askedYesLocks(stateDir, missionId) {
223
407
  const cards = [];
224
408
  for (const row of data.locks) {
225
409
  for (const r of rowRequests(row)) {
226
- if (r.approved_at || r.used_at) continue;
410
+ if (r.approved_at || r.used_at || r.denied_at) continue;
227
411
  cards.push({ step: row.step, text: row.text, categories: row.categories, held_command: r.command, held_key: r.key, hash: r.hash, asked_at: r.asked_at });
228
412
  }
229
413
  }
@@ -553,11 +737,15 @@ const ONTO_BRANCH = new Set(['merge', 'commit', 'cherry-pick', 'rebase', 'am', '
553
737
 
554
738
  function readGit(args, ctx, depth = 0) {
555
739
  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
740
  const repo = ctx.forRepo(p.prefix);
559
741
  const sub = p.sub;
560
742
  if (!sub) return CLEAR;
743
+ // config that can reroute matters to a push, or a command that reaches a
744
+ // network remote; local work passes whatever config it runs with
745
+ const risky = [...p.configs.map(configKey), ...ctx.envConfigKeys()].find(k => RISKY_GIT_CONFIG.test(k));
746
+ // an alias given with -c can turn any command into a push
747
+ const reaches = ['push', 'send-pack', 'http-push'].includes(sub) || /^alias\./i.test(risky || '') || talksToNetwork(sub, p.rest, repo);
748
+ if (risky && reaches) return refused(`git config ${risky} can change where a push goes, so it is closed while owner steps wait`);
561
749
  // git never applies an alias named after one of its own commands, so
562
750
  // neither does this: a stored alias.push is ignored and push is push
563
751
  const alias = isGitBuiltin(sub, repo) ? null : repo.alias(sub);
@@ -642,6 +830,7 @@ function gitContext(gitBin, cwd, env = process.env, prefix = []) {
642
830
  config: key => out(['config', '--get', key]) || '',
643
831
  configBool: key => out(['config', '--bool', '--get', key]) || '',
644
832
  configAll: key => (out(['config', '--get-all', key]) || '').split('\n').filter(Boolean),
833
+ remoteNames: () => (out(['remote']) || '').split('\n').filter(Boolean),
645
834
  // with insteadOf, pushInsteadOf and pushurl applied, as git push sees them
646
835
  remoteUrls: (remote, { push = false } = {}) => (out(['remote', 'get-url', ...(push ? ['--push'] : []), '--all', remote]) || '').split('\n').filter(Boolean),
647
836
  alias: name => out(['config', '--get', `alias.${name}`]),
@@ -656,38 +845,34 @@ function gitContext(gitBin, cwd, env = process.env, prefix = []) {
656
845
  };
657
846
  }
658
847
 
659
- // ---- the handoff seal: an allowlist --------------------------------------------------
848
+ // ---- the handoff seal --------------------------------------------------------------
660
849
  //
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.
850
+ // A run woken by a handoff guards git push, not every git command. Nothing
851
+ // local publishes anything, and landing goes through origin/master, not
852
+ // local master; the engines and test suites run far more git than any list
853
+ // can name (3.74.0 to 3.74.4 held 546 harmless commands). What stays guarded
854
+ // is what can reach a network remote: a push, and the -c or environment that
855
+ // could run a push past the wrapper, since git runs a program named in its
856
+ // config with its own folder first on PATH. gh and npm, yarn, pnpm keep their
857
+ // lists below, and atris holds its outward and spend verbs. A bet's owner
858
+ // steps still lock by kind (analyze above).
669
859
 
670
860
  const pass = (args = null) => ({ ok: true, args });
671
861
  const hold = why => ({ ok: false, why });
672
862
  const isHandoffSeal = sealed => sealed.some(s => s.text === HANDOFF_SEAL_TEXT);
673
863
  const PUSH_HINT = 'a branch push runs without a yes as: git push origin <branch>';
674
864
 
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', 'update-index', 'write-tree']);
678
- const GIT_SEALED_GLOBALS = new Set(['--no-pager', '-P', '--no-optional-locks', '--literal-pathspecs', '--version', '--help', '-h']);
679
865
  // the shape of a branch name git accepts, without the corners that read as something else
680
866
  const BRANCH_NAME = /^(?!-)(?!\.)(?!.*\.\.)(?!.*\/\/)(?!.*\/\.)(?!.*@\{)(?!.*\.lock(?:\/|$))[A-Za-z0-9._/-]+(?<![./])$/;
681
867
 
682
868
  // -c a woken run may pass: a value that only turns something off, or a key
683
- // that only changes how output looks. The engines harden their own git calls
684
- // this way (hooks, fsmonitor, askpass, protocols, gc all off). A -c that
685
- // names a program, path, command, alias, helper, pager, editor, or route is
686
- // held, since git runs that program with its own folder first on PATH, where
687
- // a git inside it skips the wrapper; so are the keys whose value is a name,
688
- // URL, or refspec (alias, remote, url, push, include, credential, branch
689
- // routing), where even an empty value can reroute (url.<x>.insteadOf=).
690
- const GIT_DISPLAY_KEYS = new Set(['format.pretty', 'log.showsignature', 'status.showuntrackedfiles', 'core.quotepath', 'core.safecrlf', 'safe.barerepository', 'color.ui']);
869
+ // that only changes how output looks or who a commit is by. The engines
870
+ // harden their own git calls this way. A -c that names a program, path,
871
+ // command, alias, helper, pager, editor, or route is held, and so are the
872
+ // keys whose value is a name, URL, or refspec (alias, remote, url, push,
873
+ // include, credential, branch routing), where even an empty value can
874
+ // reroute (url.<x>.insteadOf=).
875
+ const GIT_DISPLAY_KEYS = new Set(['format.pretty', 'log.showsignature', 'status.showuntrackedfiles', 'core.quotepath', 'core.safecrlf', 'safe.barerepository', 'color.ui', 'user.name', 'user.email']);
691
876
  const GIT_ROUTE_KEYS = /^(alias|remote|url|push|include|includeif|credential)\.|^branch\..+\.(remote|pushremote|merge)$/;
692
877
 
693
878
  function sealedGitConfigOk(pair) {
@@ -700,228 +885,163 @@ function sealedGitConfigOk(pair) {
700
885
  return /^(|false|never|0|no|off)$/i.test(value);
701
886
  }
702
887
 
703
- // Only -C, -c that turns something off, and a few output switches go before
704
- // the command. --config-env and --exec-path are held.
888
+ // The same, set through the environment. GIT_CONFIG_NOSYSTEM only turns the
889
+ // system file off, so it adds nothing git could run.
890
+ function gitEnvProblem(env) {
891
+ const names = Object.keys(env || {}).filter(k => env[k] !== undefined && env[k] !== '');
892
+ return names.find(k => (/^GIT_CONFIG/.test(k) && k !== 'GIT_CONFIG_NOSYSTEM') || k === 'GIT_EXEC_PATH') || null;
893
+ }
894
+
895
+ // git prints these and stops without running the command after them
896
+ const GIT_PRINT_AND_EXIT = new Set(['--exec-path', '--version', '--help', '-h', '--html-path', '--man-path', '--info-path']);
897
+
898
+ // The options before the command. -C, --git-dir, --work-tree, and
899
+ // --namespace pick the repo; a -c that fails the reading above, --config-env,
900
+ // and --exec-path=<dir> are noted as bad, which matters only for a command
901
+ // that talks to a network remote.
705
902
  function sealedGitPrefix(args) {
706
903
  const prefix = [];
904
+ let bad = null;
707
905
  for (let i = 0; i < args.length; i += 1) {
708
906
  const a = args[i];
709
- if (a === '-C' || a === '-c') {
907
+ if (['-C', '-c', '--git-dir', '--work-tree', '--namespace'].includes(a)) {
710
908
  const value = String(args[i + 1] === undefined ? '' : args[i + 1]);
711
- if (a === '-c' && !sealedGitConfigOk(value)) return { bad: `-c ${value}` };
909
+ if (a === '-c' && !sealedGitConfigOk(value)) bad = bad || `-c ${value}`;
712
910
  prefix.push(a, value);
713
911
  i += 1;
714
912
  continue;
715
913
  }
716
- if (GIT_SEALED_GLOBALS.has(a)) { prefix.push(a); continue; }
717
- if (a.startsWith('-')) return { bad: a };
718
- return { prefix, sub: a, rest: args.slice(i + 1) };
914
+ if (/^--config-env(=|$)/.test(a) || a.startsWith('--exec-path=')) bad = bad || a;
915
+ if (a.startsWith('-')) { prefix.push(a); continue; }
916
+ return { prefix, bad, sub: a, rest: args.slice(i + 1) };
719
917
  }
720
- return { prefix, sub: '', rest: [] };
918
+ return { prefix, bad, sub: '', rest: [] };
721
919
  }
722
920
 
723
- // The same, set through the environment. GIT_CONFIG_NOSYSTEM only turns the
724
- // system file off, so it adds nothing git could run.
725
- function gitEnvProblem(env) {
726
- const names = Object.keys(env || {}).filter(k => env[k] !== undefined && env[k] !== '');
727
- return names.find(k => (/^GIT_CONFIG/.test(k) && k !== 'GIT_CONFIG_NOSYSTEM') || k === 'GIT_EXEC_PATH') || null;
728
- }
729
-
730
- // A ref a command writes or switches to must be a plain branch name that is
731
- // not master and does not resolve to master: no @{-1}, @, HEAD~, ^, refs/,
732
- // and no name that is a symbolic ref to master.
733
- function targetProblem(name, repo) {
734
- const n = String(name === undefined ? '' : name);
735
- if (n === 'HEAD' || n.startsWith('refs/') || !BRANCH_NAME.test(n)) return `${n || '(no name)'} is not a plain branch name`;
736
- if (isMainRef(n)) return `${n} is master`;
737
- const full = typeof repo.fullRefName === 'function' ? repo.fullRefName(n) : '';
738
- if (full && isMainRef(full)) return `${n} resolves to master`;
739
- return null;
921
+ // Does this git command talk to a network remote? Pushes, fetches, pulls,
922
+ // ls-remote, and clones to a remote on disk are local work. When the remote
923
+ // cannot be read, it counts as a network one.
924
+ const GIT_REMOTE_SUBS = new Set(['push', 'send-pack', 'http-push', 'fetch', 'pull', 'ls-remote', 'clone']);
925
+ 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']);
926
+
927
+ function talksToNetwork(sub, rest, repo) {
928
+ if (!GIT_REMOTE_SUBS.has(sub)) return false;
929
+ if (sub === 'http-push') return true;
930
+ if (typeof repo.remoteUrls !== 'function') return true;
931
+ const isPush = sub === 'push' || sub === 'send-pack';
932
+ const repoFlag = rest.find(a => /^--repo=/.test(String(a)));
933
+ const words = positionals(rest, GIT_REMOTE_VALUE_FLAGS);
934
+ const branch = repo.currentBranch();
935
+ const targets = sub === 'fetch' && rest.some(a => a === '--all' || a === '--multiple')
936
+ ? (repo.remoteNames ? repo.remoteNames() : ['origin'])
937
+ : [repoFlag ? repoFlag.slice('--repo='.length) : words[0]
938
+ || (isPush && (repo.config(`branch.${branch}.pushRemote`) || repo.config('remote.pushDefault')))
939
+ || repo.config(`branch.${branch}.remote`) || 'origin'];
940
+ return targets.some((target) => {
941
+ const urls = repo.remoteUrls(target, { push: isPush });
942
+ if (urls.length) return !urls.every(isLocalUrl);
943
+ // no URL read: a path is local; a remote name git could not read counts as network
944
+ return !/^(file:\/\/|\/|\.\.?(\/|$)|~)/.test(String(target));
945
+ });
740
946
  }
741
947
 
742
- function sealedGit(args, ctx) {
948
+ function sealedGit(args, ctx, depth = 0) {
743
949
  const p = sealedGitPrefix(args);
744
- if (p.bad) return hold(`git ${p.bad} is not an option a handed-off job may use`);
950
+ if (!p.sub || p.prefix.some(a => GIT_PRINT_AND_EXIT.has(a))) return pass();
951
+ // the lock's own questions leave out options that could break them
952
+ const repo = ctx.forRepo(p.prefix.filter(a => !GIT_PRINT_AND_EXIT.has(a) && !/^--(config-env|exec-path=)/.test(a)));
953
+ // an alias that turns into a push is a push; one that runs a shell could
954
+ // run a git that skips the wrapper
955
+ const alias = isGitBuiltin(p.sub, repo) ? null : repo.alias(p.sub);
956
+ if (alias !== null && alias !== undefined) {
957
+ if (depth >= 5 || alias.startsWith('!')) return hold(`git ${p.sub} is an alias that runs a shell command`);
958
+ return sealedGit([...p.prefix, ...alias.trim().split(/\s+/), ...p.rest], ctx, depth + 1);
959
+ }
960
+ // local work passes in any folder, whatever config the test suite or
961
+ // engine runs git with; only a command that reaches a network remote is read
962
+ if (!talksToNetwork(p.sub, p.rest, repo)) return pass();
963
+ if (p.bad) return hold(`git ${p.bad} can run a program that skips the wrapper`);
745
964
  const envName = gitEnvProblem(typeof ctx.env === 'function' ? ctx.env() : {});
746
965
  if (envName) return hold(`git with ${envName} set can run a program that skips the wrapper`);
747
- if (!p.sub) return pass();
748
- if (GIT_SEALED_READS.has(p.sub) || GIT_SEALED_LOCAL.has(p.sub)) return pass();
749
- // the questions below are asked of the repo the command names
750
- const repo = ctx.forRepo(p.prefix.filter((a, i, all) => a === '-C' || a === '-c' || all[i - 1] === '-C' || all[i - 1] === '-c'));
751
- const rest = p.rest;
752
- switch (p.sub) {
753
- // a new repo has no remote; a template or a git folder elsewhere could bring hooks
754
- case 'init': return rest.some(a => /^--(template|separate-git-dir)(=|$)/.test(a)) ? hold('git init with a template or a git folder elsewhere can bring hooks') : pass();
755
- case 'push': return sealedPush(p, repo);
756
- case 'fetch': return sealedFetch(rest, repo);
757
- case 'commit': return isMainRef(repo.currentBranch()) ? hold('a commit here would land on master') : pass();
758
- case 'stash': {
759
- if (rest[0] !== 'branch') return pass();
760
- const why = targetProblem(positionals(rest.slice(1), new Set())[0], repo);
761
- return why ? hold(`git stash branch: ${why}`) : pass();
762
- }
763
- case 'checkout': case 'switch': return sealedSwitch(p.sub, rest, repo);
764
- case 'branch': return sealedBranch(rest, repo);
765
- case 'config': return sealedConfig(rest);
766
- case 'remote': {
767
- const op = rest.filter(a => a !== '-v' && a !== '--verbose')[0] || '';
768
- return op === '' || op === 'get-url' ? pass() : hold(`git remote ${op} changes where git sends things`);
769
- }
770
- case 'worktree': return sealedWorktree(rest, repo);
771
- default: return hold(`git ${p.sub} is not on the list a handed-off job may run`);
772
- }
966
+ // the low-level ways to update a remote are push too
967
+ if (['send-pack', 'http-push'].includes(p.sub)) return hold(`git ${p.sub} updates a remote without git push`);
968
+ if (p.sub !== 'push') return pass();
969
+ return sealedPush(p, repo);
773
970
  }
774
971
 
775
- // One branch, to origin, by name or as HEAD:refs/heads/<branch>, with no
776
- // option but -u and quiet or verbose. It runs with followTags off, origin's
777
- // mirror off, and push options cleared, and with the refspec written out in
778
- // full, so origin's own push lines cannot send it anywhere else.
779
- function sealedPush(p, repo) {
780
- let upstream = false;
781
- const quiet = [];
782
- const words = [];
783
- for (const a of p.rest) {
784
- if (a === '-u' || a === '--set-upstream') { upstream = true; continue; }
785
- if (['-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress'].includes(a)) { quiet.push(a); continue; }
786
- if (a.startsWith('-')) return hold(`git push ${a} is not an option a handed-off job may use; ${PUSH_HINT}`);
787
- words.push(a);
788
- }
789
- if (words.length !== 2 || words[0] !== 'origin') return hold(`a handed-off job pushes one branch to origin; ${PUSH_HINT}`);
790
- // HEAD:<branch> is written out as HEAD:refs/heads/<branch>, so a name that
791
- // matches a tag on origin can never update the tag
792
- const head = words[1].match(/^HEAD:(?:refs\/heads\/(?=.))?(?!refs\/)(.+)$/);
793
- const branch = head ? head[1] : words[1];
794
- if (!head && (branch.includes(':') || branch.startsWith('refs/'))) return hold(`git push ${words[1]} names more than a branch; ${PUSH_HINT}`);
795
- if (isMainRef(branch)) return hold(`git push to master lands on master; ${PUSH_HINT}`);
796
- const why = targetProblem(branch, repo);
797
- if (why) return hold(`git push ${words[1]}: ${why}; ${PUSH_HINT}`);
798
- if (!head && repo.isTag(branch) && !repo.isBranch(branch)) return hold(`git push ${branch} names a tag; ${PUSH_HINT}`);
799
- const fetchUrls = repo.remoteUrls('origin');
800
- const pushUrls = repo.remoteUrls('origin', { push: true });
801
- if (!fetchUrls.length) return hold('origin is not a remote here');
802
- if (pushUrls.length !== 1 || pushUrls[0] !== fetchUrls[0]) return hold('origin pushes somewhere other than where it fetches from');
803
- if (repo.configBool('remote.origin.mirror') === 'true') return hold('origin is set up as a mirror, which pushes every branch and tag');
804
- const prefix = p.prefix;
805
- return pass([...prefix, '-c', 'push.followTags=false', '-c', 'remote.origin.mirror=false', '-c', 'push.pushOption=', 'push', '--no-follow-tags',
806
- ...(upstream ? ['--set-upstream'] : []), ...quiet, 'origin', `${head ? 'HEAD' : `refs/heads/${branch}`}:refs/heads/${branch}`]);
972
+ // a remote that is a folder on this computer: a path, a file:// URL, or a
973
+ // remote whose every push URL is one
974
+ function isLocalUrl(url) {
975
+ const u = String(url || '');
976
+ if (/^file:\/\//.test(u) || /^(\/|\.\.?(\/|$)|~)/.test(u)) return true;
977
+ return !u.includes(':');
807
978
  }
808
979
 
809
- 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']);
980
+ 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']);
810
981
 
811
- function sealedFetch(rest, repo) {
812
- const words = [];
813
- for (const a of rest) {
814
- if (FETCH_FLAGS.has(a) || /^--(depth|deepen|shallow-since|shallow-exclude|filter|jobs)=/.test(a)) continue;
815
- if (a.startsWith('-')) return hold(`git fetch ${a} is not an option a handed-off job may use`);
816
- words.push(a);
982
+ // A push to a remote on disk runs as written: tests push tags and master to
983
+ // scratch repos. To a network remote it runs only as branch pushes that are
984
+ // not master, with followTags, the remote's mirror setting, and push options
985
+ // off, and each refspec written out in full, so the remote's own push lines
986
+ // cannot send it anywhere else. Tags, master, mirror, --all, --prune, a
987
+ // matching push (":"), and deleting master are held.
988
+ function sealedPush(p, repo) {
989
+ const words = positionals(p.rest, PUSH_VALUE_FLAGS);
990
+ const repoFlag = p.rest.find(a => /^--repo=/.test(a));
991
+ const named = repoFlag ? repoFlag.slice('--repo='.length) : words[0];
992
+ const remote = named || repo.config(`branch.${repo.currentBranch()}.pushRemote`) || repo.config('remote.pushDefault') || repo.config(`branch.${repo.currentBranch()}.remote`) || 'origin';
993
+ const configured = repo.remoteUrls(remote, { push: true });
994
+ if (configured.length ? configured.every(isLocalUrl) : isLocalUrl(remote)) return pass();
995
+ if (isGitTrue(repo.configBool(`remote.${remote}.mirror`))) return hold(`${remote} is set up as a mirror, which pushes every branch and tag`);
996
+ const kept = [];
997
+ let deleting = false;
998
+ for (const a of p.rest) {
999
+ if (a === '-d' || a === '--delete') { deleting = true; continue; }
1000
+ if (PUSH_FLAGS_OK.has(a) || /^--force-with-lease(=|$)/.test(a)) { kept.push(a); continue; }
1001
+ if (a.startsWith('-')) return hold(`git push ${a} to a network remote can move master or tags; ${PUSH_HINT}`);
817
1002
  }
818
- const remote = words[0] || repo.config(`branch.${repo.currentBranch()}.remote`) || 'origin';
819
- if (remote !== 'origin') return hold('a handed-off job fetches from origin only');
820
- if (words.slice(1).some(w => /[:+*^]/.test(w))) return hold('a fetch refspec can write a local branch');
821
- // origin's own fetch lines may only fill remote-tracking refs and tags
822
- for (const spec of repo.configAll('remote.origin.fetch')) {
823
- const dest = spec.includes(':') ? spec.split(':').pop() : '';
824
- if (dest && !/^refs\/(remotes|tags)\//.test(dest)) return hold('origin is set up to fetch into local branches');
1003
+ const specs = repoFlag ? words : words.slice(1);
1004
+ const current = repo.currentBranch();
1005
+ const out = [];
1006
+ if (!specs.length) {
1007
+ // no refspec: the branch's own push target, as one branch
1008
+ const dest = String(repo.pushDest() || '').replace(/^[^/]+\//, '');
1009
+ if (!dest || deleting) return hold(`git push with no branch named could push more than one; ${PUSH_HINT}`);
1010
+ specs.push(`HEAD:${dest}`);
825
1011
  }
826
- return pass();
827
- }
828
-
829
- 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']);
830
-
831
- function sealedSwitch(sub, rest, repo) {
832
- const create = sub === 'checkout' ? new Set(['-b', '-B', '--orphan']) : new Set(['-c', '-C', '--create', '--force-create', '--orphan']);
833
- const words = [];
834
- let creating = false;
835
- let paths = false;
836
- for (let i = 0; i < rest.length; i += 1) {
837
- const a = rest[i];
838
- if (a === '--') { paths = true; break; }
839
- const inline = a.match(/^(--orphan|--create|--force-create)=(.*)$/);
840
- if (create.has(a) || inline) {
841
- const name = inline ? inline[2] : String(rest[i + 1] === undefined ? '' : rest[i + 1]);
842
- if (!inline) i += 1;
843
- const why = targetProblem(name, repo);
844
- if (why) return hold(`git ${sub} ${a}: ${why}`);
845
- creating = true;
846
- continue;
1012
+ for (const spec of specs) {
1013
+ const force = spec.startsWith('+') ? '+' : '';
1014
+ const body = spec.replace(/^\+/, '');
1015
+ if (body === ':' || body.includes('*')) return hold(`git push ${spec} pushes many branches at once; ${PUSH_HINT}`);
1016
+ let src;
1017
+ let dst;
1018
+ if (deleting) { src = ''; dst = body; } else if (body.includes(':')) {
1019
+ [src, dst] = [body.slice(0, body.indexOf(':')), body.slice(body.indexOf(':') + 1)];
1020
+ } else if (body === 'HEAD' || body === '@') { src = 'HEAD'; dst = current; } else {
1021
+ if (body.startsWith('refs/tags/') || (repo.isTag(body) && !repo.isBranch(body))) return hold(`git push ${spec} pushes a tag; ${PUSH_HINT}`);
1022
+ dst = body.replace(/^refs\/heads\//, '');
1023
+ src = `refs/heads/${dst}`;
847
1024
  }
848
- if (SWITCH_FLAGS.has(a) || /^--(conflict|track|pathspec-from-file)=/.test(a)) continue;
849
- if (a.startsWith('-') && a !== '-') return hold(`git ${sub} ${a} is not an option a handed-off job may use`);
850
- words.push(a);
851
- }
852
- if (creating || !words.length) return pass();
853
- const target = words[0];
854
- // a checkout with paths restores files from the named commit; it moves no branch
855
- if (sub === 'checkout' && (paths || words.length > 1)) return pass();
856
- const why = targetProblem(target, repo);
857
- return why ? hold(`git ${sub} ${target}: ${why}`) : pass();
858
- }
859
-
860
- function sealedBranch(rest, repo) {
861
- const LIST_VALUE = new Set(['--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--format', '--sort']);
862
- 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']);
863
- const CREATE = new Set(['-t', '--track', '--no-track', '-f', '--force', '--create-reflog', '--recurse-submodules']);
864
- let listing = false;
865
- let deleting = false;
866
- const words = [];
867
- for (let i = 0; i < rest.length; i += 1) {
868
- const a = rest[i];
869
- if (a === '--') { words.push(...rest.slice(i + 1)); break; }
870
- if (LIST_VALUE.has(a)) { listing = true; i += 1; continue; }
871
- if (/^--(contains|no-contains|merged|no-merged|points-at|format|sort)=/.test(a)) { listing = true; continue; }
872
- if (a === '-l' || a === '--list' || a === '--show-current') { listing = true; continue; }
873
- if (LIST.has(a) || CREATE.has(a) || /^--(color|column|abbrev|track)=/.test(a)) continue;
874
- if (a === '-d' || a === '-D' || a === '--delete') { deleting = true; continue; }
875
- if (a.startsWith('-')) return hold(`git branch ${a} is not an option a handed-off job may use`);
876
- words.push(a);
877
- }
878
- if (deleting) {
879
- const why = words.map(w => targetProblem(w, repo)).find(Boolean);
880
- return why ? hold(`git branch -d: ${why}`) : pass();
1025
+ const name = String(dst || '').replace(/^refs\/heads\/(?=.)/, '');
1026
+ if (String(dst || '').startsWith('refs/') && !String(dst).startsWith('refs/heads/')) return hold(`git push ${spec} does not go to a branch; ${PUSH_HINT}`);
1027
+ if (isMainRef(name)) return hold(`git push to master lands on master; ${PUSH_HINT}`);
1028
+ const why = targetProblem(name, repo);
1029
+ if (why) return hold(`git push ${spec}: ${why}; ${PUSH_HINT}`);
1030
+ out.push(`${force}${src}:refs/heads/${name}`);
881
1031
  }
882
- if (listing || !words.length) return pass();
883
- // git branch <new> [<start>]: the start can be anything, the name it writes cannot
884
- const why = targetProblem(words[0], repo);
885
- return why ? hold(`git branch ${words[0]}: ${why}`) : pass();
886
- }
887
-
888
- // git config reads only
889
- function sealedConfig(rest) {
890
- 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');
891
- if (rest.some(a => /^(--get|--get-all|--get-regexp|--get-urlmatch|--list|-l|--get-color|--get-colorbool)$/.test(String(a).split('=')[0]))) return pass();
892
- const words = positionals(rest, new Set(['--file', '-f', '--blob', '--type', '--default']));
893
- if (words[0] === 'get' || words[0] === 'list' || words.length === 1) return pass();
894
- return hold('git config writes change what git does');
1032
+ const mirrorOff = /^[A-Za-z0-9._-]+$/.test(remote) ? ['-c', `remote.${remote}.mirror=false`] : [];
1033
+ return pass([...p.prefix, '-c', 'push.followTags=false', ...mirrorOff, '-c', 'push.pushOption=', 'push', '--no-follow-tags', ...kept, remote, ...out]);
895
1034
  }
896
1035
 
897
- function sealedWorktree(rest, repo) {
898
- const [op = '', ...more] = rest;
899
- if (op === 'list') return pass();
900
- if (op !== 'add') return hold(`git worktree ${op} changes another working folder`);
901
- const words = [];
902
- let named = false;
903
- let detached = false;
904
- for (let i = 0; i < more.length; i += 1) {
905
- const a = more[i];
906
- if (a === '-b' || a === '-B') {
907
- const name = String(more[i + 1] === undefined ? '' : more[i + 1]);
908
- i += 1;
909
- const why = targetProblem(name, repo);
910
- if (why) return hold(`git worktree add ${a}: ${why}`);
911
- named = true;
912
- continue;
913
- }
914
- if (a === '--detach' || a === '-d') { detached = true; continue; }
915
- if (a === '--reason') { i += 1; continue; }
916
- if (['-f', '--force', '--checkout', '--no-checkout', '--lock', '-q', '--quiet', '--track', '--no-track', '--guess-remote', '--no-guess-remote', '--orphan'].includes(a) || a.startsWith('--reason=')) continue;
917
- if (a.startsWith('-')) return hold(`git worktree add ${a} is not an option a handed-off job may use`);
918
- words.push(a);
919
- }
920
- if (named || detached) return pass();
921
- // with no -b, git checks out the named branch, or one named after the folder
922
- const target = words[1] !== undefined ? words[1] : path.basename(String(words[0] || ''));
923
- const why = targetProblem(target, repo);
924
- return why ? hold(`git worktree add ${target}: ${why}`) : pass();
1036
+ // A ref a push writes must be a plain branch name that is not master and
1037
+ // does not resolve to master: no @{-1}, @, HEAD~, ^, refs/.
1038
+ function targetProblem(name, repo) {
1039
+ const n = String(name === undefined ? '' : name);
1040
+ if (n === 'HEAD' || n.startsWith('refs/') || !BRANCH_NAME.test(n)) return `${n || '(no name)'} is not a plain branch name`;
1041
+ if (isMainRef(n)) return `${n} is master`;
1042
+ const full = typeof repo.fullRefName === 'function' ? repo.fullRefName(n) : '';
1043
+ if (full && isMainRef(full)) return `${n} resolves to master`;
1044
+ return null;
925
1045
  }
926
1046
 
927
1047
  // gh: reads, plus opening, reading, and commenting on a PR
@@ -967,37 +1087,19 @@ function sealedPackage(name, args) {
967
1087
  return PKG_SEALED[name].includes(sub) ? pass() : hold(`${name} ${sub || '(no command)'} is not on the list a handed-off job may run`);
968
1088
  }
969
1089
 
970
- // atris: the run's own machinery and plain reads. A verb names every sub it
971
- // allows (true: every sub stays local; a function: the subs it allows).
972
- // Anything else is held: business, site, pay, feed, deploy, release, task
973
- // accept, sweep and review runs (they land work), member run and wake,
974
- // mission start and run, and every unknown word (which atris reads as a
975
- // request to start work).
976
- const ATRIS_SEALED = {
977
- task: ['list', 'ls', 'show', 'inspect', 'status', 'events', 'where', 'lineage', 'note', 'say', 'claim', 'start', 'result', 'receipt', 'ready', 'done', 'finish', 'new', 'add'],
978
- member: ['status', 'handoff'],
979
- mission: ['status', 'tick'],
980
- slop: true,
981
- verify: true,
982
- status: true,
983
- log: sub => sub !== 'sync',
984
- youtube: (sub, args) => sub === 'search' && !args.includes('--paid'),
985
- help: true,
986
- version: true,
987
- };
988
-
989
- function sealedAtris(args, missionId) {
990
- const [cmd = '', sub = ''] = args;
991
- if (args.length === 1 && ['--version', '-v', '--help', '-h'].includes(cmd)) return pass();
992
- // the boot read: atris prints the welcome screen for its spec file and stops
993
- if (args.length === 1 && (cmd === 'atris.md' || cmd.endsWith('/atris.md'))) return pass();
994
- const allowed = Object.prototype.hasOwnProperty.call(ATRIS_SEALED, cmd) ? ATRIS_SEALED[cmd] : null;
995
- if (!allowed) return hold(`atris ${cmd} is not on the list a handed-off job may run`);
996
- const subOk = allowed === true || ['help', '--help', '-h'].includes(sub)
997
- || (typeof allowed === 'function' ? allowed(sub, args) : allowed.includes(sub));
998
- if (!subOk) return hold(`atris ${cmd} ${sub} is not on the list a handed-off job may run`);
999
- // a tick of another job would run that job's work without this seal
1000
- if (cmd === 'mission' && sub === 'tick' && positionals(args.slice(2), new Set())[0] !== missionId) return hold('atris mission tick runs only this job');
1090
+ // atris in a woken run: every verb runs except the outward and spend ones,
1091
+ // read the same way a bet reads them (readAtris), plus these by name and
1092
+ // accepting a task, which lands it.
1093
+ const ATRIS_SEALED_HELD = new Set(['spend', 'publish', 'deploy', 'release', 'land', 'merge', 'accept', 'business', 'site', 'pay', 'feed']);
1094
+ const TASK_ACCEPT = new Set(['accept', 'auto-accept-certified', 'auto-accept', 'accept-group']);
1095
+
1096
+ function sealedAtris(args) {
1097
+ const [cmd = '', sub = ''] = positionals(args, new Set(['--account', '--as', '--from', '--to', '--channel']));
1098
+ if (ATRIS_SEALED_HELD.has(cmd)) return hold(`atris ${cmd} goes out, lands, or spends`);
1099
+ if (cmd === 'task' && (TASK_ACCEPT.has(sub) || args.some(a => a === '--accept' || a === '--auto-accept'))) return hold('accepting a task lands it');
1100
+ const read = readAtris(args);
1101
+ if (read.cats.length) return hold(`atris ${cmd} would ${read.cats.map(c => CATEGORY_WORDS[c]).join(' and ')}`);
1102
+ if (read.refuse && ATRIS_REFUSED.has(cmd)) return hold(read.refuse);
1001
1103
  return pass();
1002
1104
  }
1003
1105
 
@@ -1006,7 +1108,7 @@ function sealedVerdict(tool, args, ctx, missionId) {
1006
1108
  if (name === 'git') return sealedGit(args, ctx);
1007
1109
  if (name === 'gh') return sealedGh(args);
1008
1110
  if (name === 'npm' || name === 'yarn' || name === 'pnpm') return sealedPackage(name, args);
1009
- if (name === 'atris') return sealedAtris(args, missionId);
1111
+ if (name === 'atris') return sealedAtris(args);
1010
1112
  return hold(`${name} is not on the list a handed-off job may run`);
1011
1113
  }
1012
1114
 
@@ -1068,6 +1170,10 @@ function check({ stateDir, missionId, seal, tool, args = [], gitBin = null, cwd
1068
1170
  saveLocks(stateDir, missionId, locks);
1069
1171
  return { consume: owners.map(l => l.step), claims };
1070
1172
  }
1173
+ // the owner said no to this exact command: it stays held, and no new card is asked
1174
+ if (owners.some(l => l.requests.some(r => r.hash === hash && r.denied_at && !r.approved_at))) {
1175
+ return { hold: true, steps: owners.map(l => l.step), message: `held: the owner said no to "${shown}". do not run it again; leave it undone and go on with the rest.` };
1176
+ }
1071
1177
  // held: a request on each step still missing a yes, one per exact command
1072
1178
  owners.forEach((lock, n) => {
1073
1179
  if (ready[n]) return;
@@ -1092,6 +1198,8 @@ function check({ stateDir, missionId, seal, tool, args = [], gitBin = null, cwd
1092
1198
  function consume() {}
1093
1199
 
1094
1200
  module.exports = {
1201
+ describeCommand,
1202
+ plainWhat,
1095
1203
  HELD_EXIT,
1096
1204
  handoffParent,
1097
1205
  handoffSeal,
@@ -1100,6 +1208,7 @@ module.exports = {
1100
1208
  gitContext,
1101
1209
  approveYesLock,
1102
1210
  askedYesLocks,
1211
+ denyYesLock,
1103
1212
  check,
1104
1213
  consume,
1105
1214
  effectiveLocks,