@guilz-dev/belay 0.1.1 → 0.3.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.
Files changed (106) hide show
  1. package/README.md +55 -15
  2. package/dist/adapters/shared/gate-runtime.js +25 -9
  3. package/dist/bundle/claude-runtime.mjs +1786 -379
  4. package/dist/bundle/codex-runtime.mjs +1785 -379
  5. package/dist/bundle/cursor-runtime.mjs +1786 -379
  6. package/dist/cli.js +201 -11
  7. package/dist/commands/approve.js +11 -0
  8. package/dist/commands/classify-for-report.js +3 -3
  9. package/dist/commands/config.d.ts +70 -0
  10. package/dist/commands/config.js +494 -0
  11. package/dist/commands/doctor.js +3 -2
  12. package/dist/commands/explain.js +14 -14
  13. package/dist/commands/health-snapshot.d.ts +6 -0
  14. package/dist/commands/health-snapshot.js +34 -0
  15. package/dist/commands/judge.d.ts +90 -0
  16. package/dist/commands/judge.js +285 -0
  17. package/dist/commands/recover.js +2 -2
  18. package/dist/commands/status.js +2 -2
  19. package/dist/commands/stdin-key.d.ts +1 -0
  20. package/dist/commands/stdin-key.js +8 -0
  21. package/dist/conformance/guarantee-table.js +12 -0
  22. package/dist/conformance/types.d.ts +2 -0
  23. package/dist/core/approval.d.ts +3 -0
  24. package/dist/core/approval.js +18 -3
  25. package/dist/core/audit-io.d.ts +2 -0
  26. package/dist/core/audit-io.js +14 -0
  27. package/dist/core/audit-query.js +5 -1
  28. package/dist/core/capability/index.d.ts +2 -2
  29. package/dist/core/capability/index.js +2 -2
  30. package/dist/core/capability/paths.d.ts +1 -0
  31. package/dist/core/capability/paths.js +74 -6
  32. package/dist/core/capability/reasons.d.ts +3 -0
  33. package/dist/core/capability/reasons.js +8 -0
  34. package/dist/core/classify-tool.js +97 -27
  35. package/dist/core/config-layers.js +2 -1
  36. package/dist/core/config.d.ts +23 -3
  37. package/dist/core/config.js +112 -13
  38. package/dist/core/credential-store.d.ts +11 -0
  39. package/dist/core/credential-store.js +60 -0
  40. package/dist/core/gate-contract.d.ts +1 -1
  41. package/dist/core/gate-contract.js +1 -1
  42. package/dist/core/gate-engine.js +105 -14
  43. package/dist/core/index.d.ts +2 -2
  44. package/dist/core/index.js +2 -2
  45. package/dist/core/integrity.d.ts +2 -0
  46. package/dist/core/integrity.js +13 -0
  47. package/dist/core/judge-api-key.d.ts +19 -0
  48. package/dist/core/judge-api-key.js +74 -0
  49. package/dist/core/judge-cloud-consent.d.ts +13 -0
  50. package/dist/core/judge-cloud-consent.js +38 -0
  51. package/dist/core/judge-config.d.ts +45 -4
  52. package/dist/core/judge-config.js +266 -44
  53. package/dist/core/judge-doctor.d.ts +6 -1
  54. package/dist/core/judge-doctor.js +148 -97
  55. package/dist/core/judge-model-discovery.d.ts +24 -0
  56. package/dist/core/judge-model-discovery.js +168 -0
  57. package/dist/core/judge-model-policy.d.ts +5 -0
  58. package/dist/core/judge-model-policy.js +21 -0
  59. package/dist/core/judge-runtime-detection.d.ts +9 -0
  60. package/dist/core/judge-runtime-detection.js +68 -0
  61. package/dist/core/transactional/diff-evaluator.js +1 -19
  62. package/dist/core/types.d.ts +7 -3
  63. package/dist/core/{v2 → verdict}/adapter.d.ts +1 -0
  64. package/dist/core/{v2 → verdict}/adapter.js +16 -4
  65. package/dist/core/{v2 → verdict}/containment.d.ts +5 -0
  66. package/dist/core/{v2 → verdict}/containment.js +32 -2
  67. package/dist/core/{v2 → verdict}/egress-classify.js +3 -0
  68. package/dist/core/verdict/judge-catalog.d.ts +40 -0
  69. package/dist/core/verdict/judge-catalog.js +148 -0
  70. package/dist/core/verdict/judge-cli.d.ts +23 -0
  71. package/dist/core/verdict/judge-cli.js +280 -0
  72. package/dist/core/{v2 → verdict}/judge-factory.d.ts +15 -4
  73. package/dist/core/verdict/judge-factory.js +178 -0
  74. package/dist/core/{v2 → verdict}/judge.d.ts +20 -1
  75. package/dist/core/{v2 → verdict}/judge.js +85 -18
  76. package/dist/core/{v2 → verdict}/launcher-resolve.js +72 -1
  77. package/dist/core/verdict/persistent-paths.d.ts +8 -0
  78. package/dist/core/verdict/persistent-paths.js +52 -0
  79. package/dist/core/{v2 → verdict}/types.d.ts +6 -2
  80. package/dist/core/{v2 → verdict}/verdict.js +176 -46
  81. package/dist/corpus/evaluate.js +2 -2
  82. package/dist/installer.js +66 -14
  83. package/dist/types.d.ts +8 -1
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/package.json +2 -1
  87. package/skills/belay/SKILL.md +8 -7
  88. package/dist/commands/init-wizard.d.ts +0 -16
  89. package/dist/commands/init-wizard.js +0 -50
  90. package/dist/core/v2/judge-factory.js +0 -75
  91. /package/dist/core/{v2 → verdict}/egress-classify.d.ts +0 -0
  92. /package/dist/core/{v2 → verdict}/fingerprint.d.ts +0 -0
  93. /package/dist/core/{v2 → verdict}/fingerprint.js +0 -0
  94. /package/dist/core/{v2 → verdict}/index.d.ts +0 -0
  95. /package/dist/core/{v2 → verdict}/index.js +0 -0
  96. /package/dist/core/{v2 → verdict}/judge-audit.d.ts +0 -0
  97. /package/dist/core/{v2 → verdict}/judge-audit.js +0 -0
  98. /package/dist/core/{v2 → verdict}/judge-outbound.d.ts +0 -0
  99. /package/dist/core/{v2 → verdict}/judge-outbound.js +0 -0
  100. /package/dist/core/{v2 → verdict}/launcher-resolve.d.ts +0 -0
  101. /package/dist/core/{v2 → verdict}/overrides.d.ts +0 -0
  102. /package/dist/core/{v2 → verdict}/overrides.js +0 -0
  103. /package/dist/core/{v2 → verdict}/parser.d.ts +0 -0
  104. /package/dist/core/{v2 → verdict}/parser.js +0 -0
  105. /package/dist/core/{v2 → verdict}/types.js +0 -0
  106. /package/dist/core/{v2 → verdict}/verdict.d.ts +0 -0
