@structor-dev/cli 0.1.0 → 0.2.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 (65) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +131 -21
  3. package/ROADMAP.md +38 -0
  4. package/SECURITY.md +33 -0
  5. package/bin/structor.mjs +561 -29
  6. package/contrib/self-harness/files/README.md +32 -0
  7. package/contrib/self-harness/files/ai/AGENTS.md +35 -0
  8. package/contrib/self-harness/files/ai/ARCHITECTURE.md +38 -0
  9. package/contrib/self-harness/files/ai/HUB.md +59 -0
  10. package/contrib/self-harness/files/ai/PRODUCT.md +36 -0
  11. package/contrib/self-harness/files/ai/QUALITY.md +31 -0
  12. package/contrib/self-harness/files/ai/context.md +38 -0
  13. package/contrib/self-harness/files/scripts/check-workspace.mjs +72 -0
  14. package/contrib/self-harness/harness.config.json +37 -0
  15. package/docs/CONTRIBUTOR-SETUP.md +45 -0
  16. package/docs/INIT.md +55 -2
  17. package/docs/public-launch.md +150 -0
  18. package/examples/anthropic-only/harness.config.json +26 -0
  19. package/examples/frontend-backend/harness.config.json +8 -8
  20. package/examples/generated-harness-tree.md +432 -0
  21. package/examples/openai-and-anthropic/harness.config.json +7 -7
  22. package/examples/single-repo/harness.config.json +7 -7
  23. package/harness.config.example.json +1 -1
  24. package/package.json +12 -4
  25. package/schemas/contract-manifest.schema.json +0 -1
  26. package/schemas/harness-config.schema.json +5 -2
  27. package/scripts/check-config.mjs +20 -31
  28. package/scripts/check-examples.mjs +146 -0
  29. package/scripts/check-placeholders.mjs +2 -0
  30. package/scripts/check-public-hygiene.mjs +249 -0
  31. package/scripts/check-schemas.mjs +42 -0
  32. package/scripts/check-template-files.mjs +15 -98
  33. package/scripts/generated-harness-contract.mjs +416 -0
  34. package/scripts/init-harness.mjs +227 -139
  35. package/scripts/lib.mjs +462 -12
  36. package/scripts/rendered-config.mjs +109 -0
  37. package/scripts/setup-contributor.mjs +125 -0
  38. package/scripts/smoke-template.mjs +260 -73
  39. package/template/AGENTS.md.tpl +4 -2
  40. package/template/README.md.tpl +5 -0
  41. package/template/ai/CODEX-HOOKS.md.tpl +1 -1
  42. package/template/ai/HARNESS-ENGINEERING.md.tpl +5 -2
  43. package/template/ai/HARNESS.md.tpl +4 -1
  44. package/template/ai/contracts/codex-hooks.contract.json.tpl +58 -1
  45. package/template/ai/contracts/codex-hooks.md.tpl +6 -0
  46. package/template/ai/contracts/release-flow.md.tpl +1 -1
  47. package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +3 -1
  48. package/template/ai/templates/issue-template.md.tpl +3 -1
  49. package/template/ai/workspace/LOCAL-STACK.md.tpl +1 -1
  50. package/template/ai/workspace/SYSTEM-MAP.md.tpl +2 -2
  51. package/template/consumer/AGENTS.md.tpl +4 -4
  52. package/template/consumer/CLAUDE.md.tpl +4 -4
  53. package/template/scripts/bootstrap-workspace.mjs.tpl +11 -25
  54. package/template/scripts/check-claude-compatibility.mjs.tpl +62 -9
  55. package/template/scripts/check-codex-hooks.mjs.tpl +262 -20
  56. package/template/scripts/check-template-governance.mjs.tpl +2 -114
  57. package/template/scripts/check-workspace.mjs.tpl +27 -103
  58. package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +12 -0
  59. package/template/scripts/generate-html-views.mjs.tpl +357 -56
  60. package/template/scripts/generated-harness-contract.mjs.tpl +1 -0
  61. package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +14 -3
  62. package/template/scripts/lib/path-safety.mjs.tpl +87 -0
  63. package/template/scripts/lib/worktree-bootstrap.mjs.tpl +16 -13
  64. package/template/scripts/validate-governance.mjs.tpl +52 -36
  65. package/schemas/task-brief.schema.json +0 -37
@@ -3,12 +3,26 @@
3
3
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { assertSafeWriteTarget } from "./lib/path-safety.mjs";
6
7
 
7
8
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
9
  const args = process.argv.slice(2);
9
10
  const outputArgIndex = args.indexOf("--output");
10
11
  const outputRoot = outputArgIndex === -1 ? repoRoot : path.resolve(args[outputArgIndex + 1]);
11
12
  const viewsDir = path.join(outputRoot, "ai/views");
