@prodshape/integration-openspec 0.5.3 → 0.6.0-alpha.3

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,8 +1,63 @@
1
1
  // src/index.ts
2
- import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
2
+ import { mkdir, readdir as readdir4, readFile as readFile4, rm, rmdir, writeFile } from "fs/promises";
3
3
  import { dirname } from "path";
4
- import { isNotFound, resolveInRepository } from "@prodshape/core";
5
- import { parse, stringify } from "yaml";
4
+ import {
5
+ contentDigest,
6
+ isNotFound as isNotFound2,
7
+ isRepositoryRelativePath,
8
+ resolveInRepository
9
+ } from "@prodshape/core";
10
+ import { parse as parse2, stringify } from "yaml";
11
+
12
+ // src/product-change-schema.ts
13
+ import { readdir, readFile } from "fs/promises";
14
+ import { fileURLToPath } from "url";
15
+ import { join } from "path";
16
+ var OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME = "product-change";
17
+ var OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE = "openspec/schemas/product-change";
18
+ var PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC = "1.7.0";
19
+ var OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME = "product-recovery";
20
+ var OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE = "openspec/schemas/product-recovery";
21
+ function assetsDir() {
22
+ return fileURLToPath(new URL("../assets/openspec-product-change-schema/", import.meta.url));
23
+ }
24
+ async function collectFiles(dir, prefix) {
25
+ const entries = await readdir(dir, { withFileTypes: true });
26
+ const files = [];
27
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
28
+ const relative2 = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
29
+ if (entry.isDirectory()) {
30
+ files.push(...await collectFiles(join(dir, entry.name), relative2));
31
+ } else if (entry.isFile()) {
32
+ files.push(relative2);
33
+ }
34
+ }
35
+ return files;
36
+ }
37
+ async function loadProductChangeSchemaAssets() {
38
+ const dir = assetsDir();
39
+ const relatives = await collectFiles(dir, "");
40
+ const assets = [];
41
+ for (const relative2 of relatives.sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
42
+ assets.push({
43
+ relative: relative2,
44
+ content: await readFile(join(dir, ...relative2.split("/")), "utf8")
45
+ });
46
+ }
47
+ return assets;
48
+ }
49
+ async function loadProductRecoverySchemaAssets() {
50
+ const dir = fileURLToPath(
51
+ new URL("../assets/openspec-product-recovery-schema/", import.meta.url)
52
+ );
53
+ const relatives = await collectFiles(dir, "");
54
+ return Promise.all(
55
+ relatives.sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map(async (relative2) => ({
56
+ relative: relative2,
57
+ content: await readFile(join(dir, ...relative2.split("/")), "utf8")
58
+ }))
59
+ );
60
+ }
6
61
 
7
62
  // src/process.ts
8
63
  import crossSpawn from "cross-spawn";
