oh-my-harness 0.23.0 → 0.24.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 +4 -0
- package/dist/catalog/blocks/commit-test-gate.js +6 -2
- package/dist/catalog/blocks/commit-typecheck-gate.js +6 -2
- package/dist/catalog/blocks/index.js +4 -0
- package/dist/catalog/blocks/stop-test-gate.d.ts +2 -0
- package/dist/catalog/blocks/stop-test-gate.js +81 -0
- package/dist/catalog/blocks/stop-uncommitted-warn.d.ts +2 -0
- package/dist/catalog/blocks/stop-uncommitted-warn.js +30 -0
- package/dist/catalog/types.d.ts +8 -2
- package/dist/catalog/types.js +2 -0
- package/dist/cli/commands/modify.d.ts +8 -0
- package/dist/cli/commands/modify.js +77 -0
- package/dist/cli/commands/test.js +1 -0
- package/dist/cli/harness-tester.js +10 -0
- package/dist/cli/index.js +12 -0
- package/dist/core/harness-converter-v2.js +3 -1
- package/dist/core/harness-schema.d.ts +5 -0
- package/dist/core/merged-config.d.ts +2 -0
- package/dist/core/presets.js +2 -0
- package/dist/generators/codex-config.js +1 -1
- package/dist/generators/hooks.d.ts +2 -2
- package/dist/generators/hooks.js +11 -3
- package/dist/generators/pi-extension.js +1 -1
- package/dist/nl/typesafe-chooser.d.ts +1 -1
- package/dist/nl/typesafe-chooser.js +1 -0
- package/dist/nl/typesafe-modify.d.ts +51 -0
- package/dist/nl/typesafe-modify.js +118 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,6 +99,7 @@ omh init "Next.js + FastAPI, TDD enforced, no auto PRs"
|
|
|
99
99
|
- Get a key from the [TypeSafe console](https://typesafe.ai) (early access at the time of writing). One init costs a fraction of a cent (input $0.042 per million tokens, output free) and takes about half a second.
|
|
100
100
|
- `--preset` always wins and never calls Jev. No key and no LLM provider? `omh init "description"` falls back to `safe` and says so.
|
|
101
101
|
- `omh doctor` and `omh config --show` tell you whether the chooser is active. Remove the key to go back to the LLM providers or presets.
|
|
102
|
+
- `omh modify "..."` later edits the same way: Jev answers enable / disable / ask / keep per block, you confirm the change set, `omh sync` runs. Mark an entry `locked: true` in harness.yaml and modify will refuse to remove or weaken it.
|
|
102
103
|
- Interactive `omh init` offers both: "Describe your project (Jev picks the blocks)" and "Use a preset (no AI)".
|
|
103
104
|
- Descriptions in English work best; other languages are handled but with lower confidence, so include the specifics.
|
|
104
105
|
|
|
@@ -119,6 +120,8 @@ All enforcement is powered by **catalog blocks** — reusable, parameterized hoo
|
|
|
119
120
|
| 🪝 `harness-guard` | security | Blocks shell writes to the harness's own hooks and config (always on) |
|
|
120
121
|
| 🚫 `no-verify-guard` | git | Blocks `--no-verify` / `-n` / hooksPath overrides on commit and push (always on) |
|
|
121
122
|
| 💥 `force-push-guard` | git | Blocks force pushes to protected branches (always on) |
|
|
123
|
+
| 🛑 `stop-test-gate` | quality | When the agent tries to end its turn with failing tests, sends it back (retry-capped) |
|
|
124
|
+
| 📝 `stop-uncommitted-warn` | git | Lists uncommitted changes as a system message when the turn ends |
|
|
122
125
|
| 📁 `path-guard` | file-protection | Blocks writes to protected paths |
|
|
123
126
|
| 🔐 `lockfile-guard` | file-protection | Prevents manual lockfile edits |
|
|
124
127
|
| 🤫 `secret-file-guard` | security | Blocks edits to .env, credentials |
|
|
@@ -192,6 +195,7 @@ Hand omh a `WORKPLAN.md` and it runs one work order per fresh agent session, in
|
|
|
192
195
|
omh init "your project description" # Jev with TYPESAFE_API_KEY, else an LLM provider, else the "safe" preset
|
|
193
196
|
omh init --preset strict # No provider: minimal | safe | strict
|
|
194
197
|
omh explain # Why were the last tool calls blocked, and what to do about it
|
|
198
|
+
omh modify "no auto PRs, TDD guard asks" # Edit harness.yaml from a sentence (Jev decides per block; locked: true entries stay)
|
|
195
199
|
omh init # Interactive TUI (import existing harness.yaml)
|
|
196
200
|
|
|
197
201
|
# 📋 Catalog
|
|
@@ -27,7 +27,11 @@ INPUT=$(cat)
|
|
|
27
27
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
28
28
|
if _omh_cmd_matches "$COMMAND" git commit; then
|
|
29
29
|
FP=$(_omh_tree_fingerprint)
|
|
30
|
-
|
|
30
|
+
GATE_CMD=$(cat <<'OMH_GATE_CMD'
|
|
31
|
+
{{{testCommand}}}
|
|
32
|
+
OMH_GATE_CMD
|
|
33
|
+
)
|
|
34
|
+
if _omh_gate_cached commit-test-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
|
|
31
35
|
echo "oh-my-harness: tests already passed on this exact tree; skipping" >&2
|
|
32
36
|
_log_event "allow" "cached: tree unchanged since last passing run"
|
|
33
37
|
exit 0
|
|
@@ -39,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
|
|
|
39
43
|
_emit_decision "block" "$REASON"
|
|
40
44
|
exit 0
|
|
41
45
|
fi
|
|
42
|
-
_omh_gate_record commit-test-gate "$FP"
|
|
46
|
+
_omh_gate_record commit-test-gate "$FP" "$GATE_CMD"
|
|
43
47
|
fi
|
|
44
48
|
exit 0`,
|
|
45
49
|
};
|
|
@@ -27,7 +27,11 @@ INPUT=$(cat)
|
|
|
27
27
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
28
28
|
if _omh_cmd_matches "$COMMAND" git commit; then
|
|
29
29
|
FP=$(_omh_tree_fingerprint)
|
|
30
|
-
|
|
30
|
+
GATE_CMD=$(cat <<'OMH_GATE_CMD'
|
|
31
|
+
{{{typecheckCommand}}}
|
|
32
|
+
OMH_GATE_CMD
|
|
33
|
+
)
|
|
34
|
+
if _omh_gate_cached commit-typecheck-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
|
|
31
35
|
echo "oh-my-harness: typecheck already passed on this exact tree; skipping" >&2
|
|
32
36
|
_log_event "allow" "cached: tree unchanged since last passing run"
|
|
33
37
|
exit 0
|
|
@@ -39,7 +43,7 @@ if _omh_cmd_matches "$COMMAND" git commit; then
|
|
|
39
43
|
_emit_decision "block" "$REASON"
|
|
40
44
|
exit 0
|
|
41
45
|
fi
|
|
42
|
-
_omh_gate_record commit-typecheck-gate "$FP"
|
|
46
|
+
_omh_gate_record commit-typecheck-gate "$FP" "$GATE_CMD"
|
|
43
47
|
fi
|
|
44
48
|
exit 0`,
|
|
45
49
|
};
|
|
@@ -19,6 +19,8 @@ import { loopGuard } from "./loop-guard.js";
|
|
|
19
19
|
import { harnessGuard } from "./harness-guard.js";
|
|
20
20
|
import { noVerifyGuard } from "./no-verify-guard.js";
|
|
21
21
|
import { forcePushGuard } from "./force-push-guard.js";
|
|
22
|
+
import { stopTestGate } from "./stop-test-gate.js";
|
|
23
|
+
import { stopUncommittedWarn } from "./stop-uncommitted-warn.js";
|
|
22
24
|
export const builtinBlocks = [
|
|
23
25
|
branchGuard,
|
|
24
26
|
commitTestGate,
|
|
@@ -41,4 +43,6 @@ export const builtinBlocks = [
|
|
|
41
43
|
harnessGuard,
|
|
42
44
|
noVerifyGuard,
|
|
43
45
|
forcePushGuard,
|
|
46
|
+
stopTestGate,
|
|
47
|
+
stopUncommittedWarn,
|
|
44
48
|
];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Stop-event gate (#117): when the agent is about to end its turn with a red
|
|
2
|
+
// test suite, hand the failure back so it keeps working instead of finishing
|
|
3
|
+
// on a broken tree. Two safety valves: Claude's stop_hook_active flag (a Stop
|
|
4
|
+
// hook that blocked once must not block the follow-up stop) and a per-session
|
|
5
|
+
// retry cap kept in .omh/state, which also covers runtimes without that flag.
|
|
6
|
+
// The tree-fingerprint cache shared with commit-test-gate means a suite that
|
|
7
|
+
// just passed on this exact tree is not run again.
|
|
8
|
+
export const stopTestGate = {
|
|
9
|
+
id: "stop-test-gate",
|
|
10
|
+
name: "Stop Test Gate",
|
|
11
|
+
description: "Runs tests when the agent tries to end its turn and sends it back to work while they fail",
|
|
12
|
+
category: "quality",
|
|
13
|
+
event: "Stop",
|
|
14
|
+
canBlock: true,
|
|
15
|
+
params: [
|
|
16
|
+
{ name: "testCommand", type: "string", description: "Test command to run before the turn may end", required: true },
|
|
17
|
+
{
|
|
18
|
+
name: "maxRetries",
|
|
19
|
+
type: "number",
|
|
20
|
+
description: "How many times per session the turn may be sent back before the gate gives up and lets it end",
|
|
21
|
+
default: 2,
|
|
22
|
+
required: false,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "cacheTtlSeconds",
|
|
26
|
+
type: "number",
|
|
27
|
+
description: "Skip the run when the working tree is unchanged since the last pass and that pass is younger than this many seconds (0 disables)",
|
|
28
|
+
default: 600,
|
|
29
|
+
required: false,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
tags: ["stop", "test", "quality", "gate"],
|
|
33
|
+
explain: {
|
|
34
|
+
allowOnce: "make the test command pass; after maxRetries send-backs in one session the gate lets the turn end anyway",
|
|
35
|
+
change: "harness.yaml > hooks > stop-test-gate: change testCommand, maxRetries or cacheTtlSeconds, or remove it, then omh sync",
|
|
36
|
+
},
|
|
37
|
+
template: `#!/bin/bash
|
|
38
|
+
set -euo pipefail
|
|
39
|
+
INPUT=$(cat)
|
|
40
|
+
# A Stop hook that already sent the agent back must not block the follow-up stop.
|
|
41
|
+
[[ "$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null)" == "true" ]] && exit 0
|
|
42
|
+
|
|
43
|
+
SESSION=$(echo "$INPUT" | jq -r '.session_id // "default"' 2>/dev/null | tr -c 'A-Za-z0-9_-' '_')
|
|
44
|
+
STATE_DIR="\${_OMH_STATE_DIR:-.omh/state}"
|
|
45
|
+
COUNT_FILE="$STATE_DIR/stop-test-gate-\${SESSION}.count"
|
|
46
|
+
COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
|
|
47
|
+
[[ "$COUNT" =~ ^[0-9]+$ ]] || COUNT=0
|
|
48
|
+
if [[ "$COUNT" -ge {{maxRetries}} ]]; then
|
|
49
|
+
_log_event "allow" "stop-test-gate: retry cap ({{maxRetries}}) reached for this session; letting the turn end"
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
FP=$(_omh_tree_fingerprint)
|
|
54
|
+
GATE_CMD=$(cat <<'OMH_GATE_CMD'
|
|
55
|
+
{{{testCommand}}}
|
|
56
|
+
OMH_GATE_CMD
|
|
57
|
+
)
|
|
58
|
+
if _omh_gate_cached stop-test-gate {{cacheTtlSeconds}} "$FP" "$GATE_CMD"; then
|
|
59
|
+
rm -f "$COUNT_FILE"
|
|
60
|
+
_log_event "allow" "cached: tree unchanged since last passing run"
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
STATUS=0
|
|
65
|
+
set -o pipefail
|
|
66
|
+
ESC=$'\\x1b'
|
|
67
|
+
OUTPUT=$(NO_COLOR=1 FORCE_COLOR=0 {{{testCommand}}} 2>&1 | sed "s/\${ESC}\\[[0-9;]*[A-Za-z]//g") || STATUS=$?
|
|
68
|
+
if [[ "$STATUS" -ne 0 ]]; then
|
|
69
|
+
mkdir -p "$STATE_DIR" 2>/dev/null || true
|
|
70
|
+
echo $((COUNT + 1)) > "$COUNT_FILE"
|
|
71
|
+
TAIL=$(printf '%s\\n' "$OUTPUT" | tail -n 15)
|
|
72
|
+
REASON="oh-my-harness: the test suite is failing; fix it before ending the turn (attempt $((COUNT + 1))/{{maxRetries}}):
|
|
73
|
+
$TAIL"
|
|
74
|
+
_log_event "block" "$REASON"
|
|
75
|
+
jq -cn --arg r "$REASON" '{decision:"block", reason:$r, hookSpecificOutput:{hookEventName:"Stop", decision:"block", reason:$r}}'
|
|
76
|
+
exit 0
|
|
77
|
+
fi
|
|
78
|
+
rm -f "$COUNT_FILE"
|
|
79
|
+
_omh_gate_record stop-test-gate "$FP" "$GATE_CMD"
|
|
80
|
+
exit 0`,
|
|
81
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Stop-event notice (#117): when the turn ends with uncommitted changes, say
|
|
2
|
+
// so as a system message. Never blocks; the point is that the human sees what
|
|
3
|
+
// was left behind instead of discovering it later.
|
|
4
|
+
export const stopUncommittedWarn = {
|
|
5
|
+
id: "stop-uncommitted-warn",
|
|
6
|
+
name: "Stop Uncommitted Warning",
|
|
7
|
+
description: "When the agent ends its turn with uncommitted changes, lists them as a system message (never blocks)",
|
|
8
|
+
category: "git",
|
|
9
|
+
event: "Stop",
|
|
10
|
+
canBlock: false,
|
|
11
|
+
params: [],
|
|
12
|
+
tags: ["stop", "git", "notice"],
|
|
13
|
+
template: `#!/bin/bash
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
INPUT=$(cat)
|
|
16
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
17
|
+
if ! CHANGES=$(git status --porcelain 2>&1); then
|
|
18
|
+
MSG="oh-my-harness: could not read git status, so uncommitted changes may be unreported: $(printf '%s' "$CHANGES" | head -n 3)"
|
|
19
|
+
_log_event "allow" "$MSG"
|
|
20
|
+
jq -cn --arg m "$MSG" '{systemMessage:$m}'
|
|
21
|
+
exit 0
|
|
22
|
+
fi
|
|
23
|
+
[[ -z "$CHANGES" ]] && exit 0
|
|
24
|
+
N=$(printf '%s\\n' "$CHANGES" | wc -l | tr -d ' ')
|
|
25
|
+
MSG="oh-my-harness: $N uncommitted change(s) left behind:
|
|
26
|
+
$(printf '%s\\n' "$CHANGES" | head -n 20)"
|
|
27
|
+
_log_event "allow" "$MSG"
|
|
28
|
+
jq -cn --arg m "$MSG" '{systemMessage:$m}'
|
|
29
|
+
exit 0`,
|
|
30
|
+
};
|
package/dist/catalog/types.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface HookEntry {
|
|
|
31
31
|
block: string;
|
|
32
32
|
params: Record<string, unknown>;
|
|
33
33
|
mode?: "block" | "ask";
|
|
34
|
+
/** `omh modify` may never remove or weaken a locked entry (#118). */
|
|
35
|
+
locked?: boolean;
|
|
34
36
|
}
|
|
35
37
|
export declare const ParamDefinitionSchema: z.ZodObject<{
|
|
36
38
|
name: z.ZodString;
|
|
@@ -82,7 +84,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
|
|
|
82
84
|
tags: z.ZodArray<z.ZodString, "many">;
|
|
83
85
|
}, "strip", z.ZodTypeAny, {
|
|
84
86
|
id: string;
|
|
85
|
-
event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "
|
|
87
|
+
event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "Stop" | "SubagentStop" | "UserPromptSubmit" | "PreCompact" | "PostCompact" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
|
|
86
88
|
name: string;
|
|
87
89
|
params: {
|
|
88
90
|
name: string;
|
|
@@ -99,7 +101,7 @@ export declare const BuildingBlockSchema: z.ZodObject<{
|
|
|
99
101
|
matcher?: string | undefined;
|
|
100
102
|
}, {
|
|
101
103
|
id: string;
|
|
102
|
-
event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "
|
|
104
|
+
event: "PreToolUse" | "PostToolUse" | "SessionStart" | "Notification" | "ConfigChange" | "WorktreeCreate" | "Stop" | "SubagentStop" | "UserPromptSubmit" | "PreCompact" | "PostCompact" | "PreBash" | "PostBash" | "PreEdit" | "PostEdit" | "PreRead" | "PostRead" | "PreWrite" | "PostWrite" | "SessionEnd" | "PreToolResult" | "PostToolResult" | "WorktreeRemove";
|
|
103
105
|
name: string;
|
|
104
106
|
params: {
|
|
105
107
|
name: string;
|
|
@@ -119,12 +121,16 @@ export declare const HookEntrySchema: z.ZodObject<{
|
|
|
119
121
|
block: z.ZodString;
|
|
120
122
|
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
121
123
|
mode: z.ZodDefault<z.ZodEnum<["block", "ask"]>>;
|
|
124
|
+
/** `omh modify` may never remove or weaken a locked entry (#118). */
|
|
125
|
+
locked: z.ZodOptional<z.ZodBoolean>;
|
|
122
126
|
}, "strip", z.ZodTypeAny, {
|
|
123
127
|
block: string;
|
|
124
128
|
params: Record<string, unknown>;
|
|
125
129
|
mode: "block" | "ask";
|
|
130
|
+
locked?: boolean | undefined;
|
|
126
131
|
}, {
|
|
127
132
|
block: string;
|
|
128
133
|
params?: Record<string, unknown> | undefined;
|
|
129
134
|
mode?: "block" | "ask" | undefined;
|
|
135
|
+
locked?: boolean | undefined;
|
|
130
136
|
}>;
|
package/dist/catalog/types.js
CHANGED
|
@@ -22,4 +22,6 @@ export const HookEntrySchema = z.object({
|
|
|
22
22
|
block: z.string(),
|
|
23
23
|
params: z.record(z.unknown()).default({}),
|
|
24
24
|
mode: z.enum(["block", "ask"]).default("block"),
|
|
25
|
+
/** `omh modify` may never remove or weaken a locked entry (#118). */
|
|
26
|
+
locked: z.boolean().optional(),
|
|
25
27
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import { HarnessConfigSchema } from "../../core/harness-schema.js";
|
|
6
|
+
import { detectProject } from "../../detector/project-detector.js";
|
|
7
|
+
import { builtinBlocks } from "../../catalog/blocks/index.js";
|
|
8
|
+
import { resolveTypesafeApiKey, TYPESAFE_MODEL } from "../../nl/typesafe-chooser.js";
|
|
9
|
+
import { modifyWithJev, applyModifications } from "../../nl/typesafe-modify.js";
|
|
10
|
+
export async function modifyCommand(requestParts, options = {}) {
|
|
11
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
12
|
+
const request = requestParts.join(" ").trim();
|
|
13
|
+
if (!request) {
|
|
14
|
+
console.log('Usage: omh modify "what to change" (e.g. "no auto PRs, make the TDD guard ask instead of block")');
|
|
15
|
+
return { exitCode: 1 };
|
|
16
|
+
}
|
|
17
|
+
const apiKey = resolveTypesafeApiKey(projectDir);
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
console.log("omh modify needs TYPESAFE_API_KEY (environment or the project's .env): Jev decides which blocks the request refers to.");
|
|
20
|
+
console.log("Without it, edit harness.yaml directly and run `omh sync`.");
|
|
21
|
+
return { exitCode: 1 };
|
|
22
|
+
}
|
|
23
|
+
const harnessPath = path.join(projectDir, "harness.yaml");
|
|
24
|
+
let harness;
|
|
25
|
+
try {
|
|
26
|
+
const parsed = HarnessConfigSchema.safeParse(yaml.load(await fs.readFile(harnessPath, "utf-8")));
|
|
27
|
+
if (!parsed.success) {
|
|
28
|
+
console.log(`harness.yaml does not validate: ${parsed.error.message}`);
|
|
29
|
+
return { exitCode: 1 };
|
|
30
|
+
}
|
|
31
|
+
harness = parsed.data;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
console.log("No harness.yaml here. Run `omh init` first.");
|
|
35
|
+
return { exitCode: 1 };
|
|
36
|
+
}
|
|
37
|
+
let facts;
|
|
38
|
+
try {
|
|
39
|
+
facts = await detectProject(projectDir);
|
|
40
|
+
}
|
|
41
|
+
catch { /* non-fatal */ }
|
|
42
|
+
let result;
|
|
43
|
+
try {
|
|
44
|
+
result = await modifyWithJev({ request, harness, blocks: builtinBlocks }, { apiKey });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.log(`TypeSafe chooser unavailable (${err.message}). Edit harness.yaml directly and run \`omh sync\`.`);
|
|
48
|
+
return { exitCode: 1 };
|
|
49
|
+
}
|
|
50
|
+
const applied = applyModifications(harness, result.decisions, facts, builtinBlocks);
|
|
51
|
+
console.log(chalk.dim(`Jev (${TYPESAFE_MODEL}) read the request, ${result.usage?.input_tokens ?? "?"} input tokens`));
|
|
52
|
+
for (const r of applied.refused)
|
|
53
|
+
console.log(` ${chalk.yellow("refused")} ${r.reason}`);
|
|
54
|
+
if (applied.changes.length === 0) {
|
|
55
|
+
console.log("No changes: Jev did not match the request to any block with enough confidence. Try naming the block or the behaviour.");
|
|
56
|
+
return { exitCode: 0 };
|
|
57
|
+
}
|
|
58
|
+
for (const c of applied.changes) {
|
|
59
|
+
const verb = c.action === "enable" ? chalk.green("enable ") : c.action === "disable" ? chalk.red("disable") : chalk.yellow("ask ");
|
|
60
|
+
console.log(` ${verb} ${c.block}`);
|
|
61
|
+
}
|
|
62
|
+
if (options.dryRun) {
|
|
63
|
+
console.log(chalk.dim("dry run: harness.yaml not written"));
|
|
64
|
+
return { exitCode: 0 };
|
|
65
|
+
}
|
|
66
|
+
if (!options.yes) {
|
|
67
|
+
const { confirm } = await import("@inquirer/prompts");
|
|
68
|
+
if (!(await confirm({ message: "Apply these changes to harness.yaml and re-sync?", default: true }))) {
|
|
69
|
+
console.log("Aborted.");
|
|
70
|
+
return { exitCode: 0 };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await fs.writeFile(harnessPath, yaml.dump(applied.harness, { lineWidth: 120 }), "utf-8");
|
|
74
|
+
const { syncCommand } = await import("./sync.js");
|
|
75
|
+
const sync = await syncCommand({ projectDir });
|
|
76
|
+
return { exitCode: sync.exitCode };
|
|
77
|
+
}
|
|
@@ -14,6 +14,7 @@ export function formatCategoryName(category) {
|
|
|
14
14
|
"command-guard": "Command guards",
|
|
15
15
|
"harness-guard": "Harness self-protection",
|
|
16
16
|
"no-verify-guard": "Git hook bypass guard",
|
|
17
|
+
"stop-test-gate": "Stop test gate",
|
|
17
18
|
"force-push-guard": "Force push guard",
|
|
18
19
|
"branch-guard": "Branch guard",
|
|
19
20
|
"lockfile-guard": "Lockfile guard",
|
|
@@ -195,6 +195,16 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
|
|
|
195
195
|
});
|
|
196
196
|
break;
|
|
197
197
|
}
|
|
198
|
+
case "stop-test-gate": {
|
|
199
|
+
cases.push({
|
|
200
|
+
name: "Stop with stop_hook_active → ALLOWED (loop guard)",
|
|
201
|
+
category: "stop-test-gate",
|
|
202
|
+
hookScript,
|
|
203
|
+
input: { tool_name: "", tool_input: {}, stop_hook_active: true, session_id: "omh-test" },
|
|
204
|
+
expectation: "allow",
|
|
205
|
+
});
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
198
208
|
case "no-verify-guard": {
|
|
199
209
|
cases.push({
|
|
200
210
|
name: '"git commit --no-verify" → BLOCKED',
|
package/dist/cli/index.js
CHANGED
|
@@ -35,6 +35,18 @@ export function createCli() {
|
|
|
35
35
|
process.exitCode = 1;
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
|
+
program
|
|
39
|
+
.command("modify [request...]")
|
|
40
|
+
.description('Edit harness.yaml from a sentence, e.g. omh modify "no auto PRs, make the TDD guard ask" (needs TYPESAFE_API_KEY)')
|
|
41
|
+
.option("-d, --project-dir <dir>", "Project directory")
|
|
42
|
+
.option("-y, --yes", "Apply without confirmation")
|
|
43
|
+
.option("--dry-run", "Show the change set without writing")
|
|
44
|
+
.action(async (request, options) => {
|
|
45
|
+
const { modifyCommand } = await import("./commands/modify.js");
|
|
46
|
+
const result = await modifyCommand(request, options);
|
|
47
|
+
if (result.exitCode !== 0)
|
|
48
|
+
process.exitCode = result.exitCode;
|
|
49
|
+
});
|
|
38
50
|
program
|
|
39
51
|
.command("explain")
|
|
40
52
|
.description("Show the last blocked tool calls in plain language, with how to allow once and how to change the rule")
|
|
@@ -29,7 +29,7 @@ function harnessToMergedConfig(harness) {
|
|
|
29
29
|
presets: ["harness"],
|
|
30
30
|
variables,
|
|
31
31
|
claudeMdSections,
|
|
32
|
-
hooks: { preToolUse: [], postToolUse: [], sessionStart: [], notification: [], configChange: [], worktreeCreate: [] },
|
|
32
|
+
hooks: { preToolUse: [], postToolUse: [], sessionStart: [], notification: [], configChange: [], worktreeCreate: [], stop: [] },
|
|
33
33
|
settings: { permissions: { allow: harness.permissions.allow, deny: harness.permissions.deny } },
|
|
34
34
|
...(harness.loop?.enabled ? { loop: harness.loop } : {}),
|
|
35
35
|
};
|
|
@@ -111,6 +111,7 @@ const eventToField = {
|
|
|
111
111
|
Notification: "notification",
|
|
112
112
|
ConfigChange: "configChange",
|
|
113
113
|
WorktreeCreate: "worktreeCreate",
|
|
114
|
+
Stop: "stop",
|
|
114
115
|
};
|
|
115
116
|
export async function harnessToMergedConfigV2(harness, registry, projectDir) {
|
|
116
117
|
// Start with base conversion (rules, variables, permissions — no inline enforcement scripts)
|
|
@@ -156,6 +157,7 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
|
|
|
156
157
|
notification: [...(base.hooks.notification ?? []), ...(additionalHooks.notification ?? [])],
|
|
157
158
|
configChange: [...(base.hooks.configChange ?? []), ...(additionalHooks.configChange ?? [])],
|
|
158
159
|
worktreeCreate: [...(base.hooks.worktreeCreate ?? []), ...(additionalHooks.worktreeCreate ?? [])],
|
|
160
|
+
stop: [...(base.hooks.stop ?? []), ...(additionalHooks.stop ?? [])],
|
|
159
161
|
};
|
|
160
162
|
return {
|
|
161
163
|
...base,
|
|
@@ -105,14 +105,17 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
105
105
|
block: z.ZodString;
|
|
106
106
|
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
107
107
|
mode: z.ZodDefault<z.ZodEnum<["block", "ask"]>>;
|
|
108
|
+
locked: z.ZodOptional<z.ZodBoolean>;
|
|
108
109
|
}, "strip", z.ZodTypeAny, {
|
|
109
110
|
block: string;
|
|
110
111
|
params: Record<string, unknown>;
|
|
111
112
|
mode: "block" | "ask";
|
|
113
|
+
locked?: boolean | undefined;
|
|
112
114
|
}, {
|
|
113
115
|
block: string;
|
|
114
116
|
params?: Record<string, unknown> | undefined;
|
|
115
117
|
mode?: "block" | "ask" | undefined;
|
|
118
|
+
locked?: boolean | undefined;
|
|
116
119
|
}>, "many">>;
|
|
117
120
|
loop: z.ZodDefault<z.ZodEffects<z.ZodObject<{
|
|
118
121
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -205,6 +208,7 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
205
208
|
block: string;
|
|
206
209
|
params: Record<string, unknown>;
|
|
207
210
|
mode: "block" | "ask";
|
|
211
|
+
locked?: boolean | undefined;
|
|
208
212
|
}[];
|
|
209
213
|
permissions: {
|
|
210
214
|
allow: string[];
|
|
@@ -264,6 +268,7 @@ export declare const HarnessConfigSchema: z.ZodObject<{
|
|
|
264
268
|
block: string;
|
|
265
269
|
params?: Record<string, unknown> | undefined;
|
|
266
270
|
mode?: "block" | "ask" | undefined;
|
|
271
|
+
locked?: boolean | undefined;
|
|
267
272
|
}[] | undefined;
|
|
268
273
|
permissions?: {
|
|
269
274
|
allow?: string[] | undefined;
|
|
@@ -21,6 +21,8 @@ export interface HooksConfig {
|
|
|
21
21
|
notification?: HookDefinition[];
|
|
22
22
|
configChange?: HookDefinition[];
|
|
23
23
|
worktreeCreate?: HookDefinition[];
|
|
24
|
+
/** Stop-event hooks (#117): fire when the agent is about to end its turn. No matcher. */
|
|
25
|
+
stop?: HookDefinition[];
|
|
24
26
|
}
|
|
25
27
|
export interface SettingsConfig {
|
|
26
28
|
permissions?: {
|
package/dist/core/presets.js
CHANGED
|
@@ -39,6 +39,8 @@ export function defaultParamsFor(blockId, facts) {
|
|
|
39
39
|
return facts?.lintCommands?.[0] ? { filePattern: l.glob, command: facts.lintCommands[0] } : null;
|
|
40
40
|
case "test-on-save":
|
|
41
41
|
return facts?.testCommands?.[0] ? { testCommand: facts.testCommands[0], filePattern: l.src } : null;
|
|
42
|
+
case "stop-test-gate":
|
|
43
|
+
return facts?.testCommands?.[0] ? { testCommand: facts.testCommands[0] } : null;
|
|
42
44
|
case "format-on-save":
|
|
43
45
|
return null; // no formatter detection yet; ponytail: add when the detector learns formatters
|
|
44
46
|
case "path-guard":
|
|
@@ -46,7 +46,7 @@ export function buildCodexHooks(hooksOutput) {
|
|
|
46
46
|
continue;
|
|
47
47
|
}
|
|
48
48
|
codexHooks.hooks[event] = entries.map((entry) => ({
|
|
49
|
-
matcher: normalizeMatcher(entry.matcher),
|
|
49
|
+
matcher: normalizeMatcher(entry.matcher ?? ""),
|
|
50
50
|
hooks: entry.hooks.map((h) => ({ type: h.type, command: h.command })),
|
|
51
51
|
}));
|
|
52
52
|
}
|
|
@@ -11,7 +11,7 @@ export interface HookCommand {
|
|
|
11
11
|
}
|
|
12
12
|
export interface HooksOutput {
|
|
13
13
|
hooksConfig: Record<string, Array<{
|
|
14
|
-
matcher
|
|
14
|
+
matcher?: string;
|
|
15
15
|
hooks: HookCommand[];
|
|
16
16
|
}>>;
|
|
17
17
|
generatedFiles: string[];
|
|
@@ -20,7 +20,7 @@ export interface HooksPlan {
|
|
|
20
20
|
/** Hook script files (each chmod 0o755). */
|
|
21
21
|
files: PlannedFile[];
|
|
22
22
|
hooksConfig: Record<string, Array<{
|
|
23
|
-
matcher
|
|
23
|
+
matcher?: string;
|
|
24
24
|
hooks: HookCommand[];
|
|
25
25
|
}>>;
|
|
26
26
|
generatedFiles: string[];
|
package/dist/generators/hooks.js
CHANGED
|
@@ -186,11 +186,17 @@ const OMH_TREE_FINGERPRINT = `_omh_tree_fingerprint() {
|
|
|
186
186
|
# _omh_gate_cached <name> <ttl-seconds> <fp> -> 0 when the last recorded pass
|
|
187
187
|
# for <name> is <fp> and younger than ttl.
|
|
188
188
|
# _omh_gate_record <name> <fp> -> remember <fp> as passed.
|
|
189
|
+
# The optional [cmd] argument becomes part of the cache key, so changing the
|
|
190
|
+
# command in harness.yaml never reuses a pass recorded for the old one.
|
|
191
|
+
_omh_gate_key() {
|
|
192
|
+
local name="\${1:-}" cmd="\${2:-}"
|
|
193
|
+
if [ -n "$cmd" ]; then printf '%s-%s' "$name" "$(printf '%s' "$cmd" | cksum | cut -d' ' -f1)"; else printf '%s' "$name"; fi
|
|
194
|
+
}
|
|
189
195
|
_omh_gate_cached() {
|
|
190
196
|
local name="$1" ttl="\${2:-0}" fp="\${3:-none}" file now cached_fp cached_ts
|
|
191
197
|
[ "$ttl" -gt 0 ] 2>/dev/null || return 1
|
|
192
198
|
[ "$fp" = "none" ] && return 1
|
|
193
|
-
file="\${_OMH_STATE_DIR:-.omh/state}/gate-$name.fp"
|
|
199
|
+
file="\${_OMH_STATE_DIR:-.omh/state}/gate-$(_omh_gate_key "$name" "\${4:-}").fp"
|
|
194
200
|
[ -f "$file" ] || return 1
|
|
195
201
|
read -r cached_fp cached_ts < "$file" || return 1
|
|
196
202
|
now=$(date +%s)
|
|
@@ -199,7 +205,7 @@ _omh_gate_cached() {
|
|
|
199
205
|
_omh_gate_record() {
|
|
200
206
|
local name="$1" fp="\${2:-none}" file
|
|
201
207
|
[ "$fp" = "none" ] && return 0
|
|
202
|
-
file="\${_OMH_STATE_DIR:-.omh/state}/gate-$name.fp"
|
|
208
|
+
file="\${_OMH_STATE_DIR:-.omh/state}/gate-$(_omh_gate_key "$name" "\${3:-}").fp"
|
|
203
209
|
mkdir -p "$(dirname "$file")" 2>/dev/null || true
|
|
204
210
|
printf '%s %s\\n' "$fp" "$(date +%s)" > "$file"
|
|
205
211
|
}`;
|
|
@@ -438,6 +444,7 @@ export async function computeHooks(options) {
|
|
|
438
444
|
["Notification", config.hooks.notification ?? []],
|
|
439
445
|
["ConfigChange", config.hooks.configChange ?? []],
|
|
440
446
|
["WorktreeCreate", config.hooks.worktreeCreate ?? []],
|
|
447
|
+
["Stop", config.hooks.stop ?? []],
|
|
441
448
|
];
|
|
442
449
|
const allHooks = eventMap.flatMap(([event, hooks]) => hooks.map((h) => ({ ...h, event })));
|
|
443
450
|
// Read previous manifest to identify stale hook files to clean up.
|
|
@@ -479,7 +486,8 @@ export async function computeHooks(options) {
|
|
|
479
486
|
if (!hooksConfig[p.event])
|
|
480
487
|
hooksConfig[p.event] = [];
|
|
481
488
|
hooksConfig[p.event].push({
|
|
482
|
-
matcher:
|
|
489
|
+
// Stop (and SubagentStop) have no matcher support in Claude Code: omit the key rather than emit "".
|
|
490
|
+
...(p.event === "Stop" || p.event === "SubagentStop" ? {} : { matcher: p.matcher }),
|
|
483
491
|
hooks: [{ type: "command", command: `bash ${shellSingleQuote(p.scriptPath)}` }],
|
|
484
492
|
});
|
|
485
493
|
}
|
|
@@ -36,7 +36,7 @@ export function extractPiBindings(hooksConfig) {
|
|
|
36
36
|
const pre = hooksConfig["PreToolUse"] ?? [];
|
|
37
37
|
const bindings = [];
|
|
38
38
|
for (const entry of pre) {
|
|
39
|
-
const tools = matcherToPiTools(entry.matcher);
|
|
39
|
+
const tools = matcherToPiTools(entry.matcher ?? "");
|
|
40
40
|
if (tools.length === 0)
|
|
41
41
|
continue;
|
|
42
42
|
for (const hook of entry.hooks) {
|
|
@@ -5,7 +5,7 @@ import { type PresetName } from "../core/presets.js";
|
|
|
5
5
|
export declare const TYPESAFE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
|
|
6
6
|
export declare const TYPESAFE_MODEL = "jev-latest";
|
|
7
7
|
/** Blocks a project may or may not want. Always-on and loop-internal blocks are never asked. */
|
|
8
|
-
export declare const SELECTABLE_BLOCKS: readonly ["branch-guard", "commit-test-gate", "commit-typecheck-gate", "command-guard", "path-guard", "lockfile-guard", "secret-file-guard", "tdd-guard", "sql-guard", "lint-on-save", "format-on-save", "test-on-save", "auto-pr", "desktop-notify", "compact-context"];
|
|
8
|
+
export declare const SELECTABLE_BLOCKS: readonly ["branch-guard", "commit-test-gate", "commit-typecheck-gate", "command-guard", "path-guard", "lockfile-guard", "secret-file-guard", "tdd-guard", "sql-guard", "lint-on-save", "format-on-save", "test-on-save", "auto-pr", "desktop-notify", "compact-context", "stop-test-gate", "stop-uncommitted-warn"];
|
|
9
9
|
export type Decision = "on" | "off" | "undecided";
|
|
10
10
|
export interface ChooserInput {
|
|
11
11
|
description: string;
|
|
@@ -16,6 +16,7 @@ export const SELECTABLE_BLOCKS = [
|
|
|
16
16
|
"branch-guard", "commit-test-gate", "commit-typecheck-gate", "command-guard", "path-guard",
|
|
17
17
|
"lockfile-guard", "secret-file-guard", "tdd-guard", "sql-guard",
|
|
18
18
|
"lint-on-save", "format-on-save", "test-on-save", "auto-pr", "desktop-notify", "compact-context",
|
|
19
|
+
"stop-test-gate", "stop-uncommitted-warn",
|
|
19
20
|
];
|
|
20
21
|
export const THRESHOLDS = { on: 0.65, off: 0.35, choiceConfidence: 0.5 };
|
|
21
22
|
export function buildJevRequest(input) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { HarnessConfig } from "../core/harness-schema.js";
|
|
2
|
+
import type { BuildingBlock } from "../catalog/types.js";
|
|
3
|
+
import type { ProjectFacts } from "../detector/types.js";
|
|
4
|
+
export declare const MODIFY_ACTIONS: readonly ["enable", "disable", "ask", "keep"];
|
|
5
|
+
export type ModifyAction = (typeof MODIFY_ACTIONS)[number];
|
|
6
|
+
export interface ModifyInput {
|
|
7
|
+
request: string;
|
|
8
|
+
harness: HarnessConfig;
|
|
9
|
+
blocks: Pick<BuildingBlock, "id" | "description">[];
|
|
10
|
+
}
|
|
11
|
+
export interface ModifyRequest {
|
|
12
|
+
model: string;
|
|
13
|
+
state: Record<string, unknown>;
|
|
14
|
+
questions: Record<string, Record<string, unknown>>;
|
|
15
|
+
}
|
|
16
|
+
type ChoiceAnswer = {
|
|
17
|
+
type: "choice";
|
|
18
|
+
choice: string;
|
|
19
|
+
confidence: number;
|
|
20
|
+
probabilities: Record<string, number>;
|
|
21
|
+
};
|
|
22
|
+
export declare function buildModifyRequest(input: ModifyInput): ModifyRequest;
|
|
23
|
+
export declare function routeModifyAnswers(answers: Record<string, ChoiceAnswer | {
|
|
24
|
+
type: string;
|
|
25
|
+
}>): Map<string, ModifyAction>;
|
|
26
|
+
export interface ModifyResult {
|
|
27
|
+
decisions: Map<string, ModifyAction>;
|
|
28
|
+
usage?: {
|
|
29
|
+
input_tokens: number;
|
|
30
|
+
output_tokens: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export declare function modifyWithJev(input: ModifyInput, opts: {
|
|
34
|
+
apiKey: string;
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}): Promise<ModifyResult>;
|
|
37
|
+
export interface AppliedModifications {
|
|
38
|
+
harness: HarnessConfig;
|
|
39
|
+
changes: {
|
|
40
|
+
block: string;
|
|
41
|
+
action: ModifyAction;
|
|
42
|
+
}[];
|
|
43
|
+
refused: {
|
|
44
|
+
block: string;
|
|
45
|
+
action: ModifyAction;
|
|
46
|
+
reason: string;
|
|
47
|
+
}[];
|
|
48
|
+
}
|
|
49
|
+
/** Apply decisions as structured edits. `locked: true` entries are never removed or weakened. */
|
|
50
|
+
export declare function applyModifications(harness: HarnessConfig, decisions: Map<string, ModifyAction>, facts: ProjectFacts | undefined, blocks: Pick<BuildingBlock, "id">[]): AppliedModifications;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { defaultParamsFor } from "../core/presets.js";
|
|
2
|
+
import { SELECTABLE_BLOCKS, TYPESAFE_ENDPOINT, TYPESAFE_MODEL, THRESHOLDS } from "./typesafe-chooser.js";
|
|
3
|
+
// `omh modify "request"` (#118) through Jev: one choice question per
|
|
4
|
+
// selectable block ("what does the request ask for this block?"), answers
|
|
5
|
+
// routed by confidence, applied as structured edits to harness.yaml. No
|
|
6
|
+
// text is generated; params come from the detector or catalog defaults.
|
|
7
|
+
export const MODIFY_ACTIONS = ["enable", "disable", "ask", "keep"];
|
|
8
|
+
export function buildModifyRequest(input) {
|
|
9
|
+
const byId = new Map(input.blocks.map((b) => [b.id, b]));
|
|
10
|
+
const enabled = new Set(input.harness.hooks.map((h) => h.block));
|
|
11
|
+
const questions = {};
|
|
12
|
+
for (const id of SELECTABLE_BLOCKS) {
|
|
13
|
+
const b = byId.get(id);
|
|
14
|
+
if (!b)
|
|
15
|
+
continue;
|
|
16
|
+
const state = enabled.has(id) ? "currently ENABLED" : "currently DISABLED";
|
|
17
|
+
questions[`block:${id}`] = {
|
|
18
|
+
type: "choice",
|
|
19
|
+
instructions: `The user wants to change their guardrail configuration. Block '${id}' (${state}) ${lowerFirst(b.description)}. ` +
|
|
20
|
+
"What does the user's request ask for this block? Answer keep unless the request clearly refers to it.",
|
|
21
|
+
criteria: {
|
|
22
|
+
enable: "turn this block on (add it, or restore it to blocking mode)",
|
|
23
|
+
disable: "turn this block off entirely",
|
|
24
|
+
ask: "keep it but make it ask for confirmation instead of blocking",
|
|
25
|
+
keep: "the request does not concern this block",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
model: TYPESAFE_MODEL,
|
|
31
|
+
state: {
|
|
32
|
+
request: input.request,
|
|
33
|
+
currently_enabled: [...enabled],
|
|
34
|
+
ask_mode: input.harness.hooks.filter((h) => h.mode === "ask").map((h) => h.block),
|
|
35
|
+
},
|
|
36
|
+
questions,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function routeModifyAnswers(answers) {
|
|
40
|
+
const out = new Map();
|
|
41
|
+
for (const [key, a] of Object.entries(answers)) {
|
|
42
|
+
if (!key.startsWith("block:") || a.type !== "choice")
|
|
43
|
+
continue;
|
|
44
|
+
const id = key.slice("block:".length);
|
|
45
|
+
if (!SELECTABLE_BLOCKS.includes(id))
|
|
46
|
+
continue;
|
|
47
|
+
const c = a;
|
|
48
|
+
if (!MODIFY_ACTIONS.includes(c.choice) || c.choice === "keep")
|
|
49
|
+
continue;
|
|
50
|
+
if (typeof c.confidence !== "number" || c.confidence < THRESHOLDS.choiceConfidence)
|
|
51
|
+
continue;
|
|
52
|
+
out.set(id, c.choice);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
export async function modifyWithJev(input, opts) {
|
|
57
|
+
const res = await fetch(TYPESAFE_ENDPOINT, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
60
|
+
body: JSON.stringify(buildModifyRequest(input)),
|
|
61
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const body = (await res.text().catch(() => "")).slice(0, 200);
|
|
65
|
+
throw new Error(`TypeSafe API error ${res.status}: ${body || res.statusText}`);
|
|
66
|
+
}
|
|
67
|
+
const json = (await res.json());
|
|
68
|
+
return { decisions: routeModifyAnswers(json.answers ?? {}), usage: json.usage };
|
|
69
|
+
}
|
|
70
|
+
/** Apply decisions as structured edits. `locked: true` entries are never removed or weakened. */
|
|
71
|
+
export function applyModifications(harness, decisions, facts, blocks) {
|
|
72
|
+
const known = new Set(blocks.map((b) => b.id));
|
|
73
|
+
const hooks = harness.hooks.map((h) => ({ ...h, params: { ...h.params } }));
|
|
74
|
+
const changes = [];
|
|
75
|
+
const refused = [];
|
|
76
|
+
for (const [block, action] of decisions) {
|
|
77
|
+
if (action === "keep" || !known.has(block))
|
|
78
|
+
continue;
|
|
79
|
+
const idx = hooks.findIndex((h) => h.block === block);
|
|
80
|
+
const existing = idx >= 0 ? hooks[idx] : undefined;
|
|
81
|
+
if (existing?.locked && (action === "disable" || action === "ask")) {
|
|
82
|
+
refused.push({ block, action, reason: `${block} is locked in harness.yaml; remove \`locked: true\` first` });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (action === "disable") {
|
|
86
|
+
if (idx >= 0) {
|
|
87
|
+
hooks.splice(idx, 1);
|
|
88
|
+
changes.push({ block, action });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (action === "ask") {
|
|
92
|
+
if (existing && existing.mode !== "ask") {
|
|
93
|
+
existing.mode = "ask";
|
|
94
|
+
changes.push({ block, action });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (action === "enable") {
|
|
98
|
+
if (existing) {
|
|
99
|
+
if (existing.mode === "ask") {
|
|
100
|
+
existing.mode = "block";
|
|
101
|
+
changes.push({ block, action });
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const params = defaultParamsFor(block, facts);
|
|
106
|
+
if (params === null) {
|
|
107
|
+
refused.push({ block, action, reason: `${block} needs a parameter the project detector could not supply; add it to harness.yaml by hand` });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
hooks.push({ block, params, mode: "block" });
|
|
111
|
+
changes.push({ block, action });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { harness: { ...harness, hooks }, changes, refused };
|
|
115
|
+
}
|
|
116
|
+
function lowerFirst(s) {
|
|
117
|
+
return s ? s[0].toLowerCase() + s.slice(1) : s;
|
|
118
|
+
}
|