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

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/src/index.ts CHANGED
@@ -1,20 +1,39 @@
1
1
  /**
2
- * OpenSpec integration for Product Definition as Code.
2
+ * OpenSpec integration for Product Definition as Code, in two lanes.
3
3
  *
4
- * This module configures an existing OpenSpec workspace with PDaC citation rules by merging
5
- * guidance into `openspec/config.yaml`, and records metadata under `.product/integrations/`.
6
- * It never creates an OpenSpec plugin, never patches OpenSpec-generated commands or skills, and
7
- * never forks OpenSpec's default schema. The integration is configuration + deterministic
8
- * verification: consumer documents cite canonical product artifacts by id + digest, and
9
- * `prodshape citations verify` checks them.
4
+ * The citation lane configures OpenSpec's spec-driven delivery workflow: PDaC citation rules are
5
+ * merged into `openspec/config.yaml`, metadata is recorded under `.product/integrations/`, and
6
+ * consumer documents cite canonical product artifacts by id + digest for
7
+ * `prodshape citations verify` to check.
10
8
  *
11
- * The merge logic is the heart of this module: it preserves every field the user authored
9
+ * The product lane hosts the PDaC product workflow inside OpenSpec: the integration installs a
10
+ * project-local `product` schema at `openspec/schemas/product/`, OpenSpec's official project
11
+ * extension surface, whose changes host a Product Change delta that `product-workflow.ts`
12
+ * validates as an overlay and applies deterministically.
13
+ *
14
+ * Both lanes write only official OpenSpec surfaces plus ProductShape's own integration metadata.
15
+ * The integration never patches OpenSpec-generated commands or skills, never modifies OpenSpec's
16
+ * built-in schemas, and never writes into a native spec-driven change's documents.
17
+ *
18
+ * The merge logic is the heart of the citation lane: it preserves every field the user authored
12
19
  * (schema, context, rules, operations, githubCopilot, and anything else) and only appends PDaC
13
20
  * guidance, deduplicating identical entries so the operation is idempotent.
14
21
  */
15
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
16
- import { dirname, join } from 'node:path';
22
+ import { mkdir, readdir, readFile, rm, rmdir, writeFile } from 'node:fs/promises';
23
+ import { dirname } from 'node:path';
24
+ import {
25
+ contentDigest,
26
+ isNotFound,
27
+ isRepositoryRelativePath,
28
+ resolveInRepository,
29
+ } from '@prodshape/core';
17
30
  import { parse, stringify } from 'yaml';
31
+ import {
32
+ loadProductSchemaAssets,
33
+ OPENSPEC_PRODUCT_SCHEMA_NAME,
34
+ OPENSPEC_PRODUCT_SCHEMA_RELATIVE,
35
+ PRODUCT_SCHEMA_MIN_OPENSPEC,
36
+ } from './product-schema.js';
18
37
  import { runCommand } from './process.js';
19
38
  import { envWithLocalBin, isOpenSpecWorkspace, pathExists } from './workspace.js';
20
39
 
@@ -24,6 +43,31 @@ export {
24
43
  isOpenSpecCliAvailable,
25
44
  openSpecProvider,
26
45
  } from './population.js';
46
+ export {
47
+ loadProductSchemaAssets,
48
+ OPENSPEC_PRODUCT_SCHEMA_NAME,
49
+ OPENSPEC_PRODUCT_SCHEMA_RELATIVE,
50
+ PRODUCT_SCHEMA_MIN_OPENSPEC,
51
+ } from './product-schema.js';
52
+ export type { ProductSchemaAsset } from './product-schema.js';
53
+ export {
54
+ applyOpenSpecProductChange,
55
+ deriveDeliveryContext,
56
+ inspectProductModel,
57
+ listOpenSpecProductChanges,
58
+ loadOpenSpecProductChange,
59
+ OPENSPEC_PRODUCT_SUBDIR,
60
+ validateOpenSpecProductChange,
61
+ } from './product-workflow.js';
62
+ export type {
63
+ DeliveryContextBundle,
64
+ DeliveryContextEntry,
65
+ OpenSpecProductApplyOutcome,
66
+ OpenSpecProductApplyResult,
67
+ OpenSpecProductChangeRef,
68
+ OpenSpecProductChangeValidation,
69
+ ProductModelInspection,
70
+ } from './product-workflow.js';
27
71
 
28
72
  /** The minimum supported OpenSpec CLI version. */
29
73
  export const MIN_SUPPORTED_OPENSPEC = '1.0.0';
@@ -49,7 +93,7 @@ A citation binds consumer text to a canonical artifact: it records the artifact
49
93
  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.
50
94
  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 — never fix it quietly.
