@cr1992/agentkit 1.0.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.en.md +107 -0
- package/README.md +103 -0
- package/bin/agentkit.mjs +4 -0
- package/bin/cli.mjs +273 -0
- package/core/atomic-fs.mjs +23 -0
- package/core/cli-help.mjs +54 -0
- package/core/content-digest.mjs +66 -0
- package/core/digest.mjs +67 -0
- package/core/json-schema-lite.mjs +60 -0
- package/core/legacy-entry.mjs +37 -0
- package/core/reflection.mjs +142 -0
- package/core/runtime-bundle.mjs +101 -0
- package/docs/loop/embedded-review-adapter.md +41 -0
- package/docs/loop/loop-state-machine.md +43 -0
- package/docs/loop/recovery-and-fuses.md +34 -0
- package/docs/orchestrate/dispatch-contract.md +92 -0
- package/docs/orchestrate/failure-routing-and-recovery.md +46 -0
- package/docs/orchestrate/host-capability-cache.md +170 -0
- package/docs/orchestrate/isolation-fallback.md +18 -0
- package/docs/orchestrate/model-routing-config.md +186 -0
- package/docs/orchestrate/orchestration-runtime.md +261 -0
- package/docs/orchestrate/review-budget.md +90 -0
- package/docs/orchestrate/task-playbooks.md +85 -0
- package/docs/orchestrate/user-facing-reporting.md +14 -0
- package/docs/verify/evidence-schema.md +167 -0
- package/docs/verify/input-preparation.md +44 -0
- package/docs/verify/verification-protocol.md +76 -0
- package/docs/worktree/batch-integration.md +176 -0
- package/docs/worktree/delivery-identity.md +41 -0
- package/docs/worktree/profile.md +107 -0
- package/docs/worktree/reclaim-and-watch.md +96 -0
- package/docs/worktree/review-lifecycle.md +92 -0
- package/docs/worktree/spawn-and-stack.md +74 -0
- package/domains/loop/loop-runtime.mjs +1056 -0
- package/domains/orchestrate/contract-tool.mjs +169 -0
- package/domains/orchestrate/host_capability_cache.mjs +437 -0
- package/domains/orchestrate/orchestration-ledger.mjs +332 -0
- package/domains/orchestrate/orchestration-metadata.mjs +4 -0
- package/domains/orchestrate/orchestration-reflection.mjs +119 -0
- package/domains/orchestrate/resolve_model_policy.mjs +311 -0
- package/domains/orchestrate/review-budget.mjs +162 -0
- package/domains/orchestrate/worker-capability-preflight.mjs +227 -0
- package/domains/verify/verification-runtime.mjs +1638 -0
- package/domains/worktree/worktree-archive.mjs +135 -0
- package/domains/worktree/worktree-artifact.mjs +123 -0
- package/domains/worktree/worktree-batch-integrate.mjs +713 -0
- package/domains/worktree/worktree-batch-plan.mjs +198 -0
- package/domains/worktree/worktree-batch-result.mjs +241 -0
- package/domains/worktree/worktree-core.mjs +908 -0
- package/domains/worktree/worktree-doctor.mjs +493 -0
- package/domains/worktree/worktree-history.mjs +377 -0
- package/domains/worktree/worktree-learning.mjs +110 -0
- package/domains/worktree/worktree-lifecycle.mjs +786 -0
- package/domains/worktree/worktree-merge-preview.mjs +409 -0
- package/domains/worktree/worktree-mgr.mjs +261 -0
- package/domains/worktree/worktree-process.mjs +55 -0
- package/domains/worktree/worktree-profile.mjs +800 -0
- package/domains/worktree/worktree-provider-gitlab.mjs +59 -0
- package/domains/worktree/worktree-reclaim.mjs +683 -0
- package/domains/worktree/worktree-review-refresh.mjs +574 -0
- package/domains/worktree/worktree-review-watch.mjs +661 -0
- package/domains/worktree/worktree-scan.mjs +510 -0
- package/domains/worktree/worktree-trace-test-worker.mjs +23 -0
- package/domains/worktree/worktree-trace.mjs +478 -0
- package/manage-worktrees/SKILL.md +87 -0
- package/manage-worktrees/agents/openai.yaml +4 -0
- package/manage-worktrees/scripts/worktree-mgr.mjs +10 -0
- package/manage-worktrees/scripts/worktree-scan.mjs +10 -0
- package/orchestrate-subagents/SKILL.md +173 -0
- package/orchestrate-subagents/agents/openai.yaml +4 -0
- package/orchestrate-subagents/scripts/contract-tool.mjs +10 -0
- package/orchestrate-subagents/scripts/host_capability_cache.mjs +10 -0
- package/orchestrate-subagents/scripts/orchestration-ledger.mjs +10 -0
- package/orchestrate-subagents/scripts/orchestration-reflection.mjs +10 -0
- package/orchestrate-subagents/scripts/resolve_model_policy.mjs +10 -0
- package/orchestrate-subagents/scripts/review-budget.mjs +10 -0
- package/orchestrate-subagents/scripts/worker-capability-preflight.mjs +10 -0
- package/package.json +48 -0
- package/run-agent-verify-loop/SKILL.md +127 -0
- package/run-agent-verify-loop/agents/openai.yaml +4 -0
- package/run-agent-verify-loop/scripts/loop-runtime.mjs +10 -0
- package/schemas/artifact-ref-v1.schema.json +23 -0
- package/schemas/batch-result-v1.schema.json +138 -0
- package/schemas/controller-recheck-record-v1.schema.json +22 -0
- package/schemas/convergence-report-v1.schema.json +9 -0
- package/schemas/effective-worker-capability-v1.schema.json +36 -0
- package/schemas/embedded-verification-record-v1.schema.json +32 -0
- package/schemas/evidence-package-v1.schema.json +41 -0
- package/schemas/improvement-proposal-v1.schema.json +18 -0
- package/schemas/loop-state-v1.schema.json +34 -0
- package/schemas/model-policy-resolution-v1.schema.json +41 -0
- package/schemas/orchestration-ledger-v1.schema.json +110 -0
- package/schemas/reflection-record-v1.schema.json +24 -0
- package/schemas/review-result-v1.schema.json +37 -0
- package/schemas/task-contract-v1.schema.json +83 -0
- package/schemas/verification-profile-v1.schema.json +60 -0
- package/schemas/worker-capability-requirements-v1.schema.json +21 -0
- package/schemas/worktree-binding-v1.schema.json +14 -0
- package/shell-manifest.json +79 -0
- package/verify-agent-output/SKILL.md +119 -0
- package/verify-agent-output/agents/openai.yaml +4 -0
- package/verify-agent-output/scripts/verification-runtime.mjs +10 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { homedir, platform } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { parseJsonStrict } from './contract-tool.mjs';
|
|
10
|
+
|
|
11
|
+
const CONFIG_SCHEMA_VERSION = 2;
|
|
12
|
+
const RESULT_SCHEMA_VERSION = 1;
|
|
13
|
+
const HOST_KEYS = new Set(['schema_version', 'host', 'effort_order', 'tier_order', 'tiers', 'dynamic_adjustment']);
|
|
14
|
+
const TIER_KEYS = new Set(['models', 'effort', 'channel', 'dispatch']);
|
|
15
|
+
const EFFORT_KEYS = new Set(['default', 'min', 'max']);
|
|
16
|
+
const DYNAMIC_KEYS = new Set(['enabled', 'max_attempts', 'allowed_actions']);
|
|
17
|
+
const DISPATCH_PROVENANCE = new Set(['explicit', 'inherited-controller', 'host-default']);
|
|
18
|
+
export const ADJUSTMENT_ACTIONS = new Set(['retry_same', 'raise_effort', 'switch_model', 'promote_tier', 'fresh_context', 'change_strategy']);
|
|
19
|
+
export const FAILURE_KINDS = new Set(['implementation_defect', 'reasoning_gap', 'context_gap', 'strategy_gap', 'environment_fault', 'contract_gap', 'safety', 'undecidable']);
|
|
20
|
+
const REROUTABLE_FAILURES = new Set(['implementation_defect', 'reasoning_gap', 'context_gap', 'strategy_gap']);
|
|
21
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
22
|
+
|
|
23
|
+
export class PolicyError extends Error {}
|
|
24
|
+
|
|
25
|
+
function expandUserPath(value, home = homedir()) {
|
|
26
|
+
if (value === '~') return home;
|
|
27
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return join(home, value.slice(2));
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveUserPath(value, home = homedir()) {
|
|
32
|
+
return resolvePath(expandUserPath(value, home));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function userConfigDir(env = process.env, platformName = platform(), home = homedir()) {
|
|
36
|
+
const explicit = env.ORCHESTRATE_SUBAGENTS_CONFIG;
|
|
37
|
+
if (explicit) return resolveUserPath(explicit, home);
|
|
38
|
+
const suffix = join('agent-skills', 'orchestrate-subagents');
|
|
39
|
+
if (platformName.startsWith('win')) return join(env.APPDATA || join(home, 'AppData', 'Roaming'), suffix);
|
|
40
|
+
if (platformName === 'darwin') return join(home, 'Library', 'Application Support', suffix);
|
|
41
|
+
return join(env.XDG_CONFIG_HOME || join(home, '.config'), suffix);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Capability cache still has a project-scoped facts directory. Model preferences do not load from it.
|
|
45
|
+
export function repositoryRoot(candidatePath) {
|
|
46
|
+
const requested = resolveUserPath(candidatePath);
|
|
47
|
+
let current = requested;
|
|
48
|
+
while (!existsSync(current)) {
|
|
49
|
+
const parent = dirname(current);
|
|
50
|
+
if (parent === current) return requested;
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
while (true) {
|
|
54
|
+
if (existsSync(join(current, '.git'))) return current;
|
|
55
|
+
const parent = dirname(current);
|
|
56
|
+
if (parent === current) return requested;
|
|
57
|
+
current = parent;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function projectConfigDir(repoPath) {
|
|
62
|
+
return join(repositoryRoot(repoPath), '.agents', 'orchestrate-subagents');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function assertObject(value, label) {
|
|
66
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new PolicyError(`${label} must be a JSON object`);
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function checkKeys(value, allowed, label) {
|
|
71
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key)).sort();
|
|
72
|
+
if (unknown.length) throw new PolicyError(`${label} has unknown keys: ${unknown.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function uniqueStrings(value, label, { nonEmpty = true } = {}) {
|
|
76
|
+
if (!Array.isArray(value) || (nonEmpty && value.length === 0) || value.some((item) => typeof item !== 'string' || !item.trim()) || new Set(value).size !== value.length) {
|
|
77
|
+
throw new PolicyError(`${label} must be a${nonEmpty ? ' non-empty' : ''} unique string array`);
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readJson(path, label = path) {
|
|
83
|
+
try { return parseJsonStrict(readFileSync(path, 'utf8')); }
|
|
84
|
+
catch (error) { throw new PolicyError(`cannot read ${label}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadHostConfig(path, expectedHost) {
|
|
88
|
+
if (!existsSync(path)) return null;
|
|
89
|
+
const config = assertObject(readJson(path), path);
|
|
90
|
+
checkKeys(config, HOST_KEYS, path);
|
|
91
|
+
if (config.schema_version !== CONFIG_SCHEMA_VERSION) throw new PolicyError(`${path} must declare schema_version ${CONFIG_SCHEMA_VERSION}`);
|
|
92
|
+
if (config.host !== expectedHost) throw new PolicyError(`${path} host must be ${JSON.stringify(expectedHost)}`);
|
|
93
|
+
|
|
94
|
+
const effortOrder = uniqueStrings(config.effort_order, `${path}.effort_order`);
|
|
95
|
+
const effortRank = new Map(effortOrder.map((name, index) => [name, index]));
|
|
96
|
+
const tierOrder = uniqueStrings(config.tier_order, `${path}.tier_order`);
|
|
97
|
+
const tiers = assertObject(config.tiers, `${path}.tiers`);
|
|
98
|
+
if (Object.keys(tiers).length !== tierOrder.length || tierOrder.some((name) => !Object.hasOwn(tiers, name)) || Object.keys(tiers).some((name) => !tierOrder.includes(name))) {
|
|
99
|
+
throw new PolicyError(`${path}.tier_order and tiers must name the same tiers exactly once`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const [name, raw] of Object.entries(tiers)) {
|
|
103
|
+
const tier = assertObject(raw, `${path}.tiers.${name}`);
|
|
104
|
+
checkKeys(tier, TIER_KEYS, `${path}.tiers.${name}`);
|
|
105
|
+
uniqueStrings(tier.models, `${path}.tiers.${name}.models`);
|
|
106
|
+
const effort = assertObject(tier.effort, `${path}.tiers.${name}.effort`);
|
|
107
|
+
checkKeys(effort, EFFORT_KEYS, `${path}.tiers.${name}.effort`);
|
|
108
|
+
for (const key of EFFORT_KEYS) {
|
|
109
|
+
if (typeof effort[key] !== 'string' || !effortRank.has(effort[key])) throw new PolicyError(`${path}.tiers.${name}.effort.${key} must appear in effort_order`);
|
|
110
|
+
}
|
|
111
|
+
if (effortRank.get(effort.min) > effortRank.get(effort.default) || effortRank.get(effort.default) > effortRank.get(effort.max)) throw new PolicyError(`${path}.tiers.${name}.effort must satisfy min <= default <= max`);
|
|
112
|
+
if (typeof tier.channel !== 'string' || !tier.channel.trim()) throw new PolicyError(`${path}.tiers.${name}.channel must be a non-empty string`);
|
|
113
|
+
if (!DISPATCH_PROVENANCE.has(tier.dispatch)) throw new PolicyError(`${path}.tiers.${name}.dispatch is invalid`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const dynamic = assertObject(config.dynamic_adjustment, `${path}.dynamic_adjustment`);
|
|
117
|
+
checkKeys(dynamic, DYNAMIC_KEYS, `${path}.dynamic_adjustment`);
|
|
118
|
+
if (typeof dynamic.enabled !== 'boolean') throw new PolicyError(`${path}.dynamic_adjustment.enabled must be boolean`);
|
|
119
|
+
if (!Number.isSafeInteger(dynamic.max_attempts) || dynamic.max_attempts < 1) throw new PolicyError(`${path}.dynamic_adjustment.max_attempts must be a positive safe integer`);
|
|
120
|
+
uniqueStrings(dynamic.allowed_actions, `${path}.dynamic_adjustment.allowed_actions`, { nonEmpty: false });
|
|
121
|
+
const unknownActions = dynamic.allowed_actions.filter((action) => !ADJUSTMENT_ACTIONS.has(action));
|
|
122
|
+
if (unknownActions.length) throw new PolicyError(`${path}.dynamic_adjustment.allowed_actions has invalid actions: ${unknownActions.join(', ')}`);
|
|
123
|
+
return config;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function rankOf(order, value, label) {
|
|
127
|
+
const rank = order.indexOf(value);
|
|
128
|
+
if (rank < 0) throw new PolicyError(`${label} ${JSON.stringify(value)} is not configured`);
|
|
129
|
+
return rank;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function chooseModel(tier, explicitModel, availableModels, userExplicit) {
|
|
133
|
+
if (explicitModel) {
|
|
134
|
+
if (!userExplicit && !tier.models.includes(explicitModel)) throw new PolicyError(`model ${JSON.stringify(explicitModel)} is outside the selected local tier`);
|
|
135
|
+
if (availableModels.length && !availableModels.includes(explicitModel)) throw new PolicyError(`model ${JSON.stringify(explicitModel)} is not exposed by the current host schema`);
|
|
136
|
+
return explicitModel;
|
|
137
|
+
}
|
|
138
|
+
if (!availableModels.length) throw new PolicyError('model discovery is unavailable; persisted tier candidates cannot be validated');
|
|
139
|
+
const selected = tier.models.find((model) => availableModels.includes(model));
|
|
140
|
+
if (!selected) throw new PolicyError('none of the selected tier models is exposed by the current host schema');
|
|
141
|
+
return selected;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateEffort(tier, effortOrder, effort, availableEfforts) {
|
|
145
|
+
const effortRank = new Map(effortOrder.map((name, index) => [name, index]));
|
|
146
|
+
if (!effortRank.has(effort)) throw new PolicyError(`effort ${JSON.stringify(effort)} is absent from effort_order`);
|
|
147
|
+
if (effortRank.get(effort) < effortRank.get(tier.effort.min) || effortRank.get(effort) > effortRank.get(tier.effort.max)) throw new PolicyError(`effort ${JSON.stringify(effort)} is outside tier range ${JSON.stringify(tier.effort.min)}..${JSON.stringify(tier.effort.max)}`);
|
|
148
|
+
if (availableEfforts.length && !availableEfforts.includes(effort)) throw new PolicyError(`effort ${JSON.stringify(effort)} is not exposed by the current host schema`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateAdjustment(args, config, selection, previous) {
|
|
152
|
+
if (args.attempt === 1) {
|
|
153
|
+
if (previous || args.action || args.failure_kind || args.failure_ref) throw new PolicyError('initial dispatch cannot declare retry inputs');
|
|
154
|
+
return { action: 'initial', failure_kind: null, failure_ref: null, previous_attempt_id: null };
|
|
155
|
+
}
|
|
156
|
+
if (!config.dynamic_adjustment.enabled) throw new PolicyError('dynamic adjustment is disabled by local config');
|
|
157
|
+
if (args.attempt > config.dynamic_adjustment.max_attempts) throw new PolicyError(`attempt ${args.attempt} exceeds local max_attempts ${config.dynamic_adjustment.max_attempts}`);
|
|
158
|
+
if (!previous) throw new PolicyError('retry dispatch requires --previous-dispatch');
|
|
159
|
+
const previousPatch = previous.dispatch_record_patch;
|
|
160
|
+
if (previous.schema_version !== RESULT_SCHEMA_VERSION || previous.record_type !== 'model-policy-resolution' || previous.host !== args.host || !previousPatch || typeof previousPatch !== 'object' || Array.isArray(previousPatch)) throw new PolicyError('previous resolution schema/host mismatch');
|
|
161
|
+
if (previousPatch.attempt !== args.attempt - 1) throw new PolicyError('previous resolution attempt is not immediately prior');
|
|
162
|
+
if (!ADJUSTMENT_ACTIONS.has(args.action)) throw new PolicyError('retry dispatch requires a valid --action');
|
|
163
|
+
if (!config.dynamic_adjustment.allowed_actions.includes(args.action)) throw new PolicyError(`adjustment action ${JSON.stringify(args.action)} is outside local config`);
|
|
164
|
+
if (!FAILURE_KINDS.has(args.failure_kind)) throw new PolicyError('retry dispatch requires a valid --failure-kind');
|
|
165
|
+
if (!REROUTABLE_FAILURES.has(args.failure_kind)) throw new PolicyError(`failure kind ${JSON.stringify(args.failure_kind)} must stop/re-contract instead of model rerouting`);
|
|
166
|
+
if (!DIGEST_PATTERN.test(args.failure_ref || '')) throw new PolicyError('retry dispatch requires a sha256 --failure-ref');
|
|
167
|
+
|
|
168
|
+
const sameTier = previousPatch.tier === selection.tier;
|
|
169
|
+
const sameModel = previousPatch.model === selection.model;
|
|
170
|
+
const sameEffort = previousPatch.reasoning_effort === selection.effort;
|
|
171
|
+
const effortRank = new Map(config.effort_order.map((name, index) => [name, index]));
|
|
172
|
+
if (args.action === 'retry_same' && !(sameTier && sameModel && sameEffort)) throw new PolicyError('retry_same must preserve tier, model and effort');
|
|
173
|
+
if (args.action === 'raise_effort' && !(sameTier && sameModel && effortRank.get(selection.effort) > effortRank.get(previousPatch.reasoning_effort))) throw new PolicyError('raise_effort must keep tier/model and increase effort');
|
|
174
|
+
if (args.action === 'switch_model' && sameModel) throw new PolicyError('switch_model must change the model');
|
|
175
|
+
if (args.action === 'promote_tier' && rankOf(config.tier_order, selection.tier, 'tier') <= rankOf(config.tier_order, previousPatch.tier, 'previous tier')) throw new PolicyError('promote_tier must move to a higher configured tier');
|
|
176
|
+
if (args.action === 'fresh_context' && !(sameTier && sameModel && sameEffort)) throw new PolicyError('fresh_context changes worker context, not model parameters');
|
|
177
|
+
return { action: args.action, failure_kind: args.failure_kind, failure_ref: args.failure_ref, previous_attempt_id: previousPatch.attempt_id };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function resolve(args) {
|
|
181
|
+
const configRoot = args.config_dir ? resolveUserPath(args.config_dir) : userConfigDir();
|
|
182
|
+
const configPath = join(configRoot, 'hosts', `${args.host}.json`);
|
|
183
|
+
const config = loadHostConfig(configPath, args.host);
|
|
184
|
+
if (!config) throw new PolicyError(`no local host config: ${configPath}`);
|
|
185
|
+
const tierName = args.tier;
|
|
186
|
+
const tier = Object.hasOwn(config.tiers, tierName) ? config.tiers[tierName] : null;
|
|
187
|
+
if (!tier) throw new PolicyError(`tier ${JSON.stringify(tierName)} is not configured`);
|
|
188
|
+
const availableModels = args.available_model || [];
|
|
189
|
+
const availableEfforts = args.available_effort || [];
|
|
190
|
+
const availableChannels = args.available_channel || [];
|
|
191
|
+
const userExplicit = args.user_explicit === true;
|
|
192
|
+
if (userExplicit && (!args.model || !args.effort)) throw new PolicyError('--user-explicit requires explicit --model and --effort values');
|
|
193
|
+
const model = chooseModel(tier, args.model, availableModels, userExplicit);
|
|
194
|
+
const effort = args.effort || tier.effort.default;
|
|
195
|
+
validateEffort(tier, config.effort_order, effort, availableEfforts);
|
|
196
|
+
if (availableChannels.length && !availableChannels.includes(tier.channel)) throw new PolicyError(`channel ${JSON.stringify(tier.channel)} is not exposed by the current host schema`);
|
|
197
|
+
if ((!availableModels.length || !availableEfforts.length) && !userExplicit) throw new PolicyError('persisted local config requires live model and effort discovery');
|
|
198
|
+
|
|
199
|
+
const previous = args.previous_dispatch ? assertObject(readJson(resolveUserPath(args.previous_dispatch), args.previous_dispatch), args.previous_dispatch) : null;
|
|
200
|
+
const selection = { tier: tierName, model, effort };
|
|
201
|
+
const adjustment = validateAdjustment(args, config, selection, previous);
|
|
202
|
+
const dispatchRecordPatch = {
|
|
203
|
+
attempt_id: args.attempt_id || randomUUID(),
|
|
204
|
+
attempt: args.attempt,
|
|
205
|
+
previous_attempt_id: adjustment.previous_attempt_id,
|
|
206
|
+
tier: tierName,
|
|
207
|
+
model,
|
|
208
|
+
reasoning_effort: effort,
|
|
209
|
+
adjustment_action: adjustment.action,
|
|
210
|
+
failure_kind: adjustment.failure_kind,
|
|
211
|
+
failure_ref: adjustment.failure_ref,
|
|
212
|
+
selection_reason: args.selection_reason,
|
|
213
|
+
config_source: [configPath],
|
|
214
|
+
configuration_state: userExplicit ? 'user-explicit' : 'persisted-config',
|
|
215
|
+
model_resolution_state: availableModels.length ? 'discovered-and-validated' : 'user-explicit-unverifiable',
|
|
216
|
+
dispatch_provenance: tier.dispatch,
|
|
217
|
+
max_attempts: config.dynamic_adjustment.max_attempts,
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
schema_version: RESULT_SCHEMA_VERSION,
|
|
221
|
+
record_type: 'model-policy-resolution',
|
|
222
|
+
host: args.host,
|
|
223
|
+
tier_rank: rankOf(config.tier_order, tierName, 'tier'),
|
|
224
|
+
channel: tier.channel,
|
|
225
|
+
selection_source: userExplicit ? 'user-explicit' : 'local-config',
|
|
226
|
+
dispatch_record_patch: dispatchRecordPatch,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function explain(result) {
|
|
231
|
+
const patch = result.dispatch_record_patch;
|
|
232
|
+
return [
|
|
233
|
+
`attempt: ${patch.attempt}`,
|
|
234
|
+
`tier: ${patch.tier}`,
|
|
235
|
+
`model: ${patch.model}`,
|
|
236
|
+
`effort: ${patch.reasoning_effort}`,
|
|
237
|
+
`action: ${patch.adjustment_action}`,
|
|
238
|
+
`channel: ${result.channel}`,
|
|
239
|
+
`provenance: ${patch.dispatch_provenance}`,
|
|
240
|
+
`configuration_state: ${patch.configuration_state}`,
|
|
241
|
+
`model_resolution_state: ${patch.model_resolution_state}`,
|
|
242
|
+
`selection_reason: ${patch.selection_reason}`,
|
|
243
|
+
'source:',
|
|
244
|
+
...patch.config_source.map((value) => ` - ${value}`),
|
|
245
|
+
].join('\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function parseCli(argv = process.argv.slice(2)) {
|
|
249
|
+
const options = {
|
|
250
|
+
host: '', config_dir: null, tier: '', model: null, effort: null, selection_reason: '', user_explicit: false,
|
|
251
|
+
attempt: 1, attempt_id: null, previous_dispatch: null, action: null, failure_kind: null, failure_ref: null,
|
|
252
|
+
available_model: [], available_effort: [], available_channel: [], explain: false,
|
|
253
|
+
};
|
|
254
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
255
|
+
const arg = argv[i];
|
|
256
|
+
const takeValue = () => {
|
|
257
|
+
const value = argv[i + 1];
|
|
258
|
+
if (value === undefined || value.startsWith('--')) throw new PolicyError(`${arg} 缺少值`);
|
|
259
|
+
i += 1;
|
|
260
|
+
return value;
|
|
261
|
+
};
|
|
262
|
+
if (arg === '--host') options.host = takeValue();
|
|
263
|
+
else if (arg === '--config-dir') options.config_dir = takeValue();
|
|
264
|
+
else if (arg === '--tier') options.tier = takeValue();
|
|
265
|
+
else if (arg === '--model') options.model = takeValue();
|
|
266
|
+
else if (arg === '--effort') options.effort = takeValue();
|
|
267
|
+
else if (arg === '--selection-reason') options.selection_reason = takeValue();
|
|
268
|
+
else if (arg === '--user-explicit') options.user_explicit = true;
|
|
269
|
+
else if (arg === '--attempt') options.attempt = Number(takeValue());
|
|
270
|
+
else if (arg === '--attempt-id') options.attempt_id = takeValue();
|
|
271
|
+
else if (arg === '--previous-dispatch') options.previous_dispatch = takeValue();
|
|
272
|
+
else if (arg === '--action') options.action = takeValue();
|
|
273
|
+
else if (arg === '--failure-kind') options.failure_kind = takeValue();
|
|
274
|
+
else if (arg === '--failure-ref') options.failure_ref = takeValue();
|
|
275
|
+
else if (arg === '--available-model') options.available_model.push(takeValue());
|
|
276
|
+
else if (arg === '--available-effort') options.available_effort.push(takeValue());
|
|
277
|
+
else if (arg === '--available-channel') options.available_channel.push(takeValue());
|
|
278
|
+
else if (arg === '--explain') options.explain = true;
|
|
279
|
+
else throw new PolicyError(`unknown option: ${arg}`);
|
|
280
|
+
}
|
|
281
|
+
if (!options.host) throw new PolicyError('缺少 --host');
|
|
282
|
+
if (!options.tier) throw new PolicyError('缺少 --tier');
|
|
283
|
+
if (!options.selection_reason || /[\r\n]/u.test(options.selection_reason)) throw new PolicyError('缺少合法 --selection-reason');
|
|
284
|
+
if (!Number.isSafeInteger(options.attempt) || options.attempt < 1) throw new PolicyError('--attempt 必须为正安全整数');
|
|
285
|
+
return options;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function main(argv = process.argv.slice(2)) {
|
|
289
|
+
const options = parseCli(argv);
|
|
290
|
+
const result = resolve(options);
|
|
291
|
+
process.stdout.write(options.explain ? `${explain(result)}\n` : `${JSON.stringify(result, null, 2)}\n`);
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function entry() {
|
|
296
|
+
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
|
|
297
|
+
catch { return pathToFileURL(resolvePath(process.argv[1] ?? '')).href === import.meta.url; }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function runCli(argv = process.argv.slice(2)) {
|
|
301
|
+
try { return main(argv); }
|
|
302
|
+
catch (error) {
|
|
303
|
+
if (error instanceof PolicyError) {
|
|
304
|
+
process.stderr.write(`model policy error: ${error.message}\n`);
|
|
305
|
+
return 2;
|
|
306
|
+
}
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (entry()) process.exitCode = runCli();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { allOptionNames, isHelpRequest, renderCliHelp } from '../../core/cli-help.mjs';
|
|
9
|
+
import { parseJsonStrict } from './contract-tool.mjs';
|
|
10
|
+
|
|
11
|
+
// CLI 命令与参数的唯一真源:选项白名单、`--help` 清单和命令错误信息都从这里推导。
|
|
12
|
+
const CLI_SPEC = {
|
|
13
|
+
capabilities: {},
|
|
14
|
+
evaluate: { required: ['policy', 'history', 'request'] },
|
|
15
|
+
};
|
|
16
|
+
const CLI_OPTIONS = allOptionNames(CLI_SPEC);
|
|
17
|
+
const CLI_NOTES = [
|
|
18
|
+
'--history 是 JSON 数组:没有历史 review 传 [],已有 review 传 [{review_id, artifact_digest, lens, kind, outcome}, ...]。',
|
|
19
|
+
'allowed:false 时退出码为 2,按 next_action 缩小输入、复用现有 review 或升级给人。',
|
|
20
|
+
];
|
|
21
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
|
22
|
+
const REVIEW_KINDS = new Set(['primary', 'escalation']);
|
|
23
|
+
const REVIEW_OUTCOMES = new Set(['pass', 'fail', 'undecidable', 'blocked_safety']);
|
|
24
|
+
const ESCALATION_TRIGGERS = new Set(['undecidable', 'evidence_conflict', 'protocol_ambiguity']);
|
|
25
|
+
|
|
26
|
+
export class ReviewBudgetError extends Error {}
|
|
27
|
+
|
|
28
|
+
/** @param {string} path */
|
|
29
|
+
function readJson(path) {
|
|
30
|
+
return parseJsonStrict(readFileSync(resolve(path), 'utf8'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {unknown} value @param {string} label @param {number} minimum */
|
|
34
|
+
function integer(value, label, minimum = 0) {
|
|
35
|
+
if (!Number.isSafeInteger(value) || Number(value) < minimum) throw new ReviewBudgetError(`${label} 必须是 >= ${minimum} 的安全整数`);
|
|
36
|
+
return Number(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {Record<string,any>} policy */
|
|
40
|
+
export function validateReviewPolicy(policy) {
|
|
41
|
+
const keys = [
|
|
42
|
+
'schema_version',
|
|
43
|
+
'max_primary_reviews_per_artifact',
|
|
44
|
+
'max_escalation_reviews_per_artifact',
|
|
45
|
+
'require_distinct_lens',
|
|
46
|
+
'review_only_after_smoke_pass',
|
|
47
|
+
'max_review_input_tokens',
|
|
48
|
+
];
|
|
49
|
+
if (!policy || policy.schema_version !== 1 || Object.keys(policy).some((key) => !keys.includes(key))) throw new ReviewBudgetError('review policy schema/字段无效');
|
|
50
|
+
const normalized = {
|
|
51
|
+
schema_version: 1,
|
|
52
|
+
max_primary_reviews_per_artifact: integer(policy.max_primary_reviews_per_artifact, 'max_primary_reviews_per_artifact'),
|
|
53
|
+
max_escalation_reviews_per_artifact: integer(policy.max_escalation_reviews_per_artifact, 'max_escalation_reviews_per_artifact'),
|
|
54
|
+
require_distinct_lens: policy.require_distinct_lens,
|
|
55
|
+
review_only_after_smoke_pass: policy.review_only_after_smoke_pass,
|
|
56
|
+
max_review_input_tokens: integer(policy.max_review_input_tokens, 'max_review_input_tokens', 1),
|
|
57
|
+
};
|
|
58
|
+
if (typeof normalized.require_distinct_lens !== 'boolean' || typeof normalized.review_only_after_smoke_pass !== 'boolean') throw new ReviewBudgetError('review policy boolean 字段无效');
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {unknown} value */
|
|
63
|
+
function validateHistory(value) {
|
|
64
|
+
if (!Array.isArray(value)) throw new ReviewBudgetError('history 必须是数组:没有历史 review 传 [],已有 review 传 [{review_id, artifact_digest, lens, kind, outcome}, ...]');
|
|
65
|
+
const ids = new Set();
|
|
66
|
+
return value.map((item) => {
|
|
67
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) throw new ReviewBudgetError('history item 无效');
|
|
68
|
+
if (typeof item.review_id !== 'string' || !item.review_id || ids.has(item.review_id)) throw new ReviewBudgetError('history review_id 缺失或重复');
|
|
69
|
+
ids.add(item.review_id);
|
|
70
|
+
if (!DIGEST.test(String(item.artifact_digest ?? '')) || typeof item.lens !== 'string' || !item.lens || !REVIEW_KINDS.has(item.kind) || !REVIEW_OUTCOMES.has(item.outcome)) throw new ReviewBudgetError(`history item 无效: ${item.review_id}`);
|
|
71
|
+
return item;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {Record<string,any>} request */
|
|
76
|
+
function validateRequest(request) {
|
|
77
|
+
if (!request || request.schema_version !== 1 || !DIGEST.test(String(request.artifact_digest ?? ''))) throw new ReviewBudgetError('review request schema/artifact_digest 无效');
|
|
78
|
+
if (typeof request.lens !== 'string' || !request.lens.trim() || !REVIEW_KINDS.has(request.kind)) throw new ReviewBudgetError('review request lens/kind 无效');
|
|
79
|
+
if (typeof request.decision_impact !== 'string' || !request.decision_impact.trim()) throw new ReviewBudgetError('review request 必须说明 decision_impact');
|
|
80
|
+
if (typeof request.smoke_passed !== 'boolean') throw new ReviewBudgetError('review request smoke_passed 必须是 boolean');
|
|
81
|
+
integer(request.estimated_input_tokens, 'estimated_input_tokens');
|
|
82
|
+
if (request.kind === 'escalation' && !ESCALATION_TRIGGERS.has(request.escalation_trigger)) throw new ReviewBudgetError('escalation review 必须给出 undecidable/evidence_conflict/protocol_ambiguity trigger');
|
|
83
|
+
if (request.kind === 'primary' && request.escalation_trigger != null) throw new ReviewBudgetError('primary review 不接受 escalation_trigger');
|
|
84
|
+
return request;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 在派 reviewer 前执行的纯函数门禁;不自行派发、不改写历史。
|
|
89
|
+
* @param {Record<string,any>} rawPolicy @param {unknown} rawHistory @param {Record<string,any>} rawRequest
|
|
90
|
+
*/
|
|
91
|
+
export function evaluateReviewBudget(rawPolicy, rawHistory, rawRequest) {
|
|
92
|
+
const policy = validateReviewPolicy(rawPolicy);
|
|
93
|
+
const history = validateHistory(rawHistory);
|
|
94
|
+
const request = validateRequest(rawRequest);
|
|
95
|
+
const sameArtifact = history.filter((item) => item.artifact_digest === request.artifact_digest);
|
|
96
|
+
const primaryCount = sameArtifact.filter((item) => item.kind === 'primary').length;
|
|
97
|
+
const escalationCount = sameArtifact.filter((item) => item.kind === 'escalation').length;
|
|
98
|
+
const result = (allowed, code, nextAction) => ({
|
|
99
|
+
allowed,
|
|
100
|
+
code,
|
|
101
|
+
next_action: nextAction,
|
|
102
|
+
artifact_digest: request.artifact_digest,
|
|
103
|
+
counts: { primary: primaryCount, escalation: escalationCount, total: sameArtifact.length },
|
|
104
|
+
remaining: {
|
|
105
|
+
primary: Math.max(0, policy.max_primary_reviews_per_artifact - primaryCount),
|
|
106
|
+
escalation: Math.max(0, policy.max_escalation_reviews_per_artifact - escalationCount),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (policy.review_only_after_smoke_pass && !request.smoke_passed) return result(false, 'SMOKE_REQUIRED', 'run_smoke_first');
|
|
111
|
+
if (request.estimated_input_tokens > policy.max_review_input_tokens) return result(false, 'REVIEW_INPUT_BUDGET_EXCEEDED', 'project_contract_or_reduce_scope');
|
|
112
|
+
if (sameArtifact.some((item) => item.outcome === 'blocked_safety')) return result(false, 'SAFETY_STOP', 'escalate_to_human');
|
|
113
|
+
if (policy.require_distinct_lens && sameArtifact.some((item) => item.lens === request.lens)) return result(false, 'DUPLICATE_REVIEW_LENS', 'reuse_existing_review_or_choose_decision_changing_lens');
|
|
114
|
+
|
|
115
|
+
if (request.kind === 'primary') {
|
|
116
|
+
if (primaryCount >= policy.max_primary_reviews_per_artifact) return result(false, 'PRIMARY_REVIEW_LIMIT', 'reuse_existing_review');
|
|
117
|
+
return result(true, 'ALLOWED_PRIMARY', 'dispatch_one_reviewer');
|
|
118
|
+
}
|
|
119
|
+
if (primaryCount === 0) return result(false, 'PRIMARY_REVIEW_MISSING', 'run_primary_review_first');
|
|
120
|
+
if (escalationCount >= policy.max_escalation_reviews_per_artifact) return result(false, 'ESCALATION_REVIEW_LIMIT', 'stop_or_escalate_to_human');
|
|
121
|
+
if (request.escalation_trigger === 'undecidable' && !sameArtifact.some((item) => item.outcome === 'undecidable')) return result(false, 'UNDECIDABLE_EVIDENCE_MISSING', 'reuse_primary_result');
|
|
122
|
+
return result(true, 'ALLOWED_ESCALATION', 'dispatch_distinct_escalation_reviewer');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function usage() {
|
|
126
|
+
return renderCliHelp('review-budget.mjs', CLI_SPEC, CLI_NOTES);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** @param {string[]} argv */
|
|
130
|
+
export function main(argv = process.argv.slice(2)) {
|
|
131
|
+
if (isHelpRequest(argv)) return { help: usage() };
|
|
132
|
+
if (argv[0] === 'capabilities') return { runtime: 'review-budget', runtime_version: '1.0.0', contracts: { review_policy: [1], review_budget_request: [1] }, features: ['per-artifact-review-limit', 'distinct-lens-gate', 'smoke-first-gate', 'review-input-token-limit', 'safety-stop'] };
|
|
133
|
+
if (argv[0] !== 'evaluate') throw new ReviewBudgetError(`命令必须是 ${Object.keys(CLI_SPEC).join('/')};参数清单见 --help`);
|
|
134
|
+
const options = {};
|
|
135
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
136
|
+
const token = argv[index];
|
|
137
|
+
if (!token.startsWith('--') || !CLI_OPTIONS.has(token.slice(2)) || !argv[index + 1] || argv[index + 1].startsWith('--')) throw new ReviewBudgetError(`选项无效或缺值: ${token};参数清单见 --help`);
|
|
138
|
+
options[token.slice(2)] = argv[++index];
|
|
139
|
+
}
|
|
140
|
+
for (const key of ['policy', 'history', 'request']) if (!options[key]) throw new ReviewBudgetError(`缺少 --${key}`);
|
|
141
|
+
return evaluateReviewBudget(readJson(options.policy), readJson(options.history), readJson(options.request));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function isCliEntry(metaUrl = import.meta.url, argv1 = process.argv[1]) {
|
|
145
|
+
if (!argv1) return false;
|
|
146
|
+
try { return realpathSync(fileURLToPath(metaUrl)) === realpathSync(argv1); }
|
|
147
|
+
catch { return pathToFileURL(resolve(argv1)).href === metaUrl; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function runCli(argv = process.argv.slice(2)) {
|
|
151
|
+
try {
|
|
152
|
+
const result = main(argv);
|
|
153
|
+
if (result.help) process.stdout.write(result.help);
|
|
154
|
+
else process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
155
|
+
return result.allowed === false ? 2 : 0;
|
|
156
|
+
} catch (error) {
|
|
157
|
+
process.stderr.write(`${JSON.stringify({ error: 'invalid_input', message: error instanceof Error ? error.message : String(error) })}\n`);
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isCliEntry()) process.exitCode = runCli();
|