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 +39 -0
- package/README.md +147 -132
- package/cli/bin/openspecpm.js +20 -3
- package/cli/src/adapters/azure.js +21 -5
- package/cli/src/adapters/gitlab.js +10 -5
- package/cli/src/audit.js +28 -5
- package/cli/src/commands/bulk.js +10 -0
- package/cli/src/commands/reconcile.js +17 -4
- package/cli/src/commands/sync.js +43 -4
- package/cli/src/http.js +14 -2
- package/cli/src/notify.js +25 -2
- package/cli/src/openspec-bridge.js +31 -0
- package/cli/src/tracking.js +30 -5
- package/package.json +1 -1
- package/skill/openspecpm/SKILL.md +3 -3
- package/skill/openspecpm/references/conventions.md +1 -1
- package/skill/openspecpm/references/execute.md +4 -4
- package/skill/openspecpm/references/plan.md +2 -2
- package/skill/openspecpm/references/sync.md +2 -2
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
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](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
|
|
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
|
|
33
|
+
It is a sibling of [CCPM](https://github.com/automazeio/ccpm), with five differences:
|
|
34
34
|
|
|
35
|
-
1. **OpenSpec drives spec authoring
|
|
36
|
-
2. **
|
|
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
|
-
|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
-

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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).
|
|
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
|
|
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/
|
|
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
|
|
364
|
+
│ └── references/ conventions · plan · structure · sync · execute · track
|
|
356
365
|
└── cli/
|
|
357
366
|
├── bin/openspecpm.js Commander entrypoint
|
|
358
367
|
├── src/
|
|
359
|
-
│ ├── commands/ init
|
|
360
|
-
│
|
|
361
|
-
│
|
|
362
|
-
│ ├──
|
|
363
|
-
│ ├──
|
|
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
|
package/cli/bin/openspecpm.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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([
|
|
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;
|
package/cli/src/commands/bulk.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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;
|
package/cli/src/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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);
|
package/cli/src/tracking.js
CHANGED
|
@@ -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
|
|
40
|
-
proposal =
|
|
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
|
|
46
|
-
|
|
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
|
@@ -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
|
|
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> (
|
|
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`
|
|
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`
|
|
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
|
|
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>`
|
|
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
|
|
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
|
-
|
|
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
|
|
46
|
-
- If the user wants to decompose into tasks first: route to `references/structure.md
|
|
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
|
|
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>`
|
|
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.
|