13
+ const projectName = {{PROJECT_NAME_JSON}};
14
+ const harnessRepoName = "{{HARNESS_REPO_NAME}}";
15
+ const consumers = {{CONSUMER_CONFIG_JSON}};
16
+ const modelSupport = {
17
+ openai: {{MODEL_OPENAI_ENABLED}},
18
+ anthropic: {{MODEL_ANTHROPIC_ENABLED}},
19
+ };
20
+ const clientSupport = {
21
+ codexHooks: {{CLIENT_CODEX_HOOKS_ENABLED}},
22
+ claudeRules: {{CLIENT_CLAUDE_RULES_ENABLED}},
23
+ claudeHooks: {{CLIENT_CLAUDE_HOOKS_ENABLED}},
24
+ claudeSkills: {{CLIENT_CLAUDE_SKILLS_ENABLED}},
25
+ };
12
26
 
13
27
  async function read(relativePath) {
14
28
  try {
@@ -46,32 +60,250 @@ function frontMatterValue(content, key) {
46
60
  return content.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? "";
47
61
  }
48
62
 
63
+ function code(value) {
64
+ return `<code>${escapeHtml(value)}</code>`;
65
+ }
66
+
67
+ function list(items) {
68
+ if (items.length === 0) return `<span class="muted">n/a</span>`;
69
+ return `<ul class="compact-list">${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
70
+ }
71
+
72
+ function pill(label, tone = "neutral") {
73
+ return `<span class="pill ${tone}">${escapeHtml(label)}</span>`;
74
+ }
75
+
76
+ function parseJson(content) {
77
+ if (!content.trim()) return null;
78
+ try {
79
+ return JSON.parse(content);
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function markdownTableRows(content, headerText, expectedCellCount) {
86
+ const rows = [];
87
+ const lines = content.split(/\r?\n/);
88
+ const start = lines.findIndex((line) => line.startsWith(headerText));
89
+ if (start === -1) return rows;
90
+ for (const line of lines.slice(start + 2)) {
91
+ if (!line.startsWith("|")) break;
92
+ const cells = line
93
+ .split("|")
94
+ .slice(1, -1)
95
+ .map((cell) => cell.trim());
96
+ if (cells.length >= expectedCellCount) rows.push(cells.slice(0, expectedCellCount));
97
+ }
98
+ return rows;
99
+ }
100
+
101
+ function firstScriptFromCommand(command) {
102
+ return command.match(/\bnode\s+(scripts\/[^\s`|&;]+)/)?.[1] ?? "";
103
+ }
104
+
105
+ async function scriptExistsForCommand(command) {
106
+ const script = firstScriptFromCommand(command);
107
+ if (!script) return false;
108
+ return Boolean(await read(script));
109
+ }
110
+
111
+ async function contractRecords() {
112
+ const contractFiles = await files("ai/contracts", ".md");
113
+ const rows = [];
114
+ for (const file of contractFiles.filter((item) => item !== "ai/contracts/README.md")) {
115
+ const content = await read(file);
116
+ const manifestPath = file.replace(/\.md$/, ".contract.json");
117
+ const manifest = parseJson(await read(manifestPath));
118
+ const title = titleFromMarkdown(content, path.basename(file));
119
+ rows.push({
120
+ source: file,
121
+ title,
122
+ manifestPath,
123
+ manifest,
124
+ manifestPresent: Boolean(manifest),
125
+ manifestName: manifest?.name ?? manifest?.title ?? "",
126
+ manifestId: manifest?.id ?? "",
127
+ requiredFiles: Array.isArray(manifest?.requiredFiles) ? manifest.requiredFiles : [],
128
+ validation: Array.isArray(manifest?.validation) ? manifest.validation : [],
129
+ });
130
+ }
131
+ return rows;
132
+ }
133
+
134
+ async function readinessRecords() {
135
+ const readiness = await read("ai/READINESS.md");
136
+ const rows = markdownTableRows(readiness, "| Gate | Command | Required evidence |", 3);
137
+ return await Promise.all(
138
+ rows.map(async ([gate, command, evidence]) => {
139
+ const normalizedCommand = command.replaceAll("`", "");
140
+ const script = firstScriptFromCommand(normalizedCommand);
141
+ return {
142
+ gate,
143
+ command: normalizedCommand,
144
+ evidence,
145
+ source: "ai/READINESS.md",
146
+ script,
147
+ scriptExists: script ? await scriptExistsForCommand(normalizedCommand) : false,
148
+ expectation: /when|optional|deferred/i.test(evidence) ? "conditional" : "expected",
149
+ };
150
+ }),
151
+ );
152
+ }
153
+
154
+ function consumerRecords() {
155
+ return consumers.map((consumer) => {
156
+ const validation = Object.entries(consumer.validation ?? {});
157
+ return {
158
+ name: consumer.name,
159
+ path: consumer.workspacePath ?? consumer.path,
160
+ purpose: consumer.purpose ?? "n/a",
161
+ validation,
162
+ codexEntrypoint: modelSupport.openai ? "AGENTS.md" : "disabled",
163
+ claudeEntrypoints: modelSupport.anthropic ? ["CLAUDE.md", ".claude/CLAUDE.md"] : [],
164
+ codexEnabled: modelSupport.openai,
165
+ claudeEnabled: modelSupport.anthropic,
166
+ };
167
+ });
168
+ }
169
+
170
+ function summarizeContractCategories(contracts) {
171
+ const categories = new Set();
172
+ for (const contract of contracts) {
173
+ const id = contract.manifestId || path.basename(contract.source, ".md");
174
+ categories.add(id.split("-")[0]);
175
+ }
176
+ return [...categories].slice(0, 5);
177
+ }
178
+
179
+ function topologyDiagram({ contracts, readiness }) {
180
+ const consumerCount = consumers.length;
181
+ const contractCount = contracts.length;
182
+ const gateCount = readiness.length;
183
+ return `
184
+ <svg class="topology" viewBox="0 0 980 390" role="img" aria-labelledby="topology-title topology-desc">
185
+ <title id="topology-title">Harness Cockpit topology diagram</title>
186
+ <desc id="topology-desc">Static generated diagram showing the generated harness, consumer repositories, client surfaces, contracts, and validation expectations.</desc>
187
+ <defs>
188
+ <linearGradient id="panel-gradient" x1="0" x2="1" y1="0" y2="1">
189
+ <stop offset="0%" stop-color="#0f2533"/>
190
+ <stop offset="100%" stop-color="#132f24"/>
191
+ </linearGradient>
192
+ <filter id="soft-shadow" x="-10%" y="-10%" width="120%" height="130%">
193
+ <feDropShadow dx="0" dy="8" stdDeviation="10" flood-color="#08131a" flood-opacity="0.18"/>
194
+ </filter>
195
+ </defs>
196
+ <rect width="980" height="390" rx="8" fill="#eef3f0"/>
197
+ <rect x="30" y="34" width="300" height="322" rx="8" fill="url(#panel-gradient)" filter="url(#soft-shadow)"/>
198
+ <text x="58" y="78" class="svg-kicker">GENERATED HARNESS</text>
199
+ <text x="58" y="112" class="svg-title">${escapeHtml(harnessRepoName)}</text>
200
+ <text x="58" y="146" class="svg-copy">${escapeHtml(projectName)}</text>
201
+ <text x="58" y="198" class="svg-label">Canonical policy</text>
202
+ <text x="58" y="226" class="svg-label">Contracts and task shape</text>
203
+ <text x="58" y="254" class="svg-label">Readiness expectations</text>
204
+ <rect x="390" y="48" width="238" height="92" rx="8" fill="#ffffff" stroke="#b7c6c0"/>
205
+ <text x="414" y="82" class="svg-node-title">Consumer repositories</text>
206
+ <text x="414" y="112" class="svg-node-copy">${consumerCount} configured</text>
207
+ <rect x="390" y="164" width="238" height="92" rx="8" fill="#ffffff" stroke="#b7c6c0"/>
208
+ <text x="414" y="198" class="svg-node-title">Client surfaces</text>
209
+ <text x="414" y="228" class="svg-node-copy">Codex ${modelSupport.openai ? "enabled" : "disabled"} / Claude ${modelSupport.anthropic ? "enabled" : "disabled"}</text>
210
+ <rect x="390" y="280" width="238" height="60" rx="8" fill="#ffffff" stroke="#b7c6c0"/>
211
+ <text x="414" y="316" class="svg-node-title">Consumer entrypoints</text>
212
+ <rect x="704" y="80" width="216" height="84" rx="8" fill="#ffffff" stroke="#b7c6c0"/>
213
+ <text x="728" y="114" class="svg-node-title">Contract groups</text>
214
+ <text x="728" y="144" class="svg-node-copy">${contractCount} markdown sources</text>
215
+ <rect x="704" y="220" width="216" height="84" rx="8" fill="#ffffff" stroke="#b7c6c0"/>
216
+ <text x="728" y="254" class="svg-node-title">Validation readiness</text>
217
+ <text x="728" y="284" class="svg-node-copy">${gateCount} expected gates</text>
218
+ <path d="M330 106 C360 106 360 94 390 94" stroke="#2f6f5e" stroke-width="3" fill="none"/>
219
+ <path d="M330 214 C360 214 360 210 390 210" stroke="#2f6f5e" stroke-width="3" fill="none"/>
220
+ <path d="M330 296 C360 296 360 310 390 310" stroke="#2f6f5e" stroke-width="3" fill="none"/>
221
+ <path d="M628 94 C664 94 668 122 704 122" stroke="#2f6f5e" stroke-width="3" fill="none"/>
222
+ <path d="M628 210 C668 210 666 122 704 122" stroke="#2f6f5e" stroke-width="3" fill="none"/>
223
+ <path d="M628 310 C666 310 668 262 704 262" stroke="#2f6f5e" stroke-width="3" fill="none"/>
224
+ </svg>
225
+ `;
226
+ }
227
+
49
228
  function layout(title, body) {
50
229
  return `<!doctype html>
51
230
  <html lang="en">
52
231
  <head>
53
232
  <meta charset="utf-8">
54
233
  <meta name="viewport" content="width=device-width, initial-scale=1">
55
- <title>${escapeHtml(title)} - {{PROJECT_NAME}} Harness Views</title>
234
+ <title>${escapeHtml(title)} - ${escapeHtml(projectName)} Harness Cockpit</title>
56
235
  <style>
57
- body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; color: #172026; background: #f7f8fa; }
58
- header, main { max-width: 1080px; margin: 0 auto; padding: 24px; }
59
- header { border-bottom: 1px solid #d8dee4; background: #fff; }
60
- h1 { margin: 0 0 8px; font-size: 28px; }
61
- h2 { margin-top: 28px; font-size: 18px; }
236
+ :root { color-scheme: light; --ink: #172026; --muted: #5b6670; --line: #d9e0de; --panel: #ffffff; --field: #f4f7f6; --accent: #2f6f5e; --accent-2: #b14d2c; --navy: #0f2533; --gold: #9a6a16; }
237
+ * { box-sizing: border-box; }
238
+ body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; color: var(--ink); background: #eef3f0; }
239
+ header { background: var(--navy); color: #f5fbf8; border-bottom: 4px solid var(--accent); }
240
+ header, main { margin: 0 auto; padding: 24px; }
241
+ main { max-width: 1180px; }
242
+ .header-inner { max-width: 1180px; margin: 0 auto; display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) auto; align-items: end; }
243
+ .eyebrow { margin: 0 0 8px; color: #9fd6c6; font-size: 12px; font-weight: 800; letter-spacing: 0; text-transform: uppercase; }
244
+ h1 { margin: 0; font-size: 34px; line-height: 1.08; letter-spacing: 0; }
245
+ h2 { margin: 34px 0 14px; font-size: 20px; line-height: 1.2; }
246
+ h3 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0; color: var(--muted); }
62
247
  p { line-height: 1.5; }
63
- a { color: #0969da; }
64
- table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d8dee4; }
65
- th, td { padding: 10px 12px; border-bottom: 1px solid #d8dee4; text-align: left; vertical-align: top; }
66
- th { background: #eef2f6; font-size: 13px; }
67
- code { background: #eef2f6; padding: 2px 4px; border-radius: 4px; }
68
- .note { color: #57606a; }
248
+ a { color: #1260a3; }
249
+ table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); }
250
+ th, td { padding: 10px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
251
+ th { background: #e6eeeb; color: #24343c; font-size: 12px; text-transform: uppercase; letter-spacing: 0; }
252
+ code { background: #e7eeec; padding: 2px 5px; border-radius: 4px; }
253
+ .note, .muted { color: var(--muted); }
254
+ header .note { color: #c9d8d3; margin: 8px 0 0; }
255
+ nav { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
256
+ nav a { color: #f5fbf8; text-decoration: none; border: 1px solid rgba(255,255,255,.24); border-radius: 6px; padding: 8px 10px; font-size: 13px; }
257
+ .cockpit-band { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 16px; align-items: stretch; margin-top: 18px; }
258
+ .identity-panel, .attention-panel, .section-panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 18px; }
259
+ .identity-panel { background: #102b38; color: #f8fbfa; border-color: #274553; }
260
+ .identity-panel code { background: rgba(255,255,255,.12); color: #ffffff; }
261
+ .attention-panel { border-top: 4px solid var(--gold); }
262
+ .tile-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 16px; }
263
+ .tile { background: var(--field); border: 1px solid var(--line); border-radius: 8px; padding: 12px; min-height: 86px; }
264
+ .tile strong { display: block; font-size: 26px; line-height: 1; margin-bottom: 8px; }
265
+ .tile span { color: var(--muted); font-size: 13px; }
266
+ .identity-panel .tile { background: rgba(255,255,255,.08); border-color: rgba(255,255,255,.16); }
267
+ .identity-panel .tile span { color: #c9d8d3; }
268
+ .matrix { overflow-x: auto; }
269
+ .compact-list { margin: 0; padding-left: 18px; }
270
+ .compact-list li + li { margin-top: 4px; }
271
+ .pill { display: inline-flex; align-items: center; min-height: 22px; border-radius: 999px; padding: 3px 8px; font-size: 12px; font-weight: 700; background: #e7eeec; color: #263238; }
272
+ .pill.ok { background: #dff1e8; color: #15513f; }
273
+ .pill.warn { background: #fff0d3; color: #68430c; }
274
+ .pill.off { background: #eceff1; color: #5b6670; }
275
+ .pill.missing { background: #ffe5dc; color: #7d2f17; }
276
+ .topology { width: 100%; height: auto; display: block; border: 1px solid var(--line); border-radius: 8px; background: #eef3f0; }
277
+ .svg-kicker { fill: #9fd6c6; font-size: 12px; font-weight: 800; letter-spacing: 0; }
278
+ .svg-title { fill: #ffffff; font-size: 26px; font-weight: 800; }
279
+ .svg-copy { fill: #c9d8d3; font-size: 14px; }
280
+ .svg-label { fill: #f5fbf8; font-size: 15px; }
281
+ .svg-node-title { fill: #172026; font-size: 16px; font-weight: 800; }
282
+ .svg-node-copy { fill: #5b6670; font-size: 13px; }
283
+ @media (max-width: 820px) {
284
+ .header-inner, .cockpit-band { grid-template-columns: 1fr; }
285
+ nav { justify-content: flex-start; }
286
+ .tile-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
287
+ }
69
288
  </style>
70
289
  </head>
71
290
  <body>
72
291
  <header>
73
- <h1>${escapeHtml(title)}</h1>
74
- <p class="note">Generated review artifact. Markdown, JSON, and YAML files remain canonical.</p>
292
+ <div class="header-inner">
293
+ <div>
294
+ <p class="eyebrow">Harness Cockpit</p>
295
+ <h1>${escapeHtml(title)}</h1>
296
+ <p class="note">Generated read-only review artifact. Markdown, JSON, and YAML files remain canonical.</p>
297
+ </div>
298
+ <nav aria-label="Generated view navigation">
299
+ <a href="index.html">Overview</a>
300
+ <a href="plans.html">Plans</a>
301
+ <a href="contracts.html">Contracts</a>
302
+ <a href="readiness.html">Readiness</a>
303
+ <a href="quality.html">Quality</a>
304
+ <a href="workspace.html">Workspace</a>
305
+ </nav>
306
+ </div>
75
307
  </header>
76
308
  <main>
77
309
  ${body}
@@ -84,21 +316,67 @@ ${body}
84
316
  function table(headers, rows) {
85
317
  const head = headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("");
86
318
  const body = rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join("")}</tr>`).join("\n");
