oh-my-harness 0.21.0 → 0.22.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 +8 -1
- package/dist/catalog/blocks/force-push-guard.js +25 -15
- package/dist/catalog/blocks/harness-guard.js +9 -2
- package/dist/catalog/blocks/no-verify-guard.js +12 -4
- package/dist/cli/commands/config.js +11 -0
- package/dist/cli/commands/doctor.js +10 -3
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +68 -15
- package/dist/cli/commands/test.js +19 -4
- package/dist/cli/harness-tester.js +13 -9
- package/dist/cli/index.js +8 -1
- package/dist/cli/stats/data.js +2 -1
- package/dist/cli/tui/init-flow.d.ts +11 -0
- package/dist/cli/tui/init-flow.js +63 -26
- package/dist/core/harness-converter-v2.d.ts +10 -0
- package/dist/core/harness-converter-v2.js +44 -26
- package/dist/core/presets.d.ts +17 -0
- package/dist/core/presets.js +125 -0
- package/dist/generators/hooks.js +12 -6
- package/dist/nl/parse-intent.d.ts +3 -0
- package/dist/nl/parse-intent.js +1 -1
- package/dist/nl/typesafe-chooser.d.ts +70 -0
- package/dist/nl/typesafe-chooser.js +144 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,12 @@ Because agents read instructions and then forget them halfway through a long ses
|
|
|
47
47
|
# Zero-install: run directly with npx
|
|
48
48
|
npx oh-my-harness init "TypeScript Next.js frontend with Python FastAPI backend"
|
|
49
49
|
|
|
50
|
+
# No AI provider? Deterministic presets need nothing but the repo:
|
|
51
|
+
npx oh-my-harness init --preset strict # minimal | safe | strict
|
|
52
|
+
|
|
53
|
+
# With a TypeSafe key, Jev tunes the preset to your description in one ~500 ms, sub-cent call:
|
|
54
|
+
TYPESAFE_API_KEY=... npx oh-my-harness init "TypeScript API, TDD enforced, no auto PRs"
|
|
55
|
+
|
|
50
56
|
# Or install globally
|
|
51
57
|
npm install -g oh-my-harness
|
|
52
58
|
oh-my-harness init "React app with TDD"
|
|
@@ -147,7 +153,8 @@ Hand omh a `WORKPLAN.md` and it runs one work order per fresh agent session, in
|
|
|
147
153
|
|
|
148
154
|
```bash
|
|
149
155
|
# 🚀 Initialize
|
|
150
|
-
omh init "your project description" #
|
|
156
|
+
omh init "your project description" # Jev with TYPESAFE_API_KEY, else an LLM provider, else the "safe" preset
|
|
157
|
+
omh init --preset strict # No provider: minimal | safe | strict
|
|
151
158
|
omh init # Interactive TUI (import existing harness.yaml)
|
|
152
159
|
|
|
153
160
|
# 📋 Catalog
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// `git push` when the destination is a protected branch and the push is
|
|
3
3
|
// forced: --force / -f / a "+refspec". The destination
|
|
4
4
|
// is the refspec's target (`origin main`, `origin HEAD:main`,
|
|
5
|
-
// `origin feat:refs/heads/main`, `origin +main`)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// `origin feat:refs/heads/main`, `origin +main`), every refspec is checked;
|
|
6
|
+
// with no refspec it is git's own push target (@{push}, so push.default=upstream
|
|
7
|
+
// is honoured), falling back to the current branch, and if even that cannot be
|
|
8
|
+
// determined the push is blocked rather than guessed. --force-with-lease is allowed by default (it refuses to
|
|
8
9
|
// clobber unseen commits); set allowLease: false to block it too.
|
|
9
10
|
export const forcePushGuard = {
|
|
10
11
|
id: "force-push-guard",
|
|
@@ -40,9 +41,14 @@ _omh_cmd_matches "$COMMAND" git push || exit 0
|
|
|
40
41
|
|
|
41
42
|
PROTECTED=({{#each protected}}"{{{this}}}" {{/each}})
|
|
42
43
|
CURRENT=$(git branch --show-current 2>/dev/null || echo "")
|
|
44
|
+
# With no refspec git pushes to the configured target, which under
|
|
45
|
+
# push.default=upstream can be a different branch than HEAD (feature -> main).
|
|
46
|
+
PUSH_TARGET=$(git rev-parse --abbrev-ref --symbolic-full-name '@{push}' 2>/dev/null || echo "")
|
|
47
|
+
PUSH_TARGET="\${PUSH_TARGET#*/}"
|
|
48
|
+
[[ -z "$PUSH_TARGET" ]] && PUSH_TARGET="$CURRENT"
|
|
43
49
|
|
|
44
50
|
HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
|
|
45
|
-
-v protected="$(IFS='|'; printf '%s' "\${PROTECTED[*]}")" -v current="$
|
|
51
|
+
-v protected="$(IFS='|'; printf '%s' "\${PROTECTED[*]}")" -v current="$PUSH_TARGET" -v allow_lease="{{allowLease}}" '
|
|
46
52
|
BEGIN { np = split(protected, P, "|"); for (k = 1; k <= np; k++) isp[P[k]] = 1 }
|
|
47
53
|
found != "" { next }
|
|
48
54
|
{
|
|
@@ -53,26 +59,30 @@ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
|
|
|
53
59
|
j = i + 1
|
|
54
60
|
while (j <= NF && $j ~ /^-/) { if ($j == "-c" || $j == "-C") j++; j++ }
|
|
55
61
|
if (j > NF || $j != "push") next
|
|
56
|
-
force = 0; lease = 0; remote = "";
|
|
62
|
+
force = 0; lease = 0; remote = ""; nref = 0; plus = 0
|
|
57
63
|
for (k = j + 1; k <= NF; k++) {
|
|
58
64
|
t = $k
|
|
59
|
-
if (t == "--") { for (m = k + 1; m <= NF; m++) { if (remote == "") remote = $m; else
|
|
65
|
+
if (t == "--") { for (m = k + 1; m <= NF; m++) { if (remote == "") remote = $m; else refs[++nref] = $m }; break }
|
|
60
66
|
if (t == "--force" || t == "-f" || t ~ /^-[A-Za-z]*f[A-Za-z]*$/) { force = 1; continue }
|
|
61
67
|
if (t == "--force-if-includes") continue # only modifies --force-with-lease; not a force on its own
|
|
62
68
|
if (t ~ /^--force-with-lease/) { lease = 1; continue }
|
|
63
69
|
if (t ~ /^--repo=/) { remote = substr(t, 8); continue }
|
|
64
70
|
if (t == "--repo" || t == "-o" || t == "--push-option" || t == "--receive-pack" || t == "--exec") { k++; continue }
|
|
65
71
|
if (t ~ /^-/) continue
|
|
66
|
-
if (remote == "") remote = t; else
|
|
72
|
+
if (remote == "") remote = t; else refs[++nref] = t
|
|
73
|
+
}
|
|
74
|
+
# git pushes every refspec given; any protected destination is enough to block
|
|
75
|
+
if (nref == 0) refs[++nref] = ""
|
|
76
|
+
for (r = 1; r <= nref; r++) {
|
|
77
|
+
dst = refs[r]; plus = 0
|
|
78
|
+
if (substr(dst, 1, 1) == "+") { plus = 1; dst = substr(dst, 2) } # "+refspec" forces without --force
|
|
79
|
+
if (index(dst, ":") > 0) dst = substr(dst, index(dst, ":") + 1)
|
|
80
|
+
sub(/^refs\\/heads\\//, "", dst)
|
|
81
|
+
if (!force && !plus && !(lease && allow_lease == "false")) continue
|
|
82
|
+
if (dst == "HEAD" || dst == "") dst = current
|
|
83
|
+
if (dst == "") { found = "force push with an undeterminable target branch"; break }
|
|
84
|
+
if (isp[dst]) { found = "force push to " dst; break }
|
|
67
85
|
}
|
|
68
|
-
dst = refspec
|
|
69
|
-
if (substr(dst, 1, 1) == "+") { plus = 1; dst = substr(dst, 2) } # "+refspec" forces without --force
|
|
70
|
-
if (index(dst, ":") > 0) dst = substr(dst, index(dst, ":") + 1)
|
|
71
|
-
sub(/^refs\\/heads\\//, "", dst)
|
|
72
|
-
if (!force && !plus && !(lease && allow_lease == "false")) next
|
|
73
|
-
if (dst == "HEAD" || dst == "") dst = current
|
|
74
|
-
if (dst == "") { found = "force push with an undeterminable target branch"; next }
|
|
75
|
-
if (isp[dst]) found = "force push to " dst
|
|
76
86
|
}
|
|
77
87
|
END { if (found != "") print found }')
|
|
78
88
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// commands that would rewrite, delete or neuter them: file tools with a
|
|
4
4
|
// protected path as an argument, output redirections into a protected path,
|
|
5
5
|
// and git checkout/restore/clean/rm/mv on one. Read-only commands (cat, grep,
|
|
6
|
-
// ls, diff) stay allowed so the agent can inspect its own rules, and `omh` is
|
|
6
|
+
// ls, diff, sed without -i) stay allowed so the agent can inspect its own rules, and `omh` is
|
|
7
7
|
// never a writer here, so `omh sync` / `omh hook add` remain the sanctioned
|
|
8
8
|
// way to change the harness.
|
|
9
9
|
// ponytail: harness.yaml itself is deliberately not protected (it is the
|
|
@@ -37,7 +37,7 @@ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
|
37
37
|
[[ -z "$COMMAND" ]] && exit 0
|
|
38
38
|
|
|
39
39
|
PROTECTED=(".omh" ".claude/settings.json" ".codex/hooks.json" ".codex/config.toml" ".pi/extensions/omh-harness.ts" {{#each extraPaths}}"{{{this}}}" {{/each}})
|
|
40
|
-
WRITERS="rm mv cp
|
|
40
|
+
WRITERS="rm mv cp tee chmod chown chgrp truncate dd ln touch install rsync shred unlink perl"
|
|
41
41
|
GIT_WRITERS="checkout restore clean rm mv"
|
|
42
42
|
|
|
43
43
|
HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
|
|
@@ -69,6 +69,13 @@ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' \\
|
|
|
69
69
|
a0 = $i
|
|
70
70
|
write = 0
|
|
71
71
|
if (isw[a0]) write = 1
|
|
72
|
+
else if (a0 == "sed") { # sed only writes in place: -i, any -i cluster, --in-place or a GNU abbreviation of it (--i, --in-p ...)
|
|
73
|
+
for (k = i + 1; k <= NF; k++) {
|
|
74
|
+
if ($k == "--") break
|
|
75
|
+
if ($k ~ /^-[A-Za-z]*i/) { write = 1; break }
|
|
76
|
+
if ($k ~ /^--i/) { o = $k; sub(/=.*/, "", o); if (index("--in-place", o) == 1) { write = 1; break } }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
72
79
|
else if (a0 == "git") {
|
|
73
80
|
j = i + 1; while (j <= NF && $j ~ /^-/) { if ($j == "-c" || $j == "-C") j++; j++ }
|
|
74
81
|
if (j <= NF && isg[$j]) write = 1
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// or `git push` (through env/sudo/sh -c, see #109/#110):
|
|
6
6
|
// git commit --no-verify | -n | any short cluster containing n (-anm, -qn)
|
|
7
7
|
// git push --no-verify
|
|
8
|
-
// git -c core.hooksPath=... <anything> (points hooks at an empty dir)
|
|
8
|
+
// git -c core.hooksPath=... <anything> (points hooks at an empty dir; key match is case-insensitive)
|
|
9
|
+
// GIT_CONFIG_KEY_n=core.hooksPath ... git and GIT_CONFIG_PARAMETERS='core.hooksPath=...' git
|
|
9
10
|
// `git push -n` is dry-run, not no-verify, so it stays allowed.
|
|
10
11
|
export const noVerifyGuard = {
|
|
11
12
|
id: "no-verify-guard",
|
|
@@ -27,14 +28,21 @@ HIT=$(_omh_simple_commands "$COMMAND" | awk -F '\\t' '
|
|
|
27
28
|
found != "" { next }
|
|
28
29
|
{
|
|
29
30
|
i = 1
|
|
30
|
-
|
|
31
|
+
env_bypass = 0
|
|
32
|
+
while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) {
|
|
33
|
+
# GIT_CONFIG_KEY_n=core.hooksPath / GIT_CONFIG_PARAMETERS='core.hooksPath=...' disable hooks too
|
|
34
|
+
lw = tolower($i)
|
|
35
|
+
if (lw ~ /^git_config_key_[0-9]+=core\\.hookspath$/ || lw ~ /^git_config_parameters=.*core\\.hookspath=/) env_bypass = 1
|
|
36
|
+
i++
|
|
37
|
+
}
|
|
31
38
|
if (i <= NF && ($i == "sudo" || $i == "doas")) { i++; while (i <= NF && $i ~ /^-/) i++ }
|
|
32
39
|
if (i > NF || $i != "git") next
|
|
33
|
-
|
|
40
|
+
if (env_bypass) { found = "GIT_CONFIG core.hooksPath override"; next }
|
|
41
|
+
# global options before the subcommand; -c core.hooksPath=... is itself a bypass (config keys are case-insensitive)
|
|
34
42
|
j = i + 1
|
|
35
43
|
while (j <= NF && $j ~ /^-/) {
|
|
36
44
|
if ($j == "-c" || $j == "-C") {
|
|
37
|
-
if ($j == "-c" && j + 1 <= NF && $(j + 1) ~ /^core\\.
|
|
45
|
+
if ($j == "-c" && j + 1 <= NF && tolower($(j + 1)) ~ /^core\\.hookspath=/) { found = "git -c core.hooksPath override"; next }
|
|
38
46
|
j++
|
|
39
47
|
}
|
|
40
48
|
j++
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import { resolveTypesafeApiKey } from "../../nl/typesafe-chooser.js";
|
|
2
3
|
import { loadProviderConfig, deleteProviderConfig, maskApiKey, } from "../../nl/config-store.js";
|
|
3
4
|
const NO_CONFIG_MESSAGE = "No AI provider configured yet. Run `omh config` (or `omh init`) to set one up.";
|
|
5
|
+
function printChooserLine() {
|
|
6
|
+
const key = resolveTypesafeApiKey(process.cwd());
|
|
7
|
+
console.log(key
|
|
8
|
+
? ` chooser: ${chalk.cyan("TypeSafe Jev")} ${chalk.dim("(TYPESAFE_API_KEY found; `omh init \"...\"` selects catalog blocks with it, no LLM call)")}`
|
|
9
|
+
: ` chooser: ${chalk.dim("none (set TYPESAFE_API_KEY to let Jev pick catalog blocks; `omh init --preset` needs neither)")}`);
|
|
10
|
+
}
|
|
4
11
|
function printSummary(config) {
|
|
5
12
|
console.log(chalk.bold("Current AI provider configuration:"));
|
|
6
13
|
console.log(` provider: ${chalk.cyan(config.provider)}`);
|
|
@@ -53,15 +60,18 @@ export async function configCommand(options = {}) {
|
|
|
53
60
|
if (options.show) {
|
|
54
61
|
if (!existing) {
|
|
55
62
|
console.log(NO_CONFIG_MESSAGE);
|
|
63
|
+
printChooserLine();
|
|
56
64
|
return { exitCode: 0 };
|
|
57
65
|
}
|
|
58
66
|
printSummary(existing);
|
|
67
|
+
printChooserLine();
|
|
59
68
|
return { exitCode: 0 };
|
|
60
69
|
}
|
|
61
70
|
// --reset: delete the saved config.
|
|
62
71
|
if (options.reset) {
|
|
63
72
|
if (!existing) {
|
|
64
73
|
console.log(NO_CONFIG_MESSAGE);
|
|
74
|
+
printChooserLine();
|
|
65
75
|
return { exitCode: 0 };
|
|
66
76
|
}
|
|
67
77
|
const confirm = options.confirm ?? defaultConfirm;
|
|
@@ -78,6 +88,7 @@ export async function configCommand(options = {}) {
|
|
|
78
88
|
// `omh config` is a safe read; `--yes` skips the prompt.
|
|
79
89
|
if (existing) {
|
|
80
90
|
printSummary(existing);
|
|
91
|
+
printChooserLine();
|
|
81
92
|
console.log("");
|
|
82
93
|
if (!options.yes) {
|
|
83
94
|
const confirm = options.confirm ?? defaultConfirm;
|
|
@@ -4,6 +4,7 @@ import { parse } from "smol-toml";
|
|
|
4
4
|
import { OMH_HOOKS_DIR } from "../../utils/paths.js";
|
|
5
5
|
import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
|
|
6
6
|
import { loadProviderConfig } from "../../nl/config-store.js";
|
|
7
|
+
import { resolveTypesafeApiKey } from "../../nl/typesafe-chooser.js";
|
|
7
8
|
export async function doctorCommand(options = {}) {
|
|
8
9
|
const projectDir = options.projectDir ?? process.cwd();
|
|
9
10
|
const messages = [];
|
|
@@ -158,7 +159,7 @@ export async function doctorCommand(options = {}) {
|
|
|
158
159
|
// 9. AI provider config (~/.omh/config.json). Global and optional, so this is
|
|
159
160
|
// purely informational (INFO) and never affects health — it just surfaces
|
|
160
161
|
// `omh config` so users can rotate an expired key or switch provider/model.
|
|
161
|
-
const providerConfigured = await checkProviderConfig(messages);
|
|
162
|
+
const providerConfigured = await checkProviderConfig(messages, projectDir);
|
|
162
163
|
const checksHealthy = Object.values(checks).every(Boolean);
|
|
163
164
|
const healthy = checksHealthy && (!options.strict || inSync !== false);
|
|
164
165
|
const exitCode = healthy ? 0 : 1;
|
|
@@ -180,10 +181,16 @@ export async function doctorCommand(options = {}) {
|
|
|
180
181
|
* Inspects the global AI provider config and appends an INFO hint to `messages`.
|
|
181
182
|
* Returns whether a provider is configured. Never reads or echoes the API key.
|
|
182
183
|
*/
|
|
183
|
-
async function checkProviderConfig(messages) {
|
|
184
|
+
async function checkProviderConfig(messages, projectDir) {
|
|
185
|
+
const jev = Boolean(resolveTypesafeApiKey(projectDir));
|
|
186
|
+
if (jev) {
|
|
187
|
+
messages.push("INFO: Chooser: TypeSafe Jev (TYPESAFE_API_KEY found) — `omh init \"...\"` picks catalog blocks with it; no LLM provider needed.");
|
|
188
|
+
}
|
|
184
189
|
const config = await loadProviderConfig();
|
|
185
190
|
if (!config) {
|
|
186
|
-
messages.push(
|
|
191
|
+
messages.push(jev
|
|
192
|
+
? "INFO: LLM provider: none (optional; only needed for generated rule text — `omh config` to add one)."
|
|
193
|
+
: "INFO: No AI provider configured for natural-language mode — run `omh config` to set one up, or use `omh init --preset`.");
|
|
187
194
|
return false;
|
|
188
195
|
}
|
|
189
196
|
if (config.method === "api") {
|
|
@@ -4,6 +4,8 @@ export interface InitOptions {
|
|
|
4
4
|
projectDir?: string;
|
|
5
5
|
nlRunner?: ClaudeRunner;
|
|
6
6
|
description?: string;
|
|
7
|
+
/** Deterministic preset (#116): minimal | safe | strict. No provider, no network. */
|
|
8
|
+
preset?: string;
|
|
7
9
|
}
|
|
8
10
|
export interface HarnessState {
|
|
9
11
|
presets: string[];
|
|
@@ -2,7 +2,10 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { generate } from "../../core/generator.js";
|
|
5
|
-
import { generateHarnessConfig } from "../../nl/parse-intent.js";
|
|
5
|
+
import { generateHarnessConfig, providerConfigFromEnv } from "../../nl/parse-intent.js";
|
|
6
|
+
import { hasProviderConfig } from "../../nl/config-store.js";
|
|
7
|
+
import { buildPresetHarness, isPresetName, PRESET_NAMES } from "../../core/presets.js";
|
|
8
|
+
import { chooseWithJev, harnessFromChoices, resolveTypesafeApiKey, TYPESAFE_MODEL } from "../../nl/typesafe-chooser.js";
|
|
6
9
|
import { detectProject } from "../../detector/project-detector.js";
|
|
7
10
|
import { harnessToMergedConfigV2 } from "../../core/harness-converter-v2.js";
|
|
8
11
|
import { createDefaultRegistry } from "../../catalog/registry.js";
|
|
@@ -32,7 +35,9 @@ export async function initCommand(descriptionParts, options = {}) {
|
|
|
32
35
|
if (inlineDescription) {
|
|
33
36
|
options = { ...options, description: inlineDescription };
|
|
34
37
|
}
|
|
35
|
-
|
|
38
|
+
// Anything that already says what to build skips the TUI; initWithNL still
|
|
39
|
+
// asks "Proceed?" unless -y was given.
|
|
40
|
+
if (options.nlRunner || options.yes || options.preset || options.description) {
|
|
36
41
|
await initWithNL(projectDir, options);
|
|
37
42
|
return;
|
|
38
43
|
}
|
|
@@ -41,25 +46,29 @@ export async function initCommand(descriptionParts, options = {}) {
|
|
|
41
46
|
}
|
|
42
47
|
export const initCommandHeadless = initCommand;
|
|
43
48
|
export async function initWithNL(projectDir, options) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
description = options.description ?? "generate config";
|
|
49
|
+
if (options.preset !== undefined && !isPresetName(options.preset)) {
|
|
50
|
+
throw new Error(`Unknown preset "${options.preset}". Valid presets: ${PRESET_NAMES.join(", ")}`);
|
|
47
51
|
}
|
|
48
|
-
|
|
52
|
+
const preset = options.preset;
|
|
53
|
+
let description = options.description?.trim() ?? "";
|
|
54
|
+
if (!description && options.nlRunner)
|
|
55
|
+
description = "generate config";
|
|
56
|
+
if (!description && !preset) {
|
|
57
|
+
if (options.yes) {
|
|
58
|
+
console.log("No description provided.");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
49
61
|
const { input } = await import("@inquirer/prompts");
|
|
50
|
-
description = await input({
|
|
62
|
+
description = (await input({
|
|
51
63
|
message: "Describe your project (e.g., 'Next.js e-commerce app with Stripe'):",
|
|
52
|
-
});
|
|
53
|
-
if (!description
|
|
64
|
+
})).trim();
|
|
65
|
+
if (!description) {
|
|
54
66
|
console.log("No description provided.");
|
|
55
67
|
return;
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
|
-
|
|
59
|
-
console.log(
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
console.log(`Generating harness config for: "${description}"`);
|
|
70
|
+
if (description)
|
|
71
|
+
console.log(`Generating harness config for: "${description}"`);
|
|
63
72
|
let facts;
|
|
64
73
|
try {
|
|
65
74
|
facts = await detectProject(projectDir);
|
|
@@ -76,7 +85,51 @@ export async function initWithNL(projectDir, options) {
|
|
|
76
85
|
matcher: b.matcher,
|
|
77
86
|
params: b.params.map((p) => ({ name: p.name, type: p.type, description: p.description, required: p.required, default: p.default })),
|
|
78
87
|
}));
|
|
79
|
-
|
|
88
|
+
// Which generator? --preset always wins and stays offline and deterministic;
|
|
89
|
+
// then an injected LLM runner (tests); then Jev when a TYPESAFE_API_KEY is
|
|
90
|
+
// around and there is a description to judge; then a configured LLM
|
|
91
|
+
// provider; and with none of those, the "safe" preset with a hint instead
|
|
92
|
+
// of a provider-setup error (#116, #129).
|
|
93
|
+
const typesafeKey = resolveTypesafeApiKey(projectDir);
|
|
94
|
+
const registryBlocks = registry.list();
|
|
95
|
+
let harness;
|
|
96
|
+
if (preset) {
|
|
97
|
+
harness = buildPresetHarness(preset, facts, { description: description || undefined });
|
|
98
|
+
console.log(`preset: ${preset}`);
|
|
99
|
+
}
|
|
100
|
+
else if (options.nlRunner) {
|
|
101
|
+
harness = await generateHarnessConfig(description, options.nlRunner, catalogBlocks, facts);
|
|
102
|
+
}
|
|
103
|
+
else if (typesafeKey && description) {
|
|
104
|
+
try {
|
|
105
|
+
const result = await chooseWithJev({ description, facts, blocks: registryBlocks }, { apiKey: typesafeKey });
|
|
106
|
+
const applied = harnessFromChoices(result, facts, { description });
|
|
107
|
+
const on = [...result.blocks].filter(([, v]) => v === "on").map(([k]) => k);
|
|
108
|
+
const off = [...result.blocks].filter(([, v]) => v === "off").map(([k]) => k);
|
|
109
|
+
const undecided = [...result.blocks].filter(([, v]) => v === "undecided").map(([k]) => k);
|
|
110
|
+
console.log(`Jev (${TYPESAFE_MODEL}) chose: strictness=${result.strictness}, ${result.usage?.input_tokens ?? "?"} input tokens`);
|
|
111
|
+
console.log(` enabled: ${on.join(", ") || "none"}`);
|
|
112
|
+
console.log(` disabled: ${off.join(", ") || "none"}`);
|
|
113
|
+
if (undecided.length)
|
|
114
|
+
console.log(` undecided: ${undecided.join(", ")} (kept as the preset has them)`);
|
|
115
|
+
for (const s of applied.skipped)
|
|
116
|
+
console.log(` skipped: ${s.reason}`);
|
|
117
|
+
const { skipped: _skipped, ...rest } = applied;
|
|
118
|
+
harness = rest;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
console.log(`TypeSafe chooser unavailable (${err.message}); using the "safe" preset instead.`);
|
|
122
|
+
harness = buildPresetHarness("safe", facts, { description });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if ((await hasProviderConfig()) || providerConfigFromEnv()) {
|
|
126
|
+
harness = await generateHarnessConfig(description, options.nlRunner, catalogBlocks, facts);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
harness = buildPresetHarness("safe", facts, { description });
|
|
130
|
+
console.log('No AI provider and no TYPESAFE_API_KEY found; using the "safe" preset.');
|
|
131
|
+
console.log(` Pick one explicitly with --preset ${PRESET_NAMES.join("|")}, set TYPESAFE_API_KEY to let Jev tune it, or run \`omh config\` for an LLM provider.`);
|
|
132
|
+
}
|
|
80
133
|
const stackNames = harness.project.stacks.map((s) => `${s.name} (${s.framework})`).join(", ");
|
|
81
134
|
console.log(`\nStacks: ${stackNames}`);
|
|
82
135
|
console.log(`Rules: ${harness.rules.length}`);
|
|
@@ -6,7 +6,7 @@ import yaml from "js-yaml";
|
|
|
6
6
|
import { getRegisteredHooks, generateBlockTestCases, runTestCase } from "../harness-tester.js";
|
|
7
7
|
import { checkHarnessCommands } from "../command-checker.js";
|
|
8
8
|
import { HarnessConfigSchema } from "../../core/harness-schema.js";
|
|
9
|
-
import {
|
|
9
|
+
import { effectiveHookEntries } from "../../core/harness-converter-v2.js";
|
|
10
10
|
import { builtinBlocks } from "../../catalog/blocks/index.js";
|
|
11
11
|
export function formatCategoryName(category) {
|
|
12
12
|
const names = {
|
|
@@ -38,7 +38,7 @@ export async function testCommand(options = {}) {
|
|
|
38
38
|
const result = HarnessConfigSchema.safeParse(parsed);
|
|
39
39
|
if (result.success) {
|
|
40
40
|
// Merge enforcement-derived hooks with explicit hooks
|
|
41
|
-
hookEntries =
|
|
41
|
+
hookEntries = effectiveHookEntries(result.data, { has: (id) => builtinBlocks.some((b) => b.id === id) });
|
|
42
42
|
}
|
|
43
43
|
else {
|
|
44
44
|
console.log(chalk.yellow(`Warning: harness.yaml schema validation failed: ${result.error.message}`));
|
|
@@ -85,8 +85,23 @@ export async function testCommand(options = {}) {
|
|
|
85
85
|
catch {
|
|
86
86
|
// git 없으면 undefined
|
|
87
87
|
}
|
|
88
|
-
// Generate test cases from block-based hooks only
|
|
89
|
-
|
|
88
|
+
// Generate test cases from block-based hooks only. A case whose hook script
|
|
89
|
+
// is not on disk (harness.yaml ahead of `omh sync`) is reported as skipped
|
|
90
|
+
// rather than failed, so the dry run stays about hook behaviour.
|
|
91
|
+
const allCases = generateBlockTestCases(hookEntries, builtinBlocks, currentBranch, hooks);
|
|
92
|
+
const testCases = [];
|
|
93
|
+
const missingScripts = new Set();
|
|
94
|
+
for (const tc of allCases) {
|
|
95
|
+
try {
|
|
96
|
+
await fs.access(path.isAbsolute(tc.hookScript) ? tc.hookScript : path.join(projectDir, tc.hookScript));
|
|
97
|
+
testCases.push(tc);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
missingScripts.add(tc.hookScript);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const s of missingScripts)
|
|
104
|
+
console.log(chalk.dim(` skipped: ${s} not found (run \`omh sync\`)`));
|
|
90
105
|
const results = [];
|
|
91
106
|
// 카테고리별 그룹핑
|
|
92
107
|
const categories = [...new Set(testCases.map((tc) => tc.category))];
|
|
@@ -213,18 +213,22 @@ export function generateBlockTestCases(hookEntries, blocks, currentBranch, regis
|
|
|
213
213
|
break;
|
|
214
214
|
}
|
|
215
215
|
case "force-push-guard": {
|
|
216
|
+
const protectedBranches = params.protected ?? [];
|
|
217
|
+
const target = protectedBranches[0];
|
|
218
|
+
if (target) {
|
|
219
|
+
cases.push({
|
|
220
|
+
name: `"git push --force origin ${target}" → BLOCKED`,
|
|
221
|
+
category: "force-push-guard",
|
|
222
|
+
hookScript,
|
|
223
|
+
input: { tool_name: "Bash", tool_input: { command: `git push --force origin ${target}` } },
|
|
224
|
+
expectation: "block",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
216
227
|
cases.push({
|
|
217
|
-
name:
|
|
218
|
-
category: "force-push-guard",
|
|
219
|
-
hookScript,
|
|
220
|
-
input: { tool_name: "Bash", tool_input: { command: "git push --force origin main" } },
|
|
221
|
-
expectation: "block",
|
|
222
|
-
});
|
|
223
|
-
cases.push({
|
|
224
|
-
name: '"git push origin main" → ALLOWED',
|
|
228
|
+
name: `"git push origin ${target ?? "main"}" → ALLOWED`,
|
|
225
229
|
category: "force-push-guard",
|
|
226
230
|
hookScript,
|
|
227
|
-
input: { tool_name: "Bash", tool_input: { command:
|
|
231
|
+
input: { tool_name: "Bash", tool_input: { command: `git push origin ${target ?? "main"}` } },
|
|
228
232
|
expectation: "allow",
|
|
229
233
|
});
|
|
230
234
|
break;
|
package/dist/cli/index.js
CHANGED
|
@@ -24,9 +24,16 @@ export function createCli() {
|
|
|
24
24
|
.command("init [description...]")
|
|
25
25
|
.description("Initialize harness from a project description")
|
|
26
26
|
.option("-y, --yes", "Skip confirmation prompts")
|
|
27
|
+
.option("--preset <name>", "Deterministic setup with no AI provider: minimal | safe | strict (Jev tunes it when TYPESAFE_API_KEY is set)")
|
|
27
28
|
.action(async (description, options) => {
|
|
28
29
|
const { initCommand } = await import("./commands/init.js");
|
|
29
|
-
|
|
30
|
+
try {
|
|
31
|
+
await initCommand(description, options);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.error(`error: ${err.message}`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
30
37
|
});
|
|
31
38
|
program
|
|
32
39
|
.command("doctor")
|
package/dist/cli/stats/data.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readEvents, aggregateStats } from "../event-logger.js";
|
|
2
2
|
import { HarnessConfigSchema } from "../../core/harness-schema.js";
|
|
3
|
+
import { effectiveHookEntries } from "../../core/harness-converter-v2.js";
|
|
3
4
|
import { builtinBlocks } from "../../catalog/blocks/index.js";
|
|
4
5
|
import fs from "node:fs/promises";
|
|
5
6
|
import path from "node:path";
|
|
@@ -107,7 +108,7 @@ export async function loadStatsData(projectDir, dateRange = "all") {
|
|
|
107
108
|
const parsed = yaml.load(raw);
|
|
108
109
|
const result = HarnessConfigSchema.safeParse(parsed);
|
|
109
110
|
if (result.success) {
|
|
110
|
-
hookEntries = result.data.
|
|
111
|
+
hookEntries = effectiveHookEntries(result.data, { has: (id) => builtinBlocks.some((b) => b.id === id) });
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
catch {
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { DepCheck } from "../deps-checker.js";
|
|
2
2
|
import type { HarnessConfig } from "../../core/harness-schema.js";
|
|
3
3
|
import type { ProjectFacts } from "../../detector/types.js";
|
|
4
|
+
export type ModeValue = "nl" | "preset" | "import";
|
|
5
|
+
/** Mode menu for the TUI; the AI entry says what will actually run (QA). */
|
|
6
|
+
export declare function buildModeOptions(ctx: {
|
|
7
|
+
claudeInstalled: boolean;
|
|
8
|
+
providerConfigured: boolean;
|
|
9
|
+
jevKey: boolean;
|
|
10
|
+
}): Array<{
|
|
11
|
+
value: ModeValue;
|
|
12
|
+
label: string;
|
|
13
|
+
hint?: string;
|
|
14
|
+
}>;
|
|
4
15
|
export declare function formatDepResults(deps: DepCheck[]): string;
|
|
5
16
|
export declare function formatConfigSummary(config: HarnessConfig): string;
|
|
6
17
|
export declare function formatProjectFacts(facts: ProjectFacts): string;
|
|
@@ -7,13 +7,28 @@ import { checkDependencies } from "../deps-checker.js";
|
|
|
7
7
|
import { checkReferencedTools } from "../tool-checker.js";
|
|
8
8
|
import { writeHarnessState } from "../commands/init.js";
|
|
9
9
|
import { generate } from "../../core/generator.js";
|
|
10
|
-
import { generateHarnessConfig, createDefaultRunner } from "../../nl/parse-intent.js";
|
|
10
|
+
import { generateHarnessConfig, createDefaultRunner, providerConfigFromEnv } from "../../nl/parse-intent.js";
|
|
11
|
+
import { buildPresetHarness, PRESET_NAMES } from "../../core/presets.js";
|
|
12
|
+
import { chooseWithJev, harnessFromChoices, resolveTypesafeApiKey, TYPESAFE_MODEL } from "../../nl/typesafe-chooser.js";
|
|
11
13
|
import { hasProviderConfig } from "../../nl/config-store.js";
|
|
12
14
|
import { runProviderSetup } from "./provider-setup.js";
|
|
13
15
|
import { mergeEnforcementAndHooks } from "../../core/harness-converter-v2.js";
|
|
14
16
|
import { HarnessConfigSchema } from "../../core/harness-schema.js";
|
|
15
17
|
import { detectProject } from "../../detector/project-detector.js";
|
|
16
18
|
import { hasStarPromptBeenShown, markStarPromptShown, starRepo } from "../github-star.js";
|
|
19
|
+
/** Mode menu for the TUI; the AI entry says what will actually run (QA). */
|
|
20
|
+
export function buildModeOptions(ctx) {
|
|
21
|
+
const nl = ctx.jevKey
|
|
22
|
+
? { value: "nl", label: "Describe your project (Jev picks the blocks, no LLM)" }
|
|
23
|
+
: ctx.providerConfigured || ctx.claudeInstalled
|
|
24
|
+
? { value: "nl", label: "Describe your project (AI-powered)" }
|
|
25
|
+
: { value: "nl", label: "Describe your project (AI-powered)", hint: "needs an AI provider or TYPESAFE_API_KEY" };
|
|
26
|
+
return [
|
|
27
|
+
nl,
|
|
28
|
+
{ value: "preset", label: "Use a preset (no AI): minimal | safe | strict" },
|
|
29
|
+
{ value: "import", label: "Import existing harness.yaml" },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
17
32
|
export function formatDepResults(deps) {
|
|
18
33
|
if (deps.length === 0)
|
|
19
34
|
return "";
|
|
@@ -129,32 +144,41 @@ export async function runInitTUI(options) {
|
|
|
129
144
|
}
|
|
130
145
|
if (projectFacts)
|
|
131
146
|
p.note(formatProjectFacts(projectFacts), "Detected Project");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{ value: "import", label: "Import existing harness.yaml" },
|
|
137
|
-
];
|
|
147
|
+
// Mode Selection
|
|
148
|
+
const jevKey = resolveTypesafeApiKey(projectDir);
|
|
149
|
+
const providerConfigured = (await hasProviderConfig()) || Boolean(providerConfigFromEnv());
|
|
150
|
+
const modeOptions = buildModeOptions({ claudeInstalled, providerConfigured, jevKey: Boolean(jevKey) });
|
|
138
151
|
const mode = await p.select({ message: "How would you like to configure your harness?", options: modeOptions });
|
|
139
152
|
handleCancel(mode);
|
|
140
|
-
if (mode === "nl" && !claudeInstalled) {
|
|
141
|
-
p.log.error("claude CLI is required for AI-powered mode. Install it with:");
|
|
142
|
-
p.log.info(" npm install -g @anthropic-ai/claude-code");
|
|
143
|
-
p.cancel("Cannot proceed without claude CLI.");
|
|
144
|
-
process.exit(1);
|
|
145
|
-
}
|
|
146
153
|
let harnessConfig;
|
|
147
|
-
if (mode === "
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
if (mode === "preset") {
|
|
155
|
+
const preset = await p.select({
|
|
156
|
+
message: "Which preset?",
|
|
157
|
+
options: [
|
|
158
|
+
{ value: "safe", label: "safe", hint: "commit gates, lockfile/secret guards, lint on save" },
|
|
159
|
+
{ value: "strict", label: "strict", hint: "safe + test-first (TDD) on every source edit" },
|
|
160
|
+
{ value: "minimal", label: "minimal", hint: "dangerous commands, main branch, build output only" },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
handleCancel(preset);
|
|
164
|
+
harnessConfig = buildPresetHarness(preset, projectFacts);
|
|
165
|
+
p.note(formatConfigSummary(harnessConfig), `Preset: ${preset}`);
|
|
166
|
+
const confirmed = await p.confirm({ message: "Proceed with this configuration?", initialValue: true });
|
|
167
|
+
handleCancel(confirmed);
|
|
168
|
+
if (!confirmed) {
|
|
169
|
+
p.cancel("Aborted.");
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else if (mode === "nl") {
|
|
174
|
+
if (!jevKey && !providerConfigured && !claudeInstalled) {
|
|
175
|
+
p.log.info("No AI provider configured yet. Let's set one up (or pick a preset instead).");
|
|
151
176
|
const providerConfig = await runProviderSetup();
|
|
152
177
|
if (!providerConfig) {
|
|
153
178
|
p.cancel("Provider setup cancelled.");
|
|
154
179
|
process.exit(0);
|
|
155
180
|
}
|
|
156
181
|
}
|
|
157
|
-
const runner = await createDefaultRunner();
|
|
158
182
|
const description = await p.text({
|
|
159
183
|
message: "Describe your project:",
|
|
160
184
|
placeholder: "e.g., Next.js e-commerce app with Stripe and Tailwind",
|
|
@@ -163,21 +187,34 @@ export async function runInitTUI(options) {
|
|
|
163
187
|
});
|
|
164
188
|
handleCancel(description);
|
|
165
189
|
const genSpinner = p.spinner();
|
|
166
|
-
genSpinner.start("Generating harness configuration...");
|
|
190
|
+
genSpinner.start(jevKey ? `Asking Jev (${TYPESAFE_MODEL}) which blocks fit...` : "Generating harness configuration...");
|
|
167
191
|
try {
|
|
168
192
|
const { createDefaultRegistry } = await import("../../catalog/registry.js");
|
|
169
193
|
const catalogRegistry = await createDefaultRegistry();
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
description:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
194
|
+
if (jevKey) {
|
|
195
|
+
const result = await chooseWithJev({ description: description, facts: projectFacts, blocks: catalogRegistry.list() }, { apiKey: jevKey });
|
|
196
|
+
const applied = harnessFromChoices(result, projectFacts, { description: description });
|
|
197
|
+
const { skipped, ...rest } = applied;
|
|
198
|
+
harnessConfig = rest;
|
|
199
|
+
genSpinner.stop(`Jev chose strictness=${result.strictness} (${result.usage?.input_tokens ?? "?"} input tokens)`);
|
|
200
|
+
for (const s of skipped)
|
|
201
|
+
p.log.warn(`skipped: ${s.reason}`);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const runner = await createDefaultRunner();
|
|
205
|
+
const catalogBlocks = catalogRegistry.list().map((b) => ({
|
|
206
|
+
id: b.id,
|
|
207
|
+
description: b.description,
|
|
208
|
+
params: b.params.map((pp) => ({ name: pp.name, required: pp.required, default: pp.default, description: pp.description })),
|
|
209
|
+
}));
|
|
210
|
+
harnessConfig = await generateHarnessConfig(description, runner, catalogBlocks, projectFacts);
|
|
211
|
+
genSpinner.stop("Configuration generated");
|
|
212
|
+
}
|
|
177
213
|
}
|
|
178
214
|
catch (err) {
|
|
179
215
|
genSpinner.stop("Generation failed");
|
|
180
216
|
p.log.error(`Failed to generate config: ${err.message}`);
|
|
217
|
+
p.log.info(`Tip: \`omh init --preset ${PRESET_NAMES.join("|")}\` needs no provider.`);
|
|
181
218
|
p.cancel("Try again.");
|
|
182
219
|
process.exit(1);
|
|
183
220
|
}
|
|
@@ -2,5 +2,15 @@ import type { HarnessConfig } from "./harness-schema.js";
|
|
|
2
2
|
import type { MergedConfig } from "./merged-config.js";
|
|
3
3
|
import type { CatalogRegistry } from "../catalog/registry.js";
|
|
4
4
|
import type { HookEntry } from "../catalog/types.js";
|
|
5
|
+
export declare const ALWAYS_ON_GUARDS: readonly ["harness-guard", "no-verify-guard", "force-push-guard"];
|
|
6
|
+
/**
|
|
7
|
+
* The hook entries a harness really runs: enforcement-derived + explicit +
|
|
8
|
+
* loop-guard (when the loop is on) + the always-on guards, in the order the
|
|
9
|
+
* generator emits them. `omh test` and `omh stats` use this so they see the
|
|
10
|
+
* same set (QA: they used to miss the always-on guards).
|
|
11
|
+
*/
|
|
12
|
+
export declare function effectiveHookEntries(harness: HarnessConfig, registry: {
|
|
13
|
+
has(id: string): boolean;
|
|
14
|
+
}): HookEntry[];
|
|
5
15
|
export declare function mergeEnforcementAndHooks(harness: HarnessConfig): HookEntry[];
|
|
6
16
|
export declare function harnessToMergedConfigV2(harness: HarnessConfig, registry?: CatalogRegistry, projectDir?: string): Promise<MergedConfig>;
|
|
@@ -55,6 +55,48 @@ function convertEnforcementToHooks(enforcement) {
|
|
|
55
55
|
}
|
|
56
56
|
return hooks;
|
|
57
57
|
}
|
|
58
|
+
// A harness that enforces anything also guards itself: without this an agent
|
|
59
|
+
// could delete or rewrite the hooks through Bash (#113), skip git hooks or
|
|
60
|
+
// force-push main (#98, #99, #114). An explicit entry keeps its mode/params;
|
|
61
|
+
// custom registries without the block are skipped.
|
|
62
|
+
export const ALWAYS_ON_GUARDS = ["harness-guard", "no-verify-guard", "force-push-guard"];
|
|
63
|
+
function addAlwaysOnGuards(entries, registry) {
|
|
64
|
+
if (entries.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
for (const id of ALWAYS_ON_GUARDS) {
|
|
67
|
+
if (registry.has(id) && !entries.some((h) => h.block === id)) {
|
|
68
|
+
entries.push({ block: id, params: {}, mode: "block" });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// The loop engine brings its guard along. An explicit loop-guard entry may
|
|
73
|
+
// choose its mode, but the paths it protects always come from the loop
|
|
74
|
+
// config — otherwise an empty explicit entry would silently unguard the
|
|
75
|
+
// real work-order directory.
|
|
76
|
+
function addLoopGuard(entries, harness) {
|
|
77
|
+
if (!harness.loop?.enabled)
|
|
78
|
+
return;
|
|
79
|
+
const loopParams = { workOrders: harness.loop.workOrders, architectOnly: harness.loop.architectOnly };
|
|
80
|
+
const explicit = entries.find((h) => h.block === "loop-guard");
|
|
81
|
+
if (explicit) {
|
|
82
|
+
explicit.params = { ...explicit.params, ...loopParams };
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
entries.push({ block: "loop-guard", params: loopParams, mode: "block" });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The hook entries a harness really runs: enforcement-derived + explicit +
|
|
90
|
+
* loop-guard (when the loop is on) + the always-on guards, in the order the
|
|
91
|
+
* generator emits them. `omh test` and `omh stats` use this so they see the
|
|
92
|
+
* same set (QA: they used to miss the always-on guards).
|
|
93
|
+
*/
|
|
94
|
+
export function effectiveHookEntries(harness, registry) {
|
|
95
|
+
const entries = mergeEnforcementAndHooks(harness);
|
|
96
|
+
addLoopGuard(entries, harness);
|
|
97
|
+
addAlwaysOnGuards(entries, registry);
|
|
98
|
+
return entries;
|
|
99
|
+
}
|
|
58
100
|
export function mergeEnforcementAndHooks(harness) {
|
|
59
101
|
const enforcementHooks = convertEnforcementToHooks(harness.enforcement);
|
|
60
102
|
const explicitHooks = harness.hooks ?? [];
|
|
@@ -75,38 +117,14 @@ export async function harnessToMergedConfigV2(harness, registry, projectDir) {
|
|
|
75
117
|
const base = harnessToMergedConfig(harness);
|
|
76
118
|
// Merge enforcement-derived hooks with explicit hooks (dedup by block id)
|
|
77
119
|
const allHookEntries = mergeEnforcementAndHooks(harness);
|
|
78
|
-
|
|
79
|
-
// choose its mode, but the paths it protects always come from the loop
|
|
80
|
-
// config — otherwise an empty explicit entry would silently unguard the
|
|
81
|
-
// real work-order directory.
|
|
82
|
-
if (harness.loop?.enabled) {
|
|
83
|
-
const loopParams = { workOrders: harness.loop.workOrders, architectOnly: harness.loop.architectOnly };
|
|
84
|
-
const explicit = allHookEntries.find((h) => h.block === "loop-guard");
|
|
85
|
-
if (explicit) {
|
|
86
|
-
explicit.params = { ...explicit.params, ...loopParams };
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
allHookEntries.push({ block: "loop-guard", params: loopParams, mode: "block" });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
120
|
+
addLoopGuard(allHookEntries, harness);
|
|
92
121
|
// If no hook entries at all, return base config unchanged
|
|
93
122
|
if (allHookEntries.length === 0) {
|
|
94
123
|
return { ...base };
|
|
95
124
|
}
|
|
96
125
|
// Resolve registry — use provided one or create the default
|
|
97
126
|
const resolvedRegistry = registry ?? (await createDefaultRegistry());
|
|
98
|
-
|
|
99
|
-
// agent could delete or rewrite the hooks through Bash (#113). An explicit
|
|
100
|
-
// entry may change its mode or add paths. Skipped for custom registries
|
|
101
|
-
// that do not ship the block.
|
|
102
|
-
// Same rule for the git-safety pair (#114): a harness that enforces anything
|
|
103
|
-
// gets no-verify-guard and force-push-guard on every runtime, instead of the
|
|
104
|
-
// Claude-only permissions.deny list the NL prompt sometimes emitted.
|
|
105
|
-
for (const id of ["harness-guard", "no-verify-guard", "force-push-guard"]) {
|
|
106
|
-
if (resolvedRegistry.has(id) && !allHookEntries.some((h) => h.block === id)) {
|
|
107
|
-
allHookEntries.push({ block: id, params: {}, mode: "block" });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
127
|
+
addAlwaysOnGuards(allHookEntries, resolvedRegistry);
|
|
110
128
|
const catalogResult = await convertHookEntries(allHookEntries, resolvedRegistry, projectDir ?? ".");
|
|
111
129
|
// Convert hooksConfig entries from catalog into HookDefinition format.
|
|
112
130
|
// Errors are reported as warnings but don't block valid hooks.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type HarnessConfig } from "./harness-schema.js";
|
|
2
|
+
import type { ProjectFacts } from "../detector/types.js";
|
|
3
|
+
export type PresetName = "minimal" | "safe" | "strict";
|
|
4
|
+
export declare const PRESET_NAMES: PresetName[];
|
|
5
|
+
export declare function isPresetName(v: string): v is PresetName;
|
|
6
|
+
type Params = Record<string, unknown>;
|
|
7
|
+
/**
|
|
8
|
+
* Params for a block, filled from the detector facts. `null` means a required
|
|
9
|
+
* param has no source and the block must not be enabled. `{}` means the
|
|
10
|
+
* catalog defaults are enough.
|
|
11
|
+
*/
|
|
12
|
+
export declare function defaultParamsFor(blockId: string, facts?: ProjectFacts): Params | null;
|
|
13
|
+
export declare function buildPresetHarness(preset: PresetName, facts?: ProjectFacts, meta?: {
|
|
14
|
+
name?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
}): HarnessConfig;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { HarnessConfigSchema } from "./harness-schema.js";
|
|
2
|
+
export const PRESET_NAMES = ["minimal", "safe", "strict"];
|
|
3
|
+
export function isPresetName(v) {
|
|
4
|
+
return PRESET_NAMES.includes(v);
|
|
5
|
+
}
|
|
6
|
+
// Per-language file patterns for the blocks that need them.
|
|
7
|
+
const LANG = {
|
|
8
|
+
typescript: { src: "\\.(ts|tsx|js|jsx)$", test: "\\.(test|spec)\\.(ts|tsx|js|jsx)$", glob: "*.ts", tddDefault: true },
|
|
9
|
+
javascript: { src: "\\.(ts|tsx|js|jsx)$", test: "\\.(test|spec)\\.(ts|tsx|js|jsx)$", glob: "*.js", tddDefault: true },
|
|
10
|
+
python: { src: "\\.py$", test: "(^|/)(test_[^/]*|[^/]*_test)\\.py$", glob: "*.py" },
|
|
11
|
+
go: { src: "\\.go$", test: "_test\\.go$", glob: "*.go" },
|
|
12
|
+
rust: { src: "\\.rs$", test: "(tests/.*|_test)\\.rs$", glob: "*.rs" },
|
|
13
|
+
swift: { src: "\\.swift$", test: "Tests?\\.swift$", glob: "*.swift" },
|
|
14
|
+
kotlin: { src: "\\.kt$", test: "Test\\.kt$", glob: "*.kt" },
|
|
15
|
+
java: { src: "\\.java$", test: "Test\\.java$", glob: "*.java" },
|
|
16
|
+
ruby: { src: "\\.rb$", test: "_spec\\.rb$", glob: "*.rb" },
|
|
17
|
+
php: { src: "\\.php$", test: "Test\\.php$", glob: "*.php" },
|
|
18
|
+
dart: { src: "\\.dart$", test: "_test\\.dart$", glob: "*.dart" },
|
|
19
|
+
elixir: { src: "\\.exs?$", test: "_test\\.exs$", glob: "*.ex" },
|
|
20
|
+
scala: { src: "\\.scala$", test: "(Spec|Test)\\.scala$", glob: "*.scala" },
|
|
21
|
+
};
|
|
22
|
+
function lang(facts) {
|
|
23
|
+
const l = facts?.languages?.[0]?.toLowerCase() ?? "";
|
|
24
|
+
return LANG[l] ?? LANG.typescript;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Params for a block, filled from the detector facts. `null` means a required
|
|
28
|
+
* param has no source and the block must not be enabled. `{}` means the
|
|
29
|
+
* catalog defaults are enough.
|
|
30
|
+
*/
|
|
31
|
+
export function defaultParamsFor(blockId, facts) {
|
|
32
|
+
const l = lang(facts);
|
|
33
|
+
switch (blockId) {
|
|
34
|
+
case "commit-test-gate":
|
|
35
|
+
return facts?.testCommands?.[0] ? { testCommand: facts.testCommands[0] } : null;
|
|
36
|
+
case "commit-typecheck-gate":
|
|
37
|
+
return facts?.typecheckCommands?.[0] ? { typecheckCommand: facts.typecheckCommands[0] } : null;
|
|
38
|
+
case "lint-on-save":
|
|
39
|
+
return facts?.lintCommands?.[0] ? { filePattern: l.glob, command: facts.lintCommands[0] } : null;
|
|
40
|
+
case "test-on-save":
|
|
41
|
+
return facts?.testCommands?.[0] ? { testCommand: facts.testCommands[0], filePattern: l.src } : null;
|
|
42
|
+
case "format-on-save":
|
|
43
|
+
return null; // no formatter detection yet; ponytail: add when the detector learns formatters
|
|
44
|
+
case "path-guard":
|
|
45
|
+
return facts?.blockedPaths?.length ? { blockedPaths: [...facts.blockedPaths] } : null;
|
|
46
|
+
case "tdd-guard":
|
|
47
|
+
return l.tddDefault ? {} : { srcPattern: l.src, testPattern: l.test };
|
|
48
|
+
default:
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const HOOKS_BY_PRESET = {
|
|
53
|
+
minimal: ["branch-guard", "command-guard", "path-guard"],
|
|
54
|
+
safe: ["branch-guard", "command-guard", "path-guard", "commit-test-gate", "commit-typecheck-gate", "lockfile-guard", "secret-file-guard", "lint-on-save"],
|
|
55
|
+
strict: ["branch-guard", "command-guard", "path-guard", "commit-test-gate", "commit-typecheck-gate", "lockfile-guard", "secret-file-guard", "lint-on-save", "tdd-guard"],
|
|
56
|
+
};
|
|
57
|
+
const RULES = {
|
|
58
|
+
workflow: (preset) => ({
|
|
59
|
+
id: "preset-workflow",
|
|
60
|
+
title: "Development Workflow",
|
|
61
|
+
priority: 10,
|
|
62
|
+
content: preset === "strict"
|
|
63
|
+
? [
|
|
64
|
+
"## TDD Development Rules",
|
|
65
|
+
"- Always write or update tests before changing source code.",
|
|
66
|
+
"- Confirm tests fail for the expected reason before implementing the minimum code change.",
|
|
67
|
+
"- Every source file must have a corresponding test file.",
|
|
68
|
+
"- Do not modify source code without test coverage for the behavior being changed.",
|
|
69
|
+
].join("\n")
|
|
70
|
+
: [
|
|
71
|
+
"## Testing Rules",
|
|
72
|
+
"- Run the test suite before every commit; a red suite is never committed.",
|
|
73
|
+
"- Add or update tests alongside any behavior change.",
|
|
74
|
+
].join("\n"),
|
|
75
|
+
}),
|
|
76
|
+
branching: {
|
|
77
|
+
id: "preset-branching",
|
|
78
|
+
title: "Branch Workflow",
|
|
79
|
+
priority: 20,
|
|
80
|
+
content: [
|
|
81
|
+
"## Branch Workflow Rules",
|
|
82
|
+
"- Always work on a dedicated branch; never commit directly on main or master.",
|
|
83
|
+
"- Never force-push to a shared branch and never bypass git hooks.",
|
|
84
|
+
"- Commit work in logical task-sized units.",
|
|
85
|
+
].join("\n"),
|
|
86
|
+
},
|
|
87
|
+
quality: {
|
|
88
|
+
id: "preset-quality",
|
|
89
|
+
title: "Quality Gates",
|
|
90
|
+
priority: 30,
|
|
91
|
+
content: [
|
|
92
|
+
"## Quality Rules",
|
|
93
|
+
"- Lint and type errors must be fixed before completing work.",
|
|
94
|
+
"- Do not write into build output, dependency directories or lockfiles by hand.",
|
|
95
|
+
"- Never touch secrets or credential files.",
|
|
96
|
+
].join("\n"),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
export function buildPresetHarness(preset, facts, meta = {}) {
|
|
100
|
+
const hooks = [];
|
|
101
|
+
for (const block of HOOKS_BY_PRESET[preset]) {
|
|
102
|
+
const params = defaultParamsFor(block, facts);
|
|
103
|
+
if (params === null)
|
|
104
|
+
continue;
|
|
105
|
+
hooks.push({ block, params, mode: "block" });
|
|
106
|
+
}
|
|
107
|
+
const stacks = (facts?.languages ?? []).map((language, i) => ({
|
|
108
|
+
name: language,
|
|
109
|
+
framework: facts?.frameworks?.[i] ?? facts?.frameworks?.[0] ?? "none",
|
|
110
|
+
language,
|
|
111
|
+
packageManager: facts?.packageManagers?.[i] ?? facts?.packageManagers?.[0],
|
|
112
|
+
testRunner: facts?.testCommands?.[0],
|
|
113
|
+
linter: facts?.lintCommands?.[0],
|
|
114
|
+
}));
|
|
115
|
+
const rules = preset === "minimal"
|
|
116
|
+
? [RULES.branching, RULES.quality]
|
|
117
|
+
: [RULES.workflow(preset), RULES.branching, RULES.quality];
|
|
118
|
+
// Schema defaults (loop, permissions, enforcement) come from the schema itself.
|
|
119
|
+
return HarnessConfigSchema.parse({
|
|
120
|
+
version: "1.0",
|
|
121
|
+
project: { name: meta.name, description: meta.description, stacks },
|
|
122
|
+
rules,
|
|
123
|
+
hooks,
|
|
124
|
+
});
|
|
125
|
+
}
|
package/dist/generators/hooks.js
CHANGED
|
@@ -114,10 +114,13 @@ function flush(d, t) {
|
|
|
114
114
|
const OMH_CMD_UNWRAP_AWK = String.raw `
|
|
115
115
|
BEGIN { FS = "\t" }
|
|
116
116
|
{
|
|
117
|
-
i = 1
|
|
117
|
+
i = 1; envp = ""
|
|
118
118
|
while (i <= NF) {
|
|
119
|
-
|
|
119
|
+
# NAME=value words are kept in the output (guards such as no-verify-guard
|
|
120
|
+
# inspect GIT_CONFIG_*); every argv0 matcher skips them.
|
|
121
|
+
while (i <= NF && $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/) { envp = envp $i "\t"; i++ }
|
|
120
122
|
if (i > NF) break
|
|
123
|
+
sub(/.*\//, "", $i) # /usr/bin/git -> git: argv0 matchers compare basenames
|
|
121
124
|
f = $i
|
|
122
125
|
if (f == "env") {
|
|
123
126
|
i++; while (i <= NF && $i ~ /^-/) { if ($i ~ /^-(u|C|S)$/) i++; i++ }; continue
|
|
@@ -137,20 +140,20 @@ BEGIN { FS = "\t" }
|
|
|
137
140
|
if (f == "sh" || f == "bash" || f == "zsh" || f == "dash" || f == "ksh") {
|
|
138
141
|
j = i + 1
|
|
139
142
|
while (j <= NF && $j ~ /^-/) {
|
|
140
|
-
if ($j ~ /^-[A-Za-z]*c$/) { if (j + 1 <= NF) { print "S\t" $(j + 1); next }; break }
|
|
143
|
+
if ($j ~ /^-[A-Za-z]*c$/) { if (j + 1 <= NF) { gsub(/\t/, " ", envp); print "S\t" envp $(j + 1); next }; break }
|
|
141
144
|
j++
|
|
142
145
|
}
|
|
143
146
|
break
|
|
144
147
|
}
|
|
145
148
|
if (f == "eval") {
|
|
146
149
|
out = ""; for (k = i + 1; k <= NF; k++) out = out (k > i + 1 ? " " : "") $k
|
|
147
|
-
print "S\t" out; next
|
|
150
|
+
gsub(/\t/, " ", envp); print "S\t" envp out; next
|
|
148
151
|
}
|
|
149
152
|
break
|
|
150
153
|
}
|
|
151
154
|
if (i > NF) next
|
|
152
155
|
out = ""; for (k = i; k <= NF; k++) out = out (k > i ? "\t" : "") $k
|
|
153
|
-
print "R\t" out
|
|
156
|
+
print "R\t" envp out
|
|
154
157
|
}`;
|
|
155
158
|
// Working-tree fingerprint for gates that want to skip a re-run when nothing
|
|
156
159
|
// changed since the last pass (#112): HEAD plus the tree hash of a temporary
|
|
@@ -169,7 +172,10 @@ const OMH_TREE_FINGERPRINT = `_omh_tree_fingerprint() {
|
|
|
169
172
|
# Always from the repository root, whatever directory the hook runs in.
|
|
170
173
|
# .omh/state is hook-owned scratch (events.jsonl grows on every hook run) and
|
|
171
174
|
# must never count as a change, whether or not the user gitignored it.
|
|
172
|
-
|
|
175
|
+
# (Naming .omh/state in an exclude pathspec makes git refuse when the path
|
|
176
|
+
# is gitignored, which every omh project does; so add everything, then drop
|
|
177
|
+
# it from the temporary index.)
|
|
178
|
+
tree=$(GIT_INDEX_FILE="$idx" git -C "$root" add -A -- . >/dev/null 2>&1 && { GIT_INDEX_FILE="$idx" git -C "$root" rm -r -q --cached --ignore-unmatch -- .omh/state >/dev/null 2>&1 || true; } && GIT_INDEX_FILE="$idx" git -C "$root" write-tree 2>/dev/null) || tree=none
|
|
173
179
|
rm -f "$idx"
|
|
174
180
|
[ "$tree" = "none" ] && { echo none; return 0; }
|
|
175
181
|
printf '%s:%s\\n' "$head" "$tree"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PresetInfo, CatalogBlockInfo } from "./prompt-templates.js";
|
|
2
2
|
import type { HarnessConfig } from "../core/harness-schema.js";
|
|
3
3
|
import type { ProjectFacts } from "../detector/project-detector.js";
|
|
4
|
+
import { type ProviderConfig } from "./config-store.js";
|
|
4
5
|
export interface ParsedIntent {
|
|
5
6
|
presets: string[];
|
|
6
7
|
confidence: number;
|
|
@@ -12,6 +13,8 @@ export type LLMRunner = (prompt: string) => Promise<string>;
|
|
|
12
13
|
export type ClaudeRunner = LLMRunner;
|
|
13
14
|
/** Creates a runner from the saved provider config (~/.omh/config.json) or falls back to claude CLI */
|
|
14
15
|
export declare function createDefaultRunner(): Promise<LLMRunner>;
|
|
16
|
+
/** CI-friendly fallback: pick an API provider from the usual env vars when nothing is saved. */
|
|
17
|
+
export declare function providerConfigFromEnv(): ProviderConfig | undefined;
|
|
15
18
|
/** Legacy default runner using claude CLI directly */
|
|
16
19
|
export declare const defaultClaudeRunner: LLMRunner;
|
|
17
20
|
export declare function parseNaturalLanguage(description: string, availablePresets: PresetInfo[], runner?: ClaudeRunner): Promise<ParsedIntent>;
|
package/dist/nl/parse-intent.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function createDefaultRunner() {
|
|
|
16
16
|
return (prompt) => cliProvider.run(prompt);
|
|
17
17
|
}
|
|
18
18
|
/** CI-friendly fallback: pick an API provider from the usual env vars when nothing is saved. */
|
|
19
|
-
function providerConfigFromEnv() {
|
|
19
|
+
export function providerConfigFromEnv() {
|
|
20
20
|
const key = (name) => process.env[name]?.trim() || undefined;
|
|
21
21
|
const candidates = [
|
|
22
22
|
["claude", key("ANTHROPIC_API_KEY")],
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
import { type PresetName } from "../core/presets.js";
|
|
5
|
+
export declare const TYPESAFE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
|
|
6
|
+
export declare const TYPESAFE_MODEL = "jev-latest";
|
|
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"];
|
|
9
|
+
export type Decision = "on" | "off" | "undecided";
|
|
10
|
+
export interface ChooserInput {
|
|
11
|
+
description: string;
|
|
12
|
+
facts?: ProjectFacts;
|
|
13
|
+
blocks: Pick<BuildingBlock, "id" | "description">[];
|
|
14
|
+
}
|
|
15
|
+
export interface JevRequest {
|
|
16
|
+
model: string;
|
|
17
|
+
state: Record<string, unknown>;
|
|
18
|
+
questions: Record<string, Record<string, unknown>>;
|
|
19
|
+
}
|
|
20
|
+
export interface ChooserResult {
|
|
21
|
+
strictness: PresetName;
|
|
22
|
+
blocks: Map<string, Decision>;
|
|
23
|
+
/** raw probabilities, for `omh init` to print */
|
|
24
|
+
probabilities?: Record<string, number>;
|
|
25
|
+
usage?: {
|
|
26
|
+
input_tokens: number;
|
|
27
|
+
output_tokens: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
type NoulAnswer = {
|
|
31
|
+
type: "noul";
|
|
32
|
+
noul: number;
|
|
33
|
+
};
|
|
34
|
+
type ChoiceAnswer = {
|
|
35
|
+
type: "choice";
|
|
36
|
+
choice: string;
|
|
37
|
+
confidence: number;
|
|
38
|
+
probabilities: Record<string, number>;
|
|
39
|
+
};
|
|
40
|
+
type Answer = NoulAnswer | ChoiceAnswer | {
|
|
41
|
+
type: string;
|
|
42
|
+
[k: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
export declare const THRESHOLDS: {
|
|
45
|
+
on: number;
|
|
46
|
+
off: number;
|
|
47
|
+
choiceConfidence: number;
|
|
48
|
+
};
|
|
49
|
+
export declare function buildJevRequest(input: ChooserInput): JevRequest;
|
|
50
|
+
export declare function routeAnswers(answers: Record<string, Answer>): Pick<ChooserResult, "strictness" | "blocks" | "probabilities">;
|
|
51
|
+
export declare function chooseWithJev(input: ChooserInput, opts: {
|
|
52
|
+
apiKey: string;
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
}): Promise<ChooserResult>;
|
|
56
|
+
export interface AppliedHarness extends HarnessConfig {
|
|
57
|
+
skipped: {
|
|
58
|
+
block: string;
|
|
59
|
+
reason: string;
|
|
60
|
+
}[];
|
|
61
|
+
}
|
|
62
|
+
/** Start from the preset named by the strictness choice (or `base`) and apply the per-block decisions. */
|
|
63
|
+
export declare function applyChoices(base: HarnessConfig, result: Pick<ChooserResult, "strictness" | "blocks">, facts?: ProjectFacts): AppliedHarness;
|
|
64
|
+
/** Preset to start from, then the chooser's decisions on top. */
|
|
65
|
+
export declare function harnessFromChoices(result: Pick<ChooserResult, "strictness" | "blocks">, facts: ProjectFacts | undefined, meta: {
|
|
66
|
+
description?: string;
|
|
67
|
+
}): AppliedHarness;
|
|
68
|
+
/** TYPESAFE_API_KEY from the environment, else from a .env file in the project directory. */
|
|
69
|
+
export declare function resolveTypesafeApiKey(projectDir: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
70
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { buildPresetHarness, defaultParamsFor } from "../core/presets.js";
|
|
4
|
+
// Jev (TypeSafe's System One model) as the catalog chooser (#129). It never
|
|
5
|
+
// generates text: we send the description plus detector facts as `state` and
|
|
6
|
+
// ask one yes/no ("noul") question per selectable block plus one "choice" for
|
|
7
|
+
// the overall strictness, in a single fan-out call. Answers are calibrated
|
|
8
|
+
// probabilities, so a confidence band decides on / off / leave-as-preset.
|
|
9
|
+
// Rule text and free-form params come from presets and the detector, never
|
|
10
|
+
// from the model. Jev is early access, so this is always optional: no key,
|
|
11
|
+
// no call.
|
|
12
|
+
export const TYPESAFE_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
|
|
13
|
+
export const TYPESAFE_MODEL = "jev-latest";
|
|
14
|
+
/** Blocks a project may or may not want. Always-on and loop-internal blocks are never asked. */
|
|
15
|
+
export const SELECTABLE_BLOCKS = [
|
|
16
|
+
"branch-guard", "commit-test-gate", "commit-typecheck-gate", "command-guard", "path-guard",
|
|
17
|
+
"lockfile-guard", "secret-file-guard", "tdd-guard", "sql-guard",
|
|
18
|
+
"lint-on-save", "format-on-save", "test-on-save", "auto-pr", "desktop-notify", "compact-context",
|
|
19
|
+
];
|
|
20
|
+
export const THRESHOLDS = { on: 0.65, off: 0.35, choiceConfidence: 0.5 };
|
|
21
|
+
export function buildJevRequest(input) {
|
|
22
|
+
const byId = new Map(input.blocks.map((b) => [b.id, b]));
|
|
23
|
+
const questions = {};
|
|
24
|
+
for (const id of SELECTABLE_BLOCKS) {
|
|
25
|
+
const b = byId.get(id);
|
|
26
|
+
if (!b)
|
|
27
|
+
continue;
|
|
28
|
+
questions[`block:${id}`] = {
|
|
29
|
+
type: "noul",
|
|
30
|
+
instructions: `Should the '${id}' guardrail be enabled for this project? It ${lowerFirst(b.description)}. ` +
|
|
31
|
+
"Enable it only if the user's description or the detected project facts call for it, " +
|
|
32
|
+
"or it is a sensible default that would not get in the user's way. " +
|
|
33
|
+
"If the user explicitly rejects it, the answer is no.",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
questions.strictness = {
|
|
37
|
+
type: "choice",
|
|
38
|
+
instructions: "How strict should the generated guardrails be overall, given the user's description?",
|
|
39
|
+
criteria: {
|
|
40
|
+
minimal: "only catastrophic actions are blocked (dangerous shell commands, direct commits to main, writes into build output)",
|
|
41
|
+
safe: "tests and typecheck must pass before commits, lockfiles and secret files are protected, lint runs on save; the workflow stays smooth",
|
|
42
|
+
strict: "test-first (TDD) is enforced on every source edit in addition to every quality gate",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const state = { user_description: input.description };
|
|
46
|
+
if (input.facts)
|
|
47
|
+
state.detected_project_facts = compactFacts(input.facts);
|
|
48
|
+
return { model: TYPESAFE_MODEL, state, questions };
|
|
49
|
+
}
|
|
50
|
+
export function routeAnswers(answers) {
|
|
51
|
+
const blocks = new Map();
|
|
52
|
+
const probabilities = {};
|
|
53
|
+
for (const [key, a] of Object.entries(answers)) {
|
|
54
|
+
if (!key.startsWith("block:") || a.type !== "noul")
|
|
55
|
+
continue;
|
|
56
|
+
const p = a.noul;
|
|
57
|
+
const id = key.slice("block:".length);
|
|
58
|
+
// Only ids we asked about, only well-formed probabilities: the response is
|
|
59
|
+
// external input and must not be able to enable an arbitrary block.
|
|
60
|
+
if (!SELECTABLE_BLOCKS.includes(id))
|
|
61
|
+
continue;
|
|
62
|
+
if (typeof p !== "number" || !Number.isFinite(p) || p < 0 || p > 1)
|
|
63
|
+
continue;
|
|
64
|
+
probabilities[id] = p;
|
|
65
|
+
blocks.set(id, p >= THRESHOLDS.on ? "on" : p <= THRESHOLDS.off ? "off" : "undecided");
|
|
66
|
+
}
|
|
67
|
+
let strictness = "safe";
|
|
68
|
+
const s = answers.strictness;
|
|
69
|
+
if (s && s.type === "choice" && s.confidence >= THRESHOLDS.choiceConfidence && ["minimal", "safe", "strict"].includes(s.choice)) {
|
|
70
|
+
strictness = s.choice;
|
|
71
|
+
}
|
|
72
|
+
return { strictness, blocks, probabilities };
|
|
73
|
+
}
|
|
74
|
+
export async function chooseWithJev(input, opts) {
|
|
75
|
+
const request = buildJevRequest(input);
|
|
76
|
+
const f = opts.fetchImpl ?? fetch;
|
|
77
|
+
const res = await f(TYPESAFE_ENDPOINT, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
80
|
+
body: JSON.stringify(request),
|
|
81
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const body = (await res.text().catch(() => "")).slice(0, 200);
|
|
85
|
+
throw new Error(`TypeSafe API error ${res.status}: ${body || res.statusText}`);
|
|
86
|
+
}
|
|
87
|
+
const json = (await res.json());
|
|
88
|
+
return { ...routeAnswers(json.answers ?? {}), usage: json.usage };
|
|
89
|
+
}
|
|
90
|
+
/** Start from the preset named by the strictness choice (or `base`) and apply the per-block decisions. */
|
|
91
|
+
export function applyChoices(base, result, facts) {
|
|
92
|
+
const hooks = base.hooks.map((h) => ({ ...h, params: { ...h.params } }));
|
|
93
|
+
const skipped = [];
|
|
94
|
+
for (const [id, decision] of result.blocks) {
|
|
95
|
+
const idx = hooks.findIndex((h) => h.block === id);
|
|
96
|
+
if (decision === "off") {
|
|
97
|
+
if (idx >= 0)
|
|
98
|
+
hooks.splice(idx, 1);
|
|
99
|
+
}
|
|
100
|
+
else if (decision === "on" && idx < 0) {
|
|
101
|
+
const params = defaultParamsFor(id, facts);
|
|
102
|
+
if (params === null) {
|
|
103
|
+
skipped.push({ block: id, reason: `${id} needs a param the project detector could not supply (${requiredParamHint(id)})` });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
hooks.push({ block: id, params, mode: "block" });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { ...base, hooks, skipped };
|
|
110
|
+
}
|
|
111
|
+
/** Preset to start from, then the chooser's decisions on top. */
|
|
112
|
+
export function harnessFromChoices(result, facts, meta) {
|
|
113
|
+
const base = buildPresetHarness(result.strictness, facts, meta);
|
|
114
|
+
return applyChoices(base, result, facts);
|
|
115
|
+
}
|
|
116
|
+
/** TYPESAFE_API_KEY from the environment, else from a .env file in the project directory. */
|
|
117
|
+
export function resolveTypesafeApiKey(projectDir, env = process.env) {
|
|
118
|
+
if (env.TYPESAFE_API_KEY?.trim())
|
|
119
|
+
return env.TYPESAFE_API_KEY.trim();
|
|
120
|
+
try {
|
|
121
|
+
const text = fs.readFileSync(path.join(projectDir, ".env"), "utf-8");
|
|
122
|
+
const m = text.match(/^\s*(?:export\s+)?TYPESAFE_API_KEY\s*=\s*["']?([^"'\r\n#]+)["']?/m);
|
|
123
|
+
return m?.[1]?.trim() || undefined;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function requiredParamHint(id) {
|
|
130
|
+
return { "commit-test-gate": "testCommand", "commit-typecheck-gate": "typecheckCommand", "lint-on-save": "command",
|
|
131
|
+
"test-on-save": "testCommand", "format-on-save": "command", "path-guard": "blockedPaths" }[id] ?? "params";
|
|
132
|
+
}
|
|
133
|
+
function lowerFirst(s) {
|
|
134
|
+
return s ? s[0].toLowerCase() + s.slice(1) : s;
|
|
135
|
+
}
|
|
136
|
+
// Only the facts that inform a selection; large irrelevant state degrades Jev.
|
|
137
|
+
function compactFacts(f) {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const k of ["languages", "frameworks", "packageManagers", "testCommands", "lintCommands", "typecheckCommands", "buildCommands", "blockedPaths"]) {
|
|
140
|
+
if (f[k]?.length)
|
|
141
|
+
out[k] = f[k];
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|