@abseed/spectra-agent-tools 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.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Luke Pezet
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,6 @@
1
+ Spectra
2
+ Copyright 2026 Luke Pezet
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
5
+ this project except in compliance with the License. You may obtain a copy of the
6
+ License in the LICENSE file, or at http://www.apache.org/licenses/LICENSE-2.0
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Who is in the channel — the two agents, as data.
3
+ *
4
+ * Two agents with deliberately different reach. `@spec` has domain tools and no filesystem at all, so
5
+ * it cannot bypass the changesets-only rule. `@coder` has real file access, but rooted at its project
6
+ * and with the glossary path explicitly denied — it implements what the glossary says and cannot
7
+ * quietly rewrite the glossary to match what it built.
8
+ *
9
+ * WHY this lives in `@abseed/spectra-agent-tools` and not the server: the definitions are the single source
10
+ * of who `@spec` and `@coder` are, and a runtime fetches them from the coordinator rather than
11
+ * carrying its own copy (the copy an attacker in the box could edit). Both coordinators — the open
12
+ * server and the hosted Worker — must serve the *same* definitions, so they live beside the tools.
13
+ * The only host-specific parts are the two filesystem paths the definitions reference (the glossary
14
+ * dir and @coder's working dir); those are passed in, so this stays free of node builtins.
15
+ */
16
+ import type { AuthorKind, ProjectInfo } from '@abseed/spectra-core';
17
+ export type AgentName = Extract<AuthorKind, 'spec' | 'coder'>;
18
+ export interface AgentDefinition {
19
+ name: AgentName;
20
+ /** Shown in the composer and on message chips. */
21
+ label: string;
22
+ description: string;
23
+ systemPrompt: string;
24
+ cwd: string;
25
+ /** Built-in tools. Empty disables the filesystem entirely. */
26
+ builtins: string[];
27
+ /**
28
+ * Built-ins that run without asking. Anything in `builtins` but not here goes through the approval
29
+ * card — and note the SDK's rule: a tool named bare in `allowedTools` never reaches the permission
30
+ * callback, so listing a write here silently disables its card.
31
+ */
32
+ autoApprove: string[];
33
+ /** Domain tools this agent may call, by short name. */
34
+ domainTools: string[];
35
+ disallowedTools?: string[];
36
+ }
37
+ /** The filesystem paths the definitions reference — supplied by the host, never assumed. */
38
+ export interface AgentPaths {
39
+ /** The glossary directory: @spec's cwd, and the path @coder is forbidden to write. */
40
+ specsDir: string;
41
+ /** @coder's working directory — the project it implements into. */
42
+ appDir: string;
43
+ }
44
+ /**
45
+ * The two agent definitions, built for a given project so their shared prompt names the real
46
+ * glossary, and for a given host so their paths are the real ones. Constructed in the coordinator's
47
+ * composition root from `store.projectInfo()`.
48
+ */
49
+ export declare function buildAgents(project: ProjectInfo, paths: AgentPaths): Record<AgentName, AgentDefinition>;
50
+ export declare const AGENT_NAMES: AgentName[];
package/dist/agents.js ADDED
@@ -0,0 +1,140 @@
1
+ // The project's name and domain are threaded in, not hardcoded — they come from the SpecStore, so the
2
+ // same coordinator serves whatever glossary it is pointed at.
3
+ const sharedPrompt = (project) => `You are one of two agents in a channel with a human, working on ${project.name}: a shared glossary that a human and an AI coder both work from, describing ${project.domain}. The glossary lives in specs/terms as JSON — Terms with a spec, a parent, and typed attributes.
4
+
5
+ The other agent is addressed as @spec or @coder. You cannot message them; only the human can. If work belongs to the other one, say so and let the human hand it over.
6
+
7
+ You can see the whole channel, including messages addressed to the other agent. Read them for context; act only on what is addressed to you.
8
+
9
+ Be concise and concrete. Cite term names, question ids and changeset ids. Prefer quoting spec text over paraphrasing it.
10
+
11
+ Lead with the conclusion. The first sentence of your final message must be a single plain sentence saying what happened or what the answer is, and the detail goes after it. Two things read that sentence and nothing else: the folded view of a finished run, and a screen reader speaking it aloud. So keep it free of file paths, code and formatting — ids like q-009 or completeTask are fine because they are short and mean something, but "app/src/domain/domain.test.ts now passes" is not a sentence anyone can hear. "The tests pass and q-009 is still open" is.
12
+
13
+ That sentence is not a summary of your whole reply and should not try to be. If the work had one outcome, say it. If it had two, say the one that decides what happens next.`;
14
+ /**
15
+ * The two agent definitions, built for a given project so their shared prompt names the real
16
+ * glossary, and for a given host so their paths are the real ones. Constructed in the coordinator's
17
+ * composition root from `store.projectInfo()`.
18
+ */
19
+ export function buildAgents(project, paths) {
20
+ const SHARED = sharedPrompt(project);
21
+ const { specsDir: SPECS_DIR, appDir: APP_DIR } = paths;
22
+ // The object below keeps its original indentation — its systemPrompt template literals are
23
+ // multi-line, so re-indenting would corrupt the prompt text.
24
+ return {
25
+ spec: {
26
+ name: 'spec',
27
+ label: 'spec',
28
+ description: 'Reads and edits the glossary. Proposes changesets, raises questions.',
29
+ cwd: SPECS_DIR,
30
+ // No filesystem at all: everything it can reach goes through the domain tools, which
31
+ // is what keeps the human write path changesets-only.
32
+ builtins: [],
33
+ autoApprove: [],
34
+ domainTools: [
35
+ 'read_glossary',
36
+ 'read_questions',
37
+ 'read_changesets',
38
+ 'read_expectations',
39
+ 'analyze_pending',
40
+ 'search_transcripts',
41
+ 'raise_question',
42
+ 'raise_expectation',
43
+ 'propose_changeset',
44
+ ],
45
+ systemPrompt: `${SHARED}
46
+
47
+ You own the glossary. You cannot edit terms directly and must not describe doing so as though you could.
48
+
49
+ Route a request to one of four places, and say which:
50
+ 1. The change is clear and no product decision is left — propose a changeset.
51
+ 2. It turns on a choice only the human can make — raise a question, and do not settle the fork by proposing one side of it.
52
+ 3. The specs already say what a thing is, but nobody has said what should happen in some situation — raise an expectation. This is the common case for anything noticed while using the app rather than reading the glossary.
53
+ 4. It needs no glossary change at all — say so plainly. The glossary describes the domain, not the app that renders it, so presentation, wording and display are implementation work for @coder. Saying "that is app work, not a spec change" is a real answer, not a refusal to help.
54
+
55
+ A question is for a decision a human must make, not for an observation. If it cannot be phrased as something someone answers, do not raise it.
56
+
57
+ Questions and expectations are not the same thing and the difference is who decides. A question asks; an expectation asserts. If you know what should happen, record an expectation. If it turns on a decision nobody has made, ask — writing an expectation instead would settle a product question by stating it as fact.
58
+
59
+ An expectation marked contested disagrees with a term's spec and was recorded anyway. It covers nothing until that is settled, and settling it is a decision for the human — so raise a question naming both sides, quoting the expectation and the spec sentence it clashes with. Do not propose a changeset that quietly makes the spec match the expectation, and do not suggest retiring the expectation as though it were obviously wrong: it is usually a change somebody wants and nobody has proposed yet.
60
+
61
+ When asked what is untested, under-specified, or what to think about next, call read_expectations with coverage. It reports which entity/action pairs nothing has been said about. Do not work that out by reading terms: the pairs that matter are the ones two hops apart, which is exactly what nobody spots by eye.
62
+
63
+ When asked what to work on first, call analyze_pending and answer from what it returns. Do not reason about conflicts by reading ops yourself — order-dependent breakage is easy to get wrong by eye and the tool replays it through the real engine.`,
64
+ },
65
+ coder: {
66
+ name: 'coder',
67
+ label: 'coder',
68
+ description: 'Implements applied changesets in app/. Cannot edit specs.',
69
+ cwd: APP_DIR,
70
+ builtins: ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'],
71
+ // Reading is free; changing a file or running a command is not. Edit, Write and Bash
72
+ // are deliberately absent, which is what routes them through the approval card.
73
+ //
74
+ // With one caveat, found by testing rather than by reading: for Bash the SDK classifies
75
+ // the command itself and lets ones it judges read-only through without a card. `pwd &&
76
+ // ls` ran unprompted; `touch app/probe-file` raised a card and was blocked. So the card
77
+ // covers commands that change things, which is the useful guarantee — but it is not
78
+ // "every command", and the prompts must not claim otherwise.
79
+ autoApprove: ['Read', 'Glob', 'Grep'],
80
+ // Reads the glossary through the same read-only tools @spec uses, so it works from the
81
+ // specs without being able to touch them. It can raise a question — an implementation
82
+ // pass hitting something the specs do not settle is where most questions come from.
83
+ domainTools: [
84
+ 'read_glossary',
85
+ 'read_questions',
86
+ 'read_changesets',
87
+ // Reads what must hold and can add to it, but has no tool to retire one — the move that
88
+ // turns a red check green without touching code stays a human act.
89
+ 'read_expectations',
90
+ 'raise_expectation',
91
+ 'search_transcripts',
92
+ 'raise_question',
93
+ 'mark_implemented',
94
+ // How the drift check gets its half of the inputs back. app/ cannot see specs/ from
95
+ // inside the sandbox, so the glossary arrives as a file @coder writes and commits.
96
+ 'export_specs',
97
+ ],
98
+ /**
99
+ * Path rules for the file tools, plus a short denylist of shell commands.
100
+ *
101
+ * Be clear about what this is worth: Bash escapes every path restriction here — `cd`
102
+ * goes anywhere, redirection writes anywhere. The approval card is the actual boundary,
103
+ * and these patterns are a speed bump for the obviously destructive cases, not a
104
+ * sandbox. A sandbox is the next step, and this is the reason for it.
105
+ */
106
+ disallowedTools: [
107
+ `Edit(//${SPECS_DIR}/**)`,
108
+ `Write(//${SPECS_DIR}/**)`,
109
+ 'Bash(rm -rf *)',
110
+ 'Bash(git commit *)',
111
+ 'Bash(git push *)',
112
+ 'Bash(git reset *)',
113
+ 'Bash(git checkout *)',
114
+ ],
115
+ systemPrompt: `${SHARED}
116
+
117
+ You own app/. You implement what the glossary already says; you do not decide what it should say.
118
+
119
+ Working directory is app/. You can read, search, edit and create files there. You cannot write to specs/ — if the specs are wrong, incomplete, or say two contradictory things, raise a question rather than working around it or changing the code to something the specs do not describe.
120
+
121
+ How to run an implementation pass:
122
+ 0. export_specs, and write what it returns verbatim to specs.snapshot.json in your working directory. That file is the contract the drift check reads, and it is how the code can be checked against the specs without being able to see them. Refresh it first, so \`git diff specs.snapshot.json\` shows you exactly which terms moved since the code was last written — including spec rewrites, which the markers cannot show you.
123
+ 1. read_changesets and read_glossary to see what landed and what the terms now say. Do this even after refreshing the snapshot: the snapshot carries hashes, not spec text, so it tells you which terms moved and never what they now say. Knowing a hash changed is not knowing the requirement.
124
+ 2. Find the files whose "// implements:" marker names the affected terms. That marker is the link from a term to the code responsible for it — keep it accurate, and add the term to a marker when you make a file responsible for it.
125
+ 3. Change the code to match. Quote the spec text you are implementing in the file, as the existing files do. Every edit is shown to the human for approval before it happens, so make one focused change at a time and say what it is for — a diff nobody can follow gets declined.
126
+ 4. Update the tests, including any the changeset committed to under "tests". Call read_expectations for what must hold, and name the expectation id in the test that proves it — \`it('e-014: deleteProject refuses while a live RecurringTask remains', ...)\`. That id is what links a statement in the specs to the test standing behind it, and it has to survive being read years later.
127
+ 5. Never write code to satisfy a contested expectation. It disagrees with a term's spec, so making it true would make the specs false, and you cannot change those. Report it and move on — the human settles which side gives.
128
+ 6. If implementing turned up a situation the specs name but never settle the outcome of, call raise_expectation. Do not fix it silently in code and do not retire an existing expectation your code just failed — you have no tool for the second, deliberately. An expectation that has become wrong is a human decision; say so and let the human retire it.
129
+ 7. Run \`npm test\` and \`npm run typecheck\` in your working directory to check your work, and fix what they report.
130
+ 8. Call mark_implemented with the changeset id. It is refused unless your stored snapshot is at the current specs version — the same way a push is refused when the remote has moved. If that happens, the specs changed while you were working: refresh the snapshot, read what actually changed, make sure the code still matches, then call it again. There is no override, and asking for one is not the answer.
131
+
132
+ You have a shell. Every command that changes anything is shown to the human before it runs; commands the SDK judges read-only run without asking. Use it to check your work — running tests, typechecking, searching. Prefer the project's own scripts over ad-hoc commands, and say what a command is for. Do not commit, push, or otherwise touch git: the human owns the history, and those commands are refused anyway.
133
+
134
+ If an ambiguity is cheap to get wrong, pick a reading, say which you picked and why, and move on. If getting it wrong would waste the work, stop and raise a question instead.`,
135
+ },
136
+ };
137
+ }
138
+ // Static: the roster is fixed by construction, not derived from a built instance, so it needs no
139
+ // ProjectInfo and callers can validate an agent name without building the definitions.
140
+ export const AGENT_NAMES = ['spec', 'coder'];
@@ -0,0 +1,2 @@
1
+ export * from './tools.js';
2
+ export * from './agents.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './tools.js';
2
+ export * from './agents.js';
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The agent's tool surface — the pure, transport-agnostic half.
3
+ *
4
+ * These are domain tools, not file tools, and that is the whole point. The human write path is
5
+ * changesets-only; if the agent had `Write` on the glossary it could edit a term directly and the
6
+ * discipline would rest on the system prompt asking it not to. Here it rests on there being no such
7
+ * tool. The agent physically cannot bypass review.
8
+ *
9
+ * WHY this is its own package, not `@abseed/spectra-core`: these schemas are consumed by the agent SDK,
10
+ * which peer-requires zod 4, while core is zod 3. So the tool definitions live here (zod 4, no node
11
+ * builtins, no SDK dependency) where both the open server and the hosted coordinator can import them
12
+ * — the server registers them in-process and over HTTP, the Worker over its fetch-native MCP.
13
+ *
14
+ * Only the *pure* tools live here: reads, plus `propose_changeset` and `raise_question` (whose write
15
+ * logic is in core). The three that touch the host — `raise_expectation` (a model-backed check),
16
+ * `mark_implemented` and `export_specs` (a filesystem snapshot) — stay in the server and are composed
17
+ * on top of these (see the server's `toolsFor`).
18
+ *
19
+ * The tool objects are plain `SdkMcpToolDefinition`-shaped records (see {@link defineTool}); the agent
20
+ * SDK's `tool()` helper is only a typed constructor for that same shape, so we reproduce it here
21
+ * rather than depend on the SDK.
22
+ */
23
+ import { z } from 'zod';
24
+ import type { Author, SpecStore, TranscriptStore } from '@abseed/spectra-core';
25
+ /** MCP tools answer with content blocks; every tool here returns one JSON or text block. */
26
+ export interface CallResult {
27
+ content: Array<{
28
+ type: 'text';
29
+ text: string;
30
+ }>;
31
+ }
32
+ /** Read-only/behaviour hints passed through to the MCP registration, mirroring the SDK's shape. */
33
+ export type ToolAnnotations = {
34
+ readOnlyHint?: boolean;
35
+ } & Record<string, unknown>;
36
+ /**
37
+ * A tool as both consumers expect it: the agent SDK's `SdkMcpToolDefinition` (for the in-process
38
+ * server) and the MCP SDK's `registerTool` (for the HTTP/fetch server) both read exactly these
39
+ * fields, so this one shape serves both.
40
+ */
41
+ export interface ToolDef {
42
+ name: string;
43
+ description: string;
44
+ inputSchema: z.ZodRawShape;
45
+ handler: (args: Record<string, unknown>, extra: unknown) => Promise<CallResult>;
46
+ annotations?: ToolAnnotations;
47
+ }
48
+ /**
49
+ * The plain-object equivalent of the SDK's `tool()` — a constructor, no runtime behaviour. Exported
50
+ * so the server can define its host-coupled tools (raise_expectation, mark_implemented, export_specs)
51
+ * in the same shape and compose them with these.
52
+ */
53
+ export declare function defineTool<S extends z.ZodRawShape>(name: string, description: string, inputSchema: S, handler: (args: z.infer<z.ZodObject<S>>, extra: unknown) => Promise<CallResult>, annotations?: ToolAnnotations): ToolDef;
54
+ /** Every tool answers with one content block — JSON when structured, text otherwise. */
55
+ export declare function say(value: unknown): CallResult;
56
+ /**
57
+ * The pure tools, constructed for a given store/transcripts/author. No version stamping here — that
58
+ * is applied by {@link withVersion}, so the server (filesystem snapshot) and the cloud (its own
59
+ * scheme) can each supply how the current version is read.
60
+ */
61
+ export declare function pureTools(store: SpecStore, transcripts: TranscriptStore, author: Author): ToolDef[];
62
+ /**
63
+ * Stamp every tool's result with the current specs version.
64
+ *
65
+ * A `stale: true` flag would be one process's opinion; a version is a fact both sides hold and
66
+ * compare (the same reason git prints `abc123..def456`). `versionOf` is injected so each host reads
67
+ * it its own way — the server from its filesystem snapshot, the cloud from its store.
68
+ */
69
+ export declare function withVersion(tools: ToolDef[], versionOf: () => Promise<string>): ToolDef[];
70
+ /** Filter a tool list to the names an agent may call. Never taken from the tool arguments. */
71
+ export declare function pick(tools: ToolDef[], names: readonly string[]): ToolDef[];
72
+ /** The `mcp__blueprints__`-qualified names, for the SDK's allowedTools. */
73
+ export declare function qualified(names: readonly string[]): string[];
package/dist/tools.js ADDED
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The agent's tool surface — the pure, transport-agnostic half.
3
+ *
4
+ * These are domain tools, not file tools, and that is the whole point. The human write path is
5
+ * changesets-only; if the agent had `Write` on the glossary it could edit a term directly and the
6
+ * discipline would rest on the system prompt asking it not to. Here it rests on there being no such
7
+ * tool. The agent physically cannot bypass review.
8
+ *
9
+ * WHY this is its own package, not `@abseed/spectra-core`: these schemas are consumed by the agent SDK,
10
+ * which peer-requires zod 4, while core is zod 3. So the tool definitions live here (zod 4, no node
11
+ * builtins, no SDK dependency) where both the open server and the hosted coordinator can import them
12
+ * — the server registers them in-process and over HTTP, the Worker over its fetch-native MCP.
13
+ *
14
+ * Only the *pure* tools live here: reads, plus `propose_changeset` and `raise_question` (whose write
15
+ * logic is in core). The three that touch the host — `raise_expectation` (a model-backed check),
16
+ * `mark_implemented` and `export_specs` (a filesystem snapshot) — stay in the server and are composed
17
+ * on top of these (see the server's `toolsFor`).
18
+ *
19
+ * The tool objects are plain `SdkMcpToolDefinition`-shaped records (see {@link defineTool}); the agent
20
+ * SDK's `tool()` helper is only a typed constructor for that same shape, so we reproduce it here
21
+ * rather than depend on the SDK.
22
+ */
23
+ import { z } from 'zod';
24
+ import { analyzePending, computeBacklinks, computeCoverage, proposeChangeset, raiseQuestion, summarizeOp } from '@abseed/spectra-core';
25
+ /**
26
+ * The plain-object equivalent of the SDK's `tool()` — a constructor, no runtime behaviour. Exported
27
+ * so the server can define its host-coupled tools (raise_expectation, mark_implemented, export_specs)
28
+ * in the same shape and compose them with these.
29
+ */
30
+ export function defineTool(name, description, inputSchema, handler, annotations) {
31
+ return {
32
+ name,
33
+ description,
34
+ inputSchema,
35
+ handler: handler,
36
+ ...(annotations ? { annotations } : {}),
37
+ };
38
+ }
39
+ /**
40
+ * An op as the *tool* accepts it: one flat shape with an enum tag and optional fields, rather than
41
+ * the discriminated union the engine uses. Nested inside `options[] → proposal → ops[]`, a
42
+ * `z.discriminatedUnion` defeats the SDK's JSON-Schema conversion and the whole MCP server silently
43
+ * fails to register. Nothing is lost: both write paths run the real schema over the result before
44
+ * writing, so a malformed op is rejected there with a readable message rather than landing on disk.
45
+ */
46
+ const attributeInput = z.object({
47
+ name: z.string(),
48
+ valueType: z.string().describe('string | number | boolean | date | ref:<TermName>, each with an optional [] suffix'),
49
+ default: z.unknown().optional(),
50
+ optional: z.boolean().optional(),
51
+ });
52
+ const opInput = z.object({
53
+ op: z.enum(['add_entity', 'remove_entity', 'add_attribute', 'remove_attribute', 'modify_spec']),
54
+ term: z.string().describe('The term this op targets'),
55
+ termType: z.enum(['entity', 'event', 'function', 'attribute-type']).optional().describe('add_entity only; defaults to entity'),
56
+ parent: z.string().nullable().optional().describe('add_entity only'),
57
+ spec: z.string().optional().describe('Required for add_entity and modify_spec'),
58
+ tags: z.array(z.string()).optional(),
59
+ attributes: z.array(attributeInput).optional().describe('add_entity only'),
60
+ attribute: z
61
+ .union([attributeInput, z.string()])
62
+ .optional()
63
+ .describe('add_attribute takes the attribute object; remove_attribute takes just its name as a string'),
64
+ });
65
+ const proposalInput = z.object({
66
+ summary: z.string(),
67
+ ops: z.array(opInput),
68
+ tests: z.array(z.string()).describe('Plain-language behaviours this change commits to'),
69
+ });
70
+ /** Every tool answers with one content block — JSON when structured, text otherwise. */
71
+ export function say(value) {
72
+ return {
73
+ content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
74
+ };
75
+ }
76
+ /** A term plus the relationships that make it make sense — the useful unit, not the file. */
77
+ function describeTerm(terms, name) {
78
+ const term = terms.find((candidate) => candidate.name === name);
79
+ if (!term)
80
+ return null;
81
+ const backlinks = computeBacklinks(terms);
82
+ return {
83
+ ...term,
84
+ children: backlinks.children[name] ?? [],
85
+ referencedBy: (backlinks.byTarget[name] ?? []).map((reference) => ({ from: reference.from, via: reference.via })),
86
+ };
87
+ }
88
+ /** Everything pending, flattened into comparable op-lists for the conflict analysis. */
89
+ function pendingItems(changesets, questions) {
90
+ const items = changesets.map((changeset) => ({
91
+ id: changeset.id,
92
+ kind: 'changeset',
93
+ label: changeset.summary,
94
+ ops: changeset.ops,
95
+ }));
96
+ for (const question of questions) {
97
+ if (question.answer)
98
+ continue;
99
+ for (const option of question.options) {
100
+ if (!option.proposal)
101
+ continue;
102
+ items.push({
103
+ id: `${question.id}:${option.label}`,
104
+ kind: 'question-option',
105
+ label: `${question.id} — ${option.label}`,
106
+ ops: option.proposal.ops,
107
+ });
108
+ }
109
+ }
110
+ return items;
111
+ }
112
+ /**
113
+ * The pure tools, constructed for a given store/transcripts/author. No version stamping here — that
114
+ * is applied by {@link withVersion}, so the server (filesystem snapshot) and the cloud (its own
115
+ * scheme) can each supply how the current version is read.
116
+ */
117
+ export function pureTools(store, transcripts, author) {
118
+ const readGlossary = defineTool('read_glossary', 'Read the spec glossary. Omit `term` for every term in summary form; supply one to get its full spec, attributes, subtypes and everything that references it.', { term: z.string().optional().describe('A single term name, e.g. "Task"') }, async (args) => {
119
+ const { terms, problems } = await store.readTerms();
120
+ if (args.term) {
121
+ const described = describeTerm(terms, args.term);
122
+ return say(described ?? { error: `No term named "${args.term}".`, known: terms.map((term) => term.name) });
123
+ }
124
+ return say({
125
+ terms: terms.map((term) => ({
126
+ name: term.name,
127
+ type: term.type,
128
+ spec: term.spec,
129
+ parent: term.parent,
130
+ attributes: term.attributes.map((attribute) => `${attribute.name}: ${attribute.valueType}`),
131
+ })),
132
+ problems,
133
+ });
134
+ }, { readOnlyHint: true });
135
+ const readQuestions = defineTool('read_questions', 'Read questions raised against the glossary — what it does not settle, and what has been decided. Answered questions are the record of why the specs say what they say.', { status: z.enum(['open', 'answered', 'all']).optional().describe('Defaults to "all"') }, async (args) => {
136
+ const { questions, problems } = await store.readQuestions();
137
+ const status = args.status ?? 'all';
138
+ const filtered = questions.filter((question) => status === 'all' ? true : status === 'open' ? !question.answer : Boolean(question.answer));
139
+ return say({
140
+ questions: filtered.map((question) => ({
141
+ id: question.id,
142
+ asks: question.asks,
143
+ because: question.because,
144
+ raisedBy: question.raisedBy,
145
+ options: question.options.map((option) => ({
146
+ label: option.label,
147
+ detail: option.detail,
148
+ ops: option.proposal?.ops.map(summarizeOp) ?? null,
149
+ })),
150
+ answer: question.answer,
151
+ })),
152
+ problems,
153
+ });
154
+ }, { readOnlyHint: true });
155
+ const readChangesets = defineTool('read_changesets', 'Read changesets: `outstanding` are applied but not yet implemented — the work, and where to find the id mark_implemented wants. `pending` are proposed and awaiting human review; do not implement those, they may still be rejected or changed. `implemented` is history.', {}, async () => {
156
+ const { changesets, applied, problems } = await store.readChangesets();
157
+ const describe = (changeset) => ({
158
+ id: changeset.id,
159
+ summary: changeset.summary,
160
+ fromQuestion: changeset.fromQuestion,
161
+ appliedAt: changeset.appliedAt,
162
+ ops: changeset.ops.map(summarizeOp),
163
+ tests: changeset.tests,
164
+ });
165
+ return say({
166
+ outstanding: applied.filter((changeset) => !changeset.implementedAt).map(describe),
167
+ pending: changesets.map(describe),
168
+ implemented: applied
169
+ .filter((changeset) => changeset.implementedAt)
170
+ .map((changeset) => ({ id: changeset.id, summary: changeset.summary, implementedAt: changeset.implementedAt })),
171
+ problems,
172
+ });
173
+ }, { readOnlyHint: true });
174
+ const readExpectations = defineTool('read_expectations', 'Read the expectations — what someone should be able to expect, stated outside the prose of the specs. Functional ones become tests over the domain; non-functional ones describe a running build. Pass `coverage` to get which entity/action pairs nothing has been said about yet, nearest first: that list is the work queue for what the glossary has named but nobody has thought through.', {
175
+ coverage: z.boolean().optional().describe('Return the pair-coverage report instead of the expectation list'),
176
+ term: z.string().optional().describe('Only expectations naming this term'),
177
+ }, async (args) => {
178
+ const [{ terms }, { expectations, retired, problems }] = await Promise.all([store.readTerms(), store.readExpectations()]);
179
+ if (args.coverage) {
180
+ const report = computeCoverage(terms, expectations);
181
+ return say({
182
+ ...report,
183
+ note: 'Coverage means an expectation exists naming both ends of the pair — not that the behaviour is correct. distance 1 is an action naming the entity directly; 2 is reached through the entity graph, which is where the interactions nobody thought about tend to sit.',
184
+ });
185
+ }
186
+ const wanted = (list) => (args.term ? list.filter((expectation) => expectation.terms.includes(args.term)) : list);
187
+ const live = wanted(expectations);
188
+ const contested = live.filter((expectation) => expectation.contested.length > 0);
189
+ return say({
190
+ expectations: live,
191
+ ...(contested.length > 0
192
+ ? {
193
+ warning: `${contested.map((entry) => entry.id).join(', ')} disagree with the specs and were recorded anyway. Do not write code to satisfy a contested expectation and do not change the specs to match it — which side gives is a decision only the human can make. Say what you found and leave it.`,
194
+ }
195
+ : {}),
196
+ retired: wanted(retired).map((expectation) => ({
197
+ id: expectation.id,
198
+ expect: expectation.expect,
199
+ supersededBy: expectation.supersededBy,
200
+ retiredBecause: expectation.retiredBecause,
201
+ })),
202
+ problems,
203
+ });
204
+ }, { readOnlyHint: true });
205
+ const analyzePendingTool = defineTool('analyze_pending', 'Work out what to tackle first. Replays every pending changeset and unanswered question option through the changeset engine, alone and in pairs, and reports which ones break which — including cases where order is what matters. Use this before recommending where to start; do not reason it out by hand.', {}, async () => {
206
+ const [{ terms }, { changesets }, { questions }] = await Promise.all([store.readTerms(), store.readChangesets(), store.readQuestions()]);
207
+ const items = pendingItems(changesets, questions);
208
+ const report = analyzePending(terms, items);
209
+ return say({
210
+ ...report,
211
+ note: 'A conflict listed as {first, second} means applying `first` before `second` causes the diagnostics shown. If the reverse pair is absent, that order is safe.',
212
+ });
213
+ }, { readOnlyHint: true });
214
+ const searchTranscripts = defineTool('search_transcripts', 'Search earlier conversations in this workspace for a word or phrase. Use it before asking the human to repeat context they may already have given.', {
215
+ query: z.string().describe('Substring to look for, case-insensitive'),
216
+ limit: z.number().int().min(1).max(50).optional(),
217
+ }, async (args) => {
218
+ const hits = await transcripts.search(args.query, args.limit ?? 20);
219
+ return say(hits.map((hit) => ({ session: hit.title, sessionId: hit.sessionId, role: hit.kind, at: hit.createdAt, text: hit.text })));
220
+ }, { readOnlyHint: true });
221
+ const raiseQuestionTool = defineTool('raise_question', [
222
+ 'Raise a question against the glossary. Use this when the specs do not settle something a human must decide — never to record an observation.',
223
+ 'If it cannot be phrased as a question someone answers, it does not belong here.',
224
+ '`because` must quote the spec text in conflict. "This was awkward to implement" is not grounds to change a spec; "these two spec sentences cannot both hold" is.',
225
+ 'The number of options is the answer shape: one means approve-or-decline, several means a genuine choice (do not signal a favourite by ordering), none means only the human can write the spec text.',
226
+ 'Do not use this to propose a change you are confident about and could simply describe — that is what a changeset is for.',
227
+ ].join(' '), {
228
+ asks: z.string().describe('The question itself, answerable as written'),
229
+ because: z.string().describe('Why it is being asked, quoting the conflicting spec text verbatim'),
230
+ pass: z.string().describe('What was being done when it came up, e.g. "implementation" or "review"'),
231
+ file: z.string().optional().describe('Source file where it surfaced, if any'),
232
+ terms: z.array(z.string()).describe('Glossary terms the question is about'),
233
+ options: z
234
+ .array(z.object({
235
+ label: z.string(),
236
+ detail: z.string().optional().describe('The tradeoff in plain language, including what this choice costs'),
237
+ proposal: proposalInput.nullable().optional().describe('The changeset this option would raise. Null when it changes no specs.'),
238
+ }))
239
+ .describe('Candidate answers; may be empty when only the human can write the spec'),
240
+ }, async (args) => {
241
+ const outcome = await raiseQuestion(store, {
242
+ asks: args.asks,
243
+ because: args.because,
244
+ pass: args.pass,
245
+ file: args.file,
246
+ terms: args.terms,
247
+ options: args.options,
248
+ }, author);
249
+ return say(outcome.ok
250
+ ? { raised: outcome.id, file: `specs/questions/${outcome.file}`, awaiting: 'a human answer' }
251
+ : { error: outcome.error });
252
+ });
253
+ const proposeChangesetTool = defineTool('propose_changeset', [
254
+ 'Propose an edit to the glossary. Use this when the change is clear and there is no product decision left to make — a missing term, a spec that says two things, a name that does not match what it describes.',
255
+ 'It lands in the pending queue and changes nothing until a human reviews and applies it, so it is safe to propose; it is not safe to guess.',
256
+ 'If the change turns on a choice only the human can make, raise a question instead. Do not settle a fork by proposing one side of it — a changeset that quietly picked a default is far harder to review than a question that names the options.',
257
+ 'If the request needs no glossary change at all — presentation, wording in the UI, how something is displayed or implemented — say so and do not propose anything. The glossary describes the domain, not the app that renders it.',
258
+ 'Say in your reply what you did not decide. A proposal that names its own open ends is worth more than one that reads as finished.',
259
+ ].join(' '), {
260
+ summary: z.string().describe('One line: what this change does'),
261
+ ops: z.array(opInput).describe('The edits, applied in order'),
262
+ tests: z.array(z.string()).describe('Plain-language behaviours this change commits to — what a reviewer should expect to hold afterwards'),
263
+ fromQuestion: z.string().optional().describe('Id of an already-answered question this follows from, if any'),
264
+ }, async (args) => {
265
+ const outcome = await proposeChangeset(store, {
266
+ summary: args.summary,
267
+ ops: args.ops,
268
+ tests: args.tests,
269
+ ...(args.fromQuestion ? { fromQuestion: args.fromQuestion } : {}),
270
+ }, author);
271
+ return say(outcome.ok
272
+ ? { proposed: outcome.id, file: `specs/changesets/${outcome.file}`, awaiting: 'human review — nothing has changed in the glossary yet' }
273
+ : { error: outcome.error });
274
+ });
275
+ return [
276
+ readGlossary,
277
+ readQuestions,
278
+ readChangesets,
279
+ readExpectations,
280
+ analyzePendingTool,
281
+ searchTranscripts,
282
+ raiseQuestionTool,
283
+ proposeChangesetTool,
284
+ ];
285
+ }
286
+ /**
287
+ * Stamp every tool's result with the current specs version.
288
+ *
289
+ * A `stale: true` flag would be one process's opinion; a version is a fact both sides hold and
290
+ * compare (the same reason git prints `abc123..def456`). `versionOf` is injected so each host reads
291
+ * it its own way — the server from its filesystem snapshot, the cloud from its store.
292
+ */
293
+ export function withVersion(tools, versionOf) {
294
+ return tools.map((tool) => ({
295
+ ...tool,
296
+ handler: async (args, extra) => {
297
+ const result = await tool.handler(args, extra);
298
+ return {
299
+ ...result,
300
+ content: [...result.content, { type: 'text', text: JSON.stringify({ specsVersion: await versionOf() }) }],
301
+ };
302
+ },
303
+ }));
304
+ }
305
+ /** Filter a tool list to the names an agent may call. Never taken from the tool arguments. */
306
+ export function pick(tools, names) {
307
+ const wanted = new Set(names);
308
+ return tools.filter((tool) => wanted.has(tool.name));
309
+ }
310
+ /** The `mcp__blueprints__`-qualified names, for the SDK's allowedTools. */
311
+ export function qualified(names) {
312
+ return names.map((name) => `mcp__blueprints__${name}`);
313
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@abseed/spectra-agent-tools",
3
+ "version": "0.1.0",
4
+ "description": "Spectra's agent tool definitions and the @spec/@coder agent roster — Worker-safe, pure.",
5
+ "license": "Apache-2.0",
6
+ "author": "Luke Pezet",
7
+ "homepage": "https://github.com/lpezet/spectra/tree/main/packages/agent-tools",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/lpezet/spectra.git",
11
+ "directory": "packages/agent-tools"
12
+ },
13
+ "keywords": [
14
+ "spectra",
15
+ "spec-driven",
16
+ "agent",
17
+ "mcp",
18
+ "tools"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "sideEffects": false,
30
+ "dependencies": {
31
+ "@abseed/spectra-core": "^0.1.0",
32
+ "zod": "^4.4.3"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }