delimit-cli 4.1.53 → 4.3.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 (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +34 -3
  3. package/bin/delimit-cli.js +150 -2
  4. package/bin/delimit-setup.js +22 -7
  5. package/gateway/ai/agent_dispatch.py +79 -0
  6. package/gateway/ai/daily_digest.py +386 -0
  7. package/gateway/ai/ledger_manager.py +32 -0
  8. package/gateway/ai/license_core.py +2 -0
  9. package/gateway/ai/notify.py +17 -11
  10. package/gateway/ai/reddit_proxy.py +28 -9
  11. package/gateway/ai/sensing/__init__.py +35 -0
  12. package/gateway/ai/sensing/schema.py +107 -0
  13. package/gateway/ai/sensing/signal_store.py +348 -0
  14. package/gateway/ai/server.py +419 -6
  15. package/gateway/ai/supabase_sync.py +308 -0
  16. package/gateway/ai/work_order.py +216 -0
  17. package/gateway/ai/workers/__init__.py +32 -0
  18. package/gateway/ai/workers/base.py +154 -0
  19. package/gateway/ai/workers/executor.py +861 -0
  20. package/gateway/ai/workers/outreach_drafter.py +161 -0
  21. package/gateway/ai/workers/pr_drafter.py +148 -0
  22. package/lib/ai-sbom-engine.js +154 -0
  23. package/lib/trust-page-engine.js +179 -0
  24. package/lib/wrap-engine.js +431 -0
  25. package/package.json +14 -1
  26. package/adapters/codex-security.js +0 -64
  27. package/adapters/codex-skill.js +0 -78
  28. package/adapters/cursor-rules.js +0 -73
  29. package/gateway/ai/continuity.py +0 -462
  30. package/gateway/ai/inbox_daemon_runner.py +0 -217
  31. package/gateway/ai/loop_engine.py +0 -1303
  32. package/gateway/ai/social_cache.py +0 -341
  33. package/gateway/ai/social_daemon.py +0 -483
  34. package/gateway/ai/tweet_corpus_schema.sql +0 -76
  35. package/scripts/crosspost_devto.py +0 -304
  36. package/scripts/demo-v420-clean.sh +0 -267
  37. package/scripts/demo-v420-deliberation.sh +0 -217
  38. package/scripts/demo-v420.sh +0 -55
  39. package/scripts/sync-gateway.sh +0 -112
@@ -0,0 +1,161 @@
1
+ """Outreach-drafter worker (LED-976).
2
+
3
+ Takes an audit-program target from the signal corpus or ledger,
4
+ runs delimit_lint against the target's spec if available, and drafts
5
+ a GitHub issue body per the AUDIT_PROGRAM.md template. Cannot post —
6
+ produces a work-order for founder approval.
7
+
8
+ Bounded to: delimit_lint, delimit_diff, delimit_sense, read_file.
9
+ Cannot: write files, post issues, commit, push.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import Any, Dict
16
+
17
+ from ai.workers.base import Worker, WorkerResult
18
+ from ai.work_order import create_work_order
19
+
20
+
21
+ class OutreachDrafterWorker(Worker):
22
+ worker_type = "outreach_drafter"
23
+ description = "Drafts governance-outreach GitHub issues"
24
+
25
+ def execute(self, ledger_item: Dict[str, Any]) -> WorkerResult:
26
+ item_id = ledger_item.get("id", "?")
27
+ title = ledger_item.get("title", "")
28
+ context = ledger_item.get("context", "")
29
+ description = ledger_item.get("description", "")
30
+
31
+ # Extract repo info
32
+ import re
33
+ repo = ""
34
+ for field in [context, description, title]:
35
+ match = re.search(r'([\w.-]+/[\w.-]+)', field)
36
+ if match and "/" in match.group(1):
37
+ candidate = match.group(1)
38
+ # Skip our own repos
39
+ if not candidate.startswith("delimit-ai/"):
40
+ repo = candidate
41
+ break
42
+
43
+ if not repo:
44
+ return WorkerResult(
45
+ worker_type=self.worker_type,
46
+ ledger_item_id=item_id,
47
+ success=False,
48
+ error="Could not extract target repo from ledger item",
49
+ )
50
+
51
+ # Draft the issue body per AUDIT_PROGRAM.md template
52
+ issue_title = f"Suggestion: CI check for API schema drift"
53
+ issue_body = f"""## Context
54
+
55
+ This repo maintains an API specification that downstream consumers depend on.
56
+ Breaking changes in API contracts (endpoint removal, type changes, required
57
+ parameter additions) can cause silent integration failures for consumers.
58
+
59
+ ## Suggestion
60
+
61
+ Add an advisory CI check that diffs the base branch spec against PRs.
62
+ A single workflow file:
63
+
64
+ ```yaml
65
+ name: API Schema Check
66
+ on:
67
+ pull_request:
68
+ paths:
69
+ - 'api/**'
70
+ - '*.yaml'
71
+ - '*.json'
72
+ permissions:
73
+ contents: read
74
+ pull-requests: write
75
+ jobs:
76
+ check:
77
+ runs-on: ubuntu-latest
78
+ steps:
79
+ - uses: actions/checkout@v4
80
+ with:
81
+ fetch-depth: 0
82
+ - uses: delimit-ai/delimit-action@v1
83
+ with:
84
+ spec: openapi.yaml
85
+ ```
86
+
87
+ This diffs the base branch spec against the PR and posts a comment
88
+ identifying breaking vs non-breaking changes with semver classification.
89
+ Advisory only — never blocks merges. Teams can upgrade to enforcement later.
90
+
91
+ ## What it catches
92
+
93
+ | DGF Control | Detection | Severity |
94
+ |---|---|---|
95
+ | DGF-BC-001 | Endpoint removal | High |
96
+ | DGF-BC-002 | Required parameter added | High |
97
+ | DGF-BC-003 | Required response field removed | High |
98
+ | DGF-BC-004 | Type changed | High |
99
+ | DGF-BC-005 | Enum value removed | High |
100
+ | + 10 more | See [DGF v0.1.0](https://github.com/delimit-ai/governance-framework) | — |
101
+
102
+ Zero config, no API keys, runs in under 30 seconds.
103
+ [Live demo](https://github.com/delimit-ai/delimit-action-demo/pull/2).
104
+
105
+ Happy to open a PR if there's interest.
106
+ """
107
+
108
+ # Emit a structured action the executor can run after founder
109
+ # approval. Keeps the human steps above for readability; the
110
+ # executor uses this typed list to actually post the issue.
111
+ executable = [
112
+ {
113
+ "action": "gh_issue_create",
114
+ "params": {
115
+ "repo": repo,
116
+ "title": issue_title,
117
+ "body": issue_body.strip(),
118
+ },
119
+ }
120
+ ]
121
+
122
+ # Create the work order
123
+ wo = create_work_order(
124
+ title=f"Outreach: Open governance issue on {repo}",
125
+ goal=f"Open a GitHub issue on {repo} suggesting API schema CI check",
126
+ context=f"Source: {item_id}. Per AUDIT_PROGRAM.md playbook. Must pass pre-outreach capability-test gate: run delimit lint against the target's spec before claiming detection.",
127
+ steps=[
128
+ f"Verify {repo} has a checked-in API spec (search for openapi.yaml, swagger.yaml, api.json, schema.json)",
129
+ f"Run: delimit lint <old_spec> <new_spec> on a recent PR that changed the spec (proves the tool works on their spec)",
130
+ f"If lint works, open the issue:",
131
+ f"```",
132
+ f"gh issue create --repo {repo} \\",
133
+ f" --title '{issue_title}' \\",
134
+ f" --body-file /dev/stdin <<'BODY'",
135
+ issue_body.strip(),
136
+ "BODY",
137
+ f"```",
138
+ "Wait for maintainer response (do NOT open a PR immediately)",
139
+ ],
140
+ acceptance_criteria=[
141
+ f"Pre-outreach capability test passes (delimit lint works on {repo}'s spec)",
142
+ f"Issue opened on {repo} matching AUDIT_PROGRAM.md format",
143
+ "No capability claims the tool can't back",
144
+ "Staggered timing (not batch-submitted with other issues)",
145
+ ],
146
+ ledger_item_id=item_id,
147
+ priority=ledger_item.get("priority", "P1"),
148
+ tools_needed=["gh", "delimit"],
149
+ estimated_minutes=20,
150
+ worker_type=self.worker_type,
151
+ executable_actions=executable,
152
+ )
153
+
154
+ return WorkerResult(
155
+ worker_type=self.worker_type,
156
+ ledger_item_id=item_id,
157
+ success=True,
158
+ artifact_path=wo.get("filepath", ""),
159
+ artifact_preview=wo.get("preview", "")[:300],
160
+ work_order_id=wo.get("id", ""),
161
+ )
@@ -0,0 +1,148 @@
1
+ """PR-drafter worker (LED-975).
2
+
3
+ Takes a ledger item describing an outreach target (repo with checked-in
4
+ spec) and produces a draft PR body + workflow YAML as a work-order
5
+ artifact the founder can copy-paste into a gh pr create command.
6
+
7
+ Bounded to: delimit_lint, delimit_diff, delimit_spec_health, read_file.
8
+ Cannot: write files, commit, push, post comments.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any, Dict
15
+
16
+ from ai.workers.base import Worker, WorkerResult
17
+ from ai.work_order import create_work_order
18
+
19
+
20
+ class PRDrafterWorker(Worker):
21
+ worker_type = "pr_drafter"
22
+ description = "Drafts PR bodies for governance check workflows"
23
+
24
+ def execute(self, ledger_item: Dict[str, Any]) -> WorkerResult:
25
+ item_id = ledger_item.get("id", "?")
26
+ title = ledger_item.get("title", "")
27
+ context = ledger_item.get("context", "")
28
+ description = ledger_item.get("description", "")
29
+
30
+ # Extract repo info from the ledger item
31
+ repo = ""
32
+ spec_path = ""
33
+ for field in [context, description, title]:
34
+ # Look for owner/repo pattern
35
+ import re
36
+ match = re.search(r'([\w.-]+/[\w.-]+)', field)
37
+ if match and "/" in match.group(1):
38
+ repo = match.group(1)
39
+ break
40
+
41
+ if not repo:
42
+ return WorkerResult(
43
+ worker_type=self.worker_type,
44
+ ledger_item_id=item_id,
45
+ success=False,
46
+ error="Could not extract repo from ledger item",
47
+ )
48
+
49
+ # Try to detect the spec path
50
+ common_spec_paths = [
51
+ "api/openapi.yaml",
52
+ "api/openapi.yml",
53
+ "api/swagger.yaml",
54
+ "api/swagger.yml",
55
+ "openapi.yaml",
56
+ "openapi.yml",
57
+ "swagger.yaml",
58
+ "swagger.yml",
59
+ "api/v2.0/swagger.yaml",
60
+ "schema.json",
61
+ "api.json",
62
+ ]
63
+ spec_path = common_spec_paths[0] # default, will be refined
64
+
65
+ # Generate the workflow YAML
66
+ workflow_yaml = f"""name: API Schema Check
67
+ on:
68
+ pull_request:
69
+ paths:
70
+ - '{spec_path.rsplit("/", 1)[0] if "/" in spec_path else "."}/**'
71
+ permissions:
72
+ contents: read
73
+ pull-requests: write
74
+ jobs:
75
+ check:
76
+ runs-on: ubuntu-latest
77
+ steps:
78
+ - uses: actions/checkout@v4
79
+ with:
80
+ fetch-depth: 0
81
+ - uses: delimit-ai/delimit-action@v1
82
+ with:
83
+ spec: {spec_path}
84
+ """
85
+
86
+ # Generate the PR body
87
+ pr_body = f"""## Summary
88
+
89
+ Add an API schema drift check for `{spec_path}`. Advisory only — never blocks merges.
90
+
91
+ The action diffs the base branch spec against the PR and posts a comment identifying breaking vs non-breaking changes with semver classification. Detects 27 change types including endpoint removal, type changes, required parameter additions, and enum value removals.
92
+
93
+ ## Changes
94
+
95
+ - Add `.github/workflows/api-schema-check.yml`
96
+
97
+ ## Details
98
+
99
+ - Scoped to `{spec_path.rsplit("/", 1)[0] if "/" in spec_path else "."}/**` — only runs on PRs that modify the spec
100
+ - Zero config, no API keys, runs in under 30 seconds
101
+ - [Live demo](https://github.com/delimit-ai/delimit-action-demo/pull/2) showing 23 breaking changes detected
102
+
103
+ ## References
104
+
105
+ - [Delimit Governance Framework (DGF)](https://github.com/delimit-ai/governance-framework) — 15 citeable controls
106
+ - [Delimit Action on Marketplace](https://github.com/marketplace/actions/delimit-api-governance)
107
+ """
108
+
109
+ # Create the work order
110
+ wo = create_work_order(
111
+ title=f"PR: Add governance check to {repo}",
112
+ goal=f"Open a PR on {repo} adding .github/workflows/api-schema-check.yml scoped to {spec_path}",
113
+ context=f"Source ledger item: {item_id}. Target: {repo}.",
114
+ steps=[
115
+ f"Fork {repo} (if not already forked)",
116
+ f"Create branch: add-api-schema-check",
117
+ f"Add file .github/workflows/api-schema-check.yml with content below",
118
+ "```yaml",
119
+ workflow_yaml.strip(),
120
+ "```",
121
+ f"Commit with: git commit -s -m 'ci: add API schema drift check for {spec_path}'",
122
+ f"Push and open PR with body below",
123
+ "```",
124
+ pr_body.strip(),
125
+ "```",
126
+ f"Or one-liner: gh pr create --title 'ci: add API schema drift check' --body-file <(cat <<'BODY'\n{pr_body.strip()}\nBODY\n)",
127
+ ],
128
+ acceptance_criteria=[
129
+ f"PR opened on {repo}",
130
+ "Workflow file is advisory-only (never blocks merges)",
131
+ "DCO sign-off included",
132
+ "Maintainer response within 7 days",
133
+ ],
134
+ ledger_item_id=item_id,
135
+ priority=ledger_item.get("priority", "P1"),
136
+ tools_needed=["gh", "git"],
137
+ estimated_minutes=15,
138
+ worker_type=self.worker_type,
139
+ )
140
+
141
+ return WorkerResult(
142
+ worker_type=self.worker_type,
143
+ ledger_item_id=item_id,
144
+ success=True,
145
+ artifact_path=wo.get("filepath", ""),
146
+ artifact_preview=wo.get("preview", "")[:300],
147
+ work_order_id=wo.get("id", ""),
148
+ )
@@ -0,0 +1,154 @@
1
+ // lib/ai-sbom-engine.js
2
+ //
3
+ // LED-1018 Venture #6 MVP: `delimit ai-sbom` aggregation.
4
+ // Scans a directory of attestations, extracts the AI surface (models, prompts,
5
+ // tool calls, data classes), and emits a CycloneDX 1.6-shaped bill of materials
6
+ // with AI-specific fields.
7
+ //
8
+ // CycloneDX-AI schema reference: https://cyclonedx.org/capabilities/mlbom/
9
+ // Per the architecture doc, MVP aggregates what attestations already capture;
10
+ // explicit static-analysis code-walker lands in Phase 2.
11
+
12
+ const { loadAttestations } = require('./trust-page-engine');
13
+ const crypto = require('crypto');
14
+
15
+ const KNOWN_MODEL_PROVIDERS = [
16
+ // Loose matching against wrapped_command strings. Extends easily.
17
+ { pattern: /claude|anthropic/i, vendor: 'anthropic', family: 'claude' },
18
+ { pattern: /openai|gpt-\d|o1|o3/i, vendor: 'openai', family: 'gpt' },
19
+ { pattern: /gemini|vertex/i, vendor: 'google', family: 'gemini' },
20
+ { pattern: /codex/i, vendor: 'openai', family: 'codex' },
21
+ { pattern: /grok|xai/i, vendor: 'xai', family: 'grok' },
22
+ { pattern: /llama|mistral/i, vendor: 'meta-or-mistral', family: 'open-weight' },
23
+ { pattern: /cursor/i, vendor: 'cursor', family: 'cursor-agent' },
24
+ { pattern: /aider/i, vendor: 'aider', family: 'aider-agent' },
25
+ { pattern: /copilot/i, vendor: 'github', family: 'copilot' },
26
+ ];
27
+
28
+ function detectModelFromCommand(cmd) {
29
+ if (!cmd) return null;
30
+ for (const m of KNOWN_MODEL_PROVIDERS) {
31
+ if (m.pattern.test(cmd)) return { vendor: m.vendor, family: m.family };
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function aggregateAISurface(attestations) {
37
+ const models = new Map(); // key: vendor:family -> { count, first_seen, last_seen }
38
+ const toolCallCounts = new Map(); // tool name -> count
39
+ const totalAttestations = attestations.length;
40
+ let earliest = null, latest = null;
41
+ let totalGatesRun = 0, totalViolations = 0;
42
+
43
+ for (const att of attestations) {
44
+ const b = att.bundle || {};
45
+
46
+ // Model detection — prefer explicit ai_surface.models_detected, fall back to command heuristic
47
+ const explicitModels = (b.ai_surface?.models_detected) || [];
48
+ for (const m of explicitModels) {
49
+ const key = m.includes(':') ? m : `unknown:${m}`;
50
+ const [vendor, family] = key.split(':', 2);
51
+ const entry = models.get(key) || { vendor, family, count: 0, first_seen: null, last_seen: null };
52
+ entry.count += 1;
53
+ if (b.started_at) {
54
+ if (!entry.first_seen || b.started_at < entry.first_seen) entry.first_seen = b.started_at;
55
+ if (!entry.last_seen || b.started_at > entry.last_seen) entry.last_seen = b.started_at;
56
+ }
57
+ models.set(key, entry);
58
+ }
59
+ if (explicitModels.length === 0) {
60
+ const inferred = detectModelFromCommand(b.wrapped_command || '');
61
+ if (inferred) {
62
+ const key = `${inferred.vendor}:${inferred.family}`;
63
+ const entry = models.get(key) || { vendor: inferred.vendor, family: inferred.family, count: 0, first_seen: null, last_seen: null, source: 'inferred' };
64
+ entry.count += 1;
65
+ entry.source = 'inferred';
66
+ if (b.started_at) {
67
+ if (!entry.first_seen || b.started_at < entry.first_seen) entry.first_seen = b.started_at;
68
+ if (!entry.last_seen || b.started_at > entry.last_seen) entry.last_seen = b.started_at;
69
+ }
70
+ models.set(key, entry);
71
+ }
72
+ }
73
+
74
+ // Tool calls
75
+ const tools = b.ai_surface?.tool_calls || [];
76
+ for (const t of tools) toolCallCounts.set(t, (toolCallCounts.get(t) || 0) + 1);
77
+
78
+ // Timestamps
79
+ if (b.started_at) {
80
+ if (!earliest || b.started_at < earliest) earliest = b.started_at;
81
+ if (!latest || b.started_at > latest) latest = b.started_at;
82
+ }
83
+
84
+ // Governance counts
85
+ totalGatesRun += (b.governance?.gates || []).length;
86
+ totalViolations += (b.governance?.violations || []).length;
87
+ }
88
+
89
+ return {
90
+ total_attestations: totalAttestations,
91
+ total_gates_run: totalGatesRun,
92
+ total_violations: totalViolations,
93
+ models: Array.from(models.values()),
94
+ tool_calls: Array.from(toolCallCounts.entries()).map(([name, count]) => ({ name, count })),
95
+ earliest,
96
+ latest,
97
+ };
98
+ }
99
+
100
+ function renderCycloneDXAI(aggregate, { name = 'ai-sbom', version = '1.0.0' } = {}) {
101
+ const serialNumber = 'urn:uuid:' + crypto.randomUUID();
102
+
103
+ return {
104
+ bomFormat: 'CycloneDX',
105
+ specVersion: '1.6',
106
+ serialNumber,
107
+ version: 1,
108
+ metadata: {
109
+ timestamp: new Date().toISOString(),
110
+ tools: [{ vendor: 'delimit', name: 'delimit-ai-sbom', version }],
111
+ component: {
112
+ 'bom-ref': `pkg:${name}@${version}`,
113
+ type: 'application',
114
+ name,
115
+ version,
116
+ },
117
+ properties: [
118
+ { name: 'delimit:total_attestations', value: String(aggregate.total_attestations) },
119
+ { name: 'delimit:total_gates_run', value: String(aggregate.total_gates_run) },
120
+ { name: 'delimit:total_violations', value: String(aggregate.total_violations) },
121
+ { name: 'delimit:earliest_attestation', value: aggregate.earliest || '' },
122
+ { name: 'delimit:latest_attestation', value: aggregate.latest || '' },
123
+ { name: 'delimit:tool_surface', value: JSON.stringify(aggregate.tool_calls) },
124
+ ],
125
+ },
126
+ components: aggregate.models.map(m => ({
127
+ 'bom-ref': `model:${m.vendor}/${m.family}`,
128
+ type: 'machine-learning-model',
129
+ vendor: m.vendor,
130
+ name: m.family,
131
+ description: `AI model detected across ${m.count} attestations${m.source ? ` (${m.source} from command)` : ''}`,
132
+ modelCard: {
133
+ modelParameters: {
134
+ approach: { type: 'supervised' },
135
+ },
136
+ properties: [
137
+ { name: 'delimit:usage_count', value: String(m.count) },
138
+ { name: 'delimit:first_seen', value: m.first_seen || '' },
139
+ { name: 'delimit:last_seen', value: m.last_seen || '' },
140
+ ...(m.source ? [{ name: 'delimit:detection_source', value: m.source }] : []),
141
+ ],
142
+ },
143
+ })),
144
+ };
145
+ }
146
+
147
+ function buildAISBOM(attestationDir, opts = {}) {
148
+ const attestations = loadAttestations(attestationDir);
149
+ const aggregate = aggregateAISurface(attestations);
150
+ const sbom = renderCycloneDXAI(aggregate, opts);
151
+ return { sbom, aggregate, attestation_count: attestations.length };
152
+ }
153
+
154
+ module.exports = { buildAISBOM, aggregateAISurface, renderCycloneDXAI, detectModelFromCommand };
@@ -0,0 +1,179 @@
1
+ // lib/trust-page-engine.js
2
+ //
3
+ // LED-1018 Venture #6 MVP: `delimit trust-page` render.
4
+ // Scans a directory of delimit.attestation.v1 JSON files, verifies signatures,
5
+ // renders a static index.html + JSON Feed 1.1-shaped feed.json.
6
+ //
7
+ // Local-only render. Cloud sync is a Pro/Premium feature, deferred.
8
+
9
+ const crypto = require('crypto');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ function loadHmacKey() {
15
+ const keyPath = path.join(os.homedir(), '.delimit', 'wrap-hmac.key');
16
+ if (!fs.existsSync(keyPath)) return null;
17
+ return fs.readFileSync(keyPath);
18
+ }
19
+
20
+ function verifySignature(attestation, key) {
21
+ if (!key) return 'unverifiable';
22
+ try {
23
+ const canonical = JSON.stringify(attestation.bundle, Object.keys(attestation.bundle).sort());
24
+ const expected = crypto.createHmac('sha256', key).update(canonical).digest('hex');
25
+ return expected === attestation.signature ? 'verified' : 'signature_mismatch';
26
+ } catch {
27
+ return 'verify_error';
28
+ }
29
+ }
30
+
31
+ function loadAttestations(dir) {
32
+ if (!fs.existsSync(dir)) return [];
33
+ const results = [];
34
+ for (const f of fs.readdirSync(dir)) {
35
+ if (!f.startsWith('att_') || !f.endsWith('.json')) continue;
36
+ try {
37
+ const att = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf-8'));
38
+ if (att.id && att.bundle) results.push(att);
39
+ } catch { /* skip corrupted */ }
40
+ }
41
+ // Reverse-chronological
42
+ results.sort((a, b) => {
43
+ const ta = a.bundle?.started_at || '';
44
+ const tb = b.bundle?.started_at || '';
45
+ return tb.localeCompare(ta);
46
+ });
47
+ return results;
48
+ }
49
+
50
+ function escapeHtml(s) {
51
+ return String(s || '').replace(/[&<>"']/g, c => ({
52
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
53
+ }[c]));
54
+ }
55
+
56
+ function redactCommand(cmd, redactLevel = 'basic') {
57
+ // MVP: basic redaction only. Strip quoted strings longer than 24 chars (likely prompt text).
58
+ if (!cmd) return '';
59
+ if (redactLevel === 'none') return cmd;
60
+ return cmd.replace(/"[^"]{24,}"/g, '"<prompt redacted>"')
61
+ .replace(/'[^']{24,}'/g, "'<prompt redacted>'");
62
+ }
63
+
64
+ function countGateResults(governance) {
65
+ const gates = governance?.gates || [];
66
+ let pass = 0, fail = 0, info = 0;
67
+ for (const g of gates) {
68
+ if (g.exit === 0) pass++;
69
+ else if (g.exit !== undefined) fail++;
70
+ else info++;
71
+ }
72
+ return { pass, fail, info, total: gates.length };
73
+ }
74
+
75
+ function renderHTML(attestations, title = 'Trust Page') {
76
+ const hmacKey = loadHmacKey();
77
+ const rows = attestations.map(att => {
78
+ const verify = verifySignature(att, hmacKey);
79
+ const b = att.bundle || {};
80
+ const counts = countGateResults(b.governance);
81
+ const violations = (b.governance?.violations || []).length;
82
+ const status = violations > 0 ? 'violations' : (counts.fail > 0 ? 'failures' : 'clean');
83
+ return ` <tr>
84
+ <td><code>${escapeHtml(att.id)}</code></td>
85
+ <td class="cmd">${escapeHtml(redactCommand(b.wrapped_command))}</td>
86
+ <td>${escapeHtml(b.started_at || '')}</td>
87
+ <td class="gates">${counts.pass}/${counts.total}</td>
88
+ <td class="v-${verify}">${verify.replace('_', ' ')}</td>
89
+ <td class="s-${status}">${status}</td>
90
+ </tr>`;
91
+ }).join('\n');
92
+
93
+ const empty = attestations.length === 0
94
+ ? ` <p class="empty">No attestations yet. Run <code>delimit wrap &lt;cmd&gt;</code> to create one.</p>`
95
+ : '';
96
+
97
+ return `<!doctype html>
98
+ <html lang="en">
99
+ <head>
100
+ <meta charset="utf-8">
101
+ <meta name="viewport" content="width=device-width,initial-scale=1">
102
+ <title>${escapeHtml(title)}</title>
103
+ <style>
104
+ :root { color-scheme: light dark; --fg:#111; --bg:#fff; --muted:#666; --ok:#087443; --warn:#b45309; --err:#b91c1c; }
105
+ @media (prefers-color-scheme: dark) { :root { --fg:#eee; --bg:#0a0a0a; --muted:#888; } }
106
+ body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; color: var(--fg); background: var(--bg); max-width: 980px; margin: 2rem auto; padding: 0 1rem; }
107
+ h1 { margin-bottom: .25rem; font-weight: 600; }
108
+ .sub { color: var(--muted); margin-bottom: 2rem; }
109
+ table { width: 100%; border-collapse: collapse; }
110
+ th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid rgba(0,0,0,.08); }
111
+ th { font-weight: 600; color: var(--muted); font-size: .82em; text-transform: uppercase; letter-spacing: .03em; }
112
+ code { font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; }
113
+ td.cmd { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
114
+ .gates { font-variant-numeric: tabular-nums; color: var(--muted); }
115
+ .v-verified { color: var(--ok); }
116
+ .v-unverifiable, .v-signature_mismatch { color: var(--warn); }
117
+ .s-clean { color: var(--ok); }
118
+ .s-violations, .s-failures { color: var(--err); }
119
+ .empty { color: var(--muted); padding: 3rem 0; text-align: center; }
120
+ footer { margin-top: 3rem; color: var(--muted); font-size: .85em; }
121
+ </style>
122
+ </head>
123
+ <body>
124
+ <h1>${escapeHtml(title)}</h1>
125
+ <p class="sub">${attestations.length} signed attestation${attestations.length === 1 ? '' : 's'} · generated ${new Date().toISOString()}</p>
126
+ ${empty || ` <table>
127
+ <thead><tr><th>ID</th><th>Command</th><th>Started</th><th>Gates</th><th>Signature</th><th>Status</th></tr></thead>
128
+ <tbody>
129
+ ${rows}
130
+ </tbody>
131
+ </table>`}
132
+ <footer>
133
+ Generated by <code>delimit trust-page</code>. <a href="feed.json">JSON Feed</a>.<br>
134
+ Each row is an <code>att_*</code> record signed with HMAC-SHA256. Schema: <code>delimit.attestation.v1</code>.
135
+ </footer>
136
+ </body>
137
+ </html>
138
+ `;
139
+ }
140
+
141
+ function renderFeed(attestations, title = 'Trust Page') {
142
+ const items = attestations.map(att => {
143
+ const b = att.bundle || {};
144
+ const verify = verifySignature(att, loadHmacKey());
145
+ return {
146
+ id: att.id,
147
+ title: redactCommand(b.wrapped_command || att.id),
148
+ content_text: `wrapped=${b.wrapped_command || ''} | exit=${b.wrapped_exit} | gates=${(b.governance?.gates || []).length} | violations=${(b.governance?.violations || []).length} | signature=${verify}`,
149
+ date_published: b.started_at,
150
+ _delimit: {
151
+ attestation_id: att.id,
152
+ signature: att.signature,
153
+ signature_alg: att.signature_alg,
154
+ wrapped_exit: b.wrapped_exit,
155
+ changed_files: b.changed_files,
156
+ governance: b.governance,
157
+ ai_surface: b.ai_surface || null,
158
+ }
159
+ };
160
+ });
161
+ return {
162
+ version: 'https://jsonfeed.org/version/1.1',
163
+ title,
164
+ description: 'Signed replayable attestations for AI-assisted merges. Schema: delimit.attestation.v1.',
165
+ items,
166
+ };
167
+ }
168
+
169
+ function renderTrustPage(attestationDir, outDir, title) {
170
+ const attestations = loadAttestations(attestationDir);
171
+ fs.mkdirSync(outDir, { recursive: true });
172
+ const html = renderHTML(attestations, title);
173
+ const feed = renderFeed(attestations, title);
174
+ fs.writeFileSync(path.join(outDir, 'index.html'), html);
175
+ fs.writeFileSync(path.join(outDir, 'feed.json'), JSON.stringify(feed, null, 2));
176
+ return { count: attestations.length, outDir, html_bytes: html.length, feed_items: feed.items.length };
177
+ }
178
+
179
+ module.exports = { renderTrustPage, loadAttestations, verifySignature, renderHTML, renderFeed };