openspecpm 1.0.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.1] - 2026-05-18
11
+
12
+ ### Security hardening — pass through audit findings
13
+
14
+ Eleven commits addressing every HIGH / MEDIUM / LOW-with-security-impact finding from the v1.0.0 quality audit. 113/113 tests; +14 new regression tests across the touched surfaces.
15
+
16
+ **HIGH severity:**
17
+
18
+ - **`cli/src/http.js`** — every adapter HTTP request now bounded by `AbortSignal.timeout(timeoutMs)` (default 30s). A hung Jira / ADO / Linear / GitLab endpoint no longer wedges `sync --all` indefinitely. New test confirms the request rejects within budget, not at wall-clock max.
19
+ - **`cli/src/adapters/azure.js`** + **`cli/src/adapters/gitlab.js`** — user-controlled `item.id` / `child.id` / `parent.id` (from `tasks.md` frontmatter, user-editable) now wrapped in `encodeURIComponent` before URL-path interpolation. Closes a path-injection vector — a typo'd `external_id: "1/../99"` no longer reaches unintended endpoints. Jira was already correct.
20
+ - **`cli/src/tracking.js`** — `loadChange` now validates that `tasks.md` frontmatter `items:` is an array of well-shaped objects (each with a string `title`). Malformed entries are silently dropped; non-array `items:` raises a clear error with a remediation pointing at the file to repair. Malformed YAML itself raises with file context instead of crashing deep with `TypeError: items is not iterable`.
21
+ - **`cli/src/openspec-bridge.js`** — new `assertSafeFeatureName()` rejects empty / non-string input, anything containing `..` / `/` / `\` / Windows drive letters, and anything outside `/^[a-z0-9][a-z0-9._-]*$/i`. Called at the top of `changeDir()`, `changeExists()`, and `propose()` so every entry point into the OpenSpec layout validates first. Closes path-traversal via feature name.
22
+ - **`cli/src/commands/sync.js`** + **`cli/src/commands/bulk.js`** — `sync`, `sync --all`, and `ship --all-ready` now throw at the end if any task or change failed, instead of swallowing per-task errors and returning 0. `openspecpm sync feature && deploy` in CI no longer proceeds on silent partial sync. Per-task `last_error` still persisted to `tasks.md` frontmatter for inspection.
23
+ - **`cli/src/notify.js`** — `fetch` response status is now checked instead of being discarded. A 401 / 403 / 500 from Slack / Teams / generic counts as an error, not a successful send. `standup --broadcast` no longer claims success when the webhook silently rejected.
24
+
25
+ **MEDIUM severity:**
26
+
27
+ - **`cli/tests/github-adapter.test.js`** — locked the leading-dash title behavior under array argv (M3). No code change needed; execa array-args + cobra's flag parser already handle `--title "--evil-flag"` correctly, but the regression test prevents a future refactor from breaking it.
28
+ - **`cli/src/audit.js`** — `SECRET_SEGMENTS` extended to include `bearer`, `cookie`, `session`, `webhook`, `signature`, `assertion`. New `scrubValue()` redacts webhook URLs from any string value (Slack hooks, MS Teams connectors, M365 webhooks). `record()`'s `result` and `error` fields now also go through `scrubValue`. **`cli/src/notify.js`** sanitizes its `target.url` from every error message at the source. Two-layer defense: the URL can't leak from notify's error path, and even if some other code path puts a webhook URL in an audit entry, the sink redacts it. (M6 + M11 + LOW-4)
29
+ - **`cli/bin/openspecpm.js`** — installed `uncaughtException` and `unhandledRejection` handlers that print sanitized message + remediation + a pointer to `audit.log`. A future programming bug (TypeError, etc.) no longer dumps Node's default stack trace with absolute install / tmp paths to stderr. (M8)
30
+ - **`cli/src/commands/sync.js`** — `extractSummary` now strips C0/C1 control chars (except TAB/LF/CR), DEL, zero-width / joiner chars, bidi overrides (LRE/RLE/PDF/LRO/RLO), and isolates (LRI/RLI/FSI/PDI) before sending the proposal body to the remote tracker as the epic description. Closes homograph / hidden-content vector in issue titles and bodies. (M9)
31
+
32
+ **LOW severity (defense-in-depth):**
33
+
34
+ - **`cli/src/adapters/azure.js`** — `listWorkItems` now allowlist-validates the WIQL `tag` against `/^[a-zA-Z0-9._:-]+$/`. Single-quote doubling escape already handled the only known WIQL string-literal breakout, but allowlist validation is more robust than escape-based protection. (LOW-1)
35
+
36
+ ### Post-1.0 doc sweep
37
+
38
+ - **README.md:** lede tagline + flow Mermaid + "differences from CCPM" section now reference all five PM backends (GitHub, Azure DevOps, Jira, **Linear, GitLab**) instead of the original three. The "three differences" framing bumped to five — added bullets for audit-log-by-default and cross-feature task graphs, with the LLM judge folded into the BDD-authoring bullet.
39
+ - **README cross-cutting line:** added optional Slack / Teams / generic-webhook broadcasts on `standup --broadcast`.
40
+ - **README Roadmap:** marked `bdd-llm-reviewer` as shipped (no longer in the active 6); count adjusted to 5 remaining.
41
+ - **README Project structure:** rewritten to reflect actual `cli/src/` contents (20 commands, 5 adapters under `cli/src/adapters/`, `audit.js` / `notify.js` / `telemetry.js` / `install-hints.js` / `bdd/judge.js`), workflow set (`test.yml` + `auto-approve.yml` + `release.yml` + `publish.yml`), `docs/screenshots/`, and `openspec/changes/` v2 roadmap directory.
42
+ - **SKILL.md:** "three differences" → "five differences"; "(Sprint 3+)" / "(Sprint 3)" markers stripped from the workflow header and the phase table.
43
+ - **SECURITY.md:** out-of-scope vendor list extended with Linear + GitLab (the in-scope secrets list already had them).
44
+ - **`skill/openspecpm/references/{conventions,sync,execute,plan}.md`:** "(Sprint N)" / "(Sprint N+)" annotations stripped throughout — those markers were pre-1.0 development history noise, no longer meaningful.
45
+ - **`openspec/changes/README.md`:** added a "Shipped" section calling out `bdd-llm-reviewer` (v1.0.0); active roadmap table reduced from 6 to 5 changes; totals recomputed (47 tasks, ~178 hrs).
46
+ - **`openspec/changes/bdd-llm-reviewer/proposal.md`:** frontmatter `status: draft` → `status: shipped` with `shipped_in: 1.0.0` and `shipped_at: 2026-05-18`.
47
+ - **`cli/bin/openspecpm.js`:** `program.version()` now reads from `package.json` at runtime instead of the hardcoded `'0.1.0-alpha.0'`. Future `npm version` bumps keep `openspecpm --version` in sync automatically.
48
+
10
49
  ## [1.0.0] - 2026-05-18
11
50
 
12
51
  ### Fix — release.yml must use a user-owned PAT, not GITHUB_TOKEN
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![tests](https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2Faks-builds%2F03ce34dc5c6486c004dd8cf4c27ea87c%2Fraw%2Ftests.json)](cli/tests)
6
6
 
7
- > Spec-driven, BDD-shaped project management for AI agents — author once in [OpenSpec](https://github.com/Fission-AI/OpenSpec), sync to GitHub Issues, Azure DevOps Boards, or Jira.
7
+ > Spec-driven, BDD-shaped project management for AI agents — author once in [OpenSpec](https://github.com/Fission-AI/OpenSpec), sync to GitHub Issues, Azure DevOps Boards, Jira, Linear, or GitLab.
8
8
 
9
9
  OpenSpecPM turns natural-language intent ("plan X", "sync the X epic", "what's blocked", "ship X") into a disciplined flow:
10
10
 
@@ -14,7 +14,7 @@ flowchart LR
14
14
  Proposal["📝 **proposal.md**<br/>(OpenSpec)"]
15
15
  BDD["📋 **BDD specs**<br/>Given / When / Then"]
16
16
  Tasks["✅ **tasks**"]
17
- Tracked["🎯 **Tracked work items**<br/>GitHub · ADO · Jira"]
17
+ Tracked["🎯 **Tracked work items**<br/>GitHub · ADO · Jira · Linear · GitLab"]
18
18
  Shipped["🚀 **Shipped code**"]
19
19
 
20
20
  Idea --> Proposal --> BDD --> Tasks --> Tracked --> Shipped
@@ -30,127 +30,13 @@ flowchart LR
30
30
  class Shipped doneC
31
31
  ```
