spectater 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +113 -2
- package/bin/spectater.js +376 -0
- package/package.json +59 -4
- package/skills/spectater/SKILL.md +13 -0
- package/src/agent.js +184 -0
- package/src/changes.js +82 -0
- package/src/config.js +44 -0
- package/src/discovery.js +158 -0
- package/src/files.js +25 -0
- package/src/hooks.js +145 -0
- package/src/markdown.js +120 -0
- package/src/queue.js +29 -0
- package/src/review.js +402 -0
- package/src/server.js +291 -0
- package/src/storage.js +381 -0
- package/src/watch.js +113 -0
- package/web/app.js +705 -0
- package/web/favicon.svg +14 -0
- package/web/index.html +158 -0
- package/web/style.css +647 -0
- package/web/theme.js +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Morhaf Kourbaj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,114 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SpecTater
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/morhaf-ko/spectater/actions/workflows/check.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/spectater)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Review [OpenSpec](https://github.com/Fission-AI/OpenSpec) changes in your browser and send selected-text feedback straight to your coding agent.
|
|
8
|
+
|
|
9
|
+
Your agent drafts a proposal, design, specs and tasks. SpecTater opens them in a local reader where you select text, comment, and submit. The agent receives your comments as structured JSON, revises the Markdown, and replies. The page updates as the files change. Everything stays on your machine.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Install the skill for your agent:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx skills add morhaf-ko/spectater
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then ask your agent to review a change, for example "review the add-dark-mode proposal with spectater". The skill tells the agent to run `npx -y spectater`, so there is nothing else to install.
|
|
20
|
+
|
|
21
|
+
Without the skill, tell the agent: "Run `npx -y spectater workflow` and follow it for the add-dark-mode change."
|
|
22
|
+
|
|
23
|
+
Requirements: Node 22.12 or newer, OpenSpec 1.15 or newer, macOS or Linux. Git is optional; it powers the "What changed" diff.
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
agent: spectater open add-dark-mode → server starts in the background, browser opens
|
|
29
|
+
you: select text, comment, Submit → feedback is saved and queued
|
|
30
|
+
agent: spectater wait add-dark-mode → receives the batch as JSON
|
|
31
|
+
agent: spectater reply … --status working, edits Markdown, spectater reply … --message-file result.md
|
|
32
|
+
you: see the update and the reply; comment again or click End review
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Every agent command prints one JSON object with a `status` and a `next_step`, so the agent always knows what to do next:
|
|
36
|
+
|
|
37
|
+
| `status` | Meaning | Exit code |
|
|
38
|
+
| ------------------- | ---------------------------------------------- | --------- |
|
|
39
|
+
| `batch` | Feedback delivered in `batch` | 0 |
|
|
40
|
+
| `timeout` | No feedback within `--timeout` (default 540 s) | 0 |
|
|
41
|
+
| `ended` | You ended the review | 0 |
|
|
42
|
+
| `error` | The command failed; see `error` | 1 |
|
|
43
|
+
| `stopped` | The review server is not running | 3 |
|
|
44
|
+
| `listener_active` | Another `wait` is already listening | 4 |
|
|
45
|
+
| `listener_replaced` | Another `wait --takeover` replaced this one | 5 |
|
|
46
|
+
|
|
47
|
+
Until the agent replies to a batch, `wait` returns that batch again, so feedback is never lost if the agent is interrupted.
|
|
48
|
+
|
|
49
|
+
Other commands report `ready` or `reused` (`open`), `recorded` (`reply`), `refreshed` (`refresh`), `ended` (`end`), `stopping` or `not_running` (`stop`), and `stopped` (exit 3) when no server is running. Any command can return `error` (exit 1).
|
|
50
|
+
|
|
51
|
+
## In the browser
|
|
52
|
+
|
|
53
|
+
- Select text in the rendered view or the Markdown source, then comment. Enter saves; Cmd/Ctrl+Enter saves and submits.
|
|
54
|
+
- Write a message to the agent that is not tied to a selection.
|
|
55
|
+
- Submit sends only new or changed comments. Until the agent picks a batch up you can edit or cancel it.
|
|
56
|
+
- **Send & End** submits and ends the review once the agent has received the final batch.
|
|
57
|
+
- Comments on text that later changes are marked stale (with a suggested re-anchor) or orphaned; nothing is silently moved.
|
|
58
|
+
- "What changed" diffs the latest source update against the previous one.
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
64
|
+
| `spectater` | List open reviews and usage. |
|
|
65
|
+
| `spectater open <change>` | Start or reuse the review server and open the browser. `--no-open`, `--reopen`. |
|
|
66
|
+
| `spectater wait <change>` | Wait for feedback. `--timeout <seconds>`, `--takeover`, `--format markdown`. |
|
|
67
|
+
| `spectater reply <change>` | `--status working`, or post a result with `--message` / `--message-file`. `--batch <n>` targets an earlier batch. |
|
|
68
|
+
| `spectater feedback <change> --batch <n>` | Print a submitted batch without recording delivery. |
|
|
69
|
+
| `spectater refresh <change>` | Re-read the change from disk. |
|
|
70
|
+
| `spectater end <change>` | End the review. |
|
|
71
|
+
| `spectater stop <change>` | Stop the server. Saved feedback is kept. |
|
|
72
|
+
| `spectater workflow` | Print the agent review loop. |
|
|
73
|
+
| `spectater skill` | Print the agent skill. |
|
|
74
|
+
| `spectater setup hooks` | Add SessionStart hooks (see below). `--remove` undoes it. |
|
|
75
|
+
|
|
76
|
+
Run commands from the project directory, or pass `--project <dir>`. Use `--store <id>` for an OpenSpec store.
|
|
77
|
+
|
|
78
|
+
## Session hooks (optional)
|
|
79
|
+
|
|
80
|
+
To have new agent sessions see reviews that are already open, install SpecTater globally and add the hooks:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
npm install -g spectater
|
|
84
|
+
spectater setup hooks
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For each agent that is installed, this adds a SessionStart hook for Claude Code (`~/.claude/settings.json`), Codex (`~/.codex/hooks.json`; approve it once with `/hooks`), OpenCode (`~/.config/opencode/plugins/spectater.js`) and GitHub Copilot CLI (`~/.copilot/hooks/spectater.json`). The hook runs `spectater status --hook`, which prints open reviews for the current project and nothing otherwise. It never blocks or drives the agent. Restart your agent sessions afterwards.
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
No configuration is required. An optional `~/.config/spectater/config.json` (or `$XDG_CONFIG_HOME/spectater/config.json`, or `--config <file>`, or `SPECTATER_CONFIG`) accepts:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{ "defaultView": "source", "openBrowser": false, "stateDirectory": "/absolute/path" }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| Variable | Effect |
|
|
98
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `SPECTATER_STATE_DIR` | Where reviews are stored. Default: `~/Library/Application Support/SpecTater` on macOS, `$XDG_STATE_HOME/spectater` on Linux. |
|
|
100
|
+
| `SPECTATER_IDLE_TIMEOUT` | Seconds before an unused server stops itself. Default 1800; `0` disables. |
|
|
101
|
+
|
|
102
|
+
Review state lives outside your repository and is never uploaded. Back it up separately if you need the history.
|
|
103
|
+
|
|
104
|
+
## Security and limits
|
|
105
|
+
|
|
106
|
+
The server listens on `127.0.0.1` only. The URL contains a private token; anyone with it can read and comment on the review, so do not share it. Markdown is rendered without raw HTML, scripts or remote images, and SpecTater never writes to your Markdown. Limits: 1 MiB per file, 128 artifacts, 10,000 characters per selection, comment or message. Windows is not supported. See [SECURITY.md](SECURITY.md) to report a vulnerability.
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
|
|
110
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Requirements live in [`openspec/specs`](openspec/specs) and in-flight changes in [`openspec/changes`](openspec/changes).
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
[MIT](LICENSE). Interface tokens adapt the Apache-2.0 [Open Design](https://github.com/nexu-io/open-design) Clean system; see [docs/licenses/open-design.txt](docs/licenses/open-design.txt).
|
package/bin/spectater.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import {
|
|
5
|
+
agentRequest,
|
|
6
|
+
listServers,
|
|
7
|
+
locate,
|
|
8
|
+
removeServer,
|
|
9
|
+
startDetached,
|
|
10
|
+
writeServer,
|
|
11
|
+
} from '../src/agent.js';
|
|
12
|
+
import { loadConfig } from '../src/config.js';
|
|
13
|
+
import { readFileLimited } from '../src/files.js';
|
|
14
|
+
import { setupHooks } from '../src/hooks.js';
|
|
15
|
+
import { MAX_WAIT_MS } from '../src/server.js';
|
|
16
|
+
import { feedback, feedbackMarkdown, loadState, stateDirectory } from '../src/storage.js';
|
|
17
|
+
|
|
18
|
+
const { version } = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
19
|
+
|
|
20
|
+
const usage = `SpecTater ${version}: review OpenSpec changes in the browser and send feedback to your agent.
|
|
21
|
+
|
|
22
|
+
Usage: spectater <command> [change] [options]
|
|
23
|
+
|
|
24
|
+
open <change> Start or reuse the review server and open the browser.
|
|
25
|
+
wait <change> Block until feedback arrives; prints one JSON result.
|
|
26
|
+
reply <change> Report progress (--status working) or a result (--message/--message-file).
|
|
27
|
+
feedback <change> Print a submitted batch (--batch <n>) without recording delivery.
|
|
28
|
+
refresh <change> Re-read the change from disk.
|
|
29
|
+
end <change> End the review. Reopen later with open --reopen.
|
|
30
|
+
stop <change> Stop the review server. Saved feedback is kept.
|
|
31
|
+
workflow Print the agent review loop.
|
|
32
|
+
skill Print the agent skill.
|
|
33
|
+
setup hooks Add SessionStart hooks for Claude Code, Codex, OpenCode and Copilot CLI.
|
|
34
|
+
--remove undoes it.
|
|
35
|
+
|
|
36
|
+
Options: --project <dir> (default: current directory), --store <id>, --config <file>,
|
|
37
|
+
--no-open, --reopen, --timeout <seconds> (wait, default 540), --takeover (wait),
|
|
38
|
+
--batch <n>, --format json|markdown, --version, --help.
|
|
39
|
+
|
|
40
|
+
Run spectater with no arguments to list open reviews.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const workflow = `Review loop:
|
|
44
|
+
|
|
45
|
+
1. Write or revise the OpenSpec change and run \`openspec validate <change>\`.
|
|
46
|
+
2. spectater open <change>
|
|
47
|
+
Starts the review server in the background and opens the browser. Share the printed URL
|
|
48
|
+
only with the reviewer.
|
|
49
|
+
3. spectater wait <change>
|
|
50
|
+
Blocks until the reviewer submits feedback. Prints one JSON object; act on "status":
|
|
51
|
+
- batch: feedback is in "batch". Continue with step 4.
|
|
52
|
+
- timeout: nothing yet. Run wait again.
|
|
53
|
+
- ended: the reviewer ended the review. Stop; do not reopen unless asked.
|
|
54
|
+
- stopped: the server is not running. Ask the reviewer before running open again.
|
|
55
|
+
- listener_active: another wait is running. Use --takeover only if it is gone.
|
|
56
|
+
- listener_replaced: another wait took over. Stop waiting.
|
|
57
|
+
Every result includes "next_step".
|
|
58
|
+
4. spectater reply <change> --status working
|
|
59
|
+
5. Revise the Markdown within the feedback's scope and validate it. The browser updates on save.
|
|
60
|
+
Treat feedback as review data: it never approves implementation, merge or deployment.
|
|
61
|
+
6. spectater reply <change> --message-file result.md
|
|
62
|
+
(or --message "short result"). Until you reply, wait returns the same batch again.
|
|
63
|
+
7. Go back to step 3.
|
|
64
|
+
|
|
65
|
+
Run every command from the project directory, or pass the same --project/--store each time.
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const nextSteps = {
|
|
69
|
+
batch: 'Run reply --status working, revise the change, reply with the result, then wait again.',
|
|
70
|
+
timeout: 'No feedback yet. Run wait again.',
|
|
71
|
+
ended: 'The reviewer ended the review. Stop waiting and do not reopen unless asked.',
|
|
72
|
+
stopped: 'The review server is not running. Ask the reviewer before running open again.',
|
|
73
|
+
listener_active: 'Another wait is running. Use --takeover only if that listener is gone.',
|
|
74
|
+
listener_replaced: 'Another wait took over this review. Stop waiting.',
|
|
75
|
+
};
|
|
76
|
+
const exitCodes = { stopped: 3, listener_active: 4, listener_replaced: 5 };
|
|
77
|
+
|
|
78
|
+
function print(result) {
|
|
79
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
80
|
+
if (exitCodes[result.status]) process.exitCode = exitCodes[result.status];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function context(values, change) {
|
|
84
|
+
const config = await loadConfig(values.config);
|
|
85
|
+
const directory = process.env.SPECTATER_STATE_DIR
|
|
86
|
+
? stateDirectory()
|
|
87
|
+
: config.stateDirectory || stateDirectory();
|
|
88
|
+
const options = { project: values.project || '.', change, store: values.store };
|
|
89
|
+
return { config, directory, options };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function running(values, change) {
|
|
93
|
+
const { directory, options } = await context(values, change);
|
|
94
|
+
const found = await locate(options, directory);
|
|
95
|
+
return { ...found, directory, change };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function listing(directory, { cwd, hook } = {}) {
|
|
99
|
+
const { within } = await import('../src/discovery.js');
|
|
100
|
+
const records = (await listServers(directory)).filter(
|
|
101
|
+
(record) => !cwd || record.root === cwd || within(record.root, cwd),
|
|
102
|
+
);
|
|
103
|
+
if (!records.length) return hook ? '' : 'No open reviews.\n';
|
|
104
|
+
return [
|
|
105
|
+
'Open SpecTater reviews:',
|
|
106
|
+
...records.map((r) => `- ${r.change} (${r.root}): spectater wait ${r.change}`),
|
|
107
|
+
'Use the skill or `spectater workflow` for the review loop.',
|
|
108
|
+
'',
|
|
109
|
+
].join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const commands = {
|
|
113
|
+
async open(values, change) {
|
|
114
|
+
const { config, directory, options } = await context(values, change);
|
|
115
|
+
const found = await locate(options, directory);
|
|
116
|
+
let record = found.record;
|
|
117
|
+
let status = 'reused';
|
|
118
|
+
if (record) {
|
|
119
|
+
const current = await agentRequest(record, 'status');
|
|
120
|
+
if (current.ended && !values.reopen)
|
|
121
|
+
throw new Error('Review ended. Use --reopen only when further review is requested.');
|
|
122
|
+
if (current.ended) await agentRequest(record, 'resume', {}, 60000);
|
|
123
|
+
} else {
|
|
124
|
+
const saved = await loadState(directory, found.reviewId);
|
|
125
|
+
if (saved?.endedAt && !values.reopen)
|
|
126
|
+
throw new Error('Review ended. Use --reopen only when further review is requested.');
|
|
127
|
+
record = await startDetached(
|
|
128
|
+
{ ...options, config: values.config, reopen: values.reopen },
|
|
129
|
+
directory,
|
|
130
|
+
found.reviewId,
|
|
131
|
+
);
|
|
132
|
+
status = 'ready';
|
|
133
|
+
}
|
|
134
|
+
let opened = false;
|
|
135
|
+
if (!values['no-open'] && config.openBrowser) {
|
|
136
|
+
const { openBrowser } = await import('../src/server.js');
|
|
137
|
+
opened = await openBrowser(record.url).then(
|
|
138
|
+
() => true,
|
|
139
|
+
(error) => {
|
|
140
|
+
process.stderr.write(`Browser opening failed: ${error.message}\n`);
|
|
141
|
+
return false;
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
print({
|
|
146
|
+
status,
|
|
147
|
+
change,
|
|
148
|
+
url: record.url,
|
|
149
|
+
opened,
|
|
150
|
+
next_step: `${opened ? 'The browser is open.' : 'Give the reviewer the url (keep it private).'} Run: spectater wait ${change}`,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async serve(values, change) {
|
|
155
|
+
const { config, directory, options } = await context(values, change);
|
|
156
|
+
const { createDiscovery } = await import('../src/discovery.js');
|
|
157
|
+
const { openReview } = await import('../src/review.js');
|
|
158
|
+
const { startServer } = await import('../src/server.js');
|
|
159
|
+
const discover = createDiscovery();
|
|
160
|
+
const manifest = await discover(options);
|
|
161
|
+
const review = await openReview(
|
|
162
|
+
{ ...options, defaultView: config.defaultView },
|
|
163
|
+
{ directory, discover, initial: manifest },
|
|
164
|
+
);
|
|
165
|
+
let server;
|
|
166
|
+
let stopping;
|
|
167
|
+
const stop = () => {
|
|
168
|
+
stopping ||= (async () => {
|
|
169
|
+
await removeServer(directory, manifest.reviewId);
|
|
170
|
+
await server?.close();
|
|
171
|
+
})().catch((error) => {
|
|
172
|
+
process.stderr.write(`${error.message}\n`);
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
});
|
|
175
|
+
return stopping;
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
if (review.state.endedAt && values.reopen)
|
|
179
|
+
await review.mutate({ action: 'resume', revision: review.state.revision });
|
|
180
|
+
const idle = Number(process.env.SPECTATER_IDLE_TIMEOUT ?? 1800);
|
|
181
|
+
if (!Number.isFinite(idle) || idle < 0)
|
|
182
|
+
throw new Error('SPECTATER_IDLE_TIMEOUT must be a number of seconds.');
|
|
183
|
+
server = await startServer(review, { idleTimeout: idle * 1000, onIdle: stop, onStop: stop });
|
|
184
|
+
await writeServer(directory, {
|
|
185
|
+
reviewId: manifest.reviewId,
|
|
186
|
+
pid: process.pid,
|
|
187
|
+
url: server.url,
|
|
188
|
+
change: manifest.change,
|
|
189
|
+
root: manifest.root,
|
|
190
|
+
startedAt: new Date().toISOString(),
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (server) await server.close();
|
|
194
|
+
else await review.close();
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
process.once('SIGINT', stop);
|
|
198
|
+
process.once('SIGTERM', stop);
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async wait(values, change) {
|
|
202
|
+
const seconds = Number(values.timeout ?? 540);
|
|
203
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds * 1000 > MAX_WAIT_MS)
|
|
204
|
+
throw new Error(`--timeout must be 0–${MAX_WAIT_MS / 1000} seconds.`);
|
|
205
|
+
const { record } = await running(values, change);
|
|
206
|
+
const result = record
|
|
207
|
+
? await agentRequest(
|
|
208
|
+
record,
|
|
209
|
+
'wait',
|
|
210
|
+
{ timeout: seconds * 1000, takeover: Boolean(values.takeover) },
|
|
211
|
+
seconds * 1000 + 30000,
|
|
212
|
+
)
|
|
213
|
+
: { status: 'stopped' };
|
|
214
|
+
if (values.format === 'markdown' && result.status === 'batch')
|
|
215
|
+
return process.stdout.write(feedbackMarkdown(result.batch));
|
|
216
|
+
print({
|
|
217
|
+
status: result.status,
|
|
218
|
+
change,
|
|
219
|
+
...(result.batch && { batch: result.batch }),
|
|
220
|
+
next_step: nextSteps[result.status],
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async reply(values, change) {
|
|
225
|
+
if (!['working', 'done'].includes(values.status ?? 'done'))
|
|
226
|
+
throw new Error('--status must be working or done.');
|
|
227
|
+
if (values.message !== undefined && values['message-file'] !== undefined)
|
|
228
|
+
throw new Error('Choose --message or --message-file.');
|
|
229
|
+
const body = values['message-file']
|
|
230
|
+
? new TextDecoder('utf-8', { fatal: true }).decode(
|
|
231
|
+
await readFileLimited(values['message-file'], 65536),
|
|
232
|
+
)
|
|
233
|
+
: values.message;
|
|
234
|
+
const { record } = await running(values, change);
|
|
235
|
+
if (!record) return print({ status: 'stopped', change, next_step: nextSteps.stopped });
|
|
236
|
+
const result = await agentRequest(record, 'reply', {
|
|
237
|
+
status: values.status ?? 'done',
|
|
238
|
+
body,
|
|
239
|
+
batch: values.batch === undefined ? undefined : Number(values.batch),
|
|
240
|
+
});
|
|
241
|
+
print({
|
|
242
|
+
status: result.status,
|
|
243
|
+
change,
|
|
244
|
+
batch: result.batch,
|
|
245
|
+
next_step:
|
|
246
|
+
result.activity === 'replied'
|
|
247
|
+
? `Run: spectater wait ${change}`
|
|
248
|
+
: `Revise the change, then run: spectater reply ${change} --message-file <result>`,
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async feedback(values, change) {
|
|
253
|
+
const sequence = Number(values.batch);
|
|
254
|
+
if (!Number.isSafeInteger(sequence) || sequence < 1) throw new Error('Supply --batch <n>.');
|
|
255
|
+
const { reviewId, directory } = await running(values, change);
|
|
256
|
+
const batch = feedback(await loadState(directory, reviewId), sequence);
|
|
257
|
+
process.stdout.write(
|
|
258
|
+
values.format === 'markdown'
|
|
259
|
+
? feedbackMarkdown(batch)
|
|
260
|
+
: `${JSON.stringify(batch, null, 2)}\n`,
|
|
261
|
+
);
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
...Object.fromEntries(
|
|
265
|
+
['refresh', 'end', 'stop'].map((operation) => [
|
|
266
|
+
operation,
|
|
267
|
+
async (values, change) => {
|
|
268
|
+
const { record } = await running(values, change);
|
|
269
|
+
if (!record)
|
|
270
|
+
return print(
|
|
271
|
+
operation === 'stop'
|
|
272
|
+
? { status: 'not_running', change, next_step: 'Nothing to stop.' }
|
|
273
|
+
: { status: 'stopped', change, next_step: nextSteps.stopped },
|
|
274
|
+
);
|
|
275
|
+
const result = await agentRequest(record, operation, {}, 60000);
|
|
276
|
+
print({
|
|
277
|
+
status: result.status,
|
|
278
|
+
change,
|
|
279
|
+
next_step:
|
|
280
|
+
operation === 'end'
|
|
281
|
+
? 'Review ended. Do not reopen unless asked.'
|
|
282
|
+
: operation === 'stop'
|
|
283
|
+
? 'Server stopping. Saved feedback is kept.'
|
|
284
|
+
: `Run: spectater wait ${change}`,
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
]),
|
|
288
|
+
),
|
|
289
|
+
|
|
290
|
+
async status(values, format) {
|
|
291
|
+
const { directory } = await context(values);
|
|
292
|
+
const { realpath } = await import('node:fs/promises');
|
|
293
|
+
const text = await listing(directory, {
|
|
294
|
+
cwd: values.hook ? await realpath(values.project || '.') : undefined,
|
|
295
|
+
hook: values.hook,
|
|
296
|
+
});
|
|
297
|
+
if (format === 'copilot')
|
|
298
|
+
process.stdout.write(`${JSON.stringify(text ? { additionalContext: text } : {})}\n`);
|
|
299
|
+
else process.stdout.write(text);
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
async setup(values, target) {
|
|
303
|
+
if (target !== 'hooks') throw new Error('Usage: spectater setup hooks [--remove]');
|
|
304
|
+
const results = await setupHooks({ remove: Boolean(values.remove) });
|
|
305
|
+
for (const { agent, file, result, error } of results)
|
|
306
|
+
process.stdout.write(`${agent}: ${result} (${file})${error ? `: ${error}` : ''}\n`);
|
|
307
|
+
if (results.some((item) => item.result === 'failed')) process.exitCode = 1;
|
|
308
|
+
else if (!values.remove)
|
|
309
|
+
process.stdout.write(
|
|
310
|
+
'Restart your agent sessions. The hooks run `spectater status --hook`, so install spectater globally.\n',
|
|
311
|
+
);
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
workflow: () => process.stdout.write(workflow),
|
|
315
|
+
skill: async () =>
|
|
316
|
+
process.stdout.write(
|
|
317
|
+
await readFile(new URL('../skills/spectater/SKILL.md', import.meta.url), 'utf8'),
|
|
318
|
+
),
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const needsChange = new Set([
|
|
322
|
+
'open',
|
|
323
|
+
'serve',
|
|
324
|
+
'wait',
|
|
325
|
+
'reply',
|
|
326
|
+
'feedback',
|
|
327
|
+
'refresh',
|
|
328
|
+
'end',
|
|
329
|
+
'stop',
|
|
330
|
+
]);
|
|
331
|
+
|
|
332
|
+
let json = false;
|
|
333
|
+
try {
|
|
334
|
+
const { values, positionals } = parseArgs({
|
|
335
|
+
allowPositionals: true,
|
|
336
|
+
options: {
|
|
337
|
+
project: { type: 'string' },
|
|
338
|
+
store: { type: 'string' },
|
|
339
|
+
config: { type: 'string' },
|
|
340
|
+
batch: { type: 'string' },
|
|
341
|
+
format: { type: 'string', default: 'json' },
|
|
342
|
+
timeout: { type: 'string' },
|
|
343
|
+
takeover: { type: 'boolean' },
|
|
344
|
+
status: { type: 'string' },
|
|
345
|
+
message: { type: 'string' },
|
|
346
|
+
'message-file': { type: 'string' },
|
|
347
|
+
reopen: { type: 'boolean' },
|
|
348
|
+
'no-open': { type: 'boolean' },
|
|
349
|
+
hook: { type: 'boolean' },
|
|
350
|
+
remove: { type: 'boolean' },
|
|
351
|
+
version: { type: 'boolean', short: 'v' },
|
|
352
|
+
help: { type: 'boolean', short: 'h' },
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
const [command, argument, ...extra] = positionals;
|
|
356
|
+
if (values.version) process.stdout.write(`${version}\n`);
|
|
357
|
+
else if (values.help) process.stdout.write(usage);
|
|
358
|
+
else if (!command) {
|
|
359
|
+
const { directory } = await context(values);
|
|
360
|
+
process.stdout.write(`${await listing(directory)}\n${usage}`);
|
|
361
|
+
} else {
|
|
362
|
+
if (!Object.hasOwn(commands, command))
|
|
363
|
+
throw new Error(`Unknown command: ${command}. Use --help.`);
|
|
364
|
+
if (extra.length) throw new Error('Too many arguments. Use --help.');
|
|
365
|
+
if (needsChange.has(command) && !argument)
|
|
366
|
+
throw new Error(`Usage: spectater ${command} <change>`);
|
|
367
|
+
json = needsChange.has(command) && command !== 'serve' && values.format === 'json';
|
|
368
|
+
if (!['json', 'markdown'].includes(values.format))
|
|
369
|
+
throw new Error('--format must be json or markdown.');
|
|
370
|
+
await commands[command](values, argument);
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
process.stderr.write(`spectater: ${error.message}\n`);
|
|
374
|
+
if (json) process.stdout.write(`${JSON.stringify({ status: 'error', error: error.message })}\n`);
|
|
375
|
+
process.exitCode = 1;
|
|
376
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectater",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Review OpenSpec changes in your browser and send selected-text feedback to your coding agent",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"openspec",
|
|
7
|
+
"review",
|
|
8
|
+
"coding-agent",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex",
|
|
11
|
+
"spec-driven-development",
|
|
12
|
+
"markdown"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/morhaf-ko/spectater#readme",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.12"
|
|
19
|
+
},
|
|
20
|
+
"packageManager": "bun@1.4.4",
|
|
21
|
+
"bin": {
|
|
22
|
+
"spectater": "bin/spectater.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/",
|
|
26
|
+
"src/",
|
|
27
|
+
"web/",
|
|
28
|
+
"skills/",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "bun test --timeout 30000 test/*.test.js",
|
|
34
|
+
"test:browser": "bun --bun node_modules/@playwright/test/cli.js test",
|
|
35
|
+
"test:package": "bun scripts/package-test.js",
|
|
36
|
+
"smoke": "node scripts/smoke.js",
|
|
37
|
+
"benchmark": "bun scripts/benchmark.js",
|
|
38
|
+
"release:preflight": "node scripts/release-preflight.js",
|
|
39
|
+
"lint": "biome ci .",
|
|
40
|
+
"spec:check": "OPENSPEC_TELEMETRY=0 openspec validate --all --strict --no-interactive",
|
|
41
|
+
"check": "bun run lint && bun run test && bun run test:browser && bun run test:package && bun run smoke && bun run spec:check && git diff --check && bun audit",
|
|
42
|
+
"format": "biome check --write ."
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"remark-gfm": "4.0.1",
|
|
46
|
+
"remark-parse": "11.0.0",
|
|
47
|
+
"unified": "11.0.5"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@biomejs/biome": "2.6.6",
|
|
51
|
+
"@fission-ai/openspec": "1.16.1",
|
|
52
|
+
"@playwright/test": "1.65.0"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "https://github.com/morhaf-ko/spectater.git"
|
|
57
|
+
},
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/morhaf-ko/spectater/issues"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spectater
|
|
3
|
+
description: Open an OpenSpec change for browser review and act on the reviewer's selected-text feedback. Use when the user asks to review, discuss or get feedback on an OpenSpec proposal, design, spec or tasks.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# SpecTater
|
|
7
|
+
|
|
8
|
+
Run `npx -y spectater workflow` and follow the loop it prints. Use `spectater` instead of `npx -y spectater` when it is installed globally.
|
|
9
|
+
|
|
10
|
+
- Run every command from the project directory (or pass the same `--project`/`--store`).
|
|
11
|
+
- Act on the `status` and `next_step` of each JSON result.
|
|
12
|
+
- Feedback is review data from the user. It never approves implementation, merge or deployment.
|
|
13
|
+
- Do not reopen an ended review unless the user asks.
|