51
95
  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.
52
- 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.
96
+ 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.
53
97
  ${PDAC_CONTEXT_END}`;
54
98
 
55
99
  /** The PDaC citation rules injected per artifact into openspec/config.yaml `rules:`. */
@@ -58,7 +102,7 @@ export const PDAC_RULES: Record<string, string[]> = {
58
102
  '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.',
59
103
  '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 — the backlog item behind it, if there is one — 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.',
60
104
  '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.',
61
- '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.',
105
+ '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.',
62
106
  '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.',
63
107
  ],
64
108
  specs: [
@@ -118,12 +162,84 @@ export interface OpenSpecIntegrationMeta {
118
162
  provider: 'openspec';
119
163
  version: string;
120
164
  openspecVersion: string;
165
+ /**
166
+ * When the integration was first installed. Preserved across every later add, update and no-op:
167
+ * an install date that moves on each invocation records nothing, and rewriting it made every
168
+ * no-op command dirty the working tree.
169
+ */
121
170
  installedAt: string;
171
+ /**
172
+ * When managed content last actually changed. Absent until the first change after installation;
173
+ * written only when the merged configuration or the CI example differed, so a no-op leaves this
174
+ * file byte-identical. It answers the question `installedAt` cannot: whether this installation
175
+ * has been regenerated since a ProductShape upgrade.
176
+ */
177
+ updatedAt?: string;
122
178
  configPath: string;
123
179
  /** Absent in metadata written before the CI example existed; `integration update` adds it. */
124
180
  ciExamplePath?: string;
125
181
  /** Absent in metadata written before the field existed; treated as the current guidance. */
126
182
  managed?: ManagedStrings;
183
+ /**
184
+ * The installed OpenSpec product schema: its name, the OpenSpec floor the product workflow
185
+ * needs, and every managed file it consists of, each with the content digest it was installed
186
+ * at. The per-file record and digest are the ownership proof (ADR 0008): update replaces and
187
+ * remove deletes a file only when that exact path was recorded and its bytes still match the
188
+ * recorded digest (legacy path-only records additionally require the current asset bytes). A
189
+ * diverged or unrecorded file is preserved and reported, never overwritten or deleted. Absent in metadata written before the
190
+ * schema existed; `integration update` installs the schema and adds the record. Metadata
191
+ * written by earlier spike revisions carried a plain path array; it is read as paths with no
192
+ * recorded digest, so ownership then rests on matching the current assets.
193
+ */
194
+ productSchema?: { name: string; requiresOpenspec: string; files: Record<string, string> };
195
+ }
196
+
197
+ /** The recorded product-schema files as path-to-digest, tolerating the earlier array shape. */
198
+ function recordedProductSchemaFiles(
199
+ meta: OpenSpecIntegrationMeta | null,
200
+ ): Record<string, string> | undefined {
201
+ const record = meta?.productSchema;
202
+ if (!record) return undefined;
203
+ if (
204
+ typeof record !== 'object' ||
205
+ record === null ||
206
+ record.name !== OPENSPEC_PRODUCT_SCHEMA_NAME ||
207
+ typeof record.requiresOpenspec !== 'string'
208
+ ) {
209
+ throw new Error(
210
+ "'productSchema' must name the product schema and carry a string 'requiresOpenspec'",
211
+ );
212
+ }
213
+ const files = record.files as unknown;
214
+ const validatePath = (file: unknown): file is string =>
215
+ typeof file === 'string' &&
216
+ file.startsWith(`${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/`) &&
217
+ isRepositoryRelativePath(file);
218
+ if (Array.isArray(files)) {
219
+ if (!files.every(validatePath)) {
220
+ throw new Error(
221
+ `'productSchema.files' contains a path outside ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}`,
222
+ );
223
+ }
224
+ return Object.fromEntries(files.map((file) => [file, '']));
225
+ }
226
+ if (files !== null && typeof files === 'object') {
227
+ const entries = Object.entries(files as Record<string, unknown>);
228
+ if (!entries.every(([file]) => validatePath(file))) {
229
+ throw new Error(
230
+ `'productSchema.files' contains a path outside ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}`,
231
+ );
232
+ }
233
+ if (
234
+ !entries.every(
235
+ ([, digest]) => typeof digest === 'string' && /^sha256:[0-9a-f]{64}$/.test(digest),
236
+ )
237
+ ) {
238
+ throw new Error("'productSchema.files' contains an invalid content digest");
239
+ }
240
+ return Object.fromEntries(entries as [string, string][]);
241
+ }
242
+ throw new Error("'productSchema.files' must be a path-to-digest object or legacy path array");
127
243
  }
128
244
 
129
245
  /** Repository-relative paths used by this integration. */
@@ -199,16 +315,21 @@ export function renderCiExample(options: { warningsAsErrors: boolean }): string
199
315
  ].join('\n');
200
316
  }
201
317
 
318
+ /**
319
+ * Every path this integration writes is one of its own module-level literals, resolved through the
320
+ * repository-containment resolver rather than joined directly, so the contract is enforced by the
321
+ * same code that enforces it for repository-supplied paths instead of by inspection of this file.
322
+ */
202
323
  function configPath(root: string): string {
203
- return join(root, ...CONFIG_RELATIVE.split('/'));
324
+ return resolveInRepository(root, CONFIG_RELATIVE, 'the OpenSpec integration');
204
325
  }
205
326
 
206
327
  function metaPath(root: string): string {
207
- return join(root, ...META_RELATIVE.split('/'));
328
+ return resolveInRepository(root, META_RELATIVE, 'the OpenSpec integration');
208
329
  }
209
330
 
210
331
  function ciExamplePath(root: string): string {
211
- return join(root, ...CI_EXAMPLE_RELATIVE.split('/'));
332
+ return resolveInRepository(root, CI_EXAMPLE_RELATIVE, 'the OpenSpec integration');
212
333
  }
213
334
 
214
335
  /**
@@ -502,9 +623,9 @@ async function productshapeVersion(): Promise<string> {
502
623
  * - Merges PDaC guidance into `openspec/config.yaml`, preserving existing user configuration.
503
624
  * - Records metadata under `.product/integrations/openspec.json`, including the exact strings
504
625
  * injected, so a later update replaces outdated PDaC entries instead of accumulating them.
505
- * - Idempotent: running twice produces the same result.
626
+ * - Byte-idempotent: running twice produces the same result, reports no changes and rewrites
627
+ * nothing at all — not the merged configuration, not the CI example, not the metadata.
506
628
  * - `--dry-run` reports what would change without writing.
507
- * - `--force` re-merges even if the PDaC block is already present (useful after a PDaC upgrade).
508
629
  */
509
630
  export async function addOpenSpecIntegration(
510
631
  root: string,
@@ -519,7 +640,11 @@ export async function addOpenSpecIntegration(
519
640
  warningsAsErrors?: boolean;
520
641
  } = {},
521
642
  ): Promise<{ written: string[]; changes: string[]; meta: OpenSpecIntegrationMeta }> {
522
- const { force = false, dryRun = false, warningsAsErrors = false } = options;
643
+ // `force` is accepted for signature compatibility with the other integrations and deliberately
644
+ // ignored: every managed surface here is compared by content, so there is nothing a forced run
645
+ // could regenerate that a normal one does not. It used to mean "rewrite even when identical",
646
+ // which is exactly the byte-churn this integration must not produce.
647
+ const { dryRun = false, warningsAsErrors = false } = options;
523
648
 
524
649
  let openspecVersion = options.cliVersion;
525
650
  if (!openspecVersion) {
@@ -558,56 +683,241 @@ export async function addOpenSpecIntegration(
558
683
  );
559
684
  }
560
685
 
686
+ // The OpenSpec product schema: managed files under openspec/schemas/product, the framework's
687
+ // official project-local schema surface. The files are version-independent data, so they are
688
+ // installed regardless of the detected CLI version; whether the product workflow is USABLE on
689
+ // this CLI is a separate, capability-specific report (`checkOpenSpecIntegration`), never a
690
+ // different set of bytes.
691
+ //
692
+ // Ownership is proven per file, never inferred from the schema path or byte equality (ADR
693
+ // 0008): a file may be replaced only when the metadata recorded that exact path and its bytes
694
+ // still match the recorded digest. Any pre-existing unrecorded file is a collision, even when
695
+ // byte-identical to the asset; on update a diverged managed file is preserved and reported, and
696
+ // an obsolete still-managed file is removed only while its digest continues to match.
697
+ const schemaAssets = await loadProductSchemaAssets();
698
+ const recordedSchema = recordedProductSchemaFiles(previousMeta);
699
+ const currentSchemaPaths = new Set(
700
+ schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`),
701
+ );
702
+ const schemaFileDigests: Record<string, string> = {};
703
+ const schemaWrites: { relative: string; absolute: string; content: string }[] = [];
704
+ const schemaDeletes: { relative: string; absolute: string }[] = [];
705
+ const schemaCollisions: string[] = [];
706
+ const schemaPreserved: string[] = [];
707
+ let schemaInstalledAnything = false;
708
+ for (const asset of schemaAssets) {
709
+ const relative = `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`;
710
+ const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
711
+ const existing = (await pathExists(absolute)) ? await readFile(absolute, 'utf8') : null;
712
+ const assetDigest = contentDigest(asset.content);
713
+ if (existing === null) {
714
+ schemaWrites.push({ relative, absolute, content: asset.content });
715
+ schemaFileDigests[relative] = assetDigest;
716
+ schemaInstalledAnything = true;
717
+ continue;
718
+ }
719
+ const recordedDigest = recordedSchema?.[relative];
720
+ if (recordedDigest === undefined) {
721
+ // Ownership is per file. Byte equality proves compatibility, not authorship: adopting an
722
+ // identical user file on a fresh install or when a later release adds a new asset would let
723
+ // a subsequent remove delete content the integration never installed.
724
+ schemaCollisions.push(relative);
725
+ continue;
726
+ }
727
+ if (existing === asset.content) {
728
+ // A recorded managed path is already byte-identical to the current asset: nothing to write.
729
+ schemaFileDigests[relative] = assetDigest;
730
+ continue;
731
+ }
732
+ if (recordedDigest !== '' && contentDigest(existing) === recordedDigest) {
733
+ // Proven managed at an older recorded version: replace with the current assets.
734
+ schemaWrites.push({ relative, absolute, content: asset.content });
735
+ schemaFileDigests[relative] = assetDigest;
736
+ continue;
737
+ }
738
+ // Managed path whose content diverged from both the record and the current assets: a hand
739
+ // edit. Preserve it, keep its recorded digest (restoring the recorded content re-enables
740
+ // management), and report it.
741
+ schemaPreserved.push(relative);
742
+ if (recordedDigest !== '') schemaFileDigests[relative] = recordedDigest;
743
+ }
744
+ for (const [relative, recordedDigest] of Object.entries(recordedSchema ?? {})) {
745
+ if (currentSchemaPaths.has(relative)) continue;
746
+ const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
747
+ if (!(await pathExists(absolute))) continue;
748
+ const existing = await readFile(absolute, 'utf8');
749
+ if (recordedDigest !== '' && contentDigest(existing) === recordedDigest) {
750
+ schemaDeletes.push({ relative, absolute });
751
+ continue;
752
+ }
753
+ // Ownership extends to absence, but deletion still requires the recorded digest proof. A
754
+ // diverged obsolete file stays user-visible and remains recorded so check/remove can continue
755
+ // reporting it; a digest-less legacy record is preserved but cannot become ownership proof.
756
+ schemaPreserved.push(relative);
757
+ if (recordedDigest !== '') schemaFileDigests[relative] = recordedDigest;
758
+ }
759
+ if (schemaCollisions.length > 0) {
760
+ throw new Error(
761
+ `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.`,
762
+ );
763
+ }
764
+ if (schemaWrites.length > 0) {
765
+ changes.push(
766
+ recordedSchema !== undefined || !schemaInstalledAnything
767
+ ? `Updated the OpenSpec product schema at ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE} (${schemaWrites.length} file${schemaWrites.length === 1 ? '' : 's'}).`
768
+ : `Installed the OpenSpec product schema at ${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}.`,
769
+ );
770
+ if (compareVersions(openspecVersion, PRODUCT_SCHEMA_MIN_OPENSPEC) < 0) {
771
+ changes.push(
772
+ `Product workflow unavailable until OpenSpec >= ${PRODUCT_SCHEMA_MIN_OPENSPEC} (detected ${openspecVersion}); the citation lane is unaffected.`,
773
+ );
774
+ }
775
+ }
776
+ for (const { relative } of schemaDeletes) {
777
+ changes.push(`Removed obsolete managed file ${relative}.`);
778
+ }
779
+ for (const relative of schemaPreserved) {
780
+ changes.push(
781
+ `Preserved hand-edited managed file ${relative}; restore or delete it, then run: prodshape integration update.`,
782
+ );
783
+ }
784
+
785
+ // `installedAt` records when this integration was first installed, and is preserved from here
786
+ // on. Stamping a fresh timestamp on every invocation is what made a no-op `add` — and every
787
+ // `integration update` — rewrite this file, so a command that reported "already up to date"
788
+ // still left the working tree dirty. `updatedAt` carries the moving fact instead, and moves
789
+ // only when managed content actually changed.
790
+ const now = new Date().toISOString();
561
791
  const meta: OpenSpecIntegrationMeta = {
562
792
  provider: 'openspec',
563
793
  version: await productshapeVersion(),
564
794
  openspecVersion,
565
- installedAt: new Date().toISOString(),
795
+ installedAt: previousMeta?.installedAt ?? now,
566
796
  configPath: CONFIG_RELATIVE,
567
797
  ciExamplePath: CI_EXAMPLE_RELATIVE,
568
798
  managed: currentManagedStrings(),
799
+ productSchema: {
800
+ name: 'product',
801
+ requiresOpenspec: PRODUCT_SCHEMA_MIN_OPENSPEC,
802
+ files: schemaFileDigests,
803
+ },
569
804
  };
