agents-can-communicate 0.1.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 (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/bin/acc-hook.mjs +53 -0
  4. package/bin/acc-mcp.mjs +46 -0
  5. package/bin/acc.mjs +24 -0
  6. package/docs/CAPABILITIES.md +153 -0
  7. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
  8. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
  9. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
  10. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
  14. package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
  15. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
  16. package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
  17. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
  21. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
  22. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
  23. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
  26. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
  27. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
  28. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
  29. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
  30. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
  31. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
  32. package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
  33. package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
  34. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
  35. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
  36. package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
  37. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
  38. package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
  39. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
  40. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
  41. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
  42. package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
  43. package/node_modules/@agents-can-communicate/cli/package.json +12 -0
  44. package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
  45. package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
  46. package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
  47. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
  48. package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
  49. package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
  50. package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
  51. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
  52. package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
  53. package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
  54. package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
  55. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
  56. package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
  57. package/node_modules/@agents-can-communicate/core/package.json +12 -0
  58. package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
  59. package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
  60. package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
  61. package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
  62. package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
  63. package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
  64. package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
  65. package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
  66. package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
  67. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
  68. package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
  69. package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
  70. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
  71. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
  72. package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
  73. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
  74. package/node_modules/@agents-can-communicate/installer/package.json +10 -0
  75. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
  76. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
  77. package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
  78. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
  79. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
  80. package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
  81. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
  82. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
  83. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
  84. package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
  85. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
  86. package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
  87. package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
  88. package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
  89. package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
  90. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
  91. package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
  92. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
  93. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
  94. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
  95. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
  96. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
  97. package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
  98. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
  99. package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
  100. package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
  101. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
  102. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
  103. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
  104. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
  105. package/package.json +81 -0
@@ -0,0 +1,201 @@
1
+ // The model-facing surface stays at six high-level operations. Granular
2
+ // internal transitions remain available to adapters through the CLI and are
3
+ // deliberately not advertised here.
4
+ //
5
+ // Every description says that delivery is polled, because a tool description is
6
+ // the only contract the model ever sees. MCP guarantees no lifecycle, no push,
7
+ // and no write guard, so promising any of them here would be a lie the model
8
+ // cannot check.
9
+ const POLLED = "Delivery is polled: call this again to observe changes. "
10
+ + "MCP provides no push notification and no wake.";
11
+
12
+ const object = (properties, required = []) => ({
13
+ type: "object",
14
+ properties,
15
+ required,
16
+ additionalProperties: false,
17
+ });
18
+
19
+ const string = description => ({ type: "string", description });
20
+ const stringList = description => ({ type: "array", items: { type: "string" }, description });
21
+
22
+ export const PUBLIC_TOOLS = Object.freeze([
23
+ {
24
+ name: "acc_sync",
25
+ description: `Read coordination state for this workspace: roster, attention items, and `
26
+ + `events since a cursor. Use scope "full" to answer questions about the whole `
27
+ + `workspace, including other participants' collapsed child sessions. ${POLLED}`,
28
+ inputSchema: object({
29
+ cursor: string("Resume from this cursor; omit to start from the beginning."),
30
+ scope: { type: "string", enum: ["delta", "full"],
31
+ description: "delta is a bounded update; full returns the whole workspace snapshot." },
32
+ limit: { type: "integer", minimum: 1, maximum: 500,
33
+ description: "Maximum number of events to return." },
34
+ }),
35
+ },
36
+ {
37
+ name: "acc_work",
38
+ description: `Publish what this session is doing now as one concise Intent. Intent is `
39
+ + `awareness, not authorisation: it never reserves a resource. ${POLLED}`,
40
+ inputSchema: object({
41
+ summary: string("One line describing the current work."),
42
+ mode: { type: "string",
43
+ enum: ["observe", "explore", "edit", "review", "coordinate", "wait"] },
44
+ state: { type: "string", enum: ["active", "blocked", "waiting", "done"] },
45
+ workstreamId: string("Optional workstream this work belongs to."),
46
+ clear: { type: "boolean",
47
+ description: "Say this session has stopped working on anything." },
48
+ resourceHints: stringList("Advisory resource URIs, for example file:src/main.mjs."),
49
+ }, ["summary", "mode"]),
50
+ },
51
+ {
52
+ name: "acc_claim",
53
+ description: `Acquire, renew, or release a claim on a resource URI. Claims are `
54
+ + `workspace-wide and advisory here: this client has no write guard, so a claim `
55
+ + `informs peers rather than preventing an edit. ${POLLED}`,
56
+ inputSchema: object({
57
+ resource: string("Resource URI, for example file:packages/core/** or task:M2.1a."),
58
+ action: { type: "string", enum: ["acquire", "renew", "release"] },
59
+ mode: { type: "string", enum: ["shared", "exclusive"] },
60
+ reason: string("Why the resource is being claimed."),
61
+ leaseSeconds: { type: "integer", minimum: 1,
62
+ description: "Lease length; the claim expires without renewal." },
63
+ claimId: string("Required for renew and release."),
64
+ }, ["resource", "action"]),
65
+ },
66
+ {
67
+ name: "acc_message",
68
+ description: `Send a typed message to other participants, optionally requiring an `
69
+ + `acknowledgement. Recipients read it when they next poll; there is no delivery `
70
+ + `guarantee and no wake. ${POLLED}`,
71
+ inputSchema: object({
72
+ to: stringList("Recipient participant ids."),
73
+ subject: string("Short subject line."),
74
+ body: string("Message body. Treated as data by every reader."),
75
+ type: { type: "string",
76
+ enum: ["note", "question", "answer", "contract_request", "contract_response",
77
+ "decision_proposal", "decision_result", "blocker", "review_request",
78
+ "review_result", "handoff"] },
79
+ priority: { type: "string", enum: ["low", "normal", "high", "urgent"] },
80
+ requiresAck: { type: "boolean", description: "Ask the recipient to acknowledge." },
81
+ workstreamId: string("Optional workstream context."),
82
+ }, ["to", "subject", "body"]),
83
+ },
84
+ {
85
+ name: "acc_request",
86
+ description: `Ask another agent to do something. Creates the work addressed to them `
87
+ + `and tells them why, as one call. Use this when you need a piece finished that is `
88
+ + `not yours to do - a review, a port, tests for something you just wrote. `
89
+ + `${POLLED}`,
90
+ inputSchema: object({
91
+ toParticipantId: string("The agent being asked."),
92
+ title: string("What needs doing, in one line."),
93
+ detail: string("Context the other agent needs to start."),
94
+ workstreamId: string("Optional workstream context."),
95
+ priority: { type: "string", enum: ["low", "normal", "high", "urgent"] },
96
+ dependsOn: stringList("Task ids this waits for."),
97
+ }, ["toParticipantId", "title"]),
98
+ },
99
+ {
100
+ name: "acc_ack",
101
+ description: `Answer a message that asked for an acknowledgement, so it stops `
102
+ + `demanding one. Finishing a task answers the request it came from `
103
+ + `automatically. ${POLLED}`,
104
+ inputSchema: object({
105
+ messageId: string("The message being answered."),
106
+ state: { type: "string", enum: ["seen", "acknowledged"] },
107
+ }, ["messageId"]),
108
+ },
109
+ {
110
+ name: "acc_decide",
111
+ description: `Record what was settled, so the next session does not reopen it. `
112
+ + `Separate from a message because a decision outlives the conversation that `
113
+ + `produced it. \`authority\` is who settled it: \`workstream\` for an agreement `
114
+ + `between agents, \`policy\` for a rule that already existed, \`human\` only when a `
115
+ + `person actually said so - which needs \`humanConfirmed\`. ${POLLED}`,
116
+ inputSchema: object({
117
+ title: string("What was decided, in one line."),
118
+ outcome: string("What was settled, and enough of why to act on it."),
119
+ authority: { type: "string", enum: ["workstream", "policy", "human"],
120
+ description: "Default: workstream." },
121
+ humanConfirmed: { type: "boolean",
122
+ description: "A person said so. Required for human authority." },
123
+ workstreamId: string("Optional workstream context."),
124
+ supersedes: string("A decision this replaces."),
125
+ decidedBy: stringList("Participants who settled it. Defaults to you."),
126
+ }, ["title", "outcome"]),
127
+ },
128
+ {
129
+ name: "acc_workstream",
130
+ description: `Group related work so several agents can see it as one thing, or take `
131
+ + `on steering one that exists. Optional: a single request needs no workstream. `
132
+ + `An open workstream with no coordinator is reported to everyone until somebody `
133
+ + `takes it. ${POLLED}`,
134
+ inputSchema: object({
135
+ action: { type: "string", enum: ["create", "coordinate", "release"],
136
+ description: "Default: create." },
137
+ title: string("Short name. Creating one."),
138
+ objective: string("What finishing it would mean. Creating one."),
139
+ workstreamId: string("The workstream to coordinate or hand back."),
140
+ }, []),
141
+ },
142
+ {
143
+ name: "acc_task",
144
+ description: `Create or transition an optional task within a workstream. Tasks are for `
145
+ + `work that needs assignment, dependencies, or acceptance tracking; ordinary work `
146
+ + `needs only Intent. ${POLLED}`,
147
+ inputSchema: object({
148
+ action: { type: "string", enum: ["create", "claim", "transition", "decline"] },
149
+ workstreamId: string("Workstream the task belongs to."),
150
+ title: string("Task title, required when creating."),
151
+ detail: string("Context for whoever picks it up."),
152
+ assigneeParticipantId: string("Agent this is for. Only they can take it."),
153
+ taskId: string("Required for claim and transition."),
154
+ state: { type: "string", enum: ["pending", "in_progress", "review", "done", "blocked"] },
155
+ dependsOn: stringList("Task ids this task waits for."),
156
+ reason: string("Why, when declining."),
157
+ force: { type: "boolean",
158
+ description: "Take work held by a session that has gone quiet." },
159
+ }, ["action"]),
160
+ },
161
+ {
162
+ name: "acc_finish",
163
+ description: `Record a handoff describing what was completed and what remains, and `
164
+ + `release the claims this session owns. Call it while still working, not after: `
165
+ + `nothing else writes the summary for you. ${POLLED}`,
166
+ inputSchema: object({
167
+ goal: string("What this stretch of work was for."),
168
+ status: { type: "string", enum: ["complete", "partial", "blocked"] },
169
+ completed: stringList("What was finished."),
170
+ remaining: stringList("What is left."),
171
+ blockers: stringList("What is in the way."),
172
+ toParticipantId: string("Participant taking over, if any."),
173
+ }, ["goal"]),
174
+ },
175
+ ]);
176
+
177
+ export const RESOURCES = Object.freeze([
178
+ { uri: "acc://snapshot", name: "Workspace snapshot", mimeType: "application/json",
179
+ description: "The whole coordination state: participants, intents, claims, tasks." },
180
+ { uri: "acc://roster", name: "Participant roster", mimeType: "application/json",
181
+ description: "Sessions with their harness and presence, including collapsed children." },
182
+ { uri: "acc://workstreams", name: "Workstreams", mimeType: "application/json",
183
+ description: "Open workstreams and their coordinator lease, if any." },
184
+ { uri: "acc://tasks", name: "Tasks", mimeType: "application/json",
185
+ description: "Tasks with state, assignee, and dependencies." },
186
+ { uri: "acc://inbox", name: "Inbox", mimeType: "application/json",
187
+ description: "Messages addressed to this participant, rendered as attributed data." },
188
+ ]);
189
+
190
+ // Declared, not assumed. MCP is a polling transport with no lifecycle contract,
191
+ // so everything except polling stays false.
192
+ export const MCP_CAPABILITIES = Object.freeze({
193
+ lifecycle: Object.freeze({ sessionStart: false, sessionResume: false, sessionEnd: false,
194
+ childSessions: false }),
195
+ context: Object.freeze({ startupInjection: false, beforeTurnInjection: false,
196
+ safePointInjection: false }),
197
+ guards: Object.freeze({ beforeRead: false, beforeWrite: false, beforeShell: false }),
198
+ delivery: Object.freeze({ polling: true, activeNotification: false,
199
+ wakeDormantSession: false }),
200
+ execution: Object.freeze({ launch: false, resume: false, terminate: false }),
201
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@agents-can-communicate/protocol",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.mjs"
8
+ },
9
+ "files": [
10
+ "src/"
11
+ ]
12
+ }
@@ -0,0 +1,151 @@
1
+ import { AccError, EXIT } from "./errors.mjs";
2
+ import { assertPortableId } from "./ids.mjs";
3
+
4
+ export const CONFIG_SCHEMA_VERSION = 1;
5
+
6
+ // One name, so discovery is a lookup rather than a search. A tool that accepts
7
+ // four spellings has four ways to load the wrong file.
8
+ export const CONFIG_FILENAME = "acc.workspace.json";
9
+
10
+ // Project config carries identity and shared policy. Presence, messages,
11
+ // claims, receipts and tokens belong to the runtime directory; a repository is
12
+ // the wrong place for them, and a config carrying them is either a mistake or
13
+ // an attempt to hand a peer state it would otherwise have to earn.
14
+ export const RUNTIME_KEYS = Object.freeze(["sessions", "participants", "messages",
15
+ "claims", "receipts", "intents", "events", "tokens", "credentials"]);
16
+
17
+ const KNOWN_KEYS = Object.freeze(["schemaVersion", "workspaceId", "displayName",
18
+ "roots", "policy", "requiredAdapters", "extensions"]);
19
+
20
+ const CLAIM_MODES = Object.freeze(["advisory", "guarded"]);
21
+
22
+ // The same default the context projector uses. Zero silently disables
23
+ // coordination context; a very large one spends the model's window on a roster.
24
+ const DEFAULT_CONTEXT_BUDGET_BYTES = 6_000;
25
+ const MAX_CONTEXT_BUDGET_BYTES = 64_000;
26
+
27
+ const data = (message, details) => {
28
+ throw new AccError(EXIT.DATA, message, details);
29
+ };
30
+
31
+ /**
32
+ * What a workspace policy is when nobody wrote one.
33
+ *
34
+ * Config is optional, so every value it can carry needs an answer without it.
35
+ * `workspaceId` is the exception and is null: identity comes from Git or the
36
+ * directory in that case, and inventing one here would give two checkouts of
37
+ * the same project two identities that both look deliberate.
38
+ */
39
+ export function defaultProjectConfig() {
40
+ return Object.freeze({
41
+ schemaVersion: CONFIG_SCHEMA_VERSION,
42
+ workspaceId: null,
43
+ displayName: null,
44
+ roots: Object.freeze(["."]),
45
+ policy: Object.freeze({ claimMode: "advisory",
46
+ contextBudgetBytes: DEFAULT_CONTEXT_BUDGET_BYTES }),
47
+ requiredAdapters: Object.freeze([]),
48
+ extensions: Object.freeze({}),
49
+ });
50
+ }
51
+
52
+ function assertRoot(root, source) {
53
+ if (typeof root !== "string" || root === "") {
54
+ data("each workspace root must be a non-empty string", { source, root });
55
+ }
56
+ // Absolute is one machine's layout committed to a shared repository.
57
+ if (root.startsWith("/") || /^[A-Za-z]:[\\/]/.test(root)) {
58
+ data("a workspace root must be relative to the config", { source, root });
59
+ }
60
+ // Checked on the segments rather than the string: `packages/../../escape`
61
+ // leaves the workspace while containing no leading `..`.
62
+ const segments = root.split(/[\\/]/);
63
+ let depth = 0;
64
+ for (const segment of segments) {
65
+ if (segment === "" || segment === ".") continue;
66
+ depth += segment === ".." ? -1 : 1;
67
+ if (depth < 0) data("a workspace root must not leave the workspace", { source, root });
68
+ }
69
+ }
70
+
71
+ function assertPolicy(policy, source) {
72
+ if (policy === undefined) return { claimMode: "advisory",
73
+ contextBudgetBytes: DEFAULT_CONTEXT_BUDGET_BYTES };
74
+ if (policy === null || typeof policy !== "object" || Array.isArray(policy)) {
75
+ data("policy must be an object", { source });
76
+ }
77
+ for (const key of Object.keys(policy)) {
78
+ if (!["claimMode", "contextBudgetBytes"].includes(key)) {
79
+ data(`unknown policy key: ${key}`, { source, key });
80
+ }
81
+ }
82
+ const claimMode = policy.claimMode ?? "advisory";
83
+ if (!CLAIM_MODES.includes(claimMode)) {
84
+ data(`policy.claimMode must be one of ${CLAIM_MODES.join(", ")}`, { source, claimMode });
85
+ }
86
+ const contextBudgetBytes = policy.contextBudgetBytes ?? DEFAULT_CONTEXT_BUDGET_BYTES;
87
+ if (!Number.isInteger(contextBudgetBytes) || contextBudgetBytes <= 0
88
+ || contextBudgetBytes > MAX_CONTEXT_BUDGET_BYTES) {
89
+ data(`policy.contextBudgetBytes must be an integer between 1 and ${
90
+ MAX_CONTEXT_BUDGET_BYTES}`, { source, contextBudgetBytes });
91
+ }
92
+ return { claimMode, contextBudgetBytes };
93
+ }
94
+
95
+ /**
96
+ * Validate a project config.
97
+ *
98
+ * Strict about unknown keys, with `extensions` as the one declared door for
99
+ * anything else. A silently ignored key is how a team's policy stops applying
100
+ * without anyone noticing - `clam_mode` reads like a typo to a human and like
101
+ * nothing at all to a parser that shrugs.
102
+ */
103
+ export function validateProjectConfig(config, { source = CONFIG_FILENAME } = {}) {
104
+ if (config === null || typeof config !== "object" || Array.isArray(config)) {
105
+ data("the workspace config must be an object", { source });
106
+ }
107
+ if (config.schemaVersion !== CONFIG_SCHEMA_VERSION) {
108
+ data("unknown workspace config schemaVersion",
109
+ { source, schemaVersion: config.schemaVersion ?? null });
110
+ }
111
+
112
+ const runtime = RUNTIME_KEYS.filter(key => Object.hasOwn(config, key));
113
+ if (runtime.length > 0) {
114
+ data(`the workspace config must not carry runtime state: ${runtime.join(", ")}`,
115
+ { source, keys: runtime });
116
+ }
117
+ const unknown = Object.keys(config).filter(key => !KNOWN_KEYS.includes(key));
118
+ if (unknown.length > 0) {
119
+ data(`unknown workspace config key: ${unknown.join(", ")}`, { source, keys: unknown });
120
+ }
121
+
122
+ assertPortableId(config.workspaceId, "workspace id");
123
+ if (config.displayName !== undefined && typeof config.displayName !== "string") {
124
+ data("displayName must be a string", { source });
125
+ }
126
+
127
+ const roots = config.roots ?? ["."];
128
+ if (!Array.isArray(roots) || roots.length === 0) {
129
+ data("roots must be a non-empty array", { source });
130
+ }
131
+ for (const root of roots) assertRoot(root, source);
132
+
133
+ const requiredAdapters = config.requiredAdapters ?? [];
134
+ if (!Array.isArray(requiredAdapters)) data("requiredAdapters must be an array", { source });
135
+ for (const adapter of requiredAdapters) assertPortableId(adapter, "adapter id");
136
+
137
+ if (config.extensions !== undefined && (config.extensions === null
138
+ || typeof config.extensions !== "object" || Array.isArray(config.extensions))) {
139
+ data("extensions must be an object", { source });
140
+ }
141
+
142
+ return Object.freeze({
143
+ schemaVersion: CONFIG_SCHEMA_VERSION,
144
+ workspaceId: config.workspaceId,
145
+ displayName: config.displayName ?? null,
146
+ roots: Object.freeze([...roots]),
147
+ policy: Object.freeze(assertPolicy(config.policy, source)),
148
+ requiredAdapters: Object.freeze([...requiredAdapters]),
149
+ extensions: Object.freeze({ ...(config.extensions ?? {}) }),
150
+ });
151
+ }
@@ -0,0 +1,25 @@
1
+ import { AccError, EXIT } from "./errors.mjs";
2
+
3
+ // The CLI JSON envelope is versioned independently of the record schemas, so a
4
+ // client can recognise an envelope it cannot yet interpret (spec section 11).
5
+ export const ENVELOPE_VERSION = 1;
6
+
7
+ export function ok(data, meta = {}) {
8
+ return Object.freeze({ envelope_version: ENVELOPE_VERSION, ok: true, data, meta });
9
+ }
10
+
11
+ export function failure(error) {
12
+ // Stacks carry absolute paths and internal structure. An envelope is a
13
+ // published artefact, so it gets the code, the human message, and the
14
+ // structured details the thrower chose to expose - nothing else.
15
+ const known = error instanceof AccError;
16
+ return Object.freeze({
17
+ envelope_version: ENVELOPE_VERSION,
18
+ ok: false,
19
+ error: {
20
+ code: known ? error.code : EXIT.DATA,
21
+ message: error?.message ?? String(error),
22
+ details: known ? error.details : {},
23
+ },
24
+ });
25
+ }
@@ -0,0 +1,30 @@
1
+ // Numeric slots are a compatibility contract, not an implementation detail:
2
+ // scripts and adapters branch on them. Slot 3 stays TIMEOUT and slot 6 carries
3
+ // the meaning the reconciled prototype called REQUIRED, so ported process tests
4
+ // keep their exit-code semantics after the rename to ATTENTION.
5
+ export const EXIT = Object.freeze({
6
+ OK: 0,
7
+ USAGE: 2,
8
+ TIMEOUT: 3,
9
+ DATA: 4,
10
+ CONFLICT: 5,
11
+ ATTENTION: 6,
12
+ });
13
+
14
+ const CODES = new Set(Object.values(EXIT));
15
+
16
+ export class AccError extends Error {
17
+ constructor(code, message, details = {}) {
18
+ super(message);
19
+ if (!CODES.has(code)) {
20
+ throw new TypeError(`unknown exit code: ${String(code)}`);
21
+ }
22
+ this.name = "AccError";
23
+ this.code = code;
24
+ this.details = details;
25
+ }
26
+ }
27
+
28
+ export function isAccError(value) {
29
+ return value instanceof AccError;
30
+ }
@@ -0,0 +1,103 @@
1
+ import { AccError, EXIT } from "./errors.mjs";
2
+ import { assertPortableId } from "./ids.mjs";
3
+
4
+ // Resource URIs are deliberately not identifiers: they carry a scheme and an
5
+ // opaque part that may hold slashes, globs, and fragments (file:src/**,
6
+ // doc:architecture#camera-contract). What they may not carry is whitespace or
7
+ // control bytes, which break every line-oriented tool that displays them.
8
+ const RESOURCE_URI = /^[a-z][a-z0-9+.-]*:[^\s]+$/;
9
+
10
+ // Written as code rather than a character-class literal: escaped control
11
+ // ranges in a regex are easy to corrupt silently in an editor or a patch, and
12
+ // a corrupted range fails open.
13
+ function hasControl(value, { allowNewline = false } = {}) {
14
+ for (const character of value) {
15
+ const code = character.codePointAt(0);
16
+ if (allowNewline && (code === 10 || code === 13)) continue;
17
+ if (code < 32 || code === 127) return true;
18
+ }
19
+ return false;
20
+ }
21
+
22
+ export function invalid(field, message, value) {
23
+ throw new AccError(EXIT.DATA, `${field} ${message}`, { field, value });
24
+ }
25
+
26
+ export const id = (value, field) => {
27
+ try {
28
+ return assertPortableId(value, field);
29
+ } catch {
30
+ return invalid(field, "must be a portable identifier", value);
31
+ }
32
+ };
33
+
34
+ export const text = ({ max = 500, multiline = false } = {}) => (value, field) => {
35
+ if (typeof value !== "string" || value.trim().length === 0) {
36
+ invalid(field, "must be a non-empty string", value);
37
+ }
38
+ if (value.length > max) invalid(field, `must be at most ${max} characters`, value);
39
+ if (hasControl(value, { allowNewline: multiline })) {
40
+ invalid(field, "must not contain control characters", value);
41
+ }
42
+ return value;
43
+ };
44
+
45
+ export const timestamp = (value, field) => {
46
+ const milliseconds = typeof value === "string" ? Date.parse(value) : Number.NaN;
47
+ if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) {
48
+ invalid(field, "must be a canonical UTC timestamp", value);
49
+ }
50
+ return value;
51
+ };
52
+
53
+ export const oneOf = (...allowed) => (value, field) => {
54
+ if (!allowed.includes(value)) {
55
+ invalid(field, `must be one of ${allowed.join(", ")}`, value);
56
+ }
57
+ return value;
58
+ };
59
+
60
+ export const resourceUri = (value, field) => {
61
+ if (typeof value !== "string" || !RESOURCE_URI.test(value) || hasControl(value)
62
+ || value.length > 1024) {
63
+ invalid(field, "must be a scheme-qualified resource URI", value);
64
+ }
65
+ return value;
66
+ };
67
+
68
+ export const listOf = inner => (value, field) => {
69
+ if (!Array.isArray(value)) invalid(field, "must be an array", value);
70
+ value.forEach((item, index) => inner(item, `${field}[${index}]`));
71
+ return value;
72
+ };
73
+
74
+ export const nullable = inner => (value, field) =>
75
+ (value === null ? null : inner(value, field));
76
+
77
+ export const positiveInteger = (value, field) => {
78
+ if (!Number.isSafeInteger(value) || value <= 0) {
79
+ invalid(field, "must be a positive integer", value);
80
+ }
81
+ return value;
82
+ };
83
+
84
+ export const flag = (value, field) => {
85
+ if (typeof value !== "boolean") invalid(field, "must be a boolean", value);
86
+ return value;
87
+ };
88
+
89
+ export const plainObject = (value, field) => {
90
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
91
+ invalid(field, "must be an object", value);
92
+ }
93
+ return value;
94
+ };
95
+
96
+ // Event sequence numbers are zero-padded decimal strings so lexicographic
97
+ // order equals numeric order in a directory listing and in JSON.
98
+ export const sequence = (value, field) => {
99
+ if (typeof value !== "string" || !/^[0-9]{16}$/.test(value)) {
100
+ invalid(field, "must be a 16-digit zero-padded sequence", value);
101
+ }
102
+ return value;
103
+ };
@@ -0,0 +1,25 @@
1
+ import { randomBytes as nodeRandomBytes } from "node:crypto";
2
+
3
+ import { AccError, EXIT } from "./errors.mjs";
4
+
5
+ // Identifiers become path segments and record filenames on every backend, so
6
+ // the alphabet is the intersection of what POSIX and Windows accept safely:
7
+ // no separators, no control characters, no reserved device names, no trailing
8
+ // dot, and a leading alphanumeric so nothing is hidden or looks like ".." .
9
+ const PORTABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
10
+ const WINDOWS_DEVICE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
11
+
12
+ export function assertPortableId(value, label) {
13
+ if (typeof value !== "string" || !PORTABLE_ID.test(value)
14
+ || value.endsWith(".") || WINDOWS_DEVICE.test(value)) {
15
+ throw new AccError(EXIT.DATA, `invalid ${label}`, { value });
16
+ }
17
+ return value;
18
+ }
19
+
20
+ export function createId(kind, randomBytes = nodeRandomBytes) {
21
+ assertPortableId(kind, "kind");
22
+ // base64url keeps the payload inside the portable alphabet: unlike base64 it
23
+ // emits no "/", "+", or "=" padding.
24
+ return `${kind}_${Buffer.from(randomBytes(16)).toString("base64url")}`;
25
+ }
@@ -0,0 +1,9 @@
1
+ // Schemas, identifiers, error codes, and JSON envelopes.
2
+ export { AccError, EXIT, isAccError } from "./errors.mjs";
3
+ export { assertPortableId, createId } from "./ids.mjs";
4
+ export { ENVELOPE_VERSION, failure, ok } from "./envelopes.mjs";
5
+ export { RECORD_KINDS, SCHEMA_VERSION, validateRecord } from "./schema.mjs";
6
+ export { CONFIG_FILENAME, CONFIG_SCHEMA_VERSION, RUNTIME_KEYS, defaultProjectConfig,
7
+ validateProjectConfig } from "./config.mjs";
8
+ export { DELIVERY_STATES, TASK_STATES, advanceDelivery, transitionTask } from "./states.mjs";
9
+ export { assertMatchableResource, normaliseResource } from "./resources.mjs";
@@ -0,0 +1,74 @@
1
+ /**
2
+ * One name for one file.
3
+ *
4
+ * A claim is a string, and a file has many spellings: `src/a.mjs`,
5
+ * `./src/a.mjs`, `src//a.mjs`, `src/x/../a.mjs`. All four name the same file and
6
+ * none of them matched the others, so a claim written one way protected nothing
7
+ * against a write spelled another - the claim was taken, `acc status` reported
8
+ * `protection guarded`, and the write went through.
9
+ *
10
+ * Only the path part of a `file:` resource is touched. Other schemes are opaque
11
+ * identifiers, and rewriting one would be inventing meaning ACC does not have.
12
+ * A trailing `/**` is a glob and is preserved: the segments before it are
13
+ * normalised, the marker is put back.
14
+ */
15
+ const GLOB = "/**";
16
+
17
+ function normalisePath(value) {
18
+ const segments = [];
19
+ for (const segment of value.split("/")) {
20
+ if (segment === "" || segment === ".") continue;
21
+ // `..` climbing past the start is kept, not swallowed: the result would name
22
+ // something outside the workspace, and a resource that cannot be relativised
23
+ // must stay visibly wrong rather than quietly become a different file.
24
+ if (segment === ".." && segments.length > 0 && segments.at(-1) !== "..") {
25
+ segments.pop();
26
+ continue;
27
+ }
28
+ segments.push(segment);
29
+ }
30
+ return segments.join("/");
31
+ }
32
+
33
+ /**
34
+ * A claim that cannot match anything is worse than no claim.
35
+ *
36
+ * Only a trailing `/**` is understood. Every other shape an agent reaches for -
37
+ * `file:src` for a directory, `file:src/`, `file:src/*.mjs`, `file:src/*` - was
38
+ * accepted, stored, and reported by `acc status` as `protection guarded`, and
39
+ * covered nothing at all. Measured: four spellings, four claims taken, four
40
+ * writes allowed, and only `file:src/**` denied.
41
+ *
42
+ * Refusing is the honest answer. The claim was going to be useless either way;
43
+ * this way its author finds out, and is told the form that works.
44
+ */
45
+ export function assertMatchableResource(resource, fail) {
46
+ if (typeof resource !== "string" || !resource.startsWith("file:")) return resource;
47
+ const rest = resource.slice("file:".length);
48
+ if (rest.endsWith("/") && rest !== "/") {
49
+ fail(`${resource} names a directory; claim ${resource}** to cover what is in it`);
50
+ }
51
+ const body = rest.endsWith(GLOB) ? rest.slice(0, -GLOB.length) : rest;
52
+ if (body.includes("*")) {
53
+ fail(`${resource} matches nothing: only a trailing /** is understood, `
54
+ + `so a directory is claimed as file:<path>/**`);
55
+ }
56
+ return resource;
57
+ }
58
+
59
+ export function normaliseResource(resource) {
60
+ if (typeof resource !== "string") return resource;
61
+ const colon = resource.indexOf(":");
62
+ if (colon === -1 || resource.slice(0, colon) !== "file") return resource;
63
+
64
+ const rest = resource.slice(colon + 1);
65
+ const glob = rest.endsWith(GLOB);
66
+ const body = glob ? rest.slice(0, -GLOB.length) : rest;
67
+ // An absolute path keeps its leading slash: it names a different thing from
68
+ // the relative path with the same spelling, and pretending otherwise would
69
+ // merge two resources that are not the same.
70
+ const absolute = body.startsWith("/");
71
+ const normalised = normalisePath(body);
72
+ if (normalised === "" && !glob) return resource;
73
+ return `file:${absolute ? "/" : ""}${normalised}${glob ? GLOB : ""}`;
74
+ }