@echopath-labs/forgerail 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (63) hide show
  1. package/.codex-plugin/plugin.json +2 -3
  2. package/CHANGELOG.md +22 -1
  3. package/CODE_OF_CONDUCT.md +34 -0
  4. package/CONTRIBUTING.md +68 -4
  5. package/README.md +126 -49
  6. package/README.zh-CN.md +131 -28
  7. package/SECURITY.md +48 -4
  8. package/SUPPORT.md +37 -0
  9. package/adapters/claude-code.json +6 -1
  10. package/adapters/codex.json +6 -0
  11. package/adapters/cursor.json +5 -0
  12. package/contracts/adoption-plan.schema.json +39 -18
  13. package/contracts/effective-profile.schema.json +4 -4
  14. package/contracts/host-adapter.schema.json +66 -4
  15. package/contracts/host-binding-receipt.schema.json +1 -1
  16. package/contracts/launch-contract.schema.json +38 -2
  17. package/contracts/profile-change-candidate.schema.json +1 -1
  18. package/contracts/return-receipt.schema.json +1 -1
  19. package/contracts/task-envelope.schema.json +1 -1
  20. package/directory/README.md +1 -1
  21. package/directory/release-notes-alpha3.md +7 -0
  22. package/directory/release-notes-alpha4.md +9 -0
  23. package/directory/submission-candidate.json +5 -6
  24. package/docs/adoption.md +63 -26
  25. package/docs/adoption.zh-CN.md +62 -25
  26. package/docs/architecture-acceptance.md +1 -1
  27. package/docs/composable-autonomy.zh-CN.md +16 -22
  28. package/docs/installation.md +72 -40
  29. package/docs/installation.zh-CN.md +90 -31
  30. package/docs/release-alpha3.md +25 -0
  31. package/docs/release-alpha3.zh-CN.md +25 -0
  32. package/docs/release-alpha4.md +33 -0
  33. package/docs/release-alpha4.zh-CN.md +33 -0
  34. package/package.json +7 -3
  35. package/scripts/adoption-closeout-regressions.mjs +100 -0
  36. package/scripts/build-universal-directory-candidate.mjs +2 -2
  37. package/scripts/disposable-consumer.mjs +11 -18
  38. package/scripts/fixtures/contracts/adoption-plan.multi-host.valid.json +16 -7
  39. package/scripts/fixtures/contracts/adoption-plan.mutating.invalid.json +6 -3
  40. package/scripts/fixtures/contracts/adoption-plan.single-host.valid.json +9 -4
  41. package/scripts/fixtures/contracts/effective-profile.duplicate-rule.invalid.json +1 -1
  42. package/scripts/fixtures/contracts/effective-profile.valid.json +3 -4
  43. package/scripts/fixtures/contracts/host-adapter.claude-code.profile-only.valid.json +6 -1
  44. package/scripts/fixtures/contracts/host-adapter.codex.valid.json +6 -0
  45. package/scripts/fixtures/contracts/host-adapter.cursor.profile-only.valid.json +5 -0
  46. package/scripts/fixtures/contracts/host-adapter.false-supported.invalid.json +6 -1
  47. package/scripts/fixtures/contracts/launch-contract.execution-owner.invalid.json +5 -1
  48. package/scripts/fixtures/contracts/launch-contract.valid.json +5 -1
  49. package/scripts/fixtures/open-source-docs/cases.json +65 -0
  50. package/scripts/forgerail.mjs +61 -16
  51. package/scripts/integrity-regressions.mjs +1261 -0
  52. package/scripts/lib/adoption.mjs +666 -51
  53. package/scripts/lib/bounded-read.mjs +80 -0
  54. package/scripts/lib/composition.mjs +77 -7
  55. package/scripts/lib/contracts.mjs +126 -40
  56. package/scripts/lib/diagnosis.mjs +146 -39
  57. package/scripts/shadow-comparison.mjs +52 -34
  58. package/scripts/validate-open-source-docs.mjs +132 -0
  59. package/scripts/validate-release.mjs +77 -13
  60. package/scripts/validate-universal-directory.mjs +17 -5
  61. package/skills/forgerail/references/adoption.md +2 -2
  62. package/skills/forgerail/references/contracts.md +2 -2
  63. package/scripts/lib/bundle.mjs +0 -77
