@tech-leads-club/harness-toolkit 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +44 -8
  2. package/bin/tlc-cli.ts +40 -1
  3. package/bin/tlc-exec.d.mts +1 -0
  4. package/bin/tlc-exec.mjs +47 -2
  5. package/capabilities/catalog.json +47 -30
  6. package/dist/compact-before.mjs +84 -78
  7. package/dist/doctor.mjs +87 -81
  8. package/dist/help-topic.mjs +6 -5
  9. package/dist/init-project.mjs +147 -9
  10. package/dist/install-runtime.mjs +85 -79
  11. package/dist/lessons-cli.mjs +87 -81
  12. package/dist/obs-cli.mjs +83 -77
  13. package/dist/price-lookup.mjs +2 -2
  14. package/dist/prompt-submit.mjs +84 -78
  15. package/dist/refresh-model-prices.mjs +85 -79
  16. package/dist/response-after.mjs +84 -78
  17. package/dist/run.mjs +84 -78
  18. package/dist/session-end.mjs +90 -84
  19. package/dist/session-start.mjs +92 -86
  20. package/dist/shim.mjs +82 -76
  21. package/dist/stop.mjs +90 -84
  22. package/dist/subagent-start.mjs +84 -78
  23. package/dist/subagent-stop.mjs +85 -79
  24. package/dist/support.mjs +88 -82
  25. package/dist/tlc-cli.mjs +104 -98
  26. package/dist/tool-after.mjs +84 -78
  27. package/dist/tool-before.mjs +84 -78
  28. package/dist/tool-failure.mjs +84 -78
  29. package/dist/uninstall-runtime.mjs +4 -4
  30. package/docs/architecture.md +1 -0
  31. package/docs/concepts.md +86 -0
  32. package/docs/diagnose.md +20 -0
  33. package/docs/init.md +10 -2
  34. package/docs/lessons.md +12 -0
  35. package/docs/log.md +6 -0
  36. package/package.json +1 -1
  37. package/skills/harness-init/references/capabilities.md +54 -0
  38. package/src/contracts/index.ts +1 -0
  39. package/src/contracts/tool-names.ts +25 -0
  40. package/src/core/core.facade.ts +54 -0
  41. package/src/core/floor/floor.paths.ts +2 -2
  42. package/src/core/floor/floor.policy-surface.ts +6 -1
  43. package/src/core/lesson/lesson.select.ts +30 -7
  44. package/src/core/policy/policy.defaults.ts +3 -0
  45. package/src/core/policy/policy.guard.ts +2 -3
  46. package/src/core/policy/policy.integrity.ts +2 -2
  47. package/src/core/policy/policy.loader.ts +14 -3
  48. package/src/core/policy/policy.shadow.ts +97 -0
  49. package/src/core/policy/policy.types.ts +8 -0
  50. package/src/core/presence/presence.service.ts +10 -2
  51. package/src/core/release/release.decisions.ts +3 -13
  52. package/src/core/rules/rules.decide.ts +123 -0
  53. package/src/core/rules/rules.observe.ts +76 -0
  54. package/src/core/rules/rules.parse.ts +142 -0
  55. package/src/core/rules/rules.proof.ts +130 -0
  56. package/src/core/rules/rules.service.ts +141 -0
  57. package/src/core/rules/rules.store.ts +77 -0
  58. package/src/core/rules/rules.trigger.ts +101 -0
  59. package/src/core/rules/rules.types.ts +64 -0
  60. package/src/entrypoints/run.ts +31 -1
  61. package/src/entrypoints/shim.ts +9 -1
  62. package/src/entrypoints/stop.ts +75 -1
  63. package/src/entrypoints/subagent-stop.ts +9 -1
  64. package/src/entrypoints/support.ts +32 -0
  65. package/src/entrypoints/tool-after.ts +7 -2
  66. package/src/entrypoints/tool-before.ts +44 -3
  67. package/src/platform/frontmatter.ts +142 -0
  68. package/src/platform/links.ts +32 -0
  69. package/src/platform/paths.ts +58 -4
  70. package/src/platform/pricing.ts +3 -3
  71. package/src/platform/screen.ts +62 -3
  72. package/tools/doctor.ts +162 -2
  73. package/tools/help-topic.ts +39 -23
  74. package/tools/init-project.ts +51 -6
  75. package/tools/install-runtime.ts +23 -2
  76. package/tools/lessons-cli.ts +4 -1
  77. package/tools/refresh-model-prices.ts +2 -2
  78. package/tools/uninstall-runtime.ts +11 -3
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Whether the proof a rule demands exists.
3
+ *
4
+ * invariant: a proof is satisfied only by something the harness itself observed. Never by the agent asserting it,
5
+ * and never by a model's verdict — the same change gets different verdicts across runs, so a probabilistic
6
+ * reviewer cannot be an enforcement mechanism ([/decisions/ad-100.md](/decisions/ad-100.md)).
7
+ *
8
+ * invariant: pure. The store hands over the observations; this decides what they mean.
9
+ */
10
+ import { matchesPhrase } from "./rules.trigger.ts";
11
+ import type { Rule, RuleProof } from "./rules.types.ts";
12
+
13
+ /**
14
+ * One thing the harness saw.
15
+ *
16
+ * why `sha`: freshness is part of the proof. A review of the code as it was two commits ago reviewed something
17
+ * else, so an observation carries the HEAD it was made against and `since HEAD` compares them.
18
+ */
19
+ export type Observation = {
20
+ kind: RuleProof["kind"];
21
+ /** The subagent type, the command as words, the gate name, or the path. */
22
+ value: string;
23
+ /** HEAD when it was observed. `null` when the project is not a git checkout. */
24
+ sha: string | null;
25
+ sessionKey: string;
26
+ at: string;
27
+ };
28
+
29
+ export type ProofContext = { sha: string | null; sessionKey: string };
30
+
31
+ /**
32
+ * why a suffix and a directory instead of a glob engine: nothing else in this repository needs globs, and an
33
+ * engine written for one caller is generality nobody asked for. Three shapes, each stated: an exact path,
34
+ * `*.<ext>`, and a `dir/` prefix. A rule that needs more than that is a decision with a reason, not a silent
35
+ * extension of this function.
36
+ */
37
+ function pathMatches(pattern: string, path: string): boolean {
38
+ if (pattern === path) {
39
+ return true;
40
+ }
41
+ if (pattern.startsWith("*")) {
42
+ return path.endsWith(pattern.slice(1));
43
+ }
44
+ if (pattern.endsWith("/")) {
45
+ return path.startsWith(pattern);
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function valueMatches(proof: RuleProof, observation: Observation): boolean {
51
+ if (proof.kind !== observation.kind) {
52
+ return false;
53
+ }
54
+ switch (proof.kind) {
55
+ case "command":
56
+ // why the same phrase rule as the trigger: the operator wrote words in an order, in both places.
57
+ return matchesPhrase(observation.value.split(/\s+/), proof.value);
58
+ case "file":
59
+ return pathMatches(proof.value, observation.value);
60
+ default:
61
+ return observation.value === proof.value;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * invariant: `since HEAD` needs a sha on both sides. When the project is not a git checkout there is no HEAD to
67
+ * compare, so a `since HEAD` proof cannot be satisfied — and saying so is the honest answer, rather than treating
68
+ * "no sha" as "any sha".
69
+ */
70
+ function windowMatches(proof: RuleProof, observation: Observation, context: ProofContext): boolean {
71
+ if (proof.since === "session") {
72
+ return observation.sessionKey === context.sessionKey;
73
+ }
74
+ return context.sha !== null && observation.sha === context.sha;
75
+ }
76
+
77
+ export function proofSatisfied(
78
+ proof: RuleProof,
79
+ observations: readonly Observation[],
80
+ context: ProofContext,
81
+ ): boolean {
82
+ return observations.some(
83
+ (observation) => valueMatches(proof, observation) && windowMatches(proof, observation, context),
84
+ );
85
+ }
86
+
87
+ /** invariant: every proof must hold. The list is a conjunction, which is why there is no boolean algebra. */
88
+ export function missingProofs(
89
+ rule: Rule,
90
+ observations: readonly Observation[],
91
+ context: ProofContext,
92
+ ): RuleProof[] {
93
+ return rule.require.filter((proof) => !proofSatisfied(proof, observations, context));
94
+ }
95
+
96
+ export function proofLabel(proof: RuleProof): string {
97
+ return `${proof.kind}(${proof.value}) since ${proof.since === "head" ? "HEAD" : "session"}`;
98
+ }
99
+
100
+ /**
101
+ * Whether recording this kind could ever matter here.
102
+ *
103
+ * why: the observing rails fire on every tool call, and a fact nothing reads is a write and a process spawn for
104
+ * nothing. An operator whose only rule wants `subagent(the-jury)` pays no git on any command
105
+ * ([/decisions/ad-100.md](/decisions/ad-100.md)).
106
+ */
107
+ export function kindIsRequired(rules: readonly Rule[], kind: RuleProof["kind"]): boolean {
108
+ return rules.some((rule) => rule.require.some((proof) => proof.kind === kind));
109
+ }
110
+
111
+ /**
112
+ * What `doctor` needs to tell an operator that a rule can never be satisfied here.
113
+ *
114
+ * why this rather than a capability flag: both hosts report subagent types today, so a flag for it would be a flag
115
+ * that is never false — the shape of a rail that reads as protection and measures nothing
116
+ * ([/decisions/ad-034.md](/decisions/ad-034.md)). What is worth saying is factual: this rule wants a kind of
117
+ * observation that has never been recorded in this project.
118
+ */
119
+ export function unobservedKinds(
120
+ rules: readonly Rule[],
121
+ observations: readonly Observation[],
122
+ ): Array<{ rule: string; kinds: RuleProof["kind"][] }> {
123
+ const seen = new Set(observations.map((observation) => observation.kind));
124
+ return rules
125
+ .map((rule) => ({
126
+ rule: rule.name,
127
+ kinds: [...new Set(rule.require.map((proof) => proof.kind))].filter((kind) => !seen.has(kind)),
128
+ }))
129
+ .filter((entry) => entry.kinds.length > 0);
130
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The composition the entrypoints call: read the rules, see what fired, decide.
3
+ *
4
+ * why here and not in the entrypoint: the entrypoints are adapters. Which rules apply, what proves them and what
5
+ * the verdict is are all decisions, and decisions live in core ([/decisions/ad-016.md](/decisions/ad-016.md)).
6
+ *
7
+ * invariant: with the capability off, or with no rule files, this reads two directory entries and returns nothing.
8
+ * That is what makes the feature inert until an operator declares something (AC1).
9
+ */
10
+ import type { Decision } from "../../contracts/decision.ts";
11
+ import type { OperatorMode } from "../policy/policy.types.ts";
12
+ import { actionDecision, evaluateRules, type RuleOutcome, stopDecision, strictest } from "./rules.decide.ts";
13
+ import {
14
+ gateObservation,
15
+ type ObservableEvent,
16
+ type ObserveContext,
17
+ observationFrom,
18
+ observedFact,
19
+ } from "./rules.observe.ts";
20
+ import { buildRuleSet } from "./rules.parse.ts";
21
+ import { kindIsRequired } from "./rules.proof.ts";
22
+ import { readObservations, readRuleSources, recordObservation } from "./rules.store.ts";
23
+ import { firingRules, type TriggerContext } from "./rules.trigger.ts";
24
+ import type { RuleError, RuleSet } from "./rules.types.ts";
25
+
26
+ export type RulesConfig = { enabled: boolean };
27
+
28
+ export function loadRules(root: string, config: RulesConfig): RuleSet {
29
+ if (!config.enabled) {
30
+ return { rules: [], disabled: [], errors: [] };
31
+ }
32
+ return buildRuleSet(readRuleSources(root));
33
+ }
34
+
35
+ /**
36
+ * Whether this event is worth a sha.
37
+ *
38
+ * hazard: nothing called `observe` at all in the first cut of this feature. The store was never written, so no
39
+ * proof could exist, so every rule that parsed denied for ever — and `require:` is mandatory, so that was every
40
+ * rule. The end-to-end run that appeared to show the loop working was a script calling `observe` by hand, which
41
+ * supplied the missing half and hid it ([/decisions/ad-100.md](/decisions/ad-100.md)).
42
+ *
43
+ * why the question is asked before the answer is fetched: the observing rails fire on every tool call and the sha
44
+ * is a process spawn. An operator whose only rule wants `subagent(the-jury)` pays two directory reads per command
45
+ * and no git at all.
46
+ */
47
+ export function wantsObservation(root: string, config: RulesConfig, event: ObservableEvent): boolean {
48
+ const fact = observedFact(event);
49
+ return fact !== null && kindIsRequired(loadRules(root, config).rules, fact.kind);
50
+ }
51
+
52
+ /** invariant: with the capability off nothing is written, so a machine that never opted in carries no new file. */
53
+ export function observe(
54
+ root: string,
55
+ config: RulesConfig,
56
+ event: ObservableEvent,
57
+ context: ObserveContext,
58
+ ): void {
59
+ if (!config.enabled) {
60
+ return;
61
+ }
62
+ const observation = observationFrom(event, context);
63
+ if (observation !== null) {
64
+ recordObservation(root, observation);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A gate is the one proof the harness decides rather than witnesses, so it is recorded where it is decided.
70
+ *
71
+ * invariant: only a gate that passed. Recording a failure as an observation would make "the gate ran" satisfy a
72
+ * rule that asked for "the gate passed".
73
+ */
74
+ export function wantsGateObservation(root: string, config: RulesConfig): boolean {
75
+ return kindIsRequired(loadRules(root, config).rules, "gate");
76
+ }
77
+
78
+ export function observeGate(root: string, config: RulesConfig, gate: string, context: ObserveContext): void {
79
+ if (!config.enabled) {
80
+ return;
81
+ }
82
+ recordObservation(root, gateObservation(gate, context));
83
+ }
84
+
85
+ export type RulesVerdict = {
86
+ decision: Decision;
87
+ /** Everything that fired, so a `follow-up` or a `warn` can be reported even when the action is allowed. */
88
+ outcomes: RuleOutcome[];
89
+ errors: RuleError[];
90
+ };
91
+
92
+ const NOTHING: RulesVerdict = { decision: { kind: "abstain" }, outcomes: [], errors: [] };
93
+
94
+ /**
95
+ * The action-time answer. `deny` and `ask` block here; `follow-up` and `warn` are answers to the end of a turn and
96
+ * to the record, so they abstain and are returned for the caller to report.
97
+ */
98
+ export function decideAction(
99
+ root: string,
100
+ config: RulesConfig,
101
+ trigger: TriggerContext,
102
+ context: RuleContext,
103
+ ): RulesVerdict {
104
+ return decide(root, config, trigger, context, actionDecision);
105
+ }
106
+
107
+ /**
108
+ * The end-of-turn answer, and the only caller that can see an `on: stop` rule.
109
+ *
110
+ * why a second entry rather than a flag: the trigger is fixed and the mapping differs, so a boolean would make one
111
+ * function answer two questions ([/decisions/ad-100.md](/decisions/ad-100.md)).
112
+ */
113
+ export function decideStop(root: string, config: RulesConfig, context: RuleContext): RulesVerdict {
114
+ return decide(root, config, { event: "stop" }, context, stopDecision);
115
+ }
116
+
117
+ export type RuleContext = { sha: string | null; sessionKey: string; mode: OperatorMode };
118
+
119
+ function decide(
120
+ root: string,
121
+ config: RulesConfig,
122
+ trigger: TriggerContext,
123
+ context: RuleContext,
124
+ map: (outcome: RuleOutcome) => Decision,
125
+ ): RulesVerdict {
126
+ const set = loadRules(root, config);
127
+ if (set.rules.length === 0 && set.errors.length === 0) {
128
+ return NOTHING;
129
+ }
130
+ const firing = firingRules(set.rules, trigger);
131
+ if (firing.length === 0) {
132
+ return { ...NOTHING, errors: set.errors };
133
+ }
134
+ const outcomes = evaluateRules(firing, readObservations(root), context);
135
+ const worst = strictest(outcomes);
136
+ return {
137
+ decision: worst === null ? { kind: "abstain" } : map(worst),
138
+ outcomes,
139
+ errors: set.errors,
140
+ };
141
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Where the harness records what it observed, and where it reads the rules from.
3
+ *
4
+ * invariant: the agent cannot write here. This lives under the project state directory, which the floor's
5
+ * `policy-surface-write` refuses to an agent through a shell redirect, an interpreter or a write tool — and the
6
+ * mutating `tlc harness` subcommands are refused from inside a session. So the only writer is the harness
7
+ * observing a host event, which is what makes a proof unforgeable rather than conventional
8
+ * ([/decisions/ad-100.md](/decisions/ad-100.md), [/decisions/ad-022.md](/decisions/ad-022.md)).
9
+ *
10
+ * why append-only jsonl and not a merged document: two sessions observe at the same time, and an append is the one
11
+ * write that needs no lock. The reader takes the tail, because a proof is about now.
12
+ */
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { basename, join } from "node:path";
15
+ import { appendRecord, readTail } from "../../platform/fs-jsonl.ts";
16
+ import { machineHome, projectStateDir } from "../../platform/paths.ts";
17
+ import type { RuleSource } from "./rules.parse.ts";
18
+ import type { Observation } from "./rules.proof.ts";
19
+ import type { RuleTier } from "./rules.types.ts";
20
+
21
+ /**
22
+ * why a bound: an observation older than this window cannot satisfy `since HEAD` anyway, and a file that grows
23
+ * without limit is a file nobody prunes. The tail is generous enough that a long session keeps its own proofs.
24
+ */
25
+ const OBSERVATION_TAIL = 500;
26
+
27
+ export function observationsPath(root: string): string {
28
+ return join(projectStateDir(root), "rule-observations.jsonl");
29
+ }
30
+
31
+ /** The project's rules, versioned with it. */
32
+ export function projectRulesDir(root: string): string {
33
+ return join(projectStateDir(root), "..", "rules");
34
+ }
35
+
36
+ /** This machine's rules, every repository — the tier that follows the operator across products. */
37
+ export function globalRulesDir(): string {
38
+ return join(machineHome(), "rules");
39
+ }
40
+
41
+ function readDir(dir: string, tier: RuleTier): RuleSource[] {
42
+ if (!existsSync(dir)) {
43
+ return [];
44
+ }
45
+ return readdirSync(dir, { withFileTypes: true })
46
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
47
+ .map((entry) => {
48
+ const path = join(dir, entry.name);
49
+ return { name: basename(entry.name, ".md"), tier, text: readFileSync(path, "utf8") };
50
+ });
51
+ }
52
+
53
+ /**
54
+ * invariant: both tiers are read, global first, so `buildRuleSet` can let the project win by name. Absent
55
+ * directories are absent rules, not an error — no rules means no behaviour change
56
+ * ([/decisions/ad-040.md](/decisions/ad-040.md)).
57
+ */
58
+ export function readRuleSources(root: string): RuleSource[] {
59
+ return [...readDir(globalRulesDir(), "global"), ...readDir(projectRulesDir(root), "project")];
60
+ }
61
+
62
+ export function recordObservation(root: string, observation: Observation): void {
63
+ try {
64
+ appendRecord(observationsPath(root), observation);
65
+ } catch {
66
+ // why swallowed: an unwritable state directory must not fail the turn that was being observed. The proof will
67
+ // be missing, which the gate reports as missing rather than as an error nobody can act on.
68
+ }
69
+ }
70
+
71
+ export function readObservations(root: string): Observation[] {
72
+ try {
73
+ return readTail<Observation>(observationsPath(root), OBSERVATION_TAIL);
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Whether a rule's trigger fires on this event.
3
+ *
4
+ * invariant: pure, and it never reads a host payload. It takes the published event shape, so a rule written once
5
+ * fires the same way on every provider ([/decisions/ad-004.md](/decisions/ad-004.md)).
6
+ *
7
+ * hazard: a shell trigger cannot be a substring test against the whole command. `x && gh pr create` is a pull
8
+ * request being opened, and a heredoc body containing the words `gh pr create` is a document. `tokenizeShell`
9
+ * separates both and is the only splitter in this repository — a second regex here would be the duplication that
10
+ * makes one of them wrong later ([/decisions/ad-100.md](/decisions/ad-100.md)).
11
+ */
12
+ import { tokenizeShell } from "../floor/floor.tokenize.ts";
13
+ import type { Rule, RuleTrigger } from "./rules.types.ts";
14
+
15
+ /**
16
+ * What the harness reads to decide whether a trigger fires. A subset of the event, named so the vocabulary is
17
+ * visible: adding a trigger that needs a new field has to widen this deliberately.
18
+ */
19
+ export type TriggerContext = {
20
+ event: string;
21
+ toolName?: string;
22
+ command?: string;
23
+ };
24
+
25
+ /**
26
+ * why a set per trigger rather than one pattern the operator writes: `pr-open` has to mean the same thing in
27
+ * every repository, or a rule copied between them silently stops firing. An operator who wants their own shape
28
+ * writes `command(<pattern>)`.
29
+ */
30
+ const SHELL_SHAPES: Record<"pr-open" | "commit" | "push", readonly string[][]> = {
31
+ "pr-open": [
32
+ ["gh", "pr", "create"],
33
+ ["gh", "pr", "ready"],
34
+ ],
35
+ commit: [["git", "commit"]],
36
+ push: [["git", "push"]],
37
+ };
38
+
39
+ /**
40
+ * invariant: `tokenizeShell` already declines to emit segments from a heredoc body, so a body is never mistaken
41
+ * for a command and a command after one is still seen. Measured both ways on
42
+ * `cat <<EOF > runbook.md\ngh pr create --fill\nEOF` and on the same with a real command after the terminator:
43
+ * identical output.
44
+ *
45
+ * hazard: the first version of this called `splitHeredocs` first as well. It changed nothing — the mutation that
46
+ * removed it survived, which is what exposed it as dead rather than as untested
47
+ * ([/decisions/ad-100.md](/decisions/ad-100.md)).
48
+ */
49
+ function subCommands(command: string): string[][] {
50
+ return tokenizeShell(command)
51
+ .map((segment) => segment.words.map((word) => word.text))
52
+ .filter((words) => words.length > 0);
53
+ }
54
+
55
+ /** why prefix rather than equality: `gh pr create --fill --base main` is the same act as `gh pr create`. */
56
+ function startsWithShape(words: readonly string[], shape: readonly string[]): boolean {
57
+ return shape.every((token, index) => words[index] === token);
58
+ }
59
+
60
+ /**
61
+ * why a phrase and not a word: an operator writes `command(gh pr review)`, meaning those words in that order.
62
+ * Matching the raw string against the whole command would let a heredoc or an unrelated argument satisfy it.
63
+ */
64
+ export function matchesPhrase(words: readonly string[], pattern: string): boolean {
65
+ const phrase = pattern.trim().split(/\s+/);
66
+ if (phrase.length === 0) {
67
+ return false;
68
+ }
69
+ return words.some((_, start) => phrase.every((token, index) => words[start + index] === token));
70
+ }
71
+
72
+ export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): boolean {
73
+ switch (trigger.kind) {
74
+ case "stop":
75
+ return context.event === "stop";
76
+ case "tool":
77
+ return context.toolName === trigger.name;
78
+ case "pr-open":
79
+ case "commit":
80
+ case "push": {
81
+ if (context.command === undefined) {
82
+ return false;
83
+ }
84
+ const shapes = SHELL_SHAPES[trigger.kind];
85
+ return subCommands(context.command).some((words) =>
86
+ shapes.some((shape) => startsWithShape(words, shape)),
87
+ );
88
+ }
89
+ default: {
90
+ if (context.command === undefined) {
91
+ return false;
92
+ }
93
+ return subCommands(context.command).some((words) => matchesPhrase(words, trigger.pattern));
94
+ }
95
+ }
96
+ }
97
+
98
+ /** invariant: a disabled rule never fires. It exists to switch a global off and to record why. */
99
+ export function firingRules(rules: readonly Rule[], context: TriggerContext): Rule[] {
100
+ return rules.filter((rule) => rule.enabled && triggerMatches(rule.on, context));
101
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What an operator rule is.
3
+ *
4
+ * why two closed vocabularies: a gate may only rest on something the harness observed, and a trigger may only be
5
+ * something it can recognise. An open expression language would let an operator declare a rule the harness cannot
6
+ * evaluate, and the honest answer to that is a parse error rather than a rule that never fires
7
+ * ([/decisions/ad-100.md](/decisions/ad-100.md)).
8
+ */
9
+
10
+ /** Where the rule came from. `project` replaces a `global` of the same name, and both apply otherwise. */
11
+ export type RuleTier = "global" | "project";
12
+
13
+ /**
14
+ * What happens when the proof is missing.
15
+ *
16
+ * invariant: `deny`, `follow-up` and `warn` are verification and are identical at every posture. `ask` is an
17
+ * interruption, which is the one thing posture governs ([/decisions/ad-025.md](/decisions/ad-025.md)).
18
+ */
19
+ export type RuleVerdict = "deny" | "ask" | "follow-up" | "warn";
20
+
21
+ export const RULE_VERDICTS: ReadonlySet<string> = new Set<RuleVerdict>(["deny", "ask", "follow-up", "warn"]);
22
+
23
+ /** When the rule is evaluated. */
24
+ export type RuleTrigger =
25
+ | { kind: "pr-open" }
26
+ | { kind: "commit" }
27
+ | { kind: "push" }
28
+ | { kind: "stop" }
29
+ | { kind: "tool"; name: string }
30
+ | { kind: "command"; pattern: string };
31
+
32
+ /**
33
+ * What counts as proof, and how fresh it must be.
34
+ *
35
+ * why `head` by default: a review of the code as it was two commits ago is not a review of what the pull request
36
+ * carries.
37
+ */
38
+ export type ProofWindow = "head" | "session";
39
+
40
+ export type RuleProof =
41
+ | { kind: "subagent"; value: string; since: ProofWindow }
42
+ | { kind: "command"; value: string; since: ProofWindow }
43
+ | { kind: "gate"; value: string; since: ProofWindow }
44
+ | { kind: "file"; value: string; since: ProofWindow };
45
+
46
+ export const PROOF_KINDS: ReadonlySet<string> = new Set(["subagent", "command", "gate", "file"]);
47
+
48
+ export type Rule = {
49
+ /** The file name without its extension. This is the id the tiers dedupe on. */
50
+ name: string;
51
+ tier: RuleTier;
52
+ enabled: boolean;
53
+ on: RuleTrigger;
54
+ /** invariant: every proof must hold. There is no boolean algebra here on purpose. */
55
+ require: RuleProof[];
56
+ otherwise: RuleVerdict;
57
+ /** The operator's own text, injected verbatim when the rule fires. */
58
+ body: string;
59
+ };
60
+
61
+ /** A rule that could not be read. Named, so `doctor` can report it instead of the harness ignoring it silently. */
62
+ export type RuleError = { name: string; tier: RuleTier; error: string };
63
+
64
+ export type RuleSet = { rules: Rule[]; errors: RuleError[]; disabled: Rule[] };
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import type { Decision, HarnessEvent, ProviderCapabilities, Rendered } from "../contracts/index.ts";
3
+ import { isWriteTool } from "../contracts/tool-names.ts";
3
4
  import { coreFacade, type Policy } from "../core/index.ts";
4
5
  import { appendRecord } from "../platform/fs-jsonl.ts";
5
6
  import { projectStateDir } from "../platform/paths.ts";
@@ -36,6 +37,25 @@ export type RunOutcome = {
36
37
  // (lessons.maxCharsSession) — reusing that here truncated the operator posture and handoff.
37
38
  export const CONTEXT_BUDGET_CHARS = 6000;
38
39
 
40
+ /**
41
+ * Whether this event may claim its file against other sessions.
42
+ *
43
+ * invariant: a claim exists so that two writers do not lose each other's work. A reader loses nothing, so a read
44
+ * carries no claim however many files it opens ([/decisions/ad-099.md](/decisions/ad-099.md)).
45
+ *
46
+ * why `edit.after` with no tool name still claims: the write already happened, and the host does not always name
47
+ * the tool on that event. An event that reports a completed edit is a writer by definition.
48
+ */
49
+ export function claimsFile(event: HarnessEvent): boolean {
50
+ if (event.filePath === undefined) {
51
+ return false;
52
+ }
53
+ if (event.event === "edit.after") {
54
+ return true;
55
+ }
56
+ return isWriteTool(event.toolName);
57
+ }
58
+
39
59
  function errorMessage(error: unknown): string {
40
60
  return error instanceof Error ? error.message : String(error);
41
61
  }
@@ -144,10 +164,20 @@ export async function runHandler(handler: Handler, io: RunIo = {}): Promise<RunO
144
164
  effectiveBlockedPatterns(policy.subagents.blockedPatterns, provider),
145
165
  );
146
166
  }
167
+ /**
168
+ * hazard: this passed `event.filePath` for every event, and `read.before` carries one. So reading a file
169
+ * claimed it for ten minutes, and the next session to write it was refused — under a rule called
170
+ * `edit-collision`, with a message saying the file had been edited. Measured on a real machine: a review agent
171
+ * that only read blocked the operator's own writes to two files, while their `git status` showed a single
172
+ * modification, theirs ([/decisions/ad-099.md](/decisions/ad-099.md)).
173
+ *
174
+ * invariant: the heartbeat is unconditional — a reading session is still a live session, and staleness is what
175
+ * expires a claim. Only the *claim* is write-only, because only a writer can lose somebody's work.
176
+ */
147
177
  coreFacade.presence.heartbeat(event.projectDir, {
148
178
  provider: event.provider,
149
179
  session: sessionIdFromKey(event),
150
- file: event.filePath,
180
+ ...(claimsFile(event) ? { file: event.filePath } : {}),
151
181
  now,
152
182
  });
153
183
  const context: HandlerContext = { policy, capabilities, provider, now };
@@ -79,6 +79,14 @@ if (existsSync(execBin)) {
79
79
  } else if (existsSync(srcHandler)) {
80
80
  run(process.env.BUN_BIN || "bun", ["run", srcHandler]);
81
81
  } else {
82
+ /**
83
+ * hazard: this exited 127 with nothing on stdout. The project shim answers a host hook, so a shim that cannot
84
+ * find its handler was standing between the agent and its tools with no verdict at all — the same defect the
85
+ * launcher had one layer down ([/decisions/ad-101.md](/decisions/ad-101.md)).
86
+ *
87
+ * invariant: the diagnosis still reaches stderr. Failing open is not failing silently.
88
+ */
82
89
  console.error(`tlc shim: handler not found: ${handler}`);
83
- process.exit(127);
90
+ process.stdout.write("{}");
91
+ process.exit(0);
84
92
  }