@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.
- package/dist/codegen/generate-runtime-index.d.ts +3 -1
- package/dist/codegen/generate-runtime-index.d.ts.map +1 -1
- package/dist/op/activities/factory.d.ts +1 -1
- package/dist/op/activities/factory.d.ts.map +1 -1
- package/dist/vendor/js-yaml-load.d.ts.map +1 -1
- package/dist/workspace/agent-cli.d.ts +14 -8
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/broker-protocol.d.ts +65 -3
- package/dist/workspace/broker-protocol.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts.map +1 -1
- package/dist/workspace/conformance/broker.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +9 -2
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +32 -6
- package/dist/workspace/export.d.ts.map +1 -1
- package/dist/workspace/factory-rules.d.ts +9 -4
- package/dist/workspace/factory-rules.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +7 -5
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/codegen/generate-runtime-index.test.ts +3 -3
- package/src/codegen/generate-runtime-index.ts +5 -3
- package/src/op/activities/factory.ts +1 -1
- package/src/op/factory.e2e.test.ts +16 -5
- package/src/vendor/js-yaml-load.ts +14 -63
- package/src/workspace/agent-cli.ts +26 -9
- package/src/workspace/agent.schema.json +33 -21
- package/src/workspace/broker-protocol.schema.json +45 -3
- package/src/workspace/broker-protocol.test.ts +45 -0
- package/src/workspace/broker-protocol.ts +131 -5
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +3 -1
- package/src/workspace/conformance/broker.ts +22 -0
- package/src/workspace/declaration.schema.json +32 -6
- package/src/workspace/declaration.ts +29 -15
- package/src/workspace/export.ts +10 -3
- package/src/workspace/factory-rules.test.ts +6 -1
- package/src/workspace/factory-rules.ts +10 -3
- package/src/workspace/reason-codes.ts +1 -1
- package/src/workspace/write-scope.test.ts +18 -1
- 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
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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,
|
|
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
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1009
|
+
return [...byName.values()];
|
|
996
1010
|
}
|
|
997
1011
|
|
|
998
1012
|
/** The `changes` block, already validated, with its defaults (#2773). */
|
package/src/workspace/export.ts
CHANGED
|
@@ -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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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:
|
|
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}):
|
|
8
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
10
|
-
* of kinds
|
|
11
|
-
* allows.
|
|
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
|
|
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)
|
|
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 &&
|
|
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
|
|
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
|
|
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);
|