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.
Files changed (51) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +352 -0
  4. package/cli/bin/openspecpm.js +198 -0
  5. package/cli/src/adapters/azure.js +230 -0
  6. package/cli/src/adapters/base.js +86 -0
  7. package/cli/src/adapters/github.js +224 -0
  8. package/cli/src/adapters/gitlab.js +170 -0
  9. package/cli/src/adapters/index.js +54 -0
  10. package/cli/src/adapters/jira.js +228 -0
  11. package/cli/src/adapters/linear.js +261 -0
  12. package/cli/src/audit.js +78 -0
  13. package/cli/src/bdd/linter.js +183 -0
  14. package/cli/src/bdd/templates.js +172 -0
  15. package/cli/src/commands/assign.js +78 -0
  16. package/cli/src/commands/blocked.js +17 -0
  17. package/cli/src/commands/bug-report.js +78 -0
  18. package/cli/src/commands/bulk.js +67 -0
  19. package/cli/src/commands/comment.js +83 -0
  20. package/cli/src/commands/decompose.js +106 -0
  21. package/cli/src/commands/doctor.js +61 -0
  22. package/cli/src/commands/fan-out.js +79 -0
  23. package/cli/src/commands/help.js +69 -0
  24. package/cli/src/commands/init.js +111 -0
  25. package/cli/src/commands/next.js +18 -0
  26. package/cli/src/commands/propose.js +67 -0
  27. package/cli/src/commands/reconcile.js +74 -0
  28. package/cli/src/commands/search.js +52 -0
  29. package/cli/src/commands/ship.js +79 -0
  30. package/cli/src/commands/standup.js +42 -0
  31. package/cli/src/commands/status.js +29 -0
  32. package/cli/src/commands/sync.js +128 -0
  33. package/cli/src/commands/validate.js +79 -0
  34. package/cli/src/commands/watch.js +67 -0
  35. package/cli/src/config.js +43 -0
  36. package/cli/src/frontmatter.js +22 -0
  37. package/cli/src/http.js +92 -0
  38. package/cli/src/install-hints.js +44 -0
  39. package/cli/src/notify.js +56 -0
  40. package/cli/src/openspec-bridge.js +64 -0
  41. package/cli/src/ratelimit.js +50 -0
  42. package/cli/src/telemetry.js +45 -0
  43. package/cli/src/tracking.js +197 -0
  44. package/package.json +60 -0
  45. package/skill/openspecpm/SKILL.md +74 -0
  46. package/skill/openspecpm/references/conventions.md +105 -0
  47. package/skill/openspecpm/references/execute.md +62 -0
  48. package/skill/openspecpm/references/plan.md +47 -0
  49. package/skill/openspecpm/references/structure.md +52 -0
  50. package/skill/openspecpm/references/sync.md +56 -0
  51. package/skill/openspecpm/references/track.md +55 -0
