@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,11 @@
1
+ {
2
+ "workspace": "fixture-workspace",
3
+ "rules": [],
4
+ "packs": [
5
+ {
6
+ "id": "github-rulesets",
7
+ "state": "enabled",
8
+ "reason": "The project explicitly enabled the separately installed Rulesets pack."
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "workspace": "fixture-workspace",
3
+ "rules": [],
4
+ "packs": [
5
+ {
6
+ "id": "cross-workspace-orchestration",
7
+ "state": "available",
8
+ "reason": "Installed for on-demand use; current task has no authoritative enablement."
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "taskId": "fixture-task-1",
4
+ "ownerWorkspace": "fixture-workspace",
5
+ "branch": "feat/fixture",
6
+ "commit": "1111111111111111111111111111111111111111",
7
+ "changedScope": ["README.md"],
8
+ "validationEvidence": ["git diff --check passed"],
9
+ "externalSideEffects": [],
10
+ "confirmedNonMutations": ["no release"],
11
+ "residualRisks": [],
12
+ "rollbackOrRecovery": "Revert the local documentation commit.",
13
+ "deviations": ["A prohibited operation was attempted."],
14
+ "closeout": "complete"
15
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "taskId": "fixture-task-1",
4
+ "ownerWorkspace": "fixture-workspace",
5
+ "branch": "feat/fixture",
6
+ "commit": "1111111111111111111111111111111111111111",
7
+ "changedScope": ["README.md"],
8
+ "validationEvidence": ["git diff --check passed"],
9
+ "externalSideEffects": [],
10
+ "confirmedNonMutations": ["no push", "no merge", "no release"],
11
+ "residualRisks": [],
12
+ "rollbackOrRecovery": "Revert the local documentation commit.",
13
+ "deviations": [],
14
+ "closeout": "complete"
15
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "taskId": "fixture-task-overlap",
4
+ "intent": "Demonstrate fail-closed operation overlap.",
5
+ "nonGoals": [],
6
+ "ownerWorkspace": "fixture-workspace",
7
+ "allowedOperations": ["push"],
8
+ "prohibitedOperations": ["push"],
9
+ "packs": [],
10
+ "approvalGates": [],
11
+ "validation": ["contract validation"],
12
+ "returnContract": "forgerail-return-receipt-v1"
13
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "taskId": "fixture-task-1",
4
+ "intent": "Update local documentation with ForgeRail guidance.",
5
+ "nonGoals": ["Publish a release", "Change repository settings"],
6
+ "ownerWorkspace": "fixture-workspace",
7
+ "allowedOperations": ["read files", "edit documentation", "run local validation"],
8
+ "prohibitedOperations": ["push", "merge", "tag", "release", "ruleset mutation"],
9
+ "packs": [],
10
+ "approvalGates": ["remote-integration-approval", "release-approval"],
11
+ "validation": ["documentation links resolve", "git diff --check passes"],
12
+ "returnContract": "forgerail-return-receipt-v1"
13
+ }
@@ -0,0 +1,3 @@
1
+ # Empty record fixture
2
+
3
+ This workspace intentionally has no declared durable engineering record practice.
@@ -0,0 +1,3 @@
1
+ # Fixture Instructions
2
+
3
+ Use the existing Markdown ADRs for durable architecture decisions. Do not initialize another specification system.
@@ -0,0 +1,3 @@
1
+ # ADR 0001: Markdown decisions
2
+
3
+ This fixture records decisions as Markdown ADRs.
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "forgerail-markdown-fixture",
3
+ "private": true,
4
+ "scripts": {
5
+ "test": "node --test"
6
+ }
7
+ }
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { dirname, relative, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { loadHostAdapters, planAdoption } from "./lib/adoption.mjs";
8
+ import { buildBundle } from "./lib/bundle.mjs";
9
+ import { createLaunchContract, resolveProfile, verifyReceipt } from "./lib/composition.mjs";
10
+ import { contractTypes, readJson, validateContract } from "./lib/contracts.mjs";
11
+ import { diagnoseWorkspace } from "./lib/diagnosis.mjs";
12
+
13
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
14
+
15
+ function fail(message) { console.error(`forgerail: ${message}`); process.exit(1); }
16
+ function emit(value) { console.log(JSON.stringify(value, null, 2)); }
17
+ function arg(name) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; }
18
+ function args(name) {
19
+ const values = [];
20
+ process.argv.forEach((value, index) => { if (value === name && process.argv[index + 1]) values.push(process.argv[index + 1]); });
21
+ return values;
22
+ }
23
+
24
+ function workspaceSnapshot(path) {
25
+ return readdirSync(path, { recursive: true })
26
+ .sort()
27
+ .map((entry) => {
28
+ const target = resolve(path, entry);
29
+ const stat = statSync(target);
30
+ return stat.isFile() ? `${entry}:file:${createHash("sha256").update(readFileSync(target)).digest("hex")}` : `${entry}:directory`;
31
+ });
32
+ }
33
+
34
+ function validatePlugin() {
35
+ const errors = [];
36
+ const manifestPath = resolve(root, ".codex-plugin/plugin.json");
37
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
38
+ if (manifest.name !== "forgerail") errors.push("Plugin name must be forgerail");
39
+ if (manifest.version !== "0.1.0-alpha.1") errors.push("Plugin version must be 0.1.0-alpha.1");
40
+ if (manifest.license !== "Apache-2.0") errors.push("Plugin license must be Apache-2.0");
41
+ const expectedSkills = ["forgerail", "forgerail-workspace-diagnosis", "workspace-health-review"];
42
+ const actualSkills = readdirSync(resolve(root, "skills"), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
43
+ if (JSON.stringify(actualSkills) !== JSON.stringify(expectedSkills)) errors.push(`Expected Skills ${expectedSkills.join(", ")}; received ${actualSkills.join(", ")}`);
44
+ for (const skill of expectedSkills) {
45
+ const skillRoot = resolve(root, "skills", skill);
46
+ const content = readFileSync(resolve(skillRoot, "SKILL.md"), "utf8");
47
+ const name = content.match(/^---\s*[\s\S]*?^name:\s*([^\n]+)$/m)?.[1]?.trim();
48
+ if (name !== skill) errors.push(`${skill} frontmatter name is invalid`);
49
+ if (!existsSync(resolve(skillRoot, "agents/openai.yaml"))) errors.push(`${skill}/agents/openai.yaml is missing`);
50
+ for (const match of content.matchAll(/`(references\/[a-z0-9._/-]+\.md)`/g)) {
51
+ if (!existsSync(resolve(skillRoot, match[1]))) errors.push(`${skill} reference is missing: ${match[1]}`);
52
+ }
53
+ }
54
+ for (const type of contractTypes) {
55
+ const name = { pack: "capability-pack", profile: "effective-profile", "profile-candidate": "profile-change-candidate", envelope: "task-envelope", launch: "launch-contract", receipt: "return-receipt", "host-adapter": "host-adapter", "adoption-plan": "adoption-plan", "binding-receipt": "host-binding-receipt" }[type];
56
+ JSON.parse(readFileSync(resolve(root, "contracts", `${name}.schema.json`), "utf8"));
57
+ }
58
+ const adapterRegistry = loadHostAdapters(root);
59
+ if (!adapterRegistry.valid) errors.push(...adapterRegistry.errors);
60
+ const packPath = resolve(root, "packs/workspace-health-review.json");
61
+ const packResult = validateContract("pack", readJson(packPath));
62
+ if (!packResult.valid) errors.push(...packResult.errors);
63
+ const coverage = readJson(resolve(root, "docs/agw-coverage-baseline.json"));
64
+ const allowedDispositions = new Set(coverage.allowedDispositions);
65
+ if (new Set(coverage.items.map((item) => item.id)).size !== coverage.items.length) errors.push("AGW coverage contains duplicate ids");
66
+ for (const item of coverage.items) {
67
+ if (!allowedDispositions.has(item.target)) errors.push(`AGW coverage target is invalid: ${item.id}`);
68
+ if (!["mapped", "unresolved"].includes(item.status)) errors.push(`AGW coverage status is invalid: ${item.id}`);
69
+ if (item.status === "unresolved" && item.target !== "unresolved") errors.push(`AGW unresolved item must target unresolved: ${item.id}`);
70
+ }
71
+ if (coverage.migrationReady && coverage.items.some((item) => item.status !== "mapped")) errors.push("AGW migration cannot be ready with unresolved coverage");
72
+ const contextBytes = Object.fromEntries(actualSkills.map((skill) => [skill, readFileSync(resolve(root, "skills", skill, "SKILL.md")).length]));
73
+ return { valid: errors.length === 0, errors, skills: actualSkills, contracts: contractTypes, contextBytes };
74
+ }
75
+
76
+ function validateFixtures() {
77
+ const fixtureRoot = resolve(root, "scripts/fixtures");
78
+ const cases = [
79
+ ["profile", "contracts/effective-profile.valid.json", true],
80
+ ["profile-candidate", "contracts/profile-change-candidate.valid.json", true],
81
+ ["envelope", "contracts/task-envelope.valid.json", true],
82
+ ["envelope", "contracts/task-envelope.overlap.invalid.json", false],
83
+ ["launch", "contracts/launch-contract.valid.json", true],
84
+ ["receipt", "contracts/return-receipt.valid.json", true],
85
+ ["receipt", "contracts/return-receipt.deviation.invalid.json", false],
86
+ ["host-adapter", "contracts/host-adapter.codex.valid.json", true],
87
+ ["host-adapter", "contracts/host-adapter.claude-code.profile-only.valid.json", true],
88
+ ["host-adapter", "contracts/host-adapter.cursor.profile-only.valid.json", true],
89
+ ["adoption-plan", "contracts/adoption-plan.single-host.valid.json", true],
90
+ ["adoption-plan", "contracts/adoption-plan.multi-host.valid.json", true],
91
+ ["adoption-plan", "contracts/adoption-plan.mutating.invalid.json", false],
92
+ ["binding-receipt", "contracts/host-binding-receipt.valid.json", true],
93
+ ["binding-receipt", "contracts/host-binding-receipt.unverified.invalid.json", false],
94
+ ];
95
+ const results = cases.map(([type, path, expected]) => {
96
+ const result = validateContract(type, readJson(resolve(fixtureRoot, path)));
97
+ return { type, path, expected, actual: result.valid, passed: result.valid === expected, errors: result.errors };
98
+ });
99
+ for (const workspace of ["markdown-existing", "empty-records"]) {
100
+ const path = resolve(fixtureRoot, "workspaces", workspace);
101
+ const before = JSON.stringify(workspaceSnapshot(path));
102
+ const diagnosis = diagnoseWorkspace(path);
103
+ const after = JSON.stringify(workspaceSnapshot(path));
104
+ results.push({ type: "diagnosis", path: relative(fixtureRoot, path), expected: true, actual: diagnosis.mutations.length === 0 && before === after, passed: diagnosis.mutations.length === 0 && before === after, errors: [] });
105
+ }
106
+ const manifests = readdirSync(resolve(root, "packs")).filter((name) => name.endsWith(".json")).map((name) => readJson(resolve(root, "packs", name)));
107
+ const available = resolveProfile(readJson(resolve(fixtureRoot, "contracts/profile-input.available-pack.json")), manifests);
108
+ results.push({ type: "composition", path: "contracts/profile-input.available-pack.json", expected: true, actual: available.valid && available.activePacks.length === 0 && available.profile.rules[0]?.value === "release", passed: available.valid && available.activePacks.length === 0 && available.profile.rules[0]?.value === "release", errors: available.errors });
109
+ const conflict = resolveProfile(readJson(resolve(fixtureRoot, "contracts/profile-input.conflict.json")), manifests);
110
+ results.push({ type: "composition", path: "contracts/profile-input.conflict.json", expected: false, actual: conflict.valid, passed: !conflict.valid && conflict.profile.conflicts.length === 1, errors: conflict.errors });
111
+ const orchestrationPackCandidates = [
112
+ resolve(root, "../forgerail-cross-workspace-orchestration/pack.json"),
113
+ resolve(root, "plugins/forgerail-cross-workspace-orchestration/pack.json"),
114
+ ];
115
+ const orchestrationPackPath = orchestrationPackCandidates.find((path) => existsSync(path));
116
+ if (orchestrationPackPath) {
117
+ const orchestrationPack = readJson(orchestrationPackPath);
118
+ const orchestrationPackValidation = validateContract("pack", orchestrationPack);
119
+ const orchestrationAvailable = resolveProfile(readJson(resolve(fixtureRoot, "contracts/profile-input.orchestration-available.json")), [...manifests, orchestrationPack]);
120
+ results.push({
121
+ type: "composition",
122
+ path: "contracts/profile-input.orchestration-available.json",
123
+ expected: true,
124
+ actual: orchestrationPackValidation.valid && orchestrationAvailable.valid && orchestrationAvailable.activePacks.length === 0,
125
+ passed: orchestrationPackValidation.valid && orchestrationAvailable.valid && orchestrationAvailable.activePacks.length === 0,
126
+ errors: [...orchestrationPackValidation.errors, ...orchestrationAvailable.errors],
127
+ });
128
+ } else results.push({ type: "composition", path: "cross-workspace-orchestration-manifest", expected: true, actual: false, passed: false, errors: ["external orchestration Pack manifest is unavailable"] });
129
+ const inactiveLaunch = createLaunchContract(available.profile, { ...readJson(resolve(fixtureRoot, "contracts/task-envelope.valid.json")), packs: ["workspace-health-review"] }, "Codex");
130
+ results.push({ type: "launch", path: "inactive-pack", expected: false, actual: inactiveLaunch.valid, passed: !inactiveLaunch.valid && inactiveLaunch.errors.some((error) => error.includes("inactive pack")), errors: inactiveLaunch.errors });
131
+ const receipt = readJson(resolve(fixtureRoot, "contracts/return-receipt.valid.json"));
132
+ const mismatch = verifyReceipt({ ...receipt, branch: "not-the-current-branch", commit: null }, resolve(root, "../.."));
133
+ results.push({ type: "receipt-observation", path: "observable-git-mismatch", expected: false, actual: mismatch.valid, passed: !mismatch.valid && mismatch.closeout === "incomplete", errors: mismatch.errors });
134
+ const adoption = validateAdoption();
135
+ results.push({ type: "adoption", path: "read-only-planner", expected: true, actual: adoption.passed, passed: adoption.passed, errors: adoption.errors });
136
+ return { passed: results.every((item) => item.passed), results };
137
+ }
138
+
139
+ function validateAdoption() {
140
+ const errors = [];
141
+ const registry = loadHostAdapters(root);
142
+ if (!registry.valid) errors.push(...registry.errors);
143
+ const workspace = resolve(root, "scripts/fixtures/workspaces/markdown-existing");
144
+ const before = JSON.stringify(workspaceSnapshot(workspace));
145
+ let single;
146
+ let multi;
147
+ try {
148
+ single = planAdoption(root, workspace, ["codex"]);
149
+ multi = planAdoption(root, workspace, ["codex", "claude-code", "cursor"]);
150
+ } catch (error) {
151
+ errors.push(error.message);
152
+ }
153
+ const after = JSON.stringify(workspaceSnapshot(workspace));
154
+ if (before !== after) errors.push("adoption planning mutated its fixture workspace");
155
+ if (single?.strategy !== "single-host-managed-block" || single?.proposedWrites?.length !== 1 || single?.proposedWrites?.[0]?.path !== "AGENTS.md") errors.push("single-host plan is not a bounded AGENTS.md managed block");
156
+ if (multi?.strategy !== "shared-contract-with-thin-bindings" || !multi?.proposedWrites?.some((write) => write.path === "FORGERAIL.md")) errors.push("multi-host plan is missing the shared Adoption Contract");
157
+ if (multi?.hosts?.find((host) => host.adapterId === "claude-code")?.status !== "profile-only" || multi?.hosts?.find((host) => host.adapterId === "cursor")?.status !== "profile-only") errors.push("unverified hosts must remain profile-only");
158
+ if ([...(single?.proposedWrites ?? []), ...(multi?.proposedWrites ?? [])].some((write) => write.path === ".forgerail" || write.path.startsWith(".forgerail/"))) errors.push("alpha.1 adoption plan cannot propose .forgerail state");
159
+ try {
160
+ planAdoption(root, workspace, ["codex"], "persisted-governance");
161
+ errors.push("persisted-governance planning must be refused in alpha.1");
162
+ } catch (error) {
163
+ if (!error.message.includes("evidence-gated")) errors.push(`unexpected persisted-governance error: ${error.message}`);
164
+ }
165
+ return { passed: errors.length === 0, errors, adapters: registry.adapters.map(({ id, status }) => ({ id, status })), single, multi };
166
+ }
167
+
168
+ const [command] = process.argv.slice(2);
169
+ if (command === "validate") {
170
+ const result = validatePlugin(); emit(result); if (!result.valid) process.exitCode = 1;
171
+ } else if (command === "validate-fixtures") {
172
+ const result = validateFixtures(); emit(result); if (!result.passed) process.exitCode = 1;
173
+ } else if (command === "validate-adoption") {
174
+ const result = validateAdoption(); emit(result); if (!result.passed) process.exitCode = 1;
175
+ } else if (command === "validate-contract") {
176
+ const type = arg("--type"); const file = arg("--file");
177
+ if (!type || !file) fail("validate-contract requires --type and --file");
178
+ const result = validateContract(type, readJson(resolve(file))); emit(result); if (!result.valid) process.exitCode = 1;
179
+ } else if (command === "diagnose") {
180
+ const workspace = arg("--workspace"); if (!workspace) fail("diagnose requires --workspace"); emit(diagnoseWorkspace(workspace));
181
+ } else if (command === "adoption-plan") {
182
+ const workspace = arg("--workspace"); const hosts = args("--host"); const level = arg("--level") ?? "lightweight-adoption";
183
+ if (!workspace || hosts.length === 0) fail("adoption-plan requires --workspace and at least one --host");
184
+ try { emit(planAdoption(root, workspace, hosts, level)); } catch (error) { fail(error.message); }
185
+ } else if (command === "resolve-profile") {
186
+ const file = arg("--file"); if (!file) fail("resolve-profile requires --file");
187
+ const manifests = [
188
+ ...readdirSync(resolve(root, "packs")).filter((name) => name.endsWith(".json")).map((name) => readJson(resolve(root, "packs", name))),
189
+ ...args("--pack-manifest").map((path) => readJson(resolve(path))),
190
+ ];
191
+ for (const manifest of manifests) {
192
+ const validation = validateContract("pack", manifest);
193
+ if (!validation.valid) fail(`invalid pack manifest ${manifest.id ?? "unknown"}: ${validation.errors.join("; ")}`);
194
+ }
195
+ const result = resolveProfile(readJson(resolve(file)), manifests); emit(result); if (!result.valid) process.exitCode = 1;
196
+ } else if (command === "launch") {
197
+ const profile = arg("--profile"); const envelope = arg("--envelope"); const hostAgent = arg("--host-agent");
198
+ if (!profile || !envelope || !hostAgent) fail("launch requires --profile, --envelope, and --host-agent");
199
+ const profilePayload = readJson(resolve(profile));
200
+ const effectiveProfile = profilePayload.profile ?? profilePayload;
201
+ const result = createLaunchContract(effectiveProfile, readJson(resolve(envelope)), hostAgent); emit(result); if (!result.valid) process.exitCode = 1;
202
+ } else if (command === "verify-receipt") {
203
+ const receipt = arg("--receipt"); const workspace = arg("--workspace");
204
+ if (!receipt || !workspace) fail("verify-receipt requires --receipt and --workspace");
205
+ const result = verifyReceipt(readJson(resolve(receipt)), workspace); emit(result); if (!result.valid) process.exitCode = 1;
206
+ } else if (command === "build-bundle") {
207
+ const output = arg("--output"); if (!output) fail("build-bundle requires --output");
208
+ const result = buildBundle(root, output);
209
+ emit(process.argv.includes("--summary") ? { schemaVersion: result.schemaVersion, productId: result.productId, projection: result.projection, fileCount: result.fileCount, totalBytes: result.totalBytes, digest: result.digest, receiptDigest: result.receiptDigest } : result);
210
+ } else fail("usage: forgerail.mjs validate | validate-fixtures | validate-adoption | validate-contract | diagnose | adoption-plan | resolve-profile | launch | verify-receipt | build-bundle");
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { basename, resolve } from "node:path";
4
+ import { validateContract } from "./contracts.mjs";
5
+
6
+ const levels = ["plugin-only", "lightweight-adoption", "persisted-governance"];
7
+
8
+ function sha256(value) {
9
+ return createHash("sha256").update(value).digest("hex");
10
+ }
11
+
12
+ function read(path) {
13
+ return readFileSync(path, "utf8");
14
+ }
15
+
16
+ function adapterFiles(pluginRoot) {
17
+ return readdirSync(resolve(pluginRoot, "adapters"))
18
+ .filter((name) => name.endsWith(".json"))
19
+ .sort();
20
+ }
21
+
22
+ export function loadHostAdapters(pluginRoot) {
23
+ const adapters = adapterFiles(pluginRoot).map((name) => JSON.parse(read(resolve(pluginRoot, "adapters", name))));
24
+ const errors = [];
25
+ const ids = new Set();
26
+ for (const adapter of adapters) {
27
+ const validation = validateContract("host-adapter", adapter);
28
+ if (!validation.valid) errors.push(...validation.errors.map((error) => `${adapter.id ?? name}: ${error}`));
29
+ if (ids.has(adapter.id)) errors.push(`duplicate host adapter: ${adapter.id}`);
30
+ ids.add(adapter.id);
31
+ }
32
+ return { valid: errors.length === 0, errors, adapters };
33
+ }
34
+
35
+ export function observeAdoptionLevel(workspace, adapters = []) {
36
+ const root = resolve(workspace);
37
+ if (existsSync(resolve(root, ".forgerail"))) return "persisted-governance";
38
+ if (existsSync(resolve(root, "FORGERAIL.md"))) return "lightweight-adoption";
39
+ for (const adapter of adapters) {
40
+ const target = resolve(root, adapter.bindingTarget);
41
+ if (existsSync(target) && statSync(target).isFile() && read(target).includes(`<!-- ${adapter.managedMarker}:start -->`)) return "lightweight-adoption";
42
+ }
43
+ return "plugin-only";
44
+ }
45
+
46
+ function templateName(adapterId, strategy) {
47
+ if (adapterId === "codex") return strategy === "single-host-managed-block" ? "codex-compact.md" : "codex-thin.md";
48
+ if (adapterId === "claude-code") return "claude-code-thin.md";
49
+ if (adapterId === "cursor") return "cursor-thin.mdc";
50
+ throw new Error(`no binding template for host adapter: ${adapterId}`);
51
+ }
52
+
53
+ function proposedWrite(workspace, path, content, managedMarker) {
54
+ const target = resolve(workspace, path);
55
+ const exists = existsSync(target);
56
+ if (exists && !statSync(target).isFile()) throw new Error(`adoption target is not a file: ${path}`);
57
+ const prior = exists ? read(target) : null;
58
+ const start = `<!-- ${managedMarker}:start -->`;
59
+ const end = `<!-- ${managedMarker}:end -->`;
60
+ const hasStart = prior?.includes(start) ?? false;
61
+ const hasEnd = prior?.includes(end) ?? false;
62
+ if (hasStart !== hasEnd) throw new Error(`adoption target has an incomplete managed marker: ${path}`);
63
+ if (hasStart && prior.indexOf(start) > prior.indexOf(end)) throw new Error(`adoption target has reversed managed markers: ${path}`);
64
+ if (hasStart && (prior.match(new RegExp(start.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))?.length ?? 0) !== 1) throw new Error(`adoption target has duplicate managed markers: ${path}`);
65
+ if (exists && path === ".cursor/rules/forgerail.mdc" && !hasStart) throw new Error("Cursor binding target already exists without a ForgeRail managed marker");
66
+ const operation = exists ? (hasStart ? "replace-managed-block" : "append-managed-block") : "create";
67
+ const approvedContent = operation === "replace-managed-block" && content.indexOf(start) > 0
68
+ ? `${content.slice(content.indexOf(start), content.indexOf(end) + end.length)}\n`
69
+ : content;
70
+ return {
71
+ path,
72
+ operation,
73
+ baseSha256: prior === null ? null : sha256(prior),
74
+ contentSha256: sha256(approvedContent),
75
+ content: approvedContent,
76
+ managedMarker,
77
+ };
78
+ }
79
+
80
+ export function renderProposedWrite(workspace, write) {
81
+ const target = resolve(workspace, write.path);
82
+ const prior = existsSync(target) ? read(target) : "";
83
+ if (write.operation === "create") return write.content;
84
+ if (sha256(prior) !== write.baseSha256) throw new Error(`base digest drifted for ${write.path}`);
85
+ if (write.operation === "append-managed-block") return `${prior.replace(/\s*$/, "")}\n\n${write.content}`;
86
+ const start = `<!-- ${write.managedMarker}:start -->`;
87
+ const end = `<!-- ${write.managedMarker}:end -->`;
88
+ const startIndex = prior.indexOf(start);
89
+ const endIndex = prior.indexOf(end, startIndex);
90
+ if (startIndex < 0 || endIndex < 0) throw new Error(`managed block is missing for ${write.path}`);
91
+ return `${prior.slice(0, startIndex)}${write.content}${prior.slice(endIndex + end.length)}`;
92
+ }
93
+
94
+ export function planAdoption(pluginRoot, workspace, hostIds, proposedLevel = "lightweight-adoption") {
95
+ const root = resolve(workspace);
96
+ if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error("workspace must be an existing directory");
97
+ if (!levels.includes(proposedLevel)) throw new Error(`unknown adoption level: ${proposedLevel}`);
98
+ if (proposedLevel === "persisted-governance") throw new Error("persisted-governance is evidence-gated and deferred in ForgeRail alpha.1");
99
+ if (!Array.isArray(hostIds) || hostIds.length === 0) throw new Error("at least one explicit --host is required");
100
+ if (new Set(hostIds).size !== hostIds.length) throw new Error("host selection contains duplicates");
101
+ const registry = loadHostAdapters(pluginRoot);
102
+ if (!registry.valid) throw new Error(`host adapter registry is invalid: ${registry.errors.join("; ")}`);
103
+ const byId = new Map(registry.adapters.map((adapter) => [adapter.id, adapter]));
104
+ const selected = hostIds.map((id) => {
105
+ const adapter = byId.get(id);
106
+ if (!adapter) throw new Error(`unknown host adapter: ${id}`);
107
+ return adapter;
108
+ });
109
+ const currentLevel = observeAdoptionLevel(root, registry.adapters);
110
+ if (currentLevel !== "plugin-only" && proposedLevel === "plugin-only") throw new Error("adoption removal or downgrade requires a separate reviewed plan and is not generated by alpha.1");
111
+ if (currentLevel === "persisted-governance") throw new Error("persisted-governance was observed; alpha.1 will diagnose it but will not generate replacement or downgrade writes");
112
+ const strategy = proposedLevel === "plugin-only" ? "no-change" : selected.length === 1 ? "single-host-managed-block" : "shared-contract-with-thin-bindings";
113
+ const writes = [];
114
+ if (strategy === "single-host-managed-block") {
115
+ const adapter = selected[0];
116
+ if (!adapter.bindingModes.includes("managed-block")) throw new Error(`${adapter.id} does not support a managed-block binding`);
117
+ const content = read(resolve(pluginRoot, "templates/bindings", templateName(adapter.id, strategy)));
118
+ writes.push(proposedWrite(root, adapter.bindingTarget, content, adapter.managedMarker));
119
+ } else if (strategy === "shared-contract-with-thin-bindings") {
120
+ const contract = read(resolve(pluginRoot, "templates/FORGERAIL.md")).replace("{{HOSTS}}", selected.map((adapter) => adapter.displayName).join(", "));
121
+ writes.push(proposedWrite(root, "FORGERAIL.md", contract, "forgerail:adoption-contract:v1"));
122
+ for (const adapter of selected) {
123
+ if (!adapter.bindingModes.includes("thin-reference")) throw new Error(`${adapter.id} does not support a thin-reference binding`);
124
+ const content = read(resolve(pluginRoot, "templates/bindings", templateName(adapter.id, strategy)));
125
+ writes.push(proposedWrite(root, adapter.bindingTarget, content, adapter.managedMarker));
126
+ }
127
+ }
128
+ const identity = sha256(JSON.stringify({ workspace: basename(root), currentLevel, proposedLevel, strategy, hosts: hostIds, writes: writes.map(({ path, operation, baseSha256, contentSha256 }) => ({ path, operation, baseSha256, contentSha256 })) })).slice(0, 20);
129
+ const plan = {
130
+ schemaVersion: "1.0",
131
+ planId: `adoption:${identity}`,
132
+ workspace: basename(root),
133
+ currentLevel,
134
+ proposedLevel,
135
+ strategy,
136
+ evidence: [
137
+ `Observed current adoption level: ${currentLevel}.`,
138
+ `User or host Agent explicitly selected host adapters: ${hostIds.join(", ")}.`,
139
+ "ForgeRail alpha.1 does not generate persisted .forgerail state.",
140
+ ],
141
+ hosts: selected.map((adapter) => ({ adapterId: adapter.id, status: adapter.status, bindingTarget: adapter.bindingTarget, verificationMode: adapter.verification.mode })),
142
+ proposedWrites: writes,
143
+ requiredConfirmation: true,
144
+ verification: selected.map((adapter) => adapter.status === "supported"
145
+ ? `${adapter.displayName}: start a new task in the adopted workspace and verify the binding plus expected Skills are discovered.`
146
+ : `${adapter.displayName}: profile-only; perform host-specific discovery verification before treating this binding as active.`),
147
+ confirmedNonMutations: [
148
+ "The planner did not write workspace files.",
149
+ "The planner did not create .forgerail/ state.",
150
+ "The planner did not enable Capability Packs or authorize external side effects.",
151
+ ],
152
+ mutations: [],
153
+ status: "candidate",
154
+ };
155
+ const validation = validateContract("adoption-plan", plan);
156
+ if (!validation.valid) throw new Error(`generated adoption plan is invalid: ${validation.errors.join("; ")}`);
157
+ return plan;
158
+ }
@@ -0,0 +1,77 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { dirname, relative, resolve, sep } from "node:path";
4
+
5
+ const roots = [".codex-plugin", ".github", "adapters", "contracts", "docs", "packs", "scripts", "skills", "templates"];
6
+ const files = ["CHANGELOG.md", "CONTRIBUTING.md", "LICENSE", "NOTICE", "PLUGIN.md", "README.md", "README.zh-CN.md", "SECURITY.md", "package.json"];
7
+ const catalog = "marketplace/.agents/plugins/marketplace.json";
8
+ const externalPluginNames = [
9
+ "forgerail-cross-workspace-orchestration",
10
+ "forgerail-github-rulesets",
11
+ "forgerail-release-safety",
12
+ "forgerail-thread-closure",
13
+ ];
14
+
15
+ function below(base, prefix, result = []) {
16
+ for (const entry of readdirSync(resolve(base, prefix), { withFileTypes: true })) {
17
+ const path = `${prefix}/${entry.name}`;
18
+ if (entry.isDirectory()) below(base, path, result);
19
+ else if (entry.isFile()) result.push(path);
20
+ else throw new Error(`unsupported entry: ${path}`);
21
+ }
22
+ return result;
23
+ }
24
+
25
+ export function buildBundle(root, output) {
26
+ const target = resolve(output);
27
+ const relativeToTmp = relative("/private/tmp", target);
28
+ const relativeToSystemTmp = relative("/tmp", target);
29
+ const safe = (value) => value !== "" && value !== ".." && !value.startsWith(`..${sep}`) && !value.startsWith("/");
30
+ if (!safe(relativeToTmp) && !safe(relativeToSystemTmp)) throw new Error("output must be a new directory below /private/tmp or /tmp");
31
+ if (existsSync(target)) throw new Error("output already exists");
32
+ for (const required of [...roots, ...files, catalog]) if (!existsSync(resolve(root, required))) throw new Error(`public bundle source is missing: ${required}`);
33
+ const payload = [...files, ...roots.flatMap((prefix) => below(root, prefix))].sort();
34
+ const externalPlugins = externalPluginNames.map((name) => {
35
+ const pluginRoot = resolve(root, `../${name}`);
36
+ if (!existsSync(pluginRoot)) throw new Error(`external Plugin source is missing: ${name}`);
37
+ return {
38
+ name,
39
+ root: pluginRoot,
40
+ files: below(pluginRoot, ".").map((path) => path.startsWith("./") ? path.slice(2) : path).sort(),
41
+ };
42
+ });
43
+ const inventory = [];
44
+ const projections = [
45
+ { source: catalog, target: ".agents/plugins/marketplace.json" },
46
+ ...payload.flatMap((path) => [
47
+ { source: path, target: path },
48
+ { source: path, target: `plugins/forgerail/${path}` },
49
+ ]),
50
+ ...externalPlugins.flatMap((plugin) => plugin.files.map((path) => ({
51
+ source: resolve(plugin.root, path),
52
+ target: `plugins/${plugin.name}/${path}`,
53
+ externalSource: `../${plugin.name}/${path}`,
54
+ absolute: true,
55
+ }))),
56
+ ].sort((left, right) => left.target.localeCompare(right.target));
57
+ for (const { source: path, target: publicPath, externalSource, absolute = false } of projections) {
58
+ const source = absolute ? path : resolve(root, path);
59
+ if (!statSync(source).isFile()) throw new Error(`bundle source is not a file: ${path}`);
60
+ const destination = resolve(target, publicPath);
61
+ mkdirSync(dirname(destination), { recursive: true });
62
+ copyFileSync(source, destination);
63
+ const bytes = readFileSync(source);
64
+ inventory.push({ path: publicPath, source: absolute ? externalSource : path, bytes: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") });
65
+ }
66
+ const digest = createHash("sha256").update(`${JSON.stringify(inventory)}\n`).digest("hex");
67
+ return {
68
+ schemaVersion: "1.0",
69
+ productId: "forgerail",
70
+ projection: "marketplace-root-plus-nested-plugin",
71
+ fileCount: inventory.length,
72
+ totalBytes: inventory.reduce((sum, item) => sum + item.bytes, 0),
73
+ digest,
74
+ receiptDigest: createHash("sha256").update(`forgerail\n${digest}\n${inventory.length}\n`).digest("hex"),
75
+ files: inventory,
76
+ };
77
+ }