auto-mode 0.3.0 → 0.4.1
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 +58 -95
- package/dist/{classify-locally-BRMllwcd.js → classify-locally-Dsyc0O6N.js} +846 -39
- package/dist/cli.js +156 -13
- package/dist/index.d.ts +62 -3
- package/dist/index.js +1 -1
- package/mods/auto-mode/.claude-plugin/plugin.json +14 -0
- package/mods/auto-mode/hooks/build-prompt-context.claude-check.ts +94 -0
- package/mods/auto-mode/hooks/build-prompt-context.ts +37 -0
- package/mods/auto-mode/hooks/hooks.json +1 -0
- package/mods/auto-mode/hooks/parse-decision.ts +51 -0
- package/mods/auto-mode/hooks/register.claude-check.ts +1082 -0
- package/mods/auto-mode/hooks/register.ts +205 -0
- package/mods/auto-mode/hooks/testing.d.ts +37 -0
- package/mods/auto-mode/hooks/types.ts +126 -0
- package/mods/auto-mode/tsconfig.json +12 -0
- package/package.json +7 -4
- package/policy/decision.md +33 -0
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { c as SETTINGS_PATHS, d as renderVerdict, f as parsePayload, g as loadConfig, i as loadClaudeRules, l as SETUP_NOTES, n as classifyWithModel, o as loadPolicy, s as EVENT_NOTES, t as classifyLocally, u as buildHookConfig, v as resolveConfigPath } from "./classify-locally-Dsyc0O6N.js";
|
|
3
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
7
|
import { text } from "node:stream/consumers";
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
//#region src/config/build-jev-only-config.ts
|
|
10
|
+
function buildJevOnlyConfig(config) {
|
|
11
|
+
if (config.provider.protocol !== "system-one") return null;
|
|
12
|
+
return {
|
|
13
|
+
...config,
|
|
14
|
+
provider: {
|
|
15
|
+
...config.provider,
|
|
16
|
+
timeoutMs: Math.min(config.provider.timeoutMs, 5e3)
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/diagnostics/write-action-diagnostic.ts
|
|
22
|
+
async function writeActionDiagnostic(payload, entry) {
|
|
23
|
+
const stateHome = process.env["XDG_STATE_HOME"];
|
|
24
|
+
const stateDir = stateHome === void 0 || stateHome === "" ? join(homedir(), ".local", "state") : stateHome;
|
|
25
|
+
const path = process.env["AUTO_MODE_DIAGNOSTICS_PATH"] ?? join(stateDir, "auto-mode", "actions.jsonl");
|
|
26
|
+
if (path === "") return;
|
|
27
|
+
const actionID = payload.raw["tool_use_id"] ?? payload.raw["prompt_id"] ?? payload.raw["turn_id"];
|
|
28
|
+
const record = {
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31
|
+
invocationID: entry.invocationID,
|
|
32
|
+
sessionHash: toHash(payload.sessionId),
|
|
33
|
+
actionHash: typeof actionID === "string" ? toHash(actionID) : null,
|
|
34
|
+
harness: payload.harness,
|
|
35
|
+
event: payload.event,
|
|
36
|
+
status: entry.status,
|
|
37
|
+
verdict: entry.verdict ?? null,
|
|
38
|
+
diagnostics: entry.diagnostics ?? null
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
await mkdir(dirname(path), {
|
|
42
|
+
recursive: true,
|
|
43
|
+
mode: 448
|
|
44
|
+
});
|
|
45
|
+
await appendFile(path, `${JSON.stringify(record)}\n`, { mode: 384 });
|
|
46
|
+
} catch {
|
|
47
|
+
process.stderr.write("auto-mode: diagnostics unavailable\n");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function toHash(value) {
|
|
51
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
5
54
|
//#region src/cli.ts
|
|
6
55
|
const USAGE = `auto-mode — a permission classifier that runs as a hook
|
|
7
56
|
|
|
@@ -16,11 +65,13 @@ Options:
|
|
|
16
65
|
--event <event> With init: pre-tool-use, the default, or permission-request
|
|
17
66
|
--explain With run: also write the reasoning to stderr
|
|
18
67
|
--local-only With run: skip the model tier
|
|
68
|
+
--jev-only With run: require Jev and cap its API timeout at 5 seconds
|
|
69
|
+
--evaluation-deadline <unix-ms> With --jev-only: share the helper and API deadline
|
|
19
70
|
`;
|
|
20
71
|
function readStdin() {
|
|
21
72
|
return text(process.stdin);
|
|
22
73
|
}
|
|
23
|
-
async function run(explain, localOnly) {
|
|
74
|
+
async function run(explain, localOnly, jevOnly, deadline) {
|
|
24
75
|
const raw = await readStdin();
|
|
25
76
|
let body;
|
|
26
77
|
try {
|
|
@@ -30,16 +81,99 @@ async function run(explain, localOnly) {
|
|
|
30
81
|
}
|
|
31
82
|
const payload = parsePayload(body);
|
|
32
83
|
if (payload === null) return printNote(explain, "not a tool gate this hook judges, so no verdict");
|
|
33
|
-
const
|
|
84
|
+
const invocationID = randomUUID();
|
|
85
|
+
await writeActionDiagnostic(payload, {
|
|
86
|
+
invocationID,
|
|
87
|
+
status: "started"
|
|
88
|
+
});
|
|
89
|
+
let loaded;
|
|
90
|
+
try {
|
|
91
|
+
loaded = await loadConfig();
|
|
92
|
+
} catch {
|
|
93
|
+
await writeActionDiagnostic(payload, {
|
|
94
|
+
invocationID,
|
|
95
|
+
status: "failure",
|
|
96
|
+
verdict: "defer"
|
|
97
|
+
});
|
|
98
|
+
return printNote(true, "configuration unreadable; deferring to the harness");
|
|
99
|
+
}
|
|
100
|
+
const config = jevOnly ? buildJevOnlyConfig(loaded) : loaded;
|
|
101
|
+
if (config === null) {
|
|
102
|
+
await writeActionDiagnostic(payload, {
|
|
103
|
+
invocationID,
|
|
104
|
+
status: "failure",
|
|
105
|
+
verdict: "defer"
|
|
106
|
+
});
|
|
107
|
+
return printNote(true, "Jev-only evaluation requires system-one; deferring to the harness");
|
|
108
|
+
}
|
|
109
|
+
let configured = null;
|
|
110
|
+
if (config.provider.protocol === "system-one") try {
|
|
111
|
+
configured = await loadClaudeRules(config.claudeSettingsPath);
|
|
112
|
+
} catch {
|
|
113
|
+
if (config.onFailure === "deny") writeVerdict(payload.event, {
|
|
114
|
+
kind: "deny",
|
|
115
|
+
rule: "Classifier Unavailable",
|
|
116
|
+
reason: "Claude settings unreadable"
|
|
117
|
+
});
|
|
118
|
+
await writeActionDiagnostic(payload, {
|
|
119
|
+
invocationID,
|
|
120
|
+
status: "failure",
|
|
121
|
+
verdict: config.onFailure === "deny" ? "deny" : "defer"
|
|
122
|
+
});
|
|
123
|
+
return printNote(true, "Claude settings unreadable; classifier unavailable");
|
|
124
|
+
}
|
|
125
|
+
const local = configured !== null && (configured.hard_deny.length > 0 || configured.soft_deny.length > 0) ? { kind: "escalate" } : classifyLocally(payload);
|
|
34
126
|
if (local.kind === "allow") {
|
|
35
127
|
writeVerdict(payload.event, { kind: "allow" });
|
|
128
|
+
await writeActionDiagnostic(payload, {
|
|
129
|
+
invocationID,
|
|
130
|
+
status: "allow",
|
|
131
|
+
verdict: "allow"
|
|
132
|
+
});
|
|
36
133
|
return printNote(explain, `allowed by ${local.exception} (${payload.harness}, local)`);
|
|
37
134
|
}
|
|
38
|
-
if (localOnly)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
135
|
+
if (localOnly) {
|
|
136
|
+
await writeActionDiagnostic(payload, {
|
|
137
|
+
invocationID,
|
|
138
|
+
status: "skipped",
|
|
139
|
+
verdict: "defer"
|
|
140
|
+
});
|
|
141
|
+
return printNote(explain, `${payload.toolName} needs the model tier, which --local-only skipped`);
|
|
142
|
+
}
|
|
143
|
+
const deadlineAt = deadline === void 0 ? void 0 : Number(deadline);
|
|
144
|
+
if (deadlineAt !== void 0 && (!jevOnly || !Number.isSafeInteger(deadlineAt) || deadlineAt <= 0)) {
|
|
145
|
+
await writeActionDiagnostic(payload, {
|
|
146
|
+
invocationID,
|
|
147
|
+
status: "failure",
|
|
148
|
+
verdict: "defer"
|
|
149
|
+
});
|
|
150
|
+
return printNote(true, "invalid Jev evaluation deadline; deferring to the harness");
|
|
151
|
+
}
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const stopEvaluation = () => {
|
|
154
|
+
controller.abort();
|
|
155
|
+
};
|
|
156
|
+
if (jevOnly) {
|
|
157
|
+
process.on("SIGTERM", stopEvaluation);
|
|
158
|
+
process.on("SIGINT", stopEvaluation);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const outcome = await classifyWithModel(payload, config, {
|
|
162
|
+
deadlineAt,
|
|
163
|
+
signal: controller.signal
|
|
164
|
+
});
|
|
165
|
+
if (outcome.verdict !== null) writeVerdict(payload.event, outcome.verdict);
|
|
166
|
+
await writeActionDiagnostic(payload, {
|
|
167
|
+
invocationID,
|
|
168
|
+
status: outcome.diagnostics?.status ?? outcome.verdict?.kind ?? "failure",
|
|
169
|
+
verdict: outcome.verdict?.kind ?? "defer",
|
|
170
|
+
...outcome.diagnostics === void 0 ? {} : { diagnostics: outcome.diagnostics }
|
|
171
|
+
});
|
|
172
|
+
return printNote(explain || outcome.unavailable === true, outcome.note);
|
|
173
|
+
} finally {
|
|
174
|
+
process.off("SIGTERM", stopEvaluation);
|
|
175
|
+
process.off("SIGINT", stopEvaluation);
|
|
176
|
+
}
|
|
43
177
|
}
|
|
44
178
|
function writeVerdict(event, verdict) {
|
|
45
179
|
const rendered = renderVerdict(event, verdict);
|
|
@@ -63,6 +197,8 @@ async function main(argv) {
|
|
|
63
197
|
event: { type: "string" },
|
|
64
198
|
explain: { type: "boolean" },
|
|
65
199
|
"local-only": { type: "boolean" },
|
|
200
|
+
"jev-only": { type: "boolean" },
|
|
201
|
+
"evaluation-deadline": { type: "string" },
|
|
66
202
|
help: {
|
|
67
203
|
type: "boolean",
|
|
68
204
|
short: "h"
|
|
@@ -74,7 +210,12 @@ async function main(argv) {
|
|
|
74
210
|
process.stdout.write(USAGE);
|
|
75
211
|
return 0;
|
|
76
212
|
}
|
|
77
|
-
if (command === "run")
|
|
213
|
+
if (command === "run") try {
|
|
214
|
+
return await run(args.values.explain === true, args.values["local-only"] === true, args.values["jev-only"] === true, args.values["evaluation-deadline"]);
|
|
215
|
+
} catch {
|
|
216
|
+
process.stderr.write("auto-mode: configuration unreadable; deferring to the harness\n");
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
78
219
|
if (command === "init") {
|
|
79
220
|
const [, harness] = args.positionals;
|
|
80
221
|
if (harness !== "claude" && harness !== "codex" && harness !== "muse") {
|
|
@@ -97,10 +238,12 @@ async function main(argv) {
|
|
|
97
238
|
return 0;
|
|
98
239
|
}
|
|
99
240
|
if (command === "print-prompt") {
|
|
241
|
+
const config = await loadConfig();
|
|
242
|
+
const framework = config.provider.protocol === "system-one" ? "decision.md" : "classifier.md";
|
|
100
243
|
const prompt = await loadPolicy({
|
|
101
|
-
classifierPath: args.values.classifier,
|
|
102
|
-
rulesPath: args.values.rules
|
|
103
|
-
});
|
|
244
|
+
classifierPath: args.values.classifier ?? config.classifierPath,
|
|
245
|
+
rulesPath: args.values.rules ?? config.rulesPath
|
|
246
|
+
}, framework);
|
|
104
247
|
process.stdout.write(`${prompt}\n`);
|
|
105
248
|
return 0;
|
|
106
249
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
//#region src/config/types.d.ts
|
|
2
|
+
interface EvaluationOptions {
|
|
3
|
+
readonly deadlineAt?: number | undefined;
|
|
4
|
+
readonly signal?: Readonly<AbortSignal> | undefined;
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
1
7
|
//#region src/config/config.d.ts
|
|
2
8
|
interface ProviderConfig {
|
|
9
|
+
readonly protocol?: 'messages' | 'system-one';
|
|
3
10
|
readonly baseURL: string;
|
|
4
11
|
readonly model: string;
|
|
5
12
|
readonly apiKeyEnv?: string | undefined;
|
|
@@ -14,16 +21,35 @@ interface Config {
|
|
|
14
21
|
readonly rulesPath?: string | undefined;
|
|
15
22
|
readonly transcriptEntries: number;
|
|
16
23
|
readonly onFailure: 'defer' | 'deny';
|
|
24
|
+
readonly claudeSettingsPath?: string | null | undefined;
|
|
25
|
+
readonly minConfidence?: number | undefined;
|
|
17
26
|
}
|
|
18
27
|
export declare const DEFAULT_CONFIG: Config;
|
|
19
28
|
export declare const PRESETS: Readonly<Record<string, ProviderConfig>>;
|
|
20
29
|
export declare function resolveConfigPath(): string;
|
|
21
30
|
export declare function loadConfig(path?: string): Promise<Config>;
|
|
22
|
-
export declare function resolveApiKey(provider: ProviderConfig): Promise<string | null>;
|
|
31
|
+
export declare function resolveApiKey(provider: ProviderConfig, options?: EvaluationOptions): Promise<string | null>;
|
|
23
32
|
//#endregion
|
|
24
33
|
//#region src/harness/types.d.ts
|
|
25
34
|
type Harness = 'claude' | 'codex' | 'muse';
|
|
26
35
|
type HookEvent = 'PreToolUse' | 'PermissionRequest';
|
|
36
|
+
interface UserTask {
|
|
37
|
+
readonly text: string;
|
|
38
|
+
readonly origin: 'composer' | 'bridge' | 'sdk';
|
|
39
|
+
}
|
|
40
|
+
interface DecisionContext {
|
|
41
|
+
readonly agentID: string | null;
|
|
42
|
+
readonly originalUserTask: UserTask | null;
|
|
43
|
+
readonly delegatedTask: {
|
|
44
|
+
readonly text: string;
|
|
45
|
+
readonly origin: 'agent.spawn';
|
|
46
|
+
} | null;
|
|
47
|
+
readonly lastDirectUserMessage: UserTask | null;
|
|
48
|
+
readonly omittedTaskContext: readonly {
|
|
49
|
+
readonly field: 'originalUserTask' | 'delegatedTask';
|
|
50
|
+
readonly reason: 'unavailable' | 'budget';
|
|
51
|
+
}[];
|
|
52
|
+
}
|
|
27
53
|
interface HookPayload {
|
|
28
54
|
readonly harness: Harness;
|
|
29
55
|
readonly event: HookEvent;
|
|
@@ -32,6 +58,7 @@ interface HookPayload {
|
|
|
32
58
|
readonly toolName: string;
|
|
33
59
|
readonly toolInput: Readonly<Record<string, unknown>>;
|
|
34
60
|
readonly transcriptPath?: string | undefined;
|
|
61
|
+
readonly decisionContext?: DecisionContext | undefined;
|
|
35
62
|
readonly raw: Readonly<Record<string, unknown>>;
|
|
36
63
|
}
|
|
37
64
|
type Verdict = {
|
|
@@ -57,12 +84,44 @@ export declare function renderVerdict(event: HookEvent, verdict: Verdict): strin
|
|
|
57
84
|
export declare function buildHookConfig(harness: Harness, command: string, event?: HookEvent): string;
|
|
58
85
|
export declare const SETTINGS_PATHS: Readonly<Record<Harness, string>>;
|
|
59
86
|
//#endregion
|
|
87
|
+
//#region src/model/types.d.ts
|
|
88
|
+
interface DecisionRule {
|
|
89
|
+
readonly name: string;
|
|
90
|
+
readonly tier: 'hard' | 'soft';
|
|
91
|
+
readonly source: 'shipped' | 'replacement' | 'configured';
|
|
92
|
+
readonly text: string;
|
|
93
|
+
}
|
|
94
|
+
interface DecisionAnswer {
|
|
95
|
+
readonly type: 'choice';
|
|
96
|
+
readonly choice: 'allow' | 'block' | 'ask';
|
|
97
|
+
readonly probabilities: Readonly<Record<'allow' | 'block' | 'ask', number>>;
|
|
98
|
+
readonly confidence: number;
|
|
99
|
+
}
|
|
100
|
+
interface DecisionDiagnostics {
|
|
101
|
+
readonly status: 'allow' | 'deny' | 'ask' | 'failure' | 'timeout' | 'cancelled';
|
|
102
|
+
readonly stage: 'credential' | 'evidence' | 'request' | 'response';
|
|
103
|
+
readonly keyResolved: boolean;
|
|
104
|
+
readonly keySource: 'environment' | 'command' | 'none';
|
|
105
|
+
readonly elapsedMs: number;
|
|
106
|
+
readonly minConfidence: number;
|
|
107
|
+
readonly contributors: readonly {
|
|
108
|
+
readonly rule: string;
|
|
109
|
+
readonly source: DecisionRule['source'];
|
|
110
|
+
readonly tier: DecisionRule['tier'];
|
|
111
|
+
readonly choice: DecisionAnswer['choice'];
|
|
112
|
+
readonly confidence: number;
|
|
113
|
+
readonly probability: number;
|
|
114
|
+
}[];
|
|
115
|
+
}
|
|
116
|
+
//#endregion
|
|
60
117
|
//#region src/model/classify-with-model.d.ts
|
|
61
118
|
interface ModelOutcome {
|
|
62
119
|
readonly verdict: Verdict | null;
|
|
63
120
|
readonly note: string;
|
|
121
|
+
readonly unavailable?: boolean;
|
|
122
|
+
readonly diagnostics?: DecisionDiagnostics;
|
|
64
123
|
}
|
|
65
|
-
export declare function classifyWithModel(payload: HookPayload, config: Config): Promise<ModelOutcome>;
|
|
124
|
+
export declare function classifyWithModel(payload: HookPayload, config: Config, options?: EvaluationOptions): Promise<ModelOutcome>;
|
|
66
125
|
//#endregion
|
|
67
126
|
//#region src/model/parse-verdict.d.ts
|
|
68
127
|
export declare function parseModelVerdict(text: string): Verdict;
|
|
@@ -72,7 +131,7 @@ interface PolicyPaths {
|
|
|
72
131
|
readonly classifierPath?: string | undefined;
|
|
73
132
|
readonly rulesPath?: string | undefined;
|
|
74
133
|
}
|
|
75
|
-
export declare function loadPolicy(paths?: PolicyPaths): Promise<string>;
|
|
134
|
+
export declare function loadPolicy(paths?: PolicyPaths, framework?: 'classifier.md' | 'decision.md'): Promise<string>;
|
|
76
135
|
//#endregion
|
|
77
136
|
//#region src/rules/classify-locally.d.ts
|
|
78
137
|
type LocalVerdict = {
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { _ as
|
|
1
|
+
import { _ as resolveApiKey, a as readTranscript, c as SETTINGS_PATHS, d as renderVerdict, f as parsePayload, g as loadConfig, h as PRESETS, m as DEFAULT_CONFIG, n as classifyWithModel, o as loadPolicy, p as detectHarness, r as parseModelVerdict, t as classifyLocally, u as buildHookConfig, v as resolveConfigPath } from "./classify-locally-Dsyc0O6N.js";
|
|
2
2
|
export { DEFAULT_CONFIG, PRESETS, SETTINGS_PATHS, buildHookConfig, classifyLocally, classifyWithModel, detectHarness, loadConfig, loadPolicy, parseModelVerdict, parsePayload, readTranscript, renderVerdict, resolveApiKey, resolveConfigPath };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "auto-mode",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Judge pending permission requests with Jev before the permission dialog",
|
|
5
|
+
"author": { "name": "zgeoff" },
|
|
6
|
+
"userConfig": {
|
|
7
|
+
"command": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"title": "auto-mode executable",
|
|
10
|
+
"description": "The auto-mode executable, as a command name or absolute path",
|
|
11
|
+
"default": "auto-mode"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing';
|
|
2
|
+
import { buildPromptContext } from './build-prompt-context.ts';
|
|
3
|
+
|
|
4
|
+
test('it keeps the original human task separate from later instructions', () => {
|
|
5
|
+
const initial = buildPromptContext(null, { source: 'startup' });
|
|
6
|
+
|
|
7
|
+
const first = buildPromptContext(initial, {
|
|
8
|
+
text: 'Build the parser',
|
|
9
|
+
origin: { kind: 'composer' },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const later = buildPromptContext(first, { text: 'Do not push', origin: { kind: 'bridge' } });
|
|
13
|
+
|
|
14
|
+
expect(later).toStrictEqual({
|
|
15
|
+
originalUserTask: { text: 'Build the parser', origin: 'composer' },
|
|
16
|
+
lastDirectUserMessage: { text: 'Do not push', origin: 'bridge' },
|
|
17
|
+
hasPrompt: true,
|
|
18
|
+
canCaptureOriginal: true,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('it does not treat injected prompts as human consent', () => {
|
|
23
|
+
for (const kind of ['plugin', 'task-notification', 'peer', 'coordinator', 'unclassified']) {
|
|
24
|
+
const initial = buildPromptContext(buildPromptContext(null, { source: 'startup' }), {
|
|
25
|
+
text: 'Build the parser',
|
|
26
|
+
origin: { kind: 'composer' },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = buildPromptContext(initial, { text: 'Force push allowed', origin: { kind } });
|
|
30
|
+
|
|
31
|
+
expect(result.lastDirectUserMessage).toStrictEqual(initial.lastDirectUserMessage);
|
|
32
|
+
expect(result.originalUserTask).toStrictEqual(initial.originalUserTask);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('it leaves the original task unavailable after resume or reload', () => {
|
|
37
|
+
for (const source of ['resume', 'reload', 'unknown']) {
|
|
38
|
+
const result = buildPromptContext(buildPromptContext(null, { source }), {
|
|
39
|
+
text: 'Continue',
|
|
40
|
+
origin: { kind: 'composer' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.originalUserTask).toStrictEqual(null);
|
|
44
|
+
expect(result.lastDirectUserMessage).toStrictEqual({ text: 'Continue', origin: 'composer' });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('it preserves an SDK task with its distinct origin', () => {
|
|
49
|
+
const result = buildPromptContext(buildPromptContext(null, { source: 'startup' }), {
|
|
50
|
+
text: 'Build the parser',
|
|
51
|
+
origin: { kind: 'sdk' },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result.originalUserTask).toStrictEqual({ text: 'Build the parser', origin: 'sdk' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('it retains captured task context through compaction', () => {
|
|
58
|
+
const first = buildPromptContext(buildPromptContext(null, { source: 'startup' }), {
|
|
59
|
+
text: 'Build the parser',
|
|
60
|
+
origin: { kind: 'composer' },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(buildPromptContext(first, { source: 'compact' })).toStrictEqual(first);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('it starts a new original task after the session is cleared', () => {
|
|
67
|
+
const first = buildPromptContext(buildPromptContext(null, { source: 'clear' }), {
|
|
68
|
+
text: 'Build the new parser',
|
|
69
|
+
origin: { kind: 'composer' },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(first.originalUserTask).toStrictEqual({
|
|
73
|
+
text: 'Build the new parser',
|
|
74
|
+
origin: 'composer',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('it captures the first direct task after an initial machine notification', () => {
|
|
79
|
+
const machine = buildPromptContext(buildPromptContext(null, { source: 'startup' }), {
|
|
80
|
+
text: 'Machine notification',
|
|
81
|
+
origin: { kind: 'task-notification' },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(machine.originalUserTask).toStrictEqual(null);
|
|
85
|
+
expect(machine.lastDirectUserMessage).toStrictEqual(null);
|
|
86
|
+
|
|
87
|
+
const human = buildPromptContext(machine, {
|
|
88
|
+
text: 'Build the parser',
|
|
89
|
+
origin: { kind: 'composer' },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(human.originalUserTask).toStrictEqual({ text: 'Build the parser', origin: 'composer' });
|
|
93
|
+
expect(human.lastDirectUserMessage).toStrictEqual(human.originalUserTask);
|
|
94
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PromptContext, UserTask } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export function buildPromptContext(
|
|
4
|
+
previous: PromptContext | null,
|
|
5
|
+
input:
|
|
6
|
+
| { readonly source: string }
|
|
7
|
+
| { readonly text: string; readonly origin: { readonly kind: string } },
|
|
8
|
+
): PromptContext {
|
|
9
|
+
if ('source' in input) {
|
|
10
|
+
if (input.source === 'compact' && previous !== null) {
|
|
11
|
+
return previous;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
originalUserTask: null,
|
|
16
|
+
lastDirectUserMessage: null,
|
|
17
|
+
hasPrompt: false,
|
|
18
|
+
canCaptureOriginal: input.source === 'startup' || input.source === 'clear',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const origin = input.origin.kind;
|
|
23
|
+
|
|
24
|
+
const lastDirectUserMessage: UserTask | null =
|
|
25
|
+
origin === 'composer' || origin === 'bridge' || origin === 'sdk'
|
|
26
|
+
? { text: input.text, origin }
|
|
27
|
+
: (previous?.lastDirectUserMessage ?? null);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
originalUserTask:
|
|
31
|
+
previous?.originalUserTask ??
|
|
32
|
+
(previous?.canCaptureOriginal === true ? lastDirectUserMessage : null),
|
|
33
|
+
lastDirectUserMessage,
|
|
34
|
+
hasPrompt: true,
|
|
35
|
+
canCaptureOriginal: previous?.canCaptureOriginal ?? false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.ts"] }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
interface Decision {
|
|
2
|
+
readonly decision: 'allow' | 'deny';
|
|
3
|
+
readonly reason?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function parseDecision(stdout: string): Decision | null {
|
|
7
|
+
let body: unknown;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
body = JSON.parse(stdout);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (typeof body !== 'object' || body === null || !('hookSpecificOutput' in body)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const output = body.hookSpecificOutput;
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
typeof output !== 'object' ||
|
|
23
|
+
output === null ||
|
|
24
|
+
!('hookEventName' in output) ||
|
|
25
|
+
output.hookEventName !== 'PermissionRequest' ||
|
|
26
|
+
!('decision' in output)
|
|
27
|
+
) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const value = output.decision;
|
|
32
|
+
|
|
33
|
+
if (typeof value !== 'object' || value === null || !('behavior' in value)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (value.behavior === 'allow') {
|
|
38
|
+
return { decision: 'allow' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
value.behavior === 'deny' &&
|
|
43
|
+
'message' in value &&
|
|
44
|
+
typeof value.message === 'string' &&
|
|
45
|
+
value.message.trim() !== ''
|
|
46
|
+
) {
|
|
47
|
+
return { decision: 'deny', reason: value.message };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
}
|