@neurcode-ai/governance-runtime 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +201 -0
  2. package/dist/admission-provenance.d.ts +111 -0
  3. package/dist/admission-provenance.d.ts.map +1 -0
  4. package/dist/admission-provenance.js +735 -0
  5. package/dist/admission-provenance.js.map +1 -0
  6. package/dist/agent-guard-posture.d.ts +40 -0
  7. package/dist/agent-guard-posture.d.ts.map +1 -0
  8. package/dist/agent-guard-posture.js +117 -0
  9. package/dist/agent-guard-posture.js.map +1 -0
  10. package/dist/agent-invocation-observability.d.ts +47 -0
  11. package/dist/agent-invocation-observability.d.ts.map +1 -0
  12. package/dist/agent-invocation-observability.js +229 -0
  13. package/dist/agent-invocation-observability.js.map +1 -0
  14. package/dist/agent-plan.d.ts +119 -0
  15. package/dist/agent-plan.d.ts.map +1 -0
  16. package/dist/agent-plan.js +565 -0
  17. package/dist/agent-plan.js.map +1 -0
  18. package/dist/agent-runtime-adapter.d.ts +69 -0
  19. package/dist/agent-runtime-adapter.d.ts.map +1 -0
  20. package/dist/agent-runtime-adapter.js +274 -0
  21. package/dist/agent-runtime-adapter.js.map +1 -0
  22. package/dist/ai-change-record.d.ts +185 -0
  23. package/dist/ai-change-record.d.ts.map +1 -0
  24. package/dist/ai-change-record.js +580 -0
  25. package/dist/ai-change-record.js.map +1 -0
  26. package/dist/architecture-graph.d.ts +153 -0
  27. package/dist/architecture-graph.d.ts.map +1 -0
  28. package/dist/architecture-graph.js +646 -0
  29. package/dist/architecture-graph.js.map +1 -0
  30. package/dist/architecture-obligations.d.ts +153 -0
  31. package/dist/architecture-obligations.d.ts.map +1 -0
  32. package/dist/architecture-obligations.js +505 -0
  33. package/dist/architecture-obligations.js.map +1 -0
  34. package/dist/index.d.ts +10 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +103 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/profile.d.ts +159 -0
  39. package/dist/profile.d.ts.map +1 -0
  40. package/dist/profile.js +611 -0
  41. package/dist/profile.js.map +1 -0
  42. package/dist/session.d.ts +428 -0
  43. package/dist/session.d.ts.map +1 -0
  44. package/dist/session.js +2052 -0
  45. package/dist/session.js.map +1 -0
  46. package/package.json +19 -8
  47. package/src/constraints.ts +0 -828
  48. package/src/index.test.ts +0 -502
  49. package/src/index.ts +0 -463
  50. package/tsconfig.json +0 -19
