@intentius/chant 0.104.3 → 0.105.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.
Files changed (43) hide show
  1. package/dist/codegen/generate-runtime-index.d.ts +3 -1
  2. package/dist/codegen/generate-runtime-index.d.ts.map +1 -1
  3. package/dist/op/activities/factory.d.ts +1 -1
  4. package/dist/op/activities/factory.d.ts.map +1 -1
  5. package/dist/vendor/js-yaml-load.d.ts.map +1 -1
  6. package/dist/workspace/agent-cli.d.ts +14 -8
  7. package/dist/workspace/agent-cli.d.ts.map +1 -1
  8. package/dist/workspace/broker-protocol.d.ts +65 -3
  9. package/dist/workspace/broker-protocol.d.ts.map +1 -1
  10. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts.map +1 -1
  11. package/dist/workspace/conformance/broker.d.ts.map +1 -1
  12. package/dist/workspace/declaration.d.ts +9 -2
  13. package/dist/workspace/declaration.d.ts.map +1 -1
  14. package/dist/workspace/declaration.schema.json +32 -6
  15. package/dist/workspace/export.d.ts.map +1 -1
  16. package/dist/workspace/factory-rules.d.ts +9 -4
  17. package/dist/workspace/factory-rules.d.ts.map +1 -1
  18. package/dist/workspace/reason-codes.d.ts +1 -1
  19. package/dist/workspace/reason-codes.d.ts.map +1 -1
  20. package/dist/workspace/write-scope.d.ts +7 -5
  21. package/dist/workspace/write-scope.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/mcp/workspace-tools.ts +1 -1
  24. package/src/codegen/generate-runtime-index.test.ts +3 -3
  25. package/src/codegen/generate-runtime-index.ts +5 -3
  26. package/src/op/activities/factory.ts +1 -1
  27. package/src/op/factory.e2e.test.ts +16 -5
  28. package/src/vendor/js-yaml-load.ts +14 -63
  29. package/src/workspace/agent-cli.ts +26 -9
  30. package/src/workspace/agent.schema.json +33 -21
  31. package/src/workspace/broker-protocol.schema.json +45 -3
  32. package/src/workspace/broker-protocol.test.ts +45 -0
  33. package/src/workspace/broker-protocol.ts +131 -5
  34. package/src/workspace/conformance/__fixtures__/reference-broker.ts +3 -1
  35. package/src/workspace/conformance/broker.ts +22 -0
  36. package/src/workspace/declaration.schema.json +32 -6
  37. package/src/workspace/declaration.ts +29 -15
  38. package/src/workspace/export.ts +10 -3
  39. package/src/workspace/factory-rules.test.ts +6 -1
  40. package/src/workspace/factory-rules.ts +10 -3
  41. package/src/workspace/reason-codes.ts +1 -1
  42. package/src/workspace/write-scope.test.ts +18 -1
  43. package/src/workspace/write-scope.ts +15 -10
@@ -12,6 +12,7 @@ import {
12
12
  parsePayerHeader,
13
13
  payerOf,
14
14
  refusalMessage,
15
+ spendFromRuns,
15
16
  STANDARD_CAPABILITIES,
16
17
  } from "./broker-protocol";
17
18
 
@@ -120,3 +121,47 @@ describe("a grant (#3477)", () => {
120
121
  for (const bad of ["", "has space", "line\nbreak", "x".repeat(GRANT_MAX_LENGTH + 1), 42, null]) expect(isGrantValue(bad), String(bad).slice(0, 20)).toBe(false);
121
122
  });
122
123
  });
124
+
125
+ describe("a report's listing and spend (#3508)", () => {
126
+ const spend = { month: "2026-10", usd: 1.84, runs: 3, unpriced: 1, byPrincipal: [{ principal: "github:octo", usd: 1.84, runs: 2, unpriced: 0 }, { principal: null, usd: 0, runs: 1, unpriced: 1 }] };
127
+
128
+ test("are kept when well formed, with published defaulting to true, and left out when absent", () => {
129
+ expect(parseDeclarationReport({ capabilities: [], listing: { title: "Salad", line: "a salad app" }, spend }, "lobby")).toEqual({
130
+ capabilities: [],
131
+ listing: { published: true, title: "Salad", line: "a salad app" },
132
+ spend,
133
+ });
134
+ expect(parseDeclarationReport({ capabilities: [] }, "lobby")).toEqual({ capabilities: [] });
135
+ });
136
+
137
+ test("a malformed listing or spend refuses the report", () => {
138
+ for (const listing of [[], { title: "x".repeat(61) }, { title: "a\nb" }, { line: "x".repeat(141) }, { published: "yes" }]) {
139
+ expect(parseDeclarationReport({ capabilities: [], listing }, "lobby"), JSON.stringify(listing)).toHaveProperty("error");
140
+ }
141
+ for (const bad of [{ ...spend, month: "2026-13" }, { ...spend, usd: -1 }, { ...spend, runs: 1.5 }, { ...spend, byPrincipal: [{ principal: "", usd: 0, runs: 0, unpriced: 0 }] }]) {
142
+ expect(parseDeclarationReport({ capabilities: [], spend: bad }, "lobby"), JSON.stringify(bad)).toHaveProperty("error");
143
+ }
144
+ });
145
+
146
+ test("spend sums a month's runs per principal, with a run that has no USD cost unpriced, never zero", () => {
147
+ const runs = [
148
+ { startedAt: "2026-10-02T10:00:00Z", by: "github:octo", cost: { amount: 1.5, currency: "USD", source: "x" } },
149
+ { startedAt: "2026-10-03T10:00:00Z", by: "github:octo", cost: { amount: 0.34, currency: "USD", source: "x" } },
150
+ { startedAt: "2026-10-03T11:00:00Z", by: null, cost: null },
151
+ { startedAt: "2026-10-04T11:00:00Z", by: "github:ada", cost: { amount: 2, currency: "EUR", source: "x" } },
152
+ { startedAt: "2026-09-30T23:00:00Z", by: "github:octo", cost: { amount: 9, currency: "USD", source: "x" } },
153
+ ];
154
+ expect(spendFromRuns({ runs }, "2026-10")).toEqual({
155
+ month: "2026-10",
156
+ usd: 1.84,
157
+ runs: 4,
158
+ unpriced: 2,
159
+ byPrincipal: [
160
+ { principal: "github:octo", usd: 1.84, runs: 2, unpriced: 0 },
161
+ { principal: null, usd: 0, runs: 1, unpriced: 1 },
162
+ { principal: "github:ada", usd: 0, runs: 1, unpriced: 1 },
163
+ ],
164
+ });
165
+ expect(spendFromRuns({ error: { code: "x" } }, "2026-10")).toBeNull();
166
+ });
167
+ });
@@ -30,6 +30,10 @@
30
30
  * decide answer ({@link BrokerPayer}). It is optional within version 1.