@@ -1,65 +1,173 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
- import { basename, resolve } from "node:path";
1
+ import {
2
+ lstatSync,
3
+ opendirSync,
4
+ realpathSync,
5
+ } from "node:fs";
6
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { loadHostAdapters } from "./adoption.mjs";
9
+ import { inspectBoundedPath } from "./bounded-read.mjs";
10
+
11
+ const defaultPluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
12
+ const maximumDiagnosticDirectoryEntries = 4096;
3
13
 
4
14
  function observed(id, source, value) {
5
15
  return { id, kind: "observed_fact", source, value };
6
16
  }
7
17
 
8
- function safeJson(path) {
9
- try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
18
+ function confined(root, target) {
19
+ const value = relative(root, target);
20
+ return value === "" || (
21
+ !isAbsolute(value)
22
+ && !/^[a-zA-Z]:/.test(value)
23
+ && value !== ".."
24
+ && !value.startsWith(`..${sep}`)
25
+ && !value.startsWith("/")
26
+ );
27
+ }
28
+
29
+ function sameFile(left, right) {
30
+ return left.dev === right.dev && left.ino === right.ino;
10
31
  }
11
32
 
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"));
33
+ function safeJson(root, path) {
34
+ const inspected = inspectBoundedPath(root, path, { finalKind: "file", read: true });
35
+ if (inspected.state === "absent") return { state: "absent", value: null, error: null };
36
+ if (inspected.state !== "available") return { state: "unavailable", value: null, error: inspected.state };
37
+ try {
38
+ const value = JSON.parse(inspected.content);
39
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
40
+ return { state: "malformed", value: null, error: "invalid-root-shape" };
41
+ }
42
+ return { state: "available", value, error: null };
43
+ } catch (error) {
44
+ return { state: "malformed", value: null, error: error instanceof SyntaxError ? "invalid-json" : "unreadable" };
45
+ }
15
46
  }
16
47
 
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");
48
+ function hasMarkdownRecord(root, path) {
49
+ const inspected = inspectBoundedPath(root, path, { finalKind: "directory" });
50
+ if (inspected.state !== "available") return false;
51
+ const directory = resolve(root, path);
52
+ let before;
53
+ let observed;
54
+ let handle;
55
+ try {
56
+ before = lstatSync(directory);
57
+ observed = realpathSync(directory);
58
+ const after = lstatSync(observed);
59
+ if (!confined(root, observed) || after.isSymbolicLink() || !sameFile(before, after) || !after.isDirectory()) return false;
60
+ handle = opendirSync(observed);
61
+ const openedObserved = realpathSync(directory);
62
+ const opened = lstatSync(openedObserved);
63
+ if (openedObserved !== observed || !confined(root, openedObserved) || !sameFile(after, opened) || !opened.isDirectory()) return false;
64
+ const names = [];
65
+ while (true) {
66
+ const entry = handle.readSync();
67
+ if (entry === null) break;
68
+ if (names.length >= maximumDiagnosticDirectoryEntries) return false;
69
+ names.push(entry.name);
70
+ }
71
+ const finalObserved = realpathSync(directory);
72
+ const final = lstatSync(finalObserved);
73
+ if (finalObserved !== observed || !confined(root, finalObserved) || !sameFile(after, final) || !final.isDirectory()) return false;
74
+ return names.some((name) => typeof name === "string"
75
+ && name.toLowerCase().endsWith(".md")
76
+ && inspectBoundedPath(root, `${path}/${name}`, { finalKind: "file", verify: true }).state === "available");
77
+ } catch {
78
+ return false;
79
+ } finally {
80
+ try { handle?.closeSync(); } catch {}
81
+ }
82
+ }
83
+
84
+ export function diagnoseWorkspace(workspace, pluginRoot = defaultPluginRoot) {
85
+ let root;
86
+ try { root = realpathSync(resolve(workspace)); }
87
+ catch { throw new Error("workspace must be an existing directory"); }
88
+ const rootMetadata = lstatSync(root);
89
+ if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw new Error("workspace must be an existing directory");
90
+ const registry = loadHostAdapters(pluginRoot);
91
+ if (!registry.valid) throw new Error(`host adapter registry is invalid: ${registry.errors.join("; ")}`);
20
92
  const evidence = [];
21
93
  const recommendations = [];
22
94
  const confirmationRequired = [];
95
+ const gaps = [];
96
+
97
+ const hostAdapters = registry.adapters.map((adapter) => {
98
+ const binding = inspectBoundedPath(root, adapter.bindingTarget, { finalKind: "file", read: true });
99
+ const detected = adapter.detectionTargets.some((path) => inspectBoundedPath(root, path).present);
100
+ if (binding.present && binding.state !== "available") gaps.push(`host-binding-unavailable:${adapter.id}`);
101
+ return {
102
+ id: adapter.id,
103
+ status: adapter.status,
104
+ target: adapter.bindingTarget,
105
+ observed: detected || binding.present,
106
+ readState: binding.state,
107
+ managedBindingObserved: binding.state === "available" && binding.content.includes(`<!-- ${adapter.managedMarker}:start -->`),
108
+ };
109
+ });
23
110
 
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"));
111
+ for (const adapter of hostAdapters) {
112
+ if (adapter.readState === "available") evidence.push(observed(`instructions:${adapter.target}`, adapter.target, "available"));
113
+ }
114
+ if (inspectBoundedPath(root, "README.md", { finalKind: "file" }).state === "available") {
115
+ evidence.push(observed("instructions:README.md", "README.md", "available"));
26
116
  }
27
117
 
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));
118
+ const managedBindingObserved = hostAdapters.some((adapter) => adapter.managedBindingObserved);
119
+ const persisted = inspectBoundedPath(root, ".forgerail").state === "available";
120
+ const portableContract = inspectBoundedPath(root, "FORGERAIL.md", { finalKind: "file" }).state === "available";
121
+ const adoptionLevel = persisted ? "persisted-governance" : portableContract || managedBindingObserved ? "lightweight-adoption" : "plugin-only";
122
+ evidence.push(observed("host-adapters", "registry-owned bounded host instruction paths", hostAdapters));
43
123
  evidence.push(observed("forgerail-adoption-level", "bounded ForgeRail markers", adoptionLevel));