@@ -0,0 +1,178 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { repoLocalStateDirFor } from '../../config-io.js';
4
+ import { normalizeJudgeProvider, scrubOptionsFromConfig } from '../config.js';
5
+ import { resolveJudgeCredential } from '../judge-api-key.js';
6
+ import { hasValidCloudConsent, isCloudJudgeConfig } from '../judge-config.js';
7
+ import { rejectDeprecatedJudgeModelAuto } from '../judge-model-policy.js';
8
+ import { detectJudgeRuntimeCapabilities } from '../judge-runtime-detection.js';
9
+ import { createDeterministicJudgeStub, createFailClosedJudge, createOllamaJudge, createOpenAiCompatibleJudge, } from './judge.js';
10
+ import { getJudgeProviderSpec, inferProviderIdFromConfig, isRemovedProviderId, normalizeLegacyProviderId, } from './judge-catalog.js';
11
+ import { createClaudeCliJudge, createCodexCliJudge, createCursorCliJudge } from './judge-cli.js';
12
+ const FIXTURE_MODELS_URL = new URL('../../../fixtures/judge-models.json', import.meta.url);
13
+ let cachedPinnedModels = null;
14
+ export function resetPinnedJudgeModelsCache() {
15
+ cachedPinnedModels = null;
16
+ }
17
+ export async function loadPinnedJudgeModels() {
18
+ if (cachedPinnedModels) {
19
+ return cachedPinnedModels;
20
+ }
21
+ try {
22
+ const raw = await readFile(fileURLToPath(FIXTURE_MODELS_URL), 'utf8');
23
+ cachedPinnedModels = JSON.parse(raw);
24
+ return cachedPinnedModels;
25
+ }
26
+ catch {
27
+ cachedPinnedModels = {
28
+ 'openai-compatible': { autoResolved: 'composer-2.5' },
29
+ ollama: { ciPin: 'gemma4:e2b' },
30
+ };
31
+ return cachedPinnedModels;
32
+ }
33
+ }
34
+ export function resolveJudgeModel(judge) {
35
+ rejectDeprecatedJudgeModelAuto(judge.model);
36
+ const requested = judge.model;
37
+ const envOverride = testJudgeModelOverride();
38
+ return { requested, resolved: envOverride || requested };
39
+ }
40
+ function testJudgeModelOverride() {
41
+ if (!process.env.VITEST && !process.env.VITEST_WORKER_ID) {
42
+ return undefined;
43
+ }
44
+ return process.env.BELAY_JUDGE_MODEL_RESOLVED?.trim() || undefined;
45
+ }
46
+ /** @deprecated Use resolveJudgeModel */
47
+ export const resolveCloudModel = (requested, _pinned) => {
48
+ rejectDeprecatedJudgeModelAuto(requested);
49
+ const envResolved = testJudgeModelOverride();
50
+ return {
51
+ requested,
52
+ resolved: envResolved || requested,
53
+ };
54
+ };
55
+ /** @deprecated Use resolveJudgeModel */
56
+ export const resolveCursorModel = resolveCloudModel;
57
+ function providerIdFromJudge(judge) {
58
+ if (judge.providerId) {
59
+ const normalized = normalizeLegacyProviderId(judge.providerId);
60
+ if (normalized) {
61
+ return normalized;
62
+ }
63
+ }
64
+ return inferProviderIdFromConfig(judge);
65
+ }
66
+ function createCliJudgeForProvider(providerId, judgeConfig, config) {
67
+ const { resolved } = resolveJudgeModel(judgeConfig);
68
+ const options = {
69
+ modelRequested: judgeConfig.model,
70
+ modelResolved: resolved,
71
+ timeoutMs: judgeConfig.timeoutMs,
72
+ sensitivePaths: config.classifier.sensitivePaths,
73
+ scrubOptions: scrubOptionsFromConfig(config),
74
+ };
75
+ if (providerId === 'codex') {
76
+ return createCodexCliJudge(options);
77
+ }
78
+ if (providerId === 'cursor') {
79
+ return createCursorCliJudge(options);
80
+ }
81
+ return createClaudeCliJudge(options);
82
+ }
83
+ export function createJudgeFromConfig(config, options = {}) {
84
+ if (process.env.BELAY_DETERMINISTIC_JUDGE === '1') {
85
+ return createDeterministicJudgeStub();
86
+ }
87
+ const judgeConfig = config.judge;
88
+ if (judgeConfig.providerId && isRemovedProviderId(String(judgeConfig.providerId))) {
89
+ const { resolved } = resolveJudgeModel(judgeConfig);
90
+ return createFailClosedJudge({
91
+ reason: 'judge_provider_removed',
92
+ fallbackReason: 'provider_migration_required',
93
+ modelRequested: judgeConfig.model,
94
+ modelResolved: resolved,
95
+ });
96
+ }
97
+ const provider = normalizeJudgeProvider(judgeConfig.provider);
98
+ const providerId = providerIdFromJudge(judgeConfig);
99
+ const catalogSpec = getJudgeProviderSpec(providerId);
100
+ const { resolved } = resolveJudgeModel(judgeConfig);
101
+ const runtime = detectJudgeRuntimeCapabilities(providerId);
102
+ if (provider === 'openai-compatible') {
103
+ const endpoint = judgeConfig.endpoint?.trim();
104
+ if (!endpoint && runtime.cliTransport) {
105
+ if (providerId === 'codex' || providerId === 'cursor') {
106
+ return createCliJudgeForProvider(providerId, judgeConfig, config);
107
+ }
108
+ }
109
+ if (!endpoint) {
110
+ return createFailClosedJudge({
111
+ reason: 'openai_compatible_endpoint_missing',
112
+ fallbackReason: 'missing_endpoint',
113
+ modelRequested: judgeConfig.model,
114
+ modelResolved: resolved,
115
+ });
116
+ }
117
+ if (isCloudJudgeConfig(judgeConfig) && !hasValidCloudConsent(judgeConfig)) {
118
+ return createFailClosedJudge({
119
+ reason: 'openai_compatible_consent_missing',
120
+ fallbackReason: 'cloud_consent_missing',
121
+ modelRequested: judgeConfig.model,
122
+ modelResolved: resolved,
123
+ });
124
+ }
125
+ const repoRoot = options.repoRoot ?? process.cwd();
126
+ const repoLocalStateDir = repoLocalStateDirFor(repoRoot, config);
127
+ return createOpenAiCompatibleJudge({
128
+ endpoint,
129
+ modelRequested: judgeConfig.model,
130
+ modelResolved: resolved,
131
+ timeoutMs: judgeConfig.timeoutMs,
132
+ sensitivePaths: config.classifier.sensitivePaths,
133
+ scrubOptions: scrubOptionsFromConfig(config),
134
+ resolveApiKey: async () => resolveJudgeCredential({
135
+ judge: judgeConfig,
136
+ catalogSpec: catalogSpec ?? undefined,
137
+ repoRoot,
138
+ repoLocalStateDir,
139
+ config,
140
+ }),
141
+ });
142
+ }
143
+ if (provider === 'ollama') {
144
+ return createOllamaJudge({
145
+ model: judgeConfig.model,
146
+ baseUrl: judgeConfig.endpoint ?? 'http://127.0.0.1:11434',
147
+ timeoutMs: judgeConfig.timeoutMs,
148
+ keepAlive: judgeConfig.keepAlive,
149
+ });
150
+ }
151
+ if (provider === 'anthropic') {
152
+ if (runtime.cliTransport) {
153
+ return createClaudeCliJudge({
154
+ modelRequested: judgeConfig.model,
155
+ modelResolved: resolved,
156
+ timeoutMs: judgeConfig.timeoutMs,
157
+ sensitivePaths: config.classifier.sensitivePaths,
158
+ scrubOptions: scrubOptionsFromConfig(config),
159
+ });
160
+ }
161
+ return createFailClosedJudge({
162
+ reason: 'anthropic_not_implemented',
163
+ fallbackReason: 'anthropic_runtime_unavailable',
164
+ modelRequested: judgeConfig.model,
165
+ modelResolved: resolved,
166
+ });
167
+ }
168
+ return createFailClosedJudge({
169
+ reason: 'unsupported_judge_provider',
170
+ fallbackReason: 'unsupported_provider',
171
+ modelRequested: judgeConfig.model,
172
+ modelResolved: resolved,
173
+ });
174
+ }
175
+ export function judgeConfigSummary(judge) {
176
+ const id = judge.providerId ?? judge.provider;
177
+ return `${id}/${judge.model}`;
178
+ }
@@ -1,17 +1,32 @@
1
1
  import type { ScrubOptions } from '../types.js';
