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.
- package/FOR_AGENTS.md +12 -12
- package/atris/AGENTS.md +7 -7
- package/atris/CLAUDE.md +10 -10
- package/atris/policies/atris-backend.md +1 -1
- package/atris/policies/atris-design.md +71 -71
- package/atris/policies/design-seed.md +28 -28
- package/atris/policies/writing.md +1 -1
- package/atris/skills/apps/SKILL.md +3 -3
- package/atris/skills/atris-feedback/SKILL.md +2 -2
- package/atris/skills/autopilot/SKILL.md +10 -10
- package/atris/skills/blocks/SKILL.md +16 -16
- package/atris/skills/calendar/SKILL.md +1 -1
- package/atris/skills/clawhub/atris/SKILL.md +4 -4
- package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
- package/atris/skills/create-app/SKILL.md +18 -18
- package/atris/skills/create-member/SKILL.md +11 -11
- package/atris/skills/design/SKILL.md +1 -1
- package/atris/skills/drive/SKILL.md +25 -25
- package/atris/skills/email-agent/SKILL.md +11 -11
- package/atris/skills/endgame/SKILL.md +33 -33
- package/atris/skills/magic-inbox/SKILL.md +15 -15
- package/atris/skills/notion/SKILL.md +1 -1
- package/atris/skills/ramp/SKILL.md +7 -7
- package/atris/skills/research-search/SKILL.md +7 -7
- package/atris/skills/slides/SKILL.md +7 -7
- package/atris/skills/tidy/SKILL.md +11 -11
- package/atris/skills/wake/SKILL.md +11 -11
- package/atris/skills/writing/SKILL.md +5 -5
- package/atris/team/_template/SOUL.md +3 -3
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris/team/launcher/MEMBER.md +2 -2
- package/atris/team/mission-lead/MISSION.md +7 -7
- package/atris/team/opus-overnight/MISSION.md +16 -16
- package/atris/team/opus-overnight/STEERING.md +5 -5
- package/atris.md +9 -0
- package/bin/atris.js +16 -3
- package/commands/campaign.js +17 -2
- package/commands/card.js +19 -12
- package/commands/engine.js +92 -17
- package/commands/gm.js +30 -0
- package/commands/idea.js +263 -0
- package/commands/init.js +11 -2
- package/commands/land.js +39 -18
- package/commands/mission.js +4 -3
- package/commands/ninety-nine.js +288 -0
- package/commands/one-lap.js +46 -27
- package/commands/reel.js +12 -7
- package/commands/sync.js +10 -2
- package/commands/task.js +4 -2
- package/commands/xp.js +68 -15
- package/commands/zen.js +10 -3
- package/lib/campaign-keep.js +23 -8
- package/lib/campaign-loop.js +24 -5
- package/lib/career-xp-lock.js +61 -0
- package/lib/career-xp-root.js +61 -0
- package/lib/claude-boot-block.js +7 -4
- package/lib/engine-registry.js +139 -1
- package/lib/fleet.js +11 -2
- package/lib/gm-bet.js +734 -42
- package/lib/gm-card.js +431 -0
- package/lib/gm-phone-page.js +486 -717
- package/lib/gm-phone.js +33 -34
- package/lib/gm-season.js +14 -0
- package/lib/headless-chrome.js +39 -0
- package/lib/idea-gate.js +33 -0
- package/lib/idea-inbox.js +220 -0
- package/lib/idea-ledger.js +196 -0
- package/lib/idea-tick.js +384 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +1 -0
- package/lib/ninety-nine-sync.js +156 -0
- package/lib/ninety-nine.js +247 -0
- package/lib/opening-lines.js +94 -0
- package/lib/roster-markdown.js +39 -1
- package/lib/roster-models.js +1 -1
- package/lib/roster-swap.js +353 -0
- package/lib/spawn-env.js +29 -2
- package/lib/task-db.js +8 -4
- package/lib/wish-audit.js +14 -0
- package/lib/yes-lock.js +365 -256
- package/lib/zen-apply.js +6 -3
- package/lib/zen.js +28 -1
- package/package.json +1 -1
- package/templates/business-starter/CLAUDE.md +7 -7
- package/templates/business-starter/MAP.md +1 -1
- package/templates/business-starter/TODO.md +1 -1
- package/templates/business-starter/atris.md +2 -2
- package/templates/business-starter/context/README.md +6 -6
- package/templates/business-starter/context/live-workspace.md +1 -1
- package/templates/business-starter/goals.md +1 -1
- package/templates/business-starter/instructions.md +1 -1
- package/templates/business-starter/memory.md +4 -4
- package/templates/business-starter/persona.md +3 -3
- package/templates/business-starter/policies/LESSONS.md +1 -1
- package/templates/business-starter/reports/README.md +1 -1
- package/templates/business-starter/skills/README.md +1 -1
- package/templates/business-starter/team/README.md +1 -1
- package/templates/business-starter/team/START_HERE.md +1 -1
- package/templates/business-starter/team/_template/SOUL.md +3 -3
- package/templates/business-starter/team/comms/SOUL.md +2 -2
- package/templates/business-starter/team/ops/SOUL.md +4 -4
- package/templates/business-starter/team/research/SOUL.md +3 -3
- package/templates/business-starter/wiki/index.md +1 -1
- package/templates/research-canonical/CLAUDE.md +7 -7
- package/templates/research-canonical/MAP.md +1 -1
- package/templates/research-canonical/TODO.md +1 -1
- package/templates/research-canonical/atris.md +2 -2
- package/templates/research-canonical/context/README.md +8 -8
- package/templates/research-canonical/context/live-workspace.md +1 -1
- package/templates/research-canonical/goals.md +2 -2
- package/templates/research-canonical/instructions.md +1 -1
- package/templates/research-canonical/memory.md +4 -4
- package/templates/research-canonical/persona.md +1 -1
- package/templates/research-canonical/policies/LESSONS.md +1 -1
- package/templates/research-canonical/reports/README.md +1 -1
- package/templates/research-canonical/skills/README.md +1 -1
- package/templates/research-canonical/team/README.md +1 -1
- package/templates/research-canonical/team/eval/SOUL.md +1 -1
- package/templates/research-canonical/team/experiment/SOUL.md +2 -2
- package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
- package/templates/research-canonical/team/literature/SOUL.md +2 -2
- package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
- package/templates/research-canonical/wiki/index.md +1 -1
- 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
|
-
|
|
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
|
|
848
|
+
// ---- the handoff seal --------------------------------------------------------------
|
|
660
849
|
//
|
|
661
|
-
// A run woken by a handoff
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
// name
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
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
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
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
|
-
//
|
|
704
|
-
//
|
|
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 (
|
|
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))
|
|
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 (
|
|
717
|
-
if (a.startsWith('-'))
|
|
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
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
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.
|
|
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
|
-
|
|
748
|
-
if (
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
//
|
|
776
|
-
//
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const dest =
|
|
824
|
-
if (dest
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
849
|
-
if (
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
883
|
-
|
|
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
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
|
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,
|