tmux-ide 1.2.1 → 2.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/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/** Shared status shape for tunnel providers (Tailscale Serve, ngrok, Cloudflare Quick Tunnel). */
|
|
4
|
+
export type TunnelStatus = {
|
|
5
|
+
running: boolean;
|
|
6
|
+
publicUrl?: string | null;
|
|
7
|
+
port?: number;
|
|
8
|
+
lastError?: string;
|
|
9
|
+
/** Provider-specific fields (funnel, mode, etc.) */
|
|
10
|
+
meta?: Record<string, unknown>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Common surface for tunnel integrations (see `tailscale.ts`, `ngrok.ts`, `cloudflare.ts`).
|
|
15
|
+
* Concrete classes may take additional constructor arguments (port, auth, etc.).
|
|
16
|
+
*/
|
|
17
|
+
export interface TunnelService {
|
|
18
|
+
start(...args: unknown[]): Promise<unknown>;
|
|
19
|
+
stop(): Promise<void>;
|
|
20
|
+
status(): Promise<TunnelStatus>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const tunnelConfigSchema = z.discriminatedUnion("provider", [
|
|
24
|
+
z.object({
|
|
25
|
+
provider: z.literal("tailscale"),
|
|
26
|
+
port: z.number().int().positive(),
|
|
27
|
+
enableFunnel: z.boolean().optional(),
|
|
28
|
+
}),
|
|
29
|
+
z.object({
|
|
30
|
+
provider: z.literal("ngrok"),
|
|
31
|
+
port: z.number().int().positive(),
|
|
32
|
+
authToken: z.string().optional(),
|
|
33
|
+
domain: z.string().optional(),
|
|
34
|
+
region: z.string().optional(),
|
|
35
|
+
startupTimeoutMs: z.number().positive().optional(),
|
|
36
|
+
}),
|
|
37
|
+
z.object({
|
|
38
|
+
provider: z.literal("cloudflare"),
|
|
39
|
+
port: z.number().int().positive(),
|
|
40
|
+
startupTimeoutMs: z.number().positive().optional(),
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export type TunnelConfig = z.infer<typeof tunnelConfigSchema>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, expect } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
loadValidationContract,
|
|
7
|
+
loadValidationState,
|
|
8
|
+
saveValidationState,
|
|
9
|
+
isAllPassing,
|
|
10
|
+
getFailedAssertions,
|
|
11
|
+
parseAssertionIds,
|
|
12
|
+
checkCoverage,
|
|
13
|
+
type ValidationState,
|
|
14
|
+
} from "./validation.ts";
|
|
15
|
+
import { ensureTasksDir, saveTask } from "./task-store.ts";
|
|
16
|
+
import { makeTask } from "../__tests__/support.ts";
|
|
17
|
+
|
|
18
|
+
let tmpDir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-validation-test-"));
|
|
22
|
+
mkdirSync(join(tmpDir, ".tasks"), { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("loadValidationContract", () => {
|
|
30
|
+
it("returns null when file does not exist", () => {
|
|
31
|
+
expect(loadValidationContract(tmpDir)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reads the contract file", () => {
|
|
35
|
+
writeFileSync(join(tmpDir, ".tasks", "validation-contract.md"), "# Contract\n- [A1] test");
|
|
36
|
+
expect(loadValidationContract(tmpDir)).toBe("# Contract\n- [A1] test");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("loadValidationState / saveValidationState", () => {
|
|
41
|
+
it("returns null when file does not exist", () => {
|
|
42
|
+
expect(loadValidationState(tmpDir)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("round-trips state", () => {
|
|
46
|
+
const state: ValidationState = {
|
|
47
|
+
assertions: {
|
|
48
|
+
A1: {
|
|
49
|
+
status: "passing",
|
|
50
|
+
verifiedBy: "agent-1",
|
|
51
|
+
verifiedAt: "2026-01-01T00:00:00Z",
|
|
52
|
+
evidence: "tests pass",
|
|
53
|
+
blockedBy: null,
|
|
54
|
+
},
|
|
55
|
+
A2: {
|
|
56
|
+
status: "failing",
|
|
57
|
+
verifiedBy: null,
|
|
58
|
+
verifiedAt: null,
|
|
59
|
+
evidence: null,
|
|
60
|
+
blockedBy: null,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
lastVerified: "2026-01-01T00:00:00Z",
|
|
64
|
+
};
|
|
65
|
+
saveValidationState(tmpDir, state);
|
|
66
|
+
const loaded = loadValidationState(tmpDir);
|
|
67
|
+
expect(loaded).toEqual(state);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns null for corrupted file", () => {
|
|
71
|
+
writeFileSync(join(tmpDir, ".tasks", "validation-state.json"), "not json");
|
|
72
|
+
expect(loadValidationState(tmpDir)).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("isAllPassing", () => {
|
|
77
|
+
it("returns true when all assertions pass", () => {
|
|
78
|
+
const state: ValidationState = {
|
|
79
|
+
assertions: {
|
|
80
|
+
A1: {
|
|
81
|
+
status: "passing",
|
|
82
|
+
verifiedBy: null,
|
|
83
|
+
verifiedAt: null,
|
|
84
|
+
evidence: null,
|
|
85
|
+
blockedBy: null,
|
|
86
|
+
},
|
|
87
|
+
A2: {
|
|
88
|
+
status: "passing",
|
|
89
|
+
verifiedBy: null,
|
|
90
|
+
verifiedAt: null,
|
|
91
|
+
evidence: null,
|
|
92
|
+
blockedBy: null,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
lastVerified: null,
|
|
96
|
+
};
|
|
97
|
+
expect(isAllPassing(state)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns false when any assertion is not passing", () => {
|
|
101
|
+
const state: ValidationState = {
|
|
102
|
+
assertions: {
|
|
103
|
+
A1: {
|
|
104
|
+
status: "passing",
|
|
105
|
+
verifiedBy: null,
|
|
106
|
+
verifiedAt: null,
|
|
107
|
+
evidence: null,
|
|
108
|
+
blockedBy: null,
|
|
109
|
+
},
|
|
110
|
+
A2: {
|
|
111
|
+
status: "pending",
|
|
112
|
+
verifiedBy: null,
|
|
113
|
+
verifiedAt: null,
|
|
114
|
+
evidence: null,
|
|
115
|
+
blockedBy: null,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
lastVerified: null,
|
|
119
|
+
};
|
|
120
|
+
expect(isAllPassing(state)).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns false for empty assertions", () => {
|
|
124
|
+
expect(isAllPassing({ assertions: {}, lastVerified: null })).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("getFailedAssertions", () => {
|
|
129
|
+
it("returns IDs of failing assertions", () => {
|
|
130
|
+
const state: ValidationState = {
|
|
131
|
+
assertions: {
|
|
132
|
+
A1: {
|
|
133
|
+
status: "passing",
|
|
134
|
+
verifiedBy: null,
|
|
135
|
+
verifiedAt: null,
|
|
136
|
+
evidence: null,
|
|
137
|
+
blockedBy: null,
|
|
138
|
+
},
|
|
139
|
+
A2: {
|
|
140
|
+
status: "failing",
|
|
141
|
+
verifiedBy: null,
|
|
142
|
+
verifiedAt: null,
|
|
143
|
+
evidence: null,
|
|
144
|
+
blockedBy: null,
|
|
145
|
+
},
|
|
146
|
+
A3: {
|
|
147
|
+
status: "failing",
|
|
148
|
+
verifiedBy: null,
|
|
149
|
+
verifiedAt: null,
|
|
150
|
+
evidence: null,
|
|
151
|
+
blockedBy: null,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
lastVerified: null,
|
|
155
|
+
};
|
|
156
|
+
expect(getFailedAssertions(state)).toEqual(["A2", "A3"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns empty array when no failures", () => {
|
|
160
|
+
const state: ValidationState = {
|
|
161
|
+
assertions: {
|
|
162
|
+
A1: {
|
|
163
|
+
status: "passing",
|
|
164
|
+
verifiedBy: null,
|
|
165
|
+
verifiedAt: null,
|
|
166
|
+
evidence: null,
|
|
167
|
+
blockedBy: null,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
lastVerified: null,
|
|
171
|
+
};
|
|
172
|
+
expect(getFailedAssertions(state)).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("includes blocked assertions", () => {
|
|
176
|
+
const state: ValidationState = {
|
|
177
|
+
assertions: {
|
|
178
|
+
A1: {
|
|
179
|
+
status: "passing",
|
|
180
|
+
verifiedBy: null,
|
|
181
|
+
verifiedAt: null,
|
|
182
|
+
evidence: null,
|
|
183
|
+
blockedBy: null,
|
|
184
|
+
},
|
|
185
|
+
A2: {
|
|
186
|
+
status: "blocked",
|
|
187
|
+
verifiedBy: null,
|
|
188
|
+
verifiedAt: null,
|
|
189
|
+
evidence: null,
|
|
190
|
+
blockedBy: "needs infra",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
lastVerified: null,
|
|
194
|
+
};
|
|
195
|
+
expect(getFailedAssertions(state)).toEqual(["A2"]);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("blocked assertion status", () => {
|
|
200
|
+
it("isAllPassing returns false when assertion is blocked", () => {
|
|
201
|
+
const state: ValidationState = {
|
|
202
|
+
assertions: {
|
|
203
|
+
A1: {
|
|
204
|
+
status: "passing",
|
|
205
|
+
verifiedBy: null,
|
|
206
|
+
verifiedAt: null,
|
|
207
|
+
evidence: null,
|
|
208
|
+
blockedBy: null,
|
|
209
|
+
},
|
|
210
|
+
A2: {
|
|
211
|
+
status: "blocked",
|
|
212
|
+
verifiedBy: null,
|
|
213
|
+
verifiedAt: null,
|
|
214
|
+
evidence: null,
|
|
215
|
+
blockedBy: "depends on deploy",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
lastVerified: null,
|
|
219
|
+
};
|
|
220
|
+
expect(isAllPassing(state)).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("parseAssertionIds", () => {
|
|
225
|
+
it("parses ASSERT-style IDs", () => {
|
|
226
|
+
const contract = `# Contract\n**ASSERT01**: Auth works\n**ASSERT02**: Tests pass`;
|
|
227
|
+
expect(parseAssertionIds(contract)).toEqual(["ASSERT01", "ASSERT02"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("parses VAL-style IDs", () => {
|
|
231
|
+
const contract = `**VAL-AUTH-001**: Endpoint returns 200\n**VAL-DB-002**: Data persists`;
|
|
232
|
+
expect(parseAssertionIds(contract)).toEqual(["VAL-AUTH-001", "VAL-DB-002"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("deduplicates IDs", () => {
|
|
236
|
+
const contract = `**ASSERT01**: first mention\n**ASSERT01**: second mention`;
|
|
237
|
+
expect(parseAssertionIds(contract)).toEqual(["ASSERT01"]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("returns empty array for no matches", () => {
|
|
241
|
+
expect(parseAssertionIds("No assertions here")).toEqual([]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("checkCoverage", () => {
|
|
246
|
+
it("reports unclaimed assertions", () => {
|
|
247
|
+
ensureTasksDir(tmpDir);
|
|
248
|
+
writeFileSync(
|
|
249
|
+
join(tmpDir, ".tasks", "validation-contract.md"),
|
|
250
|
+
"**ASSERT01**: Auth\n**ASSERT02**: Tests\n**ASSERT03**: Deploy",
|
|
251
|
+
);
|
|
252
|
+
// Only ASSERT01 is claimed
|
|
253
|
+
saveTask(tmpDir, makeTask({ id: "001", fulfills: ["ASSERT01"] }));
|
|
254
|
+
|
|
255
|
+
const result = checkCoverage(tmpDir);
|
|
256
|
+
expect(result.unclaimed).toEqual(["ASSERT02", "ASSERT03"]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("reports duplicate claims", () => {
|
|
260
|
+
ensureTasksDir(tmpDir);
|
|
261
|
+
writeFileSync(join(tmpDir, ".tasks", "validation-contract.md"), "**ASSERT01**: Auth");
|
|
262
|
+
saveTask(tmpDir, makeTask({ id: "001", fulfills: ["ASSERT01"] }));
|
|
263
|
+
saveTask(tmpDir, makeTask({ id: "002", title: "Other", fulfills: ["ASSERT01"] }));
|
|
264
|
+
|
|
265
|
+
const result = checkCoverage(tmpDir);
|
|
266
|
+
expect(result.unclaimed).toEqual([]);
|
|
267
|
+
expect(result.duplicates["ASSERT01"]).toEqual(["001", "002"]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("returns empty when no contract", () => {
|
|
271
|
+
ensureTasksDir(tmpDir);
|
|
272
|
+
const result = checkCoverage(tmpDir);
|
|
273
|
+
expect(result.unclaimed).toEqual([]);
|
|
274
|
+
expect(result.duplicates).toEqual({});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("returns full coverage when all claimed", () => {
|
|
278
|
+
ensureTasksDir(tmpDir);
|
|
279
|
+
writeFileSync(
|
|
280
|
+
join(tmpDir, ".tasks", "validation-contract.md"),
|
|
281
|
+
"**ASSERT01**: Auth\n**ASSERT02**: Tests",
|
|
282
|
+
);
|
|
283
|
+
saveTask(tmpDir, makeTask({ id: "001", fulfills: ["ASSERT01", "ASSERT02"] }));
|
|
284
|
+
|
|
285
|
+
const result = checkCoverage(tmpDir);
|
|
286
|
+
expect(result.unclaimed).toEqual([]);
|
|
287
|
+
expect(result.duplicates).toEqual({});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
3
|
+
import { loadTasks } from "./task-store.ts";
|
|
4
|
+
|
|
5
|
+
export interface AssertionEntry {
|
|
6
|
+
status: "pending" | "passing" | "failing" | "blocked";
|
|
7
|
+
verifiedBy: string | null;
|
|
8
|
+
verifiedAt: string | null;
|
|
9
|
+
evidence: string | null;
|
|
10
|
+
blockedBy: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ValidationState {
|
|
14
|
+
assertions: Record<string, AssertionEntry>;
|
|
15
|
+
lastVerified: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TASKS_DIR = ".tasks";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read the validation contract markdown file.
|
|
22
|
+
* Returns the file contents, or null if it doesn't exist.
|
|
23
|
+
*/
|
|
24
|
+
export function loadValidationContract(dir: string): string | null {
|
|
25
|
+
const path = join(dir, TASKS_DIR, "validation-contract.md");
|
|
26
|
+
if (!existsSync(path)) return null;
|
|
27
|
+
return readFileSync(path, "utf-8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the validation state JSON file.
|
|
32
|
+
* Returns the parsed state, or null if it doesn't exist or is corrupted.
|
|
33
|
+
*/
|
|
34
|
+
export function loadValidationState(dir: string): ValidationState | null {
|
|
35
|
+
const path = join(dir, TASKS_DIR, "validation-state.json");
|
|
36
|
+
if (!existsSync(path)) return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(path, "utf-8")) as ValidationState;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write the validation state JSON file atomically.
|
|
46
|
+
*/
|
|
47
|
+
export function saveValidationState(dir: string, state: ValidationState): void {
|
|
48
|
+
const path = join(dir, TASKS_DIR, "validation-state.json");
|
|
49
|
+
const tmpPath = path + ".tmp";
|
|
50
|
+
writeFileSync(tmpPath, JSON.stringify(state, null, 2) + "\n");
|
|
51
|
+
renameSync(tmpPath, path);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if all assertions in a validation state are passing.
|
|
56
|
+
*/
|
|
57
|
+
export function isAllPassing(state: ValidationState): boolean {
|
|
58
|
+
const entries = Object.values(state.assertions);
|
|
59
|
+
if (entries.length === 0) return false;
|
|
60
|
+
return entries.every((e) => e.status === "passing");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get the IDs of all failing or blocked assertions.
|
|
65
|
+
*/
|
|
66
|
+
export function getFailedAssertions(state: ValidationState): string[] {
|
|
67
|
+
return Object.entries(state.assertions)
|
|
68
|
+
.filter(([, e]) => e.status === "failing" || e.status === "blocked")
|
|
69
|
+
.map(([id]) => id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Coverage invariant ---
|
|
73
|
+
|
|
74
|
+
const ASSERTION_ID_PATTERN = /\*\*((?:VAL|ASSERT)[A-Z0-9_-]+)\*\*/g;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse assertion IDs from a validation contract markdown.
|
|
78
|
+
* Matches bold-wrapped IDs like **VAL-AUTH-001** or **ASSERT01**.
|
|
79
|
+
*/
|
|
80
|
+
export function parseAssertionIds(contract: string): string[] {
|
|
81
|
+
const ids = new Set<string>();
|
|
82
|
+
let match: RegExpExecArray | null;
|
|
83
|
+
while ((match = ASSERTION_ID_PATTERN.exec(contract)) !== null) {
|
|
84
|
+
ids.add(match[1]!);
|
|
85
|
+
}
|
|
86
|
+
return [...ids];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check coverage: every assertion in the contract should be claimed
|
|
91
|
+
* by at least one task's fulfills.
|
|
92
|
+
*/
|
|
93
|
+
export function checkCoverage(dir: string): {
|
|
94
|
+
unclaimed: string[];
|
|
95
|
+
duplicates: Record<string, string[]>;
|
|
96
|
+
} {
|
|
97
|
+
const contract = loadValidationContract(dir);
|
|
98
|
+
if (!contract) return { unclaimed: [], duplicates: {} };
|
|
99
|
+
|
|
100
|
+
const assertionIds = parseAssertionIds(contract);
|
|
101
|
+
if (assertionIds.length === 0) return { unclaimed: [], duplicates: {} };
|
|
102
|
+
|
|
103
|
+
const tasks = loadTasks(dir);
|
|
104
|
+
const claimMap = new Map<string, string[]>();
|
|
105
|
+
|
|
106
|
+
for (const task of tasks) {
|
|
107
|
+
for (const id of task.fulfills) {
|
|
108
|
+
const existing = claimMap.get(id) ?? [];
|
|
109
|
+
existing.push(task.id);
|
|
110
|
+
claimMap.set(id, existing);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const unclaimed = assertionIds.filter((id) => !claimMap.has(id));
|
|
115
|
+
const duplicates: Record<string, string[]> = {};
|
|
116
|
+
for (const [id, taskIds] of claimMap) {
|
|
117
|
+
if (taskIds.length > 1 && assertionIds.includes(id)) {
|
|
118
|
+
duplicates[id] = taskIds;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { unclaimed, duplicates };
|
|
123
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import type { OrchestratorEvent, StructuredEvent } from "./event-log.ts";
|
|
3
|
+
|
|
4
|
+
export interface WebhookConfig {
|
|
5
|
+
url: string;
|
|
6
|
+
events?: string[];
|
|
7
|
+
secret?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fire webhooks for an event. Fire-and-forget — never blocks the caller.
|
|
12
|
+
* Failures are silently ignored (logged only in debug mode).
|
|
13
|
+
*/
|
|
14
|
+
export function fireWebhooks(
|
|
15
|
+
webhooks: WebhookConfig[],
|
|
16
|
+
event: OrchestratorEvent | StructuredEvent,
|
|
17
|
+
): void {
|
|
18
|
+
const eventType = event.type;
|
|
19
|
+
|
|
20
|
+
for (const hook of webhooks) {
|
|
21
|
+
// Filter by event type if configured
|
|
22
|
+
if (hook.events && hook.events.length > 0 && !hook.events.includes(eventType)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const body = JSON.stringify(event);
|
|
27
|
+
const headers: Record<string, string> = {
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
"User-Agent": "tmux-ide-webhook/1.0",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// HMAC-SHA256 signing
|
|
33
|
+
if (hook.secret) {
|
|
34
|
+
const signature = createHmac("sha256", hook.secret).update(body).digest("hex");
|
|
35
|
+
headers["X-Signature-256"] = `sha256=${signature}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Fire-and-forget with 5s timeout
|
|
39
|
+
fetch(hook.url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers,
|
|
42
|
+
body,
|
|
43
|
+
signal: AbortSignal.timeout(5000),
|
|
44
|
+
}).catch(() => {
|
|
45
|
+
// Silently ignore webhook failures
|
|
46
|
+
if (process.env.TMUX_IDE_DEBUG === "1") {
|
|
47
|
+
process.stderr.write(`Webhook failed: ${hook.url}\n`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|