@structor-dev/cli 0.1.0 → 0.2.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.
- package/CHANGELOG.md +56 -0
- package/README.md +131 -21
- package/ROADMAP.md +38 -0
- package/SECURITY.md +33 -0
- package/bin/structor.mjs +553 -29
- package/contrib/self-harness/files/README.md +32 -0
- package/contrib/self-harness/files/ai/AGENTS.md +35 -0
- package/contrib/self-harness/files/ai/ARCHITECTURE.md +38 -0
- package/contrib/self-harness/files/ai/HUB.md +59 -0
- package/contrib/self-harness/files/ai/PRODUCT.md +36 -0
- package/contrib/self-harness/files/ai/QUALITY.md +31 -0
- package/contrib/self-harness/files/ai/context.md +38 -0
- package/contrib/self-harness/files/scripts/check-workspace.mjs +72 -0
- package/contrib/self-harness/harness.config.json +37 -0
- package/docs/CONTRIBUTOR-SETUP.md +45 -0
- package/docs/INIT.md +55 -2
- package/docs/public-launch.md +150 -0
- package/examples/anthropic-only/harness.config.json +26 -0
- package/examples/frontend-backend/harness.config.json +8 -8
- package/examples/generated-harness-tree.md +432 -0
- package/examples/openai-and-anthropic/harness.config.json +7 -7
- package/examples/single-repo/harness.config.json +7 -7
- package/harness.config.example.json +1 -1
- package/package.json +12 -4
- package/schemas/contract-manifest.schema.json +0 -1
- package/schemas/harness-config.schema.json +5 -2
- package/scripts/check-config.mjs +20 -31
- package/scripts/check-examples.mjs +146 -0
- package/scripts/check-public-hygiene.mjs +249 -0
- package/scripts/check-schemas.mjs +42 -0
- package/scripts/check-template-files.mjs +15 -98
- package/scripts/generated-harness-contract.mjs +416 -0
- package/scripts/init-harness.mjs +227 -139
- package/scripts/lib.mjs +462 -12
- package/scripts/rendered-config.mjs +109 -0
- package/scripts/setup-contributor.mjs +125 -0
- package/scripts/smoke-template.mjs +260 -73
- package/template/AGENTS.md.tpl +4 -2
- package/template/README.md.tpl +5 -0
- package/template/ai/CODEX-HOOKS.md.tpl +1 -1
- package/template/ai/HARNESS-ENGINEERING.md.tpl +5 -2
- package/template/ai/HARNESS.md.tpl +4 -1
- package/template/ai/contracts/codex-hooks.contract.json.tpl +58 -1
- package/template/ai/contracts/codex-hooks.md.tpl +6 -0
- package/template/ai/contracts/release-flow.md.tpl +1 -1
- package/template/ai/templates/fixtures/issues/valid-ready.md.tpl +3 -1
- package/template/ai/templates/issue-template.md.tpl +3 -1
- package/template/ai/workspace/LOCAL-STACK.md.tpl +1 -1
- package/template/ai/workspace/SYSTEM-MAP.md.tpl +2 -2
- package/template/consumer/AGENTS.md.tpl +4 -4
- package/template/consumer/CLAUDE.md.tpl +4 -4
- package/template/scripts/bootstrap-workspace.mjs.tpl +11 -25
- package/template/scripts/check-claude-compatibility.mjs.tpl +62 -9
- package/template/scripts/check-codex-hooks.mjs.tpl +262 -20
- package/template/scripts/check-template-governance.mjs.tpl +2 -114
- package/template/scripts/check-workspace.mjs.tpl +27 -103
- package/template/scripts/check-worktree-bootstrap-fixtures.mjs.tpl +12 -0
- package/template/scripts/generate-html-views.mjs.tpl +357 -56
- package/template/scripts/generated-harness-contract.mjs.tpl +1 -0
- package/template/scripts/hooks/lib/codex-hooks-core.mjs.tpl +14 -3
- package/template/scripts/lib/path-safety.mjs.tpl +87 -0
- package/template/scripts/lib/worktree-bootstrap.mjs.tpl +16 -13
- package/template/scripts/validate-governance.mjs.tpl +52 -36
- 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)} - {
|
|
234
|
+
<title>${escapeHtml(title)} - ${escapeHtml(projectName)} Harness Cockpit</title>
|
|
56
235
|
<style>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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: #
|
|
64
|
-
table { width: 100%; border-collapse: collapse; background:
|
|
65
|
-
th, td { padding: 10px 12px; border-bottom: 1px solid
|
|
66
|
-
th { background: #
|
|
67
|
-
code { background: #
|
|
68
|
-
.note { color:
|
|
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
|
-
<
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
123
|
-
const rows = [
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return layout("
|
|
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
|
|
146
|
-
const rows = [
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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:
|
|
161
|
-
${
|
|
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
|
|
444
|
+
const docRows = [];
|
|
168
445
|
for (const file of workspaceFiles) {
|
|
169
446
|
const content = await read(file);
|
|
170
|
-
|
|
447
|
+
docRows.push([code(file), escapeHtml(titleFromMarkdown(content, path.basename(file)))]);
|
|
171
448
|
}
|
|
172
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|