@@ -64,7 +119,7 @@ function runCommand(command, args, options = {}) {
64
119
 
65
120
  // src/workspace.ts
66
121
  import { access } from "fs/promises";
67
- import { join } from "path";
122
+ import { join as join2 } from "path";
68
123
  async function pathExists(path) {
69
124
  try {
70
125
  await access(path);
@@ -74,7 +129,7 @@ async function pathExists(path) {
74
129
  }
75
130
  }
76
131
  async function isOpenSpecWorkspace(root) {
77
- return pathExists(join(root, "openspec"));
132
+ return pathExists(join2(root, "openspec"));
78
133
  }
79
134
  function envWithLocalBin(root) {
80
135
  const env = { ...process.env };
@@ -86,15 +141,15 @@ function envWithLocalBin(root) {
86
141
  delete env[key];
87
142
  }
88
143
  }
89
- const localBin = join(root, "node_modules", ".bin");
144
+ const localBin = join2(root, "node_modules", ".bin");
90
145
  env["PATH"] = existing ? `${localBin}${separator}${existing}` : localBin;
91
146
  return env;
92
147
  }
93
148
 
94
149
  // src/population.ts
95
150
  import { readFileSync } from "fs";
96
- import { readdir, readFile } from "fs/promises";
97
- import { join as join2, relative, sep } from "path";
151
+ import { readdir as readdir2, readFile as readFile2 } from "fs/promises";
152
+ import { join as join3, relative, sep } from "path";
98
153
  import fg from "fast-glob";
99
154
  import { codes } from "@prodshape/core";
100
155
  var CHANGE_ARTIFACT_FILES = [
@@ -150,16 +205,16 @@ function buildConsumerDocument(absolutePath, repoRoot, opts) {
150
205
  async function discoverChangeDocuments(changeDir, repoRoot, change, archived) {
151
206
  const docs = [];
152
207
  for (const { file, kind } of CHANGE_ARTIFACT_FILES) {
153
- const absolutePath = join2(changeDir, file);
208
+ const absolutePath = join3(changeDir, file);
154
209
  try {
155
- await readFile(absolutePath);
210
+ await readFile2(absolutePath);
156
211
  docs.push(
157
212
  buildConsumerDocument(absolutePath, repoRoot, { change, archived, artifactKind: kind })
158
213
  );
159
214
  } catch {
160
215
  }
161
216
  }
162
- const specsDir = join2(changeDir, "specs");
217
+ const specsDir = join3(changeDir, "specs");
163
218
  try {
164
219
  const specFiles = await discoverMarkdownFiles(specsDir);
165
220
  for (const absolutePath of specFiles) {
@@ -186,7 +241,7 @@ async function discoverSpecDocuments(specsDir, repoRoot) {
186
241
  }
187
242
  async function discoverArchivedChanges(archiveDir) {
188
243
  try {
189
- const entries = await readdir(archiveDir, { withFileTypes: true });
244
+ const entries = await readdir2(archiveDir, { withFileTypes: true });
190
245
  return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
191
246
  } catch {
192
247
  return [];
@@ -194,10 +249,10 @@ async function discoverArchivedChanges(archiveDir) {
194
249
  }
195
250
  async function enumerateOpenSpecDocuments(repoRoot, options) {
196
251
  const includeArchived = options?.includeArchived ?? false;
197
- const root = join2(repoRoot, "openspec");
198
- const changesDir = join2(root, "changes");
199
- const archiveDir = join2(changesDir, "archive");
200
- const specsDir = join2(root, "specs");
252
+ const root = join3(repoRoot, "openspec");
253
+ const changesDir = join3(root, "changes");
254
+ const archiveDir = join3(changesDir, "archive");
255
+ const specsDir = join3(root, "specs");
201
256
  const documents = [];
202
257
  const diagnostics = [];
203
258
  let currentChangeNames = await pathExists(changesDir) ? await openspecListChanges(repoRoot) : [];
@@ -209,7 +264,7 @@ async function enumerateOpenSpecDocuments(repoRoot, options) {
209
264
  file: "openspec/"
210
265
  });
211
266
  try {
212
- const entries = await readdir(changesDir, { withFileTypes: true });
267
+ const entries = await readdir2(changesDir, { withFileTypes: true });
213
268
  currentChangeNames = entries.filter((e) => e.isDirectory() && e.name !== "archive").map((e) => e.name).sort();
214
269
  } catch {
215
270
  currentChangeNames = [];
@@ -224,14 +279,14 @@ async function enumerateOpenSpecDocuments(repoRoot, options) {
224
279
  }
225
280
  }
226
281
  for (const name of currentChangeNames) {
227
- const changeDir = join2(changesDir, name);
282
+ const changeDir = join3(changesDir, name);
228
283
  documents.push(...await discoverChangeDocuments(changeDir, repoRoot, name, false));
229
284
  }
230
285
  if (includeArchived) {
231
286
  const archivedNames = await discoverArchivedChanges(archiveDir);
232
287
  for (const name of archivedNames) {
233
288
  if (options?.change && name !== options.change) continue;
234
- const changeDir = join2(archiveDir, name);
289
+ const changeDir = join3(archiveDir, name);
235
290
  documents.push(...await discoverChangeDocuments(changeDir, repoRoot, name, true));
236
291
  }
237
292
  }
@@ -251,6 +306,295 @@ var openSpecProvider = {
251
306
  enumerateDocuments: enumerateOpenSpecDocuments
252
307
  };
253
308
 
309
+ // src/product-workflow.ts
310
+ import { readdir as readdir3, readFile as readFile3 } from "fs/promises";
311
+ import { join as join4 } from "path";
312
+ import { parse } from "yaml";
313
+ import {
314
+ analyzeImpact,
315
+ compareCodePoints,
316
+ discoverChanges,
317
+ executeApply,
318
+ isNotFound,
319
+ loadChange,
320
+ openRepository,
321
+ planHostedProductChange,
322
+ preflightApply,
323
+ sortDiagnostics,
324
+ validateBaseline,
325
+ validateHostedProductChange,
326
+ validateChange
327
+ } from "@prodshape/core";
328
+ var OPENSPEC_PRODUCT_SUBDIR = "product-change";
329
+ var OPENSPEC_CHANGE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
330
+ function assertOpenSpecChangeName(changeName) {
331
+ if (changeName.length > 200 || !OPENSPEC_CHANGE_NAME.test(changeName)) {
332
+ throw new Error(
333
+ `OpenSpec change '${changeName}' is not a valid OpenSpec change name; use kebab-case with lowercase letters, numbers and single hyphen separators.`
334
+ );
335
+ }
336
+ }
337
+ async function inspectProductModel(root) {
338
+ const repo = await openRepository(root);
339
+ const baseline = await validateBaseline(repo);
340
+ return {
341
+ repo,
342
+ artifacts: baseline.artifacts,
343
+ graph: baseline.graph,
344
+ diagnostics: sortDiagnostics([...repo.configDiagnostics, ...baseline.diagnostics])
345
+ };
346
+ }
347
+ var OPEN_SPEC_KEBAB_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
348
+ function openSpecChangeMetadataProblem(parsed) {
349
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
350
+ return "the document must be an object";
351
+ }
352
+ const record = parsed;
353
+ if (typeof record["schema"] !== "string" || record["schema"].length === 0) {
354
+ return "schema is required";
355
+ }
356
+ const created = record["created"];
357
+ if (created !== void 0 && (typeof created !== "string" || !/^\d{4}-\d{2}-\d{2}$/u.test(created))) {
358
+ return "created must be YYYY-MM-DD format";
359
+ }
360
+ const goal = record["goal"];
361
+ if (goal !== void 0 && (typeof goal !== "string" || goal.length === 0)) {
362
+ return "goal must be a non-empty string";
363
+ }
364
+ const affectedAreas = record["affected_areas"];
365
+ if (affectedAreas !== void 0 && (!Array.isArray(affectedAreas) || !affectedAreas.every((area) => typeof area === "string" && area.length > 0))) {
366
+ return "affected_areas must be an array of non-empty strings";
367
+ }
368
+ const initiative = record["initiative"];
369
+ if (initiative !== void 0) {
370
+ if (initiative === null || typeof initiative !== "object" || Array.isArray(initiative)) {
371
+ return "initiative must be an object";
372
+ }
373
+ const link = initiative;
374
+ if (Object.keys(link).some((key) => key !== "store" && key !== "id") || typeof link["store"] !== "string" || !OPEN_SPEC_KEBAB_ID.test(link["store"]) || typeof link["id"] !== "string" || !OPEN_SPEC_KEBAB_ID.test(link["id"])) {
375
+ return "initiative must contain only kebab-case store and id fields";
376
+ }
377
+ }
378
+ if (record["skip_specs"] !== void 0 && typeof record["skip_specs"] !== "boolean") {
379
+ return "skip_specs must be a boolean";
380
+ }
381
+ if (record["retire_capabilities"] !== void 0 && typeof record["retire_capabilities"] !== "boolean") {
382
+ return "retire_capabilities must be a boolean";
383
+ }
384
+ return void 0;
385
+ }
386
+ async function readHostedSchemaPin(changeDir) {
387
+ const metadataPath = join4(changeDir, ".openspec.yaml");
388
+ if (!await pathExists(metadataPath)) return { problem: "missing" };
389
+ let parsed;
390
+ try {
391
+ parsed = parse(await readFile3(metadataPath, "utf8"));
392
+ } catch (error) {
393
+ return {
394
+ problem: "malformed",
395
+ detail: error instanceof Error ? error.message : String(error)
396
+ };
397
+ }
398
+ const problem = openSpecChangeMetadataProblem(parsed);
399
+ if (problem !== void 0) return { problem: "malformed", detail: problem };
400
+ return { schema: parsed["schema"] };
401
+ }
402
+ function hostedSchemaPinError(changeName, pin) {
403
+ if (pin.problem === "missing") {
404
+ return new Error(
405
+ `OpenSpec change '${changeName}' has no .openspec.yaml, so its schema pin is unknown; the hosted product rail refuses it. Create product changes with: openspec new change ${changeName} --schema ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME}.`
406
+ );
407
+ }
408
+ return new Error(
409
+ `OpenSpec change '${changeName}' has an unreadable .openspec.yaml (${pin.detail}); the hosted product rail refuses it until the metadata is valid under OpenSpec 1.11.`
410
+ );
411
+ }
412
+ async function listOpenSpecProductChanges(root) {
413
+ const changesDir = join4(root, "openspec", "changes");
414
+ let entries;
415
+ try {
416
+ entries = await readdir3(changesDir, { withFileTypes: true });
417
+ } catch (error) {
418
+ if (isNotFound(error)) return [];
419
+ throw error;
420
+ }
421
+ const names = entries.filter((entry) => entry.isDirectory() && entry.name !== "archive").map((entry) => entry.name).sort(compareCodePoints);
422
+ const refs = [];
423
+ for (const name of names) {
424
+ const changeDir = join4(changesDir, name);
425
+ const dir = join4(changeDir, OPENSPEC_PRODUCT_SUBDIR);
426
+ if (!await pathExists(join4(dir, "change.md"))) continue;
427
+ assertOpenSpecChangeName(name);
428
+ const pin = await readHostedSchemaPin(changeDir);
429
+ if ("problem" in pin) throw hostedSchemaPinError(name, pin);
430
+ if (pin.schema !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME) continue;
431
+ refs.push({
432
+ name,
433
+ dir,
434
+ file: `openspec/changes/${name}/${OPENSPEC_PRODUCT_SUBDIR}/change.md`
435
+ });
436
+ }
437
+ return refs;
438
+ }
439
+ async function loadOpenSpecProductChange(root, changeName) {
440
+ const repo = await openRepository(root);
441
+ return loadHostedChange(root, repo, changeName);
442
+ }
443
+ async function loadHostedChange(root, repo, changeName) {
444
+ assertOpenSpecChangeName(changeName);
445
+ const changesDir = join4(root, "openspec", "changes");
446
+ const changeDir = join4(changesDir, changeName);
447
+ const deltaDir = join4(changeDir, OPENSPEC_PRODUCT_SUBDIR);
448
+ const hasDelta = changeName !== "archive" && await pathExists(changeDir) && await pathExists(join4(deltaDir, "change.md"));
449
+ if (!hasDelta) {
450
+ const refs = await listOpenSpecProductChanges(root);
451
+ const known = refs.map((candidate) => candidate.name).join(", ") || "none";
452
+ throw new Error(
453
+ `No OpenSpec product change named '${changeName}' under openspec/changes/ (a product change hosts product-change/change.md; found: ${known}).`
454
+ );
455
+ }
456
+ const pin = await readHostedSchemaPin(changeDir);
457
+ if ("problem" in pin) {
458
+ throw hostedSchemaPinError(changeName, pin);
459
+ }
460
+ if (pin.schema !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME) {
461
+ throw new Error(
462
+ `OpenSpec change '${changeName}' is pinned to schema '${pin.schema}', not '${OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME}'; the hosted product rail refuses it. Product intent travels through a change created with: openspec new change <name> --schema ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME}.`
463
+ );
464
+ }
465
+ return loadChange(deltaDir, repo.root, repo.registry);
466
+ }
467
+ var TERMINAL_CHANGE_STATUSES = /* @__PURE__ */ new Set(["applied", "rejected", "superseded"]);
468
+ async function loadAllLiveChanges(root, repo) {
469
+ const changes = [];
470
+ for (const ref of await listOpenSpecProductChanges(root)) {
471
+ const loaded = await loadChange(ref.dir, repo.root, repo.registry);
472
+ if (loaded.status !== void 0 && TERMINAL_CHANGE_STATUSES.has(loaded.status)) continue;
473
+ changes.push(loaded);
474
+ }
475
+ for (const dir of await discoverChanges(join4(repo.changesDir, "active"))) {
476
+ changes.push(await loadChange(dir, repo.root, repo.registry));
477
+ }
478
+ return changes;
479
+ }
480
+ async function validateOpenSpecProductChange(root, changeName) {
481
+ const repo = await openRepository(root);
482
+ const baseline = await validateBaseline(repo);
483
+ const all = await loadAllLiveChanges(root, repo);
484
+ const change = all.find((candidate) => hostedName(root, candidate) === changeName) ?? await loadHostedChange(root, repo, changeName);
485
+ const validation = validateChange(change, baseline.artifacts, all);
486
+ const hosted = validateHostedProductChange(
487
+ repo,
488
+ baseline.artifacts,
489
+ change,
490
+ all,
491
+ baseline.diagnostics
492
+ );
493
+ const diagnostics = hosted.diagnostics;
494
+ return {
495
+ change,
496
+ baseline,
497
+ overlayArtifacts: validation.overlayArtifacts,
498
+ overlayGraph: validation.overlayGraph,
499
+ diagnostics,
500
+ blocking: hosted.blocking
501
+ };
502
+ }
503
+ function hostedName(root, change) {
504
+ const normalizedRoot = root.replaceAll("\\", "/");
505
+ const normalizedDir = change.dir.replaceAll("\\", "/");
506
+ const relative2 = normalizedDir.startsWith(normalizedRoot) ? normalizedDir.slice(normalizedRoot.length).replace(/^\//, "") : normalizedDir;
507
+ const match = relative2.match(/^openspec\/changes\/([^/]+)\/product-change$/);
508
+ return match?.[1];
509
+ }
510
+ async function applyOpenSpecProductChange(root, changeName, options = {}) {
511
+ const { dryRun = false } = options;
512
+ const repo = await openRepository(root);
513
+ const baseline = await validateBaseline(repo);
514
+ const all = await loadAllLiveChanges(root, repo);
515
+ const change = all.find((candidate) => hostedName(root, candidate) === changeName) ?? await loadHostedChange(root, repo, changeName);
516
+ const hosted = validateHostedProductChange(
517
+ repo,
518
+ baseline.artifacts,
519
+ change,
520
+ all,
521
+ baseline.diagnostics
522
+ );
523
+ const rawPlan = await planHostedProductChange({
524
+ repoRoot: repo.root,
525
+ modelRelative: repo.config.product.model,
526
+ changesRelative: repo.config.product.changes,
527
+ change,
528
+ baseline: baseline.artifacts,
529
+ overlayErrors: hosted.blocking
530
+ });
531
+ const plan = {
532
+ ...rawPlan,
533
+ actions: rawPlan.actions.filter((action) => action.kind !== "move-change"),
534
+ // The hosted rail states authorisation as policy. Core's PRODUCT028 wording prescribes a
535
+ // human hand edit, which is the native lifecycle's own contract; here the code, severity,
536
+ // file and fields stay core's while the message (implementation-defined and never compared)
537
+ // says what the hosted contract requires. A message rewrite of an existing diagnostic, never
538
+ // a new emission.
539
+ diagnostics: rawPlan.diagnostics.map(
540
+ (diagnostic) => diagnostic.code === "PRODUCT028" ? {
541
+ ...diagnostic,
542
+ message: `Apply requires status 'approved'; the change is '${change.status ?? "unknown"}'. The transition into the apply-authorised state belongs to the caller's authorisation policy; the integration never performs or judges it.`
543
+ } : diagnostic
544
+ )
545
+ };
546
+ if (plan.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
547
+ return { outcome: "refused", plan, change };
548
+ }
549
+ if (dryRun) {
550
+ await preflightApply(repo.root, plan);
551
+ return { outcome: "dry-run", plan, change };
552
+ }
553
+ await executeApply(repo.root, plan);
554
+ const resultingModel = await validateBaseline(repo);
555
+ return { outcome: "applied", plan, change, resultingModel };
556
+ }
557
+ async function deriveDeliveryContext(root, ids) {
558
+ const inspection = await inspectProductModel(root);
559
+ const byId = new Map(
560
+ inspection.artifacts.filter((artifact) => artifact.id !== void 0).map((artifact) => [artifact.id, artifact])
561
+ );
562
+ const entryOf = (artifact) => {
563
+ const entry = {
564
+ id: artifact.id,
565
+ file: artifact.file,
566
+ digest: artifact.digest
567
+ };
568
+ if (artifact.type !== void 0) entry.type = artifact.type;
569
+ if (artifact.title !== void 0) entry.title = artifact.title;
570
+ if (artifact.status !== void 0) entry.status = artifact.status;
571
+ return entry;
572
+ };
573
+ const sortedIds = [...ids].sort(compareCodePoints);
574
+ const artifacts = [];
575
+ const impacts = {};
576
+ const neighbourIds = /* @__PURE__ */ new Set();
577
+ for (const id of sortedIds) {
578
+ const artifact = byId.get(id);
579
+ if (!artifact) {
580
+ throw new Error(`Unknown artifact id '${id}' in the accepted model at ${root}.`);
581
+ }
582
+ artifacts.push(entryOf(artifact));
583
+ const report = analyzeImpact(inspection.graph, id, { direction: "both", depth: 2 });
584
+ impacts[id] = report;
585
+ for (const item of report.direct) neighbourIds.add(item.id);
586
+ }
587
+ for (const id of sortedIds) neighbourIds.delete(id);
588
+ const neighbours = [...neighbourIds].sort(compareCodePoints).map((id) => byId.get(id)).filter((artifact) => artifact !== void 0).map(entryOf);
589
+ return {
590
+ ids: sortedIds,
591
+ artifacts,
592
+ impacts,
593
+ neighbours,
594
+ modelErrorFree: inspection.diagnostics.every((diagnostic) => diagnostic.severity !== "error")
595
+ };
596
+ }
597
+
254
598
  // src/index.ts
255
599
  var MIN_SUPPORTED_OPENSPEC = "1.0.0";
256
600
  var OPENSPEC_NPX_SPEC = "@fission-ai/openspec@1";
@@ -262,14 +606,14 @@ A citation binds consumer text to a canonical artifact: it records the artifact
262
606
  To cite: run \`npx prodshape inspect <ID>\` to read the current digest, then \`npx prodshape cite --id <ID> --digest <digest>\` to emit the canonical payload. Wrap it in the document's native comment (\`<!-- ... -->\` in Markdown) on its own line directly under the text it grounds.
263
607
  To find which artifacts a change impacts, compare the change's intent with the whole product definition first, then widen the result with \`npx prodshape impact <ID>\`. When the intent contradicts or goes beyond the definition, record that drift in the proposal with the marker \`<!-- pdac-drift ids="..." summary="..." -->\` (listed by \`npx prodshape drift\`) and let humans decide \u2014 never fix it quietly.
264
608
  Every current OpenSpec document (proposal, specs, design, tasks and openspec/specs/) carries exactly one explicit scope declaration: \`pdac-scope: cited\` for a bound document that carries at least one citation, or \`pdac-scope: none\` with a non-empty human-authored reason (\`pdac-scope-reason: <why>\` in frontmatter, or \`<!-- pdac-scope: none reason="<why>" -->\`) for an exempt one. Citations alone never bind: a document without a declaration is unclassified and fails verification, a bound document with zero citations fails, and an exemption without a reason or contradicted by citations fails. Binding and exemption are human declarations: never declare an exemption merely because citations are missing. Verify with \`npx prodshape citations verify --provider openspec\`: it enumerates the expected current documents, so zero discovered citations is a set of failures, never a pass. Archived changes are excluded by default; \`--include-archived\` also verifies the citations history carries, reported as warnings, because archived history cannot be edited.
265
- The accepted Product Definition changes only through a Product Change under docs/product/changes/: validate it as an overlay while the baseline stays untouched, obtain human product approval, apply explicitly on a working branch, and let a human accept the resulting baseline by merging its pull request. A Product Change is not a pull request; apply is not acceptance; neither apply nor merge attests implementation, verification, release or deployment. OpenSpec changes never edit docs/product/model directly: an OpenSpec change may share a pull request with an applied Product Change or follow later, but it never supplies Product Change status.
609
+ The accepted Product Definition changes only through a Product Change: validate it as an overlay while the baseline stays untouched, obtain human product approval (represented by status: approved; the integration verifies the state but cannot identify or judge the actor), apply explicitly on a working branch, and let a human accept the resulting baseline by merging its pull request. A Product Change is not a pull request; apply is not acceptance; neither apply nor merge attests implementation, verification, release or deployment. A Product Change lives under docs/product/changes/ or hosted inside a valid OpenSpec change created with the product schema (product-change/change.md plus product-change/proposed/**); its deterministically validated, apply-authorised apply is the only write path into docs/product/model. A spec-driven OpenSpec delivery change never edits docs/product/model directly: it may share a pull request with an applied Product Change or follow later, but it never supplies Product Change status.
266
610
  ${PDAC_CONTEXT_END}`;
267
611
  var PDAC_RULES = {
268
612
  proposal: [
269
613
  "State which PDaC artifacts this change touches, each with a citation payload emitted by `npx prodshape cite --id <ID> --digest <digest>` and wrapped in the document native comment (`<!-- ... -->` in Markdown). Never write a citation record by hand.",
270
614
  "Find the impacted artifacts as a separate step, before writing any proposal content. Read the whole product definition (docs/product/model by default), compare it with what this change wants \u2014 the backlog item behind it, if there is one \u2014 and list every artifact the change depends on, alters or contradicts. Then widen the list: run `npx prodshape impact <ID>` on each artifact you found and check its direct and indirect neighbours. Put the resulting list in the proposal, cite every impacted artifact from each document that uses it (proposal, specs, design, tasks), and name any neighbour you checked and left out.",
271
615
  'If this change\'s goals contradict the product definition, or need behaviour it does not describe, that is product-definition drift. Record it in the proposal under a \'Product definition drift\' note naming the artifacts involved, with the marker `<!-- pdac-drift ids="<ID>[, <ID>...]" summary="<one line>" -->` on its own line so `npx prodshape drift` can list it. The decision is human: the developer and the product owner agree whether to propose a Product Change or adjust this change. Never fix drift quietly, drop or weaken a citation to hide it, or write around the conflict.',
272
- "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.",
616
+ "If the change implements altered product behaviour, name the Product Change (CHG id) whose applied or accepted artifacts it implements. If no overlay-validated, apply-authorised Product Change exists yet, stop and ask for one instead of proceeding. A spec-driven OpenSpec change is not the Product Change; product intent travels through a Product Change, under docs/product/changes or hosted by a product-change OpenSpec change.",
273
617
  'Every document of this change must end up bound or exempt, each with an explicit declaration: declare `pdac-scope: cited` on a line of its own and cite the canonical text the document depends on, or declare `pdac-scope: none` with a non-empty reason (`pdac-scope-reason: <why>` in frontmatter, or `<!-- pdac-scope: none reason="<why>" -->`) when a human judges the document has no product-semantic dependency. Citations alone never bind, and never declare an exemption just because citations are missing.'
274
618
  ],
275
619
  specs: [
@@ -306,6 +650,55 @@ function currentManagedStrings() {
306
650
  )
307
651
  };
308
652
  }
653
+ var LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME = "product";
654
+ var LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE = "openspec/schemas/product";
655
+ function recordedProductSchemaFiles(meta) {
656
+ const record = meta?.productSchema;
657
+ if (!record) return void 0;
658
+ if (typeof record !== "object" || record === null || record.name !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME && record.name !== LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME && record.name !== OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME || typeof record.requiresOpenspec !== "string") {
659
+ throw new Error(
660
+ "'productSchema' must name the product schema and carry a string 'requiresOpenspec'"
661
+ );
662
+ }
663
+ const files = record.files;
664
+ const validatePath = (file) => typeof file === "string" && (file.startsWith(`${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/`) || file.startsWith(`${LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/`) || file.startsWith(`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/`)) && isRepositoryRelativePath(file);
665
+ if (Array.isArray(files)) {
666
+ if (!files.every(validatePath)) {
667
+ throw new Error(
668
+ `'productSchema.files' contains a path outside the managed OpenSpec schema locations`
669
+ );
670
+ }
671
+ return Object.fromEntries(files.map((file) => [file, ""]));
672
+ }
673
+ if (files !== null && typeof files === "object") {
674
+ const entries = Object.entries(files);
675
+ if (!entries.every(([file]) => validatePath(file))) {
676
+ throw new Error(
677
+ `'productSchema.files' contains a path outside the managed OpenSpec schema locations`
678
+ );
679
+ }
680
+ if (!entries.every(
681
+ ([, digest]) => typeof digest === "string" && /^sha256:[0-9a-f]{64}$/.test(digest)
682
+ )) {
683
+ throw new Error("'productSchema.files' contains an invalid content digest");
684
+ }
685
+ return Object.fromEntries(entries);
686
+ }
687
+ throw new Error("'productSchema.files' must be a path-to-digest object or legacy path array");
688
+ }
689
+ function recordedRecoverySchemaFiles(meta) {
690
+ const record = meta?.recoverySchema;
691
+ if (!record) return void 0;
692
+ if (record.name !== OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME || typeof record.requiresOpenspec !== "string") {
693
+ throw new Error(
694
+ "'recoverySchema' must name the product-recovery schema and carry a string 'requiresOpenspec'"
695
+ );
696
+ }
697
+ if (!record.files || typeof record.files !== "object" || Array.isArray(record.files)) {
698
+ throw new Error("'recoverySchema.files' must be a path-to-digest object");
699
+ }
700
+ return record.files;
701
+ }
309
702
  var CONFIG_RELATIVE = "openspec/config.yaml";
310
703
  var META_RELATIVE = ".product/integrations/openspec.json";
311
704
  var CI_EXAMPLE_RELATIVE = ".product/integrations/openspec.ci.yml";
@@ -434,8 +827,8 @@ async function isOpenSpecIntegrationInstalled(root) {
434
827
  async function readOpenSpecConfig(root) {
435
828
  const path = configPath(root);
436
829
  if (!await pathExists(path)) return null;
437
- const text = await readFile2(path, "utf8");
438
- const parsed = parse(text);
830
+ const text = await readFile4(path, "utf8");
831
+ const parsed = parse2(text);
439
832
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
440
833
  return {};
441
834
  }
@@ -544,7 +937,7 @@ function serializeConfig(config) {
544
937
  }
545
938
  async function productshapeVersion() {
546
939
  const packageJson = JSON.parse(
547
- await readFile2(new URL("../package.json", import.meta.url), "utf8")
940
+ await readFile4(new URL("../package.json", import.meta.url), "utf8")
548
941
  );
549
942
  return packageJson.version;
550
943
  }
@@ -571,7 +964,7 @@ async function addOpenSpecIntegration(root, options = {}) {
571
964
  const { config, changes: configChanges } = mergeConfig(existing, previousMeta?.managed);
572
965
  const ciDesired = renderCiExample({ warningsAsErrors });
573
966
  const ciAbsolute = ciExamplePath(root);
574
- const ciExisting = await pathExists(ciAbsolute) ? await readFile2(ciAbsolute, "utf8") : null;
967
+ const ciExisting = await pathExists(ciAbsolute) ? await readFile4(ciAbsolute, "utf8") : null;
575
968
  const ciChanged = ciExisting !== ciDesired;
576
969
  const changes = [...configChanges];
577
970
  if (ciChanged) {
@@ -579,6 +972,118 @@ async function addOpenSpecIntegration(root, options = {}) {
579
972
  ciExisting === null ? `Installed CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.` : `Updated CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.`
580
973
  );
581
974
  }
975
+ const schemaAssets = await loadProductChangeSchemaAssets();
976
+ const recordedSchema = recordedProductSchemaFiles(previousMeta);
977
+ const currentSchemaPaths = new Set(
978
+ schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`)
979
+ );
980
+ const schemaFileDigests = {};
981
+ const schemaWrites = [];
982
+ const schemaDeletes = [];
983
+ const schemaCollisions = [];
984
+ const schemaPreserved = [];
985
+ let schemaInstalledAnything = false;
986
+ for (const asset of schemaAssets) {
987
+ const relative2 = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
988
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
989
+ const existing2 = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
990
+ const assetDigest = contentDigest(asset.content);
991
+ if (existing2 === null) {
992
+ schemaWrites.push({ relative: relative2, absolute, content: asset.content });
993
+ schemaFileDigests[relative2] = assetDigest;
994
+ schemaInstalledAnything = true;
995
+ continue;
996
+ }
997
+ const recordedDigest = recordedSchema?.[relative2];
998
+ if (recordedDigest === void 0) {
999
+ schemaCollisions.push(relative2);
1000
+ continue;
1001
+ }
1002
+ if (existing2 === asset.content) {
1003
+ schemaFileDigests[relative2] = assetDigest;
1004
+ continue;
1005
+ }
1006
+ if (recordedDigest !== "" && contentDigest(existing2) === recordedDigest) {
1007
+ schemaWrites.push({ relative: relative2, absolute, content: asset.content });
1008
+ schemaFileDigests[relative2] = assetDigest;
1009
+ continue;
1010
+ }
1011
+ schemaPreserved.push(relative2);
1012
+ if (recordedDigest !== "") schemaFileDigests[relative2] = recordedDigest;
1013
+ }
1014
+ for (const [relative2, recordedDigest] of Object.entries(recordedSchema ?? {})) {
1015
+ if (currentSchemaPaths.has(relative2)) continue;
1016
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1017
+ if (!await pathExists(absolute)) continue;
1018
+ const existing2 = await readFile4(absolute, "utf8");
1019
+ if (recordedDigest !== "" && contentDigest(existing2) === recordedDigest) {
1020
+ schemaDeletes.push({ relative: relative2, absolute });
1021
+ continue;
1022
+ }
1023
+ schemaPreserved.push(relative2);
1024
+ if (recordedDigest !== "") schemaFileDigests[relative2] = recordedDigest;
1025
+ }
1026
+ if (schemaCollisions.length > 0) {
1027
+ throw new Error(
1028
+ `Refusing to install the OpenSpec product schema: ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE} already contains files this integration does not manage (${schemaCollisions.join(", ")}). Move them aside or delete them, then run: prodshape integration add openspec. The integration never overwrites user-authored files.`
1029
+ );
1030
+ }
1031
+ if (schemaWrites.length > 0) {
1032
+ changes.push(
1033
+ recordedSchema !== void 0 || !schemaInstalledAnything ? `Updated the OpenSpec product schema at ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE} (${schemaWrites.length} file${schemaWrites.length === 1 ? "" : "s"}).` : `Installed the OpenSpec product schema at ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}.`
1034
+ );
1035
+ if (compareVersions(openspecVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
1036
+ changes.push(
1037
+ `Product workflow unavailable until OpenSpec >= ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} (detected ${openspecVersion}); the citation lane is unaffected.`
1038
+ );
1039
+ }
1040
+ }
1041
+ for (const { relative: relative2 } of schemaDeletes) {
1042
+ changes.push(`Removed obsolete managed file ${relative2}.`);
1043
+ }
1044
+ for (const relative2 of schemaPreserved) {
1045
+ changes.push(
1046
+ `Preserved hand-edited managed file ${relative2}; restore or delete it, then run: prodshape integration update.`
1047
+ );
1048
+ }
1049
+ const recoveryAssets = await loadProductRecoverySchemaAssets();
1050
+ const previousRecovery = recordedRecoverySchemaFiles(previousMeta);
1051
+ const recoveryDigests = {};
1052
+ const recoveryWrites = [];
1053
+ const recoveryCollisions = [];
1054
+ for (const asset of recoveryAssets) {
1055
+ const relative2 = `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`;
1056
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1057
+ const existing2 = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
1058
+ const digest = contentDigest(asset.content);
1059
+ const recorded = previousRecovery?.[relative2];
1060
+ if (existing2 === null) {
1061
+ recoveryWrites.push({ relative: relative2, absolute, content: asset.content });
1062
+ recoveryDigests[relative2] = digest;
1063
+ } else if (recorded === void 0) {
1064
+ recoveryCollisions.push(relative2);
1065
+ } else if (existing2 === asset.content) {
1066
+ recoveryDigests[relative2] = digest;
1067
+ } else if (recorded !== "" && contentDigest(existing2) === recorded) {
1068
+ recoveryWrites.push({ relative: relative2, absolute, content: asset.content });
1069
+ recoveryDigests[relative2] = digest;
1070
+ } else {
1071
+ recoveryDigests[relative2] = recorded;
1072
+ changes.push(
1073
+ `Preserved hand-edited managed file ${relative2}; restore or delete it, then run: prodshape integration update.`
1074
+ );
1075
+ }
1076
+ }
1077
+ if (recoveryCollisions.length > 0) {
1078
+ throw new Error(
1079
+ `Refusing to install the OpenSpec product-recovery schema: unrecorded files exist (${recoveryCollisions.join(", ")}). The integration never overwrites user-authored files.`
1080
+ );
1081
+ }
1082
+ if (recoveryWrites.length > 0) {
1083
+ changes.push(
1084
+ previousRecovery === void 0 ? `Installed the OpenSpec product-recovery schema at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}.` : `Updated the OpenSpec product-recovery schema at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE} (${recoveryWrites.length} files).`
1085
+ );
1086
+ }
582
1087
  const now = (/* @__PURE__ */ new Date()).toISOString();
583
1088
  const meta = {
584
1089
  provider: "openspec",
@@ -587,7 +1092,17 @@ async function addOpenSpecIntegration(root, options = {}) {
587
1092
  installedAt: previousMeta?.installedAt ?? now,
588
1093
  configPath: CONFIG_RELATIVE,
589
1094
  ciExamplePath: CI_EXAMPLE_RELATIVE,
590
- managed: currentManagedStrings()
1095
+ managed: currentManagedStrings(),
1096
+ productSchema: {
1097
+ name: OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
1098
+ requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
1099
+ files: schemaFileDigests
1100
+ },
1101
+ recoverySchema: {
1102
+ name: OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
1103
+ requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
1104
+ files: recoveryDigests
1105
+ }
591
1106
  };
592
1107
  const updatedAt = changes.length > 0 && previousMeta !== null ? now : previousMeta?.updatedAt ?? void 0;
593
1108
  if (updatedAt !== void 0) meta.updatedAt = updatedAt;
@@ -604,6 +1119,19 @@ async function addOpenSpecIntegration(root, options = {}) {
604
1119
  await writeFile(ciAbsolute, ciDesired, "utf8");
605
1120
  written.push(CI_EXAMPLE_RELATIVE);
606
1121
  }
1122
+ for (const deletion of schemaDeletes) {
1123
+ await rm(deletion.absolute, { force: true });
1124
+ }
1125
+ for (const write of schemaWrites) {
1126
+ await mkdir(dirname(write.absolute), { recursive: true });
1127
+ await writeFile(write.absolute, write.content, "utf8");
1128
+ written.push(write.relative);
1129
+ }
1130
+ for (const write of recoveryWrites) {
1131
+ await mkdir(dirname(write.absolute), { recursive: true });
1132
+ await writeFile(write.absolute, write.content, "utf8");
1133
+ written.push(write.relative);
1134
+ }
607
1135
  if (await writeMetaIfChanged(root, meta)) {
608
1136
  written.push(META_RELATIVE);
609
1137
  }
@@ -616,9 +1144,9 @@ async function writeMetaIfChanged(root, meta) {
616
1144
  `;
617
1145
  let existing;
618
1146
  try {
619
- existing = await readFile2(path, "utf8");
1147
+ existing = await readFile4(path, "utf8");
620
1148
  } catch (error) {
621
- if (!isNotFound(error)) throw error;
1149
+ if (!isNotFound2(error)) throw error;
622
1150
  }
623
1151
  if (existing === desired) return false;
624
1152
  await mkdir(dirname(path), { recursive: true });
@@ -639,9 +1167,9 @@ async function readMeta(root) {
639
1167
  const path = metaPath(root);
640
1168
  let content;
641
1169
  try {
642
- content = await readFile2(path, "utf8");
1170
+ content = await readFile4(path, "utf8");
643
1171
  } catch (error) {
644
- if (isNotFound(error)) return null;
1172
+ if (isNotFound2(error)) return null;
645
1173
  throw new OpenSpecMetaError(
646
1174
  `it exists but could not be read (${error instanceof Error ? error.message : String(error)})`,
647
1175
  { cause: error }
@@ -665,19 +1193,31 @@ async function readMeta(root) {
665
1193
  "it does not match the metadata schema ('provider' must be 'openspec' and 'installedAt' a string)"
666
1194
  );
667
1195
  }
668
- return record;
1196
+ const meta = record;
1197
+ try {
1198
+ recordedProductSchemaFiles(meta);
1199
+ } catch (error) {
1200
+ throw new OpenSpecMetaError(
1201
+ `it does not match the product schema ownership record (${error instanceof Error ? error.message : String(error)})`,
1202
+ { cause: error }
1203
+ );
1204
+ }
1205
+ return meta;
669
1206
  }
670
1207
  async function updateOpenSpecIntegration(root, options = {}) {
1208
+ const previousMeta = await readMeta(root);
671
1209
  const result = await addOpenSpecIntegration(root, {
672
1210
  force: true,
673
1211
  dryRun: options.dryRun,
674
- warningsAsErrors: options.warningsAsErrors
1212
+ warningsAsErrors: options.warningsAsErrors,
1213
+ ...previousMeta?.openspecVersion ? { cliVersion: previousMeta.openspecVersion } : {}
675
1214
  });
676
1215
  return { written: result.written, changes: result.changes };
677
1216
  }
678
1217
  async function checkOpenSpecIntegration(root) {
679
1218
  const checks = [];
680
1219
  const detected = await detectOpenSpecVersion(root);
1220
+ const detectedVersion = "error" in detected ? void 0 : detected.version;
681
1221
  if ("error" in detected) {
682
1222
  checks.push({ name: "openspec CLI", ok: false, detail: detected.error });
683
1223
  } else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
@@ -769,13 +1309,131 @@ async function checkOpenSpecIntegration(root) {
769
1309
  checks.push({ name: "rules", ok: true, detail: "All PDaC citation rules present." });
770
1310
  }
771
1311
  }
1312
+ const schemaAssets = await loadProductChangeSchemaAssets();
1313
+ if (meta === null || meta.productSchema === void 0) {
1314
+ checks.push({
1315
+ name: "product workflow",
1316
+ ok: false,
1317
+ detail: `OpenSpec product schema not installed (${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}). Run: prodshape integration update`
1318
+ });
1319
+ } else {
1320
+ const recordedSchema = recordedProductSchemaFiles(meta) ?? {};
1321
+ const currentSchemaPaths = new Set(
1322
+ schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`)
1323
+ );
1324
+ const missing = [];
1325
+ const unrecorded = [];
1326
+ const outdated = [];
1327
+ const diverged = [];
1328
+ const obsolete = Object.keys(recordedSchema).filter((relative2) => !currentSchemaPaths.has(relative2)).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1329
+ for (const asset of schemaAssets) {
1330
+ const relative2 = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
1331
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1332
+ const existing = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
1333
+ const recordedDigest = recordedSchema[relative2];
1334
+ if (recordedDigest === void 0) {
1335
+ unrecorded.push(relative2);
1336
+ } else if (existing === null) {
1337
+ missing.push(relative2);
1338
+ } else if (existing !== asset.content) {
1339
+ if (recordedDigest !== "" && contentDigest(existing) === recordedDigest) {
1340
+ outdated.push(relative2);
1341
+ } else {
1342
+ diverged.push(relative2);
1343
+ }
1344
+ }
1345
+ }
1346
+ if (missing.length > 0 || unrecorded.length > 0 || outdated.length > 0 || diverged.length > 0 || obsolete.length > 0) {
1347
+ const parts = [];
1348
+ if (missing.length > 0) {
1349
+ parts.push(`missing: ${missing.join(", ")} (run: prodshape integration update)`);
1350
+ }
1351
+ if (unrecorded.length > 0) {
1352
+ parts.push(
1353
+ `unrecorded: ${unrecorded.join(", ")} (move user files aside, then run: prodshape integration update)`
1354
+ );
1355
+ }
1356
+ if (outdated.length > 0) {
1357
+ parts.push(
1358
+ `outdated managed content: ${outdated.join(", ")} (run: prodshape integration update)`
1359
+ );
1360
+ }
1361
+ if (diverged.length > 0) {
1362
+ parts.push(
1363
+ `hand-edited and preserved: ${diverged.join(", ")} (restore or delete them, then run: prodshape integration update)`
1364
+ );
1365
+ }
1366
+ if (obsolete.length > 0) {
1367
+ parts.push(
1368
+ `obsolete managed content: ${obsolete.join(", ")} (run: prodshape integration update)`
1369
+ );
1370
+ }
1371
+ checks.push({
1372
+ name: "product workflow",
1373
+ ok: false,
1374
+ detail: `Product schema files need attention. ${parts.join(" ")}`
1375
+ });
1376
+ } else if (detectedVersion === void 0) {
1377
+ checks.push({
1378
+ name: "product workflow",
1379
+ ok: true,
1380
+ detail: "Product workflow UNAVAILABLE: the OpenSpec CLI was not detected. The schema is installed and intact; the openspec CLI check carries the remedy."
1381
+ });
1382
+ } else if (compareVersions(detectedVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
1383
+ checks.push({
1384
+ name: "product workflow",
1385
+ ok: true,
1386
+ detail: `Product workflow UNAVAILABLE: requires OpenSpec >= ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC}, detected ${detectedVersion}. The citation lane is unaffected; upgrade OpenSpec to use the product schema.`
1387
+ });
1388
+ } else {
1389
+ checks.push({
1390
+ name: "product workflow",
1391
+ ok: true,
1392
+ detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} floor.`
1393
+ });
1394
+ }
1395
+ }
1396
+ const recoveryAssets = await loadProductRecoverySchemaAssets();
1397
+ const recoveryRecord = recordedRecoverySchemaFiles(meta);
1398
+ if (recoveryRecord === void 0) {
1399
+ checks.push({
1400
+ name: "product recovery workflow",
1401
+ // Existing installations predate #229. Keep the citation and existing product-change
1402
+ // lanes healthy while advertising the opt-in migration; a fresh add/update records the
1403
+ // recovery schema and is checked strictly below.
1404
+ ok: true,
1405
+ detail: `Product recovery workflow UNAVAILABLE: schema not installed (${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}). Run: prodshape integration update to enable it.`
1406
+ });
1407
+ } else {
1408
+ const defects = [];
1409
+ for (const asset of recoveryAssets) {
1410
+ const relative2 = `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`;
1411
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1412
+ const expected = recoveryRecord[relative2];
1413
+ if (expected === void 0) defects.push(`unrecorded: ${relative2}`);
1414
+ else if (!await pathExists(absolute)) defects.push(`missing: ${relative2}`);
1415
+ else {
1416
+ const actual = await readFile4(absolute, "utf8");
1417
+ if (actual !== asset.content && contentDigest(actual) !== expected) {
1418
+ defects.push(`hand-edited and preserved: ${relative2}`);
1419
+ } else if (actual !== asset.content) defects.push(`outdated managed content: ${relative2}`);
1420
+ }
1421
+ }
1422
+ checks.push({
1423
+ name: "product recovery workflow",
1424
+ ok: defects.length === 0,
1425
+ detail: defects.length === 0 ? `Product recovery workflow available: schema installed at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}.` : `Product-recovery schema files need attention. ${defects.join(" ")}`
1426
+ });
1427
+ }
772
1428
  const ok = checks.every((c) => c.ok);
773
1429
  return { ok, checks };
774
1430
  }
775
1431
  async function removeOpenSpecIntegration(root, options = {}) {
776
1432
  const { dryRun = false } = options;
777
1433
  const removed = [];
778
- const recorded = (await readMeta(root))?.managed;
1434
+ const preserved = [];
1435
+ const previousMeta = await readMeta(root);
1436
+ const recorded = previousMeta?.managed;
779
1437
  const current = currentManagedStrings();
780
1438
  const managedRules = {};
781
1439
  for (const key of /* @__PURE__ */ new Set([
@@ -876,39 +1534,140 @@ async function removeOpenSpecIntegration(root, options = {}) {
876
1534
  }
877
1535
  removed.push(CI_EXAMPLE_RELATIVE);
878
1536
  }
1537
+ const recordedSchema = recordedProductSchemaFiles(previousMeta);
1538
+ if (recordedSchema !== void 0) {
1539
+ const assetContentByRelative = new Map(
1540
+ (await loadProductChangeSchemaAssets()).map((asset) => [
1541
+ `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`,
1542
+ asset.content
1543
+ ])
1544
+ );
1545
+ const schemaFileSet = Object.keys(recordedSchema).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1546
+ const pruneCandidates = /* @__PURE__ */ new Set();
1547
+ for (const relative2 of schemaFileSet) {
1548
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1549
+ if (!await pathExists(absolute)) continue;
1550
+ const existing = await readFile4(absolute, "utf8");
1551
+ const recordedDigest = recordedSchema[relative2];
1552
+ const proven = existing === assetContentByRelative.get(relative2) || recordedDigest !== void 0 && recordedDigest !== "" && contentDigest(existing) === recordedDigest;
1553
+ if (!proven) {
1554
+ preserved.push(relative2);
1555
+ continue;
1556
+ }
1557
+ if (!dryRun) {
1558
+ await rm(absolute, { force: true });
1559
+ let parent = dirname(relative2);
1560
+ while (parent.startsWith("openspec/schemas") && parent !== "openspec/schemas" && parent !== "openspec") {
1561
+ pruneCandidates.add(parent);
1562
+ parent = parent.split("/").slice(0, -1).join("/");
1563
+ }
1564
+ pruneCandidates.add("openspec/schemas");
1565
+ }
1566
+ removed.push(relative2);
1567
+ }
1568
+ for (const relative2 of [...assetContentByRelative.keys()].sort(
1569
+ (a, b) => a < b ? -1 : a > b ? 1 : 0
1570
+ )) {
1571
+ if (recordedSchema[relative2] !== void 0) continue;
1572
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1573
+ if (await pathExists(absolute)) preserved.push(relative2);
1574
+ }
1575
+ if (!dryRun) {
1576
+ const ordered = [...pruneCandidates].sort((a, b) => b.length - a.length);
1577
+ for (const relative2 of ordered) {
1578
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1579
+ try {
1580
+ const entries = await readdir4(absolute);
1581
+ if (entries.length === 0) await rmdir(absolute);
1582
+ } catch (error) {
1583
+ if (!isNotFound2(error)) throw error;
1584
+ }
1585
+ }
1586
+ }
1587
+ }
1588
+ const recordedRecovery = recordedRecoverySchemaFiles(previousMeta);
1589
+ if (recordedRecovery !== void 0) {
1590
+ const recoveryAssets = new Map(
1591
+ (await loadProductRecoverySchemaAssets()).map((asset) => [
1592
+ `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`,
1593
+ asset.content
1594
+ ])
1595
+ );
1596
+ for (const relative2 of Object.keys(recordedRecovery).sort()) {
1597
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1598
+ if (!await pathExists(absolute)) continue;
1599
+ const existing = await readFile4(absolute, "utf8");
1600
+ const recordedDigest = recordedRecovery[relative2];
1601
+ const proven = existing === recoveryAssets.get(relative2) || recordedDigest !== "" && contentDigest(existing) === recordedDigest;
1602
+ if (!proven) {
1603
+ preserved.push(relative2);
1604
+ } else {
1605
+ if (!dryRun) await rm(absolute, { force: true });
1606
+ removed.push(relative2);
1607
+ }
1608
+ }
1609
+ if (!dryRun) {
1610
+ for (const relative2 of [
1611
+ `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/templates`,
1612
+ OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
1613
+ "openspec/schemas"
1614
+ ]) {
1615
+ const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1616
+ try {
1617
+ if ((await readdir4(absolute)).length === 0) await rmdir(absolute);
1618
+ } catch (error) {
1619
+ if (!isNotFound2(error)) throw error;
1620
+ }
1621
+ }
1622
+ }
1623
+ }
879
1624
  if (await pathExists(metaPath(root))) {
880
1625
  if (!dryRun) {
881
1626
  await rm(metaPath(root), { force: true });
882
1627
  }
883
1628
  removed.push(META_RELATIVE);
884
1629
  }
885
- return { removed };
1630
+ return { removed, preserved };
886
1631
  }
887
1632
  export {
888
1633
  MIN_SUPPORTED_OPENSPEC,
889
1634
  OPENSPEC_CI_EXAMPLE_RELATIVE,
890
1635
  OPENSPEC_META_RELATIVE,
891
1636
  OPENSPEC_NPX_SPEC,
1637
+ OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
1638
+ OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE,
1639
+ OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
1640
+ OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
1641
+ OPENSPEC_PRODUCT_SUBDIR,
892
1642
  OPENSPEC_VERIFY_COMMAND,
893
1643
  OpenSpecMetaError,
894
1644
  PDAC_CONTEXT_BLOCK,
895
1645
  PDAC_OPERATIONS,
896
1646
  PDAC_RULES,
1647
+ PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
897
1648
  addOpenSpecIntegration,
1649
+ applyOpenSpecProductChange,
898
1650
  bootstrapOpenSpecWorkspace,
899
1651
  checkOpenSpecIntegration,
900
1652
  currentManagedStrings,
1653
+ deriveDeliveryContext,
901
1654
  detectOpenSpecVersion,
902
1655
  enumerateOpenSpecDocuments,
1656
+ inspectProductModel,
903
1657
  isOpenSpecCliAvailable,
904
1658
  isOpenSpecIntegrationInstalled,
905
1659
  isOpenSpecWorkspace,
1660
+ listOpenSpecProductChanges,
1661
+ loadOpenSpecProductChange,
1662
+ loadProductChangeSchemaAssets,
1663
+ loadProductRecoverySchemaAssets,
906
1664
  mergeConfig,
907
1665
  openSpecProvider,
908
1666
  readOpenSpecConfig,
909
1667
  removeOpenSpecIntegration,
910
1668
  renderCiExample,
911
1669
  serializeConfig,
912
- updateOpenSpecIntegration
1670
+ updateOpenSpecIntegration,
1671
+ validateOpenSpecProductChange
913
1672
  };
914
1673
  //# sourceMappingURL=index.js.map