openspecpm 0.1.0-alpha.0 → 1.0.1

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.
@@ -1,198 +1,218 @@
1
- #!/usr/bin/env node
2
- import { Command } from 'commander';
3
- import { audited } from '../src/audit.js';
4
- import { runInit } from '../src/commands/init.js';
5
- import { runDoctor } from '../src/commands/doctor.js';
6
- import { runPropose } from '../src/commands/propose.js';
7
- import { runDecompose } from '../src/commands/decompose.js';
8
- import { runSync } from '../src/commands/sync.js';
9
- import { runComment } from '../src/commands/comment.js';
10
- import { runReconcile } from '../src/commands/reconcile.js';
11
- import { runStatus } from '../src/commands/status.js';
12
- import { runStandup } from '../src/commands/standup.js';
13
- import { runNext } from '../src/commands/next.js';
14
- import { runBlocked } from '../src/commands/blocked.js';
15
- import { runValidate } from '../src/commands/validate.js';
16
- import { runSearch } from '../src/commands/search.js';
17
- import { runFanOut } from '../src/commands/fan-out.js';
18
- import { runBugReport } from '../src/commands/bug-report.js';
19
- import { runShip } from '../src/commands/ship.js';
20
- import { runHelp } from '../src/commands/help.js';
21
- import { runAssign } from '../src/commands/assign.js';
22
- import { runSyncAll, runShipAllReady } from '../src/commands/bulk.js';
23
- import { runWatch } from '../src/commands/watch.js';
24
-
25
- const program = new Command();
26
-
27
- program
28
- .name('openspecpm')
29
- .description('Spec-driven, BDD-shaped project management for AI agents.')
30
- .version('0.1.0-alpha.0');
31
-
32
- program
33
- .command('init')
34
- .description('Interactive wizard: pick a PM tool and write .openspecpm/config.json')
35
- .option('--non-interactive', 'Fail instead of prompting when input is required')
36
- .action((opts) => audited('init', runInit)(opts).catch(fatal));
37
-
38
- program
39
- .command('doctor [adapter]')
40
- .description('Check auth + tooling health for one or all adapters')
41
- .option('--install', 'Print OS-specific install commands for missing CLIs')
42
- .option('--setup-auth', 'Print PAT/token URLs and required scopes')
43
- .action((adapter, opts) => audited('doctor', runDoctor)({ adapter, install: opts.install, setupAuth: opts.setupAuth }).catch(fatal));
44
-
45
- program
46
- .command('propose <feature>')
47
- .description('Create an OpenSpec proposal (proposal.md, design.md, tasks.md, specs/) for <feature>')
48
- .option('-p, --prompt <text>', 'One-line description for the AI to seed the proposal')
49
- .option('-t, --type <type>', 'Change type: feature | bug | refactor | incident', 'feature')
50
- .option('--offline', 'Scaffold from templates without calling the openspec CLI')
51
- .action((feature, opts) => audited('propose', runPropose)({ feature, prompt: opts.prompt, type: opts.type, offline: opts.offline }).catch(fatal));
52
-
53
- program
54
- .command('decompose <feature>')
55
- .description('Extract tasks from proposal + BDD scenarios into tasks.md')
56
- .option('--force', 'Merge into an existing tasks.md instead of refusing')
57
- .action((feature, opts) => audited('decompose', runDecompose)({ feature, force: opts.force }).catch(fatal));
58
-
59
- program
60
- .command('sync [feature]')
61
- .description('Push an OpenSpec change to the configured PM tool (idempotent; BDD-linted)')
62
- .option('--all', 'Sync every change under openspec/changes/')
63
- .option('--dry-run', 'Print the call plan without making remote changes')
64
- .option('--force', 'Bypass BDD lint errors')
65
- .option('--diff', 'Show the call plan in detail')
66
- .option('-y, --yes', 'Skip the confirmation prompt for --all')
67
- .action((feature, opts) => {
68
- if (opts.all) {
69
- return audited('sync-all', runSyncAll)({ dryRun: opts.dryRun, force: opts.force, yes: opts.yes }).catch(fatal);
70
- }
71
- if (!feature) {
72
- process.stderr.write('error: provide a <feature> or pass --all\n');
73
- process.exit(1);
74
- }
75
- return audited('sync', runSync)({ feature, dryRun: opts.dryRun, force: opts.force, diff: opts.diff }).catch(fatal);
76
- });
77
-
78
- program
79
- .command('comment <feature> <task>')
80
- .description('Post a progress comment to the PM tool work item')
81
- .option('-m, --message <text>', 'Inline message (otherwise reads from local progress.md)')
82
- .option('--dry-run', 'Print the comment instead of posting')
83
- .action((feature, task, opts) =>
84
- audited('comment', runComment)({ feature, task, message: opts.message, dryRun: opts.dryRun }).catch(fatal),
85
- );
86
-
87
- program
88
- .command('reconcile <feature>')
89
- .description('Pull remote state back into local task frontmatter')
90
- .option('--dry-run', 'Show drift without writing local changes')
91
- .action((feature, opts) => audited('reconcile', runReconcile)({ feature, dryRun: opts.dryRun }).catch(fatal));
92
-
93
- program
94
- .command('status')
95
- .description('Local snapshot: configured adapter + per-change task counts')
96
- .action(() => audited('status', runStatus)().catch(fatal));
97
-
98
- program
99
- .command('standup')
100
- .description('Show progress updates within a recent window')
101
- .option('--since <window>', 'Time window: e.g. 12h, 2d, 1w', '24h')
102
- .option('--broadcast', 'Also POST to configured Slack/Teams/generic webhooks')
103
- .action((opts) => audited('standup', runStandup)({ since: opts.since, broadcast: opts.broadcast }).catch(fatal));
104
-
105
- program
106
- .command('next')
107
- .description('List tasks ready to start (no unmet dependencies)')
108
- .option('-l, --limit <n>', 'Max items to show', (v) => parseInt(v, 10), 5)
109
- .action((opts) => audited('next', runNext)({ limit: opts.limit }).catch(fatal));
110
-
111
- program
112
- .command('blocked')
113
- .description('List tasks waiting on unmet dependencies')
114
- .action(() => audited('blocked', runBlocked)().catch(fatal));
115
-
116
- program
117
- .command('validate')
118
- .description('Schema + dependency + BDD-lint sweep across every change')
119
- .action(() => audited('validate', runValidate)().catch(fatal));
120
-
121
- program
122
- .command('search <query>')
123
- .description('Grep across proposals, specs, tasks, and progress notes')
124
- .option('-l, --limit <n>', 'Max matches to show', (v) => parseInt(v, 10), 50)
125
- .option('--case-sensitive', 'Match case')
126
- .action((query, opts) =>
127
- audited('search', runSearch)({ query, limit: opts.limit, caseSensitive: opts.caseSensitive }).catch(fatal),
128
- );
129
-
130
- program
131
- .command('fan-out <feature>')
132
- .description('Emit ready-to-paste agent prompts for parallel:true tasks')
133
- .option('-l, --limit <n>', 'Max prompts to emit', (v) => parseInt(v, 10), 5)
134
- .action((feature, opts) => audited('fan-out', runFanOut)({ feature, limit: opts.limit }).catch(fatal));
135
-
136
- program
137
- .command('bug-report <feature> <task>')
138
- .description('File a regression bug linked to a shipped task')
139
- .option('-t, --title <text>', 'Bug title (required)')
140
- .option('-b, --body <text>', 'Bug body')
141
- .action((feature, task, opts) =>
142
- audited('bug-report', runBugReport)({ feature, task, title: opts.title, body: opts.body }).catch(fatal),
143
- );
144
-
145
- program
146
- .command('assign <feature> <task>')
147
- .description('Set assignee / sprint / iteration / area / story-points on a synced task')
148
- .option('--assignee <id>', 'Assignee id (backend-specific)')
149
- .option('--sprint <id>', 'Sprint / cycle / milestone id (whichever the backend supports)')
150
- .option('--iteration <path>', 'Iteration path (Azure DevOps; alias for --sprint elsewhere)')
151
- .option('--area <path>', 'Area path (Azure DevOps)')
152
- .option('--story-points <n>', 'Story points / estimate / weight')
153
- .action((feature, task, opts) =>
154
- audited('assign', runAssign)({
155
- feature, task,
156
- assignee: opts.assignee, sprint: opts.sprint, iteration: opts.iteration,
157
- area: opts.area, storyPoints: opts.storyPoints,
158
- }).catch(fatal),
159
- );
160
-
161
- program
162
- .command('ship [feature]')
163
- .description('Close all work items for <feature> and archive the OpenSpec change')
164
- .option('--all-ready', 'Ship every change whose tasks are all synced (no pending/failed)')
165
- .option('-y, --yes', 'Skip the confirmation prompt')
166
- .option('--skip-archive', 'Close work items but leave openspec/changes/<feature>/ in place')
167
- .action((feature, opts) => {
168
- if (opts.allReady) {
169
- return audited('ship-all-ready', runShipAllReady)({ yes: opts.yes, skipArchive: opts.skipArchive }).catch(fatal);
170
- }
171
- if (!feature) {
172
- process.stderr.write('error: provide a <feature> or pass --all-ready\n');
173
- process.exit(1);
174
- }
175
- return audited('ship', runShip)({ feature, yes: opts.yes, skipArchive: opts.skipArchive }).catch(fatal);
176
- });
177
-
178
- program
179
- .command('watch [feature]')
180
- .description('Re-lint on file change. Use --all to validate the whole project on every change.')
181
- .option('--all', 'Validate every change instead of linting one feature')
182
- .option('--debounce <ms>', 'Debounce window in milliseconds', (v) => parseInt(v, 10), 300)
183
- .action((feature, opts) =>
184
- audited('watch', runWatch)({ feature, allChanges: opts.all, debounceMs: opts.debounce }).catch(fatal),
185
- );
186
-
187
- program
188
- .command('help-table [topic]')
189
- .description('Context-aware help grouped by workflow phase')
190
- .action((topic) => { runHelp({ topic }); });
191
-
192
- program.parseAsync(process.argv);
193
-
194
- function fatal(err) {
195
- process.stderr.write(`\n✖ ${err.message}\n`);
196
- if (err.remediation) process.stderr.write(` → ${err.remediation}\n`);
197
- process.exit(1);
198
- }
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { Command } from 'commander';
4
+ import { audited } from '../src/audit.js';
5
+
6
+ // Read version from package.json so `npm version` bumps and the CLI's
7
+ // `--version` output stay in sync. package.json always ships in the
8
+ // npm tarball, so this resolves correctly under `npx openspecpm`.
9
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
10
+ import { runInit } from '../src/commands/init.js';
11
+ import { runDoctor } from '../src/commands/doctor.js';
12
+ import { runPropose } from '../src/commands/propose.js';
13
+ import { runDecompose } from '../src/commands/decompose.js';
14
+ import { runSync } from '../src/commands/sync.js';
15
+ import { runComment } from '../src/commands/comment.js';
16
+ import { runReconcile } from '../src/commands/reconcile.js';
17
+ import { runStatus } from '../src/commands/status.js';
18
+ import { runStandup } from '../src/commands/standup.js';
19
+ import { runNext } from '../src/commands/next.js';
20
+ import { runBlocked } from '../src/commands/blocked.js';
21
+ import { runValidate } from '../src/commands/validate.js';
22
+ import { runSearch } from '../src/commands/search.js';
23
+ import { runFanOut } from '../src/commands/fan-out.js';
24
+ import { runBugReport } from '../src/commands/bug-report.js';
25
+ import { runShip } from '../src/commands/ship.js';
26
+ import { runHelp } from '../src/commands/help.js';
27
+ import { runAssign } from '../src/commands/assign.js';
28
+ import { runSyncAll, runShipAllReady } from '../src/commands/bulk.js';
29
+ import { runWatch } from '../src/commands/watch.js';
30
+
31
+ const program = new Command();
32
+
33
+ program
34
+ .name('openspecpm')
35
+ .description('Spec-driven, BDD-shaped project management for AI agents.')
36
+ .version(pkg.version);
37
+
38
+ program
39
+ .command('init')
40
+ .description('Interactive wizard: pick a PM tool and write .openspecpm/config.json')
41
+ .option('--non-interactive', 'Fail instead of prompting when input is required')
42
+ .action((opts) => audited('init', runInit)(opts).catch(fatal));
43
+
44
+ program
45
+ .command('doctor [adapter]')
46
+ .description('Check auth + tooling health for one or all adapters')
47
+ .option('--install', 'Print OS-specific install commands for missing CLIs')
48
+ .option('--setup-auth', 'Print PAT/token URLs and required scopes')
49
+ .action((adapter, opts) => audited('doctor', runDoctor)({ adapter, install: opts.install, setupAuth: opts.setupAuth }).catch(fatal));
50
+
51
+ program
52
+ .command('propose <feature>')
53
+ .description('Create an OpenSpec proposal (proposal.md, design.md, tasks.md, specs/) for <feature>')
54
+ .option('-p, --prompt <text>', 'One-line description for the AI to seed the proposal')
55
+ .option('-t, --type <type>', 'Change type: feature | bug | refactor | incident', 'feature')
56
+ .option('--offline', 'Scaffold from templates without calling the openspec CLI')
57
+ .option('--llm', 'Augment BDD soft-lint with the LLM judge (requires ANTHROPIC_API_KEY)')
58
+ .action((feature, opts) => audited('propose', runPropose)({ feature, prompt: opts.prompt, type: opts.type, offline: opts.offline, llm: opts.llm }).catch(fatal));
59
+
60
+ program
61
+ .command('decompose <feature>')
62
+ .description('Extract tasks from proposal + BDD scenarios into tasks.md')
63
+ .option('--force', 'Merge into an existing tasks.md instead of refusing')
64
+ .action((feature, opts) => audited('decompose', runDecompose)({ feature, force: opts.force }).catch(fatal));
65
+
66
+ program
67
+ .command('sync [feature]')
68
+ .description('Push an OpenSpec change to the configured PM tool (idempotent; BDD-linted)')
69
+ .option('--all', 'Sync every change under openspec/changes/')
70
+ .option('--dry-run', 'Print the call plan without making remote changes')
71
+ .option('--force', 'Bypass BDD lint errors')
72
+ .option('--diff', 'Show the call plan in detail')
73
+ .option('--llm', 'Augment BDD hard-lint with the LLM judge (requires ANTHROPIC_API_KEY)')
74
+ .option('-y, --yes', 'Skip the confirmation prompt for --all')
75
+ .action((feature, opts) => {
76
+ if (opts.all) {
77
+ return audited('sync-all', runSyncAll)({ dryRun: opts.dryRun, force: opts.force, yes: opts.yes }).catch(fatal);
78
+ }
79
+ if (!feature) {
80
+ process.stderr.write('error: provide a <feature> or pass --all\n');
81
+ process.exit(1);
82
+ }
83
+ return audited('sync', runSync)({ feature, dryRun: opts.dryRun, force: opts.force, diff: opts.diff, llm: opts.llm }).catch(fatal);
84
+ });
85
+
86
+ program
87
+ .command('comment <feature> <task>')
88
+ .description('Post a progress comment to the PM tool work item')
89
+ .option('-m, --message <text>', 'Inline message (otherwise reads from local progress.md)')
90
+ .option('--dry-run', 'Print the comment instead of posting')
91
+ .action((feature, task, opts) =>
92
+ audited('comment', runComment)({ feature, task, message: opts.message, dryRun: opts.dryRun }).catch(fatal),
93
+ );
94
+
95
+ program
96
+ .command('reconcile <feature>')
97
+ .description('Pull remote state back into local task frontmatter')
98
+ .option('--dry-run', 'Show drift without writing local changes')
99
+ .action((feature, opts) => audited('reconcile', runReconcile)({ feature, dryRun: opts.dryRun }).catch(fatal));
100
+
101
+ program
102
+ .command('status')
103
+ .description('Local snapshot: configured adapter + per-change task counts')
104
+ .action(() => audited('status', runStatus)().catch(fatal));
105
+
106
+ program
107
+ .command('standup')
108
+ .description('Show progress updates within a recent window')
109
+ .option('--since <window>', 'Time window: e.g. 12h, 2d, 1w', '24h')
110
+ .option('--broadcast', 'Also POST to configured Slack/Teams/generic webhooks')
111
+ .action((opts) => audited('standup', runStandup)({ since: opts.since, broadcast: opts.broadcast }).catch(fatal));
112
+
113
+ program
114
+ .command('next')
115
+ .description('List tasks ready to start (no unmet dependencies)')
116
+ .option('-l, --limit <n>', 'Max items to show', (v) => parseInt(v, 10), 5)
117
+ .action((opts) => audited('next', runNext)({ limit: opts.limit }).catch(fatal));
118
+
119
+ program
120
+ .command('blocked')
121
+ .description('List tasks waiting on unmet dependencies')
122
+ .action(() => audited('blocked', runBlocked)().catch(fatal));
123
+
124
+ program
125
+ .command('validate')
126
+ .description('Schema + dependency + BDD-lint sweep across every change')
127
+ .option('--llm', 'Augment BDD lint with the LLM judge (requires ANTHROPIC_API_KEY)')
128
+ .action((opts) => audited('validate', runValidate)({ llm: opts.llm }).catch(fatal));
129
+
130
+ program
131
+ .command('search <query>')
132
+ .description('Grep across proposals, specs, tasks, and progress notes')
133
+ .option('-l, --limit <n>', 'Max matches to show', (v) => parseInt(v, 10), 50)
134
+ .option('--case-sensitive', 'Match case')
135
+ .action((query, opts) =>
136
+ audited('search', runSearch)({ query, limit: opts.limit, caseSensitive: opts.caseSensitive }).catch(fatal),
137
+ );
138
+
139
+ program
140
+ .command('fan-out <feature>')
141
+ .description('Emit ready-to-paste agent prompts for parallel:true tasks')
142
+ .option('-l, --limit <n>', 'Max prompts to emit', (v) => parseInt(v, 10), 5)
143
+ .action((feature, opts) => audited('fan-out', runFanOut)({ feature, limit: opts.limit }).catch(fatal));
144
+
145
+ program
146
+ .command('bug-report <feature> <task>')
147
+ .description('File a regression bug linked to a shipped task')
148
+ .option('-t, --title <text>', 'Bug title (required)')
149
+ .option('-b, --body <text>', 'Bug body')
150
+ .action((feature, task, opts) =>
151
+ audited('bug-report', runBugReport)({ feature, task, title: opts.title, body: opts.body }).catch(fatal),
152
+ );
153
+
154
+ program
155
+ .command('assign <feature> <task>')
156
+ .description('Set assignee / sprint / iteration / area / story-points on a synced task')
157
+ .option('--assignee <id>', 'Assignee id (backend-specific)')
158
+ .option('--sprint <id>', 'Sprint / cycle / milestone id (whichever the backend supports)')
159
+ .option('--iteration <path>', 'Iteration path (Azure DevOps; alias for --sprint elsewhere)')
160
+ .option('--area <path>', 'Area path (Azure DevOps)')
161
+ .option('--story-points <n>', 'Story points / estimate / weight')
162
+ .action((feature, task, opts) =>
163
+ audited('assign', runAssign)({
164
+ feature, task,
165
+ assignee: opts.assignee, sprint: opts.sprint, iteration: opts.iteration,
166
+ area: opts.area, storyPoints: opts.storyPoints,
167
+ }).catch(fatal),
168
+ );
169
+
170
+ program
171
+ .command('ship [feature]')
172
+ .description('Close all work items for <feature> and archive the OpenSpec change')
173
+ .option('--all-ready', 'Ship every change whose tasks are all synced (no pending/failed)')
174
+ .option('-y, --yes', 'Skip the confirmation prompt')
175
+ .option('--skip-archive', 'Close work items but leave openspec/changes/<feature>/ in place')
176
+ .action((feature, opts) => {
177
+ if (opts.allReady) {
178
+ return audited('ship-all-ready', runShipAllReady)({ yes: opts.yes, skipArchive: opts.skipArchive }).catch(fatal);
179
+ }
180
+ if (!feature) {
181
+ process.stderr.write('error: provide a <feature> or pass --all-ready\n');
182
+ process.exit(1);
183
+ }
184
+ return audited('ship', runShip)({ feature, yes: opts.yes, skipArchive: opts.skipArchive }).catch(fatal);
185
+ });
186
+
187
+ program
188
+ .command('watch [feature]')
189
+ .description('Re-lint on file change. Use --all to validate the whole project on every change.')
190
+ .option('--all', 'Validate every change instead of linting one feature')
191
+ .option('--debounce <ms>', 'Debounce window in milliseconds', (v) => parseInt(v, 10), 300)
192
+ .action((feature, opts) =>
193
+ audited('watch', runWatch)({ feature, allChanges: opts.all, debounceMs: opts.debounce }).catch(fatal),
194
+ );
195
+
196
+ program
197
+ .command('help-table [topic]')
198
+ .description('Context-aware help grouped by workflow phase')
199
+ .action((topic) => { runHelp({ topic }); });
200
+
201
+ // Sanitize uncaught errors so a future programming bug (TypeError, etc.)
202
+ // doesn't dump a stack trace with absolute install / tmp paths to stderr.
203
+ // fatal() is the normal path; these handlers are insurance for code paths
204
+ // that don't reach Commander's .catch(fatal).
205
+ process.on('uncaughtException', fatal);
206
+ process.on('unhandledRejection', (reason) => {
207
+ fatal(reason instanceof Error ? reason : new Error(String(reason ?? 'unknown')));
208
+ });
209
+
210
+ program.parseAsync(process.argv);
211
+
212
+ function fatal(err) {
213
+ const msg = err?.message ?? String(err ?? 'unknown error');
214
+ process.stderr.write(`\n✖ ${msg}\n`);
215
+ if (err?.remediation) process.stderr.write(` → ${err.remediation}\n`);
216
+ process.stderr.write(` See .openspecpm/audit.log for details.\n`);
217
+ process.exit(1);
218
+ }
@@ -2,6 +2,11 @@ import { Adapter, AdapterError } from './base.js';
2
2
  import { HttpClient, basicAuth } from '../http.js';