2
2
  import type { Tier1Judge, Tier1Verdict } from './types.js';
3
+ export type Tier1JudgeTransport = 'http' | 'ollama-http' | 'codex-cli' | 'cursor-cli' | 'claude-cli' | 'unavailable';
3
4
  export interface Tier1JudgeTrace {
4
- provider: 'openai-compatible' | 'ollama' | 'fallback';
5
+ provider: 'openai-compatible' | 'ollama' | 'anthropic' | 'fallback';
5
6
  modelRequested: string;
6
7
  modelResolved: string;
7
8
  latencyMs: number;
9
+ transport?: Tier1JudgeTransport;
8
10
  outboundRedacted?: boolean;
9
11
  fallbackReason?: string;
10
12
  }
11
13
  export interface TracedTier1Judge extends Tier1Judge {
12
14
  lastTrace?: Tier1JudgeTrace;
13
15
  }
16
+ export declare function buildTier1Prompt(text: string): string;
17
+ export declare function parseTier1Json(raw: string): Tier1Verdict | null;
14
18
  export declare function prescanInterpreterCode(code: string): Tier1Verdict | null;
19
+ export interface MutationPrescanParams {
20
+ targets: string[];
21
+ cwd: string;
22
+ repoRoot: string;
23
+ trustedCwd: boolean;
24
+ sensitivePaths: string[];
25
+ }
26
+ /** ADR-002 M3: structural prescan for sensitive / persistent mutation targets (shell redirects, etc.). */
27
+ export declare function prescanMutationTargets(params: MutationPrescanParams): Tier1Verdict | null;
28
+ /** Returns prescan verdict when structural M3 rules require ask (before Tier1 LLM). */
29
+ export declare function mutationPrescanRequiresAsk(params: MutationPrescanParams): Tier1Verdict | null;
15
30
  /** Conservative stub: Tier1 defers to Tier0; returns safe negatives for structural suite. */
