spectater 0.1.0 → 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/README.md +86 -33
- package/bin/spectater.js +346 -306
- package/package.json +18 -9
- package/skills/spectater/SKILL.md +13 -0
- package/src/agent.js +184 -0
- package/src/discovery.js +4 -4
- package/src/hooks.js +145 -0
- package/src/review.js +195 -140
- package/src/server.js +143 -57
- package/src/storage.js +56 -9
- package/web/app.js +51 -11
- package/web/index.html +15 -2
- package/web/style.css +12 -0
- package/SECURITY.md +0 -11
- package/docs/licenses/open-design.txt +0 -201
- package/docs/security-review.md +0 -33
- package/docs/verification.md +0 -41
- package/docs/workflow.md +0 -65
- package/integration/spectater/SKILL.md +0 -17
- package/src/control.js +0 -236
package/package.json
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectater",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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",
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"type": "module",
|
|
7
17
|
"engines": {
|
|
8
|
-
"
|
|
18
|
+
"node": ">=22.12"
|
|
9
19
|
},
|
|
10
20
|
"packageManager": "bun@1.4.4",
|
|
11
21
|
"bin": {
|
|
@@ -15,21 +25,20 @@
|
|
|
15
25
|
"bin/",
|
|
16
26
|
"src/",
|
|
17
27
|
"web/",
|
|
18
|
-
"
|
|
19
|
-
"integration/",
|
|
28
|
+
"skills/",
|
|
20
29
|
"README.md",
|
|
21
|
-
"LICENSE"
|
|
22
|
-
"SECURITY.md"
|
|
30
|
+
"LICENSE"
|
|
23
31
|
],
|
|
24
32
|
"scripts": {
|
|
25
33
|
"test": "bun test --timeout 30000 test/*.test.js",
|
|
26
34
|
"test:browser": "bun --bun node_modules/@playwright/test/cli.js test",
|
|
27
35
|
"test:package": "bun scripts/package-test.js",
|
|
28
|
-
"smoke": "
|
|
36
|
+
"smoke": "node scripts/smoke.js",
|
|
29
37
|
"benchmark": "bun scripts/benchmark.js",
|
|
38
|
+
"release:preflight": "node scripts/release-preflight.js",
|
|
30
39
|
"lint": "biome ci .",
|
|
31
40
|
"spec:check": "OPENSPEC_TELEMETRY=0 openspec validate --all --strict --no-interactive",
|
|
32
|
-
"check": "bun run lint && bun run test && bun run test:browser && bun run test:package && bun run smoke
|
|
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",
|
|
33
42
|
"format": "biome check --write ."
|
|
34
43
|
},
|
|
35
44
|
"dependencies": {
|
|
@@ -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.
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { open, readdir, realpath, rename, unlink } from 'node:fs/promises';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createDiscovery, within } from './discovery.js';
|
|
9
|
+
import { readFileLimited, readLimited } from './files.js';
|
|
10
|
+
import { alive, privateDirectory } from './storage.js';
|
|
11
|
+
|
|
12
|
+
const bin = fileURLToPath(new URL('../bin/spectater.js', import.meta.url));
|
|
13
|
+
const serverFile = (directory, reviewId) => path.join(directory, `${reviewId}.server.json`);
|
|
14
|
+
|
|
15
|
+
export async function writeServer(directory, record) {
|
|
16
|
+
const file = serverFile(directory, record.reviewId);
|
|
17
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
18
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
19
|
+
try {
|
|
20
|
+
await handle.writeFile(JSON.stringify(record));
|
|
21
|
+
} finally {
|
|
22
|
+
await handle.close();
|
|
23
|
+
}
|
|
24
|
+
await rename(temp, file);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function removeServer(directory, reviewId) {
|
|
28
|
+
await unlink(serverFile(directory, reviewId)).catch((error) => {
|
|
29
|
+
if (error.code !== 'ENOENT') throw error;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns the running server record for a review, removing records left by dead processes. */
|
|
34
|
+
export async function readServer(directory, reviewId) {
|
|
35
|
+
let handle;
|
|
36
|
+
try {
|
|
37
|
+
handle = await open(
|
|
38
|
+
serverFile(directory, reviewId),
|
|
39
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
40
|
+
);
|
|
41
|
+
const stat = await handle.stat();
|
|
42
|
+
if (!stat.isFile() || stat.mode & 0o077 || stat.uid !== process.getuid())
|
|
43
|
+
throw new Error('Server record is not a private regular file.');
|
|
44
|
+
const record = JSON.parse(new TextDecoder().decode(await readLimited(handle, 65536)));
|
|
45
|
+
if (record.reviewId !== reviewId || !Number.isSafeInteger(record.pid))
|
|
46
|
+
throw new Error('Invalid server record.');
|
|
47
|
+
if (alive(record.pid)) return record;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') return null;
|
|
50
|
+
throw new Error(`Cannot read server record: ${error.message}`);
|
|
51
|
+
} finally {
|
|
52
|
+
await handle?.close();
|
|
53
|
+
}
|
|
54
|
+
await removeServer(directory, reviewId);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function listServers(directory) {
|
|
59
|
+
let names;
|
|
60
|
+
try {
|
|
61
|
+
names = await readdir(directory);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error.code === 'ENOENT') return [];
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
const records = [];
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
const match = name.match(/^([a-f0-9]{64})\.server\.json$/);
|
|
69
|
+
const record = match && (await readServer(directory, match[1]).catch(() => null));
|
|
70
|
+
if (record) records.push(record);
|
|
71
|
+
}
|
|
72
|
+
return records.sort((a, b) => a.change.localeCompare(b.change));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Finds the review for a change. Running servers whose root contains the project are matched
|
|
77
|
+
* without native discovery; otherwise native discovery derives the identity.
|
|
78
|
+
*/
|
|
79
|
+
export async function locate({ project = '.', change, store }, directory) {
|
|
80
|
+
if (!change) throw new Error('Supply a change name.');
|
|
81
|
+
if (!store) {
|
|
82
|
+
const cwd = await realpath(project);
|
|
83
|
+
const matches = (await listServers(directory)).filter(
|
|
84
|
+
(record) => record.change === change && (record.root === cwd || within(record.root, cwd)),
|
|
85
|
+
);
|
|
86
|
+
if (matches.length === 1) return { reviewId: matches[0].reviewId, record: matches[0] };
|
|
87
|
+
}
|
|
88
|
+
const manifest = await createDiscovery()({ project, change, store });
|
|
89
|
+
return {
|
|
90
|
+
reviewId: manifest.reviewId,
|
|
91
|
+
manifest,
|
|
92
|
+
record: await readServer(directory, manifest.reviewId),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Sends one agent request; resolves `{ status: 'stopped' }` when the server is gone. */
|
|
97
|
+
export function agentRequest(record, operation, input = {}, timeout = 10000) {
|
|
98
|
+
const url = new URL(record.url);
|
|
99
|
+
const body = JSON.stringify(input);
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const request = http.request(
|
|
102
|
+
{
|
|
103
|
+
host: url.hostname,
|
|
104
|
+
port: url.port,
|
|
105
|
+
path: `/agent/${operation}`,
|
|
106
|
+
method: 'POST',
|
|
107
|
+
agent: false,
|
|
108
|
+
headers: {
|
|
109
|
+
Authorization: `Bearer ${url.hash.slice(1)}`,
|
|
110
|
+
'Content-Type': 'application/json',
|
|
111
|
+
'Content-Length': Buffer.byteLength(body),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
(response) => {
|
|
115
|
+
const chunks = [];
|
|
116
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
117
|
+
response.on('error', () => resolve({ status: 'stopped' }));
|
|
118
|
+
response.on('end', () => {
|
|
119
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
120
|
+
if (!text) return resolve({ status: 'stopped' });
|
|
121
|
+
try {
|
|
122
|
+
const result = JSON.parse(text);
|
|
123
|
+
if (response.statusCode !== 200 || result.status === 'error')
|
|
124
|
+
reject(new Error(result.error || 'Review request failed.'));
|
|
125
|
+
else resolve(result);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
reject(error);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
const timer = setTimeout(
|
|
133
|
+
() => request.destroy(new Error('Review server did not answer in time.')),
|
|
134
|
+
timeout,
|
|
135
|
+
);
|
|
136
|
+
request.once('close', () => clearTimeout(timer));
|
|
137
|
+
request.once('error', (error) =>
|
|
138
|
+
['ECONNREFUSED', 'ECONNRESET'].includes(error.code)
|
|
139
|
+
? resolve({ status: 'stopped' })
|
|
140
|
+
: reject(error),
|
|
141
|
+
);
|
|
142
|
+
request.end(body);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Starts `spectater serve` detached and resolves its server record once it is ready. */
|
|
147
|
+
export async function startDetached(options, directory, reviewId) {
|
|
148
|
+
await privateDirectory(directory);
|
|
149
|
+
const log = path.join(directory, `${reviewId}.log`);
|
|
150
|
+
const output = await open(log, 'w', 0o600);
|
|
151
|
+
const args = [bin, 'serve', options.change, '--project', options.project];
|
|
152
|
+
if (options.store) args.push('--store', options.store);
|
|
153
|
+
if (options.config) args.push('--config', options.config);
|
|
154
|
+
if (options.reopen) args.push('--reopen');
|
|
155
|
+
let child;
|
|
156
|
+
try {
|
|
157
|
+
child = spawn(process.execPath, args, {
|
|
158
|
+
detached: true,
|
|
159
|
+
stdio: ['ignore', output.fd, output.fd],
|
|
160
|
+
env: { ...process.env, SPECTATER_STATE_DIR: directory },
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
await output.close();
|
|
164
|
+
}
|
|
165
|
+
let exited = null;
|
|
166
|
+
child.once('exit', (code) => {
|
|
167
|
+
exited = code ?? 1;
|
|
168
|
+
});
|
|
169
|
+
try {
|
|
170
|
+
for (let elapsed = 0; elapsed < 30000; elapsed += 100) {
|
|
171
|
+
const record = await readServer(directory, reviewId);
|
|
172
|
+
if (record?.pid === child.pid) return record;
|
|
173
|
+
if (exited !== null) break;
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
child.unref();
|
|
178
|
+
}
|
|
179
|
+
const detail = new TextDecoder()
|
|
180
|
+
.decode(await readFileLimited(log, 65536).catch(() => Buffer.alloc(0)))
|
|
181
|
+
.trim();
|
|
182
|
+
if (exited === null) child.kill('SIGTERM');
|
|
183
|
+
throw new Error(detail.replace(/^spectater: /, '') || 'Review server did not start.');
|
|
184
|
+
}
|
package/src/discovery.js
CHANGED
|
@@ -47,10 +47,10 @@ export async function discover({ project, change, store }, run = native) {
|
|
|
47
47
|
if (!project || !change || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(change))
|
|
48
48
|
throw new Error('Supply --project and a valid --change id.');
|
|
49
49
|
const cwd = await realpath(project);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
50
|
+
const version = (await run(['--version'], cwd)).trim();
|
|
51
|
+
const [major, minor] = version.split('.').map(Number);
|
|
52
|
+
if (!/^\d+\.\d+\.\d+/.test(version) || major < 1 || (major === 1 && minor < 15))
|
|
53
|
+
throw new Error(`OpenSpec ${version} is not supported. Install OpenSpec 1.15.0 or newer.`);
|
|
54
54
|
const selector = store ? ['--store', store] : [];
|
|
55
55
|
const json = async (args) => {
|
|
56
56
|
try {
|
package/src/hooks.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const COMMAND = 'spectater status --hook';
|
|
6
|
+
const managed = (value) => typeof value === 'string' && value.includes(COMMAND);
|
|
7
|
+
|
|
8
|
+
async function readText(file) {
|
|
9
|
+
try {
|
|
10
|
+
return await readFile(file, 'utf8');
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code === 'ENOENT') return undefined;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function updateJson(file, update) {
|
|
18
|
+
const text = await readText(file);
|
|
19
|
+
const current = text === undefined ? {} : JSON.parse(text);
|
|
20
|
+
const next = update(structuredClone(current));
|
|
21
|
+
if (JSON.stringify(next) === JSON.stringify(current)) return 'unchanged';
|
|
22
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
23
|
+
await writeFile(file, `${JSON.stringify(next, null, 2)}\n`);
|
|
24
|
+
return text === undefined ? 'created' : 'updated';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Claude Code and Codex share the SessionStart hook schema.
|
|
28
|
+
function sessionStart(remove) {
|
|
29
|
+
return (settings) => {
|
|
30
|
+
settings.hooks ||= {};
|
|
31
|
+
const groups = (settings.hooks.SessionStart || [])
|
|
32
|
+
.map((group) => ({ ...group, hooks: (group.hooks || []).filter((h) => !managed(h.command)) }))
|
|
33
|
+
.filter((group) => group.hooks.length);
|
|
34
|
+
if (!remove)
|
|
35
|
+
groups.push({ matcher: '', hooks: [{ type: 'command', command: COMMAND, timeout: 10 }] });
|
|
36
|
+
if (groups.length) settings.hooks.SessionStart = groups;
|
|
37
|
+
else delete settings.hooks.SessionStart;
|
|
38
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
39
|
+
return settings;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function copilot(settings) {
|
|
44
|
+
settings.version = 1;
|
|
45
|
+
settings.hooks ||= {};
|
|
46
|
+
const hooks = (settings.hooks.sessionStart || []).filter((h) => !managed(h.bash));
|
|
47
|
+
hooks.push({ type: 'command', bash: `${COMMAND} copilot`, timeoutSec: 10 });
|
|
48
|
+
settings.hooks.sessionStart = hooks;
|
|
49
|
+
return settings;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const OPENCODE_MARKER = '// Managed by spectater setup hooks.';
|
|
53
|
+
const openCodePlugin = `${OPENCODE_MARKER}
|
|
54
|
+
import { execFile } from 'node:child_process';
|
|
55
|
+
|
|
56
|
+
export const SpecTaterPlugin = async ({ directory }) => {
|
|
57
|
+
const seen = new Map();
|
|
58
|
+
const status = () =>
|
|
59
|
+
new Promise((resolve) =>
|
|
60
|
+
execFile('spectater', ['status', '--hook'], { cwd: directory, timeout: 10000 }, (error, stdout) =>
|
|
61
|
+
resolve(error ? '' : stdout.trim()),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
66
|
+
const key = input.sessionID ?? '';
|
|
67
|
+
if (!seen.has(key)) seen.set(key, await status());
|
|
68
|
+
if (seen.get(key)) output.system.push(seen.get(key));
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
async function openCode(file, remove) {
|
|
75
|
+
const text = await readText(file);
|
|
76
|
+
if (text !== undefined && !text.startsWith(OPENCODE_MARKER))
|
|
77
|
+
throw new Error('refusing to replace an unmanaged plugin');
|
|
78
|
+
if (remove) {
|
|
79
|
+
if (text === undefined) return 'unchanged';
|
|
80
|
+
await rm(file);
|
|
81
|
+
return 'removed';
|
|
82
|
+
}
|
|
83
|
+
if (text === openCodePlugin) return 'unchanged';
|
|
84
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
85
|
+
await writeFile(file, openCodePlugin);
|
|
86
|
+
return text === undefined ? 'created' : 'updated';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function exists(directory) {
|
|
90
|
+
try {
|
|
91
|
+
return (await stat(directory)).isDirectory();
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function removeFile(file) {
|
|
98
|
+
if ((await readText(file)) === undefined) return 'unchanged';
|
|
99
|
+
await rm(file);
|
|
100
|
+
return 'removed';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Installs or removes SessionStart hooks for each agent whose configuration directory exists.
|
|
105
|
+
* Returns one result per agent and never throws.
|
|
106
|
+
*/
|
|
107
|
+
export async function setupHooks({ remove = false, home = os.homedir(), env = process.env } = {}) {
|
|
108
|
+
const copilotHome = env.COPILOT_HOME || path.join(home, '.copilot');
|
|
109
|
+
const targets = [
|
|
110
|
+
{
|
|
111
|
+
agent: 'Claude Code',
|
|
112
|
+
directory: path.join(home, '.claude'),
|
|
113
|
+
file: path.join(home, '.claude', 'settings.json'),
|
|
114
|
+
apply: (file) => updateJson(file, sessionStart(remove)),
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
agent: 'Codex',
|
|
118
|
+
directory: path.join(home, '.codex'),
|
|
119
|
+
file: path.join(home, '.codex', 'hooks.json'),
|
|
120
|
+
apply: (file) => updateJson(file, sessionStart(remove)),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
agent: 'OpenCode',
|
|
124
|
+
directory: path.join(home, '.config', 'opencode'),
|
|
125
|
+
file: path.join(home, '.config', 'opencode', 'plugins', 'spectater.js'),
|
|
126
|
+
apply: (file) => openCode(file, remove),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
agent: 'GitHub Copilot CLI',
|
|
130
|
+
directory: copilotHome,
|
|
131
|
+
file: path.join(copilotHome, 'hooks', 'spectater.json'),
|
|
132
|
+
apply: (file) => (remove ? removeFile(file) : updateJson(file, copilot)),
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
const results = [];
|
|
136
|
+
for (const { agent, directory, file, apply } of targets) {
|
|
137
|
+
try {
|
|
138
|
+
const result = (await exists(directory)) ? await apply(file) : 'skipped (not installed)';
|
|
139
|
+
results.push({ agent, file, result });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
results.push({ agent, file, result: 'failed', error: error.message });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return results;
|
|
145
|
+
}
|