@@ -0,0 +1,2052 @@
1
+ "use strict";
2
+ /**
3
+ * V0 session store — lightweight JSON-file-backed governance session.
4
+ *
5
+ * One session per .neurcode/sessions/<id>.json.
6
+ * No daemon required; CLI commands and hooks read/write directly.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.DEFAULT_OBLIGATION_WAIVER_TTL_MS = exports.DEFAULT_APPROVAL_TTL_MS = void 0;
13
+ exports.sessionsDir = sessionsDir;
14
+ exports.sessionPath = sessionPath;
15
+ exports.createSession = createSession;
16
+ exports.loadActiveSession = loadActiveSession;
17
+ exports.loadSession = loadSession;
18
+ exports.appendEvent = appendEvent;
19
+ exports.refreshArchitectureObligations = refreshArchitectureObligations;
20
+ exports.activeApprovalPaths = activeApprovalPaths;
21
+ exports.expireSessionApprovals = expireSessionApprovals;
22
+ exports.expireArchitectureObligationWaivers = expireArchitectureObligationWaivers;
23
+ exports.waiveArchitectureObligation = waiveArchitectureObligation;
24
+ exports.approveSession = approveSession;
25
+ exports.revokeSessionApproval = revokeSessionApproval;
26
+ exports.finishSession = finishSession;
27
+ exports.replaySession = replaySession;
28
+ exports.evaluateIntentCoherence = evaluateIntentCoherence;
29
+ exports.attachAgentPlan = attachAgentPlan;
30
+ exports.classifyAgentPlanAmendment = classifyAgentPlanAmendment;
31
+ exports.captureAgentPlan = captureAgentPlan;
32
+ exports.amendAgentPlan = amendAgentPlan;
33
+ exports.decideAgentPlanAmendment = decideAgentPlanAmendment;
34
+ exports.evaluateSessionPlanCoherence = evaluateSessionPlanCoherence;
35
+ exports.evaluatePlanCoherencePolicy = evaluatePlanCoherencePolicy;
36
+ exports.activeAgentPlanRevision = activeAgentPlanRevision;
37
+ exports.buildPlanTimeline = buildPlanTimeline;
38
+ const node_crypto_1 = require("node:crypto");
39
+ const node_fs_1 = require("node:fs");
40
+ const node_path_1 = require("node:path");
41
+ const micromatch_1 = __importDefault(require("micromatch"));
42
+ const profile_1 = require("./profile");
43
+ const agent_plan_1 = require("./agent-plan");
44
+ const architecture_obligations_1 = require("./architecture-obligations");
45
+ const ai_change_record_1 = require("./ai-change-record");
46
+ exports.DEFAULT_APPROVAL_TTL_MS = 60 * 60 * 1000;
47
+ exports.DEFAULT_OBLIGATION_WAIVER_TTL_MS = 60 * 60 * 1000;
48
+ // ── Path helpers ──────────────────────────────────────────────────────────────
49
+ function sessionsDir(projectRoot) {
50
+ return (0, node_path_1.join)(projectRoot, '.neurcode', 'sessions');
51
+ }
52
+ function sessionPath(projectRoot, sessionId) {
53
+ return (0, node_path_1.join)(sessionsDir(projectRoot), `${sessionId}.json`);
54
+ }
55
+ function activePointerPath(projectRoot) {
56
+ return (0, node_path_1.join)(projectRoot, '.neurcode', 'active-session.json');
57
+ }
58
+ const SESSION_EVENT_LOCK_TIMEOUT_MS = 2_000;
59
+ const SESSION_EVENT_LOCK_STALE_MS = 30_000;
60
+ const SESSION_EVENT_LOCK_WAIT_MS = 10;
61
+ const SESSION_EVENT_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
62
+ function withSessionEventLock(projectRoot, sessionId, operation) {
63
+ const lockPath = `${sessionPath(projectRoot, sessionId)}.events.lock`;
64
+ (0, node_fs_1.mkdirSync)(sessionsDir(projectRoot), { recursive: true });
65
+ const deadline = Date.now() + SESSION_EVENT_LOCK_TIMEOUT_MS;
66
+ while (true) {
67
+ try {
68
+ (0, node_fs_1.mkdirSync)(lockPath);
69
+ break;
70
+ }
71
+ catch (error) {
72
+ const code = error && typeof error === 'object' && 'code' in error
73
+ ? String(error.code)
74
+ : '';
75
+ if (code !== 'EEXIST')
76
+ throw error;
77
+ try {
78
+ if (Date.now() - (0, node_fs_1.statSync)(lockPath).mtimeMs > SESSION_EVENT_LOCK_STALE_MS) {
79
+ (0, node_fs_1.rmSync)(lockPath, { recursive: true, force: true });
80
+ continue;
81
+ }
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ if (Date.now() >= deadline) {
87
+ throw new Error(`Timed out waiting to mutate session ${sessionId}.`);
88
+ }
89
+ Atomics.wait(SESSION_EVENT_LOCK_SLEEP, 0, 0, SESSION_EVENT_LOCK_WAIT_MS);
90
+ }
91
+ }
92
+ try {
93
+ return operation();
94
+ }
95
+ finally {
96
+ (0, node_fs_1.rmSync)(lockPath, { recursive: true, force: true });
97
+ }
98
+ }
99
+ // ── CRUD ──────────────────────────────────────────────────────────────────────
100
+ function createSession(projectRoot, profile, goal) {
101
+ const sessionId = (0, node_crypto_1.randomBytes)(6).toString('hex');
102
+ const dir = sessionsDir(projectRoot);
103
+ if (!(0, node_fs_1.existsSync)(dir))
104
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
105
+ const { allowedGlobs, scopeMode } = deriveAllowedGlobs(goal, profile);
106
+ const intentContract = deriveIntentContract(goal, profile, allowedGlobs, scopeMode);
107
+ const startedAt = new Date().toISOString();
108
+ const architectureObligationPolicy = (0, architecture_obligations_1.normalizeArchitectureObligationPolicy)(profile.runtimeConfig?.architectureObligations);
109
+ const architectureGraph = profile.architecture;
110
+ const architectureObligations = (0, architecture_obligations_1.deriveArchitectureObligations)({
111
+ goal,
112
+ intentContract,
113
+ graph: architectureGraph,
114
+ policy: architectureObligationPolicy,
115
+ now: startedAt,
116
+ });
117
+ const session = {
118
+ schemaVersion: 1,
119
+ sessionId,
120
+ profileHash: profile.profileHash,
121
+ repoName: profile.repo.name,
122
+ contract: {
123
+ goal,
124
+ allowedGlobs,
125
+ sensitiveGlobs: profile.sensitiveBoundaries.map((s) => s.glob),
126
+ approvalRequiredGlobs: profile.approvalRequiredPaths,
127
+ ownershipRules: profile.ownershipBoundaries,
128
+ scopeMode,
129
+ safeSupportGlobs: profile.runtimeConfig?.safeSupportGlobs ?? [],
130
+ ignoredGlobs: profile.runtimeConfig?.ignoredGlobs ?? [],
131
+ approvedPaths: [],
132
+ approvalGrants: [],
133
+ intentContract,
134
+ planCoherenceMode: profile.runtimeConfig?.planCoherence ?? profile_1.DEFAULT_PLAN_COHERENCE_MODE,
135
+ architectureObligations,
136
+ architectureObligationPolicy,
137
+ architectureObligationWaivers: [],
138
+ ...(architectureGraph ? { architectureGraph } : {}),
139
+ },
140
+ events: [
141
+ {
142
+ type: 'session_start',
143
+ ts: startedAt,
144
+ message: `Goal: ${goal}`,
145
+ detail: {
146
+ allowedGlobs,
147
+ scopeMode,
148
+ intentContract,
149
+ planCoherenceMode: profile.runtimeConfig?.planCoherence ?? profile_1.DEFAULT_PLAN_COHERENCE_MODE,
150
+ architectureObligations,
151
+ architectureObligationPolicy,
152
+ },
153
+ },
154
+ ],
155
+ status: 'active',
156
+ };
157
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
158
+ const neurcodeDir = (0, node_path_1.join)(projectRoot, '.neurcode');
159
+ if (!(0, node_fs_1.existsSync)(neurcodeDir))
160
+ (0, node_fs_1.mkdirSync)(neurcodeDir, { recursive: true });
161
+ (0, node_fs_1.writeFileSync)(activePointerPath(projectRoot), JSON.stringify({ sessionId }, null, 2) + '\n', 'utf8');
162
+ return session;
163
+ }
164
+ function loadActiveSession(projectRoot) {
165
+ const ptr = activePointerPath(projectRoot);
166
+ if (!(0, node_fs_1.existsSync)(ptr))
167
+ return null;
168
+ try {
169
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(ptr, 'utf8'));
170
+ if (!parsed.sessionId)
171
+ return null;
172
+ return loadSession(projectRoot, parsed.sessionId);
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ }
178
+ function loadSession(projectRoot, sessionId) {
179
+ const p = sessionPath(projectRoot, sessionId);
180
+ if (!(0, node_fs_1.existsSync)(p))
181
+ return null;
182
+ try {
183
+ return JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ function appendEvent(projectRoot, sessionId, event) {
190
+ return withSessionEventLock(projectRoot, sessionId, () => {
191
+ const session = loadSession(projectRoot, sessionId);
192
+ if (!session)
193
+ return null;
194
+ session.events.push(event);
195
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
196
+ return session;
197
+ });
198
+ }
199
+ function recomputeArchitectureObligations(session, now = new Date().toISOString()) {
200
+ const previous = session.contract.architectureObligations ?? [];
201
+ const next = (0, architecture_obligations_1.deriveArchitectureObligations)({
202
+ goal: session.contract.goal,
203
+ intentContract: session.contract.intentContract,
204
+ agentPlan: session.contract.agentPlan,
205
+ events: session.events,
206
+ approvedPaths: activeApprovalPaths(session.contract, now),
207
+ graph: session.contract.architectureGraph,
208
+ policy: session.contract.architectureObligationPolicy,
209
+ waivers: (0, architecture_obligations_1.activeArchitectureObligationWaivers)(session.contract.architectureObligationWaivers ?? [], now),
210
+ previous,
211
+ now,
212
+ });
213
+ session.contract.architectureObligations = next;
214
+ const previousById = new Map(previous.map((item) => [item.id, item]));
215
+ const transitions = next
216
+ .filter((item) => previousById.get(item.id)?.status !== item.status)
217
+ .map((item) => ({
218
+ id: item.id,
219
+ title: item.title,
220
+ severity: item.severity,
221
+ effectiveMode: item.effectiveMode ?? 'warn',
222
+ previousStatus: previousById.get(item.id)?.status ?? null,
223
+ status: item.status,
224
+ observedEvidence: item.observedEvidence,
225
+ waiver: item.waiver ?? null,
226
+ }));
227
+ if (transitions.length > 0) {
228
+ session.events.push({
229
+ type: 'obligation_state_changed',
230
+ ts: now,
231
+ message: `${transitions.length} architecture obligation state change${transitions.length === 1 ? '' : 's'}`,
232
+ detail: {
233
+ transitions,
234
+ architectureObligationSummary: (0, architecture_obligations_1.summarizeArchitectureObligations)(next),
235
+ },
236
+ });
237
+ }
238
+ return next;
239
+ }
240
+ function refreshArchitectureObligations(projectRoot, sessionId, now = new Date().toISOString()) {
241
+ const session = loadSession(projectRoot, sessionId);
242
+ if (!session)
243
+ return null;
244
+ recomputeArchitectureObligations(session, now);
245
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, session.sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
246
+ return session;
247
+ }
248
+ /**
249
+ * Append an explicit approval for a path/glob to the active (or named) session.
250
+ *
251
+ * Safety invariant: this does NOT expand approval beyond the given path/glob.
252
+ * Approving "src/billing/charge.py" does not approve "src/billing/**".
253
+ * The boundary check in checkFileBoundary enforces this by matching approvedPaths
254
+ * against the exact filePath.
255
+ */
256
+ /**
257
+ * Normalise an approval path to be repo-relative.
258
+ *
259
+ * Rules (in order):
260
+ * 1. Absolute globs under projectRoot — strip the repo prefix, keep the glob suffix.
261
+ * 2. Absolute globs NOT under projectRoot — rejected.
262
+ * 3. Relative globs — used as-is (strip leading / or ./).
263
+ * 4. Absolute exact paths under projectRoot — make repo-relative via path.relative().
264
+ * 5. Absolute exact paths NOT under projectRoot — rejected.
265
+ * 6. Relative exact paths — used as-is.
266
+ */
267
+ function normaliseApprovalPath(projectRoot, raw) {
268
+ const trimmed = raw.trim();
269
+ if (!trimmed)
270
+ throw new Error('approvedPath must not be empty.');
271
+ const isGlob = trimmed.includes('*') || trimmed.includes('?');
272
+ if (isGlob) {
273
+ if (!(0, node_path_1.isAbsolute)(trimmed)) {
274
+ // Relative glob — strip a leading ./ if present, nothing else
275
+ return trimmed.replace(/^\.\//, '');
276
+ }
277
+ // Absolute glob — resolve the repo root (symlinks), then check prefix.
278
+ // Globs cannot be passed to realpathSync (file doesn't exist), so we
279
+ // split at the first wildcard, realpath the concrete prefix, then
280
+ // reattach the glob suffix.
281
+ let absRepo;
282
+ try {
283
+ absRepo = (0, node_fs_1.realpathSync)(projectRoot);
284
+ }
285
+ catch {
286
+ absRepo = (0, node_path_1.resolve)(projectRoot);
287
+ }
288
+ // Extract the non-glob prefix (everything before the first * or ?)
289
+ const firstWild = trimmed.search(/[*?]/);
290
+ const concretePart = trimmed.slice(0, firstWild); // e.g. "/repo/src/billing/"
291
+ const globSuffix = trimmed.slice(firstWild); // e.g. "**"
292
+ // Resolve the concrete prefix, following symlinks where possible.
293
+ // realpathSync may fail if the directory doesn't exist; in that case
294
+ // fall back to plain resolve() and re-resolve the repo the same way.
295
+ let resolvedConcrete;
296
+ try {
297
+ resolvedConcrete = (0, node_fs_1.realpathSync)(concretePart.replace(/\/$/, '') || '/');
298
+ }
299
+ catch {
300
+ resolvedConcrete = (0, node_path_1.resolve)(concretePart);
301
+ absRepo = (0, node_path_1.resolve)(projectRoot);
302
+ }
303
+ if (!resolvedConcrete.startsWith(absRepo + '/') && resolvedConcrete !== absRepo) {
304
+ throw new Error(`Approval path "${trimmed}" is outside the repo root "${projectRoot}". ` +
305
+ `Only paths within the repo may be approved.`);
306
+ }
307
+ const relConcrete = (0, node_path_1.relative)(absRepo, resolvedConcrete); // e.g. "src/billing"
308
+ // Reattach the glob suffix; the concretePart may end with "/" which becomes
309
+ // part of relConcrete already, so normalise double-slashes.
310
+ const joined = relConcrete ? relConcrete + '/' + globSuffix : globSuffix;
311
+ return joined.replace(/\/\//g, '/');
312
+ }
313
+ // ── Exact (non-glob) path ─────────────────────────────────────────────────
314
+ if ((0, node_path_1.isAbsolute)(trimmed)) {
315
+ // Use realpathSync to resolve symlinks (e.g. /tmp → /private/tmp on macOS)
316
+ // so that the comparison is reliable on all platforms.
317
+ let absRepo;
318
+ let absPath;
319
+ try {
320
+ absRepo = (0, node_fs_1.realpathSync)(projectRoot);
321
+ }
322
+ catch {
323
+ absRepo = (0, node_path_1.resolve)(projectRoot);
324
+ }
325
+ try {
326
+ absPath = (0, node_fs_1.realpathSync)(trimmed);
327
+ }
328
+ catch {
329
+ // File may not exist yet — fall back to syntactic resolution
330
+ absPath = (0, node_path_1.resolve)(trimmed);
331
+ absRepo = (0, node_path_1.resolve)(projectRoot);
332
+ }
333
+ if (!absPath.startsWith(absRepo + '/') && absPath !== absRepo) {
334
+ throw new Error(`Approval path "${trimmed}" is outside the repo root "${projectRoot}". ` +
335
+ `Only paths within the repo may be approved.`);
336
+ }
337
+ const rel = (0, node_path_1.relative)(absRepo, absPath);
338
+ if (!rel || rel.startsWith('..')) {
339
+ throw new Error(`Could not make "${trimmed}" relative to repo root "${projectRoot}".`);
340
+ }
341
+ return rel;
342
+ }
343
+ // Already relative — remove a leading ./ if present
344
+ return trimmed.replace(/^\.\//, '');
345
+ }
346
+ function normaliseApprovalArgs(reasonOrOptions, sessionId) {
347
+ if (reasonOrOptions && typeof reasonOrOptions === 'object') {
348
+ return { ...reasonOrOptions };
349
+ }
350
+ return {
351
+ reason: reasonOrOptions,
352
+ sessionId,
353
+ };
354
+ }
355
+ function resolveApprovalExpiry(options, approvedAt) {
356
+ if (options.expiresAt === null || options.ttlMs === null)
357
+ return null;
358
+ if (typeof options.expiresAt === 'string' && options.expiresAt.trim()) {
359
+ const parsed = Date.parse(options.expiresAt);
360
+ if (!Number.isFinite(parsed))
361
+ throw new Error(`Invalid approval expiry "${options.expiresAt}".`);
362
+ return new Date(parsed).toISOString();
363
+ }
364
+ const ttlMs = typeof options.ttlMs === 'number' && Number.isFinite(options.ttlMs)
365
+ ? Math.max(0, Math.floor(options.ttlMs))
366
+ : exports.DEFAULT_APPROVAL_TTL_MS;
367
+ if (ttlMs === 0)
368
+ return new Date(Date.parse(approvedAt)).toISOString();
369
+ return new Date(Date.parse(approvedAt) + ttlMs).toISOString();
370
+ }
371
+ function approvalGrants(contract) {
372
+ return Array.isArray(contract.approvalGrants) ? contract.approvalGrants : [];
373
+ }
374
+ function isApprovalGrantActive(grant, checkedAt = new Date().toISOString()) {
375
+ if (!grant.path || grant.revokedAt)
376
+ return false;
377
+ if (!grant.expiresAt)
378
+ return true;
379
+ const expiresAtMs = Date.parse(grant.expiresAt);
380
+ const checkedAtMs = Date.parse(checkedAt);
381
+ if (!Number.isFinite(expiresAtMs) || !Number.isFinite(checkedAtMs))
382
+ return false;
383
+ return expiresAtMs > checkedAtMs;
384
+ }
385
+ function activeApprovalPaths(contract, checkedAt = new Date().toISOString()) {
386
+ const grants = approvalGrants(contract);
387
+ if (grants.length === 0)
388
+ return [...(contract.approvedPaths ?? [])];
389
+ return Array.from(new Set(grants
390
+ .filter((grant) => isApprovalGrantActive(grant, checkedAt))
391
+ .map((grant) => grant.path)));
392
+ }
393
+ function expireSessionApprovals(projectRoot, sessionId, checkedAt = new Date().toISOString()) {
394
+ const session = loadSession(projectRoot, sessionId);
395
+ if (!session)
396
+ return null;
397
+ const grants = approvalGrants(session.contract);
398
+ if (grants.length === 0)
399
+ return session;
400
+ const checkedAtMs = Date.parse(checkedAt);
401
+ const expired = grants.filter((grant) => {
402
+ if (!grant.expiresAt || grant.revokedAt)
403
+ return false;
404
+ const expiresAtMs = Date.parse(grant.expiresAt);
405
+ if (!Number.isFinite(expiresAtMs) || !Number.isFinite(checkedAtMs))
406
+ return false;
407
+ return expiresAtMs <= checkedAtMs;
408
+ });
409
+ if (expired.length === 0) {
410
+ const activePaths = activeApprovalPaths(session.contract, checkedAt);
411
+ if (JSON.stringify(activePaths) !==
412
+ JSON.stringify(session.contract.approvedPaths ?? [])) {
413
+ session.contract.approvedPaths = activePaths;
414
+ recomputeArchitectureObligations(session, checkedAt);
415
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, session.sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
416
+ }
417
+ return session;
418
+ }
419
+ const alreadyRecorded = new Set(session.events
420
+ .filter((event) => event.type === 'approval_decision' && event.decision === 'expired')
421
+ .map((event) => String(event.detail?.approvalEventId || '')));
422
+ for (const grant of expired) {
423
+ if (alreadyRecorded.has(grant.eventId))
424
+ continue;
425
+ session.events.push({
426
+ type: 'approval_decision',
427
+ ts: checkedAt,
428
+ filePath: grant.path,
429
+ decision: 'expired',
430
+ detail: {
431
+ reason: 'approval expired',
432
+ approvalEventId: grant.eventId,
433
+ expiresAt: grant.expiresAt,
434
+ },
435
+ });
436
+ }
437
+ session.contract.approvedPaths = activeApprovalPaths(session.contract, checkedAt);
438
+ recomputeArchitectureObligations(session, checkedAt);
439
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, session.sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
440
+ return session;
441
+ }
442
+ function resolveDecisionExpiry(expiresAt, ttlMs, decidedAt, defaultTtlMs, label) {
443
+ if (expiresAt === null || ttlMs === null)
444
+ return null;
445
+ if (typeof expiresAt === 'string' && expiresAt.trim()) {
446
+ const parsed = Date.parse(expiresAt);
447
+ if (!Number.isFinite(parsed))
448
+ throw new Error(`Invalid ${label} expiry "${expiresAt}".`);
449
+ return new Date(parsed).toISOString();
450
+ }
451
+ const ttl = typeof ttlMs === 'number' && Number.isFinite(ttlMs)
452
+ ? Math.max(0, Math.floor(ttlMs))
453
+ : defaultTtlMs;
454
+ if (ttl === 0)
455
+ return new Date(Date.parse(decidedAt)).toISOString();
456
+ return new Date(Date.parse(decidedAt) + ttl).toISOString();
457
+ }
458
+ function expireArchitectureObligationWaivers(projectRoot, sessionId, checkedAt = new Date().toISOString()) {
459
+ const session = loadSession(projectRoot, sessionId);
460
+ if (!session)
461
+ return null;
462
+ const waivers = Array.isArray(session.contract.architectureObligationWaivers)
463
+ ? session.contract.architectureObligationWaivers
464
+ : [];
465
+ if (waivers.length === 0)
466
+ return session;
467
+ const checkedAtMs = Date.parse(checkedAt);
468
+ const expired = waivers.filter((waiver) => {
469
+ if (!waiver.expiresAt || waiver.revokedAt)
470
+ return false;
471
+ const expiresAtMs = Date.parse(waiver.expiresAt);
472
+ if (!Number.isFinite(expiresAtMs) || !Number.isFinite(checkedAtMs))
473
+ return false;
474
+ return expiresAtMs <= checkedAtMs;
475
+ });
476
+ const alreadyRecorded = new Set(session.events
477
+ .filter((event) => event.type === 'obligation_waiver_decision' && event.decision === 'expired')
478
+ .map((event) => String(event.detail?.waiverEventId || '')));
479
+ let recorded = false;
480
+ for (const waiver of expired) {
481
+ if (alreadyRecorded.has(waiver.eventId))
482
+ continue;
483
+ recorded = true;
484
+ session.events.push({
485
+ type: 'obligation_waiver_decision',
486
+ ts: checkedAt,
487
+ decision: 'expired',
488
+ detail: {
489
+ obligationId: waiver.obligationId,
490
+ reason: 'obligation waiver expired',
491
+ waiverEventId: waiver.eventId,
492
+ expiresAt: waiver.expiresAt,
493
+ },
494
+ });
495
+ }
496
+ recomputeArchitectureObligations(session, checkedAt);
497
+ if (recorded || expired.length > 0) {
498
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, session.sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
499
+ }
500
+ return session;
501
+ }
502
+ function waiveArchitectureObligation(projectRoot, obligationId, options = {}) {
503
+ const id = obligationId.trim();
504
+ if (!id)
505
+ throw new Error('obligationId must not be empty.');
506
+ const session = options.sessionId
507
+ ? loadSession(projectRoot, options.sessionId)
508
+ : loadActiveSession(projectRoot);
509
+ if (!session)
510
+ throw new Error('No active session found. Run a task first.');
511
+ if (session.status !== 'active')
512
+ throw new Error(`Session ${session.sessionId} is already finished.`);
513
+ recomputeArchitectureObligations(session, options.waivedAt || new Date().toISOString());
514
+ const target = session.contract.architectureObligations?.find((item) => item.id === id);
515
+ if (!target)
516
+ throw new Error(`Architecture obligation not found: ${id}`);
517
+ if (target.status === 'satisfied')
518
+ throw new Error(`Architecture obligation is already satisfied: ${id}`);
519
+ const waivedAt = options.waivedAt || new Date().toISOString();
520
+ const expiresAt = resolveDecisionExpiry(options.expiresAt, options.ttlMs, waivedAt, exports.DEFAULT_OBLIGATION_WAIVER_TTL_MS, 'obligation waiver');
521
+ const eventId = `obligation_waiver_${Date.now()}_${(0, node_crypto_1.randomBytes)(3).toString('hex')}`;
522
+ const waiver = {
523
+ obligationId: id,
524
+ reason: options.reason?.trim() || 'human accepted obligation risk in session',
525
+ waivedAt,
526
+ expiresAt,
527
+ source: options.source || 'local_cli',
528
+ eventId,
529
+ waivedBy: options.waivedBy || null,
530
+ };
531
+ const existingWaivers = Array.isArray(session.contract.architectureObligationWaivers)
532
+ ? session.contract.architectureObligationWaivers
533
+ : [];
534
+ session.contract.architectureObligationWaivers = [
535
+ ...existingWaivers.filter((existing) => existing.obligationId !== id || existing.revokedAt),
536
+ waiver,
537
+ ];
538
+ session.events.push({
539
+ type: 'obligation_waiver_decision',
540
+ ts: waivedAt,
541
+ decision: 'waived',
542
+ detail: {
543
+ obligationId: id,
544
+ reason: waiver.reason,
545
+ eventId,
546
+ expiresAt,
547
+ source: waiver.source,
548
+ waivedBy: waiver.waivedBy,
549
+ },
550
+ });
551
+ const architectureObligations = recomputeArchitectureObligations(session, waivedAt);
552
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, session.sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
553
+ return {
554
+ sessionId: session.sessionId,
555
+ obligationId: id,
556
+ waiver,
557
+ expiresAt,
558
+ eventId,
559
+ architectureObligations,
560
+ };
561
+ }
562
+ function approveSession(projectRoot, approvedPath, reason, sessionId) {
563
+ const options = normaliseApprovalArgs(reason, sessionId);
564
+ const initialSession = options.sessionId
565
+ ? loadSession(projectRoot, options.sessionId)
566
+ : loadActiveSession(projectRoot);
567
+ if (!initialSession)
568
+ throw new Error('No active session found. Run a task first.');
569
+ return withSessionEventLock(projectRoot, initialSession.sessionId, () => {
570
+ const session = loadSession(projectRoot, initialSession.sessionId);
571
+ if (!session)
572
+ throw new Error(`Session ${initialSession.sessionId} not found.`);
573
+ if (session.status !== 'active')
574
+ throw new Error(`Session ${session.sessionId} is already finished.`);
575
+ const normalised = normaliseApprovalPath(projectRoot, approvedPath);
576
+ if (!normalised)
577
+ throw new Error('approvedPath must not be empty.');
578
+ const approvedAt = options.approvedAt || new Date().toISOString();
579
+ const expiresAt = resolveApprovalExpiry(options, approvedAt);
580
+ const eventId = `approval_${Date.now()}_${(0, node_crypto_1.randomBytes)(3).toString('hex')}`;
581
+ const grant = {
582
+ path: normalised,
583
+ reason: options.reason?.trim() || 'human approved in session',
584
+ approvedAt,
585
+ expiresAt,
586
+ source: options.source || 'local_cli',
587
+ eventId,
588
+ approvedBy: options.approvedBy || null,
589
+ requestId: options.requestId || null,
590
+ };
591
+ const grants = approvalGrants(session.contract).filter((existing) => existing.path !== normalised);
592
+ grants.push(grant);
593
+ session.contract.approvalGrants = grants;
594
+ // Backward-compatible active path list. Structured grants are authoritative
595
+ // when present; this list remains for old clients and simple UI counters.
596
+ if (!session.contract.approvedPaths.includes(normalised)) {
597
+ session.contract.approvedPaths.push(normalised);
598
+ }
599
+ session.contract.approvedPaths = activeApprovalPaths(session.contract, approvedAt);
600
+ session.events.push({
601
+ type: 'approval_decision',
602
+ ts: approvedAt,
603
+ filePath: normalised,
604
+ decision: 'approved',
605
+ detail: {
606
+ reason: grant.reason,
607
+ eventId,
608
+ expiresAt,
609
+ source: grant.source,
610
+ approvedBy: grant.approvedBy,
611
+ requestId: grant.requestId,
612
+ },
613
+ });
614
+ recomputeArchitectureObligations(session, approvedAt);
615
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, session.sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
616
+ return {
617
+ sessionId: session.sessionId,
618
+ approvedPath: normalised,
619
+ approvedPaths: session.contract.approvedPaths,
620
+ approvalGrant: grant,
621
+ expiresAt,
622
+ eventId,
623
+ };
624
+ });
625
+ }
626
+ /**
627
+ * Revoke one exact session grant and recompute the backward-compatible active
628
+ * path list. Dashboard revocations prefer requestId so the same path can be
629
+ * approved again later without revoking the wrong historical grant.
630
+ */
631
+ function revokeSessionApproval(projectRoot, approvedPath, options = {}) {
632
+ const initialSession = options.sessionId
633
+ ? loadSession(projectRoot, options.sessionId)
634
+ : loadActiveSession(projectRoot);
635
+ if (!initialSession)
636
+ throw new Error('No active session found. Run a task first.');
637
+ return withSessionEventLock(projectRoot, initialSession.sessionId, () => {
638
+ const session = loadSession(projectRoot, initialSession.sessionId);
639
+ if (!session)
640
+ throw new Error(`Session ${initialSession.sessionId} not found.`);
641
+ if (session.status !== 'active')
642
+ throw new Error(`Session ${session.sessionId} is already finished.`);
643
+ const normalised = normaliseApprovalPath(projectRoot, approvedPath);
644
+ if (!normalised)
645
+ throw new Error('approvedPath must not be empty.');
646
+ const grants = approvalGrants(session.contract);
647
+ const targetIndex = grants.findIndex((grant) => options.requestId
648
+ ? grant.requestId === options.requestId
649
+ : grant.path === normalised && !grant.revokedAt);
650
+ if (targetIndex < 0)
651
+ throw new Error(`Approval grant not found for ${normalised}.`);
652
+ const revokedAt = options.revokedAt || new Date().toISOString();
653
+ const target = grants[targetIndex];
654
+ if (!target.revokedAt) {
655
+ target.revokedAt = revokedAt;
656
+ target.revokedBy = options.revokedBy || null;
657
+ target.revocationReason = options.reason?.trim() || 'human revoked approval in session';
658
+ session.events.push({
659
+ type: 'approval_decision',
660
+ ts: revokedAt,
661
+ filePath: target.path,
662
+ decision: 'revoked',
663
+ detail: {
664
+ reason: target.revocationReason,
665
+ approvalEventId: target.eventId,
666
+ requestId: target.requestId,
667
+ source: options.source || 'local_cli',
668
+ revokedBy: target.revokedBy,
669
+ },
670
+ });
671
+ }
672
+ session.contract.approvalGrants = grants;
673
+ session.contract.approvedPaths = activeApprovalPaths(session.contract, revokedAt);
674
+ recomputeArchitectureObligations(session, revokedAt);
675
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, session.sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
676
+ return {
677
+ sessionId: session.sessionId,
678
+ revokedPath: target.path,
679
+ approvedPaths: session.contract.approvedPaths,
680
+ approvalGrant: target,
681
+ revokedAt: target.revokedAt || revokedAt,
682
+ };
683
+ });
684
+ }
685
+ function finishSession(projectRoot, sessionId, options = {}) {
686
+ const session = loadSession(projectRoot, sessionId);
687
+ if (!session)
688
+ return null;
689
+ session.status = 'finished';
690
+ session.finishedAt = new Date().toISOString();
691
+ recomputeArchitectureObligations(session, session.finishedAt);
692
+ const canonical = JSON.stringify({
693
+ sessionId: session.sessionId,
694
+ profileHash: session.profileHash,
695
+ contract: session.contract,
696
+ events: session.events.map((e) => ({
697
+ type: e.type,
698
+ filePath: e.filePath,
699
+ verdict: e.verdict,
700
+ decision: e.decision,
701
+ })),
702
+ });
703
+ session.replayHash = (0, node_crypto_1.createHash)('sha256').update(canonical).digest('hex').slice(0, 24);
704
+ session.events.push({
705
+ type: 'session_finish',
706
+ ts: session.finishedAt,
707
+ detail: {
708
+ replayHash: session.replayHash,
709
+ ...(options.reason ? { reason: options.reason } : {}),
710
+ ...(options.unresolvedApprovalBlocks?.length
711
+ ? { unresolvedApprovalBlocks: options.unresolvedApprovalBlocks }
712
+ : {}),
713
+ },
714
+ });
715
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
716
+ (0, ai_change_record_1.writeAIChangeRecord)(projectRoot, session);
717
+ const ptr = activePointerPath(projectRoot);
718
+ if ((0, node_fs_1.existsSync)(ptr)) {
719
+ (0, node_fs_1.writeFileSync)(ptr, JSON.stringify({ sessionId: null }, null, 2) + '\n', 'utf8');
720
+ }
721
+ return session;
722
+ }
723
+ function replaySession(session) {
724
+ const canonical = JSON.stringify({
725
+ sessionId: session.sessionId,
726
+ profileHash: session.profileHash,
727
+ contract: session.contract,
728
+ events: session.events
729
+ .filter((e) => e.type !== 'session_finish')
730
+ .map((e) => ({
731
+ type: e.type,
732
+ filePath: e.filePath,
733
+ verdict: e.verdict,
734
+ decision: e.decision,
735
+ })),
736
+ });
737
+ const replayHash = (0, node_crypto_1.createHash)('sha256').update(canonical).digest('hex').slice(0, 24);
738
+ return {
739
+ replayHash,
740
+ matchesOriginal: replayHash === session.replayHash,
741
+ originalHash: session.replayHash,
742
+ };
743
+ }
744
+ // ── Intent contract and coherence ────────────────────────────────────────────
745
+ function unique(values) {
746
+ return Array.from(new Set(values.filter(Boolean)));
747
+ }
748
+ function uniqueTrimmed(values) {
749
+ const out = [];
750
+ const seen = new Set();
751
+ for (const raw of values) {
752
+ const value = String(raw ?? '').trim();
753
+ if (!value || seen.has(value))
754
+ continue;
755
+ seen.add(value);
756
+ out.push(value);
757
+ }
758
+ return out;
759
+ }
760
+ function removeTrimmed(existing, removals) {
761
+ const removeSet = new Set(removals.map((value) => value.trim()).filter(Boolean));
762
+ if (removeSet.size === 0)
763
+ return uniqueTrimmed(existing);
764
+ return uniqueTrimmed(existing.filter((value) => !removeSet.has(value.trim())));
765
+ }
766
+ function normalizePlanPath(pathValue) {
767
+ return pathValue.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '').trim();
768
+ }
769
+ function normalizePlanPaths(values) {
770
+ return uniqueTrimmed(Array.from(values, (value) => normalizePlanPath(String(value ?? ''))));
771
+ }
772
+ function normalizeGoal(goal) {
773
+ return goal.replace(/\s+/g, ' ').trim().slice(0, 280);
774
+ }
775
+ const SCOPE_PATH_ROOTS = new Set([
776
+ 'app',
777
+ 'apps',
778
+ 'bin',
779
+ 'cmd',
780
+ 'config',
781
+ 'docs',
782
+ 'fixtures',
783
+ 'lib',
784
+ 'migrations',
785
+ 'packages',
786
+ 'scripts',
787
+ 'services',
788
+ 'src',
789
+ 'test',
790
+ 'tests',
791
+ 'web',
792
+ '.github',
793
+ ]);
794
+ function cleanScopePathToken(raw) {
795
+ return raw
796
+ .replace(/\\/g, '/')
797
+ .replace(/^\.\//, '')
798
+ .replace(/^\//, '')
799
+ .replace(/^[("'`<\[]+/, '')
800
+ .replace(/[)"'`>\].,;:]+$/, '')
801
+ .trim();
802
+ }
803
+ function isLikelyRepoScopePath(token) {
804
+ if (!token || token.length > 240 || token.includes('://'))
805
+ return false;
806
+ if (!token.includes('/'))
807
+ return false;
808
+ if (/\s/.test(token))
809
+ return false;
810
+ const segments = token.split('/').filter(Boolean);
811
+ if (segments.length < 2)
812
+ return false;
813
+ const first = segments[0];
814
+ if (!SCOPE_PATH_ROOTS.has(first))
815
+ return false;
816
+ const lastSeg = segments.at(-1) ?? '';
817
+ if (!lastSeg || lastSeg === '*' || lastSeg === '**')
818
+ return false;
819
+ if (/^[A-Z][A-Za-z]+$/.test(first))
820
+ return false;
821
+ return true;
822
+ }
823
+ function extractPathTokens(goal) {
824
+ return unique((goal.match(/[a-z0-9_./-]+\/[a-z0-9_./-]+/gi) ?? [])
825
+ .map(cleanScopePathToken)
826
+ .filter(isLikelyRepoScopePath));
827
+ }
828
+ function primaryActionForGoal(lower) {
829
+ if (/\b(add|create|implement|build|introduce)\b/.test(lower))
830
+ return 'add';
831
+ if (/\b(fix|bug|hotfix|repair|resolve)\b/.test(lower))
832
+ return 'fix';
833
+ if (/\b(refactor|cleanup|simplify|restructure)\b/.test(lower))
834
+ return 'refactor';
835
+ if (/\b(test|spec|coverage)\b/.test(lower))
836
+ return 'test';
837
+ if (/\b(doc|docs|document|readme)\b/.test(lower))
838
+ return 'document';
839
+ if (/\b(remove|delete|drop)\b/.test(lower))
840
+ return 'remove';
841
+ if (/\b(migration|migrate|schema)\b/.test(lower))
842
+ return 'migrate';
843
+ if (/\b(modify|update|change|edit)\b/.test(lower))
844
+ return 'modify';
845
+ return 'unknown';
846
+ }
847
+ function domainKeywordsForGoal(lower) {
848
+ const domains = [
849
+ [/\b(tasks?|exports?|jobs?|workers?)\b/, 'tasks'],
850
+ [/\bservices?\b/, 'services'],
851
+ [/\b(routes?|api|handlers?|controllers?)\b/, 'api'],
852
+ [/\bcomponents?|pages?|ui\b/, 'frontend'],
853
+ [/\bmodels?|schemas?\b/, 'data-model'],
854
+ [/\btests?|specs?|coverage\b/, 'tests'],
855
+ [/\bauth|oauth|sso|jwt|session\b/, 'auth'],
856
+ [/\bbilling|payments?|checkout|stripe|invoice\b/, 'payments'],
857
+ [/\bmigrations?|schema|database|db\b/, 'database'],
858
+ [/\bretry|backoff|timeout|idempotent|idempotency\b/, 'reliability'],
859
+ [/\bsecurity|secret|credential|encryption|crypto\b/, 'security'],
860
+ [/\bdocs?|readme|guide\b/, 'docs'],
861
+ [/\bconfig|settings|env\b/, 'config'],
862
+ ];
863
+ return domains.filter(([re]) => re.test(lower)).map(([, domain]) => domain);
864
+ }
865
+ function supportGlobsForIntent(goal, profile) {
866
+ const lower = goal.toLowerCase();
867
+ if (hasExclusiveScopeCue(lower) && extractPathTokens(goal).length > 0) {
868
+ return [];
869
+ }
870
+ const globs = [
871
+ 'tests/**',
872
+ 'test/**',
873
+ 'src/util/**',
874
+ 'src/utils/**',
875
+ 'src/helpers/**',
876
+ 'src/lib/**',
877
+ 'lib/**',
878
+ ...(profile.runtimeConfig?.safeSupportGlobs ?? []),
879
+ ];
880
+ if (/\b(doc|docs|readme|guide)\b/.test(lower)) {
881
+ globs.push('docs/**', '*.md');
882
+ }
883
+ if (/\bconfig|settings\b/.test(lower)) {
884
+ globs.push('config/**', '*.config.*');
885
+ }
886
+ return unique(globs);
887
+ }
888
+ function hasExclusiveScopeCue(lowerGoal) {
889
+ return /\bonly\b/.test(lowerGoal)
890
+ || /\bexact(?:ly)?\b/.test(lowerGoal)
891
+ || /\bsingle[- ]file\b/.test(lowerGoal)
892
+ || /\bno other files?\b/.test(lowerGoal)
893
+ || /\bdo not touch\b/.test(lowerGoal)
894
+ || /\bdon't touch\b/.test(lowerGoal)
895
+ || /\bwithout touching\b/.test(lowerGoal);
896
+ }
897
+ function isFileScopeToken(token) {
898
+ const lastSeg = token.replace(/^\//, '').split('/').at(-1) ?? '';
899
+ return lastSeg.includes('.');
900
+ }
901
+ function intentObligations(goal, action, domains, scopeMode) {
902
+ const lower = goal.toLowerCase();
903
+ const obligations = [
904
+ {
905
+ id: 'stay-within-intent-scope',
906
+ title: 'Stay within the declared task intent',
907
+ description: 'Changes should remain tied to the target paths, domains, or support files implied by the user prompt.',
908
+ severity: scopeMode === 'ambiguous' ? 'warn' : 'info',
909
+ },
910
+ ];
911
+ if (scopeMode === 'ambiguous') {
912
+ obligations.push({
913
+ id: 'narrow-ambiguous-intent',
914
+ title: 'Narrow ambiguous intent before broad edits',
915
+ description: 'The user prompt did not identify a clear module or path, so unrelated edits should be treated as drift.',
916
+ severity: 'warn',
917
+ });
918
+ }
919
+ if (action === 'refactor') {
920
+ obligations.push({
921
+ id: 'preserve-behavior',
922
+ title: 'Preserve existing behavior',
923
+ description: 'Refactors should avoid public API, data model, or cross-boundary behavior changes unless explicitly requested.',
924
+ severity: 'critical',
925
+ });
926
+ }
927
+ if (action === 'fix') {
928
+ obligations.push({
929
+ id: 'minimize-fix-blast-radius',
930
+ title: 'Keep fix blast radius small',
931
+ description: 'Bug fixes should prefer the smallest coherent change and avoid unrelated cleanup.',
932
+ severity: 'warn',
933
+ });
934
+ }
935
+ if (/\bretry|backoff|timeout\b/.test(lower) || domains.includes('reliability')) {
936
+ obligations.push({
937
+ id: 'preserve-idempotency',
938
+ title: 'Preserve idempotency around retries',
939
+ description: 'Retry/backoff work must avoid duplicate side effects in billing, auth, and external calls.',
940
+ severity: 'critical',
941
+ }, {
942
+ id: 'cover-retry-path',
943
+ title: 'Cover retry behavior',
944
+ description: 'A retry/backoff task should normally include or preserve tests for failure and retry paths.',
945
+ severity: 'warn',
946
+ });
947
+ }
948
+ if (domains.some((domain) => domain === 'auth' || domain === 'payments' || domain === 'security' || domain === 'database')) {
949
+ obligations.push({
950
+ id: 'respect-owned-sensitive-boundaries',
951
+ title: 'Respect owned sensitive boundaries',
952
+ description: 'Sensitive owned paths require exact approval before writes and should not be expanded silently.',
953
+ severity: 'critical',
954
+ });
955
+ }
956
+ return obligations;
957
+ }
958
+ function pathMatchesAny(filePath, globs) {
959
+ return globs.filter((glob) => {
960
+ const prefix = glob.replace('/**', '').replace('/*', '');
961
+ return (filePath === prefix ||
962
+ filePath.startsWith(prefix + '/') ||
963
+ micromatch_1.default.isMatch(filePath, glob, { dot: true, matchBase: true }));
964
+ });
965
+ }
966
+ function deriveIntentContract(goal, profile, allowedGlobs, scopeMode) {
967
+ const lower = goal.toLowerCase();
968
+ const pathTokens = extractPathTokens(goal);
969
+ const primaryAction = primaryActionForGoal(lower);
970
+ const domainKeywords = domainKeywordsForGoal(lower);
971
+ const supportPathGlobs = supportGlobsForIntent(goal, profile);
972
+ const supportSet = new Set(supportPathGlobs);
973
+ const expectedPathGlobs = unique(allowedGlobs).filter((glob) => !supportSet.has(glob));
974
+ const confidence = scopeMode === 'explicit' ? 'high' : scopeMode === 'inferred' ? 'medium' : 'low';
975
+ const outOfScopeGlobs = unique([
976
+ ...profile.approvalRequiredPaths,
977
+ ...profile.sensitiveBoundaries.map((boundary) => boundary.glob),
978
+ ]).filter((glob) => pathMatchesAny(glob.replace('/**', ''), expectedPathGlobs).length === 0);
979
+ const riskNotes = [];
980
+ if (scopeMode === 'ambiguous') {
981
+ riskNotes.push('Prompt did not name a clear file, module, or domain; coherence checks should be treated as low-confidence.');
982
+ }
983
+ if (profile.approvalRequiredPaths.length > 0) {
984
+ riskNotes.push(`${profile.approvalRequiredPaths.length} approval-required boundaries excluded from normal scope.`);
985
+ }
986
+ if (profile.unownedPercent > 25) {
987
+ riskNotes.push(`${profile.unownedPercent}% of source paths are not covered by CODEOWNERS.`);
988
+ }
989
+ return {
990
+ schemaVersion: 1,
991
+ summary: normalizeGoal(goal),
992
+ primaryAction,
993
+ confidence,
994
+ target: {
995
+ pathTokens,
996
+ domainKeywords,
997
+ expectedPathGlobs,
998
+ supportPathGlobs,
999
+ },
1000
+ obligations: intentObligations(goal, primaryAction, domainKeywords, scopeMode),
1001
+ outOfScopeGlobs,
1002
+ riskNotes,
1003
+ createdAt: new Date().toISOString(),
1004
+ };
1005
+ }
1006
+ function evaluateIntentCoherence(contract, filePath) {
1007
+ const intent = contract.intentContract;
1008
+ if (!intent) {
1009
+ return {
1010
+ verdict: 'unknown',
1011
+ score: 50,
1012
+ filePath,
1013
+ matchedGlobs: [],
1014
+ reasons: ['No intent contract is present on this session.'],
1015
+ obligations: [],
1016
+ };
1017
+ }
1018
+ const expectedMatches = pathMatchesAny(filePath, intent.target.expectedPathGlobs);
1019
+ if (expectedMatches.length > 0) {
1020
+ return {
1021
+ verdict: 'aligned',
1022
+ score: intent.confidence === 'high' ? 96 : 88,
1023
+ filePath,
1024
+ matchedGlobs: expectedMatches,
1025
+ reasons: [`File matches expected intent scope: ${expectedMatches.join(', ')}`],
1026
+ obligations: intent.obligations,
1027
+ };
1028
+ }
1029
+ const supportMatches = pathMatchesAny(filePath, intent.target.supportPathGlobs);
1030
+ if (supportMatches.length > 0) {
1031
+ return {
1032
+ verdict: 'supporting',
1033
+ score: 74,
1034
+ filePath,
1035
+ matchedGlobs: supportMatches,
1036
+ reasons: [`File is a plausible support/test/helper path for the task: ${supportMatches.join(', ')}`],
1037
+ obligations: intent.obligations,
1038
+ };
1039
+ }
1040
+ const outOfScopeMatches = pathMatchesAny(filePath, intent.outOfScopeGlobs);
1041
+ if (outOfScopeMatches.length > 0) {
1042
+ return {
1043
+ verdict: 'drift',
1044
+ score: 25,
1045
+ filePath,
1046
+ matchedGlobs: outOfScopeMatches,
1047
+ reasons: [`File is in a sensitive or approval-required boundary outside the intent contract: ${outOfScopeMatches.join(', ')}`],
1048
+ obligations: intent.obligations,
1049
+ };
1050
+ }
1051
+ const domainMatch = intent.target.domainKeywords.find((domain) => filePath.toLowerCase().includes(domain));
1052
+ if (domainMatch) {
1053
+ return {
1054
+ verdict: 'supporting',
1055
+ score: 68,
1056
+ filePath,
1057
+ matchedGlobs: [],
1058
+ reasons: [`File path loosely matches the task domain keyword "${domainMatch}".`],
1059
+ obligations: intent.obligations,
1060
+ };
1061
+ }
1062
+ return {
1063
+ verdict: 'drift',
1064
+ score: intent.confidence === 'low' ? 45 : 38,
1065
+ filePath,
1066
+ matchedGlobs: [],
1067
+ reasons: ['File does not match expected intent scope, support paths, or task domain keywords.'],
1068
+ obligations: intent.obligations,
1069
+ };
1070
+ }
1071
+ // ── Agent plan capture and plan coherence ─────────────────────────────────────
1072
+ function currentAgentPlanRevision(contract) {
1073
+ if (typeof contract.agentPlanRevision === 'number' && contract.agentPlanRevision > 0) {
1074
+ return Math.floor(contract.agentPlanRevision);
1075
+ }
1076
+ const revisions = Array.isArray(contract.agentPlanRevisions)
1077
+ ? contract.agentPlanRevisions
1078
+ : [];
1079
+ const latest = revisions.reduce((max, revision) => {
1080
+ const value = typeof revision.revision === 'number' ? revision.revision : 0;
1081
+ return Math.max(max, value);
1082
+ }, 0);
1083
+ if (latest > 0)
1084
+ return latest;
1085
+ return contract.agentPlan ? 1 : 0;
1086
+ }
1087
+ function planRevisionLedger(contract) {
1088
+ const revisions = Array.isArray(contract.agentPlanRevisions)
1089
+ ? contract.agentPlanRevisions.filter((revision) => revision?.plan)
1090
+ : [];
1091
+ if (revisions.length > 0) {
1092
+ return [...revisions];
1093
+ }
1094
+ if (!contract.agentPlan) {
1095
+ return [];
1096
+ }
1097
+ const revision = currentAgentPlanRevision(contract) || 1;
1098
+ return [
1099
+ {
1100
+ revision,
1101
+ kind: 'captured',
1102
+ plan: contract.agentPlan,
1103
+ reason: 'legacy active plan snapshot',
1104
+ source: contract.agentPlan.source || 'unknown',
1105
+ capturedAt: contract.agentPlan.capturedAt || new Date().toISOString(),
1106
+ eventId: `plan_legacy_${revision}`,
1107
+ },
1108
+ ];
1109
+ }
1110
+ function makePlanEventId(prefix = 'plan') {
1111
+ return `${prefix}_${Date.now()}_${(0, node_crypto_1.randomBytes)(3).toString('hex')}`;
1112
+ }
1113
+ function recordAgentPlanRevision(args) {
1114
+ const previousRevision = currentAgentPlanRevision(args.session.contract);
1115
+ const revision = previousRevision === 0 ? 1 : previousRevision + 1;
1116
+ const eventId = makePlanEventId(args.kind);
1117
+ const capturedAt = args.plan.capturedAt || new Date().toISOString();
1118
+ const ledger = planRevisionLedger(args.session.contract).filter((entry) => entry.revision < revision);
1119
+ args.session.contract.agentPlan = args.plan;
1120
+ args.session.contract.agentPlanRevision = revision;
1121
+ args.session.contract.agentPlanRevisions = [
1122
+ ...ledger,
1123
+ {
1124
+ revision,
1125
+ kind: args.kind,
1126
+ plan: args.plan,
1127
+ reason: args.reason,
1128
+ source: args.source,
1129
+ capturedAt,
1130
+ eventId,
1131
+ },
1132
+ ];
1133
+ args.session.events.push({
1134
+ type: args.eventType,
1135
+ ts: capturedAt,
1136
+ message: args.kind === 'captured'
1137
+ ? `Agent plan captured as revision ${revision} (${args.source}, confidence ${args.plan.confidence}, ${args.plan.steps.length} step${args.plan.steps.length === 1 ? '' : 's'})`
1138
+ : `Agent plan amended to revision ${revision} (${args.source})`,
1139
+ detail: {
1140
+ eventId,
1141
+ previousRevision,
1142
+ revision,
1143
+ agentPlan: args.plan,
1144
+ ...(args.amendment
1145
+ ? {
1146
+ planAmendment: {
1147
+ previousRevision,
1148
+ revision,
1149
+ amendedAt: capturedAt,
1150
+ activePlan: args.plan,
1151
+ ...args.amendment,
1152
+ },
1153
+ }
1154
+ : {}),
1155
+ },
1156
+ });
1157
+ recomputeArchitectureObligations(args.session, capturedAt);
1158
+ (0, node_fs_1.writeFileSync)(sessionPath(args.projectRoot, args.session.sessionId), JSON.stringify(args.session, null, 2) + '\n', 'utf8');
1159
+ return args.session;
1160
+ }
1161
+ /**
1162
+ * Attach (or replace) the agent's captured plan on a session contract and record
1163
+ * a `plan_captured` event. Source-free: only the AgentPlan metadata is stored.
1164
+ * Returns null when the session cannot be loaded; never throws on a missing plan.
1165
+ */
1166
+ function attachAgentPlan(projectRoot, sessionId, plan) {
1167
+ const session = loadSession(projectRoot, sessionId);
1168
+ if (!session)
1169
+ return null;
1170
+ const hasExistingPlan = Boolean(session.contract.agentPlan);
1171
+ return recordAgentPlanRevision({
1172
+ projectRoot,
1173
+ session,
1174
+ plan,
1175
+ kind: hasExistingPlan ? 'amended' : 'captured',
1176
+ reason: hasExistingPlan ? 'agent published an updated plan' : 'initial agent plan captured',
1177
+ source: plan.source || 'unknown',
1178
+ eventType: hasExistingPlan ? 'plan_amended' : 'plan_captured',
1179
+ amendment: hasExistingPlan
1180
+ ? {
1181
+ action: 'replace',
1182
+ reason: 'agent published an updated plan',
1183
+ source: plan.source || 'unknown',
1184
+ activePlan: plan,
1185
+ }
1186
+ : undefined,
1187
+ });
1188
+ }
1189
+ function emptyAgentPlan(now, source) {
1190
+ return {
1191
+ schemaVersion: 1,
1192
+ summary: 'Agent plan',
1193
+ steps: [],
1194
+ expectedFiles: [],
1195
+ expectedGlobs: [],
1196
+ constraints: [],
1197
+ risks: [],
1198
+ capturedAt: now,
1199
+ source,
1200
+ confidence: 'low',
1201
+ };
1202
+ }
1203
+ function hasPatchChanges(input) {
1204
+ return Boolean(input.summary?.trim() ||
1205
+ input.planText?.trim() ||
1206
+ (input.addSteps?.length ?? 0) > 0 ||
1207
+ (input.removeSteps?.length ?? 0) > 0 ||
1208
+ (input.addExpectedFiles?.length ?? 0) > 0 ||
1209
+ (input.removeExpectedFiles?.length ?? 0) > 0 ||
1210
+ (input.addExpectedGlobs?.length ?? 0) > 0 ||
1211
+ (input.removeExpectedGlobs?.length ?? 0) > 0 ||
1212
+ (input.addConstraints?.length ?? 0) > 0 ||
1213
+ (input.removeConstraints?.length ?? 0) > 0 ||
1214
+ (input.addRisks?.length ?? 0) > 0 ||
1215
+ (input.removeRisks?.length ?? 0) > 0);
1216
+ }
1217
+ function confidenceForPlan(plan) {
1218
+ if ((plan.expectedFiles.length + plan.expectedGlobs.length > 0) && plan.steps.length >= 1) {
1219
+ return 'high';
1220
+ }
1221
+ if (plan.steps.length > 0 || plan.expectedFiles.length + plan.expectedGlobs.length > 0) {
1222
+ return 'medium';
1223
+ }
1224
+ return 'low';
1225
+ }
1226
+ function planMateriallyChanged(existing, next) {
1227
+ if (!existing)
1228
+ return true;
1229
+ const stable = (values) => JSON.stringify(values);
1230
+ return (existing.summary !== next.summary ||
1231
+ stable(existing.steps) !== stable(next.steps) ||
1232
+ stable(existing.expectedFiles) !== stable(next.expectedFiles) ||
1233
+ stable(existing.expectedGlobs) !== stable(next.expectedGlobs) ||
1234
+ stable(existing.constraints) !== stable(next.constraints) ||
1235
+ stable(existing.risks) !== stable(next.risks));
1236
+ }
1237
+ function addedValues(existing, next) {
1238
+ const before = new Set((existing ?? []).map((value) => value.trim()).filter(Boolean));
1239
+ return uniqueTrimmed((next ?? []).filter((value) => !before.has(value.trim())));
1240
+ }
1241
+ function removedValues(existing, next) {
1242
+ const after = new Set((next ?? []).map((value) => value.trim()).filter(Boolean));
1243
+ return uniqueTrimmed((existing ?? []).filter((value) => !after.has(value.trim())));
1244
+ }
1245
+ function fileMatchesAny(filePath, globs) {
1246
+ return pathMatchesAny(filePath, globs).length > 0;
1247
+ }
1248
+ /**
1249
+ * Deterministically classify a proposed plan change. The risk model is
1250
+ * intentionally conservative around permission-envelope expansion:
1251
+ * - broad globs require human review;
1252
+ * - newly named sensitive / approval-required / owned files require review;
1253
+ * - files outside the original intent envelope require review;
1254
+ * - removing a stated constraint requires review.
1255
+ *
1256
+ * Adding a concrete file already inside the declared task intent stays fluid,
1257
+ * which preserves normal iterative implementation work.
1258
+ */
1259
+ function classifyAgentPlanAmendment(contract, proposedPlan) {
1260
+ const existing = contract.agentPlan;
1261
+ const addedFiles = addedValues(existing?.expectedFiles, proposedPlan.expectedFiles)
1262
+ .map(normalizePlanPath);
1263
+ const addedGlobs = addedValues(existing?.expectedGlobs, proposedPlan.expectedGlobs)
1264
+ .map(normalizePlanPath);
1265
+ const removedConstraints = removedValues(existing?.constraints, proposedPlan.constraints);
1266
+ const reasons = [];
1267
+ let level = 'low';
1268
+ const escalate = (next, reason) => {
1269
+ const order = { low: 0, medium: 1, high: 2 };
1270
+ if (order[next] > order[level])
1271
+ level = next;
1272
+ reasons.push(reason);
1273
+ };
1274
+ if (addedGlobs.length > 0) {
1275
+ escalate('high', `Plan adds broad target glob${addedGlobs.length === 1 ? '' : 's'}: ${addedGlobs.join(', ')}`);
1276
+ }
1277
+ if (removedConstraints.length > 0) {
1278
+ escalate('high', `Plan removes stated constraint${removedConstraints.length === 1 ? '' : 's'}: ${removedConstraints.join(', ')}`);
1279
+ }
1280
+ if (addedFiles.length > 5) {
1281
+ escalate('high', `Plan expands to ${addedFiles.length} additional files in one amendment.`);
1282
+ }
1283
+ const intentGlobs = unique([
1284
+ ...(contract.intentContract?.target.expectedPathGlobs ?? []),
1285
+ ...(contract.intentContract?.target.supportPathGlobs ?? []),
1286
+ ]);
1287
+ const ownedGlobs = contract.ownershipRules.map((rule) => rule.glob);
1288
+ for (const filePath of addedFiles) {
1289
+ if (fileMatchesAny(filePath, contract.approvalRequiredGlobs)) {
1290
+ escalate('high', `Added file requires explicit boundary approval: ${filePath}`);
1291
+ continue;
1292
+ }
1293
+ if (fileMatchesAny(filePath, contract.sensitiveGlobs)) {
1294
+ escalate('high', `Added file crosses a sensitive boundary: ${filePath}`);
1295
+ continue;
1296
+ }
1297
+ if (fileMatchesAny(filePath, ownedGlobs)) {
1298
+ escalate('high', `Added file crosses a team-owned boundary: ${filePath}`);
1299
+ continue;
1300
+ }
1301
+ if (intentGlobs.length > 0 && !fileMatchesAny(filePath, intentGlobs)) {
1302
+ escalate('high', `Added file is outside the original intent envelope: ${filePath}`);
1303
+ continue;
1304
+ }
1305
+ escalate('medium', `Plan adds an in-intent implementation file: ${filePath}`);
1306
+ }
1307
+ if (reasons.length === 0 && planMateriallyChanged(existing, proposedPlan)) {
1308
+ reasons.push('Plan refines existing steps without expanding governed target scope.');
1309
+ }
1310
+ const effectiveLevel = level;
1311
+ return {
1312
+ level: effectiveLevel,
1313
+ requiresHumanApproval: effectiveLevel === 'high',
1314
+ reasons,
1315
+ addedFiles,
1316
+ addedGlobs,
1317
+ removedConstraints,
1318
+ };
1319
+ }
1320
+ function deriveAmendedPlan(existing, input) {
1321
+ const source = input.source || 'manual';
1322
+ const amendedAt = input.amendedAt || new Date().toISOString();
1323
+ const reason = input.reason?.trim() || 'plan updated during session';
1324
+ if (!hasPatchChanges(input)) {
1325
+ throw new Error('No plan changes supplied. Provide --plan, --add-step, --remove-step, --add-file, or --remove-file.');
1326
+ }
1327
+ if (input.planText?.trim()) {
1328
+ const plan = (0, agent_plan_1.extractAgentPlan)({ plan: input.planText }, { now: new Date(amendedAt), source });
1329
+ if (!plan) {
1330
+ throw new Error('Could not parse replacement plan text.');
1331
+ }
1332
+ return {
1333
+ action: 'replace',
1334
+ plan: {
1335
+ ...plan,
1336
+ summary: input.summary?.trim() || plan.summary,
1337
+ capturedAt: amendedAt,
1338
+ source,
1339
+ },
1340
+ amendment: {
1341
+ action: 'replace',
1342
+ reason,
1343
+ source,
1344
+ },
1345
+ };
1346
+ }
1347
+ const base = existing
1348
+ ? { ...existing }
1349
+ : emptyAgentPlan(amendedAt, source);
1350
+ const addSteps = uniqueTrimmed(input.addSteps ?? []);
1351
+ const removeSteps = uniqueTrimmed(input.removeSteps ?? []);
1352
+ const addConstraints = uniqueTrimmed(input.addConstraints ?? []);
1353
+ const removeConstraints = uniqueTrimmed(input.removeConstraints ?? []);
1354
+ const addRisks = uniqueTrimmed(input.addRisks ?? []);
1355
+ const removeRisks = uniqueTrimmed(input.removeRisks ?? []);
1356
+ const inferredAdded = (0, agent_plan_1.extractExpectedTargetsFromText)([
1357
+ input.summary || '',
1358
+ ...addSteps,
1359
+ ...addConstraints,
1360
+ ].join('\n'));
1361
+ const inferredRemoved = (0, agent_plan_1.extractExpectedTargetsFromText)(removeSteps.join('\n'));
1362
+ const filesToAdd = normalizePlanPaths([
1363
+ ...(input.addExpectedFiles ?? []),
1364
+ ...inferredAdded.expectedFiles,
1365
+ ]);
1366
+ const filesToRemove = normalizePlanPaths([
1367
+ ...(input.removeExpectedFiles ?? []),
1368
+ ...inferredRemoved.expectedFiles,
1369
+ ]);
1370
+ const globsToAdd = normalizePlanPaths([
1371
+ ...(input.addExpectedGlobs ?? []),
1372
+ ...inferredAdded.expectedGlobs,
1373
+ ]);
1374
+ const globsToRemove = normalizePlanPaths([
1375
+ ...(input.removeExpectedGlobs ?? []),
1376
+ ...inferredRemoved.expectedGlobs,
1377
+ ]);
1378
+ const plan = {
1379
+ ...base,
1380
+ summary: input.summary?.trim() || base.summary || 'Agent plan',
1381
+ steps: removeTrimmed([...(base.steps ?? []), ...addSteps], removeSteps),
1382
+ expectedFiles: removeTrimmed(normalizePlanPaths([...(base.expectedFiles ?? []), ...filesToAdd]), filesToRemove),
1383
+ expectedGlobs: removeTrimmed(normalizePlanPaths([...(base.expectedGlobs ?? []), ...globsToAdd]), globsToRemove),
1384
+ constraints: removeTrimmed([...(base.constraints ?? []), ...addConstraints], removeConstraints),
1385
+ risks: removeTrimmed([...(base.risks ?? []), ...addRisks], removeRisks),
1386
+ capturedAt: amendedAt,
1387
+ source,
1388
+ confidence: 'low',
1389
+ };
1390
+ plan.confidence = confidenceForPlan(plan);
1391
+ return {
1392
+ action: 'patch',
1393
+ plan,
1394
+ amendment: {
1395
+ action: 'patch',
1396
+ reason,
1397
+ source,
1398
+ added: {
1399
+ steps: addSteps,
1400
+ expectedFiles: filesToAdd,
1401
+ expectedGlobs: globsToAdd,
1402
+ constraints: addConstraints,
1403
+ risks: addRisks,
1404
+ },
1405
+ removed: {
1406
+ steps: removeSteps,
1407
+ expectedFiles: filesToRemove,
1408
+ expectedGlobs: globsToRemove,
1409
+ constraints: removeConstraints,
1410
+ risks: removeRisks,
1411
+ },
1412
+ },
1413
+ };
1414
+ }
1415
+ function proposalLedger(contract) {
1416
+ return Array.isArray(contract.planAmendmentProposals)
1417
+ ? contract.planAmendmentProposals
1418
+ : [];
1419
+ }
1420
+ function persistSession(projectRoot, session) {
1421
+ (0, node_fs_1.writeFileSync)(sessionPath(projectRoot, session.sessionId), JSON.stringify(session, null, 2) + '\n', 'utf8');
1422
+ }
1423
+ function createPendingPlanProposal(args) {
1424
+ const existingPending = proposalLedger(args.session.contract).find((proposal) => proposal.status === 'pending' &&
1425
+ proposal.previousRevision === currentAgentPlanRevision(args.session.contract) &&
1426
+ !planMateriallyChanged(proposal.proposedPlan, args.proposedPlan));
1427
+ if (existingPending)
1428
+ return existingPending;
1429
+ const proposal = {
1430
+ proposalId: makePlanEventId('replan_proposal'),
1431
+ sessionId: args.session.sessionId,
1432
+ previousRevision: currentAgentPlanRevision(args.session.contract),
1433
+ action: args.action,
1434
+ proposedBy: args.proposedBy,
1435
+ source: args.source,
1436
+ reason: args.reason,
1437
+ proposedPlan: args.proposedPlan,
1438
+ risk: args.risk,
1439
+ status: 'pending',
1440
+ createdAt: args.createdAt,
1441
+ };
1442
+ args.session.contract.planAmendmentProposals = [
1443
+ ...proposalLedger(args.session.contract),
1444
+ proposal,
1445
+ ];
1446
+ args.session.events.push({
1447
+ type: 'plan_amendment_proposed',
1448
+ ts: args.createdAt,
1449
+ message: `Agent plan amendment proposed (${proposal.risk.level} risk, human decision required)`,
1450
+ detail: { planAmendmentProposal: proposal },
1451
+ });
1452
+ persistSession(args.projectRoot, args.session);
1453
+ return proposal;
1454
+ }
1455
+ function applyOrProposeAgentPlan(args) {
1456
+ const previousRevision = currentAgentPlanRevision(args.session.contract);
1457
+ const risk = classifyAgentPlanAmendment(args.session.contract, args.plan);
1458
+ if (args.proposedBy === 'agent' && risk.requiresHumanApproval) {
1459
+ const proposal = createPendingPlanProposal({
1460
+ projectRoot: args.projectRoot,
1461
+ session: args.session,
1462
+ action: args.action,
1463
+ proposedPlan: args.plan,
1464
+ reason: args.reason,
1465
+ source: args.source,
1466
+ proposedBy: args.proposedBy,
1467
+ risk,
1468
+ createdAt: args.amendedAt,
1469
+ });
1470
+ return {
1471
+ sessionId: args.session.sessionId,
1472
+ previousRevision,
1473
+ revision: null,
1474
+ action: args.action,
1475
+ reason: args.reason,
1476
+ eventId: proposal.proposalId,
1477
+ status: 'pending',
1478
+ risk,
1479
+ activePlan: args.session.contract.agentPlan ?? null,
1480
+ proposal,
1481
+ };
1482
+ }
1483
+ const updated = recordAgentPlanRevision({
1484
+ projectRoot: args.projectRoot,
1485
+ session: args.session,
1486
+ plan: args.plan,
1487
+ kind: 'amended',
1488
+ reason: args.reason,
1489
+ source: args.source,
1490
+ eventType: 'plan_amended',
1491
+ amendment: {
1492
+ ...args.amendment,
1493
+ proposedBy: args.proposedBy,
1494
+ decidedBy: args.decidedBy || (args.proposedBy === 'human' ? 'local-human' : null),
1495
+ risk,
1496
+ },
1497
+ });
1498
+ const revision = currentAgentPlanRevision(updated.contract);
1499
+ const event = updated.events[updated.events.length - 1];
1500
+ return {
1501
+ sessionId: updated.sessionId,
1502
+ previousRevision,
1503
+ revision,
1504
+ action: args.action,
1505
+ reason: args.reason,
1506
+ eventId: String(event.detail?.eventId || ''),
1507
+ status: 'applied',
1508
+ risk,
1509
+ activePlan: updated.contract.agentPlan,
1510
+ };
1511
+ }
1512
+ /**
1513
+ * Capture an agent-emitted plan from the live hook path. The initial plan is
1514
+ * accepted as revision 1. Later safe refinements apply automatically, while
1515
+ * risky agent-authored expansions remain pending until a human decides.
1516
+ */
1517
+ function captureAgentPlan(projectRoot, sessionId, plan) {
1518
+ const session = loadSession(projectRoot, sessionId);
1519
+ if (!session)
1520
+ return null;
1521
+ if (!session.contract.agentPlan) {
1522
+ const updated = attachAgentPlan(projectRoot, sessionId, plan);
1523
+ return updated ? { session: updated, status: 'captured' } : null;
1524
+ }
1525
+ if (!planMateriallyChanged(session.contract.agentPlan, plan)) {
1526
+ return { session, status: 'unchanged' };
1527
+ }
1528
+ const result = applyOrProposeAgentPlan({
1529
+ projectRoot,
1530
+ session,
1531
+ action: 'replace',
1532
+ plan,
1533
+ reason: 'agent published an updated plan',
1534
+ source: plan.source || 'unknown',
1535
+ proposedBy: 'agent',
1536
+ amendedAt: plan.capturedAt || new Date().toISOString(),
1537
+ amendment: {
1538
+ action: 'replace',
1539
+ reason: 'agent published an updated plan',
1540
+ source: plan.source || 'unknown',
1541
+ },
1542
+ });
1543
+ const updated = loadSession(projectRoot, sessionId) || session;
1544
+ return {
1545
+ session: updated,
1546
+ status: result.status,
1547
+ proposal: result.proposal,
1548
+ };
1549
+ }
1550
+ /**
1551
+ * Amend the active agent plan for a live session. This is the user/agent
1552
+ * re-plan path: it updates `contract.agentPlan` immediately, appends a
1553
+ * source-free revision, and records a `plan_amended` event for replay.
1554
+ */
1555
+ function amendAgentPlan(projectRoot, input) {
1556
+ const session = input.sessionId
1557
+ ? loadSession(projectRoot, input.sessionId)
1558
+ : loadActiveSession(projectRoot);
1559
+ if (!session)
1560
+ throw new Error('No active session found. Start a governed task first.');
1561
+ if (session.status !== 'active')
1562
+ throw new Error(`Session ${session.sessionId} is already finished.`);
1563
+ const reason = input.reason?.trim() || 'plan updated during session';
1564
+ const { action, plan, amendment } = deriveAmendedPlan(session.contract.agentPlan, input);
1565
+ const source = input.source || 'manual';
1566
+ const proposedBy = input.proposedBy || 'human';
1567
+ const amendedAt = input.amendedAt || plan.capturedAt || new Date().toISOString();
1568
+ return applyOrProposeAgentPlan({
1569
+ projectRoot,
1570
+ session,
1571
+ action,
1572
+ plan: {
1573
+ ...plan,
1574
+ capturedAt: amendedAt,
1575
+ source,
1576
+ },
1577
+ reason,
1578
+ source,
1579
+ proposedBy,
1580
+ amendedAt,
1581
+ decidedBy: input.decidedBy,
1582
+ amendment: {
1583
+ ...amendment,
1584
+ reason,
1585
+ amendedAt,
1586
+ activePlan: {
1587
+ ...plan,
1588
+ capturedAt: amendedAt,
1589
+ source,
1590
+ },
1591
+ },
1592
+ });
1593
+ }
1594
+ function decideAgentPlanAmendment(projectRoot, input) {
1595
+ const session = input.sessionId
1596
+ ? loadSession(projectRoot, input.sessionId)
1597
+ : loadActiveSession(projectRoot);
1598
+ if (!session)
1599
+ throw new Error('No active session found. Start a governed task first.');
1600
+ if (session.status !== 'active')
1601
+ throw new Error(`Session ${session.sessionId} is already finished.`);
1602
+ const proposals = proposalLedger(session.contract);
1603
+ const proposal = proposals.find((entry) => entry.proposalId === input.proposalId);
1604
+ if (!proposal)
1605
+ throw new Error(`Plan amendment proposal ${input.proposalId} was not found.`);
1606
+ if (proposal.status !== 'pending') {
1607
+ throw new Error(`Plan amendment proposal ${input.proposalId} is already ${proposal.status}.`);
1608
+ }
1609
+ const decidedAt = input.decidedAt || new Date().toISOString();
1610
+ const decidedBy = input.decidedBy?.trim() || 'human';
1611
+ const reason = input.reason?.trim() || `human ${input.decision}ed plan amendment`;
1612
+ proposal.status = input.decision === 'accept' ? 'accepted' : 'rejected';
1613
+ proposal.decidedAt = decidedAt;
1614
+ proposal.decidedBy = decidedBy;
1615
+ proposal.decisionReason = reason;
1616
+ if (input.decision === 'reject') {
1617
+ session.events.push({
1618
+ type: 'plan_amendment_decision',
1619
+ ts: decidedAt,
1620
+ decision: 'rejected',
1621
+ message: `Plan amendment ${proposal.proposalId} rejected by ${decidedBy}`,
1622
+ detail: { planAmendmentProposal: proposal },
1623
+ });
1624
+ persistSession(projectRoot, session);
1625
+ return {
1626
+ sessionId: session.sessionId,
1627
+ proposalId: proposal.proposalId,
1628
+ decision: input.decision,
1629
+ status: proposal.status,
1630
+ previousRevision: currentAgentPlanRevision(session.contract),
1631
+ revision: null,
1632
+ activePlan: session.contract.agentPlan ?? null,
1633
+ };
1634
+ }
1635
+ const previousRevision = currentAgentPlanRevision(session.contract);
1636
+ const updated = recordAgentPlanRevision({
1637
+ projectRoot,
1638
+ session,
1639
+ plan: {
1640
+ ...proposal.proposedPlan,
1641
+ capturedAt: decidedAt,
1642
+ source: input.source || proposal.source,
1643
+ },
1644
+ kind: 'amended',
1645
+ reason,
1646
+ source: input.source || proposal.source,
1647
+ eventType: 'plan_amended',
1648
+ amendment: {
1649
+ action: proposal.action,
1650
+ proposalId: proposal.proposalId,
1651
+ proposedBy: proposal.proposedBy,
1652
+ decidedBy,
1653
+ decisionReason: reason,
1654
+ risk: proposal.risk,
1655
+ },
1656
+ });
1657
+ const revision = currentAgentPlanRevision(updated.contract);
1658
+ proposal.appliedRevision = revision;
1659
+ updated.events.push({
1660
+ type: 'plan_amendment_decision',
1661
+ ts: decidedAt,
1662
+ decision: 'accepted',
1663
+ message: `Plan amendment ${proposal.proposalId} accepted by ${decidedBy}`,
1664
+ detail: { planAmendmentProposal: proposal, appliedRevision: revision },
1665
+ });
1666
+ persistSession(projectRoot, updated);
1667
+ return {
1668
+ sessionId: updated.sessionId,
1669
+ proposalId: proposal.proposalId,
1670
+ decision: input.decision,
1671
+ status: proposal.status,
1672
+ previousRevision,
1673
+ revision,
1674
+ activePlan: updated.contract.agentPlan ?? null,
1675
+ };
1676
+ }
1677
+ /**
1678
+ * Plan/edit coherence for a session: does this edit follow the agent's own plan?
1679
+ *
1680
+ * Maps the session's intent-support scope into the deterministic plan-coherence
1681
+ * evaluator. Boundary/approval blocks always override this advisory verdict at
1682
+ * the call site; an `unplanned` verdict must not block on its own in V1.
1683
+ */
1684
+ function evaluateSessionPlanCoherence(contract, filePath) {
1685
+ return (0, agent_plan_1.evaluatePlanCoherence)({
1686
+ agentPlan: contract.agentPlan ?? null,
1687
+ filePath,
1688
+ intentSupportGlobs: contract.intentContract?.target.supportPathGlobs ?? [],
1689
+ });
1690
+ }
1691
+ function evaluatePlanCoherencePolicy(mode, planCoherence) {
1692
+ const effectiveMode = mode ?? profile_1.DEFAULT_PLAN_COHERENCE_MODE;
1693
+ if (effectiveMode === 'off') {
1694
+ return {
1695
+ mode: effectiveMode,
1696
+ action: 'none',
1697
+ reason: 'Plan coherence enforcement is disabled for this session.',
1698
+ };
1699
+ }
1700
+ if (planCoherence.verdict !== 'unplanned') {
1701
+ return {
1702
+ mode: effectiveMode,
1703
+ action: 'none',
1704
+ reason: `Plan coherence verdict is ${planCoherence.verdict}; no policy action required.`,
1705
+ };
1706
+ }
1707
+ return {
1708
+ mode: effectiveMode,
1709
+ action: effectiveMode,
1710
+ reason: planCoherence.reasons[0] || 'Path is not justified by the captured agent plan.',
1711
+ };
1712
+ }
1713
+ /**
1714
+ * Latest non-reverted agent-plan revision for a contract. `0` means no agent
1715
+ * plan has been captured yet. Public wrapper around the internal resolver so
1716
+ * callers (hooks, evidence, dashboard) can record which plan version was active.
1717
+ */
1718
+ function activeAgentPlanRevision(contract) {
1719
+ return currentAgentPlanRevision(contract);
1720
+ }
1721
+ function timelineKindRank(kind) {
1722
+ switch (kind) {
1723
+ case 'intent':
1724
+ return 0;
1725
+ case 'plan_captured':
1726
+ case 'plan_amended':
1727
+ return 1;
1728
+ case 'amendment_proposed':
1729
+ return 2;
1730
+ case 'amendment_accepted':
1731
+ case 'amendment_rejected':
1732
+ return 3;
1733
+ default:
1734
+ return 4;
1735
+ }
1736
+ }
1737
+ function activeRevisionAt(ledger, ts) {
1738
+ let active = 0;
1739
+ for (const entry of ledger) {
1740
+ const capturedAt = entry.capturedAt || '';
1741
+ if (capturedAt && capturedAt <= ts && entry.revision > active) {
1742
+ active = entry.revision;
1743
+ }
1744
+ }
1745
+ return active;
1746
+ }
1747
+ function shortLabel(value, max = 120) {
1748
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
1749
+ if (text.length <= max)
1750
+ return text;
1751
+ return `${text.slice(0, max - 1)}…`;
1752
+ }
1753
+ /**
1754
+ * Build a source-free plan timeline from a governance session.
1755
+ *
1756
+ * Derived purely from data already persisted on the session (goal, intent
1757
+ * contract, plan revision ledger, amendment proposals, and boundary-check
1758
+ * events). No source, diffs, or file contents are read or emitted — only the
1759
+ * summaries, paths, and revision numbers that already live in the record.
1760
+ *
1761
+ * The timeline reads: Intent → Plan v1 → Amendment v2 → Block / Warning /
1762
+ * Approval, with every milestone tagged with the plan revision that was active
1763
+ * when it occurred.
1764
+ */
1765
+ function buildPlanTimeline(session) {
1766
+ const contract = session.contract;
1767
+ const ledger = planRevisionLedger(contract);
1768
+ const proposals = proposalLedger(contract);
1769
+ const events = Array.isArray(session.events) ? session.events : [];
1770
+ const startEvent = events.find((event) => event.type === 'session_start');
1771
+ const intentTs = startEvent?.ts || contract.intentContract?.createdAt || ledger[0]?.capturedAt || '';
1772
+ const intentSummary = shortLabel(contract.intentContract?.summary || contract.goal || 'No intent captured');
1773
+ const entries = [];
1774
+ // 1. Initial intent record.
1775
+ entries.push({
1776
+ kind: 'intent',
1777
+ ts: intentTs,
1778
+ activePlanRevision: 0,
1779
+ label: intentSummary,
1780
+ source: contract.intentContract ? 'intent' : 'goal',
1781
+ });
1782
+ // 2. Every plan version (never overwritten; older revisions are preserved).
1783
+ for (const entry of ledger) {
1784
+ entries.push({
1785
+ kind: entry.kind === 'amended' ? 'plan_amended' : 'plan_captured',
1786
+ ts: entry.capturedAt || intentTs,
1787
+ activePlanRevision: entry.revision,
1788
+ revision: entry.revision,
1789
+ label: shortLabel(entry.plan?.summary || entry.reason || `plan revision ${entry.revision}`),
1790
+ source: entry.source,
1791
+ });
1792
+ }
1793
+ // 3. Amendment proposals + their human decisions.
1794
+ for (const proposal of proposals) {
1795
+ const proposedAt = proposal.createdAt || intentTs;
1796
+ entries.push({
1797
+ kind: 'amendment_proposed',
1798
+ ts: proposedAt,
1799
+ activePlanRevision: activeRevisionAt(ledger, proposedAt),
1800
+ label: shortLabel(`${proposal.risk?.level || 'unknown'} risk: ${proposal.reason || 'plan amendment proposed'}`),
1801
+ source: proposal.source,
1802
+ });
1803
+ if (proposal.status !== 'pending' && proposal.decidedAt) {
1804
+ entries.push({
1805
+ kind: proposal.status === 'accepted' ? 'amendment_accepted' : 'amendment_rejected',
1806
+ ts: proposal.decidedAt,
1807
+ activePlanRevision: activeRevisionAt(ledger, proposal.decidedAt),
1808
+ revision: proposal.appliedRevision ?? undefined,
1809
+ label: shortLabel(proposal.decisionReason || `amendment ${proposal.status} by ${proposal.decidedBy || 'human'}`),
1810
+ source: proposal.source,
1811
+ });
1812
+ }
1813
+ }
1814
+ // 4. Boundary check outcomes that matter for review: blocks, drift warnings,
1815
+ // approvals, and obligation waivers — each tagged with the active plan.
1816
+ for (const event of events) {
1817
+ const ts = event.ts || intentTs;
1818
+ const activePlanRevision = activeRevisionAt(ledger, ts);
1819
+ if (event.type === 'check_block') {
1820
+ entries.push({
1821
+ kind: 'boundary_block',
1822
+ ts,
1823
+ activePlanRevision,
1824
+ label: shortLabel(event.message || event.filePath || 'edit blocked'),
1825
+ filePath: event.filePath,
1826
+ verdict: event.verdict,
1827
+ });
1828
+ }
1829
+ else if (event.type === 'check_warn') {
1830
+ entries.push({
1831
+ kind: 'drift_warning',
1832
+ ts,
1833
+ activePlanRevision,
1834
+ label: shortLabel(event.message || event.filePath || 'edit warning'),
1835
+ filePath: event.filePath,
1836
+ verdict: event.verdict,
1837
+ });
1838
+ }
1839
+ else if (event.type === 'approval_decision') {
1840
+ entries.push({
1841
+ kind: 'approval',
1842
+ ts,
1843
+ activePlanRevision,
1844
+ label: shortLabel(event.message || event.filePath || 'approval recorded'),
1845
+ filePath: event.filePath,
1846
+ verdict: event.decision,
1847
+ });
1848
+ }
1849
+ else if (event.type === 'obligation_waiver_decision') {
1850
+ entries.push({
1851
+ kind: 'obligation_waiver',
1852
+ ts,
1853
+ activePlanRevision,
1854
+ label: shortLabel(event.message || 'architecture obligation waived'),
1855
+ });
1856
+ }
1857
+ }
1858
+ entries.sort((a, b) => {
1859
+ if (a.ts !== b.ts)
1860
+ return a.ts < b.ts ? -1 : 1;
1861
+ return timelineKindRank(a.kind) - timelineKindRank(b.kind);
1862
+ });
1863
+ return {
1864
+ sessionId: session.sessionId,
1865
+ intentSummary,
1866
+ activePlanRevision: currentAgentPlanRevision(contract),
1867
+ planVersions: ledger.length,
1868
+ amendmentCount: ledger.filter((entry) => entry.kind === 'amended').length,
1869
+ pendingAmendmentCount: proposals.filter((proposal) => proposal.status === 'pending').length,
1870
+ driftWarningCount: events.filter((event) => event.type === 'check_warn').length,
1871
+ blockedBoundaryCount: events.filter((event) => event.type === 'check_block').length,
1872
+ approvalCount: events.filter((event) => event.type === 'approval_decision').length,
1873
+ entries,
1874
+ };
1875
+ }
1876
+ function deriveAllowedGlobs(goal, profile) {
1877
+ const lower = goal.toLowerCase();
1878
+ const approvalPrefixes = profile.approvalRequiredPaths.map((g) => g.replace('/**', '').replace('/*', ''));
1879
+ const safeSupportGlobs = [
1880
+ 'src/util/**',
1881
+ 'src/utils/**',
1882
+ 'src/helpers/**',
1883
+ 'src/lib/**',
1884
+ 'lib/**',
1885
+ 'tests/**',
1886
+ 'test/**',
1887
+ ...(profile.runtimeConfig?.safeSupportGlobs ?? []),
1888
+ ];
1889
+ const sourceRootPrefixes = deriveSourceRootPrefixes(profile);
1890
+ function expandNestedSourceGlobs(globs) {
1891
+ const expanded = new Set();
1892
+ for (const glob of globs) {
1893
+ expanded.add(glob);
1894
+ if (!glob.startsWith('src/'))
1895
+ continue;
1896
+ for (const prefix of sourceRootPrefixes) {
1897
+ expanded.add(`${prefix}${glob}`);
1898
+ }
1899
+ }
1900
+ return Array.from(expanded);
1901
+ }
1902
+ // Helper: remove any glob that overlaps with an approval-required prefix.
1903
+ // A glob like "src/billing/**" overlaps "src/billing"; "src/**" does NOT start
1904
+ // with "src/billing" so it passes — but "src/**" would contain billing inside it.
1905
+ // We therefore do a two-way check: glob-prefix starts with ap-prefix OR ap-prefix
1906
+ // starts with glob-prefix (the latter catches broad globs that contain sensitive dirs).
1907
+ function excludeApprovalRequired(globs) {
1908
+ return globs.filter((g) => {
1909
+ const gPrefix = g.replace('/**', '').replace('/*', '');
1910
+ return !approvalPrefixes.some((ap) => gPrefix.startsWith(ap) || ap.startsWith(gPrefix + '/'));
1911
+ });
1912
+ }
1913
+ // ── Explicit path tokens in the goal (highest confidence) ───────────────────
1914
+ // Two cases:
1915
+ // file path "modify src/tasks/export_task.py" → allow exactly "src/tasks/export_task.py"
1916
+ // dir path "refactor src/tasks" → allow "src/tasks/**"
1917
+ //
1918
+ // A token is a file path when the last segment contains a '.' (i.e. has an extension).
1919
+ // We must NOT strip the extension and append /**, which was the bug that turned
1920
+ // "src/tasks/export_task.py" into "src/tasks/export_task/**" and then blocked
1921
+ // the exact file the user named.
1922
+ const pathTokens = extractPathTokens(goal);
1923
+ if (pathTokens.length > 0) {
1924
+ const exclusiveExplicitScope = hasExclusiveScopeCue(lower);
1925
+ const expanded = pathTokens.map((t) => {
1926
+ const normalised = t.replace(/^\//, '');
1927
+ return isFileScopeToken(normalised) ? normalised : normalised.replace(/\/$/, '') + '/**';
1928
+ });
1929
+ const globs = excludeApprovalRequired(expandNestedSourceGlobs([
1930
+ ...expanded,
1931
+ ...(exclusiveExplicitScope ? [] : safeSupportGlobs),
1932
+ ]));
1933
+ if (globs.length > 0)
1934
+ return { allowedGlobs: globs, scopeMode: 'explicit' };
1935
+ }
1936
+ // ── Keyword inference ────────────────────────────────────────────────────────
1937
+ // Match whole words only to avoid "services" matching "service" inside "user_services_old".
1938
+ const wordOf = (kw) => new RegExp(`\\b${kw}\\b`, 'i').test(lower);
1939
+ const DIR_KEYWORDS = [
1940
+ [/\btasks?\b/i, 'src/tasks/**'],
1941
+ [/\bservices?\b/i, 'src/services/**'],
1942
+ [/\bhandlers?\b/i, 'src/handlers/**'],
1943
+ [/\broutes?\b/i, 'src/routes/**'],
1944
+ [/\bcontrollers?\b/i, 'src/controllers/**'],
1945
+ [/\bmodels?\b/i, 'src/models/**'],
1946
+ [/\bschemas?\b/i, 'src/schemas/**'],
1947
+ [/\bcomponents?\b/i, 'src/components/**'],
1948
+ [/\bpages?\b/i, 'src/pages/**'],
1949
+ [/\bworkers?\b/i, 'src/workers/**'],
1950
+ [/\bjobs?\b/i, 'src/jobs/**'],
1951
+ [/\bapi\b/i, 'src/api/**'],
1952
+ ];
1953
+ const matched = new Set();
1954
+ for (const [re, glob] of DIR_KEYWORDS) {
1955
+ if (re.test(lower))
1956
+ matched.add(glob);
1957
+ }
1958
+ if (matched.size > 0) {
1959
+ // Always include non-sensitive support dirs alongside the primary match.
1960
+ // These are genuinely low-risk and blocking them in a task-focused session
1961
+ // is pure noise.
1962
+ for (const support of safeSupportGlobs) {
1963
+ matched.add(support);
1964
+ }
1965
+ const globs = excludeApprovalRequired(expandNestedSourceGlobs(Array.from(matched)));
1966
+ return { allowedGlobs: globs, scopeMode: 'inferred' };
1967
+ }
1968
+ // ── Ambiguous fallback ───────────────────────────────────────────────────────
1969
+ //
1970
+ // We cannot infer a meaningful scope from the goal.
1971
+ // Do NOT use broad globs like src/** — they silently contain approval-required
1972
+ // subdirectories, and the prefix-exclusion filter above cannot catch
1973
+ // "src/**" ⊇ "src/billing/**" because "src/billing".startsWith("src") but
1974
+ // `excludeApprovalRequired` only checks that gPrefix.startsWith(ap), and
1975
+ // "src" does NOT start with "src/billing". The broad glob slips through.
1976
+ //
1977
+ // Instead: return only the safe, non-sensitive leaf directories that
1978
+ // actually exist in the profile (derived from file paths, not hardcoded).
1979
+ // scopeMode='ambiguous' additionally causes the boundary check to treat
1980
+ // any approval-required path as a hard block even if it appears in-scope.
1981
+ const safeDirs = deriveSafeDirs(profile);
1982
+ return { allowedGlobs: safeDirs, scopeMode: 'ambiguous' };
1983
+ }
1984
+ function deriveSourceRootPrefixes(profile) {
1985
+ const candidates = [
1986
+ ...profile.approvalRequiredPaths,
1987
+ ...profile.sensitiveBoundaries.map((boundary) => boundary.glob),
1988
+ ...profile.ownershipBoundaries.map((boundary) => boundary.glob),
1989
+ ];
1990
+ const prefixes = new Set();
1991
+ for (const raw of candidates) {
1992
+ const glob = raw.replace(/^\//, '').replace(/\\/g, '/');
1993
+ const index = glob.indexOf('src/');
1994
+ if (index <= 0)
1995
+ continue;
1996
+ prefixes.add(glob.slice(0, index));
1997
+ }
1998
+ return Array.from(prefixes).sort();
1999
+ }
2000
+ /**
2001
+ * Derive a list of top-level source directories that are provably NOT
2002
+ * approval-required, by inspecting the profile's actual file paths.
2003
+ *
2004
+ * This replaces the dangerous src/** fallback.
2005
+ */
2006
+ function deriveSafeDirs(profile) {
2007
+ // All approval-required prefixes (without trailing /**)
2008
+ const approvalPrefixes = profile.approvalRequiredPaths.map((g) => g.replace('/**', '').replace('/*', ''));
2009
+ const sensitive = profile.sensitiveBoundaries.map((s) => s.glob.replace('/**', '').replace('/*', ''));
2010
+ const blocked = new Set([...approvalPrefixes, ...sensitive]);
2011
+ // Collect non-sensitive top-level directories from the profile
2012
+ // (We don't have the path list here, so we derive from known-safe patterns
2013
+ // that are guaranteed not to overlap with approval-required paths.)
2014
+ // Common non-sensitive source directories that are safe to allow by default.
2015
+ // This list is intentionally inclusive for normal source code — the approval
2016
+ // gate in checkFileBoundary provides the second line of defence for any
2017
+ // sensitive subdirectory that might sit inside one of these.
2018
+ const candidates = [
2019
+ 'src/tasks',
2020
+ 'src/task',
2021
+ 'src/jobs',
2022
+ 'src/workers',
2023
+ 'src/handlers',
2024
+ 'src/controllers',
2025
+ 'src/routes',
2026
+ 'src/api',
2027
+ 'src/services',
2028
+ 'src/models',
2029
+ 'src/schemas',
2030
+ 'src/components',
2031
+ 'src/pages',
2032
+ 'src/util',
2033
+ 'src/utils',
2034
+ 'src/helpers',
2035
+ 'src/lib',
2036
+ 'src/common',
2037
+ 'src/shared',
2038
+ 'lib',
2039
+ 'tests',
2040
+ 'test',
2041
+ ];
2042
+ return [
2043
+ ...candidates.map((c) => c + '/**'),
2044
+ ...deriveSourceRootPrefixes(profile).flatMap((prefix) => candidates
2045
+ .filter((candidate) => candidate.startsWith('src/'))
2046
+ .map((candidate) => `${prefix}${candidate}/**`)),
2047
+ ...(profile.runtimeConfig?.safeSupportGlobs ?? []),
2048
+ ]
2049
+ .filter((c) => !blocked.has(c) && !approvalPrefixes.some((ap) => c.startsWith(ap + '/')))
2050
+ .filter((glob, index, all) => all.indexOf(glob) === index);
2051
+ }
2052
+ //# sourceMappingURL=session.js.map