@prodshape/integration-openspec 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -1,21 +1,217 @@
1
1
  // src/index.ts
2
- import { execFile } from "child_process";
3
- import { access, mkdir, readFile, rm, writeFile } from "fs/promises";
4
- import { dirname, join } from "path";
2
+ import { execFile as execFile2 } from "child_process";
3
+ import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
4
+ import { dirname, join as join3 } from "path";
5
5
  import { parse, stringify } from "yaml";
6
+
7
+ // src/workspace.ts
8
+ import { access } from "fs/promises";
9
+ import { join } from "path";
10
+ async function pathExists(path) {
11
+ try {
12
+ await access(path);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+ async function isOpenSpecWorkspace(root) {
19
+ return pathExists(join(root, "openspec"));
20
+ }
21
+ function envWithLocalBin(root) {
22
+ const env = { ...process.env };
23
+ const separator = process.platform === "win32" ? ";" : ":";
24
+ let existing = "";
25
+ for (const key of Object.keys(env)) {
26
+ if (key.toUpperCase() === "PATH") {
27
+ existing = env[key] ?? "";
28
+ delete env[key];
29
+ }
30
+ }
31
+ const localBin = join(root, "node_modules", ".bin");
32
+ env["PATH"] = existing ? `${localBin}${separator}${existing}` : localBin;
33
+ return env;
34
+ }
35
+
36
+ // src/population.ts
37
+ import { execFile } from "child_process";
38
+ import { readdir, readFile } from "fs/promises";
39
+ import { join as join2, relative, sep } from "path";
40
+ import { promisify } from "util";
41
+ import fg from "fast-glob";
42
+ import { codes } from "@prodshape/core";
43
+ var execFileAsync = promisify(execFile);
44
+ var CHANGE_ARTIFACT_FILES = [
45
+ { file: "proposal.md", kind: "proposal" },
46
+ { file: "design.md", kind: "design" },
47
+ { file: "tasks.md", kind: "tasks" }
48
+ ];
49
+ async function runOpenSpec(repoRoot, args) {
50
+ return execFileAsync("openspec", args, {
51
+ cwd: repoRoot,
52
+ shell: true,
53
+ env: envWithLocalBin(repoRoot),
54
+ maxBuffer: 10 * 1024 * 1024
55
+ });
56
+ }
57
+ async function openspecListChanges(repoRoot) {
58
+ try {
59
+ const { stdout } = await runOpenSpec(repoRoot, ["list", "--json"]);
60
+ const parsed = JSON.parse(stdout);
61
+ if (!Array.isArray(parsed.changes)) return null;
62
+ return parsed.changes.map((c) => c.name).filter((n) => typeof n === "string");
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+ async function isOpenSpecCliAvailable(repoRoot) {
68
+ try {
69
+ const { stdout } = await execFileAsync("openspec", ["--version"], {
70
+ shell: true,
71
+ ...repoRoot ? { env: envWithLocalBin(repoRoot) } : {}
72
+ });
73
+ return /\d+\.\d+\.\d+/.test(stdout);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+ async function discoverMarkdownFiles(dir) {
79
+ const entries = await fg("**/*.md", {
80
+ cwd: dir,
81
+ absolute: true,
82
+ dot: false,
83
+ ignore: ["**/node_modules/**"]
84
+ });
85
+ return entries.map((e) => e.split("/").join(sep)).sort();
86
+ }
87
+ function buildConsumerDocument(absolutePath, repoRoot, opts) {
88
+ return {
89
+ path: relative(repoRoot, absolutePath).split(sep).join("/"),
90
+ absolutePath,
91
+ change: opts.change,
92
+ archived: opts.archived,
93
+ artifactKind: opts.artifactKind
94
+ };
95
+ }
96
+ async function discoverChangeDocuments(changeDir, repoRoot, change, archived) {
97
+ const docs = [];
98
+ for (const { file, kind } of CHANGE_ARTIFACT_FILES) {
99
+ const absolutePath = join2(changeDir, file);
100
+ try {
101
+ await readFile(absolutePath);
102
+ docs.push(
103
+ buildConsumerDocument(absolutePath, repoRoot, { change, archived, artifactKind: kind })
104
+ );
105
+ } catch {
106
+ }
107
+ }
108
+ const specsDir = join2(changeDir, "specs");
109
+ try {
110
+ const specFiles = await discoverMarkdownFiles(specsDir);
111
+ for (const absolutePath of specFiles) {
112
+ docs.push(
113
+ buildConsumerDocument(absolutePath, repoRoot, { change, archived, artifactKind: "specs" })
114
+ );
115
+ }
116
+ } catch {
117
+ }
118
+ return docs;
119
+ }
120
+ async function discoverSpecDocuments(specsDir, repoRoot) {
121
+ const docs = [];
122
+ try {
123
+ const specFiles = await discoverMarkdownFiles(specsDir);
124
+ for (const absolutePath of specFiles) {
125
+ docs.push(
126
+ buildConsumerDocument(absolutePath, repoRoot, { archived: false, artifactKind: "spec" })
127
+ );
128
+ }
129
+ } catch {
130
+ }
131
+ return docs;
132
+ }
133
+ async function discoverArchivedChanges(archiveDir) {
134
+ try {
135
+ const entries = await readdir(archiveDir, { withFileTypes: true });
136
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+ async function enumerateOpenSpecDocuments(repoRoot, options) {
142
+ const includeArchived = options?.includeArchived ?? false;
143
+ const root = join2(repoRoot, "openspec");
144
+ const changesDir = join2(root, "changes");
145
+ const archiveDir = join2(changesDir, "archive");
146
+ const specsDir = join2(root, "specs");
147
+ const documents = [];
148
+ const diagnostics = [];
149
+ let currentChangeNames = await openspecListChanges(repoRoot);
150
+ if (currentChangeNames === null) {
151
+ diagnostics.push({
152
+ severity: "error",
153
+ code: codes.openSpecCliMissing,
154
+ message: `OpenSpec CLI not available; the document population was enumerated by filesystem convention instead of 'openspec list'. Install with 'npm install -g @fission-ai/openspec@1' for authoritative population discovery.`,
155
+ file: "openspec/"
156
+ });
157
+ try {
158
+ const entries = await readdir(changesDir, { withFileTypes: true });
159
+ currentChangeNames = entries.filter((e) => e.isDirectory() && e.name !== "archive").map((e) => e.name).sort();
160
+ } catch {
161
+ currentChangeNames = [];
162
+ }
163
+ }
164
+ if (options?.change) {
165
+ const wanted = options.change;
166
+ if (currentChangeNames.includes(wanted)) {
167
+ currentChangeNames = [wanted];
168
+ } else {
169
+ currentChangeNames = [];
170
+ }
171
+ }
172
+ for (const name of currentChangeNames) {
173
+ const changeDir = join2(changesDir, name);
174
+ documents.push(...await discoverChangeDocuments(changeDir, repoRoot, name, false));
175
+ }
176
+ if (includeArchived) {
177
+ const archivedNames = await discoverArchivedChanges(archiveDir);
178
+ for (const name of archivedNames) {
179
+ if (options?.change && name !== options.change) continue;
180
+ const changeDir = join2(archiveDir, name);
181
+ documents.push(...await discoverChangeDocuments(changeDir, repoRoot, name, true));
182
+ }
183
+ }
184
+ documents.push(...await discoverSpecDocuments(specsDir, repoRoot));
185
+ return {
186
+ documents,
187
+ root: "openspec",
188
+ includeArchived,
189
+ diagnostics
190
+ };
191
+ }
192
+ var openSpecProvider = {
193
+ name: "openspec",
194
+ detectWorkspace: isOpenSpecWorkspace,
195
+ enumerateDocuments: enumerateOpenSpecDocuments
196
+ };
197
+
198
+ // src/index.ts
6
199
  var MIN_SUPPORTED_OPENSPEC = "1.0.0";
200
+ var OPENSPEC_NPX_SPEC = "@fission-ai/openspec@1";
7
201
  var PDAC_CONTEXT_BEGIN = "<!-- pdac:context begin -->";
8
202
  var PDAC_CONTEXT_END = "<!-- pdac:context end -->";
9
203
  var PDAC_CONTEXT_BLOCK = `${PDAC_CONTEXT_BEGIN}
10
204
  This repository uses Product Definition as Code (PDaC). The canonical product definition lives in docs/product/model (actors, journeys, use cases, business rules, domain terms, requirements) and is the single source of truth for product behaviour. OpenSpec artifacts are downstream consumers of it, never a second source of truth.
11
205
  Consumers bind to canonical text through the PDaC Citation Contract: a citation records the target artifact id, a sha256 content digest, and an optional verification scenario anchor. Verification reports one status per citation: current, stale, tampered or unresolved.
12
206
  To cite: run \`npx prodshape inspect <ID>\` to read the current digest, then \`npx prodshape cite --id <ID> --digest <digest>\` to emit the citation record. Place inline citations on their own line directly under the text they ground.
13
- The product definition changes only through a Product Change under docs/product/changes/. OpenSpec changes never edit docs/product/model directly.
207
+ Every current OpenSpec document (proposal, specs, design, tasks and openspec/specs/) has exactly one effective scope state: bound (it carries at least one citation, or declares \`pdac-scope: cited\`), exempt (a human declared \`pdac-scope: none\` because the document has no product-semantic dependency), or unclassified (neither declared \u2014 a verification failure). Binding and exemption are human declarations: never declare \`pdac-scope: none\` merely because citations are missing, and a document declared \`pdac-scope: cited\` with zero citations fails. Verify with \`npx prodshape citations verify --provider openspec\`: it enumerates the expected current document population (archived changes excluded by default), so zero discovered citations is a set of failures, never a pass.
208
+ The accepted Product Definition changes only through this lifecycle: propose a Product Change under docs/product/changes/, validate its overlay while the baseline remains untouched, obtain human product approval, apply explicitly on a working branch, review the applied result in a pull request, and accept the resulting baseline by human merge. A Product Change is not a pull request. Apply is not acceptance, and neither apply nor merge attests implementation, verification, release or deployment. OpenSpec changes never edit docs/product/model directly. Product-definition work and implementation work have independent cadence: an OpenSpec change may share a pull request with an applied Product Change or follow an accepted definition later, but it remains a downstream implementation concern and never supplies Product Change status.
14
209
  ${PDAC_CONTEXT_END}`;
15
210
  var PDAC_RULES = {
16
211
  proposal: [
17
212
  "State which PDaC artifacts this change touches, each with an inline citation emitted by `npx prodshape cite --id <ID> --digest <digest>`. Never write a citation record by hand.",
18
- "If the change alters product behaviour, name the Product Change (CHG id) it implements. If no Product Change exists yet, stop and ask for one instead of proceeding."
213
+ "If the change implements altered product behaviour, name the Product Change (CHG id) whose applied or accepted artifacts it implements. If no overlay-validated and human-approved Product Change exists yet, stop and ask for one instead of proceeding. The OpenSpec change is not the Product Change.",
214
+ "Every document of this change must end up bound or exempt: bind by citing the canonical text it depends on, or declare `pdac-scope: none` on a line of its own (frontmatter or `<!-- pdac-scope: none -->`) when a human judges the document has no product-semantic dependency. Never declare an exemption just because citations are missing."
19
215
  ],
20
216
  specs: [
21
217
  "Every requirement derived from canonical product text MUST carry a citation to every PDaC artifact it derives from, one citation per line, placed after the requirement text and before the first scenario. A requirement often derives from more than one artifact; citing only the closest one silently hides the other derivations. Never place citations between the requirement heading and the requirement text; OpenSpec reads the first paragraph as the requirement.",
@@ -27,36 +223,86 @@ var PDAC_RULES = {
27
223
  "When a design decision depends on canonical product text, cite the artifact it depends on."
28
224
  ],
29
225
  tasks: [
30
- "A task that changes cited behaviour must include a follow-up task to refresh the affected citations."
226
+ "A task that changes cited behaviour must include a follow-up task to refresh the affected citations.",
227
+ "Never infer implementation, verification, release or deployment from Product Change status; record that evidence in the delivery workflow."
31
228
  ]
32
229
  };
33
230
  var PDAC_OPERATIONS = {
34
231
  apply: {
35
232
  guidance: [
36
- "Before finishing, run `npx prodshape citations verify` and fix every stale, tampered or unresolved citation. Refresh a stale digest by re-running `npx prodshape inspect <ID>`; never delete a citation to silence a diagnostic."
233
+ "Before finishing, run `npx prodshape citations verify --provider openspec` and fix every stale, tampered or unresolved citation, every unclassified document and every bound document without citations. Refresh a stale digest by re-running `npx prodshape inspect <ID>`; never delete a citation or declare `pdac-scope: none` to silence a diagnostic."
37
234
  ]
38
235
  },
39
236
  archive: {
40
237
  guidance: [
41
- "Run `npx prodshape citations verify` one final time. Archive only when every citation is current."
238
+ "Run `npx prodshape citations verify --provider openspec` one final time. Archive only when every document is bound or exempt and every citation is current."
42
239
  ]
43
240
  }
44
241
  };
242
+ function currentManagedStrings() {
243
+ return {
244
+ rules: Object.fromEntries(Object.entries(PDAC_RULES).map(([key, list]) => [key, [...list]])),
245
+ operations: Object.fromEntries(
246
+ Object.entries(PDAC_OPERATIONS).map(([key, op]) => [key, [...op.guidance]])
247
+ )
248
+ };
249
+ }
45
250
  var CONFIG_RELATIVE = "openspec/config.yaml";
46
251
  var META_RELATIVE = ".product/integrations/openspec.json";
252
+ var CI_EXAMPLE_RELATIVE = ".product/integrations/openspec.ci.yml";
253
+ var OPENSPEC_META_RELATIVE = META_RELATIVE;
254
+ var OPENSPEC_CI_EXAMPLE_RELATIVE = CI_EXAMPLE_RELATIVE;
255
+ var OPENSPEC_VERIFY_COMMAND = "npx prodshape citations verify --provider openspec";
256
+ function renderCiExample(options) {
257
+ const stalePolicy = options.warningsAsErrors ? [
258
+ "# - stale citations BLOCK this gate: .product/config.yaml sets",
259
+ "# `validation.warnings-as-errors: true`, escalating the PRODUCT061 warning to an error.",
260
+ "# The integration never changes that policy; relax it by setting the key to false."
261
+ ] : [
262
+ "# - stale citations DO NOT block this gate: .product/config.yaml sets",
263
+ "# `validation.warnings-as-errors: false`, so PRODUCT061 stays a warning. To make stale",
264
+ "# citations block the merge, set `validation.warnings-as-errors: true` in",
265
+ "# .product/config.yaml. The integration never changes that policy for you."
266
+ ];
267
+ return [
268
+ "# PDaC citation gate for OpenSpec consumer documents (CI-ready example).",
269
+ "# Managed by `prodshape integration add openspec`; copy it into your pipeline",
270
+ "# (e.g. .github/workflows/) and adapt the triggers.",
271
+ "#",
272
+ "# The gate runs ProductShape citation and scope verification only. It never invokes",
273
+ "# `openspec validate`; keep OpenSpec's own verdict a separate step so a native-spec defect",
274
+ "# and a grounding defect stay distinguishable.",
275
+ "#",
276
+ "# Blocking behaviour:",
277
+ "# - unresolved citations, tampered embedded projections, unclassified current documents",
278
+ "# and bound documents with zero citations always fail the gate;",
279
+ ...stalePolicy,
280
+ "name: pdac-citations",
281
+ "on:",
282
+ " pull_request:",
283
+ "jobs:",
284
+ " verify:",
285
+ " runs-on: ubuntu-latest",
286
+ " steps:",
287
+ " - uses: actions/checkout@v4",
288
+ " - uses: actions/setup-node@v4",
289
+ " with:",
290
+ " node-version: '22'",
291
+ " # The OpenSpec CLI ships as @fission-ai/openspec; population discovery runs",
292
+ " # `openspec list`, so the gate needs it installed.",
293
+ ` - run: npm install -g ${OPENSPEC_NPX_SPEC}`,
294
+ ` - run: ${OPENSPEC_VERIFY_COMMAND}`,
295
+ ""
296
+ ].join("\n");
297
+ }
47
298
  function configPath(root) {
48
- return join(root, ...CONFIG_RELATIVE.split("/"));
299
+ return join3(root, ...CONFIG_RELATIVE.split("/"));
49
300
  }
50
301
  function metaPath(root) {
51
- return join(root, ...META_RELATIVE.split("/"));
302
+ return join3(root, ...META_RELATIVE.split("/"));
52
303
  }
53
- async function exists(path) {
54
- try {
55
- await access(path);
56
- return true;
57
- } catch {
58
- return false;
59
- }
304
+ function ciExamplePath(root) {
305
+ return join3(root, ...CI_EXAMPLE_RELATIVE.split("/"));
60
306
  }
61
307
  function compareVersions(a, b) {
62
308
  const parseParts = (v) => {
@@ -70,32 +316,77 @@ function compareVersions(a, b) {
70
316
  if (aMin !== bMin) return aMin - bMin;
71
317
  return aPatch - bPatch;
72
318
  }
73
- async function detectOpenSpecVersion() {
74
- return new Promise((resolve) => {
75
- execFile("openspec", ["--version"], { shell: true }, (error, stdout, stderr) => {
76
- if (error) {
77
- resolve({
78
- error: `OpenSpec CLI not found on PATH (run: npm install -g openspec). ${error.message}`
79
- });
80
- return;
319
+ function runAtRoot(root, command, args) {
320
+ return new Promise((resolve, reject) => {
321
+ execFile2(
322
+ command,
323
+ args,
324
+ { cwd: root, shell: true, env: envWithLocalBin(root) },
325
+ (error, stdout, stderr) => {
326
+ if (error) {
327
+ reject(new Error(`${command} ${args.join(" ")} failed: ${error.message}`));
328
+ return;
329
+ }
330
+ resolve(`${stdout}${stderr}`);
81
331
  }
82
- const output = `${stdout}${stderr}`.trim();
83
- const match = output.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
84
- if (!match?.[1]) {
85
- resolve({ error: `Could not parse OpenSpec version from output: ${output}` });
86
- return;
332
+ );
333
+ });
334
+ }
335
+ async function detectOpenSpecVersion(root) {
336
+ return new Promise((resolve) => {
337
+ execFile2(
338
+ "openspec",
339
+ ["--version"],
340
+ { shell: true, ...root ? { env: envWithLocalBin(root) } : {} },
341
+ (error, stdout, stderr) => {
342
+ if (error) {
343
+ resolve({
344
+ error: `OpenSpec CLI not found on PATH (run: npm install -g ${OPENSPEC_NPX_SPEC}). ${error.message}`
345
+ });
346
+ return;
347
+ }
348
+ const output = `${stdout}${stderr}`.trim();
349
+ const match = output.match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
350
+ if (!match?.[1]) {
351
+ resolve({ error: `Could not parse OpenSpec version from output: ${output}` });
352
+ return;
353
+ }
354
+ resolve({ version: match[1] });
87
355
  }
88
- resolve({ version: match[1] });
89
- });
356
+ );
90
357
  });
91
358
  }
92
- async function isOpenSpecWorkspace(root) {
93
- return exists(join(root, "openspec"));
359
+ async function bootstrapOpenSpecWorkspace(root) {
360
+ if (await isOpenSpecWorkspace(root)) {
361
+ throw new Error("An OpenSpec workspace already exists at this root.");
362
+ }
363
+ const detected = await detectOpenSpecVersion(root);
364
+ if (!("error" in detected)) {
365
+ if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
366
+ throw new Error(
367
+ `OpenSpec CLI version ${detected.version} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`
368
+ );
369
+ }
370
+ const command2 = "openspec init --tools none";
371
+ await runAtRoot(root, "openspec", ["init", "--tools", "none"]);
372
+ return { version: detected.version, viaNpx: false, command: command2 };
373
+ }
374
+ const command = `npx -y ${OPENSPEC_NPX_SPEC} init --tools none`;
375
+ await runAtRoot(root, "npx", ["-y", OPENSPEC_NPX_SPEC, "init", "--tools", "none"]);
376
+ const output = await runAtRoot(root, "npx", ["-y", OPENSPEC_NPX_SPEC, "--version"]);
377
+ const match = output.trim().match(/(\d+\.\d+\.\d+(?:-[^\s]+)?)/);
378
+ if (!match?.[1]) {
379
+ throw new Error(`Could not parse OpenSpec version from npx output: ${output.trim()}`);
380
+ }
381
+ return { version: match[1], viaNpx: true, command };
382
+ }
383
+ async function isOpenSpecIntegrationInstalled(root) {
384
+ return pathExists(metaPath(root));
94
385
  }
95
386
  async function readOpenSpecConfig(root) {
96
387
  const path = configPath(root);
97
- if (!await exists(path)) return null;
98
- const text = await readFile(path, "utf8");
388
+ if (!await pathExists(path)) return null;
389
+ const text = await readFile2(path, "utf8");
99
390
  const parsed = parse(text);
100
391
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
101
392
  return {};
@@ -109,9 +400,10 @@ function extractPdacContext(context) {
109
400
  if (begin === -1 || end === -1 || end < begin) return null;
110
401
  return context.slice(begin, end + PDAC_CONTEXT_END.length);
111
402
  }
112
- function mergeConfig(existing) {
403
+ function mergeConfig(existing, previouslyManaged) {
113
404
  const config = { ...existing ?? {} };
114
405
  const changes = [];
406
+ const staleManaged = (recorded, current) => (recorded ?? []).filter((entry) => !current.includes(entry));
115
407
  const existingContext = typeof config["context"] === "string" ? config["context"] : void 0;
116
408
  const existingPdacBlock = extractPdacContext(existingContext);
117
409
  if (existingPdacBlock !== null) {
@@ -132,28 +424,55 @@ function mergeConfig(existing) {
132
424
  }
133
425
  const existingRules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? { ...config["rules"] } : {};
134
426
  let rulesChanged = false;
135
- for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {
427
+ let staleRulesRemoved = false;
428
+ const ruleArtifacts = /* @__PURE__ */ new Set([
429
+ ...Object.keys(PDAC_RULES),
430
+ ...Object.keys(previouslyManaged?.rules ?? {})
431
+ ]);
432
+ for (const artifact of ruleArtifacts) {
433
+ const pdacRules = PDAC_RULES[artifact] ?? [];
434
+ const stale = staleManaged(previouslyManaged?.rules[artifact], pdacRules);
136
435
  const existingList = Array.isArray(existingRules[artifact]) ? [...existingRules[artifact]] : [];
137
- const merged = [...existingList];
436
+ const merged = existingList.filter((entry) => !stale.includes(entry));
437
+ if (merged.length !== existingList.length) {
438
+ staleRulesRemoved = true;
439
+ rulesChanged = true;
440
+ }
138
441
  for (const rule of pdacRules) {
139
442
  if (!merged.includes(rule)) {
140
443
  merged.push(rule);
141
444
  rulesChanged = true;
142
445
  }
143
446
  }
144
- existingRules[artifact] = merged;
447
+ if (merged.length === 0) {
448
+ delete existingRules[artifact];
449
+ } else {
450
+ existingRules[artifact] = merged;
451
+ }
145
452
  }
146
453
  if (rulesChanged) {
147
- config["rules"] = existingRules;
148
- changes.push("Merged PDaC citation rules into openspec/config.yaml (rules:).");
454
+ if (Object.keys(existingRules).length === 0) {
455
+ delete config["rules"];
456
+ } else {
457
+ config["rules"] = existingRules;
458
+ }
459
+ changes.push(
460
+ staleRulesRemoved ? "Merged PDaC citation rules into openspec/config.yaml (rules:), replacing outdated PDaC entries." : "Merged PDaC citation rules into openspec/config.yaml (rules:)."
461
+ );
149
462
  }
150
463
  const existingOperations = config["operations"] && typeof config["operations"] === "object" && !Array.isArray(config["operations"]) ? { ...config["operations"] } : {};
151
464
  let opsChanged = false;
465
+ let staleOpsRemoved = false;
152
466
  for (const opKey of ["apply", "archive"]) {
153
467
  const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;
468
+ const stale = staleManaged(previouslyManaged?.operations[opKey], pdacGuidance);
154
469
  const existingOp = existingOperations[opKey] && typeof existingOperations[opKey] === "object" && !Array.isArray(existingOperations[opKey]) ? { ...existingOperations[opKey] } : {};
155
470
  const existingGuidance = Array.isArray(existingOp["guidance"]) ? [...existingOp["guidance"]] : [];
156
- const merged = [...existingGuidance];
471
+ const merged = existingGuidance.filter((entry) => !stale.includes(entry));
472
+ if (merged.length !== existingGuidance.length) {
473
+ staleOpsRemoved = true;
474
+ opsChanged = true;
475
+ }
157
476
  for (const guidance of pdacGuidance) {
158
477
  if (!merged.includes(guidance)) {
159
478
  merged.push(guidance);
@@ -165,49 +484,63 @@ function mergeConfig(existing) {
165
484
  }
166
485
  if (opsChanged) {
167
486
  config["operations"] = existingOperations;
168
- changes.push("Merged PDaC operation guidance into openspec/config.yaml (operations:).");
487
+ changes.push(
488
+ staleOpsRemoved ? "Merged PDaC operation guidance into openspec/config.yaml (operations:), replacing outdated PDaC entries." : "Merged PDaC operation guidance into openspec/config.yaml (operations:)."
489
+ );
169
490
  }
170
491
  return { config, changes };
171
492
  }
172
493
  function serializeConfig(config) {
173
- return `${stringify(config)}
494
+ return `${stringify(config).trimEnd()}
174
495
  `;
175
496
  }
176
497
  async function productshapeVersion() {
177
498
  const packageJson = JSON.parse(
178
- await readFile(new URL("../package.json", import.meta.url), "utf8")
499
+ await readFile2(new URL("../package.json", import.meta.url), "utf8")
179
500
  );
180
501
  return packageJson.version;
181
502
  }
182
503
  async function addOpenSpecIntegration(root, options = {}) {
183
- const { force = false, dryRun = false } = options;
184
- const detected = await detectOpenSpecVersion();
185
- if ("error" in detected) {
186
- throw new Error(detected.error);
504
+ const { force = false, dryRun = false, warningsAsErrors = false } = options;
505
+ let openspecVersion = options.cliVersion;
506
+ if (!openspecVersion) {
507
+ const detected = await detectOpenSpecVersion(root);
508
+ if ("error" in detected) {
509
+ throw new Error(detected.error);
510
+ }
511
+ openspecVersion = detected.version;
187
512
  }
188
- if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
513
+ if (compareVersions(openspecVersion, MIN_SUPPORTED_OPENSPEC) < 0) {
189
514
  throw new Error(
190
- `OpenSpec CLI version ${detected.version} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`
515
+ `OpenSpec CLI version ${openspecVersion} is below the minimum supported ${MIN_SUPPORTED_OPENSPEC}. Please upgrade OpenSpec.`
191
516
  );
192
517
  }
193
518
  if (!await isOpenSpecWorkspace(root)) {
194
519
  throw new Error("No OpenSpec workspace found. Run `openspec init` first.");
195
520
  }
196
521
  const existing = await readOpenSpecConfig(root);
197
- if (!force && existing) {
198
- const existingContext = typeof existing["context"] === "string" ? existing["context"] : void 0;
199
- if (existingContext && extractPdacContext(existingContext) === null) {
200
- }
522
+ const previousMeta = await readMeta(root);
523
+ const { config, changes: configChanges } = mergeConfig(existing, previousMeta?.managed);
524
+ const ciDesired = renderCiExample({ warningsAsErrors });
525
+ const ciAbsolute = ciExamplePath(root);
526
+ const ciExisting = await pathExists(ciAbsolute) ? await readFile2(ciAbsolute, "utf8") : null;
527
+ const ciChanged = ciExisting !== ciDesired;
528
+ const changes = [...configChanges];
529
+ if (ciChanged) {
530
+ changes.push(
531
+ ciExisting === null ? `Installed CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.` : `Updated CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.`
532
+ );
201
533
  }
202
- const { config, changes } = mergeConfig(existing);
534
+ const meta = {
535
+ provider: "openspec",
536
+ version: await productshapeVersion(),
537
+ openspecVersion,
538
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
539
+ configPath: CONFIG_RELATIVE,
540
+ ciExamplePath: CI_EXAMPLE_RELATIVE,
541
+ managed: currentManagedStrings()
542
+ };
203
543
  if (changes.length === 0 && !force) {
204
- const meta = {
205
- provider: "openspec",
206
- version: await productshapeVersion(),
207
- openspecVersion: detected.version,
208
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
209
- configPath: CONFIG_RELATIVE
210
- };
211
544
  if (!dryRun) {
212
545
  await writeMeta(root, meta);
213
546
  }
@@ -215,31 +548,21 @@ async function addOpenSpecIntegration(root, options = {}) {
215
548
  }
216
549
  const written = [];
217
550
  if (!dryRun) {
218
- const serialized = serializeConfig(config);
219
- await mkdir(dirname(configPath(root)), { recursive: true });
220
- await writeFile(configPath(root), serialized, "utf8");
221
- written.push(CONFIG_RELATIVE);
222
- const meta = {
223
- provider: "openspec",
224
- version: await productshapeVersion(),
225
- openspecVersion: detected.version,
226
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
227
- configPath: CONFIG_RELATIVE
228
- };
551
+ if (configChanges.length > 0 || force) {
552
+ const serialized = serializeConfig(config);
553
+ await mkdir(dirname(configPath(root)), { recursive: true });
554
+ await writeFile(configPath(root), serialized, "utf8");
555
+ written.push(CONFIG_RELATIVE);
556
+ }
557
+ if (ciChanged || force) {
558
+ await mkdir(dirname(ciAbsolute), { recursive: true });
559
+ await writeFile(ciAbsolute, ciDesired, "utf8");
560
+ written.push(CI_EXAMPLE_RELATIVE);
561
+ }
229
562
  await writeMeta(root, meta);
230
563
  written.push(META_RELATIVE);
231
564
  }
232
- return {
233
- written,
234
- changes,
235
- meta: {
236
- provider: "openspec",
237
- version: await productshapeVersion(),
238
- openspecVersion: detected.version,
239
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
240
- configPath: CONFIG_RELATIVE
241
- }
242
- };
565
+ return { written, changes, meta };
243
566
  }
244
567
  async function writeMeta(root, meta) {
245
568
  const path = metaPath(root);
@@ -249,16 +572,20 @@ async function writeMeta(root, meta) {
249
572
  }
250
573
  async function readMeta(root) {
251
574
  const path = metaPath(root);
252
- if (!await exists(path)) return null;
253
- return JSON.parse(await readFile(path, "utf8"));
575
+ if (!await pathExists(path)) return null;
576
+ return JSON.parse(await readFile2(path, "utf8"));
254
577
  }
255
578
  async function updateOpenSpecIntegration(root, options = {}) {
256
- const result = await addOpenSpecIntegration(root, { force: true, dryRun: options.dryRun });
579
+ const result = await addOpenSpecIntegration(root, {
580
+ force: true,
581
+ dryRun: options.dryRun,
582
+ warningsAsErrors: options.warningsAsErrors
583
+ });
257
584
  return { written: result.written, changes: result.changes };
258
585
  }
259
586
  async function checkOpenSpecIntegration(root) {
260
587
  const checks = [];
261
- const detected = await detectOpenSpecVersion();
588
+ const detected = await detectOpenSpecVersion(root);
262
589
  if ("error" in detected) {
263
590
  checks.push({ name: "openspec CLI", ok: false, detail: detected.error });
264
591
  } else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
@@ -318,6 +645,19 @@ async function checkOpenSpecIntegration(root) {
318
645
  detail: `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}).`
319
646
  });
320
647
  }
648
+ if (await pathExists(ciExamplePath(root))) {
649
+ checks.push({
650
+ name: "ci example",
651
+ ok: true,
652
+ detail: `CI-ready verification example present at ${CI_EXAMPLE_RELATIVE}.`
653
+ });
654
+ } else {
655
+ checks.push({
656
+ name: "ci example",
657
+ ok: false,
658
+ detail: `CI-ready verification example missing (${CI_EXAMPLE_RELATIVE}). Run: prodshape integration update`
659
+ });
660
+ }
321
661
  if (config) {
322
662
  const rules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? config["rules"] : {};
323
663
  const missingRules = [];
@@ -343,6 +683,26 @@ async function checkOpenSpecIntegration(root) {
343
683
  async function removeOpenSpecIntegration(root, options = {}) {
344
684
  const { dryRun = false } = options;
345
685
  const removed = [];
686
+ const recorded = (await readMeta(root))?.managed;
687
+ const current = currentManagedStrings();
688
+ const managedRules = {};
689
+ for (const key of /* @__PURE__ */ new Set([
690
+ ...Object.keys(current.rules),
691
+ ...Object.keys(recorded?.rules ?? {})
692
+ ])) {
693
+ managedRules[key] = [
694
+ .../* @__PURE__ */ new Set([...current.rules[key] ?? [], ...recorded?.rules[key] ?? []])
695
+ ];
696
+ }
697
+ const managedOperations = {};
698
+ for (const key of /* @__PURE__ */ new Set([
699
+ ...Object.keys(current.operations),
700
+ ...Object.keys(recorded?.operations ?? {})
701
+ ])) {
702
+ managedOperations[key] = [
703
+ .../* @__PURE__ */ new Set([...current.operations[key] ?? [], ...recorded?.operations[key] ?? []])
704
+ ];
705
+ }
346
706
  const config = await readOpenSpecConfig(root);
347
707
  if (config !== null) {
348
708
  let configChanged = false;
@@ -362,7 +722,7 @@ async function removeOpenSpecIntegration(root, options = {}) {
362
722
  const rules = config["rules"] && typeof config["rules"] === "object" && !Array.isArray(config["rules"]) ? config["rules"] : null;
363
723
  if (rules) {
364
724
  let rulesChanged = false;
365
- for (const [artifact, pdacRules] of Object.entries(PDAC_RULES)) {
725
+ for (const [artifact, pdacRules] of Object.entries(managedRules)) {
366
726
  if (!Array.isArray(rules[artifact])) continue;
367
727
  const list = rules[artifact];
368
728
  const filtered = list.filter((r) => !pdacRules.includes(r));
@@ -386,7 +746,7 @@ async function removeOpenSpecIntegration(root, options = {}) {
386
746
  if (operations) {
387
747
  let opsChanged = false;
388
748
  for (const opKey of ["apply", "archive"]) {
389
- const pdacGuidance = PDAC_OPERATIONS[opKey].guidance;
749
+ const pdacGuidance = managedOperations[opKey] ?? [];
390
750
  const op = operations[opKey] && typeof operations[opKey] === "object" && !Array.isArray(operations[opKey]) ? operations[opKey] : null;
391
751
  if (!op || !Array.isArray(op["guidance"])) continue;
392
752
  const list = op["guidance"];
@@ -418,7 +778,13 @@ async function removeOpenSpecIntegration(root, options = {}) {
418
778
  removed.push(CONFIG_RELATIVE);
419
779
  }
420
780
  }
421
- if (await exists(metaPath(root))) {
781
+ if (await pathExists(ciExamplePath(root))) {
782
+ if (!dryRun) {
783
+ await rm(ciExamplePath(root), { force: true });
784
+ }
785
+ removed.push(CI_EXAMPLE_RELATIVE);
786
+ }
787
+ if (await pathExists(metaPath(root))) {
422
788
  if (!dryRun) {
423
789
  await rm(metaPath(root), { force: true });
424
790
  }
@@ -428,16 +794,27 @@ async function removeOpenSpecIntegration(root, options = {}) {
428
794
  }
429
795
  export {
430
796
  MIN_SUPPORTED_OPENSPEC,
797
+ OPENSPEC_CI_EXAMPLE_RELATIVE,
798
+ OPENSPEC_META_RELATIVE,
799
+ OPENSPEC_NPX_SPEC,
800
+ OPENSPEC_VERIFY_COMMAND,
431
801
  PDAC_CONTEXT_BLOCK,
432
802
  PDAC_OPERATIONS,
433
803
  PDAC_RULES,
434
804
  addOpenSpecIntegration,
805
+ bootstrapOpenSpecWorkspace,
435
806
  checkOpenSpecIntegration,
807
+ currentManagedStrings,
436
808
  detectOpenSpecVersion,
809
+ enumerateOpenSpecDocuments,
810
+ isOpenSpecCliAvailable,
811
+ isOpenSpecIntegrationInstalled,
437
812
  isOpenSpecWorkspace,
438
813
  mergeConfig,
814
+ openSpecProvider,
439
815
  readOpenSpecConfig,
440
816
  removeOpenSpecIntegration,
817
+ renderCiExample,
441
818
  serializeConfig,
442
819
  updateOpenSpecIntegration
443
820
  };