@join-pantheon/cli 0.20.0 → 0.21.0
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/README.md +14 -1
- package/dist/accounts.js +106 -1
- package/dist/agents.js +4 -0
- package/dist/cli.js +85 -10
- package/dist/client.js +23 -8
- package/dist/config.js +14 -0
- package/dist/doctor.js +9 -0
- package/dist/mcp.js +43 -9
- package/dist/notifier.js +26 -6
- package/dist/notify.js +1 -1
- package/dist/pending.js +3 -1
- package/dist/plans.js +46 -0
- package/dist/profile.js +5 -0
- package/dist/protocol.js +13 -1
- package/dist/quickstart.js +2 -1
- package/dist/relay.js +219 -21
- package/dist/roster-file.js +15 -2
- package/dist/setup-guide.js +238 -0
- package/dist/untrusted.js +16 -3
- package/dist/worker.js +78 -13
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -67,7 +67,10 @@ every Conductor window, nothing to commit), the wake-up hook so an agent finishe
|
|
|
67
67
|
waiting for, the **worker** so the machine answers the team even while its human is away (on by
|
|
68
68
|
default when run inside a git repo; `--no-worker` opts out — and a peer still needs an explicit
|
|
69
69
|
`--may-wake` grant before they can use it), native decision alerts, and the menu bar. Each command
|
|
70
|
-
ends with a "You're set" summary
|
|
70
|
+
ends with a "You're set" summary, then a short guided setup on a terminal: whether teammates may wake
|
|
71
|
+
your machine while you are away, what a woken run may execute, who you are and which decisions are
|
|
72
|
+
yours alone (so agents stop and ask you), and which alerts you want. Plain questions, mostly options;
|
|
73
|
+
`pantheon setup` re-runs it any time. Restart your agent once and ask it *"check pantheon_status"*.
|
|
71
74
|
|
|
72
75
|
Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
|
|
73
76
|
`pantheon install --user` (from your project's repo).
|
|
@@ -89,6 +92,15 @@ pantheon: that invite was already used by "alex" — ask for a fresh one
|
|
|
89
92
|
|
|
90
93
|
Need another (a third person, or your own second machine)? `pantheon invite`.
|
|
91
94
|
|
|
95
|
+
### Plans on the hosted relay
|
|
96
|
+
|
|
97
|
+
A free account creates **one space** with **two people** in it — you and one invite. Personas,
|
|
98
|
+
messages and history are not limited. Joining spaces other people created is not limited either,
|
|
99
|
+
so a colleague on a bigger plan can always invite you. When you hit a limit the command says so
|
|
100
|
+
and what to do; `pantheon status` shows your plan and how much of it you are using. Need more?
|
|
101
|
+
[joinpantheon.network](https://www.joinpantheon.network). A space you founded and no longer need
|
|
102
|
+
can be dissolved with `pantheon space delete <team> --yes`, which frees the slot.
|
|
103
|
+
|
|
92
104
|
### Reaching each other across cities
|
|
93
105
|
|
|
94
106
|
On the hosted relay there is nothing to work out: every signed-in machine dials
|
|
@@ -119,6 +131,7 @@ agent opened in a folder bound to no team refuses to send rather than guess.
|
|
|
119
131
|
pantheon spaces # your teams, which repo is bound to which, where this folder points
|
|
120
132
|
pantheon use mobile --here # bind this repo to a team (and register the repo with it)
|
|
121
133
|
pantheon repo list # the repos a team works from — a delivery must come from one of them
|
|
134
|
+
pantheon repo add --base main # the branch workers cut from and deliveries are judged against (default: origin's HEAD)
|
|
122
135
|
pantheon leave mobile # drop one team from this machine
|
|
123
136
|
pantheon profile set --space mobile --human-required pricing # authority that applies in one team only
|
|
124
137
|
```
|
package/dist/accounts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_PLANS, parsePlans, parseLimits, planLimits } from "./plans.js";
|
|
1
2
|
import { createRequire } from "node:module";
|
|
2
3
|
import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
3
4
|
import { chmodSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
@@ -104,7 +105,24 @@ export class Accounts {
|
|
|
104
105
|
kind TEXT NOT NULL,
|
|
105
106
|
created_at INTEGER NOT NULL
|
|
106
107
|
);
|
|
108
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
109
|
+
key TEXT PRIMARY KEY,
|
|
110
|
+
json TEXT NOT NULL,
|
|
111
|
+
updated_at INTEGER NOT NULL
|
|
112
|
+
);
|
|
113
|
+
CREATE TABLE IF NOT EXISTS spaces (
|
|
114
|
+
name TEXT PRIMARY KEY,
|
|
115
|
+
owner_account TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
|
116
|
+
created_at INTEGER NOT NULL,
|
|
117
|
+
deleted_at INTEGER
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_account);
|
|
107
120
|
`);
|
|
121
|
+
const cols = new Set(this.db.prepare("PRAGMA table_info(accounts)").all().map((c) => c.name));
|
|
122
|
+
if (!cols.has("plan"))
|
|
123
|
+
this.db.exec("ALTER TABLE accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'");
|
|
124
|
+
if (!cols.has("limits_json"))
|
|
125
|
+
this.db.exec("ALTER TABLE accounts ADD COLUMN limits_json TEXT");
|
|
108
126
|
}
|
|
109
127
|
close() { this.db.close(); }
|
|
110
128
|
accountByUsername(username) {
|
|
@@ -201,7 +219,7 @@ export class Accounts {
|
|
|
201
219
|
else {
|
|
202
220
|
const id = newId("acct");
|
|
203
221
|
run("INSERT INTO accounts (id, email, username, status, created_at, tos_at) VALUES (?, ?, ?, 'active', ?, ?)").run(id, e, v.pending_username, now(), now());
|
|
204
|
-
account = { id, email: e, username: v.pending_username, status: "active", created_at: now(), tos_at: now() };
|
|
222
|
+
account = { id, email: e, username: v.pending_username, status: "active", created_at: now(), tos_at: now(), plan: "free", limits_json: null };
|
|
205
223
|
}
|
|
206
224
|
}
|
|
207
225
|
else {
|
|
@@ -340,6 +358,93 @@ export class Accounts {
|
|
|
340
358
|
recentVerifications(limit = 50) {
|
|
341
359
|
return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
342
360
|
}
|
|
361
|
+
plans() {
|
|
362
|
+
const row = this.db.prepare("SELECT json FROM settings WHERE key = 'plans'").get();
|
|
363
|
+
if (row) {
|
|
364
|
+
try {
|
|
365
|
+
const p = parsePlans(JSON.parse(row.json));
|
|
366
|
+
if (p)
|
|
367
|
+
return p;
|
|
368
|
+
}
|
|
369
|
+
catch { }
|
|
370
|
+
}
|
|
371
|
+
if (process.env.PANTHEON_PLANS_JSON) {
|
|
372
|
+
try {
|
|
373
|
+
const p = parsePlans(JSON.parse(process.env.PANTHEON_PLANS_JSON));
|
|
374
|
+
if (p)
|
|
375
|
+
return p;
|
|
376
|
+
}
|
|
377
|
+
catch { }
|
|
378
|
+
}
|
|
379
|
+
return structuredClone(DEFAULT_PLANS);
|
|
380
|
+
}
|
|
381
|
+
setPlans(plans) {
|
|
382
|
+
if (!parsePlans(plans))
|
|
383
|
+
throw new Error("malformed plan table");
|
|
384
|
+
this.db.prepare("INSERT INTO settings (key, json, updated_at) VALUES ('plans', ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at")
|
|
385
|
+
.run(JSON.stringify(plans), now());
|
|
386
|
+
}
|
|
387
|
+
setPlan(accountId, plan) {
|
|
388
|
+
if (!this.plans()[plan])
|
|
389
|
+
return false;
|
|
390
|
+
return this.db.prepare("UPDATE accounts SET plan = ? WHERE id = ?").run(plan, accountId).changes > 0;
|
|
391
|
+
}
|
|
392
|
+
setLimits(accountId, json) {
|
|
393
|
+
if (json !== null) {
|
|
394
|
+
let ok = false;
|
|
395
|
+
try {
|
|
396
|
+
ok = !!parseLimits(JSON.parse(json));
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
ok = false;
|
|
400
|
+
}
|
|
401
|
+
if (!ok)
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
return this.db.prepare("UPDATE accounts SET limits_json = ? WHERE id = ?").run(json, accountId).changes > 0;
|
|
405
|
+
}
|
|
406
|
+
limitsFor(acct) { return planLimits(this.plans(), acct.plan, acct.limits_json); }
|
|
407
|
+
planCounts() {
|
|
408
|
+
const out = {};
|
|
409
|
+
for (const r of this.db.prepare("SELECT plan, COUNT(*) c FROM accounts GROUP BY plan").all())
|
|
410
|
+
out[r.plan] = r.c;
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
planLimitHits() {
|
|
414
|
+
const m = new Map();
|
|
415
|
+
for (const r of this.db.prepare("SELECT actor_account a, COUNT(*) c FROM audit WHERE event = 'plan_limit' AND actor_account IS NOT NULL GROUP BY actor_account").all())
|
|
416
|
+
m.set(r.a, r.c);
|
|
417
|
+
return m;
|
|
418
|
+
}
|
|
419
|
+
recordSpace(name, ownerAccount) {
|
|
420
|
+
this.db.prepare("INSERT INTO spaces (name, owner_account, created_at, deleted_at) VALUES (?, ?, ?, NULL) " +
|
|
421
|
+
"ON CONFLICT(name) DO UPDATE SET owner_account = excluded.owner_account, created_at = excluded.created_at, deleted_at = NULL WHERE spaces.deleted_at IS NOT NULL").run(name, ownerAccount, now());
|
|
422
|
+
}
|
|
423
|
+
markSpaceDeleted(name) {
|
|
424
|
+
this.db.prepare("UPDATE spaces SET deleted_at = ? WHERE name = ? AND deleted_at IS NULL").run(now(), name);
|
|
425
|
+
}
|
|
426
|
+
spaceOwner(name) {
|
|
427
|
+
const r = this.db.prepare("SELECT owner_account FROM spaces WHERE name = ? AND deleted_at IS NULL").get(name);
|
|
428
|
+
return r?.owner_account ? this.accountById(r.owner_account) : null;
|
|
429
|
+
}
|
|
430
|
+
spacesOwned(accountId) {
|
|
431
|
+
return this.db.prepare("SELECT name FROM spaces WHERE owner_account = ? AND deleted_at IS NULL ORDER BY created_at").all(accountId).map((r) => r.name);
|
|
432
|
+
}
|
|
433
|
+
spaceOwners() {
|
|
434
|
+
const m = new Map();
|
|
435
|
+
for (const r of this.db.prepare("SELECT name, owner_account FROM spaces WHERE deleted_at IS NULL AND owner_account IS NOT NULL").all())
|
|
436
|
+
m.set(r.name, r.owner_account);
|
|
437
|
+
return m;
|
|
438
|
+
}
|
|
439
|
+
ownedCounts() {
|
|
440
|
+
const m = new Map();
|
|
441
|
+
for (const r of this.db.prepare("SELECT owner_account a, COUNT(*) c FROM spaces WHERE deleted_at IS NULL AND owner_account IS NOT NULL GROUP BY owner_account").all())
|
|
442
|
+
m.set(r.a, r.c);
|
|
443
|
+
return m;
|
|
444
|
+
}
|
|
445
|
+
hasSpaceRecord(name) {
|
|
446
|
+
return !!this.db.prepare("SELECT 1 FROM spaces WHERE name = ?").get(name);
|
|
447
|
+
}
|
|
343
448
|
allAccounts() {
|
|
344
449
|
return this.db.prepare("SELECT * FROM accounts ORDER BY created_at DESC").all();
|
|
345
450
|
}
|
package/dist/agents.js
CHANGED
|
@@ -93,6 +93,8 @@ export function headlessSpawn(opts) {
|
|
|
93
93
|
PANTHEON_AGENT: opts.backend.id,
|
|
94
94
|
PANTHEON_ROLE: "worker",
|
|
95
95
|
PANTHEON_BRANCH: opts.branch,
|
|
96
|
+
...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
|
|
97
|
+
...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
|
|
96
98
|
...(opts.cfg.repo ? { PANTHEON_REPO: opts.cfg.repo } : {}),
|
|
97
99
|
...(opts.cfg.spaceId ? { PANTHEON_SPACE_ID: opts.cfg.spaceId } : {}),
|
|
98
100
|
};
|
|
@@ -101,6 +103,8 @@ export function headlessSpawn(opts) {
|
|
|
101
103
|
PANTHEON_AGENT: opts.backend.id,
|
|
102
104
|
PANTHEON_ROLE: "worker",
|
|
103
105
|
PANTHEON_BRANCH: opts.branch,
|
|
106
|
+
...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
|
|
107
|
+
...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
|
|
104
108
|
...(home ? { PANTHEON_HOME: home } : {}),
|
|
105
109
|
};
|
|
106
110
|
if (opts.backend.id === "claude-code") {
|
package/dist/cli.js
CHANGED
|
@@ -9,14 +9,16 @@ process.emitWarning = ((warning, ...rest) => {
|
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
11
|
import { PantheonClient } from "./client.js";
|
|
12
|
-
import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY } from "./config.js";
|
|
12
|
+
import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
|
|
13
|
+
import { fmtLimit } from "./plans.js";
|
|
13
14
|
import { validEmail, validUsername } from "./accounts.js";
|
|
14
15
|
import { install, launcher } from "./install.js";
|
|
15
16
|
import { Relay } from "./relay.js";
|
|
16
17
|
import { runMcp } from "./mcp.js";
|
|
17
18
|
import { Worker } from "./worker.js";
|
|
18
19
|
import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
|
|
19
|
-
import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile } from "./profile.js";
|
|
20
|
+
import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
|
|
21
|
+
import { runGuide, terminalIO } from "./setup-guide.js";
|
|
20
22
|
import { doctor, render } from "./doctor.js";
|
|
21
23
|
import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
|
|
22
24
|
import { assess } from "./untrusted.js";
|
|
@@ -266,6 +268,39 @@ function unattendedProject() {
|
|
|
266
268
|
return { project: resolve(flag("project", ".")), how: "explicit" };
|
|
267
269
|
return { project: resolve("."), how: "default" };
|
|
268
270
|
}
|
|
271
|
+
async function guidedSetup(cfg, project, opts = {}) {
|
|
272
|
+
if (argv.includes("--no-guide"))
|
|
273
|
+
return;
|
|
274
|
+
const interactive = !!process.stdin.isTTY && !argv.includes("--yes");
|
|
275
|
+
if (!interactive && !opts.force)
|
|
276
|
+
return;
|
|
277
|
+
const rl = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : null;
|
|
278
|
+
const io = rl ? terminalIO(rl, (l) => console.error(l)) : null;
|
|
279
|
+
console.error(`\n${B("A few choices, then you're done.")}${io ? "" : D(" (no terminal — taking the defaults)")}`);
|
|
280
|
+
try {
|
|
281
|
+
const r = await runGuide(io, { cfg, project }, {
|
|
282
|
+
publish: async (p) => {
|
|
283
|
+
const c = new PantheonClient(cfg, undefined, { role: "cli" });
|
|
284
|
+
try {
|
|
285
|
+
await c.connect();
|
|
286
|
+
await c.publishProfile(organizationProfile(p, cfg.spaceId, cfg.space));
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
c.close();
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
console.error("");
|
|
294
|
+
for (const l of r.applied)
|
|
295
|
+
console.error(` ${C("✓")} ${l}`);
|
|
296
|
+
for (const w of r.warnings)
|
|
297
|
+
console.error(`\x1b[33m ! ${w}\x1b[0m`);
|
|
298
|
+
console.error(D(`\n Change any of this later: pantheon setup`));
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
rl?.close();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
269
304
|
function printReady(tools) {
|
|
270
305
|
const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
|
|
271
306
|
console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
|
|
@@ -317,7 +352,9 @@ ${B("several teams on one machine")}
|
|
|
317
352
|
pantheon spaces your teams, which repo is bound to which, and where this folder points
|
|
318
353
|
pantheon use <team> --here bind this repo to a team (agents opened here speak in it)
|
|
319
354
|
pantheon repo add | list the repos a team works from; a delivery must come from one of them
|
|
355
|
+
(repo add --base <branch>: the branch workers cut from, else origin's default)
|
|
320
356
|
pantheon leave <team> drop one team from this machine (its token and repo bindings)
|
|
357
|
+
pantheon space delete <team> --yes dissolve a team you founded: disconnects everyone, frees a plan slot
|
|
321
358
|
pantheon profile set --space <team> … authority that applies in one team only (e.g. --human-required pricing)
|
|
322
359
|
|
|
323
360
|
${B("day to day")}
|
|
@@ -350,6 +387,8 @@ ${B("control")}
|
|
|
350
387
|
pantheon leave remove what pantheon put on this machine
|
|
351
388
|
pantheon panic cut off all unattended work on this machine, now
|
|
352
389
|
pantheon doctor check this machine end to end
|
|
390
|
+
pantheon setup the guided choices: who may wake this machine, what a run may execute,
|
|
391
|
+
what you personally decide, which alerts you want — re-run any time
|
|
353
392
|
pantheon upgrade pull the latest pantheon, rebuild, and re-wire your agents
|
|
354
393
|
|
|
355
394
|
${D("more: pantheon help --all")}
|
|
@@ -428,6 +467,8 @@ async function main() {
|
|
|
428
467
|
console.error(D(` ${r.reach.hint}`));
|
|
429
468
|
}
|
|
430
469
|
printReady(r.tools);
|
|
470
|
+
if (r.claimed)
|
|
471
|
+
await guidedSetup(load() ?? r.cfg, repoInfo(process.cwd())?.root ?? null);
|
|
431
472
|
if (r.relay) {
|
|
432
473
|
console.error(D(`\nKeeping the relay running here. Ctrl-C stops it (your teammate goes offline too).`));
|
|
433
474
|
await new Promise(() => { });
|
|
@@ -440,7 +481,8 @@ async function main() {
|
|
|
440
481
|
await client.connect().catch((e) => die(client.pending ?? e.message));
|
|
441
482
|
await new Promise((r) => setTimeout(r, 250));
|
|
442
483
|
const ttl = Number(flag("ttl", "1440"));
|
|
443
|
-
const
|
|
484
|
+
const mayWake = argv.includes("--may-wake") ? true : argv.includes("--no-wake") ? false : wakeDefault(loadProfile(cfg));
|
|
485
|
+
const inv = await client.makeInvite(mayWake, ttl).catch((e) => die(e.message));
|
|
444
486
|
console.log(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`);
|
|
445
487
|
console.error(D(`\nSingle use, expires ${new Date(inv.expires_at).toLocaleString()}.`));
|
|
446
488
|
client.close();
|
|
@@ -558,6 +600,11 @@ async function main() {
|
|
|
558
600
|
console.error(`\x1b[33m ! ${w}\x1b[0m`);
|
|
559
601
|
printGranted(r.granted);
|
|
560
602
|
printReady(r.tools);
|
|
603
|
+
{
|
|
604
|
+
const joined = load();
|
|
605
|
+
if (joined)
|
|
606
|
+
await guidedSetup(joined, repoInfo(process.cwd())?.root ?? null);
|
|
607
|
+
}
|
|
561
608
|
console.error(`\n${D('Then ask your agent: "check pantheon_status and say hello to the team".')}`);
|
|
562
609
|
return;
|
|
563
610
|
}
|
|
@@ -618,6 +665,11 @@ async function main() {
|
|
|
618
665
|
}
|
|
619
666
|
return;
|
|
620
667
|
}
|
|
668
|
+
case "setup": {
|
|
669
|
+
const cfg = need();
|
|
670
|
+
await guidedSetup(cfg, repoInfo(process.cwd())?.root ?? null, { force: true });
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
621
673
|
case "version": {
|
|
622
674
|
console.log(VERSION);
|
|
623
675
|
return;
|
|
@@ -636,6 +688,8 @@ async function main() {
|
|
|
636
688
|
await new Promise((r) => setTimeout(r, 350));
|
|
637
689
|
const peers = client.roster.filter((m) => m.id !== cfg.member);
|
|
638
690
|
console.log(`${B(cfg.space)} you are ${C(cfg.member)} ${D(cfg.relay)} ${D(`${client.head} acts`)}\n`);
|
|
691
|
+
if (client.plan)
|
|
692
|
+
console.log(D(` ${client.plan.name} plan · ${client.plan.usage.spaces} of ${fmtLimit(client.plan.limits.spaces)} spaces created · ${client.plan.usage.members} of ${fmtLimit(client.plan.limits.members)} people here${client.plan.limits.wake ? "" : " · no unattended workers"}`) + "\n");
|
|
639
693
|
console.log(B("members"));
|
|
640
694
|
if (!peers.length)
|
|
641
695
|
console.log(` ${D("(nobody else yet — send them an invite from `pantheon invite`)")}`);
|
|
@@ -944,7 +998,7 @@ async function main() {
|
|
|
944
998
|
}
|
|
945
999
|
case "hook": {
|
|
946
1000
|
const sub = argv[1] ?? "status";
|
|
947
|
-
const hookCfg =
|
|
1001
|
+
const hookCfg = resolveForHook();
|
|
948
1002
|
const reportIdle = async (session, seen) => {
|
|
949
1003
|
if (!hookCfg)
|
|
950
1004
|
return true;
|
|
@@ -1047,7 +1101,7 @@ async function main() {
|
|
|
1047
1101
|
const { pending: pendingFn } = await import("./pending.js");
|
|
1048
1102
|
const { load: loadCfg } = await import("./config.js");
|
|
1049
1103
|
const d = await decideStop(JSON.stringify({ session_id: session }), {
|
|
1050
|
-
pending: async (s) => pendingFn({ session: s }),
|
|
1104
|
+
pending: async (s) => (hookCfg ? pendingFn({ session: s, cfg: hookCfg }) : Promise.resolve(null)),
|
|
1051
1105
|
...(loadCfg()?.member ? { me: loadCfg().member } : {}),
|
|
1052
1106
|
});
|
|
1053
1107
|
if (d.exit === 2 && d.message) {
|
|
@@ -1080,7 +1134,7 @@ async function main() {
|
|
|
1080
1134
|
const { pending: pendingFn } = await import("./pending.js");
|
|
1081
1135
|
const { load: loadCfg } = await import("./config.js");
|
|
1082
1136
|
const d = await decideStop(raw, {
|
|
1083
|
-
pending: async (session) => pendingFn({ session }),
|
|
1137
|
+
pending: async (session) => (hookCfg ? pendingFn({ session, cfg: hookCfg }) : Promise.resolve(null)),
|
|
1084
1138
|
...(loadCfg()?.member ? { me: loadCfg().member } : {}),
|
|
1085
1139
|
});
|
|
1086
1140
|
if (d.exit === 2 && d.message) {
|
|
@@ -1118,7 +1172,9 @@ async function main() {
|
|
|
1118
1172
|
let surfaced;
|
|
1119
1173
|
const d = await decideStop(raw, {
|
|
1120
1174
|
pending: async (session) => {
|
|
1121
|
-
|
|
1175
|
+
if (!hookCfg)
|
|
1176
|
+
return null;
|
|
1177
|
+
const r = await pendingFn({ session, cfg: hookCfg });
|
|
1122
1178
|
if (r)
|
|
1123
1179
|
surfaced = r.seq;
|
|
1124
1180
|
return r;
|
|
@@ -1188,6 +1244,24 @@ async function main() {
|
|
|
1188
1244
|
}
|
|
1189
1245
|
return;
|
|
1190
1246
|
}
|
|
1247
|
+
case "space": {
|
|
1248
|
+
const sub = argv[1];
|
|
1249
|
+
const name = argv[2];
|
|
1250
|
+
if (sub !== "delete" || !name || name.startsWith("--"))
|
|
1251
|
+
die("usage: pantheon space delete <team> --yes");
|
|
1252
|
+
const all = loadAll() ?? die("not configured");
|
|
1253
|
+
const m = findMembership(all, name) ?? die(`you are not in a team called "${name}". Yours: ${Object.values(all.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
|
|
1254
|
+
if (!argv.includes("--yes"))
|
|
1255
|
+
die(`this deletes "${m.name}" for everyone in it — its history, goals and invites — and cannot be undone.\n If you mean it: pantheon space delete ${m.name} --yes`);
|
|
1256
|
+
const client = new PantheonClient(configFor(all, m, null), undefined, { role: "cli" });
|
|
1257
|
+
await client.connect().catch((e) => die(client.pending ?? e.message));
|
|
1258
|
+
await client.deleteSpace(m.name).catch((e) => { client.close(); die(e.message); });
|
|
1259
|
+
client.close();
|
|
1260
|
+
const bound = Object.values(all.bindings ?? {}).filter((b) => b.space === m.id).length;
|
|
1261
|
+
removeMembership(m.id);
|
|
1262
|
+
console.error(`${B("deleted")} "${m.name}" — everyone in it was disconnected; its token${bound ? ` and ${bound} repo binding${bound === 1 ? "" : "s"}` : ""} left this machine`);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1191
1265
|
case "leave": {
|
|
1192
1266
|
if (argv[1] && !argv[1].startsWith("--")) {
|
|
1193
1267
|
const all = loadAll() ?? die("not configured");
|
|
@@ -2159,16 +2233,17 @@ async function main() {
|
|
|
2159
2233
|
await client.connect().catch((e) => die(client.pending ?? e.message));
|
|
2160
2234
|
if (sub === "add") {
|
|
2161
2235
|
const r = repoInfo(flag("path") ?? process.cwd()) ?? die("not inside a git repo");
|
|
2162
|
-
|
|
2236
|
+
const base = flag("base") ?? null;
|
|
2237
|
+
await client.bindRepo(r.fingerprint, r.origin, base).catch((e) => die(e.message));
|
|
2163
2238
|
if (cfg.spaceId)
|
|
2164
2239
|
bindRepo(r, cfg.spaceId);
|
|
2165
|
-
console.error(`${B("registered")} ${r.origin ?? r.root} as a repo "${cfg.space}" works from${r.shared ? "" : D(" (no origin remote — deliveries from it cannot be fetched)")}`);
|
|
2240
|
+
console.error(`${B("registered")} ${r.origin ?? r.root} as a repo "${cfg.space}" works from${base ? ` — workers cut from ${C(base)}` : ""}${r.shared ? "" : D(" (no origin remote — deliveries from it cannot be fetched)")}`);
|
|
2166
2241
|
}
|
|
2167
2242
|
else {
|
|
2168
2243
|
await new Promise((res) => setTimeout(res, 150));
|
|
2169
2244
|
console.error(`${B(`repos "${cfg.space}" works from`)} ${D(`(${client.repos.length})`)}`);
|
|
2170
2245
|
for (const r of client.repos)
|
|
2171
|
-
console.error(` ${(r.origin ?? r.fingerprint).padEnd(48)} ${D(`added by ${r.added_by}`)}`);
|
|
2246
|
+
console.error(` ${(r.origin ?? r.fingerprint).padEnd(48)} ${D(`added by ${r.added_by}`)}${r.base ? ` ${D("base")} ${C(r.base)}` : ""}`);
|
|
2172
2247
|
if (!client.repos.length)
|
|
2173
2248
|
console.error(D(" none yet — the first member to connect from a checkout registers it"));
|
|
2174
2249
|
}
|
package/dist/client.js
CHANGED
|
@@ -38,6 +38,7 @@ export class PantheonClient {
|
|
|
38
38
|
enrolledToken = null;
|
|
39
39
|
spaceId = null;
|
|
40
40
|
repos = [];
|
|
41
|
+
plan = null;
|
|
41
42
|
enroll = null;
|
|
42
43
|
inviteUrl = null;
|
|
43
44
|
constructor(cfg, onEvent, opts) {
|
|
@@ -157,6 +158,7 @@ export class PantheonClient {
|
|
|
157
158
|
this.cfg.spaceId = msg.space_id;
|
|
158
159
|
}
|
|
159
160
|
this.repos = msg.repos ?? [];
|
|
161
|
+
this.plan = msg.plan ?? null;
|
|
160
162
|
this.adoptKnownRepo();
|
|
161
163
|
this.backoff = 500;
|
|
162
164
|
this.roster = msg.roster;
|
|
@@ -375,29 +377,42 @@ export class PantheonClient {
|
|
|
375
377
|
if (c.cwdRepoOrigin)
|
|
376
378
|
c.repoOrigin = c.cwdRepoOrigin;
|
|
377
379
|
}
|
|
378
|
-
|
|
380
|
+
reportWorkerState(state, detail, until) {
|
|
381
|
+
try {
|
|
382
|
+
this.ws?.send(JSON.stringify({ t: "worker_state", state, ...(detail ? { detail: detail.slice(0, 200) } : {}), ...(until ? { until } : {}) }));
|
|
383
|
+
}
|
|
384
|
+
catch { }
|
|
385
|
+
}
|
|
386
|
+
deleteSpace(confirm) {
|
|
387
|
+
return this.rpc({ t: "delete_space", confirm });
|
|
388
|
+
}
|
|
389
|
+
bindRepo(fingerprint = this.cfg.repo, origin = this.cfg.repoOrigin ?? null, base = null) {
|
|
379
390
|
if (!fingerprint)
|
|
380
391
|
return Promise.reject(new Error("not inside a git repo — run this from the team's checkout"));
|
|
381
|
-
return this.rpc({ t: "bind_repo", repo: fingerprint, origin });
|
|
392
|
+
return this.rpc({ t: "bind_repo", repo: fingerprint, origin, ...(base ? { base } : {}) });
|
|
382
393
|
}
|
|
383
394
|
makeInvite(mayWake = true, ttlMinutes = 60) {
|
|
384
395
|
return new Promise((res, rej) => {
|
|
385
|
-
const
|
|
396
|
+
const cid = `c${++this.cid}`;
|
|
397
|
+
const finish = () => { clearInterval(poll); clearTimeout(t); this.acks.delete(cid); };
|
|
398
|
+
const t = setTimeout(() => { finish(); rej(new Error("relay did not return an invite in 10s")); }, 10_000);
|
|
386
399
|
const poll = setInterval(() => {
|
|
387
400
|
if (this.inviteUrl) {
|
|
388
|
-
|
|
389
|
-
clearTimeout(t);
|
|
401
|
+
finish();
|
|
390
402
|
const v = this.inviteUrl;
|
|
391
403
|
this.inviteUrl = null;
|
|
392
404
|
res(v);
|
|
393
405
|
}
|
|
394
406
|
}, 40);
|
|
407
|
+
this.acks.set(cid, (v) => { if ("error" in v) {
|
|
408
|
+
finish();
|
|
409
|
+
rej(new Error(v.error));
|
|
410
|
+
} });
|
|
395
411
|
try {
|
|
396
|
-
this.ws?.send(JSON.stringify({ t: "invite", may_wake: mayWake, ttl_minutes: ttlMinutes }));
|
|
412
|
+
this.ws?.send(JSON.stringify({ t: "invite", may_wake: mayWake, ttl_minutes: ttlMinutes, cid }));
|
|
397
413
|
}
|
|
398
414
|
catch (e) {
|
|
399
|
-
|
|
400
|
-
clearTimeout(t);
|
|
415
|
+
finish();
|
|
401
416
|
rej(e);
|
|
402
417
|
}
|
|
403
418
|
});
|
package/dist/config.js
CHANGED
|
@@ -153,6 +153,20 @@ export function resolveFor(cwd = process.cwd(), opts = {}) {
|
|
|
153
153
|
}
|
|
154
154
|
return { cfg: null, reason: "unbound", memberships: n, repo };
|
|
155
155
|
}
|
|
156
|
+
export function resolveForHook(cwd = process.cwd()) {
|
|
157
|
+
const r = resolveFor(cwd, { strict: true });
|
|
158
|
+
if (!r.cfg?.space)
|
|
159
|
+
return null;
|
|
160
|
+
if (r.reason !== "single" || !r.repo)
|
|
161
|
+
return r.cfg;
|
|
162
|
+
const f = loadAll();
|
|
163
|
+
const m = Object.values(f?.spaces ?? {})[0];
|
|
164
|
+
const bound = Object.values(f?.bindings ?? {}).filter((b) => b.space === m?.id);
|
|
165
|
+
if (!bound.length)
|
|
166
|
+
return r.cfg;
|
|
167
|
+
const here = r.repo;
|
|
168
|
+
return bound.some((b) => b.path === here.root || (!!b.origin && b.origin === here.origin)) ? r.cfg : null;
|
|
169
|
+
}
|
|
156
170
|
export function load() {
|
|
157
171
|
return resolveFor(process.cwd()).cfg;
|
|
158
172
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { loadProfile, profileInitialized } from "./profile.js";
|
|
2
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { PantheonClient } from "./client.js";
|
|
@@ -74,6 +75,14 @@ export async function doctor(projectDir) {
|
|
|
74
75
|
verifyChallenge(cfg.publicKey, cfg.space, cfg.member, nonce, sig)
|
|
75
76
|
? add("pass", "identity", `keypair valid — fingerprint ${fingerprint(cfg.publicKey)}`)
|
|
76
77
|
: add("fail", "identity", "your keypair does not verify against itself", "the config is corrupt — re-run `pantheon join` to mint a new identity, then get re-admitted");
|
|
78
|
+
const prof = loadProfile(cfg);
|
|
79
|
+
if (profileInitialized(prof)) {
|
|
80
|
+
const owns = Object.entries(prof.authority).filter(([, mode]) => mode === "human_required").map(([d]) => d);
|
|
81
|
+
add("pass", "profile", `${prof.human.display_name} — ${prof.human.title}${owns.length ? `; you personally decide: ${owns.join(", ")}` : "; no human-required domains yet"}`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
add("warn", "profile", "no profile published — decisions cannot be routed to you, and a HUMAN_REQUIRED naming you arrives marked unverified", "pantheon profile set --name <you> --title <role> --human-required <domains you personally decide>");
|
|
85
|
+
}
|
|
77
86
|
const mcpFile = join(projectDir, ".mcp.json");
|
|
78
87
|
if (!existsSync(mcpFile)) {
|
|
79
88
|
add("warn", "mcp entry", "no .mcp.json in this project", "run `pantheon install`");
|
package/dist/mcp.js
CHANGED
|
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { PantheonClient } from "./client.js";
|
|
5
|
-
import { fenceBlock, fenceRubric } from "./untrusted.js";
|
|
5
|
+
import { fenceBlock, fenceRubric, assess, fenceReminder } from "./untrusted.js";
|
|
6
6
|
import { resolveFor } from "./config.js";
|
|
7
7
|
import { LIMITS, SENDABLE } from "./protocol.js";
|
|
8
8
|
import { VERSION } from "./version.js";
|
|
@@ -92,8 +92,16 @@ RULES
|
|
|
92
92
|
- Answer every ASK addressed to you. An unanswered ASK blocks a peer and eventually stalls the goal.
|
|
93
93
|
- ACCEPT means you actually checked. If something is wrong, OBJECT with the reason.
|
|
94
94
|
- One act per pantheon_send. Keep bodies short and reference file paths instead of pasting code.
|
|
95
|
-
- DELIVERED means pushed, not written: commit, push, then send branch + commit
|
|
96
|
-
see your disk. When a peer DELIVERS to you, fetch their commit
|
|
95
|
+
- DELIVERED means pushed, not written: commit, push, then send branch + commit (+ base: the commit
|
|
96
|
+
you cut from). Your peer cannot see your disk. When a peer DELIVERS to you, fetch their commit,
|
|
97
|
+
check the declared base is an ancestor and nothing unrelated rides along, then ACCEPT or OBJECT.
|
|
98
|
+
- Scope against what the team can see. Grep origin/<branch>, not your working tree: your unpushed
|
|
99
|
+
commits and local edits do not exist for your peer, and naming files from them wastes a round.
|
|
100
|
+
- If your human answers you directly in this session, settle your own ESCALATE yourself: pantheon_send
|
|
101
|
+
ANSWER with ref = the ESCALATE's id. In an interactive session ask your human first; ESCALATE is for
|
|
102
|
+
when they are not here. Never RETRACT an escalation to clear it.
|
|
103
|
+
- Verdict first, evidence after, and short: a peer reads your act in one screen. Say when you edited
|
|
104
|
+
your own human's working tree as a side effect, and leave those edits uncommitted for them.
|
|
97
105
|
- If pantheon_send returns PEER_TURN you have been talking to yourself — go back to pantheon_wait.
|
|
98
106
|
|
|
99
107
|
The relay only moves messages. It has no access to your files, your shell, or your credentials.
|
|
@@ -137,6 +145,8 @@ export async function runMcp() {
|
|
|
137
145
|
const role = process.env.PANTHEON_ROLE === "worker" ? "worker" : "interactive";
|
|
138
146
|
const branch = process.env.PANTHEON_BRANCH;
|
|
139
147
|
const client = new PantheonClient(cfg, undefined, { role });
|
|
148
|
+
let siblingsWarned = false;
|
|
149
|
+
let rubricShown = false;
|
|
140
150
|
const server = new McpServer({ name: "pantheon", version: VERSION }, { instructions: instructions(cfg, role, branch) });
|
|
141
151
|
let connectError = null;
|
|
142
152
|
await client.connect().catch((e) => { connectError = e.message; });
|
|
@@ -211,6 +221,12 @@ export async function runMcp() {
|
|
|
211
221
|
const ref = m?.branch && m?.commit
|
|
212
222
|
? `\n>> fetchable: branch ${m.branch} at ${m.commit} — fetch and check it before you ACCEPT`
|
|
213
223
|
: "";
|
|
224
|
+
const base = m?.base && m?.commit
|
|
225
|
+
? `\n>> declared base ${m.base}: verify it is an ancestor (git merge-base --is-ancestor ${m.base} ${m.commit}) and that nothing unrelated rides along (git log --oneline ${m.base}..${m.commit})`
|
|
226
|
+
: "";
|
|
227
|
+
const unverified = r.act === "HUMAN_REQUIRED" && m?.authority
|
|
228
|
+
? `\n>> authority ${m.authority}: no published profile covers ${(m.unverified_domains ?? []).join(", ") || "these domains"}, so the relay let this reach the named human unverified. Ask them to run pantheon profile set.`
|
|
229
|
+
: "";
|
|
214
230
|
const who = r.session ? `${r.from}[${r.session.slice(-6)}${r.role === "worker" ? "·worker" : ""}]` : r.from;
|
|
215
231
|
const at = new Date(r.ts).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
216
232
|
const safeBody = fenceBlock(who, r.body).block;
|
|
@@ -223,13 +239,25 @@ export async function runMcp() {
|
|
|
223
239
|
.map((o) => `\n!! this ${r.act} dissolves obligation ${o.id}, which you had claimed (${o.body}) — it is no longer owed; stop working on it.`)
|
|
224
240
|
.join("");
|
|
225
241
|
return `[${r.seq}] ${r.act} from ${who}${r.to ? ` to ${r.to.join(",")}` : ""}` +
|
|
226
|
-
`${r.goal ? ` (goal ${r.goal})` : ""} at ${at} id=${r.id}${r.ref ? ` ref=${r.ref}` : ""}${lead}${stale}\n${safeBody}${ref}${dissolved}`;
|
|
242
|
+
`${r.goal ? ` (goal ${r.goal})` : ""} at ${at} id=${r.id}${r.ref ? ` ref=${r.ref}` : ""}${lead}${stale}\n${safeBody}${ref}${base}${unverified}${dissolved}`;
|
|
243
|
+
};
|
|
244
|
+
const fmtAll = (acts) => {
|
|
245
|
+
const flagged = acts.some((a) => assess(a.body).length > 0);
|
|
246
|
+
const rubric = !rubricShown || flagged ? fenceRubric(acts.length) : fenceReminder(acts.length);
|
|
247
|
+
rubricShown = true;
|
|
248
|
+
return acts.map(fmt).join("\n\n---\n\n") + `\n\n${rubric}`;
|
|
227
249
|
};
|
|
228
|
-
const fmtAll = (acts) => acts.map(fmt).join("\n\n---\n\n") + `\n\n${fenceRubric(acts.length)}`;
|
|
229
250
|
const situation = () => {
|
|
230
251
|
const open = [...client.goals.values()].filter((g) => g.state !== "CLOSED");
|
|
231
252
|
const reach = (p) => {
|
|
232
253
|
const head = client.head;
|
|
254
|
+
const w = p.worker;
|
|
255
|
+
const worker = w?.state === "holding"
|
|
256
|
+
? `; their worker is holding — ${w.detail ?? "deferring to a live session"}${w.until && w.until > Date.now() ? `, ~${Math.round((w.until - Date.now()) / 1000)}s more` : ""}`
|
|
257
|
+
: w?.state === "running" ? `; their worker is running${w.detail ? ` (${w.detail})` : ""}` : "";
|
|
258
|
+
return reachText(p, head) + worker;
|
|
259
|
+
};
|
|
260
|
+
const reachText = (p, head) => {
|
|
233
261
|
if (p.seen_seq === undefined) {
|
|
234
262
|
return p.delivered_seq !== undefined && p.delivered_seq >= head
|
|
235
263
|
? ", delivered but no read-receipt — they have no wake-up hook, so it waits for their human"
|
|
@@ -245,10 +273,13 @@ export async function runMcp() {
|
|
|
245
273
|
const peers = client.roster.filter((m) => m.id !== cfg.member);
|
|
246
274
|
const siblings = (client.roster.find((m) => m.id === cfg.member)?.sessions ?? [])
|
|
247
275
|
.filter((s) => s.id !== client.session);
|
|
276
|
+
const warnSiblings = siblings.length && !siblingsWarned;
|
|
277
|
+
if (siblings.length)
|
|
278
|
+
siblingsWarned = true;
|
|
248
279
|
return [
|
|
249
280
|
`you are: ${cfg.member} (${cfg.agent ?? "unknown agent"}, session ${client.session}, role ${role}) space: ${cfg.space} relay: ${client.connected ? "connected" : "OFFLINE"}`,
|
|
250
281
|
siblings.length
|
|
251
|
-
? `your other sessions: ${siblings.map((s) => `${s.id}[${s.role}]`).join(" ")} — claim work before doing it, or you will duplicate theirs`
|
|
282
|
+
? `your other sessions: ${siblings.map((s) => `${s.id}[${s.role}]`).join(" ")}${warnSiblings ? " — claim work before doing it, or you will duplicate theirs" : ""}`
|
|
252
283
|
: `your other sessions: none`,
|
|
253
284
|
peers.length
|
|
254
285
|
? `peers: ${peers.map((p) => `${p.id} [${p.online ? "online" : "offline"}, ${p.agent}${reach(p)}]`).join(" ")}`
|
|
@@ -319,7 +350,7 @@ export async function runMcp() {
|
|
|
319
350
|
: `ASK ${owner.member}'s agent about ${owner.domains.join(", ")}.`);
|
|
320
351
|
}
|
|
321
352
|
if (route.unmatched_domains.length || !route.domains.length) {
|
|
322
|
-
next.push(`
|
|
353
|
+
next.push(`No published profile owns${route.unmatched_domains.length ? ` ${route.unmatched_domains.join(", ")}` : " these domains"}. If one named peer's human clearly must decide, HUMAN_REQUIRED to them still goes through, marked unverified — and ask them to run pantheon profile set. If it is your own human's call, ESCALATE. Do not guess an owner.`);
|
|
323
354
|
}
|
|
324
355
|
return text(`${JSON.stringify(route, null, 2)}\n\nNEXT\n${next.map((x) => `- ${x}`).join("\n")}`);
|
|
325
356
|
}));
|
|
@@ -362,16 +393,19 @@ export async function runMcp() {
|
|
|
362
393
|
ref: z.string().optional().describe("id of the act you are responding to; required for ANSWER/ACCEPT/OBJECT"),
|
|
363
394
|
branch: z.string().optional().describe("REQUIRED for DELIVERED: the branch you pushed the work to"),
|
|
364
395
|
commit: z.string().optional().describe("REQUIRED for DELIVERED: the commit sha your peer should fetch and check"),
|
|
396
|
+
base: z.string().optional().describe("for DELIVERED: the commit your branch was cut from, so your peer can check nothing unrelated rides along. Worker runs fill this in automatically."),
|
|
365
397
|
remote: z.string().optional().describe("for DELIVERED: the git remote your peer should fetch from (https://, ssh://, or user@host:path). Without it they have to guess that your origin is theirs."),
|
|
366
398
|
defers_to: z.string().optional().describe("on CLOSE: the goal id you are standing down in favour of. The relay refuses this if that goal is closed or is itself deferring, because both sides standing down at once leaves the work with nowhere to live."),
|
|
367
399
|
affects: z.array(z.string()).optional().describe("on PROPOSE: whose agreement this actually needs. Without it EVERY member of the goal owes a verdict — including anyone offline — so a three-person goal stalls on whoever is away."),
|
|
368
400
|
},
|
|
369
|
-
}, async ({ act, body, goal, to, ref, branch, commit, remote, decision_domains, summary, defers_to, affects }) => guard(async () => {
|
|
370
|
-
const
|
|
401
|
+
}, async ({ act, body, goal, to, ref, branch, commit, remote, base, decision_domains, summary, defers_to, affects }) => guard(async () => {
|
|
402
|
+
const baseSha = act === "DELIVERED" ? (base ?? process.env.PANTHEON_BASE) : undefined;
|
|
403
|
+
const meta = branch || commit || remote || baseSha || decision_domains?.length
|
|
371
404
|
? {
|
|
372
405
|
...(branch ? { branch } : {}),
|
|
373
406
|
...(commit ? { commit } : {}),
|
|
374
407
|
...(remote ? { remote } : {}),
|
|
408
|
+
...(baseSha ? { base: baseSha } : {}),
|
|
375
409
|
...(decision_domains?.length ? { decision_domains } : {}),
|
|
376
410
|
}
|
|
377
411
|
: undefined;
|