@neurcode-ai/cli 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -8
- package/dist/api-client.d.ts +284 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +111 -0
- package/dist/api-client.js.map +1 -1
- package/dist/commands/activate.d.ts +82 -0
- package/dist/commands/activate.d.ts.map +1 -0
- package/dist/commands/activate.js +551 -0
- package/dist/commands/activate.js.map +1 -0
- package/dist/commands/admission.d.ts +67 -0
- package/dist/commands/admission.d.ts.map +1 -0
- package/dist/commands/admission.js +350 -0
- package/dist/commands/admission.js.map +1 -0
- package/dist/commands/agent.d.ts +3 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +2045 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/demo.d.ts +3 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +102 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +58 -44
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +44 -22
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/profile.d.ts +14 -0
- package/dist/commands/profile.d.ts.map +1 -0
- package/dist/commands/profile.js +118 -0
- package/dist/commands/profile.js.map +1 -0
- package/dist/commands/quickstart.d.ts +2 -2
- package/dist/commands/quickstart.d.ts.map +1 -1
- package/dist/commands/quickstart.js +31 -30
- package/dist/commands/quickstart.js.map +1 -1
- package/dist/commands/remediate-export.d.ts +6 -1
- package/dist/commands/remediate-export.d.ts.map +1 -1
- package/dist/commands/remediate-export.js +359 -7
- package/dist/commands/remediate-export.js.map +1 -1
- package/dist/commands/replay.d.ts.map +1 -1
- package/dist/commands/replay.js +84 -0
- package/dist/commands/replay.js.map +1 -1
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +98 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/runtime-adapter.d.ts +8 -0
- package/dist/commands/runtime-adapter.d.ts.map +1 -0
- package/dist/commands/runtime-adapter.js +375 -0
- package/dist/commands/runtime-adapter.js.map +1 -0
- package/dist/commands/runtime-doctor.d.ts +6 -0
- package/dist/commands/runtime-doctor.d.ts.map +1 -0
- package/dist/commands/runtime-doctor.js +478 -0
- package/dist/commands/runtime-doctor.js.map +1 -0
- package/dist/commands/runtime-report.d.ts +13 -0
- package/dist/commands/runtime-report.d.ts.map +1 -0
- package/dist/commands/runtime-report.js +81 -0
- package/dist/commands/runtime-report.js.map +1 -0
- package/dist/commands/runtime-sync.d.ts +17 -0
- package/dist/commands/runtime-sync.d.ts.map +1 -0
- package/dist/commands/runtime-sync.js +656 -0
- package/dist/commands/runtime-sync.js.map +1 -0
- package/dist/commands/runtime.d.ts +16 -0
- package/dist/commands/runtime.d.ts.map +1 -0
- package/dist/commands/runtime.js +380 -0
- package/dist/commands/runtime.js.map +1 -0
- package/dist/commands/session-hook.d.ts +35 -0
- package/dist/commands/session-hook.d.ts.map +1 -0
- package/dist/commands/session-hook.js +1297 -0
- package/dist/commands/session-hook.js.map +1 -0
- package/dist/commands/session.d.ts +91 -0
- package/dist/commands/session.d.ts.map +1 -1
- package/dist/commands/session.js +1226 -0
- package/dist/commands/session.js.map +1 -1
- package/dist/commands/whoami.d.ts +7 -4
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +59 -34
- package/dist/commands/whoami.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -5
- package/dist/config.js.map +1 -1
- package/dist/daemon/routes.d.ts.map +1 -1
- package/dist/daemon/routes.js +8 -0
- package/dist/daemon/routes.js.map +1 -1
- package/dist/daemon/server.d.ts.map +1 -1
- package/dist/daemon/server.js +88 -0
- package/dist/daemon/server.js.map +1 -1
- package/dist/governance/impact-analysis.d.ts +27 -0
- package/dist/governance/impact-analysis.d.ts.map +1 -0
- package/dist/governance/impact-analysis.js +274 -0
- package/dist/governance/impact-analysis.js.map +1 -0
- package/dist/index.js +472 -29
- package/dist/index.js.map +1 -1
- package/dist/intent-engine/matcher.d.ts.map +1 -1
- package/dist/intent-engine/matcher.js +3 -12
- package/dist/intent-engine/matcher.js.map +1 -1
- package/dist/utils/admission-artifact.d.ts +59 -0
- package/dist/utils/admission-artifact.d.ts.map +1 -0
- package/dist/utils/admission-artifact.js +410 -0
- package/dist/utils/admission-artifact.js.map +1 -0
- package/dist/utils/agent-adapter-setup.d.ts +80 -0
- package/dist/utils/agent-adapter-setup.d.ts.map +1 -0
- package/dist/utils/agent-adapter-setup.js +577 -0
- package/dist/utils/agent-adapter-setup.js.map +1 -0
- package/dist/utils/agent-guard-supervisor.d.ts +75 -0
- package/dist/utils/agent-guard-supervisor.d.ts.map +1 -0
- package/dist/utils/agent-guard-supervisor.js +388 -0
- package/dist/utils/agent-guard-supervisor.js.map +1 -0
- package/dist/utils/agent-guard.d.ts +92 -0
- package/dist/utils/agent-guard.d.ts.map +1 -0
- package/dist/utils/agent-guard.js +326 -0
- package/dist/utils/agent-guard.js.map +1 -0
- package/dist/utils/agent-session-launcher.d.ts +89 -0
- package/dist/utils/agent-session-launcher.d.ts.map +1 -0
- package/dist/utils/agent-session-launcher.js +308 -0
- package/dist/utils/agent-session-launcher.js.map +1 -0
- package/dist/utils/bash-command-analysis.d.ts +19 -0
- package/dist/utils/bash-command-analysis.d.ts.map +1 -0
- package/dist/utils/bash-command-analysis.js +295 -0
- package/dist/utils/bash-command-analysis.js.map +1 -0
- package/dist/utils/consequence-nudges.d.ts +30 -0
- package/dist/utils/consequence-nudges.d.ts.map +1 -0
- package/dist/utils/consequence-nudges.js +313 -0
- package/dist/utils/consequence-nudges.js.map +1 -0
- package/dist/utils/drift-intelligence.d.ts.map +1 -1
- package/dist/utils/drift-intelligence.js +29 -7
- package/dist/utils/drift-intelligence.js.map +1 -1
- package/dist/utils/git-coverage.d.ts +57 -0
- package/dist/utils/git-coverage.d.ts.map +1 -0
- package/dist/utils/git-coverage.js +302 -0
- package/dist/utils/git-coverage.js.map +1 -0
- package/dist/utils/gitignore.d.ts.map +1 -1
- package/dist/utils/gitignore.js +2 -1
- package/dist/utils/gitignore.js.map +1 -1
- package/dist/utils/governed-intent.d.ts +10 -0
- package/dist/utils/governed-intent.d.ts.map +1 -0
- package/dist/utils/governed-intent.js +108 -0
- package/dist/utils/governed-intent.js.map +1 -0
- package/dist/utils/hook-heartbeat.d.ts +55 -0
- package/dist/utils/hook-heartbeat.d.ts.map +1 -0
- package/dist/utils/hook-heartbeat.js +116 -0
- package/dist/utils/hook-heartbeat.js.map +1 -0
- package/dist/utils/intent-continuity.d.ts +21 -0
- package/dist/utils/intent-continuity.d.ts.map +1 -0
- package/dist/utils/intent-continuity.js +192 -0
- package/dist/utils/intent-continuity.js.map +1 -0
- package/dist/utils/messages.d.ts +1 -1
- package/dist/utils/messages.d.ts.map +1 -1
- package/dist/utils/messages.js +24 -21
- package/dist/utils/messages.js.map +1 -1
- package/dist/utils/runtime-companion.d.ts +137 -0
- package/dist/utils/runtime-companion.d.ts.map +1 -0
- package/dist/utils/runtime-companion.js +231 -0
- package/dist/utils/runtime-companion.js.map +1 -0
- package/dist/utils/runtime-connection.d.ts +46 -0
- package/dist/utils/runtime-connection.d.ts.map +1 -0
- package/dist/utils/runtime-connection.js +148 -0
- package/dist/utils/runtime-connection.js.map +1 -0
- package/dist/utils/runtime-evidence.d.ts +68 -0
- package/dist/utils/runtime-evidence.d.ts.map +1 -0
- package/dist/utils/runtime-evidence.js +248 -0
- package/dist/utils/runtime-evidence.js.map +1 -0
- package/dist/utils/runtime-live.d.ts +33 -0
- package/dist/utils/runtime-live.d.ts.map +1 -0
- package/dist/utils/runtime-live.js +361 -0
- package/dist/utils/runtime-live.js.map +1 -0
- package/dist/utils/runtime-outbox.d.ts +76 -0
- package/dist/utils/runtime-outbox.d.ts.map +1 -0
- package/dist/utils/runtime-outbox.js +410 -0
- package/dist/utils/runtime-outbox.js.map +1 -0
- package/dist/utils/runtime-receipt.d.ts +50 -0
- package/dist/utils/runtime-receipt.d.ts.map +1 -0
- package/dist/utils/runtime-receipt.js +223 -0
- package/dist/utils/runtime-receipt.js.map +1 -0
- package/dist/utils/state.d.ts +21 -0
- package/dist/utils/state.d.ts.map +1 -1
- package/dist/utils/state.js +30 -0
- package/dist/utils/state.js.map +1 -1
- package/dist/utils/structural-understanding.d.ts +334 -0
- package/dist/utils/structural-understanding.d.ts.map +1 -0
- package/dist/utils/structural-understanding.js +2316 -0
- package/dist/utils/structural-understanding.js.map +1 -0
- package/dist/utils/v0-governance.d.ts +197 -0
- package/dist/utils/v0-governance.d.ts.map +1 -0
- package/dist/utils/v0-governance.js +904 -0
- package/dist/utils/v0-governance.js.map +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* neurcode session-hook (internal — called by Claude Code hooks, not by users)
|
|
4
|
+
*
|
|
5
|
+
* Sub-commands:
|
|
6
|
+
* start — UserPromptSubmit: create a session from the user's prompt
|
|
7
|
+
* check — PreToolUse: check a pending Edit/Write before it lands
|
|
8
|
+
* finish — Stop: finalize the session and write the replay record
|
|
9
|
+
*
|
|
10
|
+
* Claude Code hook protocol (stdin → JSON, stdout → JSON):
|
|
11
|
+
* PreToolUse exit 0 + { permissionDecision: "deny" } → block the edit
|
|
12
|
+
* PreToolUse exit 0 (no deny) → allow
|
|
13
|
+
* UserPromptSubmit / Stop → side-effect only; always exit 0
|
|
14
|
+
*
|
|
15
|
+
* Fail-open policy:
|
|
16
|
+
* Governance errors must not break the agent. We fail open (allow) and
|
|
17
|
+
* emit a stderr diagnostic so the developer can diagnose the issue.
|
|
18
|
+
* The exceptions: when a session IS active and the boundary or configured
|
|
19
|
+
* plan-coherence policy returns 'block', we deny — intentional enforcement.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.resolveSessionForHook = resolveSessionForHook;
|
|
23
|
+
exports.normalizeHookFilePathForRepo = normalizeHookFilePathForRepo;
|
|
24
|
+
exports.hookFilePathCandidates = hookFilePathCandidates;
|
|
25
|
+
exports.shouldKeepSessionActiveForPendingApproval = shouldKeepSessionActiveForPendingApproval;
|
|
26
|
+
exports.sessionHookCommand = sessionHookCommand;
|
|
27
|
+
const child_process_1 = require("child_process");
|
|
28
|
+
const fs_1 = require("fs");
|
|
29
|
+
const path_1 = require("path");
|
|
30
|
+
const governance_runtime_1 = require("@neurcode-ai/governance-runtime");
|
|
31
|
+
const v0_governance_1 = require("../utils/v0-governance");
|
|
32
|
+
const runtime_connection_1 = require("../utils/runtime-connection");
|
|
33
|
+
const admission_artifact_1 = require("../utils/admission-artifact");
|
|
34
|
+
const hook_heartbeat_1 = require("../utils/hook-heartbeat");
|
|
35
|
+
const runtime_live_1 = require("../utils/runtime-live");
|
|
36
|
+
const agent_session_launcher_1 = require("../utils/agent-session-launcher");
|
|
37
|
+
const governed_intent_1 = require("../utils/governed-intent");
|
|
38
|
+
const intent_continuity_1 = require("../utils/intent-continuity");
|
|
39
|
+
const bash_command_analysis_1 = require("../utils/bash-command-analysis");
|
|
40
|
+
const diff_parser_1 = require("@neurcode-ai/diff-parser");
|
|
41
|
+
const structural_understanding_1 = require("../utils/structural-understanding");
|
|
42
|
+
const consequence_nudges_1 = require("../utils/consequence-nudges");
|
|
43
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
44
|
+
/** Read the full hook JSON from stdin, or return {} on any error. */
|
|
45
|
+
function readHookInput() {
|
|
46
|
+
try {
|
|
47
|
+
const raw = (0, fs_1.readFileSync)('/dev/stdin', 'utf8');
|
|
48
|
+
if (raw.trim())
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
catch { /* stdin closed or not JSON */ }
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Extract the working directory from the hook input.
|
|
56
|
+
*
|
|
57
|
+
* Claude Code injects a `cwd` field into every hook payload containing the
|
|
58
|
+
* directory in which the agent is running. We use it to resolve the repo root
|
|
59
|
+
* when the hook is invoked from a location other than the repo root.
|
|
60
|
+
*/
|
|
61
|
+
function cwdFromHookInput(hookInput, fallback) {
|
|
62
|
+
const raw = hookInput['cwd'];
|
|
63
|
+
if (typeof raw === 'string' && raw.trim() && (0, path_1.isAbsolute)(raw.trim())) {
|
|
64
|
+
return raw.trim();
|
|
65
|
+
}
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
function sessionIdFromHookInput(hookInput) {
|
|
69
|
+
const raw = hookInput['session_id'] ||
|
|
70
|
+
hookInput['sessionId'];
|
|
71
|
+
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
|
72
|
+
return trimmed || undefined;
|
|
73
|
+
}
|
|
74
|
+
function resolveSessionForHook(repoRoot, requestedSessionId) {
|
|
75
|
+
if (requestedSessionId) {
|
|
76
|
+
const requested = (0, governance_runtime_1.loadSession)(repoRoot, requestedSessionId);
|
|
77
|
+
if (requested && requested.status === 'active') {
|
|
78
|
+
return { session: requested, requestedSessionId, usedActiveFallback: false };
|
|
79
|
+
}
|
|
80
|
+
const active = (0, governance_runtime_1.loadActiveSession)(repoRoot);
|
|
81
|
+
if (active && active.status === 'active') {
|
|
82
|
+
return { session: active, requestedSessionId, usedActiveFallback: true };
|
|
83
|
+
}
|
|
84
|
+
return { session: null, requestedSessionId, usedActiveFallback: false };
|
|
85
|
+
}
|
|
86
|
+
const active = (0, governance_runtime_1.loadActiveSession)(repoRoot);
|
|
87
|
+
return { session: active && active.status === 'active' ? active : null, usedActiveFallback: false };
|
|
88
|
+
}
|
|
89
|
+
function safeRealpath(path) {
|
|
90
|
+
try {
|
|
91
|
+
return (0, fs_1.realpathSync)(path);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function absolutizeMissingPath(path) {
|
|
98
|
+
const parent = safeRealpath((0, path_1.dirname)(path));
|
|
99
|
+
return parent ? (0, path_1.join)(parent, (0, path_1.basename)(path)) : null;
|
|
100
|
+
}
|
|
101
|
+
function normalizeHookFilePathForRepo(rawPath, repoRoot) {
|
|
102
|
+
let filePath = rawPath.replace(/\\/g, '/');
|
|
103
|
+
if (!(0, path_1.isAbsolute)(filePath))
|
|
104
|
+
return filePath.replace(/^\.\//, '');
|
|
105
|
+
const repoReal = safeRealpath(repoRoot) || repoRoot;
|
|
106
|
+
const directCandidates = [
|
|
107
|
+
filePath,
|
|
108
|
+
safeRealpath(filePath),
|
|
109
|
+
absolutizeMissingPath(filePath),
|
|
110
|
+
].filter((value) => Boolean(value));
|
|
111
|
+
for (const candidate of directCandidates) {
|
|
112
|
+
if (candidate === repoRoot)
|
|
113
|
+
return '';
|
|
114
|
+
if (candidate.startsWith(repoRoot + path_1.sep) || candidate.startsWith(repoRoot + '/')) {
|
|
115
|
+
return (0, path_1.relative)(repoRoot, candidate).replace(/\\/g, '/');
|
|
116
|
+
}
|
|
117
|
+
if (candidate === repoReal)
|
|
118
|
+
return '';
|
|
119
|
+
if (candidate.startsWith(repoReal + path_1.sep) || candidate.startsWith(repoReal + '/')) {
|
|
120
|
+
return (0, path_1.relative)(repoReal, candidate).replace(/\\/g, '/');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return filePath.replace(/^\//, '');
|
|
124
|
+
}
|
|
125
|
+
/** Emit a diagnostic to stderr without breaking the hook exit code. */
|
|
126
|
+
function diagnostic(msg) {
|
|
127
|
+
process.stderr.write(`[neurcode] ${msg}\n`);
|
|
128
|
+
}
|
|
129
|
+
function hookAnalysisLimit(envName, fallback) {
|
|
130
|
+
const raw = process.env[envName];
|
|
131
|
+
if (!raw)
|
|
132
|
+
return fallback;
|
|
133
|
+
const parsed = Number.parseInt(raw, 10);
|
|
134
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
135
|
+
}
|
|
136
|
+
function sessionAlreadyEmittedNudge(session, nudgeKey) {
|
|
137
|
+
return session.events.some((event) => event.type === 'consequence_nudge' &&
|
|
138
|
+
event.detail &&
|
|
139
|
+
typeof event.detail === 'object' &&
|
|
140
|
+
event.detail['nudgeKey'] === nudgeKey);
|
|
141
|
+
}
|
|
142
|
+
function readWorkingDiff(repoRoot) {
|
|
143
|
+
return (0, child_process_1.execFileSync)('git', ['-C', repoRoot, 'diff', '--no-ext-diff', 'HEAD'], {
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
|
+
maxBuffer: 48 * 1024 * 1024,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async function maybeRecordConsequenceNudge(repoRoot, session) {
|
|
150
|
+
if (!(0, consequence_nudges_1.consequenceNudgesEnabled)())
|
|
151
|
+
return null;
|
|
152
|
+
try {
|
|
153
|
+
const diffText = readWorkingDiff(repoRoot);
|
|
154
|
+
if (!diffText.trim())
|
|
155
|
+
return null;
|
|
156
|
+
const diffFiles = (0, diff_parser_1.parseDiff)(diffText);
|
|
157
|
+
const artifact = (0, structural_understanding_1.buildStructuralUnderstanding)(repoRoot, diffFiles, {
|
|
158
|
+
session,
|
|
159
|
+
maxProgramFiles: hookAnalysisLimit('NEURCODE_CONSEQUENCE_NUDGE_MAX_PROGRAM_FILES', 2500),
|
|
160
|
+
timeBudgetMs: hookAnalysisLimit('NEURCODE_CONSEQUENCE_NUDGE_TIME_BUDGET_MS', 3500),
|
|
161
|
+
});
|
|
162
|
+
const nudges = (0, consequence_nudges_1.selectInFlowConsequenceNudges)(artifact, { max: 3 });
|
|
163
|
+
const [nudge] = nudges;
|
|
164
|
+
if (!nudge)
|
|
165
|
+
return null;
|
|
166
|
+
const latest = (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId) || session;
|
|
167
|
+
if (sessionAlreadyEmittedNudge(latest, nudge.nudgeKey))
|
|
168
|
+
return null;
|
|
169
|
+
const artifactPath = (0, structural_understanding_1.writeStructuralUnderstanding)(repoRoot, session.sessionId, artifact);
|
|
170
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, session.sessionId, {
|
|
171
|
+
type: 'structural_understanding',
|
|
172
|
+
ts: artifact.generatedAt,
|
|
173
|
+
message: artifact.analysis.analyzed
|
|
174
|
+
? `Structural understanding: ${artifact.analysis.changedSymbolCount} changed symbols, ${artifact.analysis.referenceCount} references, ${artifact.analysis.testReferenceCount} test references.`
|
|
175
|
+
: `Structural understanding not analyzed: ${artifact.analysis.reason ?? 'unknown reason'}.`,
|
|
176
|
+
detail: {
|
|
177
|
+
schemaVersion: artifact.schemaVersion,
|
|
178
|
+
artifactHash: artifact.artifactHash,
|
|
179
|
+
artifactPath: artifactPath.replace(`${repoRoot}/`, ''),
|
|
180
|
+
analysis: artifact.analysis,
|
|
181
|
+
changedFiles: artifact.changedFiles,
|
|
182
|
+
changedSymbols: artifact.changedSymbols,
|
|
183
|
+
digest: artifact.digest,
|
|
184
|
+
boundaryImpact: artifact.boundaryImpact,
|
|
185
|
+
suppressedArtifacts: artifact.suppressedArtifacts,
|
|
186
|
+
consequenceUnderstanding: {
|
|
187
|
+
schemaVersion: artifact.consequenceUnderstanding.schemaVersion,
|
|
188
|
+
analyzed: artifact.consequenceUnderstanding.analyzed,
|
|
189
|
+
reason: artifact.consequenceUnderstanding.reason,
|
|
190
|
+
summary: artifact.consequenceUnderstanding.summary,
|
|
191
|
+
topImpacts: artifact.consequenceUnderstanding.topImpacts.slice(0, 8),
|
|
192
|
+
topFindings: artifact.consequenceUnderstanding.topFindings.slice(0, 8),
|
|
193
|
+
artifactHash: artifact.consequenceUnderstanding.artifactHash,
|
|
194
|
+
},
|
|
195
|
+
privacy: artifact.privacy,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, session.sessionId, {
|
|
199
|
+
type: 'consequence_nudge',
|
|
200
|
+
ts: new Date().toISOString(),
|
|
201
|
+
message: nudge.headline,
|
|
202
|
+
detail: {
|
|
203
|
+
nudgeVersion: nudge.nudgeVersion,
|
|
204
|
+
nudgeKey: nudge.nudgeKey,
|
|
205
|
+
severity: nudge.severity,
|
|
206
|
+
consequenceClass: nudge.consequenceClass,
|
|
207
|
+
operatorAction: nudge.operatorAction,
|
|
208
|
+
reviewFocus: nudge.reviewFocus,
|
|
209
|
+
artifactHash: nudge.artifactHash,
|
|
210
|
+
impact: nudge.impact ? {
|
|
211
|
+
rank: nudge.impact.rank,
|
|
212
|
+
score: nudge.impact.score,
|
|
213
|
+
file: nudge.impact.file,
|
|
214
|
+
symbol: nudge.impact.symbol,
|
|
215
|
+
summary: nudge.impact.summary,
|
|
216
|
+
findingTypes: nudge.impact.findingTypes,
|
|
217
|
+
findingRanks: nudge.impact.findingRanks,
|
|
218
|
+
findingCount: nudge.impact.findingCount,
|
|
219
|
+
productionConsumerCount: nudge.impact.productionConsumerCount,
|
|
220
|
+
testConsumerCount: nudge.impact.testConsumerCount,
|
|
221
|
+
reachableProductionConsumerCount: nudge.impact.reachableProductionConsumerCount,
|
|
222
|
+
externalProductionConsumerCount: nudge.impact.externalProductionConsumerCount,
|
|
223
|
+
changedProductionConsumerCount: nudge.impact.changedProductionConsumerCount,
|
|
224
|
+
sensitiveConsumerCount: nudge.impact.sensitiveConsumerCount,
|
|
225
|
+
approvalRequiredConsumerCount: nudge.impact.approvalRequiredConsumerCount,
|
|
226
|
+
runtimeGovernanceConsumerCount: nudge.impact.runtimeGovernanceConsumerCount,
|
|
227
|
+
productionFiles: nudge.impact.productionFiles,
|
|
228
|
+
changedProductionFiles: nudge.impact.changedProductionFiles,
|
|
229
|
+
testFiles: nudge.impact.testFiles,
|
|
230
|
+
sensitiveFiles: nudge.impact.sensitiveFiles,
|
|
231
|
+
approvalRequiredFiles: nudge.impact.approvalRequiredFiles,
|
|
232
|
+
runtimeGovernanceFiles: nudge.impact.runtimeGovernanceFiles,
|
|
233
|
+
highFanout: nudge.impact.highFanout,
|
|
234
|
+
architectureRelevant: nudge.impact.architectureRelevant,
|
|
235
|
+
reasonCodes: nudge.impact.reasonCodes,
|
|
236
|
+
provenance: nudge.impact.provenance,
|
|
237
|
+
} : null,
|
|
238
|
+
finding: {
|
|
239
|
+
rank: nudge.finding.rank,
|
|
240
|
+
score: nudge.finding.score,
|
|
241
|
+
findingType: nudge.finding.findingType,
|
|
242
|
+
file: nudge.finding.file,
|
|
243
|
+
symbol: nudge.finding.symbol,
|
|
244
|
+
summary: nudge.finding.summary,
|
|
245
|
+
consumerCount: nudge.finding.consumerCount,
|
|
246
|
+
nonTestConsumerCount: nudge.finding.nonTestConsumerCount,
|
|
247
|
+
testConsumerCount: nudge.finding.testConsumerCount,
|
|
248
|
+
externalConsumerCount: nudge.finding.externalConsumerCount,
|
|
249
|
+
externalConsumerFiles: nudge.finding.externalConsumerFiles,
|
|
250
|
+
consumerSummary: nudge.finding.consumerSummary,
|
|
251
|
+
reasonCodes: nudge.finding.reasonCodes,
|
|
252
|
+
},
|
|
253
|
+
topImpacts: nudges
|
|
254
|
+
.map((item) => item.impact)
|
|
255
|
+
.filter((impact) => Boolean(impact))
|
|
256
|
+
.map((impact) => ({
|
|
257
|
+
rank: impact.rank,
|
|
258
|
+
score: impact.score,
|
|
259
|
+
file: impact.file,
|
|
260
|
+
symbol: impact.symbol,
|
|
261
|
+
summary: impact.summary,
|
|
262
|
+
findingTypes: impact.findingTypes,
|
|
263
|
+
findingCount: impact.findingCount,
|
|
264
|
+
reachableProductionConsumerCount: impact.reachableProductionConsumerCount,
|
|
265
|
+
externalProductionConsumerCount: impact.externalProductionConsumerCount,
|
|
266
|
+
changedProductionConsumerCount: impact.changedProductionConsumerCount,
|
|
267
|
+
productionFiles: impact.productionFiles,
|
|
268
|
+
changedProductionFiles: impact.changedProductionFiles,
|
|
269
|
+
sensitiveConsumerCount: impact.sensitiveConsumerCount,
|
|
270
|
+
approvalRequiredConsumerCount: impact.approvalRequiredConsumerCount,
|
|
271
|
+
runtimeGovernanceConsumerCount: impact.runtimeGovernanceConsumerCount,
|
|
272
|
+
highFanout: impact.highFanout,
|
|
273
|
+
architectureRelevant: impact.architectureRelevant,
|
|
274
|
+
reasonCodes: impact.reasonCodes,
|
|
275
|
+
})),
|
|
276
|
+
topFindings: nudges.map((item) => ({
|
|
277
|
+
nudgeKey: item.nudgeKey,
|
|
278
|
+
severity: item.severity,
|
|
279
|
+
consequenceClass: item.consequenceClass,
|
|
280
|
+
operatorAction: item.operatorAction,
|
|
281
|
+
reviewFocus: item.reviewFocus,
|
|
282
|
+
findingType: item.finding.findingType,
|
|
283
|
+
file: item.finding.file,
|
|
284
|
+
symbol: item.finding.symbol,
|
|
285
|
+
externalConsumerCount: item.finding.externalConsumerCount,
|
|
286
|
+
externalConsumerFiles: item.finding.externalConsumerFiles,
|
|
287
|
+
consumerSummary: item.finding.consumerSummary,
|
|
288
|
+
reasonCodes: item.finding.reasonCodes,
|
|
289
|
+
})),
|
|
290
|
+
provenance: nudge.provenance,
|
|
291
|
+
killSwitch: 'NEURCODE_DISABLE_CONSEQUENCE_NUDGES=1',
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
const refreshed = (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId);
|
|
295
|
+
if (refreshed)
|
|
296
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, refreshed);
|
|
297
|
+
return nudge;
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
diagnostic(`consequence nudge skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function denyPreToolUse(reason, extra) {
|
|
305
|
+
process.stdout.write(JSON.stringify({
|
|
306
|
+
hookSpecificOutput: {
|
|
307
|
+
hookEventName: 'PreToolUse',
|
|
308
|
+
permissionDecision: 'deny',
|
|
309
|
+
permissionDecisionReason: reason,
|
|
310
|
+
...(extra || {}),
|
|
311
|
+
},
|
|
312
|
+
}) + '\n');
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
function stringFromUnknownPath(value) {
|
|
316
|
+
if (typeof value === 'string' && value.trim())
|
|
317
|
+
return value.trim();
|
|
318
|
+
if (!value || typeof value !== 'object')
|
|
319
|
+
return null;
|
|
320
|
+
const record = value;
|
|
321
|
+
for (const key of ['path', 'file_path', 'filePath', 'uri', 'fileUri', 'fsPath']) {
|
|
322
|
+
const candidate = record[key];
|
|
323
|
+
if (typeof candidate === 'string' && candidate.trim())
|
|
324
|
+
return candidate.trim();
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
function hookFilePathCandidates(hookInput) {
|
|
329
|
+
const toolInput = hookInput['tool_input'] ??
|
|
330
|
+
hookInput['toolInput'] ??
|
|
331
|
+
{};
|
|
332
|
+
const candidates = [];
|
|
333
|
+
for (const key of ['path', 'file_path', 'filePath', 'uri', 'fileUri', 'fsPath', 'targetFile', 'target_file']) {
|
|
334
|
+
const direct = stringFromUnknownPath(toolInput[key]) || stringFromUnknownPath(hookInput[key]);
|
|
335
|
+
if (direct)
|
|
336
|
+
candidates.push(direct);
|
|
337
|
+
}
|
|
338
|
+
const files = toolInput['files'] ?? hookInput['files'];
|
|
339
|
+
if (Array.isArray(files)) {
|
|
340
|
+
for (const file of files) {
|
|
341
|
+
const candidate = stringFromUnknownPath(file);
|
|
342
|
+
if (candidate)
|
|
343
|
+
candidates.push(candidate);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return Array.from(new Set(candidates));
|
|
347
|
+
}
|
|
348
|
+
function latestUnresolvedApprovalBlock(session) {
|
|
349
|
+
for (let i = session.events.length - 1; i >= 0; i -= 1) {
|
|
350
|
+
const event = session.events[i];
|
|
351
|
+
if (event.type !== 'check_block')
|
|
352
|
+
continue;
|
|
353
|
+
const context = event.detail?.approvalContext;
|
|
354
|
+
const blockedPath = event.filePath || context?.blockedPath || context?.suggestedApprovalPath;
|
|
355
|
+
if (!blockedPath)
|
|
356
|
+
continue;
|
|
357
|
+
const verdict = (0, governance_runtime_1.checkFileBoundary)({
|
|
358
|
+
filePath: blockedPath,
|
|
359
|
+
allowedGlobs: session.contract.allowedGlobs,
|
|
360
|
+
ownershipRules: session.contract.ownershipRules,
|
|
361
|
+
sensitiveGlobs: session.contract.sensitiveGlobs,
|
|
362
|
+
approvalRequiredGlobs: session.contract.approvalRequiredGlobs,
|
|
363
|
+
approvedPaths: session.contract.approvedPaths,
|
|
364
|
+
approvalGrants: session.contract.approvalGrants,
|
|
365
|
+
scopeMode: session.contract.scopeMode,
|
|
366
|
+
});
|
|
367
|
+
if (verdict.verdict === 'block' && verdict.approvalContext) {
|
|
368
|
+
return {
|
|
369
|
+
filePath: blockedPath,
|
|
370
|
+
suggestedApprovalPath: verdict.approvalContext.suggestedApprovalPath || context?.suggestedApprovalPath || blockedPath,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
function shouldKeepSessionActiveForPendingApproval(session, pendingApproval) {
|
|
378
|
+
if (!pendingApproval)
|
|
379
|
+
return false;
|
|
380
|
+
const hasRecordedApproval = session.contract.approvedPaths.length > 0 ||
|
|
381
|
+
(session.contract.approvalGrants ?? []).some((grant) => !grant.revokedAt) ||
|
|
382
|
+
session.events.some((event) => event.type === 'approval_decision' && event.decision === 'approved');
|
|
383
|
+
return !hasRecordedApproval;
|
|
384
|
+
}
|
|
385
|
+
async function recordBashCheck(repoRoot, session, args) {
|
|
386
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, session.sessionId, {
|
|
387
|
+
type: args.verdict === 'ok' ? 'check_ok' : args.verdict === 'warn' ? 'check_warn' : 'check_block',
|
|
388
|
+
ts: new Date().toISOString(),
|
|
389
|
+
filePath: args.filePath,
|
|
390
|
+
verdict: args.verdict,
|
|
391
|
+
message: args.message,
|
|
392
|
+
detail: {
|
|
393
|
+
...(args.approvalContext ? { approvalContext: args.approvalContext } : {}),
|
|
394
|
+
toolName: 'Bash',
|
|
395
|
+
bash: {
|
|
396
|
+
operation: args.operation,
|
|
397
|
+
targetPaths: args.targetPaths,
|
|
398
|
+
commandFingerprint: args.commandFingerprint,
|
|
399
|
+
},
|
|
400
|
+
...(args.boundaryVerdict ? { boundaryVerdict: args.boundaryVerdict } : {}),
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
const refreshed = (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId);
|
|
404
|
+
if (refreshed)
|
|
405
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, refreshed);
|
|
406
|
+
}
|
|
407
|
+
async function handleBashCheck(repoRoot, session, command) {
|
|
408
|
+
const analysis = (0, bash_command_analysis_1.analyzeBashCommand)(command);
|
|
409
|
+
if (analysis.operatorDiagnostic) {
|
|
410
|
+
diagnostic(`Bash ${analysis.operation} classified as operator diagnostic; not recorded as governed edit evidence`);
|
|
411
|
+
process.exit(0);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!analysis.mutates) {
|
|
415
|
+
process.exit(0);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (analysis.targetPaths.length === 0) {
|
|
419
|
+
diagnostic(`Bash ${analysis.operation} target extraction was inconclusive; not recorded as governed edit evidence`);
|
|
420
|
+
process.stdout.write(JSON.stringify({
|
|
421
|
+
hookSpecificOutput: {
|
|
422
|
+
hookEventName: 'PreToolUse',
|
|
423
|
+
permissionDecision: 'allow',
|
|
424
|
+
reason: `⚠️ Neurcode: Bash ${analysis.operation} target extraction was inconclusive; allowed without governed edit evidence.`,
|
|
425
|
+
},
|
|
426
|
+
}) + '\n');
|
|
427
|
+
process.exit(0);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const targetPaths = analysis.targetPaths.map((path) => normalizeHookFilePathForRepo(path, repoRoot));
|
|
431
|
+
const results = targetPaths.map((filePath) => ({
|
|
432
|
+
filePath,
|
|
433
|
+
result: (0, governance_runtime_1.checkFileBoundary)({
|
|
434
|
+
filePath,
|
|
435
|
+
allowedGlobs: session.contract.allowedGlobs,
|
|
436
|
+
ownershipRules: session.contract.ownershipRules,
|
|
437
|
+
sensitiveGlobs: session.contract.sensitiveGlobs,
|
|
438
|
+
approvalRequiredGlobs: session.contract.approvalRequiredGlobs,
|
|
439
|
+
approvedPaths: session.contract.approvedPaths,
|
|
440
|
+
approvalGrants: session.contract.approvalGrants,
|
|
441
|
+
scopeMode: session.contract.scopeMode,
|
|
442
|
+
}),
|
|
443
|
+
}));
|
|
444
|
+
for (const { filePath, result } of results) {
|
|
445
|
+
await recordBashCheck(repoRoot, session, {
|
|
446
|
+
filePath,
|
|
447
|
+
verdict: result.verdict,
|
|
448
|
+
message: result.message,
|
|
449
|
+
operation: analysis.operation,
|
|
450
|
+
targetPaths,
|
|
451
|
+
commandFingerprint: analysis.commandFingerprint,
|
|
452
|
+
boundaryVerdict: result.verdict,
|
|
453
|
+
approvalContext: result.approvalContext,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
const blocking = results.find(({ result }) => result.verdict === 'block');
|
|
457
|
+
if (blocking) {
|
|
458
|
+
const message = `⏸ Neurcode: Bash ${analysis.operation} targets ${blocking.filePath}. ` +
|
|
459
|
+
blocking.result.message.replace(/^⏸ Neurcode:\s*/, '');
|
|
460
|
+
denyPreToolUse(message, blocking.result.approvalContext ? { approvalContext: blocking.result.approvalContext } : undefined);
|
|
461
|
+
}
|
|
462
|
+
const warning = results.find(({ result }) => result.verdict === 'warn');
|
|
463
|
+
if (warning) {
|
|
464
|
+
process.stdout.write(JSON.stringify({
|
|
465
|
+
hookSpecificOutput: {
|
|
466
|
+
hookEventName: 'PreToolUse',
|
|
467
|
+
permissionDecision: 'allow',
|
|
468
|
+
reason: `⚠️ Neurcode: Bash ${analysis.operation} is allowed with warning for ${warning.filePath}.`,
|
|
469
|
+
},
|
|
470
|
+
}) + '\n');
|
|
471
|
+
}
|
|
472
|
+
process.exit(0);
|
|
473
|
+
}
|
|
474
|
+
function parseDurationMs(value) {
|
|
475
|
+
if (!value)
|
|
476
|
+
return undefined;
|
|
477
|
+
const trimmed = value.trim().toLowerCase();
|
|
478
|
+
const match = trimmed.match(/^(\d+)(ms|s|m|h|d)?$/);
|
|
479
|
+
if (!match)
|
|
480
|
+
throw new Error('expires-in must be a duration like 15m, 2h, or 1d');
|
|
481
|
+
const amount = Number.parseInt(match[1], 10);
|
|
482
|
+
const unit = match[2] || 'm';
|
|
483
|
+
const multipliers = {
|
|
484
|
+
ms: 1,
|
|
485
|
+
s: 1000,
|
|
486
|
+
m: 60 * 1000,
|
|
487
|
+
h: 60 * 60 * 1000,
|
|
488
|
+
d: 24 * 60 * 60 * 1000,
|
|
489
|
+
};
|
|
490
|
+
return amount * multipliers[unit];
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Deterministically capture an agent plan from a hook payload and attach it to
|
|
494
|
+
* the session when present and changed. Source-free and fail-open: any failure
|
|
495
|
+
* returns null and the hook proceeds unaffected.
|
|
496
|
+
*/
|
|
497
|
+
function maybeCaptureAgentPlan(repoRoot, session, hookInput) {
|
|
498
|
+
try {
|
|
499
|
+
const plan = (0, governance_runtime_1.extractAgentPlan)(hookInput);
|
|
500
|
+
if (!plan)
|
|
501
|
+
return null;
|
|
502
|
+
const result = (0, governance_runtime_1.captureAgentPlan)(repoRoot, session.sessionId, plan);
|
|
503
|
+
if (result && result.status !== 'unchanged') {
|
|
504
|
+
diagnostic(result.status === 'pending'
|
|
505
|
+
? `agent plan amendment pending human decision (${result.proposal?.proposalId || 'unknown proposal'}, ${result.proposal?.risk.level || 'high'} risk)`
|
|
506
|
+
: `agent plan ${result.status} (${plan.source}, ${plan.steps.length} step${plan.steps.length === 1 ? '' : 's'}, confidence ${plan.confidence})`);
|
|
507
|
+
}
|
|
508
|
+
return result?.status === 'unchanged' ? null : result?.session ?? null;
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// Plan capture must never fail the hook.
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
async function maybeReuseLaunchedClaudeSession(repoRoot, goal, hookInput, profileFreshness) {
|
|
516
|
+
const activeSession = (0, governance_runtime_1.loadActiveSession)(repoRoot);
|
|
517
|
+
if (!activeSession || activeSession.status !== 'active')
|
|
518
|
+
return null;
|
|
519
|
+
const launcher = (0, agent_session_launcher_1.latestAgentLauncherState)(activeSession);
|
|
520
|
+
if (!launcher || launcher.agent.adapter !== 'claude-code-hooks')
|
|
521
|
+
return null;
|
|
522
|
+
const promptReferencesSession = goal.includes(activeSession.sessionId);
|
|
523
|
+
const awaitingPrompt = launcher.handshakeStatus === 'awaiting_agent_prompt';
|
|
524
|
+
if (!promptReferencesSession && !awaitingPrompt)
|
|
525
|
+
return null;
|
|
526
|
+
let session = (0, agent_session_launcher_1.recordLauncherHandshake)(repoRoot, activeSession, {
|
|
527
|
+
handshakeStatus: 'prompt_seen',
|
|
528
|
+
promptMatched: promptReferencesSession ? 'session_id' : 'active_launcher',
|
|
529
|
+
source: 'claude-code-hooks',
|
|
530
|
+
message: 'Claude Code prompt handshook into launcher-created session.',
|
|
531
|
+
});
|
|
532
|
+
const plannedAtStart = maybeCaptureAgentPlan(repoRoot, session, hookInput);
|
|
533
|
+
if (plannedAtStart) {
|
|
534
|
+
session = (0, agent_session_launcher_1.recordLauncherHandshake)(repoRoot, plannedAtStart, {
|
|
535
|
+
handshakeStatus: 'plan_captured',
|
|
536
|
+
promptMatched: promptReferencesSession ? 'session_id' : 'active_launcher',
|
|
537
|
+
source: 'claude-code-hooks',
|
|
538
|
+
message: 'Claude Code prompt captured an initial plan for the launcher-created session.',
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, session, { profileFreshness });
|
|
542
|
+
return session;
|
|
543
|
+
}
|
|
544
|
+
async function maybeContinueActiveClaudeSession(repoRoot, rawPrompt, intentSelection, profileFreshness) {
|
|
545
|
+
const activeSession = (0, governance_runtime_1.loadActiveSession)(repoRoot);
|
|
546
|
+
if (!activeSession || activeSession.status !== 'active')
|
|
547
|
+
return null;
|
|
548
|
+
const decision = (0, intent_continuity_1.classifyIntentContinuity)(rawPrompt, intentSelection, activeSession);
|
|
549
|
+
if (decision.action === 'start_new_session')
|
|
550
|
+
return null;
|
|
551
|
+
if (decision.action === 'record_operator_note') {
|
|
552
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, activeSession.sessionId, {
|
|
553
|
+
type: 'user_decision',
|
|
554
|
+
ts: new Date().toISOString(),
|
|
555
|
+
decision: 'operator_prompt_recorded',
|
|
556
|
+
message: 'Human follow-up prompt recorded without changing the active governed plan.',
|
|
557
|
+
detail: {
|
|
558
|
+
intentContinuity: decision.detail,
|
|
559
|
+
reason: decision.reason,
|
|
560
|
+
confidence: decision.confidence,
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
const refreshed = (0, governance_runtime_1.loadSession)(repoRoot, activeSession.sessionId) || activeSession;
|
|
564
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, refreshed, { profileFreshness });
|
|
565
|
+
return refreshed;
|
|
566
|
+
}
|
|
567
|
+
if (!decision.amendment)
|
|
568
|
+
return activeSession;
|
|
569
|
+
const amended = (0, governance_runtime_1.amendAgentPlan)(repoRoot, {
|
|
570
|
+
...decision.amendment,
|
|
571
|
+
amendedAt: new Date().toISOString(),
|
|
572
|
+
});
|
|
573
|
+
let session = (0, governance_runtime_1.loadSession)(repoRoot, amended.sessionId) || activeSession;
|
|
574
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, amended.sessionId, {
|
|
575
|
+
type: 'user_decision',
|
|
576
|
+
ts: new Date().toISOString(),
|
|
577
|
+
decision: 'intent_continuity_amended',
|
|
578
|
+
message: amended.status === 'pending'
|
|
579
|
+
? 'Human follow-up prompt proposed a plan amendment that is pending decision.'
|
|
580
|
+
: `Human follow-up prompt updated active plan revision ${amended.previousRevision} -> ${amended.revision}.`,
|
|
581
|
+
detail: {
|
|
582
|
+
intentContinuity: decision.detail,
|
|
583
|
+
reason: decision.reason,
|
|
584
|
+
confidence: decision.confidence,
|
|
585
|
+
planAmendment: {
|
|
586
|
+
status: amended.status,
|
|
587
|
+
previousRevision: amended.previousRevision,
|
|
588
|
+
revision: amended.revision,
|
|
589
|
+
action: amended.action,
|
|
590
|
+
risk: amended.risk,
|
|
591
|
+
proposalId: amended.proposal?.proposalId,
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
session = (0, governance_runtime_1.loadSession)(repoRoot, amended.sessionId) || session;
|
|
596
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, session, { profileFreshness });
|
|
597
|
+
diagnostic(amended.status === 'pending'
|
|
598
|
+
? `intent continuity proposed plan amendment ${amended.proposal?.proposalId || amended.eventId}`
|
|
599
|
+
: `intent continuity updated active plan revision ${amended.previousRevision} -> ${amended.revision}`);
|
|
600
|
+
return session;
|
|
601
|
+
}
|
|
602
|
+
// ── Hook handlers ─────────────────────────────────────────────────────────────
|
|
603
|
+
/** UserPromptSubmit — create a new governance session from the prompt. */
|
|
604
|
+
async function handleStart(cmdCwd) {
|
|
605
|
+
const hookInput = readHookInput();
|
|
606
|
+
const effectiveCwd = cwdFromHookInput(hookInput, cmdCwd);
|
|
607
|
+
const repoRoot = (0, v0_governance_1.resolveRepoRoot)(effectiveCwd);
|
|
608
|
+
(0, hook_heartbeat_1.recordHookHeartbeat)({ repoRoot, eventType: 'start' });
|
|
609
|
+
const rawGoal = hookInput['prompt'] ||
|
|
610
|
+
hookInput['user_prompt'] ||
|
|
611
|
+
'';
|
|
612
|
+
const intentSelection = (0, governed_intent_1.selectGovernedIntent)(rawGoal);
|
|
613
|
+
const goal = intentSelection.goal;
|
|
614
|
+
if (!goal.trim()) {
|
|
615
|
+
// No text in the prompt — skip session creation (tool-use-only turn)
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
const profileResult = (0, v0_governance_1.ensureFreshGovernanceProfile)(repoRoot);
|
|
620
|
+
const profileFreshness = (0, v0_governance_1.buildProfileFreshnessSignal)(profileResult, profileResult.refreshed ? 'auto_refreshed' : 'none');
|
|
621
|
+
if (profileResult.refreshed && profileResult.status !== 'missing') {
|
|
622
|
+
diagnostic(`profile refreshed before session start (${profileResult.reasons.join('; ')})`);
|
|
623
|
+
}
|
|
624
|
+
for (const warning of intentSelection.warnings) {
|
|
625
|
+
diagnostic(warning);
|
|
626
|
+
}
|
|
627
|
+
const reused = await maybeReuseLaunchedClaudeSession(repoRoot, rawGoal.trim(), hookInput, profileFreshness);
|
|
628
|
+
if (reused) {
|
|
629
|
+
process.stdout.write(JSON.stringify({
|
|
630
|
+
message: `🤝 Neurcode session ${reused.sessionId} · launcher handshake complete`,
|
|
631
|
+
}) + '\n');
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const continued = await maybeContinueActiveClaudeSession(repoRoot, rawGoal.trim(), intentSelection, profileFreshness);
|
|
635
|
+
if (continued) {
|
|
636
|
+
process.stdout.write(JSON.stringify({
|
|
637
|
+
message: `🔁 Neurcode session ${continued.sessionId} · continuing active governed intent · ` +
|
|
638
|
+
`plan revision ${(0, governance_runtime_1.activeAgentPlanRevision)(continued.contract)}`,
|
|
639
|
+
}) + '\n');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!(0, governed_intent_1.shouldStartGovernedSession)(intentSelection)) {
|
|
643
|
+
process.stdout.write(JSON.stringify({
|
|
644
|
+
message: 'Neurcode did not start a governed session for this operator/status prompt. ' +
|
|
645
|
+
'Use `Demo goal:` or `Governed goal:` when you want a new governed implementation session.',
|
|
646
|
+
}) + '\n');
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const profile = profileResult.profile;
|
|
650
|
+
let session = (0, governance_runtime_1.createSession)(repoRoot, profile, goal.trim());
|
|
651
|
+
const plannedAtStart = maybeCaptureAgentPlan(repoRoot, session, hookInput);
|
|
652
|
+
if (plannedAtStart)
|
|
653
|
+
session = plannedAtStart;
|
|
654
|
+
if (intentSelection.source === 'labeled_goal' || intentSelection.operatorPrompt) {
|
|
655
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, session.sessionId, {
|
|
656
|
+
type: 'user_decision',
|
|
657
|
+
ts: new Date().toISOString(),
|
|
658
|
+
decision: 'governed_intent_selected',
|
|
659
|
+
message: intentSelection.source === 'labeled_goal'
|
|
660
|
+
? 'Governed intent selected from labeled prompt section.'
|
|
661
|
+
: 'Operator-style prompt used directly as governed intent.',
|
|
662
|
+
detail: {
|
|
663
|
+
source: intentSelection.source,
|
|
664
|
+
operatorPrompt: intentSelection.operatorPrompt,
|
|
665
|
+
warnings: intentSelection.warnings,
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
const refreshed = (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId);
|
|
669
|
+
if (refreshed)
|
|
670
|
+
session = refreshed;
|
|
671
|
+
}
|
|
672
|
+
// Goal-quality warning (lightweight; does not redesign the parser): a very long or
|
|
673
|
+
// path-heavy goal tends to produce broad/noisy scope where everything is
|
|
674
|
+
// approval-required. Counts only — no goal/prompt text is echoed (source-free).
|
|
675
|
+
const trimmedGoal = goal.trim();
|
|
676
|
+
const goalLength = trimmedGoal.length;
|
|
677
|
+
const goalLines = trimmedGoal.split('\n').length;
|
|
678
|
+
const broadApprovalScope = session.contract.approvalRequiredGlobs.includes('**');
|
|
679
|
+
if (broadApprovalScope || goalLength > 600 || goalLines > 6) {
|
|
680
|
+
diagnostic(`⚠ goal is verbose (${goalLength} chars, ${goalLines} lines)` +
|
|
681
|
+
(broadApprovalScope ? ' and produced broad scope (approvalRequiredGlobs includes "**" — every file needs approval)' : '') +
|
|
682
|
+
'. For cleaner governance and demos, start the session with a short, crisp goal.');
|
|
683
|
+
}
|
|
684
|
+
const scopeNote = session.contract.scopeMode === 'ambiguous'
|
|
685
|
+
? '(scope ambiguous — approval-required boundaries will block)'
|
|
686
|
+
: session.contract.allowedGlobs.slice(0, 2).join(', ') +
|
|
687
|
+
(session.contract.allowedGlobs.length > 2
|
|
688
|
+
? ` +${session.contract.allowedGlobs.length - 2} more`
|
|
689
|
+
: '');
|
|
690
|
+
const banner = `🔒 Neurcode session ${session.sessionId} · ${scopeNote} · ` +
|
|
691
|
+
`${session.contract.approvalRequiredGlobs.length} approval-required boundaries`;
|
|
692
|
+
process.stdout.write(JSON.stringify({ message: banner }) + '\n');
|
|
693
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, session, { profileFreshness });
|
|
694
|
+
}
|
|
695
|
+
catch (err) {
|
|
696
|
+
diagnostic(`start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
697
|
+
// Fail open — don't break the agent turn
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** PreToolUse — check a pending Edit/Write/MultiEdit before it lands. */
|
|
701
|
+
async function handleCheck(cmdCwd) {
|
|
702
|
+
const hookInput = readHookInput();
|
|
703
|
+
const effectiveCwd = cwdFromHookInput(hookInput, cmdCwd);
|
|
704
|
+
const repoRoot = (0, v0_governance_1.resolveRepoRoot)(effectiveCwd);
|
|
705
|
+
(0, hook_heartbeat_1.recordHookHeartbeat)({ repoRoot, eventType: 'check' });
|
|
706
|
+
const requestedSessionId = sessionIdFromHookInput(hookInput);
|
|
707
|
+
const resolution = resolveSessionForHook(repoRoot, requestedSessionId);
|
|
708
|
+
const activeSession = resolution.session;
|
|
709
|
+
if (!activeSession) {
|
|
710
|
+
// No active session — not governed, pass through
|
|
711
|
+
diagnostic(requestedSessionId
|
|
712
|
+
? `no active session ${requestedSessionId} at ${repoRoot} — edit allowed (ungoverned)`
|
|
713
|
+
: `no active session at ${repoRoot} — edit allowed (ungoverned)`);
|
|
714
|
+
process.exit(0);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (resolution.usedActiveFallback && requestedSessionId) {
|
|
718
|
+
diagnostic(`Claude session_id ${requestedSessionId} did not match a Neurcode session; using active session ${activeSession.sessionId}`);
|
|
719
|
+
}
|
|
720
|
+
let session = activeSession;
|
|
721
|
+
try {
|
|
722
|
+
const hasPriorBlock = session.events.some((event) => event.type === 'check_block');
|
|
723
|
+
if (hasPriorBlock) {
|
|
724
|
+
const pending = await (0, runtime_live_1.applyPendingRuntimeLiveApprovals)(repoRoot, session.sessionId);
|
|
725
|
+
if (pending.applied > 0 || pending.revoked > 0) {
|
|
726
|
+
const refreshed = (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId);
|
|
727
|
+
if (refreshed)
|
|
728
|
+
session = refreshed;
|
|
729
|
+
}
|
|
730
|
+
if (pending.applied > 0) {
|
|
731
|
+
diagnostic(`applied ${pending.applied} dashboard approval${pending.applied === 1 ? '' : 's'}`);
|
|
732
|
+
}
|
|
733
|
+
if (pending.revoked > 0) {
|
|
734
|
+
diagnostic(`revoked ${pending.revoked} dashboard approval${pending.revoked === 1 ? '' : 's'}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Live approval polling is best-effort; local deterministic enforcement still runs.
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
const expired = (0, governance_runtime_1.expireSessionApprovals)(repoRoot, session.sessionId);
|
|
743
|
+
if (expired)
|
|
744
|
+
session = expired;
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
// Expiry cleanup is best-effort; checkFileBoundary still ignores expired grants.
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
const expired = (0, governance_runtime_1.expireArchitectureObligationWaivers)(repoRoot, session.sessionId);
|
|
751
|
+
if (expired)
|
|
752
|
+
session = expired;
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
// Waiver expiry cleanup is best-effort; obligation evaluation uses timestamps.
|
|
756
|
+
}
|
|
757
|
+
// ── Agent plan capture ───────────────────────────────────────────────────
|
|
758
|
+
// ExitPlanMode / TodoWrite PreToolUse payloads carry the agent's own plan but
|
|
759
|
+
// no file path — capture it before the no-path early return below.
|
|
760
|
+
try {
|
|
761
|
+
const planned = maybeCaptureAgentPlan(repoRoot, session, hookInput);
|
|
762
|
+
if (planned) {
|
|
763
|
+
session = planned;
|
|
764
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, session);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
// Plan capture is best-effort and must never fail the hook.
|
|
769
|
+
}
|
|
770
|
+
// ── Extract the target file path ─────────────────────────────────────────
|
|
771
|
+
// Claude Code PreToolUse payload shape:
|
|
772
|
+
// { tool_name, tool_input: { path, ... }, cwd, ... }
|
|
773
|
+
const toolName = hookInput['tool_name'] ||
|
|
774
|
+
hookInput['toolName'] ||
|
|
775
|
+
'';
|
|
776
|
+
const toolInput = hookInput['tool_input'] ??
|
|
777
|
+
hookInput['toolInput'] ??
|
|
778
|
+
{};
|
|
779
|
+
if (/^(bash|shell|runCommand|run_command|runInTerminal|run_in_terminal|terminal)$/i.test(toolName)) {
|
|
780
|
+
const command = toolInput['command'] ||
|
|
781
|
+
toolInput['cmd'] ||
|
|
782
|
+
hookInput['command'] ||
|
|
783
|
+
'';
|
|
784
|
+
await handleBashCheck(repoRoot, session, command);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const rawPaths = hookFilePathCandidates(hookInput);
|
|
788
|
+
if (rawPaths.length > 1) {
|
|
789
|
+
const message = `⏸ Neurcode: this ${toolName || 'tool'} call attempts to edit multiple files at once ` +
|
|
790
|
+
`(${rawPaths.length} paths). Split the edit into one file per tool call so each path can be governed before write.`;
|
|
791
|
+
try {
|
|
792
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, session.sessionId, {
|
|
793
|
+
type: 'check_block',
|
|
794
|
+
ts: new Date().toISOString(),
|
|
795
|
+
filePath: rawPaths.map((path) => normalizeHookFilePathForRepo(path, repoRoot)).join(','),
|
|
796
|
+
verdict: 'block',
|
|
797
|
+
message,
|
|
798
|
+
detail: {
|
|
799
|
+
reason: 'multi_file_tool_call_requires_split',
|
|
800
|
+
paths: rawPaths.map((path) => normalizeHookFilePathForRepo(path, repoRoot)),
|
|
801
|
+
toolName,
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
const refreshed = (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId);
|
|
805
|
+
if (refreshed)
|
|
806
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, refreshed);
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
// Recording failure must not weaken the deny.
|
|
810
|
+
}
|
|
811
|
+
denyPreToolUse(message, {
|
|
812
|
+
reason: 'multi_file_tool_call_requires_split',
|
|
813
|
+
paths: rawPaths.map((path) => normalizeHookFilePathForRepo(path, repoRoot)),
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
const rawPath = rawPaths[0] || '';
|
|
817
|
+
if (!rawPath) {
|
|
818
|
+
diagnostic('PreToolUse: no file path in hook input — edit allowed (cannot check)');
|
|
819
|
+
process.exit(0);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
// Normalise to a path relative to the repo root. Use realpath-aware
|
|
823
|
+
// comparison so macOS /tmp -> /private/tmp and other repo symlinks do not
|
|
824
|
+
// turn an in-repo edit into a bogus "tmp/repo/..." path.
|
|
825
|
+
const filePath = normalizeHookFilePathForRepo(rawPath, repoRoot);
|
|
826
|
+
// ── Profile freshness guard ─────────────────────────────────────────────
|
|
827
|
+
// Safe refreshes are automatic. If repo metadata changed enough that the
|
|
828
|
+
// active session was derived from a different profile, the current edit is
|
|
829
|
+
// denied until a new session contract is created from the fresh profile.
|
|
830
|
+
let profileFreshness;
|
|
831
|
+
try {
|
|
832
|
+
const staleness = (0, v0_governance_1.getProfileStaleness)(repoRoot);
|
|
833
|
+
const action = (0, v0_governance_1.profileFreshnessActionForSession)(staleness, session.profileHash);
|
|
834
|
+
if (action === 'session_restart_required') {
|
|
835
|
+
let signal = (0, v0_governance_1.buildProfileFreshnessSignal)(staleness, action);
|
|
836
|
+
try {
|
|
837
|
+
const refreshedProfile = (0, v0_governance_1.ensureFreshGovernanceProfile)(repoRoot);
|
|
838
|
+
signal = (0, v0_governance_1.buildProfileFreshnessSignal)(refreshedProfile, action);
|
|
839
|
+
}
|
|
840
|
+
catch (refreshError) {
|
|
841
|
+
signal = {
|
|
842
|
+
...signal,
|
|
843
|
+
action: 'manual_refresh_required',
|
|
844
|
+
reasons: [
|
|
845
|
+
...signal.reasons,
|
|
846
|
+
`profile refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`,
|
|
847
|
+
],
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
profileFreshness = signal;
|
|
851
|
+
const message = `⏸ Neurcode: repository governance profile changed during this active session. ` +
|
|
852
|
+
`This edit to ${filePath} was not checked against the updated repo topology. ` +
|
|
853
|
+
`Start a new governed session, or run \`neurcode profile\` / \`neurcode activate claude\` and retry.`;
|
|
854
|
+
try {
|
|
855
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, session.sessionId, {
|
|
856
|
+
type: 'check_block',
|
|
857
|
+
ts: new Date().toISOString(),
|
|
858
|
+
filePath,
|
|
859
|
+
verdict: 'block',
|
|
860
|
+
message,
|
|
861
|
+
detail: { profileFreshness },
|
|
862
|
+
});
|
|
863
|
+
const refreshedSession = (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId);
|
|
864
|
+
if (refreshedSession) {
|
|
865
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, refreshedSession, { profileFreshness });
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
// Recording failure must not weaken the deny.
|
|
870
|
+
}
|
|
871
|
+
denyPreToolUse(message, { profileFreshness });
|
|
872
|
+
}
|
|
873
|
+
if (staleness.status !== 'fresh') {
|
|
874
|
+
const refreshedProfile = (0, v0_governance_1.ensureFreshGovernanceProfile)(repoRoot);
|
|
875
|
+
profileFreshness = (0, v0_governance_1.buildProfileFreshnessSignal)(refreshedProfile, refreshedProfile.refreshed ? 'auto_refreshed' : 'none');
|
|
876
|
+
if (refreshedProfile.refreshed) {
|
|
877
|
+
diagnostic(`profile refreshed before edit check (${refreshedProfile.reasons.join('; ') || refreshedProfile.status})`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
profileFreshness = (0, v0_governance_1.buildProfileFreshnessSignal)(staleness, 'none');
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
catch (err) {
|
|
885
|
+
const message = `⏸ Neurcode: could not verify the repository governance profile before checking ${filePath}. ` +
|
|
886
|
+
`Run \`neurcode profile\` or \`neurcode activate claude\`, then retry. ` +
|
|
887
|
+
`Cause: ${err instanceof Error ? err.message : String(err)}`;
|
|
888
|
+
try {
|
|
889
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, session.sessionId, {
|
|
890
|
+
type: 'check_block',
|
|
891
|
+
ts: new Date().toISOString(),
|
|
892
|
+
filePath,
|
|
893
|
+
verdict: 'block',
|
|
894
|
+
message,
|
|
895
|
+
detail: {
|
|
896
|
+
profileFreshness: {
|
|
897
|
+
status: 'unreadable',
|
|
898
|
+
refreshed: false,
|
|
899
|
+
action: 'manual_refresh_required',
|
|
900
|
+
checkedAt: new Date().toISOString(),
|
|
901
|
+
profilePath: '.neurcode/profile.json',
|
|
902
|
+
reasons: [err instanceof Error ? err.message : String(err)],
|
|
903
|
+
currentProfileHash: session.profileHash,
|
|
904
|
+
currentTopologyHash: session.profileHash,
|
|
905
|
+
trackedFileCount: 0,
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
const refreshedSession = (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId);
|
|
910
|
+
if (refreshedSession)
|
|
911
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, refreshedSession);
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
// Recording failure must not weaken the deny.
|
|
915
|
+
}
|
|
916
|
+
denyPreToolUse(message);
|
|
917
|
+
}
|
|
918
|
+
// ── Run the boundary + intent-coherence checks ───────────────────────────
|
|
919
|
+
let result;
|
|
920
|
+
try {
|
|
921
|
+
result = (0, governance_runtime_1.checkFileBoundary)({
|
|
922
|
+
filePath,
|
|
923
|
+
allowedGlobs: session.contract.allowedGlobs,
|
|
924
|
+
ownershipRules: session.contract.ownershipRules,
|
|
925
|
+
sensitiveGlobs: session.contract.sensitiveGlobs,
|
|
926
|
+
approvalRequiredGlobs: session.contract.approvalRequiredGlobs,
|
|
927
|
+
approvedPaths: session.contract.approvedPaths,
|
|
928
|
+
approvalGrants: session.contract.approvalGrants,
|
|
929
|
+
scopeMode: session.contract.scopeMode,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
catch (err) {
|
|
933
|
+
diagnostic(`check failed: ${err instanceof Error ? err.message : String(err)} — edit allowed`);
|
|
934
|
+
process.exit(0);
|
|
935
|
+
}
|
|
936
|
+
const boundaryVerdict = result.verdict;
|
|
937
|
+
const intentCoherence = (0, governance_runtime_1.evaluateIntentCoherence)(session.contract, filePath);
|
|
938
|
+
const planCoherence = (0, governance_runtime_1.evaluateSessionPlanCoherence)(session.contract, filePath);
|
|
939
|
+
const planCoherencePolicy = (0, governance_runtime_1.evaluatePlanCoherencePolicy)(session.contract.planCoherenceMode, planCoherence);
|
|
940
|
+
const architectureObligationFeedback = (0, governance_runtime_1.evaluateArchitectureObligationFeedback)(session.contract.architectureObligations ?? [], filePath);
|
|
941
|
+
// V2: structured architecture-aware verdict (pass / warn / block /
|
|
942
|
+
// obligation_pending / obligation_waived) evaluated against the dependency
|
|
943
|
+
// graph + live obligation ledger. Advisory metadata for evidence + dashboard;
|
|
944
|
+
// the deny/allow decision below is unchanged.
|
|
945
|
+
const architectureEdit = (0, governance_runtime_1.evaluateArchitectureEdit)({
|
|
946
|
+
filePath,
|
|
947
|
+
boundaryVerdict,
|
|
948
|
+
graph: session.contract.architectureGraph,
|
|
949
|
+
obligations: session.contract.architectureObligations ?? [],
|
|
950
|
+
});
|
|
951
|
+
if (result.verdict === 'ok' && planCoherencePolicy.action === 'block') {
|
|
952
|
+
result = {
|
|
953
|
+
...result,
|
|
954
|
+
verdict: 'block',
|
|
955
|
+
message: `⏸ Neurcode: ${filePath} is not justified by the agent's stated plan. ` +
|
|
956
|
+
`${planCoherencePolicy.reason} Re-plan or update the plan before editing this path. ` +
|
|
957
|
+
`Use neurcode_session_replan or \`neurcode session replan --add-file ${filePath}\`.`,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
else if (result.verdict === 'ok' && architectureObligationFeedback.action === 'block') {
|
|
961
|
+
result = {
|
|
962
|
+
...result,
|
|
963
|
+
verdict: 'block',
|
|
964
|
+
message: `⏸ Neurcode: ${filePath} is blocked by ${architectureObligationFeedback.blocking.length} ` +
|
|
965
|
+
`architecture obligation${architectureObligationFeedback.blocking.length === 1 ? '' : 's'}. ` +
|
|
966
|
+
`${architectureObligationFeedback.reasons[0]} Satisfy the obligation, re-plan, or ask the human to waive it with ` +
|
|
967
|
+
`neurcode_session_waive_obligation.`,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
else if (result.verdict === 'ok' && intentCoherence.verdict === 'drift') {
|
|
971
|
+
result = {
|
|
972
|
+
...result,
|
|
973
|
+
verdict: 'warn',
|
|
974
|
+
message: `⚠️ Neurcode: ${filePath} is allowed by boundary rules but weakly linked to the task intent. ` +
|
|
975
|
+
`${intentCoherence.reasons[0]} Proceeding — recorded in session.`,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
else if (result.verdict === 'ok' && architectureObligationFeedback.action === 'warn') {
|
|
979
|
+
result = {
|
|
980
|
+
...result,
|
|
981
|
+
verdict: 'warn',
|
|
982
|
+
message: `⚠️ Neurcode: ${filePath} is allowed, but ${architectureObligationFeedback.pending.length} ` +
|
|
983
|
+
`architecture obligation${architectureObligationFeedback.pending.length === 1 ? '' : 's'} remain open. ` +
|
|
984
|
+
`${architectureObligationFeedback.reasons[0]} Proceeding — recorded in session.`,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
else if (result.verdict === 'ok' && planCoherencePolicy.action === 'warn') {
|
|
988
|
+
result = {
|
|
989
|
+
...result,
|
|
990
|
+
verdict: 'warn',
|
|
991
|
+
message: `⚠️ Neurcode: ${filePath} is not justified by the agent's stated plan. ` +
|
|
992
|
+
`${planCoherencePolicy.reason} Proceeding — recorded in session.`,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
// ── Record the event ─────────────────────────────────────────────────────
|
|
996
|
+
// Tag every check with the agent-plan revision that was active when it ran,
|
|
997
|
+
// so the evidence record can answer "which plan version governed this edit?".
|
|
998
|
+
const activePlanRevision = (0, governance_runtime_1.activeAgentPlanRevision)(session.contract);
|
|
999
|
+
try {
|
|
1000
|
+
const event = {
|
|
1001
|
+
type: result.verdict === 'ok'
|
|
1002
|
+
? 'check_ok'
|
|
1003
|
+
: result.verdict === 'warn'
|
|
1004
|
+
? 'check_warn'
|
|
1005
|
+
: 'check_block',
|
|
1006
|
+
ts: new Date().toISOString(),
|
|
1007
|
+
filePath,
|
|
1008
|
+
verdict: result.verdict,
|
|
1009
|
+
message: result.message,
|
|
1010
|
+
detail: {
|
|
1011
|
+
...(result.approvalContext ? { approvalContext: result.approvalContext } : {}),
|
|
1012
|
+
intentCoherence,
|
|
1013
|
+
planCoherence,
|
|
1014
|
+
planCoherencePolicy,
|
|
1015
|
+
architectureObligationFeedback,
|
|
1016
|
+
architectureEdit: {
|
|
1017
|
+
status: architectureEdit.status,
|
|
1018
|
+
module: architectureEdit.module,
|
|
1019
|
+
surfaces: architectureEdit.surfaces,
|
|
1020
|
+
dependents: architectureEdit.dependents,
|
|
1021
|
+
message: architectureEdit.message,
|
|
1022
|
+
},
|
|
1023
|
+
boundaryVerdict,
|
|
1024
|
+
activePlanRevision,
|
|
1025
|
+
planPresent: Boolean(session.contract.agentPlan),
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
(0, governance_runtime_1.appendEvent)(repoRoot, session.sessionId, event);
|
|
1029
|
+
const refreshed = (0, governance_runtime_1.refreshArchitectureObligations)(repoRoot, session.sessionId)
|
|
1030
|
+
|| (0, governance_runtime_1.loadSession)(repoRoot, session.sessionId);
|
|
1031
|
+
if (refreshed)
|
|
1032
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, refreshed, { profileFreshness });
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
// Recording failure must not affect the verdict
|
|
1036
|
+
}
|
|
1037
|
+
const consequenceNudge = result.verdict === 'block'
|
|
1038
|
+
? null
|
|
1039
|
+
: await maybeRecordConsequenceNudge(repoRoot, session);
|
|
1040
|
+
// ── Emit hook response ────────────────────────────────────────────────────
|
|
1041
|
+
if (result.verdict === 'block') {
|
|
1042
|
+
// Include machine-readable approvalContext when the block is approval-required,
|
|
1043
|
+
// so the agent can surface a structured approval request to the human.
|
|
1044
|
+
denyPreToolUse(result.message, result.approvalContext ? { approvalContext: result.approvalContext } : undefined);
|
|
1045
|
+
}
|
|
1046
|
+
if (result.verdict === 'warn') {
|
|
1047
|
+
const reason = consequenceNudge
|
|
1048
|
+
? `${result.message}\n\n${consequenceNudge.headline}`
|
|
1049
|
+
: result.message;
|
|
1050
|
+
process.stdout.write(JSON.stringify({
|
|
1051
|
+
hookSpecificOutput: {
|
|
1052
|
+
hookEventName: 'PreToolUse',
|
|
1053
|
+
permissionDecision: 'allow',
|
|
1054
|
+
reason,
|
|
1055
|
+
},
|
|
1056
|
+
}) + '\n');
|
|
1057
|
+
process.exit(0);
|
|
1058
|
+
}
|
|
1059
|
+
if (consequenceNudge) {
|
|
1060
|
+
process.stdout.write(JSON.stringify({
|
|
1061
|
+
hookSpecificOutput: {
|
|
1062
|
+
hookEventName: 'PreToolUse',
|
|
1063
|
+
permissionDecision: 'allow',
|
|
1064
|
+
reason: consequenceNudge.headline,
|
|
1065
|
+
consequenceNudge: {
|
|
1066
|
+
nudgeVersion: consequenceNudge.nudgeVersion,
|
|
1067
|
+
nudgeKey: consequenceNudge.nudgeKey,
|
|
1068
|
+
severity: consequenceNudge.severity,
|
|
1069
|
+
consequenceClass: consequenceNudge.consequenceClass,
|
|
1070
|
+
operatorAction: consequenceNudge.operatorAction,
|
|
1071
|
+
reviewFocus: consequenceNudge.reviewFocus,
|
|
1072
|
+
artifactHash: consequenceNudge.artifactHash,
|
|
1073
|
+
impact: consequenceNudge.impact ? {
|
|
1074
|
+
rank: consequenceNudge.impact.rank,
|
|
1075
|
+
score: consequenceNudge.impact.score,
|
|
1076
|
+
file: consequenceNudge.impact.file,
|
|
1077
|
+
symbol: consequenceNudge.impact.symbol,
|
|
1078
|
+
summary: consequenceNudge.impact.summary,
|
|
1079
|
+
findingTypes: consequenceNudge.impact.findingTypes,
|
|
1080
|
+
findingCount: consequenceNudge.impact.findingCount,
|
|
1081
|
+
reachableProductionConsumerCount: consequenceNudge.impact.reachableProductionConsumerCount,
|
|
1082
|
+
externalProductionConsumerCount: consequenceNudge.impact.externalProductionConsumerCount,
|
|
1083
|
+
changedProductionConsumerCount: consequenceNudge.impact.changedProductionConsumerCount,
|
|
1084
|
+
productionFiles: consequenceNudge.impact.productionFiles,
|
|
1085
|
+
changedProductionFiles: consequenceNudge.impact.changedProductionFiles,
|
|
1086
|
+
sensitiveConsumerCount: consequenceNudge.impact.sensitiveConsumerCount,
|
|
1087
|
+
approvalRequiredConsumerCount: consequenceNudge.impact.approvalRequiredConsumerCount,
|
|
1088
|
+
runtimeGovernanceConsumerCount: consequenceNudge.impact.runtimeGovernanceConsumerCount,
|
|
1089
|
+
highFanout: consequenceNudge.impact.highFanout,
|
|
1090
|
+
architectureRelevant: consequenceNudge.impact.architectureRelevant,
|
|
1091
|
+
reasonCodes: consequenceNudge.impact.reasonCodes,
|
|
1092
|
+
} : null,
|
|
1093
|
+
finding: {
|
|
1094
|
+
findingType: consequenceNudge.finding.findingType,
|
|
1095
|
+
file: consequenceNudge.finding.file,
|
|
1096
|
+
symbol: consequenceNudge.finding.symbol,
|
|
1097
|
+
externalConsumerCount: consequenceNudge.finding.externalConsumerCount,
|
|
1098
|
+
externalConsumerFiles: consequenceNudge.finding.externalConsumerFiles,
|
|
1099
|
+
consumerSummary: consequenceNudge.finding.consumerSummary,
|
|
1100
|
+
reasonCodes: consequenceNudge.finding.reasonCodes,
|
|
1101
|
+
},
|
|
1102
|
+
surfacedFindingLimit: 3,
|
|
1103
|
+
topFindings: consequenceNudge.surfacedFindings.map((finding) => ({
|
|
1104
|
+
findingType: finding.findingType,
|
|
1105
|
+
file: finding.file,
|
|
1106
|
+
symbol: finding.symbol,
|
|
1107
|
+
externalConsumerCount: finding.externalConsumerCount,
|
|
1108
|
+
externalConsumerFiles: finding.externalConsumerFiles,
|
|
1109
|
+
consumerSummary: finding.consumerSummary,
|
|
1110
|
+
reasonCodes: finding.reasonCodes,
|
|
1111
|
+
})),
|
|
1112
|
+
surfacedImpactLimit: 3,
|
|
1113
|
+
topImpacts: consequenceNudge.surfacedImpacts.map((impact) => ({
|
|
1114
|
+
rank: impact.rank,
|
|
1115
|
+
score: impact.score,
|
|
1116
|
+
file: impact.file,
|
|
1117
|
+
symbol: impact.symbol,
|
|
1118
|
+
summary: impact.summary,
|
|
1119
|
+
findingTypes: impact.findingTypes,
|
|
1120
|
+
findingCount: impact.findingCount,
|
|
1121
|
+
reachableProductionConsumerCount: impact.reachableProductionConsumerCount,
|
|
1122
|
+
externalProductionConsumerCount: impact.externalProductionConsumerCount,
|
|
1123
|
+
changedProductionConsumerCount: impact.changedProductionConsumerCount,
|
|
1124
|
+
productionFiles: impact.productionFiles,
|
|
1125
|
+
changedProductionFiles: impact.changedProductionFiles,
|
|
1126
|
+
sensitiveConsumerCount: impact.sensitiveConsumerCount,
|
|
1127
|
+
approvalRequiredConsumerCount: impact.approvalRequiredConsumerCount,
|
|
1128
|
+
runtimeGovernanceConsumerCount: impact.runtimeGovernanceConsumerCount,
|
|
1129
|
+
highFanout: impact.highFanout,
|
|
1130
|
+
architectureRelevant: impact.architectureRelevant,
|
|
1131
|
+
reasonCodes: impact.reasonCodes,
|
|
1132
|
+
})),
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
}) + '\n');
|
|
1136
|
+
process.exit(0);
|
|
1137
|
+
}
|
|
1138
|
+
// ok or warn-allowed → exit 0
|
|
1139
|
+
process.exit(0);
|
|
1140
|
+
}
|
|
1141
|
+
/** Stop — finalize the active session and emit the replay record. */
|
|
1142
|
+
async function handleFinish(cmdCwd) {
|
|
1143
|
+
const hookInput = readHookInput();
|
|
1144
|
+
const effectiveCwd = cwdFromHookInput(hookInput, cmdCwd);
|
|
1145
|
+
const repoRoot = (0, v0_governance_1.resolveRepoRoot)(effectiveCwd);
|
|
1146
|
+
(0, hook_heartbeat_1.recordHookHeartbeat)({ repoRoot, eventType: 'finish' });
|
|
1147
|
+
const requestedSessionId = sessionIdFromHookInput(hookInput);
|
|
1148
|
+
const resolution = resolveSessionForHook(repoRoot, requestedSessionId);
|
|
1149
|
+
const session = resolution.session;
|
|
1150
|
+
if (!session || session.status !== 'active')
|
|
1151
|
+
return;
|
|
1152
|
+
if (resolution.usedActiveFallback && requestedSessionId) {
|
|
1153
|
+
diagnostic(`Claude session_id ${requestedSessionId} did not match a Neurcode session; finishing active session ${session.sessionId}`);
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const pendingApproval = latestUnresolvedApprovalBlock(session);
|
|
1157
|
+
if (shouldKeepSessionActiveForPendingApproval(session, pendingApproval)) {
|
|
1158
|
+
process.stdout.write(JSON.stringify({
|
|
1159
|
+
message: `⏸ Neurcode session ${session.sessionId} remains active; waiting for exact approval of ` +
|
|
1160
|
+
`${pendingApproval.suggestedApprovalPath}.`,
|
|
1161
|
+
}) + '\n');
|
|
1162
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, session);
|
|
1163
|
+
try {
|
|
1164
|
+
const sync = (0, runtime_connection_1.triggerRuntimeAutoSync)(repoRoot);
|
|
1165
|
+
if (sync.started)
|
|
1166
|
+
diagnostic('runtime evidence auto-sync queued');
|
|
1167
|
+
}
|
|
1168
|
+
catch (syncError) {
|
|
1169
|
+
diagnostic(`auto-sync queue failed: ${syncError instanceof Error ? syncError.message : String(syncError)}`);
|
|
1170
|
+
}
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
const finished = (0, governance_runtime_1.finishSession)(repoRoot, session.sessionId, pendingApproval
|
|
1174
|
+
? {
|
|
1175
|
+
reason: 'finished_with_unresolved_approval_blocks',
|
|
1176
|
+
unresolvedApprovalBlocks: [pendingApproval],
|
|
1177
|
+
}
|
|
1178
|
+
: undefined);
|
|
1179
|
+
if (!finished)
|
|
1180
|
+
return;
|
|
1181
|
+
const blockCount = finished.events.filter((e) => e.type === 'check_block').length;
|
|
1182
|
+
const warnCount = finished.events.filter((e) => e.type === 'check_warn').length;
|
|
1183
|
+
const unresolvedLine = pendingApproval
|
|
1184
|
+
? ` Unresolved: 1 approval block left recorded (${pendingApproval.suggestedApprovalPath})`
|
|
1185
|
+
: null;
|
|
1186
|
+
const summary = [
|
|
1187
|
+
pendingApproval
|
|
1188
|
+
? `✅ Neurcode session ${finished.sessionId} complete with unresolved block evidence`
|
|
1189
|
+
: `✅ Neurcode session ${finished.sessionId} complete`,
|
|
1190
|
+
` Scope mode: ${finished.contract.scopeMode}`,
|
|
1191
|
+
` Boundaries: ${blockCount} block${blockCount !== 1 ? 's' : ''}, ${warnCount} warning${warnCount !== 1 ? 's' : ''}`,
|
|
1192
|
+
...(unresolvedLine ? [unresolvedLine] : []),
|
|
1193
|
+
` Record: .neurcode/sessions/${finished.sessionId}.json`,
|
|
1194
|
+
` replayHash: ${finished.replayHash}`,
|
|
1195
|
+
].join('\n');
|
|
1196
|
+
process.stdout.write(JSON.stringify({ message: summary }) + '\n');
|
|
1197
|
+
// Phase A: emit the self-attested, source-free admission artifact. Never
|
|
1198
|
+
// allowed to break session finish, so failures are diagnostic-only.
|
|
1199
|
+
const admission = (0, admission_artifact_1.tryEmitSelfAttestedAdmissionRecord)({ repoRoot, session: finished });
|
|
1200
|
+
if (admission.ok) {
|
|
1201
|
+
diagnostic(`self-attested admission artifact written: ${admission.result.path}`);
|
|
1202
|
+
}
|
|
1203
|
+
else {
|
|
1204
|
+
diagnostic(`admission artifact skipped: ${admission.error}`);
|
|
1205
|
+
}
|
|
1206
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, finished);
|
|
1207
|
+
try {
|
|
1208
|
+
const sync = (0, runtime_connection_1.triggerRuntimeAutoSync)(repoRoot);
|
|
1209
|
+
if (sync.started) {
|
|
1210
|
+
diagnostic('runtime evidence auto-sync queued');
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
catch (syncError) {
|
|
1214
|
+
diagnostic(`auto-sync queue failed: ${syncError instanceof Error ? syncError.message : String(syncError)}`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
catch (err) {
|
|
1218
|
+
diagnostic(`finish failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
// ── Command registration ──────────────────────────────────────────────────────
|
|
1222
|
+
function sessionHookCommand(program) {
|
|
1223
|
+
const cmd = program
|
|
1224
|
+
.command('session-hook')
|
|
1225
|
+
.description('Internal: called by Claude Code hooks (start / check / finish)')
|
|
1226
|
+
.option('--dir <path>', 'Repository root override (default: resolved from git)');
|
|
1227
|
+
cmd
|
|
1228
|
+
.command('start')
|
|
1229
|
+
.description('Create a governance session (UserPromptSubmit hook)')
|
|
1230
|
+
.action(async () => {
|
|
1231
|
+
const opts = cmd.opts();
|
|
1232
|
+
await handleStart(opts.dir || process.cwd());
|
|
1233
|
+
});
|
|
1234
|
+
cmd
|
|
1235
|
+
.command('check')
|
|
1236
|
+
.description('Check a pending edit against the active session (PreToolUse hook)')
|
|
1237
|
+
.action(async () => {
|
|
1238
|
+
const opts = cmd.opts();
|
|
1239
|
+
await handleCheck(opts.dir || process.cwd());
|
|
1240
|
+
});
|
|
1241
|
+
cmd
|
|
1242
|
+
.command('finish')
|
|
1243
|
+
.description('Finalize the active session and emit the replay record (Stop hook)')
|
|
1244
|
+
.action(async () => {
|
|
1245
|
+
const opts = cmd.opts();
|
|
1246
|
+
await handleFinish(opts.dir || process.cwd());
|
|
1247
|
+
});
|
|
1248
|
+
cmd
|
|
1249
|
+
.command('approve')
|
|
1250
|
+
.description('Approve a path/glob for the active session (unblocks approval-required boundaries)')
|
|
1251
|
+
.requiredOption('--path <path>', 'File path or glob to approve (e.g. src/billing/charge.py)')
|
|
1252
|
+
.option('--reason <text>', 'Human-readable reason for the approval')
|
|
1253
|
+
.option('--expires-in <duration>', 'Approval lifetime (default: 60m; examples: 15m, 2h, 1d)')
|
|
1254
|
+
.option('--expires-at <iso>', 'Absolute ISO timestamp when the approval expires')
|
|
1255
|
+
.option('--no-expiry', 'Create a session-scoped approval without a time expiry')
|
|
1256
|
+
.option('--session-id <id>', 'Session ID to approve against (default: active session)')
|
|
1257
|
+
.option('--json', 'Output machine-readable JSON')
|
|
1258
|
+
.action(async (subOpts) => {
|
|
1259
|
+
const opts = cmd.opts();
|
|
1260
|
+
const cwd = opts.dir || process.cwd();
|
|
1261
|
+
const repoRoot = (0, v0_governance_1.resolveRepoRoot)(cwd);
|
|
1262
|
+
try {
|
|
1263
|
+
const result = (0, governance_runtime_1.approveSession)(repoRoot, subOpts.path, {
|
|
1264
|
+
reason: subOpts.reason,
|
|
1265
|
+
sessionId: subOpts.sessionId,
|
|
1266
|
+
expiresAt: subOpts.expiry === false ? null : subOpts.expiresAt,
|
|
1267
|
+
ttlMs: subOpts.expiry === false || subOpts.expiresAt ? undefined : parseDurationMs(subOpts.expiresIn),
|
|
1268
|
+
source: 'local_cli',
|
|
1269
|
+
});
|
|
1270
|
+
if (subOpts.json) {
|
|
1271
|
+
process.stdout.write(JSON.stringify({ ok: true, ...result }, null, 2) + '\n');
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
process.stdout.write([
|
|
1275
|
+
`✅ Approved: ${result.approvedPath}`,
|
|
1276
|
+
` Session: ${result.sessionId}`,
|
|
1277
|
+
` Expires: ${result.expiresAt || 'session end'}`,
|
|
1278
|
+
` All approved paths: ${result.approvedPaths.join(', ')}`,
|
|
1279
|
+
].join('\n') + '\n');
|
|
1280
|
+
}
|
|
1281
|
+
const session = (0, governance_runtime_1.loadSession)(repoRoot, result.sessionId);
|
|
1282
|
+
if (session)
|
|
1283
|
+
await (0, runtime_live_1.publishRuntimeLiveStatus)(repoRoot, session);
|
|
1284
|
+
}
|
|
1285
|
+
catch (err) {
|
|
1286
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1287
|
+
if (subOpts.json) {
|
|
1288
|
+
process.stdout.write(JSON.stringify({ ok: false, error: msg }, null, 2) + '\n');
|
|
1289
|
+
}
|
|
1290
|
+
else {
|
|
1291
|
+
process.stderr.write(`[neurcode] approval failed: ${msg}\n`);
|
|
1292
|
+
}
|
|
1293
|
+
process.exitCode = 1;
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
//# sourceMappingURL=session-hook.js.map
|