@prodshape/integration-openspec 0.6.0-alpha.0 → 0.6.0-alpha.4

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
@@ -9,15 +9,17 @@ import {
9
9
  } from "@prodshape/core";
10
10
  import { parse as parse2, stringify } from "yaml";
11
11
 
12
- // src/product-schema.ts
12
+ // src/product-change-schema.ts
13
13
  import { readdir, readFile } from "fs/promises";
14
14
  import { fileURLToPath } from "url";
15
15
  import { join } from "path";
16
- var OPENSPEC_PRODUCT_SCHEMA_NAME = "product";
17
- var OPENSPEC_PRODUCT_SCHEMA_RELATIVE = "openspec/schemas/product";
18
- var PRODUCT_SCHEMA_MIN_OPENSPEC = "1.7.0";
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";
19
21
  function assetsDir() {
20
- return fileURLToPath(new URL("../assets/openspec-product-schema/", import.meta.url));
22
+ return fileURLToPath(new URL("../assets/openspec-product-change-schema/", import.meta.url));
21
23
  }
22
24
  async function collectFiles(dir, prefix) {
23
25
  const entries = await readdir(dir, { withFileTypes: true });
@@ -32,7 +34,7 @@ async function collectFiles(dir, prefix) {
32
34
  }
33
35
  return files;
34
36
  }
