@neurcode-ai/governance-runtime 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/admission-provenance.d.ts +111 -0
- package/dist/admission-provenance.d.ts.map +1 -0
- package/dist/admission-provenance.js +735 -0
- package/dist/admission-provenance.js.map +1 -0
- package/dist/agent-guard-posture.d.ts +40 -0
- package/dist/agent-guard-posture.d.ts.map +1 -0
- package/dist/agent-guard-posture.js +117 -0
- package/dist/agent-guard-posture.js.map +1 -0
- package/dist/agent-invocation-observability.d.ts +47 -0
- package/dist/agent-invocation-observability.d.ts.map +1 -0
- package/dist/agent-invocation-observability.js +229 -0
- package/dist/agent-invocation-observability.js.map +1 -0
- package/dist/agent-plan.d.ts +119 -0
- package/dist/agent-plan.d.ts.map +1 -0
- package/dist/agent-plan.js +565 -0
- package/dist/agent-plan.js.map +1 -0
- package/dist/agent-runtime-adapter.d.ts +69 -0
- package/dist/agent-runtime-adapter.d.ts.map +1 -0
- package/dist/agent-runtime-adapter.js +274 -0
- package/dist/agent-runtime-adapter.js.map +1 -0
- package/dist/ai-change-record.d.ts +185 -0
- package/dist/ai-change-record.d.ts.map +1 -0
- package/dist/ai-change-record.js +580 -0
- package/dist/ai-change-record.js.map +1 -0
- package/dist/architecture-graph.d.ts +153 -0
- package/dist/architecture-graph.d.ts.map +1 -0
- package/dist/architecture-graph.js +646 -0
- package/dist/architecture-graph.js.map +1 -0
- package/dist/architecture-obligations.d.ts +153 -0
- package/dist/architecture-obligations.d.ts.map +1 -0
- package/dist/architecture-obligations.js +505 -0
- package/dist/architecture-obligations.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +103 -1
- package/dist/index.js.map +1 -1
- package/dist/profile.d.ts +159 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +611 -0
- package/dist/profile.js.map +1 -0
- package/dist/session.d.ts +428 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +2052 -0
- package/dist/session.js.map +1 -0
- package/package.json +19 -8
- package/src/constraints.ts +0 -828
- package/src/index.test.ts +0 -502
- package/src/index.ts +0 -463
- package/tsconfig.json +0 -19
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Plan Capture V1
|
|
3
|
+
*
|
|
4
|
+
* Source-free model of the *agent's own stated plan* during an agentic coding
|
|
5
|
+
* session. This is intentionally distinct from the V0 diff-based plan
|
|
6
|
+
* verification primitives in `./index.ts` (PlanVerification*, PlanDiffFile,
|
|
7
|
+
* etc.). Here we capture what the agent *said it would do* so the runtime can
|
|
8
|
+
* answer: "is the agent still executing its own plan, or has it started making
|
|
9
|
+
* changes its plan never justified?"
|
|
10
|
+
*
|
|
11
|
+
* Hard rules honored by this module:
|
|
12
|
+
* - Never store source code, diffs, patches, or file contents. Only file path
|
|
13
|
+
* references, globs, and short natural-language summaries the agent emitted.
|
|
14
|
+
* - Extraction is conservative: when no plan is present we return null rather
|
|
15
|
+
* than inventing one, and a bare user prompt is never treated as an agent
|
|
16
|
+
* plan.
|
|
17
|
+
* - Everything is deterministic (no model calls, no randomness).
|
|
18
|
+
*/
|
|
19
|
+
export type AgentPlanSource = 'claude_prompt' | 'manual' | 'mcp' | 'unknown';
|
|
20
|
+
export type AgentPlanConfidence = 'high' | 'medium' | 'low';
|
|
21
|
+
export declare const AGENT_PLAN_SCHEMA_VERSION: 1;
|
|
22
|
+
export interface AgentPlan {
|
|
23
|
+
schemaVersion: number;
|
|
24
|
+
/** Short natural-language description of the plan (source-free). */
|
|
25
|
+
summary: string;
|
|
26
|
+
/** Ordered plan steps as short text lines (source-free). */
|
|
27
|
+
steps: string[];
|
|
28
|
+
/** Concrete file paths the plan expects to touch. */
|
|
29
|
+
expectedFiles: string[];
|
|
30
|
+
/** Glob patterns the plan expects to touch. */
|
|
31
|
+
expectedGlobs: string[];
|
|
32
|
+
/** Stated constraints / guardrails the agent committed to. */
|
|
33
|
+
constraints: string[];
|
|
34
|
+
/** Stated risks / caveats the agent called out. */
|
|
35
|
+
risks: string[];
|
|
36
|
+
/** ISO-8601 timestamp of when the plan was captured. */
|
|
37
|
+
capturedAt: string;
|
|
38
|
+
source: AgentPlanSource;
|
|
39
|
+
confidence: AgentPlanConfidence;
|
|
40
|
+
}
|
|
41
|
+
export type PlanCoherenceVerdict = 'planned' | 'implied' | 'unplanned' | 'unknown';
|
|
42
|
+
export interface PlanCoherenceResult {
|
|
43
|
+
verdict: PlanCoherenceVerdict;
|
|
44
|
+
/** 0-100; higher means the edit is better justified by the agent's plan. */
|
|
45
|
+
score: number;
|
|
46
|
+
/** Plan items (file/glob/step text) that matched the edited path. */
|
|
47
|
+
matchedPlanItems: string[];
|
|
48
|
+
/** Human-readable, deterministic explanation lines. */
|
|
49
|
+
reasons: string[];
|
|
50
|
+
}
|
|
51
|
+
export interface PlanCoherenceInput {
|
|
52
|
+
/** The captured agent plan, if any. */
|
|
53
|
+
agentPlan?: AgentPlan | null;
|
|
54
|
+
/** Repo-relative path being edited. */
|
|
55
|
+
filePath: string;
|
|
56
|
+
/**
|
|
57
|
+
* Support paths derived from the *user intent* contract (e.g. globs the
|
|
58
|
+
* intent allows as supporting work). Kept as an input so this module stays
|
|
59
|
+
* decoupled from the intent-contract shape.
|
|
60
|
+
*/
|
|
61
|
+
intentSupportGlobs?: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Explicit override for whether the plan implies support work (tests,
|
|
64
|
+
* utilities, refactors). When omitted we infer it deterministically from the
|
|
65
|
+
* plan text.
|
|
66
|
+
*/
|
|
67
|
+
planImpliesSupportWork?: boolean;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Extract expected file paths and globs from free-form plan text. We only look
|
|
71
|
+
* at backtick code spans and whitespace-delimited tokens that pass strict
|
|
72
|
+
* path/glob shape checks — never arbitrary words.
|
|
73
|
+
*/
|
|
74
|
+
export declare function extractExpectedTargetsFromText(text: string): {
|
|
75
|
+
expectedFiles: string[];
|
|
76
|
+
expectedGlobs: string[];
|
|
77
|
+
};
|
|
78
|
+
/** Parse ordered/checklist step lines out of a markdown-ish block. */
|
|
79
|
+
export declare function parsePlanSteps(text: string): string[];
|
|
80
|
+
/** Does the plan text imply legitimate supporting work (tests/utils/refactor)? */
|
|
81
|
+
export declare function planImpliesSupportWork(plan: AgentPlan | null | undefined): boolean;
|
|
82
|
+
/** Is this path a conventional test or utility/support file? */
|
|
83
|
+
export declare function isTestOrUtilityPath(filePath: string): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Deterministically grade an edit against the agent's plan.
|
|
86
|
+
*
|
|
87
|
+
* Verdict precedence:
|
|
88
|
+
* 1. No agent plan captured -> unknown
|
|
89
|
+
* 2. Path matches expectedFiles/globs -> planned
|
|
90
|
+
* 3. Path is intent-support/test/util AND plan implies support work -> implied
|
|
91
|
+
* 4. Otherwise -> unplanned
|
|
92
|
+
*
|
|
93
|
+
* NOTE: this is advisory in V1. Boundary/approval blocks always override this
|
|
94
|
+
* verdict at the call sites; an `unplanned` verdict alone must not block.
|
|
95
|
+
*/
|
|
96
|
+
export declare function evaluatePlanCoherence(input: PlanCoherenceInput): PlanCoherenceResult;
|
|
97
|
+
export interface ExtractAgentPlanOptions {
|
|
98
|
+
/** Override capture timestamp (mainly for deterministic tests). */
|
|
99
|
+
now?: Date;
|
|
100
|
+
/** Override the recorded source (e.g. 'mcp' when called from the MCP server). */
|
|
101
|
+
source?: AgentPlanSource;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Deterministically extract an {@link AgentPlan} from a Claude Code hook
|
|
105
|
+
* payload. Returns null when no plan is present — callers must treat a null
|
|
106
|
+
* result as "no plan", never as a failure.
|
|
107
|
+
*
|
|
108
|
+
* This function never throws: any malformed payload yields null.
|
|
109
|
+
*/
|
|
110
|
+
export declare function extractAgentPlan(payload: unknown, options?: ExtractAgentPlanOptions): AgentPlan | null;
|
|
111
|
+
/**
|
|
112
|
+
* Source-free projection of an agent plan for live sync / evidence export.
|
|
113
|
+
* Drops nothing sensitive (the model is already source-free) but enforces the
|
|
114
|
+
* shape and trims away anything unexpected callers may have attached.
|
|
115
|
+
*/
|
|
116
|
+
export declare function sanitizeAgentPlan(value: unknown): AgentPlan | undefined;
|
|
117
|
+
/** Sanitize a plan-coherence result coming back over the wire. */
|
|
118
|
+
export declare function sanitizePlanCoherence(value: unknown): PlanCoherenceResult | undefined;
|
|
119
|
+
//# sourceMappingURL=agent-plan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-plan.d.ts","sourceRoot":"","sources":["../src/agent-plan.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,eAAe,GAAG,eAAe,GAAG,QAAQ,GAAG,KAAK,GAAG,SAAS,CAAC;AAC7E,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAE5D,eAAO,MAAM,yBAAyB,EAAG,CAAU,CAAC;AAEpD,MAAM,WAAW,SAAS;IACxB,aAAa,EAAE,MAAM,CAAC;IACtB,oEAAoE;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,qDAAqD;IACrD,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,+CAA+C;IAC/C,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,8DAA8D;IAC9D,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,mDAAmD;IACnD,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,wDAAwD;IACxD,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,eAAe,CAAC;IACxB,UAAU,EAAE,mBAAmB,CAAC;CACjC;AAED,MAAM,MAAM,oBAAoB,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,SAAS,CAAC;AAEnF,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,oBAAoB,CAAC;IAC9B,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,uDAAuD;IACvD,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,uCAAuC;IACvC,SAAS,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC7B,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC9B;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,OAAO,CAAC;CAClC;AAwED;;;;GAIG;AACH,wBAAgB,8BAA8B,CAAC,IAAI,EAAE,MAAM,GAAG;IAC5D,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB,CA0CA;AAMD,sEAAsE;AACtE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAcrD;AAyCD,kFAAkF;AAClF,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,SAAS,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAMlF;AAkBD,gEAAgE;AAChE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAM7D;AAuBD;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,kBAAkB,GAAG,mBAAmB,CAuFpF;AA4ID,MAAM,WAAW,uBAAuB;IACtC,mEAAmE;IACnE,GAAG,CAAC,EAAE,IAAI,CAAC;IACX,iFAAiF;IACjF,MAAM,CAAC,EAAE,eAAe,CAAC;CAC1B;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,uBAA4B,GACpC,SAAS,GAAG,IAAI,CAwDlB;AAcD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CA4CvE;AAED,kEAAkE;AAClE,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,OAAO,GAAG,mBAAmB,GAAG,SAAS,CAoBrF"}
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AGENT_PLAN_SCHEMA_VERSION = void 0;
|
|
7
|
+
exports.extractExpectedTargetsFromText = extractExpectedTargetsFromText;
|
|
8
|
+
exports.parsePlanSteps = parsePlanSteps;
|
|
9
|
+
exports.planImpliesSupportWork = planImpliesSupportWork;
|
|
10
|
+
exports.isTestOrUtilityPath = isTestOrUtilityPath;
|
|
11
|
+
exports.evaluatePlanCoherence = evaluatePlanCoherence;
|
|
12
|
+
exports.extractAgentPlan = extractAgentPlan;
|
|
13
|
+
exports.sanitizeAgentPlan = sanitizeAgentPlan;
|
|
14
|
+
exports.sanitizePlanCoherence = sanitizePlanCoherence;
|
|
15
|
+
const micromatch_1 = __importDefault(require("micromatch"));
|
|
16
|
+
exports.AGENT_PLAN_SCHEMA_VERSION = 1;
|
|
17
|
+
function normalizeRepoPath(pathValue) {
|
|
18
|
+
return pathValue.replace(/\\/g, '/').replace(/^\.\//, '').trim();
|
|
19
|
+
}
|
|
20
|
+
function uniqueNonEmpty(values) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const raw of values) {
|
|
24
|
+
const value = (raw ?? '').trim();
|
|
25
|
+
if (!value || seen.has(value)) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
seen.add(value);
|
|
29
|
+
out.push(value);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function stripSourceLikePlanText(text) {
|
|
34
|
+
const withoutFencedCode = text.replace(/```[\s\S]*?```/g, '\n');
|
|
35
|
+
return withoutFencedCode
|
|
36
|
+
.split(/\r?\n/)
|
|
37
|
+
.filter((line) => {
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
if (!trimmed) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (/^(?:diff --git |index [0-9a-f]{6,}|@@ |--- |\+\+\+ |Binary files )/.test(trimmed)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return !/^[+-](?!\s)/.test(trimmed);
|
|
46
|
+
})
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
const GLOB_CHARS = /[*?{}\[\]!]/;
|
|
50
|
+
function looksLikeGlob(token) {
|
|
51
|
+
return GLOB_CHARS.test(token);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Heuristic file-path detector. Matches tokens that look like repo paths:
|
|
55
|
+
* either they contain a directory separator, or they end in a common source
|
|
56
|
+
* file extension. Deliberately conservative to avoid capturing prose.
|
|
57
|
+
*/
|
|
58
|
+
const PATH_LIKE = /^(?:[\w.@~-]+\/)*[\w.@~-]+\.[A-Za-z0-9]{1,8}$/;
|
|
59
|
+
const DIR_PATH_LIKE = /^(?:[\w.@~-]+\/)+[\w.@~*-]+$/;
|
|
60
|
+
function looksLikePath(token) {
|
|
61
|
+
if (!token || token.length > 200) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (looksLikeGlob(token)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return PATH_LIKE.test(token) || DIR_PATH_LIKE.test(token);
|
|
68
|
+
}
|
|
69
|
+
/** Pull `inline code` spans out of markdown/plain text. */
|
|
70
|
+
function extractInlineCodeSpans(text) {
|
|
71
|
+
const spans = [];
|
|
72
|
+
const re = /`([^`]+)`/g;
|
|
73
|
+
let match;
|
|
74
|
+
while ((match = re.exec(text)) !== null) {
|
|
75
|
+
spans.push(match[1].trim());
|
|
76
|
+
}
|
|
77
|
+
return spans;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Extract expected file paths and globs from free-form plan text. We only look
|
|
81
|
+
* at backtick code spans and whitespace-delimited tokens that pass strict
|
|
82
|
+
* path/glob shape checks — never arbitrary words.
|
|
83
|
+
*/
|
|
84
|
+
function extractExpectedTargetsFromText(text) {
|
|
85
|
+
const files = [];
|
|
86
|
+
const globs = [];
|
|
87
|
+
const considerToken = (rawToken) => {
|
|
88
|
+
const token = rawToken
|
|
89
|
+
.replace(/^[("'`<\[]+/, '')
|
|
90
|
+
.replace(/[)"'`>\].,;:]+$/, '')
|
|
91
|
+
.trim();
|
|
92
|
+
if (!token) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const normalized = normalizeRepoPath(token);
|
|
96
|
+
if (!normalized) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (looksLikeGlob(normalized)) {
|
|
100
|
+
// A glob still needs at least one path-ish segment to be meaningful.
|
|
101
|
+
if (/[\w/]/.test(normalized.replace(GLOB_CHARS, ''))) {
|
|
102
|
+
globs.push(normalized);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (looksLikePath(normalized)) {
|
|
107
|
+
files.push(normalized);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// Prefer code spans (highest signal), then fall back to all tokens.
|
|
111
|
+
for (const span of extractInlineCodeSpans(text)) {
|
|
112
|
+
for (const piece of span.split(/\s+/)) {
|
|
113
|
+
considerToken(piece);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const token of text.split(/\s+/)) {
|
|
117
|
+
considerToken(token);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
expectedFiles: uniqueNonEmpty(files),
|
|
121
|
+
expectedGlobs: uniqueNonEmpty(globs),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const STEP_LINE = /^\s*(?:\d+[.)]|[-*+]|\[[ xX]?\])\s+(.*\S)\s*$/;
|
|
125
|
+
const NUMBERED_STEP = /^\s*\d+[.)]\s+/;
|
|
126
|
+
const CHECKLIST_STEP = /^\s*(?:[-*+]|\[[ xX]?\])\s+/;
|
|
127
|
+
/** Parse ordered/checklist step lines out of a markdown-ish block. */
|
|
128
|
+
function parsePlanSteps(text) {
|
|
129
|
+
const steps = [];
|
|
130
|
+
for (const line of text.split(/\r?\n/)) {
|
|
131
|
+
const match = line.match(STEP_LINE);
|
|
132
|
+
if (match) {
|
|
133
|
+
const step = match[1]
|
|
134
|
+
.replace(/^\[[ xX]?\]\s*/, '')
|
|
135
|
+
.trim();
|
|
136
|
+
if (step) {
|
|
137
|
+
steps.push(step);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return steps;
|
|
142
|
+
}
|
|
143
|
+
function hasPlanStructure(text) {
|
|
144
|
+
let stepCount = 0;
|
|
145
|
+
for (const line of text.split(/\r?\n/)) {
|
|
146
|
+
if (NUMBERED_STEP.test(line) || CHECKLIST_STEP.test(line)) {
|
|
147
|
+
stepCount += 1;
|
|
148
|
+
if (stepCount >= 2) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const SUPPORT_KEYWORDS = [
|
|
156
|
+
'test',
|
|
157
|
+
'tests',
|
|
158
|
+
'testing',
|
|
159
|
+
'spec',
|
|
160
|
+
'fixture',
|
|
161
|
+
'mock',
|
|
162
|
+
'util',
|
|
163
|
+
'utils',
|
|
164
|
+
'utility',
|
|
165
|
+
'helper',
|
|
166
|
+
'helpers',
|
|
167
|
+
'refactor',
|
|
168
|
+
'type',
|
|
169
|
+
'types',
|
|
170
|
+
'typing',
|
|
171
|
+
'docs',
|
|
172
|
+
'documentation',
|
|
173
|
+
'comment',
|
|
174
|
+
'config',
|
|
175
|
+
'lint',
|
|
176
|
+
'format',
|
|
177
|
+
];
|
|
178
|
+
const SUPPORT_KEYWORD_RE = new RegExp(`\\b(?:${SUPPORT_KEYWORDS.join('|')})\\b`, 'i');
|
|
179
|
+
/** Does the plan text imply legitimate supporting work (tests/utils/refactor)? */
|
|
180
|
+
function planImpliesSupportWork(plan) {
|
|
181
|
+
if (!plan) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const haystack = [plan.summary, ...plan.steps, ...plan.constraints].join('\n');
|
|
185
|
+
return SUPPORT_KEYWORD_RE.test(haystack);
|
|
186
|
+
}
|
|
187
|
+
const TEST_OR_UTILITY_GLOBS = [
|
|
188
|
+
'**/*.test.*',
|
|
189
|
+
'**/*.spec.*',
|
|
190
|
+
'**/__tests__/**',
|
|
191
|
+
'**/__mocks__/**',
|
|
192
|
+
'**/test/**',
|
|
193
|
+
'**/tests/**',
|
|
194
|
+
'**/*.d.ts',
|
|
195
|
+
'**/utils/**',
|
|
196
|
+
'**/util/**',
|
|
197
|
+
'**/helpers/**',
|
|
198
|
+
'**/helper/**',
|
|
199
|
+
'**/fixtures/**',
|
|
200
|
+
'**/__fixtures__/**',
|
|
201
|
+
];
|
|
202
|
+
/** Is this path a conventional test or utility/support file? */
|
|
203
|
+
function isTestOrUtilityPath(filePath) {
|
|
204
|
+
const normalized = normalizeRepoPath(filePath);
|
|
205
|
+
if (!normalized) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
return micromatch_1.default.isMatch(normalized, TEST_OR_UTILITY_GLOBS, { dot: true });
|
|
209
|
+
}
|
|
210
|
+
function matchPathAgainstGlobs(filePath, globs) {
|
|
211
|
+
if (globs.length === 0) {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
const normalized = normalizeRepoPath(filePath);
|
|
215
|
+
return globs.filter((glob) => {
|
|
216
|
+
const normalizedGlob = normalizeRepoPath(glob);
|
|
217
|
+
if (!normalizedGlob) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return micromatch_1.default.isMatch(normalized, normalizedGlob, { dot: true });
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function clampScore(score) {
|
|
224
|
+
if (!Number.isFinite(score)) {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Deterministically grade an edit against the agent's plan.
|
|
231
|
+
*
|
|
232
|
+
* Verdict precedence:
|
|
233
|
+
* 1. No agent plan captured -> unknown
|
|
234
|
+
* 2. Path matches expectedFiles/globs -> planned
|
|
235
|
+
* 3. Path is intent-support/test/util AND plan implies support work -> implied
|
|
236
|
+
* 4. Otherwise -> unplanned
|
|
237
|
+
*
|
|
238
|
+
* NOTE: this is advisory in V1. Boundary/approval blocks always override this
|
|
239
|
+
* verdict at the call sites; an `unplanned` verdict alone must not block.
|
|
240
|
+
*/
|
|
241
|
+
function evaluatePlanCoherence(input) {
|
|
242
|
+
const { agentPlan, filePath } = input;
|
|
243
|
+
const normalizedPath = normalizeRepoPath(filePath || '');
|
|
244
|
+
if (!agentPlan) {
|
|
245
|
+
return {
|
|
246
|
+
verdict: 'unknown',
|
|
247
|
+
score: 0,
|
|
248
|
+
matchedPlanItems: [],
|
|
249
|
+
reasons: ['No agent plan captured for this session; plan coherence is unknown.'],
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (!normalizedPath) {
|
|
253
|
+
return {
|
|
254
|
+
verdict: 'unknown',
|
|
255
|
+
score: 0,
|
|
256
|
+
matchedPlanItems: [],
|
|
257
|
+
reasons: ['No file path provided; plan coherence is unknown.'],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const reasons = [];
|
|
261
|
+
const matchedPlanItems = [];
|
|
262
|
+
// (2) Direct plan match: exact expected file or expected glob.
|
|
263
|
+
const expectedFiles = (agentPlan.expectedFiles || []).map(normalizeRepoPath);
|
|
264
|
+
if (expectedFiles.includes(normalizedPath)) {
|
|
265
|
+
matchedPlanItems.push(normalizedPath);
|
|
266
|
+
reasons.push(`Path matches a file the plan expected to touch (${normalizedPath}).`);
|
|
267
|
+
return {
|
|
268
|
+
verdict: 'planned',
|
|
269
|
+
score: 100,
|
|
270
|
+
matchedPlanItems: uniqueNonEmpty(matchedPlanItems),
|
|
271
|
+
reasons,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const matchedGlobs = matchPathAgainstGlobs(normalizedPath, agentPlan.expectedGlobs || []);
|
|
275
|
+
if (matchedGlobs.length > 0) {
|
|
276
|
+
matchedPlanItems.push(...matchedGlobs);
|
|
277
|
+
reasons.push(`Path matches an expected plan glob (${matchedGlobs.join(', ')}).`);
|
|
278
|
+
return {
|
|
279
|
+
verdict: 'planned',
|
|
280
|
+
score: 90,
|
|
281
|
+
matchedPlanItems: uniqueNonEmpty(matchedPlanItems),
|
|
282
|
+
reasons,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// (3) Implied support work: intent-support path or test/utility file, AND the
|
|
286
|
+
// plan acknowledges support work somewhere.
|
|
287
|
+
const intentMatches = matchPathAgainstGlobs(normalizedPath, input.intentSupportGlobs || []);
|
|
288
|
+
const isSupportPath = intentMatches.length > 0 || isTestOrUtilityPath(normalizedPath);
|
|
289
|
+
const planSupports = typeof input.planImpliesSupportWork === 'boolean'
|
|
290
|
+
? input.planImpliesSupportWork
|
|
291
|
+
: planImpliesSupportWork(agentPlan);
|
|
292
|
+
if (isSupportPath && planSupports) {
|
|
293
|
+
if (intentMatches.length > 0) {
|
|
294
|
+
matchedPlanItems.push(...intentMatches);
|
|
295
|
+
reasons.push(`Path falls under intent-support scope (${intentMatches.join(', ')}).`);
|
|
296
|
+
}
|
|
297
|
+
if (isTestOrUtilityPath(normalizedPath)) {
|
|
298
|
+
reasons.push('Path is a test/utility file consistent with the plan’s supporting work.');
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
verdict: 'implied',
|
|
302
|
+
score: 65,
|
|
303
|
+
matchedPlanItems: uniqueNonEmpty(matchedPlanItems),
|
|
304
|
+
reasons,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// (4) Neither plan nor intent justify this edit.
|
|
308
|
+
if (isSupportPath && !planSupports) {
|
|
309
|
+
reasons.push('Path looks like support work, but the plan never mentioned supporting work.');
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
reasons.push('Path matches neither the plan’s expected targets nor intent-support scope.');
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
verdict: 'unplanned',
|
|
316
|
+
score: clampScore(isSupportPath ? 35 : 15),
|
|
317
|
+
matchedPlanItems: [],
|
|
318
|
+
reasons,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Plan extraction from Claude Code hook payloads
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
function asString(value) {
|
|
325
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
326
|
+
}
|
|
327
|
+
function asRecord(value) {
|
|
328
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
329
|
+
? value
|
|
330
|
+
: undefined;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Locate plan-bearing text inside a Claude Code hook payload, ranked by signal.
|
|
334
|
+
* Returns undefined when the payload only carries a user prompt (or nothing).
|
|
335
|
+
*
|
|
336
|
+
* High-signal sources, in priority order:
|
|
337
|
+
* 1. PreToolUse / ExitPlanMode -> tool_input.plan (the agent's actual plan)
|
|
338
|
+
* 2. PreToolUse / TodoWrite -> tool_input.todos (the agent's task list)
|
|
339
|
+
* 3. An explicit `plan` field (string or { summary/steps })
|
|
340
|
+
* 4. An assistant message / transcript turn containing plan structure
|
|
341
|
+
*
|
|
342
|
+
* The `prompt` / `user_prompt` field is treated as *user intent* and is never,
|
|
343
|
+
* on its own, promoted to an agent plan.
|
|
344
|
+
*/
|
|
345
|
+
function findPlanText(payload) {
|
|
346
|
+
const toolName = asString(payload.tool_name) ||
|
|
347
|
+
asString(payload.toolName) ||
|
|
348
|
+
asString(asRecord(payload.tool)?.name);
|
|
349
|
+
const toolInput = asRecord(payload.tool_input) ||
|
|
350
|
+
asRecord(payload.toolInput) ||
|
|
351
|
+
asRecord(payload.input);
|
|
352
|
+
// (1) ExitPlanMode carries the agent's plan verbatim.
|
|
353
|
+
if (toolInput && /exitplanmode/i.test(toolName || '')) {
|
|
354
|
+
const plan = asString(toolInput.plan);
|
|
355
|
+
if (plan) {
|
|
356
|
+
return { text: plan, source: 'claude_prompt', structured: true };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// (2) TodoWrite carries an ordered task list.
|
|
360
|
+
if (toolInput && /todowrite/i.test(toolName || '')) {
|
|
361
|
+
const todos = Array.isArray(toolInput.todos) ? toolInput.todos : undefined;
|
|
362
|
+
if (todos && todos.length > 0) {
|
|
363
|
+
const steps = todos
|
|
364
|
+
.map((todo) => asString(asRecord(todo)?.content) || asString(asRecord(todo)?.activeForm))
|
|
365
|
+
.filter((value) => Boolean(value));
|
|
366
|
+
if (steps.length > 0) {
|
|
367
|
+
return {
|
|
368
|
+
text: steps.join('\n'),
|
|
369
|
+
source: 'claude_prompt',
|
|
370
|
+
structured: true,
|
|
371
|
+
steps,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// (3) An explicit plan field (used by manual/MCP callers).
|
|
377
|
+
const planField = payload.plan;
|
|
378
|
+
const planString = asString(planField);
|
|
379
|
+
if (planString) {
|
|
380
|
+
return { text: planString, source: 'claude_prompt', structured: true };
|
|
381
|
+
}
|
|
382
|
+
const planRecord = asRecord(planField);
|
|
383
|
+
if (planRecord) {
|
|
384
|
+
const summary = asString(planRecord.summary) || '';
|
|
385
|
+
const steps = Array.isArray(planRecord.steps)
|
|
386
|
+
? planRecord.steps.map((s) => asString(s)).filter((v) => Boolean(v))
|
|
387
|
+
: [];
|
|
388
|
+
if (summary || steps.length > 0) {
|
|
389
|
+
return {
|
|
390
|
+
text: [summary, ...steps].join('\n'),
|
|
391
|
+
source: 'claude_prompt',
|
|
392
|
+
structured: true,
|
|
393
|
+
steps: steps.length > 0 ? steps : undefined,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// (4) Assistant message / transcript with plan structure.
|
|
398
|
+
const assistantCandidates = [
|
|
399
|
+
asString(payload.assistant_message),
|
|
400
|
+
asString(payload.assistantMessage),
|
|
401
|
+
asString(asRecord(payload.message)?.content),
|
|
402
|
+
asString(payload.message),
|
|
403
|
+
asString(payload.transcript),
|
|
404
|
+
].filter((v) => Boolean(v));
|
|
405
|
+
for (const candidate of assistantCandidates) {
|
|
406
|
+
if (hasPlanStructure(candidate)) {
|
|
407
|
+
return { text: candidate, source: 'claude_prompt', structured: false };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return undefined;
|
|
411
|
+
}
|
|
412
|
+
function firstSentence(text, max = 200) {
|
|
413
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
414
|
+
if (!collapsed) {
|
|
415
|
+
return '';
|
|
416
|
+
}
|
|
417
|
+
const stop = collapsed.search(/[.!?](?:\s|$)/);
|
|
418
|
+
const sentence = stop >= 0 ? collapsed.slice(0, stop + 1) : collapsed;
|
|
419
|
+
return sentence.length > max ? `${sentence.slice(0, max - 1).trim()}…` : sentence;
|
|
420
|
+
}
|
|
421
|
+
function deriveConfidence(args) {
|
|
422
|
+
const hasTargets = args.expectedFiles.length + args.expectedGlobs.length > 0;
|
|
423
|
+
if (args.structured && (hasTargets || args.steps.length >= 2)) {
|
|
424
|
+
return 'high';
|
|
425
|
+
}
|
|
426
|
+
if (args.structured || (args.steps.length >= 2 && hasTargets)) {
|
|
427
|
+
return 'medium';
|
|
428
|
+
}
|
|
429
|
+
return 'low';
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Deterministically extract an {@link AgentPlan} from a Claude Code hook
|
|
433
|
+
* payload. Returns null when no plan is present — callers must treat a null
|
|
434
|
+
* result as "no plan", never as a failure.
|
|
435
|
+
*
|
|
436
|
+
* This function never throws: any malformed payload yields null.
|
|
437
|
+
*/
|
|
438
|
+
function extractAgentPlan(payload, options = {}) {
|
|
439
|
+
try {
|
|
440
|
+
const record = asRecord(payload);
|
|
441
|
+
if (!record) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
const found = findPlanText(record);
|
|
445
|
+
if (!found) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
const safeText = stripSourceLikePlanText(found.text);
|
|
449
|
+
const steps = found.steps && found.steps.length > 0
|
|
450
|
+
? uniqueNonEmpty(found.steps.map(stripSourceLikePlanText))
|
|
451
|
+
: uniqueNonEmpty(parsePlanSteps(safeText));
|
|
452
|
+
// Conservative: an unstructured assistant message must actually contain
|
|
453
|
+
// multiple steps to qualify as a plan.
|
|
454
|
+
if (!found.structured && steps.length < 2) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
const { expectedFiles, expectedGlobs } = extractExpectedTargetsFromText(safeText);
|
|
458
|
+
const summary = firstSentence(safeText) ||
|
|
459
|
+
(steps.length > 0 ? steps[0] : '') ||
|
|
460
|
+
'Agent plan';
|
|
461
|
+
const constraints = extractLabeledLines(safeText, ['constraint', 'guardrail', 'must not', 'do not', 'never']);
|
|
462
|
+
const risks = extractLabeledLines(safeText, ['risk', 'caveat', 'warning', 'danger']);
|
|
463
|
+
const capturedAt = (options.now ?? new Date()).toISOString();
|
|
464
|
+
return {
|
|
465
|
+
schemaVersion: exports.AGENT_PLAN_SCHEMA_VERSION,
|
|
466
|
+
summary,
|
|
467
|
+
steps,
|
|
468
|
+
expectedFiles,
|
|
469
|
+
expectedGlobs,
|
|
470
|
+
constraints,
|
|
471
|
+
risks,
|
|
472
|
+
capturedAt,
|
|
473
|
+
source: options.source ?? found.source,
|
|
474
|
+
confidence: deriveConfidence({
|
|
475
|
+
structured: found.structured,
|
|
476
|
+
steps,
|
|
477
|
+
expectedFiles,
|
|
478
|
+
expectedGlobs,
|
|
479
|
+
}),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// Plan capture must never fail the hook.
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
function extractLabeledLines(text, labels) {
|
|
488
|
+
const out = [];
|
|
489
|
+
const labelRe = new RegExp(`\\b(?:${labels.join('|')})\\b`, 'i');
|
|
490
|
+
for (const line of text.split(/\r?\n/)) {
|
|
491
|
+
const trimmed = line.replace(STEP_LINE, '$1').trim();
|
|
492
|
+
if (trimmed && labelRe.test(trimmed)) {
|
|
493
|
+
out.push(trimmed);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return uniqueNonEmpty(out);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Source-free projection of an agent plan for live sync / evidence export.
|
|
500
|
+
* Drops nothing sensitive (the model is already source-free) but enforces the
|
|
501
|
+
* shape and trims away anything unexpected callers may have attached.
|
|
502
|
+
*/
|
|
503
|
+
function sanitizeAgentPlan(value) {
|
|
504
|
+
const record = asRecord(value);
|
|
505
|
+
if (!record) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
const summary = asString(record.summary) || '';
|
|
509
|
+
const steps = Array.isArray(record.steps)
|
|
510
|
+
? uniqueNonEmpty(record.steps.map((s) => asString(s) || ''))
|
|
511
|
+
: [];
|
|
512
|
+
if (!summary && steps.length === 0) {
|
|
513
|
+
return undefined;
|
|
514
|
+
}
|
|
515
|
+
const source = ['claude_prompt', 'manual', 'mcp', 'unknown'].includes(record.source)
|
|
516
|
+
? record.source
|
|
517
|
+
: 'unknown';
|
|
518
|
+
const confidence = ['high', 'medium', 'low'].includes(record.confidence)
|
|
519
|
+
? record.confidence
|
|
520
|
+
: 'low';
|
|
521
|
+
const schemaVersion = typeof record.schemaVersion === 'number' ? record.schemaVersion : exports.AGENT_PLAN_SCHEMA_VERSION;
|
|
522
|
+
return {
|
|
523
|
+
schemaVersion,
|
|
524
|
+
summary,
|
|
525
|
+
steps,
|
|
526
|
+
expectedFiles: Array.isArray(record.expectedFiles)
|
|
527
|
+
? uniqueNonEmpty(record.expectedFiles.map((s) => asString(s) || ''))
|
|
528
|
+
: [],
|
|
529
|
+
expectedGlobs: Array.isArray(record.expectedGlobs)
|
|
530
|
+
? uniqueNonEmpty(record.expectedGlobs.map((s) => asString(s) || ''))
|
|
531
|
+
: [],
|
|
532
|
+
constraints: Array.isArray(record.constraints)
|
|
533
|
+
? uniqueNonEmpty(record.constraints.map((s) => asString(s) || ''))
|
|
534
|
+
: [],
|
|
535
|
+
risks: Array.isArray(record.risks)
|
|
536
|
+
? uniqueNonEmpty(record.risks.map((s) => asString(s) || ''))
|
|
537
|
+
: [],
|
|
538
|
+
capturedAt: asString(record.capturedAt) || new Date().toISOString(),
|
|
539
|
+
source,
|
|
540
|
+
confidence,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/** Sanitize a plan-coherence result coming back over the wire. */
|
|
544
|
+
function sanitizePlanCoherence(value) {
|
|
545
|
+
const record = asRecord(value);
|
|
546
|
+
if (!record) {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
const verdicts = ['planned', 'implied', 'unplanned', 'unknown'];
|
|
550
|
+
const verdict = verdicts.includes(record.verdict)
|
|
551
|
+
? record.verdict
|
|
552
|
+
: 'unknown';
|
|
553
|
+
const score = typeof record.score === 'number' ? clampScore(record.score) : 0;
|
|
554
|
+
return {
|
|
555
|
+
verdict,
|
|
556
|
+
score,
|
|
557
|
+
matchedPlanItems: Array.isArray(record.matchedPlanItems)
|
|
558
|
+
? uniqueNonEmpty(record.matchedPlanItems.map((s) => asString(s) || ''))
|
|
559
|
+
: [],
|
|
560
|
+
reasons: Array.isArray(record.reasons)
|
|
561
|
+
? uniqueNonEmpty(record.reasons.map((s) => asString(s) || ''))
|
|
562
|
+
: [],
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
//# sourceMappingURL=agent-plan.js.map
|