@@ -0,0 +1,111 @@
1
+ import * as p from '@clack/prompts';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { writeConfig, readConfig } from '../config.js';
5
+ import { listAdapters } from '../adapters/index.js';
6
+
7
+ export async function runInit({ nonInteractive = false } = {}) {
8
+ const existing = await readConfig();
9
+ if (existing && nonInteractive) {
10
+ p.note(`Config already exists at .openspecpm/config.json (adapter: ${existing.adapter}).`, 'init');
11
+ return existing;
12
+ }
13
+
14
+ p.intro('openspecpm init — pick your PM tool');
15
+
16
+ // Detect existing OpenSpec setup (brownfield).
17
+ const openspecDir = join(process.cwd(), 'openspec');
18
+ if (existsSync(openspecDir)) {
19
+ p.note(`Detected existing openspec/ — will not re-run \`openspec init\`. Existing proposals will be reused.`, 'brownfield');
20
+ }
21
+
22
+ if (existing) {
23
+ const overwrite = await p.confirm({
24
+ message: `A config already exists (adapter: ${existing.adapter}). Overwrite?`,
25
+ initialValue: false,
26
+ });
27
+ if (p.isCancel(overwrite) || !overwrite) {
28
+ p.cancel('Keeping existing config.');
29
+ return existing;
30
+ }
31
+ }
32
+
33
+ const adapter = await p.select({
34
+ message: 'Which PM tool does your team use?',
35
+ options: [
36
+ { value: 'github', label: 'GitHub Issues / Projects', hint: 'Stable (gh CLI)' },
37
+ { value: 'azure', label: 'Azure DevOps Boards', hint: 'Beta — REST + PAT' },
38
+ { value: 'jira', label: 'Jira', hint: 'Beta — REST + API token' },
39
+ { value: 'linear', label: 'Linear', hint: 'Beta — GraphQL + Personal API Key' },
40
+ { value: 'gitlab', label: 'GitLab Issues', hint: 'Beta — REST v4 + PAT' },
41
+ ],
42
+ });
43
+ if (p.isCancel(adapter)) return cancelled();
44
+ if (!listAdapters().includes(adapter)) return cancelled();
45
+
46
+ const config = { adapter };
47
+
48
+ if (adapter === 'github') {
49
+ const repo = await p.text({
50
+ message: 'GitHub repo (owner/name)',
51
+ placeholder: 'aks-builds/openspecpm',
52
+ validate: (v) => (/^[^/]+\/[^/]+$/.test(v ?? '') ? undefined : 'Format must be owner/name'),
53
+ });
54
+ if (p.isCancel(repo)) return cancelled();
55
+ config.repo = repo;
56
+ } else if (adapter === 'azure') {
57
+ const organization = await p.text({ message: 'Azure DevOps organization', placeholder: 'contoso' });
58
+ if (p.isCancel(organization)) return cancelled();
59
+ const project = await p.text({ message: 'Project name', placeholder: 'MyProject' });
60
+ if (p.isCancel(project)) return cancelled();
61
+ config.organization = organization;
62
+ config.project = project;
63
+ p.note('Set AZURE_DEVOPS_EXT_PAT in your environment with Work Items (Read/Write) scope.', 'azure auth');
64
+ } else if (adapter === 'jira') {
65
+ const baseUrl = await p.text({
66
+ message: 'Jira base URL',
67
+ placeholder: 'https://yourorg.atlassian.net',
68
+ validate: (v) => (/^https?:\/\//.test(v ?? '') ? undefined : 'Must be an http(s) URL'),
69
+ });
70
+ if (p.isCancel(baseUrl)) return cancelled();
71
+ const projectKey = await p.text({ message: 'Project key', placeholder: 'PROJ' });
72
+ if (p.isCancel(projectKey)) return cancelled();
73
+ config.baseUrl = baseUrl;
74
+ config.projectKey = projectKey;
75
+ p.note('Set JIRA_EMAIL and JIRA_API_TOKEN in your environment.', 'jira auth');
76
+ } else if (adapter === 'linear') {
77
+ const teamId = await p.text({
78
+ message: 'Linear team ID (UUID from team settings URL)',
79
+ placeholder: '12345678-90ab-cdef-1234-567890abcdef',
80
+ validate: (v) => (v && v.length >= 4 ? undefined : 'Team ID is required'),
81
+ });
82
+ if (p.isCancel(teamId)) return cancelled();
83
+ config.teamId = teamId;
84
+ p.note('Set LINEAR_API_KEY in your environment (create at linear.app/settings/api).', 'linear auth');
85
+ } else if (adapter === 'gitlab') {
86
+ const baseUrl = await p.text({
87
+ message: 'GitLab base URL',
88
+ placeholder: 'https://gitlab.com',
89
+ validate: (v) => (/^https?:\/\//.test(v ?? '') ? undefined : 'Must be an http(s) URL'),
90
+ });
91
+ if (p.isCancel(baseUrl)) return cancelled();
92
+ const projectId = await p.text({
93
+ message: 'Project ID (numeric or URL-encoded "group/repo")',
94
+ placeholder: '12345 or mygroup%2Fmyrepo',
95
+ validate: (v) => (v ? undefined : 'Project ID is required'),
96
+ });
97
+ if (p.isCancel(projectId)) return cancelled();
98
+ config.baseUrl = baseUrl;
99
+ config.projectId = projectId;
100
+ p.note('Set GITLAB_TOKEN with `api` scope in your environment.', 'gitlab auth');
101
+ }
102
+
103
+ const path = await writeConfig(config);
104
+ p.outro(`Wrote ${path}. Next: run \`openspecpm doctor ${adapter}\` to verify auth.`);
105
+ return config;
106
+ }
107
+
108
+ function cancelled() {
109
+ p.cancel('Aborted.');
110
+ return null;
111
+ }
@@ -0,0 +1,18 @@
1
+ import { listChanges, findNextTasks } from '../tracking.js';
2
+
3
+ export async function runNext({ limit = 5 } = {}) {
4
+ const changes = await listChanges();
5
+ const candidates = findNextTasks(changes);
6
+ out(`openspecpm next — ${candidates.length} task(s) ready to start\n`);
7
+ if (!candidates.length) return;
8
+ for (const { change, task } of candidates.slice(0, limit)) {
9
+ const tag = task.external_id ? ` [${task.external_id}]` : ` [unsynced]`;
10
+ const parallel = task.parallel ? ' parallel' : '';
11
+ out(` • ${change} — ${task.title}${tag}${parallel}`);
12
+ }
13
+ if (candidates.length > limit) out(` …and ${candidates.length - limit} more.`);
14
+ }
15
+
16
+ function out(s) {
17
+ process.stdout.write(s + '\n');
18
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { propose, changeExists, changeDir, OpenSpecError } from '../openspec-bridge.js';
5
+ import { lintChange, summarize, formatFindings } from '../bdd/linter.js';
6
+ import { CHANGE_TYPES, proposalTemplate, specsTemplate, STARTER_TASKS } from '../bdd/templates.js';
7
+
8
+ export async function runPropose({ feature, prompt, type = 'feature', offline = false } = {}) {
9
+ if (!feature) throw new Error('feature name is required');
10
+ if (!CHANGE_TYPES.includes(type)) {
11
+ const err = new Error(`Unknown change type "${type}".`);
12
+ err.remediation = `Use one of: ${CHANGE_TYPES.join(', ')}`;
13
+ throw err;
14
+ }
15
+
16
+ if (changeExists(feature)) {
17
+ process.stdout.write(`Change "${feature}" already exists at ${changeDir(feature)}. Skipping propose.\n`);
18
+ await softLint(changeDir(feature));
19
+ return changeDir(feature);
20
+ }
21
+
22
+ if (offline) {
23
+ const dir = await scaffoldOffline(feature, type);
24
+ process.stdout.write(`\nProposal scaffolded offline at ${dir} (type=${type}).\n`);
25
+ await softLint(dir);
26
+ process.stdout.write(`Next: refine the templates, then run \`openspecpm sync ${feature}\`.\n`);
27
+ return dir;
28
+ }
29
+
30
+ const seed = prompt ?? `Author a BDD-shaped ${type} proposal for ${feature}. Use Given/When/Then scenarios in specs/.`;
31
+ try {
32
+ const dir = await propose(feature, seed);
33
+ process.stdout.write(`\nProposal created at ${dir}.\n`);
34
+ await softLint(dir);
35
+ process.stdout.write(`Next: review proposal.md + specs/, then run \`openspecpm sync ${feature}\`.\n`);
36
+ return dir;
37
+ } catch (err) {
38
+ if (err instanceof OpenSpecError) throw err;
39
+ throw new OpenSpecError(`openspec propose failed: ${err.message}`, {
40
+ remediation: 'Run `openspecpm doctor` to verify OpenSpec is installed, or pass --offline to scaffold from a template.',
41
+ });
42
+ }
43
+ }
44
+
45
+ async function scaffoldOffline(feature, type) {
46
+ const dir = changeDir(feature);
47
+ await mkdir(join(dir, 'specs'), { recursive: true });
48
+ if (!existsSync(join(dir, 'proposal.md'))) {
49
+ await writeFile(join(dir, 'proposal.md'), proposalTemplate(type, feature), 'utf8');
50
+ }
51
+ if (!existsSync(join(dir, 'tasks.md'))) {
52
+ await writeFile(join(dir, 'tasks.md'), STARTER_TASKS, 'utf8');
53
+ }
54
+ if (!existsSync(join(dir, 'specs', 'main.md'))) {
55
+ await writeFile(join(dir, 'specs', 'main.md'), specsTemplate(type), 'utf8');
56
+ }
57
+ return dir;
58
+ }
59
+
60
+ async function softLint(dir) { // eslint-disable-line
61
+ const findings = await lintChange(dir);
62
+ const sum = summarize(findings);
63
+ if (!sum.total) return;
64
+ process.stdout.write(`\nBDD lint (soft): ${sum.errors} errors, ${sum.warnings} warnings\n`);
65
+ process.stdout.write(formatFindings(findings));
66
+ process.stdout.write('These will block `sync` unless you pass --force. Refine scenarios before pushing.\n');
67
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readConfig } from '../config.js';
4
+ import { loadAdapter } from '../adapters/index.js';
5
+ import { changeDir, changeExists } from '../openspec-bridge.js';
6
+ import * as fm from '../frontmatter.js';
7
+
8
+ export async function runReconcile({ feature, dryRun = false } = {}) {
9
+ if (!feature) throw new Error('feature name is required');
10
+ const config = await readConfig();
11
+ if (!config) {
12
+ const err = new Error('No .openspecpm/config.json found.');
13
+ err.remediation = 'Run `openspecpm init` first.';
14
+ throw err;
15
+ }
16
+ if (!changeExists(feature)) throw new Error(`OpenSpec change "${feature}" not found.`);
17
+
18
+ const dir = changeDir(feature);
19
+ const tasksPath = join(dir, 'tasks.md');
20
+ let tasksRaw = '';
21
+ try { tasksRaw = await readFile(tasksPath, 'utf8'); } catch { /* missing */ }
22
+ const { data: tdata, body: tbody } = fm.parse(tasksRaw);
23
+ const items = tdata.items ?? [];
24
+ if (!items.length) {
25
+ process.stdout.write('No items in tasks.md to reconcile.\n');
26
+ return;
27
+ }
28
+
29
+ const adapter = loadAdapter(config.adapter, config);
30
+ await adapter.init();
31
+
32
+ let drift = 0;
33
+ const updated = [];
34
+ for (const task of items) {
35
+ if (!task.external_id) {
36
+ updated.push(task);
37
+ continue;
38
+ }
39
+ try {
40
+ const view = await adapter.getWorkItem({ adapter: config.adapter, id: task.external_id });
41
+ const remoteClosed = view.status === 'closed';
42
+ const next = { ...task };
43
+ if (remoteClosed && !task.closed) {
44
+ next.closed = true;
45
+ next.done = true;
46
+ drift++;
47
+ process.stdout.write(`! ${task.title} [${task.external_id}] closed remotely — marking done locally\n`);
48
+ } else if (!remoteClosed && task.closed) {
49
+ next.closed = false;
50
+ next.done = false;
51
+ drift++;
52
+ process.stdout.write(`! ${task.title} [${task.external_id}] re-opened remotely — clearing local closed flag\n`);
53
+ }
54
+ next.remote_status = view.status;
55
+ next.remote_assignee = view.assignee ?? null;
56
+ updated.push(next);
57
+ } catch (err) {
58
+ process.stdout.write(`✖ ${task.title} [${task.external_id}] — ${err.message}\n`);
59
+ updated.push({ ...task, last_reconcile_error: err.message });
60
+ }
61
+ }
62
+
63
+ if (dryRun) {
64
+ process.stdout.write(`\n[dry-run] ${drift} item(s) would be updated locally.\n`);
65
+ return;
66
+ }
67
+ if (!drift && !updated.some((t, i) => t.remote_status !== items[i].remote_status)) {
68
+ process.stdout.write('Already in sync.\n');
69
+ return;
70
+ }
71
+ const patched = fm.serialize({ ...tdata, items: updated }, tbody);
72
+ await writeFile(tasksPath, patched, 'utf8');
73
+ process.stdout.write(`\n✓ Reconciled ${drift} drifted item(s); remote_status mirrored on every synced task.\n`);
74
+ }
@@ -0,0 +1,52 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { OPENSPEC_CHANGES_DIR } from '../openspec-bridge.js';
5
+
6
+ export async function runSearch({ query, limit = 50, caseSensitive = false } = {}) {
7
+ if (!query) throw new Error('query is required');
8
+ const flags = caseSensitive ? '' : 'i';
9
+ const re = new RegExp(escapeRegex(query), flags);
10
+ const root = process.cwd();
11
+ const dir = join(root, OPENSPEC_CHANGES_DIR);
12
+ if (!existsSync(dir)) {
13
+ process.stdout.write('No OpenSpec changes to search.\n');
14
+ return;
15
+ }
16
+ const hits = [];
17
+ await walk(dir, async (path) => {
18
+ if (!/\.md$/i.test(path)) return;
19
+ const lines = (await readFile(path, 'utf8')).split(/\r?\n/);
20
+ for (let i = 0; i < lines.length; i++) {
21
+ if (re.test(lines[i])) {
22
+ hits.push({ path: relative(root, path), line: i + 1, text: lines[i].trim().slice(0, 200) });
23
+ if (hits.length >= limit * 2) return;
24
+ }
25
+ }
26
+ });
27
+ if (!hits.length) {
28
+ process.stdout.write(`No matches for /${query}/${flags}.\n`);
29
+ return;
30
+ }
31
+ for (const h of hits.slice(0, limit)) {
32
+ process.stdout.write(`${h.path}:${h.line}: ${h.text}\n`);
33
+ }
34
+ if (hits.length > limit) process.stdout.write(`…and ${hits.length - limit} more.\n`);
35
+ }
36
+
37
+ async function walk(dir, fn) {
38
+ let entries;
39
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
40
+ for (const e of entries) {
41
+ const full = join(dir, e.name);
42
+ if (e.isDirectory()) {
43
+ await walk(full, fn);
44
+ } else if (e.isFile()) {
45
+ await fn(full);
46
+ }
47
+ }
48
+ }
49
+
50
+ function escapeRegex(s) {
51
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ }
@@ -0,0 +1,79 @@
1
+ import * as p from '@clack/prompts';
2
+ import { execa } from 'execa';
3
+ import { readConfig } from '../config.js';
4
+ import { loadAdapter } from '../adapters/index.js';
5
+ import { loadChange } from '../tracking.js';
6
+ import { changeExists } from '../openspec-bridge.js';
7
+
8
+ export async function runShip({ feature, yes = false, skipArchive = false } = {}) {
9
+ if (!feature) throw new Error('feature name is required');
10
+ const config = await readConfig();
11
+ if (!config) {
12
+ const err = new Error('No .openspecpm/config.json found.');
13
+ err.remediation = 'Run `openspecpm init` first.';
14
+ throw err;
15
+ }
16
+ if (!changeExists(feature)) {
17
+ const err = new Error(`OpenSpec change "${feature}" not found.`);
18
+ err.remediation = `Did you mean another feature? Run \`openspecpm status\`.`;
19
+ throw err;
20
+ }
21
+
22
+ const change = await loadChange(feature);
23
+ const epicRef = change.proposal.external?.[config.adapter];
24
+ const synced = change.items.filter((t) => t.sync_state === 'created' && t.external_id);
25
+ const open = synced.filter((t) => !t.done && !t.closed);
26
+
27
+ p.intro(`openspecpm ship ${feature}`);
28
+ p.note(
29
+ `Adapter: ${config.adapter}\nEpic: ${epicRef ? `${epicRef.id} (${epicRef.url ?? '?'})` : 'unsynced'}\nWork items: ${synced.length} total, ${open.length} still open`,
30
+ 'plan',
31
+ );
32
+
33
+ if (!yes) {
34
+ const ok = await p.confirm({
35
+ message: `Close ${open.length} open work item(s) and ${epicRef ? 'the epic' : '(no epic)'}, then ${skipArchive ? 'leave the OpenSpec change in place' : 'archive the OpenSpec change'}?`,
36
+ initialValue: false,
37
+ });
38
+ if (p.isCancel(ok) || !ok) {
39
+ p.cancel('Ship aborted.');
40
+ return;
41
+ }
42
+ }
43
+
44
+ const adapter = loadAdapter(config.adapter, config);
45
+ await adapter.init();
46
+
47
+ for (const t of open) {
48
+ try {
49
+ await adapter.closeWorkItem({ adapter: config.adapter, id: t.external_id }, `Shipped via openspecpm ship ${feature}`);
50
+ out(`✓ closed ${t.external_id} — ${t.title}`);
51
+ } catch (err) {
52
+ out(`✖ failed to close ${t.external_id}: ${err.message}`);
53
+ }
54
+ }
55
+
56
+ if (epicRef) {
57
+ try {
58
+ await adapter.closeWorkItem({ adapter: config.adapter, id: epicRef.id }, `Shipped: all tasks closed for ${feature}.`);
59
+ out(`✓ closed epic ${epicRef.id}`);
60
+ } catch (err) {
61
+ out(`✖ failed to close epic: ${err.message}`);
62
+ }
63
+ }
64
+
65
+ if (!skipArchive) {
66
+ try {
67
+ await execa('openspec', ['archive', feature], { stdio: 'inherit' });
68
+ out(`✓ archived ${feature}`);
69
+ } catch (err) {
70
+ out(`! openspec archive failed: ${err.message}. The change folder still exists at openspec/changes/${feature}/.`);
71
+ }
72
+ }
73
+
74
+ p.outro(`Shipped ${feature}.`);
75
+ }
76
+
77
+ function out(s) {
78
+ process.stdout.write(s + '\n');
79
+ }
@@ -0,0 +1,42 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { listChanges, findRecentUpdates } from '../tracking.js';
3
+ import { readConfig } from '../config.js';
4
+ import { notify } from '../notify.js';
5
+
6
+ export async function runStandup({ since = '24h', broadcast = false } = {}) {
7
+ const sinceMs = parseWindow(since);
8
+ const changes = await listChanges();
9
+ const recent = await findRecentUpdates(changes, sinceMs);
10
+ const lines = [`openspecpm standup — last ${since}`];
11
+ if (!recent.length) {
12
+ lines.push('No progress updates in window.');
13
+ process.stdout.write(lines.join('\n') + '\n');
14
+ return;
15
+ }
16
+ let lastChange = null;
17
+ for (const r of recent) {
18
+ if (r.change !== lastChange) {
19
+ lines.push(`\n[${r.change}]`);
20
+ lastChange = r.change;
21
+ }
22
+ const snippet = (await readFile(r.path, 'utf8')).split(/\r?\n/).slice(0, 5).join(' ').slice(0, 240);
23
+ lines.push(` ${r.mtime.toISOString().slice(0, 16).replace('T', ' ')} ${r.task}: ${snippet}`);
24
+ }
25
+ const text = lines.join('\n');
26
+ process.stdout.write(text + '\n');
27
+
28
+ if (broadcast) {
29
+ const config = await readConfig();
30
+ const r = await notify({ config, title: `OpenSpecPM standup (${since})`, body: text });
31
+ process.stdout.write(`\nBroadcast: ${r.sent} target(s)` + (r.errors.length ? `, ${r.errors.length} error(s)` : '') + '\n');
32
+ for (const e of r.errors) process.stdout.write(` ✖ ${e.target}: ${e.error}\n`);
33
+ }
34
+ }
35
+
36
+ function parseWindow(s) {
37
+ const m = String(s).match(/^(\d+)([hdw])$/i);
38
+ if (!m) return 24 * 60 * 60 * 1000;
39
+ const n = parseInt(m[1], 10);
40
+ const unit = m[2].toLowerCase();
41
+ return n * (unit === 'h' ? 3600e3 : unit === 'd' ? 86400e3 : 7 * 86400e3);
42
+ }
@@ -0,0 +1,29 @@
1
+ import { readConfig } from '../config.js';
2
+ import { listChanges, summarizeChange } from '../tracking.js';
3
+
4
+ export async function runStatus() {
5
+ const config = await readConfig();
6
+ out('openspecpm status\n');
7
+ out(`Adapter: ${config?.adapter ?? '(not configured — run `openspecpm init`)'}`);
8
+ if (config?.repo) out(`Repo: ${config.repo}`);
9
+ if (config?.organization) out(`Org: ${config.organization} / ${config.project}`);
10
+ if (config?.baseUrl && config?.projectKey) out(`Jira: ${config.baseUrl} / ${config.projectKey}`);
11
+
12
+ const changes = await listChanges();
13
+ out(`\nChanges: ${changes.length}`);
14
+ if (!changes.length) {
15
+ out(' (no OpenSpec changes yet — run `openspecpm propose <feature>`)');
16
+ return;
17
+ }
18
+ for (const c of changes) {
19
+ const { total, counts } = summarizeChange(c);
20
+ const flags = [];
21
+ if (c.proposal.status) flags.push(c.proposal.status);
22
+ if (c.proposal.external) flags.push('synced');
23
+ out(` - ${c.name} (${total} tasks: ${counts.created} synced, ${counts.pending} pending, ${counts.failed} failed, ${counts.done} done)` + (flags.length ? ` [${flags.join(', ')}]` : ''));
24
+ }
25
+ }
26
+
27
+ function out(s) {
28
+ process.stdout.write(s + '\n');
29
+ }
@@ -0,0 +1,128 @@
1
+ import { readFile, writeFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { readConfig } from '../config.js';
5
+ import { loadAdapter } from '../adapters/index.js';
6
+ import { changeDir, changeExists } from '../openspec-bridge.js';
7
+ import { lintChange, summarize, formatFindings } from '../bdd/linter.js';
8
+ import * as fm from '../frontmatter.js';
9
+
10
+ export async function runSync({ feature, dryRun = false, force = false, diff = false } = {}) {
11
+ if (!feature) throw new Error('feature name is required');
12
+ const config = await readConfig();
13
+ if (!config) {
14
+ const err = new Error('No .openspecpm/config.json found.');
15
+ err.remediation = 'Run `openspecpm init` first.';
16
+ throw err;
17
+ }
18
+ if (!changeExists(feature)) {
19
+ const err = new Error(`OpenSpec change "${feature}" not found.`);
20
+ err.remediation = `Run \`openspecpm propose ${feature}\` first.`;
21
+ throw err;
22
+ }
23
+
24
+ const dir = changeDir(feature);
25
+ const findings = await lintChange(dir);
26
+ const sum = summarize(findings);
27
+ if (sum.errors > 0 && !force) {
28
+ process.stderr.write(`BDD lint: ${sum.errors} errors, ${sum.warnings} warnings\n`);
29
+ process.stderr.write(formatFindings(findings));
30
+ const err = new Error(`Sync blocked by ${sum.errors} BDD lint errors.`);
31
+ err.remediation = 'Refine the scenarios, or pass --force to override.';
32
+ throw err;
33
+ }
34
+ if (sum.total > 0) {
35
+ process.stdout.write(`BDD lint: ${sum.errors} errors, ${sum.warnings} warnings (${force && sum.errors ? 'overridden by --force' : 'soft'})\n`);
36
+ process.stdout.write(formatFindings(findings));
37
+ }
38
+
39
+ const adapter = loadAdapter(config.adapter, config);
40
+ if (!dryRun) await adapter.init();
41
+
42
+ if (diff) {
43
+ out(`\n[diff] adapter: ${config.adapter}`);
44
+ out(`[diff] hierarchy depth: ${adapter.capabilities().hierarchyDepth}`);
45
+ }
46
+
47
+ const proposalPath = join(dir, 'proposal.md');
48
+ const proposalRaw = existsSync(proposalPath) ? await readFile(proposalPath, 'utf8') : '';
49
+ const { data: pdata } = fm.parse(proposalRaw);
50
+
51
+ // Idempotency: re-use existing epic ref if present.
52
+ let epicRef = pdata.external?.[config.adapter];
53
+ const featureSpec = { name: feature, summary: extractSummary(proposalRaw) || `OpenSpec change: ${feature}` };
54
+
55
+ if (!epicRef) {
56
+ if (dryRun) {
57
+ out(`[dry-run] would create epic for "${feature}" via ${config.adapter}`);
58
+ epicRef = { adapter: config.adapter, id: '<new>', url: '<new>' };
59
+ } else {
60
+ epicRef = await adapter.createEpic(featureSpec);
61
+ const patched = fm.patch(proposalRaw, {
62
+ ...pdata,
63
+ external: { ...(pdata.external ?? {}), [config.adapter]: epicRef },
64
+ });
65
+ await writeFile(proposalPath, patched, 'utf8');
66
+ out(`Created epic ${epicRef.url ?? epicRef.id}`);
67
+ }
68
+ } else {
69
+ out(`Epic already synced: ${epicRef.url ?? epicRef.id} (skipping)`);
70
+ }
71
+
72
+ // Walk tasks. OpenSpec stores them in tasks.md (a checklist) and/or specs/*.md.
73
+ // Sprint 1: read tasks.md as a flat list "- [ ] title" entries; create one work item per unchecked task.
74
+ const tasksPath = join(dir, 'tasks.md');
75
+ if (!existsSync(tasksPath)) {
76
+ out('No tasks.md found — only the epic was synced.');
77
+ return;
78
+ }
79
+ const tasksRaw = await readFile(tasksPath, 'utf8');
80
+ const { data: tdata, body: tbody } = fm.parse(tasksRaw);
81
+ const items = tdata.items ?? parseChecklist(tbody);
82
+ const updatedItems = [];
83
+
84
+ for (const task of items) {
85
+ if (task.sync_state === 'created' && task.external_id) {
86
+ out(`✓ ${task.title} (already created: ${task.external_id})`);
87
+ updatedItems.push(task);
88
+ continue;
89
+ }
90
+ if (dryRun) {
91
+ out(`[dry-run] would create work item: ${task.title}`);
92
+ updatedItems.push({ ...task, sync_state: 'pending' });
93
+ continue;
94
+ }
95
+ try {
96
+ const ref = await adapter.createWorkItem({ ...epicRef, feature }, { title: task.title, body: task.body ?? '' });
97
+ out(`+ ${task.title} → ${ref.url ?? ref.id}`);
98
+ updatedItems.push({ ...task, sync_state: 'created', external_id: ref.id, external_url: ref.url });
99
+ } catch (err) {
100
+ out(`✖ ${task.title} — ${err.message}`);
101
+ updatedItems.push({ ...task, sync_state: 'failed', last_error: err.message });
102
+ }
103
+ }
104
+
105
+ if (!dryRun) {
106
+ const patched = fm.serialize({ ...tdata, items: updatedItems }, tbody);
107
+ await writeFile(tasksPath, patched, 'utf8');
108
+ }
109
+ }
110
+
111
+ function out(s) {
112
+ process.stdout.write(s + '\n');
113
+ }
114
+
115
+ function extractSummary(md) {
116
+ const { body } = fm.parse(md);
117
+ const firstPara = body.split(/\r?\n\r?\n/).find((p) => p.trim() && !p.startsWith('#'));
118
+ return (firstPara ?? '').trim().slice(0, 1000);
119
+ }
120
+
121
+ function parseChecklist(body) {
122
+ const items = [];
123
+ for (const line of body.split(/\r?\n/)) {
124
+ const m = line.match(/^\s*-\s*\[( |x|X)\]\s+(.+?)\s*$/);
125
+ if (m) items.push({ title: m[2], done: m[1].toLowerCase() === 'x', sync_state: 'pending' });
126
+ }
127
+ return items;
128
+ }