44
124
 
45
125
  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/" });
126
+ if (inspectBoundedPath(root, "openspec").state === "available") recordSystems.push({ type: "openspec", source: "openspec/" });
127
+ const specify = inspectBoundedPath(root, ".specify").state === "available";
128
+ const specs = inspectBoundedPath(root, "specs").state === "available";
129
+ if (specify || specs) recordSystems.push({ type: "spec-kit-or-spec-directory", source: specify ? ".specify/" : "specs/" });
48
130
  for (const directory of ["docs/adr", "docs/adrs", "adr", "adrs", "decisions"]) {
49
- if (hasMarkdown(resolve(root, directory))) recordSystems.push({ type: "markdown-adr", source: `${directory}/` });
131
+ if (hasMarkdownRecord(root, directory)) recordSystems.push({ type: "markdown-adr", source: `${directory}/` });
50
132
  }
51
- if (hasMarkdown(resolve(root, "docs")) && recordSystems.length === 0) recordSystems.push({ type: "markdown-docs", source: "docs/" });
133
+ if (hasMarkdownRecord(root, "docs") && recordSystems.length === 0) recordSystems.push({ type: "markdown-docs", source: "docs/" });
52
134
  evidence.push(observed("record-systems", "bounded well-known paths", recordSystems));
53
135
 
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()));
136
+ const packageJson = safeJson(root, "package.json");
137
+ if (packageJson.state === "available") evidence.push(observed("package-scripts", "package.json", Object.keys(packageJson.value.scripts ?? {}).sort()));
138
+ else if (packageJson.state === "malformed" || packageJson.state === "unavailable") {
139
+ const state = packageJson.state === "malformed" ? "malformed" : "unavailable";
140
+ evidence.push(observed("package-metadata", "package.json", { state, reason: packageJson.error }));
141
+ gaps.push(packageJson.state === "malformed" ? "package-metadata-malformed" : "package-metadata-unavailable");
142
+ recommendations.push({
143
+ kind: "recommendation",
144
+ priority: "P1",
145
+ reason: packageJson.state === "malformed"
146
+ ? "package.json exists but is not usable as object metadata."
147
+ : "package.json is not a bounded readable regular file.",
148
+ options: [packageJson.state === "malformed"
149
+ ? "repair package.json before relying on package-script observations"
150
+ : "replace the unsafe package.json entry with a reviewed regular file before relying on package-script observations"],
151
+ });
152
+ confirmationRequired.push("Confirm whether unavailable package metadata should block the intended task.");
153
+ }
154
+
155
+ if (gaps.some((gap) => gap.startsWith("host-binding-unavailable:"))) {
156
+ recommendations.push({
157
+ kind: "recommendation",
158
+ priority: "P1",
159
+ reason: "One or more registered Host bindings are present but cannot be read within the no-follow regular-file boundary.",
160
+ options: ["inspect the named Host binding and replace unsafe entries only after human confirmation"],
161
+ });
162
+ confirmationRequired.push("Confirm how each unavailable Host binding should be repaired before adoption.");
163
+ }
57
164
 
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)));
165
+ if (inspectBoundedPath(root, ".git").state === "available") evidence.push(observed("git-root", ".git/", "available"));
166
+ const skillRoots = [".codex/skills", ".agents/skills"].filter((path) => inspectBoundedPath(root, path).state === "available");
60
167
  evidence.push(observed("skill-roots", "bounded well-known paths", skillRoots));
