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,78 @@
1
+ import { readConfig } from '../config.js';
2
+ import { loadAdapter } from '../adapters/index.js';
3
+ import { loadChange } from '../tracking.js';
4
+ import { changeExists } from '../openspec-bridge.js';
5
+
6
+ /**
7
+ * Generic "set fields on this work item" command. Wraps adapter.updateWorkItem
8
+ * so users don't have to know per-adapter field names.
9
+ *
10
+ * Examples:
11
+ * openspecpm assign demo "Add toggle" --assignee alice
12
+ * openspecpm assign demo "Add toggle" --sprint S5 --story-points 3
13
+ * openspecpm assign demo "Add toggle" --iteration Sprint-12 --area Web/Mobile
14
+ */
15
+ export async function runAssign({
16
+ feature,
17
+ task: taskRef,
18
+ assignee,
19
+ sprint,
20
+ iteration,
21
+ area,
22
+ storyPoints,
23
+ } = {}) {
24
+ if (!feature) throw new Error('feature name is required');
25
+ if (!taskRef) throw new Error('task title or external_id is required');
26
+ const config = await readConfig();
27
+ if (!config) {
28
+ const err = new Error('No .openspecpm/config.json found.');
29
+ err.remediation = 'Run `openspecpm init` first.';
30
+ throw err;
31
+ }
32
+ if (!changeExists(feature)) throw new Error(`OpenSpec change "${feature}" not found.`);
33
+
34
+ const change = await loadChange(feature);
35
+ const task = change.items.find((t) => t.title === taskRef) ?? change.items.find((t) => String(t.external_id) === String(taskRef));
36
+ if (!task) {
37
+ const err = new Error(`Task "${taskRef}" not found in feature "${feature}".`);
38
+ err.remediation = 'Run `openspecpm status` to see available tasks.';
39
+ throw err;
40
+ }
41
+ if (!task.external_id) {
42
+ const err = new Error(`Task "${task.title}" has no external_id — it hasn't been synced yet.`);
43
+ err.remediation = `Run \`openspecpm sync ${feature}\` first.`;
44
+ throw err;
45
+ }
46
+
47
+ const adapter = loadAdapter(config.adapter, config);
48
+ await adapter.init();
49
+
50
+ // Per-adapter field translation. Each adapter accepts the keys it supports
51
+ // and ignores the rest; this is a deliberate "best-effort" surface.
52
+ const patch = {};
53
+ if (assignee) patch.assignee = assignee;
54
+ if (sprint) {
55
+ // Jira sprint id, ADO iteration path, Linear cycle id, GitLab milestone id
56
+ patch.sprint = sprint;
57
+ patch.iterationPath = sprint;
58
+ patch.milestoneId = sprint;
59
+ patch.cycleId = sprint;
60
+ }
61
+ if (iteration) {
62
+ patch.iterationPath = iteration;
63
+ patch.sprint = iteration;
64
+ }
65
+ if (area) patch.areaPath = area;
66
+ if (storyPoints !== undefined) {
67
+ patch.estimate = Number(storyPoints); // Linear / Jira customField map externally
68
+ patch.weight = Number(storyPoints); // GitLab
69
+ patch.storyPoints = Number(storyPoints); // semantic key for future adapters
70
+ }
71
+
72
+ await adapter.updateWorkItem({ adapter: config.adapter, id: task.external_id }, patch);
73
+ process.stdout.write(`✓ Updated ${task.external_id} on ${config.adapter}.\n`);
74
+ const fields = Object.entries({ assignee, sprint, iteration, area, storyPoints })
75
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
76
+ .map(([k, v]) => `${k}=${v}`).join(', ');
77
+ if (fields) process.stdout.write(` Set: ${fields}\n`);
78
+ }
@@ -0,0 +1,17 @@
1
+ import { listChanges, findBlockedTasks } from '../tracking.js';
2
+
3
+ export async function runBlocked() {
4
+ const changes = await listChanges();
5
+ const blocked = findBlockedTasks(changes);
6
+ out(`openspecpm blocked — ${blocked.length} task(s) waiting on dependencies\n`);
7
+ if (!blocked.length) return;
8
+ for (const { change, task, unmet } of blocked) {
9
+ const tag = task.external_id ? ` [${task.external_id}]` : ' [unsynced]';
10
+ out(` • ${change} — ${task.title}${tag}`);
11
+ for (const u of unmet) out(` ↳ ${u.reason}: ${u.dep}`);
12
+ }
13
+ }
14
+
15
+ function out(s) {
16
+ process.stdout.write(s + '\n');
17
+ }
@@ -0,0 +1,78 @@
1
+ import { readConfig } from '../config.js';
2
+ import { loadAdapter } from '../adapters/index.js';
3
+ import { loadChange } from '../tracking.js';
4
+ import { changeExists } from '../openspec-bridge.js';
5
+
6
+ export async function runBugReport({ feature, task: taskRef, title, body } = {}) {
7
+ if (!feature) throw new Error('feature name is required');
8
+ if (!taskRef) throw new Error('task title or external_id is required');
9
+ if (!title) throw new Error('bug title is required (use --title)');
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 change = await loadChange(feature);
19
+ const orig = change.items.find((t) => t.title === taskRef) ?? change.items.find((t) => String(t.external_id) === String(taskRef));
20
+ if (!orig) {
21
+ const err = new Error(`Task "${taskRef}" not found in feature "${feature}".`);
22
+ err.remediation = 'Run `openspecpm status` to see available tasks.';
23
+ throw err;
24
+ }
25
+ if (!orig.external_id) {
26
+ const err = new Error(`Cannot file a bug against an unsynced task.`);
27
+ err.remediation = `Run \`openspecpm sync ${feature}\` first.`;
28
+ throw err;
29
+ }
30
+
31
+ const adapter = loadAdapter(config.adapter, config);
32
+ await adapter.init();
33
+
34
+ const epicRef = change.proposal.external?.[config.adapter];
35
+
36
+ const bugTitle = `[bug] ${title}`;
37
+ const bugBody = [
38
+ body ?? '',
39
+ '',
40
+ `Reported against task "${orig.title}" (${orig.external_id})`,
41
+ epicRef ? `Epic: ${epicRef.url ?? epicRef.id}` : '',
42
+ `Feature: ${feature}`,
43
+ '',
44
+ 'Reproduce:',
45
+ '1. <steps>',
46
+ '',
47
+ 'Expected:',
48
+ '',
49
+ 'Actual:',
50
+ '',
51
+ `Filed via \`openspecpm bug-report\`.`,
52
+ ].filter(Boolean).join('\n');
53
+
54
+ const bugRef = await adapter.createWorkItem(
55
+ epicRef ?? { id: orig.external_id, feature },
56
+ { title: bugTitle, body: bugBody, type: 'Bug' },
57
+ );
58
+ process.stdout.write(`✓ Created bug ${bugRef.url ?? bugRef.id}\n`);
59
+
60
+ // Link bug ↔ original.
61
+ try {
62
+ await adapter.linkWorkItems({ adapter: config.adapter, id: orig.external_id }, bugRef, 'Relates');
63
+ } catch (err) {
64
+ process.stdout.write(` (warning: failed to link to original: ${err.message})\n`);
65
+ }
66
+
67
+ // Comment on the original referencing the bug.
68
+ try {
69
+ await adapter.addProgressComment(
70
+ { adapter: config.adapter, id: orig.external_id },
71
+ `<!-- SYNCED: ${new Date().toISOString()} -->\nFollow-up bug filed: ${bugRef.url ?? bugRef.id} — ${title}`,
72
+ );
73
+ } catch (err) {
74
+ process.stdout.write(` (warning: failed to comment on original: ${err.message})\n`);
75
+ }
76
+
77
+ process.stdout.write(`Done. Bug is now visible in your PM tool, linked to ${orig.external_id}.\n`);
78
+ }
@@ -0,0 +1,67 @@
1
+ import * as p from '@clack/prompts';
2
+ import { listChanges, summarizeChange } from '../tracking.js';
3
+ import { runSync } from './sync.js';
4
+ import { runShip } from './ship.js';
5
+
6
+ export async function runSyncAll({ dryRun = false, force = false, yes = false } = {}) {
7
+ const changes = await listChanges();
8
+ if (!changes.length) {
9
+ process.stdout.write('No OpenSpec changes to sync.\n');
10
+ return;
11
+ }
12
+
13
+ if (!yes) {
14
+ p.intro('openspecpm sync --all');
15
+ p.note(`Will sync ${changes.length} change(s): ${changes.map((c) => c.name).join(', ')}`, 'plan');
16
+ const ok = await p.confirm({ message: 'Proceed?', initialValue: false });
17
+ if (p.isCancel(ok) || !ok) { p.cancel('Aborted.'); return; }
18
+ }
19
+
20
+ let synced = 0;
21
+ let failed = 0;
22
+ for (const change of changes) {
23
+ process.stdout.write(`\n── ${change.name} ──\n`);
24
+ try {
25
+ await runSync({ feature: change.name, dryRun, force });
26
+ synced++;
27
+ } catch (err) {
28
+ process.stderr.write(`✖ ${change.name} sync failed: ${err.message}\n`);
29
+ if (err.remediation) process.stderr.write(` → ${err.remediation}\n`);
30
+ failed++;
31
+ }
32
+ }
33
+ process.stdout.write(`\nSummary: ${synced} synced, ${failed} failed.\n`);
34
+ }
35
+
36
+ export async function runShipAllReady({ yes = false, skipArchive = false } = {}) {
37
+ const changes = await listChanges();
38
+ const ready = changes.filter((c) => {
39
+ const s = summarizeChange(c);
40
+ return s.total > 0 && s.counts.pending === 0 && s.counts.failed === 0;
41
+ });
42
+ if (!ready.length) {
43
+ process.stdout.write('No changes are fully synced and ready to ship.\n');
44
+ return;
45
+ }
46
+
47
+ if (!yes) {
48
+ p.intro('openspecpm ship --all-ready');
49
+ p.note(`Will ship ${ready.length} change(s): ${ready.map((c) => c.name).join(', ')}`, 'plan');
50
+ const ok = await p.confirm({ message: 'Close all of these in the PM tool and archive?', initialValue: false });
51
+ if (p.isCancel(ok) || !ok) { p.cancel('Aborted.'); return; }
52
+ }
53
+
54
+ let shipped = 0;
55
+ let failed = 0;
56
+ for (const change of ready) {
57
+ process.stdout.write(`\n── ${change.name} ──\n`);
58
+ try {
59
+ await runShip({ feature: change.name, yes: true, skipArchive });
60
+ shipped++;
61
+ } catch (err) {
62
+ process.stderr.write(`✖ ${change.name} ship failed: ${err.message}\n`);
63
+ failed++;
64
+ }
65
+ }
66
+ process.stdout.write(`\nSummary: ${shipped} shipped, ${failed} failed.\n`);
67
+ }
@@ -0,0 +1,83 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { readConfig } from '../config.js';
5
+ import { loadAdapter } from '../adapters/index.js';
6
+ import { loadChange } from '../tracking.js';
7
+ import { changeDir, changeExists } from '../openspec-bridge.js';
8
+
9
+ export async function runComment({ feature, task: taskRef, message, dryRun = false } = {}) {
10
+ if (!feature) throw new Error('feature name is required');
11
+ if (!taskRef) throw new Error('task title or external_id 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
+ throw err;
21
+ }
22
+
23
+ const change = await loadChange(feature);
24
+ const task = resolveTask(change.items, taskRef);
25
+ if (!task) {
26
+ const err = new Error(`Task "${taskRef}" not found in feature "${feature}".`);
27
+ err.remediation = `Run \`openspecpm status\` to see available tasks.`;
28
+ throw err;
29
+ }
30
+ if (!task.external_id) {
31
+ const err = new Error(`Task "${task.title}" has no external_id — it hasn't been synced yet.`);
32
+ err.remediation = `Run \`openspecpm sync ${feature}\` first.`;
33
+ throw err;
34
+ }
35
+
36
+ const body = await resolveBody({ message, feature, taskRef, taskTitle: task.title });
37
+ const stamped = `<!-- SYNCED: ${new Date().toISOString()} -->\n${body}`;
38
+
39
+ if (dryRun) {
40
+ process.stdout.write(`[dry-run] would post to ${config.adapter} item ${task.external_id}:\n`);
41
+ process.stdout.write(stamped + '\n');
42
+ return;
43
+ }
44
+
45
+ const adapter = loadAdapter(config.adapter, config);
46
+ await adapter.init();
47
+ await adapter.addProgressComment({ adapter: config.adapter, id: task.external_id }, stamped);
48
+ process.stdout.write(`✓ Comment posted to ${task.external_id}.\n`);
49
+
50
+ // Append to local progress.md so the local narrative reflects the broadcast.
51
+ await appendLocalProgress(feature, taskRef, stamped);
52
+ }
53
+
54
+ function resolveTask(items, ref) {
55
+ return items.find((t) => t.title === ref) ?? items.find((t) => String(t.external_id) === String(ref));
56
+ }
57
+
58
+ async function resolveBody({ message, feature, taskRef, taskTitle }) {
59
+ if (message) return message;
60
+ const slug = slugify(taskRef === taskTitle ? taskTitle : taskRef);
61
+ const progressPath = join(changeDir(feature), 'updates', slug, 'progress.md');
62
+ if (existsSync(progressPath)) {
63
+ const raw = await readFile(progressPath, 'utf8');
64
+ return raw.trim() || `(progress.md is empty)`;
65
+ }
66
+ const err = new Error('No --message provided and no local progress.md to post.');
67
+ err.remediation = `Either pass --message "..." or create ${progressPath}.`;
68
+ throw err;
69
+ }
70
+
71
+ async function appendLocalProgress(feature, taskRef, body) {
72
+ const slug = slugify(taskRef);
73
+ const dir = join(changeDir(feature), 'updates', slug);
74
+ await mkdir(dir, { recursive: true });
75
+ const file = join(dir, 'progress.md');
76
+ const existing = existsSync(file) ? await readFile(file, 'utf8') : '';
77
+ const sep = existing && !existing.endsWith('\n') ? '\n\n' : '\n';
78
+ await writeFile(file, existing + sep + body + '\n', 'utf8');
79
+ }
80
+
81
+ function slugify(s) {
82
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'task';
83
+ }
@@ -0,0 +1,106 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { changeDir, changeExists } from '../openspec-bridge.js';
5
+ import { parseScenarios } from '../bdd/linter.js';
6
+ import * as fm from '../frontmatter.js';
7
+
8
+ export async function runDecompose({ feature, force = false } = {}) {
9
+ if (!feature) throw new Error('feature name is required');
10
+ if (!changeExists(feature)) {
11
+ const err = new Error(`OpenSpec change "${feature}" not found.`);
12
+ err.remediation = `Run \`openspecpm propose ${feature}\` first.`;
13
+ throw err;
14
+ }
15
+
16
+ const dir = changeDir(feature);
17
+ const proposalPath = join(dir, 'proposal.md');
18
+ const tasksPath = join(dir, 'tasks.md');
19
+
20
+ let existingItems = [];
21
+ if (existsSync(tasksPath)) {
22
+ const raw = await readFile(tasksPath, 'utf8');
23
+ const { data } = fm.parse(raw);
24
+ existingItems = data.items ?? [];
25
+ if (existingItems.length && !force) {
26
+ process.stdout.write(`tasks.md already has ${existingItems.length} item(s). Pass --force to merge.\n`);
27
+ return;
28
+ }
29
+ }
30
+
31
+ const proposal = existsSync(proposalPath) ? await readFile(proposalPath, 'utf8') : '';
32
+ const specsItems = await extractFromSpecs(dir);
33
+ const proposalItems = extractFromProposal(proposal);
34
+ const merged = dedupe([...existingItems, ...proposalItems, ...specsItems]);
35
+
36
+ const out = fm.serialize(
37
+ { schema_version: 1, items: merged },
38
+ `\n# Tasks\n\nGenerated by \`openspecpm decompose\`. Refine titles, set depends_on, mark parallel: true where safe.\n`,
39
+ );
40
+ await writeFile(tasksPath, out, 'utf8');
41
+ process.stdout.write(`✓ Wrote ${merged.length} task(s) to ${tasksPath}.\n`);
42
+ if (proposalItems.length) process.stdout.write(` ${proposalItems.length} from proposal headings/checklists.\n`);
43
+ if (specsItems.length) process.stdout.write(` ${specsItems.length} from BDD scenarios in specs/.\n`);
44
+ process.stdout.write(`Next: review tasks.md, then \`openspecpm sync ${feature}\`.\n`);
45
+ }
46
+
47
+ function extractFromProposal(md) {
48
+ if (!md) return [];
49
+ const { body } = fm.parse(md);
50
+ const items = [];
51
+ let inTasksSection = false;
52
+ for (const raw of body.split(/\r?\n/)) {
53
+ const line = raw.trim();
54
+ if (/^#+\s*(tasks|implementation|deliverables|steps)/i.test(line)) {
55
+ inTasksSection = true;
56
+ continue;
57
+ }
58
+ if (/^#+\s/.test(line)) inTasksSection = false;
59
+ const check = line.match(/^-\s*\[( |x|X)\]\s+(.+)/);
60
+ if (check) {
61
+ items.push({ title: check[2].trim(), sync_state: 'pending', parallel: false, depends_on: [] });
62
+ continue;
63
+ }
64
+ if (inTasksSection) {
65
+ const bullet = line.match(/^[-*]\s+(.+)/);
66
+ const numbered = line.match(/^\d+[.)]\s+(.+)/);
67
+ const match = bullet ?? numbered;
68
+ if (match) items.push({ title: match[1].trim(), sync_state: 'pending', parallel: false, depends_on: [] });
69
+ }
70
+ }
71
+ return items;
72
+ }
73
+
74
+ async function extractFromSpecs(featureDir) {
75
+ const specsDir = join(featureDir, 'specs');
76
+ if (!existsSync(specsDir)) return [];
77
+ const { readdir } = await import('node:fs/promises');
78
+ const files = await readdir(specsDir);
79
+ const items = [];
80
+ for (const f of files) {
81
+ if (!f.endsWith('.md')) continue;
82
+ const raw = await readFile(join(specsDir, f), 'utf8');
83
+ for (const scenario of parseScenarios(raw)) {
84
+ items.push({
85
+ title: `Implement: ${scenario.title}`,
86
+ sync_state: 'pending',
87
+ parallel: false,
88
+ depends_on: [],
89
+ spec: f,
90
+ });
91
+ }
92
+ }
93
+ return items;
94
+ }
95
+
96
+ function dedupe(items) {
97
+ const seen = new Set();
98
+ const out = [];
99
+ for (const item of items) {
100
+ const key = (item.title ?? '').trim().toLowerCase();
101
+ if (!key || seen.has(key)) continue;
102
+ seen.add(key);
103
+ out.push(item);
104
+ }
105
+ return out;
106
+ }
@@ -0,0 +1,61 @@
1
+ import { readConfig } from '../config.js';
2
+ import { loadAdapter, listAdapters, resolveAdapter } from '../adapters/index.js';
3
+ import { probe, OpenSpecError } from '../openspec-bridge.js';
4
+ import { installCommand, patSetup, osName } from '../install-hints.js';
5
+
6
+ export async function runDoctor({ adapter, install = false, setupAuth = false } = {}) {
7
+ process.stdout.write('openspecpm doctor\n\n');
8
+
9
+ // OpenSpec health
10
+ try {
11
+ const { version } = await probe();
12
+ line(true, `OpenSpec ${version} on PATH`);
13
+ } catch (err) {
14
+ if (err instanceof OpenSpecError) {
15
+ line(false, err.message, err.remediation);
16
+ if (install) suggestInstall('openspec');
17
+ } else {
18
+ throw err;
19
+ }
20
+ }
21
+
22
+ const config = await readConfig();
23
+ const adapters = adapter
24
+ ? [resolveAdapter(adapter)]
25
+ : (config ? [resolveAdapter(config.adapter)] : listAdapters());
26
+
27
+ for (const name of adapters) {
28
+ process.stdout.write(`\n[${name}]\n`);
29
+ const cfg = config && resolveAdapter(config.adapter) === name ? config : {};
30
+ const a = loadAdapter(name, cfg);
31
+ const findings = await a.doctor();
32
+ for (const f of findings) line(f.ok, f.msg, f.remediation);
33
+ if (install) suggestAdapterInstall(name);
34
+ if (setupAuth) suggestAuth(name);
35
+ }
36
+ }
37
+
38
+ function line(ok, msg, remediation) {
39
+ process.stdout.write(` ${ok ? '✓' : '✖'} ${msg}\n`);
40
+ if (!ok && remediation) process.stdout.write(` → ${remediation}\n`);
41
+ }
42
+
43
+ function suggestInstall(tool) {
44
+ const cmd = installCommand(tool);
45
+ if (!cmd) return;
46
+ process.stdout.write(` → on ${osName()}: ${cmd}\n`);
47
+ }
48
+
49
+ function suggestAdapterInstall(name) {
50
+ if (name === 'github') suggestInstall('gh');
51
+ if (name === 'azure') suggestInstall('az');
52
+ // Linear, GitLab, Jira use REST directly — no CLI required.
53
+ }
54
+
55
+ function suggestAuth(adapterName) {
56
+ const info = patSetup(adapterName);
57
+ if (!info) return;
58
+ process.stdout.write(` auth setup:\n`);
59
+ process.stdout.write(` • create token at: ${info.url}\n`);
60
+ process.stdout.write(` • required scopes: ${info.scopes}\n`);
61
+ }
@@ -0,0 +1,79 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { loadChange, unmetDeps } from '../tracking.js';
5
+ import { changeDir, changeExists } from '../openspec-bridge.js';
6
+ import { record } from '../audit.js';
7
+
8
+ export async function runFanOut({ feature, limit = 5 } = {}) {
9
+ if (!feature) throw new Error('feature name is required');
10
+ if (!changeExists(feature)) throw new Error(`OpenSpec change "${feature}" not found.`);
11
+ const change = await loadChange(feature);
12
+
13
+ const candidates = change.items.filter(
14
+ (t) => !t.done && !t.closed && t.parallel === true && unmetDeps(t, change.items).length === 0,
15
+ );
16
+ if (!candidates.length) {
17
+ process.stdout.write('No tasks ready for parallel fan-out.\n');
18
+ process.stdout.write(' Tip: mark tasks with `parallel: true` in tasks.md and ensure depends_on are met.\n');
19
+ return;
20
+ }
21
+
22
+ const proposal = await readFileSafe(join(changeDir(feature), 'proposal.md'));
23
+ const design = await readFileSafe(join(changeDir(feature), 'design.md'));
24
+ const tasks = candidates.slice(0, limit);
25
+
26
+ process.stdout.write(`openspecpm fan-out ${feature} — ${tasks.length} parallel task(s) ready\n\n`);
27
+ process.stdout.write(`Copy each prompt below into a separate agent session. The agent should treat\n`);
28
+ process.stdout.write(`the BDD scenarios in specs/ as acceptance criteria.\n`);
29
+ process.stdout.write(`${'='.repeat(72)}\n\n`);
30
+
31
+ for (const [i, task] of tasks.entries()) {
32
+ const specFile = task.spec ? join(changeDir(feature), 'specs', task.spec) : null;
33
+ const specBlock = specFile && existsSync(specFile) ? await readFile(specFile, 'utf8') : '(no spec file linked — read all of specs/)';
34
+ process.stdout.write(`--- Agent ${i + 1} of ${tasks.length} ---\n`);
35
+ process.stdout.write(`Task: ${task.title}${task.external_id ? ` [#${task.external_id}]` : ''}\n\n`);
36
+ process.stdout.write(`Prompt to paste:\n\n`);
37
+ process.stdout.write([
38
+ `Implement the task "${task.title}" from feature "${feature}".`,
39
+ ``,
40
+ `Context (proposal):`,
41
+ truncate(proposal, 1200),
42
+ ``,
43
+ `Design notes:`,
44
+ truncate(design, 800),
45
+ ``,
46
+ `Acceptance criteria (BDD scenarios):`,
47
+ truncate(specBlock, 1200),
48
+ ``,
49
+ `Rules:`,
50
+ `- Only modify files relevant to this task. Leave other parallel streams alone.`,
51
+ `- When done, append progress notes to openspec/changes/${feature}/updates/${slugify(task.title)}/progress.md.`,
52
+ `- If you discover the spec is ambiguous, stop and ask before guessing.`,
53
+ ].join('\n') + '\n\n');
54
+ }
55
+ process.stdout.write(`${'='.repeat(72)}\n`);
56
+ process.stdout.write(`After dispatching, run \`openspecpm standup\` to see updates as agents post progress.\n`);
57
+
58
+ try {
59
+ await record({
60
+ command: 'fan-out',
61
+ args: { feature, dispatched: tasks.map((t) => t.title) },
62
+ result: 'ok',
63
+ });
64
+ } catch { /* never break the user */ }
65
+ }
66
+
67
+ async function readFileSafe(p) {
68
+ if (!existsSync(p)) return '';
69
+ try { return await readFile(p, 'utf8'); } catch { return ''; }
70
+ }
71
+
72
+ function truncate(s, max) {
73
+ if (!s) return '(none)';
74
+ return s.length > max ? s.slice(0, max) + '\n…' : s;
75
+ }
76
+
77
+ function slugify(s) {
78
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'task';
79
+ }
@@ -0,0 +1,69 @@
1
+ const SECTIONS = [
2
+ {
3
+ title: 'Setup',
4
+ rows: [
5
+ ['init', 'Interactive wizard to pick a PM tool and write .openspecpm/config.json'],
6
+ ['doctor [adapter]', 'Diagnose auth + tooling; English remediation hints'],
7
+ ],
8
+ },
9
+ {
10
+ title: 'Plan',
11
+ rows: [
12
+ ['propose <feature>', 'Author proposal.md, design.md, tasks.md, specs/ via OpenSpec'],
13
+ ['decompose <feature>', 'Extract tasks from proposal + BDD scenarios into tasks.md'],
14
+ ],
15
+ },
16
+ {
17
+ title: 'Sync',
18
+ rows: [
19
+ ['sync <feature>', 'Push to PM tool (idempotent, BDD-linted)'],
20
+ ['comment <feature> <task>', 'Broadcast progress.md (or --message) to the PM tool'],
21
+ ['reconcile <feature>', 'Pull remote state back into local frontmatter'],
22
+ ['bug-report <feature> <task>', 'File a regression against a shipped task'],
23
+ ],
24
+ },
25
+ {
26
+ title: 'Track',
27
+ rows: [
28
+ ['status', 'Per-change task counts'],
29
+ ['standup [--since 24h]', 'Recent progress.md updates, newest first'],
30
+ ['next [-l 5]', 'Tasks ready to start (no unmet deps)'],
31
+ ['blocked', 'Tasks waiting on unmet deps'],
32
+ ['validate', 'Schema + dependency + BDD-lint sweep across every change'],
33
+ ['search <query>', 'Grep across changes, specs, progress notes'],
34
+ ],
35
+ },
36
+ {
37
+ title: 'Execute / Ship',
38
+ rows: [
39
+ ['fan-out <feature>', 'Emit parallel-agent prompts for parallel:true tasks'],
40
+ ['ship <feature>', 'Close every task + epic + archive the OpenSpec change'],
41
+ ],
42
+ },
43
+ ];
44
+
45
+ export function runHelp({ topic } = {}) {
46
+ if (topic) {
47
+ const section = SECTIONS.find((s) => s.title.toLowerCase() === topic.toLowerCase());
48
+ if (!section) {
49
+ process.stdout.write(`Unknown topic "${topic}". Try: ${SECTIONS.map((s) => s.title.toLowerCase()).join(', ')}\n`);
50
+ return;
51
+ }
52
+ print([section]);
53
+ return;
54
+ }
55
+ process.stdout.write('OpenSpecPM — command reference by phase\n\n');
56
+ print(SECTIONS);
57
+ process.stdout.write('\nDetails: `openspecpm <command> --help` (Commander auto-help).\n');
58
+ process.stdout.write('Skill docs: skill/openspecpm/references/{plan,structure,sync,execute,track}.md\n');
59
+ }
60
+
61
+ function print(sections) {
62
+ const w = Math.max(...sections.flatMap((s) => s.rows.map((r) => r[0].length))) + 2;
63
+ for (const s of sections) {
64
+ process.stdout.write(`\n[${s.title}]\n`);
65
+ for (const [cmd, desc] of s.rows) {
66
+ process.stdout.write(` ${cmd.padEnd(w)} ${desc}\n`);
67
+ }
68
+ }
69
+ }