@echopath-labs/forgerail 0.1.0-alpha.1

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.
Files changed (86) hide show
  1. package/.codex-plugin/plugin.json +37 -0
  2. package/CHANGELOG.md +17 -0
  3. package/CONTRIBUTING.md +7 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +4 -0
  6. package/PLUGIN.md +11 -0
  7. package/README.md +65 -0
  8. package/README.zh-CN.md +39 -0
  9. package/SECURITY.md +7 -0
  10. package/adapters/claude-code.json +20 -0
  11. package/adapters/codex.json +20 -0
  12. package/adapters/cursor.json +20 -0
  13. package/contracts/adoption-plan.schema.json +53 -0
  14. package/contracts/capability-pack.schema.json +23 -0
  15. package/contracts/effective-profile.schema.json +42 -0
  16. package/contracts/host-adapter.schema.json +43 -0
  17. package/contracts/host-binding-receipt.schema.json +47 -0
  18. package/contracts/launch-contract.schema.json +15 -0
  19. package/contracts/profile-change-candidate.schema.json +21 -0
  20. package/contracts/return-receipt.schema.json +23 -0
  21. package/contracts/task-envelope.schema.json +21 -0
  22. package/docs/adoption.md +51 -0
  23. package/docs/adoption.zh-CN.md +51 -0
  24. package/docs/agw-coverage-baseline.json +21 -0
  25. package/docs/agw-coverage-baseline.md +21 -0
  26. package/docs/agw-frozen-baseline.json +51 -0
  27. package/docs/architecture-acceptance.md +38 -0
  28. package/docs/context-cost.md +13 -0
  29. package/docs/external-capability-packs.md +25 -0
  30. package/docs/installation.md +88 -0
  31. package/docs/installation.zh-CN.md +60 -0
  32. package/docs/migration-from-agw.md +12 -0
  33. package/docs/pack-authoring.md +17 -0
  34. package/docs/release.md +50 -0
  35. package/docs/release.zh-CN.md +50 -0
  36. package/package.json +48 -0
  37. package/packs/workspace-health-review.json +15 -0
  38. package/scripts/disposable-consumer.mjs +143 -0
  39. package/scripts/fixtures/contracts/adoption-plan.multi-host.valid.json +44 -0
  40. package/scripts/fixtures/contracts/adoption-plan.mutating.invalid.json +18 -0
  41. package/scripts/fixtures/contracts/adoption-plan.single-host.valid.json +27 -0
  42. package/scripts/fixtures/contracts/effective-profile.valid.json +29 -0
  43. package/scripts/fixtures/contracts/host-adapter.claude-code.profile-only.valid.json +14 -0
  44. package/scripts/fixtures/contracts/host-adapter.codex.valid.json +17 -0
  45. package/scripts/fixtures/contracts/host-adapter.cursor.profile-only.valid.json +14 -0
  46. package/scripts/fixtures/contracts/host-binding-receipt.unverified.invalid.json +27 -0
  47. package/scripts/fixtures/contracts/host-binding-receipt.valid.json +27 -0
  48. package/scripts/fixtures/contracts/launch-contract.valid.json +19 -0
  49. package/scripts/fixtures/contracts/profile-change-candidate.valid.json +13 -0
  50. package/scripts/fixtures/contracts/profile-input.available-pack.json +26 -0
  51. package/scripts/fixtures/contracts/profile-input.conflict.json +20 -0
  52. package/scripts/fixtures/contracts/profile-input.external-rulesets.json +11 -0
  53. package/scripts/fixtures/contracts/profile-input.orchestration-available.json +11 -0
  54. package/scripts/fixtures/contracts/return-receipt.deviation.invalid.json +15 -0
  55. package/scripts/fixtures/contracts/return-receipt.valid.json +15 -0
  56. package/scripts/fixtures/contracts/task-envelope.overlap.invalid.json +13 -0
  57. package/scripts/fixtures/contracts/task-envelope.valid.json +13 -0
  58. package/scripts/fixtures/workspaces/empty-records/README.md +3 -0
  59. package/scripts/fixtures/workspaces/markdown-existing/AGENTS.md +3 -0
  60. package/scripts/fixtures/workspaces/markdown-existing/docs/adr/0001-record-strategy.md +3 -0
  61. package/scripts/fixtures/workspaces/markdown-existing/package.json +7 -0
  62. package/scripts/forgerail.mjs +210 -0
  63. package/scripts/lib/adoption.mjs +158 -0
  64. package/scripts/lib/bundle.mjs +77 -0
  65. package/scripts/lib/composition.mjs +96 -0
  66. package/scripts/lib/contracts.mjs +316 -0
  67. package/scripts/lib/diagnosis.mjs +93 -0
  68. package/scripts/shadow-comparison.mjs +98 -0
  69. package/scripts/validate-external-packs.mjs +27 -0
  70. package/scripts/validate-release.mjs +194 -0
  71. package/skills/forgerail/SKILL.md +53 -0
  72. package/skills/forgerail/agents/openai.yaml +7 -0
  73. package/skills/forgerail/references/adoption.md +13 -0
  74. package/skills/forgerail/references/contracts.md +57 -0
  75. package/skills/forgerail/references/profile-resolution.md +14 -0
  76. package/skills/forgerail-workspace-diagnosis/SKILL.md +42 -0
  77. package/skills/forgerail-workspace-diagnosis/agents/openai.yaml +7 -0
  78. package/skills/forgerail-workspace-diagnosis/references/record-strategies.md +13 -0
  79. package/skills/workspace-health-review/SKILL.md +27 -0
  80. package/skills/workspace-health-review/agents/openai.yaml +7 -0
  81. package/skills/workspace-health-review/references/health-review.md +21 -0
  82. package/templates/FORGERAIL.md +15 -0
  83. package/templates/bindings/claude-code-thin.md +5 -0
  84. package/templates/bindings/codex-compact.md +5 -0
  85. package/templates/bindings/codex-thin.md +5 -0
  86. package/templates/bindings/cursor-thin.mdc +7 -0