3
3
  import { TokenBucket, PRESETS } from '../ratelimit.js';
4
4
 
5
+ // User-controlled ids (from tasks.md frontmatter) MUST be encoded before
6
+ // interpolation into URL paths or body URL values, or a value like
7
+ // "1/../99" can reach unintended endpoints.
8
+ const enc = (v) => encodeURIComponent(String(v));
9
+
5
10
  const API_VERSION = '7.1';
6
11
  const COMMENTS_API_VERSION = '7.1-preview.3';
7
12
 
@@ -151,11 +156,11 @@ export class AzureAdapter extends Adapter {
151
156
  path: '/relations/-',
152
157
  value: {
153
158
  rel: 'System.LinkTypes.Hierarchy-Reverse',
154
- url: `${this.config.baseUrl ?? `https://dev.azure.com/${this.config.organization}`}/_apis/wit/workItems/${parent.id}`,
159
+ url: `${this.config.baseUrl ?? `https://dev.azure.com/${this.config.organization}`}/_apis/wit/workItems/${enc(parent.id)}`,
155
160
  },
156
161
  },
157
162
  ];
158
- const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${child.id}`;
163
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${enc(child.id)}`;
159
164
  await this.#req('PATCH', path, {
160
165
  query: { 'api-version': API_VERSION },