87
- return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
319
+ const tableBody = body || `<tr><td colspan="${headers.length}"><span class="muted">No records found.</span></td></tr>`;
320
+ return `<div class="matrix"><table><thead><tr>${head}</tr></thead><tbody>${tableBody}</tbody></table></div>`;
88
321
  }
89
322
 
90
323
  async function indexView() {
91
- return layout("Harness Review Views", `
92
- <h2>Views</h2>
93
- <ul>
94
- <li><a href="plans.html">Plans</a></li>
95
- <li><a href="contracts.html">Contracts</a></li>
96
- <li><a href="readiness.html">Readiness</a></li>
97
- <li><a href="quality.html">Quality</a></li>
98
- <li><a href="workspace.html">Workspace</a></li>
99
- </ul>
100
- <h2>Canonical Sources</h2>
101
- <p>Start from <code>ai/HUB.md</code>, <code>ai/context.md</code>, and <code>ai/knowledge-manifest.json</code>.</p>
324
+ const contracts = await contractRecords();
325
+ const readiness = await readinessRecords();
326
+ const consumerSummary = consumerRecords();
327
+ const missingManifests = contracts.filter((contract) => !contract.manifestPresent);
328
+ const missingScripts = readiness.filter((gate) => gate.script && !gate.scriptExists);
329
+ const categories = summarizeContractCategories(contracts);
330
+ const attention = [
331
+ ...missingManifests.map((contract) => `${code(contract.manifestPath)} missing for ${escapeHtml(contract.title)}`),
332
+ ...missingScripts.map((gate) => `${code(gate.script)} missing for ${escapeHtml(gate.gate)}`),
333
+ `${code("node scripts/check-html-views.mjs")} verifies deterministic generated view freshness.`,
334
+ `${code("node scripts/check-workspace.mjs")} verifies consumer pointer routing when pointer status needs proof.`,
335
+ ];
336
+ return layout("Harness Cockpit", `
337
+ <section class="cockpit-band" aria-label="Harness cockpit overview">
338
+ <div class="identity-panel">
339
+ <h2>Overview</h2>
340
+ <p>${escapeHtml(projectName)} is wired through generated harness repo ${code(harnessRepoName)}. This cockpit visualizes local Structor facts; it does not run validation or control workflows.</p>
341
+ <div class="tile-grid">
342
+ <div class="tile"><strong>${escapeHtml(String(consumerSummary.length))}</strong><span>consumer repos</span></div>
343
+ <div class="tile"><strong>${escapeHtml(String(contracts.length))}</strong><span>contract docs</span></div>
344
+ <div class="tile"><strong>${escapeHtml(String(readiness.length))}</strong><span>readiness gates</span></div>
345
+ <div class="tile"><strong>${modelSupport.openai || modelSupport.anthropic ? "On" : "Off"}</strong><span>client surfaces</span></div>
346
+ </div>
347
+ </div>
348
+ <div class="attention-panel">
349
+ <h2>Needs Attention</h2>
350
+ ${list(attention)}
351
+ </div>
352
+ </section>
353
+ <section>
354
+ <h2>What Is Wired</h2>
355
+ <div class="tile-grid">
356
+ <div class="tile"><strong>${modelSupport.openai ? "Codex" : "Off"}</strong><span>${modelSupport.openai ? "AGENTS.md and OpenAI overlay expected" : "OpenAI support disabled"}</span></div>
357
+ <div class="tile"><strong>${modelSupport.anthropic ? "Claude" : "Off"}</strong><span>${modelSupport.anthropic ? "CLAUDE.md surfaces expected" : "Anthropic support disabled"}</span></div>
358
+ <div class="tile"><strong>${clientSupport.codexHooks ? "Hooks" : "No hooks"}</strong><span>Codex hook guardrails</span></div>
359
+ <div class="tile"><strong>${clientSupport.claudeRules ? "Rules" : "No rules"}</strong><span>Claude project rules</span></div>
360
+ </div>
361
+ </section>
362
+ <section>
363
+ <h2>What Is Governed</h2>
364
+ <p>Contract categories: ${categories.length ? categories.map((category) => pill(category, "ok")).join(" ") : '<span class="muted">none found</span>'}</p>
365
+ <p class="note">Canonical sources start at ${code("ai/HUB.md")}, ${code("ai/context.md")}, ${code("ai/contracts/*")}, and ${code("ai/READINESS.md")}.</p>
366
+ </section>
367
+ <section>
368
+ <h2>Topology Diagram</h2>
369
+ ${topologyDiagram({ contracts, readiness })}
370
+ </section>
371
+ <section>
372
+ <h2>Drill-Down Views</h2>
373
+ <div class="tile-grid">
374
+ <div class="tile"><strong><a href="contracts.html">Contracts</a></strong><span>Matrix of docs and manifests</span></div>
375
+ <div class="tile"><strong><a href="readiness.html">Readiness</a></strong><span>Expected validation gates</span></div>
376
+ <div class="tile"><strong><a href="workspace.html">Workspace</a></strong><span>Repos and local docs</span></div>
377
+ <div class="tile"><strong><a href="quality.html">Quality</a></strong><span>Canonical quality notes</span></div>
378
+ </div>
379
+ </section>
102
380
  `);
103
381
  }
104
382
 
@@ -119,17 +397,20 @@ async function plansView() {
119
397
  }
120
398
 
121
399
  async function contractsView() {
122
- const contractFiles = await files("ai/contracts", ".md");
123
- const rows = [];
124
- for (const file of contractFiles.filter((item) => item !== "ai/contracts/README.md")) {
125
- const content = await read(file);
126
- rows.push([
127
- `<code>${escapeHtml(file)}</code>`,
128
- escapeHtml(titleFromMarkdown(content, path.basename(file))),
129
- `<code>${escapeHtml(file.replace(/\\.md$/, ".contract.json"))}</code>`,
130
- ]);
131
- }
132
- return layout("Contracts", table(["Source", "Title", "Manifest"], rows));
400
+ const contracts = await contractRecords();
401
+ const rows = contracts.map((contract) => [
402
+ escapeHtml(contract.title),
403
+ code(contract.source),
404
+ `${code(contract.manifestPath)} ${contract.manifestPresent ? pill("present", "ok") : pill("missing", "missing")}`,
405
+ contract.manifestId ? code(contract.manifestId) : '<span class="muted">n/a</span>',
406
+ contract.manifestName ? escapeHtml(contract.manifestName) : '<span class="muted">n/a</span>',
407
+ list(contract.requiredFiles.map((item) => code(item))),
408
+ list(contract.validation.map((item) => code(item))),
409
+ ]);
410
+ return layout("Contract Matrix", `
411
+ <p>Contract rows are derived from ${code("ai/contracts/*.md")} and matching ${code("ai/contracts/*.contract.json")} manifests. No review status is invented here.</p>
412
+ ${table(["Title", "Markdown Source", "Manifest", "Manifest ID", "Manifest Name", "Canonical Source Docs", "Validation Command"], rows)}
413
+ `);
133
414
  }
134
415
 
135
416
  async function qualityView() {
@@ -142,34 +423,46 @@ async function qualityView() {
142
423
  }
143
424
 
144
425
  async function readinessView() {
145
- const readiness = await read("ai/READINESS.md");
146
- const rows = [];
147
- const lines = readiness.split(/\r?\n/);
148
- const start = lines.findIndex((line) => line.startsWith("| Gate | Command | Required evidence |"));
149
- if (start !== -1) {
150
- for (const line of lines.slice(start + 2)) {
151
- if (!line.startsWith("|")) break;
152
- const cells = line
153
- .split("|")
154
- .slice(1, -1)
155
- .map((cell) => escapeHtml(cell.trim().replaceAll("`", "")));
156
- if (cells.length === 3) rows.push(cells);
157
- }
158
- }
426
+ const readiness = await readinessRecords();
427
+ const rows = readiness.map((gate) => [
428
+ escapeHtml(gate.gate),
429
+ code(gate.command),
430
+ escapeHtml(gate.evidence),
431
+ code(gate.source),
432
+ gate.script ? `${code(gate.script)} ${gate.scriptExists ? pill("exists", "ok") : pill("missing", "missing")}` : '<span class="muted">manual or composite</span>',
433
+ pill(gate.expectation, gate.expectation === "expected" ? "ok" : "warn"),
434
+ ]);
159
435
  return layout("Readiness", `
160
- <p>Source: <code>ai/READINESS.md</code></p>
161
- ${table(["Gate", "Command", "Required evidence"], rows)}
436
+ <p>Source: ${code("ai/READINESS.md")}. The cockpit shows expectations only; it does not run validation or record pass/fail results.</p>
437
+ <p>Run ${code("node scripts/validate-governance.mjs")} and ${code("node scripts/check-workspace.mjs")} for authoritative local validation.</p>
438
+ ${table(["Gate", "Command", "Required Evidence", "Source Doc", "Script", "Expectation"], rows)}
162
439
  `);
