@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 +202 -0
- package/NOTICE +6 -0
- package/dist/agents.d.ts +50 -0
- package/dist/agents.js +140 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/tools.d.ts +73 -0
- package/dist/tools.js +313 -0
- package/package.json +37 -0
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
|
package/dist/agents.d.ts
ADDED
|
@@ -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'];
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/tools.d.ts
ADDED
|
@@ -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
|
+
}
|