161
166
  body: ops,
@@ -164,7 +169,7 @@ export class AzureAdapter extends Adapter {
164
169
  }
165
170
 
166
171
  async addProgressComment(item, body) {
167
- const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workItems/${item.id}/comments`;
172
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workItems/${enc(item.id)}/comments`;
168
173
  await this.#req('POST', path, {
169
174
  query: { 'api-version': COMMENTS_API_VERSION },
170
175
  body: { text: body },
@@ -179,7 +184,7 @@ export class AzureAdapter extends Adapter {
179
184
  if (patch.iterationPath) ops.push({ op: 'add', path: '/fields/System.IterationPath', value: patch.iterationPath });
180
185
  if (patch.areaPath) ops.push({ op: 'add', path: '/fields/System.AreaPath', value: patch.areaPath });
181
186
  if (!ops.length) return;
182
- const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${item.id}`;
187
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${enc(item.id)}`;
183
188
  await this.#req('PATCH', path, {
184
189
  query: { 'api-version': API_VERSION },
185
190
  body: ops,
@@ -193,7 +198,7 @@ export class AzureAdapter extends Adapter {
193
198
  }
194
199
 
195
200
  async getWorkItem(item) {
196
- const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${item.id}`;
201
+ const path = `/${encodeURIComponent(this.#project())}/_apis/wit/workitems/${enc(item.id)}`;
197
202
  const data = await this.#req('GET', path, { query: { 'api-version': API_VERSION } });
198
203
  return {
199
204
  ref: { adapter: 'azure', id: String(data.id), url: data._links?.html?.href },
@@ -206,6 +211,17 @@ export class AzureAdapter extends Adapter {
206
211
 
207
212
  async listWorkItems(query = {}) {
208
213
  const tag = query.tag ?? `openspec`;
214
+ // Defense in depth: WIQL string-literal syntax means `'` is the only
215
+ // breakout char and we already double it. But there's no known WIQL
216
+ // feature that escapes a literal via other chars, so reject anything
217
+ // outside a known-safe set rather than trust escaping alone. Tags in
218
+ // this codebase are always `openspec` or `openspec:<feature>`; the
219
+ // feature side is validated by openspec-bridge.assertSafeFeatureName.
220
+ if (!/^[a-zA-Z0-9._:-]+$/.test(String(tag))) {
221
+ throw new AdapterError(`Unsafe tag for WIQL: "${tag}".`, {
222
+ remediation: 'Tag must match /^[a-zA-Z0-9._:-]+$/. Use a plain slug.',
223
+ });
224
+ }
209
225
  const wiql = `SELECT [System.Id], [System.Title], [System.State], [System.Tags], [System.AssignedTo] FROM workitems WHERE [System.TeamProject] = @project AND [System.Tags] CONTAINS '${tag.replace(/'/g, "''")}' ORDER BY [System.Id] DESC`;
210
226
  const path = `/${encodeURIComponent(this.#project())}/_apis/wit/wiql`;
211
227
  const res = await this.#req('POST', path, {
@@ -2,6 +2,11 @@ import { Adapter, AdapterError } from './base.js';
2
2
  import { HttpClient } from '../http.js';
3
3
  import { TokenBucket } from '../ratelimit.js';
4
4
 
5
+ // User-controlled ids (from tasks.md frontmatter) MUST be encoded before
6
+ // interpolation into URL paths, or a value like "1/../99" can reach
7
+ // unintended project endpoints.
8
+ const enc = (v) => encodeURIComponent(String(v));
9
+
5
10
  const STATE_TO_NORMALIZED = (s) => {
6
11
  const v = (s ?? '').toLowerCase();
7
12
  if (v === 'closed') return 'closed';
@@ -109,7 +114,7 @@ export class GitLabAdapter extends Adapter {
109
114
  }
110
115
 
111
116
  async linkWorkItems(parent, child, type = 'relates_to') {
112
- await this.#req('POST', `/projects/${this.#project()}/issues/${child.id}/links`, {
117
+ await this.#req('POST', `/projects/${this.#project()}/issues/${enc(child.id)}/links`, {
113
118
  body: {
114
119
  target_project_id: this.config.projectId,
115
120
  target_issue_iid: parent.id,
@@ -119,7 +124,7 @@ export class GitLabAdapter extends Adapter {
119
124
  }
120
125
 
121
126
  async addProgressComment(item, body) {
122
- await this.#req('POST', `/projects/${this.#project()}/issues/${item.id}/notes`, {
127
+ await this.#req('POST', `/projects/${this.#project()}/issues/${enc(item.id)}/notes`, {
123
128
  body: { body },
124
129
  });
125
130
  }
@@ -134,18 +139,18 @@ export class GitLabAdapter extends Adapter {
134
139
  if (patch.milestoneId) body.milestone_id = patch.milestoneId; // sprint
135
140
  if (patch.weight !== undefined) body.weight = patch.weight; // story points
136
141
  if (!Object.keys(body).length) return;
137
- await this.#req('PUT', `/projects/${this.#project()}/issues/${item.id}`, { body });
142
+ await this.#req('PUT', `/projects/${this.#project()}/issues/${enc(item.id)}`, { body });
138
143
  }
139
144
 
140
145
  async closeWorkItem(item, resolution) {
141
- await this.#req('PUT', `/projects/${this.#project()}/issues/${item.id}`, {
146
+ await this.#req('PUT', `/projects/${this.#project()}/issues/${enc(item.id)}`, {
142
147
  body: { state_event: 'close' },
143
148
  });
144
149
  if (resolution) await this.addProgressComment(item, resolution);
145
150
  }
146
151
 
147
152
  async getWorkItem(item) {
148
- const data = await this.#req('GET', `/projects/${this.#project()}/issues/${item.id}`);
153
+ const data = await this.#req('GET', `/projects/${this.#project()}/issues/${enc(item.id)}`);
149
154
  return {
150
155
  ref: { adapter: 'gitlab', id: String(data.iid), url: data.web_url },
151
156
  title: data.title,