163
440
  }
164
441
 
165
442
  async function workspaceView() {
166
443
  const workspaceFiles = await files("ai/workspace", ".md");
167
- const rows = [];
444
+ const docRows = [];
168
445
  for (const file of workspaceFiles) {
169
446
  const content = await read(file);
170
- rows.push([`<code>${escapeHtml(file)}</code>`, escapeHtml(titleFromMarkdown(content, path.basename(file)))]);
447
+ docRows.push([code(file), escapeHtml(titleFromMarkdown(content, path.basename(file)))]);
171
448
  }
172
- return layout("Workspace", table(["Source", "Title"], rows));
449
+ const consumerRows = consumerRecords().map((consumer) => [
450
+ escapeHtml(consumer.name),
451
+ code(consumer.path),
452
+ escapeHtml(consumer.purpose),
453
+ consumer.codexEnabled ? code(consumer.codexEntrypoint) : pill("disabled", "off"),
454
+ consumer.claudeEnabled ? list(consumer.claudeEntrypoints.map((item) => code(item))) : pill("disabled", "off"),
455
+ consumer.validation.length ? list(consumer.validation.map(([name, command]) => `${escapeHtml(name)}: ${code(command)}`)) : '<span class="muted">No configured commands</span>',
456
+ `${consumer.codexEnabled ? pill("Codex expected", "ok") : pill("Codex disabled", "off")} ${consumer.claudeEnabled ? pill("Claude expected", "ok") : pill("Claude disabled", "off")}`,
457
+ `Expected; verify with ${code("node scripts/check-workspace.mjs")}`,
458
+ ]);
459
+ return layout("Workspace", `
460
+ <h2>Consumer Repo Matrix</h2>
461
+ <p>These are Structor wiring facts from generated configuration, not live repo operations.</p>
462
+ ${table(["Consumer", "Path", "Purpose", "Codex Entrypoint", "Claude Entrypoints", "Configured Validation", "Client Surfaces", "Pointer Status"], consumerRows)}
463
+ <h2>Workspace Source Docs</h2>
464
+ ${table(["Source", "Title"], docRows)}
465
+ `);
173
466
  }