61
168
 
62
169
  if (recordSystems.length === 0) {
170
+ gaps.push("durable-record-practice-not-observed");
63
171
  recommendations.push({
64
172
  kind: "recommendation",
65
173
  priority: "P1",
@@ -73,10 +181,9 @@ export function diagnoseWorkspace(workspace) {
73
181
  schemaVersion: "1.0",
74
182
  mode: "read-only",
75
183
  workspace: basename(root),
76
- workspacePath: root,
77
184
  evidence,
78
185
  inheritedHabits: recordSystems,
79
- gaps: recordSystems.length === 0 ? ["durable-record-practice-not-observed"] : [],
186
+ gaps,
80
187
  recommendations,
81
188
  confirmationRequired,
82
189
  mutations: [],
@@ -85,7 +192,7 @@ export function diagnoseWorkspace(workspace) {
85
192
  recommendedLevel: adoptionLevel,
86
193
  changeRecommended: false,
87
194
  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",
195
+ planCommandAvailable: "forgerail adoption-plan --workspace .",
89
196
  persistedGovernanceGeneration: "deferred",
90
197
  },
91
198
  fullHealthReviewRecommended: evidence.filter((item) => item.id.startsWith("instructions:")).length > 2,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
- import { fileURLToPath } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
 
7
7
  const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
8
  const core = readFileSync(resolve(root, "skills/forgerail/SKILL.md"), "utf8");
@@ -23,76 +23,94 @@ function externalSkill(plugin, skill = plugin) {
23
23
  const rulesets = externalSkill("forgerail-github-rulesets");
24
24
  const releaseSafety = externalSkill("forgerail-release-safety");
25
25
  const threadClosure = externalSkill("forgerail-thread-closure");
26
- const agwEvidence = frozen.behaviorAssertions;
26
+ const expectedAgwEvidence = Object.freeze({
27
+ featureBranchRecords: ["smallest child workspace", "durable records", "git status"],
28
+ dirtyWorktreePreservation: ["Preserve user changes"],
29
+ existingRecordHabit: ["existing durable record system"],
30
+ workspaceHealth: ["Workspace Health Review"],
31
+ githubRulesets: ["project-specific workflow or safety checklist"],
32
+ releaseSafety: ["project-specific release or operations checklist"],
33
+ threadClosure: ["machine-readable closeout"],
34
+ });
27
35
 
28
- const scenarios = [
36
+ const scenarioDefinitions = [
29
37
  {
30
38
  id: "feature-branch-records",
31
- agwEvidence: agwEvidence.featureBranchRecords,
39
+ agwEvidence: expectedAgwEvidence.featureBranchRecords,
32
40
  forgerailEvidence: ["smallest owner workspace", "Task Envelope", "existing habits"],
33
- source: `${agwEvidence.featureBranchRecords.join("\n")}\n${core}\n${diagnosis}`,
41
+ source: `${core}\n${diagnosis}`,
34
42
  status: "covered",
35
43
  },
36
44
  {
37
45
  id: "dirty-worktree-preservation",
38
- agwEvidence: agwEvidence.dirtyWorktreePreservation,
46
+ agwEvidence: expectedAgwEvidence.dirtyWorktreePreservation,
39
47
  forgerailEvidence: ["preserve unrelated user changes", "dirty-worktree state"],
40
- source: `${agwEvidence.dirtyWorktreePreservation.join("\n")}\n${core}`,
48
+ source: core,
41
49
  status: "covered",
42
50
  },
43
51
  {
44
52
  id: "markdown-existing-habit",
45
- agwEvidence: agwEvidence.existingRecordHabit,
53
+ agwEvidence: expectedAgwEvidence.existingRecordHabit,
46
54
  forgerailEvidence: ["existing habits", "OpenSpec may be a preferred example"],
47
- source: `${agwEvidence.existingRecordHabit.join("\n")}\n${diagnosis}`,
55
+ source: diagnosis,
48
56
  status: "covered",
49
57
  },
50
58
  {
51
59
  id: "workspace-health",
52
- agwEvidence: agwEvidence.workspaceHealth,
60
+ agwEvidence: expectedAgwEvidence.workspaceHealth,
53
61
  forgerailEvidence: ["first built-in ForgeRail Capability Pack", "Analyze First"],
54
- source: `${agwEvidence.workspaceHealth.join("\n")}\n${health}`,
62
+ source: health,
55
63
  status: "covered-with-follow-up",
56
64
  },
57
65
  {
58
66
  id: "github-rulesets-read-first",
59
- agwEvidence: agwEvidence.githubRulesets,
67
+ agwEvidence: expectedAgwEvidence.githubRulesets,
60
68
  forgerailEvidence: ["read-only diagnosis", "Stop until the user explicitly approves"],
61
- source: `${agwEvidence.githubRulesets.join("\n")}\n${rulesets}`,
69
+ source: rulesets,
62
70
  status: "covered",
63
71
  },
64
72
  {
65
73
  id: "release-safety-project-runbook",
66
- agwEvidence: agwEvidence.releaseSafety,
74
+ agwEvidence: expectedAgwEvidence.releaseSafety,
67
75
  forgerailEvidence: ["project-owned release runbook", "does not contain publish, deploy"],
68
- source: `${agwEvidence.releaseSafety.join("\n")}\n${releaseSafety}`,
76
+ source: releaseSafety,
69
77
  status: "covered",
70
78
  },
71
79
  {
72
80
  id: "evidence-first-thread-closure",
73
- agwEvidence: agwEvidence.threadClosure,
81
+ agwEvidence: expectedAgwEvidence.threadClosure,
74
82
  forgerailEvidence: ["Keep closeout incomplete", "Do not implement follow-up work"],
75
- source: `${agwEvidence.threadClosure.join("\n")}\n${core}\n${threadClosure}`,
83
+ source: `${core}\n${threadClosure}`,
76
84
  status: "covered",
77
85
  },
78
86
  ];
79
87
 
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;
88
+ export function evaluateShadowComparison(overrides = {}, baseline = frozen) {
89
+ const baselineSource = JSON.stringify(baseline);
90
+ const scenarios = scenarioDefinitions.map((definition) => {
91
+ const source = overrides[definition.id] ?? definition.source;
92
+ const scenario = { ...definition };
93
+ scenario.missingAgw = scenario.agwEvidence.filter((phrase) => !baselineSource.toLocaleLowerCase("en-US").includes(phrase.toLocaleLowerCase("en-US")));
94
+ scenario.missingForgeRail = scenario.forgerailEvidence.filter((phrase) => !source.toLocaleLowerCase("en-US").includes(phrase.toLocaleLowerCase("en-US")));
95
+ scenario.passed = scenario.missingAgw.length === 0 && scenario.missingForgeRail.length === 0 && scenario.status !== "unresolved";
96
+ delete scenario.source;
97
+ return scenario;
98
+ });
99
+ return {
100
+ schemaVersion: "1.0",
101
+ agwBaseline: "plugins/agent-workflow-governance@0.2.0-canonical",
102
+ forgeRailCandidate: "plugins/forgerail@0.1.0-alpha.4-canonical",
103
+ scenarios,
104
+ covered: scenarios.filter((item) => item.passed).length,
105
+ unresolved: scenarios.filter((item) => !item.passed).map((item) => item.id),
106
+ behaviorCoverageReady: scenarios.every((item) => item.passed),
107
+ migrationReady: false,
108
+ migrationBlockers: ["real compatibility-period canaries are incomplete", "AGW lifecycle change is not approved"],
109
+ };
85
110
  }
86
111
 
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.2-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));
112
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
113
+ const result = evaluateShadowComparison();
114
+ console.log(JSON.stringify(result, null, 2));
115
+ if (!result.behaviorCoverageReady) process.exitCode = 1;
116
+ }
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, extname, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+ const checks = [];
9
+ const failures = [];
10
+
11
+ const requiredFiles = [
12
+ "README.md",
13
+ "README.zh-CN.md",
14
+ "CONTRIBUTING.md",
15
+ "SECURITY.md",
16
+ "CODE_OF_CONDUCT.md",
17
+ "SUPPORT.md",
18
+ "LICENSE",
19
+ "docs/installation.md",
20
+ "docs/installation.zh-CN.md",
21
+ "docs/adoption.md",
22
+ "docs/adoption.zh-CN.md",
23
+ "docs/composable-autonomy.zh-CN.md",
24
+ ".github/PULL_REQUEST_TEMPLATE.md",
25
+ ".github/ISSUE_TEMPLATE/bug_report.yml",
26
+ ".github/ISSUE_TEMPLATE/feature_request.yml",
27
+ ".github/ISSUE_TEMPLATE/documentation.yml",
28
+ ".github/ISSUE_TEMPLATE/config.yml"
29
+ ];
30
+
31
+ const publicTextFiles = requiredFiles.filter((path) => extname(path) === ".md" || extname(path) === ".yml");
32
+ const entryFiles = ["README.md", "README.zh-CN.md", "docs/installation.md", "docs/installation.zh-CN.md", "docs/adoption.md", "docs/adoption.zh-CN.md"];
33
+ const issueForms = [
34
+ ".github/ISSUE_TEMPLATE/bug_report.yml",
35
+ ".github/ISSUE_TEMPLATE/feature_request.yml",
36
+ ".github/ISSUE_TEMPLATE/documentation.yml"
37
+ ];
38
+ const skills = ["$forgerail", "$forgerail-workspace-diagnosis", "$workspace-health-review", "$architecture-convergence-audit"];
39
+ const exactInstall = "codex plugin marketplace add echopath-labs/forgerail --ref v0.1.0-alpha.4";
40
+
41
+ function record(condition, message) {
42
+ if (!condition) failures.push(message);
43
+ else checks.push(message);
44
+ }
45
+
46
+ function read(relativePath) {
47
+ return readFileSync(resolve(pluginRoot, relativePath), "utf8");
48
+ }
49
+
50
+ function classify(kind, content, base = pluginRoot) {
51
+ const errors = [];
52
+ if (kind === "release-text") {
53
+ if (/current[^\n]{0,80}(?:alpha\.2|0\.1\.0-alpha\.2)|install[^\n]{0,80}v0\.1\.0-alpha\.2/i.test(content)) errors.push("stale-release");
54
+ }
55
+ if (kind === "markdown-link") {
56
+ for (const match of content.matchAll(/\[[^\]]+\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g)) {
57
+ const target = match[1];
58
+ if (/^(?:https?:\/\/|mailto:|#)/.test(target)) continue;
59
+ const path = decodeURIComponent(target.split("#")[0]);
60
+ if (!path) continue;
61
+ const resolved = resolve(base, path);
62
+ if (!resolved.startsWith(`${pluginRoot}${sep}`) || !existsSync(resolved)) errors.push("broken-link");
63
+ }
64
+ }
65
+ if (kind === "issue-form") {
66
+ if (!/^name:\s*\S.+$/m.test(content) || !/^description:\s*\S.+$/m.test(content)) errors.push("invalid-issue-metadata");
67
+ if (/current alpha\.3 documentation|example v0\.1\.0-alpha\.3/i.test(content)) errors.push("stale-issue-version");
68
+ }
69
+ if (kind === "public-text") {
70
+ if (/\/Users\/|\.codex\/worktrees\/|[A-Za-z]:\\Users\\/.test(content)) errors.push("private-path");
71
+ if (/openspec\/changes\/|canonical private source workspace|active change[^\n]{0,80}tasks\.md|inventory\/[^\s`)]+-\d{8}\.md/i.test(content)) {
72
+ errors.push("private-process-reference");
73
+ }
74
+ }
75
+ if (kind === "no-node-claim") {
76
+ const lines = content.split(/\r?\n/);
77
+ const requiresProjectNode = lines.some((line) => {
78
+ if (!/(?:project|workspace)[^\n]{0,80}(?:requires?|must have|needs?)[^\n]{0,40}Node\.js/i.test(line)) return false;
79
+ return !/(?:does not|doesn't|do not|don't|no project-local|不要求|不需要|无需)/i.test(line);
80
+ });
81
+ if (/(?:before|to) (?:use|using|install)[^\n]{0,100}(?:initialize|create|add)[^\n]{0,40}package\.json/i.test(content) || requiresProjectNode) errors.push("project-node-required");
82
+ }
83
+ return [...new Set(errors)].sort();
84
+ }
85
+
86
+ for (const path of requiredFiles) record(existsSync(resolve(pluginRoot, path)), `required public file exists: ${path}`);
87
+
88
+ for (const path of entryFiles) {
89
+ const content = read(path);
90
+ record(content.includes("0.1.0-alpha.4") && content.includes("v0.1.0-alpha.4"), `released alpha.4 identity is explicit: ${path}`);
91
+ record(!classify("release-text", content).includes("stale-release"), `no stale alpha.2 current-install claim: ${path}`);
92
+ record(classify("public-text", content).length === 0, `no private path: ${path}`);
93
+ record(classify("markdown-link", content, dirname(resolve(pluginRoot, path))).length === 0, `relative Markdown links resolve: ${path}`);
94
+ }
95
+
96
+ for (const path of ["README.md", "README.zh-CN.md", "docs/installation.md", "docs/installation.zh-CN.md"]) {
97
+ const content = read(path);
98
+ record(content.includes(exactInstall), `exact alpha.4 Marketplace command is present: ${path}`);
99
+ for (const skill of skills) record(content.includes(skill), `${path} covers ${skill}`);
100
+ }
101
+
102
+ for (const path of ["README.md", "README.zh-CN.md", "docs/installation.md", "docs/installation.zh-CN.md"]) {
103
+ const content = read(path);
104
+ record(/does not (?:require|need)|不要求|不需要/.test(content) && content.includes("package.json") && content.includes("node_modules") && content.includes(".forgerail/"), `Plugin Only no-project-Node boundary is explicit: ${path}`);
105
+ record(classify("no-node-claim", content).length === 0, `no accidental project Node requirement: ${path}`);
106
+ }
107
+
108
+ for (const path of publicTextFiles) {
109
+ const content = read(path);
110
+ record(classify("public-text", content).length === 0, `public text has no private path or process reference: ${path}`);
111
+ }
112
+
113
+ for (const path of issueForms) {
114
+ record(classify("issue-form", read(path)).length === 0, `issue form has current version, name, and description: ${path}`);
115
+ }
116
+
117
+ const fixtures = JSON.parse(read("scripts/fixtures/open-source-docs/cases.json"));
118
+ record(fixtures.schemaVersion === "1.0" && Array.isArray(fixtures.cases), "documentation fixture schema is valid");
119
+ for (const fixture of fixtures.cases) {
120
+ const base = fixture.kind === "markdown-link" ? resolve(pluginRoot, "scripts/fixtures/open-source-docs") : pluginRoot;
121
+ const content = fixture.content.replace("__PRIVATE_USER_PATH__", ["", "Users", "example", ".codex", "worktrees", "private", "project"].join("/"));
122
+ const actual = classify(fixture.kind, content, base);
123
+ const expected = [...fixture.expectedErrors].sort();
124
+ record(JSON.stringify(actual) === JSON.stringify(expected), `fixture fails closed as expected: ${fixture.id}`);
125
+ }
126
+
127
+ if (failures.length > 0) {
128
+ console.error(JSON.stringify({ status: "failed", checks: checks.length, failures }, null, 2));
129
+ process.exit(1);
130
+ }
131
+
132
+ console.log(JSON.stringify({ status: "passed", checks: checks.length, fixtures: fixtures.cases.length }, null, 2));