@danielblomma/cortex-mcp 1.7.2 → 2.0.3
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 +4 -24
- package/bin/cortex.mjs +679 -32
- package/bin/style.mjs +349 -0
- package/package.json +4 -3
- package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
- package/scaffold/mcp/src/cli/govern.ts +987 -0
- package/scaffold/mcp/src/cli/run.ts +306 -0
- package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
- package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
- package/scaffold/mcp/src/core/audit/query.ts +81 -0
- package/scaffold/mcp/src/core/audit/writer.ts +68 -0
- package/scaffold/mcp/src/core/config.ts +329 -0
- package/scaffold/mcp/src/core/index.ts +34 -0
- package/scaffold/mcp/src/core/license.ts +202 -0
- package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
- package/scaffold/mcp/src/core/policy/injection.ts +229 -0
- package/scaffold/mcp/src/core/policy/store.ts +197 -0
- package/scaffold/mcp/src/core/rbac/check.ts +40 -0
- package/scaffold/mcp/src/core/telemetry/collector.ts +408 -0
- package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
- package/scaffold/mcp/src/core/validators/config.ts +47 -0
- package/scaffold/mcp/src/core/validators/engine.ts +199 -0
- package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
- package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
- package/scaffold/mcp/src/daemon/client.ts +155 -0
- package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
- package/scaffold/mcp/src/daemon/main.ts +435 -0
- package/scaffold/mcp/src/daemon/paths.ts +41 -0
- package/scaffold/mcp/src/daemon/protocol.ts +101 -0
- package/scaffold/mcp/src/daemon/server.ts +227 -0
- package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
- package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
- package/scaffold/mcp/src/enterprise/index.ts +386 -0
- package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
- package/scaffold/mcp/src/enterprise/privacy/boundary.ts +214 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +73 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
- package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
- package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
- package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
- package/scaffold/mcp/src/hooks/session-end.ts +73 -0
- package/scaffold/mcp/src/hooks/session-start.ts +78 -0
- package/scaffold/mcp/src/hooks/shared.ts +134 -0
- package/scaffold/mcp/src/hooks/stop.ts +60 -0
- package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
- package/scaffold/mcp/src/loadGraph.ts +2 -0
- package/scaffold/mcp/src/plugin.ts +150 -0
- package/scaffold/mcp/src/server.ts +218 -7
- package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
- package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
- package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
- package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
- package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
- package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
- package/scaffold/mcp/tests/govern.test.mjs +74 -0
- package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
- package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
- package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
- package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
- package/scaffold/mcp/tests/run.test.mjs +109 -0
- package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
- package/scaffold/mcp/tests/telemetry-collector.test.mjs +30 -0
- package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
- package/scaffold/scripts/bootstrap.sh +0 -11
- package/scaffold/scripts/doctor.sh +24 -4
- package/types.js +5 -0
- package/docs/MCP_MARKETPLACE.md +0 -160
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type {
|
|
9
|
+
ReviewOutput,
|
|
10
|
+
ReviewResult,
|
|
11
|
+
ReviewSummary,
|
|
12
|
+
} from "../../core/validators/engine.js";
|
|
13
|
+
|
|
14
|
+
export type WorkflowPhase =
|
|
15
|
+
| "planning"
|
|
16
|
+
| "plan_review"
|
|
17
|
+
| "implementation_pending"
|
|
18
|
+
| "implementation"
|
|
19
|
+
| "iterating"
|
|
20
|
+
| "reviewed"
|
|
21
|
+
| "approved";
|
|
22
|
+
|
|
23
|
+
export type WorkflowPlanStatus =
|
|
24
|
+
| "missing"
|
|
25
|
+
| "pending_review"
|
|
26
|
+
| "changes_requested"
|
|
27
|
+
| "approved";
|
|
28
|
+
|
|
29
|
+
export type WorkflowReviewStatus = "not_run" | "failed" | "passed";
|
|
30
|
+
export type WorkflowApprovalStatus = "blocked" | "ready" | "approved";
|
|
31
|
+
|
|
32
|
+
export type WorkflowBlocker = {
|
|
33
|
+
code:
|
|
34
|
+
| "plan_missing"
|
|
35
|
+
| "plan_not_approved"
|
|
36
|
+
| "implementation_not_started"
|
|
37
|
+
| "code_review_required"
|
|
38
|
+
| "review_failed";
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type WorkflowPlan = {
|
|
43
|
+
title: string | null;
|
|
44
|
+
summary: string | null;
|
|
45
|
+
tasks: string[];
|
|
46
|
+
status: WorkflowPlanStatus;
|
|
47
|
+
updated_at: string | null;
|
|
48
|
+
reviewed_at: string | null;
|
|
49
|
+
review_notes: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type WorkflowReviewSnapshot = {
|
|
53
|
+
status: WorkflowReviewStatus;
|
|
54
|
+
scope: "all" | "changed" | null;
|
|
55
|
+
reviewed_at: string | null;
|
|
56
|
+
artifact_path: string | null;
|
|
57
|
+
summary: ReviewSummary | null;
|
|
58
|
+
failed_policies: string[];
|
|
59
|
+
warning_policies: string[];
|
|
60
|
+
reviewed_files: WorkflowReviewedFileSnapshot[] | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type WorkflowApproval = {
|
|
64
|
+
status: WorkflowApprovalStatus;
|
|
65
|
+
approved_at: string | null;
|
|
66
|
+
notes: string | null;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type WorkflowNote = {
|
|
70
|
+
id: number;
|
|
71
|
+
title: string;
|
|
72
|
+
details: string;
|
|
73
|
+
created_at: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type WorkflowTodo = {
|
|
77
|
+
id: number;
|
|
78
|
+
title: string;
|
|
79
|
+
details: string;
|
|
80
|
+
status: "open" | "done";
|
|
81
|
+
created_at: string;
|
|
82
|
+
updated_at: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type WorkflowHistoryEntry = {
|
|
86
|
+
at: string;
|
|
87
|
+
event:
|
|
88
|
+
| "plan_set"
|
|
89
|
+
| "plan_reviewed"
|
|
90
|
+
| "implementation_started"
|
|
91
|
+
| "review_recorded"
|
|
92
|
+
| "workflow_updated"
|
|
93
|
+
| "workflow_approved"
|
|
94
|
+
| "note_added"
|
|
95
|
+
| "todo_added"
|
|
96
|
+
| "todo_completed";
|
|
97
|
+
details?: Record<string, unknown>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type WorkflowState = {
|
|
101
|
+
version: 1;
|
|
102
|
+
created_at: string;
|
|
103
|
+
updated_at: string;
|
|
104
|
+
phase: WorkflowPhase;
|
|
105
|
+
blocked_reasons: WorkflowBlocker[];
|
|
106
|
+
plan: WorkflowPlan;
|
|
107
|
+
last_review: WorkflowReviewSnapshot;
|
|
108
|
+
approval: WorkflowApproval;
|
|
109
|
+
next_note_id: number;
|
|
110
|
+
next_todo_id: number;
|
|
111
|
+
notes: WorkflowNote[];
|
|
112
|
+
todos: WorkflowTodo[];
|
|
113
|
+
history: WorkflowHistoryEntry[];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type WorkflowMutationResult = {
|
|
117
|
+
ok: boolean;
|
|
118
|
+
state: WorkflowState;
|
|
119
|
+
error?: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type WorkflowReviewArtifact = {
|
|
123
|
+
recorded_at: string;
|
|
124
|
+
scope: "all" | "changed";
|
|
125
|
+
summary: ReviewSummary;
|
|
126
|
+
results: ReviewResult[];
|
|
127
|
+
reviewed_files: WorkflowReviewedFileSnapshot[] | null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export type WorkflowReviewedFileSnapshot = {
|
|
131
|
+
path: string;
|
|
132
|
+
exists: boolean;
|
|
133
|
+
hash: string | null;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function nowIso(): string {
|
|
137
|
+
return new Date().toISOString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function workflowDir(contextDir: string): string {
|
|
141
|
+
return join(contextDir, "workflow");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function workflowStatePath(contextDir: string): string {
|
|
145
|
+
return join(workflowDir(contextDir), "state.json");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function workflowReviewsDir(contextDir: string): string {
|
|
149
|
+
return join(workflowDir(contextDir), "reviews");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function initialState(): WorkflowState {
|
|
153
|
+
const now = nowIso();
|
|
154
|
+
return {
|
|
155
|
+
version: 1,
|
|
156
|
+
created_at: now,
|
|
157
|
+
updated_at: now,
|
|
158
|
+
phase: "planning",
|
|
159
|
+
blocked_reasons: [
|
|
160
|
+
{
|
|
161
|
+
code: "plan_missing",
|
|
162
|
+
message: "A plan must be created before implementation can begin.",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
code: "code_review_required",
|
|
166
|
+
message: "A passing code review is required before approval.",
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
plan: {
|
|
170
|
+
title: null,
|
|
171
|
+
summary: null,
|
|
172
|
+
tasks: [],
|
|
173
|
+
status: "missing",
|
|
174
|
+
updated_at: null,
|
|
175
|
+
reviewed_at: null,
|
|
176
|
+
review_notes: null,
|
|
177
|
+
},
|
|
178
|
+
last_review: {
|
|
179
|
+
status: "not_run",
|
|
180
|
+
scope: null,
|
|
181
|
+
reviewed_at: null,
|
|
182
|
+
artifact_path: null,
|
|
183
|
+
summary: null,
|
|
184
|
+
failed_policies: [],
|
|
185
|
+
warning_policies: [],
|
|
186
|
+
reviewed_files: null,
|
|
187
|
+
},
|
|
188
|
+
approval: {
|
|
189
|
+
status: "blocked",
|
|
190
|
+
approved_at: null,
|
|
191
|
+
notes: null,
|
|
192
|
+
},
|
|
193
|
+
next_note_id: 1,
|
|
194
|
+
next_todo_id: 1,
|
|
195
|
+
notes: [],
|
|
196
|
+
todos: [],
|
|
197
|
+
history: [],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function ensureWorkflowDirs(contextDir: string): void {
|
|
202
|
+
mkdirSync(workflowReviewsDir(contextDir), { recursive: true });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function clampHistory(history: WorkflowHistoryEntry[]): WorkflowHistoryEntry[] {
|
|
206
|
+
return history.slice(-100);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function blockersFor(state: WorkflowState): WorkflowBlocker[] {
|
|
210
|
+
const blockers: WorkflowBlocker[] = [];
|
|
211
|
+
|
|
212
|
+
if (!state.plan.title || state.plan.status === "missing") {
|
|
213
|
+
blockers.push({
|
|
214
|
+
code: "plan_missing",
|
|
215
|
+
message: "A plan must be created before implementation can begin.",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (state.plan.status !== "approved") {
|
|
220
|
+
blockers.push({
|
|
221
|
+
code: "plan_not_approved",
|
|
222
|
+
message: "The plan must be reviewed and approved before approval.",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!state.history.some((entry) => entry.event === "implementation_started")) {
|
|
227
|
+
blockers.push({
|
|
228
|
+
code: "implementation_not_started",
|
|
229
|
+
message: "Implementation has not started yet.",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (state.last_review.status === "not_run") {
|
|
234
|
+
blockers.push({
|
|
235
|
+
code: "code_review_required",
|
|
236
|
+
message: "A passing code review is required before approval.",
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (state.last_review.status === "failed") {
|
|
241
|
+
blockers.push({
|
|
242
|
+
code: "review_failed",
|
|
243
|
+
message: "The latest code review failed and must be resolved before approval.",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return blockers;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function withRecalculatedApproval(
|
|
251
|
+
state: WorkflowState,
|
|
252
|
+
preserveApproved = false
|
|
253
|
+
): WorkflowState {
|
|
254
|
+
const blockers = blockersFor(state);
|
|
255
|
+
state.blocked_reasons = blockers;
|
|
256
|
+
|
|
257
|
+
if (blockers.length > 0) {
|
|
258
|
+
state.approval = {
|
|
259
|
+
status: "blocked",
|
|
260
|
+
approved_at: null,
|
|
261
|
+
notes: null,
|
|
262
|
+
};
|
|
263
|
+
return state;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (preserveApproved && state.approval.status === "approved") {
|
|
267
|
+
state.approval.status = "approved";
|
|
268
|
+
return state;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
state.approval = {
|
|
272
|
+
status: "ready",
|
|
273
|
+
approved_at: null,
|
|
274
|
+
notes: null,
|
|
275
|
+
};
|
|
276
|
+
return state;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function writeState(
|
|
280
|
+
contextDir: string,
|
|
281
|
+
state: WorkflowState,
|
|
282
|
+
touchUpdatedAt = true
|
|
283
|
+
): WorkflowState {
|
|
284
|
+
ensureWorkflowDirs(contextDir);
|
|
285
|
+
if (touchUpdatedAt) {
|
|
286
|
+
state.updated_at = nowIso();
|
|
287
|
+
}
|
|
288
|
+
state.history = clampHistory(state.history);
|
|
289
|
+
writeFileSync(workflowStatePath(contextDir), `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
290
|
+
return state;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function loadWorkflowState(contextDir: string): WorkflowState {
|
|
294
|
+
ensureWorkflowDirs(contextDir);
|
|
295
|
+
const statePath = workflowStatePath(contextDir);
|
|
296
|
+
if (!existsSync(statePath)) {
|
|
297
|
+
return writeState(contextDir, initialState());
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf8")) as WorkflowState;
|
|
302
|
+
return withRecalculatedApproval(parsed, true);
|
|
303
|
+
} catch {
|
|
304
|
+
return writeState(contextDir, initialState());
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function addHistory(
|
|
309
|
+
state: WorkflowState,
|
|
310
|
+
event: WorkflowHistoryEntry["event"],
|
|
311
|
+
details?: Record<string, unknown>
|
|
312
|
+
): void {
|
|
313
|
+
state.history.push({
|
|
314
|
+
at: nowIso(),
|
|
315
|
+
event,
|
|
316
|
+
details,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function setWorkflowPlan(
|
|
321
|
+
contextDir: string,
|
|
322
|
+
input: { title: string; summary: string; tasks?: string[] }
|
|
323
|
+
): WorkflowState {
|
|
324
|
+
const state = loadWorkflowState(contextDir);
|
|
325
|
+
state.phase = "plan_review";
|
|
326
|
+
state.plan = {
|
|
327
|
+
title: input.title,
|
|
328
|
+
summary: input.summary,
|
|
329
|
+
tasks: input.tasks?.filter(Boolean) ?? [],
|
|
330
|
+
status: "pending_review",
|
|
331
|
+
updated_at: nowIso(),
|
|
332
|
+
reviewed_at: null,
|
|
333
|
+
review_notes: null,
|
|
334
|
+
};
|
|
335
|
+
state.last_review = {
|
|
336
|
+
status: "not_run",
|
|
337
|
+
scope: null,
|
|
338
|
+
reviewed_at: null,
|
|
339
|
+
artifact_path: null,
|
|
340
|
+
summary: null,
|
|
341
|
+
failed_policies: [],
|
|
342
|
+
warning_policies: [],
|
|
343
|
+
reviewed_files: null,
|
|
344
|
+
};
|
|
345
|
+
addHistory(state, "plan_set", {
|
|
346
|
+
title: input.title,
|
|
347
|
+
task_count: state.plan.tasks.length,
|
|
348
|
+
});
|
|
349
|
+
return writeState(contextDir, withRecalculatedApproval(state));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function reviewWorkflowPlan(
|
|
353
|
+
contextDir: string,
|
|
354
|
+
input: { approved: boolean; notes?: string }
|
|
355
|
+
): WorkflowMutationResult {
|
|
356
|
+
const state = loadWorkflowState(contextDir);
|
|
357
|
+
if (!state.plan.title) {
|
|
358
|
+
return { ok: false, state, error: "No plan exists to review" };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
state.plan.status = input.approved ? "approved" : "changes_requested";
|
|
362
|
+
state.plan.reviewed_at = nowIso();
|
|
363
|
+
state.plan.review_notes = input.notes?.trim() || null;
|
|
364
|
+
state.phase = input.approved ? "implementation_pending" : "planning";
|
|
365
|
+
addHistory(state, "plan_reviewed", {
|
|
366
|
+
approved: input.approved,
|
|
367
|
+
});
|
|
368
|
+
return { ok: true, state: writeState(contextDir, withRecalculatedApproval(state)) };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function startWorkflowImplementation(
|
|
372
|
+
contextDir: string
|
|
373
|
+
): WorkflowMutationResult {
|
|
374
|
+
const state = loadWorkflowState(contextDir);
|
|
375
|
+
if (state.plan.status !== "approved") {
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
state,
|
|
379
|
+
error: "Plan must be approved before implementation can start",
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
state.phase = "implementation";
|
|
384
|
+
addHistory(state, "implementation_started");
|
|
385
|
+
return { ok: true, state: writeState(contextDir, withRecalculatedApproval(state)) };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function recordWorkflowReview(
|
|
389
|
+
contextDir: string,
|
|
390
|
+
input: {
|
|
391
|
+
scope: "all" | "changed";
|
|
392
|
+
output: ReviewOutput;
|
|
393
|
+
reviewed_files?: WorkflowReviewedFileSnapshot[] | null;
|
|
394
|
+
}
|
|
395
|
+
): WorkflowState {
|
|
396
|
+
const state = loadWorkflowState(contextDir);
|
|
397
|
+
const recordedAt = nowIso();
|
|
398
|
+
const blockingFailures = input.output.results.filter(
|
|
399
|
+
(result) => !result.pass && result.severity === "error"
|
|
400
|
+
);
|
|
401
|
+
const warningFailures = input.output.results.filter(
|
|
402
|
+
(result) => !result.pass && result.severity === "warning"
|
|
403
|
+
);
|
|
404
|
+
const hasBlockingFailures = blockingFailures.length > 0;
|
|
405
|
+
const fileName = `review-${recordedAt.replace(/[:.]/g, "-")}.json`;
|
|
406
|
+
const relativeArtifactPath = `.context/workflow/reviews/${fileName}`;
|
|
407
|
+
const artifact: WorkflowReviewArtifact = {
|
|
408
|
+
recorded_at: recordedAt,
|
|
409
|
+
scope: input.scope,
|
|
410
|
+
summary: input.output.summary,
|
|
411
|
+
results: input.output.results,
|
|
412
|
+
reviewed_files: input.reviewed_files ?? null,
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
ensureWorkflowDirs(contextDir);
|
|
416
|
+
writeFileSync(
|
|
417
|
+
join(workflowReviewsDir(contextDir), fileName),
|
|
418
|
+
`${JSON.stringify(artifact, null, 2)}\n`,
|
|
419
|
+
"utf8"
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
state.last_review = {
|
|
423
|
+
status: hasBlockingFailures ? "failed" : "passed",
|
|
424
|
+
scope: input.scope,
|
|
425
|
+
reviewed_at: recordedAt,
|
|
426
|
+
artifact_path: relativeArtifactPath,
|
|
427
|
+
summary: input.output.summary,
|
|
428
|
+
failed_policies: blockingFailures.map((result) => result.policy_id),
|
|
429
|
+
warning_policies: warningFailures.map((result) => result.policy_id),
|
|
430
|
+
reviewed_files: input.reviewed_files ?? null,
|
|
431
|
+
};
|
|
432
|
+
state.phase = hasBlockingFailures ? "iterating" : "reviewed";
|
|
433
|
+
addHistory(state, "review_recorded", {
|
|
434
|
+
scope: input.scope,
|
|
435
|
+
total: input.output.summary.total,
|
|
436
|
+
failed: blockingFailures.length,
|
|
437
|
+
warnings: warningFailures.length,
|
|
438
|
+
});
|
|
439
|
+
return writeState(contextDir, withRecalculatedApproval(state));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function recordWorkflowUpdate(
|
|
443
|
+
contextDir: string,
|
|
444
|
+
input: { summary: string; phase?: "implementation" | "iterating" | "plan_review" }
|
|
445
|
+
): WorkflowState {
|
|
446
|
+
const state = loadWorkflowState(contextDir);
|
|
447
|
+
if (input.phase) {
|
|
448
|
+
state.phase = input.phase;
|
|
449
|
+
}
|
|
450
|
+
addHistory(state, "workflow_updated", {
|
|
451
|
+
summary: input.summary,
|
|
452
|
+
phase: input.phase ?? state.phase,
|
|
453
|
+
});
|
|
454
|
+
return writeState(
|
|
455
|
+
contextDir,
|
|
456
|
+
withRecalculatedApproval(state, input.phase === undefined)
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function addWorkflowNote(
|
|
461
|
+
contextDir: string,
|
|
462
|
+
input: { title: string; details: string }
|
|
463
|
+
): WorkflowState {
|
|
464
|
+
const state = loadWorkflowState(contextDir);
|
|
465
|
+
const note: WorkflowNote = {
|
|
466
|
+
id: state.next_note_id++,
|
|
467
|
+
title: input.title,
|
|
468
|
+
details: input.details,
|
|
469
|
+
created_at: nowIso(),
|
|
470
|
+
};
|
|
471
|
+
state.notes.push(note);
|
|
472
|
+
addHistory(state, "note_added", { note_id: note.id, title: note.title });
|
|
473
|
+
return writeState(contextDir, withRecalculatedApproval(state, true));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function addWorkflowTodo(
|
|
477
|
+
contextDir: string,
|
|
478
|
+
input: { title: string; details?: string }
|
|
479
|
+
): WorkflowState {
|
|
480
|
+
const state = loadWorkflowState(contextDir);
|
|
481
|
+
const now = nowIso();
|
|
482
|
+
const todo: WorkflowTodo = {
|
|
483
|
+
id: state.next_todo_id++,
|
|
484
|
+
title: input.title,
|
|
485
|
+
details: input.details?.trim() || "",
|
|
486
|
+
status: "open",
|
|
487
|
+
created_at: now,
|
|
488
|
+
updated_at: now,
|
|
489
|
+
};
|
|
490
|
+
state.todos.push(todo);
|
|
491
|
+
addHistory(state, "todo_added", { todo_id: todo.id, title: todo.title });
|
|
492
|
+
return writeState(contextDir, withRecalculatedApproval(state, true));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function completeWorkflowTodo(
|
|
496
|
+
contextDir: string,
|
|
497
|
+
todoId: number
|
|
498
|
+
): WorkflowMutationResult {
|
|
499
|
+
const state = loadWorkflowState(contextDir);
|
|
500
|
+
const todo = state.todos.find((item) => item.id === todoId);
|
|
501
|
+
if (!todo) {
|
|
502
|
+
return { ok: false, state, error: `Todo ${todoId} was not found` };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
todo.status = "done";
|
|
506
|
+
todo.updated_at = nowIso();
|
|
507
|
+
addHistory(state, "todo_completed", { todo_id: todo.id });
|
|
508
|
+
return { ok: true, state: writeState(contextDir, withRecalculatedApproval(state, true)) };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function approveWorkflow(
|
|
512
|
+
contextDir: string,
|
|
513
|
+
notes?: string
|
|
514
|
+
): WorkflowMutationResult {
|
|
515
|
+
const state = loadWorkflowState(contextDir);
|
|
516
|
+
const blockers = blockersFor(state);
|
|
517
|
+
if (blockers.length > 0) {
|
|
518
|
+
const refreshed = writeState(contextDir, withRecalculatedApproval(state));
|
|
519
|
+
return {
|
|
520
|
+
ok: false,
|
|
521
|
+
state: refreshed,
|
|
522
|
+
error: blockers.map((blocker) => blocker.message).join(" "),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
state.phase = "approved";
|
|
527
|
+
state.blocked_reasons = [];
|
|
528
|
+
state.approval = {
|
|
529
|
+
status: "approved",
|
|
530
|
+
approved_at: nowIso(),
|
|
531
|
+
notes: notes?.trim() || null,
|
|
532
|
+
};
|
|
533
|
+
addHistory(state, "workflow_approved");
|
|
534
|
+
return { ok: true, state: writeState(contextDir, state) };
|
|
535
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { call } from "../daemon/client.js";
|
|
2
|
+
import type { AuditLogPayload, AuditLogResult } from "../daemon/protocol.js";
|
|
3
|
+
import {
|
|
4
|
+
ensureDaemon,
|
|
5
|
+
parseInput,
|
|
6
|
+
readStdin,
|
|
7
|
+
resolveDaemonEntry,
|
|
8
|
+
} from "./shared.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* PreCompact hook. Fires just before Claude Code compresses session
|
|
12
|
+
* context. Ideal moment to snapshot session-summary for audit so we
|
|
13
|
+
* preserve a record of activity that's about to be truncated from
|
|
14
|
+
* Claude's working memory.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type ClaudePreCompactInput = {
|
|
18
|
+
session_id?: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
// Claude Code may send context size hints; we record what we get.
|
|
21
|
+
context_size_tokens?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function main(): Promise<void> {
|
|
25
|
+
const raw = await readStdin();
|
|
26
|
+
const input = parseInput(raw) as ClaudePreCompactInput;
|
|
27
|
+
const cwd = input.cwd ?? process.cwd();
|
|
28
|
+
|
|
29
|
+
ensureDaemon(resolveDaemonEntry(import.meta.url));
|
|
30
|
+
|
|
31
|
+
const payload: AuditLogPayload = {
|
|
32
|
+
cwd,
|
|
33
|
+
entry: {
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
tool: "session.pre_compact",
|
|
36
|
+
input: {
|
|
37
|
+
session_id: input.session_id ?? null,
|
|
38
|
+
context_size_tokens: input.context_size_tokens ?? null,
|
|
39
|
+
},
|
|
40
|
+
event_type: "session",
|
|
41
|
+
evidence_level: "diagnostic",
|
|
42
|
+
resource_type: "session",
|
|
43
|
+
session_id: input.session_id,
|
|
44
|
+
metadata: {
|
|
45
|
+
context_size_tokens: input.context_size_tokens ?? null,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
await call<AuditLogResult>("audit.log", payload, { timeoutMs: 3000 });
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { call } from "../daemon/client.js";
|
|
2
|
+
import type { PolicyCheckPayload, PolicyCheckResult } from "../daemon/protocol.js";
|
|
3
|
+
import {
|
|
4
|
+
ensureDaemon,
|
|
5
|
+
isEnterpriseProject,
|
|
6
|
+
parseInput,
|
|
7
|
+
readStdin,
|
|
8
|
+
resolveDaemonEntry,
|
|
9
|
+
sendHeartbeat,
|
|
10
|
+
} from "./shared.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PreToolUse hook for Claude Code.
|
|
14
|
+
*
|
|
15
|
+
* Reads the tool invocation from stdin, asks the daemon if policy permits,
|
|
16
|
+
* exits 0 (allow) or 2 (block).
|
|
17
|
+
*
|
|
18
|
+
* Failure modes (Alt A, beslutat 2026-04-30):
|
|
19
|
+
* community + daemon down → fail-open (exit 0)
|
|
20
|
+
* enterprise + daemon down → fail-closed (exit 2)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type ClaudePreToolUseInput = {
|
|
24
|
+
tool_name?: string;
|
|
25
|
+
tool_input?: Record<string, unknown>;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
session_id?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function main(): Promise<void> {
|
|
31
|
+
const raw = await readStdin();
|
|
32
|
+
const input = parseInput(raw) as ClaudePreToolUseInput;
|
|
33
|
+
const cwd = input.cwd || process.cwd();
|
|
34
|
+
const tool = input.tool_name || "unknown";
|
|
35
|
+
const enterprise = isEnterpriseProject(cwd);
|
|
36
|
+
|
|
37
|
+
// Try to bring the daemon up if it's not already.
|
|
38
|
+
ensureDaemon(resolveDaemonEntry(import.meta.url));
|
|
39
|
+
|
|
40
|
+
if (input.session_id) {
|
|
41
|
+
void sendHeartbeat({
|
|
42
|
+
cli: "claude",
|
|
43
|
+
hook: "PreToolUse",
|
|
44
|
+
session_id: input.session_id,
|
|
45
|
+
cwd,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const payload: PolicyCheckPayload = {
|
|
50
|
+
tool,
|
|
51
|
+
cwd,
|
|
52
|
+
input: input.tool_input ?? {},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const res = await call<PolicyCheckResult>("policy.check", payload, {
|
|
56
|
+
timeoutMs: 5000,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (res.ok) {
|
|
60
|
+
if (res.result.allow) {
|
|
61
|
+
process.exit(0);
|
|
62
|
+
} else {
|
|
63
|
+
// Hook spec: exit 2 + stderr message → Claude Code blocks the tool.
|
|
64
|
+
process.stderr.write(
|
|
65
|
+
`[cortex] Blocked by policy: ${res.result.reason ?? "unspecified"}\n`,
|
|
66
|
+
);
|
|
67
|
+
process.exit(2);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Daemon unreachable — apply split fail-mode.
|
|
72
|
+
if (enterprise) {
|
|
73
|
+
process.stderr.write(
|
|
74
|
+
`[cortex] Enterprise daemon unreachable (${res.error}). Blocking tool per fail-closed policy.\n`,
|
|
75
|
+
);
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
"[cortex] Start the daemon with: cortex daemon start\n",
|
|
78
|
+
);
|
|
79
|
+
process.exit(2);
|
|
80
|
+
} else {
|
|
81
|
+
process.stderr.write(
|
|
82
|
+
`[cortex] Daemon unreachable (${res.error}). Allowing tool (community mode).\n`,
|
|
83
|
+
);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
main().catch((err) => {
|
|
89
|
+
process.stderr.write(
|
|
90
|
+
`[cortex pre-tool-use] error: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
91
|
+
);
|
|
92
|
+
// Hook errors should not block the user — fail-open on internal exceptions
|
|
93
|
+
// regardless of mode. The split fail-mode applies only to the explicit
|
|
94
|
+
// "daemon unreachable" path above.
|
|
95
|
+
process.exit(0);
|
|
96
|
+
});
|