@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,110 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { FailureCategory } from "../gate/gate.types.ts";
|
|
3
|
+
import { readProjectLessons, upsertProjectLesson } from "./lesson.store.ts";
|
|
4
|
+
import type { HarnessLesson } from "./lesson.types.ts";
|
|
5
|
+
|
|
6
|
+
function lessonId(gate: string, fingerprint: string): string {
|
|
7
|
+
const digest = createHash("sha256").update(`${gate}|${fingerprint}`).digest("hex").slice(0, 12);
|
|
8
|
+
return `project:${gate}:${digest}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function tokensFrom(gate: string, output: string, category: string): string[] {
|
|
12
|
+
const tokens = new Set<string>([gate, category]);
|
|
13
|
+
for (const line of output.split("\n").slice(0, 20)) {
|
|
14
|
+
for (const word of line.toLowerCase().match(/[a-z][a-z0-9_./-]{2,}/g) ?? []) {
|
|
15
|
+
if (word.length <= 40) {
|
|
16
|
+
tokens.add(word);
|
|
17
|
+
}
|
|
18
|
+
if (tokens.size >= 16) {
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (tokens.size >= 16) {
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...tokens];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// hazard: `suggestion` used to be a parameter here and was prefixed onto `instruction`. The consumer prints
|
|
30
|
+
// next_action from the same suggestionFor(category, gate) call, so every lesson opened by restating the line
|
|
31
|
+
// the gate had already emitted — duplicated by construction, spending the lessons budget on an echo. What a
|
|
32
|
+
// lesson uniquely knows is the recurring signature and the retry guidance, so that is all it carries.
|
|
33
|
+
const MAX_SESSION_KEYS = 12;
|
|
34
|
+
|
|
35
|
+
// why: a bounded set, because the count only has to reach `promoteHitCount` and an unbounded list would grow a
|
|
36
|
+
// stored record without limit.
|
|
37
|
+
function withSessionKey(existing: readonly string[], sessionKey: string): string[] {
|
|
38
|
+
if (!sessionKey || existing.includes(sessionKey)) {
|
|
39
|
+
return [...existing];
|
|
40
|
+
}
|
|
41
|
+
return [...existing, sessionKey].slice(-MAX_SESSION_KEYS);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function recordLessonFromFailure(args: {
|
|
45
|
+
projectDir: string;
|
|
46
|
+
gate: string;
|
|
47
|
+
category: FailureCategory;
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
output: string;
|
|
50
|
+
sessionKey: string;
|
|
51
|
+
}): Promise<HarnessLesson> {
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
const id = lessonId(args.gate, args.fingerprint);
|
|
54
|
+
const existing = readProjectLessons(args.projectDir).find((item) => item.id === id);
|
|
55
|
+
const snippet = args.output
|
|
56
|
+
.split("\n")
|
|
57
|
+
.map((l) => l.trim())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.slice(0, 3)
|
|
60
|
+
.join(" | ")
|
|
61
|
+
.slice(0, 220);
|
|
62
|
+
|
|
63
|
+
if (existing) {
|
|
64
|
+
const updated: HarnessLesson = {
|
|
65
|
+
...existing,
|
|
66
|
+
hitCount: existing.hitCount + 1,
|
|
67
|
+
sessionKeys: withSessionKey(existing.sessionKeys, args.sessionKey),
|
|
68
|
+
lastSeenAt: now,
|
|
69
|
+
lastAccessedAt: now,
|
|
70
|
+
updatedAt: now,
|
|
71
|
+
confidence: Math.min(1, existing.confidence + 0.08),
|
|
72
|
+
triggerTokens: snippet
|
|
73
|
+
? [
|
|
74
|
+
...new Set([...existing.triggerTokens, ...tokensFrom(args.gate, args.output, args.category)]),
|
|
75
|
+
].slice(0, 16)
|
|
76
|
+
: existing.triggerTokens,
|
|
77
|
+
};
|
|
78
|
+
return upsertProjectLesson(args.projectDir, updated);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const lesson: HarnessLesson = {
|
|
82
|
+
id,
|
|
83
|
+
scope: "gate-execution",
|
|
84
|
+
failedGate: args.gate,
|
|
85
|
+
category: args.category,
|
|
86
|
+
triggerTokens: tokensFrom(args.gate, args.output, args.category),
|
|
87
|
+
instruction: `Recurrent failure signature on gate "${args.gate}".${snippet ? ` Signal: ${snippet}` : ""}`,
|
|
88
|
+
avoid: "Do not repeat the same failing edit, suppression, or command that produced this fingerprint.",
|
|
89
|
+
prefer: "Change approach using the gate output; verify with the same gate before claiming done.",
|
|
90
|
+
preRetryCheck: `Re-read the ${args.gate} output and confirm the next edit targets a different root cause.`,
|
|
91
|
+
source: "project",
|
|
92
|
+
tier: "project",
|
|
93
|
+
status: "candidate",
|
|
94
|
+
confidence: 0.55,
|
|
95
|
+
hitCount: 1,
|
|
96
|
+
priority: 70,
|
|
97
|
+
pinned: false,
|
|
98
|
+
refs: [],
|
|
99
|
+
sessionKeys: withSessionKey([], args.sessionKey),
|
|
100
|
+
injectedCount: 0,
|
|
101
|
+
gradeableCount: 0,
|
|
102
|
+
helpedCount: 0,
|
|
103
|
+
neutralCount: 0,
|
|
104
|
+
firstSeenAt: now,
|
|
105
|
+
lastSeenAt: now,
|
|
106
|
+
lastAccessedAt: now,
|
|
107
|
+
updatedAt: now,
|
|
108
|
+
};
|
|
109
|
+
return upsertProjectLesson(args.projectDir, lesson);
|
|
110
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { updateJsonAtomic } from "../../platform/fs-atomic.ts";
|
|
4
|
+
import { projectStateDir, runtimeStateDir } from "../../platform/paths.ts";
|
|
5
|
+
import { divergedMessage, seal, shouldInject, verifySeal } from "../integrity/state-seal.ts";
|
|
6
|
+
import { creditLesson, type LessonVerdict } from "./lesson.credit.ts";
|
|
7
|
+
import type { HarnessLesson, LessonStoreFile, LessonTier } from "./lesson.types.ts";
|
|
8
|
+
|
|
9
|
+
const EPOCH = "1970-01-01T00:00:00.000Z";
|
|
10
|
+
|
|
11
|
+
type CoreLessonInput = Pick<
|
|
12
|
+
HarnessLesson,
|
|
13
|
+
| "id"
|
|
14
|
+
| "failedGate"
|
|
15
|
+
| "category"
|
|
16
|
+
| "triggerTokens"
|
|
17
|
+
| "instruction"
|
|
18
|
+
| "avoid"
|
|
19
|
+
| "prefer"
|
|
20
|
+
| "preRetryCheck"
|
|
21
|
+
| "priority"
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
function coreLesson(input: CoreLessonInput): HarnessLesson {
|
|
25
|
+
return {
|
|
26
|
+
...input,
|
|
27
|
+
scope: "gate-execution",
|
|
28
|
+
source: "core",
|
|
29
|
+
tier: "core",
|
|
30
|
+
status: "active",
|
|
31
|
+
confidence: 1,
|
|
32
|
+
hitCount: 1,
|
|
33
|
+
pinned: false,
|
|
34
|
+
refs: [],
|
|
35
|
+
sessionKeys: [],
|
|
36
|
+
injectedCount: 0,
|
|
37
|
+
gradeableCount: 0,
|
|
38
|
+
helpedCount: 0,
|
|
39
|
+
neutralCount: 0,
|
|
40
|
+
firstSeenAt: EPOCH,
|
|
41
|
+
lastSeenAt: EPOCH,
|
|
42
|
+
lastAccessedAt: EPOCH,
|
|
43
|
+
updatedAt: EPOCH,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const CORE_LESSONS: readonly HarnessLesson[] = [
|
|
48
|
+
coreLesson({
|
|
49
|
+
id: "core:gate:lint",
|
|
50
|
+
failedGate: "lint",
|
|
51
|
+
category: "verification",
|
|
52
|
+
triggerTokens: ["lint", "biome", "eslint", "ruff", "format"],
|
|
53
|
+
instruction:
|
|
54
|
+
"A lint gate failure means changed files still violate the project lint command. Fix the reported findings without suppressions.",
|
|
55
|
+
avoid: "Do not add lint suppressions, disable comments, or delete failing files to silence the gate.",
|
|
56
|
+
prefer: "Apply the smallest fix that clears each finding, then let the stop hook re-check.",
|
|
57
|
+
preRetryCheck:
|
|
58
|
+
"Confirm the lint command targets only the intended changed files and still fails for the same codes.",
|
|
59
|
+
priority: 90,
|
|
60
|
+
}),
|
|
61
|
+
coreLesson({
|
|
62
|
+
id: "core:gate:test",
|
|
63
|
+
failedGate: "test",
|
|
64
|
+
category: "verification",
|
|
65
|
+
triggerTokens: ["test", "vitest", "jest", "pytest", "failing"],
|
|
66
|
+
instruction:
|
|
67
|
+
"A test gate failure means assertions still fail. Fix the behavior or the test under the real contract — do not delete or skip tests.",
|
|
68
|
+
avoid: "Do not delete failing tests, mark them skipped, or weaken assertions to force green.",
|
|
69
|
+
prefer: "Reproduce the failure, fix root cause, re-run the same test target.",
|
|
70
|
+
preRetryCheck: "Identify the failing test name/file from the gate output before editing.",
|
|
71
|
+
priority: 90,
|
|
72
|
+
}),
|
|
73
|
+
coreLesson({
|
|
74
|
+
id: "core:gate:comments",
|
|
75
|
+
failedGate: "comments",
|
|
76
|
+
category: "verification",
|
|
77
|
+
triggerTokens: ["junk comment", "TODO", "FIXME", "banner"],
|
|
78
|
+
instruction:
|
|
79
|
+
"Junk-comment policy failed. Delete narrating comments, banners, TODO/FIXME, and commented-out code.",
|
|
80
|
+
avoid: "Do not keep TODO markers or section banners 'for clarity'.",
|
|
81
|
+
prefer: "Keep only comments that explain a non-obvious why (invariant, hazard, external constraint).",
|
|
82
|
+
preRetryCheck: "Scan the listed file:line hits and remove each one.",
|
|
83
|
+
priority: 80,
|
|
84
|
+
}),
|
|
85
|
+
coreLesson({
|
|
86
|
+
id: "core:gate:ship",
|
|
87
|
+
failedGate: "ship",
|
|
88
|
+
category: "ship-evidence",
|
|
89
|
+
triggerTokens: ["ship", "evidence", "90-verdict", "PASS"],
|
|
90
|
+
instruction:
|
|
91
|
+
"Ship claim without recent production PASS evidence. Produce real evidence before claiming done.",
|
|
92
|
+
avoid: "Do not claim shipped based on unit tests alone when runtime paths changed.",
|
|
93
|
+
prefer: "Run production E2E, write 90-verdict.txt PASS, cite the evidence path.",
|
|
94
|
+
preRetryCheck: "Confirm evidenceDir and a recent PASS verdict exist for this change.",
|
|
95
|
+
priority: 95,
|
|
96
|
+
}),
|
|
97
|
+
coreLesson({
|
|
98
|
+
id: "core:gate:empty-diff",
|
|
99
|
+
failedGate: "empty-diff",
|
|
100
|
+
category: "ship-evidence",
|
|
101
|
+
triggerTokens: ["empty", "diff", "no changes", "shipped"],
|
|
102
|
+
instruction:
|
|
103
|
+
"Done/shipped was claimed with zero file changes. Either implement the work or explain why zero-diff is correct — do not claim shipped on an empty tree.",
|
|
104
|
+
avoid: "Do not restate 'done' without a real diff or an explicit zero-change justification.",
|
|
105
|
+
prefer: "Make the missing change, or clearly document why no files should change.",
|
|
106
|
+
preRetryCheck: "Inspect git status / changed files before the next stop.",
|
|
107
|
+
priority: 92,
|
|
108
|
+
}),
|
|
109
|
+
coreLesson({
|
|
110
|
+
id: "core:gate:stagnation",
|
|
111
|
+
failedGate: "stagnation",
|
|
112
|
+
category: "stagnation",
|
|
113
|
+
triggerTokens: ["stagnation", "identical", "fingerprint", "same fail"],
|
|
114
|
+
instruction:
|
|
115
|
+
"Identical validation fingerprint repeated. Change approach — do not re-apply the same failing edit.",
|
|
116
|
+
avoid: "Do not retry the exact same patch, command, or suppression.",
|
|
117
|
+
prefer: "Diagnose root cause with a different path, or escalate with BLOCKED / TRIED / NEED.",
|
|
118
|
+
preRetryCheck: "Diff your last edit against the gate output; ensure the next action is different.",
|
|
119
|
+
priority: 100,
|
|
120
|
+
}),
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
export function lessonsStorePath(root: string): string {
|
|
124
|
+
return join(projectStateDir(root), "lessons.json");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// why: one store for every repository on the machine. The per-repo store stays authoritative for this
|
|
128
|
+
// repository; this is the tier that follows the operator across products ([/decisions/ad-040.md](/decisions/ad-040.md)).
|
|
129
|
+
export function globalLessonsStorePath(): string {
|
|
130
|
+
return join(runtimeStateDir(), "lessons.json");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function storePathFor(root: string, tier: Exclude<LessonTier, "core">): string {
|
|
134
|
+
return tier === "global" ? globalLessonsStorePath() : lessonsStorePath(root);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// hazard: a record written before a field existed reads as `undefined`, and `undefined` propagated into
|
|
138
|
+
// `hitCount + 1` or `refs.length` throws or poisons a comparator. Every read normalizes, so no consumer has to.
|
|
139
|
+
function normalizeLesson(raw: HarnessLesson, tier: LessonTier): HarnessLesson {
|
|
140
|
+
return {
|
|
141
|
+
...raw,
|
|
142
|
+
tier,
|
|
143
|
+
pinned: raw.pinned === true,
|
|
144
|
+
refs: Array.isArray(raw.refs) ? raw.refs : [],
|
|
145
|
+
sessionKeys: Array.isArray(raw.sessionKeys) ? raw.sessionKeys : [],
|
|
146
|
+
injectedCount: Number.isFinite(raw.injectedCount) ? raw.injectedCount : 0,
|
|
147
|
+
gradeableCount: Number.isFinite(raw.gradeableCount) ? raw.gradeableCount : 0,
|
|
148
|
+
helpedCount: Number.isFinite(raw.helpedCount) ? raw.helpedCount : 0,
|
|
149
|
+
neutralCount: Number.isFinite(raw.neutralCount) ? raw.neutralCount : 0,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readStore(path: string, tier: LessonTier): HarnessLesson[] {
|
|
154
|
+
if (!existsSync(path)) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const file = JSON.parse(readFileSync(path, "utf8")) as LessonStoreFile;
|
|
159
|
+
return Array.isArray(file.lessons) ? file.lessons.map((lesson) => normalizeLesson(lesson, tier)) : [];
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* why: the project store only. The global store is written by sessions in other repositories, so a per-project
|
|
167
|
+
* seal would diverge on every legitimate cross-project write ([/decisions/ad-078.md](/decisions/ad-078.md)).
|
|
168
|
+
*/
|
|
169
|
+
export function projectLessonsInjectable(root: string): { ok: boolean; note: string | null } {
|
|
170
|
+
const target = lessonsStorePath(root);
|
|
171
|
+
const verdict = verifySeal(target);
|
|
172
|
+
return shouldInject(verdict)
|
|
173
|
+
? { ok: true, note: null }
|
|
174
|
+
: { ok: false, note: divergedMessage(target, "The project lesson store") };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function readProjectLessons(root: string): HarnessLesson[] {
|
|
178
|
+
return readStore(lessonsStorePath(root), "project");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function readGlobalLessons(): HarnessLesson[] {
|
|
182
|
+
return readStore(globalLessonsStorePath(), "global");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// invariant: the nearer tier wins on a duplicate id. A project that has taken a global lesson and rewritten it
|
|
186
|
+
// reads its own version, and the same lesson is never injected twice.
|
|
187
|
+
export function allLessons(root: string): HarnessLesson[] {
|
|
188
|
+
const byId = new Map<string, HarnessLesson>();
|
|
189
|
+
for (const lesson of [...CORE_LESSONS, ...readGlobalLessons(), ...readProjectLessons(root)]) {
|
|
190
|
+
byId.set(lesson.id, lesson);
|
|
191
|
+
}
|
|
192
|
+
return [...byId.values()];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function mutateStore(
|
|
196
|
+
path: string,
|
|
197
|
+
tier: LessonTier,
|
|
198
|
+
mutate: (current: HarnessLesson[]) => HarnessLesson[],
|
|
199
|
+
): Promise<HarnessLesson[]> {
|
|
200
|
+
const file = await updateJsonAtomic<LessonStoreFile>(
|
|
201
|
+
path,
|
|
202
|
+
(current) => {
|
|
203
|
+
const lessons = current && Array.isArray(current.lessons) ? current.lessons : [];
|
|
204
|
+
return { version: 1, lessons: mutate(lessons.map((lesson) => normalizeLesson(lesson, tier))) };
|
|
205
|
+
},
|
|
206
|
+
{ lockPath: `${path}.lock`, afterWrite: seal },
|
|
207
|
+
);
|
|
208
|
+
return file.lessons;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function writeProjectLessons(root: string, lessons: HarnessLesson[]): Promise<void> {
|
|
212
|
+
await mutateStore(lessonsStorePath(root), "project", () => lessons);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function upsert(lessons: HarnessLesson[], lesson: HarnessLesson): HarnessLesson[] {
|
|
216
|
+
const index = lessons.findIndex((item) => item.id === lesson.id);
|
|
217
|
+
if (index < 0) {
|
|
218
|
+
return [...lessons, lesson];
|
|
219
|
+
}
|
|
220
|
+
const next = [...lessons];
|
|
221
|
+
next[index] = lesson;
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function upsertProjectLesson(root: string, lesson: HarnessLesson): Promise<HarnessLesson> {
|
|
226
|
+
const saved = { ...lesson, tier: "project" as const };
|
|
227
|
+
await mutateStore(lessonsStorePath(root), "project", (current) => upsert(current, saved));
|
|
228
|
+
return saved;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function upsertGlobalLesson(lesson: HarnessLesson): Promise<HarnessLesson> {
|
|
232
|
+
const saved = { ...lesson, tier: "global" as const };
|
|
233
|
+
await mutateStore(globalLessonsStorePath(), "global", (current) => upsert(current, saved));
|
|
234
|
+
return saved;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function upsertLesson(
|
|
238
|
+
root: string,
|
|
239
|
+
lesson: HarnessLesson,
|
|
240
|
+
tier: Exclude<LessonTier, "core">,
|
|
241
|
+
): Promise<HarnessLesson> {
|
|
242
|
+
return tier === "global" ? upsertGlobalLesson(lesson) : upsertProjectLesson(root, lesson);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function mutateWritableTiers(
|
|
246
|
+
root: string,
|
|
247
|
+
ids: readonly string[],
|
|
248
|
+
patch: (lesson: HarnessLesson) => HarnessLesson,
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
if (ids.length === 0) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const idSet = new Set(ids);
|
|
254
|
+
// why: an injected set can span both writable tiers, so both are visited. Core is skipped because it is
|
|
255
|
+
// shipped and identical everywhere — a counter on it would be a per-machine edit to a constant.
|
|
256
|
+
for (const tier of ["project", "global"] as const) {
|
|
257
|
+
const path = storePathFor(root, tier);
|
|
258
|
+
if (!existsSync(path)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
await mutateStore(path, tier, (current) =>
|
|
262
|
+
current.map((lesson) => (idSet.has(lesson.id) ? patch(lesson) : lesson)),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function touchAccessed(root: string, ids: string[], now = new Date()): Promise<void> {
|
|
268
|
+
const iso = now.toISOString();
|
|
269
|
+
await mutateWritableTiers(root, ids, (lesson) => ({
|
|
270
|
+
...lesson,
|
|
271
|
+
lastAccessedAt: iso,
|
|
272
|
+
updatedAt: iso,
|
|
273
|
+
injectedCount: lesson.injectedCount + 1,
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Marks an injection as one a later gate run can grade.
|
|
279
|
+
*
|
|
280
|
+
* why: called where the pending credit is written, so the counter and the credit cannot disagree. Counting it at
|
|
281
|
+
* injection time instead would include session-start injections, which nothing ever grades
|
|
282
|
+
* ([/decisions/ad-044.md](/decisions/ad-044.md)).
|
|
283
|
+
*/
|
|
284
|
+
export async function markGradeable(root: string, ids: readonly string[], now = new Date()): Promise<void> {
|
|
285
|
+
const iso = now.toISOString();
|
|
286
|
+
await mutateWritableTiers(root, ids, (lesson) => ({
|
|
287
|
+
...lesson,
|
|
288
|
+
gradeableCount: lesson.gradeableCount + 1,
|
|
289
|
+
updatedAt: iso,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function creditLessons(
|
|
294
|
+
root: string,
|
|
295
|
+
ids: readonly string[],
|
|
296
|
+
verdict: LessonVerdict,
|
|
297
|
+
now = new Date(),
|
|
298
|
+
): Promise<void> {
|
|
299
|
+
const iso = now.toISOString();
|
|
300
|
+
await mutateWritableTiers(root, ids, (lesson) => creditLesson(lesson, verdict, iso));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function gardenProjectLessons(
|
|
304
|
+
root: string,
|
|
305
|
+
mutate: (current: HarnessLesson[]) => HarnessLesson[],
|
|
306
|
+
): Promise<HarnessLesson[]> {
|
|
307
|
+
return mutateStore(lessonsStorePath(root), "project", mutate);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function gardenGlobalLessons(
|
|
311
|
+
mutate: (current: HarnessLesson[]) => HarnessLesson[],
|
|
312
|
+
): Promise<HarnessLesson[]> {
|
|
313
|
+
if (!existsSync(globalLessonsStorePath())) {
|
|
314
|
+
return [];
|
|
315
|
+
}
|
|
316
|
+
return mutateStore(globalLessonsStorePath(), "global", mutate);
|
|
317
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the durable provider view is decided. `auto` asks the host: a provider that does not deliver context from
|
|
3
|
+
* its session-start hook has no other route, so the view is written there and not where the hook works
|
|
4
|
+
* ([/decisions/ad-050.md](/decisions/ad-050.md)).
|
|
5
|
+
*/
|
|
6
|
+
export type LessonsSyncMode = "auto" | "always" | "never";
|
|
7
|
+
|
|
8
|
+
export type SyncModeResolution = {
|
|
9
|
+
mode: LessonsSyncMode;
|
|
10
|
+
/** The boolean this mode was read from, when a config predating the mode is still in place. */
|
|
11
|
+
coercedFrom?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* hazard: the file the coerced value is actually in. The migration line named the project config unconditionally
|
|
14
|
+
* and the value can come from the runtime home, so it sent an operator to edit a file that did not have it.
|
|
15
|
+
*/
|
|
16
|
+
coercedIn?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* hazard: `.tlc/harness/config.json` is merged structurally and never validated, so the field arrives as whatever
|
|
21
|
+
* the operator wrote. A config carrying the old boolean would have compared unequal to every mode and silently
|
|
22
|
+
* read as `auto`, turning an explicit `false` into a file the operator had switched off.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveSyncMode(raw: unknown): SyncModeResolution {
|
|
25
|
+
if (raw === true) {
|
|
26
|
+
return { mode: "always", coercedFrom: true };
|
|
27
|
+
}
|
|
28
|
+
if (raw === false) {
|
|
29
|
+
return { mode: "never", coercedFrom: false };
|
|
30
|
+
}
|
|
31
|
+
if (raw === "always" || raw === "never" || raw === "auto") {
|
|
32
|
+
return { mode: raw };
|
|
33
|
+
}
|
|
34
|
+
return { mode: "auto" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function lessonsSyncMode(raw: unknown): LessonsSyncMode {
|
|
38
|
+
return resolveSyncMode(raw).mode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DurableViewVerdict = {
|
|
42
|
+
writes: boolean;
|
|
43
|
+
/** Why, in the operator's terms. `lessons status` prints this, so a view that is not written says so. */
|
|
44
|
+
reason: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* why: the verdict carries a reason, following `appendFilesVerdict`. A capability that silently does nothing is the
|
|
49
|
+
* defect this project keeps finding; one that says why is a configuration choice.
|
|
50
|
+
*/
|
|
51
|
+
export function durableViewVerdict(mode: LessonsSyncMode, hookContextReliable: boolean): DurableViewVerdict {
|
|
52
|
+
if (mode === "never") {
|
|
53
|
+
return { writes: false, reason: "syncRulesFile is set to never" };
|
|
54
|
+
}
|
|
55
|
+
if (mode === "always") {
|
|
56
|
+
return { writes: true, reason: "syncRulesFile is set to always" };
|
|
57
|
+
}
|
|
58
|
+
if (hookContextReliable) {
|
|
59
|
+
return {
|
|
60
|
+
writes: false,
|
|
61
|
+
reason:
|
|
62
|
+
"this provider delivers context from its session-start hook, so lessons arrive without a rules file",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
writes: true,
|
|
67
|
+
reason:
|
|
68
|
+
"this provider does not deliver context from its session-start hook, so the durable view is the route",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export type LessonStatus = "candidate" | "active" | "quarantine";
|
|
2
|
+
|
|
3
|
+
/** How the lesson was learned. Provenance, not location — see {@link LessonTier}. */
|
|
4
|
+
export type LessonSource = "core" | "project" | "manual";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the lesson lives, which decides who sees it: `core` ships in the runtime, `global` is this machine and
|
|
8
|
+
* every product on it, `project` is this repository alone.
|
|
9
|
+
*
|
|
10
|
+
* invariant: nothing moves between tiers on its own ([/decisions/ad-040.md](/decisions/ad-040.md)).
|
|
11
|
+
*/
|
|
12
|
+
export type LessonTier = "core" | "global" | "project";
|
|
13
|
+
|
|
14
|
+
/** A repository-relative reference to the thing that makes a lesson true. */
|
|
15
|
+
export type LessonLink = {
|
|
16
|
+
path: string;
|
|
17
|
+
symbol?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type LessonLinkStatus = "present" | "path-missing" | "symbol-missing" | "unreadable";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* hazard: `unproven` is not a passing reading — it means the lesson was injected and no gate run has graded it.
|
|
24
|
+
* `not-injected` is the separate, uninteresting case, kept apart so a store of brand-new lessons does not read as
|
|
25
|
+
* a store of unjustified ones ([/decisions/ad-039.md](/decisions/ad-039.md)).
|
|
26
|
+
*/
|
|
27
|
+
export type LessonEffectiveness = "helped" | "neutral" | "unproven" | "not-injected";
|
|
28
|
+
|
|
29
|
+
export type HarnessLesson = {
|
|
30
|
+
id: string;
|
|
31
|
+
scope: "gate-execution";
|
|
32
|
+
failedGate: string;
|
|
33
|
+
category: string;
|
|
34
|
+
triggerTokens: string[];
|
|
35
|
+
instruction: string;
|
|
36
|
+
avoid: string;
|
|
37
|
+
prefer: string;
|
|
38
|
+
preRetryCheck: string;
|
|
39
|
+
source: LessonSource;
|
|
40
|
+
// invariant: derived on read from the store the lesson came out of, never trusted from the file.
|
|
41
|
+
tier: LessonTier;
|
|
42
|
+
status: LessonStatus;
|
|
43
|
+
confidence: number;
|
|
44
|
+
hitCount: number;
|
|
45
|
+
priority: number;
|
|
46
|
+
/**
|
|
47
|
+
* A standing rule the operator does not want ranked. Pinned lessons are placed before every scored lesson,
|
|
48
|
+
* still subject to staleness, validity and the char budget.
|
|
49
|
+
*
|
|
50
|
+
* why: ranking is built for lessons the harness inferred from failures. An instruction the operator wrote
|
|
51
|
+
* deliberately competes with those on confidence and priority and loses to a shipped seed, so it is written,
|
|
52
|
+
* stored, correct, and never delivered ([/decisions/ad-043.md](/decisions/ad-043.md)).
|
|
53
|
+
*/
|
|
54
|
+
pinned: boolean;
|
|
55
|
+
/** What makes this lesson true. Empty means it is about conduct and cannot go stale. */
|
|
56
|
+
refs: LessonLink[];
|
|
57
|
+
staleReason?: LessonLinkStatus;
|
|
58
|
+
staleCheckedAt?: string;
|
|
59
|
+
validFrom?: string;
|
|
60
|
+
validTo?: string;
|
|
61
|
+
// why: promotion counts these, not `hitCount` — one stuck session repeating a failure is one observation of
|
|
62
|
+
// the world, not several ([/decisions/ad-038.md](/decisions/ad-038.md)).
|
|
63
|
+
sessionKeys: string[];
|
|
64
|
+
/** Times selected for injection, in any mode. */
|
|
65
|
+
injectedCount: number;
|
|
66
|
+
/**
|
|
67
|
+
* Times injected **for a gate**, which is the only injection a later gate run can grade.
|
|
68
|
+
*
|
|
69
|
+
* hazard: `injectedCount` counts session-start injections too, and those are never graded — a lesson whose gate
|
|
70
|
+
* is `any` is not eligible on a retry at all, so judging it by `injectedCount` warned "unproven" forever on a
|
|
71
|
+
* perfectly healthy store ([/decisions/ad-044.md](/decisions/ad-044.md)).
|
|
72
|
+
*/
|
|
73
|
+
gradeableCount: number;
|
|
74
|
+
helpedCount: number;
|
|
75
|
+
neutralCount: number;
|
|
76
|
+
firstSeenAt: string;
|
|
77
|
+
lastSeenAt: string;
|
|
78
|
+
lastAccessedAt: string;
|
|
79
|
+
updatedAt: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type LessonStoreFile = {
|
|
83
|
+
version: 1;
|
|
84
|
+
lessons: HarnessLesson[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** A gate's injected lessons awaiting a verdict, carried on the handoff between one stop and the next. */
|
|
88
|
+
export type PendingLessonCredit = {
|
|
89
|
+
gate: string;
|
|
90
|
+
ids: string[];
|
|
91
|
+
at: string;
|
|
92
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HarnessLesson } from "./lesson.types.ts";
|
|
2
|
+
|
|
3
|
+
type Boundary = "absent" | "invalid" | number;
|
|
4
|
+
|
|
5
|
+
function boundary(iso: string | undefined): Boundary {
|
|
6
|
+
if (iso === undefined || iso.trim() === "") {
|
|
7
|
+
return "absent";
|
|
8
|
+
}
|
|
9
|
+
const ms = Date.parse(iso);
|
|
10
|
+
return Number.isFinite(ms) ? ms : "invalid";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// invariant: an unparseable bound fails the window. A lesson whose validity cannot be evaluated is withheld,
|
|
14
|
+
// never injected.
|
|
15
|
+
export function isWithinValidity(lesson: HarnessLesson, now: Date): boolean {
|
|
16
|
+
const nowMs = now.getTime();
|
|
17
|
+
const from = boundary(lesson.validFrom);
|
|
18
|
+
if (from === "invalid" || (typeof from === "number" && from > nowMs)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const to = boundary(lesson.validTo);
|
|
22
|
+
if (to === "invalid" || (typeof to === "number" && to <= nowMs)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validityReason(
|
|
29
|
+
lesson: HarnessLesson,
|
|
30
|
+
now: Date,
|
|
31
|
+
): "active" | "pending" | "expired" | "invalid" {
|
|
32
|
+
const from = boundary(lesson.validFrom);
|
|
33
|
+
const to = boundary(lesson.validTo);
|
|
34
|
+
if (from === "invalid" || to === "invalid") {
|
|
35
|
+
return "invalid";
|
|
36
|
+
}
|
|
37
|
+
if (typeof from === "number" && from > now.getTime()) {
|
|
38
|
+
return "pending";
|
|
39
|
+
}
|
|
40
|
+
if (typeof to === "number" && to <= now.getTime()) {
|
|
41
|
+
return "expired";
|
|
42
|
+
}
|
|
43
|
+
return "active";
|
|
44
|
+
}
|