16
31
  export declare function createDeterministicJudgeStub(): TracedTier1Judge;
17
32
  /** Fail-closed judge for when Tier1 is required but unavailable. */
@@ -35,6 +50,10 @@ export interface OpenAiCompatibleJudgeOptions {
35
50
  modelResolved: string;
36
51
  timeoutMs: number;
37
52
  apiKey?: string;
53
+ resolveApiKey?: () => Promise<{
54
+ key: string | null;
55
+ source: string | null;
56
+ }>;
38
57
  sensitivePaths: string[];
39
58
  scrubOptions: ScrubOptions;
40
59
  fetchImpl?: typeof fetch;
@@ -1,36 +1,60 @@
1
+ import { matchesSensitivePath } from '../glob.js';
2
+ import { relativeWithinRepo, resolveMutationTarget } from '../path-utils.js';
3
+ import { resolveTrustedPath } from './containment.js';
1
4
  import { scrubOutboundForJudge } from './judge-outbound.js';
5
+ import { isOutsideRepoSecretCredentialPath, isPersistentAgentPath } from './persistent-paths.js';
2
6
  const SECRET_PATTERNS = [/\.env\b/i, /\.pem\b/i, /id_rsa\b/i, /credentials/i, /secrets?\b/i];
3
7
  const DESTRUCTIVE_VERBS = /\b(rm|rmtree|unlink|delete|truncate|shred|destroy|drop)\b/i;
4
8
  const GIT_PATTERNS = /\.git\b/i;
5
- const TIER1_PROMPT = `Answer ONLY with JSON: {"external_change":boolean,"destroys_outside_repo":boolean,"destroys_history_or_secrets":boolean,"reason":string}
6
- Default all three booleans to true when unsure. reason should be a short snake_case label.
9
+ const TIER1_PROMPT = `Answer ONLY with JSON: {"local_recoverable":boolean,"destroys_history_or_secrets":boolean,"reason":string}
10
+ reason should be a short snake_case label.
7
11
 
8
- external_change: CREATE/MODIFY/DELETE on database, network endpoint, cloud, remote git, registry, queue. READ/QUERY/GET is NOT a change.
9
- destroys_outside_repo: DELETE/OVERWRITE files OUTSIDE the current repository working tree.
10
- destroys_history_or_secrets: DELETE/OVERWRITE .git directory OR secret/credential files (.env, *.pem, id_rsa, credentials).
12
+ local_recoverable: true if a mistaken operation can be undone locally (git revert, fs snapshot, trivial manual revert).
13
+ local_recoverable: false if remote/destructive (git push --force, aws s3 rm, secret send) OR persistent local harm (~/.ssh/authorized_keys, shell rc, crontab, ~/.config persistence).
14
+
15
+ destroys_history_or_secrets: true if the operation deletes or corrupts git history (.git), overwrites secrets/credentials, or modifies persistent agent startup (shell rc, crontab, launchd, authorized_keys).
16
+
17
+ Examples local_recoverable=true, destroys_history_or_secrets=false: IDE plan files under the user home, /tmp files, in-repo edits, READ/QUERY/GET.
18
+ Examples local_recoverable=false OR destroys_history_or_secrets=true: remote mutations, credential paths, agent startup persistence, rm -rf .git.
19
+ If genuinely ambiguous about recoverability or persistent harm, answer false.
11
20
 
12
21
  Command/code:
13
22
  `;
14
23
  function failClosedVerdict(reason) {
15
24
  return {
16
- external_change: true,
25
+ local_recoverable: false,
17
26
  destroys_outside_repo: true,
18
27
  destroys_history_or_secrets: true,
28
+ external_change: true,
19
29
  reason,
20
30
  };
21
31
  }
22
- function parseTier1Json(raw) {
32
+ function parseLocalRecoverable(parsed) {
33
+ if (typeof parsed.local_recoverable === 'boolean') {
34
+ return parsed.local_recoverable;
35
+ }
36
+ if (typeof parsed.external_change === 'boolean') {
37
+ return !parsed.external_change;
38
+ }
39
+ return null;
40
+ }
41
+ export function buildTier1Prompt(text) {
42
+ return `${TIER1_PROMPT}${text}`;
43
+ }
44
+ export function parseTier1Json(raw) {
23
45
  try {
24
46
  const parsed = JSON.parse(raw);
25
- if (typeof parsed.external_change !== 'boolean' ||
26
- typeof parsed.destroys_outside_repo !== 'boolean' ||
27
- typeof parsed.destroys_history_or_secrets !== 'boolean') {
47
+ const localRecoverable = parseLocalRecoverable(parsed);
48
+ if (localRecoverable === null) {
28
49
  return null;
29
50
  }
30
51
  return {
31
- external_change: parsed.external_change !== false,
32
- destroys_outside_repo: parsed.destroys_outside_repo !== false,
33
- destroys_history_or_secrets: parsed.destroys_history_or_secrets !== false,
52
+ local_recoverable: localRecoverable,
53
+ destroys_outside_repo: typeof parsed.destroys_outside_repo === 'boolean' ? parsed.destroys_outside_repo : false,
54
+ destroys_history_or_secrets: typeof parsed.destroys_history_or_secrets === 'boolean'
55
+ ? parsed.destroys_history_or_secrets
56
+ : false,
57
+ external_change: parsed.external_change,
34
58
  reason: typeof parsed.reason === 'string' ? parsed.reason : 'tier1_llm',
35
59
  };
36
60
  }
@@ -45,7 +69,7 @@ export function prescanInterpreterCode(code) {
45
69
  const hitsDestructive = DESTRUCTIVE_VERBS.test(normalized);
46
70
  if ((hitsSecret || hitsGit) && hitsDestructive) {
47
71
  return {
48
- external_change: false,
72
+ local_recoverable: true,
49
73
  destroys_outside_repo: false,
50
74
  destroys_history_or_secrets: true,
51
75
  reason: 'prescan_destructive_secret',
@@ -53,12 +77,54 @@ export function prescanInterpreterCode(code) {
53
77
  }
54
78
  return null;
55
79
  }
80
+ /** ADR-002 M3: structural prescan for sensitive / persistent mutation targets (shell redirects, etc.). */
81
+ export function prescanMutationTargets(params) {
82
+ for (const target of params.targets) {
83
+ const resolved = resolveTrustedPath(target, params.cwd, params.trustedCwd) ??
84
+ resolveMutationTarget(target, params.cwd);
85
+ if (!resolved) {
86
+ continue;
87
+ }
88
+ const relative = relativeWithinRepo(params.repoRoot, resolved);
89
+ if (relative !== null &&
90
+ matchesSensitivePath(relative.replaceAll('\\', '/'), params.sensitivePaths)) {
91
+ return {
92
+ local_recoverable: false,
93
+ destroys_outside_repo: false,
94
+ destroys_history_or_secrets: true,
95
+ reason: 'sensitive_path_mutation',
96
+ };
97
+ }
98
+ if (relative === null && isOutsideRepoSecretCredentialPath(resolved)) {
99
+ return {
100
+ local_recoverable: false,
101
+ destroys_outside_repo: false,
102
+ destroys_history_or_secrets: true,
103
+ reason: 'outside_repo_secret_credential_path',
104
+ };
105
+ }
106
+ if (isPersistentAgentPath(resolved)) {
107
+ return {
108
+ local_recoverable: false,
109
+ destroys_outside_repo: false,
110
+ destroys_history_or_secrets: true,
111
+ reason: 'persistent_agent_path',
112
+ };
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ /** Returns prescan verdict when structural M3 rules require ask (before Tier1 LLM). */
118
+ export function mutationPrescanRequiresAsk(params) {
119
+ const prescan = prescanMutationTargets(params);
120
+ return prescan && tier1RequiresAsk(prescan) ? prescan : null;
121
+ }
56
122
  /** Conservative stub: Tier1 defers to Tier0; returns safe negatives for structural suite. */
57
123
  export function createDeterministicJudgeStub() {
58
124
  return {
59
125
  evaluate() {
60
126
  return Promise.resolve({
61
- external_change: false,
127
+ local_recoverable: true,
62
128
  destroys_outside_repo: false,
63
129
  destroys_history_or_secrets: false,
64
130
  reason: 'deterministic_stub',
@@ -192,8 +258,9 @@ export function createOpenAiCompatibleJudge(options) {
192
258
  };
193
259
  return failClosedVerdict('outbound_scrub_failed');
194
260
  }
195
- const { resolveJudgeApiKey } = await import('../judge-api-key.js');
196
- const resolvedKey = resolveJudgeApiKey();
261
+ const resolvedKey = options.resolveApiKey
262
+ ? await options.resolveApiKey()
263
+ : { key: options.apiKey ?? null, source: null };
197
264
  const apiKey = options.apiKey ?? resolvedKey.key;
198
265
  if (!apiKey) {
199
266
  judge.lastTrace = {
@@ -260,5 +327,5 @@ export function createOpenAiCompatibleJudge(options) {
260
327
  /** @deprecated Use createOpenAiCompatibleJudge */
261
328
  export const createCursorJudge = createOpenAiCompatibleJudge;
262
329
  export function tier1RequiresAsk(verdict) {
263
- return (verdict.external_change || verdict.destroys_outside_repo || verdict.destroys_history_or_secrets);
330
+ return !verdict.local_recoverable || verdict.destroys_history_or_secrets;
264
331
  }
@@ -1,6 +1,50 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  const MAX_RESOLVE_DEPTH = 8;
4
+ const PNPM_BUILTIN_COMMANDS = new Set([
5
+ 'add',
6
+ 'audit',
7
+ 'cache',
8
+ 'config',
9
+ 'deploy',
10
+ 'dlx',
11
+ 'exec',
12
+ 'fetch',
13
+ 'help',
14
+ 'import',
15
+ 'init',
16
+ 'install',
17
+ 'i',
18
+ 'licenses',
19
+ 'link',
20
+ 'list',
21
+ 'outdated',
22
+ 'pack',
23
+ 'patch',
24
+ 'patch-commit',
25
+ 'patch-remove',
26
+ 'publish',
27
+ 'prune',
28
+ 'rebuild',
29
+ 'remove',
30
+ 'rm',
31
+ 'store',
32
+ 'unlink',
33
+ 'update',
34
+ 'up',
35
+ 'why',
36
+ ]);
37
+ const PNPM_EXEC_LIKE_HEADS = new Set([
38
+ 'vitest',
39
+ 'vite',
40
+ 'biome',
41
+ 'eslint',
42
+ 'jest',
43
+ 'mocha',
44
+ 'tsc',
45
+ 'tsx',
46
+ 'node',
47
+ ]);
4
48
  function readPackageJson(dir) {
5
49
  const packagePath = path.join(dir, 'package.json');
6
50
  if (!existsSync(packagePath)) {
@@ -57,6 +101,15 @@ function npmScriptName(tokens) {
57
101
  if (launcher[0] === 'pnpm' && launcher[1] === 'run' && launcher[2]) {
58
102
  return launcher[2];
59
103
  }
104
+ if (launcher[0] === 'pnpm' && launcher[1] === 'test') {
105
+ return 'test';
106
+ }
107
+ if (launcher[0] === 'pnpm' &&
108
+ launcher[1] &&
109
+ !launcher[1].startsWith('-') &&
110
+ !PNPM_BUILTIN_COMMANDS.has(launcher[1])) {
111
+ return launcher[1];
112
+ }
60
113
  if (launcher[0] === 'npm' && launcher[1] && launcher[1] !== 'run' && launcher[1] !== 'install') {
61
114
  return null;
62
115
  }
@@ -176,11 +229,29 @@ export function resolveLauncherRecipe(params) {
176
229
  const tokens = params.tokens;
177
230
  const scriptName = npmScriptName(tokens);
178
231
  if (scriptName) {
179
- return resolveNpmRecipe(params.cwd, params.repoRoot, scriptName, forwardedArgs(tokens));
232
+ const resolution = resolveNpmRecipe(params.cwd, params.repoRoot, scriptName, forwardedArgs(tokens));
233
+ if (tokens[0] === 'pnpm' &&
234
+ tokens[1] &&
235
+ PNPM_EXEC_LIKE_HEADS.has(tokens[1]) &&
236
+ resolution.reason === 'npm_script_undefined') {
237
+ return {
238
+ recipes: [tokens.slice(1).join(' ')],
239
+ opaque: false,
240
+ reason: 'pnpm_exec_like',
241
+ };
242
+ }
243
+ return resolution;
180
244
  }
181
245
  if (tokens[0] === 'make' && tokens[1] && !tokens[1].startsWith('-')) {
182
246
  return resolveMakeRecipe(params.cwd, params.repoRoot, tokens[1]);
183
247
  }
248
+ if (tokens[0] === 'pnpm' && tokens[1] && PNPM_EXEC_LIKE_HEADS.has(tokens[1])) {
249
+ return {
250
+ recipes: [tokens.slice(1).join(' ')],
251
+ opaque: false,
252
+ reason: 'pnpm_exec_like',
253
+ };
254
+ }
184
255
  return null;
185
256
  }
186
257
  export function isRoutineLauncher(tokens) {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * M3 catalog: paths that survive reboot or change agent startup.
3
+ * @see docs/adr/tier0-retention-ledger.md
4
+ */
5
+ export declare const PERSISTENT_AGENT_PATH_MARKERS: readonly [".ssh/authorized_keys", ".zshrc", ".bashrc", ".bash_profile", ".profile", ".config/", "/crontab", "LaunchAgents/", "/Library/LaunchDaemons/"];
6
+ export declare const OUTSIDE_REPO_SECRET_CREDENTIAL_MARKERS: readonly [".env", ".env.*", "*.pem", ".ssh/id_*", ".aws/", ".npmrc", ".git-credentials", ".netrc", ".kube/config", ".docker/config.json", ".gnupg/", ".pypirc"];
7
+ export declare function isPersistentAgentPath(resolvedPath: string): boolean;
8
+ export declare function isOutsideRepoSecretCredentialPath(resolvedPath: string): boolean;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * M3 catalog: paths that survive reboot or change agent startup.
3
+ * @see docs/adr/tier0-retention-ledger.md
4
+ */
5
+ export const PERSISTENT_AGENT_PATH_MARKERS = [
6
+ '.ssh/authorized_keys',
7
+ '.zshrc',
8
+ '.bashrc',
9
+ '.bash_profile',
10
+ '.profile',
11
+ '.config/',
12
+ '/crontab',
13
+ 'LaunchAgents/',
14
+ '/Library/LaunchDaemons/',
15
+ ];
16
+ export const OUTSIDE_REPO_SECRET_CREDENTIAL_MARKERS = [
17
+ '.env',
18
+ '.env.*',
19
+ '*.pem',
20
+ '.ssh/id_*',
21
+ '.aws/',
22
+ '.npmrc',
23
+ '.git-credentials',
24
+ '.netrc',
25
+ '.kube/config',
26
+ '.docker/config.json',
27
+ '.gnupg/',
28
+ '.pypirc',
29
+ ];
30
+ export function isPersistentAgentPath(resolvedPath) {
31
+ const normalized = resolvedPath.replaceAll('\\', '/');
32
+ return (/\.ssh\/authorized_keys$/i.test(normalized) ||
33
+ /\/\.(zshrc|bashrc|bash_profile|profile)$/i.test(normalized) ||
34
+ /\/\.config\//i.test(normalized) ||
35
+ /\/crontab$/i.test(normalized) ||
36
+ /LaunchAgents\//i.test(normalized) ||
37
+ /\/Library\/LaunchDaemons\//i.test(normalized));
38
+ }
39
+ export function isOutsideRepoSecretCredentialPath(resolvedPath) {
40
+ const normalized = resolvedPath.replaceAll('\\', '/');
41
+ return (/\/\.env(?:\.[^/]+)?$/i.test(normalized) ||
42
+ /\/[^/]+\.pem$/i.test(normalized) ||
43
+ /\/\.ssh\/id_[^/]+$/i.test(normalized) ||
44
+ /\/\.aws\//i.test(normalized) ||
45
+ /\/\.npmrc$/i.test(normalized) ||
46
+ /\/\.git-credentials$/i.test(normalized) ||
47
+ /\/\.netrc$/i.test(normalized) ||
48
+ /\/\.kube\/config$/i.test(normalized) ||
49
+ /\/\.docker\/config\.json$/i.test(normalized) ||
50
+ /\/\.gnupg\//i.test(normalized) ||
51
+ /\/\.pypirc$/i.test(normalized));
52
+ }
@@ -16,10 +16,14 @@ export interface VerdictResult {
16
16
  judgeTrace?: JudgeTrace;
17
17
  }
18
18
  export interface Tier1Verdict {
19
- external_change: boolean;
20
- destroys_outside_repo: boolean;
19
+ /** true = undoable via git/fs snapshot or trivial local revert (ADR-002 local-recoverable). */
20
+ local_recoverable: boolean;
21
21
  destroys_history_or_secrets: boolean;
22
22
  reason: string;
23
+ /** @deprecated Legacy LLM field; parsed only for migration. */
24
+ external_change?: boolean;
25
+ /** @deprecated Unused in decision logic. */
26
+ destroys_outside_repo?: boolean;
23
27
  }
24
28
  export interface Tier1EvaluateInput {
25
29
  text: string;