create-yss-spec 3.5.4 → 3.5.6
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 +10 -0
- package/cli-core.lock.json +130 -0
- package/package.json +7 -3
- package/resources/upgrade-skill/SKILL.md +26 -0
- package/resources/upgrade-skill/protocol.md +40 -0
- package/resources/upgrade-skill/references/cli-families.md +14 -0
- package/src/cli/help.js +1 -0
- package/src/cli/index.js +12 -0
- package/src/filesystem/transaction-runner.js +27 -0
- package/src/template/asset-runtime.js +9 -1
- package/src/template/instance-runtime.js +4 -2
- package/src/template/verification-runtime.js +9 -1
- package/template/.agents/skills/.strategic-design-skills-manifest.json +2 -2
- package/template/.agents/skills/yss-harness-upgrade/SKILL.md +26 -0
- package/template/.agents/skills/yss-harness-upgrade/references/cli-families.md +14 -0
- package/template/.agents/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
- package/template/.agents/skills/yss-product-lifecycle/SKILL.md +15 -19
- package/template/.agents/skills/yss-stage-decision/SKILL.md +3 -0
- package/template/.agents/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
- package/template/.agents/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
- package/template/.agents/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
- package/template/.codex/skills/yss-harness-upgrade/SKILL.md +26 -0
- package/template/.codex/skills/yss-harness-upgrade/references/cli-families.md +14 -0
- package/template/.codex/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
- package/template/.codex/skills/yss-product-lifecycle/SKILL.md +15 -19
- package/template/.codex/skills/yss-stage-decision/SKILL.md +3 -0
- package/template/.codex/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
- package/template/.codex/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
- package/template/.codex/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
- package/template/.cursor/skills/yss-harness-upgrade/SKILL.md +26 -0
- package/template/.cursor/skills/yss-harness-upgrade/references/cli-families.md +14 -0
- package/template/.cursor/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
- package/template/.cursor/skills/yss-product-lifecycle/SKILL.md +15 -19
- package/template/.cursor/skills/yss-stage-decision/SKILL.md +3 -0
- package/template/.cursor/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
- package/template/.cursor/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
- package/template/.cursor/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
- package/template/.pi/skills/yss-harness-upgrade/SKILL.md +26 -0
- package/template/.pi/skills/yss-harness-upgrade/references/cli-families.md +14 -0
- package/template/.pi/skills/yss-implementation-contract-compiler/references/boundaries.md +1 -0
- package/template/.pi/skills/yss-product-lifecycle/SKILL.md +15 -19
- package/template/.pi/skills/yss-stage-decision/SKILL.md +3 -0
- package/template/.pi/skills/yss-stage-decision/scripts/validate-domain-strategy.mjs +8 -4
- package/template/.pi/skills/yss-stage-decision/scripts/validate-stage-decision-package.mjs +8 -4
- package/template/.pi/skills/yss-stage-decision/tests/run-scenarios.mjs +13 -1
- package/template/.template-spec/agents/yss-skill-registry.yaml +9 -1
- package/template/.template-spec/process/contract-reading.md +1 -1
- package/template/.template-spec/process/document-writing.md +12 -0
- package/template/.template-spec/process/harness-upgrade.md +40 -0
- package/template/.template-spec/process/lifecycle-artifact-map.md +2 -2
- package/template/.template-spec/process/lifecycle-registry.yaml +3 -1
- package/template/.template-spec/process/schemas/approval-record.schema.json +23 -0
- package/template/.template-spec/process/stage-tracking.md +2 -2
- package/template/.template-spec/process/structured-assets.md +35 -0
- package/template/.template-spec/process/templates/lifecycle-checkpoint-template.json +48 -0
- package/template/.template-spec/templates/approval-record-template.json +23 -0
- package/template/scripts/check-plan-stage-coverage +12 -0
- package/template/scripts/contract +6 -0
- package/template/scripts/lib/approval-record.mjs +4 -3
- package/template/scripts/lib/asset-command.mjs +35 -0
- package/template/scripts/lib/asset-migration.mjs +101 -0
- package/template/scripts/lib/asset-transactions.mjs +235 -0
- package/template/scripts/lib/checkpoint-boundary.mjs +3 -0
- package/template/scripts/lib/contract-views.mjs +7 -1
- package/template/scripts/lib/json-schema.mjs +43 -4
- package/template/scripts/lib/lifecycle-context-query.mjs +7 -0
- package/template/scripts/lib/lifecycle-presentation.mjs +77 -0
- package/template/scripts/lib/lifecycle-status.mjs +16 -1
- package/template/scripts/lib/plan-stage-coverage.mjs +40 -0
- package/template/scripts/lib/reading-view-bundle.mjs +1 -0
- package/template/scripts/lib/reading-view-markdown.mjs +8 -6
- package/template/scripts/lib/stage-tracking-migration.mjs +11 -3
- package/template/scripts/lib/structured-assets.mjs +81 -0
- package/template/scripts/lib/task-package.mjs +4 -1
- package/template/scripts/lifecycle-status +6 -3
- package/template/scripts/verify-lifecycle-checkpoint +8 -5
- package/template/skills-lock.json +21 -9
- package/template.manifest.json +2 -1
- package/template.snapshot.json +5 -5
- package/vendor/cli-core/build.mjs +302 -0
- package/vendor/cli-core/bundle.mjs +198 -0
- package/vendor/cli-core/cli.mjs +182 -0
- package/vendor/cli-core/command-runner.mjs +107 -0
- package/vendor/cli-core/engine.mjs +239 -0
- package/vendor/cli-core/family.mjs +28 -0
- package/vendor/cli-core/gitignore.mjs +15 -0
- package/vendor/cli-core/identity.mjs +274 -0
- package/vendor/cli-core/io.mjs +153 -0
- package/vendor/cli-core/layout-migration.mjs +64 -0
- package/vendor/cli-core/legacy.mjs +19 -0
- package/vendor/cli-core/migrate.mjs +237 -0
- package/vendor/cli-core/migration-adapter.mjs +19 -0
- package/vendor/cli-core/package.json +13 -0
- package/vendor/cli-core/scaffold.mjs +117 -0
- package/vendor/cli-core/spec-migration-adapter.mjs +83 -0
- package/vendor/cli-core/tests/build.test.mjs +40 -0
- package/vendor/cli-core/tests/cli.test.mjs +1065 -0
- package/vendor/cli-core/tests/migration-version.test.mjs +8 -0
- package/vendor/cli-core/tests/package-contract.mjs +89 -0
- package/vendor/cli-core/transaction.mjs +529 -0
- package/vendor/cli-core/update.mjs +79 -0
- package/vendor/cli-core/vendor/NOTICES.md +6 -0
- package/vendor/cli-core/vendor/yaml.LICENSE +13 -0
- package/vendor/cli-core/vendor/yaml.mjs +6311 -0
- package/vendor/cli-core/verification.mjs +131 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { readAsset, parseAsset, serializeAsset } from './structured-assets.mjs';
|
|
5
|
+
import { safeFile } from './governance-io.mjs';
|
|
6
|
+
import { planAssetWrite, applyAssetWrite, recoverAssetWrite, validateAssetSemantics, assertAssetTransactionIdle, assertCurrentAssetReference } from './asset-transactions.mjs';
|
|
7
|
+
import { planAssetMigration, applyAssetMigration } from './asset-migration.mjs';
|
|
8
|
+
|
|
9
|
+
export const isAssetCommand = command => ['validate','plan-write','apply-write','plan-migrate','apply-migrate','recover'].includes(command);
|
|
10
|
+
export function runAssetCommand(args) {
|
|
11
|
+
const command = args.shift();
|
|
12
|
+
const {values, positionals} = parseArgs({ args, allowPositionals: true, strict: true, options: { root:{type:'string'},kind:{type:'string'},input:{type:'string'},output:{type:'string'},resolutions:{type:'string'},'structure-only':{type:'boolean'} } });
|
|
13
|
+
const root = path.resolve(values.root || process.cwd());
|
|
14
|
+
const input = file => parseAsset(fs.readFileSync(path.resolve(file)), file);
|
|
15
|
+
let result;
|
|
16
|
+
if (command === 'recover') { if (positionals.length) throw new Error('recover takes --root only'); result = recoverAssetWrite(root); }
|
|
17
|
+
else {
|
|
18
|
+
if (positionals.length !== 1) throw new Error(`${command} requires one asset/plan path`);
|
|
19
|
+
const ref = positionals[0];
|
|
20
|
+
if (command === 'validate') {
|
|
21
|
+
assertAssetTransactionIdle(root);
|
|
22
|
+
assertCurrentAssetReference(root,ref);
|
|
23
|
+
const record = readAsset(safeFile(root, ref), values.kind, {schemaRoot:root});
|
|
24
|
+
result = {status:'valid',kind:values.kind,ref,digest:record.digest,checks:['schema'],execution_authorization:'not-granted'};
|
|
25
|
+
if (!values['structure-only']) {result.verification=validateAssetSemantics(root,ref,values.kind);result.checks.push('consumer');}
|
|
26
|
+
} else if(command === 'plan-write') {
|
|
27
|
+
if (!values.input) throw new Error('plan-write <target.json> --kind <kind> --input <candidate.json>');
|
|
28
|
+
result = planAssetWrite(root,[{ref,kind:values.kind,value:input(values.input)}],{schemaRoot:root});
|
|
29
|
+
} else if(command === 'apply-write') result = applyAssetWrite(root,input(ref));
|
|
30
|
+
else if(command === 'plan-migrate') result = planAssetMigration(root,ref,{schemaRoot:root});
|
|
31
|
+
else result = applyAssetMigration(root,input(ref),values.resolutions?input(values.resolutions):undefined,{schemaRoot:root});
|
|
32
|
+
}
|
|
33
|
+
if(values.output) fs.writeFileSync(path.resolve(values.output),serializeAsset(result),{flag:'wx'});
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { safeFile } from './governance-io.mjs';
|
|
4
|
+
import { parseAsset, readAsset, serializeAsset, byteDigest, semanticDigest, assetComments, TOOL_ROOT } from './structured-assets.mjs';
|
|
5
|
+
import { planAssetWrite, applyAssetWrite, assertAssetTransactionIdle } from './asset-transactions.mjs';
|
|
6
|
+
|
|
7
|
+
export function identifyAsset(value) {
|
|
8
|
+
if (value.stage_decision_id) return 'stage-decision-package';
|
|
9
|
+
if (value.domain_strategy_id) return 'domain-strategy';
|
|
10
|
+
if (value.task_id && value.contract && value.execution_state) return 'task-package';
|
|
11
|
+
if (value.gate_id && value.decision || value.kind === 'review-bundle') return 'approval-record';
|
|
12
|
+
if (value.repository_mode && value.stage && value.gates) return 'checkpoint';
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const immutable = key => ['verification', 'result', 'source', 'captures', 'history', 'git_checkpoint'].includes(key);
|
|
16
|
+
function refs(value, out = new Set(), key = '') {
|
|
17
|
+
if (immutable(key)) return out;
|
|
18
|
+
if (typeof value === 'string' && /(?:^ref$|_ref$|_refs$|^inputs$)/.test(key) && /\.(yaml|json)$/.test(value)) out.add(value);
|
|
19
|
+
else if (Array.isArray(value)) value.forEach(v => refs(v, out, key));
|
|
20
|
+
else if (value && typeof value === 'object') for (const [name, child] of Object.entries(value)) refs(child, out, name);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function replaceRefs(value, mapping, key = '') {
|
|
24
|
+
if (immutable(key)) return structuredClone(value);
|
|
25
|
+
if (typeof value === 'string' && /(?:^ref$|_ref$|_refs$|^inputs$)/.test(key)) return mapping[value] || value;
|
|
26
|
+
if (Array.isArray(value)) return value.map(v => replaceRefs(v, mapping, key));
|
|
27
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, child]) => [name, replaceRefs(child, mapping, name)]));
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Produces reviewable candidates, never approval or a second live checkpoint. */
|
|
32
|
+
export function planAssetMigration(root, checkpointRef, { schemaRoot = TOOL_ROOT } = {}) {
|
|
33
|
+
root = fs.realpathSync(root); assertAssetTransactionIdle(root);
|
|
34
|
+
const queue = [checkpointRef], sources = {}, candidates = [], blockers = [], currentJson = [];
|
|
35
|
+
while (queue.length) {
|
|
36
|
+
const ref = queue.shift(); if (Object.hasOwn(sources, ref)) continue;
|
|
37
|
+
const file = safeFile(root, ref);
|
|
38
|
+
if (!fs.existsSync(file)) { blockers.push({ code: 'missing-reference', ref }); continue; }
|
|
39
|
+
const bytes = fs.readFileSync(file), value = parseAsset(bytes, ref), kind = identifyAsset(value);
|
|
40
|
+
if (!kind) continue; // Original user messages and other evidence keep their format and bytes.
|
|
41
|
+
sources[ref] = byteDigest(bytes);
|
|
42
|
+
// Completed execution packages and old reviews remain immutable evidence.
|
|
43
|
+
if (kind === 'task-package' && ['resolved', 'failed'].includes(value.workflow_status)) continue;
|
|
44
|
+
readAsset(file, kind, { schemaRoot });
|
|
45
|
+
queue.push(...refs(value));
|
|
46
|
+
if (ref.endsWith('.json')) { currentJson.push({ref,value,kind,bytes}); continue; }
|
|
47
|
+
const target = ref.replace(/\.yaml$/, '.json');
|
|
48
|
+
if (fs.existsSync(safeFile(root, target))) { blockers.push({ code: 'target-exists', ref: target }); continue; }
|
|
49
|
+
const comments = assetComments(bytes);
|
|
50
|
+
if (comments.length) blockers.push({ code: 'comment-review-required', ref, comments });
|
|
51
|
+
candidates.push({ source_ref: ref, ref: target, kind, source_digest: byteDigest(bytes), source_semantic_digest: semanticDigest(value), value });
|
|
52
|
+
}
|
|
53
|
+
const mapping = Object.fromEntries(candidates.map(c => [c.source_ref, c.ref]));
|
|
54
|
+
for (const item of currentJson) if (semanticDigest(item.value) !== semanticDigest(replaceRefs(item.value, mapping))) {
|
|
55
|
+
if (item.kind === 'approval-record') blockers.push({code:'current-json-reference-rebind-required',ref:item.ref,reason:'Issue a new approval record; an existing approval is immutable.'});
|
|
56
|
+
else candidates.push({source_ref:item.ref,ref:item.ref,kind:item.kind,source_digest:byteDigest(item.bytes),source_semantic_digest:semanticDigest(item.value),value:item.value});
|
|
57
|
+
}
|
|
58
|
+
for (const c of candidates) {
|
|
59
|
+
c.value = replaceRefs(c.value, mapping);
|
|
60
|
+
if (c.kind === 'approval-record' || ['domain-strategy', 'stage-decision-package'].includes(c.kind) && c.value.status === 'approved') {
|
|
61
|
+
blockers.push({ code: 'approval-rebind-required', ref: c.ref, reason: 'Candidate bytes do not inherit the source approval; issue independent current evidence.' });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Updating digests is mechanical only for direct current bindings, not historical proof.
|
|
65
|
+
const byTarget = new Map(candidates.map(c => [c.ref, c]));
|
|
66
|
+
const ordered = candidates.sort((a,b) => ['domain-strategy','approval-record','stage-decision-package','task-package','checkpoint'].indexOf(a.kind) - ['domain-strategy','approval-record','stage-decision-package','task-package','checkpoint'].indexOf(b.kind));
|
|
67
|
+
function bind(value, key = '') {
|
|
68
|
+
if (!value || typeof value !== 'object' || immutable(key)) return;
|
|
69
|
+
if (value.domain_strategy_ref && byTarget.has(value.domain_strategy_ref.persisted_ref)) value.domain_strategy_ref.digest = semanticDigest(byTarget.get(value.domain_strategy_ref.persisted_ref).value);
|
|
70
|
+
if (typeof value.ref === 'string' && typeof value.digest === 'string' && byTarget.has(value.ref)) value.digest = byteDigest(serializeAsset(byTarget.get(value.ref).value));
|
|
71
|
+
for (const [name, child] of Object.entries(value)) bind(child, name);
|
|
72
|
+
}
|
|
73
|
+
for (const c of ordered) bind(c.value);
|
|
74
|
+
const payload = { schema_version: 1, kind: 'asset-migration-plan', root, checkpoint_ref: checkpointRef, sources, candidates: ordered, blockers, history_policy: 'preserve-original-bytes', execution_authorization: 'not-granted' };
|
|
75
|
+
return { ...payload, plan_id: byteDigest(serializeAsset(payload)).slice(7) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolutions carry reviewed candidate objects and evidence; they do not bypass semantic validators. */
|
|
79
|
+
export function prepareAssetMigrationWrite(root, plan, resolutions, options = {}) {
|
|
80
|
+
const { plan_id, ...payload } = plan;
|
|
81
|
+
if (fs.realpathSync(root) !== plan.root || plan.kind !== 'asset-migration-plan' || plan_id !== byteDigest(serializeAsset(payload)).slice(7)) throw new Error('ASSET_MIGRATION_PLAN_INVALID');
|
|
82
|
+
for (const [ref, digest] of Object.entries(plan.sources)) if (byteDigest(fs.readFileSync(safeFile(root, ref))) !== digest) throw new Error(`ASSET_MIGRATION_DRIFT: ${ref}`);
|
|
83
|
+
if (plan.blockers.some(b => ['missing-reference', 'target-exists', 'current-json-reference-rebind-required'].includes(b.code))) throw new Error('ASSET_MIGRATION_BLOCKED: rebuild the plan after resolving missing/conflicting inputs');
|
|
84
|
+
if (plan.blockers.length && (!resolutions || resolutions.plan_id !== plan_id)) throw new Error('ASSET_MIGRATION_REVIEW_REQUIRED');
|
|
85
|
+
for (const b of plan.blockers) {
|
|
86
|
+
const resolution = resolutions.items?.find(r => r.ref === b.ref && r.code === b.code);
|
|
87
|
+
if (!resolution?.evidence_ref || !resolution.evidence_digest || byteDigest(fs.readFileSync(safeFile(root, resolution.evidence_ref))) !== resolution.evidence_digest) throw new Error(`ASSET_MIGRATION_EVIDENCE_REQUIRED: ${b.ref}`);
|
|
88
|
+
}
|
|
89
|
+
const replacements = resolutions?.candidates || [];
|
|
90
|
+
for (const replacement of replacements) if (!plan.candidates.some(c => c.ref === replacement.ref && c.kind === replacement.kind)) throw new Error('ASSET_MIGRATION_SCOPE_CHANGED');
|
|
91
|
+
const specs = plan.candidates.map(c => replacements.find(r => r.ref === c.ref) || c);
|
|
92
|
+
const migration={plan_id:plan.plan_id,sources:plan.sources,source_to_target:Object.fromEntries(plan.candidates.filter(c=>c.source_ref!==c.ref).map(c=>[c.source_ref,c.ref]))};
|
|
93
|
+
return planAssetWrite(root, specs, {...options,migration});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function applyAssetMigration(root, plan, resolutions, options = {}) {
|
|
97
|
+
const writePlan = prepareAssetMigrationWrite(root, plan, resolutions, options);
|
|
98
|
+
const result = applyAssetWrite(root, writePlan, options);
|
|
99
|
+
const updated = new Set(writePlan.changes.filter(c => Object.hasOwn(plan.sources,c.ref)).map(c => c.ref));
|
|
100
|
+
return { ...result, migration_plan_id: plan.plan_id, preserved_sources: Object.keys(plan.sources).filter(ref => !updated.has(ref)), rebound_json_sources: [...updated] };
|
|
101
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { safeFile } from './governance-io.mjs';
|
|
7
|
+
import { parseAsset, readAsset, serializeAsset, byteDigest, semanticDigest, validateAssetStructure, TOOL_ROOT } from './structured-assets.mjs';
|
|
8
|
+
|
|
9
|
+
const TX = '.yss/asset-transactions';
|
|
10
|
+
const ensure = (ok, message) => { if (!ok) throw new Error(message); };
|
|
11
|
+
const exists = file => fs.existsSync(file);
|
|
12
|
+
const descriptor = file => exists(file) ? { digest: byteDigest(fs.readFileSync(file)), mode: fs.statSync(file).mode & 0o777 } : null;
|
|
13
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
14
|
+
|
|
15
|
+
/** Adapter for an existing validated writer which also owns Markdown/tracker updates. */
|
|
16
|
+
export function runAssetTransaction(root, changes, action) {
|
|
17
|
+
root=fs.realpathSync(root);assertAssetTransactionIdle(root);
|
|
18
|
+
const directory=safeFile(root,TX);fs.mkdirSync(directory,{recursive:true});
|
|
19
|
+
const lock=safeFile(root,`${TX}/lock`),fd=fs.openSync(lock,'wx');fs.writeFileSync(fd,String(process.pid));fs.fsyncSync(fd);
|
|
20
|
+
const plan_id=byteDigest(serializeAsset({changes,nonce:randomUUID()})).slice(7),journal_ref=`${TX}/${plan_id}.json`,active=safeFile(root,`${TX}/active.json`);
|
|
21
|
+
const tx={schema_version:1,root,plan_id,status:'applying',changes,backups:{}};
|
|
22
|
+
try {
|
|
23
|
+
ensure(!exists(safeFile(root, `${TX}/recovery.lock`)), 'ASSET_RECOVERY_LOCKED');
|
|
24
|
+
for(const change of changes){ensure(same(descriptor(safeFile(root,change.ref)),change.before),`ASSET_INPUT_DRIFT: ${change.ref}`);tx.backups[change.ref]=change.before?fs.readFileSync(safeFile(root,change.ref)).toString('base64'):null;}
|
|
25
|
+
atomic(safeFile(root,journal_ref),serializeAsset(tx));atomic(active,serializeAsset({journal_ref}));
|
|
26
|
+
const result=action();
|
|
27
|
+
for(const change of changes)ensure(descriptor(safeFile(root,change.ref))?.digest===change.after_digest,`ASSET_POSTWRITE_DRIFT: ${change.ref}`);
|
|
28
|
+
tx.status='applied';atomic(safeFile(root,journal_ref),serializeAsset(tx));fs.unlinkSync(active);return result;
|
|
29
|
+
} catch(error) {
|
|
30
|
+
if(exists(active)){const recovered=recoverAssetWrite(root,{lockHeld:true});throw new Error(`${error.message}; ${recovered.status}`);}
|
|
31
|
+
throw error;
|
|
32
|
+
} finally {fs.closeSync(fd);if(exists(lock))fs.unlinkSync(lock);}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertAssetTransactionIdle(root) {
|
|
36
|
+
ensure(!exists(safeFile(root, `${TX}/active.json`)), 'ASSET_TRANSACTION_PENDING: recover the unfinished transaction before lifecycle transition');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function assertCurrentAssetReference(root, ref) {
|
|
40
|
+
const directory = safeFile(root, TX);
|
|
41
|
+
if (!exists(directory)) return;
|
|
42
|
+
for (const name of fs.readdirSync(directory)) {
|
|
43
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
|
|
44
|
+
const receipt = parseAsset(fs.readFileSync(path.join(directory, name)), name);
|
|
45
|
+
if (receipt.status === 'applied' && receipt.migration?.source_to_target?.[ref]) throw new Error(`ASSET_HISTORICAL_ONLY: ${ref}; current=${receipt.migration.source_to_target[ref]}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The observed closure includes governance, tools and project evidence, but not external checkouts/caches.
|
|
50
|
+
export function observeAssetInputs(root) {
|
|
51
|
+
const rows = {};
|
|
52
|
+
function walk(ref) {
|
|
53
|
+
const file = safeFile(root, ref);
|
|
54
|
+
if (!exists(file)) return;
|
|
55
|
+
const st = fs.lstatSync(file);
|
|
56
|
+
ensure(!st.isSymbolicLink(), `ASSET_SYMLINK: ${ref}`);
|
|
57
|
+
if (st.isDirectory()) for (const name of fs.readdirSync(file).sort()) {
|
|
58
|
+
if (['node_modules', '.git', '.codegraph', '__pycache__'].includes(name)) continue;
|
|
59
|
+
walk(`${ref}/${name}`);
|
|
60
|
+
}
|
|
61
|
+
else if (st.isFile()) rows[ref] = descriptor(file);
|
|
62
|
+
}
|
|
63
|
+
for (const ref of ['yss-project.yaml', 'CONTEXT.md', 'DESIGN.md', 'AGENTS.md', 'skills-lock.json', '.template-spec', '.agents', 'scripts', 'docs']) walk(ref);
|
|
64
|
+
return rows;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateAssetSemantics(root, ref, kind) {
|
|
68
|
+
const file = safeFile(root, ref), { value } = readAsset(file, kind, { schemaRoot: root });
|
|
69
|
+
let tool, args;
|
|
70
|
+
if (kind === 'domain-strategy' || kind === 'stage-decision-package') {
|
|
71
|
+
tool = `.agents/skills/yss-stage-decision/scripts/validate-${kind}.mjs`;
|
|
72
|
+
args = [file, '--root', root];
|
|
73
|
+
} else {
|
|
74
|
+
tool = { checkpoint: 'scripts/verify-lifecycle-checkpoint', 'task-package': 'scripts/verify-digital-human-task-package', 'approval-record': 'scripts/verify-approval-record' }[kind];
|
|
75
|
+
args = kind === 'approval-record' ? [value.decision === 'approved' || value.kind === 'review-bundle' ? '--require-approved' : '--history', file] : [file];
|
|
76
|
+
}
|
|
77
|
+
ensure(exists(safeFile(root, tool)), `ASSET_CONSUMER_MISSING: ${tool}`);
|
|
78
|
+
const result = spawnSync(process.execPath, [path.join(root, tool), ...args], { cwd: root, encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
|
|
79
|
+
ensure(!result.error && result.status === 0, `ASSET_SEMANTIC: ${ref}: ${result.error?.message || result.stderr || result.stdout}`);
|
|
80
|
+
return { ref, kind, command: [tool, ...args.map(x => x.replaceAll(root, '.'))], exit_code: result.status, output: result.stdout.trim() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function approvalBindings(root, changes) {
|
|
84
|
+
for (const change of changes) {
|
|
85
|
+
const value = parseAsset(change.after, change.ref);
|
|
86
|
+
// Writers never turn a bare status into an approval. Bound, independently issued evidence is mandatory.
|
|
87
|
+
const records = change.kind === 'approval-record' ? value.kind === 'review-bundle' ? value.reviews : [value] : [];
|
|
88
|
+
for (const row of records) if (row.decision === 'approved') {
|
|
89
|
+
ensure(row.subject_ref && row.subject_digest && row.drafter_principal_ref && row.drafter_principal_ref !== row.principal_ref, 'ASSET_APPROVAL_REBIND_REQUIRED: subject and independent reviewer required');
|
|
90
|
+
ensure(byteDigest(fs.readFileSync(safeFile(root, row.subject_ref))).replace('sha256:', '') === row.subject_digest.replace('sha256:', ''), 'ASSET_APPROVAL_STALE');
|
|
91
|
+
}
|
|
92
|
+
if (['domain-strategy', 'stage-decision-package'].includes(change.kind) && value.status === 'approved') {
|
|
93
|
+
const approval = parseAsset(fs.readFileSync(safeFile(root, value.approval.approval_ref)), value.approval.approval_ref);
|
|
94
|
+
const check = change.kind === 'domain-strategy' ? 'check.domain-strategy-approved' : 'check.stage-decision-package-approved';
|
|
95
|
+
const row = approval.kind === 'review-bundle' ? approval.reviews.find(x => x.gate_id === check) : approval;
|
|
96
|
+
ensure(row?.subject_ref === change.ref && row.subject_digest?.replace('sha256:', '') === byteDigest(change.after).replace('sha256:', ''), `ASSET_APPROVAL_REBIND_REQUIRED: ${change.ref}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function overlayVerification(root, observed, changes, { verify = validateAssetSemantics } = {}) {
|
|
102
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'yss-asset-check-'));
|
|
103
|
+
try {
|
|
104
|
+
for (const ref of Object.keys(observed)) {
|
|
105
|
+
const target = safeFile(staging, ref); fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
106
|
+
fs.copyFileSync(safeFile(root, ref), target); fs.chmodSync(target, observed[ref].mode);
|
|
107
|
+
}
|
|
108
|
+
for (const change of changes) {
|
|
109
|
+
const target = safeFile(staging, change.ref); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, change.after);
|
|
110
|
+
}
|
|
111
|
+
approvalBindings(staging, changes);
|
|
112
|
+
return changes.map(change => verify(staging, change.ref, change.kind));
|
|
113
|
+
} finally { fs.rmSync(staging, { recursive: true, force: true }); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function planAssetWrite(root, specs, { schemaRoot = TOOL_ROOT, verify, migration } = {}) {
|
|
117
|
+
root = fs.realpathSync(root); assertAssetTransactionIdle(root);
|
|
118
|
+
ensure(Array.isArray(specs) && specs.length, 'ASSET_EMPTY_PLAN');
|
|
119
|
+
const observed = observeAssetInputs(root), seen = new Set();
|
|
120
|
+
const changes = specs.map(({ ref, kind, value }) => {
|
|
121
|
+
ensure(!seen.has(ref), `ASSET_DUPLICATE_TARGET: ${ref}`); seen.add(ref);
|
|
122
|
+
ensure(ref.endsWith('.json') && /^(docs\/|\.yss\/)/.test(ref) && !ref.startsWith(`${TX}/`), 'ASSET_WRITE_SCOPE: project JSON assets only');
|
|
123
|
+
const file = safeFile(root, ref), before = descriptor(file);
|
|
124
|
+
const legacy = ref.replace(/\.json$/, '.yaml');
|
|
125
|
+
if (!before && exists(safeFile(root, legacy))) ensure(migration?.source_to_target?.[legacy] === ref, 'ASSET_AMBIGUOUS_AUTHORITY: use plan-migrate');
|
|
126
|
+
if (kind === 'approval-record' && before) throw new Error('ASSET_IMMUTABLE_APPROVAL: issue a new record');
|
|
127
|
+
validateAssetStructure(value, kind, { schemaRoot });
|
|
128
|
+
const after = serializeAsset(value); parseAsset(after, ref);
|
|
129
|
+
return { ref, kind, before, after, after_digest: byteDigest(after), semantic_digest: semanticDigest(value) };
|
|
130
|
+
}).filter(change => change.before?.digest !== change.after_digest);
|
|
131
|
+
for (const change of changes) if (!Object.hasOwn(observed, change.ref)) observed[change.ref] = change.before;
|
|
132
|
+
const verification = changes.length ? overlayVerification(root, Object.fromEntries(Object.entries(observed).filter(([,v]) => v)), changes, { verify }) : [];
|
|
133
|
+
const payload = { schema_version: 1, kind: 'asset-write-plan', root, observed, changes, verification, ...(migration ? {migration} : {}), execution_authorization: 'not-granted' };
|
|
134
|
+
return { ...payload, plan_id: byteDigest(serializeAsset(payload)).slice(7) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function atomic(file, bytes, mode = 0o600) {
|
|
138
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
139
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
140
|
+
const fd = fs.openSync(temp, 'wx', mode);
|
|
141
|
+
try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
142
|
+
try { ensure(fs.readFileSync(temp).equals(Buffer.from(bytes)), 'ASSET_TEMP_READBACK'); fs.renameSync(temp, file); const dir = fs.openSync(path.dirname(file), 'r'); try { fs.fsyncSync(dir); } finally { fs.closeSync(dir); } }
|
|
143
|
+
finally { if (exists(temp)) fs.unlinkSync(temp); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function applyAssetWrite(root, plan, { verify, afterWrite } = {}) {
|
|
147
|
+
root = fs.realpathSync(root); assertAssetTransactionIdle(root);
|
|
148
|
+
const { plan_id, ...payload } = plan;
|
|
149
|
+
ensure(plan.root === root && plan.kind === 'asset-write-plan' && plan_id === byteDigest(serializeAsset(payload)).slice(7), 'ASSET_PLAN_INVALID');
|
|
150
|
+
const directory = safeFile(root, TX); fs.mkdirSync(directory, { recursive: true });
|
|
151
|
+
const lock = safeFile(root, `${TX}/lock`), fd = fs.openSync(lock, 'wx');
|
|
152
|
+
fs.writeFileSync(fd, String(process.pid));fs.fsyncSync(fd);
|
|
153
|
+
const active = safeFile(root, `${TX}/active.json`);
|
|
154
|
+
let transaction;
|
|
155
|
+
try {
|
|
156
|
+
ensure(!exists(safeFile(root, `${TX}/recovery.lock`)), 'ASSET_RECOVERY_LOCKED');
|
|
157
|
+
const current = observeAssetInputs(root);
|
|
158
|
+
for (const [ref, before] of Object.entries(plan.observed)) ensure(same(descriptor(safeFile(root, ref)), before), `ASSET_INPUT_DRIFT: ${ref}`);
|
|
159
|
+
ensure(Object.keys(current).every(ref => Object.hasOwn(plan.observed, ref)), 'ASSET_INPUT_DRIFT: new dependency files');
|
|
160
|
+
if (!plan.changes.length) return { status: 'unchanged', plan_id, changed_refs: [], execution_authorization: 'not-granted' };
|
|
161
|
+
// Recompute, including schemas and semantics. A plan's recorded successful exit is never trusted.
|
|
162
|
+
if(plan.migration) for(const [ref,digest] of Object.entries(plan.migration.sources)) ensure(byteDigest(fs.readFileSync(safeFile(root,ref)))===digest,`ASSET_MIGRATION_DRIFT: ${ref}`);
|
|
163
|
+
const fresh = planAssetWrite(root, plan.changes.map(c => ({ ref: c.ref, kind: c.kind, value: parseAsset(c.after, c.ref) })), { schemaRoot: root, verify, migration:plan.migration });
|
|
164
|
+
ensure(same(fresh.changes, plan.changes), 'ASSET_PLAN_CHANGED');
|
|
165
|
+
const journalRef = `${TX}/${plan_id}.json`, journalFile = safeFile(root, journalRef);
|
|
166
|
+
ensure(!exists(journalFile), 'ASSET_ALREADY_APPLIED: inspect the existing receipt');
|
|
167
|
+
transaction = { schema_version: 1, plan_id, root, status: 'applying', ...(plan.migration?{migration:plan.migration}:{}), changes: plan.changes, backups: Object.fromEntries(plan.changes.map(c => [c.ref, c.before ? fs.readFileSync(safeFile(root, c.ref)).toString('base64') : null])) };
|
|
168
|
+
atomic(journalFile, serializeAsset(transaction)); atomic(active, serializeAsset({ journal_ref: journalRef }));
|
|
169
|
+
for (const change of plan.changes) {
|
|
170
|
+
ensure(same(descriptor(safeFile(root, change.ref)), change.before), `ASSET_INPUT_DRIFT: ${change.ref}`);
|
|
171
|
+
atomic(safeFile(root, change.ref), change.after, change.before?.mode ?? 0o644);
|
|
172
|
+
readAsset(safeFile(root, change.ref), change.kind, { schemaRoot: root });
|
|
173
|
+
afterWrite?.(change);
|
|
174
|
+
}
|
|
175
|
+
for (const [ref, before] of Object.entries(plan.observed)) {
|
|
176
|
+
const change = plan.changes.find(c => c.ref === ref);
|
|
177
|
+
ensure(descriptor(safeFile(root, ref))?.digest === (change?.after_digest ?? before?.digest), `ASSET_POSTWRITE_DRIFT: ${ref}`);
|
|
178
|
+
}
|
|
179
|
+
ensure(Object.keys(observeAssetInputs(root)).every(ref => Object.hasOwn(plan.observed, ref)), 'ASSET_POSTWRITE_DRIFT: new dependency files');
|
|
180
|
+
transaction.status = 'applied'; atomic(journalFile, serializeAsset(transaction)); fs.unlinkSync(active);
|
|
181
|
+
return { status: 'applied', plan_id, changed_refs: plan.changes.map(c => c.ref), receipt_ref: journalRef, execution_authorization: 'not-granted' };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (transaction && exists(active)) {
|
|
184
|
+
const result = recoverAssetWrite(root, { lockHeld: true });
|
|
185
|
+
throw new Error(`${error.message}; ${result.status}: ${result.conflicts.join(',')}`);
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
} finally { fs.closeSync(fd); if (exists(lock)) fs.unlinkSync(lock); }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function recoverAssetWrite(root, { lockHeld = false, recoveryHeld = false } = {}) {
|
|
192
|
+
root = fs.realpathSync(root);
|
|
193
|
+
const lock = safeFile(root, `${TX}/lock`);
|
|
194
|
+
if (!lockHeld && !recoveryHeld) {
|
|
195
|
+
const guard = safeFile(root, `${TX}/recovery.lock`);
|
|
196
|
+
fs.mkdirSync(path.dirname(guard), { recursive: true });
|
|
197
|
+
const fd = fs.openSync(guard, 'wx');
|
|
198
|
+
try {
|
|
199
|
+
fs.writeFileSync(fd, String(process.pid)); fs.fsyncSync(fd);
|
|
200
|
+
return recoverAssetWrite(root, { recoveryHeld: true });
|
|
201
|
+
} finally { fs.closeSync(fd); fs.unlinkSync(guard); }
|
|
202
|
+
}
|
|
203
|
+
if (!lockHeld && exists(lock)) {
|
|
204
|
+
const pid=Number(fs.readFileSync(lock,'utf8'));ensure(Number.isInteger(pid)&&pid>0,'ASSET_LOCK_INVALID');
|
|
205
|
+
let live=true;try{process.kill(pid,0);}catch(error){if(error.code==='ESRCH')live=false;else throw error;}
|
|
206
|
+
ensure(!live,'ASSET_LOCKED: writer is still alive');fs.unlinkSync(lock);
|
|
207
|
+
}
|
|
208
|
+
if (!lockHeld) {
|
|
209
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
210
|
+
const fd = fs.openSync(lock, 'wx');
|
|
211
|
+
try {
|
|
212
|
+
fs.writeFileSync(fd, String(process.pid)); fs.fsyncSync(fd);
|
|
213
|
+
return recoverAssetWrite(root, { lockHeld: true });
|
|
214
|
+
} finally { fs.closeSync(fd); fs.unlinkSync(lock); }
|
|
215
|
+
}
|
|
216
|
+
const active = safeFile(root, `${TX}/active.json`);
|
|
217
|
+
if (!exists(active)) return { status: 'idle', conflicts: [] };
|
|
218
|
+
const marker = parseAsset(fs.readFileSync(active), active), journalFile = safeFile(root, marker.journal_ref);
|
|
219
|
+
ensure(marker.journal_ref.startsWith(`${TX}/`), 'ASSET_JOURNAL_PATH');
|
|
220
|
+
const tx = parseAsset(fs.readFileSync(journalFile), journalFile);
|
|
221
|
+
ensure(tx.root === root && ['applying', 'recovery-conflict','applied'].includes(tx.status), 'ASSET_JOURNAL_INVALID');
|
|
222
|
+
if(tx.status==='applied') {for(const c of tx.changes)ensure(descriptor(safeFile(root,c.ref))?.digest===c.after_digest,'ASSET_RECOVERY_CONFLICT');fs.unlinkSync(active);return{status:'applied',conflicts:[]};}
|
|
223
|
+
const conflicts = [];
|
|
224
|
+
for (const change of [...tx.changes].reverse()) {
|
|
225
|
+
ensure(byteDigest(change.after)===change.after_digest && (change.before===null?tx.backups[change.ref]===null:byteDigest(Buffer.from(tx.backups[change.ref],'base64'))===change.before.digest),'ASSET_JOURNAL_CORRUPT');
|
|
226
|
+
const file = safeFile(root, change.ref), actual = descriptor(file);
|
|
227
|
+
if (same(actual, change.before)) continue;
|
|
228
|
+
if (actual?.digest !== change.after_digest) { conflicts.push(change.ref); continue; }
|
|
229
|
+
if (tx.backups[change.ref] === null) fs.unlinkSync(file);
|
|
230
|
+
else atomic(file, Buffer.from(tx.backups[change.ref], 'base64'), change.before.mode);
|
|
231
|
+
}
|
|
232
|
+
tx.status = conflicts.length ? 'recovery-conflict' : 'rolled-back'; tx.conflicts = conflicts;
|
|
233
|
+
atomic(journalFile, serializeAsset(tx)); if (!conflicts.length) fs.unlinkSync(active);
|
|
234
|
+
return { status: tx.status, conflicts };
|
|
235
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { readDocument, fileBinding } from './governance-io.mjs';
|
|
2
|
+
import { assertAssetTransactionIdle, assertCurrentAssetReference } from './asset-transactions.mjs';
|
|
2
3
|
|
|
3
4
|
/** Validate existing lifecycle context evidence; never grants approval. */
|
|
4
5
|
export function assertCheckpointBoundary(value, {root, history=false}={}) {
|
|
5
6
|
if(history) return {status:'historical-only',execution_authorization:'not-evaluated'};
|
|
7
|
+
assertAssetTransactionIdle(root);
|
|
8
|
+
if(value.stage_tracking?.checkpoint_ref)assertCurrentAssetReference(root,value.stage_tracking.checkpoint_ref);
|
|
6
9
|
const canonical='.agents/skills/yss-product-lifecycle/references/orchestration-contract.yaml';
|
|
7
10
|
const contract=readDocument(root,fileBinding(root,canonical)?canonical:'.template-spec/process/checkpoint-boundary.yaml');
|
|
8
11
|
const registry=readDocument(root,'.template-spec/process/lifecycle-registry.yaml');
|
|
@@ -4,6 +4,7 @@ import {safe,hash,schema} from './strategic-handoff-io.mjs';
|
|
|
4
4
|
import {parseSliceYaml,readSliceContract} from './slice-contract.mjs';
|
|
5
5
|
import {renderSliceContractView,diffSliceContracts} from './slice-contract-views.mjs';
|
|
6
6
|
import {renderReadingMarkdown} from './reading-view-markdown.mjs';
|
|
7
|
+
import {loadLifecyclePresenter} from './lifecycle-presentation.mjs';
|
|
7
8
|
import {readingDiff,renderReadingDiff} from './reading-view-diff.mjs';
|
|
8
9
|
import {adaptReading} from './reading-view-adapters.mjs';
|
|
9
10
|
import {readingViewModel} from './reading-view-model.mjs';
|
|
@@ -120,7 +121,12 @@ function view(ref,{root=process.cwd(),kind,profile='review',unit_id,task_layout=
|
|
|
120
121
|
content.约束分类='保留未分类字段;本视图只允许阅读,执行影响由资产所有者核验。';
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
|
-
const view={read_only:true,execution_allowed:false,kind,profile,binding,checks,approval_validity:'not-checked',content,blockers};
|
|
124
|
+
const view={read_only:true,execution_allowed:false,kind,profile,binding,checks,approval_validity:'not-checked',content,blockers};
|
|
125
|
+
if(['domain-strategy','stage-decision-package','checkpoint'].includes(kind)) {
|
|
126
|
+
const presenter=loadLifecyclePresenter(root);
|
|
127
|
+
view.presentation={names:presenter.catalog(content),sources:presenter.sources,warnings:presenter.warnings};
|
|
128
|
+
}
|
|
129
|
+
if(include_reading_model&&['domain-strategy','stage-decision-package','checkpoint','tracking-migration'].includes(kind))view.reading_model=readingViewModel(view,sourceRaw);view.markdown=markdown(view);return view;
|
|
124
130
|
}
|
|
125
131
|
export function diffContract(beforeRef,afterRef,{root=process.cwd(),beforeRoot=root,kind}={}){
|
|
126
132
|
validateKind(kind);if(kind==='slice')return diffSliceContracts(beforeRef,afterRef,{root,beforeRoot});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, validationCache } from './validation-phase.mjs';
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
4
5
|
|
|
5
6
|
const schemaCache=Symbol('schema-results');
|
|
6
7
|
const validator = String.raw`
|
|
@@ -8,6 +9,8 @@ import json
|
|
|
8
9
|
import sys
|
|
9
10
|
import traceback
|
|
10
11
|
from jsonschema import Draft202012Validator, FormatChecker
|
|
12
|
+
from referencing import Registry, Resource
|
|
13
|
+
from referencing.jsonschema import DRAFT202012
|
|
11
14
|
validators = {}
|
|
12
15
|
results = []
|
|
13
16
|
expected_bytes = int(sys.argv[1])
|
|
@@ -23,12 +26,18 @@ for item in jobs:
|
|
|
23
26
|
key = (item['schemaPath'], item['formatChecker'])
|
|
24
27
|
if key not in validators:
|
|
25
28
|
schema = json.loads(item['schemaText'])
|
|
26
|
-
|
|
29
|
+
Draft202012Validator.check_schema(schema)
|
|
30
|
+
registry = Registry()
|
|
31
|
+
for resource in item.get('resources', []):
|
|
32
|
+
contents = json.loads(resource['text'])
|
|
33
|
+
Draft202012Validator.check_schema(contents)
|
|
34
|
+
registry = registry.with_resource(resource['uri'], Resource.from_contents(contents, default_specification=DRAFT202012))
|
|
35
|
+
validators[key] = Draft202012Validator(schema, registry=registry, format_checker=FormatChecker() if key[1] else None)
|
|
27
36
|
errors = list(validators[key].iter_errors(item['value']))
|
|
28
37
|
if item['errorStyle'] == 'verbose':
|
|
29
38
|
message = '\n'.join(str(error) for error in errors)
|
|
30
39
|
else:
|
|
31
|
-
errors.sort(key=lambda error:
|
|
40
|
+
errors.sort(key=lambda error: [str(part) for part in error.absolute_path])
|
|
32
41
|
message = '\n'.join(f"{'.'.join(str(part) for part in error.absolute_path) or '<root>'}: {error.message}" for error in errors)
|
|
33
42
|
results.append({'valid': not errors, 'error': message})
|
|
34
43
|
except Exception:
|
|
@@ -36,6 +45,36 @@ for item in jobs:
|
|
|
36
45
|
json.dump(results, sys.stdout)
|
|
37
46
|
`;
|
|
38
47
|
|
|
48
|
+
// Supply the complete local reference closure; never retrieve schemas over the network.
|
|
49
|
+
function schemaResources(schemaPath) {
|
|
50
|
+
const resources = new Map(), visited = new Set();
|
|
51
|
+
function load(file, uri) {
|
|
52
|
+
const text = readFileSync(file, 'utf8'), schema = JSON.parse(text);
|
|
53
|
+
const fileURI = pathToFileURL(file).href;
|
|
54
|
+
for (const id of [uri, fileURI, schema.$id].filter(Boolean)) {
|
|
55
|
+
if (resources.has(id) && resources.get(id) !== text) throw new TypeError(`JSON_SCHEMA_ID_CONFLICT: ${id}`);
|
|
56
|
+
resources.set(id, text);
|
|
57
|
+
}
|
|
58
|
+
if (visited.has(file)) return;
|
|
59
|
+
visited.add(file);
|
|
60
|
+
const base = schema.$id || uri || fileURI;
|
|
61
|
+
function refs(value) {
|
|
62
|
+
if (!value || typeof value !== 'object') return;
|
|
63
|
+
for (const [key, ref] of Object.entries(value)) {
|
|
64
|
+
if (['$ref', '$dynamicRef'].includes(key) && typeof ref === 'string' && !ref.startsWith('#')) {
|
|
65
|
+
const resource = ref.split('#')[0], resolved = new URL(resource, base).href;
|
|
66
|
+
if (resources.has(resolved)) continue;
|
|
67
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(resource) || path.isAbsolute(resource)) throw new TypeError(`JSON_SCHEMA_OFFLINE: unregistered schema ${ref}`);
|
|
68
|
+
load(path.resolve(path.dirname(file), resource), resolved);
|
|
69
|
+
} else refs(ref);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
refs(schema);
|
|
73
|
+
}
|
|
74
|
+
load(schemaPath);
|
|
75
|
+
return [...resources].map(([uri, text]) => ({ uri, text }));
|
|
76
|
+
}
|
|
77
|
+
|
|
39
78
|
/** One immutable validation phase, never a cache of successful work units. */
|
|
40
79
|
export function validateJsonSchemas(items, { cwd = process.cwd(), timeoutMs = 30000 } = {}) {
|
|
41
80
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new TypeError("schema timeoutMs 必须是正整数毫秒数");
|
|
@@ -45,7 +84,7 @@ export function validateJsonSchemas(items, { cwd = process.cwd(), timeoutMs = 30
|
|
|
45
84
|
const effectiveSchema = path.resolve(schemaPath);
|
|
46
85
|
if (!existsSync(effectiveSchema)) throw new TypeError(`缺少 ${label}: ${effectiveSchema}`);
|
|
47
86
|
if (!["compact", "verbose"].includes(errorStyle)) throw new TypeError(`未知 schema errorStyle: ${errorStyle}`);
|
|
48
|
-
return { value, schemaPath: effectiveSchema, schemaText: readFileSync(effectiveSchema, 'utf8'), formatChecker, errorStyle };
|
|
87
|
+
return { value, schemaPath: effectiveSchema, schemaText: readFileSync(effectiveSchema, 'utf8'), resources: schemaResources(effectiveSchema), formatChecker, errorStyle };
|
|
49
88
|
});
|
|
50
89
|
// readall waits for EOF even after the complete JSON has arrived. A known UTF-8
|
|
51
90
|
// byte frame lets the validator finish when a transport keeps its write end open.
|
|
@@ -62,7 +101,7 @@ export function validateJsonSchemas(items, { cwd = process.cwd(), timeoutMs = 30
|
|
|
62
101
|
};
|
|
63
102
|
// Batch identical jobs once and reuse only deterministic checks within this operation.
|
|
64
103
|
// External references retain the uncached path: their dependency closure is not local here.
|
|
65
|
-
const keys=jobs.map(job=>JSON.stringify([job.schemaPath,job.schemaText,job.value,job.formatChecker,job.errorStyle,path.resolve(cwd),timeoutMs]));
|
|
104
|
+
const keys=jobs.map(job=>JSON.stringify([job.schemaPath,job.schemaText,job.resources,job.value,job.formatChecker,job.errorStyle,path.resolve(cwd),timeoutMs]));
|
|
66
105
|
const pending=new Map(),cached=new Map();
|
|
67
106
|
for(let i=0;i<jobs.length;i++){
|
|
68
107
|
if(/"\$(?:ref|dynamicRef)"\s*:\s*"(?!#)/.test(jobs[i].schemaText)){pending.set(`${i}:${keys[i]}`,{job:jobs[i],indices:[i],cache:false});continue;}
|
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { parseDocument } from "../vendor/yaml.mjs";
|
|
6
6
|
import { ROOT, loadRegistry, semanticDigest, validateRegistry } from "./lifecycle-registry.mjs";
|
|
7
7
|
import { loadSkillRegistry } from "./skill-registry.mjs";
|
|
8
|
+
import { loadLifecyclePresenter } from './lifecycle-presentation.mjs';
|
|
8
9
|
|
|
9
10
|
const LIFECYCLE_REGISTRY_REF = ".template-spec/process/lifecycle-registry.yaml";
|
|
10
11
|
const ORCHESTRATION_CONTRACT_REF = ".agents/skills/yss-product-lifecycle/references/orchestration-contract.yaml";
|
|
@@ -159,6 +160,12 @@ function querySnapshot({ mode, stageId, workUnitId, include = [] } = {}) {
|
|
|
159
160
|
},
|
|
160
161
|
skills: selectedSkills(route, skillRegistry),
|
|
161
162
|
};
|
|
163
|
+
const presenter = loadLifecyclePresenter(ROOT);
|
|
164
|
+
result.presentation = { read_only: true, execution_allowed: false, approval_validity: 'not-checked',
|
|
165
|
+
names: presenter.catalog(result.lifecycle), sources: presenter.sources, warnings: presenter.warnings };
|
|
166
|
+
// Include role and transition references from the requested contract subtrees as well.
|
|
167
|
+
Object.assign(result.presentation.names, presenter.catalog(result.execution));
|
|
168
|
+
Object.assign(result.presentation.names, presenter.catalog(presenter.roleIds));
|
|
162
169
|
result.references = [...collectPathReferences(result), LIFECYCLE_REGISTRY_REF, ORCHESTRATION_CONTRACT_REF, SKILL_REGISTRY_REF]
|
|
163
170
|
.filter((value, index, all) => all.indexOf(value) === index)
|
|
164
171
|
.sort();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {readFileSync} from './validation-phase.mjs';
|
|
4
|
+
import {parseDocument} from '../vendor/yaml.mjs';
|
|
5
|
+
import {safe} from './strategic-handoff-io.mjs';
|
|
6
|
+
|
|
7
|
+
const collections = ['stages', 'gates', 'checks', 'artifacts', 'work_units', 'evidence'];
|
|
8
|
+
const token = /(?<![\w./-])(?:stage|gate|check|artifact|work-unit|evidence|role)\.[a-z0-9][a-z0-9-]*(?![\w./-])/g;
|
|
9
|
+
export const presentationRefs = {
|
|
10
|
+
registry: '.template-spec/process/lifecycle-registry.yaml',
|
|
11
|
+
roles: '.template-spec/agents/digital-human-roles.yaml',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Names describe identities, never approval, readiness or execution authority. */
|
|
15
|
+
export function createLifecyclePresenter(registry = {}, roles = {}) {
|
|
16
|
+
const records = [...collections.flatMap(key => registry[key] ?? []), ...(roles.roles ?? []), ...(roles.orchestrator ? [roles.orchestrator] : [])];
|
|
17
|
+
const byId = new Map(records.filter(record => record && typeof record.id === 'string').map(record => [record.id, record]));
|
|
18
|
+
const describe = id => {
|
|
19
|
+
const record = byId.get(id);
|
|
20
|
+
const name = record?.public_name ?? record?.name;
|
|
21
|
+
return {id, name: typeof name === 'string' && name.trim() ? name : '名称未识别', known: typeof name === 'string' && !!name.trim()};
|
|
22
|
+
};
|
|
23
|
+
const label = (id, {trace = true} = {}) => {
|
|
24
|
+
const item = describe(id);
|
|
25
|
+
return item.known && !trace ? item.name : `${item.name}(${id})`;
|
|
26
|
+
};
|
|
27
|
+
const text = (value, options) => String(value).replace(token, id => label(id, options));
|
|
28
|
+
const catalog = value => {
|
|
29
|
+
const ids = new Set();
|
|
30
|
+
const visit = item => {
|
|
31
|
+
if (typeof item === 'string') for (const id of item.match(token) ?? []) ids.add(id);
|
|
32
|
+
else if (item && typeof item === 'object') for (const [key, child] of Object.entries(item)) { visit(key); visit(child); }
|
|
33
|
+
};
|
|
34
|
+
visit(value);
|
|
35
|
+
return Object.fromEntries([...ids].sort().map(id => [id, describe(id)]));
|
|
36
|
+
};
|
|
37
|
+
return {describe, label, text, catalog, roleIds: records.filter(record => typeof record?.id === 'string' && record.id.startsWith('role.')).map(record => record.id)};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Optional presentation sources: legacy instances remain readable when names are unavailable. */
|
|
41
|
+
export function loadLifecyclePresenter(root) {
|
|
42
|
+
const values = {}, sources = [], warnings = [];
|
|
43
|
+
for (const [key, ref] of Object.entries(presentationRefs)) {
|
|
44
|
+
let digest = null;
|
|
45
|
+
try {
|
|
46
|
+
const bytes = readFileSync(safe(path.resolve(root), ref));
|
|
47
|
+
digest = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
48
|
+
const doc = parseDocument(String(bytes), {uniqueKeys: true, maxAliasCount: 0});
|
|
49
|
+
if (doc.errors.length) throw Error(doc.errors[0].message);
|
|
50
|
+
const value = doc.toJS({maxAliasCount: 0});
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('名称来源须为对象');
|
|
52
|
+
for (const collection of key === 'registry' ? collections : ['roles']) {
|
|
53
|
+
if (value[collection] !== undefined && !Array.isArray(value[collection])) throw Error(`${collection} 须为数组`);
|
|
54
|
+
}
|
|
55
|
+
values[key] = value;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
warnings.push(`名称来源不可用:${ref};${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
sources.push({ref, digest});
|
|
60
|
+
}
|
|
61
|
+
return {...createLifecyclePresenter(values.registry, values.roles), sources, warnings};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderLifecycleStatus(result) {
|
|
65
|
+
const view = result.presentation;
|
|
66
|
+
if (!view) throw Error('缺少状态阅读信息');
|
|
67
|
+
return [
|
|
68
|
+
`当前阶段:${view.stage}。登记状态:${view.checkpoint_status}。`,
|
|
69
|
+
`当前阻塞:${view.blockers.length ? view.blockers.join(';') : '未登记阻塞;完整就绪与批准仍待核验'}。`,
|
|
70
|
+
`负责人:${view.owner}。`,
|
|
71
|
+
`下一项工作:${view.work_unit}。`,
|
|
72
|
+
...(view.expected_output ? [`预期产出:${view.expected_output}`] : []),
|
|
73
|
+
`下一动作:${view.next_action}。`,
|
|
74
|
+
...view.warnings,
|
|
75
|
+
'本次为只读查询,未核验完整批准或执行授权。',
|
|
76
|
+
].join('\n') + '\n';
|
|
77
|
+
}
|