@prodshape/integration-openspec 0.6.0-alpha.0 → 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/README.md +12 -8
- package/assets/{openspec-product-schema → openspec-product-change-schema}/schema.yaml +41 -10
- package/assets/{openspec-product-schema → openspec-product-change-schema}/scripts/product-apply.mjs +2 -2
- package/assets/{openspec-product-schema → openspec-product-change-schema}/scripts/product-validate.mjs +2 -2
- package/assets/openspec-product-change-schema/templates/change.md +51 -0
- package/assets/{openspec-product-schema → openspec-product-change-schema}/templates/proposal.md +12 -0
- package/assets/openspec-product-recovery-schema/schema.yaml +40 -0
- package/assets/openspec-product-recovery-schema/templates/brief.md +22 -0
- package/assets/openspec-product-recovery-schema/templates/report.md +15 -0
- package/dist/index.d.ts +21 -11
- package/dist/index.js +210 -64
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +207 -40
- package/src/{product-schema.ts → product-change-schema.ts} +29 -9
- package/src/product-workflow.ts +33 -38
- package/assets/openspec-product-schema/templates/change.md +0 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prodshape/integration-openspec",
|
|
3
|
-
"version": "0.6.0-alpha.
|
|
3
|
+
"version": "0.6.0-alpha.3",
|
|
4
4
|
"description": "OpenSpec integration for Product Definition as Code: configures OpenSpec workspaces with PDaC citation rules and verifies citations against the OpenSpec population.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `prodshape citations verify` to check.
|
|
8
8
|
*
|
|
9
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
|
|
10
|
+
* project-local `product-change` schema at `openspec/schemas/product-change/`, OpenSpec's official project
|
|
11
11
|
* extension surface, whose changes host a Product Change delta that `product-workflow.ts`
|
|
12
12
|
* validates as an overlay and applies deterministically.
|
|
13
13
|
*
|
|
@@ -29,11 +29,14 @@ import {
|
|
|
29
29
|
} from '@prodshape/core';
|
|
30
30
|
import { parse, stringify } from 'yaml';
|
|
31
31
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
loadProductChangeSchemaAssets,
|
|
33
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
|
|
34
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE,
|
|
35
|
+
PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
36
|
+
loadProductRecoverySchemaAssets,
|
|
37
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
|
|
38
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
|
|
39
|
+
} from './product-change-schema.js';
|
|
37
40
|
import { runCommand } from './process.js';
|
|
38
41
|
import { envWithLocalBin, isOpenSpecWorkspace, pathExists } from './workspace.js';
|
|
39
42
|
|
|
@@ -44,12 +47,15 @@ export {
|
|
|
44
47
|
openSpecProvider,
|
|
45
48
|
} from './population.js';
|
|
46
49
|
export {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
loadProductChangeSchemaAssets,
|
|
51
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
|
|
52
|
+
OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE,
|
|
53
|
+
PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
54
|
+
loadProductRecoverySchemaAssets,
|
|
55
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
|
|
56
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
|
|
57
|
+
} from './product-change-schema.js';
|
|
58
|
+
export type { ProductChangeSchemaAsset } from './product-change-schema.js';
|
|
53
59
|
export {
|
|
54
60
|
applyOpenSpecProductChange,
|
|
55
61
|
deriveDeliveryContext,
|
|
@@ -93,7 +99,7 @@ A citation binds consumer text to a canonical artifact: it records the artifact
|
|
|
93
99
|
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.
|
|
94
100
|
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.
|
|
95
101
|
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.
|
|
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.
|
|
102
|
+
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.
|
|
97
103
|
${PDAC_CONTEXT_END}`;
|
|
98
104
|
|
|
99
105
|
/** The PDaC citation rules injected per artifact into openspec/config.yaml `rules:`. */
|
|
@@ -102,7 +108,7 @@ export const PDAC_RULES: Record<string, string[]> = {
|
|
|
102
108
|
'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.',
|
|
103
109
|
'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.',
|
|
104
110
|
'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.',
|
|
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-
|
|
111
|
+
'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.',
|
|
106
112
|
'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.',
|
|
107
113
|
],
|
|
108
114
|
specs: [
|
|
@@ -192,8 +198,13 @@ export interface OpenSpecIntegrationMeta {
|
|
|
192
198
|
* recorded digest, so ownership then rests on matching the current assets.
|
|
193
199
|
*/
|
|
194
200
|
productSchema?: { name: string; requiresOpenspec: string; files: Record<string, string> };
|
|
201
|
+
recoverySchema?: { name: string; requiresOpenspec: string; files: Record<string, string> };
|
|
195
202
|
}
|
|
196
203
|
|
|
204
|
+
/** The pre-#227 schema location accepted only for safe, ownership-proven migration. */
|
|
205
|
+
const LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME = 'product';
|
|
206
|
+
const LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE = 'openspec/schemas/product';
|
|
207
|
+
|
|
197
208
|
/** The recorded product-schema files as path-to-digest, tolerating the earlier array shape. */
|
|
198
209
|
function recordedProductSchemaFiles(
|
|
199
210
|
meta: OpenSpecIntegrationMeta | null,
|
|
@@ -203,7 +214,9 @@ function recordedProductSchemaFiles(
|
|
|
203
214
|
if (
|
|
204
215
|
typeof record !== 'object' ||
|
|
205
216
|
record === null ||
|
|
206
|
-
record.name !==
|
|
217
|
+
(record.name !== OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME &&
|
|
218
|
+
record.name !== LEGACY_OPENSPEC_PRODUCT_SCHEMA_NAME &&
|
|
219
|
+
record.name !== OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME) ||
|
|
207
220
|
typeof record.requiresOpenspec !== 'string'
|
|
208
221
|
) {
|
|
209
222
|
throw new Error(
|
|
@@ -213,12 +226,14 @@ function recordedProductSchemaFiles(
|
|
|
213
226
|
const files = record.files as unknown;
|
|
214
227
|
const validatePath = (file: unknown): file is string =>
|
|
215
228
|
typeof file === 'string' &&
|
|
216
|
-
file.startsWith(`${
|
|
229
|
+
(file.startsWith(`${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/`) ||
|
|
230
|
+
file.startsWith(`${LEGACY_OPENSPEC_PRODUCT_SCHEMA_RELATIVE}/`) ||
|
|
231
|
+
file.startsWith(`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/`)) &&
|
|
217
232
|
isRepositoryRelativePath(file);
|
|
218
233
|
if (Array.isArray(files)) {
|
|
219
234
|
if (!files.every(validatePath)) {
|
|
220
235
|
throw new Error(
|
|
221
|
-
`'productSchema.files' contains a path outside
|
|
236
|
+
`'productSchema.files' contains a path outside the managed OpenSpec schema locations`,
|
|
222
237
|
);
|
|
223
238
|
}
|
|
224
239
|
return Object.fromEntries(files.map((file) => [file, '']));
|
|
@@ -227,7 +242,7 @@ function recordedProductSchemaFiles(
|
|
|
227
242
|
const entries = Object.entries(files as Record<string, unknown>);
|
|
228
243
|
if (!entries.every(([file]) => validatePath(file))) {
|
|
229
244
|
throw new Error(
|
|
230
|
-
`'productSchema.files' contains a path outside
|
|
245
|
+
`'productSchema.files' contains a path outside the managed OpenSpec schema locations`,
|
|
231
246
|
);
|
|
232
247
|
}
|
|
233
248
|
if (
|
|
@@ -242,6 +257,25 @@ function recordedProductSchemaFiles(
|
|
|
242
257
|
throw new Error("'productSchema.files' must be a path-to-digest object or legacy path array");
|
|
243
258
|
}
|
|
244
259
|
|
|
260
|
+
function recordedRecoverySchemaFiles(
|
|
261
|
+
meta: OpenSpecIntegrationMeta | null,
|
|
262
|
+
): Record<string, string> | undefined {
|
|
263
|
+
const record = meta?.recoverySchema;
|
|
264
|
+
if (!record) return undefined;
|
|
265
|
+
if (
|
|
266
|
+
record.name !== OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME ||
|
|
267
|
+
typeof record.requiresOpenspec !== 'string'
|
|
268
|
+
) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
"'recoverySchema' must name the product-recovery schema and carry a string 'requiresOpenspec'",
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (!record.files || typeof record.files !== 'object' || Array.isArray(record.files)) {
|
|
274
|
+
throw new Error("'recoverySchema.files' must be a path-to-digest object");
|
|
275
|
+
}
|
|
276
|
+
return record.files;
|
|
277
|
+
}
|
|
278
|
+
|
|
245
279
|
/** Repository-relative paths used by this integration. */
|
|
246
280
|
const CONFIG_RELATIVE = 'openspec/config.yaml';
|
|
247
281
|
const META_RELATIVE = '.product/integrations/openspec.json';
|
|
@@ -683,7 +717,7 @@ export async function addOpenSpecIntegration(
|
|
|
683
717
|
);
|
|
684
718
|
}
|
|
685
719
|
|
|
686
|
-
// The OpenSpec product schema: managed files under openspec/schemas/product, the framework's
|
|
720
|
+
// The OpenSpec product schema: managed files under openspec/schemas/product-change, the framework's
|
|
687
721
|
// official project-local schema surface. The files are version-independent data, so they are
|
|
688
722
|
// installed regardless of the detected CLI version; whether the product workflow is USABLE on
|
|
689
723
|
// this CLI is a separate, capability-specific report (`checkOpenSpecIntegration`), never a
|
|
@@ -694,10 +728,10 @@ export async function addOpenSpecIntegration(
|
|
|
694
728
|
// still match the recorded digest. Any pre-existing unrecorded file is a collision, even when
|
|
695
729
|
// byte-identical to the asset; on update a diverged managed file is preserved and reported, and
|
|
696
730
|
// an obsolete still-managed file is removed only while its digest continues to match.
|
|
697
|
-
const schemaAssets = await
|
|
731
|
+
const schemaAssets = await loadProductChangeSchemaAssets();
|
|
698
732
|
const recordedSchema = recordedProductSchemaFiles(previousMeta);
|
|
699
733
|
const currentSchemaPaths = new Set(
|
|
700
|
-
schemaAssets.map((asset) => `${
|
|
734
|
+
schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`),
|
|
701
735
|
);
|
|
702
736
|
const schemaFileDigests: Record<string, string> = {};
|
|
703
737
|
const schemaWrites: { relative: string; absolute: string; content: string }[] = [];
|
|
@@ -706,7 +740,7 @@ export async function addOpenSpecIntegration(
|
|
|
706
740
|
const schemaPreserved: string[] = [];
|
|
707
741
|
let schemaInstalledAnything = false;
|
|
708
742
|
for (const asset of schemaAssets) {
|
|
709
|
-
const relative = `${
|
|
743
|
+
const relative = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
710
744
|
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
711
745
|
const existing = (await pathExists(absolute)) ? await readFile(absolute, 'utf8') : null;
|
|
712
746
|
const assetDigest = contentDigest(asset.content);
|
|
@@ -758,18 +792,18 @@ export async function addOpenSpecIntegration(
|
|
|
758
792
|
}
|
|
759
793
|
if (schemaCollisions.length > 0) {
|
|
760
794
|
throw new Error(
|
|
761
|
-
`Refusing to install the OpenSpec product schema: ${
|
|
795
|
+
`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.`,
|
|
762
796
|
);
|
|
763
797
|
}
|
|
764
798
|
if (schemaWrites.length > 0) {
|
|
765
799
|
changes.push(
|
|
766
800
|
recordedSchema !== undefined || !schemaInstalledAnything
|
|
767
|
-
? `Updated the OpenSpec product schema at ${
|
|
768
|
-
: `Installed the OpenSpec product schema at ${
|
|
801
|
+
? `Updated the OpenSpec product schema at ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE} (${schemaWrites.length} file${schemaWrites.length === 1 ? '' : 's'}).`
|
|
802
|
+
: `Installed the OpenSpec product schema at ${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}.`,
|
|
769
803
|
);
|
|
770
|
-
if (compareVersions(openspecVersion,
|
|
804
|
+
if (compareVersions(openspecVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
|
|
771
805
|
changes.push(
|
|
772
|
-
`Product workflow unavailable until OpenSpec >= ${
|
|
806
|
+
`Product workflow unavailable until OpenSpec >= ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} (detected ${openspecVersion}); the citation lane is unaffected.`,
|
|
773
807
|
);
|
|
774
808
|
}
|
|
775
809
|
}
|
|
@@ -782,6 +816,49 @@ export async function addOpenSpecIntegration(
|
|
|
782
816
|
);
|
|
783
817
|
}
|
|
784
818
|
|
|
819
|
+
// The recovery workload has its own schema and ownership record. It is intentionally kept
|
|
820
|
+
// beside, rather than inside, product-change: recovery starts without an accepted baseline.
|
|
821
|
+
const recoveryAssets = await loadProductRecoverySchemaAssets();
|
|
822
|
+
const previousRecovery = recordedRecoverySchemaFiles(previousMeta);
|
|
823
|
+
const recoveryDigests: Record<string, string> = {};
|
|
824
|
+
const recoveryWrites: { relative: string; absolute: string; content: string }[] = [];
|
|
825
|
+
const recoveryCollisions: string[] = [];
|
|
826
|
+
for (const asset of recoveryAssets) {
|
|
827
|
+
const relative = `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
828
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
829
|
+
const existing = (await pathExists(absolute)) ? await readFile(absolute, 'utf8') : null;
|
|
830
|
+
const digest = contentDigest(asset.content);
|
|
831
|
+
const recorded = previousRecovery?.[relative];
|
|
832
|
+
if (existing === null) {
|
|
833
|
+
recoveryWrites.push({ relative, absolute, content: asset.content });
|
|
834
|
+
recoveryDigests[relative] = digest;
|
|
835
|
+
} else if (recorded === undefined) {
|
|
836
|
+
recoveryCollisions.push(relative);
|
|
837
|
+
} else if (existing === asset.content) {
|
|
838
|
+
recoveryDigests[relative] = digest;
|
|
839
|
+
} else if (recorded !== '' && contentDigest(existing) === recorded) {
|
|
840
|
+
recoveryWrites.push({ relative, absolute, content: asset.content });
|
|
841
|
+
recoveryDigests[relative] = digest;
|
|
842
|
+
} else {
|
|
843
|
+
recoveryDigests[relative] = recorded;
|
|
844
|
+
changes.push(
|
|
845
|
+
`Preserved hand-edited managed file ${relative}; restore or delete it, then run: prodshape integration update.`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (recoveryCollisions.length > 0) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`Refusing to install the OpenSpec product-recovery schema: unrecorded files exist (${recoveryCollisions.join(', ')}). The integration never overwrites user-authored files.`,
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (recoveryWrites.length > 0) {
|
|
855
|
+
changes.push(
|
|
856
|
+
previousRecovery === undefined
|
|
857
|
+
? `Installed the OpenSpec product-recovery schema at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}.`
|
|
858
|
+
: `Updated the OpenSpec product-recovery schema at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE} (${recoveryWrites.length} files).`,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
785
862
|
// `installedAt` records when this integration was first installed, and is preserved from here
|
|
786
863
|
// on. Stamping a fresh timestamp on every invocation is what made a no-op `add` — and every
|
|
787
864
|
// `integration update` — rewrite this file, so a command that reported "already up to date"
|
|
@@ -797,10 +874,15 @@ export async function addOpenSpecIntegration(
|
|
|
797
874
|
ciExamplePath: CI_EXAMPLE_RELATIVE,
|
|
798
875
|
managed: currentManagedStrings(),
|
|
799
876
|
productSchema: {
|
|
800
|
-
name:
|
|
801
|
-
requiresOpenspec:
|
|
877
|
+
name: OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME,
|
|
878
|
+
requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
802
879
|
files: schemaFileDigests,
|
|
803
880
|
},
|
|
881
|
+
recoverySchema: {
|
|
882
|
+
name: OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME,
|
|
883
|
+
requiresOpenspec: PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC,
|
|
884
|
+
files: recoveryDigests,
|
|
885
|
+
},
|
|
804
886
|
};
|
|
805
887
|
const updatedAt =
|
|
806
888
|
changes.length > 0 && previousMeta !== null ? now : (previousMeta?.updatedAt ?? undefined);
|
|
@@ -831,6 +913,11 @@ export async function addOpenSpecIntegration(
|
|
|
831
913
|
await writeFile(write.absolute, write.content, 'utf8');
|
|
832
914
|
written.push(write.relative);
|
|
833
915
|
}
|
|
916
|
+
for (const write of recoveryWrites) {
|
|
917
|
+
await mkdir(dirname(write.absolute), { recursive: true });
|
|
918
|
+
await writeFile(write.absolute, write.content, 'utf8');
|
|
919
|
+
written.push(write.relative);
|
|
920
|
+
}
|
|
834
921
|
if (await writeMetaIfChanged(root, meta)) {
|
|
835
922
|
written.push(META_RELATIVE);
|
|
836
923
|
}
|
|
@@ -928,12 +1015,15 @@ export async function updateOpenSpecIntegration(
|
|
|
928
1015
|
root: string,
|
|
929
1016
|
options: { force?: boolean; dryRun?: boolean; warningsAsErrors?: boolean } = {},
|
|
930
1017
|
): Promise<{ written: string[]; changes: string[] }> {
|
|
931
|
-
//
|
|
932
|
-
//
|
|
1018
|
+
// An existing metadata record is the compatibility verdict established during installation.
|
|
1019
|
+
// Managed file reconciliation must remain usable in release jobs and repositories that no
|
|
1020
|
+
// longer have OpenSpec on PATH; initial installation and explicit health checks still probe it.
|
|
1021
|
+
const previousMeta = await readMeta(root);
|
|
933
1022
|
const result = await addOpenSpecIntegration(root, {
|
|
934
1023
|
force: true,
|
|
935
1024
|
dryRun: options.dryRun,
|
|
936
1025
|
warningsAsErrors: options.warningsAsErrors,
|
|
1026
|
+
...(previousMeta?.openspecVersion ? { cliVersion: previousMeta.openspecVersion } : {}),
|
|
937
1027
|
});
|
|
938
1028
|
return { written: result.written, changes: result.changes };
|
|
939
1029
|
}
|
|
@@ -1070,17 +1160,17 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
|
1070
1160
|
// files are inert data valid under any CLI, so a floor-violating CLI is not a managed-state
|
|
1071
1161
|
// defect; it makes the product workflow UNAVAILABLE, stated as the check's verdict rather than
|
|
1072
1162
|
// buried as a footnote, while the citation lane stays independently usable.
|
|
1073
|
-
const schemaAssets = await
|
|
1163
|
+
const schemaAssets = await loadProductChangeSchemaAssets();
|
|
1074
1164
|
if (meta === null || meta.productSchema === undefined) {
|
|
1075
1165
|
checks.push({
|
|
1076
1166
|
name: 'product workflow',
|
|
1077
1167
|
ok: false,
|
|
1078
|
-
detail: `OpenSpec product schema not installed (${
|
|
1168
|
+
detail: `OpenSpec product schema not installed (${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}). Run: prodshape integration update`,
|
|
1079
1169
|
});
|
|
1080
1170
|
} else {
|
|
1081
1171
|
const recordedSchema = recordedProductSchemaFiles(meta) ?? {};
|
|
1082
1172
|
const currentSchemaPaths = new Set(
|
|
1083
|
-
schemaAssets.map((asset) => `${
|
|
1173
|
+
schemaAssets.map((asset) => `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`),
|
|
1084
1174
|
);
|
|
1085
1175
|
const missing: string[] = [];
|
|
1086
1176
|
const unrecorded: string[] = [];
|
|
@@ -1090,7 +1180,7 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
|
1090
1180
|
.filter((relative) => !currentSchemaPaths.has(relative))
|
|
1091
1181
|
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1092
1182
|
for (const asset of schemaAssets) {
|
|
1093
|
-
const relative = `${
|
|
1183
|
+
const relative = `${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
1094
1184
|
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1095
1185
|
const existing = (await pathExists(absolute)) ? await readFile(absolute, 'utf8') : null;
|
|
1096
1186
|
const recordedDigest = recordedSchema[relative];
|
|
@@ -1149,21 +1239,59 @@ export async function checkOpenSpecIntegration(root: string): Promise<{
|
|
|
1149
1239
|
detail:
|
|
1150
1240
|
'Product workflow UNAVAILABLE: the OpenSpec CLI was not detected. The schema is installed and intact; the openspec CLI check carries the remedy.',
|
|
1151
1241
|
});
|
|
1152
|
-
} else if (compareVersions(detectedVersion,
|
|
1242
|
+
} else if (compareVersions(detectedVersion, PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC) < 0) {
|
|
1153
1243
|
checks.push({
|
|
1154
1244
|
name: 'product workflow',
|
|
1155
1245
|
ok: true,
|
|
1156
|
-
detail: `Product workflow UNAVAILABLE: requires OpenSpec >= ${
|
|
1246
|
+
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.`,
|
|
1157
1247
|
});
|
|
1158
1248
|
} else {
|
|
1159
1249
|
checks.push({
|
|
1160
1250
|
name: 'product workflow',
|
|
1161
1251
|
ok: true,
|
|
1162
|
-
detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${
|
|
1252
|
+
detail: `Product workflow available: schema installed and OpenSpec ${detectedVersion} meets the ${PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC} floor.`,
|
|
1163
1253
|
});
|
|
1164
1254
|
}
|
|
1165
1255
|
}
|
|
1166
1256
|
|
|
1257
|
+
// 6. Hosted recovery schema ownership is checked independently from product-change because
|
|
1258
|
+
// the two workloads have different preconditions and may evolve on different cadences.
|
|
1259
|
+
const recoveryAssets = await loadProductRecoverySchemaAssets();
|
|
1260
|
+
const recoveryRecord = recordedRecoverySchemaFiles(meta);
|
|
1261
|
+
if (recoveryRecord === undefined) {
|
|
1262
|
+
checks.push({
|
|
1263
|
+
name: 'product recovery workflow',
|
|
1264
|
+
// Existing installations predate #229. Keep the citation and existing product-change
|
|
1265
|
+
// lanes healthy while advertising the opt-in migration; a fresh add/update records the
|
|
1266
|
+
// recovery schema and is checked strictly below.
|
|
1267
|
+
ok: true,
|
|
1268
|
+
detail: `Product recovery workflow UNAVAILABLE: schema not installed (${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}). Run: prodshape integration update to enable it.`,
|
|
1269
|
+
});
|
|
1270
|
+
} else {
|
|
1271
|
+
const defects: string[] = [];
|
|
1272
|
+
for (const asset of recoveryAssets) {
|
|
1273
|
+
const relative = `${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`;
|
|
1274
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1275
|
+
const expected = recoveryRecord[relative];
|
|
1276
|
+
if (expected === undefined) defects.push(`unrecorded: ${relative}`);
|
|
1277
|
+
else if (!(await pathExists(absolute))) defects.push(`missing: ${relative}`);
|
|
1278
|
+
else {
|
|
1279
|
+
const actual = await readFile(absolute, 'utf8');
|
|
1280
|
+
if (actual !== asset.content && contentDigest(actual) !== expected) {
|
|
1281
|
+
defects.push(`hand-edited and preserved: ${relative}`);
|
|
1282
|
+
} else if (actual !== asset.content) defects.push(`outdated managed content: ${relative}`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
checks.push({
|
|
1286
|
+
name: 'product recovery workflow',
|
|
1287
|
+
ok: defects.length === 0,
|
|
1288
|
+
detail:
|
|
1289
|
+
defects.length === 0
|
|
1290
|
+
? `Product recovery workflow available: schema installed at ${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}.`
|
|
1291
|
+
: `Product-recovery schema files need attention. ${defects.join(' ')}`,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1167
1295
|
const ok = checks.every((c) => c.ok);
|
|
1168
1296
|
return { ok, checks };
|
|
1169
1297
|
}
|
|
@@ -1330,8 +1458,8 @@ export async function removeOpenSpecIntegration(
|
|
|
1330
1458
|
const recordedSchema = recordedProductSchemaFiles(previousMeta);
|
|
1331
1459
|
if (recordedSchema !== undefined) {
|
|
1332
1460
|
const assetContentByRelative = new Map(
|
|
1333
|
-
(await
|
|
1334
|
-
`${
|
|
1461
|
+
(await loadProductChangeSchemaAssets()).map((asset) => [
|
|
1462
|
+
`${OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE}/${asset.relative}`,
|
|
1335
1463
|
asset.content,
|
|
1336
1464
|
]),
|
|
1337
1465
|
);
|
|
@@ -1390,6 +1518,45 @@ export async function removeOpenSpecIntegration(
|
|
|
1390
1518
|
}
|
|
1391
1519
|
}
|
|
1392
1520
|
|
|
1521
|
+
const recordedRecovery = recordedRecoverySchemaFiles(previousMeta);
|
|
1522
|
+
if (recordedRecovery !== undefined) {
|
|
1523
|
+
const recoveryAssets = new Map(
|
|
1524
|
+
(await loadProductRecoverySchemaAssets()).map((asset) => [
|
|
1525
|
+
`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/${asset.relative}`,
|
|
1526
|
+
asset.content,
|
|
1527
|
+
]),
|
|
1528
|
+
);
|
|
1529
|
+
for (const relative of Object.keys(recordedRecovery).sort()) {
|
|
1530
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1531
|
+
if (!(await pathExists(absolute))) continue;
|
|
1532
|
+
const existing = await readFile(absolute, 'utf8');
|
|
1533
|
+
const recordedDigest = recordedRecovery[relative];
|
|
1534
|
+
const proven =
|
|
1535
|
+
existing === recoveryAssets.get(relative) ||
|
|
1536
|
+
(recordedDigest !== '' && contentDigest(existing) === recordedDigest);
|
|
1537
|
+
if (!proven) {
|
|
1538
|
+
preserved.push(relative);
|
|
1539
|
+
} else {
|
|
1540
|
+
if (!dryRun) await rm(absolute, { force: true });
|
|
1541
|
+
removed.push(relative);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (!dryRun) {
|
|
1545
|
+
for (const relative of [
|
|
1546
|
+
`${OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE}/templates`,
|
|
1547
|
+
OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE,
|
|
1548
|
+
'openspec/schemas',
|
|
1549
|
+
]) {
|
|
1550
|
+
const absolute = resolveInRepository(root, relative, 'the OpenSpec integration');
|
|
1551
|
+
try {
|
|
1552
|
+
if ((await readdir(absolute)).length === 0) await rmdir(absolute);
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (!isNotFound(error)) throw error;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1393
1560
|
// Remove metadata file.
|
|
1394
1561
|
if (await pathExists(metaPath(root))) {
|
|
1395
1562
|
if (!dryRun) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The OpenSpec `product` schema shipped by this integration.
|
|
2
|
+
* The OpenSpec `product-change` schema shipped by this integration.
|
|
3
3
|
*
|
|
4
4
|
* The schema is a set of static assets (schema.yaml, templates, bridge scripts) bundled with the
|
|
5
|
-
* package and installed into a consumer repository at `openspec/schemas/product/`. OpenSpec reads
|
|
5
|
+
* package and installed into a consumer repository at `openspec/schemas/product-change/`. OpenSpec reads
|
|
6
6
|
* project-local schemas from that directory natively; installing files there uses the framework's
|
|
7
7
|
* official extension surface, exactly as merging guidance into `openspec/config.yaml` does.
|
|
8
8
|
*
|
|
@@ -14,10 +14,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
|
|
16
16
|
/** The OpenSpec schema name (the directory key OpenSpec resolves). */
|
|
17
|
-
export const
|
|
17
|
+
export const OPENSPEC_PRODUCT_CHANGE_SCHEMA_NAME = 'product-change';
|
|
18
18
|
|
|
19
19
|
/** The repository-relative directory the product schema is installed into. */
|
|
20
|
-
export const
|
|
20
|
+
export const OPENSPEC_PRODUCT_CHANGE_SCHEMA_RELATIVE = 'openspec/schemas/product-change';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* The minimum OpenSpec CLI version the product workflow needs. Project-local schemas are far
|
|
@@ -28,17 +28,21 @@ export const OPENSPEC_PRODUCT_SCHEMA_RELATIVE = 'openspec/schemas/product';
|
|
|
28
28
|
* files are inert data: the citation lane keeps working and the product workflow is reported
|
|
29
29
|
* unavailable, never broken.
|
|
30
30
|
*/
|
|
31
|
-
export const
|
|
31
|
+
export const PRODUCT_CHANGE_SCHEMA_MIN_OPENSPEC = '1.7.0';
|
|
32
|
+
|
|
33
|
+
/** The separate hosted brownfield recovery workload. */
|
|
34
|
+
export const OPENSPEC_PRODUCT_RECOVERY_SCHEMA_NAME = 'product-recovery';
|
|
35
|
+
export const OPENSPEC_PRODUCT_RECOVERY_SCHEMA_RELATIVE = 'openspec/schemas/product-recovery';
|
|
32
36
|
|
|
33
37
|
/** One bundled product-schema file: its path relative to the installed schema directory. */
|
|
34
|
-
export interface
|
|
38
|
+
export interface ProductChangeSchemaAsset {
|
|
35
39
|
relative: string;
|
|
36
40
|
content: string;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/** The bundled asset directory, resolved from this module (both src/ and dist/ sit one level below the package root). */
|
|
40
44
|
function assetsDir(): string {
|
|
41
|
-
return fileURLToPath(new URL('../assets/openspec-product-schema/', import.meta.url));
|
|
45
|
+
return fileURLToPath(new URL('../assets/openspec-product-change-schema/', import.meta.url));
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
async function collectFiles(dir: string, prefix: string): Promise<string[]> {
|
|
@@ -59,10 +63,10 @@ async function collectFiles(dir: string, prefix: string): Promise<string[]> {
|
|
|
59
63
|
* Load the bundled product-schema assets, sorted by relative path so installation, metadata and
|
|
60
64
|
* comparison all see one deterministic order.
|
|
61
65
|
*/
|
|
62
|
-
export async function
|
|
66
|
+
export async function loadProductChangeSchemaAssets(): Promise<ProductChangeSchemaAsset[]> {
|
|
63
67
|
const dir = assetsDir();
|
|
64
68
|
const relatives = await collectFiles(dir, '');
|
|
65
|
-
const assets:
|
|
69
|
+
const assets: ProductChangeSchemaAsset[] = [];
|
|
66
70
|
for (const relative of relatives.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
67
71
|
assets.push({
|
|
68
72
|
relative,
|
|
@@ -71,3 +75,19 @@ export async function loadProductSchemaAssets(): Promise<ProductSchemaAsset[]> {
|
|
|
71
75
|
}
|
|
72
76
|
return assets;
|
|
73
77
|
}
|
|
78
|
+
|
|
79
|
+
/** Load the bundled recovery schema assets using the same deterministic asset contract. */
|
|
80
|
+
export async function loadProductRecoverySchemaAssets(): Promise<ProductChangeSchemaAsset[]> {
|
|
81
|
+
const dir = fileURLToPath(
|
|
82
|
+
new URL('../assets/openspec-product-recovery-schema/', import.meta.url),
|
|
83
|
+
);
|
|
84
|
+
const relatives = await collectFiles(dir, '');
|
|
85
|
+
return Promise.all(
|
|
86
|
+
relatives
|
|
87
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
88
|
+
.map(async (relative) => ({
|
|
89
|
+
relative,
|
|
90
|
+
content: await readFile(join(dir, ...relative.split('/')), 'utf8'),
|
|
91
|
+
})),
|
|
92
|
+
);
|
|
93
|
+
}
|