@structor-dev/cli 0.1.0

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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +405 -0
  3. package/bin/structor.mjs +576 -0
  4. package/docs/INIT.md +109 -0
  5. package/docs/adr/0001-default-generated-repo-name.md +9 -0
  6. package/docs/issues/0001-structor-doctor.md +39 -0
  7. package/examples/frontend-backend/harness.config.json +35 -0
  8. package/examples/openai-and-anthropic/harness.config.json +28 -0
  9. package/examples/single-repo/harness.config.json +26 -0
  10. package/harness.config.example.json +38 -0
  11. package/package.json +58 -0
  12. package/schemas/contract-manifest.schema.json +18 -0
  13. package/schemas/harness-config.schema.json +85 -0
  14. package/schemas/task-brief.schema.json +37 -0
  15. package/scripts/check-config.mjs +76 -0
  16. package/scripts/check-contract-manifests.mjs +85 -0
  17. package/scripts/check-model-overlays.mjs +30 -0
  18. package/scripts/check-placeholders.mjs +48 -0
  19. package/scripts/check-task-template.mjs +53 -0
  20. package/scripts/check-template-files.mjs +110 -0
  21. package/scripts/init-harness.mjs +270 -0
  22. package/scripts/lib.mjs +190 -0
  23. package/scripts/smoke-template.mjs +309 -0
  24. package/scripts/validate-governance.mjs +3 -0
  25. package/scripts/validate-template.mjs +16 -0
  26. package/template/.claude/CLAUDE.md.tpl +12 -0
  27. package/template/.claude/rules/harness-client-surfaces.md.tpl +20 -0
  28. package/template/.claude/settings.json.tpl +10 -0
  29. package/template/.codex/hooks.json.tpl +77 -0
  30. package/template/AGENTS.md.tpl +22 -0
  31. package/template/CLAUDE.md.tpl +16 -0
  32. package/template/README.md.tpl +109 -0
  33. package/template/ai/AGENT-GARBAGE-COLLECTION.md.tpl +18 -0
  34. package/template/ai/AGENTS.md.tpl +36 -0
  35. package/template/ai/ARCHITECTURE.md.tpl +35 -0
  36. package/template/ai/CODEX-HOOKS.md.tpl +23 -0
  37. package/template/ai/DECISIONS.md.tpl +22 -0
  38. package/template/ai/DESIGN.md.tpl +22 -0
  39. package/template/ai/HARNESS-ENGINEERING.md.tpl +107 -0
  40. package/template/ai/HARNESS.md.tpl +53 -0
  41. package/template/ai/HUB.md.tpl +53 -0
  42. package/template/ai/PRODUCT-SUMMARY.md.tpl +28 -0
  43. package/template/ai/PRODUCT.md.tpl +32 -0
  44. package/template/ai/QUALITY.md.tpl +37 -0
  45. package/template/ai/READINESS.md.tpl +39 -0
  46. package/template/ai/RUNNER-READINESS.md.tpl +14 -0
  47. package/template/ai/RUNNER-SAFETY.md.tpl +21 -0
  48. package/template/ai/VERSIONING.md.tpl +16 -0
  49. package/template/ai/WORKFLOW.md.tpl +42 -0
  50. package/template/ai/context.md.tpl +17 -0
  51. package/template/ai/contracts/README.md.tpl +23 -0
  52. package/template/ai/contracts/api-boundary.contract.json.tpl +11 -0
  53. package/template/ai/contracts/api-boundary.md.tpl +17 -0
  54. package/template/ai/contracts/app-legibility.contract.json.tpl +11 -0
  55. package/template/ai/contracts/app-legibility.md.tpl +24 -0
  56. package/template/ai/contracts/codex-hooks.contract.json.tpl +15 -0
  57. package/template/ai/contracts/codex-hooks.md.tpl +18 -0
  58. package/template/ai/contracts/github-safety.contract.json.tpl +11 -0
  59. package/template/ai/contracts/github-safety.md.tpl +15 -0
  60. package/template/ai/contracts/release-flow.contract.json.tpl +12 -0
  61. package/template/ai/contracts/release-flow.md.tpl +15 -0
  62. package/template/ai/contracts/repo-boundaries.contract.json.tpl +12 -0
  63. package/template/ai/contracts/repo-boundaries.md.tpl +18 -0
  64. package/template/ai/contracts/security-boundary.contract.json.tpl +11 -0
  65. package/template/ai/contracts/security-boundary.md.tpl +19 -0
  66. package/template/ai/knowledge-manifest.json.tpl +149 -0
  67. package/template/ai/model-overlays/anthropic/CLAUDE.md.tpl +14 -0
  68. package/template/ai/model-overlays/openai/AGENTS.md.tpl +13 -0
  69. package/template/ai/plans/README.md.tpl +10 -0
  70. package/template/ai/plans/tech-debt.md.tpl +7 -0
  71. package/template/ai/skills/README.md.tpl +15 -0
  72. package/template/ai/skills/review-architecture.md.tpl +41 -0
  73. package/template/ai/skills/review-contract-drift.md.tpl +41 -0
  74. package/template/ai/skills/review-governance-drift.md.tpl +42 -0
  75. package/template/ai/skills/review-security.md.tpl +40 -0
  76. package/template/ai/specs/README.md.tpl +14 -0
  77. package/template/ai/templates/README.md.tpl +13 -0
  78. package/template/ai/templates/fixtures/issues/invalid-placeholder.md.tpl +20 -0
  79. package/template/ai/templates/fixtures/issues/invalid-protected-surface.md.tpl +21 -0
  80. package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +105 -0
  81. package/template/ai/templates/issue-template.md.tpl +107 -0
  82. package/template/ai/templates/task-brief-template.md.tpl +185 -0
  83. package/template/ai/workspace/LOCAL-STACK.md.tpl +21 -0
  84. package/template/ai/workspace/REPOS.md.tpl +19 -0
  85. package/template/ai/workspace/SESSION-BOOTSTRAP.md.tpl +27 -0
  86. package/template/ai/workspace/SYSTEM-MAP.md.tpl +19 -0
  87. package/template/ai/workspace/TEST-STRATEGY.md.tpl +22 -0
  88. package/template/consumer/.claude/CLAUDE.md.tpl +14 -0
  89. package/template/consumer/AGENTS.md.tpl +23 -0
  90. package/template/consumer/CLAUDE.md.tpl +15 -0
  91. package/template/scripts/bootstrap-codex-worktree.mjs.tpl +52 -0
  92. package/template/scripts/bootstrap-workspace.mjs.tpl +100 -0
  93. package/template/scripts/check-claude-compatibility.mjs.tpl +120 -0
  94. package/template/scripts/check-codex-hooks.mjs.tpl +190 -0
  95. package/template/scripts/check-contract-manifests.mjs.tpl +81 -0
  96. package/template/scripts/check-garbage-collection.mjs.tpl +25 -0
  97. package/template/scripts/check-html-views.mjs.tpl +60 -0
  98. package/template/scripts/check-issue-template.mjs.tpl +167 -0
  99. package/template/scripts/check-knowledge-manifest.mjs.tpl +82 -0
  100. package/template/scripts/check-overlay-drift.mjs.tpl +49 -0
  101. package/template/scripts/check-plans.mjs.tpl +70 -0
  102. package/template/scripts/check-readiness.mjs.tpl +130 -0
  103. package/template/scripts/check-review-skills.mjs.tpl +48 -0
  104. package/template/scripts/check-task-template.mjs.tpl +63 -0
  105. package/template/scripts/check-template-governance.mjs.tpl +161 -0
  106. package/template/scripts/check-workspace.mjs.tpl +212 -0
  107. package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +122 -0
  108. package/template/scripts/check-worktrees.mjs.tpl +69 -0
  109. package/template/scripts/fixtures/worktrees/README.md.tpl +4 -0
  110. package/template/scripts/generate-html-views.mjs.tpl +189 -0
  111. package/template/scripts/hooks/codex-hook.mjs.tpl +21 -0
  112. package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +114 -0
  113. package/template/scripts/lib/worktree-bootstrap.mjs.tpl +388 -0
  114. package/template/scripts/validate-governance.mjs.tpl +78 -0
  115. package/template/workspace/.claude/CLAUDE.md.tpl +9 -0
  116. package/template/workspace/.claude/rules/harness-client-surfaces.md.tpl +15 -0
  117. package/template/workspace/.claude/settings.json.tpl +10 -0
  118. package/template/workspace/AGENTS.md.tpl +17 -0
  119. package/template/workspace/CLAUDE.md.tpl +18 -0
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cp, mkdir, access } from "node:fs/promises";
4
+ import { constants as fsConstants } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { execFileSync } from "node:child_process";
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ const workspaceRoot = path.resolve(repoRoot, "..");
11
+ const consumers = {{CONSUMER_CONFIG_JSON}};
12
+ const models = {
13
+ openai: {{MODEL_OPENAI_ENABLED}},
14
+ anthropic: {{MODEL_ANTHROPIC_ENABLED}},
15
+ };
16
+ const clientSupport = {
17
+ claudeRules: {{CLIENT_CLAUDE_RULES_ENABLED}},
18
+ };
19
+
20
+ function parseArgs(argv) {
21
+ return {
22
+ force: argv.includes("--force"),
23
+ dryRun: argv.includes("--dry-run"),
24
+ };
25
+ }
26
+
27
+ async function exists(filePath) {
28
+ try {
29
+ await access(filePath, fsConstants.F_OK);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ async function copyIfAllowed(sourceRelative, targetRelative, options) {
37
+ const source = path.join(repoRoot, sourceRelative);
38
+ const target = path.join(workspaceRoot, targetRelative);
39
+ if (!(await exists(source))) return;
40
+ if (options.dryRun) {
41
+ console.log(`would install ${target}`);
42
+ return;
43
+ }
44
+ if ((await exists(target)) && !options.force) {
45
+ console.log(`skipped existing ${target}`);
46
+ return;
47
+ }
48
+ await mkdir(path.dirname(target), { recursive: true });
49
+ await cp(source, target);
50
+ console.log(`installed ${target}`);
51
+ }
52
+
53
+ async function verifyConsumers() {
54
+ const missing = [];
55
+ for (const consumer of consumers) {
56
+ const consumerRoot = path.resolve(workspaceRoot, consumer.workspacePath);
57
+ if (!(await exists(consumerRoot))) {
58
+ missing.push(`${consumer.name}: expected repo at ${consumerRoot}`);
59
+ }
60
+ }
61
+ if (missing.length > 0) {
62
+ throw new Error(`Missing consumer repos:\n${missing.map((item) => `- ${item}`).join("\n")}`);
63
+ }
64
+ }
65
+
66
+ async function main() {
67
+ const options = parseArgs(process.argv.slice(2));
68
+ await verifyConsumers();
69
+
70
+ if (models.openai) {
71
+ await copyIfAllowed("workspace/AGENTS.md", "AGENTS.md", options);
72
+ }
73
+ if (models.anthropic) {
74
+ await copyIfAllowed("workspace/CLAUDE.md", "CLAUDE.md", options);
75
+ await copyIfAllowed("workspace/.claude/CLAUDE.md", ".claude/CLAUDE.md", options);
76
+ await copyIfAllowed("workspace/.claude/settings.json", ".claude/settings.json", options);
77
+ if (clientSupport.claudeRules) {
78
+ await copyIfAllowed(
79
+ "workspace/.claude/rules/harness-client-surfaces.md",
80
+ ".claude/rules/harness-client-surfaces.md",
81
+ options,
82
+ );
83
+ }
84
+ }
85
+
86
+ if (!options.dryRun) {
87
+ execFileSync(process.execPath, [path.join(repoRoot, "scripts/check-workspace.mjs")], {
88
+ cwd: repoRoot,
89
+ stdio: "inherit",
90
+ });
91
+ }
92
+
93
+ console.log("Workspace bootstrap complete.");
94
+ console.log(`Workspace root: ${workspaceRoot}`);
95
+ }
96
+
97
+ main().catch((error) => {
98
+ console.error(error instanceof Error ? error.message : String(error));
99
+ process.exit(1);
100
+ });
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, readdir, access } from "node:fs/promises";
4
+ import { constants as fsConstants } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
+ const claudeRulesEnabled = {{CLIENT_CLAUDE_RULES_ENABLED}};
10
+ const claudeHooksEnabled = {{CLIENT_CLAUDE_HOOKS_ENABLED}};
11
+ const claudeSkillsEnabled = {{CLIENT_CLAUDE_SKILLS_ENABLED}};
12
+
13
+ const requiredFiles = [
14
+ "CLAUDE.md",
15
+ ".claude/CLAUDE.md",
16
+ ".claude/settings.json",
17
+ "ai/model-overlays/anthropic/CLAUDE.md",
18
+ ];
19
+
20
+ if (claudeRulesEnabled) requiredFiles.push(".claude/rules/harness-client-surfaces.md");
21
+
22
+ async function exists(relativePath) {
23
+ try {
24
+ await access(path.join(repoRoot, relativePath), fsConstants.F_OK);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ async function read(relativePath) {
32
+ return readFile(path.join(repoRoot, relativePath), "utf8");
33
+ }
34
+
35
+ async function readJson(relativePath) {
36
+ return JSON.parse(await read(relativePath));
37
+ }
38
+
39
+ async function listMarkdownFiles(relativeRoot) {
40
+ const absoluteRoot = path.join(repoRoot, relativeRoot);
41
+ const files = [];
42
+ async function walk(currentPath) {
43
+ const entries = await readdir(currentPath, { withFileTypes: true });
44
+ for (const entry of entries) {
45
+ const absolute = path.join(currentPath, entry.name);
46
+ const relative = path.relative(repoRoot, absolute).replaceAll(path.sep, "/");
47
+ if (entry.isDirectory()) await walk(absolute);
48
+ else if (entry.isFile() && entry.name.endsWith(".md")) files.push(relative);
49
+ }
50
+ }
51
+ if (await exists(relativeRoot)) await walk(absoluteRoot);
52
+ return files;
53
+ }
54
+
55
+ function requireIncludes(content, needle, label, errors) {
56
+ if (!content.includes(needle)) errors.push(`${label} must include '${needle}'.`);
57
+ }
58
+
59
+ const errors = [];
60
+ for (const relativePath of requiredFiles) {
61
+ if (!(await exists(relativePath))) errors.push(`${relativePath} is required for Claude Code compatibility.`);
62
+ }
63
+
64
+ if (errors.length === 0) {
65
+ const rootClaude = await read("CLAUDE.md");
66
+ requireIncludes(rootClaude, "Claude Code", "CLAUDE.md", errors);
67
+ requireIncludes(rootClaude, "./ai/AGENTS.md", "CLAUDE.md", errors);
68
+ requireIncludes(rootClaude, "./ai/HUB.md", "CLAUDE.md", errors);
69
+ if (/^\s*1\.\s+`\.\/AGENTS\.md`/m.test(rootClaude)) {
70
+ errors.push("CLAUDE.md must not require Claude Code to start from AGENTS.md.");
71
+ }
72
+
73
+ const claudeProject = await read(".claude/CLAUDE.md");
74
+ requireIncludes(claudeProject, "root `CLAUDE.md`", ".claude/CLAUDE.md", errors);
75
+
76
+ const settings = await readJson(".claude/settings.json");
77
+ if (!settings.permissions || !Array.isArray(settings.permissions.deny)) {
78
+ errors.push(".claude/settings.json must define permissions.deny.");
79
+ }
80
+ for (const denyPattern of ["Read(./.agent.env)", "Read(./.env)", "Read(./.env.*)"]) {
81
+ if (!settings.permissions?.deny?.includes(denyPattern)) {
82
+ errors.push(`.claude/settings.json permissions.deny must include ${denyPattern}.`);
83
+ }
84
+ }
85
+ if (!claudeHooksEnabled && Object.hasOwn(settings, "hooks")) {
86
+ errors.push(".claude/settings.json must not configure Claude hooks unless clientSupport.claude.hooks is enabled.");
87
+ }
88
+
89
+ if (claudeRulesEnabled) {
90
+ const rule = await read(".claude/rules/harness-client-surfaces.md");
91
+ for (const token of ["paths:", "AGENTS.md", "CLAUDE.md", ".claude/**", "ai/model-overlays/**"]) {
92
+ requireIncludes(rule, token, ".claude/rules/harness-client-surfaces.md", errors);
93
+ }
94
+ }
95
+
96
+ if (claudeSkillsEnabled) {
97
+ const skillFiles = await listMarkdownFiles(".claude/skills");
98
+ const skillRoots = new Set(skillFiles.map((file) => file.match(/^\.claude\/skills\/([^/]+)\//)?.[1]).filter(Boolean));
99
+ for (const skillName of skillRoots) {
100
+ if (!skillFiles.includes(`.claude/skills/${skillName}/SKILL.md`)) {
101
+ errors.push(`.claude/skills/${skillName}/SKILL.md is required for committed Claude skill directories.`);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ if (errors.length > 0) {
108
+ console.error("Claude compatibility check failed.");
109
+ for (const error of errors) console.error(`- ${error}`);
110
+ process.exit(1);
111
+ }
112
+
113
+ console.log("Claude compatibility check passed.");
114
+ console.log("Supported Claude Code surface:");
115
+ console.log("- CLAUDE.md");
116
+ console.log("- .claude/CLAUDE.md");
117
+ console.log("- .claude/settings.json");
118
+ if (claudeRulesEnabled) console.log("- .claude/rules/harness-client-surfaces.md");
119
+ if (!claudeHooksEnabled) console.log("Deferred Claude Code surface: .claude/hooks/**");
120
+ if (!claudeSkillsEnabled) console.log("Deferred Claude Code surface: .claude/skills/**");
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { spawnSync } from "node:child_process";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { denyRules } from "./hooks/lib/codex-hooks-core.mjs";
8
+
9
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+
11
+ const eventSessionStart = "SessionStart";
12
+ const eventUserPromptSubmit = "UserPromptSubmit";
13
+ const eventPreToolUse = "PreToolUse";
14
+ const eventPermissionRequest = "PermissionRequest";
15
+ const eventPostToolUse = "PostToolUse";
16
+ const eventStop = "Stop";
17
+ const hookCommandForEvent = (event) => `node scripts/hooks/codex-hook.mjs ${event} --json`;
18
+ const fixtureTimeoutMs = 2000;
19
+ const defaultExitCodeSuccess = 0;
20
+
21
+ const expectedEvents = [
22
+ eventSessionStart,
23
+ eventUserPromptSubmit,
24
+ eventPreToolUse,
25
+ eventPermissionRequest,
26
+ eventPostToolUse,
27
+ eventStop,
28
+ ];
29
+
30
+ const fixtures = [
31
+ {
32
+ name: "session-start-context",
33
+ event: eventSessionStart,
34
+ input: { cwd: repoRoot },
35
+ expectedAction: "context",
36
+ expectedExitCode: defaultExitCodeSuccess,
37
+ },
38
+ {
39
+ name: "prompt-implementation-context",
40
+ event: eventUserPromptSubmit,
41
+ input: { prompt: "implement the next task" },
42
+ expectedAction: "context",
43
+ expectedExitCode: defaultExitCodeSuccess,
44
+ },
45
+ {
46
+ name: "destructive-command-deny",
47
+ event: eventPreToolUse,
48
+ input: { toolInput: { cmd: "git reset --hard HEAD" } },
49
+ expectedAction: "deny",
50
+ expectedExitCode: 2,
51
+ },
52
+ {
53
+ name: "failed-validation-context",
54
+ event: eventPostToolUse,
55
+ input: { command: "node scripts/validate-governance.mjs", exitCode: 1 },
56
+ expectedAction: "context",
57
+ expectedExitCode: defaultExitCodeSuccess,
58
+ },
59
+ {
60
+ name: "stop-no-change-allow",
61
+ event: eventStop,
62
+ input: { changedFiles: [] },
63
+ expectedAction: "allow",
64
+ expectedExitCode: defaultExitCodeSuccess,
65
+ },
66
+ {
67
+ name: "malformed-input-context",
68
+ event: eventPreToolUse,
69
+ rawInput: "{not json",
70
+ expectedAction: "context",
71
+ expectedExitCode: defaultExitCodeSuccess,
72
+ },
73
+ ];
74
+
75
+ async function readJson(relativePath) {
76
+ return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
77
+ }
78
+
79
+ function checkTimeoutLimit(errors, event, timeoutMs) {
80
+ if (timeoutMs && timeoutMs > fixtureTimeoutMs) {
81
+ errors.push(`${event} timeoutMs must be ${fixtureTimeoutMs} or less.`);
82
+ }
83
+ }
84
+
85
+ function hookCommandFor(event) {
86
+ return hookCommandForEvent(event);
87
+ }
88
+
89
+ async function checkConfig(errors) {
90
+ const config = await readJson(".codex/hooks.json");
91
+ if (config.version !== 1) errors.push(".codex/hooks.json must set version: 1.");
92
+ for (const event of expectedEvents) {
93
+ const entries = config.hooks?.[event];
94
+ if (!Array.isArray(entries) || entries.length === 0) {
95
+ errors.push(`.codex/hooks.json missing ${event} hook entry.`);
96
+ continue;
97
+ }
98
+ const commands = entries.flatMap((entry) => (entry.hooks ?? []).map((hook) => hook.command));
99
+ const expectedCommand = hookCommandFor(event);
100
+ if (!commands.includes(expectedCommand)) {
101
+ errors.push(`${event} must reference committed command '${expectedCommand}'.`);
102
+ }
103
+ for (const entry of entries) {
104
+ checkTimeoutLimit(errors, event, entry.timeoutMs);
105
+ }
106
+ }
107
+ }
108
+
109
+ async function checkHookScripts(errors) {
110
+ const hooksDir = path.join(repoRoot, "scripts/hooks");
111
+ const files = (await readdir(hooksDir, { recursive: true }))
112
+ .filter((file) => file.endsWith(".mjs"))
113
+ .map((file) => `scripts/hooks/${file}`);
114
+ const bannedPatterns = [
115
+ { pattern: /node:child_process|from\s+["']child_process["']/i, label: "process supervision" },
116
+ { pattern: /\b(fetch|XMLHttpRequest)\s*\(/, label: "network call" },
117
+ { pattern: /from\s+["']node:fs\/promises["']|from\s+["']fs\/promises["']/i, label: "file-system write-capable import" },
118
+ { pattern: /\b(writeFile|appendFile|mkdir|rm|unlink|rmdir)\s*\(/, label: "file mutation" },
119
+ ];
120
+ for (const relativePath of files) {
121
+ const source = await readFile(path.join(repoRoot, relativePath), "utf8");
122
+ for (const banned of bannedPatterns) {
123
+ if (banned.pattern.test(source)) {
124
+ errors.push(`${relativePath} contains ${banned.label} token. Hook scripts must stay deterministic and local.`);
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ function checkDenyRules(errors) {
131
+ for (const rule of denyRules) {
132
+ for (const field of ["id", "prevents", "remediation", "falsePositiveNote"]) {
133
+ if (!rule[field]) errors.push(`deny rule missing ${field}.`);
134
+ }
135
+ if (!Array.isArray(rule.policyDocs) || rule.policyDocs.length === 0) {
136
+ errors.push(`${rule.id ?? "deny rule"} missing policyDocs.`);
137
+ }
138
+ if (!(rule.pattern instanceof RegExp)) {
139
+ errors.push(`${rule.id ?? "deny rule"} missing RegExp pattern.`);
140
+ }
141
+ }
142
+ }
143
+
144
+ function runFixture(fixture) {
145
+ const child = spawnSync(process.execPath, ["scripts/hooks/codex-hook.mjs", fixture.event, "--json"], {
146
+ cwd: repoRoot,
147
+ input: fixture.rawInput ?? JSON.stringify(fixture.input ?? {}),
148
+ encoding: "utf8",
149
+ timeout: fixtureTimeoutMs,
150
+ });
151
+ return child;
152
+ }
153
+
154
+ function checkFixtures(errors) {
155
+ for (const fixture of fixtures) {
156
+ const child = runFixture(fixture);
157
+ if (child.error) {
158
+ errors.push(`${fixture.name}: hook execution failed (${child.error.message}).`);
159
+ continue;
160
+ }
161
+ let output;
162
+ try {
163
+ output = JSON.parse(child.stdout);
164
+ } catch {
165
+ errors.push(`${fixture.name}: hook did not return JSON output. stdout=${JSON.stringify(child.stdout)}`);
166
+ continue;
167
+ }
168
+ if (child.status !== fixture.expectedExitCode) {
169
+ errors.push(`${fixture.name}: expected exit ${fixture.expectedExitCode}, got ${child.status}.`);
170
+ }
171
+ if (output.action !== fixture.expectedAction) {
172
+ errors.push(`${fixture.name}: expected action ${fixture.expectedAction}, got ${output.action}.`);
173
+ }
174
+ }
175
+ }
176
+
177
+ const errors = [];
178
+ await checkConfig(errors);
179
+ await checkHookScripts(errors);
180
+ checkDenyRules(errors);
181
+ checkFixtures(errors);
182
+
183
+ if (errors.length > 0) {
184
+ console.error("Codex hook check failed.");
185
+ for (const error of errors) console.error(`- ${error}`);
186
+ process.exit(1);
187
+ }
188
+
189
+ console.log("Codex hook check passed.");
190
+ console.log("External writes/network/runtime-state writes: none detected in hook scripts.");
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { access, readFile, readdir } from "node:fs/promises";
4
+ import { constants as fsConstants } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
+ const contractsDirectory = "ai/contracts";
10
+ const contractsReadmePath = `${contractsDirectory}/README.md`;
11
+ const contractFileSuffix = ".contract.json";
12
+ const docFileSuffix = ".md";
13
+ const requiredFields = ["id", "name", "version", "owners", "affectedRepos", "requiredFiles"];
14
+ const semverPattern = /^\d+\.\d+\.\d+$/;
15
+
16
+ async function exists(relativePath) {
17
+ try {
18
+ await access(path.join(repoRoot, relativePath), fsConstants.F_OK);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ function validateManifest(manifest, label, errors) {
26
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
27
+ errors.push(`${label} must be a JSON object.`);
28
+ return;
29
+ }
30
+ for (const field of requiredFields) {
31
+ if (!Object.hasOwn(manifest, field)) {
32
+ errors.push(`${label} is missing '${field}'.`);
33
+ }
34
+ }
35
+ if (typeof manifest.version === "string" && !semverPattern.test(manifest.version)) {
36
+ errors.push(`${label}.version must use semver-like x.y.z.`);
37
+ }
38
+ for (const field of ["owners", "affectedRepos", "requiredFiles"]) {
39
+ if (!Array.isArray(manifest[field]) || manifest[field].length === 0) {
40
+ errors.push(`${label}.${field} must be a non-empty array.`);
41
+ }
42
+ }
43
+ }
44
+
45
+ const errors = [];
46
+ const entries = await readdir(path.join(repoRoot, contractsDirectory), { withFileTypes: true });
47
+ const readme = await readFile(path.join(repoRoot, contractsReadmePath), "utf8");
48
+
49
+ for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(docFileSuffix) && item.name !== "README.md")) {
50
+ if (!readme.includes(entry.name)) {
51
+ errors.push(`${contractsDirectory}/${entry.name} is not linked from ${contractsReadmePath}.`);
52
+ }
53
+ }
54
+
55
+ for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(contractFileSuffix))) {
56
+ const relativePath = `${contractsDirectory}/${entry.name}`;
57
+ try {
58
+ const manifest = JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
59
+ validateManifest(manifest, relativePath, errors);
60
+ for (const requiredFile of manifest.requiredFiles ?? []) {
61
+ if (!(await exists(requiredFile))) {
62
+ errors.push(`${relativePath} requires missing file ${requiredFile}.`);
63
+ }
64
+ }
65
+ } catch {
66
+ errors.push(`${relativePath} must be valid JSON.`);
67
+ }
68
+
69
+ const docPath = `${relativePath.replace(contractFileSuffix, docFileSuffix)}`;
70
+ if (!(await exists(docPath))) {
71
+ errors.push(`${relativePath} must have a sibling ${docPath}.`);
72
+ }
73
+ }
74
+
75
+ if (errors.length > 0) {
76
+ console.error("Contract manifest check failed.");
77
+ for (const error of errors) console.error(`- ${error}`);
78
+ process.exit(1);
79
+ }
80
+
81
+ console.log("Contract manifest check passed.");
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const garbageCollectionPolicyPath = "ai/AGENT-GARBAGE-COLLECTION.md";
9
+ const requiredGarbagePhrases = ["archive", "stale", "generated", "validation"];
10
+ const content = await readFile(path.join(repoRoot, garbageCollectionPolicyPath), "utf8");
11
+ const errors = [];
12
+
13
+ for (const phrase of requiredGarbagePhrases) {
14
+ if (!content.toLowerCase().includes(phrase)) {
15
+ errors.push(`${garbageCollectionPolicyPath} should mention ${phrase}.`);
16
+ }
17
+ }
18
+
19
+ if (errors.length > 0) {
20
+ console.error("Garbage collection check failed.");
21
+ for (const error of errors) console.error(`- ${error}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ console.log("Garbage collection check passed.");
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdtemp, readFile, readdir } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { execFileSync } from "node:child_process";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
+ const tempDirectoryTemplate = "{{PROJECT_SLUG}}-views-";
12
+ const viewsDirectory = "ai/views";
13
+ const generateHtmlViewsScript = "scripts/generate-html-views.mjs";
14
+ const staleGeneratedViewInstruction = `run node ${generateHtmlViewsScript}`;
15
+ const htmlExtension = ".html";
16
+ const expectedViewPathPrefix = `${viewsDirectory}/`;
17
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), tempDirectoryTemplate));
18
+
19
+ execFileSync(process.execPath, [generateHtmlViewsScript, "--output", tempRoot], {
20
+ cwd: repoRoot,
21
+ stdio: "pipe",
22
+ });
23
+
24
+ async function htmlFiles(baseDir) {
25
+ const dir = path.join(baseDir, viewsDirectory);
26
+ if (!existsSync(dir)) return [];
27
+ const entries = await readdir(dir, { withFileTypes: true });
28
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(htmlExtension)).map((entry) => entry.name).sort();
29
+ }
30
+
31
+ const expected = await htmlFiles(tempRoot);
32
+ const actual = await htmlFiles(repoRoot);
33
+ const errors = [];
34
+
35
+ for (const name of expected) {
36
+ const expectedViewPath = `${expectedViewPathPrefix}${name}`;
37
+ if (!actual.includes(name)) {
38
+ errors.push(`missing generated view ${expectedViewPath}`);
39
+ continue;
40
+ }
41
+ const expectedContent = await readFile(path.join(tempRoot, viewsDirectory, name), "utf8");
42
+ const actualContent = await readFile(path.join(repoRoot, viewsDirectory, name), "utf8");
43
+ if (expectedContent !== actualContent) {
44
+ errors.push(`stale generated view ${expectedViewPath}; ${staleGeneratedViewInstruction}`);
45
+ }
46
+ }
47
+
48
+ for (const name of actual) {
49
+ if (!expected.includes(name)) {
50
+ errors.push(`unexpected generated view ${expectedViewPathPrefix}${name}`);
51
+ }
52
+ }
53
+
54
+ if (errors.length > 0) {
55
+ console.error("HTML view check failed.");
56
+ for (const error of errors) console.error(`- ${error}`);
57
+ process.exit(1);
58
+ }
59
+
60
+ console.log("HTML view check passed.");