openspecpm 0.1.0-alpha.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/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +352 -0
- package/cli/bin/openspecpm.js +198 -0
- package/cli/src/adapters/azure.js +230 -0
- package/cli/src/adapters/base.js +86 -0
- package/cli/src/adapters/github.js +224 -0
- package/cli/src/adapters/gitlab.js +170 -0
- package/cli/src/adapters/index.js +54 -0
- package/cli/src/adapters/jira.js +228 -0
- package/cli/src/adapters/linear.js +261 -0
- package/cli/src/audit.js +78 -0
- package/cli/src/bdd/linter.js +183 -0
- package/cli/src/bdd/templates.js +172 -0
- package/cli/src/commands/assign.js +78 -0
- package/cli/src/commands/blocked.js +17 -0
- package/cli/src/commands/bug-report.js +78 -0
- package/cli/src/commands/bulk.js +67 -0
- package/cli/src/commands/comment.js +83 -0
- package/cli/src/commands/decompose.js +106 -0
- package/cli/src/commands/doctor.js +61 -0
- package/cli/src/commands/fan-out.js +79 -0
- package/cli/src/commands/help.js +69 -0
- package/cli/src/commands/init.js +111 -0
- package/cli/src/commands/next.js +18 -0
- package/cli/src/commands/propose.js +67 -0
- package/cli/src/commands/reconcile.js +74 -0
- package/cli/src/commands/search.js +52 -0
- package/cli/src/commands/ship.js +79 -0
- package/cli/src/commands/standup.js +42 -0
- package/cli/src/commands/status.js +29 -0
- package/cli/src/commands/sync.js +128 -0
- package/cli/src/commands/validate.js +79 -0
- package/cli/src/commands/watch.js +67 -0
- package/cli/src/config.js +43 -0
- package/cli/src/frontmatter.js +22 -0
- package/cli/src/http.js +92 -0
- package/cli/src/install-hints.js +44 -0
- package/cli/src/notify.js +56 -0
- package/cli/src/openspec-bridge.js +64 -0
- package/cli/src/ratelimit.js +50 -0
- package/cli/src/telemetry.js +45 -0
- package/cli/src/tracking.js +197 -0
- package/package.json +60 -0
- package/skill/openspecpm/SKILL.md +74 -0
- package/skill/openspecpm/references/conventions.md +105 -0
- package/skill/openspecpm/references/execute.md +62 -0
- package/skill/openspecpm/references/plan.md +47 -0
- package/skill/openspecpm/references/structure.md +52 -0
- package/skill/openspecpm/references/sync.md +56 -0
- package/skill/openspecpm/references/track.md +55 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { listChanges } from '../tracking.js';
|
|
4
|
+
import { lintChange, summarize } from '../bdd/linter.js';
|
|
5
|
+
|
|
6
|
+
const REQUIRED_PROPOSAL = ['name'];
|
|
7
|
+
const TASK_STATES = ['pending', 'created', 'failed'];
|
|
8
|
+
|
|
9
|
+
export async function runValidate() {
|
|
10
|
+
const changes = await listChanges();
|
|
11
|
+
out(`openspecpm validate — ${changes.length} change(s)\n`);
|
|
12
|
+
let totalIssues = 0;
|
|
13
|
+
|
|
14
|
+
for (const change of changes) {
|
|
15
|
+
const issues = [];
|
|
16
|
+
|
|
17
|
+
// Proposal frontmatter
|
|
18
|
+
for (const k of REQUIRED_PROPOSAL) {
|
|
19
|
+
if (!change.proposal[k]) issues.push(`proposal.md missing required field "${k}"`);
|
|
20
|
+
}
|
|
21
|
+
if (change.proposal.name && change.proposal.name !== change.name) {
|
|
22
|
+
issues.push(`proposal.md name="${change.proposal.name}" does not match directory "${change.name}"`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Task items
|
|
26
|
+
const titles = new Set();
|
|
27
|
+
for (const [i, task] of change.items.entries()) {
|
|
28
|
+
const ref = `tasks.md item #${i + 1}`;
|
|
29
|
+
if (!task.title) issues.push(`${ref}: missing title`);
|
|
30
|
+
if (task.sync_state && !TASK_STATES.includes(task.sync_state)) {
|
|
31
|
+
issues.push(`${ref} "${task.title}": invalid sync_state "${task.sync_state}" (expected ${TASK_STATES.join(' | ')})`);
|
|
32
|
+
}
|
|
33
|
+
if (task.sync_state === 'created' && !task.external_id) {
|
|
34
|
+
issues.push(`${ref} "${task.title}": sync_state=created but external_id missing`);
|
|
35
|
+
}
|
|
36
|
+
if (task.title) {
|
|
37
|
+
if (titles.has(task.title)) issues.push(`${ref}: duplicate title "${task.title}"`);
|
|
38
|
+
titles.add(task.title);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// depends_on references
|
|
43
|
+
for (const task of change.items) {
|
|
44
|
+
for (const dep of task.depends_on ?? []) {
|
|
45
|
+
const byTitle = change.items.some((t) => t.title === dep);
|
|
46
|
+
const byId = change.items.some((t) => String(t.external_id) === String(dep));
|
|
47
|
+
if (!byTitle && !byId) {
|
|
48
|
+
issues.push(`tasks.md "${task.title}": depends_on "${dep}" does not resolve`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// BDD lint
|
|
54
|
+
const findings = await lintChange(change.dir);
|
|
55
|
+
const { errors, warnings } = summarize(findings);
|
|
56
|
+
|
|
57
|
+
const total = issues.length + errors;
|
|
58
|
+
totalIssues += total;
|
|
59
|
+
if (total === 0 && warnings === 0) {
|
|
60
|
+
out(` ✓ ${change.name}`);
|
|
61
|
+
} else {
|
|
62
|
+
out(` ${total > 0 ? '✖' : '⚠'} ${change.name}: ${issues.length} schema, ${errors} BDD errors, ${warnings} BDD warnings`);
|
|
63
|
+
for (const i of issues) out(` - ${i}`);
|
|
64
|
+
for (const f of findings.filter((x) => x.severity === 'error')) {
|
|
65
|
+
out(` - BDD ${f.rule}: ${f.scenario} — ${f.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (totalIssues) {
|
|
71
|
+
const err = new Error(`${totalIssues} issue(s) found across ${changes.length} change(s).`);
|
|
72
|
+
err.remediation = 'Fix the items above, then re-run validate.';
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function out(s) {
|
|
78
|
+
process.stdout.write(s + '\n');
|
|
79
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { changeDir, changeExists } from '../openspec-bridge.js';
|
|
5
|
+
import { lintChange, summarize, formatFindings } from '../bdd/linter.js';
|
|
6
|
+
import { runValidate } from './validate.js';
|
|
7
|
+
|
|
8
|
+
export async function runWatch({ feature, allChanges = false, debounceMs = 300 } = {}) {
|
|
9
|
+
if (!feature && !allChanges) throw new Error('feature name is required (or pass --all)');
|
|
10
|
+
if (feature && !changeExists(feature)) throw new Error(`OpenSpec change "${feature}" not found.`);
|
|
11
|
+
|
|
12
|
+
const targets = allChanges ? [] : [feature];
|
|
13
|
+
const watchedDir = allChanges
|
|
14
|
+
? join(process.cwd(), 'openspec', 'changes')
|
|
15
|
+
: changeDir(feature);
|
|
16
|
+
if (!existsSync(watchedDir)) {
|
|
17
|
+
throw new Error(`Cannot watch — directory does not exist: ${watchedDir}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
process.stdout.write(`openspecpm watch — ${allChanges ? 'all changes' : feature}\n`);
|
|
21
|
+
process.stdout.write(`Watching ${watchedDir} (Ctrl-C to stop)\n\n`);
|
|
22
|
+
|
|
23
|
+
let timer = null;
|
|
24
|
+
let pending = new Set();
|
|
25
|
+
|
|
26
|
+
const rerun = async () => {
|
|
27
|
+
timer = null;
|
|
28
|
+
const changed = [...pending];
|
|
29
|
+
pending.clear();
|
|
30
|
+
process.stdout.write(`\n[${new Date().toISOString().slice(11, 19)}] re-checking after change to: ${changed.slice(0, 3).join(', ')}${changed.length > 3 ? '…' : ''}\n`);
|
|
31
|
+
try {
|
|
32
|
+
if (allChanges) {
|
|
33
|
+
await runValidate();
|
|
34
|
+
} else {
|
|
35
|
+
const findings = await lintChange(changeDir(feature));
|
|
36
|
+
const sum = summarize(findings);
|
|
37
|
+
if (sum.total === 0) {
|
|
38
|
+
process.stdout.write(' ✓ BDD lint clean.\n');
|
|
39
|
+
} else {
|
|
40
|
+
process.stdout.write(` ${sum.errors} errors, ${sum.warnings} warnings\n`);
|
|
41
|
+
process.stdout.write(formatFindings(findings));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
process.stderr.write(` ✖ ${err.message}\n`);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const watcher = watch(watchedDir, { recursive: true }, (_eventType, filename) => {
|
|
50
|
+
if (!filename) return;
|
|
51
|
+
pending.add(filename);
|
|
52
|
+
if (timer) clearTimeout(timer);
|
|
53
|
+
timer = setTimeout(rerun, debounceMs);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Initial run.
|
|
57
|
+
await rerun();
|
|
58
|
+
|
|
59
|
+
// Hold the process open until interrupted.
|
|
60
|
+
await new Promise((resolve) => {
|
|
61
|
+
process.on('SIGINT', () => {
|
|
62
|
+
watcher.close();
|
|
63
|
+
process.stdout.write('\n\nStopped watching.\n');
|
|
64
|
+
resolve();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = '.openspecpm';
|
|
6
|
+
const CONFIG_FILE = 'config.json';
|
|
7
|
+
const STATE_FILE = 'state.json';
|
|
8
|
+
|
|
9
|
+
export const SCHEMA_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export function configPath(cwd = process.cwd()) {
|
|
12
|
+
return join(cwd, CONFIG_DIR, CONFIG_FILE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function statePath(cwd = process.cwd()) {
|
|
16
|
+
return join(cwd, CONFIG_DIR, STATE_FILE);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function readConfig(cwd = process.cwd()) {
|
|
20
|
+
const p = configPath(cwd);
|
|
21
|
+
if (!existsSync(p)) return null;
|
|
22
|
+
return JSON.parse(await readFile(p, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function writeConfig(config, cwd = process.cwd()) {
|
|
26
|
+
const p = configPath(cwd);
|
|
27
|
+
await mkdir(dirname(p), { recursive: true });
|
|
28
|
+
const payload = { schema_version: SCHEMA_VERSION, ...config };
|
|
29
|
+
await writeFile(p, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readState(cwd = process.cwd()) {
|
|
34
|
+
const p = statePath(cwd);
|
|
35
|
+
if (!existsSync(p)) return {};
|
|
36
|
+
return JSON.parse(await readFile(p, 'utf8'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function writeState(state, cwd = process.cwd()) {
|
|
40
|
+
const p = statePath(cwd);
|
|
41
|
+
await mkdir(dirname(p), { recursive: true });
|
|
42
|
+
await writeFile(p, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
|
|
3
|
+
const FENCE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
4
|
+
|
|
5
|
+
export function parse(source) {
|
|
6
|
+
const m = source.match(FENCE);
|
|
7
|
+
if (!m) return { data: {}, body: source };
|
|
8
|
+
const data = YAML.parse(m[1]) ?? {};
|
|
9
|
+
return { data, body: m[2] ?? '' };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function serialize(data, body) {
|
|
13
|
+
const yaml = YAML.stringify(data).trimEnd();
|
|
14
|
+
const sep = body.startsWith('\n') ? '' : '\n';
|
|
15
|
+
return `---\n${yaml}\n---${sep}${body}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function patch(source, patchObj) {
|
|
19
|
+
const { data, body } = parse(source);
|
|
20
|
+
const merged = { ...data, ...patchObj };
|
|
21
|
+
return serialize(merged, body);
|
|
22
|
+
}
|
package/cli/src/http.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AdapterError } from './adapters/base.js';
|
|
2
|
+
|
|
3
|
+
export class HttpClient {
|
|
4
|
+
#baseUrl;
|
|
5
|
+
#authHeader;
|
|
6
|
+
#fetchImpl;
|
|
7
|
+
#defaultHeaders;
|
|
8
|
+
#remediationHint;
|
|
9
|
+
|
|
10
|
+
constructor({ baseUrl, auth, fetch: fetchImpl = globalThis.fetch, defaultHeaders = {}, remediationHint } = {}) {
|
|
11
|
+
if (!baseUrl) throw new Error('HttpClient requires baseUrl');
|
|
12
|
+
if (typeof fetchImpl !== 'function') throw new Error('global fetch not available; pass {fetch} explicitly');
|
|
13
|
+
this.#baseUrl = baseUrl.replace(/\/+$/, '');
|
|
14
|
+
this.#authHeader = auth ?? null;
|
|
15
|
+
this.#fetchImpl = fetchImpl;
|
|
16
|
+
this.#defaultHeaders = defaultHeaders;
|
|
17
|
+
this.#remediationHint = remediationHint;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async request(method, path, { query, body, headers, contentType = 'application/json', accept = 'application/json' } = {}) {
|
|
21
|
+
const url = this.#buildUrl(path, query);
|
|
22
|
+
const finalHeaders = {
|
|
23
|
+
Accept: accept,
|
|
24
|
+
...(this.#defaultHeaders ?? {}),
|
|
25
|
+
...(this.#authHeader ? { Authorization: this.#authHeader } : {}),
|
|
26
|
+
...(headers ?? {}),
|
|
27
|
+
};
|
|
28
|
+
let payload;
|
|
29
|
+
if (body !== undefined && body !== null) {
|
|
30
|
+
if (typeof body === 'string' || body instanceof ArrayBuffer || body instanceof Uint8Array) {
|
|
31
|
+
payload = body;
|
|
32
|
+
} else {
|
|
33
|
+
payload = JSON.stringify(body);
|
|
34
|
+
}
|
|
35
|
+
finalHeaders['Content-Type'] = contentType;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await this.#fetchImpl(url, { method, headers: finalHeaders, body: payload });
|
|
41
|
+
} catch (err) {
|
|
42
|
+
throw new AdapterError(`Network error calling ${method} ${url}: ${err.message}`, {
|
|
43
|
+
remediation: this.#remediationHint ?? 'Check connectivity and base URL.',
|
|
44
|
+
cause: err,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const text = await res.text();
|
|
49
|
+
let parsed;
|
|
50
|
+
if (text && /^application\/json/i.test(res.headers.get('content-type') ?? '')) {
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(text);
|
|
53
|
+
} catch {
|
|
54
|
+
parsed = text;
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
parsed = text;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const detail = typeof parsed === 'string' ? parsed.slice(0, 500) : JSON.stringify(parsed).slice(0, 500);
|
|
62
|
+
throw new AdapterError(`${method} ${path} failed: ${res.status} ${res.statusText} — ${detail}`, {
|
|
63
|
+
remediation: this.#statusRemediation(res.status),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#buildUrl(path, query) {
|
|
70
|
+
const base = path.startsWith('http') ? path : this.#baseUrl + (path.startsWith('/') ? path : '/' + path);
|
|
71
|
+
if (!query) return base;
|
|
72
|
+
const params = new URLSearchParams();
|
|
73
|
+
for (const [k, v] of Object.entries(query)) {
|
|
74
|
+
if (v === undefined || v === null) continue;
|
|
75
|
+
params.set(k, String(v));
|
|
76
|
+
}
|
|
77
|
+
const qs = params.toString();
|
|
78
|
+
return qs ? `${base}${base.includes('?') ? '&' : '?'}${qs}` : base;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#statusRemediation(status) {
|
|
82
|
+
if (status === 401 || status === 403) return this.#remediationHint ?? 'Check authentication credentials and required scopes.';
|
|
83
|
+
if (status === 404) return 'The resource does not exist or your account lacks visibility.';
|
|
84
|
+
if (status === 429) return 'Rate-limited by the backend; retry after a backoff or lower request volume.';
|
|
85
|
+
if (status >= 500) return 'Backend error; retry, then check the service status page.';
|
|
86
|
+
return this.#remediationHint;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function basicAuth(user, password) {
|
|
91
|
+
return 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
|
92
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { platform } from 'node:os';
|
|
2
|
+
|
|
3
|
+
const PACKAGES = {
|
|
4
|
+
gh: {
|
|
5
|
+
win32: 'winget install --id GitHub.cli',
|
|
6
|
+
darwin: 'brew install gh',
|
|
7
|
+
linux: 'sudo apt-get install gh # or: snap install gh',
|
|
8
|
+
},
|
|
9
|
+
openspec: {
|
|
10
|
+
all: 'npm install -g @fission-ai/openspec',
|
|
11
|
+
},
|
|
12
|
+
az: {
|
|
13
|
+
win32: 'winget install --id Microsoft.AzureCLI',
|
|
14
|
+
darwin: 'brew install azure-cli',
|
|
15
|
+
linux: 'curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash',
|
|
16
|
+
},
|
|
17
|
+
jiracli: {
|
|
18
|
+
all: 'npm install -g @ankitpokhrel/jira-cli # optional; OpenSpecPM uses REST directly',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const PAT_URLS = {
|
|
23
|
+
github: { url: 'https://github.com/settings/tokens', scopes: 'repo, read:org (classic) — or fine-grained PAT scoped to your repo with Contents/Issues/Pull-requests RW' },
|
|
24
|
+
azure: { url: 'https://dev.azure.com/<org>/_usersSettings/tokens', scopes: 'Work Items: Read & Write' },
|
|
25
|
+
jira: { url: 'https://id.atlassian.com/manage-profile/security/api-tokens', scopes: 'API token (no scopes required; full account access)' },
|
|
26
|
+
linear: { url: 'https://linear.app/settings/api', scopes: 'Personal API Key with full scope (read + write)' },
|
|
27
|
+
gitlab: { url: 'https://gitlab.com/-/user_settings/personal_access_tokens', scopes: 'api (full read/write)' },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function installCommand(tool) {
|
|
31
|
+
const entry = PACKAGES[tool];
|
|
32
|
+
if (!entry) return null;
|
|
33
|
+
const os = platform();
|
|
34
|
+
return entry[os] ?? entry.all ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function patSetup(adapter) {
|
|
38
|
+
return PAT_URLS[adapter] ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function osName() {
|
|
42
|
+
const map = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' };
|
|
43
|
+
return map[platform()] ?? platform();
|
|
44
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound notifications to Slack / Teams / generic webhooks.
|
|
3
|
+
*
|
|
4
|
+
* Config (in .openspecpm/config.json):
|
|
5
|
+
* "notify": {
|
|
6
|
+
* "slack": "https://hooks.slack.com/services/T.../B.../...",
|
|
7
|
+
* "teams": "https://outlook.office.com/webhook/...",
|
|
8
|
+
* "generic": ["https://your.endpoint/openspecpm"]
|
|
9
|
+
* }
|
|
10
|
+
*
|
|
11
|
+
* Secrets stay in the config file — make sure that file isn't committed if
|
|
12
|
+
* the webhook URLs themselves are sensitive (they often are).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export async function notify({ config, title, body, level = 'info', fetchImpl = globalThis.fetch } = {}) {
|
|
16
|
+
if (!config?.notify) return { sent: 0, errors: [] };
|
|
17
|
+
const targets = [];
|
|
18
|
+
if (config.notify.slack) targets.push({ kind: 'slack', url: config.notify.slack });
|
|
19
|
+
if (config.notify.teams) targets.push({ kind: 'teams', url: config.notify.teams });
|
|
20
|
+
for (const url of config.notify.generic ?? []) targets.push({ kind: 'generic', url });
|
|
21
|
+
if (!targets.length) return { sent: 0, errors: [] };
|
|
22
|
+
|
|
23
|
+
let sent = 0;
|
|
24
|
+
const errors = [];
|
|
25
|
+
for (const t of targets) {
|
|
26
|
+
try {
|
|
27
|
+
await fetchImpl(t.url, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify(formatPayload(t.kind, { title, body, level })),
|
|
31
|
+
});
|
|
32
|
+
sent++;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
errors.push({ target: t.kind, error: err.message });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { sent, errors };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatPayload(kind, { title, body, level }) {
|
|
41
|
+
const text = `*${title}*\n${body}`;
|
|
42
|
+
if (kind === 'slack') return { text };
|
|
43
|
+
if (kind === 'teams') {
|
|
44
|
+
// Teams legacy connector card.
|
|
45
|
+
return {
|
|
46
|
+
'@type': 'MessageCard',
|
|
47
|
+
'@context': 'https://schema.org/extensions',
|
|
48
|
+
summary: title,
|
|
49
|
+
themeColor: level === 'error' ? 'D32F2F' : level === 'warn' ? 'F9A825' : '2E7D32',
|
|
50
|
+
title,
|
|
51
|
+
text: body,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Generic: a plain JSON envelope.
|
|
55
|
+
return { source: 'openspecpm', title, body, level, ts: new Date().toISOString() };
|
|
56
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const OPENSPEC_MIN_VERSION = '0.4.0';
|
|
6
|
+
export const OPENSPEC_CHANGES_DIR = 'openspec/changes';
|
|
7
|
+
export const OPENSPEC_ARCHIVE_DIR = 'openspec/archive';
|
|
8
|
+
|
|
9
|
+
export class OpenSpecError extends Error {
|
|
10
|
+
constructor(message, { remediation } = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'OpenSpecError';
|
|
13
|
+
this.remediation = remediation;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function compareSemver(a, b) {
|
|
18
|
+
const pa = a.split('.').map((n) => parseInt(n, 10));
|
|
19
|
+
const pb = b.split('.').map((n) => parseInt(n, 10));
|
|
20
|
+
for (let i = 0; i < 3; i++) {
|
|
21
|
+
const da = pa[i] ?? 0;
|
|
22
|
+
const db = pb[i] ?? 0;
|
|
23
|
+
if (da !== db) return da - db;
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function probe({ runner = execa } = {}) {
|
|
29
|
+
let stdout;
|
|
30
|
+
try {
|
|
31
|
+
({ stdout } = await runner('openspec', ['--version']));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new OpenSpecError('OpenSpec CLI not found on PATH.', {
|
|
34
|
+
remediation: 'Install it with `npm install -g @fission-ai/openspec`, then re-run.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const m = stdout.match(/(\d+\.\d+\.\d+)/);
|
|
38
|
+
if (!m) {
|
|
39
|
+
throw new OpenSpecError(`Could not parse OpenSpec version from: ${stdout}`, {
|
|
40
|
+
remediation: 'Upgrade OpenSpec to a version that supports `openspec --version`.',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const version = m[1];
|
|
44
|
+
if (compareSemver(version, OPENSPEC_MIN_VERSION) < 0) {
|
|
45
|
+
throw new OpenSpecError(`OpenSpec ${version} is below the required minimum ${OPENSPEC_MIN_VERSION}.`, {
|
|
46
|
+
remediation: `Upgrade with \`npm install -g @fission-ai/openspec@>=${OPENSPEC_MIN_VERSION}\`.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return { version };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function changeDir(feature, cwd = process.cwd()) {
|
|
53
|
+
return join(cwd, OPENSPEC_CHANGES_DIR, feature);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function changeExists(feature, cwd = process.cwd()) {
|
|
57
|
+
return existsSync(changeDir(feature, cwd));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function propose(feature, prompt, { runner = execa, cwd = process.cwd() } = {}) {
|
|
61
|
+
await probe({ runner });
|
|
62
|
+
await runner('openspec', ['propose', feature, '--prompt', prompt], { cwd, stdio: 'inherit' });
|
|
63
|
+
return changeDir(feature, cwd);
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class TokenBucket {
|
|
2
|
+
#capacity;
|
|
3
|
+
#refillPerSec;
|
|
4
|
+
#tokens;
|
|
5
|
+
#last;
|
|
6
|
+
|
|
7
|
+
constructor({ capacity, refillPerSec }) {
|
|
8
|
+
if (!Number.isFinite(capacity) || capacity <= 0) throw new Error('capacity must be positive');
|
|
9
|
+
if (!Number.isFinite(refillPerSec) || refillPerSec <= 0) throw new Error('refillPerSec must be positive');
|
|
10
|
+
this.#capacity = capacity;
|
|
11
|
+
this.#refillPerSec = refillPerSec;
|
|
12
|
+
this.#tokens = capacity;
|
|
13
|
+
this.#last = Date.now();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#refill() {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const elapsed = (now - this.#last) / 1000;
|
|
19
|
+
this.#tokens = Math.min(this.#capacity, this.#tokens + elapsed * this.#refillPerSec);
|
|
20
|
+
this.#last = now;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
tryTake(cost = 1) {
|
|
24
|
+
this.#refill();
|
|
25
|
+
if (this.#tokens >= cost) {
|
|
26
|
+
this.#tokens -= cost;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async take(cost = 1) {
|
|
33
|
+
while (!this.tryTake(cost)) {
|
|
34
|
+
const deficit = cost - this.#tokens;
|
|
35
|
+
const waitMs = Math.max(50, Math.ceil((deficit / this.#refillPerSec) * 1000));
|
|
36
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get tokens() {
|
|
41
|
+
this.#refill();
|
|
42
|
+
return this.#tokens;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const PRESETS = {
|
|
47
|
+
github: { capacity: 30, refillPerSec: 1.3 },
|
|
48
|
+
ado: { capacity: 20, refillPerSec: 0.5 },
|
|
49
|
+
jira: { capacity: 10, refillPerSec: 0.3 },
|
|
50
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry for OpenSpecPM.
|
|
3
|
+
*
|
|
4
|
+
* Off by default. Users opt in via:
|
|
5
|
+
* .openspecpm/config.json:
|
|
6
|
+
* { "telemetry": { "enabled": true } }
|
|
7
|
+
*
|
|
8
|
+
* In alpha, this module records to the audit log only and never sends anything
|
|
9
|
+
* over the network — the future endpoint isn't built yet, but the opt-in flow
|
|
10
|
+
* and the data shape are. Setting OPENSPECPM_TELEMETRY_DRY=1 forces dry mode
|
|
11
|
+
* even if config has enabled=true.
|
|
12
|
+
*
|
|
13
|
+
* What we'd send if enabled: command name, success/fail, duration_ms, OS,
|
|
14
|
+
* Node version. Never: feature names, task titles, repo identifiers, tokens,
|
|
15
|
+
* or any user-authored content.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { record } from './audit.js';
|
|
19
|
+
|
|
20
|
+
export function isEnabled(config) {
|
|
21
|
+
if (process.env.OPENSPECPM_TELEMETRY_DRY === '1') return false;
|
|
22
|
+
return Boolean(config?.telemetry?.enabled);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function track(event, config) {
|
|
26
|
+
if (!isEnabled(config)) return;
|
|
27
|
+
// In alpha: only mirror to the audit log so users can inspect what *would*
|
|
28
|
+
// be sent. No network calls.
|
|
29
|
+
try {
|
|
30
|
+
await record({
|
|
31
|
+
command: '__telemetry__',
|
|
32
|
+
args: scrub(event),
|
|
33
|
+
result: 'mirrored-only-no-network-in-alpha',
|
|
34
|
+
});
|
|
35
|
+
} catch { /* never raise */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function scrub(event) {
|
|
39
|
+
if (!event || typeof event !== 'object') return event;
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const k of ['name', 'success', 'duration_ms', 'adapter', 'node', 'os']) {
|
|
42
|
+
if (event[k] !== undefined) out[k] = event[k];
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|