@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,876 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Decision, HarnessEvent } from "../contracts/index.ts";
|
|
4
|
+
import { coreFacade, type LastGateArtifact, type PendingLessonCredit, type Policy } from "../core/index.ts";
|
|
5
|
+
import {
|
|
6
|
+
filterCodeTargets,
|
|
7
|
+
filterTestTargets,
|
|
8
|
+
listAddedLines,
|
|
9
|
+
listChangedRepoFiles,
|
|
10
|
+
listTrackedFiles,
|
|
11
|
+
runCommand,
|
|
12
|
+
} from "../platform/git.ts";
|
|
13
|
+
import { flagsDir } from "../platform/paths.ts";
|
|
14
|
+
import type { Handler, HandlerContext } from "./run.ts";
|
|
15
|
+
import { main } from "./run.ts";
|
|
16
|
+
import { formatLessonsBlock, obsConfigFor, sessionIdFromKey } from "./support.ts";
|
|
17
|
+
|
|
18
|
+
const STAGNATION_FOLLOWUP = [
|
|
19
|
+
"BLOCKED: identical validation fingerprint repeated — no progress between attempts.",
|
|
20
|
+
"TRIED: same gate failure signature as the previous stop loop.",
|
|
21
|
+
"NEED: change approach. Do not repeat the same fix. Inspect root cause, try a different path, or escalate with BLOCKED/TRIED/NEED.",
|
|
22
|
+
].join("\n");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* hazard: `gate.outcome` was consumed in two places — the rollup counter and the session report's
|
|
26
|
+
* "Gates pass/fail" line — and emitted by nothing. Both read structurally zero, so the report printed a
|
|
27
|
+
* truthful-looking `0 / 0` for every gate this harness has ever run
|
|
28
|
+
* ([/decisions/ad-027.md](/decisions/ad-027.md)).
|
|
29
|
+
*
|
|
30
|
+
* why: recorded here rather than at each call site, so a gate added later cannot be forgotten. Every gate goes
|
|
31
|
+
* through this function; a gate that does not is not run under the lock either.
|
|
32
|
+
*/
|
|
33
|
+
function recordGateOutcome(args: {
|
|
34
|
+
root: string;
|
|
35
|
+
provider: string;
|
|
36
|
+
sessionKey: string;
|
|
37
|
+
policy: Policy;
|
|
38
|
+
artifact: LastGateArtifact;
|
|
39
|
+
reused: boolean;
|
|
40
|
+
}): void {
|
|
41
|
+
coreFacade.observability.recordObs(args.root, obsConfigFor(args.policy), {
|
|
42
|
+
provider: args.provider,
|
|
43
|
+
kind: "gate.outcome",
|
|
44
|
+
sessionKey: args.sessionKey,
|
|
45
|
+
attrs: {
|
|
46
|
+
gate: args.artifact.gate,
|
|
47
|
+
passed: args.artifact.passed,
|
|
48
|
+
exit_code: args.artifact.exitCode,
|
|
49
|
+
duration_ms: args.artifact.durationMs,
|
|
50
|
+
file_count: args.artifact.files.length,
|
|
51
|
+
// why: so `obs report` and `attest` can answer "what environment did this gate run under" after the fact,
|
|
52
|
+
// without the follow-up having had to say it.
|
|
53
|
+
scoped_env: (args.artifact.scopedEnv ?? []).join(",") || "none",
|
|
54
|
+
// why: a reused verdict costs no time, so counting it as a run would make the total gate time read lower
|
|
55
|
+
// than it is and hide the saving instead of showing it ([/decisions/ad-045.md](/decisions/ad-045.md)).
|
|
56
|
+
reused: args.reused,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* why: a gate that deferred has to be findable afterwards. Without a record the only trace was one turn's reply,
|
|
63
|
+
* which is why the same neighbour collision was reported three times as if each were new
|
|
64
|
+
* ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
65
|
+
*/
|
|
66
|
+
function recordGateDeferred(args: {
|
|
67
|
+
root: string;
|
|
68
|
+
provider: string;
|
|
69
|
+
sessionKey: string;
|
|
70
|
+
gate: string;
|
|
71
|
+
holder: string;
|
|
72
|
+
policy: Policy;
|
|
73
|
+
}): void {
|
|
74
|
+
coreFacade.observability.recordObs(args.root, obsConfigFor(args.policy), {
|
|
75
|
+
provider: args.provider,
|
|
76
|
+
kind: "gate.outcome",
|
|
77
|
+
sessionKey: args.sessionKey,
|
|
78
|
+
attrs: {
|
|
79
|
+
gate: args.gate,
|
|
80
|
+
// invariant: not `passed: false`. A deferred gate produced no verdict, and recording one as a failure would
|
|
81
|
+
// put a failure in the report that nothing failed.
|
|
82
|
+
deferred_to: args.holder,
|
|
83
|
+
rule: "grind",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The verdict a gate would produce, without producing it twice.
|
|
90
|
+
*
|
|
91
|
+
* why: keyed on a content hash of the command and the files, which is the monorepo-tooling rule — same inputs,
|
|
92
|
+
* replay the result. A read-only turn in a repository with uncommitted work re-ran the whole suite on every
|
|
93
|
+
* question, because the trigger read the state of the tree rather than what the turn did
|
|
94
|
+
* ([/decisions/ad-045.md](/decisions/ad-045.md)).
|
|
95
|
+
*/
|
|
96
|
+
/**
|
|
97
|
+
* invariant: a union, so a deferred gate cannot be read as a passing artifact. A shape with an optional artifact
|
|
98
|
+
* would have let `undefined` mean "fine" at three call sites that reach straight for `artifact.passed`
|
|
99
|
+
* ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
100
|
+
*/
|
|
101
|
+
type GateRun =
|
|
102
|
+
| { kind: "ran"; artifact: LastGateArtifact; reused: boolean }
|
|
103
|
+
| { kind: "deferred"; holder: string };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* hazard: `GATE_LOCK_WAIT_MS` is 120 000 and the Stop hook is registered with `timeoutSeconds: 120`, so waiting
|
|
107
|
+
* the library default leaves nothing for the gate the wait exists to run — the host kills the hook first. This is
|
|
108
|
+
* the share of the budget a neighbour may spend before the turn stops waiting for it.
|
|
109
|
+
*/
|
|
110
|
+
export const STOP_LOCK_WAIT_MS = 10_000;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* why: a test that proves the wait has to wait, and a suite that pays ten seconds for it pays that on every gate
|
|
114
|
+
* run in every environment. This is the only seam and it is read here, so the production default is a constant
|
|
115
|
+
* nobody can reconfigure from a project ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
116
|
+
*/
|
|
117
|
+
export function stopLockWaitMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
118
|
+
const declared = Number(env.TLC_TEST_GATE_LOCK_WAIT_MS);
|
|
119
|
+
return Number.isFinite(declared) && declared > 0 ? declared : STOP_LOCK_WAIT_MS;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Grades the lessons that were injected the last time this gate failed. `helped` means the gate the lessons were
|
|
124
|
+
* chosen for then passed; `neutral` means it failed again.
|
|
125
|
+
*
|
|
126
|
+
* invariant: consumed exactly once. The pending credit is cleared whether or not any lesson matched, so a single
|
|
127
|
+
* injection cannot be graded twice by two later runs of the same gate.
|
|
128
|
+
*
|
|
129
|
+
* hazard: the gate name is compared. Without it, lessons injected for `lint` would be credited by whichever gate
|
|
130
|
+
* ran next, which is `test` in this handler and would read as help the lesson never gave.
|
|
131
|
+
*/
|
|
132
|
+
async function creditPendingLessons(args: {
|
|
133
|
+
root: string;
|
|
134
|
+
provider: string;
|
|
135
|
+
pending: PendingLessonCredit | undefined;
|
|
136
|
+
gate: string;
|
|
137
|
+
passed: boolean;
|
|
138
|
+
}): Promise<void> {
|
|
139
|
+
const { pending } = args;
|
|
140
|
+
if (!pending || pending.gate !== args.gate || pending.ids.length === 0) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
await coreFacade.lesson.creditLessons(args.root, pending.ids, args.passed ? "helped" : "neutral");
|
|
144
|
+
await coreFacade.handoff.patchHandoff(args.root, args.provider, {
|
|
145
|
+
slice: { pending_lesson_credit: undefined },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function runLockedGate(args: {
|
|
150
|
+
root: string;
|
|
151
|
+
provider: string;
|
|
152
|
+
session: string;
|
|
153
|
+
gate: "lint" | "test" | "docs";
|
|
154
|
+
command: string[];
|
|
155
|
+
argvFiles: string[];
|
|
156
|
+
recordFiles: string[];
|
|
157
|
+
sessionKey: string;
|
|
158
|
+
policy: Policy;
|
|
159
|
+
pendingCredit: PendingLessonCredit | undefined;
|
|
160
|
+
}): Promise<GateRun> {
|
|
161
|
+
const command = [...args.command, ...args.argvFiles];
|
|
162
|
+
const inputs = coreFacade.gate.computeInputsHash(args.root, args.recordFiles, command);
|
|
163
|
+
const cached = coreFacade.gate.cachedVerdict(coreFacade.gate.readLastGate(args.root), args.gate, inputs);
|
|
164
|
+
|
|
165
|
+
let artifact: LastGateArtifact;
|
|
166
|
+
if (cached !== null) {
|
|
167
|
+
artifact = cached;
|
|
168
|
+
} else {
|
|
169
|
+
try {
|
|
170
|
+
artifact = await coreFacade.gate.withGateLock(
|
|
171
|
+
args.root,
|
|
172
|
+
args.provider,
|
|
173
|
+
args.session,
|
|
174
|
+
async () => {
|
|
175
|
+
const result = await runCommand(args.root, args.command, args.argvFiles);
|
|
176
|
+
return coreFacade.gate.writeLastGate({
|
|
177
|
+
root: args.root,
|
|
178
|
+
gate: args.gate,
|
|
179
|
+
exitCode: result.exitCode,
|
|
180
|
+
command,
|
|
181
|
+
files: args.recordFiles,
|
|
182
|
+
durationMs: result.durationMs,
|
|
183
|
+
output: result.output,
|
|
184
|
+
...(inputs.complete ? { inputsHash: inputs.hash } : {}),
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
{ waitMs: stopLockWaitMs() },
|
|
188
|
+
);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// why: a neighbour in the same checkout is running these very commands over this very tree, so the property
|
|
191
|
+
// is being verified — by somebody else. Blocking this turn tells the one participant that cannot act.
|
|
192
|
+
if (!(error instanceof coreFacade.gate.GateLockTimeoutError)) {
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
const holder = coreFacade.gate.describeHolder(args.root) ?? "another session";
|
|
196
|
+
recordGateDeferred({ ...args, holder });
|
|
197
|
+
return { kind: "deferred", holder };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// invariant: recorded outside the lock. A measurement must not widen the window in which one gate blocks another.
|
|
202
|
+
recordGateOutcome({ ...args, artifact, reused: cached !== null });
|
|
203
|
+
await creditPendingLessons({
|
|
204
|
+
root: args.root,
|
|
205
|
+
provider: args.provider,
|
|
206
|
+
pending: args.pendingCredit,
|
|
207
|
+
gate: args.gate,
|
|
208
|
+
passed: artifact.passed,
|
|
209
|
+
});
|
|
210
|
+
return { kind: "ran", artifact, reused: cached !== null };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function failGate(args: {
|
|
214
|
+
root: string;
|
|
215
|
+
provider: string;
|
|
216
|
+
sessionKey: string;
|
|
217
|
+
gate: string;
|
|
218
|
+
artifact: LastGateArtifact;
|
|
219
|
+
loopCount: number;
|
|
220
|
+
maxLoops: number;
|
|
221
|
+
policy: Policy;
|
|
222
|
+
}): Promise<Decision> {
|
|
223
|
+
const { policy } = args;
|
|
224
|
+
const intel = policy.intelligence;
|
|
225
|
+
const fingerprint = coreFacade.stagnation.computeFingerprint({
|
|
226
|
+
files: args.artifact.files,
|
|
227
|
+
gate: args.gate,
|
|
228
|
+
exitCode: args.artifact.exitCode,
|
|
229
|
+
output: args.artifact.outputTail,
|
|
230
|
+
});
|
|
231
|
+
const hits = coreFacade.stagnation.trackFingerprint(args.root, args.sessionKey, fingerprint);
|
|
232
|
+
const category = coreFacade.gate.isCommandResolutionFailure({
|
|
233
|
+
exitCode: args.artifact.exitCode,
|
|
234
|
+
output: args.artifact.outputTail,
|
|
235
|
+
})
|
|
236
|
+
? "config"
|
|
237
|
+
: coreFacade.turn.classifyGateFailure(args.gate);
|
|
238
|
+
const freshGaps = coreFacade.gate.gapsFromArtifact({ artifact: args.artifact, category });
|
|
239
|
+
const handoff = coreFacade.handoff.readHandoff(args.root, args.provider);
|
|
240
|
+
const gaps = intel.progressiveContext
|
|
241
|
+
? coreFacade.turn.mergeGaps(handoff.previous_gaps, freshGaps)
|
|
242
|
+
: freshGaps;
|
|
243
|
+
const suggestion = coreFacade.turn.suggestionFor(category, args.gate);
|
|
244
|
+
const effectiveCategory = hits >= 2 ? "stagnation" : category;
|
|
245
|
+
const plan = intel.autopilot
|
|
246
|
+
? coreFacade.turn.resolveAutopilot({
|
|
247
|
+
category: effectiveCategory,
|
|
248
|
+
gate: hits >= 2 ? "stagnation" : args.gate,
|
|
249
|
+
mode: policy.mode,
|
|
250
|
+
loopCount: args.loopCount,
|
|
251
|
+
maxLoops: args.maxLoops,
|
|
252
|
+
failingFiles: coreFacade.gate.filesFromOutput(args.artifact.outputTail, args.root),
|
|
253
|
+
changedFiles: args.artifact.files,
|
|
254
|
+
})
|
|
255
|
+
: null;
|
|
256
|
+
|
|
257
|
+
await coreFacade.handoff.patchHandoff(args.root, args.provider, {
|
|
258
|
+
slice: {
|
|
259
|
+
last_gate_result: "fail",
|
|
260
|
+
last_fingerprint: fingerprint,
|
|
261
|
+
fingerprint_hits: hits,
|
|
262
|
+
last_failure_category: intel.failureClassification ? effectiveCategory : undefined,
|
|
263
|
+
previous_gaps: intel.gapFeedback ? gaps : undefined,
|
|
264
|
+
blockers: `${args.gate} gate failed (${effectiveCategory}).`,
|
|
265
|
+
next_action: plan?.next_action ?? suggestion,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (hits >= 2 && intel.lessons.enabled) {
|
|
270
|
+
await coreFacade.lesson.recordLessonFromFailure({
|
|
271
|
+
projectDir: args.root,
|
|
272
|
+
gate: args.gate,
|
|
273
|
+
category,
|
|
274
|
+
fingerprint,
|
|
275
|
+
output: args.artifact.outputTail,
|
|
276
|
+
sessionKey: args.sessionKey,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// why: the same failure identity, resolved before. Offered as a record of what happened rather than a list to
|
|
281
|
+
// edit — a previous resolution is evidence, and AD-024 established that a plan names files from evidence and
|
|
282
|
+
// never from proximity. Absent history changes nothing.
|
|
283
|
+
const resolution = coreFacade.stagnation.resolutionFor(args.root, fingerprint);
|
|
284
|
+
const historyLine = resolution ? coreFacade.stagnation.resolutionHistoryLine(resolution) : "";
|
|
285
|
+
|
|
286
|
+
const selected = intel.lessons.enabled
|
|
287
|
+
? await coreFacade.lesson.selectLessons({
|
|
288
|
+
projectDir: args.root,
|
|
289
|
+
config: intel.lessons,
|
|
290
|
+
mode: "retry",
|
|
291
|
+
gate: args.gate,
|
|
292
|
+
text: hits >= 2 ? `stagnation ${args.artifact.outputTail}` : args.artifact.outputTail,
|
|
293
|
+
})
|
|
294
|
+
: { lessons: [], usedIds: [], omitted: 0 };
|
|
295
|
+
const lessonsBlock = formatLessonsBlock(
|
|
296
|
+
selected.lessons,
|
|
297
|
+
"Lessons for this gate (ranked — apply before inventing a new plan):",
|
|
298
|
+
selected.omitted,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// why: written after the lessons are chosen and before the turn resumes, so the next run of this same gate is
|
|
302
|
+
// the thing that grades them ([/decisions/ad-039.md](/decisions/ad-039.md)).
|
|
303
|
+
if (selected.usedIds.length > 0) {
|
|
304
|
+
await coreFacade.lesson.markGradeable(args.root, selected.usedIds);
|
|
305
|
+
await coreFacade.handoff.patchHandoff(args.root, args.provider, {
|
|
306
|
+
slice: {
|
|
307
|
+
pending_lesson_credit: {
|
|
308
|
+
gate: args.gate,
|
|
309
|
+
ids: selected.usedIds,
|
|
310
|
+
at: new Date().toISOString(),
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (hits >= 2) {
|
|
317
|
+
const stagnationGaps = intel.gapFeedback
|
|
318
|
+
? [
|
|
319
|
+
...gaps,
|
|
320
|
+
{
|
|
321
|
+
id: "stagnation-0",
|
|
322
|
+
gate: "stagnation",
|
|
323
|
+
category: "stagnation" as const,
|
|
324
|
+
summary: STAGNATION_FOLLOWUP,
|
|
325
|
+
},
|
|
326
|
+
]
|
|
327
|
+
: [];
|
|
328
|
+
const body = [STAGNATION_FOLLOWUP];
|
|
329
|
+
if (intel.gapFeedback) {
|
|
330
|
+
body.push(
|
|
331
|
+
"",
|
|
332
|
+
coreFacade.turn.formatGapFeedback(
|
|
333
|
+
stagnationGaps,
|
|
334
|
+
coreFacade.turn.suggestionFor("stagnation", "stagnation"),
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (historyLine) {
|
|
339
|
+
body.push("", historyLine);
|
|
340
|
+
}
|
|
341
|
+
if (lessonsBlock) {
|
|
342
|
+
body.push("", lessonsBlock);
|
|
343
|
+
}
|
|
344
|
+
if (plan) {
|
|
345
|
+
body.push("", coreFacade.turn.formatAutopilotBlock(plan));
|
|
346
|
+
}
|
|
347
|
+
return { kind: "continue", text: body.join("\n") };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const parts = [
|
|
351
|
+
`BLOCKED: ${args.gate} failed (loop ${args.loopCount}/${args.maxLoops}).`,
|
|
352
|
+
`TRIED: ${args.gate} on changed files.`,
|
|
353
|
+
`NEED: ${plan?.next_action ?? suggestion}`,
|
|
354
|
+
];
|
|
355
|
+
if (intel.progressiveContext) {
|
|
356
|
+
parts.push(
|
|
357
|
+
"",
|
|
358
|
+
coreFacade.turn.formatProgressiveContext({
|
|
359
|
+
loopCount: args.loopCount,
|
|
360
|
+
maxLoops: args.maxLoops,
|
|
361
|
+
gate: args.gate,
|
|
362
|
+
category,
|
|
363
|
+
gaps,
|
|
364
|
+
gateOutput: args.artifact.outputTail,
|
|
365
|
+
suggestion: plan?.next_action ?? suggestion,
|
|
366
|
+
// why: the environment the gate actually ran under, and the command that settles it outside the hook.
|
|
367
|
+
// Named from the second attempt only ([/decisions/ad-060.md](/decisions/ad-060.md)).
|
|
368
|
+
scopedEnv: args.artifact.scopedEnv ?? [],
|
|
369
|
+
command: args.artifact.command,
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
} else {
|
|
373
|
+
parts.push("", args.artifact.outputTail);
|
|
374
|
+
if (intel.gapFeedback && gaps.length > 0) {
|
|
375
|
+
parts.push("", coreFacade.turn.formatGapFeedback(gaps, suggestion));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (historyLine) {
|
|
379
|
+
parts.push("", historyLine);
|
|
380
|
+
}
|
|
381
|
+
if (lessonsBlock) {
|
|
382
|
+
parts.push("", lessonsBlock);
|
|
383
|
+
}
|
|
384
|
+
if (plan) {
|
|
385
|
+
parts.push("", coreFacade.turn.formatAutopilotBlock(plan));
|
|
386
|
+
}
|
|
387
|
+
return { kind: "continue", text: parts.join("\n") };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerContext): Promise<Decision> => {
|
|
391
|
+
const { policy, capabilities } = ctx;
|
|
392
|
+
const root = event.projectDir;
|
|
393
|
+
const provider = event.provider;
|
|
394
|
+
const sessionKey = event.sessionKey;
|
|
395
|
+
const session = sessionIdFromKey(event);
|
|
396
|
+
const status = event.status ?? "completed";
|
|
397
|
+
const maxLoops = policy.grind.maxLoops;
|
|
398
|
+
const loopCount = capabilities.nativeLoopCounter
|
|
399
|
+
? (event.loopCount ?? 0)
|
|
400
|
+
: coreFacade.turn.nextLoop(root, sessionKey);
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* why: the stop reads the handoff to decide rather than to tell the model, so a diverged file is a different
|
|
404
|
+
* risk — a decision taken from planted text. Withholding here means deciding from the file's absence, which is
|
|
405
|
+
* the same answer a first turn gets ([/decisions/ad-080.md](/decisions/ad-080.md)).
|
|
406
|
+
*/
|
|
407
|
+
const stopSeal = coreFacade.handoff.handoffInjectable(root);
|
|
408
|
+
const handoff = stopSeal.ok
|
|
409
|
+
? coreFacade.handoff.readHandoff(root, provider)
|
|
410
|
+
: ({} as ReturnType<typeof coreFacade.handoff.readHandoff>);
|
|
411
|
+
// hazard: read before the file list, because the list is diffed against it. A turn that commits moves `HEAD`
|
|
412
|
+
// past its own changes, and every gate below then saw an empty diff and skipped — the comment gate in a repo
|
|
413
|
+
// whose task was "schema v2 + tests + commit" ([/decisions/ad-058.md](/decisions/ad-058.md)).
|
|
414
|
+
//
|
|
415
|
+
// invariant: absent, this is the string `HEAD`, which is exactly the previous behaviour.
|
|
416
|
+
const turnBase = handoff.turn_base_sha ?? "HEAD";
|
|
417
|
+
/**
|
|
418
|
+
* why: collected rather than returned. A turn may defer more than one gate to the same neighbour, and the reply
|
|
419
|
+
* says so once at the end instead of three times ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
420
|
+
*/
|
|
421
|
+
const deferred: string[] = [];
|
|
422
|
+
const changedFiles = await listChangedRepoFiles(root, turnBase);
|
|
423
|
+
const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
|
|
424
|
+
const testTargets = filterTestTargets(changedFiles);
|
|
425
|
+
// why: read from the snapshot taken before this handler patches anything, so a credit written by the previous
|
|
426
|
+
// stop is still visible when the gate it belongs to runs below.
|
|
427
|
+
const pendingCredit = handoff.pending_lesson_credit;
|
|
428
|
+
|
|
429
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
430
|
+
slice: { last_stop_status: status, last_changed_files: changedFiles, last_gate_result: "skipped" },
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const skipVerify = existsSync(join(flagsDir(root), "skip-verify"));
|
|
434
|
+
const cap = coreFacade.turn.checkLoopCap(loopCount, maxLoops);
|
|
435
|
+
|
|
436
|
+
if (skipVerify || status !== "completed" || cap.capReached) {
|
|
437
|
+
if (cap.capReached) {
|
|
438
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
439
|
+
slice: {
|
|
440
|
+
blockers: `Grind cap hit (${maxLoops} stop loops). Fix manually or pause gates.`,
|
|
441
|
+
next_action: "Inspect failures, fix root cause, then continue.",
|
|
442
|
+
last_failure_category: "budget",
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return { kind: "abstain" };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const intel = policy.intelligence;
|
|
450
|
+
const unfinishedWork =
|
|
451
|
+
Boolean(handoff.blockers) ||
|
|
452
|
+
Boolean(handoff.previous_gaps?.length) ||
|
|
453
|
+
Boolean(handoff.pending?.length) ||
|
|
454
|
+
Boolean(handoff.in_progress?.length);
|
|
455
|
+
if (
|
|
456
|
+
intel.idleTurnGate &&
|
|
457
|
+
coreFacade.turn.endedWithoutActing({
|
|
458
|
+
activity: coreFacade.turn.readTurnActivity(root, event.sessionKey),
|
|
459
|
+
changedFiles: changedFiles.length,
|
|
460
|
+
hasOpenWork: unfinishedWork,
|
|
461
|
+
})
|
|
462
|
+
) {
|
|
463
|
+
// hazard: this used to write `blockers`, which is one of the four fields `unfinishedWork` reads — so one
|
|
464
|
+
// firing manufactured its own precondition and the rail then blocked every later turn regardless of what the
|
|
465
|
+
// agent did. Two defects compounded: the activity counter could not rise either, so nothing cleared it and
|
|
466
|
+
// the operator saw the same BLOCKED four times in a row ([/decisions/ad-059.md](/decisions/ad-059.md)).
|
|
467
|
+
//
|
|
468
|
+
// invariant: this rail records what it saw and never writes a field it reads. `next_action` is not one of
|
|
469
|
+
// them, and the follow-up text carries the instruction anyway.
|
|
470
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
471
|
+
slice: {
|
|
472
|
+
last_failure_category: "agent-quality",
|
|
473
|
+
next_action: "Attempt the work, or proceed under a stated assumption.",
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
return { kind: "continue", text: coreFacade.turn.idleTurnMessage() };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const budgetPressure =
|
|
480
|
+
loopCount >= intel.budgetContinueAfterLoops ||
|
|
481
|
+
(typeof event.contextUsagePercent === "number" && event.contextUsagePercent >= 85);
|
|
482
|
+
|
|
483
|
+
if (intel.budgetContinue && unfinishedWork && budgetPressure) {
|
|
484
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
485
|
+
slice: {
|
|
486
|
+
last_failure_category: "budget",
|
|
487
|
+
next_action: coreFacade.turn.suggestionFor("budget", "budget"),
|
|
488
|
+
blockers: handoff.blockers ?? "Budget/continue signal: do not end early.",
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
return {
|
|
492
|
+
kind: "continue",
|
|
493
|
+
text: [
|
|
494
|
+
"BLOCKED: continue working — do not summarize or end this turn early.",
|
|
495
|
+
`TRIED: stop loop ${loopCount}.`,
|
|
496
|
+
`NEED: ${coreFacade.turn.suggestionFor("budget", "budget")}`,
|
|
497
|
+
].join("\n"),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (policy.grind.enabled && policy.grind.lintCommand && codeTargets.length > 0) {
|
|
502
|
+
const run = await runLockedGate({
|
|
503
|
+
root,
|
|
504
|
+
provider,
|
|
505
|
+
session,
|
|
506
|
+
pendingCredit,
|
|
507
|
+
sessionKey,
|
|
508
|
+
policy,
|
|
509
|
+
gate: "lint",
|
|
510
|
+
command: policy.grind.lintCommand,
|
|
511
|
+
argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.lintCommand, policy.grind.appendFiles)
|
|
512
|
+
? codeTargets
|
|
513
|
+
: [],
|
|
514
|
+
recordFiles: codeTargets,
|
|
515
|
+
});
|
|
516
|
+
if (run.kind === "deferred") {
|
|
517
|
+
deferred.push(run.holder);
|
|
518
|
+
} else if (!run.artifact.passed) {
|
|
519
|
+
return failGate({
|
|
520
|
+
root,
|
|
521
|
+
provider,
|
|
522
|
+
sessionKey,
|
|
523
|
+
gate: "lint",
|
|
524
|
+
artifact: run.artifact,
|
|
525
|
+
loopCount,
|
|
526
|
+
maxLoops,
|
|
527
|
+
policy,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (policy.grind.enabled && policy.grind.testCommand) {
|
|
533
|
+
// why: changed code is enough, at every posture. The narrow form ran the suite only when a test file changed,
|
|
534
|
+
// which skips exactly the change that most needs testing — and it made verification depend on a surfacing
|
|
535
|
+
// preference. Still gated by grind.enabled.
|
|
536
|
+
const shouldRunTests = testTargets.length > 0 || codeTargets.length > 0;
|
|
537
|
+
if (shouldRunTests) {
|
|
538
|
+
const recordFiles = testTargets.length > 0 ? testTargets : codeTargets;
|
|
539
|
+
const run = await runLockedGate({
|
|
540
|
+
root,
|
|
541
|
+
provider,
|
|
542
|
+
session,
|
|
543
|
+
pendingCredit,
|
|
544
|
+
sessionKey,
|
|
545
|
+
policy,
|
|
546
|
+
gate: "test",
|
|
547
|
+
command: policy.grind.testCommand,
|
|
548
|
+
argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.testCommand, policy.grind.appendFiles)
|
|
549
|
+
? testTargets
|
|
550
|
+
: [],
|
|
551
|
+
recordFiles,
|
|
552
|
+
});
|
|
553
|
+
if (run.kind === "deferred") {
|
|
554
|
+
deferred.push(run.holder);
|
|
555
|
+
} else if (!run.artifact.passed) {
|
|
556
|
+
return failGate({
|
|
557
|
+
root,
|
|
558
|
+
provider,
|
|
559
|
+
sessionKey,
|
|
560
|
+
gate: "test",
|
|
561
|
+
artifact: run.artifact,
|
|
562
|
+
loopCount,
|
|
563
|
+
maxLoops,
|
|
564
|
+
policy,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// invariant: observation runs before the enforcing branch and returns nothing. It answers the question a firing
|
|
571
|
+
// rate cannot — was the rule ever needed — by running the checker while the prose is absent. A measurement that
|
|
572
|
+
// can change what it measures is not a measurement ([/decisions/ad-027.md](/decisions/ad-027.md)).
|
|
573
|
+
if (
|
|
574
|
+
codeTargets.length > 0 &&
|
|
575
|
+
coreFacade.observe.shouldObserve(policy.observe, "comments", policy.comments.enabled)
|
|
576
|
+
) {
|
|
577
|
+
const hits = await coreFacade.commentPolicy.scanAddedComments(
|
|
578
|
+
root,
|
|
579
|
+
codeTargets,
|
|
580
|
+
policy.comments.mode,
|
|
581
|
+
turnBase,
|
|
582
|
+
);
|
|
583
|
+
coreFacade.observability.recordObs(root, obsConfigFor(policy), {
|
|
584
|
+
provider,
|
|
585
|
+
kind: "policy.observe",
|
|
586
|
+
sessionKey,
|
|
587
|
+
attrs: {
|
|
588
|
+
...coreFacade.observe.observeAttrs({
|
|
589
|
+
rail: "comments",
|
|
590
|
+
violations: hits.length,
|
|
591
|
+
proseInjected: policy.comments.enabled,
|
|
592
|
+
}),
|
|
593
|
+
rule: "comments",
|
|
594
|
+
// why: a language the catalog does not carry produces no findings, which reads identically to "the
|
|
595
|
+
// property held". Naming the extensions is the difference between a clean reading and a blind spot.
|
|
596
|
+
unknown_extensions: coreFacade.commentPolicy.unknownExtensions(codeTargets).join(",") || "none",
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (policy.comments.enabled && policy.comments.onViolation === "followup" && codeTargets.length > 0) {
|
|
602
|
+
const hits = await coreFacade.commentPolicy.scanAddedComments(
|
|
603
|
+
root,
|
|
604
|
+
codeTargets,
|
|
605
|
+
policy.comments.mode,
|
|
606
|
+
turnBase,
|
|
607
|
+
);
|
|
608
|
+
if (hits.length > 0) {
|
|
609
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
610
|
+
slice: {
|
|
611
|
+
last_gate_result: "fail",
|
|
612
|
+
blockers: `This turn added ${hits.length} undeclared comment line(s).`,
|
|
613
|
+
next_action: coreFacade.turn.suggestionFor("verification", "comments"),
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
return {
|
|
617
|
+
kind: "continue",
|
|
618
|
+
text: coreFacade.commentPolicy.commentViolationMessage(hits, policy.comments.mode),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* why: a dependency added in a turn is code that runs on every later turn, in CI, and on every machine that
|
|
625
|
+
* installs the project. Two mechanical failures are worth a stop: a manifest that moved without its lockfile,
|
|
626
|
+
* and a specifier that names no version ([/decisions/ad-075.md](/decisions/ad-075.md)).
|
|
627
|
+
*/
|
|
628
|
+
if (policy.supplyChain.enabled && changedFiles.length > 0) {
|
|
629
|
+
const manifests = changedFiles.filter((path) => coreFacade.supplyChain.isManifest(path));
|
|
630
|
+
if (manifests.length > 0) {
|
|
631
|
+
const added = await listAddedLines(root, manifests, turnBase);
|
|
632
|
+
const outcome = coreFacade.supplyChain.inspectSupplyChain({
|
|
633
|
+
changedFiles,
|
|
634
|
+
added,
|
|
635
|
+
readManifest: (relativePath) => {
|
|
636
|
+
try {
|
|
637
|
+
return readFileSync(join(root, relativePath), "utf8");
|
|
638
|
+
} catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
if (outcome.findings.length > 0) {
|
|
644
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
645
|
+
slice: {
|
|
646
|
+
last_gate_result: "fail",
|
|
647
|
+
blockers: `This turn changed the dependency graph in ${outcome.findings.length} way(s) that outlive it.`,
|
|
648
|
+
next_action: coreFacade.turn.suggestionFor("verification", "supplyChain"),
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
return {
|
|
652
|
+
kind: "continue",
|
|
653
|
+
text: coreFacade.supplyChain.supplyChainMessage(outcome.findings),
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* why: the same diff scope the comment gate uses, asking a different question — did this turn write something
|
|
661
|
+
* the project already has? Two copies of a run drift apart, and the second copy is where the drift starts
|
|
662
|
+
* ([/decisions/ad-071.md](/decisions/ad-071.md)).
|
|
663
|
+
*/
|
|
664
|
+
if (policy.duplication.enabled && codeTargets.length > 0) {
|
|
665
|
+
const added = await listAddedLines(root, codeTargets, turnBase);
|
|
666
|
+
const tracked = await listTrackedFiles(root);
|
|
667
|
+
const scan = coreFacade.duplication.scanProject(
|
|
668
|
+
tracked,
|
|
669
|
+
(relativePath) => {
|
|
670
|
+
try {
|
|
671
|
+
return readFileSync(join(root, relativePath), "utf8");
|
|
672
|
+
} catch {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
policy.duplication.minRun,
|
|
677
|
+
);
|
|
678
|
+
const hits = coreFacade.duplication.findDuplications(added, scan.index, policy.duplication.minRun);
|
|
679
|
+
if (hits.length > 0) {
|
|
680
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
681
|
+
slice: {
|
|
682
|
+
last_gate_result: "fail",
|
|
683
|
+
blockers: `This turn added ${hits.length} run(s) the project already has.`,
|
|
684
|
+
next_action: coreFacade.turn.suggestionFor("verification", "duplication"),
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
return { kind: "continue", text: coreFacade.duplication.duplicationMessage(hits) };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// invariant: this is the grind pattern. The project brings the structural tool — drift, oasdiff, ast-grep
|
|
692
|
+
// — and the harness runs it through the same lock, artifact writer and failure path as lint and test.
|
|
693
|
+
// Inferring staleness from directory mapping was measured at 82-100% false reports and removed.
|
|
694
|
+
if (policy.docs.command && policy.docs.command.length > 0) {
|
|
695
|
+
const run = await runLockedGate({
|
|
696
|
+
root,
|
|
697
|
+
provider,
|
|
698
|
+
session,
|
|
699
|
+
pendingCredit,
|
|
700
|
+
sessionKey,
|
|
701
|
+
policy,
|
|
702
|
+
gate: "docs",
|
|
703
|
+
command: policy.docs.command,
|
|
704
|
+
argvFiles: [],
|
|
705
|
+
recordFiles: changedFiles,
|
|
706
|
+
});
|
|
707
|
+
if (run.kind === "deferred") {
|
|
708
|
+
deferred.push(run.holder);
|
|
709
|
+
} else if (!run.artifact.passed) {
|
|
710
|
+
if (policy.docs.severity === "deny") {
|
|
711
|
+
return failGate({
|
|
712
|
+
root,
|
|
713
|
+
provider,
|
|
714
|
+
sessionKey,
|
|
715
|
+
gate: "docs",
|
|
716
|
+
artifact: run.artifact,
|
|
717
|
+
loopCount,
|
|
718
|
+
maxLoops,
|
|
719
|
+
policy,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
kind: "context",
|
|
724
|
+
text: [
|
|
725
|
+
"ADVISORY: the documentation gate reported.",
|
|
726
|
+
`TRIED: ${policy.docs.command.join(" ")}`,
|
|
727
|
+
"NEED: update what it names, or accept it knowingly — this does not block the stop.",
|
|
728
|
+
"",
|
|
729
|
+
run.artifact.outputTail,
|
|
730
|
+
].join("\n"),
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// invariant: the plan gate runs before the ship gate. A turn that changed files nobody planned has an
|
|
736
|
+
// invalid scope, which makes any evidence it produced evidence for the wrong change.
|
|
737
|
+
const planDecision = coreFacade.plan.evaluatePlanGate({
|
|
738
|
+
enabled: policy.planGate.enabled,
|
|
739
|
+
declaredAt: handoff.plan_at,
|
|
740
|
+
windowMinutes: policy.planGate.windowMinutes,
|
|
741
|
+
planned: handoff.plan_paths ?? [],
|
|
742
|
+
deviations: handoff.plan_deviations ?? [],
|
|
743
|
+
changedFiles,
|
|
744
|
+
});
|
|
745
|
+
if (planDecision.kind !== "abstain") {
|
|
746
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
747
|
+
slice: {
|
|
748
|
+
last_gate_result: "fail",
|
|
749
|
+
last_failure_category: "policy",
|
|
750
|
+
blockers: "Changed files fall outside the declared HARNESS_PLAN.",
|
|
751
|
+
next_action: "Revert what the plan did not call for, or justify each path with a stated reason.",
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
return planDecision;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const recentShipClaim =
|
|
758
|
+
handoff.last_ship_claim_kind === "structured" &&
|
|
759
|
+
coreFacade.ship.recentShipClaimActive(handoff.last_ship_claim_at, policy.shipGate.claimWindowMinutes);
|
|
760
|
+
|
|
761
|
+
const emptyDiffDecision = coreFacade.ship.evaluateEmptyDiffAntiShip({
|
|
762
|
+
enabled: policy.shipGate.enabled && policy.shipGate.emptyDiffAntiShip,
|
|
763
|
+
recentShipClaim,
|
|
764
|
+
changedFilesCount: changedFiles.length,
|
|
765
|
+
});
|
|
766
|
+
if (emptyDiffDecision.kind !== "abstain") {
|
|
767
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
768
|
+
slice: {
|
|
769
|
+
last_gate_result: "fail",
|
|
770
|
+
blockers: "Structured ship claim with empty diff.",
|
|
771
|
+
next_action: coreFacade.turn.suggestionFor("ship-evidence", "empty-diff"),
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
coreFacade.ship.appendShipLedger(root, {
|
|
775
|
+
provider,
|
|
776
|
+
event: "challenge",
|
|
777
|
+
claimKind: "structured",
|
|
778
|
+
gate: "empty-diff",
|
|
779
|
+
detail: handoff.last_ship_claim_snippet,
|
|
780
|
+
});
|
|
781
|
+
return emptyDiffDecision;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const shipEvidenceDecision = coreFacade.ship.evaluateShipEvidenceGate({
|
|
785
|
+
enabled: policy.shipGate.enabled,
|
|
786
|
+
recentShipClaim,
|
|
787
|
+
changedFiles,
|
|
788
|
+
runtimePathPrefixes: policy.shipGate.runtimePathPrefixes,
|
|
789
|
+
runtimePathExcludes: policy.shipGate.runtimePathExcludes,
|
|
790
|
+
evidenceDir: policy.shipGate.evidenceDir,
|
|
791
|
+
evidenceMaxAgeHours: policy.shipGate.evidenceMaxAgeHours,
|
|
792
|
+
// why: the changed-file list is already in hand, so ordering the evidence against the code costs a stat per
|
|
793
|
+
// file and no git call.
|
|
794
|
+
evidenceNotBeforeMs: coreFacade.ship.newestChangeMs(root, changedFiles),
|
|
795
|
+
});
|
|
796
|
+
if (shipEvidenceDecision.kind !== "abstain") {
|
|
797
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
798
|
+
slice: {
|
|
799
|
+
last_gate_result: "fail",
|
|
800
|
+
blockers: "HARNESS_SHIP_CLAIM without recent production evidence on runtime changes.",
|
|
801
|
+
next_action: coreFacade.turn.suggestionFor("ship-evidence", "ship"),
|
|
802
|
+
},
|
|
803
|
+
});
|
|
804
|
+
coreFacade.ship.appendShipLedger(root, {
|
|
805
|
+
provider,
|
|
806
|
+
event: "challenge",
|
|
807
|
+
claimKind: "structured",
|
|
808
|
+
gate: "ship",
|
|
809
|
+
files: changedFiles.slice(0, 12),
|
|
810
|
+
evidenceDir: policy.shipGate.evidenceDir,
|
|
811
|
+
detail: handoff.last_ship_claim_snippet,
|
|
812
|
+
});
|
|
813
|
+
return shipEvidenceDecision;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (
|
|
817
|
+
policy.shipGate.enabled &&
|
|
818
|
+
recentShipClaim &&
|
|
819
|
+
policy.shipGate.evidenceDir &&
|
|
820
|
+
coreFacade.ship.hasRecentEvidence(policy.shipGate.evidenceDir, policy.shipGate.evidenceMaxAgeHours)
|
|
821
|
+
) {
|
|
822
|
+
coreFacade.ship.appendShipLedger(root, {
|
|
823
|
+
provider,
|
|
824
|
+
event: "pass",
|
|
825
|
+
claimKind: "structured",
|
|
826
|
+
gate: "ship",
|
|
827
|
+
evidenceDir: policy.shipGate.evidenceDir,
|
|
828
|
+
detail: handoff.last_ship_claim_snippet,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// why: the pairing of a failure with what resolved it is captured here, immediately before the record that
|
|
833
|
+
// holds the failure identity is cleared. This is the one moment both halves exist
|
|
834
|
+
// ([/decisions/ad-028.md](/decisions/ad-028.md)).
|
|
835
|
+
if (handoff.last_fingerprint && handoff.last_gate_result === "fail" && changedFiles.length > 0) {
|
|
836
|
+
coreFacade.stagnation.recordResolution(root, handoff.last_fingerprint, {
|
|
837
|
+
files: changedFiles,
|
|
838
|
+
at: new Date().toISOString(),
|
|
839
|
+
gate: handoff.last_failure_category ?? "gate",
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
coreFacade.stagnation.clearFingerprint(root, sessionKey);
|
|
844
|
+
coreFacade.shellPolicy.clearShellStall(root, sessionKey);
|
|
845
|
+
coreFacade.turn.resetLoop(root, sessionKey);
|
|
846
|
+
/**
|
|
847
|
+
* invariant: a deferred gate is `skipped`, never `pass`. Nothing verified this turn — a neighbour session is
|
|
848
|
+
* verifying the same tree — and writing `pass` would put a verdict in the handoff that no gate produced
|
|
849
|
+
* ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
850
|
+
*
|
|
851
|
+
* why: recorded and not narrated. `continue` is the only stop-time channel that carries text and it renders as
|
|
852
|
+
* `{"decision":"block"}`, so telling the turn would mean blocking it — the defect being fixed. `gate.outcome` is
|
|
853
|
+
* a `why` kind, so `tlc harness why` answers it after the fact.
|
|
854
|
+
*/
|
|
855
|
+
const holders = [...new Set(deferred)];
|
|
856
|
+
await coreFacade.handoff.patchHandoff(root, provider, {
|
|
857
|
+
slice: {
|
|
858
|
+
last_gate_result: holders.length > 0 ? "skipped" : "pass",
|
|
859
|
+
blockers: undefined,
|
|
860
|
+
previous_gaps: undefined,
|
|
861
|
+
last_failure_category: undefined,
|
|
862
|
+
next_action:
|
|
863
|
+
holders.length > 0
|
|
864
|
+
? `The grind gate deferred to ${holders.join(", ")} — that session is running it over this tree.`
|
|
865
|
+
: changedFiles.length > 0
|
|
866
|
+
? "Continue or commit when ready."
|
|
867
|
+
: undefined,
|
|
868
|
+
fingerprint_hits: 0,
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
return { kind: "abstain" };
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
if (import.meta.main) {
|
|
875
|
+
await main(stopHandler);
|
|
876
|
+
}
|