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,261 @@
1
+ import { Adapter, AdapterError } from './base.js';
2
+ import { TokenBucket } from '../ratelimit.js';
3
+
4
+ const ENDPOINT = 'https://api.linear.app/graphql';
5
+
6
+ const STATE_TO_NORMALIZED = (typeName) => {
7
+ const v = (typeName ?? '').toLowerCase();
8
+ if (v === 'completed' || v === 'canceled') return 'closed';
9
+ if (v === 'started') return 'in_progress';
10
+ if (v === 'unstarted' || v === 'backlog' || v === 'triage') return 'open';
11
+ return 'open';
12
+ };
13
+
14
+ export class LinearAdapter extends Adapter {
15
+ #fetch;
16
+ #bucket;
17
+
18
+ constructor(config = {}, { fetch: fetchImpl = globalThis.fetch } = {}) {
19
+ super(config);
20
+ this.#fetch = fetchImpl;
21
+ // Linear publishes 1500 req/hour soft limit on personal API keys.
22
+ this.#bucket = new TokenBucket({ capacity: 30, refillPerSec: 0.4 });
23
+ }
24
+
25
+ get name() {
26
+ return 'linear';
27
+ }
28
+
29
+ capabilities() {
30
+ return {
31
+ hierarchyDepth: 2,
32
+ supportsSubIssues: true,
33
+ supportsSprints: true, // Cycles
34
+ supportsLabels: true,
35
+ fieldMap: { epic: 'Project', task: 'Issue', sprint: 'Cycle' },
36
+ };
37
+ }
38
+
39
+ #apiKey() {
40
+ const key = process.env.LINEAR_API_KEY;
41
+ if (!key) {
42
+ throw new AdapterError('LINEAR_API_KEY not set.', {
43
+ remediation: 'Create a Personal API Key at linear.app/settings/api and export LINEAR_API_KEY.',
44
+ });
45
+ }
46
+ return key;
47
+ }
48
+
49
+ #teamId() {
50
+ const id = this.config.teamId;
51
+ if (!id) {
52
+ throw new AdapterError('Linear adapter requires config.teamId.', {
53
+ remediation: 'Re-run `openspecpm init`; team ID is shown in your Linear team settings URL.',
54
+ });
55
+ }
56
+ return id;
57
+ }
58
+
59
+ async #gql(query, variables = {}) {
60
+ await this.#bucket.take();
61
+ let res;
62
+ try {
63
+ res = await this.#fetch(ENDPOINT, {
64
+ method: 'POST',
65
+ headers: {
66
+ Authorization: this.#apiKey(),
67
+ 'Content-Type': 'application/json',
68
+ },
69
+ body: JSON.stringify({ query, variables }),
70
+ });
71
+ } catch (err) {
72
+ throw new AdapterError(`Linear network error: ${err.message}`, {
73
+ remediation: 'Check connectivity and LINEAR_API_KEY scope.',
74
+ cause: err,
75
+ });
76
+ }
77
+ const text = await res.text();
78
+ let payload;
79
+ try { payload = JSON.parse(text); } catch { payload = { raw: text }; }
80
+ if (!res.ok || payload.errors) {
81
+ const msg = payload.errors?.map((e) => e.message).join('; ') || `${res.status} ${res.statusText}`;
82
+ throw new AdapterError(`Linear GraphQL error: ${msg}`, {
83
+ remediation: res.status === 401 || res.status === 403
84
+ ? 'Verify LINEAR_API_KEY has full scope (read+write).'
85
+ : 'Inspect the GraphQL error and adjust the request.',
86
+ });
87
+ }
88
+ return payload.data;
89
+ }
90
+
91
+ async doctor() {
92
+ if (!process.env.LINEAR_API_KEY) {
93
+ return [{ ok: false, msg: 'LINEAR_API_KEY not set.', remediation: 'Create a key at linear.app/settings/api.' }];
94
+ }
95
+ try {
96
+ const data = await this.#gql(`query { viewer { id name } }`);
97
+ return [{ ok: true, msg: `Authenticated as ${data.viewer?.name ?? data.viewer?.id}` }];
98
+ } catch (err) {
99
+ return [{ ok: false, msg: `Auth failed: ${err.message}`, remediation: err.remediation }];
100
+ }
101
+ }
102
+
103
+ async init() {
104
+ this.#apiKey();
105
+ this.#teamId();
106
+ return this.capabilities();
107
+ }
108
+
109
+ async createEpic(feature) {
110
+ // Linear models "epic" as a Project.
111
+ const data = await this.#gql(
112
+ `mutation($input: ProjectCreateInput!) {
113
+ projectCreate(input: $input) {
114
+ success
115
+ project { id name url }
116
+ }
117
+ }`,
118
+ {
119
+ input: {
120
+ name: feature.name,
121
+ description: feature.summary ?? '',
122
+ teamIds: [this.#teamId()],
123
+ },
124
+ },
125
+ );
126
+ if (!data.projectCreate?.success) {
127
+ throw new AdapterError('Linear projectCreate returned success=false.');
128
+ }
129
+ const p = data.projectCreate.project;
130
+ return { adapter: 'linear', id: p.id, url: p.url };
131
+ }
132
+
133
+ async createWorkItem(epic, task /*, opts */) {
134
+ const data = await this.#gql(
135
+ `mutation($input: IssueCreateInput!) {
136
+ issueCreate(input: $input) {
137
+ success
138
+ issue { id identifier url }
139
+ }
140
+ }`,
141
+ {
142
+ input: {
143
+ teamId: this.#teamId(),
144
+ title: task.title,
145
+ description: task.body ?? '',
146
+ projectId: epic?.id,
147
+ labelIds: this.config.openspecLabelId ? [this.config.openspecLabelId] : undefined,
148
+ },
149
+ },
150
+ );
151
+ if (!data.issueCreate?.success) {
152
+ throw new AdapterError('Linear issueCreate returned success=false.');
153
+ }
154
+ const i = data.issueCreate.issue;
155
+ return { adapter: 'linear', id: i.identifier, url: i.url };
156
+ }
157
+
158
+ async linkWorkItems(parent, child /*, type */) {
159
+ // Linear supports parent/child via Issue.parent. Sub-issue mapping.
160
+ await this.#gql(
161
+ `mutation($id: String!, $parentId: String!) {
162
+ issueUpdate(id: $id, input: { parentId: $parentId }) {
163
+ success
164
+ }
165
+ }`,
166
+ { id: child.id, parentId: parent.id },
167
+ );
168
+ }
169
+
170
+ async addProgressComment(item, body) {
171
+ await this.#gql(
172
+ `mutation($input: CommentCreateInput!) {
173
+ commentCreate(input: $input) { success }
174
+ }`,
175
+ { input: { issueId: item.id, body } },
176
+ );
177
+ }
178
+
179
+ async updateWorkItem(item, patch) {
180
+ const input = {};
181
+ if (patch.title) input.title = patch.title;
182
+ if (patch.description) input.description = patch.description;
183
+ if (patch.assignee) input.assigneeId = patch.assignee;
184
+ if (patch.stateId) input.stateId = patch.stateId;
185
+ if (patch.estimate !== undefined) input.estimate = patch.estimate; // story points
186
+ if (patch.cycleId) input.cycleId = patch.cycleId; // sprint
187
+ if (!Object.keys(input).length) return;
188
+ await this.#gql(
189
+ `mutation($id: String!, $input: IssueUpdateInput!) {
190
+ issueUpdate(id: $id, input: $input) { success }
191
+ }`,
192
+ { id: item.id, input },
193
+ );
194
+ }
195
+
196
+ async closeWorkItem(item, resolution) {
197
+ // Find a "Done"-like workflow state for the team.
198
+ const data = await this.#gql(
199
+ `query($teamId: String!) {
200
+ workflowStates(filter: { team: { id: { eq: $teamId } } }) {
201
+ nodes { id name type }
202
+ }
203
+ }`,
204
+ { teamId: this.#teamId() },
205
+ );
206
+ const done = (data.workflowStates?.nodes ?? []).find((s) => s.type === 'completed');
207
+ if (!done) {
208
+ throw new AdapterError('No "completed" workflow state found for the team.', {
209
+ remediation: 'Add a Done state in Linear team settings.',
210
+ });
211
+ }
212
+ await this.updateWorkItem(item, { stateId: done.id });
213
+ if (resolution) await this.addProgressComment(item, resolution);
214
+ }
215
+
216
+ async getWorkItem(item) {
217
+ const data = await this.#gql(
218
+ `query($id: String!) {
219
+ issue(id: $id) {
220
+ id identifier url title
221
+ state { type }
222
+ labels { nodes { name } }
223
+ assignee { id name email }
224
+ }
225
+ }`,
226
+ { id: item.id },
227
+ );
228
+ const i = data.issue;
229
+ if (!i) throw new AdapterError(`Linear issue ${item.id} not found.`);
230
+ return {
231
+ ref: { adapter: 'linear', id: i.identifier, url: i.url },
232
+ title: i.title,
233
+ status: STATE_TO_NORMALIZED(i.state?.type),
234
+ labels: (i.labels?.nodes ?? []).map((l) => l.name),
235
+ assignee: i.assignee?.email ?? i.assignee?.name ?? null,
236
+ };
237
+ }
238
+
239
+ async listWorkItems(query = {}) {
240
+ const data = await this.#gql(
241
+ `query($teamId: String!, $first: Int!) {
242
+ issues(filter: { team: { id: { eq: $teamId } } }, first: $first) {
243
+ nodes {
244
+ identifier url title
245
+ state { type }
246
+ labels { nodes { name } }
247
+ assignee { email name }
248
+ }
249
+ }
250
+ }`,
251
+ { teamId: this.#teamId(), first: query.limit ?? 50 },
252
+ );
253
+ return (data.issues?.nodes ?? []).map((i) => ({
254
+ ref: { adapter: 'linear', id: i.identifier, url: i.url },
255
+ title: i.title,
256
+ status: STATE_TO_NORMALIZED(i.state?.type),
257
+ labels: (i.labels?.nodes ?? []).map((l) => l.name),
258
+ assignee: i.assignee?.email ?? i.assignee?.name ?? null,
259
+ }));
260
+ }
261
+ }
@@ -0,0 +1,78 @@
1
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const DIR = '.openspecpm';
6
+ const FILE = 'audit.log';
7
+
8
+ export function auditPath(cwd = process.cwd()) {
9
+ return join(cwd, DIR, FILE);
10
+ }
11
+
12
+ export async function record({ command, args = {}, result = null, error = null, cwd = process.cwd() } = {}) {
13
+ if (!command) return;
14
+ const path = auditPath(cwd);
15
+ await mkdir(dirname(path), { recursive: true });
16
+ const entry = {
17
+ ts: new Date().toISOString(),
18
+ command,
19
+ args: scrub(args),
20
+ result: result ? truncate(result, 500) : null,
21
+ error: error ? truncate(typeof error === 'string' ? error : error.message ?? String(error), 500) : null,
22
+ };
23
+ await appendFile(path, JSON.stringify(entry) + '\n', 'utf8');
24
+ }
25
+
26
+ export async function tail(n = 50, cwd = process.cwd()) {
27
+ const path = auditPath(cwd);
28
+ if (!existsSync(path)) return [];
29
+ const raw = await readFile(path, 'utf8');
30
+ const lines = raw.split(/\r?\n/).filter(Boolean);
31
+ return lines.slice(-n).map((l) => {
32
+ try { return JSON.parse(l); } catch { return { raw: l }; }
33
+ });
34
+ }
35
+
36
+ const SECRET_KEYS = /token|secret|password|pat|api[_-]?key|auth|credential/i;
37
+
38
+ function scrub(obj) {
39
+ if (!obj || typeof obj !== 'object') return obj;
40
+ if (Array.isArray(obj)) return obj.map(scrub);
41
+ const out = {};
42
+ for (const [k, v] of Object.entries(obj)) {
43
+ if (SECRET_KEYS.test(k)) {
44
+ out[k] = '<redacted>';
45
+ } else if (v && typeof v === 'object') {
46
+ out[k] = scrub(v);
47
+ } else if (typeof v === 'string' && v.length > 200) {
48
+ out[k] = v.slice(0, 200) + '…';
49
+ } else {
50
+ out[k] = v;
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ function truncate(s, max) {
57
+ if (s == null) return s;
58
+ const str = String(s);
59
+ return str.length > max ? str.slice(0, max) + '…' : str;
60
+ }
61
+
62
+ /**
63
+ * Wrap a command runner so it auto-records to the audit log.
64
+ * Captures success/failure but never throws *from* the audit layer.
65
+ */
66
+ export function audited(command, fn) {
67
+ return async (args) => {
68
+ const safeArgs = args ?? {};
69
+ try {
70
+ const result = await fn(safeArgs);
71
+ try { await record({ command, args: safeArgs, result: result === undefined ? 'ok' : 'ok' }); } catch { /* never break the command */ }
72
+ return result;
73
+ } catch (err) {
74
+ try { await record({ command, args: safeArgs, error: err }); } catch { /* same */ }
75
+ throw err;
76
+ }
77
+ };
78
+ }
@@ -0,0 +1,183 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ const OBSERVABLE_VERBS = [
6
+ 'displays', 'shows', 'returns', 'stores', 'persists', 'rejects', 'allows',
7
+ 'emails', 'sends', 'creates', 'updates', 'deletes', 'renders', 'redirects',
8
+ 'logs', 'records', 'enqueues', 'publishes', 'fails', 'succeeds', 'navigates',
9
+ 'saves', 'loads', 'increments', 'decrements', 'enables', 'disables', 'closes',
10
+ 'opens', 'highlights', 'hides', 'reveals', 'archives', 'restores', 'cancels',
11
+ 'rolls', 'commits', 'broadcasts', 'notifies', 'flags', 'tags', 'untags',
12
+ 'matches', 'contains', 'equals', 'exceeds', 'is', 'are', 'has', 'have',
13
+ 'becomes', 'remains',
14
+ ];
15
+
16
+ const DENY_PHRASES = [
17
+ 'should work',
18
+ 'should be correct',
19
+ 'is successful',
20
+ 'is fine',
21
+ 'works correctly',
22
+ 'works as expected',
23
+ 'is good',
24
+ 'is okay',
25
+ ];
26
+
27
+ /**
28
+ * @typedef {Object} LintFinding
29
+ * @property {'error'|'warning'} severity
30
+ * @property {string} file
31
+ * @property {number} [line]
32
+ * @property {string} scenario
33
+ * @property {string} rule
34
+ * @property {string} message
35
+ */
36
+
37
+ const SCENARIO_RE = /^\s*Scenario:\s*(.+?)\s*$/i;
38
+ const STEP_RE = /^\s*(Given|When|Then|And|But)\s+(.+?)\s*$/i;
39
+
40
+ export function parseScenarios(source) {
41
+ const lines = source.split(/\r?\n/);
42
+ const scenarios = [];
43
+ let current = null;
44
+ let lineNo = 0;
45
+ for (const raw of lines) {
46
+ lineNo++;
47
+ const sMatch = raw.match(SCENARIO_RE);
48
+ if (sMatch) {
49
+ if (current) scenarios.push(current);
50
+ current = { title: sMatch[1], line: lineNo, steps: [] };
51
+ continue;
52
+ }
53
+ if (!current) continue;
54
+ const stepMatch = raw.match(STEP_RE);
55
+ if (stepMatch) {
56
+ current.steps.push({ keyword: titleCase(stepMatch[1]), text: stepMatch[2], line: lineNo });
57
+ }
58
+ }
59
+ if (current) scenarios.push(current);
60
+ return scenarios;
61
+ }
62
+
63
+ function titleCase(s) {
64
+ return s[0].toUpperCase() + s.slice(1).toLowerCase();
65
+ }
66
+
67
+ export function lintScenario(scenario, { file = '<input>' } = {}) {
68
+ const findings = [];
69
+ const counts = { Given: 0, When: 0, Then: 0 };
70
+ let lastPrimary = null;
71
+ for (const step of scenario.steps) {
72
+ if (['Given', 'When', 'Then'].includes(step.keyword)) {
73
+ counts[step.keyword]++;
74
+ lastPrimary = step.keyword;
75
+ } else if (step.keyword === 'And' || step.keyword === 'But') {
76
+ if (!lastPrimary) {
77
+ findings.push(finding('error', file, step.line, scenario.title, 'bdd/orphan-and', `${step.keyword} step appears before any Given/When/Then.`));
78
+ }
79
+ }
80
+ }
81
+ for (const k of ['Given', 'When', 'Then']) {
82
+ if (counts[k] === 0) {
83
+ findings.push(finding('error', file, scenario.line, scenario.title, 'bdd/missing-step', `Scenario is missing a ${k} step.`));
84
+ }
85
+ if (counts[k] > 1) {
86
+ findings.push(finding('warning', file, scenario.line, scenario.title, 'bdd/multiple-primary', `Scenario has ${counts[k]} ${k} steps — prefer one ${k} plus And-chained steps.`));
87
+ }
88
+ }
89
+
90
+ const whens = scenario.steps.filter((s) => s.keyword === 'When');
91
+ for (const w of whens) {
92
+ const first = w.text.trim().split(/\s+/)[0]?.toLowerCase();
93
+ if (!first || /^(is|are|was|were|will)$/.test(first)) {
94
+ findings.push(finding('warning', file, w.line, scenario.title, 'bdd/weak-when', `When clause "${w.text}" should start with an action verb (clicks, submits, requests, …), not a state verb.`));
95
+ }
96
+ }
97
+
98
+ const thens = scenario.steps.filter((s) => s.keyword === 'Then');
99
+ for (const t of thens) {
100
+ const lower = t.text.toLowerCase();
101
+ for (const deny of DENY_PHRASES) {
102
+ if (lower.includes(deny)) {
103
+ findings.push(finding('error', file, t.line, scenario.title, 'bdd/vague-then', `Then predicate uses denied phrase "${deny}". State an observable outcome instead.`));
104
+ }
105
+ }
106
+ if (!OBSERVABLE_VERBS.some((v) => new RegExp(`\\b${v}\\b`, 'i').test(t.text))) {
107
+ findings.push(finding('warning', file, t.line, scenario.title, 'bdd/non-observable-then', `Then "${t.text}" lacks an observable verb. Consider: ${OBSERVABLE_VERBS.slice(0, 6).join(', ')}, …`));
108
+ }
109
+ }
110
+
111
+ // Tautology check: When and Then with high similarity.
112
+ for (const w of whens) {
113
+ for (const t of thens) {
114
+ if (similarity(w.text, t.text) > 0.8) {
115
+ findings.push(finding('error', file, t.line, scenario.title, 'bdd/tautological-then', `Then "${t.text}" closely paraphrases When "${w.text}". State an outcome distinct from the action.`));
116
+ }
117
+ }
118
+ }
119
+
120
+ return findings;
121
+ }
122
+
123
+ export function lintSource(source, { file = '<input>' } = {}) {
124
+ const scenarios = parseScenarios(source);
125
+ if (!scenarios.length) return [];
126
+ const findings = [];
127
+ for (const s of scenarios) findings.push(...lintScenario(s, { file }));
128
+ return findings;
129
+ }
130
+
131
+ export async function lintChange(featureDir) {
132
+ const specsDir = join(featureDir, 'specs');
133
+ if (!existsSync(specsDir)) return [];
134
+ const files = await readdir(specsDir);
135
+ const findings = [];
136
+ for (const f of files) {
137
+ if (!f.endsWith('.md')) continue;
138
+ const full = join(specsDir, f);
139
+ const src = await readFile(full, 'utf8');
140
+ findings.push(...lintSource(src, { file: full }));
141
+ }
142
+ return findings;
143
+ }
144
+
145
+ export function summarize(findings) {
146
+ const errors = findings.filter((f) => f.severity === 'error').length;
147
+ const warnings = findings.filter((f) => f.severity === 'warning').length;
148
+ return { errors, warnings, total: findings.length };
149
+ }
150
+
151
+ export function formatFindings(findings) {
152
+ if (!findings.length) return ' No BDD findings.\n';
153
+ const lines = [];
154
+ for (const f of findings) {
155
+ const sigil = f.severity === 'error' ? '✖' : '⚠';
156
+ lines.push(` ${sigil} ${f.file}:${f.line ?? '?'} [${f.rule}] ${f.scenario}: ${f.message}`);
157
+ }
158
+ return lines.join('\n') + '\n';
159
+ }
160
+
161
+ function finding(severity, file, line, scenario, rule, message) {
162
+ return { severity, file, line, scenario, rule, message };
163
+ }
164
+
165
+ // Dice coefficient on word bigrams — small, dependency-free, good enough for paraphrase detection.
166
+ function similarity(a, b) {
167
+ const A = bigrams(a.toLowerCase());
168
+ const B = bigrams(b.toLowerCase());
169
+ if (!A.size || !B.size) return 0;
170
+ let overlap = 0;
171
+ for (const g of A) if (B.has(g)) overlap++;
172
+ return (2 * overlap) / (A.size + B.size);
173
+ }
174
+
175
+ function bigrams(s) {
176
+ const words = s.split(/\s+/).filter(Boolean);
177
+ const out = new Set();
178
+ for (let i = 0; i < words.length - 1; i++) {
179
+ out.add(words[i] + ' ' + words[i + 1]);
180
+ }
181
+ if (words.length === 1) out.add(words[0]);
182
+ return out;
183
+ }
@@ -0,0 +1,172 @@
1
+ export const STARTER_SPEC = `# Scenarios
2
+
3
+ Document each user-visible behavior as a Scenario with Given/When/Then.
4
+ Aim for one Given, one When, one Then per scenario, with optional And/But chains.
5
+
6
+ Scenario: Replace this title with a one-sentence behavior
7
+ Given <pre-condition>
8
+ And <another pre-condition (optional)>
9
+ When <the user or system performs an action>
10
+ Then <an observable outcome>
11
+ And <another observable outcome (optional)>
12
+ `;
13
+
14
+ export const STARTER_TASKS = `---
15
+ schema_version: 1
16
+ items: []
17
+ ---
18
+
19
+ # Tasks
20
+
21
+ Add one entry per work item to the \`items:\` frontmatter list. The frontmatter
22
+ is authoritative; this body is informational only.
23
+
24
+ Schema (per item):
25
+
26
+ \`\`\`yaml
27
+ - title: "Implement X"
28
+ sync_state: pending
29
+ depends_on: []
30
+ parallel: true
31
+ effort_hours: 3
32
+ \`\`\`
33
+ `;
34
+
35
+ export const CHANGE_TYPES = ['feature', 'bug', 'refactor', 'incident'];
36
+
37
+ const PROPOSAL_TEMPLATES = {
38
+ feature: (name) => `---
39
+ name: ${name}
40
+ type: feature
41
+ status: draft
42
+ schema_version: 1
43
+ ---
44
+
45
+ # Feature: ${name}
46
+
47
+ ## Problem
48
+ What can't users do today? Who feels it?
49
+
50
+ ## Proposed solution
51
+ One paragraph describing the user-visible behavior.
52
+
53
+ ## Success criteria
54
+ - Observable outcome 1
55
+ - Observable outcome 2
56
+
57
+ ## Out of scope
58
+ - Things we are deliberately not doing in this change
59
+ `,
60
+
61
+ bug: (name) => `---
62
+ name: ${name}
63
+ type: bug
64
+ status: draft
65
+ schema_version: 1
66
+ severity: low | medium | high | critical
67
+ ---
68
+
69
+ # Bug: ${name}
70
+
71
+ ## Symptom
72
+ What does the user see?
73
+
74
+ ## Reproduce
75
+ 1. Step
76
+ 2. Step
77
+
78
+ ## Root cause (after investigation)
79
+ Fill in once known.
80
+
81
+ ## Fix approach
82
+ One paragraph.
83
+
84
+ ## Regression test
85
+ Which scenarios in specs/ exercise this path?
86
+ `,
87
+
88
+ refactor: (name) => `---
89
+ name: ${name}
90
+ type: refactor
91
+ status: draft
92
+ schema_version: 1
93
+ behavior_change: none
94
+ ---
95
+
96
+ # Refactor: ${name}
97
+
98
+ ## What's being changed
99
+ Files / modules / patterns affected.
100
+
101
+ ## Why now
102
+ The motivating constraint or smell.
103
+
104
+ ## Behavior contract
105
+ This refactor MUST NOT change observable behavior. List the BDD scenarios
106
+ in specs/ that must continue to pass unchanged.
107
+
108
+ ## Risk + rollback
109
+ What's the blast radius if this goes wrong? How do we revert?
110
+ `,
111
+
112
+ incident: (name) => `---
113
+ name: ${name}
114
+ type: incident
115
+ status: draft
116
+ schema_version: 1
117
+ severity: sev1 | sev2 | sev3
118
+ detected_at: <ISO-8601>
119
+ mitigated_at: <ISO-8601>
120
+ ---
121
+
122
+ # Incident: ${name}
123
+
124
+ ## Impact
125
+ Who was affected, for how long, in what way.
126
+
127
+ ## Timeline
128
+ | Time | Event |
129
+ |------|-------|
130
+ | ... | ... |
131
+
132
+ ## Root cause
133
+ One paragraph.
134
+
135
+ ## Mitigation
136
+ What stopped the bleeding.
137
+
138
+ ## Action items
139
+ - [ ] Owner: short description
140
+ `,
141
+ };
142
+
143
+ const SPECS_TEMPLATES = {
144
+ feature: STARTER_SPEC,
145
+ bug: `# Regression scenarios
146
+
147
+ Each scenario MUST fail without the fix and pass with it.
148
+
149
+ Scenario: <symptom title>
150
+ Given <state at time of bug>
151
+ When <action that triggered it>
152
+ Then <observable correct behavior, not the buggy one>
153
+ `,
154
+ refactor: STARTER_SPEC,
155
+ incident: `# Post-incident verification
156
+
157
+ Scenario: <action that triggered the incident> no longer breaks
158
+ Given <conditions at incident time, reproduced>
159
+ When <triggering action>
160
+ Then <safe behavior occurs>
161
+ And <no alert fires>
162
+ `,
163
+ };
164
+
165
+ export function proposalTemplate(type, name) {
166
+ const t = PROPOSAL_TEMPLATES[type] ?? PROPOSAL_TEMPLATES.feature;
167
+ return t(name);
168
+ }
169
+
170
+ export function specsTemplate(type) {
171
+ return SPECS_TEMPLATES[type] ?? STARTER_SPEC;
172
+ }