35
- async function loadProductSchemaAssets() {
37
+ async function loadProductChangeSchemaAssets() {
36
38
  const dir = assetsDir();
37
39
  const relatives = await collectFiles(dir, "");
38
40
  const assets = [];
@@ -44,6 +46,18 @@ async function loadProductSchemaAssets() {
44
46
  }
45
47
  return assets;
46
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
+ }
47
61
 
48
62
  // src/process.ts
49
63
  import crossSpawn from "cross-spawn";
@@ -298,21 +312,20 @@ import { join as join4 } from "path";
298
312
  import { parse } from "yaml";
299
313
  import {
300
314
  analyzeImpact,
301
- blockingDiagnostics,
302
315
  compareCodePoints,
303
- dedupeDiagnostics,
304
316
  discoverChanges,
305
317
  executeApply,
306
318
  isNotFound,
307
319
  loadChange,
308
320
  openRepository,
309
- planApply,
321
+ planHostedProductChange,
310
322
  preflightApply,
311
323
  sortDiagnostics,
312
324
  validateBaseline,
325
+ validateHostedProductChange,
313
326
  validateChange
314
327
  } from "@prodshape/core";
315
- var OPENSPEC_PRODUCT_SUBDIR = "product";
328
+ var OPENSPEC_PRODUCT_SUBDIR = "product-change";
316
329
  var OPENSPEC_CHANGE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
317
330
  function assertOpenSpecChangeName(changeName) {
318
331
  if (changeName.length > 200 || !OPENSPEC_CHANGE_NAME.test(changeName)) {
@@ -389,7 +402,7 @@ async function readHostedSchemaPin(changeDir) {
389
402
  function hostedSchemaPinError(changeName, pin) {
390
403
  if (pin.problem === "missing") {
391
404
  return new Error(
392
- `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_SCHEMA_NAME}.`
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}.`
393
406
  );
394
407
  }
395
408
  return new Error(
@@ -414,7 +427,7 @@ async function listOpenSpecProductChanges(root) {
414
427
  assertOpenSpecChangeName(name);
415
428
  const pin = await readHostedSchemaPin(changeDir);
416
429
  if ("problem" in pin) throw hostedSchemaPinError(name, pin);
417
- if (pin.schema !== OPENSPEC_PRODUCT_SCHEMA_NAME) continue;
430
+ if (pin.schema !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME) continue;
418
431
  refs.push({
419
432
  name,
420
433
  dir,
@@ -437,16 +450,16 @@ async function loadHostedChange(root, repo, changeName) {
437
450
  const refs = await listOpenSpecProductChanges(root);
438
451
  const known = refs.map((candidate) => candidate.name).join(", ") || "none";
439
452
  throw new Error(
440
- `No OpenSpec product change named '${changeName}' under openspec/changes/ (a product change hosts product/change.md; found: ${known}).`
453
+ `No OpenSpec product change named '${changeName}' under openspec/changes/ (a product change hosts product-change/change.md; found: ${known}).`
441
454
  );
442
455
  }
443
456
  const pin = await readHostedSchemaPin(changeDir);
444
457
  if ("problem" in pin) {
445
458
  throw hostedSchemaPinError(changeName, pin);
446
459
  }
447
- if (pin.schema !== OPENSPEC_PRODUCT_SCHEMA_NAME) {
460
+ if (pin.schema !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME) {
448
461
  throw new Error(
449
- `OpenSpec change '${changeName}' is pinned to schema '${pin.schema}', not '${OPENSPEC_PRODUCT_SCHEMA_NAME}'; the hosted product rail refuses it. Product intent travels through a change created with: openspec new change <name> --schema ${OPENSPEC_PRODUCT_SCHEMA_NAME}.`
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}.`
450
463
  );
451
464
  }
452
465
  return loadChange(deltaDir, repo.root, repo.registry);
@@ -470,27 +483,28 @@ async function validateOpenSpecProductChange(root, changeName) {
470
483
  const all = await loadAllLiveChanges(root, repo);
471
484
  const change = all.find((candidate) => hostedName(root, candidate) === changeName) ?? await loadHostedChange(root, repo, changeName);
472
485
  const validation = validateChange(change, baseline.artifacts, all);
473
- const diagnostics = sortDiagnostics(
474
- dedupeDiagnostics([
475
- ...repo.configDiagnostics,
476
- ...baseline.diagnostics,
477
- ...validation.diagnostics
478
- ])
486
+ const hosted = validateHostedProductChange(
487
+ repo,
488
+ baseline.artifacts,
489
+ change,
490
+ all,
491
+ baseline.diagnostics
479
492
  );
493
+ const diagnostics = hosted.diagnostics;
480
494
  return {
481
495
  change,
482
496
  baseline,
483
497
  overlayArtifacts: validation.overlayArtifacts,
484
498
  overlayGraph: validation.overlayGraph,
485
499
  diagnostics,
486
- blocking: blockingDiagnostics(diagnostics, repo.config.validation["warnings-as-errors"])
500
+ blocking: hosted.blocking
487
501
  };
488
502
  }
489
503
  function hostedName(root, change) {
490
504
  const normalizedRoot = root.replaceAll("\\", "/");
491
505
  const normalizedDir = change.dir.replaceAll("\\", "/");
492
506
  const relative2 = normalizedDir.startsWith(normalizedRoot) ? normalizedDir.slice(normalizedRoot.length).replace(/^\//, "") : normalizedDir;
493
- const match = relative2.match(/^openspec\/changes\/([^/]+)\/product$/);
507
+ const match = relative2.match(/^openspec\/changes\/([^/]+)\/product-change$/);
494
508
  return match?.[1];
495
509
  }
496
510
  async function applyOpenSpecProductChange(root, changeName, options = {}) {
@@ -499,24 +513,20 @@ async function applyOpenSpecProductChange(root, changeName, options = {}) {
499
513
  const baseline = await validateBaseline(repo);
500
514
  const all = await loadAllLiveChanges(root, repo);
501
515
  const change = all.find((candidate) => hostedName(root, candidate) === changeName) ?? await loadHostedChange(root, repo, changeName);
502
- const validation = validateChange(change, baseline.artifacts, all);
503
- const overlayErrors = blockingDiagnostics(
504
- sortDiagnostics(
505
- dedupeDiagnostics([
506
- ...repo.configDiagnostics,
507
- ...baseline.diagnostics,
508
- ...validation.diagnostics
509
- ])
510
- ),
511
- repo.config.validation["warnings-as-errors"]
516
+ const hosted = validateHostedProductChange(
517
+ repo,
518
+ baseline.artifacts,
519
+ change,
520
+ all,
521
+ baseline.diagnostics
512
522
  );
513
- const rawPlan = await planApply({
523
+ const rawPlan = await planHostedProductChange({
514
524
  repoRoot: repo.root,
515
525
  modelRelative: repo.config.product.model,
516
526
  changesRelative: repo.config.product.changes,
517
527
  change,
518
528
  baseline: baseline.artifacts,
519
- overlayErrors
529
+ overlayErrors: hosted.blocking
520
530
  });
521
531
  const plan = {
522
532
  ...rawPlan,
@@ -596,14 +606,14 @@ A citation binds consumer text to a canonical artifact: it records the artifact
596
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.
597
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.
598
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.
599
- 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.md plus product/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.
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.
600
610
  ${PDAC_CONTEXT_END}`;
601
611
  var PDAC_RULES = {
602
612
  proposal: [
603
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.",
604
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.",
605
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.',
606
- "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-schema OpenSpec 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.",
607
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.'
608
618
  ],
609
619
  specs: [
@@ -640,20 +650,22 @@ function currentManagedStrings() {
640
650
  )
641
651
  };
642
652
  }
653
+ var LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME = "product";
654
+ var LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE = "openspec/schemas/product";
643
655
  function recordedProductSchemaFiles(meta) {
644
656
  const record = meta?.productSchema;
645
657
  if (!record) return void 0;
646
- if (typeof record !== "object" || record === null || record.name !== OPENSPEC_PRODUCT_SCHEMA_NAME || typeof record.requiresOpenspec !== "string") {
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") {
647
659
  throw new Error(
648
660
  "'productSchema' must name the product schema and carry a string 'requiresOpenspec'"
649
661
  );
650
662
  }
651
663
  const files = record.files;
652
- const validatePath = (file) => typeof file === "string" && file.startsWith(`${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/`) && isRepositoryRelativePath(file);
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);
653
665
  if (Array.isArray(files)) {
654
666
  if (!files.every(validatePath)) {
655
667
  throw new Error(
656
- `'productSchema.files' contains a path outside ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}`
668
+ `'productSchema.files' contains a path outside the managed OpenSpec schema locations`
657
669
  );
658
670
  }
659
671
  return Object.fromEntries(files.map((file) => [file, ""]));
@@ -662,7 +674,7 @@ function recordedProductSchemaFiles(meta) {
662
674
  const entries = Object.entries(files);
663
675
  if (!entries.every(([file]) => validatePath(file))) {
664
676
  throw new Error(
665
- `'productSchema.files' contains a path outside ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}`
677
+ `'productSchema.files' contains a path outside the managed OpenSpec schema locations`
666
678
  );
667
679
  }
668
680
  if (!entries.every(
@@ -674,6 +686,19 @@ function recordedProductSchemaFiles(meta) {
674
686
  }
675
687
  throw new Error("'productSchema.files' must be a path-to-digest object or legacy path array");
676
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
+ }
677
702
  var CONFIG_RELATIVE = "openspec/config.yaml";
678
703
  var META_RELATIVE = ".product/integrations/openspec.json";
679
704
  var CI_EXAMPLE_RELATIVE = ".product/integrations/openspec.ci.yml";
@@ -947,10 +972,10 @@ async function addOpenSpecIntegration(root, options = {}) {
947
972
  ciExisting === null ? `Installed CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.` : `Updated CI-ready verification example at ${CI_EXAMPLE_RELATIVE}.`
948
973
  );
949
974
  }
950
- const schemaAssets = await loadProductSchemaAssets();
975
+ const schemaAssets = await loadProductChangeSchemaAssets();
951
976
  const recordedSchema = recordedProductSchemaFiles(previousMeta);
952
977
  const currentSchemaPaths = new Set(
953
- schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`)
978
+ schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`)
954
979
  );
955
980
  const schemaFileDigests = {};
956
981
  const schemaWrites = [];
@@ -959,7 +984,7 @@ async function addOpenSpecIntegration(root, options = {}) {
959
984
  const schemaPreserved = [];
960
985
  let schemaInstalledAnything = false;
961
986
  for (const asset of schemaAssets) {
962
- const relative2 = `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`;
987
+ const relative2 = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
963
988
  const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
964
989
  const existing2 = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
965
990
  const assetDigest = contentDigest(asset.content);
@@ -1000,16 +1025,16 @@ async function addOpenSpecIntegration(root, options = {}) {
1000
1025
  }
1001
1026
  if (schemaCollisions.length > 0) {
1002
1027
  throw new Error(
1003
- `Refusing to install the OpenSpec product schema: ${OPENSPEC_PRODUCT_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.`
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.`
1004
1029
  );
1005
1030
  }
1006
1031
  if (schemaWrites.length > 0) {
1007
1032
  changes.push(
1008
- recordedSchema !== void 0 || !schemaInstalledAnything ? `Updated the OpenSpec product schema at ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE} (${schemaWrites.length} file${schemaWrites.length === 1 ? "" : "s"}).` : `Installed the OpenSpec product schema at ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}.`
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}.`
1009
1034
  );
1010
- if (compareVersions(openspecVersion, PRODUCT_SCHEMA_MIN_OPENSPEC) < 0) {
1035
+ if (compareVersions(openspecVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
1011
1036
  changes.push(
1012
- `Product workflow unavailable until OpenSpec >= ${PRODUCT_SCHEMA_MIN_OPENSPEC} (detected ${openspecVersion}); the citation lane is unaffected.`
1037
+ `Product workflow unavailable until OpenSpec >= ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} (detected ${openspecVersion}); the citation lane is unaffected.`
1013
1038
  );
1014
1039
  }
1015
1040
  }
@@ -1021,6 +1046,44 @@ async function addOpenSpecIntegration(root, options = {}) {
1021
1046
  `Preserved hand-edited managed file ${relative2}; restore or delete it, then run: prodshape integration update.`
1022
1047
  );
1023
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
+ }
1024
1087
  const now = (/* @__PURE__ */ new Date()).toISOString();
1025
1088
  const meta = {
1026
1089
  provider: "openspec",
@@ -1031,9 +1094,14 @@ async function addOpenSpecIntegration(root, options = {}) {
1031
1094
  ciExamplePath: CI_EXAMPLE_RELATIVE,
1032
1095
  managed: currentManagedStrings(),
1033
1096
  productSchema: {
1034
- name: "product",
1035
- requiresOpenspec: PRODUCT_SCHEMA_MIN_OPENSPEC,
1097
+ name: OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
1098
+ requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
1036
1099
  files: schemaFileDigests
1100
+ },
1101
+ recoverySchema: {
1102
+ name: OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
1103
+ requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
1104
+ files: recoveryDigests
1037
1105
  }
1038
1106
  };
1039
1107
  const updatedAt = changes.length > 0 && previousMeta !== null ? now : previousMeta?.updatedAt ?? void 0;
@@ -1059,6 +1127,11 @@ async function addOpenSpecIntegration(root, options = {}) {
1059
1127
  await writeFile(write.absolute, write.content, "utf8");
1060
1128
  written.push(write.relative);
1061
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
+ }
1062
1135
  if (await writeMetaIfChanged(root, meta)) {
1063
1136
  written.push(META_RELATIVE);
1064
1137
  }
@@ -1132,10 +1205,12 @@ async function readMeta(root) {
1132
1205
  return meta;
1133
1206
  }
1134
1207
  async function updateOpenSpecIntegration(root, options = {}) {
1208
+ const previousMeta = await readMeta(root);
1135
1209
  const result = await addOpenSpecIntegration(root, {
1136
1210
  force: true,
1137
1211
  dryRun: options.dryRun,
1138
- warningsAsErrors: options.warningsAsErrors
1212
+ warningsAsErrors: options.warningsAsErrors,
1213
+ ...previousMeta?.openspecVersion ? { cliVersion: previousMeta.openspecVersion } : {}
1139
1214
  });
1140
1215
  return { written: result.written, changes: result.changes };
1141
1216
  }
@@ -1234,17 +1309,17 @@ async function checkOpenSpecIntegration(root) {
1234
1309
  checks.push({ name: "rules", ok: true, detail: "All PDaC citation rules present." });
1235
1310
  }
1236
1311
  }
1237
- const schemaAssets = await loadProductSchemaAssets();
1312
+ const schemaAssets = await loadProductChangeSchemaAssets();
1238
1313
  if (meta === null || meta.productSchema === void 0) {
1239
1314
  checks.push({
1240
1315
  name: "product workflow",
1241
1316
  ok: false,
1242
- detail: `OpenSpec product schema not installed (${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}). Run: prodshape integration update`
1317
+ detail: `OpenSpec product schema not installed (${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}). Run: prodshape integration update`
1243
1318
  });
1244
1319
  } else {
1245
1320
  const recordedSchema = recordedProductSchemaFiles(meta) ?? {};
1246
1321
  const currentSchemaPaths = new Set(
1247
- schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`)
1322
+ schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`)
1248
1323
  );
1249
1324
  const missing = [];
1250
1325
  const unrecorded = [];
@@ -1252,7 +1327,7 @@ async function checkOpenSpecIntegration(root) {
1252
1327
  const diverged = [];
1253
1328
  const obsolete = Object.keys(recordedSchema).filter((relative2) => !currentSchemaPaths.has(relative2)).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
1254
1329
  for (const asset of schemaAssets) {
1255
- const relative2 = `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`;
1330
+ const relative2 = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
1256
1331
  const absolute = resolveInRepository(root, relative2, "the OpenSpec integration");
1257
1332
  const existing = await pathExists(absolute) ? await readFile4(absolute, "utf8") : null;
1258
1333
  const recordedDigest = recordedSchema[relative2];
@@ -1304,20 +1379,52 @@ async function checkOpenSpecIntegration(root) {
1304
1379
  ok: true,
1305
1380
  detail: "Product workflow UNAVAILABLE: the OpenSpec CLI was not detected. The schema is installed and intact; the openspec CLI check carries the remedy."
1306
1381
  });
1307
- } else if (compareVersions(detectedVersion, PRODUCT_SCHEMA_MIN_OPENSPEC) < 0) {
1382
+ } else if (compareVersions(detectedVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
1308
1383
  checks.push({
1309
1384
  name: "product workflow",
1310
1385
  ok: true,
1311
- detail: `Product workflow UNAVAILABLE: requires OpenSpec >= ${PRODUCT_SCHEMA_MIN_OPENSPEC}, detected ${detectedVersion}. The citation lane is unaffected; upgrade OpenSpec to use the product schema.`
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.`
1312
1387
  });
1313
1388
  } else {
1314
1389
  checks.push({
1315
1390
  name: "product workflow",
1316
1391
  ok: true,
1317
- detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${PRODUCT_SCHEMA_MIN_OPENSPEC} floor.`
1392
+ detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} floor.`
1318
1393
  });
1319
1394
  }
1320
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
+ }
1321
1428
  const ok = checks.every((c) => c.ok);
1322
1429
  return { ok, checks };
1323
1430
  }
@@ -1430,8 +1537,8 @@ async function removeOpenSpecIntegration(root, options = {}) {
1430
1537
  const recordedSchema = recordedProductSchemaFiles(previousMeta);
1431
1538
  if (recordedSchema !== void 0) {
1432
1539
  const assetContentByRelative = new Map(
1433
- (await loadProductSchemaAssets()).map((asset) => [
1434
- `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`,
1540
+ (await loadProductChangeSchemaAssets()).map((asset) => [
1541
+ `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`,
1435
1542
  asset.content
1436
1543
  ])
1437
1544
  );
@@ -1478,6 +1585,42 @@ async function removeOpenSpecIntegration(root, options = {}) {
1478
1585
  }
1479
1586
  }
1480
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
+ }
1481
1624
  if (await pathExists(metaPath(root))) {
1482
1625
  if (!dryRun) {
1483
1626
  await rm(metaPath(root), { force: true });
@@ -1491,15 +1634,17 @@ export {
1491
1634
  OPENSPEC_CI_EXAMPLE_RELATIVE,
1492
1635
  OPENSPEC_META_RELATIVE,
1493
1636
  OPENSPEC_NPX_SPEC,
1494
- OPENSPEC_PRODUCT_SCHEMA_NAME,
1495
- OPENSPEC_PRODUCT_SCHEMA_RELATIVE,
1637
+ OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
1638
+ OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE,
1639
+ OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
1640
+ OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
1496
1641
  OPENSPEC_PRODUCT_SUBDIR,
1497
1642
  OPENSPEC_VERIFY_COMMAND,
1498
1643
  OpenSpecMetaError,
1499
1644
  PDAC_CONTEXT_BLOCK,
1500
1645
  PDAC_OPERATIONS,
1501
1646
  PDAC_RULES,
1502
- PRODUCT_SCHEMA_MIN_OPENSPEC,
1647
+ PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
1503
1648
  addOpenSpecIntegration,
1504
1649
  applyOpenSpecProductChange,
1505
1650
  bootstrapOpenSpecWorkspace,
@@ -1514,7 +1659,8 @@ export {
1514
1659
  isOpenSpecWorkspace,
1515
1660
  listOpenSpecProductChanges,
1516
1661
  loadOpenSpecProductChange,
1517
- loadProductSchemaAssets,
1662
+ loadProductChangeSchemaAssets,
1663
+ loadProductRecoverySchemaAssets,
1518
1664
  mergeConfig,
1519
1665
  openSpecProvider,
1520
1666
  readOpenSpecConfig,