@join-pantheon/cli 0.19.1 → 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/dist/notifier.js CHANGED
@@ -17,10 +17,10 @@ export function dueHumanObligations(obligations, member, state, now, reminderMs)
17
17
  return last === undefined || now - last >= reminderMs;
18
18
  });
19
19
  }
20
- export function pruneNotificationState(state, obligations, member) {
20
+ export function pruneNotificationState(state, obligations, member, now = Date.now()) {
21
21
  const active = new Set(humanObligations(obligations, member).map((o) => o.id));
22
22
  return {
23
- notified_at: Object.fromEntries(Object.entries(state.notified_at).filter(([id]) => active.has(id))),
23
+ notified_at: Object.fromEntries(Object.entries(state.notified_at).filter(([id, at]) => active.has(id) || (id.startsWith("act:") && now - at < 7 * 86_400_000))),
24
24
  };
25
25
  }
26
26
  function readState(file) {
@@ -108,14 +108,17 @@ export class HumanNotifier {
108
108
  this.opts.log(`notifier online as ${this.cfg.member}`);
109
109
  this.opts.log(` reminders every ${Math.round(this.opts.reminderMs / 60_000)} minutes while unresolved`);
110
110
  while (!this.stopped) {
111
- const pruned = pruneNotificationState(state, client.obligations, this.cfg.member);
111
+ const pruned = pruneNotificationState(state, client.obligations, this.cfg.member, now());
112
112
  let dirty = JSON.stringify(pruned) !== JSON.stringify(state);
113
113
  state = pruned;
114
- const due = dueHumanObligations(client.obligations, this.cfg.member, state, now(), this.opts.reminderMs);
114
+ const profile = loadProfile(this.cfg);
115
+ const prefs = profile.notifications ?? { decisions: true, deliveries: false, worker_done: false };
116
+ const due = prefs.decisions
117
+ ? dueHumanObligations(client.obligations, this.cfg.member, state, now(), this.opts.reminderMs)
118
+ : [];
115
119
  for (const obligation of due) {
116
- const profile = loadProfile(this.cfg);
117
120
  const goal = obligation.goal ? client.goals.get(obligation.goal)?.title : undefined;
118
- const ok = send("Your input is needed", oneLine(goal ?? "Pantheon decision", 80), decisionTitle(obligation.body, 180), profile, decisionPrompt(obligation.id));
121
+ const ok = send("Your input is needed", oneLine(`${this.opts.label ? `${this.opts.label} · ` : ""}${goal ?? "Pantheon decision"}`, 80), decisionTitle(obligation.body, 180), profile, decisionPrompt(obligation.id));
119
122
  if (ok) {
120
123
  state.notified_at[obligation.id] = now();
121
124
  dirty = true;
@@ -127,7 +130,24 @@ export class HumanNotifier {
127
130
  }
128
131
  if (dirty || !existsSync(stateFile))
129
132
  writeState(stateFile, state);
130
- await client.next(pollMs);
133
+ const arrived = await client.next(pollMs);
134
+ if (prefs.deliveries) {
135
+ for (const r of arrived) {
136
+ if (r.act !== "DELIVERED" || r.from === this.cfg.member || state.notified_at[`act:${r.id}`])
137
+ continue;
138
+ const goal = r.goal ? client.goals.get(r.goal) : undefined;
139
+ const forMe = (r.to?.includes(this.cfg.member) ?? false) || (goal?.members.includes(this.cfg.member) ?? false);
140
+ if (!forMe)
141
+ continue;
142
+ const m = r.meta;
143
+ const ok = send("Work delivered to you", oneLine(`${this.opts.label ? `${this.opts.label} · ` : ""}${goal?.title ?? "Pantheon delivery"}`, 80), oneLine(`${r.from} pushed ${m?.branch ?? "a branch"}${m?.commit ? ` @ ${m.commit.slice(0, 7)}` : ""} — ${r.body}`, 180), profile, `A teammate delivered work in pantheon: fetch ${m?.branch ?? "their branch"}${m?.commit ? ` at ${m.commit}` : ""} from ${r.from}, check it against the goal "${goal?.title ?? r.goal ?? ""}", then ACCEPT or OBJECT.`, "Delivered");
144
+ if (ok) {
145
+ state.notified_at[`act:${r.id}`] = now();
146
+ writeState(stateFile, state);
147
+ this.opts.log(`> delivery alert for ${r.id} from ${r.from}`);
148
+ }
149
+ }
150
+ }
131
151
  }
132
152
  client.close();
133
153
  }
package/dist/notify.js CHANGED
@@ -290,7 +290,7 @@ export async function decideStop(raw, deps) {
290
290
  const acts = report.acts ?? [];
291
291
  const owed = report.obligations ?? [];
292
292
  const closed = new Set(report.closed_goals ?? []);
293
- const DEMANDS_RESPONSE = new Set(["ASK", "PROPOSE", "OBJECT", "DELIVERED", "CONTRACT", "ESCALATE"]);
293
+ const DEMANDS_RESPONSE = new Set(["ASK", "PROPOSE", "OBJECT", "DELIVERED", "CONTRACT"]);
294
294
  const work = acts.filter((a) => {
295
295
  if (a.goal && closed.has(a.goal))
296
296
  return false;
package/dist/pending.js CHANGED
@@ -102,10 +102,12 @@ export async function pending(opts) {
102
102
  const obligations = surfaced.filter((o) => !alreadySeen.has(o.id));
103
103
  const head = obligationsBody?.seq;
104
104
  const all = (historyBody?.acts ?? []);
105
+ const mine = all.filter((r) => r.from === cfg.member);
105
106
  const acts = all
106
107
  .filter((r) => r.from !== cfg.member)
107
108
  .filter((r) => r.act !== "JOIN" && r.act !== "LEAVE")
108
- .filter((r) => !r.to || r.to.length === 0 || r.to.includes(cfg.member));
109
+ .filter((r) => !r.to || r.to.length === 0 || r.to.includes(cfg.member))
110
+ .filter((r) => !mine.some((m) => m.seq > r.seq && (m.ref === r.id || (!!r.goal && m.goal === r.goal))));
109
111
  const seq = first
110
112
  ? head
111
113
  : historyBody === null
package/dist/persona.js CHANGED
@@ -5,7 +5,7 @@ import { CONFIG_DIR } from "./config.js";
5
5
  export const PERSONA_INSTRUCTIONS_MAX = 8_000;
6
6
  const KNOWN_KEYS = new Set([
7
7
  "allow_bash", "max_runs_per_hour", "max_runs_per_day",
8
- "wake_by", "kinds", "goals", "agent", "name",
8
+ "wake_by", "kinds", "goals", "agent", "name", "spaces",
9
9
  ]);
10
10
  const list = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
11
11
  export function parsePersona(name, scope, file, raw) {
@@ -63,6 +63,9 @@ export function parsePersona(name, scope, file, raw) {
63
63
  case "goals":
64
64
  p.goals = list(value);
65
65
  break;
66
+ case "spaces":
67
+ p.spaces = list(value);
68
+ break;
66
69
  case "agent":
67
70
  p.agent = value.trim();
68
71
  break;
package/dist/plans.js ADDED
@@ -0,0 +1,46 @@
1
+ export const UNLIMITED = 1_000_000;
2
+ export const DEFAULT_PLANS = {
3
+ free: { spaces: 1, members: 2, wake: true },
4
+ pro: { spaces: 10, members: 25, wake: true },
5
+ };
6
+ export const PLAN_NAME = /^[a-z][a-z0-9_-]{0,23}$/;
7
+ const int = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max ? v : null;
8
+ export function parseLimits(raw) {
9
+ if (!raw || typeof raw !== "object")
10
+ return null;
11
+ const o = raw;
12
+ const num = (v) => typeof v === "string" && v.trim() !== "" ? Number(v) : v;
13
+ const spaces = int(num(o["spaces"]), 0, UNLIMITED);
14
+ const members = int(num(o["members"]), 1, UNLIMITED);
15
+ const wake = typeof o["wake"] === "boolean" ? o["wake"] : o["wake"] === "true" || o["wake"] === "on" || o["wake"] === "1" ? true : o["wake"] === "false" || o["wake"] === "off" || o["wake"] === "0" || o["wake"] === undefined ? false : null;
16
+ if (spaces === null || members === null || wake === null)
17
+ return null;
18
+ return { spaces, members, wake };
19
+ }
20
+ export function parsePlans(raw) {
21
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
22
+ return null;
23
+ const out = {};
24
+ for (const [name, lim] of Object.entries(raw)) {
25
+ if (!PLAN_NAME.test(name))
26
+ return null;
27
+ const l = parseLimits(lim);
28
+ if (!l)
29
+ return null;
30
+ out[name] = l;
31
+ }
32
+ return out["free"] ? out : null;
33
+ }
34
+ export function planLimits(plans, plan, override) {
35
+ if (override) {
36
+ try {
37
+ const l = parseLimits(JSON.parse(override));
38
+ if (l)
39
+ return l;
40
+ }
41
+ catch { }
42
+ }
43
+ return plans[plan] ?? plans["free"] ?? DEFAULT_PLANS["free"];
44
+ }
45
+ export const fmtLimit = (n) => n >= UNLIMITED ? "unlimited" : String(n);
46
+ //# sourceMappingURL=plans.js.map
package/dist/profile.js CHANGED
@@ -25,6 +25,7 @@ export function defaultProfile(cfg) {
25
25
  human: { display_name: cfg.display ?? cfg.member, title: "", responsibilities: [] },
26
26
  agent: { primary_surface: surfaceFromAgent(cfg.agent), wake_enabled: false },
27
27
  attention: { urgent: "primary_surface", decision: "primary_surface", fyi: "inbox" },
28
+ notifications: { decisions: true, deliveries: false, worker_done: false },
28
29
  authority: {},
29
30
  decision_keywords: {},
30
31
  capabilities: [],
@@ -32,6 +33,9 @@ export function defaultProfile(cfg) {
32
33
  updated_at: 0,
33
34
  };
34
35
  }
36
+ export function wakeDefault(profile) {
37
+ return (profile.agent.wake_default ?? "anyone") === "anyone";
38
+ }
35
39
  export function loadProfile(cfg, file = profilePath()) {
36
40
  if (!existsSync(file))
37
41
  return defaultProfile(cfg);
@@ -53,11 +57,13 @@ export function loadProfile(cfg, file = profilePath()) {
53
57
  },
54
58
  agent: { ...defaultProfile(cfg).agent, ...raw.agent, primary_surface: surface },
55
59
  attention: { ...defaultProfile(cfg).attention, ...raw.attention },
60
+ notifications: { ...defaultProfile(cfg).notifications, ...(raw.notifications ?? {}) },
56
61
  authority: raw.authority ?? {},
57
62
  decision_keywords: cleanKeywords(legacy.decision_keywords),
58
63
  capabilities: Array.isArray(raw.capabilities) ? raw.capabilities.filter((x) => typeof x === "string") : [],
59
64
  delegations: cleanDelegations(legacy.delegations),
60
65
  updated_at: Number.isFinite(legacy.updated_at) ? legacy.updated_at : 0,
66
+ ...(Object.keys(sanitizeSpaces(raw.spaces)).length ? { spaces: sanitizeSpaces(raw.spaces) } : {}),
61
67
  };
62
68
  }
63
69
  catch {
@@ -95,16 +101,39 @@ function cleanDelegations(value) {
95
101
  return [{ to: normalize(d.to), domains, starts_at: d.starts_at, expires_at: d.expires_at }];
96
102
  });
97
103
  }
98
- export function organizationProfile(profile) {
104
+ function sanitizeSpaces(raw) {
105
+ const out = {};
106
+ if (!raw || typeof raw !== "object")
107
+ return out;
108
+ for (const [k, v] of Object.entries(raw)) {
109
+ if (!v || typeof v !== "object")
110
+ continue;
111
+ const o = v;
112
+ out[k] = {
113
+ ...(o.responsibilities ? { responsibilities: cleanList(o.responsibilities) } : {}),
114
+ ...(o.authority && typeof o.authority === "object" ? { authority: Object.fromEntries(Object.entries(o.authority).filter(([, m]) => m === "human_required" || m === "agent_can_decide")) } : {}),
115
+ ...(o.decision_keywords ? { decision_keywords: cleanKeywords(o.decision_keywords) } : {}),
116
+ };
117
+ }
118
+ return out;
119
+ }
120
+ export function spaceOverride(profile, ...keys) {
121
+ for (const k of keys)
122
+ if (k && profile.spaces?.[k])
123
+ return profile.spaces[k];
124
+ return undefined;
125
+ }
126
+ export function organizationProfile(profile, ...forSpace) {
127
+ const ov = spaceOverride(profile, ...forSpace);
99
128
  return {
100
129
  version: 1,
101
130
  member: profile.member,
102
131
  display_name: profile.human.display_name || profile.member,
103
132
  title: profile.human.title,
104
- responsibilities: cleanList(profile.human.responsibilities),
105
- authority: Object.fromEntries(Object.entries(profile.authority)
133
+ responsibilities: cleanList(ov?.responsibilities ?? profile.human.responsibilities),
134
+ authority: Object.fromEntries(Object.entries(ov?.authority ?? profile.authority)
106
135
  .map(([domain, mode]) => [normalize(domain), mode]).filter(([domain]) => !!domain)),
107
- decision_keywords: cleanKeywords(profile.decision_keywords),
136
+ decision_keywords: cleanKeywords(ov?.decision_keywords ?? profile.decision_keywords),
108
137
  capabilities: cleanList(profile.capabilities),
109
138
  delegations: cleanDelegations(profile.delegations),
110
139
  updated_at: profile.updated_at,
package/dist/protocol.js CHANGED
@@ -96,6 +96,8 @@ export const Hello = z.object({
96
96
  agent: z.string().max(80).optional(),
97
97
  pkg: z.string().max(32).optional(),
98
98
  from_seq: z.number().int().nonnegative().optional(),
99
+ repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
100
+ repo_origin: z.string().max(200).optional(),
99
101
  });
100
102
  export const OrganizationProfileSchema = z.object({
101
103
  version: z.literal(1),
@@ -151,6 +153,14 @@ export const SendAct = z.object({
151
153
  defers_to: z.string().max(64).optional(),
152
154
  affects: z.array(z.string().min(1).max(48)).max(16).optional(),
153
155
  meta: z.record(z.unknown()).optional(),
156
+ repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
157
+ });
158
+ export const BindRepo = z.object({
159
+ t: z.literal("bind_repo"),
160
+ cid: z.string().max(64).optional(),
161
+ repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/),
162
+ origin: z.string().max(200).nullable().optional(),
163
+ base: z.string().min(1).max(100).optional(),
154
164
  });
155
165
  export const OpenGoal = z.object({
156
166
  t: z.literal("open_goal"),
@@ -162,7 +172,18 @@ export const OpenGoal = z.object({
162
172
  deadline: z.number().int().positive().optional(),
163
173
  });
164
174
  export const Ping = z.object({ t: z.literal("ping") });
165
- export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, PublishProfile, Ping]);
175
+ export const DeleteSpace = z.object({
176
+ t: z.literal("delete_space"),
177
+ cid: z.string().max(64).optional(),
178
+ confirm: z.string().min(1).max(64),
179
+ });
180
+ export const WorkerState = z.object({
181
+ t: z.literal("worker_state"),
182
+ state: z.enum(["idle", "holding", "running"]),
183
+ detail: z.string().max(200).optional(),
184
+ until: z.number().int().positive().optional(),
185
+ });
186
+ export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, PublishProfile, BindRepo, DeleteSpace, WorkerState, Ping]);
166
187
  export function shouldDeferToHuman(sessions, work, opts = {}) {
167
188
  const now = opts.now ?? Date.now();
168
189
  const idleMs = opts.idleMs ?? LIMITS.workerIdleMs;
@@ -3,7 +3,8 @@ import { randomBytes } from "node:crypto";
3
3
  import { basename } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { PantheonClient } from "./client.js";
6
- import { load, save, toInvite, DEFAULT_RELAY } from "./config.js";
6
+ import { load, save, toInvite, resolveFor, repoInfo, bindRepo, DEFAULT_RELAY } from "./config.js";
7
+ import { loadProfile, wakeDefault } from "./profile.js";
7
8
  import { newKeypair } from "./identity.js";
8
9
  import { installForUser } from "./install.js";
9
10
  import { writeIntent, installHooks } from "./notify.js";
@@ -91,7 +92,7 @@ export function wireUp(opts, deps = {}) {
91
92
  }
92
93
  else {
93
94
  try {
94
- installSvc("worker", project, ["--project", project]);
95
+ installSvc("worker", project, []);
95
96
  tools.push({ tool: "worker", status: `on — answers peers while you are away (${project})` });
96
97
  granted.push(`A peer you have granted may_wake can start an agent run on this machine, in an isolated worktree under ${project}.`);
97
98
  if (!existsSync(policyPath())) {
@@ -131,7 +132,9 @@ export function findReach(port) {
131
132
  }
132
133
  export async function start(opts) {
133
134
  const warnings = [];
134
- const priorCfg = load();
135
+ const here = resolveFor(process.cwd());
136
+ const priorCfg = here.cfg;
137
+ const repo = repoInfo(process.cwd());
135
138
  const signedInRelay = priorCfg?.account ? (priorCfg.relay ?? DEFAULT_RELAY) : undefined;
136
139
  const chosen = opts.relay ?? signedInRelay;
137
140
  const reach = chosen
@@ -154,7 +157,8 @@ export async function start(opts) {
154
157
  }
155
158
  const existing = priorCfg;
156
159
  const hasOwnSpace = !!existing?.space && !!existing?.token;
157
- const reuse = !!existing && !opts.reset && hasOwnSpace && (!opts.space || opts.space === existing.space);
160
+ const reuse = !!existing && !opts.reset && hasOwnSpace && (here.reason === "binding" || here.reason === "single" || here.reason === "env")
161
+ && (!opts.space || opts.space === existing.space);
158
162
  const keepIdentity = !!existing && !opts.reset && !!existing.publicKey && !!existing.privateKey;
159
163
  let cfg = reuse
160
164
  ? { ...existing, relay: reach.url, agent: detectAgent() }
@@ -166,7 +170,10 @@ export async function start(opts) {
166
170
  agent: detectAgent(),
167
171
  ...(keepIdentity ? { publicKey: existing.publicKey, privateKey: existing.privateKey } : newKeypair()),
168
172
  ...(keepIdentity && existing.account ? { account: existing.account } : {}),
173
+ ...(repo ? { repo: repo.fingerprint, ...(repo.origin ? { repoOrigin: repo.origin } : {}) } : {}),
169
174
  };
175
+ if (repo && !repo.shared)
176
+ warnings.push(`this repo has no origin remote, so nothing you deliver from it can be fetched by a teammate — add one (git remote add origin …) before delivering`);
170
177
  if (reuse) {
171
178
  opts.log(` you ${cfg.member}, same as before`);
172
179
  if (opts.me && opts.me !== cfg.member) {
@@ -185,9 +192,19 @@ export async function start(opts) {
185
192
  const client = new PantheonClient(c);
186
193
  try {
187
194
  await client.connect();
195
+ if (client.spaceId)
196
+ c.spaceId = client.spaceId;
188
197
  save(c);
189
- opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}`);
190
- const inv = await client.makeInvite(true, opts.ttlMinutes);
198
+ if (repo && !client.repos.some((r) => r.fingerprint === repo.fingerprint)) {
199
+ await client.bindRepo(repo.fingerprint, repo.origin).catch((e) => warnings.push(`could not register this repo with the team: ${e.message}`));
200
+ }
201
+ if (repo && c.spaceId) {
202
+ const { replaced } = bindRepo(repo, c.spaceId);
203
+ if (replaced)
204
+ warnings.push(`this repo was bound to another team (${replaced.space}); it now belongs to "${c.space}"`);
205
+ }
206
+ opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}${repo ? ` ${repo.origin ?? "local repo"} → bound to it` : ""}`);
207
+ const inv = await client.makeInvite(wakeDefault(loadProfile(c)), opts.ttlMinutes);
191
208
  invite = toInvite({ ...c, token: inv.url });
192
209
  claimed = true;
193
210
  }
@@ -233,6 +250,7 @@ export async function start(opts) {
233
250
  }
234
251
  export async function join(opts) {
235
252
  const existing = opts.reset ? null : load();
253
+ const repo = repoInfo(process.cwd());
236
254
  const keys = existing?.publicKey && existing.privateKey
237
255
  ? { publicKey: existing.publicKey, privateKey: existing.privateKey }
238
256
  : newKeypair();
@@ -247,13 +265,25 @@ export async function join(opts) {
247
265
  agent: detectAgent(),
248
266
  ...keys,
249
267
  ...(existing?.account ? { account: existing.account } : {}),
268
+ ...(repo ? { repo: repo.fingerprint, ...(repo.origin ? { repoOrigin: repo.origin } : {}) } : {}),
250
269
  };
251
270
  const client = new PantheonClient(cfg, undefined, { enroll: opts.invite.token });
252
271
  await client.connect();
253
272
  if (!client.enrolledToken)
254
273
  throw new Error("the relay accepted us but sent no space token — version mismatch?");
255
274
  cfg.token = client.enrolledToken;
275
+ if (client.spaceId)
276
+ cfg.spaceId = client.spaceId;
256
277
  save(cfg);
278
+ const warnings = [];
279
+ if (repo && cfg.spaceId) {
280
+ const { replaced } = bindRepo(repo, cfg.spaceId);
281
+ if (replaced)
282
+ warnings.push(`this repo was bound to another team (${replaced.space}); it now belongs to "${cfg.space}"`);
283
+ opts.log(` repo ${repo.origin ?? "local repo"} → bound to "${cfg.space}"${client.repos.length > 1 ? ` (team repos: ${client.repos.map((r) => r.origin ?? r.fingerprint).join(", ")})` : ""}`);
284
+ if (!repo.shared)
285
+ warnings.push(`this repo has no origin remote, so nothing you deliver from it can be fetched by a teammate`);
286
+ }
257
287
  const peers = client.roster.filter((m) => m.id !== cfg.member).map((m) => m.id);
258
288
  opts.log(` joined "${cfg.space}" as ${cfg.member}${peers.length ? ` — with ${peers.join(", ")}` : ""}`);
259
289
  client.close();
@@ -264,7 +294,7 @@ export async function join(opts) {
264
294
  unattended: opts.unattended ?? null,
265
295
  ...(opts.workerHow ? { workerHow: opts.workerHow } : {}),
266
296
  });
267
- return { cfg, tools: wired.tools, granted: wired.granted, warnings: wired.warnings };
297
+ return { cfg, tools: wired.tools, granted: wired.granted, warnings: [...warnings, ...wired.warnings] };
268
298
  }
269
299
  function detectAgent() {
270
300
  const found = detectAvailable();