@tech-leads-club/harness-toolkit 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- package/tools/uninstall-runtime.ts +463 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Decision } from "../../contracts/decision.ts";
|
|
2
|
+
import { isUnderCodePaths } from "../policy/policy.loader.ts";
|
|
3
|
+
import { hasRecentEvidence } from "./ship.ledger.ts";
|
|
4
|
+
import type { ShipClaim } from "./ship.types.ts";
|
|
5
|
+
|
|
6
|
+
const STRUCTURED = /(?:^|\n)\s*HARNESS_SHIP_CLAIM:\s*(.+?)\s*(?=\n|$)/;
|
|
7
|
+
|
|
8
|
+
export function detectShipClaim(text: string): ShipClaim | null {
|
|
9
|
+
const trimmed = text.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const structured = trimmed.match(STRUCTURED);
|
|
14
|
+
const body = structured?.[1]?.trim();
|
|
15
|
+
if (!body) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
kind: "structured",
|
|
20
|
+
snippet: `HARNESS_SHIP_CLAIM: ${body}`.slice(0, 280),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pathExcluded(relativePath: string, excludes: string[]): boolean {
|
|
25
|
+
const norm = relativePath.replace(/\\/g, "/");
|
|
26
|
+
for (const raw of excludes) {
|
|
27
|
+
const pattern = raw.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
28
|
+
if (!pattern) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (pattern.endsWith("/**")) {
|
|
32
|
+
const base = pattern.slice(0, -3);
|
|
33
|
+
if (norm === base || norm.startsWith(`${base}/`)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (pattern.endsWith("/")) {
|
|
39
|
+
if (norm.startsWith(pattern) || norm.startsWith(`${pattern.slice(0, -1)}/`)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (pattern.includes("*")) {
|
|
45
|
+
const re = new RegExp(
|
|
46
|
+
`^${pattern
|
|
47
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
48
|
+
.replace(/\*\*/g, ".*")
|
|
49
|
+
.replace(/\*/g, "[^/]*")}$`,
|
|
50
|
+
);
|
|
51
|
+
if (re.test(norm)) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (norm === pattern || norm.startsWith(`${pattern}/`)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function touchesRuntime(relativePaths: string[], prefixes: string[], excludes: string[]): boolean {
|
|
64
|
+
return relativePaths.some((path) => {
|
|
65
|
+
if (pathExcluded(path, excludes)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return isUnderCodePaths(path, prefixes) || /^Dockerfile(\.|$)/.test(path);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function recentShipClaimActive(
|
|
73
|
+
lastShipClaimAt: string | undefined,
|
|
74
|
+
windowMinutes: number,
|
|
75
|
+
now = Date.now(),
|
|
76
|
+
): boolean {
|
|
77
|
+
if (!lastShipClaimAt) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const at = Date.parse(lastShipClaimAt);
|
|
81
|
+
if (Number.isNaN(at)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return now - at < windowMinutes * 60 * 1000;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function evaluateEmptyDiffAntiShip(args: {
|
|
88
|
+
enabled: boolean;
|
|
89
|
+
recentShipClaim: boolean;
|
|
90
|
+
changedFilesCount: number;
|
|
91
|
+
}): Decision {
|
|
92
|
+
if (args.enabled && args.recentShipClaim && args.changedFilesCount === 0) {
|
|
93
|
+
return {
|
|
94
|
+
kind: "continue",
|
|
95
|
+
text: [
|
|
96
|
+
"BLOCKED: HARNESS_SHIP_CLAIM with no file diff.",
|
|
97
|
+
"TRIED: inspected git working tree / changed files.",
|
|
98
|
+
"NEED: either implement the remaining work or remove the ship claim — do not claim ship on an empty diff.",
|
|
99
|
+
].join("\n"),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { kind: "abstain" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function evaluateShipEvidenceGate(args: {
|
|
106
|
+
enabled: boolean;
|
|
107
|
+
recentShipClaim: boolean;
|
|
108
|
+
changedFiles: string[];
|
|
109
|
+
runtimePathPrefixes: string[];
|
|
110
|
+
runtimePathExcludes: string[];
|
|
111
|
+
evidenceDir: string | null;
|
|
112
|
+
evidenceMaxAgeHours: number;
|
|
113
|
+
/** Newest mtime among the turn's changed code files. Evidence written before it certifies an older tree. */
|
|
114
|
+
evidenceNotBeforeMs?: number;
|
|
115
|
+
}): Decision {
|
|
116
|
+
if (!args.enabled || !args.recentShipClaim || args.changedFiles.length === 0) {
|
|
117
|
+
return { kind: "abstain" };
|
|
118
|
+
}
|
|
119
|
+
if (!touchesRuntime(args.changedFiles, args.runtimePathPrefixes, args.runtimePathExcludes)) {
|
|
120
|
+
return { kind: "abstain" };
|
|
121
|
+
}
|
|
122
|
+
const hasEvidence =
|
|
123
|
+
args.evidenceDir !== null &&
|
|
124
|
+
hasRecentEvidence(args.evidenceDir, args.evidenceMaxAgeHours, args.evidenceNotBeforeMs);
|
|
125
|
+
if (hasEvidence) {
|
|
126
|
+
return { kind: "abstain" };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
kind: "continue",
|
|
130
|
+
text: [
|
|
131
|
+
"BLOCKED: HARNESS_SHIP_CLAIM without recent production PASS evidence.",
|
|
132
|
+
`TRIED: checked ${args.evidenceDir ?? "(no evidenceDir configured)"}/*/90-verdict.txt.`,
|
|
133
|
+
// why: producer form. It names the move to make rather than the move to avoid.
|
|
134
|
+
"NEED: run the verification after the last code change, then cite the verdict path — evidence written before the change certifies the older tree.",
|
|
135
|
+
].join("\n"),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ShipClaimKind = "structured";
|
|
2
|
+
|
|
3
|
+
export type ShipClaim = {
|
|
4
|
+
kind: ShipClaimKind;
|
|
5
|
+
snippet: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type ShipLedgerEvent = "claim" | "challenge" | "pass";
|
|
9
|
+
|
|
10
|
+
export type ShipLedgerRow = {
|
|
11
|
+
ts: string;
|
|
12
|
+
provider: string;
|
|
13
|
+
event: ShipLedgerEvent;
|
|
14
|
+
claimKind?: ShipClaimKind;
|
|
15
|
+
gate?: "ship" | "empty-diff";
|
|
16
|
+
files?: string[];
|
|
17
|
+
evidenceDir?: string | null;
|
|
18
|
+
detail?: string;
|
|
19
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { projectStateDir } from "../../platform/paths.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The harness computes an identity for a failure, counts its repeats to detect stagnation, and used to delete the
|
|
7
|
+
* record at the exact moment the gate went green — destroying the pairing of *that failure* with *what resolved
|
|
8
|
+
* it*. That pairing is the one thing nothing else has, because nothing else holds a failure fingerprint and the
|
|
9
|
+
* diff in the same process ([/decisions/ad-028.md](/decisions/ad-028.md)).
|
|
10
|
+
*
|
|
11
|
+
* invariant: this is history, never instruction. AD-024 established that a plan names files from evidence and
|
|
12
|
+
* never from proximity; a previous resolution is evidence, and presenting it as "edit these" would reintroduce
|
|
13
|
+
* exactly the harm that decision removed.
|
|
14
|
+
*/
|
|
15
|
+
export type Resolution = {
|
|
16
|
+
/** Files that changed between the failing state and the passing one. */
|
|
17
|
+
files: string[];
|
|
18
|
+
/** ISO timestamp of the resolution, so the most recent one wins when a fingerprint is resolved twice. */
|
|
19
|
+
at: string;
|
|
20
|
+
/** How the gate was named when it failed, so a reader knows which gate this history belongs to. */
|
|
21
|
+
gate: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ResolutionStore = Record<string, Resolution>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* why: bounded because this file is read on the failure path, which runs while an operator waits. The bound is
|
|
28
|
+
* applied by dropping the oldest resolutions, so a long-lived repository keeps the recent ones rather than the
|
|
29
|
+
* first ones it ever saw.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_RESOLUTIONS = 200;
|
|
32
|
+
|
|
33
|
+
/** why: at most eight, matching the plan's own file-list cap. A longer list stops being a lead and becomes noise. */
|
|
34
|
+
export const MAX_FILES_PER_RESOLUTION = 8;
|
|
35
|
+
|
|
36
|
+
function storePath(root: string): string {
|
|
37
|
+
return join(projectStateDir(root), "fingerprint-resolutions.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readResolutions(root: string): ResolutionStore {
|
|
41
|
+
const path = storePath(root);
|
|
42
|
+
if (!existsSync(path)) {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as ResolutionStore;
|
|
47
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function prune(store: ResolutionStore): ResolutionStore {
|
|
54
|
+
const entries = Object.entries(store);
|
|
55
|
+
if (entries.length <= MAX_RESOLUTIONS) {
|
|
56
|
+
return store;
|
|
57
|
+
}
|
|
58
|
+
const kept = entries.sort((a, b) => b[1].at.localeCompare(a[1].at)).slice(0, MAX_RESOLUTIONS);
|
|
59
|
+
return Object.fromEntries(kept);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function recordResolution(root: string, fingerprint: string, resolution: Resolution): ResolutionStore {
|
|
63
|
+
const store = readResolutions(root);
|
|
64
|
+
store[fingerprint] = {
|
|
65
|
+
...resolution,
|
|
66
|
+
files: resolution.files.slice(0, MAX_FILES_PER_RESOLUTION),
|
|
67
|
+
};
|
|
68
|
+
const pruned = prune(store);
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(projectStateDir(root), { recursive: true });
|
|
71
|
+
writeFileSync(storePath(root), `${JSON.stringify(pruned, null, 2)}\n`, "utf8");
|
|
72
|
+
} catch {}
|
|
73
|
+
return pruned;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolutionFor(root: string, fingerprint: string): Resolution | null {
|
|
77
|
+
return readResolutions(root)[fingerprint] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* invariant: past tense, and no imperative. The wording is the whole safeguard here — the same list phrased as an
|
|
82
|
+
* instruction would send an agent to edit files that may be irrelevant this time, which is the AD-021 and AD-024
|
|
83
|
+
* harm arriving through a third door.
|
|
84
|
+
*/
|
|
85
|
+
export function resolutionHistoryLine(resolution: Resolution): string {
|
|
86
|
+
return `History: this same ${resolution.gate} failure was resolved once before, after changes to ${resolution.files.join(", ")}. That is a record of what happened, not a list to edit — confirm it against this failure before acting on it.`;
|
|
87
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function computeFingerprint(parts: {
|
|
4
|
+
files: string[];
|
|
5
|
+
gate: string;
|
|
6
|
+
exitCode: number;
|
|
7
|
+
output: string;
|
|
8
|
+
}): string {
|
|
9
|
+
const normalizedOutput = parts.output
|
|
10
|
+
.replace(/\d{4}-\d{2}-\d{2}T[\d:.]+Z/g, "<ts>")
|
|
11
|
+
.replace(/\b\d{5,}\b/g, "<n>")
|
|
12
|
+
.slice(0, 1500);
|
|
13
|
+
const raw = JSON.stringify({
|
|
14
|
+
files: [...parts.files].sort(),
|
|
15
|
+
gate: parts.gate,
|
|
16
|
+
exitCode: parts.exitCode,
|
|
17
|
+
output: normalizedOutput,
|
|
18
|
+
});
|
|
19
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16);
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { projectStateDir } from "../../platform/paths.ts";
|
|
4
|
+
import type { FingerprintStore } from "./stagnation.types.ts";
|
|
5
|
+
|
|
6
|
+
function storePath(root: string): string {
|
|
7
|
+
return join(projectStateDir(root), "fingerprint.json");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function readStore(root: string): FingerprintStore {
|
|
11
|
+
const path = storePath(root);
|
|
12
|
+
if (!existsSync(path)) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(path, "utf8")) as FingerprintStore;
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeStore(root: string, store: FingerprintStore): void {
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(projectStateDir(root), { recursive: true });
|
|
25
|
+
writeFileSync(storePath(root), `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function trackFingerprint(root: string, sessionKey: string, fingerprint: string): number {
|
|
30
|
+
const store = readStore(root);
|
|
31
|
+
const current = store[sessionKey] ?? { hits: 0 };
|
|
32
|
+
const next =
|
|
33
|
+
current.last === fingerprint
|
|
34
|
+
? { last: fingerprint, hits: current.hits + 1 }
|
|
35
|
+
: { last: fingerprint, hits: 1 };
|
|
36
|
+
store[sessionKey] = next;
|
|
37
|
+
writeStore(root, store);
|
|
38
|
+
return next.hits;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function fingerprintHits(root: string, sessionKey: string): number {
|
|
42
|
+
return readStore(root)[sessionKey]?.hits ?? 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clearFingerprint(root: string, sessionKey: string): void {
|
|
46
|
+
const store = readStore(root);
|
|
47
|
+
store[sessionKey] = { hits: 0 };
|
|
48
|
+
writeStore(root, store);
|
|
49
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type FingerprintEntry = {
|
|
2
|
+
last?: string;
|
|
3
|
+
hits: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type FingerprintStore = Record<string, FingerprintEntry>;
|
|
7
|
+
|
|
8
|
+
export const STAGNATION_FOLLOWUP = [
|
|
9
|
+
"BLOCKED: identical validation fingerprint repeated — no progress between attempts.",
|
|
10
|
+
"TRIED: same gate failure signature as the previous stop loop.",
|
|
11
|
+
"NEED: change approach. Do not repeat the same fix. Inspect root cause, try a different path, or escalate with BLOCKED/TRIED/NEED.",
|
|
12
|
+
].join("\n");
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { projectStateDir } from "../../platform/paths.ts";
|
|
4
|
+
import type { ModelParam, ParentModelSnapshot } from "./subagent-policy.types.ts";
|
|
5
|
+
|
|
6
|
+
const PARENT_MODEL_SCHEMA = "harness.parent-model.v1" as const;
|
|
7
|
+
|
|
8
|
+
type ParentModelFile = {
|
|
9
|
+
schema: typeof PARENT_MODEL_SCHEMA;
|
|
10
|
+
bySession: Record<string, ParentModelSnapshot>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function parentModelPath(root: string): string {
|
|
14
|
+
return join(projectStateDir(root), "parent-model.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readFile(root: string): ParentModelFile {
|
|
18
|
+
const path = parentModelPath(root);
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
return { schema: PARENT_MODEL_SCHEMA, bySession: {} };
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as ParentModelFile;
|
|
24
|
+
if (parsed?.schema === PARENT_MODEL_SCHEMA && parsed.bySession) {
|
|
25
|
+
return parsed;
|
|
26
|
+
}
|
|
27
|
+
} catch {}
|
|
28
|
+
return { schema: PARENT_MODEL_SCHEMA, bySession: {} };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeFile(root: string, file: ParentModelFile): void {
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(projectStateDir(root), { recursive: true });
|
|
34
|
+
writeFileSync(parentModelPath(root), `${JSON.stringify(file, null, 2)}\n`, "utf8");
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isFastParamTrue(params: unknown): boolean {
|
|
39
|
+
if (!Array.isArray(params)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
for (const entry of params) {
|
|
43
|
+
if (!entry || typeof entry !== "object") {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const row = entry as ModelParam;
|
|
47
|
+
if (String(row.id ?? "").toLowerCase() === "fast" && String(row.value ?? "").toLowerCase() === "true") {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseBracketParams(model: string): { base: string; params: Record<string, string> } | null {
|
|
55
|
+
const trimmed = model.trim();
|
|
56
|
+
const match = /^([^[\]]+)\[([^\]]*)\]$/.exec(trimmed);
|
|
57
|
+
const base = match?.[1];
|
|
58
|
+
const rawParams = match?.[2];
|
|
59
|
+
if (base === undefined || rawParams === undefined) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const params: Record<string, string> = {};
|
|
63
|
+
for (const part of rawParams.split(",")) {
|
|
64
|
+
const piece = part.trim();
|
|
65
|
+
if (!piece) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const eq = piece.indexOf("=");
|
|
69
|
+
if (eq < 0) {
|
|
70
|
+
params[piece.toLowerCase()] = "true";
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const key = piece.slice(0, eq).trim().toLowerCase();
|
|
74
|
+
const value = piece
|
|
75
|
+
.slice(eq + 1)
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase();
|
|
78
|
+
if (key) {
|
|
79
|
+
params[key] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { base, params };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function modelHasFastBracket(model: string): boolean {
|
|
86
|
+
const parsed = parseBracketParams(model);
|
|
87
|
+
return parsed?.params.fast === "true";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function modelMatchesBlocked(model: string, patterns: string[]): string | null {
|
|
91
|
+
const value = model.trim();
|
|
92
|
+
if (!value) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (modelHasFastBracket(value)) {
|
|
96
|
+
return "fast=true";
|
|
97
|
+
}
|
|
98
|
+
for (const pattern of patterns) {
|
|
99
|
+
try {
|
|
100
|
+
if (new RegExp(pattern, "i").test(value)) {
|
|
101
|
+
return pattern;
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
if (value.toLowerCase().includes(pattern.toLowerCase())) {
|
|
105
|
+
return pattern;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isModelAllowlisted(model: string, allowed: string[]): boolean {
|
|
113
|
+
if (!model) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (modelHasFastBracket(model)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return allowed.some((entry) => entry === model || model.startsWith(`${entry}[`));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function computeFastFlag(model: string, modelParams: unknown, patterns: string[]): boolean {
|
|
123
|
+
if (isFastParamTrue(modelParams)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
return modelMatchesBlocked(model, patterns) !== null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function candidateModelBlocked(
|
|
130
|
+
model: string,
|
|
131
|
+
patterns: string[],
|
|
132
|
+
modelParams?: unknown,
|
|
133
|
+
): string | null {
|
|
134
|
+
const fromSlug = modelMatchesBlocked(model, patterns);
|
|
135
|
+
if (fromSlug) {
|
|
136
|
+
return fromSlug;
|
|
137
|
+
}
|
|
138
|
+
if (isFastParamTrue(modelParams)) {
|
|
139
|
+
return "model_params.fast=true";
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function upsertParentModelState(
|
|
145
|
+
projectDir: string,
|
|
146
|
+
sessionKey: string | undefined,
|
|
147
|
+
input: { model?: string; model_params?: unknown },
|
|
148
|
+
patterns: string[],
|
|
149
|
+
): ParentModelSnapshot | null {
|
|
150
|
+
const key = sessionKey?.trim();
|
|
151
|
+
if (!key) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const model = typeof input.model === "string" ? input.model : "";
|
|
155
|
+
const hasParams = Array.isArray(input.model_params);
|
|
156
|
+
if (!model && !hasParams) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const model_params = hasParams ? (input.model_params as ModelParam[]) : null;
|
|
160
|
+
const fast = computeFastFlag(model, model_params, patterns);
|
|
161
|
+
const snapshot: ParentModelSnapshot = {
|
|
162
|
+
model,
|
|
163
|
+
model_params,
|
|
164
|
+
fast,
|
|
165
|
+
updated_at: new Date().toISOString(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const file = readFile(projectDir);
|
|
169
|
+
file.bySession[key] = snapshot;
|
|
170
|
+
writeFile(projectDir, file);
|
|
171
|
+
return snapshot;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function readParentModelState(
|
|
175
|
+
projectDir: string,
|
|
176
|
+
sessionKey: string | undefined,
|
|
177
|
+
): ParentModelSnapshot | null {
|
|
178
|
+
const key = sessionKey?.trim();
|
|
179
|
+
if (!key) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return readFile(projectDir).bySession[key] ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function shouldDenyParentFast(opts: {
|
|
186
|
+
enabled: boolean;
|
|
187
|
+
projectDir: string;
|
|
188
|
+
sessionKey: string | undefined;
|
|
189
|
+
patterns: string[];
|
|
190
|
+
}): boolean {
|
|
191
|
+
if (!opts.enabled) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const snap = readParentModelState(opts.projectDir, opts.sessionKey);
|
|
195
|
+
if (!snap) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (snap.fast) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return computeFastFlag(snap.model, snap.model_params, opts.patterns);
|
|
202
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Decision } from "../../contracts/decision.ts";
|
|
2
|
+
import type { EffortLevel } from "../../contracts/effort.ts";
|
|
3
|
+
import { compareEffort, isEffortLevel } from "../../contracts/effort.ts";
|
|
4
|
+
import { forProvider, type ProviderScoped } from "../policy/policy.types.ts";
|
|
5
|
+
import {
|
|
6
|
+
candidateModelBlocked,
|
|
7
|
+
isModelAllowlisted,
|
|
8
|
+
shouldDenyParentFast,
|
|
9
|
+
} from "./subagent-policy.parent-model.ts";
|
|
10
|
+
|
|
11
|
+
export type EvaluateSubagentSpawnArgs = {
|
|
12
|
+
provider: string;
|
|
13
|
+
sessionKey: string;
|
|
14
|
+
projectDir: string;
|
|
15
|
+
model: string;
|
|
16
|
+
modelParams?: unknown;
|
|
17
|
+
effort?: string;
|
|
18
|
+
allowedModels: ProviderScoped<string>;
|
|
19
|
+
blockedPatterns: ProviderScoped<string>;
|
|
20
|
+
minEffort: EffortLevel | null;
|
|
21
|
+
requireModel: boolean;
|
|
22
|
+
enforceAllowlist: boolean;
|
|
23
|
+
blockParentFast: boolean;
|
|
24
|
+
blockMode?: "deny" | "ask";
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The key an operator edits. Named in the refusal, because the list is theirs and nothing else supplies one. */
|
|
28
|
+
export const ALLOWLIST_KEY = "subagents.allowedModels";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* hazard: the refusal was `Use one of: <list>` and named no source. An operator whose own config read
|
|
32
|
+
* `"allowedModels": []` was being refused by a shipped list, opened the file, saw an empty array, and concluded
|
|
33
|
+
* that empty means none — then offered to switch the rail off. The list has no other source now, and the message
|
|
34
|
+
* says which key holds it ([/decisions/ad-053.md](/decisions/ad-053.md)).
|
|
35
|
+
*/
|
|
36
|
+
export function allowlistRefusal(model: string, allowed: readonly string[]): string {
|
|
37
|
+
const base = `"${model}" is not in \`${ALLOWLIST_KEY}\`. Use one of: ${allowed.join(", ")}.`;
|
|
38
|
+
// why: `inherit` is not a model name — it means the parent's model — so listing slugs answers a question it did
|
|
39
|
+
// not ask. It is a value the operator may put on the list, and saying so is the route that works ([/decisions/ad-047.md](/decisions/ad-047.md)).
|
|
40
|
+
return model === "inherit"
|
|
41
|
+
? `${base} \`inherit\` is a value that list may contain; add it there to permit it.`
|
|
42
|
+
: base;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* why: one rule per reason a spawn is refused, not one for the aggregate. `obs report` attributes refusals by rule
|
|
47
|
+
* and `tlc harness why` reads the same field, so "blocked by the allowlist" and "blocked because the parent is in
|
|
48
|
+
* Fast mode" have to be distinguishable — a teammate hit the first and spent the loop guessing which
|
|
49
|
+
* ([/decisions/ad-061.md](/decisions/ad-061.md)).
|
|
50
|
+
*/
|
|
51
|
+
export const SUBAGENT_RULES = {
|
|
52
|
+
blockedPattern: "subagent-blocked-pattern",
|
|
53
|
+
modelRequired: "subagent-model-required",
|
|
54
|
+
allowlist: "subagent-allowlist",
|
|
55
|
+
minEffort: "subagent-min-effort",
|
|
56
|
+
parentFast: "subagent-parent-fast",
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export function evaluateSubagentSpawn(args: EvaluateSubagentSpawnArgs): Decision {
|
|
60
|
+
const patterns = forProvider(args.blockedPatterns, args.provider) ?? [];
|
|
61
|
+
const block = (reason: string, userNote: string, rule: string): Decision =>
|
|
62
|
+
args.blockMode === "ask"
|
|
63
|
+
? { kind: "ask", reason, userNote, rule }
|
|
64
|
+
: { kind: "deny", reason, userNote, rule };
|
|
65
|
+
|
|
66
|
+
const blockedBy = candidateModelBlocked(args.model, patterns, args.modelParams);
|
|
67
|
+
if (blockedBy) {
|
|
68
|
+
return block(
|
|
69
|
+
`Do not use *-fast models. Pattern hit: ${blockedBy}.`,
|
|
70
|
+
`Blocked subagent model "${args.model}" (matches ${blockedBy}).`,
|
|
71
|
+
SUBAGENT_RULES.blockedPattern,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (args.requireModel && !args.model.trim()) {
|
|
76
|
+
return block(
|
|
77
|
+
"Set model explicitly on every Task spawn. Do not omit model.",
|
|
78
|
+
"Subagent spawned without an explicit model.",
|
|
79
|
+
SUBAGENT_RULES.modelRequired,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const allowed = forProvider(args.allowedModels, args.provider);
|
|
84
|
+
// hazard: an empty list used to deny every model, because `[]` is not `null`. With no shipped fallback that
|
|
85
|
+
// would refuse every spawn from a rule naming nothing — which is what the reader who reported this mistook for
|
|
86
|
+
// a bug, correctly. `doctor` reports the combination instead ([/decisions/ad-053.md](/decisions/ad-053.md)).
|
|
87
|
+
if (
|
|
88
|
+
args.enforceAllowlist &&
|
|
89
|
+
args.model &&
|
|
90
|
+
allowed !== null &&
|
|
91
|
+
allowed.length > 0 &&
|
|
92
|
+
!isModelAllowlisted(args.model, allowed)
|
|
93
|
+
) {
|
|
94
|
+
return block(
|
|
95
|
+
allowlistRefusal(args.model, allowed),
|
|
96
|
+
`Subagent model "${args.model}" is not on the allowlist.`,
|
|
97
|
+
SUBAGENT_RULES.allowlist,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
args.minEffort &&
|
|
103
|
+
args.effort !== undefined &&
|
|
104
|
+
isEffortLevel(args.effort) &&
|
|
105
|
+
compareEffort(args.effort, args.minEffort) < 0
|
|
106
|
+
) {
|
|
107
|
+
return block(
|
|
108
|
+
`Subagent effort "${args.effort}" is below the required minimum "${args.minEffort}".`,
|
|
109
|
+
`Raise the subagent effort to at least "${args.minEffort}" and retry.`,
|
|
110
|
+
SUBAGENT_RULES.minEffort,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
shouldDenyParentFast({
|
|
116
|
+
enabled: args.blockParentFast,
|
|
117
|
+
projectDir: args.projectDir,
|
|
118
|
+
sessionKey: args.sessionKey,
|
|
119
|
+
patterns,
|
|
120
|
+
})
|
|
121
|
+
) {
|
|
122
|
+
return block(
|
|
123
|
+
"Parent Fast mode is forbidden for Task/subagent spawns. Turn Fast off on the parent model and retry.",
|
|
124
|
+
"Blocked subagent spawn: parent conversation is in Fast mode.",
|
|
125
|
+
SUBAGENT_RULES.parentFast,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { kind: "allow" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export {
|
|
133
|
+
candidateModelBlocked,
|
|
134
|
+
isModelAllowlisted,
|
|
135
|
+
modelMatchesBlocked,
|
|
136
|
+
readParentModelState,
|
|
137
|
+
shouldDenyParentFast,
|
|
138
|
+
upsertParentModelState,
|
|
139
|
+
} from "./subagent-policy.parent-model.ts";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ModelParam = { id?: string; value?: string };
|
|
2
|
+
|
|
3
|
+
export type ParentModelSnapshot = {
|
|
4
|
+
model: string;
|
|
5
|
+
model_params: ModelParam[] | null;
|
|
6
|
+
fast: boolean;
|
|
7
|
+
updated_at: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SubagentDenialReason =
|
|
11
|
+
| "blocked_pattern"
|
|
12
|
+
| "missing_model"
|
|
13
|
+
| "not_allowlisted"
|
|
14
|
+
| "min_effort"
|
|
15
|
+
| "parent_fast";
|
|
16
|
+
|
|
17
|
+
export type SubagentDecisionDetail =
|
|
18
|
+
| { reason: "blocked_pattern"; pattern: string }
|
|
19
|
+
| { reason: "missing_model" }
|
|
20
|
+
| { reason: "not_allowlisted"; allowed: string[] }
|
|
21
|
+
| { reason: "min_effort"; observed: string | null; required: string }
|
|
22
|
+
| { reason: "parent_fast" };
|