32
32
 
33
- It is a sibling of [CCPM](https://github.com/automazeio/ccpm), with three differences:
33
+ It is a sibling of [CCPM](https://github.com/automazeio/ccpm), with five differences:
34
34
 
35
- 1. **OpenSpec drives spec authoring** every feature gets `proposal.md`, `design.md`, `tasks.md`, and a `specs/` folder of BDD scenarios.
36
- 2. **The PM tool is pluggable** — an interactive wizard at `init` time picks GitHub Issues/Projects, Azure DevOps Boards, or Jira.
37
- 3. **Built for non-engineers too** — PMs/BAs/PgMs can drive the flow. A `doctor` command owns auth-setup pain. Worktrees are hidden by default.
38
-
39
- ## Install
40
-
41
- ```bash
42
- # Standalone CLI (any harness)
43
- npx openspecpm@latest init
44
-
45
- # Claude Code Agent Skill
46
- # Copy skill/openspecpm/ into your Claude Code skills directory.
47
- # SKILL.md handles routing — just talk to Claude.
48
- ```
49
-
50
- OpenSpecPM shells out to [OpenSpec](https://github.com/Fission-AI/OpenSpec); install it first:
51
-
52
- ```bash
53
- npm install -g @fission-ai/openspec
54
- ```
55
-
56
- ## In action
57
-
58
- > A walkthrough of OpenSpecPM running against two sample features (`dark-mode`, `auth-rate-limit`). Source images live in [`docs/screenshots/`](docs/screenshots/); regenerate with `pwsh docs/screenshots/render.ps1` after CLI output changes — the renderer sets up its own sample data and cleans up after itself.
59
-
60
- **1 · Phase-grouped command reference** — `help-table` shows every command grouped by workflow phase (Setup → Plan → Sync → Track → Execute/Ship):
61
-
62
- ![openspecpm help-table](docs/screenshots/help-table.png)
63
-
64
- **2 · Health check across every adapter** — `doctor` diagnoses auth + tooling for all five backends (GitHub, Azure DevOps, Jira, Linear, GitLab) with an English remediation hint on every failure. The `[judge]` section reports `ANTHROPIC_API_KEY` for the optional LLM BDD judge:
65
-
66
- ![openspecpm doctor](docs/screenshots/doctor.png)
67
-
68
- **3 · Author a proposal** — `propose --offline` scaffolds `proposal.md`, `tasks.md`, and `specs/main.md` from templates without calling the OpenSpec CLI. Soft BDD lint runs immediately so placeholder Then-clauses are flagged before you keep editing:
69
-
70
- ![openspecpm propose](docs/screenshots/propose.png)
71
-
72
- **4 · Optional LLM BDD judge** — pass `--llm` (or set `judge.enabled: true` in `.openspecpm/config.json`) to augment the heuristic linter with Claude Haiku 4.5. The judge catches what regex can't: cross-spec contradictions (`bdd/llm-contradiction`), success criteria with no scenario (`bdd/llm-missing-coverage`), and Then-clauses that pass the verb check but state no observable outcome (`bdd/llm-vague-then`). Findings merge into the same lint stream and respect `--force` the same way. Prompt-cached on the proposal so re-runs across multiple specs stay cheap. *Sample output below — real findings vary per scenario set; the judge can't be regenerated by `render.ps1` because it requires a live `ANTHROPIC_API_KEY`:*
73
-
74
- ![openspecpm propose --llm](docs/screenshots/judge.png)
75
-
76
- **5 · Decompose into tasks** — `decompose` walks proposal headings, GitHub-style checklists, a `Tasks` section, and BDD scenarios under `specs/` and writes a structured `tasks.md` with `sync_state` frontmatter:
77
-
78
- ![openspecpm decompose](docs/screenshots/decompose.png)
79
-
80
- **6 · Multi-feature status** — `status` shows the configured adapter and per-change task counts (`synced / pending / failed / done`) at a glance:
81
-
82
- ![openspecpm status](docs/screenshots/status.png)
83
-
84
- **7 · "What can I work on right now?"** — `next` lists tasks with no unmet dependencies, marking parallel-safe ones so multiple agents can pick them up:
85
-
86
- ![openspecpm next](docs/screenshots/next.png)
87
-
88
- **8 · "What's waiting on what?"** — `blocked` lists every task held up by a dependency and names the blocker, so the path to unblocking is one read away:
89
-
90
- ![openspecpm blocked](docs/screenshots/blocked.png)
91
-
92
- **9 · Parallel-agent dispatch** — `fan-out` emits ready-to-paste prompts for `parallel: true` tasks with no unmet deps. Each prompt embeds the proposal summary, design notes, and BDD spec as acceptance criteria so subagents can work in isolation:
93
-
94
- ![openspecpm fan-out](docs/screenshots/fan-out.png)
95
-
96
- **10 · Cross-file search** — `search` is a case-insensitive grep across every proposal, spec, tasks file, and progress note. Useful for tracing back from a keyword to the change that owns it:
97
-
98
- ![openspecpm search](docs/screenshots/search.png)
99
-
100
- **11 · Project-wide validation** — `validate` runs the schema check and BDD linter across every change and reports per-feature error + warning counts:
101
-
102
- ![openspecpm validate](docs/screenshots/validate.png)
103
-
104
- ## Quick start
105
-
106
- ```bash
107
- # 1. One-time setup. The wizard asks which PM tool your team uses.
108
- npx openspecpm init
109
-
110
- # 2. Verify auth before doing anything remote.
111
- npx openspecpm doctor
112
-
113
- # 3. Author a proposal. OpenSpec generates proposal.md, design.md, tasks.md,
114
- # and BDD scenarios in specs/. Soft BDD-lint runs after authoring.
115
- npx openspecpm propose dark-mode --prompt "Per-user dark theme with persistence."
116
-
117
- # 4. Review the generated files. Refine BDD scenarios until lint is clean.
118
-
119
- # 5. Sync to the PM tool. Hard BDD lint runs first; pass --force to override.
120
- npx openspecpm sync dark-mode
121
-
122
- # 6. Pick up where you left off.
123
- npx openspecpm next # tasks ready to start
124
- npx openspecpm blocked # tasks waiting on dependencies
125
- npx openspecpm standup # progress updates in the last 24h
126
-
127
- # 7. When the feature is verified, close + archive.
128
- npx openspecpm ship dark-mode
129
- ```
130
-
131
- ## Command reference
132
-
133
- | Command | What it does |
134
- |---|---|
135
- | `init` | Interactive wizard. Picks the PM tool. Writes `.openspecpm/config.json`. |
136
- | `doctor [adapter]` | Auth/tooling health check. English remediation hints on every failure. |
137
- | `propose <feature> [--llm]` | Shell out to OpenSpec; create `openspec/changes/<feature>/`. Soft-lint BDD scenarios; `--llm` adds the LLM judge. |
138
- | `decompose <feature>` | Extract tasks from proposal headings/checklists + BDD scenarios into `tasks.md`. |
139
- | `sync <feature> [--llm]` | Hard-lint BDD, then create/update work items in the PM tool. Idempotent; `--llm` adds the LLM judge as a hard gate. |
140
- | `comment <feature> <task>` | Broadcast local `progress.md` (or `-m`) to the PM tool with `<!-- SYNCED -->` marker. |
141
- | `reconcile <feature>` | Pull remote work-item state into local frontmatter. Detects out-of-band closes. |
142
- | `bug-report <feature> <task> --title "…"` | File a linked regression against a shipped task. |
143
- | `status` | Per-change task counts: pending / created / failed / done. |
144
- | `standup [--since 24h]` | Recent `progress.md` updates, newest first. |
145
- | `next [-l 5]` | Tasks with no unmet dependencies. |
146
- | `blocked` | Tasks waiting on unmet dependencies (with reasons). |
147
- | `validate [--llm]` | Schema + dependency + BDD-lint sweep across every change; `--llm` adds the LLM judge per change. |
148
- | `search <query>` | Grep across proposals, specs, tasks, progress notes. |
149
- | `fan-out <feature>` | Emit ready-to-paste agent prompts for `parallel: true` tasks. |
150
- | `ship <feature> [-y]` | Close all task work items + close the epic + archive the OpenSpec change. |
151
- | `help-table [topic]` | Context-aware command reference grouped by workflow phase. |
152
-
153
- Every command appends a JSONL entry (secrets scrubbed) to `.openspecpm/audit.log`.
35
+ 1. **OpenSpec drives spec authoring + BDD scenarios become enforceable.** Every feature gets `proposal.md`, `design.md`, `tasks.md`, and a `specs/` folder of Given/When/Then scenarios. A heuristic linter blocks vague Thens at `sync` time. An optional [LLM judge](#architecture-highlights) (Claude Haiku 4.5, opt-in via `--llm`) catches cross-spec contradictions and missing-coverage gaps the regex linter can't see.
36
+ 2. **Five pluggable PM backends** — an interactive wizard at `init` time picks GitHub Issues/Projects, Azure DevOps Boards, Jira, Linear, or GitLab. New backends register without forking via `registerAdapter()`.
37
+ 3. **Built for non-engineers too** — PMs/BAs/PgMs can drive the flow. A `doctor` command owns auth-setup pain (with `--install` and `--setup-auth` flags for OS-specific install hints and PAT-creation URLs). Worktrees are hidden by default.
38
+ 4. **Audit-logged by default.** Every command appends a JSONL entry (secrets scrubbed) to `.openspecpm/audit.log`. Useful for regulated industries that need a paper trail; useful for the rest of us when something looks weird.
39
+ 5. **Cross-feature task graphs.** `depends_on:` can reach across changes (`<feature>/<task-title>` or `<feature>/<external-id>`), so `next` and `blocked` reflect the whole project rather than one feature in isolation.
154
40
 
155
41
  ## Architecture
156
42
 
@@ -314,7 +200,123 @@ flowchart LR
314
200
  class Shipped doneC
315
201
  ```
316
202
 
317
- > Cross-cutting on every command: audit log (`.openspecpm/audit.log`, secrets scrubbed) · token-bucket rate-limiting per adapter · OpenSpec version probe · optional opt-in telemetry.
203
+ > Cross-cutting on every command: audit log (`.openspecpm/audit.log`, secrets scrubbed) · token-bucket rate-limiting per adapter · OpenSpec version probe · optional opt-in telemetry · optional Slack / Teams / generic-webhook broadcasts on `standup --broadcast`.
204
+
205
+ ## Install
206
+
207
+ ```bash
208
+ # Standalone CLI (any harness)
209
+ npx openspecpm@latest init
210
+
211
+ # Claude Code Agent Skill
212
+ # Copy skill/openspecpm/ into your Claude Code skills directory.
213
+ # SKILL.md handles routing — just talk to Claude.
214
+ ```
215
+
216
+ OpenSpecPM shells out to [OpenSpec](https://github.com/Fission-AI/OpenSpec); install it first:
217
+
218
+ ```bash
219
+ npm install -g @fission-ai/openspec
220
+ ```
221
+
222
+ ## In action
223
+
224
+ > A walkthrough of OpenSpecPM running against two sample features (`dark-mode`, `auth-rate-limit`). Source images live in [`docs/screenshots/`](docs/screenshots/); regenerate with `pwsh docs/screenshots/render.ps1` after CLI output changes — the renderer sets up its own sample data and cleans up after itself.
225
+
226
+ **1 · Phase-grouped command reference** — `help-table` shows every command grouped by workflow phase (Setup → Plan → Sync → Track → Execute/Ship):
227
+
228
+ ![openspecpm help-table](docs/screenshots/help-table.png)
229
+
230
+ **2 · Health check across every adapter** — `doctor` diagnoses auth + tooling for all five backends (GitHub, Azure DevOps, Jira, Linear, GitLab) with an English remediation hint on every failure. The `[judge]` section reports `ANTHROPIC_API_KEY` for the optional LLM BDD judge:
231
+
232
+ ![openspecpm doctor](docs/screenshots/doctor.png)
233
+
234
+ **3 · Author a proposal** — `propose --offline` scaffolds `proposal.md`, `tasks.md`, and `specs/main.md` from templates without calling the OpenSpec CLI. Soft BDD lint runs immediately so placeholder Then-clauses are flagged before you keep editing:
235
+
236
+ ![openspecpm propose](docs/screenshots/propose.png)
237
+
238
+ **4 · Optional LLM BDD judge** — pass `--llm` (or set `judge.enabled: true` in `.openspecpm/config.json`) to augment the heuristic linter with Claude Haiku 4.5. The judge catches what regex can't: cross-spec contradictions (`bdd/llm-contradiction`), success criteria with no scenario (`bdd/llm-missing-coverage`), and Then-clauses that pass the verb check but state no observable outcome (`bdd/llm-vague-then`). Findings merge into the same lint stream and respect `--force` the same way. Prompt-cached on the proposal so re-runs across multiple specs stay cheap. *Sample output below — real findings vary per scenario set; the judge can't be regenerated by `render.ps1` because it requires a live `ANTHROPIC_API_KEY`:*
239
+
240
+ ![openspecpm propose --llm](docs/screenshots/judge.png)
241
+
242
+ **5 · Decompose into tasks** — `decompose` walks proposal headings, GitHub-style checklists, a `Tasks` section, and BDD scenarios under `specs/` and writes a structured `tasks.md` with `sync_state` frontmatter:
243
+
244
+ ![openspecpm decompose](docs/screenshots/decompose.png)
245
+
246
+ **6 · Multi-feature status** — `status` shows the configured adapter and per-change task counts (`synced / pending / failed / done`) at a glance:
247
+
248
+ ![openspecpm status](docs/screenshots/status.png)
249
+
250
+ **7 · "What can I work on right now?"** — `next` lists tasks with no unmet dependencies, marking parallel-safe ones so multiple agents can pick them up:
251
+
252
+ ![openspecpm next](docs/screenshots/next.png)
253
+
254
+ **8 · "What's waiting on what?"** — `blocked` lists every task held up by a dependency and names the blocker, so the path to unblocking is one read away:
255
+
256
+ ![openspecpm blocked](docs/screenshots/blocked.png)
257
+
258
+ **9 · Parallel-agent dispatch** — `fan-out` emits ready-to-paste prompts for `parallel: true` tasks with no unmet deps. Each prompt embeds the proposal summary, design notes, and BDD spec as acceptance criteria so subagents can work in isolation:
259
+
260
+ ![openspecpm fan-out](docs/screenshots/fan-out.png)
261
+
262
+ **10 · Cross-file search** — `search` is a case-insensitive grep across every proposal, spec, tasks file, and progress note. Useful for tracing back from a keyword to the change that owns it:
263
+
264
+ ![openspecpm search](docs/screenshots/search.png)
265
+
266
+ **11 · Project-wide validation** — `validate` runs the schema check and BDD linter across every change and reports per-feature error + warning counts:
267
+
268
+ ![openspecpm validate](docs/screenshots/validate.png)
269
+
270
+ ## Quick start
271
+
272
+ ```bash
273
+ # 1. One-time setup. The wizard asks which PM tool your team uses.
274
+ npx openspecpm init
275
+
276
+ # 2. Verify auth before doing anything remote.
277
+ npx openspecpm doctor
278
+
279
+ # 3. Author a proposal. OpenSpec generates proposal.md, design.md, tasks.md,
280
+ # and BDD scenarios in specs/. Soft BDD-lint runs after authoring.
281
+ npx openspecpm propose dark-mode --prompt "Per-user dark theme with persistence."
282
+
283
+ # 4. Review the generated files. Refine BDD scenarios until lint is clean.
284
+
285
+ # 5. Sync to the PM tool. Hard BDD lint runs first; pass --force to override.
286
+ npx openspecpm sync dark-mode
287
+
288
+ # 6. Pick up where you left off.
289
+ npx openspecpm next # tasks ready to start
290
+ npx openspecpm blocked # tasks waiting on dependencies
291
+ npx openspecpm standup # progress updates in the last 24h
292
+
293
+ # 7. When the feature is verified, close + archive.
294
+ npx openspecpm ship dark-mode
295
+ ```
296
+
297
+ ## Command reference
298
+
299
+ | Command | What it does |
300
+ |---|---|
301
+ | `init` | Interactive wizard. Picks the PM tool. Writes `.openspecpm/config.json`. |
302
+ | `doctor [adapter]` | Auth/tooling health check. English remediation hints on every failure. |
303
+ | `propose <feature> [--llm]` | Shell out to OpenSpec; create `openspec/changes/<feature>/`. Soft-lint BDD scenarios; `--llm` adds the LLM judge. |
304
+ | `decompose <feature>` | Extract tasks from proposal headings/checklists + BDD scenarios into `tasks.md`. |
305
+ | `sync <feature> [--llm]` | Hard-lint BDD, then create/update work items in the PM tool. Idempotent; `--llm` adds the LLM judge as a hard gate. |
306
+ | `comment <feature> <task>` | Broadcast local `progress.md` (or `-m`) to the PM tool with `<!-- SYNCED -->` marker. |
307
+ | `reconcile <feature>` | Pull remote work-item state into local frontmatter. Detects out-of-band closes. |
308
+ | `bug-report <feature> <task> --title "…"` | File a linked regression against a shipped task. |
309
+ | `status` | Per-change task counts: pending / created / failed / done. |
310
+ | `standup [--since 24h]` | Recent `progress.md` updates, newest first. |
311
+ | `next [-l 5]` | Tasks with no unmet dependencies. |
312
+ | `blocked` | Tasks waiting on unmet dependencies (with reasons). |
313
+ | `validate [--llm]` | Schema + dependency + BDD-lint sweep across every change; `--llm` adds the LLM judge per change. |
314
+ | `search <query>` | Grep across proposals, specs, tasks, progress notes. |
315
+ | `fan-out <feature>` | Emit ready-to-paste agent prompts for `parallel: true` tasks. |
316
+ | `ship <feature> [-y]` | Close all task work items + close the epic + archive the OpenSpec change. |
317
+ | `help-table [topic]` | Context-aware command reference grouped by workflow phase. |
318
+
319
+ Every command appends a JSONL entry (secrets scrubbed) to `.openspecpm/audit.log`.
318
320
 
319
321
  ## Workflow phases
320
322
 
@@ -337,9 +339,9 @@ OpenSpecPM is organized into five phases, each with a reference doc under [`skil
337
339
 
338
340
  ## Roadmap
339
341
 
340
- Active v2 work is tracked as OpenSpec changes under [`openspec/changes/`](openspec/changes/README.md). Six features are scaffolded with full proposals, dependency-aware task lists, and BDD scenarios: dependency-graph visualization, LLM-backed BDD reviewer, spec → test scaffolding, compliance traceability export, three new adapters (Notion / ClickUp / Asana), and a real agent orchestrator that graduates `fan-out` from prompt-emitter to dispatcher.
342
+ Active v2 work is tracked as OpenSpec changes under [`openspec/changes/`](openspec/changes/README.md). The first scaffolded feature **`bdd-llm-reviewer`** shipped in v1.0.0. Five remain: dependency-graph visualization, spec → test scaffolding, compliance traceability export, three new adapters (Notion / ClickUp / Asana), and a real agent orchestrator that graduates `fan-out` from prompt-emitter to dispatcher.
341
343
 
342
- OpenSpecPM dogfoods itself: anyone can `openspecpm next` to see what's ready to start, or `openspecpm sync <change>` to push any of the six into a tracked PM tool.
344
+ OpenSpecPM dogfoods itself: anyone can `openspecpm next` to see what's ready to start, or `openspecpm sync <change>` to push any of the five into a tracked PM tool.
343
345
 
344
346
  ## Project structure
345
347
 
@@ -348,19 +350,32 @@ openspecpm/
348
350
  ├── README.md this file
349
351
  ├── LICENSE MIT
350
352
  ├── CHANGELOG.md
353
+ ├── CONTRIBUTING.md
354
+ ├── SECURITY.md
351
355
  ├── package.json
352
- ├── .github/workflows/test.yml
356
+ ├── .github/
357
+ │ ├── workflows/ test.yml · auto-approve.yml · release.yml · publish.yml
358
+ │ ├── ISSUE_TEMPLATE/
359
+ │ └── PULL_REQUEST_TEMPLATE.md
360
+ ├── docs/screenshots/ README captures + render.ps1 renderer
361
+ ├── openspec/changes/ v2 roadmap (each subdir is a tracked change)
353
362
  ├── skill/openspecpm/ Claude Code Agent Skill
354
363
  │ ├── SKILL.md
355
- │ └── references/ conventions, plan, structure, sync, execute, track
364
+ │ └── references/ conventions · plan · structure · sync · execute · track
356
365
  └── cli/
357
366
  ├── bin/openspecpm.js Commander entrypoint
358
367
  ├── src/
359
- │ ├── commands/ init, doctor, propose, sync, status, standup, next, blocked, ship
360
- ├── adapters/ base, github, azure, jira, index
361
- ├── bdd/ linter, templates
362
- │ ├── http.js REST helper for ADO + Jira
363
- │ ├── tracking.js listChanges, findNext, findBlocked, findRecent
368
+ │ ├── commands/ init · doctor · propose · decompose · sync · comment · reconcile ·
369
+ │ status · standup · next · blocked · validate · search · fan-out ·
370
+ │ bug-report · ship · assign · watch · help · bulk
371
+ │ ├── adapters/ base · github · azure · jira · linear · gitlab · index
372
+ │ ├── bdd/ linter · judge · templates
373
+ │ ├── audit.js JSONL audit log + secret scrubber
374
+ │ ├── http.js REST helper for ADO / Jira / Linear / GitLab
375
+ │ ├── tracking.js listChanges · findNext · findBlocked · findRecent
376
+ │ ├── notify.js Slack / Teams / generic-webhook envelopes
377
+ │ ├── telemetry.js opt-in, audit-log-only at alpha
378
+ │ ├── install-hints.js
364
379
  │ ├── openspec-bridge.js
365
380
  │ ├── config.js
366
381
  │ ├── frontmatter.js
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { Command } from 'commander';
3
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'));
4
10
  import { runInit } from '../src/commands/init.js';
5
11
  import { runDoctor } from '../src/commands/doctor.js';
6
12
  import { runPropose } from '../src/commands/propose.js';
@@ -27,7 +33,7 @@ const program = new Command();
27
33
  program
28
34
  .name('openspecpm')
29
35
  .description('Spec-driven, BDD-shaped project management for AI agents.')
30
- .version('0.1.0-alpha.0');
36
+ .version(pkg.version);
31
37
 
32
38
  program
33
39
  .command('init')
@@ -192,10 +198,21 @@ program
192
198
  .description('Context-aware help grouped by workflow phase')
193
199
  .action((topic) => { runHelp({ topic }); });
194
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
+
195
210
  program.parseAsync(process.argv);
196
211
 
197
212
  function fatal(err) {
198
- process.stderr.write(`\n✖ ${err.message}\n`);
199
- if (err.remediation) process.stderr.write(` → ${err.remediation}\n`);
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`);
200
217
  process.exit(1);
201
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,
package/cli/src/audit.js CHANGED
@@ -13,12 +13,15 @@ export async function record({ command, args = {}, result = null, error = null,
13
13
  if (!command) return;
14
14
  const path = auditPath(cwd);
15
15
  await mkdir(dirname(path), { recursive: true });
16
+ const errorText = error ? (typeof error === 'string' ? error : error.message ?? String(error)) : null;
16
17
  const entry = {
17
18
  ts: new Date().toISOString(),
18
19
  command,
19
20
  args: scrub(args),
20
- result: result ? truncate(result, 500) : null,
21
- error: error ? truncate(typeof error === 'string' ? error : error.message ?? String(error), 500) : null,
21
+ // result + error can carry user-supplied strings (e.g. a failing-fetch
22
+ // message containing a webhook URL). Run them through scrubValue too.
23
+ result: result ? truncate(scrubValue(String(result)), 500) : null,
24
+ error: errorText ? truncate(scrubValue(errorText), 500) : null,
22
25
  };
23
26
  if (meta && typeof meta === 'object') entry.meta = scrub(meta);
24
27
  await appendFile(path, JSON.stringify(entry) + '\n', 'utf8');
@@ -34,7 +37,21 @@ export async function tail(n = 50, cwd = process.cwd()) {
34
37
  });
35
38
  }
36
39
 
37
- const SECRET_SEGMENTS = new Set(['token', 'secret', 'password', 'pat', 'auth', 'credential']);
40
+ const SECRET_SEGMENTS = new Set([
41
+ // Original set.
42
+ 'token', 'secret', 'password', 'pat', 'auth', 'credential',
43
+ // Added: real-world key naming a CLI accumulates over time.
44
+ // bearer/cookie/session — bearer credentials by name.
45
+ // webhook — Slack/Teams URLs ARE the credential.
46
+ // signature — webhook HMAC sigs, request-signing headers.
47
+ // assertion — SAML / OIDC.
48
+ 'bearer', 'cookie', 'session', 'webhook', 'signature', 'assertion',
49
+ ]);
50
+
51
+ // Webhook URLs that act as bearer credentials. Anyone holding the URL can
52
+ // post to the channel. Redact in any string value so accidental logging
53
+ // (e.g. a failing-fetch error message embedding the URL) never leaks.
54
+ const WEBHOOK_URL_RE = /https:\/\/(?:hooks\.slack\.com\/services|[^\/\s"'`]*\.webhook\.office(?:365)?\.com|outlook\.office(?:365)?\.com\/webhook)[^\s"'`]+/gi;
38
55
 
39
56
  function isSecretKey(k) {
40
57
  if (/api[_-]?key/i.test(k)) return true;
@@ -44,7 +61,13 @@ function isSecretKey(k) {
44
61
  return false;
45
62
  }
46
63
 
64
+ function scrubValue(s) {
65
+ if (typeof s !== 'string') return s;
66
+ return s.replace(WEBHOOK_URL_RE, '<redacted-webhook>');
67
+ }
68
+
47
69
  function scrub(obj) {
70
+ if (typeof obj === 'string') return scrubValue(obj);
48
71
  if (!obj || typeof obj !== 'object') return obj;
49
72
  if (Array.isArray(obj)) return obj.map(scrub);
50
73
  const out = {};
@@ -54,9 +77,9 @@ function scrub(obj) {
54
77
  } else if (v && typeof v === 'object') {
55
78
  out[k] = scrub(v);
56
79
  } else if (typeof v === 'string' && v.length > 200) {
57
- out[k] = v.slice(0, 200) + '…';
80
+ out[k] = scrubValue(v).slice(0, 200) + '…';
58
81
  } else {
59
- out[k] = v;
82
+ out[k] = scrubValue(v);
60
83
  }
61
84
  }
62
85
  return out;
@@ -31,6 +31,11 @@ export async function runSyncAll({ dryRun = false, force = false, yes = false }
31
31
  }
32
32
  }
33
33
  process.stdout.write(`\nSummary: ${synced} synced, ${failed} failed.\n`);
34
+ if (failed > 0) {
35
+ const err = new Error(`${failed} change(s) failed to sync.`);
36
+ err.remediation = 'See per-change errors above; re-run for affected features after fixing.';
37
+ throw err;
38
+ }
34
39
  }
35
40
 
36
41
  export async function runShipAllReady({ yes = false, skipArchive = false } = {}) {
@@ -64,4 +69,9 @@ export async function runShipAllReady({ yes = false, skipArchive = false } = {})
64
69
  }
65
70
  }
66
71
  process.stdout.write(`\nSummary: ${shipped} shipped, ${failed} failed.\n`);
72
+ if (failed > 0) {
73
+ const err = new Error(`${failed} change(s) failed to ship.`);
74
+ err.remediation = 'See per-change errors above; re-run for affected features after fixing.';
75
+ throw err;
76
+ }
67
77
  }
@@ -4,6 +4,7 @@ import { readConfig } from '../config.js';
4
4
  import { loadAdapter } from '../adapters/index.js';
5
5
  import { changeDir, changeExists } from '../openspec-bridge.js';
6
6
  import * as fm from '../frontmatter.js';
7
+ import { coerceItems, safeParseFrontmatter } from '../tracking.js';
7
8
 
8
9
  export async function runReconcile({ feature, dryRun = false } = {}) {
9
10
  if (!feature) throw new Error('feature name is required');
@@ -17,10 +18,22 @@ export async function runReconcile({ feature, dryRun = false } = {}) {
17
18
 
18
19
  const dir = changeDir(feature);
19
20
  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 ?? [];
21
+ // Read + validate through the same helpers loadChange uses, so a non-array
22
+ // items: (or malformed YAML) raises a clear error instead of iterating
23
+ // character-by-character.
24
+ let tdata = {};
25
+ let tbody = '';
26
+ try {
27
+ ({ data: tdata, body: tbody } = await safeParseFrontmatter(tasksPath, feature, 'tasks.md'));
28
+ } catch (err) {
29
+ if (err.code === 'ENOENT' || /no such file/i.test(err.message)) {
30
+ // tasks.md missing — nothing to reconcile.
31
+ process.stdout.write('No items in tasks.md to reconcile.\n');
32
+ return;
33
+ }
34
+ throw err;
35
+ }
36
+ const items = coerceItems(tdata.items, tbody, feature);
24
37
  if (!items.length) {
25
38
  process.stdout.write('No items in tasks.md to reconcile.\n');
26
39
  return;
@@ -7,6 +7,7 @@ import { changeDir, changeExists } from '../openspec-bridge.js';
7
7
  import { lintChange, summarize, formatFindings } from '../bdd/linter.js';
8
8
  import { judgeChange, defaultClient, DEFAULT_MODEL } from '../bdd/judge.js';
9
9
  import * as fm from '../frontmatter.js';
10
+ import { coerceItems, safeParseFrontmatter } from '../tracking.js';
10
11
  import { record } from '../audit.js';
11
12
 
12
13
  export async function runSync({ feature, dryRun = false, force = false, diff = false, llm = false } = {}) {
@@ -102,9 +103,11 @@ export async function runSync({ feature, dryRun = false, force = false, diff = f
102
103
  out('No tasks.md found — only the epic was synced.');
103
104
  return;
104
105
  }
105
- const tasksRaw = await readFile(tasksPath, 'utf8');
106
- const { data: tdata, body: tbody } = fm.parse(tasksRaw);
107
- const items = tdata.items ?? parseChecklist(tbody);
106
+ // Route through the same parse+coerce helpers loadChange uses, so a
107
+ // non-array items: (or malformed YAML) is rejected here too — sync is the
108
+ // primary command and bypassing the validator was the H3 regression.
109
+ const { data: tdata, body: tbody } = await safeParseFrontmatter(tasksPath, feature, 'tasks.md');
110
+ const items = coerceItems(tdata.items, tbody, feature);
108
111
  const updatedItems = [];
109
112
 
110
113
  for (const task of items) {
@@ -132,16 +135,52 @@ export async function runSync({ feature, dryRun = false, force = false, diff = f
132
135
  const patched = fm.serialize({ ...tdata, items: updatedItems }, tbody);
133
136
  await writeFile(tasksPath, patched, 'utf8');
134
137
  }
138
+
139
+ // Exit with a non-zero status if any task failed, so CI invocations like
140
+ // `openspecpm sync feature && deploy` don't proceed on silent partial sync.
141
+ // The tasks.md patch above already persisted last_error per failed task.
142
+ const failed = updatedItems.filter((t) => t.sync_state === 'failed');
143
+ if (failed.length) {
144
+ const err = new Error(`${failed.length} task(s) failed to sync in "${feature}".`);
145
+ err.remediation = 'Inspect last_error in tasks.md frontmatter and re-run sync to retry only failed items.';
146
+ throw err;
147
+ }
135
148
  }
136
149
 
137
150
  function out(s) {
138
151
  process.stdout.write(s + '\n');
139
152
  }
140
153
 
154
+ // Strip C0/C1 control chars (except common whitespace), bidi overrides, and
155
+ // zero-width chars from text we forward to a remote tracker as an issue body.
156
+ // A proposal author could intentionally or accidentally include these and
157
+ // they show up confusingly (or as homograph-attack vectors) in GitHub/Jira
158
+ // issue UIs. Implemented as a codepoint predicate rather than a regex literal
159
+ // so the source file stays pure ASCII (a regex with literal control chars
160
+ // makes git treat the file as binary).
161
+ function isPrintableChar(cp) {
162
+ if (cp === 0x09 || cp === 0x0A || cp === 0x0D) return true; // keep TAB / LF / CR
163
+ if (cp <= 0x1F) return false; // C0 controls
164
+ if (cp === 0x7F) return false; // DEL
165
+ if (cp >= 0x80 && cp <= 0x9F) return false; // C1 controls
166
+ if (cp >= 0x200B && cp <= 0x200F) return false; // zero-width + joiners + LRM/RLM
167
+ if (cp >= 0x202A && cp <= 0x202E) return false; // LRE/RLE/PDF/LRO/RLO bidi overrides
168
+ if (cp >= 0x2066 && cp <= 0x2069) return false; // LRI/RLI/FSI/PDI isolates
169
+ return true;
170
+ }
171
+
172
+ function sanitizeText(s) {
173
+ const out = [];
174
+ for (const ch of String(s)) {
175
+ if (isPrintableChar(ch.codePointAt(0))) out.push(ch);
176
+ }
177
+ return out.join('');
178
+ }
179
+
141
180
  function extractSummary(md) {
142
181
  const { body } = fm.parse(md);
143
182
  const firstPara = body.split(/\r?\n\r?\n/).find((p) => p.trim() && !p.startsWith('#'));
144
- return (firstPara ?? '').trim().slice(0, 1000);
183
+ return sanitizeText((firstPara ?? '').trim()).slice(0, 1000);
145
184
  }
146
185
 
147
186
  function parseChecklist(body) {
package/cli/src/http.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { AdapterError } from './adapters/base.js';
2
2
 
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+
3
5
  export class HttpClient {
4
6
  #baseUrl;
5
7
  #authHeader;
6
8
  #fetchImpl;
7
9
  #defaultHeaders;
8
10
  #remediationHint;
11
+ #timeoutMs;
9
12
 
10
- constructor({ baseUrl, auth, fetch: fetchImpl = globalThis.fetch, defaultHeaders = {}, remediationHint } = {}) {
13
+ constructor({ baseUrl, auth, fetch: fetchImpl = globalThis.fetch, defaultHeaders = {}, remediationHint, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
11
14
  if (!baseUrl) throw new Error('HttpClient requires baseUrl');
12
15
  if (typeof fetchImpl !== 'function') throw new Error('global fetch not available; pass {fetch} explicitly');
13
16
  this.#baseUrl = baseUrl.replace(/\/+$/, '');
@@ -15,6 +18,7 @@ export class HttpClient {
15
18
  this.#fetchImpl = fetchImpl;
16
19
  this.#defaultHeaders = defaultHeaders;
17
20
  this.#remediationHint = remediationHint;
21
+ this.#timeoutMs = timeoutMs;
18
22
  }
19
23
 
20
24
  async request(method, path, { query, body, headers, contentType = 'application/json', accept = 'application/json' } = {}) {
@@ -35,10 +39,18 @@ export class HttpClient {
35
39
  finalHeaders['Content-Type'] = contentType;
36
40
  }
37
41
 
42
+ // Bound the request with an abort signal so a hung backend can't wedge sync --all.
43
+ const signal = AbortSignal.timeout(this.#timeoutMs);
38
44
  let res;
39
45
  try {
40
- res = await this.#fetchImpl(url, { method, headers: finalHeaders, body: payload });
46
+ res = await this.#fetchImpl(url, { method, headers: finalHeaders, body: payload, signal });
41
47
  } catch (err) {
48
+ if (err?.name === 'TimeoutError' || err?.name === 'AbortError') {
49
+ throw new AdapterError(`${method} ${url} timed out after ${this.#timeoutMs}ms`, {
50
+ remediation: 'Backend did not respond in time. Retry, or raise HttpClient timeoutMs if the endpoint is known-slow.',
51
+ cause: err,
52
+ });
53
+ }
42
54
  throw new AdapterError(`Network error calling ${method} ${url}: ${err.message}`, {
43
55
  remediation: this.#remediationHint ?? 'Check connectivity and base URL.',
44
56
  cause: err,
package/cli/src/notify.js CHANGED
@@ -24,19 +24,42 @@ export async function notify({ config, title, body, level = 'info', fetchImpl =
24
24
  const errors = [];
25
25
  for (const t of targets) {
26
26
  try {
27
- await fetchImpl(t.url, {
27
+ const res = await fetchImpl(t.url, {
28
28
  method: 'POST',
29
29
  headers: { 'Content-Type': 'application/json' },
30
30
  body: JSON.stringify(formatPayload(t.kind, { title, body, level })),
31
31
  });
32
+ // A 401/403/500 from Slack/Teams reaches the network but the message
33
+ // never lands. Without this check, those would be counted as `sent`,
34
+ // and a user running `standup --broadcast` would think their channel
35
+ // got the update when the webhook silently rejected it.
36
+ if (!res?.ok) {
37
+ const status = res?.status ?? '?';
38
+ const statusText = res?.statusText ?? '';
39
+ let snippet = '';
40
+ try {
41
+ snippet = (await res.text()).slice(0, 200);
42
+ } catch { /* res.text() failed; ignore */ }
43
+ errors.push({ target: t.kind, error: redactUrl(t.url, `HTTP ${status} ${statusText}${snippet ? ` — ${snippet}` : ''}`) });
44
+ continue;
45
+ }
32
46
  sent++;
33
47
  } catch (err) {
34
- errors.push({ target: t.kind, error: err.message });
48
+ // Strip the webhook URL from fetch's error message before returning it.
49
+ // The URL is itself a bearer credential for Slack/Teams — anyone holding
50
+ // it can post to the channel. We don't want it surfacing in audit.log
51
+ // or stdout via a caller that surfaces this errors[] array.
52
+ errors.push({ target: t.kind, error: redactUrl(t.url, err.message ?? String(err)) });
35
53
  }
36
54
  }
37
55
  return { sent, errors };
38
56
  }
39
57
 
58
+ function redactUrl(url, msg) {
59
+ if (!url || typeof msg !== 'string') return msg;
60
+ return msg.split(url).join('<webhook>');
61
+ }
62
+
40
63
  function formatPayload(kind, { title, body, level }) {
41
64
  const text = `*${title}*\n${body}`;
42
65
  if (kind === 'slack') return { text };
@@ -49,15 +49,46 @@ export async function probe({ runner = execa } = {}) {
49
49
  return { version };
50
50
  }
51
51
 
52
+ // Feature names appear in filesystem paths (`openspec/changes/<feature>/`) and in
53
+ // the subprocess invocation of `openspec propose <feature>`. An unvalidated
54
+ // `feature` like `../../etc` would let path.join escape the openspec/changes
55
+ // tree and write/read files anywhere the process has access to. Validate at
56
+ // every entry point that consumes a feature name.
57
+ const FEATURE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/i;
58
+
59
+ export function assertSafeFeatureName(feature) {
60
+ if (typeof feature !== 'string' || feature.length === 0) {
61
+ const e = new Error('feature name is required.');
62
+ e.remediation = 'Provide a feature slug like `dark-mode`.';
63
+ throw e;
64
+ }
65
+ // Reject the obviously dangerous patterns up front for clearer error messages.
66
+ if (feature.includes('..') || feature.includes('/') || feature.includes('\\') || /^[a-z]:/i.test(feature)) {
67
+ const e = new Error(`Invalid feature name: "${feature}".`);
68
+ e.remediation = 'Feature names cannot contain "..", "/", "\\\\", or drive letters. Use a slug like `dark-mode`.';
69
+ throw e;
70
+ }
71
+ if (!FEATURE_NAME_RE.test(feature)) {
72
+ const e = new Error(`Invalid feature name: "${feature}".`);
73
+ e.remediation = 'Feature names must match /^[a-z0-9][a-z0-9._-]*$/i — start with a letter or digit; only letters, digits, `.`, `_`, `-` after that.';
74
+ throw e;
75
+ }
76
+ }
77
+
52
78
  export function changeDir(feature, cwd = process.cwd()) {
79
+ assertSafeFeatureName(feature);
53
80
  return join(cwd, OPENSPEC_CHANGES_DIR, feature);
54
81
  }
55
82
 
56
83
  export function changeExists(feature, cwd = process.cwd()) {
84
+ assertSafeFeatureName(feature);
57
85
  return existsSync(changeDir(feature, cwd));
58
86
  }
59
87
 
60
88
  export async function propose(feature, prompt, { runner = execa, cwd = process.cwd() } = {}) {
89
+ // Validate before the subprocess call so we never hand a traversal value to
90
+ // the openspec CLI (it may or may not validate on its own; we don't trust).
91
+ assertSafeFeatureName(feature);
61
92
  await probe({ runner });
62
93
  await runner('openspec', ['propose', feature, '--prompt', prompt], { cwd, stdio: 'inherit' });
63
94
  return changeDir(feature, cwd);
@@ -36,20 +36,45 @@ export async function loadChange(name, cwd = process.cwd()) {
36
36
  let proposal = {};
37
37
  const proposalPath = join(dir, 'proposal.md');
38
38
  if (existsSync(proposalPath)) {
39
- const raw = await readFile(proposalPath, 'utf8');
40
- proposal = fm.parse(raw).data ?? {};
39
+ const parsed = await safeParseFrontmatter(proposalPath, name, 'proposal.md');
40
+ proposal = parsed.data ?? {};
41
41
  }
42
42
  let items = [];
43
43
  const tasksPath = join(dir, 'tasks.md');
44
44
  if (existsSync(tasksPath)) {
45
- const raw = await readFile(tasksPath, 'utf8');
46
- const { data, body } = fm.parse(raw);
47
- items = data.items ?? parseChecklist(body);
45
+ const { data, body } = await safeParseFrontmatter(tasksPath, name, 'tasks.md');
46
+ items = coerceItems(data.items, body, name);
48
47
  }
49
48
  const mtime = await mostRecentMtime(dir);
50
49
  return { name, dir, proposal, items, mtime };
51
50
  }
52
51
 
52
+ export async function safeParseFrontmatter(path, changeName, fileLabel) {
53
+ const raw = await readFile(path, 'utf8');
54
+ try {
55
+ return fm.parse(raw);
56
+ } catch (err) {
57
+ const e = new Error(`${changeName}/${fileLabel}: YAML frontmatter is malformed (${err.message?.split('\n')[0] ?? err.name}).`);
58
+ e.remediation = `Repair the YAML in openspec/changes/${changeName}/${fileLabel}. Validate with \`openspecpm validate\` after fixing.`;
59
+ throw e;
60
+ }
61
+ }
62
+
63
+ export function coerceItems(rawItems, body, changeName) {
64
+ if (rawItems === undefined || rawItems === null) {
65
+ // No items: in frontmatter — fall back to the checklist body parser.
66
+ return parseChecklist(body);
67
+ }
68
+ if (!Array.isArray(rawItems)) {
69
+ const e = new Error(`${changeName}/tasks.md: frontmatter "items:" must be a YAML array, got ${Array.isArray(rawItems) ? 'array' : typeof rawItems}.`);
70
+ e.remediation = `Edit openspec/changes/${changeName}/tasks.md so "items:" is a list (each entry starts with "- title: ...").`;
71
+ throw e;
72
+ }
73
+ // Filter out malformed entries (silently skipping is better than crashing
74
+ // deep in a downstream consumer with `TypeError: items is not iterable`).
75
+ return rawItems.filter((t) => t && typeof t === 'object' && typeof t.title === 'string');
76
+ }
77
+
53
78
  /**
54
79
  * Resolve a dep token against a change. Tokens may be:
55
80
  * "task title" — same-change reference by title
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openspecpm",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Spec-driven, BDD-shaped project management for AI agents — OpenSpec proposals synced to GitHub, Azure DevOps, Jira, Linear, or GitLab.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,7 @@ description: "OpenSpecPM — spec-driven, BDD-shaped project management for any
5
5
 
6
6
  # OpenSpecPM — Spec-driven PM Agent Skill
7
7
 
8
- A sibling of CCPM with three differences: **OpenSpec** authors the specs, **adapters** make the PM backend pluggable (GitHub / Azure DevOps / Jira / Linear / GitLab), and the wizard is **friendly to non-engineers**.
8
+ A sibling of CCPM with five differences: **OpenSpec** authors the specs (with a heuristic BDD linter plus an optional LLM judge), **adapters** make the PM backend pluggable (GitHub / Azure DevOps / Jira / Linear / GitLab), the wizard is **friendly to non-engineers**, every command is **audit-logged** by default, and `depends_on:` reaches **across features** so `next`/`blocked` reflect the whole project.
9
9
 
10
10
  ## Workflow
11
11
 
@@ -14,7 +14,7 @@ idea → openspecpm propose <feature> (OpenSpec authors proposal.md, d
14
14
  → review BDD scenarios (Given/When/Then)
15
15
  → openspecpm sync <feature> (push to chosen PM backend, idempotent)
16
16
  → openspecpm status / standup (track local + remote)
17
- → openspecpm ship <feature> (Sprint 3+)
17
+ → openspecpm ship <feature> (close + archive)
18
18
  ```
19
19
 
20
20
  ## Phases
@@ -25,7 +25,7 @@ idea → openspecpm propose <feature> (OpenSpec authors proposal.md, d
25
25
  | **Structure** | A proposal exists and needs decomposition into tasks. | `references/structure.md` |
26
26
  | **Sync** | Local OpenSpec change needs to become PM-tool work items. | `references/sync.md` |
27
27
  | **Execute** | User wants to start work on a tracked item. | `references/execute.md` |
28
- | **Track** | User asks status / standup / what's next / what's blocked. | `references/track.md` (Sprint 3) |
28
+ | **Track** | User asks status / standup / what's next / what's blocked. | `references/track.md` |
29
29
 
30
30
  ## Conventions
31
31
 
@@ -79,7 +79,7 @@ Scenario: User toggles dark mode
79
79
  And their preference is saved to the profile
80
80
  ```
81
81
 
82
- Lint heuristics (enforced softly at `propose`, hard at `sync` in Sprint 3+):
82
+ Lint heuristics (enforced softly at `propose`, hard at `sync`):
83
83
 
84
84
  - Each scenario has one `Given`, one `When`, one `Then` (with optional `And`s).
85
85
  - `Then` uses an observable verb (displays, returns, stores, rejects, emails, …).
@@ -29,9 +29,9 @@ A work item moved to `in_progress` in the PM tool, a local progress directory cr
29
29
 
30
30
  5. **Now do the work.** Use the spec scenarios in `openspec/changes/<feature>/specs/` as the acceptance criteria. Implement code, write tests, refactor — whatever the task requires. Use the BDD scenarios as the test plan, not just the spec.
31
31
 
32
- 6. **Periodically broadcast progress.** Append to local `progress.md`, then sync to the PM tool with `openspecpm comment <task>` (Sprint 3). Don't post per-keystroke — once per meaningful checkpoint.
32
+ 6. **Periodically broadcast progress.** Append to local `progress.md`, then sync to the PM tool with `openspecpm comment <task>`. Don't post per-keystroke — once per meaningful checkpoint.
33
33
 
34
- 7. **When done.** Run `openspecpm ship <feature>` (Sprint 3) to close the work item with a final comment and archive the OpenSpec change.
34
+ 7. **When done.** Run `openspecpm ship <feature>` to close the work item with a final comment and archive the OpenSpec change.
35
35
 
36
36
  ## Worktrees: hidden by default
37
37
 
@@ -41,7 +41,7 @@ Engineers may want a `git worktree` per feature so concurrent work doesn't colli
41
41
  openspecpm start <feature> --dev
42
42
  ```
43
43
 
44
- This creates `../openspec-<feature>/` on branch `openspec/<feature>`. **For non-technical users, do not surface this.** They will be confused by ghost folders. The default `start` command (Sprint 3+) skips the worktree and works in the current checkout.
44
+ This creates `../openspec-<feature>/` on branch `openspec/<feature>`. **For non-technical users, do not surface this.** They will be confused by ghost folders. The default flow skips the worktree and works in the current checkout.
45
45
 
46
46
  ## Parallel agents
47
47
 
@@ -52,7 +52,7 @@ For each parallel task:
52
52
  2. Launch a sub-agent with a focused prompt: "Implement task T from the X feature. The BDD scenarios are at specs/Y.md. Use only files under <stream-scope>."
53
53
  3. Wait for completion, then merge.
54
54
 
55
- In Sprint 3, `openspecpm fan-out <feature>` automates the launch.
55
+ `openspecpm fan-out <feature>` automates the launch by emitting ready-to-paste prompts for `parallel: true` tasks.
56
56
 
57
57
  ## What to avoid
58
58
 
@@ -42,6 +42,6 @@ A fully-formed OpenSpec change at `openspec/changes/<feature>/` containing:
42
42
 
43
43
  ## After this phase
44
44
 
45
- - If the user is ready to push to their PM tool: route to `references/sync.md` (Sprint 2).
46
- - If the user wants to decompose into tasks first: route to `references/structure.md` (Sprint 2).
45
+ - If the user is ready to push to their PM tool: route to `references/sync.md`.
46
+ - If the user wants to decompose into tasks first: route to `references/structure.md`.
47
47
  - If the user wants to check what other changes exist: run `openspecpm status`.
@@ -1,6 +1,6 @@
1
1
  # Sync — Pushing an OpenSpec change to the PM tool
2
2
 
3
- **When to use this:** The user has a proposal + tasks ready and wants them tracked in GitHub Issues / Azure DevOps Boards / Jira / Linear / GitLab Issues.
3
+ **When to use this:** The user has a proposal + tasks ready and wants them tracked in GitHub Issues / Azure DevOps Boards / Jira / Linear / GitLab.
4
4
 
5
5
  ## Outcome
6
6
 
@@ -52,5 +52,5 @@ The CLI handles the translation. Author tasks in the OpenSpec/CCPM dialect descr
52
52
  ## After sync
53
53
 
54
54
  - Each item is now visible in the PM tool. The user can route stakeholders to those URLs for sign-off.
55
- - Progress narrative still lives locally in `openspec/changes/<feature>/updates/<task>/progress.md`. Use `openspecpm comment <task>` (Sprint 3) to broadcast a new update to the PM tool.
55
+ - Progress narrative still lives locally in `openspec/changes/<feature>/updates/<task>/progress.md`. Use `openspecpm comment <task>` to broadcast a new update to the PM tool.
56
56
  - Route to `references/execute.md` when the user is ready to start building.