@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,76 @@
|
|
|
1
|
+
import type { ObserveConfig } from "../policy/policy.types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Observation mode answers the one question a firing rate cannot: **was the rule ever needed?**
|
|
5
|
+
*
|
|
6
|
+
* A rail that never fires while its prose is injected is either working or unnecessary, and the count alone cannot
|
|
7
|
+
* separate those. Running the checker with the prose *absent* separates them: if the property holds anyway, the
|
|
8
|
+
* model was already doing it and the rule is paying for injected context and delivering nothing.
|
|
9
|
+
*
|
|
10
|
+
* why: this is possible here and nowhere else, because in this harness the checker and the instruction are
|
|
11
|
+
* different things. In a prose-based system the rule *is* the mechanism, so there is nothing to hold apart. The
|
|
12
|
+
* alternative — running a task N times with and without the rule — needs task repetition, and real work does not
|
|
13
|
+
* repeat ([/decisions/ad-027.md](/decisions/ad-027.md)).
|
|
14
|
+
*
|
|
15
|
+
* invariant: observation never returns a Decision and never reaches the turn. A measurement that can change what
|
|
16
|
+
* it measures is not a measurement.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* hazard: `observe.rails` accepted any string, and a name with no checker behind it did nothing and said nothing —
|
|
20
|
+
* the exact defect this project keeps removing, a config value nothing reads. Forward-compatible inertness is fine;
|
|
21
|
+
* silent inertness is not. This list is the one source of what can actually be observed, read by the classifier,
|
|
22
|
+
* by `doctor`, and by the capability catalog's own prompt ([/decisions/ad-029.md](/decisions/ad-029.md)).
|
|
23
|
+
*
|
|
24
|
+
* invariant: a name enters this list in the same change that adds its `shouldObserve` call site. A name here with
|
|
25
|
+
* no call site would advertise an observation that never happens, which is the same lie pointed the other way.
|
|
26
|
+
*/
|
|
27
|
+
export const OBSERVABLE_RAILS: readonly string[] = ["comments"];
|
|
28
|
+
|
|
29
|
+
export function isObservableRail(rail: string): boolean {
|
|
30
|
+
return OBSERVABLE_RAILS.includes(rail);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** why: names the rails that were asked for and cannot be delivered, so a surface can report them by name. */
|
|
34
|
+
export function unobservableRails(rails: readonly string[]): string[] {
|
|
35
|
+
return rails.filter((rail) => !isObservableRail(rail));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ObserveVerdict = {
|
|
39
|
+
rail: string;
|
|
40
|
+
/** How many times the checker found the property violated. Zero is the interesting case. */
|
|
41
|
+
violations: number;
|
|
42
|
+
/**
|
|
43
|
+
* Whether the rail's prose was in the injected context for this turn. This is the field that makes a record
|
|
44
|
+
* interpretable: the same zero means "the rule worked" when true and "the rule was unnecessary" when false.
|
|
45
|
+
*/
|
|
46
|
+
proseInjected: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* invariant: enforcement wins. When a rail is enforcing, its own path already records the outcome, and observing
|
|
51
|
+
* it as well would double-count — the operator would read twice the interruptions they lived through.
|
|
52
|
+
*/
|
|
53
|
+
export function shouldObserve(config: ObserveConfig, rail: string, enforcing: boolean): boolean {
|
|
54
|
+
// hazard: this answered yes for a rail with no checker behind it. Nothing called it for such a rail, so nothing
|
|
55
|
+
// broke — but the function's contract said "observe this" about something unobservable, which is exactly the gap
|
|
56
|
+
// a future call site would fall into.
|
|
57
|
+
return config.enabled && !enforcing && isObservableRail(rail) && config.rails.includes(rail);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function observeAttrs(verdict: ObserveVerdict): Record<string, unknown> {
|
|
61
|
+
return {
|
|
62
|
+
rail: verdict.rail,
|
|
63
|
+
violations: verdict.violations,
|
|
64
|
+
prose_injected: verdict.proseInjected,
|
|
65
|
+
// why: the reading, stated rather than left to be inferred from two fields. An operator scanning a log should
|
|
66
|
+
// not have to reconstruct the logic that makes the number mean something.
|
|
67
|
+
reading:
|
|
68
|
+
verdict.violations === 0
|
|
69
|
+
? verdict.proseInjected
|
|
70
|
+
? "held-with-prose"
|
|
71
|
+
: "held-without-prose"
|
|
72
|
+
: verdict.proseInjected
|
|
73
|
+
? "violated-with-prose"
|
|
74
|
+
: "violated-without-prose",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { PlanDeclaration, PlanDeviation } from "./plan.types.ts";
|
|
2
|
+
|
|
3
|
+
const PLAN_LINE = /(?:^|\n)\s*HARNESS_PLAN:\s*(.+?)\s*(?=\n|$)/;
|
|
4
|
+
const DEVIATION_LINE = /(?:^|\n)\s*HARNESS_PLAN_DEVIATION:\s*(.+?)\s*(?=\n|$)/g;
|
|
5
|
+
const REASON_SEPARATOR = /\s+(?:—|--|-)\s+/;
|
|
6
|
+
|
|
7
|
+
function splitPaths(body: string): string[] {
|
|
8
|
+
return body
|
|
9
|
+
.split(/[,\s]+/)
|
|
10
|
+
.map((path) => path.trim())
|
|
11
|
+
.filter((path) => path.length > 0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// why: an empty declaration is malformed, not "nothing is planned". Treating it as an empty plan would make
|
|
15
|
+
// every subsequent file unplanned and block the turn on a typo.
|
|
16
|
+
export function detectPlan(text: string): PlanDeclaration | null {
|
|
17
|
+
const match = PLAN_LINE.exec(text);
|
|
18
|
+
const body = match?.[1]?.trim();
|
|
19
|
+
if (!body) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const paths = splitPaths(body);
|
|
23
|
+
if (paths.length === 0) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return { paths, snippet: `HARNESS_PLAN: ${body}`.slice(0, 280) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function detectDeviations(text: string): PlanDeviation[] {
|
|
30
|
+
const found: PlanDeviation[] = [];
|
|
31
|
+
for (const match of text.matchAll(DEVIATION_LINE)) {
|
|
32
|
+
const body = match[1]?.trim();
|
|
33
|
+
if (!body) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const [rawPath, ...rest] = body.split(REASON_SEPARATOR);
|
|
37
|
+
const path = rawPath?.trim();
|
|
38
|
+
const reason = rest.join(" ").trim();
|
|
39
|
+
// why: a path with no stated reason is not a justification. Accepting it would turn the gate into a
|
|
40
|
+
// formality the agent can satisfy by naming the file it already touched.
|
|
41
|
+
if (!path || reason.length === 0) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
found.push({ path, reason });
|
|
45
|
+
}
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Decision } from "../../contracts/decision.ts";
|
|
2
|
+
import { pathExcluded } from "../ship/ship.service.ts";
|
|
3
|
+
import type { PlanDeviation, PlanVerdict } from "./plan.types.ts";
|
|
4
|
+
|
|
5
|
+
export function planActive(declaredAt: string | undefined, windowMinutes: number, now = Date.now()): boolean {
|
|
6
|
+
if (!declaredAt) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const at = Date.parse(declaredAt);
|
|
10
|
+
if (Number.isNaN(at)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return now - at < windowMinutes * 60 * 1000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// why: the matcher is the one shipGate.runtimePathExcludes already accepts, so the operator learns a single
|
|
17
|
+
// pattern syntax rather than one per gate.
|
|
18
|
+
export function unplannedPaths(args: {
|
|
19
|
+
changedFiles: readonly string[];
|
|
20
|
+
planned: readonly string[];
|
|
21
|
+
deviations: readonly PlanDeviation[];
|
|
22
|
+
}): string[] {
|
|
23
|
+
const justified = args.deviations.map((deviation) => deviation.path);
|
|
24
|
+
return args.changedFiles.filter((file) => {
|
|
25
|
+
if (pathExcluded(file, [...args.planned])) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return !pathExcluded(file, justified);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function evaluatePlanGate(args: {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
declaredAt: string | undefined;
|
|
35
|
+
windowMinutes: number;
|
|
36
|
+
planned: readonly string[];
|
|
37
|
+
deviations: readonly PlanDeviation[];
|
|
38
|
+
changedFiles: readonly string[];
|
|
39
|
+
now?: number;
|
|
40
|
+
}): Decision {
|
|
41
|
+
const verdict = planVerdict(args);
|
|
42
|
+
if (!verdict.active || verdict.unplanned.length === 0) {
|
|
43
|
+
return { kind: "abstain" };
|
|
44
|
+
}
|
|
45
|
+
const listed = verdict.unplanned.slice(0, 10).join(", ");
|
|
46
|
+
const more = verdict.unplanned.length > 10 ? ` (+${verdict.unplanned.length - 10} more)` : "";
|
|
47
|
+
return {
|
|
48
|
+
kind: "continue",
|
|
49
|
+
text: [
|
|
50
|
+
`BLOCKED: ${verdict.unplanned.length} changed file(s) are outside the declared plan: ${listed}${more}`,
|
|
51
|
+
`TRIED: compared the working tree against HARNESS_PLAN (${args.planned.join(", ")}).`,
|
|
52
|
+
"NEED: either revert what the plan did not call for, or justify each path with a reason —",
|
|
53
|
+
"HARNESS_PLAN_DEVIATION: <path> — <why this file had to change>",
|
|
54
|
+
].join("\n"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function planVerdict(args: {
|
|
59
|
+
enabled: boolean;
|
|
60
|
+
declaredAt: string | undefined;
|
|
61
|
+
windowMinutes: number;
|
|
62
|
+
planned: readonly string[];
|
|
63
|
+
deviations: readonly PlanDeviation[];
|
|
64
|
+
changedFiles: readonly string[];
|
|
65
|
+
now?: number;
|
|
66
|
+
}): PlanVerdict {
|
|
67
|
+
if (!args.enabled || args.planned.length === 0) {
|
|
68
|
+
return { active: false, unplanned: [] };
|
|
69
|
+
}
|
|
70
|
+
if (!planActive(args.declaredAt, args.windowMinutes, args.now ?? Date.now())) {
|
|
71
|
+
return { active: false, unplanned: [] };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
active: true,
|
|
75
|
+
unplanned: unplannedPaths({
|
|
76
|
+
changedFiles: args.changedFiles,
|
|
77
|
+
planned: args.planned,
|
|
78
|
+
deviations: args.deviations,
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type PlanDeclaration = {
|
|
2
|
+
paths: string[];
|
|
3
|
+
snippet: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type PlanDeviation = {
|
|
7
|
+
path: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type PlanState = {
|
|
12
|
+
paths: string[];
|
|
13
|
+
declaredAt: string;
|
|
14
|
+
deviations: PlanDeviation[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PlanPolicyConfig = {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
windowMinutes: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type PlanVerdict = {
|
|
23
|
+
active: boolean;
|
|
24
|
+
unplanned: string[];
|
|
25
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { MIN_RUN } from "../duplication/duplication.service.ts";
|
|
2
|
+
import { DEFAULT_OBS } from "../observability/observability.types.ts";
|
|
3
|
+
import type { Policy } from "./policy.types.ts";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_LESSONS_POLICY: Policy["intelligence"]["lessons"] = {
|
|
6
|
+
enabled: false,
|
|
7
|
+
maxInjectSession: 5,
|
|
8
|
+
maxInjectRetry: 8,
|
|
9
|
+
maxCharsSession: 900,
|
|
10
|
+
maxCharsRetry: 1400,
|
|
11
|
+
promoteHitCount: 2,
|
|
12
|
+
decayLambda: 0.02,
|
|
13
|
+
projectBoost: 1.5,
|
|
14
|
+
syncRulesFile: "auto",
|
|
15
|
+
gardenOnSessionEnd: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const DEFAULTS: Policy = {
|
|
19
|
+
version: 1,
|
|
20
|
+
mode: "solo",
|
|
21
|
+
codePaths: ["src", "apps", "libs", "packages"],
|
|
22
|
+
grind: {
|
|
23
|
+
enabled: false,
|
|
24
|
+
maxLoops: 5,
|
|
25
|
+
lintCommand: null,
|
|
26
|
+
testCommand: null,
|
|
27
|
+
appendFiles: "auto",
|
|
28
|
+
},
|
|
29
|
+
shipGate: {
|
|
30
|
+
enabled: false,
|
|
31
|
+
runtimePathPrefixes: ["src", "apps", "libs", "packages", "deploy", "scripts"],
|
|
32
|
+
runtimePathExcludes: [".tlc/", "**/node_modules/", "**/.git/"],
|
|
33
|
+
evidenceDir: null,
|
|
34
|
+
evidenceMaxAgeHours: 48,
|
|
35
|
+
emptyDiffAntiShip: false,
|
|
36
|
+
claimWindowMinutes: 10,
|
|
37
|
+
},
|
|
38
|
+
subagents: {
|
|
39
|
+
enforceAllowlist: false,
|
|
40
|
+
requireModel: false,
|
|
41
|
+
allowedModels: [],
|
|
42
|
+
blockedPatterns: ["-fast(?:$|[^a-z0-9])", "/fast(?:$|[^a-z0-9])"],
|
|
43
|
+
minEffort: null,
|
|
44
|
+
blockParentFast: false,
|
|
45
|
+
blockMode: "deny",
|
|
46
|
+
readOnlyTypes: ["explore"],
|
|
47
|
+
},
|
|
48
|
+
docs: {
|
|
49
|
+
command: null,
|
|
50
|
+
severity: "warn",
|
|
51
|
+
},
|
|
52
|
+
observe: {
|
|
53
|
+
enabled: false,
|
|
54
|
+
rails: [],
|
|
55
|
+
},
|
|
56
|
+
comments: {
|
|
57
|
+
enabled: false,
|
|
58
|
+
onViolation: "followup",
|
|
59
|
+
mode: "declared",
|
|
60
|
+
},
|
|
61
|
+
supplyChain: {
|
|
62
|
+
enabled: false,
|
|
63
|
+
},
|
|
64
|
+
duplication: {
|
|
65
|
+
enabled: false,
|
|
66
|
+
minRun: MIN_RUN,
|
|
67
|
+
},
|
|
68
|
+
obs: {
|
|
69
|
+
globalSpool: false,
|
|
70
|
+
includePayloads: DEFAULT_OBS.includePayloads,
|
|
71
|
+
maxAttrChars: DEFAULT_OBS.maxAttrChars,
|
|
72
|
+
sessionCostAlertUsd: DEFAULT_OBS.sessionCostAlertUsd,
|
|
73
|
+
retentionDays: DEFAULT_OBS.retentionDays,
|
|
74
|
+
},
|
|
75
|
+
untrustedContent: {
|
|
76
|
+
mode: "frame",
|
|
77
|
+
enabled: false,
|
|
78
|
+
extraTools: [],
|
|
79
|
+
extraCommandPatterns: [],
|
|
80
|
+
},
|
|
81
|
+
planGate: {
|
|
82
|
+
enabled: false,
|
|
83
|
+
windowMinutes: 120,
|
|
84
|
+
},
|
|
85
|
+
shell: {
|
|
86
|
+
catastrophicAsk: true,
|
|
87
|
+
stallDetection: false,
|
|
88
|
+
stallRepeatThreshold: 3,
|
|
89
|
+
},
|
|
90
|
+
intelligence: {
|
|
91
|
+
gapFeedback: true,
|
|
92
|
+
failureClassification: true,
|
|
93
|
+
progressiveHandoff: true,
|
|
94
|
+
progressiveContext: true,
|
|
95
|
+
autopilot: true,
|
|
96
|
+
idleTurnGate: false,
|
|
97
|
+
budgetContinue: false,
|
|
98
|
+
budgetContinueAfterLoops: 3,
|
|
99
|
+
lessons: { ...DEFAULT_LESSONS_POLICY },
|
|
100
|
+
},
|
|
101
|
+
mcpPrime: [],
|
|
102
|
+
bootstrapExtra: [],
|
|
103
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Decision } from "../../contracts/decision.ts";
|
|
2
|
+
import { isPolicySurface } from "../floor/floor.paths.ts";
|
|
3
|
+
|
|
4
|
+
export { isPolicySurface };
|
|
5
|
+
|
|
6
|
+
const WRITE_TOOLS = new Set(["Edit", "Write", "Delete", "MultiEdit", "NotebookEdit"]);
|
|
7
|
+
|
|
8
|
+
export function guardPolicySurface(args: {
|
|
9
|
+
projectDir: string;
|
|
10
|
+
toolName: string | undefined;
|
|
11
|
+
filePath: string | undefined;
|
|
12
|
+
}): Decision {
|
|
13
|
+
if (!args.toolName || !WRITE_TOOLS.has(args.toolName) || !args.filePath) {
|
|
14
|
+
return { kind: "allow" };
|
|
15
|
+
}
|
|
16
|
+
if (!isPolicySurface(args.projectDir, args.filePath)) {
|
|
17
|
+
return { kind: "allow" };
|
|
18
|
+
}
|
|
19
|
+
// hazard: this used to answer "change policy through the CLI instead", naming the very subcommands the floor
|
|
20
|
+
// refuses from inside a session. Measured: the agent read it as a route, ran `tlc harness mode`, and was denied
|
|
21
|
+
// again — a suggestion that costs a turn and teaches nothing. The CLI is the operator's route, not the reader's.
|
|
22
|
+
return {
|
|
23
|
+
kind: "deny",
|
|
24
|
+
reason: [
|
|
25
|
+
"Harness policy and state are not agent-writable — a gate an agent can switch off is not a gate.",
|
|
26
|
+
"The harness CLI does not help you here either: the same floor rule refuses the mutating subcommands from inside a session.",
|
|
27
|
+
"Tell the operator which value you would change and why, and let them run it from their own terminal.",
|
|
28
|
+
].join(" "),
|
|
29
|
+
userNote: `Blocked an agent write to ${args.filePath}.`,
|
|
30
|
+
// why: the shell route to the same paths denies with `rule=policy-surface-write` and this one carried no rule
|
|
31
|
+
// at all, so the tool half of one guard was unattributable in the report that counts refusals by rule.
|
|
32
|
+
rule: "policy-surface-write",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Decision } from "../../contracts/decision.ts";
|
|
5
|
+
import {
|
|
6
|
+
flagsDir,
|
|
7
|
+
policyBaselineDir,
|
|
8
|
+
projectConfigPath,
|
|
9
|
+
projectStateDir,
|
|
10
|
+
runtimeHome,
|
|
11
|
+
} from "../../platform/paths.ts";
|
|
12
|
+
import { sanitizeSegment } from "../../platform/sanitize.ts";
|
|
13
|
+
|
|
14
|
+
export type PolicySource = { path: string; hash: string };
|
|
15
|
+
|
|
16
|
+
const ABSENT = "absent";
|
|
17
|
+
const SCHEMA = "harness.policy-baseline.v1";
|
|
18
|
+
|
|
19
|
+
// why: every file `loadPolicy` consults. A mutation that changed the effective policy without touching one
|
|
20
|
+
// of these would be a change the loader cannot see either.
|
|
21
|
+
const MODE_FILE = "harness-mode";
|
|
22
|
+
// why: every file the loader consults. The posture flags carry the posture names, so renaming a posture
|
|
23
|
+
// renames its flag — a stale file from the old spelling decides nothing and is not hashed.
|
|
24
|
+
const FLAG_FILES = ["grind-on", "skip-verify", "focus", "paired"];
|
|
25
|
+
|
|
26
|
+
function hashOf(path: string): string {
|
|
27
|
+
if (!existsSync(path)) {
|
|
28
|
+
return ABSENT;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
32
|
+
} catch {
|
|
33
|
+
// hazard: an unreadable source must not read as unchanged, or a policy made unreadable mid-session
|
|
34
|
+
// would pass silently. It gets its own marker instead.
|
|
35
|
+
return "unreadable";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function policySourceFingerprint(root: string): PolicySource[] {
|
|
40
|
+
const paths = [
|
|
41
|
+
projectConfigPath(root),
|
|
42
|
+
join(runtimeHome(), "config.json"),
|
|
43
|
+
join(projectStateDir(root), MODE_FILE),
|
|
44
|
+
...FLAG_FILES.map((flag) => join(flagsDir(root), flag)),
|
|
45
|
+
];
|
|
46
|
+
return paths.map((path) => ({ path, hash: hashOf(path) }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function baselinePath(root: string, sessionKey: string): string {
|
|
50
|
+
return join(policyBaselineDir(root), `${sanitizeSegment(sessionKey)}.json`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function recordPolicyBaseline(root: string, sessionKey: string): void {
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(policyBaselineDir(root), { recursive: true });
|
|
56
|
+
writeFileSync(
|
|
57
|
+
baselinePath(root, sessionKey),
|
|
58
|
+
`${JSON.stringify({ schema: SCHEMA, sources: policySourceFingerprint(root) }, null, 2)}\n`,
|
|
59
|
+
"utf8",
|
|
60
|
+
);
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readBaseline(root: string, sessionKey: string): PolicySource[] | null {
|
|
65
|
+
const path = baselinePath(root, sessionKey);
|
|
66
|
+
if (!existsSync(path)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as { sources?: PolicySource[] };
|
|
71
|
+
return Array.isArray(parsed.sources) ? parsed.sources : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* hazard: this compared every current source against the baseline, so a path the baseline had never heard of
|
|
79
|
+
* counted as divergence. That is not a policy change — it means the harness's own list of policy sources grew
|
|
80
|
+
* or was renamed, which happens on upgrade. Renaming a posture flag made this fire against its own author, and
|
|
81
|
+
* it would have accused every operator with a live session of tampering the moment they updated the harness.
|
|
82
|
+
*
|
|
83
|
+
* invariant: only paths present in both are compared, and a hash that moved still fires. An out-of-band flag
|
|
84
|
+
* write is caught because the baseline records absent sources too, so that source goes from `absent` to a hash
|
|
85
|
+
* on a path the baseline already knows — detection is untouched.
|
|
86
|
+
*/
|
|
87
|
+
function divergedIn(baseline: PolicySource[], current: PolicySource[]): string[] {
|
|
88
|
+
const recorded = new Map(baseline.map((source) => [source.path, source.hash]));
|
|
89
|
+
return current
|
|
90
|
+
.filter((source) => {
|
|
91
|
+
const was = recorded.get(source.path);
|
|
92
|
+
return was !== undefined && was !== source.hash;
|
|
93
|
+
})
|
|
94
|
+
.map((source) => source.path);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* why: every diverged path, not the first. Reporting one at a time makes an operator repair, get blocked again, and
|
|
99
|
+
* repair again — and accepting requires the full list anyway
|
|
100
|
+
* ([/decisions/ad-030.md](/decisions/ad-030.md)).
|
|
101
|
+
*/
|
|
102
|
+
export function divergedPaths(root: string, sessionKey: string): string[] {
|
|
103
|
+
const baseline = readBaseline(root, sessionKey);
|
|
104
|
+
return baseline === null ? [] : divergedIn(baseline, policySourceFingerprint(root));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** why: the union across live sessions, so a listing shows what the operator has to deal with, not one session's view. */
|
|
108
|
+
export function allDivergedPaths(root: string): string[] {
|
|
109
|
+
const dir = policyBaselineDir(root);
|
|
110
|
+
if (!existsSync(dir)) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const found = new Set<string>();
|
|
114
|
+
const current = policySourceFingerprint(root);
|
|
115
|
+
for (const entry of readdirSync(dir)) {
|
|
116
|
+
if (!entry.endsWith(".json")) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const baseline = readBaseline(root, entry.replace(/\.json$/, ""));
|
|
120
|
+
if (baseline) {
|
|
121
|
+
for (const path of divergedIn(baseline, current)) {
|
|
122
|
+
found.add(path);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...found].sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type AcceptOutcome =
|
|
130
|
+
| { kind: "accepted"; paths: string[] }
|
|
131
|
+
| { kind: "not-a-source"; paths: string[]; sources: string[] }
|
|
132
|
+
| { kind: "nothing-to-accept" };
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* invariant: per source. `refreshPolicyBaselines` rewrites the whole fingerprint, so using it here would silently
|
|
136
|
+
* accept every *other* change alongside the one named — which is the hole this closes. Each named path gets its
|
|
137
|
+
* current hash written into every live session's baseline; every other entry is left exactly as it was, so a second
|
|
138
|
+
* divergence still fires.
|
|
139
|
+
*
|
|
140
|
+
* invariant: no blanket permission is expressible. The accepted hash is the hash at this moment, so a later change
|
|
141
|
+
* to the same file diverges again. There is deliberately no way to stop watching a source.
|
|
142
|
+
*
|
|
143
|
+
* hazard: this is the one function in the codebase whose job is to clear a tampering signal. It reads no policy and
|
|
144
|
+
* makes no judgement — the four locks that keep it out of an agent's reach live at the CLI and in the floor, where
|
|
145
|
+
* they can be tested independently ([/decisions/ad-030.md](/decisions/ad-030.md)).
|
|
146
|
+
*/
|
|
147
|
+
export function acceptPolicySources(root: string, paths: string[]): AcceptOutcome {
|
|
148
|
+
const current = policySourceFingerprint(root);
|
|
149
|
+
const known = new Map(current.map((source) => [source.path, source.hash]));
|
|
150
|
+
const unknown = paths.filter((path) => !known.has(path));
|
|
151
|
+
if (unknown.length > 0) {
|
|
152
|
+
return { kind: "not-a-source", paths: unknown, sources: current.map((source) => source.path) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const dir = policyBaselineDir(root);
|
|
156
|
+
if (!existsSync(dir) || paths.length === 0) {
|
|
157
|
+
return { kind: "nothing-to-accept" };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const entry of readdirSync(dir)) {
|
|
161
|
+
if (!entry.endsWith(".json")) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const sessionKey = entry.replace(/\.json$/, "");
|
|
165
|
+
const baseline = readBaseline(root, sessionKey);
|
|
166
|
+
if (!baseline) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const updated = baseline.map((source) =>
|
|
170
|
+
paths.includes(source.path) ? { path: source.path, hash: known.get(source.path) as string } : source,
|
|
171
|
+
);
|
|
172
|
+
try {
|
|
173
|
+
writeFileSync(
|
|
174
|
+
join(dir, entry),
|
|
175
|
+
`${JSON.stringify({ schema: SCHEMA, sources: updated }, null, 2)}\n`,
|
|
176
|
+
"utf8",
|
|
177
|
+
);
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
return { kind: "accepted", paths };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// invariant: this is the layer that covers what shell parsing cannot — `bash script.sh`, a compiled binary,
|
|
184
|
+
// a path built at runtime. It reads the policy's *bytes*, never its values, so nothing inside the policy can
|
|
185
|
+
// switch off the check that watches it.
|
|
186
|
+
export function checkPolicyBaseline(root: string, sessionKey: string): Decision {
|
|
187
|
+
const baseline = readBaseline(root, sessionKey);
|
|
188
|
+
// why: a missing baseline is the first hook of a session, not evidence of tampering. Recording and
|
|
189
|
+
// allowing is the only safe reading — blocking here would break every fresh session.
|
|
190
|
+
if (!baseline) {
|
|
191
|
+
recordPolicyBaseline(root, sessionKey);
|
|
192
|
+
return { kind: "allow" };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const diverged = divergedIn(baseline, policySourceFingerprint(root));
|
|
196
|
+
if (diverged.length === 0) {
|
|
197
|
+
return { kind: "allow" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* hazard: the reason used to end "the harness commands re-record the baseline when they write" — and the floor
|
|
202
|
+
* refuses every one of those commands from inside a session. A blocked agent read it as a route, tried one, and
|
|
203
|
+
* stayed blocked. `reason` reaches the agent and `userNote` reaches the operator; the fix is to stop putting the
|
|
204
|
+
* operator's instructions in the agent's half ([/decisions/ad-030.md](/decisions/ad-030.md)).
|
|
205
|
+
*/
|
|
206
|
+
return {
|
|
207
|
+
kind: "deny",
|
|
208
|
+
reason: [
|
|
209
|
+
`HARNESS: ${diverged.join(", ")} changed during this session, and no harness command changed it.`,
|
|
210
|
+
"The gates are now running a policy the operator did not set, so what they check cannot be trusted.",
|
|
211
|
+
"Report this to the operator and name the paths above. Only they can clear it, from their own terminal — the harness commands that would are refused from inside a session, so there is nothing for you to run here.",
|
|
212
|
+
].join("\n"),
|
|
213
|
+
userNote: [
|
|
214
|
+
`Harness policy changed out of band during this session: ${diverged.join(", ")}.`,
|
|
215
|
+
`If that was you, accept it with: tlc harness policy accept ${diverged.join(" ")}`,
|
|
216
|
+
].join(" "),
|
|
217
|
+
rule: "policy-baseline-divergence",
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// why: the CLI is the sanctioned mutator, so after it writes, every live session's baseline is refreshed.
|
|
222
|
+
// It cannot know which sessions are live, which is exactly why it refreshes all of them — and it makes
|
|
223
|
+
// "a harness command did this" and "the baseline matches" the same fact, with no second log to drift.
|
|
224
|
+
export function refreshPolicyBaselines(root: string): void {
|
|
225
|
+
const dir = policyBaselineDir(root);
|
|
226
|
+
if (!existsSync(dir)) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const sources = policySourceFingerprint(root);
|
|
230
|
+
for (const entry of readdirSync(dir)) {
|
|
231
|
+
if (!entry.endsWith(".json")) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
writeFileSync(join(dir, entry), `${JSON.stringify({ schema: SCHEMA, sources }, null, 2)}\n`, "utf8");
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
}
|