@neurcode-ai/governance-runtime 0.1.3 → 0.1.5

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