twinclaw 1.0.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/README.md +66 -0
- package/bin/npm-twinclaw.js +17 -0
- package/bin/run-twinbot-cli.js +36 -0
- package/bin/twinbot.js +4 -0
- package/bin/twinclaw.js +4 -0
- package/dist/api/handlers/browser.js +160 -0
- package/dist/api/handlers/callback.js +80 -0
- package/dist/api/handlers/config-validate.js +19 -0
- package/dist/api/handlers/health.js +117 -0
- package/dist/api/handlers/local-state-backup.js +118 -0
- package/dist/api/handlers/persona-state.js +59 -0
- package/dist/api/handlers/skill-packages.js +94 -0
- package/dist/api/router.js +278 -0
- package/dist/api/runtime-event-producer.js +99 -0
- package/dist/api/shared.js +82 -0
- package/dist/api/websocket-hub.js +305 -0
- package/dist/config/config-loader.js +2 -0
- package/dist/config/env-schema.js +202 -0
- package/dist/config/env-validator.js +223 -0
- package/dist/config/identity-bootstrap.js +115 -0
- package/dist/config/json-config.js +344 -0
- package/dist/config/workspace.js +186 -0
- package/dist/core/channels-cli.js +77 -0
- package/dist/core/cli.js +119 -0
- package/dist/core/context-assembly.js +33 -0
- package/dist/core/doctor.js +365 -0
- package/dist/core/gateway-cli.js +323 -0
- package/dist/core/gateway.js +416 -0
- package/dist/core/heartbeat.js +54 -0
- package/dist/core/install-cli.js +320 -0
- package/dist/core/lane-executor.js +134 -0
- package/dist/core/logs-cli.js +70 -0
- package/dist/core/onboarding.js +760 -0
- package/dist/core/pairing-cli.js +78 -0
- package/dist/core/secret-vault-cli.js +204 -0
- package/dist/core/types.js +1 -0
- package/dist/index.js +404 -0
- package/dist/interfaces/dispatcher.js +214 -0
- package/dist/interfaces/telegram_handler.js +82 -0
- package/dist/interfaces/tui-dashboard.js +53 -0
- package/dist/interfaces/whatsapp_handler.js +94 -0
- package/dist/release/cli.js +97 -0
- package/dist/release/mvp-gate-cli.js +118 -0
- package/dist/release/twinbot-config-schema.js +162 -0
- package/dist/release/twinclaw-config-schema.js +162 -0
- package/dist/services/block-chunker.js +174 -0
- package/dist/services/browser-service.js +334 -0
- package/dist/services/context-lifecycle.js +314 -0
- package/dist/services/db.js +1055 -0
- package/dist/services/delivery-tracker.js +110 -0
- package/dist/services/dm-pairing.js +245 -0
- package/dist/services/embedding-service.js +125 -0
- package/dist/services/file-watcher.js +125 -0
- package/dist/services/inbound-debounce.js +92 -0
- package/dist/services/incident-manager.js +516 -0
- package/dist/services/job-scheduler.js +176 -0
- package/dist/services/local-state-backup.js +682 -0
- package/dist/services/mcp-client-adapter.js +291 -0
- package/dist/services/mcp-server-manager.js +143 -0
- package/dist/services/model-router.js +927 -0
- package/dist/services/mvp-gate.js +845 -0
- package/dist/services/orchestration-service.js +422 -0
- package/dist/services/persona-state.js +256 -0
- package/dist/services/policy-engine.js +92 -0
- package/dist/services/proactive-notifier.js +94 -0
- package/dist/services/queue-service.js +146 -0
- package/dist/services/release-pipeline.js +652 -0
- package/dist/services/runtime-budget-governor.js +415 -0
- package/dist/services/secret-vault.js +704 -0
- package/dist/services/semantic-memory.js +249 -0
- package/dist/services/skill-package-manager.js +806 -0
- package/dist/services/skill-registry.js +122 -0
- package/dist/services/streaming-output.js +75 -0
- package/dist/services/stt-service.js +39 -0
- package/dist/services/tts-service.js +44 -0
- package/dist/skills/builtin.js +250 -0
- package/dist/skills/shell.js +87 -0
- package/dist/skills/types.js +1 -0
- package/dist/types/api.js +1 -0
- package/dist/types/context-budget.js +1 -0
- package/dist/types/doctor.js +1 -0
- package/dist/types/file-watcher.js +1 -0
- package/dist/types/incident.js +1 -0
- package/dist/types/local-state-backup.js +1 -0
- package/dist/types/mcp.js +1 -0
- package/dist/types/messaging.js +1 -0
- package/dist/types/model-routing.js +1 -0
- package/dist/types/mvp-gate.js +2 -0
- package/dist/types/orchestration.js +1 -0
- package/dist/types/persona-state.js +22 -0
- package/dist/types/policy.js +1 -0
- package/dist/types/reasoning-graph.js +1 -0
- package/dist/types/release.js +1 -0
- package/dist/types/reliability.js +1 -0
- package/dist/types/runtime-budget.js +1 -0
- package/dist/types/scheduler.js +1 -0
- package/dist/types/secret-vault.js +1 -0
- package/dist/types/skill-packages.js +1 -0
- package/dist/types/websocket.js +14 -0
- package/dist/utils/logger.js +57 -0
- package/dist/utils/retry.js +61 -0
- package/dist/utils/secret-scan.js +208 -0
- package/mcp-servers.json +179 -0
- package/package.json +81 -0
- package/skill-packages.json +92 -0
- package/skill-packages.lock.json +5 -0
- package/src/skills/builtin.ts +275 -0
- package/src/skills/shell.ts +118 -0
- package/src/skills/types.ts +30 -0
- package/src/types/api.ts +252 -0
- package/src/types/blessed-contrib.d.ts +4 -0
- package/src/types/context-budget.ts +76 -0
- package/src/types/doctor.ts +29 -0
- package/src/types/file-watcher.ts +26 -0
- package/src/types/incident.ts +57 -0
- package/src/types/local-state-backup.ts +121 -0
- package/src/types/mcp.ts +106 -0
- package/src/types/messaging.ts +35 -0
- package/src/types/model-routing.ts +61 -0
- package/src/types/mvp-gate.ts +99 -0
- package/src/types/orchestration.ts +65 -0
- package/src/types/persona-state.ts +61 -0
- package/src/types/policy.ts +27 -0
- package/src/types/reasoning-graph.ts +58 -0
- package/src/types/release.ts +115 -0
- package/src/types/reliability.ts +43 -0
- package/src/types/runtime-budget.ts +85 -0
- package/src/types/scheduler.ts +47 -0
- package/src/types/secret-vault.ts +62 -0
- package/src/types/skill-packages.ts +81 -0
- package/src/types/sqlite-vec.d.ts +5 -0
- package/src/types/websocket.ts +122 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { validateTwinBotConfigSchema } from '../release/twinbot-config-schema.js';
|
|
4
|
+
// ─── Triage Ownership Map ─────────────────────────────────────────────────────
|
|
5
|
+
const TRIAGE_OWNERSHIP = {
|
|
6
|
+
build: {
|
|
7
|
+
severity: 'blocker',
|
|
8
|
+
ownerTrack: 'Track 35: Build Contract Recovery & Compile Unblock',
|
|
9
|
+
nextAction: 'Run `npm run build` and resolve TypeScript compiler errors before re-running the gate.',
|
|
10
|
+
},
|
|
11
|
+
tests: {
|
|
12
|
+
severity: 'blocker',
|
|
13
|
+
ownerTrack: 'Track 36: Test Harness FK Integrity & Suite Unblock',
|
|
14
|
+
nextAction: 'Run `npm test` locally, fix failing specs, and ensure zero test failures before re-running the gate.',
|
|
15
|
+
},
|
|
16
|
+
'api-health': {
|
|
17
|
+
severity: 'blocker',
|
|
18
|
+
ownerTrack: 'Track 41: Runtime Health, Doctor & Readiness Surfaces',
|
|
19
|
+
nextAction: 'Start the runtime (`npm start`) and verify GET /health returns {"data":{"status":"ok"}}.',
|
|
20
|
+
},
|
|
21
|
+
'config-schema': {
|
|
22
|
+
severity: 'blocker',
|
|
23
|
+
ownerTrack: 'Track 58: MVP Gate v2 (Deep Config/Vault Validation)',
|
|
24
|
+
nextAction: 'Fix twinbot.json so it satisfies the required schema (runtime, models, messaging, storage, integration, tools).',
|
|
25
|
+
},
|
|
26
|
+
'vault-health': {
|
|
27
|
+
severity: 'blocker',
|
|
28
|
+
ownerTrack: 'Track 57: Secrets Hygiene & Credential Rotation Sweep',
|
|
29
|
+
nextAction: 'Run `twinbot secret doctor` and resolve degraded vault diagnostics before release.',
|
|
30
|
+
},
|
|
31
|
+
'interface-readiness': {
|
|
32
|
+
severity: 'blocker',
|
|
33
|
+
ownerTrack: 'Track 35: Build Contract Recovery & Compile Unblock',
|
|
34
|
+
nextAction: 'Ensure `gui/package.json`, `src/interfaces/dispatcher.ts`, and `mcp-servers.json` all exist.',
|
|
35
|
+
},
|
|
36
|
+
'npm-commands': {
|
|
37
|
+
severity: 'blocker',
|
|
38
|
+
ownerTrack: 'Track 38: NPM Command Reliability Matrix & Script Repair',
|
|
39
|
+
nextAction: 'Add the missing npm script(s) to package.json and verify each runs successfully.',
|
|
40
|
+
},
|
|
41
|
+
'installer-smoke': {
|
|
42
|
+
severity: 'blocker',
|
|
43
|
+
ownerTrack: 'Track 71: Install + Onboard E2E Smoke Gates',
|
|
44
|
+
nextAction: 'Run `npm twinbot install --help` (or `node ./bin/npm-twinbot.js install --help`) and verify it exits successfully.',
|
|
45
|
+
},
|
|
46
|
+
'onboarding-smoke': {
|
|
47
|
+
severity: 'blocker',
|
|
48
|
+
ownerTrack: 'Track 71: Install + Onboard E2E Smoke Gates',
|
|
49
|
+
nextAction: 'Run the non-interactive onboarding smoke command and ensure it generates a schema-valid config file.',
|
|
50
|
+
},
|
|
51
|
+
'dist-artifact': {
|
|
52
|
+
severity: 'advisory',
|
|
53
|
+
ownerTrack: 'Track 35: Build Contract Recovery & Compile Unblock',
|
|
54
|
+
nextAction: 'Run `npm run build` to generate dist/ artifacts. Advisory only — does not block the gate.',
|
|
55
|
+
},
|
|
56
|
+
'test-coverage': {
|
|
57
|
+
severity: 'advisory',
|
|
58
|
+
ownerTrack: 'Track 43: Coverage Gap Closure for Messaging/MCP/Proactive/Observability',
|
|
59
|
+
nextAction: 'Run `npm run test:coverage` and address gaps. Advisory only — does not block the gate.',
|
|
60
|
+
},
|
|
61
|
+
'doctor-readiness': {
|
|
62
|
+
severity: 'advisory',
|
|
63
|
+
ownerTrack: 'Track 23: CLI Hardening, User Onboarding & Doctor Diagnostics',
|
|
64
|
+
nextAction: 'Ensure the doctor/onboarding entrypoint is wired in src/core/onboarding.ts. Advisory only.',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
// ─── Required MVP Scripts ─────────────────────────────────────────────────────
|
|
68
|
+
const REQUIRED_SCRIPTS = [
|
|
69
|
+
'build',
|
|
70
|
+
'test',
|
|
71
|
+
'start',
|
|
72
|
+
'release:preflight',
|
|
73
|
+
'release:prepare',
|
|
74
|
+
'release:rollback',
|
|
75
|
+
];
|
|
76
|
+
const SMOKE_SCENARIOS = [
|
|
77
|
+
{
|
|
78
|
+
id: 'core:package-manifest',
|
|
79
|
+
label: 'Root package.json exists',
|
|
80
|
+
relativePaths: ['package.json'],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'core:mcp-config',
|
|
84
|
+
label: 'MCP server config (mcp-servers.json) exists',
|
|
85
|
+
relativePaths: ['mcp-servers.json'],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'core:config-template',
|
|
89
|
+
label: 'Configuration template (twinbot.default.json) exists',
|
|
90
|
+
relativePaths: ['twinbot.default.json'],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'runtime:interface-dispatcher',
|
|
94
|
+
label: 'Interface dispatcher module exists',
|
|
95
|
+
relativePaths: ['src/interfaces/dispatcher.ts'],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'runtime:release-cli',
|
|
99
|
+
label: 'Release pipeline CLI entrypoint exists',
|
|
100
|
+
relativePaths: ['src/release/cli.ts'],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'runtime:db-service',
|
|
104
|
+
label: 'Database service module exists',
|
|
105
|
+
relativePaths: ['src/services/db.ts'],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'runtime:gateway',
|
|
109
|
+
label: 'Core gateway module exists',
|
|
110
|
+
relativePaths: ['src/core/gateway.ts'],
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
114
|
+
function nowIso(now) {
|
|
115
|
+
return now().toISOString();
|
|
116
|
+
}
|
|
117
|
+
function compactTimestamp(now) {
|
|
118
|
+
return now().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
|
119
|
+
}
|
|
120
|
+
function quoteCommandArg(value) {
|
|
121
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
122
|
+
}
|
|
123
|
+
async function pathExists(targetPath) {
|
|
124
|
+
try {
|
|
125
|
+
await stat(targetPath);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function isDirNonEmpty(dirPath) {
|
|
133
|
+
try {
|
|
134
|
+
const entries = await readdir(dirPath);
|
|
135
|
+
return entries.length > 0;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function buildSummary(verdict, failedHardGates) {
|
|
142
|
+
if (verdict === 'go') {
|
|
143
|
+
return 'All MVP hard-gate criteria passed. The release is approved to proceed.';
|
|
144
|
+
}
|
|
145
|
+
if (verdict === 'advisory-only') {
|
|
146
|
+
return 'All hard-gate criteria passed. One or more advisory checks are failing — review and address before next release cycle.';
|
|
147
|
+
}
|
|
148
|
+
const ids = failedHardGates.map((c) => c.id).join(', ');
|
|
149
|
+
return `Release blocked. The following hard-gate(s) failed: ${ids}. Resolve the issues indicated in the triage section and re-run the gate.`;
|
|
150
|
+
}
|
|
151
|
+
// ─── Service ──────────────────────────────────────────────────────────────────
|
|
152
|
+
const DEFAULT_HEALTH_URL = 'http://localhost:18789/health';
|
|
153
|
+
export class MvpGateService {
|
|
154
|
+
#workspaceRoot;
|
|
155
|
+
#reportDir;
|
|
156
|
+
#commandRunner;
|
|
157
|
+
#healthProbe;
|
|
158
|
+
#now;
|
|
159
|
+
#defaultHealthUrl;
|
|
160
|
+
constructor(options = {}) {
|
|
161
|
+
this.#workspaceRoot = options.workspaceRoot ?? process.cwd();
|
|
162
|
+
this.#reportDir =
|
|
163
|
+
options.reportDir ?? path.join(this.#workspaceRoot, 'memory', 'mvp-gate', 'reports');
|
|
164
|
+
this.#commandRunner = options.commandRunner ?? defaultCommandRunner;
|
|
165
|
+
this.#healthProbe = options.healthProbe ?? defaultHealthProbe;
|
|
166
|
+
this.#now = options.now ?? (() => new Date());
|
|
167
|
+
this.#defaultHealthUrl = options.defaultHealthUrl ?? DEFAULT_HEALTH_URL;
|
|
168
|
+
}
|
|
169
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
170
|
+
async runGate(options = {}) {
|
|
171
|
+
await mkdir(this.#reportDir, { recursive: true });
|
|
172
|
+
const reportId = `mvp_gate_${compactTimestamp(this.#now)}`;
|
|
173
|
+
const checks = [];
|
|
174
|
+
// Hard gates — run in order of severity / dependency
|
|
175
|
+
checks.push(await this.#runBuildCheck());
|
|
176
|
+
checks.push(await this.#runTestsCheck());
|
|
177
|
+
checks.push(await this.#runNpmCommandsCheck());
|
|
178
|
+
checks.push(await this.#runConfigSchemaCheck());
|
|
179
|
+
checks.push(await this.#runInstallerSmokeCheck(reportId));
|
|
180
|
+
checks.push(await this.#runOnboardingSmokeCheck(reportId));
|
|
181
|
+
checks.push(await this.#runVaultHealthCheck());
|
|
182
|
+
checks.push(await this.#runInterfaceReadinessCheck());
|
|
183
|
+
// api-health is a hard gate by default (uses default URL if not provided)
|
|
184
|
+
if (!options.skipHealth) {
|
|
185
|
+
const healthUrl = options.healthUrl ?? this.#defaultHealthUrl;
|
|
186
|
+
checks.push(await this.#runHealthCheck(healthUrl));
|
|
187
|
+
}
|
|
188
|
+
// Advisory checks
|
|
189
|
+
checks.push(await this.#runDistArtifactCheck());
|
|
190
|
+
checks.push(await this.#runTestCoverageCheck());
|
|
191
|
+
checks.push(await this.#runDoctorReadinessCheck());
|
|
192
|
+
// Smoke scenarios
|
|
193
|
+
const smokeScenarios = await this.#runSmokeScenarios();
|
|
194
|
+
// Compute verdict
|
|
195
|
+
const failedHardGates = checks.filter((c) => c.class === 'hard-gate' && c.status === 'failed');
|
|
196
|
+
const advisoryFailures = checks.filter((c) => c.class === 'advisory' && c.status === 'failed');
|
|
197
|
+
let verdict;
|
|
198
|
+
if (failedHardGates.length > 0) {
|
|
199
|
+
verdict = 'no-go';
|
|
200
|
+
}
|
|
201
|
+
else if (advisoryFailures.length > 0) {
|
|
202
|
+
verdict = 'advisory-only';
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
verdict = 'go';
|
|
206
|
+
}
|
|
207
|
+
const triage = this.#buildTriage([...failedHardGates, ...advisoryFailures]);
|
|
208
|
+
const summary = buildSummary(verdict, failedHardGates);
|
|
209
|
+
const reportPath = path.join(this.#reportDir, `${reportId}.json`);
|
|
210
|
+
const markdownPath = path.join(this.#reportDir, `${reportId}.md`);
|
|
211
|
+
const report = {
|
|
212
|
+
reportVersion: 1,
|
|
213
|
+
reportId,
|
|
214
|
+
generatedAt: nowIso(this.#now),
|
|
215
|
+
verdict,
|
|
216
|
+
hardGatePassed: failedHardGates.length === 0,
|
|
217
|
+
checks,
|
|
218
|
+
failedHardGates,
|
|
219
|
+
advisoryFailures,
|
|
220
|
+
smokeScenarios,
|
|
221
|
+
triage,
|
|
222
|
+
summary,
|
|
223
|
+
reportPath,
|
|
224
|
+
markdownPath,
|
|
225
|
+
};
|
|
226
|
+
await this.#writeReport(report);
|
|
227
|
+
return report;
|
|
228
|
+
}
|
|
229
|
+
// ── Hard Gate Checks ────────────────────────────────────────────────────────
|
|
230
|
+
async #runBuildCheck() {
|
|
231
|
+
return this.#runCommandCheck('build', 'hard-gate', 'npm run build', 'TypeScript build');
|
|
232
|
+
}
|
|
233
|
+
async #runTestsCheck() {
|
|
234
|
+
return this.#runCommandCheck('tests', 'hard-gate', 'npm run test', 'Test suite');
|
|
235
|
+
}
|
|
236
|
+
async #runNpmCommandsCheck() {
|
|
237
|
+
const startedAt = nowIso(this.#now);
|
|
238
|
+
const started = Date.now();
|
|
239
|
+
try {
|
|
240
|
+
const pkgPath = path.join(this.#workspaceRoot, 'package.json');
|
|
241
|
+
const raw = await readFile(pkgPath, 'utf8');
|
|
242
|
+
const pkg = JSON.parse(raw);
|
|
243
|
+
const scripts = pkg.scripts ?? {};
|
|
244
|
+
const missing = REQUIRED_SCRIPTS.filter((s) => !Object.prototype.hasOwnProperty.call(scripts, s));
|
|
245
|
+
return {
|
|
246
|
+
id: 'npm-commands',
|
|
247
|
+
class: 'hard-gate',
|
|
248
|
+
status: missing.length === 0 ? 'passed' : 'failed',
|
|
249
|
+
detail: missing.length === 0
|
|
250
|
+
? `All ${REQUIRED_SCRIPTS.length} required npm scripts are defined.`
|
|
251
|
+
: `Missing required npm scripts: ${missing.join(', ')}`,
|
|
252
|
+
startedAt,
|
|
253
|
+
completedAt: nowIso(this.#now),
|
|
254
|
+
durationMs: Date.now() - started,
|
|
255
|
+
artifacts: [`package.json#scripts`],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
return {
|
|
260
|
+
id: 'npm-commands',
|
|
261
|
+
class: 'hard-gate',
|
|
262
|
+
status: 'failed',
|
|
263
|
+
detail: `Unable to read package.json: ${error instanceof Error ? error.message : String(error)}`,
|
|
264
|
+
startedAt,
|
|
265
|
+
completedAt: nowIso(this.#now),
|
|
266
|
+
durationMs: Date.now() - started,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async #runConfigSchemaCheck() {
|
|
271
|
+
const startedAt = nowIso(this.#now);
|
|
272
|
+
const started = Date.now();
|
|
273
|
+
const configPath = path.join(this.#workspaceRoot, 'twinbot.json');
|
|
274
|
+
if (!(await pathExists(configPath))) {
|
|
275
|
+
return {
|
|
276
|
+
id: 'config-schema',
|
|
277
|
+
class: 'hard-gate',
|
|
278
|
+
status: 'failed',
|
|
279
|
+
detail: 'twinbot.json is missing.',
|
|
280
|
+
startedAt,
|
|
281
|
+
completedAt: nowIso(this.#now),
|
|
282
|
+
durationMs: Date.now() - started,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const raw = await readFile(configPath, 'utf8');
|
|
287
|
+
const parsed = JSON.parse(raw);
|
|
288
|
+
const validation = validateTwinBotConfigSchema(parsed);
|
|
289
|
+
return {
|
|
290
|
+
id: 'config-schema',
|
|
291
|
+
class: 'hard-gate',
|
|
292
|
+
status: validation.valid ? 'passed' : 'failed',
|
|
293
|
+
detail: validation.valid
|
|
294
|
+
? 'twinbot.json satisfies the required schema.'
|
|
295
|
+
: `twinbot.json schema validation failed: ${validation.errors.slice(0, 4).join(' | ')}`,
|
|
296
|
+
startedAt,
|
|
297
|
+
completedAt: nowIso(this.#now),
|
|
298
|
+
durationMs: Date.now() - started,
|
|
299
|
+
artifacts: [configPath],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return {
|
|
304
|
+
id: 'config-schema',
|
|
305
|
+
class: 'hard-gate',
|
|
306
|
+
status: 'failed',
|
|
307
|
+
detail: `Unable to parse twinbot.json: ${error instanceof Error ? error.message : String(error)}`,
|
|
308
|
+
startedAt,
|
|
309
|
+
completedAt: nowIso(this.#now),
|
|
310
|
+
durationMs: Date.now() - started,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
async #runInstallerSmokeCheck(reportId) {
|
|
315
|
+
const startedAt = nowIso(this.#now);
|
|
316
|
+
const started = Date.now();
|
|
317
|
+
const command = 'node ./bin/npm-twinbot.js install --help';
|
|
318
|
+
const installerSmokeLogPath = path.join(this.#reportDir, `${reportId}.installer-smoke.log`);
|
|
319
|
+
try {
|
|
320
|
+
const run = await this.#commandRunner(command, this.#workspaceRoot);
|
|
321
|
+
await writeFile(installerSmokeLogPath, run.output, 'utf8');
|
|
322
|
+
if (!run.ok) {
|
|
323
|
+
return {
|
|
324
|
+
id: 'installer-smoke',
|
|
325
|
+
class: 'hard-gate',
|
|
326
|
+
status: 'failed',
|
|
327
|
+
detail: `Installer smoke check failed (exit ${run.exitCode}). Ensure the 'install' command is implemented and returns success for --help.`,
|
|
328
|
+
command,
|
|
329
|
+
startedAt,
|
|
330
|
+
completedAt: nowIso(this.#now),
|
|
331
|
+
durationMs: run.durationMs,
|
|
332
|
+
artifacts: [installerSmokeLogPath],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
id: 'installer-smoke',
|
|
337
|
+
class: 'hard-gate',
|
|
338
|
+
status: 'passed',
|
|
339
|
+
detail: 'Installer help command responded successfully.',
|
|
340
|
+
command,
|
|
341
|
+
startedAt,
|
|
342
|
+
completedAt: nowIso(this.#now),
|
|
343
|
+
durationMs: run.durationMs,
|
|
344
|
+
artifacts: [installerSmokeLogPath],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
await writeFile(installerSmokeLogPath, error instanceof Error ? error.stack ?? error.message : String(error), 'utf8');
|
|
349
|
+
return {
|
|
350
|
+
id: 'installer-smoke',
|
|
351
|
+
class: 'hard-gate',
|
|
352
|
+
status: 'failed',
|
|
353
|
+
detail: `Installer smoke check crashed: ${error instanceof Error ? error.message : String(error)}`,
|
|
354
|
+
command,
|
|
355
|
+
startedAt,
|
|
356
|
+
completedAt: nowIso(this.#now),
|
|
357
|
+
durationMs: Date.now() - started,
|
|
358
|
+
artifacts: [installerSmokeLogPath],
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async #runOnboardingSmokeCheck(reportId) {
|
|
363
|
+
const startedAt = nowIso(this.#now);
|
|
364
|
+
const started = Date.now();
|
|
365
|
+
const onboardPath = path.join(this.#workspaceRoot, 'src', 'core', 'onboarding.ts');
|
|
366
|
+
const onboardSmokeLogPath = path.join(this.#reportDir, `${reportId}.onboard-smoke.log`);
|
|
367
|
+
const exists = await pathExists(onboardPath);
|
|
368
|
+
if (!exists) {
|
|
369
|
+
await writeFile(onboardSmokeLogPath, 'src/core/onboarding.ts is missing — the interactive wizard is required for MVP setup.', 'utf8');
|
|
370
|
+
return {
|
|
371
|
+
id: 'onboarding-smoke',
|
|
372
|
+
class: 'hard-gate',
|
|
373
|
+
status: 'failed',
|
|
374
|
+
detail: 'src/core/onboarding.ts is missing — the interactive wizard is required for MVP setup.',
|
|
375
|
+
startedAt,
|
|
376
|
+
completedAt: nowIso(this.#now),
|
|
377
|
+
durationMs: Date.now() - started,
|
|
378
|
+
artifacts: [onboardSmokeLogPath],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const smokeConfigPath = path.join(this.#reportDir, `${reportId}.onboard-smoke.json`);
|
|
382
|
+
const onboardingSmokeLogPath = path.join(this.#reportDir, `${reportId}.onboard-smoke.log`);
|
|
383
|
+
const command = [
|
|
384
|
+
'node ./bin/twinbot.js onboard --non-interactive',
|
|
385
|
+
`--config ${quoteCommandArg(smokeConfigPath)}`,
|
|
386
|
+
'--api-secret mvp-gate-smoke-secret',
|
|
387
|
+
'--openrouter-api-key mvp-gate-smoke-model',
|
|
388
|
+
'--embedding-provider openai',
|
|
389
|
+
'--api-port 3100',
|
|
390
|
+
].join(' ');
|
|
391
|
+
try {
|
|
392
|
+
const run = await this.#commandRunner(command, this.#workspaceRoot);
|
|
393
|
+
await writeFile(onboardingSmokeLogPath, run.output, 'utf8');
|
|
394
|
+
if (!run.ok) {
|
|
395
|
+
return {
|
|
396
|
+
id: 'onboarding-smoke',
|
|
397
|
+
class: 'hard-gate',
|
|
398
|
+
status: 'failed',
|
|
399
|
+
detail: `Onboarding smoke run failed (exit ${run.exitCode}). ${run.output.trim().split('\n').slice(-5).join('\n')}`,
|
|
400
|
+
command,
|
|
401
|
+
startedAt,
|
|
402
|
+
completedAt: nowIso(this.#now),
|
|
403
|
+
durationMs: run.durationMs,
|
|
404
|
+
artifacts: ['src/core/onboarding.ts', onboardingSmokeLogPath],
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (!(await pathExists(smokeConfigPath))) {
|
|
408
|
+
return {
|
|
409
|
+
id: 'onboarding-smoke',
|
|
410
|
+
class: 'hard-gate',
|
|
411
|
+
status: 'failed',
|
|
412
|
+
detail: 'Onboarding smoke run succeeded but did not produce a config artifact.',
|
|
413
|
+
command,
|
|
414
|
+
startedAt,
|
|
415
|
+
completedAt: nowIso(this.#now),
|
|
416
|
+
durationMs: run.durationMs,
|
|
417
|
+
artifacts: ['src/core/onboarding.ts', onboardingSmokeLogPath],
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const generatedRaw = await readFile(smokeConfigPath, 'utf8');
|
|
421
|
+
const generatedConfig = JSON.parse(generatedRaw);
|
|
422
|
+
const validation = validateTwinBotConfigSchema(generatedConfig);
|
|
423
|
+
if (!validation.valid) {
|
|
424
|
+
return {
|
|
425
|
+
id: 'onboarding-smoke',
|
|
426
|
+
class: 'hard-gate',
|
|
427
|
+
status: 'failed',
|
|
428
|
+
detail: `Onboarding output failed schema validation: ${validation.errors.slice(0, 4).join(' | ')}`,
|
|
429
|
+
command,
|
|
430
|
+
startedAt,
|
|
431
|
+
completedAt: nowIso(this.#now),
|
|
432
|
+
durationMs: run.durationMs,
|
|
433
|
+
artifacts: ['src/core/onboarding.ts', onboardingSmokeLogPath, smokeConfigPath],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
id: 'onboarding-smoke',
|
|
438
|
+
class: 'hard-gate',
|
|
439
|
+
status: 'passed',
|
|
440
|
+
detail: 'Onboarding smoke run generated a schema-valid config artifact.',
|
|
441
|
+
command,
|
|
442
|
+
startedAt,
|
|
443
|
+
completedAt: nowIso(this.#now),
|
|
444
|
+
durationMs: run.durationMs,
|
|
445
|
+
artifacts: ['src/core/onboarding.ts', onboardingSmokeLogPath],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
await writeFile(onboardingSmokeLogPath, error instanceof Error ? error.stack ?? error.message : String(error), 'utf8');
|
|
450
|
+
return {
|
|
451
|
+
id: 'onboarding-smoke',
|
|
452
|
+
class: 'hard-gate',
|
|
453
|
+
status: 'failed',
|
|
454
|
+
detail: `Onboarding smoke run crashed: ${error instanceof Error ? error.message : String(error)}`,
|
|
455
|
+
command,
|
|
456
|
+
startedAt,
|
|
457
|
+
completedAt: nowIso(this.#now),
|
|
458
|
+
durationMs: Date.now() - started,
|
|
459
|
+
artifacts: ['src/core/onboarding.ts', onboardingSmokeLogPath],
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
await rm(smokeConfigPath, { force: true });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async #runVaultHealthCheck() {
|
|
467
|
+
const startedAt = nowIso(this.#now);
|
|
468
|
+
const started = Date.now();
|
|
469
|
+
const command = 'node ./bin/twinbot.js secret doctor';
|
|
470
|
+
const run = await this.#commandRunner(command, this.#workspaceRoot);
|
|
471
|
+
if (!run.ok) {
|
|
472
|
+
return {
|
|
473
|
+
id: 'vault-health',
|
|
474
|
+
class: 'hard-gate',
|
|
475
|
+
status: 'failed',
|
|
476
|
+
detail: `Secret vault doctor command failed (exit ${run.exitCode}). ${run.output.trim().split('\n').slice(-5).join('\n')}`,
|
|
477
|
+
command,
|
|
478
|
+
startedAt,
|
|
479
|
+
completedAt: nowIso(this.#now),
|
|
480
|
+
durationMs: run.durationMs,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const match = run.output.match(/Secret vault status:\s*(\w+)/i);
|
|
484
|
+
const status = match?.[1]?.toLowerCase();
|
|
485
|
+
if (status && status !== 'ok') {
|
|
486
|
+
return {
|
|
487
|
+
id: 'vault-health',
|
|
488
|
+
class: 'hard-gate',
|
|
489
|
+
status: 'failed',
|
|
490
|
+
detail: `Secret vault doctor reported non-healthy status: ${status}.`,
|
|
491
|
+
command,
|
|
492
|
+
startedAt,
|
|
493
|
+
completedAt: nowIso(this.#now),
|
|
494
|
+
durationMs: run.durationMs,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
id: 'vault-health',
|
|
499
|
+
class: 'hard-gate',
|
|
500
|
+
status: 'passed',
|
|
501
|
+
detail: status === 'ok'
|
|
502
|
+
? 'Secret vault doctor reported healthy status.'
|
|
503
|
+
: 'Secret vault doctor command succeeded.',
|
|
504
|
+
command,
|
|
505
|
+
startedAt,
|
|
506
|
+
completedAt: nowIso(this.#now),
|
|
507
|
+
durationMs: run.durationMs,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
async #runInterfaceReadinessCheck() {
|
|
511
|
+
const startedAt = nowIso(this.#now);
|
|
512
|
+
const started = Date.now();
|
|
513
|
+
const required = [
|
|
514
|
+
path.join(this.#workspaceRoot, 'gui', 'package.json'),
|
|
515
|
+
path.join(this.#workspaceRoot, 'src', 'interfaces', 'dispatcher.ts'),
|
|
516
|
+
path.join(this.#workspaceRoot, 'mcp-servers.json'),
|
|
517
|
+
];
|
|
518
|
+
const missing = [];
|
|
519
|
+
for (const target of required) {
|
|
520
|
+
if (!(await pathExists(target))) {
|
|
521
|
+
missing.push(path.relative(this.#workspaceRoot, target));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
id: 'interface-readiness',
|
|
526
|
+
class: 'hard-gate',
|
|
527
|
+
status: missing.length === 0 ? 'passed' : 'failed',
|
|
528
|
+
detail: missing.length === 0
|
|
529
|
+
? 'All critical interface assets are present.'
|
|
530
|
+
: `Missing critical interface assets: ${missing.join(', ')}`,
|
|
531
|
+
startedAt,
|
|
532
|
+
completedAt: nowIso(this.#now),
|
|
533
|
+
durationMs: Date.now() - started,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
async #runHealthCheck(healthUrl) {
|
|
537
|
+
const startedAt = nowIso(this.#now);
|
|
538
|
+
const started = Date.now();
|
|
539
|
+
const probe = await this.#healthProbe(healthUrl);
|
|
540
|
+
return {
|
|
541
|
+
id: 'api-health',
|
|
542
|
+
class: 'hard-gate',
|
|
543
|
+
status: probe.ok ? 'passed' : 'failed',
|
|
544
|
+
detail: probe.detail,
|
|
545
|
+
command: `GET ${healthUrl}`,
|
|
546
|
+
startedAt,
|
|
547
|
+
completedAt: nowIso(this.#now),
|
|
548
|
+
durationMs: Date.now() - started,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
// ── Advisory Checks ─────────────────────────────────────────────────────────
|
|
552
|
+
async #runDistArtifactCheck() {
|
|
553
|
+
const startedAt = nowIso(this.#now);
|
|
554
|
+
const started = Date.now();
|
|
555
|
+
const distPath = path.join(this.#workspaceRoot, 'dist');
|
|
556
|
+
const exists = await pathExists(distPath);
|
|
557
|
+
const nonEmpty = exists && (await isDirNonEmpty(distPath));
|
|
558
|
+
return {
|
|
559
|
+
id: 'dist-artifact',
|
|
560
|
+
class: 'advisory',
|
|
561
|
+
status: nonEmpty ? 'passed' : 'failed',
|
|
562
|
+
detail: nonEmpty
|
|
563
|
+
? 'dist/ directory exists and contains build artifacts.'
|
|
564
|
+
: exists
|
|
565
|
+
? 'dist/ directory exists but is empty — run `npm run build` to populate it.'
|
|
566
|
+
: 'dist/ directory is absent — run `npm run build` first.',
|
|
567
|
+
startedAt,
|
|
568
|
+
completedAt: nowIso(this.#now),
|
|
569
|
+
durationMs: Date.now() - started,
|
|
570
|
+
artifacts: nonEmpty ? [distPath] : undefined,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async #runTestCoverageCheck() {
|
|
574
|
+
const startedAt = nowIso(this.#now);
|
|
575
|
+
const started = Date.now();
|
|
576
|
+
const summaryPath = path.join(this.#workspaceRoot, 'coverage', 'coverage-summary.json');
|
|
577
|
+
const exists = await pathExists(summaryPath);
|
|
578
|
+
if (!exists) {
|
|
579
|
+
return {
|
|
580
|
+
id: 'test-coverage',
|
|
581
|
+
class: 'advisory',
|
|
582
|
+
status: 'skipped',
|
|
583
|
+
detail: 'No coverage summary found. Run `npm run test:coverage` to generate one.',
|
|
584
|
+
startedAt,
|
|
585
|
+
completedAt: nowIso(this.#now),
|
|
586
|
+
durationMs: Date.now() - started,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const raw = await readFile(summaryPath, 'utf8');
|
|
591
|
+
const summary = JSON.parse(raw);
|
|
592
|
+
const total = summary.total;
|
|
593
|
+
if (!total) {
|
|
594
|
+
return {
|
|
595
|
+
id: 'test-coverage',
|
|
596
|
+
class: 'advisory',
|
|
597
|
+
status: 'failed',
|
|
598
|
+
detail: 'Coverage summary is malformed — missing "total" entry.',
|
|
599
|
+
startedAt,
|
|
600
|
+
completedAt: nowIso(this.#now),
|
|
601
|
+
durationMs: Date.now() - started,
|
|
602
|
+
artifacts: [summaryPath],
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
const lines = total.lines?.pct ?? 0;
|
|
606
|
+
const functions = total.functions?.pct ?? 0;
|
|
607
|
+
const branches = total.branches?.pct ?? 0;
|
|
608
|
+
const statements = total.statements?.pct ?? 0;
|
|
609
|
+
const MIN_THRESHOLD = 25;
|
|
610
|
+
const failing = [
|
|
611
|
+
lines < MIN_THRESHOLD ? `lines: ${lines}% < ${MIN_THRESHOLD}%` : null,
|
|
612
|
+
functions < MIN_THRESHOLD ? `functions: ${functions}% < ${MIN_THRESHOLD}%` : null,
|
|
613
|
+
branches < MIN_THRESHOLD ? `branches: ${branches}% < ${MIN_THRESHOLD}%` : null,
|
|
614
|
+
statements < MIN_THRESHOLD ? `statements: ${statements}% < ${MIN_THRESHOLD}%` : null,
|
|
615
|
+
].filter((v) => v !== null);
|
|
616
|
+
return {
|
|
617
|
+
id: 'test-coverage',
|
|
618
|
+
class: 'advisory',
|
|
619
|
+
status: failing.length === 0 ? 'passed' : 'failed',
|
|
620
|
+
detail: failing.length === 0
|
|
621
|
+
? `Coverage meets thresholds: lines ${lines}%, functions ${functions}%, branches ${branches}%, statements ${statements}%.`
|
|
622
|
+
: `Coverage below threshold: ${failing.join('; ')}.`,
|
|
623
|
+
startedAt,
|
|
624
|
+
completedAt: nowIso(this.#now),
|
|
625
|
+
durationMs: Date.now() - started,
|
|
626
|
+
artifacts: [summaryPath],
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
return {
|
|
631
|
+
id: 'test-coverage',
|
|
632
|
+
class: 'advisory',
|
|
633
|
+
status: 'failed',
|
|
634
|
+
detail: `Failed to parse coverage summary: ${error instanceof Error ? error.message : String(error)}`,
|
|
635
|
+
startedAt,
|
|
636
|
+
completedAt: nowIso(this.#now),
|
|
637
|
+
durationMs: Date.now() - started,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async #runDoctorReadinessCheck() {
|
|
642
|
+
const startedAt = nowIso(this.#now);
|
|
643
|
+
const started = Date.now();
|
|
644
|
+
const doctorPath = path.join(this.#workspaceRoot, 'src', 'core', 'doctor.ts');
|
|
645
|
+
const exists = await pathExists(doctorPath);
|
|
646
|
+
return {
|
|
647
|
+
id: 'doctor-readiness',
|
|
648
|
+
class: 'advisory',
|
|
649
|
+
status: exists ? 'passed' : 'failed',
|
|
650
|
+
detail: exists
|
|
651
|
+
? 'Doctor diagnostics module is present (src/core/doctor.ts).'
|
|
652
|
+
: 'Doctor module is missing — Track 23 should wire the system diagnostic logic.',
|
|
653
|
+
startedAt,
|
|
654
|
+
completedAt: nowIso(this.#now),
|
|
655
|
+
durationMs: Date.now() - started,
|
|
656
|
+
artifacts: exists ? [doctorPath] : undefined,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
// ── Smoke Scenarios ─────────────────────────────────────────────────────────
|
|
660
|
+
async #runSmokeScenarios() {
|
|
661
|
+
const results = [];
|
|
662
|
+
for (const scenario of SMOKE_SCENARIOS) {
|
|
663
|
+
const allExist = await Promise.all(scenario.relativePaths.map((rel) => pathExists(path.join(this.#workspaceRoot, rel))));
|
|
664
|
+
const pass = allExist.every(Boolean);
|
|
665
|
+
const missing = scenario.relativePaths.filter((_, i) => !allExist[i]);
|
|
666
|
+
results.push({
|
|
667
|
+
id: scenario.id,
|
|
668
|
+
label: scenario.label,
|
|
669
|
+
pass,
|
|
670
|
+
detail: pass
|
|
671
|
+
? `${scenario.label}: OK`
|
|
672
|
+
: `${scenario.label}: missing file(s): ${missing.join(', ')}`,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
return results;
|
|
676
|
+
}
|
|
677
|
+
// ── Shared Command Check ────────────────────────────────────────────────────
|
|
678
|
+
async #runCommandCheck(id, criterionClass, command, label) {
|
|
679
|
+
const started = Date.now();
|
|
680
|
+
const startedAt = new Date(started).toISOString();
|
|
681
|
+
const run = await this.#commandRunner(command, this.#workspaceRoot);
|
|
682
|
+
return {
|
|
683
|
+
id,
|
|
684
|
+
class: criterionClass,
|
|
685
|
+
status: run.ok ? 'passed' : 'failed',
|
|
686
|
+
detail: run.ok
|
|
687
|
+
? `${label} passed.`
|
|
688
|
+
: `${label} failed (exit ${run.exitCode}). ${run.output.trim().split('\n').slice(-5).join('\n')}`,
|
|
689
|
+
command,
|
|
690
|
+
startedAt,
|
|
691
|
+
completedAt: nowIso(this.#now),
|
|
692
|
+
durationMs: run.durationMs,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
// ── Triage Builder ──────────────────────────────────────────────────────────
|
|
696
|
+
#buildTriage(failedChecks) {
|
|
697
|
+
return failedChecks.map((check) => {
|
|
698
|
+
const ownership = TRIAGE_OWNERSHIP[check.id];
|
|
699
|
+
return {
|
|
700
|
+
checkId: check.id,
|
|
701
|
+
severity: ownership.severity,
|
|
702
|
+
ownerTrack: ownership.ownerTrack,
|
|
703
|
+
detail: check.detail,
|
|
704
|
+
nextAction: ownership.nextAction,
|
|
705
|
+
};
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
// ── Report Writers ──────────────────────────────────────────────────────────
|
|
709
|
+
async #writeReport(report) {
|
|
710
|
+
await writeFile(report.reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
711
|
+
await writeFile(report.markdownPath, buildMarkdownReport(report), 'utf8');
|
|
712
|
+
const latestPath = path.join(this.#reportDir, 'latest.json');
|
|
713
|
+
await writeFile(latestPath, `${JSON.stringify({
|
|
714
|
+
reportId: report.reportId,
|
|
715
|
+
reportPath: report.reportPath,
|
|
716
|
+
markdownPath: report.markdownPath,
|
|
717
|
+
generatedAt: report.generatedAt,
|
|
718
|
+
verdict: report.verdict,
|
|
719
|
+
}, null, 2)}\n`, 'utf8');
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// ─── Markdown Report Builder ──────────────────────────────────────────────────
|
|
723
|
+
function checkStatusIcon(status) {
|
|
724
|
+
if (status === 'passed')
|
|
725
|
+
return '✅';
|
|
726
|
+
if (status === 'failed')
|
|
727
|
+
return '❌';
|
|
728
|
+
return '⏭️';
|
|
729
|
+
}
|
|
730
|
+
function verdictBadge(verdict) {
|
|
731
|
+
if (verdict === 'go')
|
|
732
|
+
return '🟢 **GO**';
|
|
733
|
+
if (verdict === 'no-go')
|
|
734
|
+
return '🔴 **NO-GO**';
|
|
735
|
+
return '🟡 **ADVISORY-ONLY**';
|
|
736
|
+
}
|
|
737
|
+
function buildMarkdownReport(report) {
|
|
738
|
+
const lines = [];
|
|
739
|
+
lines.push(`# MVP Gate Report — ${report.reportId}`);
|
|
740
|
+
lines.push('');
|
|
741
|
+
lines.push(`| Field | Value |`);
|
|
742
|
+
lines.push(`|---|---|`);
|
|
743
|
+
lines.push(`| **Verdict** | ${verdictBadge(report.verdict)} |`);
|
|
744
|
+
lines.push(`| Generated | ${report.generatedAt} |`);
|
|
745
|
+
lines.push(`| Hard Gates | ${report.hardGatePassed ? '✅ All passed' : `❌ ${report.failedHardGates.length} failed`} |`);
|
|
746
|
+
lines.push(`| Advisory Failures | ${report.advisoryFailures.length} |`);
|
|
747
|
+
lines.push('');
|
|
748
|
+
lines.push(`**Summary:** ${report.summary}`);
|
|
749
|
+
lines.push('');
|
|
750
|
+
// ── Hard Gate Results ──
|
|
751
|
+
lines.push('## Hard Gate Checks');
|
|
752
|
+
lines.push('');
|
|
753
|
+
lines.push('| Check | Status | Detail |');
|
|
754
|
+
lines.push('|---|---|---|');
|
|
755
|
+
for (const check of report.checks.filter((c) => c.class === 'hard-gate')) {
|
|
756
|
+
lines.push(`| \`${check.id}\` | ${checkStatusIcon(check.status)} ${check.status} | ${check.detail.replace(/\n/g, ' ')} |`);
|
|
757
|
+
}
|
|
758
|
+
lines.push('');
|
|
759
|
+
// ── Advisory Check Results ──
|
|
760
|
+
lines.push('## Advisory Checks');
|
|
761
|
+
lines.push('');
|
|
762
|
+
lines.push('| Check | Status | Detail |');
|
|
763
|
+
lines.push('|---|---|---|');
|
|
764
|
+
for (const check of report.checks.filter((c) => c.class === 'advisory')) {
|
|
765
|
+
lines.push(`| \`${check.id}\` | ${checkStatusIcon(check.status)} ${check.status} | ${check.detail.replace(/\n/g, ' ')} |`);
|
|
766
|
+
}
|
|
767
|
+
lines.push('');
|
|
768
|
+
// ── Smoke Scenarios ──
|
|
769
|
+
lines.push('## Smoke Scenario Matrix');
|
|
770
|
+
lines.push('');
|
|
771
|
+
lines.push('| Scenario | Result | Detail |');
|
|
772
|
+
lines.push('|---|---|---|');
|
|
773
|
+
for (const scenario of report.smokeScenarios) {
|
|
774
|
+
const icon = scenario.pass ? '✅' : '❌';
|
|
775
|
+
lines.push(`| ${scenario.label} | ${icon} ${scenario.pass ? 'pass' : 'fail'} | ${scenario.detail} |`);
|
|
776
|
+
}
|
|
777
|
+
lines.push('');
|
|
778
|
+
// ── Triage ──
|
|
779
|
+
if (report.triage.length > 0) {
|
|
780
|
+
lines.push('## Failure Triage');
|
|
781
|
+
lines.push('');
|
|
782
|
+
for (const entry of report.triage) {
|
|
783
|
+
lines.push(`### \`${entry.checkId}\` — ${entry.severity === 'blocker' ? '🔴 Blocker' : '🟡 Advisory'}`);
|
|
784
|
+
lines.push(`- **Owner:** ${entry.ownerTrack}`);
|
|
785
|
+
lines.push(`- **Detail:** ${entry.detail}`);
|
|
786
|
+
lines.push(`- **Next Action:** ${entry.nextAction}`);
|
|
787
|
+
lines.push('');
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
lines.push('## Failure Triage');
|
|
792
|
+
lines.push('');
|
|
793
|
+
lines.push('No failures — all checks passed or were skipped.');
|
|
794
|
+
lines.push('');
|
|
795
|
+
}
|
|
796
|
+
return lines.join('\n');
|
|
797
|
+
}
|
|
798
|
+
// ─── Default Implementations ──────────────────────────────────────────────────
|
|
799
|
+
async function defaultCommandRunner(command, cwd) {
|
|
800
|
+
const { exec } = await import('node:child_process');
|
|
801
|
+
const { promisify } = await import('node:util');
|
|
802
|
+
const execAsync = promisify(exec);
|
|
803
|
+
const started = Date.now();
|
|
804
|
+
try {
|
|
805
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
806
|
+
cwd,
|
|
807
|
+
windowsHide: true,
|
|
808
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
809
|
+
});
|
|
810
|
+
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
811
|
+
return { ok: true, exitCode: 0, output, durationMs: Date.now() - started };
|
|
812
|
+
}
|
|
813
|
+
catch (error) {
|
|
814
|
+
const err = error;
|
|
815
|
+
const output = [err.stdout, err.stderr, err.message]
|
|
816
|
+
.filter((p) => typeof p === 'string' && p.length > 0)
|
|
817
|
+
.join('\n')
|
|
818
|
+
.trim();
|
|
819
|
+
return {
|
|
820
|
+
ok: false,
|
|
821
|
+
exitCode: typeof err.code === 'number' ? err.code : 1,
|
|
822
|
+
output,
|
|
823
|
+
durationMs: Date.now() - started,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
async function defaultHealthProbe(url) {
|
|
828
|
+
try {
|
|
829
|
+
const response = await fetch(url, {
|
|
830
|
+
method: 'GET',
|
|
831
|
+
headers: { accept: 'application/json' },
|
|
832
|
+
signal: AbortSignal.timeout(5_000),
|
|
833
|
+
});
|
|
834
|
+
if (!response.ok) {
|
|
835
|
+
return { ok: false, detail: `Health endpoint returned HTTP ${response.status}.` };
|
|
836
|
+
}
|
|
837
|
+
return { ok: true, detail: `Health endpoint reachable (HTTP ${response.status}).` };
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
return {
|
|
841
|
+
ok: false,
|
|
842
|
+
detail: `Health probe failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
}
|