174
467
 
175
468
  const outputs = {
@@ -181,6 +474,14 @@ const outputs = {
181
474
  "workspace.html": await workspaceView(),
182
475
  };
183
476
 
477
+ for (const [name] of Object.entries(outputs)) {
478
+ await assertSafeWriteTarget({
479
+ targetPath: path.join(viewsDir, name),
480
+ rootPath: outputRoot,
481
+ label: `HTML view ${path.join("ai/views", name)}`,
482
+ });
483
+ }
484
+
184
485
  await mkdir(viewsDir, { recursive: true });
185
486
  for (const [name, content] of Object.entries(outputs)) {
186
487
  await writeFile(path.join(viewsDir, name), content);
@@ -0,0 +1 @@
1
+ {{GENERATED_HARNESS_CONTRACT_MODULE}}
@@ -18,10 +18,15 @@ const commandFailurePattern = /fail|timeout|error/i;
18
18
  const finalMessagePattern = /commands run|validation|files changed/i;
19
19
  const allowedActionAllow = "allow";
20
20
 
21
+ // `[^\n;|&]*?` allows git global options (e.g. `-C /repo`, `--git-dir=...`)
22
+ // between `git` and the subcommand without spilling across a compound command
23
+ // boundary, so `git -C /repo reset --hard` is denied just like `git reset --hard`.
24
+ const gitGlobalOptions = "(?:\\s+(?:-C\\s+\\S+|--git-dir(?:=|\\s+)\\S+|--work-tree(?:=|\\s+)\\S+|-c\\s+\\S+|-[A-Za-z]+|--[A-Za-z-]+(?:=\\S+)?))*";
25
+
21
26
  export const denyRules = [
22
27
  {
23
28
  id: "destructive-git-reset",
24
- pattern: /\bgit\s+reset\s+--hard\b/i,
29
+ pattern: new RegExp(`\\bgit${gitGlobalOptions}\\s+reset\\s+--hard\\b`, "i"),
25
30
  prevents: "discarding local work without explicit human approval",
26
31
  remediation: "stop and ask for approval before destructive git operations",
27
32
  policyDocs: ["ai/WORKFLOW.md", "ai/RUNNER-SAFETY.md"],
@@ -29,7 +34,12 @@ export const denyRules = [
29
34
  },
30
35
  {
31
36
  id: "force-push",
32
- pattern: /\bgit\s+push\b.*\s--force(?:-with-lease)?\b/i,
37
+ // Covers `--force`, `--force-with-lease`, the short `-f` form, and force
38
+ // refspecs such as `git push origin +main`.
39
+ pattern: new RegExp(
40
+ `\\bgit${gitGlobalOptions}\\s+push\\b[^\\n;|&]*?(?:--force(?:-with-lease)?\\b|\\s-[A-Za-z]*f[A-Za-z]*\\b|\\s\\+[\\w./-]+)`,
41
+ "i",
42
+ ),
33
43
  prevents: "rewriting remote history without explicit human approval",
34
44
  remediation: "use a normal push or ask for approval with the exact branch and reason",
35
45
  policyDocs: ["ai/WORKFLOW.md", "ai/RUNNER-SAFETY.md"],
@@ -37,7 +47,8 @@ export const denyRules = [
37
47
  },
38
48
  {
39
49
  id: "secret-read",
40
- pattern: /\b(?:cat|sed|grep|rg|less|tail|head)\b.*(?:\.env|secret|token|credential)/i,
50
+ pattern:
51
+ /\b(?:cat|sed|awk|grep|rg|less|more|tail|head|xxd|od|strings|printenv|env|export)\b[^\n;|&]*(?:\.env|secret|token|credential|password|api[_-]?key|private[_-]?key)/i,
41
52
  prevents: "unnecessary secret exposure in agent context",
42
53
  remediation: "read documented env var names instead of secret values",
43
54
  policyDocs: ["ai/RUNNER-SAFETY.md", "ai/contracts/security-boundary.md"],
@@ -0,0 +1,87 @@
1
+ import { access, lstat, realpath } from "node:fs/promises";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export async function exists(filePath) {
6
+ try {
7
+ await access(filePath, fsConstants.F_OK);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ export function isSameOrInsidePath(candidate, root) {
15
+ const resolvedCandidate = path.resolve(candidate);
16
+ const resolvedRoot = path.resolve(root);
17
+ const relative = path.relative(resolvedRoot, resolvedCandidate);
18
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
19
+ }
20
+
21
+ async function lstatIfExists(targetPath) {
22
+ try {
23
+ return await lstat(targetPath);
24
+ } catch (error) {
25
+ if (error?.code === "ENOENT") return null;
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ export async function canonicalPathForWrite(targetPath) {
31
+ let currentPath = path.resolve(targetPath);
32
+ const missingSegments = [];
33
+
34
+ while (true) {
35
+ if (await exists(currentPath)) {
36
+ return path.join(await realpath(currentPath), ...missingSegments);
37
+ }
38
+
39
+ const parentPath = path.dirname(currentPath);
40
+ if (parentPath === currentPath) {
41
+ return path.join(currentPath, ...missingSegments);
42
+ }
43
+
44
+ missingSegments.unshift(path.basename(currentPath));
45
+ currentPath = parentPath;
46
+ }
47
+ }
48
+
49
+ async function firstSymlinkUnderRoot(targetPath, rootPath) {
50
+ const resolvedTarget = path.resolve(targetPath);
51
+ const resolvedRoot = path.resolve(rootPath);
52
+ if (!isSameOrInsidePath(resolvedTarget, resolvedRoot)) return null;
53
+
54
+ const relative = path.relative(resolvedRoot, resolvedTarget);
55
+ if (relative === "") return null;
56
+
57
+ let currentPath = resolvedRoot;
58
+ for (const segment of relative.split(path.sep)) {
59
+ currentPath = path.join(currentPath, segment);
60
+ const info = await lstatIfExists(currentPath);
61
+ if (info === null) return null;
62
+ if (info.isSymbolicLink()) return currentPath;
63
+ }
64
+
65
+ return null;
66
+ }
67
+
68
+ export async function assertSafeWriteTarget({ targetPath, rootPath, label = "Write target" }) {
69
+ const resolvedTarget = path.resolve(targetPath);
70
+ const resolvedRoot = path.resolve(rootPath);
71
+ if (!isSameOrInsidePath(resolvedTarget, resolvedRoot)) {
72
+ throw new Error(`${label} is unsafe: target ${resolvedTarget} must stay inside ${resolvedRoot}.`);
73
+ }
74
+
75
+ const symlinkPath = await firstSymlinkUnderRoot(resolvedTarget, resolvedRoot);
76
+ if (symlinkPath !== null) {
77
+ throw new Error(`${label} is unsafe: symlinked write targets are not allowed (${symlinkPath}).`);
78
+ }
79
+
80
+ const canonicalRoot = await canonicalPathForWrite(resolvedRoot);
81
+ const canonicalTarget = await canonicalPathForWrite(resolvedTarget);
82
+ if (!isSameOrInsidePath(canonicalTarget, canonicalRoot)) {
83
+ throw new Error(`${label} is unsafe: resolved target escapes ${canonicalRoot}: ${canonicalTarget}.`);
84
+ }
85
+
86
+ return canonicalTarget;
87
+ }