@intentius/chant 0.104.4 → 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 (37) hide show
  1. package/dist/op/activities/factory.d.ts +1 -1
  2. package/dist/op/activities/factory.d.ts.map +1 -1
  3. package/dist/workspace/agent-cli.d.ts +14 -8
  4. package/dist/workspace/agent-cli.d.ts.map +1 -1
  5. package/dist/workspace/broker-protocol.d.ts +65 -3
  6. package/dist/workspace/broker-protocol.d.ts.map +1 -1
  7. package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts.map +1 -1
  8. package/dist/workspace/conformance/broker.d.ts.map +1 -1
  9. package/dist/workspace/declaration.d.ts +9 -2
  10. package/dist/workspace/declaration.d.ts.map +1 -1
  11. package/dist/workspace/declaration.schema.json +32 -6
  12. package/dist/workspace/export.d.ts.map +1 -1
  13. package/dist/workspace/factory-rules.d.ts +9 -4
  14. package/dist/workspace/factory-rules.d.ts.map +1 -1
  15. package/dist/workspace/reason-codes.d.ts +1 -1
  16. package/dist/workspace/reason-codes.d.ts.map +1 -1
  17. package/dist/workspace/write-scope.d.ts +7 -5
  18. package/dist/workspace/write-scope.d.ts.map +1 -1
  19. package/package.json +1 -1
  20. package/src/cli/mcp/workspace-tools.ts +1 -1
  21. package/src/op/activities/factory.ts +1 -1
  22. package/src/op/factory.e2e.test.ts +16 -5
  23. package/src/workspace/agent-cli.ts +26 -9
  24. package/src/workspace/agent.schema.json +33 -21
  25. package/src/workspace/broker-protocol.schema.json +45 -3
  26. package/src/workspace/broker-protocol.test.ts +45 -0
  27. package/src/workspace/broker-protocol.ts +131 -5
  28. package/src/workspace/conformance/__fixtures__/reference-broker.ts +3 -1
  29. package/src/workspace/conformance/broker.ts +22 -0
  30. package/src/workspace/declaration.schema.json +32 -6
  31. package/src/workspace/declaration.ts +29 -15
  32. package/src/workspace/export.ts +10 -3
  33. package/src/workspace/factory-rules.test.ts +6 -1
  34. package/src/workspace/factory-rules.ts +10 -3
  35. package/src/workspace/reason-codes.ts +1 -1
  36. package/src/workspace/write-scope.test.ts +18 -1
  37. package/src/workspace/write-scope.ts +15 -10
@@ -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);