31
31
  * A box names whose turn a request is with a `chant-grant` header the broker
32
32
  * issued (#3477, ws-099); the broker then may spend that person's credential.
33
+ * A report may carry the box's listing and this month's spend from its run
34
+ * records (#3508, ws-102), {@link BoxListing} and {@link BoxSpend}; both are
35
+ * optional within version 1, and a broker that has no use for them ignores
36
+ * them.
33
37
  *
34
38
  * The rule every broker keeps: a request that needs scope word `w` of
35
39
  * capability `c` is refused with a 403 unless the box's last report holds an
@@ -266,14 +270,132 @@ export interface KeptDeclaration {
266
270
  capabilities: KeptCapability[];
267
271
  at: string;
268
272
  payer?: BrokerPayer;
273
+ /** The report's listing, when the broker keeps it (#3508). */
274
+ listing?: BoxListing;
275
+ /** The report's spend, when the broker keeps it (#3508). */
276
+ spend?: BoxSpend;
269
277
  }
270
278
 
271
279
  /**
272
- * A report's body, checked, kept to the entries that name `broker`; or the
273
- * reason it is refused with a 400. The same rules as studio's lobby
274
- * (`lobby/capabilities.mjs`).
280
+ * The box block's listing (ws-077) as a report carries it (#3508, ws-102):
281
+ * what `chant workspace status --json` prints under the box member's
282
+ * `box.listing`, less the cover. A broker that lists boxes lists the box
283
+ * under it; the repository keeps it, and the broker only a cache.
275
284
  */
276
- export function parseDeclarationReport(body: unknown, broker: string): { capabilities: KeptCapability[] } | { error: string } {
285
+ export interface BoxListing {
286
+ /** Whether people other than the owner see the box listed. Absent in a report means true. */
287
+ published: boolean;
288
+ title: string;
289
+ line: string;
290
+ }
291
+
292
+ /** The bounds on a reported listing: the ones `chant workspace box listing set` writes with. */
293
+ export const LISTING_LIMITS = { title: 60, line: 140 } as const;
294
+
295
+ /** One principal's share of a month's spend, or the month's totals without `principal`. */
296
+ export interface SpendFigures {
297
+ /** The sum of the priced runs' USD costs, rounded to 1/10000 of a dollar. */
298
+ usd: number;
299
+ runs: number;
300
+ /** Runs with no USD cost: left out of `usd`, never counted as zero. */
301
+ unpriced: number;
302
+ }
303
+
304
+ /**
305
+ * What the box's run records (ws-076) say it spent in one month (#3508,
306
+ * ws-102), as a report carries it: the month's totals and the same per
307
+ * principal the runs worked for. {@link spendFromRuns} computes it.
308
+ */
309
+ export interface BoxSpend extends SpendFigures {
310
+ /** `YYYY-MM`, in UTC, matched against each run's `startedAt`. */
311
+ month: string;
312
+ /** At most {@link SPEND_LIMITS.principals}, in the order the runs first name them; `principal` null for runs that name none. */
313
+ byPrincipal: (SpendFigures & { principal: string | null })[];
314
+ }
315
+
316
+ /** The bounds on a reported spend. */
317
+ export const SPEND_LIMITS = { principals: 50, principalLength: 200, figure: 1e9 } as const;
318
+
319
+ const SPEND_MONTH = /^\d{4}-(0[1-9]|1[0-2])$/;
320
+ // eslint-disable-next-line no-control-regex
321
+ const CONTROL = /[\u0000-\u001f\u007f]/;
322
+ const roundUsd = (usd: number) => Math.round(usd * 1e4) / 1e4;
323
+
324
+ /**
325
+ * A box's spend in `month` (`YYYY-MM`) from the runs `chant workspace runs
326
+ * --json` prints (`doc.runs`): every run whose `startedAt` falls in the
327
+ * month, summed per the principal in its `by`. Only a USD `cost` is summed; a
328
+ * run with none, or with another currency, is unpriced. Null when `doc` holds
329
+ * no runs list.
330
+ */
331
+ export function spendFromRuns(doc: unknown, month: string): BoxSpend | null {
332
+ const runs = (doc as { runs?: unknown } | null)?.runs;
333
+ if (!Array.isArray(runs)) return null;
334
+ const total: SpendFigures = { usd: 0, runs: 0, unpriced: 0 };
335
+ const people = new Map<string | null, SpendFigures & { principal: string | null }>();
336
+ for (const r of runs as { startedAt?: unknown; by?: unknown; cost?: { currency?: unknown; amount?: unknown } | null }[]) {
337
+ if (!String(r?.startedAt ?? "").startsWith(month)) continue;
338
+ const principal = typeof r.by === "string" && r.by.length > 0 ? r.by.slice(0, SPEND_LIMITS.principalLength) : null;
339
+ let p = people.get(principal);
340
+ if (!p) {
341
+ p = { principal, usd: 0, runs: 0, unpriced: 0 };
342
+ people.set(principal, p);
343
+ }
344
+ const amount = r.cost?.amount;
345
+ const usd = r.cost?.currency === "USD" && typeof amount === "number" && Number.isFinite(amount) && amount >= 0 ? amount : null;
346
+ for (const o of [total, p]) {
347
+ o.runs++;
348
+ if (usd === null) o.unpriced++;
349
+ else o.usd += usd;
350
+ }
351
+ }
352
+ const byPrincipal = [...people.values()].slice(0, SPEND_LIMITS.principals).map((p) => ({ ...p, usd: roundUsd(p.usd) }));
353
+ return { month, usd: roundUsd(total.usd), runs: total.runs, unpriced: total.unpriced, byPrincipal };
354
+ }
355
+
356
+ /** A reported listing, checked as studio's lobby checks it: undefined when none was reported, or the reason it is refused. */
357
+ function parseListing(raw: unknown): { listing?: BoxListing } | { error: string } {
358
+ if (raw === undefined || raw === null) return {};
359
+ if (typeof raw !== "object" || Array.isArray(raw)) return { error: 'The listing is { "published", "title", "line" }.' };
360
+ const { published, title = "", line = "" } = raw as { published?: unknown; title?: unknown; line?: unknown };
361
+ if (typeof title !== "string" || title.length > LISTING_LIMITS.title || CONTROL.test(title)) return { error: `The listing's title is a string of at most ${LISTING_LIMITS.title} characters with no control character.` };
362
+ if (typeof line !== "string" || line.length > LISTING_LIMITS.line || CONTROL.test(line)) return { error: `The listing's line is a string of at most ${LISTING_LIMITS.line} characters with no control character.` };
363
+ if (published !== undefined && typeof published !== "boolean") return { error: "The listing's published is true or false." };
364
+ return { listing: { published: published !== false, title, line } };
365
+ }
366
+
367
+ /** A reported spend, checked as studio's lobby checks it: undefined when none was reported, or the reason it is refused. */
368
+ function parseSpend(raw: unknown): { spend?: BoxSpend } | { error: string } {
369
+ if (raw === undefined || raw === null) return {};
370
+ const bad = { error: 'The spend is { "month", "usd", "runs", "unpriced", "byPrincipal": [{ "principal", "usd", "runs", "unpriced" }] }, from chant workspace runs --json.' };
371
+ const money = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v < SPEND_LIMITS.figure;
372
+ const count = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0 && (v as number) < SPEND_LIMITS.figure;
373
+ const figures = (o: { usd?: unknown; runs?: unknown; unpriced?: unknown }): SpendFigures | null =>
374
+ money(o.usd) && count(o.runs) && count(o.unpriced) ? { usd: roundUsd(o.usd), runs: o.runs, unpriced: o.unpriced } : null;
375
+ if (typeof raw !== "object" || Array.isArray(raw)) return bad;
376
+ const r = raw as { month?: unknown; byPrincipal?: unknown };
377
+ const total = figures(raw as object);
378
+ if (!SPEND_MONTH.test(String(r.month)) || total === null) return bad;
379
+ const list = r.byPrincipal ?? [];
380
+ if (!Array.isArray(list) || list.length > SPEND_LIMITS.principals) return bad;
381
+ const byPrincipal: BoxSpend["byPrincipal"] = [];
382
+ for (const one of list as { principal?: unknown; usd?: unknown; runs?: unknown; unpriced?: unknown }[]) {
383
+ if (!one || typeof one !== "object") return bad;
384
+ const principal = one.principal;
385
+ if (principal !== null && (typeof principal !== "string" || principal.length === 0 || principal.length > SPEND_LIMITS.principalLength)) return bad;
386
+ const f = figures(one);
387
+ if (f === null) return bad;
388
+ byPrincipal.push({ principal, ...f });
389
+ }
390
+ return { spend: { month: r.month as string, ...total, byPrincipal } };
391
+ }
392
+
393
+ /**
394
+ * A report's body, checked, kept to the entries that name `broker`, with its
395
+ * listing and spend when it carries them (#3508); or the reason it is refused
396
+ * with a 400. The same rules as studio's lobby (`lobby/capabilities.mjs`).
397
+ */
398
+ export function parseDeclarationReport(body: unknown, broker: string): { capabilities: KeptCapability[]; listing?: BoxListing; spend?: BoxSpend } | { error: string } {
277
399
  const list = (body as { capabilities?: unknown } | null)?.capabilities;
278
400
  if (!Array.isArray(list)) return { error: 'The body is { "capabilities": [{ "name", "broker", "scope" }] }.' };
279
401
  if (list.length > DECLARATION_LIMITS.capabilities) return { error: `At most ${DECLARATION_LIMITS.capabilities} capabilities.` };
@@ -289,7 +411,11 @@ export function parseDeclarationReport(body: unknown, broker: string): { capabil
289
411
  for (const w of scope as string[]) if (!seen.scope.includes(w)) seen.scope.push(w);
290
412
  kept.set(c.name, seen);
291
413
  }
292
- return { capabilities: [...kept.values()] };
414
+ const listed = parseListing((body as { listing?: unknown }).listing);
415
+ if ("error" in listed) return listed;
416
+ const spent = parseSpend((body as { spend?: unknown }).spend);
417
+ if ("error" in spent) return spent;
418
+ return { capabilities: [...kept.values()], ...listed, ...spent };
293
419
  }
294
420
 
295
421
  /**
@@ -108,7 +108,9 @@ export async function startReferenceBroker(env: BrokerConformanceEnv): Promise<S
108
108
  }
109
109
  const parsed = parseDeclarationReport(body, env.broker);
110
110
  if ("error" in parsed) return refuse(res, 400, parsed.error);
111
- const kept: KeptDeclaration = { capabilities: parsed.capabilities, at: new Date().toISOString() };
111
+ // It keeps the listing and spend a report carries (#3508) and answers with them, as studio's lobby does.
112
+ const { capabilities, listing, spend } = parsed;
113
+ const kept: KeptDeclaration = { capabilities, at: new Date().toISOString(), ...(listing ? { listing } : {}), ...(spend ? { spend } : {}) };
112
114
  reports.set(box, kept);
113
115
  return send(res, 200, { ...kept, payer });
114
116
  }
@@ -350,6 +350,28 @@ export const BROKER_CHECKS: readonly BrokerCheck[] = [
350
350
  return refusalProblems(r, [], 400);
351
351
  },
352
352
  },
353
+ {
354
+ id: "declaration-listing-spend",
355
+ capability: "declaration",
356
+ title: "a report that also carries the box's listing and this month's spend (#3508) is answered 200, and an answer that echoes them echoes them unchanged",
357
+ async run(ctx) {
358
+ const body = {
359
+ capabilities: [{ name: "inference", broker: ctx.env.broker, scope: ["agent", "decide"] }],
360
+ listing: { published: true, title: "Conformance", line: "a box the broker conformance suite reports" },
361
+ spend: { month: "2026-10", usd: 1.25, runs: 3, unpriced: 1, byPrincipal: [{ principal: "github:conformance", usd: 1.25, runs: 2, unpriced: 0 }, { principal: null, usd: 0, runs: 1, unpriced: 1 }] },
362
+ };
363
+ const problems = ctx.validate("declarationReport", body).map((p) => `the suite's own report: ${p}`);
364
+ const r = await ctx.call(A, "POST", BROKER_ROUTES.declaration, { body });
365
+ if (r.status !== 200) return [...problems, `expected 200, got ${r.status}: ${r.text.slice(0, 200)}`];
366
+ problems.push(...ctx.validate("declarationKept", r.json));
367
+ const answer = r.json as { capabilities?: { name: string }[]; listing?: unknown; spend?: unknown };
368
+ if (!(answer.capabilities ?? []).some((c) => c.name === "inference")) problems.push("it did not keep the report's inference entry");
369
+ for (const key of ["listing", "spend"] as const) {
370
+ if (answer[key] !== undefined && !isDeepStrictEqual(answer[key], body[key])) problems.push(`the answer's ${key} is ${JSON.stringify(answer[key])}, not the ${key} reported`);
371
+ }
372
+ return problems;
373
+ },
374
+ },
353
375
  {
354
376
  id: "unreported-refused",
355
377
  capability: "declaration",
@@ -133,7 +133,7 @@
133
133
  },
134
134
  "agents": {
135
135
  "type": "array",
136
- "description": "The agent sessions the workspace declares (#2524 D20, #2548, ws-067). Each is bound to one member: it writes that member's files and the records of kinds that member or the workspace declares, as writeScope.agent allows. A session resumes from chant workspace agent <name> --json, which prints its member, its scope and the spec, read from the repository alone. A write names its session with the CHANT_AGENT environment variable, or a commit with a Chant-Agent trailer. Added in schema 1 by chant 0.101.0, so a declaration that uses it sets minReader to 0.101.0 or newer.",
136
+ "description": "The agent sessions the workspace declares (#2524 D20, #2548, ws-067, ws-101). Each is bound to the members it names, one with member or several with members: it writes the files of any of them and the records of kinds any of them or the workspace declares, as writeScope.agent allows. A session resumes from chant workspace agent <name> --json, which prints its member, its scope and the spec, read from the repository alone. A write names its session with the CHANT_AGENT environment variable, or a commit with a Chant-Agent trailer. Added in schema 1 by chant 0.101.0, so a declaration that uses it sets minReader to 0.101.0 or newer.",
137
137
  "items": {
138
138
  "$ref": "#/$defs/agent"
139
139
  }
@@ -1322,7 +1322,7 @@
1322
1322
  },
1323
1323
  "agentScope": {
1324
1324
  "type": "object",
1325
- "description": "The agent class's write scope. Its members are always its session's one member, so only the record kinds and the protected paths can be set.",
1325
+ "description": "The agent class's write scope. Its members are always the members its session is bound to, so only the record kinds and the protected paths can be set.",
1326
1326
  "properties": {
1327
1327
  "protected": {
1328
1328
  "type": "array",
@@ -1343,10 +1343,27 @@
1343
1343
  },
1344
1344
  "agent": {
1345
1345
  "type": "object",
1346
- "description": "An agent session (#2548).",
1346
+ "description": "An agent session (#2548). It names its members with exactly one of member and members (ws-101).",
1347
1347
  "required": [
1348
- "name",
1349
- "member"
1348
+ "name"
1349
+ ],
1350
+ "oneOf": [
1351
+ {
1352
+ "properties": {
1353
+ "member": true
1354
+ },
1355
+ "required": [
1356
+ "member"
1357
+ ]
1358
+ },
1359
+ {
1360
+ "properties": {
1361
+ "members": true
1362
+ },
1363
+ "required": [
1364
+ "members"
1365
+ ]
1366
+ }
1350
1367
  ],
1351
1368
  "properties": {
1352
1369
  "name": {
@@ -1355,7 +1372,16 @@
1355
1372
  },
1356
1373
  "member": {
1357
1374
  "$ref": "#/$defs/name",
1358
- "description": "The one member the session is bound to. It must be a declared member, not an example group."
1375
+ "description": "The one member the session is bound to. It must be a declared member, not an example group. A session bound to several names them with members instead."
1376
+ },
1377
+ "members": {
1378
+ "type": "array",
1379
+ "minItems": 1,
1380
+ "uniqueItems": true,
1381
+ "items": {
1382
+ "$ref": "#/$defs/name"
1383
+ },
1384
+ "description": "The members the session is bound to, when it writes in more than one, such as a factory that builds an app and writes its evidence records in a design member (ws-101, arugula-salad/studio#402). Each must be a declared member, not an example group. The session may write the files of any of them and the records of kinds any of them or the workspace declares, and the write scope is judged against all of them together. Added in schema 1 by chant 0.105.0, so a declaration that uses it sets minReader to 0.105.0 or newer."
1359
1385
  },
1360
1386
  "principals": {
1361
1387
  "type": "array",
@@ -119,7 +119,7 @@ export type WriteVerb = (typeof WRITE_VERBS)[number];
119
119
 
120
120
  /** One principal class's entry in the declaration's `writeScope` (#2548, ws-067). */
121
121
  export interface ClassScope {
122
- /** The members whose files the class may write, or null for every path. Always null for the agent class: a session writes its own member. */
122
+ /** The members whose files the class may write, or null for every path. Always null for the agent class: a session writes the members it is bound to. */
123
123
  members: string[] | null;
124
124
  /** Kind name to the verbs the class may write it with, or null for every kind in reach with every verb. */
125
125
  records: Record<string, WriteVerb[]> | null;
@@ -164,9 +164,16 @@ export interface IdentityPolicy {
164
164
  gates: Record<string, SignedGate>;
165
165
  }
166
166
 
167
- /** An agent session the declaration names (#2524 D20, #2548): bound to one member. */
167
+ /**
168
+ * An agent session the declaration names (#2524 D20, #2548): bound to the
169
+ * members it lists, one or more (ws-101). It writes the files of each, and
170
+ * the records of kinds any of them or the workspace declares.
171
+ */
168
172
  export interface AgentDeclaration {
169
173
  name: string;
174
+ /** The members the session is bound to, in file order: `members`, or the one `member` names. Never empty. */
175
+ members: string[];
176
+ /** The first of `members`, kept for readers of a one-member session. */
170
177
  member: string;
171
178
  /** Principals that write only as this session. */
172
179
  principals: string[];
@@ -952,8 +959,8 @@ function writeScopeOf(raw: unknown, members: Member[], at: (pointer: string, key
952
959
 
953
960
  /**
954
961
  * The `agents` list, already validated, with the rules the schema can't say
955
- * (#2548): names are unique, each names a declared member, and a principal
956
- * is listed by one session at most.
962
+ * (#2548, ws-101): names are unique, every member a session names is a
963
+ * declared member, and a principal is listed by one session at most.
957
964
  */
958
965
  /** The `identity` block, already validated by the schema (#3163). */
959
966
  function identityOf(raw: unknown): IdentityPolicy | null {
@@ -967,23 +974,30 @@ function identityOf(raw: unknown): IdentityPolicy | null {
967
974
  }
968
975
 
969
976
  function agentsOf(raw: unknown, members: Member[], at: (pointer: string, key?: boolean) => ErrorLocation): AgentDeclaration[] {
970
- const agents = ((raw as { name: string; member: string; principals?: string[] }[] | undefined) ?? []).map((a, i) => ({
971
- name: a.name,
972
- member: a.member,
973
- principals: [...(a.principals ?? [])],
974
- pointer: `/agents/${i}`,
975
- }));
977
+ const agents = ((raw as { name: string; member?: string; members?: string[]; principals?: string[] }[] | undefined) ?? []).map((a, i) => {
978
+ // The schema requires exactly one of member and members (ws-101).
979
+ const bound = a.members !== undefined ? [...a.members] : [a.member!];
980
+ return {
981
+ name: a.name,
982
+ members: bound,
983
+ member: bound[0],
984
+ principals: [...(a.principals ?? [])],
985
+ pointer: `/agents/${i}`,
986
+ listed: a.members !== undefined,
987
+ };
988
+ });
976
989
  const byName = new Map<string, AgentDeclaration>();
977
990
  const byPrincipal = new Map<string, AgentDeclaration>();
978
- for (const a of agents) {
991
+ for (const { listed, ...a } of agents) {
979
992
  const first = byName.get(a.name);
980
993
  if (first) throw new WorkspaceReadError("declaration-invalid", `the agent name ${JSON.stringify(a.name)} is already used by the agent at ${first.pointer}`, at(`${a.pointer}/name`));
981
994
  byName.set(a.name, a);
982
- if (!members.some((m) => m.name === a.member)) {
995
+ for (const [i, name] of a.members.entries()) {
996
+ if (members.some((m) => m.name === name)) continue;
983
997
  throw new WorkspaceReadError(
984
998
  "declaration-invalid",
985
- `agent ${a.name} is bound to ${JSON.stringify(a.member)}, which is not a declared member; an agent session is bound to one member, not an example group`,
986
- at(`${a.pointer}/member`),
999
+ `agent ${a.name} is bound to ${JSON.stringify(name)}, which is not a declared member; an agent session is bound to members, not an example group`,
1000
+ at(listed ? `${a.pointer}/members/${i}` : `${a.pointer}/member`),
987
1001
  );
988
1002
  }
989
1003
  for (const [i, p] of a.principals.entries()) {
@@ -992,7 +1006,7 @@ function agentsOf(raw: unknown, members: Member[], at: (pointer: string, key?: b
992
1006
  byPrincipal.set(p, a);
993
1007
  }
994
1008
  }
995
- return agents;
1009
+ return [...byName.values()];
996
1010
  }
997
1011
 
998
1012
  /** The `changes` block, already validated, with its defaults (#2773). */
@@ -414,9 +414,16 @@ export async function exportWorkspace(o: ExportOptions): Promise<ExportResult> {
414
414
  return keep.length > 0 ? { ...rest, links: keep } : rest;
415
415
  });
416
416
  } else if (key === "agents") {
417
- const agents = value as { name: string; member: string }[];
418
- for (const a of agents) if (!names.has(a.member)) dropped.agents.push(a.name);
419
- outDecl.agents = agents.filter((a) => names.has(a.member));
417
+ // A session bound to several members (ws-101) keeps those that go, and is dropped when none does.
418
+ const agents = value as { name: string; member?: string; members?: string[] }[];
419
+ const kept: Record<string, unknown>[] = [];
420
+ for (const a of agents) {
421
+ const bound = a.members ?? (a.member !== undefined ? [a.member] : []);
422
+ const going = bound.filter((m) => names.has(m));
423
+ if (going.length === 0) dropped.agents.push(a.name);
424
+ else kept.push(a.members !== undefined ? { ...a, members: going } : a);
425
+ }
426
+ outDecl.agents = kept;
420
427
  } else if (key === "pins") {
421
428
  const pins = value as { path?: string; package?: string }[];
422
429
  for (const p of pins) if (p.path !== undefined) dropped.pins.push(p.path);
@@ -8,7 +8,7 @@ import { describe, expect, test } from "vitest";
8
8
  import { isAsk, pickable, retryState, doneVerdict, proposeImplements, understandOutcome, type FactoryContext, type FactoryItem } from "./factory-rules";
9
9
 
10
10
  describe("the factory's rules", () => {
11
- const base: FactoryItem = { id: "W-1", state: "open", openState: "open", proposedState: "proposed", ready: true, data: {}, tier: null, contract: null, warnings: [], answers: [], leased: false };
11
+ const base: FactoryItem = { id: "W-1", state: "open", openState: "open", proposedState: "proposed", ready: true, data: {}, tier: null, contract: { id: "C-1", state: "approved" }, warnings: [], answers: [], leased: false };
12
12
  const ctx: FactoryContext = { intentDecided: null, claims: [], attempts: { exhausted: false }, branch: null, questions: { tier: null, understand: null } };
13
13
  const hold = (i: Partial<FactoryItem>, c: Partial<FactoryContext> = {}) => {
14
14
  const v = pickable({ ...base, ...i }, { ...ctx, ...c });
@@ -19,6 +19,11 @@ describe("the factory's rules", () => {
19
19
  expect(hold({})).toBe("ok");
20
20
  expect(hold({}, { intentDecided: false })).toBe("intent-undecided");
21
21
  expect(hold({ ready: false })).toBe("not-ready");
22
+ // Only an item that names a contract, an ask or an intent build is the factory's (#3503).
23
+ expect(hold({ contract: null })).toBe("no-contract");
24
+ expect(hold({ contract: null, data: { source: { kind: "finding", finding: "F-1" } } })).toBe("no-contract");
25
+ expect(hold({ contract: null, data: { source: { ask: { said: "x", by: "a" } } } })).toBe("ok");
26
+ expect(hold({ contract: null, data: { source: { intent: { answer: "x" } } } })).toBe("ok");
22
27
  expect(hold({ ready: false, state: "proposed", data: { source: { ask: { said: "x", by: "a" } } } })).toBe("ok");
23
28
  expect(hold({ contract: { id: "C-1", state: "draft" }, warnings: ["work-contract-undecided"] })).toBe("contract-not-approved");
24
29
  expect(hold({}, { branch: { state: "done", applied: false } })).toBe("built-not-applied");
@@ -4,8 +4,11 @@
4
4
  * reference Op (`../op/factory.ts`) runs them; an orchestrator supplies only
5
5
  * execution (the builder, the context, the check).
6
6
  *
7
- * - Readiness ({@link pickable}): on top of the work kind's `ready`, the box's
8
- * intent is decided, a contract the item builds is approved and in force,
7
+ * - Readiness ({@link pickable}): the item names a contract, or is an ask or
8
+ * the box's first build from its intent ({@link isAsk}); a work item a
9
+ * person wrote by hand or a finding's triage item stays with its author
10
+ * (#3503). On top of the work kind's `ready`, the box's intent is decided,
11
+ * a contract the item builds is approved and in force,
9
12
  * the item is under its attempt limit, it is not done and waiting to be
10
13
  * applied on its branch, its slice-tier and understand questions are not
11
14
  * open, an understand answer of redraft or ask holds it, and a failed last
@@ -37,6 +40,7 @@ export type UnderstandAnswer = (typeof UNDERSTAND_ANSWERS)[number];
37
40
  /** Why an item is not picked. Closed. */
38
41
  export const FACTORY_HOLDS = [
39
42
  "not-ready",
43
+ "no-contract",
40
44
  "intent-undecided",
41
45
  "contract-not-approved",
42
46
  "attempts-exhausted",
@@ -133,7 +137,9 @@ export function understandOutcome(answer: unknown): "dropped" | "redraft" | "ask
133
137
  const OPEN = new Set(["escalated", "proposed"]);
134
138
 
135
139
  /**
136
- * Whether the factory picks `item` now, or why not. Asks are candidates while
140
+ * Whether the factory picks `item` now, or why not. The factory builds an
141
+ * item that names a contract, an ask, or an intent build, as studio's queue
142
+ * did (#3503); any other item is its author's. Asks are candidates while
137
143
  * proposed as well as open; any other item only when the work kind reads it
138
144
  * ready.
139
145
  */
@@ -142,6 +148,7 @@ export function pickable(item: FactoryItem, ctx: FactoryContext): { ok: true } |
142
148
  if (ctx.intentDecided === false) return no("intent-undecided", "the box's intent is not decided yet, so nothing is picked");
143
149
  if (item.leased) return no("leased", `${item.id} is leased`);
144
150
  const ask = isAsk(item.data);
151
+ if (!item.contract && !ask) return no("no-contract", `${item.id} names no contract and is not an ask or an intent build, so it is left to its author`);
145
152
  const candidate = item.ready || (ask && item.proposedState !== null && item.state === item.proposedState);
146
153
  if (!candidate) return no("not-ready", `${item.id} is ${item.state ?? "in no state"} and not ready`);
147
154
  if (item.contract && (item.warnings.includes("work-contract-undecided") || item.warnings.includes("work-contract-unknown"))) {
@@ -141,7 +141,7 @@ export const REASONS = {
141
141
  "write-lock-timeout": "Another write held the working tree's write lock for longer than the write waits (CHANT_WRITE_LOCK_WAIT_MS); the refusal names the holder. Run the write again.",
142
142
  "write-lock-not-held": "CHANT_WRITE_LOCK names a batch's lock token that no longer holds the working tree's write lock: the batch released it, or it expired and another writer took it.",
143
143
  // Write scope (#2548): a records write that is refused, and a commit check --changes reports, outside the writer's scope.
144
- "write-scope-member": "The write is to a file, or to a record kind, of a member outside the writer's scope: an agent session writes only its own member, and writeScope.<class>.members leaves the member out.",
144
+ "write-scope-member": "The write is to a file, or to a record kind, of a member outside the writer's scope: an agent session writes only the members it is bound to, and writeScope.<class>.members leaves the member out.",
145
145
  "write-scope-kind": "The write is to a record kind writeScope.<class>.records does not list, with a verb it does not list for the kind, or deletes a record.",
146
146
  "write-scope-protected": "The write is to a file writeScope.<class>.protected lists, or one under a directory it lists, outside the top-level keys or JSON Pointers the entry's except allows (#3146, #3308).",
147
147
  "write-scope-class-unknown": "The declaration's writeScope at base names a principal class no pinned package supplies, and the writer is judged human, so it may be in that class; the write is refused until the package is installed at the pinned version or the entry removed (#3080).",
@@ -127,6 +127,9 @@ describe("the declaration's writeScope and agents (#2548)", () => {
127
127
  ["a principal two sessions list", declaration(SCOPE, [{ name: "a", member: "app", principals: ["p"] }, { name: "b", member: "design", principals: ["p"] }]), /already listed by agent a/],
128
128
  ["two sessions with one name", declaration(SCOPE, [{ name: "a", member: "app" }, { name: "a", member: "design" }]), /already used/],
129
129
  ["a members list on the agent class", declaration({ agent: { members: ["app"] } }), /members/],
130
+ ["an agent naming an undeclared member among several", declaration(SCOPE, [{ name: "a", members: ["app", "nowhere"] }]), /"nowhere", which is not a declared member/],
131
+ ["an agent with both member and members", declaration(SCOPE, [{ name: "a", member: "app", members: ["design"] }]), /oneOf|exactly one|must match/],
132
+ ["an agent with an empty members list", declaration(SCOPE, [{ name: "a", members: [] }]), /fewer than 1|minItems|members/],
130
133
  ["an unknown verb", declaration({ agent: { records: { decision: ["delete"] } } }), /must be equal to one of the allowed values|delete/],
131
134
  ])("refuse %s", (_, text, message) => {
132
135
  let err: unknown;
@@ -210,6 +213,19 @@ describe("judging a write", () => {
210
213
  expect(judgeRecord(decl, human, screen, "delete")).toEqual({ ok: true });
211
214
  });
212
215
 
216
+ test("an agent bound to several members writes in any of them, and its scope is their union (ws-101)", () => {
217
+ const d = parseDeclaration(declaration(SCOPE, [...AGENTS, { name: "factory", members: ["app", "design"] }]), "chant.workspace.json");
218
+ expect(d.agents.find((a) => a.name === "factory")).toMatchObject({ members: ["app", "design"], member: "app" });
219
+ expect(d.agents.find((a) => a.name === "app-agent")).toMatchObject({ members: ["app"], member: "app" });
220
+ const factory = resolveWriter(d, policy, { agent: "factory" });
221
+ expect(judgePath(d, factory, "app/server.mjs")).toEqual({ ok: true });
222
+ expect(judgePath(d, factory, "design/spec.md")).toEqual({ ok: true });
223
+ expect(judgePath(d, factory, "chant.workspace.json")).toMatchObject({ ok: false, code: "write-scope-member", message: expect.stringContaining("agent session factory, bound to members app, design,") });
224
+ expect(judgeRecord(d, factory, screen, "new")).toEqual({ ok: true });
225
+ expect(judgeRecord(d, factory, ws, "new")).toEqual({ ok: true });
226
+ expect(judgeRecord(d, factory, screen, "amend")).toMatchObject({ ok: false, code: "write-scope-kind" });
227
+ });
228
+
213
229
  test("the declared name of a kind is a key for its records rule too", () => {
214
230
  const d = parseDeclaration(declaration({ agent: { records: { screen: ["new"] } } }), "chant.workspace.json");
215
231
  const design = resolveWriter(d, policy, { agent: "design-agent" });
@@ -335,7 +351,8 @@ describe("chant workspace agent: what a session reloads from (#2548)", () => {
335
351
  agent.expectValid(doc);
336
352
  if ("error" in doc) throw new Error(doc.error.message);
337
353
  expect(doc.workspace).toEqual({ name: "studio", root: ".", scopeFrom: "base" });
338
- expect(doc.agent).toEqual({ name: "design-agent", member: { name: "design", dir: "design", kind: "other" }, principals: [] });
354
+ const design = { name: "design", dir: "design", kind: "other" };
355
+ expect(doc.agent).toEqual({ name: "design-agent", member: design, members: [design], principals: [] });
339
356
  expect(doc.scope).toEqual({
340
357
  members: ["design"],
341
358
  records: [
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * Write scope per member and record kind, for each principal class, and the
3
- * agent sessions bound to one member (#2524 D5, D20; #2548; ws-067).
3
+ * agent sessions bound to the members they name (#2524 D5, D20; #2548;
4
+ * ws-067, ws-101).
4
5
  *
5
6
  * The declaration's `writeScope` block gives a restricted class the members
6
7
  * whose files it may write and the record kinds it may write, with which
7
8
  * verbs (`new`, `amend`, `review`, `close`). A class with no entry is not
8
9
  * restricted. The declaration's `agents` list names agent sessions, each
9
- * bound to one member: a session writes that member's files, and the records
10
- * of kinds that member or the workspace declares, as `writeScope.agent`
11
- * allows. An agent's members can't be widened.
10
+ * bound to one member or several (ws-101): a session writes the files of any
11
+ * of its members, and the records of kinds any of them or the workspace
12
+ * declares, as `writeScope.agent` allows. The scope is the union of its
13
+ * members, and `writeScope.agent` can't widen it.
12
14
  *
13
15
  * Two places apply it, both reading the scope from the base revision so a
14
16
  * change can't widen its own scope (#2524 threat model):
@@ -170,7 +172,7 @@ export function unknownClassVerdict(declaration: Declaration | null, writer: Wri
170
172
  };
171
173
  }
172
174
 
173
- /** The scope that applies to `writer`, or null when its class is not restricted. An agent is always restricted to its member. */
175
+ /** The scope that applies to `writer`, or null when its class is not restricted. An agent is always restricted to its members. */
174
176
  export function scopeOf(declaration: Declaration | null, writer: Writer): ClassScope | null {
175
177
  const entry = declaration?.writeScope?.[writer.class];
176
178
  if (writer.class === "agent") return { members: null, records: entry?.records ?? null, protected: entry?.protected ?? [], pointer: entry?.pointer ?? "/agents" };
@@ -185,14 +187,17 @@ export type RecordVerdict = { ok: true } | { ok: false; code: Exclude<WriteScope
185
187
  const OK = { ok: true } as const;
186
188
 
187
189
  function who(writer: Writer): string {
188
- if (writer.agent) return `agent session ${writer.agent.name}, bound to member ${writer.agent.member},`;
190
+ if (writer.agent) {
191
+ const bound = writer.agent.members;
192
+ return `agent session ${writer.agent.name}, bound to ${bound.length === 1 ? "member" : "members"} ${bound.join(", ")},`;
193
+ }
189
194
  const name = writer.principal !== null ? `${writer.principal} (${writer.class})` : `a ${writer.class} writer`;
190
195
  return writer.class === "agent" ? `${name}, which no agent session lists,` : name;
191
196
  }
192
197
 
193
- /** Whether `writer` may write in `member` (null: a path in no member). */
198
+ /** Whether `writer` may write in `member` (null: a path in no member). An agent session may write in any member it is bound to (ws-101). */
194
199
  function memberAllowed(writer: Writer, scope: ClassScope, member: string | null): boolean {
195
- if (writer.class === "agent") return member !== null && member === writer.agent?.member;
200
+ if (writer.class === "agent") return member !== null && (writer.agent?.members.includes(member) ?? false);
196
201
  return scope.members === null || (member !== null && scope.members.includes(member));
197
202
  }
198
203
 
@@ -219,7 +224,7 @@ export function judgeRecord(declaration: Declaration | null, writer: Writer, kin
219
224
  return {
220
225
  ok: false,
221
226
  code: "write-scope-member",
222
- message: `${who(writer)} may not write ${kind.name} records, which are ${where}: ${writer.class === "agent" ? "an agent session writes only its own member" : `writeScope.${writer.class}.members leaves it out`}`,
227
+ message: `${who(writer)} may not write ${kind.name} records, which are ${where}: ${writer.class === "agent" ? "an agent session writes only the members it is bound to" : `writeScope.${writer.class}.members leaves it out`}`,
223
228
  };
224
229
  }
225
230
  if (verb === "delete") {
@@ -319,7 +324,7 @@ export function judgePath(declaration: Declaration | null, writer: Writer, path:
319
324
  return {
320
325
  ok: false,
321
326
  code: "write-scope-member",
322
- message: `${who(writer)} may not write ${path}, which ${where}: ${writer.class === "agent" ? "an agent session writes only its own member" : `writeScope.${writer.class}.members leaves it out`}`,
327
+ message: `${who(writer)} may not write ${path}, which ${where}: ${writer.class === "agent" ? "an agent session writes only the members it is bound to" : `writeScope.${writer.class}.members leaves it out`}`,
323
328
  };
324
329
  }
325
330
  const guarded = protectedEntry(scope.protected, path);