@@ -0,0 +1,96 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ import { validateContract } from "./contracts.mjs";
4
+
5
+ function canonicalValue(value) {
6
+ if (Array.isArray(value)) return value.map(canonicalValue);
7
+ if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
8
+ return value;
9
+ }
10
+
11
+ function equalValue(left, right) {
12
+ return JSON.stringify(canonicalValue(left)) === JSON.stringify(canonicalValue(right));
13
+ }
14
+
15
+ export function resolveProfile(input, packManifests = []) {
16
+ const conflicts = [];
17
+ const selected = new Map();
18
+ for (const rule of input.rules ?? []) {
19
+ const prior = selected.get(rule.id);
20
+ if (!prior || rule.precedence < prior.precedence) selected.set(rule.id, rule);
21
+ else if (rule.precedence === prior.precedence && !equalValue(rule.value, prior.value)) {
22
+ conflicts.push(`${rule.id}: equal-precedence sources disagree (${prior.source} vs ${rule.source})`);
23
+ }
24
+ }
25
+
26
+ const manifests = new Map(packManifests.map((pack) => [pack.id, pack]));
27
+ const states = new Map((input.packs ?? []).map((pack) => [pack.id, pack]));
28
+ const active = new Set([...states.values()].filter((pack) => ["enabled", "required"].includes(pack.state)).map((pack) => pack.id));
29
+ for (const id of active) {
30
+ const manifest = manifests.get(id);
31
+ if (!manifest) {
32
+ conflicts.push(`${id}: enabled pack manifest is unavailable`);
33
+ continue;
34
+ }
35
+ for (const dependency of manifest.dependencies) if (!active.has(dependency)) conflicts.push(`${id}: missing active dependency ${dependency}`);
36
+ for (const conflict of manifest.conflicts) if (active.has(conflict)) conflicts.push(`${id}: conflicts with active pack ${conflict}`);
37
+ }
38
+ if (active.has("agent-workflow-governance") && active.has("forgerail-core")) conflicts.push("duplicate core workflow owners: agent-workflow-governance and forgerail-core");
39
+
40
+ const profile = {
41
+ schemaVersion: "1.0",
42
+ workspace: input.workspace,
43
+ computed: true,
44
+ rules: [...selected.values()].sort((left, right) => left.id.localeCompare(right.id)),
45
+ packs: (input.packs ?? []).map((pack) => ({ id: pack.id, state: pack.state, reason: pack.reason })),
46
+ conflicts: [...new Set(conflicts)].sort(),
47
+ };
48
+ const contract = validateContract("profile", profile);
49
+ return { profile, activePacks: [...active].sort(), valid: contract.valid && profile.conflicts.length === 0, errors: [...contract.errors, ...profile.conflicts] };
50
+ }
51
+
52
+ export function createLaunchContract(profile, envelope, hostAgent) {
53
+ const profileResult = validateContract("profile", profile);
54
+ const envelopeResult = validateContract("envelope", envelope);
55
+ const errors = [...profileResult.errors, ...envelopeResult.errors];
56
+ if (profile.conflicts?.length > 0) errors.push(...profile.conflicts.map((conflict) => `unresolved profile conflict: ${conflict}`));
57
+ const activePacks = new Set((profile.packs ?? []).filter((pack) => ["enabled", "required"].includes(pack.state)).map((pack) => pack.id));
58
+ for (const pack of envelope.packs ?? []) if (!activePacks.has(pack)) errors.push(`task requests inactive pack: ${pack}`);
59
+ const launch = {
60
+ schemaVersion: "1.0",
61
+ envelope,
62
+ effectiveRuleSources: [...new Set(["ForgeRail Core", ...(profile.rules ?? []).map((rule) => rule.source)])],
63
+ hostAgent,
64
+ executionOwner: "host-agent",
65
+ };
66
+ const launchResult = validateContract("launch", launch);
67
+ errors.push(...launchResult.errors);
68
+ return { launch, valid: errors.length === 0, errors };
69
+ }
70
+
71
+ function git(workspace, ...args) {
72
+ const result = spawnSync("git", args, { cwd: workspace, encoding: "utf8" });
73
+ return result.status === 0 ? result.stdout.trim() : null;
74
+ }
75
+
76
+ export function verifyReceipt(receipt, workspace) {
77
+ const errors = [...validateContract("receipt", receipt).errors];
78
+ const observations = {};
79
+ const root = resolve(workspace);
80
+ const inside = git(root, "rev-parse", "--is-inside-work-tree") === "true";
81
+ observations.git = inside;
82
+ if (inside) {
83
+ observations.branch = git(root, "branch", "--show-current");
84
+ observations.commit = git(root, "rev-parse", "HEAD");
85
+ observations.worktree = git(root, "status", "--porcelain=v1") === "" ? "clean" : "dirty";
86
+ if (receipt.branch !== null && receipt.branch !== observations.branch) errors.push(`receipt branch mismatch: ${receipt.branch} != ${observations.branch}`);
87
+ if (receipt.commit !== null && receipt.commit !== observations.commit) errors.push(`receipt commit mismatch: ${receipt.commit} != ${observations.commit}`);
88
+ if (receipt.confirmedNonMutations.includes("clean worktree") && observations.worktree !== "clean") errors.push("receipt claims clean worktree but observable Git state is dirty");
89
+ } else if (receipt.branch !== null || receipt.commit !== null) errors.push("receipt declares Git identity for a non-Git workspace");
90
+ return {
91
+ valid: errors.length === 0,
92
+ closeout: errors.length === 0 ? receipt.closeout : "incomplete",
93
+ observations,
94
+ errors,
95
+ };
96
+ }
@@ -0,0 +1,316 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ export const contractTypes = ["pack", "profile", "profile-candidate", "envelope", "launch", "receipt", "host-adapter", "adoption-plan", "binding-receipt"];
5
+
6
+ const packStates = ["available", "recommended", "enabled", "required", "blocked", "disabled"];
7
+ const idPattern = /^[a-z][a-z0-9-]+$/;
8
+ const ruleIdPattern = /^[a-z][a-z0-9.-]+$/;
9
+ const taskIdPattern = /^[a-zA-Z0-9._:-]+$/;
10
+ const commitPattern = /^[0-9a-f]{40}$/;
11
+ const digestPattern = /^[0-9a-f]{64}$/;
12
+
13
+ function object(value) {
14
+ return value && typeof value === "object" && !Array.isArray(value);
15
+ }
16
+
17
+ function exactKeys(value, required, optional, label, errors) {
18
+ if (!object(value)) {
19
+ errors.push(`${label} must be an object`);
20
+ return false;
21
+ }
22
+ for (const key of required) if (!Object.hasOwn(value, key)) errors.push(`${label}.${key} is required`);
23
+ const allowed = new Set([...required, ...optional]);
24
+ for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${label}.${key} is unsupported`);
25
+ return true;
26
+ }
27
+
28
+ function string(value, label, errors, pattern) {
29
+ if (typeof value !== "string" || value.length === 0) errors.push(`${label} must be a non-empty string`);
30
+ else if (pattern && !pattern.test(value)) errors.push(`${label} has an invalid format`);
31
+ }
32
+
33
+ function nullableString(value, label, errors, pattern) {
34
+ if (value === null) return;
35
+ string(value, label, errors, pattern);
36
+ }
37
+
38
+ function strings(value, label, errors, { min = 0, pattern, unique = false } = {}) {
39
+ if (!Array.isArray(value)) {
40
+ errors.push(`${label} must be an array`);
41
+ return;
42
+ }
43
+ if (value.length < min) errors.push(`${label} must contain at least ${min} item(s)`);
44
+ value.forEach((item, index) => string(item, `${label}[${index}]`, errors, pattern));
45
+ if (unique && new Set(value).size !== value.length) errors.push(`${label} must not contain duplicates`);
46
+ }
47
+
48
+ function schemaVersion(value, label, errors) {
49
+ if (value !== "1.0") errors.push(`${label}.schemaVersion must equal 1.0`);
50
+ }
51
+
52
+ function sha256(value) {
53
+ return createHash("sha256").update(value).digest("hex");
54
+ }
55
+
56
+ function validatePack(value, errors) {
57
+ const keys = ["schemaVersion", "id", "purpose", "entry", "risk", "states", "triggers", "inputs", "dependencies", "conflicts", "approvals", "validation", "receiptExtensions"];
58
+ if (!exactKeys(value, keys, [], "pack", errors)) return;
59
+ schemaVersion(value.schemaVersion, "pack", errors);
60
+ string(value.id, "pack.id", errors, idPattern);
61
+ string(value.purpose, "pack.purpose", errors);
62
+ if (typeof value.purpose === "string" && value.purpose.length < 12) errors.push("pack.purpose is too short");
63
+ string(value.entry, "pack.entry", errors, /^skills\/[a-z][a-z0-9-]+\/SKILL\.md$/);
64
+ if (!["low", "medium", "high"].includes(value.risk)) errors.push("pack.risk is invalid");
65
+ if (JSON.stringify(value.states) !== JSON.stringify(packStates)) errors.push("pack.states must use the canonical ordered state set");
66
+ strings(value.triggers, "pack.triggers", errors, { unique: true });
67
+ strings(value.inputs, "pack.inputs", errors, { unique: true });
68
+ strings(value.dependencies, "pack.dependencies", errors, { pattern: idPattern, unique: true });
69
+ strings(value.conflicts, "pack.conflicts", errors, { pattern: idPattern, unique: true });
70
+ strings(value.approvals, "pack.approvals", errors, { pattern: idPattern, unique: true });
71
+ strings(value.validation, "pack.validation", errors, { unique: true });
72
+ strings(value.receiptExtensions, "pack.receiptExtensions", errors, { pattern: /^[a-z][A-Za-z0-9]*$/, unique: true });
73
+ }
74
+
75
+ function validateProfile(value, errors) {
76
+ if (!exactKeys(value, ["schemaVersion", "workspace", "computed", "rules", "packs", "conflicts"], [], "profile", errors)) return;
77
+ schemaVersion(value.schemaVersion, "profile", errors);
78
+ string(value.workspace, "profile.workspace", errors);
79
+ if (value.computed !== true) errors.push("profile.computed must equal true");
80
+ if (!Array.isArray(value.rules)) errors.push("profile.rules must be an array");
81
+ else value.rules.forEach((rule, index) => {
82
+ const label = `profile.rules[${index}]`;
83
+ if (!exactKeys(rule, ["id", "value", "source", "precedence", "status"], [], label, errors)) return;
84
+ string(rule.id, `${label}.id`, errors, ruleIdPattern);
85
+ string(rule.source, `${label}.source`, errors);
86
+ if (!Number.isInteger(rule.precedence) || rule.precedence < 1 || rule.precedence > 6) errors.push(`${label}.precedence must be 1-6`);
87
+ if (!["observed", "inferred", "confirmed", "default"].includes(rule.status)) errors.push(`${label}.status is invalid`);
88
+ });
89
+ if (!Array.isArray(value.packs)) errors.push("profile.packs must be an array");
90
+ else value.packs.forEach((pack, index) => {
91
+ const label = `profile.packs[${index}]`;
92
+ if (!exactKeys(pack, ["id", "state", "reason"], [], label, errors)) return;
93
+ string(pack.id, `${label}.id`, errors, idPattern);
94
+ if (!packStates.includes(pack.state)) errors.push(`${label}.state is invalid`);
95
+ string(pack.reason, `${label}.reason`, errors);
96
+ });
97
+ strings(value.conflicts, "profile.conflicts", errors);
98
+ const ids = value.rules?.map((rule) => rule.id) ?? [];
99
+ if (new Set(ids).size !== ids.length) errors.push("profile.rules contains duplicate ids");
100
+ const enabled = new Set((value.packs ?? []).filter((item) => ["enabled", "required"].includes(item.state)).map((item) => item.id));
101
+ for (const pack of value.packs ?? []) {
102
+ if (!["enabled", "required"].includes(pack.state)) continue;
103
+ if (pack.id === "agent-workflow-governance" && enabled.has("forgerail-core")) errors.push("profile has duplicate core workflow owners");
104
+ }
105
+ }
106
+
107
+ function validateEnvelope(value, errors, label = "envelope") {
108
+ const keys = ["schemaVersion", "taskId", "intent", "nonGoals", "ownerWorkspace", "allowedOperations", "prohibitedOperations", "packs", "approvalGates", "validation", "returnContract"];
109
+ if (!exactKeys(value, keys, [], label, errors)) return;
110
+ schemaVersion(value.schemaVersion, label, errors);
111
+ string(value.taskId, `${label}.taskId`, errors, taskIdPattern);
112
+ string(value.intent, `${label}.intent`, errors);
113
+ strings(value.nonGoals, `${label}.nonGoals`, errors);
114
+ string(value.ownerWorkspace, `${label}.ownerWorkspace`, errors);
115
+ strings(value.allowedOperations, `${label}.allowedOperations`, errors, { unique: true });
116
+ strings(value.prohibitedOperations, `${label}.prohibitedOperations`, errors, { unique: true });
117
+ strings(value.packs, `${label}.packs`, errors, { pattern: idPattern, unique: true });
118
+ strings(value.approvalGates, `${label}.approvalGates`, errors, { pattern: idPattern, unique: true });
119
+ strings(value.validation, `${label}.validation`, errors);
120
+ if (value.returnContract !== "forgerail-return-receipt-v1") errors.push(`${label}.returnContract is invalid`);
121
+ const overlap = (value.allowedOperations ?? []).filter((item) => (value.prohibitedOperations ?? []).includes(item));
122
+ if (overlap.length > 0) errors.push(`${label} allows and prohibits the same operations: ${overlap.join(", ")}`);
123
+ }
124
+
125
+ function validateProfileCandidate(value, errors) {
126
+ const keys = ["schemaVersion", "candidateId", "workspace", "ruleId", "proposedValue", "evidence", "targetOwner", "targetSource", "reason", "requiresConfirmation", "status"];
127
+ if (!exactKeys(value, keys, [], "profileCandidate", errors)) return;
128
+ schemaVersion(value.schemaVersion, "profileCandidate", errors);
129
+ string(value.candidateId, "profileCandidate.candidateId", errors, taskIdPattern);
130
+ string(value.workspace, "profileCandidate.workspace", errors);
131
+ string(value.ruleId, "profileCandidate.ruleId", errors, ruleIdPattern);
132
+ strings(value.evidence, "profileCandidate.evidence", errors, { min: 1, unique: true });
133
+ string(value.targetOwner, "profileCandidate.targetOwner", errors);
134
+ string(value.targetSource, "profileCandidate.targetSource", errors);
135
+ string(value.reason, "profileCandidate.reason", errors);
136
+ if (value.requiresConfirmation !== true) errors.push("profileCandidate.requiresConfirmation must equal true");
137
+ if (value.status !== "candidate") errors.push("profileCandidate.status must equal candidate");
138
+ }
139
+
140
+ function validateLaunch(value, errors) {
141
+ if (!exactKeys(value, ["schemaVersion", "envelope", "effectiveRuleSources", "hostAgent", "executionOwner"], [], "launch", errors)) return;
142
+ schemaVersion(value.schemaVersion, "launch", errors);
143
+ validateEnvelope(value.envelope, errors, "launch.envelope");
144
+ strings(value.effectiveRuleSources, "launch.effectiveRuleSources", errors, { min: 1, unique: true });
145
+ string(value.hostAgent, "launch.hostAgent", errors);
146
+ if (value.executionOwner !== "host-agent") errors.push("launch.executionOwner must equal host-agent");
147
+ }
148
+
149
+ function validateReceipt(value, errors) {
150
+ const keys = ["schemaVersion", "taskId", "ownerWorkspace", "branch", "commit", "changedScope", "validationEvidence", "externalSideEffects", "confirmedNonMutations", "residualRisks", "rollbackOrRecovery", "deviations", "closeout"];
151
+ if (!exactKeys(value, keys, [], "receipt", errors)) return;
152
+ schemaVersion(value.schemaVersion, "receipt", errors);
153
+ string(value.taskId, "receipt.taskId", errors, taskIdPattern);
154
+ string(value.ownerWorkspace, "receipt.ownerWorkspace", errors);
155
+ nullableString(value.branch, "receipt.branch", errors);
156
+ nullableString(value.commit, "receipt.commit", errors, commitPattern);
157
+ strings(value.changedScope, "receipt.changedScope", errors);
158
+ strings(value.validationEvidence, "receipt.validationEvidence", errors, { min: 1 });
159
+ strings(value.externalSideEffects, "receipt.externalSideEffects", errors);
160
+ strings(value.confirmedNonMutations, "receipt.confirmedNonMutations", errors);
161
+ strings(value.residualRisks, "receipt.residualRisks", errors);
162
+ string(value.rollbackOrRecovery, "receipt.rollbackOrRecovery", errors);
163
+ strings(value.deviations, "receipt.deviations", errors);
164
+ if (!["complete", "incomplete", "blocked"].includes(value.closeout)) errors.push("receipt.closeout is invalid");
165
+ if (value.closeout === "complete" && value.deviations?.length > 0) errors.push("receipt with deviations cannot be complete");
166
+ }
167
+
168
+ function validateHostAdapter(value, errors) {
169
+ const keys = ["schemaVersion", "id", "displayName", "status", "instructionDiscovery", "skillDiscovery", "bindingTarget", "bindingModes", "managedMarker", "activationBoundary", "verification", "limitations"];
170
+ if (!exactKeys(value, keys, [], "hostAdapter", errors)) return;
171
+ schemaVersion(value.schemaVersion, "hostAdapter", errors);
172
+ string(value.id, "hostAdapter.id", errors, idPattern);
173
+ string(value.displayName, "hostAdapter.displayName", errors);
174
+ if (!["supported", "profile-only"].includes(value.status)) errors.push("hostAdapter.status is invalid");
175
+ if (!["task-start", "rules", "explicit-only", "unknown"].includes(value.instructionDiscovery)) errors.push("hostAdapter.instructionDiscovery is invalid");
176
+ if (!["agent-plugin-skills", "agent-skills", "explicit-only", "unknown"].includes(value.skillDiscovery)) errors.push("hostAdapter.skillDiscovery is invalid");
177
+ string(value.bindingTarget, "hostAdapter.bindingTarget", errors, /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/);
178
+ strings(value.bindingModes, "hostAdapter.bindingModes", errors, { min: 1, unique: true });
179
+ for (const mode of value.bindingModes ?? []) if (!["managed-block", "thin-reference"].includes(mode)) errors.push(`hostAdapter.bindingModes contains invalid mode: ${mode}`);
180
+ string(value.managedMarker, "hostAdapter.managedMarker", errors, /^forgerail:binding:[a-z][a-z0-9-]+:v1$/);
181
+ if (value.managedMarker !== `forgerail:binding:${value.id}:v1`) errors.push("hostAdapter.managedMarker must match hostAdapter.id");
182
+ if (!["new-task-required", "host-specific-verification-required"].includes(value.activationBoundary)) errors.push("hostAdapter.activationBoundary is invalid");
183
+ if (exactKeys(value.verification, ["mode", "expectedSkills"], [], "hostAdapter.verification", errors)) {
184
+ if (!["new-task-discovery", "profile-only"].includes(value.verification.mode)) errors.push("hostAdapter.verification.mode is invalid");
185
+ strings(value.verification.expectedSkills, "hostAdapter.verification.expectedSkills", errors, { pattern: idPattern, unique: true });
186
+ }
187
+ strings(value.limitations, "hostAdapter.limitations", errors, { unique: true });
188
+ if (value.status === "supported") {
189
+ if (value.verification?.mode !== "new-task-discovery") errors.push("supported hostAdapter must use new-task-discovery verification");
190
+ if ((value.verification?.expectedSkills?.length ?? 0) === 0) errors.push("supported hostAdapter must declare expected Skills");
191
+ if (value.activationBoundary !== "new-task-required") errors.push("supported hostAdapter must require a new task");
192
+ }
193
+ if (value.status === "profile-only") {
194
+ if (value.verification?.mode !== "profile-only") errors.push("profile-only hostAdapter must use profile-only verification");
195
+ if ((value.verification?.expectedSkills?.length ?? 0) !== 0) errors.push("profile-only hostAdapter cannot claim discovered Skills");
196
+ if (value.activationBoundary !== "host-specific-verification-required") errors.push("profile-only hostAdapter must require host-specific verification");
197
+ }
198
+ }
199
+
200
+ function validateAdoptionPlan(value, errors) {
201
+ const keys = ["schemaVersion", "planId", "workspace", "currentLevel", "proposedLevel", "strategy", "evidence", "hosts", "proposedWrites", "requiredConfirmation", "verification", "confirmedNonMutations", "mutations", "status"];
202
+ if (!exactKeys(value, keys, [], "adoptionPlan", errors)) return;
203
+ schemaVersion(value.schemaVersion, "adoptionPlan", errors);
204
+ string(value.planId, "adoptionPlan.planId", errors, taskIdPattern);
205
+ string(value.workspace, "adoptionPlan.workspace", errors);
206
+ const levels = ["plugin-only", "lightweight-adoption", "persisted-governance"];
207
+ if (!levels.includes(value.currentLevel)) errors.push("adoptionPlan.currentLevel is invalid");
208
+ if (!levels.includes(value.proposedLevel)) errors.push("adoptionPlan.proposedLevel is invalid");
209
+ if (!["no-change", "single-host-managed-block", "shared-contract-with-thin-bindings"].includes(value.strategy)) errors.push("adoptionPlan.strategy is invalid");
210
+ strings(value.evidence, "adoptionPlan.evidence", errors, { min: 1, unique: true });
211
+ if (!Array.isArray(value.hosts) || value.hosts.length === 0) errors.push("adoptionPlan.hosts must contain at least one host");
212
+ else value.hosts.forEach((host, index) => {
213
+ const label = `adoptionPlan.hosts[${index}]`;
214
+ if (!exactKeys(host, ["adapterId", "status", "bindingTarget", "verificationMode"], [], label, errors)) return;
215
+ string(host.adapterId, `${label}.adapterId`, errors, idPattern);
216
+ if (!["supported", "profile-only"].includes(host.status)) errors.push(`${label}.status is invalid`);
217
+ string(host.bindingTarget, `${label}.bindingTarget`, errors, /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/);
218
+ if (!["new-task-discovery", "profile-only"].includes(host.verificationMode)) errors.push(`${label}.verificationMode is invalid`);
219
+ if (host.status === "supported" && host.verificationMode !== "new-task-discovery") errors.push(`${label} supported host must use new-task-discovery`);
220
+ if (host.status === "profile-only" && host.verificationMode !== "profile-only") errors.push(`${label} profile-only host must not claim verified discovery`);
221
+ });
222
+ const hostIds = value.hosts?.map((host) => host.adapterId) ?? [];
223
+ if (new Set(hostIds).size !== hostIds.length) errors.push("adoptionPlan.hosts contains duplicate adapter ids");
224
+ if (!Array.isArray(value.proposedWrites)) errors.push("adoptionPlan.proposedWrites must be an array");
225
+ else value.proposedWrites.forEach((write, index) => {
226
+ const label = `adoptionPlan.proposedWrites[${index}]`;
227
+ if (!exactKeys(write, ["path", "operation", "baseSha256", "contentSha256", "content", "managedMarker"], [], label, errors)) return;
228
+ string(write.path, `${label}.path`, errors, /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).+$/);
229
+ if (!["create", "append-managed-block", "replace-managed-block"].includes(write.operation)) errors.push(`${label}.operation is invalid`);
230
+ nullableString(write.baseSha256, `${label}.baseSha256`, errors, digestPattern);
231
+ string(write.contentSha256, `${label}.contentSha256`, errors, digestPattern);
232
+ string(write.content, `${label}.content`, errors);
233
+ string(write.managedMarker, `${label}.managedMarker`, errors, /^forgerail:(?:binding:[a-z][a-z0-9-]+|adoption-contract):v1$/);
234
+ if (typeof write.content === "string" && write.contentSha256 !== sha256(write.content)) errors.push(`${label}.contentSha256 does not match content`);
235
+ if (typeof write.content === "string" && (!write.content.includes(`<!-- ${write.managedMarker}:start -->`) || !write.content.includes(`<!-- ${write.managedMarker}:end -->`))) errors.push(`${label}.content must contain its complete managed marker`);
236
+ if (write.operation === "create" && write.baseSha256 !== null) errors.push(`${label}.baseSha256 must be null for create`);
237
+ if (write.operation !== "create" && !digestPattern.test(write.baseSha256 ?? "")) errors.push(`${label}.baseSha256 is required for managed-block updates`);
238
+ if (write.path === ".forgerail" || write.path.startsWith(".forgerail/")) errors.push(`${label} cannot target deferred .forgerail state`);
239
+ });
240
+ const writePaths = value.proposedWrites?.map((write) => write.path) ?? [];
241
+ if (new Set(writePaths).size !== writePaths.length) errors.push("adoptionPlan.proposedWrites contains duplicate paths");
242
+ if (value.requiredConfirmation !== true) errors.push("adoptionPlan.requiredConfirmation must equal true");
243
+ strings(value.verification, "adoptionPlan.verification", errors, { min: 1, unique: true });
244
+ strings(value.confirmedNonMutations, "adoptionPlan.confirmedNonMutations", errors, { min: 1, unique: true });
245
+ if (!Array.isArray(value.mutations) || value.mutations.length !== 0) errors.push("adoptionPlan.mutations must be empty");
246
+ if (value.status !== "candidate") errors.push("adoptionPlan.status must equal candidate");
247
+ if (value.strategy === "no-change" && (value.proposedWrites?.length ?? 0) !== 0) errors.push("no-change adoptionPlan cannot propose writes");
248
+ if (value.strategy === "single-host-managed-block") {
249
+ if (value.hosts?.length !== 1) errors.push("single-host-managed-block requires exactly one host");
250
+ if (value.proposedWrites?.length !== 1) errors.push("single-host-managed-block requires exactly one proposed write");
251
+ if (value.proposedWrites?.[0]?.path !== value.hosts?.[0]?.bindingTarget) errors.push("single-host managed write must target its Host Adapter entry");
252
+ if (value.proposedWrites?.[0]?.managedMarker !== `forgerail:binding:${value.hosts?.[0]?.adapterId}:v1`) errors.push("single-host managed write marker must match its Host Adapter");
253
+ }
254
+ if (value.strategy === "shared-contract-with-thin-bindings") {
255
+ if ((value.hosts?.length ?? 0) < 2) errors.push("shared-contract-with-thin-bindings requires at least two hosts");
256
+ if (value.proposedWrites?.length !== (value.hosts?.length ?? 0) + 1) errors.push("shared-contract-with-thin-bindings requires one contract and one write per host");
257
+ const contract = value.proposedWrites?.find((write) => write.path === "FORGERAIL.md");
258
+ if (!contract) errors.push("shared-contract-with-thin-bindings must propose FORGERAIL.md");
259
+ else if (contract.managedMarker !== "forgerail:adoption-contract:v1") errors.push("FORGERAIL.md must use the portable Adoption Contract marker");
260
+ for (const host of value.hosts ?? []) {
261
+ const binding = value.proposedWrites?.find((write) => write.path === host.bindingTarget);
262
+ if (!binding) errors.push(`shared-contract plan is missing host binding: ${host.adapterId}`);
263
+ else if (binding.managedMarker !== `forgerail:binding:${host.adapterId}:v1`) errors.push(`shared-contract binding marker is invalid: ${host.adapterId}`);
264
+ }
265
+ }
266
+ if (value.proposedLevel === "persisted-governance") errors.push("persisted-governance plan generation is deferred in ForgeRail alpha.1");
267
+ }
268
+
269
+ function validateBindingReceipt(value, errors) {
270
+ const keys = ["schemaVersion", "planId", "workspace", "adoptionLevel", "contractPath", "hosts", "changedFiles", "validationEvidence", "discoveredSkills", "activationVerification", "confirmedNonMutations", "deviations", "closeout"];
271
+ if (!exactKeys(value, keys, [], "bindingReceipt", errors)) return;
272
+ schemaVersion(value.schemaVersion, "bindingReceipt", errors);
273
+ string(value.planId, "bindingReceipt.planId", errors, taskIdPattern);
274
+ string(value.workspace, "bindingReceipt.workspace", errors);
275
+ if (!["plugin-only", "lightweight-adoption", "persisted-governance"].includes(value.adoptionLevel)) errors.push("bindingReceipt.adoptionLevel is invalid");
276
+ nullableString(value.contractPath, "bindingReceipt.contractPath", errors);
277
+ if (!Array.isArray(value.hosts) || value.hosts.length === 0) errors.push("bindingReceipt.hosts must contain at least one host");
278
+ else value.hosts.forEach((host, index) => {
279
+ const label = `bindingReceipt.hosts[${index}]`;
280
+ if (!exactKeys(host, ["adapterId", "target", "baseSha256", "appliedSha256", "status", "verification"], [], label, errors)) return;
281
+ string(host.adapterId, `${label}.adapterId`, errors, idPattern);
282
+ string(host.target, `${label}.target`, errors);
283
+ nullableString(host.baseSha256, `${label}.baseSha256`, errors, digestPattern);
284
+ string(host.appliedSha256, `${label}.appliedSha256`, errors, digestPattern);
285
+ if (!["verified", "unverified", "failed"].includes(host.status)) errors.push(`${label}.status is invalid`);
286
+ strings(host.verification, `${label}.verification`, errors, { min: 1, unique: true });
287
+ });
288
+ strings(value.changedFiles, "bindingReceipt.changedFiles", errors, { unique: true });
289
+ strings(value.validationEvidence, "bindingReceipt.validationEvidence", errors, { min: 1, unique: true });
290
+ strings(value.discoveredSkills, "bindingReceipt.discoveredSkills", errors, { pattern: idPattern, unique: true });
291
+ if (exactKeys(value.activationVerification, ["mode", "verified"], [], "bindingReceipt.activationVerification", errors)) {
292
+ if (!["new-task", "equivalent-supported-discovery", "host-specific"].includes(value.activationVerification.mode)) errors.push("bindingReceipt.activationVerification.mode is invalid");
293
+ if (typeof value.activationVerification.verified !== "boolean") errors.push("bindingReceipt.activationVerification.verified must be a boolean");
294
+ }
295
+ strings(value.confirmedNonMutations, "bindingReceipt.confirmedNonMutations", errors, { min: 1, unique: true });
296
+ strings(value.deviations, "bindingReceipt.deviations", errors, { unique: true });
297
+ if (!["complete", "incomplete", "blocked"].includes(value.closeout)) errors.push("bindingReceipt.closeout is invalid");
298
+ if (value.closeout === "complete") {
299
+ if (value.activationVerification?.verified !== true) errors.push("complete bindingReceipt requires verified activation discovery");
300
+ if ((value.hosts ?? []).some((host) => host.status !== "verified")) errors.push("complete bindingReceipt requires every host binding to be verified");
301
+ if ((value.deviations?.length ?? 0) > 0) errors.push("bindingReceipt with deviations cannot be complete");
302
+ for (const host of value.hosts ?? []) if (!value.changedFiles?.includes(host.target)) errors.push(`complete bindingReceipt.changedFiles is missing host target: ${host.target}`);
303
+ if (value.contractPath !== null && !value.changedFiles?.includes(value.contractPath)) errors.push("complete bindingReceipt.changedFiles is missing contractPath");
304
+ }
305
+ }
306
+
307
+ export function validateContract(type, payload) {
308
+ const errors = [];
309
+ if (!contractTypes.includes(type)) return { valid: false, errors: [`unknown contract type: ${type}`] };
310
+ ({ pack: validatePack, profile: validateProfile, "profile-candidate": validateProfileCandidate, envelope: validateEnvelope, launch: validateLaunch, receipt: validateReceipt, "host-adapter": validateHostAdapter, "adoption-plan": validateAdoptionPlan, "binding-receipt": validateBindingReceipt })[type](payload, errors);
311
+ return { valid: errors.length === 0, errors };
312
+ }
313
+
314
+ export function readJson(path) {
315
+ return JSON.parse(readFileSync(path, "utf8"));
316
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, resolve } from "node:path";
3
+
4
+ function observed(id, source, value) {
5
+ return { id, kind: "observed_fact", source, value };
6
+ }
7
+
8
+ function safeJson(path) {
9
+ try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
10
+ }
11
+
12
+ function hasMarkdown(directory) {
13
+ if (!existsSync(directory) || !statSync(directory).isDirectory()) return false;
14
+ return readdirSync(directory, { withFileTypes: true }).some((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"));
15
+ }
16
+
17
+ export function diagnoseWorkspace(workspace) {
18
+ const root = resolve(workspace);
19
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error("workspace must be an existing directory");
20
+ const evidence = [];
21
+ const recommendations = [];
22
+ const confirmationRequired = [];
23
+
24
+ for (const entry of ["AGENTS.md", "CLAUDE.md", ".cursor/rules/forgerail.mdc", ".github/copilot-instructions.md", "README.md"]) {
25
+ if (existsSync(resolve(root, entry))) evidence.push(observed(`instructions:${entry}`, entry, "available"));
26
+ }
27
+
28
+ const hostAdapters = [
29
+ { id: "codex", status: "supported", target: "AGENTS.md", observed: existsSync(resolve(root, "AGENTS.md")) },
30
+ { id: "claude-code", status: "profile-only", target: "CLAUDE.md", observed: existsSync(resolve(root, "CLAUDE.md")) },
31
+ { id: "cursor", status: "profile-only", target: ".cursor/rules/forgerail.mdc", observed: existsSync(resolve(root, ".cursor/rules/forgerail.mdc")) },
32
+ ];
33
+ const managedBindingObserved = hostAdapters.some((adapter) => {
34
+ if (!adapter.observed) return false;
35
+ try { return readFileSync(resolve(root, adapter.target), "utf8").includes(`forgerail:binding:${adapter.id}:v1:start`); } catch { return false; }
36
+ });
37
+ const adoptionLevel = existsSync(resolve(root, ".forgerail"))
38
+ ? "persisted-governance"
39
+ : existsSync(resolve(root, "FORGERAIL.md")) || managedBindingObserved
40
+ ? "lightweight-adoption"
41
+ : "plugin-only";
42
+ evidence.push(observed("host-adapters", "bounded host instruction paths", hostAdapters));
43
+ evidence.push(observed("forgerail-adoption-level", "bounded ForgeRail markers", adoptionLevel));
44
+
45
+ const recordSystems = [];
46
+ if (existsSync(resolve(root, "openspec"))) recordSystems.push({ type: "openspec", source: "openspec/" });
47
+ if (existsSync(resolve(root, ".specify")) || existsSync(resolve(root, "specs"))) recordSystems.push({ type: "spec-kit-or-spec-directory", source: existsSync(resolve(root, ".specify")) ? ".specify/" : "specs/" });
48
+ for (const directory of ["docs/adr", "docs/adrs", "adr", "adrs", "decisions"]) {
49
+ if (hasMarkdown(resolve(root, directory))) recordSystems.push({ type: "markdown-adr", source: `${directory}/` });
50
+ }
51
+ if (hasMarkdown(resolve(root, "docs")) && recordSystems.length === 0) recordSystems.push({ type: "markdown-docs", source: "docs/" });
52
+ evidence.push(observed("record-systems", "bounded well-known paths", recordSystems));
53
+
54
+ const packageJsonPath = resolve(root, "package.json");
55
+ const packageJson = existsSync(packageJsonPath) ? safeJson(packageJsonPath) : null;
56
+ if (packageJson) evidence.push(observed("package-scripts", "package.json", Object.keys(packageJson.scripts ?? {}).sort()));
57
+
58
+ if (existsSync(resolve(root, ".git"))) evidence.push(observed("git-root", ".git/", "available"));
59
+ const skillRoots = [".codex/skills", ".agents/skills"].filter((path) => existsSync(resolve(root, path)));
60
+ evidence.push(observed("skill-roots", "bounded well-known paths", skillRoots));
61
+
62
+ if (recordSystems.length === 0) {
63
+ recommendations.push({
64
+ kind: "recommendation",
65
+ priority: "P1",
66
+ reason: "No bounded durable record practice was observed for non-trivial engineering work.",
67
+ options: ["continue with an existing custom documented practice", "use concise Markdown/ADR", "adopt OpenSpec or Spec Kit when structured change tracking is needed"],
68
+ });
69
+ confirmationRequired.push("Confirm the durable record target before creating files or installing a record system.");
70
+ }
71
+
72
+ return {
73
+ schemaVersion: "1.0",
74
+ mode: "read-only",
75
+ workspace: basename(root),
76
+ workspacePath: root,
77
+ evidence,
78
+ inheritedHabits: recordSystems,
79
+ gaps: recordSystems.length === 0 ? ["durable-record-practice-not-observed"] : [],
80
+ recommendations,
81
+ confirmationRequired,
82
+ mutations: [],
83
+ adoption: {
84
+ currentLevel: adoptionLevel,
85
+ recommendedLevel: adoptionLevel,
86
+ changeRecommended: false,
87
+ reason: "ForgeRail keeps the minimum observed adoption level unless the user requests durable adoption or concrete evidence justifies escalation.",
88
+ planCommandAvailable: "forgerail adoption-plan --workspace . --host codex",
89
+ persistedGovernanceGeneration: "deferred",
90
+ },
91
+ fullHealthReviewRecommended: evidence.filter((item) => item.id.startsWith("instructions:")).length > 2,
92
+ };
93
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const core = readFileSync(resolve(root, "skills/forgerail/SKILL.md"), "utf8");
9
+ const diagnosis = readFileSync(resolve(root, "skills/forgerail-workspace-diagnosis/SKILL.md"), "utf8");
10
+ const health = readFileSync(resolve(root, "skills/workspace-health-review/SKILL.md"), "utf8");
11
+ const frozen = JSON.parse(readFileSync(resolve(root, "docs/agw-frozen-baseline.json"), "utf8"));
12
+
13
+ function externalSkill(plugin, skill = plugin) {
14
+ const candidates = [
15
+ resolve(root, `../${plugin}/skills/${skill}/SKILL.md`),
16
+ resolve(root, `plugins/${plugin}/skills/${skill}/SKILL.md`),
17
+ ];
18
+ const path = candidates.find((candidate) => existsSync(candidate));
19
+ if (!path) throw new Error(`external Capability Pack entry is missing: ${plugin}`);
20
+ return readFileSync(path, "utf8");
21
+ }
22
+
23
+ const rulesets = externalSkill("forgerail-github-rulesets");
24
+ const releaseSafety = externalSkill("forgerail-release-safety");
25
+ const threadClosure = externalSkill("forgerail-thread-closure");
26
+ const agwEvidence = frozen.behaviorAssertions;
27
+
28
+ const scenarios = [
29
+ {
30
+ id: "feature-branch-records",
31
+ agwEvidence: agwEvidence.featureBranchRecords,
32
+ forgerailEvidence: ["smallest owner workspace", "Task Envelope", "existing habits"],
33
+ source: `${agwEvidence.featureBranchRecords.join("\n")}\n${core}\n${diagnosis}`,
34
+ status: "covered",
35
+ },
36
+ {
37
+ id: "dirty-worktree-preservation",
38
+ agwEvidence: agwEvidence.dirtyWorktreePreservation,
39
+ forgerailEvidence: ["preserve unrelated user changes", "dirty-worktree state"],
40
+ source: `${agwEvidence.dirtyWorktreePreservation.join("\n")}\n${core}`,
41
+ status: "covered",
42
+ },
43
+ {
44
+ id: "markdown-existing-habit",
45
+ agwEvidence: agwEvidence.existingRecordHabit,
46
+ forgerailEvidence: ["existing habits", "OpenSpec may be a preferred example"],
47
+ source: `${agwEvidence.existingRecordHabit.join("\n")}\n${diagnosis}`,
48
+ status: "covered",
49
+ },
50
+ {
51
+ id: "workspace-health",
52
+ agwEvidence: agwEvidence.workspaceHealth,
53
+ forgerailEvidence: ["first built-in ForgeRail Capability Pack", "Analyze First"],
54
+ source: `${agwEvidence.workspaceHealth.join("\n")}\n${health}`,
55
+ status: "covered-with-follow-up",
56
+ },
57
+ {
58
+ id: "github-rulesets-read-first",
59
+ agwEvidence: agwEvidence.githubRulesets,
60
+ forgerailEvidence: ["read-only diagnosis", "Stop until the user explicitly approves"],
61
+ source: `${agwEvidence.githubRulesets.join("\n")}\n${rulesets}`,
62
+ status: "covered",
63
+ },
64
+ {
65
+ id: "release-safety-project-runbook",
66
+ agwEvidence: agwEvidence.releaseSafety,
67
+ forgerailEvidence: ["project-owned release runbook", "does not contain publish, deploy"],
68
+ source: `${agwEvidence.releaseSafety.join("\n")}\n${releaseSafety}`,
69
+ status: "covered",
70
+ },
71
+ {
72
+ id: "evidence-first-thread-closure",
73
+ agwEvidence: agwEvidence.threadClosure,
74
+ forgerailEvidence: ["Keep closeout incomplete", "Do not implement follow-up work"],
75
+ source: `${agwEvidence.threadClosure.join("\n")}\n${core}\n${threadClosure}`,
76
+ status: "covered",
77
+ },
78
+ ];
79
+
80
+ for (const scenario of scenarios) {
81
+ scenario.missingAgw = scenario.agwEvidence.filter((phrase) => !scenario.source.toLocaleLowerCase("en-US").includes(phrase.toLocaleLowerCase("en-US")));
82
+ scenario.missingForgeRail = scenario.forgerailEvidence.filter((phrase) => !scenario.source.toLocaleLowerCase("en-US").includes(phrase.toLocaleLowerCase("en-US")));
83
+ scenario.passed = scenario.missingAgw.length === 0 && scenario.missingForgeRail.length === 0 && scenario.status !== "unresolved";
84
+ delete scenario.source;
85
+ }
86
+
87
+ const result = {
88
+ schemaVersion: "1.0",
89
+ agwBaseline: "plugins/agent-workflow-governance@0.2.0-canonical",
90
+ forgeRailCandidate: "plugins/forgerail@0.1.0-alpha.1-canonical",
91
+ scenarios,
92
+ covered: scenarios.filter((item) => item.passed).length,
93
+ unresolved: scenarios.filter((item) => !item.passed).map((item) => item.id),
94
+ behaviorCoverageReady: scenarios.every((item) => item.passed),
95
+ migrationReady: false,
96
+ migrationBlockers: ["usable ForgeRail prerelease is not published", "real compatibility-period canaries are incomplete", "AGW lifecycle change is not approved"],
97
+ };
98
+ console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const pluginParentCandidates = [resolve(root, ".."), resolve(root, "plugins")];
9
+ const pluginParent = pluginParentCandidates.find((candidate) => existsSync(resolve(candidate, "forgerail-github-rulesets/scripts/validate.mjs")));
10
+ if (!pluginParent) {
11
+ console.error(JSON.stringify({ schemaVersion: "1.0", status: "failed", blocker: "external-capability-pack-layout-not-found" }, null, 2));
12
+ process.exit(1);
13
+ }
14
+ const entries = [
15
+ "forgerail-cross-workspace-orchestration/scripts/validate.mjs",
16
+ "forgerail-github-rulesets/scripts/validate.mjs",
17
+ "forgerail-release-safety/scripts/validate.mjs",
18
+ "forgerail-thread-closure/scripts/validate.mjs",
19
+ ];
20
+ const results = [];
21
+ for (const entry of entries) {
22
+ const result = spawnSync(process.execPath, [resolve(pluginParent, entry)], { cwd: root, encoding: "utf8" });
23
+ results.push({ entry, status: result.status === 0 ? "passed" : "failed", exitCode: result.status, output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() });
24
+ }
25
+ const report = { schemaVersion: "1.0", status: results.every(({ status }) => status === "passed") ? "passed" : "failed", results };
26
+ console.log(JSON.stringify(report, null, 2));
27
+ if (report.status !== "passed") process.exitCode = 1;