570
-
571
- if (changes.length === 0 && !force) {
572
- // Already up to date. Still record/refresh metadata.
573
- if (!dryRun) {
574
- await writeMeta(root, meta);
575
- }
576
- return { written: [], changes: [], meta };
577
- }
805
+ const updatedAt =
806
+ changes.length > 0 && previousMeta !== null ? now : (previousMeta?.updatedAt ?? undefined);
807
+ if (updatedAt !== undefined) meta.updatedAt = updatedAt;
578
808
 
579
809
  const written: string[] = [];
580
810
  if (!dryRun) {
581
- if (configChanges.length > 0 || force) {
811
+ // Every write below is conditional on the bytes actually differing. `force` re-derives the
812
+ // content; it does not license rewriting a file that already carries exactly that content,
813
+ // because a byte-identical rewrite is invisible in a diff and visible everywhere else — in
814
+ // Git's stat cache, in file watchers, and in every build that keys off modification times.
815
+ if (configChanges.length > 0) {
582
816
  const serialized = serializeConfig(config);
583
817
  await mkdir(dirname(configPath(root)), { recursive: true });
584
818
  await writeFile(configPath(root), serialized, 'utf8');
585
819
  written.push(CONFIG_RELATIVE);
586
820
  }
587
- if (ciChanged || force) {
821
+ if (ciChanged) {
588
822
  await mkdir(dirname(ciAbsolute), { recursive: true });
589
823
  await writeFile(ciAbsolute, ciDesired, 'utf8');
590
824
  written.push(CI_EXAMPLE_RELATIVE);
591
825
  }
592
- await writeMeta(root, meta);
593
- written.push(META_RELATIVE);
826
+ for (const deletion of schemaDeletes) {
827
+ await rm(deletion.absolute, { force: true });
828
+ }
829
+ for (const write of schemaWrites) {
830
+ await mkdir(dirname(write.absolute), { recursive: true });
831
+ await writeFile(write.absolute, write.content, 'utf8');
832
+ written.push(write.relative);
833
+ }
834
+ if (await writeMetaIfChanged(root, meta)) {
835
+ written.push(META_RELATIVE);
836
+ }
594
837
  }
595
838
 
596
839
  return { written, changes, meta };
597
840
  }
598
841
 
599
- /** Write the integration metadata file. */
600
- async function writeMeta(root: string, meta: OpenSpecIntegrationMeta): Promise<void> {
842
+ /**
843
+ * Write the integration metadata file, but only when its bytes would change. Reports whether it
844
+ * was written, so a caller never claims a write that did not happen.
845
+ */
846
+ async function writeMetaIfChanged(root: string, meta: OpenSpecIntegrationMeta): Promise<boolean> {
601
847
  const path = metaPath(root);
848
+ const desired = `${JSON.stringify(meta, null, 2)}\n`;
849
+ let existing: string | undefined;
850
+ try {
851
+ existing = await readFile(path, 'utf8');
852
+ } catch (error) {
853
+ if (!isNotFound(error)) throw error;
854
+ }
855
+ if (existing === desired) return false;
602
856
  await mkdir(dirname(path), { recursive: true });
603
- await writeFile(path, `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
857
+ await writeFile(path, desired, 'utf8');
858
+ return true;
604
859
  }
605
860
 
606
- /** Read the integration metadata file, or null if absent. */
861
+ /**
862
+ * An integration metadata file that exists but cannot be read or parsed.
863
+ *
864
+ * Absent metadata means the integration is not installed, which is a supported state. A metadata
865
+ * file that is present and unreadable is not: continuing as though it were absent would discard
866
+ * the record of exactly which guidance strings were injected, and the next update would append
867
+ * duplicates alongside the entries it should have replaced.
868
+ */
869
+ export class OpenSpecMetaError extends Error {
870
+ constructor(detail: string, options?: { cause?: unknown }) {
871
+ super(
872
+ `OpenSpec integration metadata ${META_RELATIVE} cannot be trusted: ${detail}` +
873
+ '\nReconcile it by hand, or remove it and re-run: prodshape integration add openspec',
874
+ options,
875
+ );
876
+ this.name = 'OpenSpecMetaError';
877
+ }
878
+ }
879
+
880
+ /** Read the integration metadata file, or null if absent. Unreadable or malformed is an error. */
607
881
  async function readMeta(root: string): Promise<OpenSpecIntegrationMeta | null> {
608
882
  const path = metaPath(root);
609
- if (!(await pathExists(path))) return null;
610
- return JSON.parse(await readFile(path, 'utf8')) as OpenSpecIntegrationMeta;
883
+ let content: string;
884
+ try {
885
+ content = await readFile(path, 'utf8');
886
+ } catch (error) {
887
+ if (isNotFound(error)) return null;
888
+ throw new OpenSpecMetaError(
889
+ `it exists but could not be read (${error instanceof Error ? error.message : String(error)})`,
890
+ { cause: error },
891
+ );
892
+ }
893
+ let parsed: unknown;
894
+ try {
895
+ parsed = JSON.parse(content);
896
+ } catch (error) {
897
+ throw new OpenSpecMetaError(
898
+ `it is not valid JSON (${error instanceof Error ? error.message : String(error)})`,
899
+ { cause: error },
900
+ );
901
+ }
902
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
903
+ throw new OpenSpecMetaError('the document is not a JSON object');
904
+ }
905
+ const record = parsed as Record<string, unknown>;
906
+ if (record.provider !== 'openspec' || typeof record.installedAt !== 'string') {
907
+ throw new OpenSpecMetaError(
908
+ "it does not match the metadata schema ('provider' must be 'openspec' and 'installedAt' a string)",
909
+ );
910
+ }
911
+ const meta = record as unknown as OpenSpecIntegrationMeta;
912
+ try {
913
+ recordedProductSchemaFiles(meta);
914
+ } catch (error) {
915
+ throw new OpenSpecMetaError(
916
+ `it does not match the product schema ownership record (${error instanceof Error ? error.message : String(error)})`,
917
+ { cause: error },
918
+ );
919
+ }
920
+ return meta;
611
921
  }
612
922
 
613
923
  /**
@@ -634,6 +944,9 @@ export async function updateOpenSpecIntegration(
634
944
  * - `openspec/config.yaml` exists and contains the PDaC context block.
635
945
  * - `.product/integrations/openspec.json` metadata exists.
636
946
  * - The config's PDaC block matches the current PDaC guidance (not removed or altered).
947
+ * - The product schema's managed files are installed and intact, with the product workflow's
948
+ * availability reported per capability: an OpenSpec CLI below the product floor leaves the
949
+ * citation lane usable and states the product workflow UNAVAILABLE instead of failing it.
637
950
  */
638
951
  export async function checkOpenSpecIntegration(root: string): Promise<{
639
952
  ok: boolean;
@@ -643,6 +956,7 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
643
956
 
644
957
  // 1. OpenSpec CLI installed and supported.
645
958
  const detected = await detectOpenSpecVersion(root);
959
+ const detectedVersion = 'error' in detected ? undefined : detected.version;
646
960
  if ('error' in detected) {
647
961
  checks.push({ name: 'openspec CLI', ok: false, detail: detected.error });
648
962
  } else if (compareVersions(detected.version, MIN_SUPPORTED_OPENSPEC) < 0) {
@@ -707,7 +1021,9 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
707
1021
  checks.push({
708
1022
  name: 'metadata',
709
1023
  ok: true,
710
- detail: `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}).`,
1024
+ detail:
1025
+ `Integration recorded (OpenSpec ${meta.openspecVersion}, installed ${meta.installedAt}` +
1026
+ `${meta.updatedAt ? `, updated ${meta.updatedAt}` : ''}).`,
711
1027
  });
712
1028
  }
713
1029
 
@@ -750,6 +1066,104 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
750
1066
  }
751
1067
  }
752
1068
 
1069
+ // 5. Product workflow: managed schema state plus capability-specific availability. The schema
1070
+ // files are inert data valid under any CLI, so a floor-violating CLI is not a managed-state
1071
+ // defect; it makes the product workflow UNAVAILABLE, stated as the check's verdict rather than
1072
+ // buried as a footnote, while the citation lane stays independently usable.
1073
+ const schemaAssets = await loadProductSchemaAssets();
1074
+ if (meta === null || meta.productSchema === undefined) {
1075
+ checks.push({
1076
+ name: 'product workflow',
1077
+ ok: false,
1078
+ detail: `OpenSpec product schema not installed (${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}). Run: prodshape integration update`,
1079
+ });
1080
+ } else {
1081
+ const recordedSchema = recordedProductSchemaFiles(meta) ?? {};
1082
+ const currentSchemaPaths = new Set(
1083
+ schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`),
1084
+ );
1085
+ const missing: string[] = [];
1086
+ const unrecorded: string[] = [];
1087
+ const outdated: string[] = [];
1088
+ const diverged: string[] = [];
1089
+ const obsolete = Object.keys(recordedSchema)
1090
+ .filter((relative) => !currentSchemaPaths.has(relative))
1091
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1092
+ for (const asset of schemaAssets) {
1093
+ const relative = `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`;
1094
+ const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
1095
+ const existing = (await pathExists(absolute)) ? await readFile(absolute, 'utf8') : null;
1096
+ const recordedDigest = recordedSchema[relative];
1097
+ if (recordedDigest === undefined) {
1098
+ unrecorded.push(relative);
1099
+ } else if (existing === null) {
1100
+ missing.push(relative);
1101
+ } else if (existing !== asset.content) {
1102
+ if (recordedDigest !== '' && contentDigest(existing) === recordedDigest) {
1103
+ outdated.push(relative);
1104
+ } else {
1105
+ diverged.push(relative);
1106
+ }
1107
+ }
1108
+ }
1109
+ if (
1110
+ missing.length > 0 ||
1111
+ unrecorded.length > 0 ||
1112
+ outdated.length > 0 ||
1113
+ diverged.length > 0 ||
1114
+ obsolete.length > 0
1115
+ ) {
1116
+ const parts: string[] = [];
1117
+ if (missing.length > 0) {
1118
+ parts.push(`missing: ${missing.join(', ')} (run: prodshape integration update)`);
1119
+ }
1120
+ if (unrecorded.length > 0) {
1121
+ parts.push(
1122
+ `unrecorded: ${unrecorded.join(', ')} (move user files aside, then run: prodshape integration update)`,
1123
+ );
1124
+ }
1125
+ if (outdated.length > 0) {
1126
+ parts.push(
1127
+ `outdated managed content: ${outdated.join(', ')} (run: prodshape integration update)`,
1128
+ );
1129
+ }
1130
+ if (diverged.length > 0) {
1131
+ parts.push(
1132
+ `hand-edited and preserved: ${diverged.join(', ')} (restore or delete them, then run: prodshape integration update)`,
1133
+ );
1134
+ }
1135
+ if (obsolete.length > 0) {
1136
+ parts.push(
1137
+ `obsolete managed content: ${obsolete.join(', ')} (run: prodshape integration update)`,
1138
+ );
1139
+ }
1140
+ checks.push({
1141
+ name: 'product workflow',
1142
+ ok: false,
1143
+ detail: `Product schema files need attention. ${parts.join(' ')}`,
1144
+ });
1145
+ } else if (detectedVersion === undefined) {
1146
+ checks.push({
1147
+ name: 'product workflow',
1148
+ ok: true,
1149
+ detail:
1150
+ 'Product workflow UNAVAILABLE: the OpenSpec CLI was not detected. The schema is installed and intact; the openspec CLI check carries the remedy.',
1151
+ });
1152
+ } else if (compareVersions(detectedVersion, PRODUCT_SCHEMA_MIN_OPENSPEC) < 0) {
1153
+ checks.push({
1154
+ name: 'product workflow',
1155
+ ok: true,
1156
+ detail: `Product workflow UNAVAILABLE: requires OpenSpec >= ${PRODUCT_SCHEMA_MIN_OPENSPEC}, detected ${detectedVersion}. The citation lane is unaffected; upgrade OpenSpec to use the product schema.`,
1157
+ });
1158
+ } else {
1159
+ checks.push({
1160
+ name: 'product workflow',
1161
+ ok: true,
1162
+ detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${PRODUCT_SCHEMA_MIN_OPENSPEC} floor.`,
1163
+ });
1164
+ }
1165
+ }
1166
+
753
1167
  const ok = checks.every((c) => c.ok);
754
1168
  return { ok, checks };
755
1169
  }
@@ -761,20 +1175,25 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
761
1175
  * - Removes the PDaC context block (between sentinels) from `context:`.
762
1176
  * - Removes PDaC rules from each artifact (keeps user-authored rules).
763
1177
  * - Removes PDaC operation guidance (keeps user-authored guidance).
1178
+ * - Deletes the installed product schema files still proven managed (recorded digest or current
1179
+ * asset bytes); hand-edited managed files are preserved and reported in `preserved`, and with
1180
+ * no recorded product schema nothing under openspec/schemas is touched.
764
1181
  * - Deletes `.product/integrations/openspec.json`.
765
- * - `--dry-run` reports what would be removed without writing.
1182
+ * - `--dry-run` reports what would be removed and preserved without writing.
766
1183
  */
767
1184
  export async function removeOpenSpecIntegration(
768
1185
  root: string,
769
1186
  options: { dryRun?: boolean } = {},
770
- ): Promise<{ removed: string[] }> {
1187
+ ): Promise<{ removed: string[]; preserved: string[] }> {
771
1188
  const { dryRun = false } = options;
772
1189
  const removed: string[] = [];
1190
+ const preserved: string[] = [];
773
1191
 
774
1192
  // Remove both what the metadata recorded as injected and what the current guidance would
775
1193
  // inject: the union covers metadata written before the record existed as well as entries left
776
1194
  // behind by an older ProductShape.
777
- const recorded = (await readMeta(root))?.managed;
1195
+ const previousMeta = await readMeta(root);
1196
+ const recorded = previousMeta?.managed;
778
1197
  const current = currentManagedStrings();
779
1198
  const managedRules: Record<string, string[]> = {};
780
1199
  for (const key of new Set([
@@ -903,6 +1322,74 @@ export async function removeOpenSpecIntegration(
903
1322
  removed.push(CI_EXAMPLE_RELATIVE);
904
1323
  }
905
1324
 
1325
+ // Remove the installed product schema, deleting only paths with a per-file ownership record and
1326
+ // bytes still proven managed by the recorded digest (or, for a legacy path-only record, the
1327
+ // current asset). A diverged or unrecorded file is preserved and reported, and with no recorded
1328
+ // product schema at all nothing under openspec/schemas is touched. Directories are pruned only
1329
+ // when the removal emptied them, so adjacent schemas and user files inside ours survive.
1330
+ const recordedSchema = recordedProductSchemaFiles(previousMeta);
1331
+ if (recordedSchema !== undefined) {
1332
+ const assetContentByRelative = new Map(
1333
+ (await loadProductSchemaAssets()).map((asset) => [
1334
+ `${OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/${asset.relative}`,
1335
+ asset.content,
1336
+ ]),
1337
+ );
1338
+ const schemaFileSet = Object.keys(recordedSchema).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1339
+ const pruneCandidates = new Set<string>();
1340
+ for (const relative of schemaFileSet) {
1341
+ const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
1342
+ if (!(await pathExists(absolute))) continue;
1343
+ const existing = await readFile(absolute, 'utf8');
1344
+ const recordedDigest = recordedSchema[relative];
1345
+ const proven =
1346
+ existing === assetContentByRelative.get(relative) ||
1347
+ (recordedDigest !== undefined &&
1348
+ recordedDigest !== '' &&
1349
+ contentDigest(existing) === recordedDigest);
1350
+ if (!proven) {
1351
+ preserved.push(relative);
1352
+ continue;
1353
+ }
1354
+ if (!dryRun) {
1355
+ await rm(absolute, { force: true });
1356
+ let parent = dirname(relative);
1357
+ while (
1358
+ parent.startsWith('openspec/schemas') &&
1359
+ parent !== 'openspec/schemas' &&
1360
+ parent !== 'openspec'
1361
+ ) {
1362
+ pruneCandidates.add(parent);
1363
+ parent = parent.split('/').slice(0, -1).join('/');
1364
+ }
1365
+ pruneCandidates.add('openspec/schemas');
1366
+ }
1367
+ removed.push(relative);
1368
+ }
1369
+ // A schema-level record is not ownership of every current asset path. If a path is absent
1370
+ // from the per-file record, preserve and report it even when its bytes equal today's asset.
1371
+ for (const relative of [...assetContentByRelative.keys()].sort((a, b) =>
1372
+ a < b ? -1 : a > b ? 1 : 0,
1373
+ )) {
1374
+ if (recordedSchema[relative] !== undefined) continue;
1375
+ const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
1376
+ if (await pathExists(absolute)) preserved.push(relative);
1377
+ }
1378
+ if (!dryRun) {
1379
+ // Deepest first, so a directory is only considered after its children were.
1380
+ const ordered = [...pruneCandidates].sort((a, b) => b.length - a.length);
1381
+ for (const relative of ordered) {
1382
+ const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
1383
+ try {
1384
+ const entries = await readdir(absolute);
1385
+ if (entries.length === 0) await rmdir(absolute);
1386
+ } catch (error) {
1387
+ if (!isNotFound(error)) throw error;
1388
+ }
1389
+ }
1390
+ }
1391
+ }
1392
+
906
1393
  // Remove metadata file.
907
1394
  if (await pathExists(metaPath(root))) {
908
1395
  if (!dryRun) {
@@ -911,5 +1398,5 @@ export async function removeOpenSpecIntegration(
911
1398
  removed.push(META_RELATIVE);
912
1399
  }
913
1400
 
914
- return { removed };
1401
+ return { removed, preserved };
915
1402
  }