@tech-leads-club/harness-toolkit 0.2.1 → 0.2.4

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 (120) hide show
  1. package/bin/tlc-cli.ts +20 -5
  2. package/dist/compact-before.mjs +86 -8
  3. package/dist/doctor.mjs +116 -10
  4. package/dist/init-project.mjs +13 -7
  5. package/dist/lessons-cli.mjs +86 -8
  6. package/dist/obs-cli.mjs +86 -8
  7. package/dist/prompt-submit.mjs +86 -8
  8. package/dist/response-after.mjs +86 -8
  9. package/dist/run.mjs +86 -8
  10. package/dist/session-end.mjs +86 -8
  11. package/dist/session-start.mjs +86 -8
  12. package/dist/shim.mjs +6976 -15
  13. package/dist/stop.mjs +86 -8
  14. package/dist/subagent-start.mjs +86 -8
  15. package/dist/subagent-stop.mjs +86 -8
  16. package/dist/support.mjs +86 -8
  17. package/dist/tlc-cli.mjs +106 -12
  18. package/dist/tool-after.mjs +86 -8
  19. package/dist/tool-before.mjs +86 -8
  20. package/dist/tool-failure.mjs +86 -8
  21. package/docs/log.md +5 -0
  22. package/package.json +3 -2
  23. package/src/core/core.facade.ts +11 -0
  24. package/src/core/index.ts +2 -0
  25. package/src/core/release/release.version.ts +147 -0
  26. package/src/core/shim/shim.precedence.ts +72 -0
  27. package/src/core/skill/skill.link.ts +81 -0
  28. package/src/entrypoints/shim.ts +41 -9
  29. package/src/platform/fs-atomic.ts +61 -23
  30. package/src/platform/paths.ts +27 -0
  31. package/tools/doctor.ts +50 -3
  32. package/tools/init-project.ts +27 -7
  33. package/CHANGELOG.md +0 -95
  34. package/docs/decisions/ad-001.md +0 -32
  35. package/docs/decisions/ad-002.md +0 -51
  36. package/docs/decisions/ad-003.md +0 -30
  37. package/docs/decisions/ad-004.md +0 -37
  38. package/docs/decisions/ad-005.md +0 -36
  39. package/docs/decisions/ad-006.md +0 -49
  40. package/docs/decisions/ad-007.md +0 -36
  41. package/docs/decisions/ad-008.md +0 -54
  42. package/docs/decisions/ad-009.md +0 -61
  43. package/docs/decisions/ad-010.md +0 -45
  44. package/docs/decisions/ad-011.md +0 -59
  45. package/docs/decisions/ad-012.md +0 -71
  46. package/docs/decisions/ad-013.md +0 -87
  47. package/docs/decisions/ad-014.md +0 -56
  48. package/docs/decisions/ad-015.md +0 -33
  49. package/docs/decisions/ad-016.md +0 -98
  50. package/docs/decisions/ad-017.md +0 -65
  51. package/docs/decisions/ad-018.md +0 -77
  52. package/docs/decisions/ad-019.md +0 -75
  53. package/docs/decisions/ad-020.md +0 -88
  54. package/docs/decisions/ad-021.md +0 -57
  55. package/docs/decisions/ad-022.md +0 -120
  56. package/docs/decisions/ad-023.md +0 -87
  57. package/docs/decisions/ad-024.md +0 -85
  58. package/docs/decisions/ad-025.md +0 -92
  59. package/docs/decisions/ad-026.md +0 -131
  60. package/docs/decisions/ad-027.md +0 -90
  61. package/docs/decisions/ad-028.md +0 -90
  62. package/docs/decisions/ad-029.md +0 -73
  63. package/docs/decisions/ad-030.md +0 -97
  64. package/docs/decisions/ad-031.md +0 -94
  65. package/docs/decisions/ad-032.md +0 -83
  66. package/docs/decisions/ad-033.md +0 -89
  67. package/docs/decisions/ad-034.md +0 -86
  68. package/docs/decisions/ad-035.md +0 -86
  69. package/docs/decisions/ad-036.md +0 -68
  70. package/docs/decisions/ad-037.md +0 -47
  71. package/docs/decisions/ad-038.md +0 -52
  72. package/docs/decisions/ad-039.md +0 -69
  73. package/docs/decisions/ad-040.md +0 -89
  74. package/docs/decisions/ad-041.md +0 -98
  75. package/docs/decisions/ad-042.md +0 -82
  76. package/docs/decisions/ad-043.md +0 -79
  77. package/docs/decisions/ad-044.md +0 -61
  78. package/docs/decisions/ad-045.md +0 -94
  79. package/docs/decisions/ad-046.md +0 -111
  80. package/docs/decisions/ad-047.md +0 -96
  81. package/docs/decisions/ad-048.md +0 -85
  82. package/docs/decisions/ad-049.md +0 -66
  83. package/docs/decisions/ad-050.md +0 -94
  84. package/docs/decisions/ad-051.md +0 -69
  85. package/docs/decisions/ad-052.md +0 -69
  86. package/docs/decisions/ad-053.md +0 -78
  87. package/docs/decisions/ad-054.md +0 -98
  88. package/docs/decisions/ad-055.md +0 -74
  89. package/docs/decisions/ad-056.md +0 -85
  90. package/docs/decisions/ad-057.md +0 -68
  91. package/docs/decisions/ad-058.md +0 -97
  92. package/docs/decisions/ad-059.md +0 -82
  93. package/docs/decisions/ad-060.md +0 -75
  94. package/docs/decisions/ad-061.md +0 -68
  95. package/docs/decisions/ad-062.md +0 -72
  96. package/docs/decisions/ad-063.md +0 -84
  97. package/docs/decisions/ad-064.md +0 -79
  98. package/docs/decisions/ad-065.md +0 -81
  99. package/docs/decisions/ad-066.md +0 -111
  100. package/docs/decisions/ad-067.md +0 -64
  101. package/docs/decisions/ad-068.md +0 -79
  102. package/docs/decisions/ad-069.md +0 -74
  103. package/docs/decisions/ad-070.md +0 -86
  104. package/docs/decisions/ad-071.md +0 -93
  105. package/docs/decisions/ad-072.md +0 -82
  106. package/docs/decisions/ad-073.md +0 -102
  107. package/docs/decisions/ad-074.md +0 -91
  108. package/docs/decisions/ad-075.md +0 -79
  109. package/docs/decisions/ad-076.md +0 -102
  110. package/docs/decisions/ad-077.md +0 -94
  111. package/docs/decisions/ad-078.md +0 -84
  112. package/docs/decisions/ad-079.md +0 -73
  113. package/docs/decisions/ad-080.md +0 -86
  114. package/docs/decisions/ad-081.md +0 -70
  115. package/docs/decisions/ad-082.md +0 -79
  116. package/docs/decisions/ad-083.md +0 -88
  117. package/docs/decisions/index.md +0 -111
  118. package/tools/test-env.mjs +0 -28
  119. package/tools/test-env.names.d.mts +0 -1
  120. package/tools/test-env.names.mjs +0 -14
@@ -1,66 +0,0 @@
1
- ---
2
- type: Decision
3
- title: "AD-049 — An empty synced file says which of four reasons made it empty"
4
- description: "An operator reported that the harness never updates its lessons file. It was being rewritten every session with the same sentence — \"No active project lessons yet.\" — because lessons were switched off. That sentence covered four different situations: off, on-but-nothing-repeated, candidates-not-promoted, and active-but-withheld. Each now names itself."
5
- tags: [decision, lessons, visibility]
6
- timestamp: "2026-08-05"
7
- ---
8
-
9
- # AD-049 — An empty synced file says which of four reasons made it empty
10
-
11
- - **status**: active
12
- - **date**: 2026-08-05
13
-
14
- ## Decision
15
-
16
- 1. **`emptySyncReason` names the reason** the synced markdown has no lessons in it, from the store and the config.
17
- 2. **Four distinct reasons**, asserted distinct by a test: the capability is off; on with an empty store; candidates
18
- recorded but none promoted; active but withheld.
19
- 3. **The switched-off reason names the config field and the route to change it** — the init skill, not the file.
20
- 4. **`renderLessonsMarkdown` takes the whole config** rather than one number from it, because the reason needs
21
- `enabled` and `promoteHitCount` too.
22
- 5. **The docs name `.specs/LESSONS.md` as belonging to the `/sdd` skill**, so the two files stop being compared.
23
-
24
- ## Why
25
-
26
- The report: "uma coisa que eu percebi é que ele não tá atualizando nunca esse arquivo, mas atualiza o `.specs` do
27
- tlc." Followed by "mandei um init all default".
28
-
29
- Both halves are informative.
30
-
31
- `intelligence.lessons.enabled` defaults to **false**, and an all-defaults init leaves it off. With
32
- `syncRulesFile` on, `gardenAndPersistLessons` rewrote the file at every session end — with the same empty text,
33
- forever. From the outside that is indistinguishable from a file nothing writes, which is exactly how it was
34
- reported.
35
-
36
- And the sentence could not have told him anything, because it was the same sentence for four situations. Reaching a
37
- non-empty file requires all of: the capability on, the **same** gate failure repeating inside one session
38
- (`hits >= 2`), and the resulting candidate promoted across `promoteHitCount` distinct sessions — `rankLessonsForSync`
39
- filters on `status === "active"`. Three hurdles, one message.
40
-
41
- The other half of the report is a naming collision the harness does not own. `.specs/LESSONS.md` is the `/sdd`
42
- skill's own lessons layer. Seeing one update and the other not is reasonable evidence that the second is broken,
43
- and the answer is documentation rather than code: the harness must not couple to another tool's files, and AD-035
44
- already refused to read a project's documentation at all.
45
-
46
- ## Trade-offs
47
-
48
- **Four strings to keep true.** A fifth state — say, every lesson quarantined — falls through to the original
49
- sentence. The test asserts the four are distinct, not that the set is complete.
50
-
51
- **The reason is computed on every sync.** It reads the store that was just gardened and does no extra I/O.
52
-
53
- **It says "ask the agent to run the harness-init skill" rather than naming a command.** There is no CLI to toggle a
54
- capability, because `tlc harness` mutations are the operator's from a terminal ([/decisions/ad-022.md](/decisions/ad-022.md)) and the init skill is the
55
- sanctioned interactive route. A line pointing at a command that does not exist would be the AD-047 defect again.
56
-
57
- ## Not decided here
58
-
59
- - **Whether the file should be written at all when lessons are off.** It is, and it says so. Not writing it would
60
- leave `syncRulesFile: true` silently doing nothing, which is the same defect one layer down.
61
- - **Surfacing the off state in `doctor`.** `checkLessonHealth` is silent when the capability is off, deliberately —
62
- a row about a disabled feature is noise on every healthy run ([/decisions/ad-034.md](/decisions/ad-034.md)). The synced file is the place an operator
63
- who enabled `syncRulesFile` is already looking.
64
- - **Whether `promoteHitCount: 2` is the right default** for a capability whose first visible output needs two
65
- sessions. It is the recurrence threshold AD-038 argued for; the interaction with time-to-first-output is not
66
- measured.
@@ -1,94 +0,0 @@
1
- ---
2
- type: Decision
3
- title: "AD-050 — Lesson transport is a provider capability, not an operator preference"
4
- description: "A question about duplicated lessons found the opposite of duplication: on the host that drops context returned from its session-start hook, the durable rules file was the only route to the model, and it was off by default. Transport becomes a declared capability, the advisory stops being rendered into a field that host ignores, and the cost report names which of the two is paid."
5
- tags: [decision, lessons, providers, capabilities, observability]
6
- timestamp: "2026-08-05"
7
- ---
8
-
9
- # AD-050 — Lesson transport is a provider capability, not an operator preference
10
-
11
- - **status**: active
12
- - **date**: 2026-08-05
13
-
14
- ## Decision
15
-
16
- 1. **`sessionStartContextReliable` is declared per provider** and states whether context returned from the
17
- session-start hook reaches the model. It is not the same question as whether the field exists.
18
- 2. **`contextAtStop` is declared per provider** and read by `degrade`, so a `context` decision raised on `stop`
19
- abstains where the host has no field to carry it.
20
- 3. **`intelligence.lessons.syncRulesFile` becomes `auto` | `always` | `never`, defaulting to `auto`.** Under `auto`
21
- the durable view is written where the host does not deliver hook context, and withheld where it does.
22
- 4. **The legacy boolean is coerced** — `true` reads as `always`, `false` as `never` — and `lessons list` names the
23
- coercion once, next to the value it produced.
24
- 5. **`durableViewVerdict` takes the capability as data** and returns a reason, following `appendFilesVerdict`. Core
25
- does not import a provider to decide it.
26
- 6. **The durable view is written at session start as well as session end**, from `allLessons` rather than from the
27
- project store, filtered by `appliesHere` as well as `isInjectable`.
28
- 7. **The cost report says which transport is paid.** `durable_chars` is measured from the file that was written and
29
- `hook_context_reliable` records what the provider declares.
30
- 8. **`session.start` keeps carrying context on every provider**, because the drop is a race rather than a refusal.
31
-
32
- ## Why
33
-
34
- The question was whether it is correct to hold the same lessons in `.cursor/rules/harness-lessons.mdc` and in
35
- `.tlc/harness/lessons.md`. Measuring it found the reverse of duplication.
36
-
37
- Cursor's own staff answered it, on the `sessionStart` `additional_context` bug report (forum thread 158452,
38
- 2026-04-20): *"This is a bug on our side. `additional_context` from `sessionStart` gets dropped due to a timing issue
39
- between when the hook runs and when the composer handle is created."* `env` from the same payload arrives, because
40
- it follows a different code path. It was reported again against 3.14.7 on 2026-08-02 (thread 167274), and no
41
- changelog entry fixes it.
42
-
43
- So on that host the hook route is unreliable and the rules file is the durable one — which AD-011 item 4 had already
44
- reasoned, without a citation. What nobody had noticed is that `syncRulesFile` defaulted to `false`. An all-defaults
45
- init with lessons switched on therefore delivered lessons by **no** route at all, which is consistent with the
46
- report that opened AD-049. One boolean was deciding transport for two hosts with different capabilities: on the one
47
- that delivers hook context it was a convenience, and on the one that does not it was the only route.
48
-
49
- Two further defects came out of reading the schemas rather than assuming them.
50
-
51
- Cursor's `stop` output schema carries `followup_message` and nothing else, and `cursorRender` maps every `context`
52
- decision to `additional_context`. The docs gate's non-blocking advisory was therefore rendered into a field that
53
- host ignores. The test covering it asserted the text on a Cursor stop event, which is how it passed. `degrade`
54
- already abstained a `context` decision a provider could not carry — it only knew about `tool.before` and
55
- `tool.after`. Claude Code's `Stop` *does* accept `hookSpecificOutput.additionalContext` for feedback that continues
56
- the turn, so the advisory survives where it can be read.
57
-
58
- And the durable view was rendering from `readProjectLessons`, so it carried one of the three tiers AD-040
59
- introduced. On the host where that file is the only route, every shipped core lesson and every global lesson
60
- reached nothing.
61
-
62
- ## Trade-offs
63
-
64
- **`auto` changes behaviour for an existing Cursor project.** A file appears in `.cursor/rules/` that was not there
65
- before. That is the fix, and `never` is the way to decline it — which is why the mode has three values rather than
66
- being inferred outright.
67
-
68
- **A file written into someone's repo.** `.cursor/rules/` is tracked by default in this project, on the reasoning
69
- that lessons are worth sharing with a team. In another repo that is the operator's call, and `never` is how they
70
- make it.
71
-
72
- **Two transports pay twice when the race is won.** Emitting at `session.start` on a host that usually drops the text
73
- means that when it does arrive, the same lessons arrive twice on that turn. The alternative is to stop emitting and
74
- lose the cases that work; the emission is bounded by `maxCharsSession`, so the cost of the overlap is bounded too.
75
-
76
- **`sessionStartContextReliable` is a claim about someone else's bug.** It will be wrong the day Cursor fixes the
77
- race, and nothing here will notice. The adapter cites the thread and the version so the claim can be re-checked
78
- rather than inherited.
79
-
80
- **`lessons list` carries a migration line.** It retires itself once the config is updated, and until then it is one
81
- line on a command an operator runs deliberately. `doctor` was the other candidate and was rejected: a row about a
82
- coerced field on every healthy run is the AD-034 defect.
83
-
84
- ## Not decided here
85
-
86
- - **Whether the `alwaysApply` file's real token cost can be reported.** The harness cannot see the assembled prompt.
87
- It reports the characters it wrote and states that the host is *asked* to include them on every request.
88
- - **Whether the advisory should reach the operator by another route on a host that cannot carry it.** It is in the
89
- gate artifact, which is where `tlc harness` reads it. A handoff blocker for a non-blocking advisory was not
90
- written.
91
- - **Whether the emission should stop once the Cursor race is fixed.** That is a capability flip, not a code change,
92
- and the flip needs evidence that does not exist yet.
93
- - **Whether Claude Code should get the durable pointer by default.** `always` writes it. Under `auto` it does not,
94
- because the hook delivers and the pointer would be a second copy of a working route.
@@ -1,69 +0,0 @@
1
- ---
2
- type: Decision
3
- title: "AD-051 — A warning fails the gate, and a suppression states what breaks without it"
4
- description: "Three fixable warnings sat in this repository across several green gates, because a warn-level biome rule does not change the exit code. The gate now runs biome with --error-on-warnings, and a new checker fails it on any suppression whose reason is not a reason. Escalating every rule group to error was measured first and rejected."
5
- tags: [decision, gate, lint, quality]
6
- timestamp: "2026-08-05"
7
- ---
8
-
9
- # AD-051 — A warning fails the gate, and a suppression states what breaks without it
10
-
11
- - **status**: active
12
- - **date**: 2026-08-05
13
-
14
- ## Decision
15
-
16
- 1. **The gate runs `biome check --error-on-warnings`.** A warn-level diagnostic fails it like an error.
17
- 2. **`linter.rules` stays on the `recommended` preset.** Group-level `error` severity was measured and rejected.
18
- 3. **`tools/check-suppressions.ts` is a gate step.** A `biome-ignore`, `@ts-ignore` or `@ts-expect-error` must carry
19
- a reason opening with `why:`, `hazard:` or `invariant:` — the words this project already requires of any comment
20
- that stays — followed by more than four words.
21
- 4. **`@ts-nocheck` fails regardless of its reason**, because it silences a whole file rather than a diagnostic.
22
- 5. **The three warnings are fixed rather than suppressed**, and one of them was a real defect.
23
-
24
- ## Why
25
-
26
- `npx biome check .` exited 0 while printing "Found 2 warnings. Found 1 info." Warn-level diagnostics do not change
27
- biome's exit code unless `--error-on-warnings` is passed, so the gate was green and the output said otherwise. Three
28
- findings had survived that way long enough that nobody knew when they arrived.
29
-
30
- One was not cosmetic. `linkedRuntimeMessage(dest, target)` accepted `dest` and never used it, so the message read
31
- "the runtime path is a link to a working clone" without naming the path — while both sibling messages,
32
- `unmanagedRuntimeMessage` and `resetFailureMessage`, name theirs. An operator with more than one runtime could not
33
- tell which link was meant. `noUnusedFunctionParameters` had been pointing at it in the warning list.
34
-
35
- The other two were `useIndexOf` (a `findIndex` with an equality predicate) and `useOptionalChain`.
36
-
37
- **Group-level severity was tried and measured.** Setting every group to `error` in `biome.json` produced **3763**
38
- findings, because a group severity enables that group's non-recommended rules as well. Among them: `noBarrelFile`
39
- and `noReExportAll`, which forbid precisely the core facade this architecture is built on ([/decisions/ad-004.md](/decisions/ad-004.md)), and
40
- `noNodejsModules` in a Node CLI. Adopting that would have meant a config full of exclusions — the opposite of
41
- removing clutter — so the curated preset stays and the flag does the enforcing.
42
-
43
- **`--error-on-warnings` cannot see a rule that was suppressed instead of fixed.** Biome 2 requires text after the
44
- colon, so `biome-ignore lint/complexity/useIndexOf: needed` parses and says nothing. That is exactly the escape a
45
- stricter exit code creates pressure to use, which is why the checker ships in the same change rather than later.
46
-
47
- ## Trade-offs
48
-
49
- **A prose rule enforced by a regex.** The checker cannot tell a real reason from four words that look like one. It
50
- raises the cost of a thoughtless suppression; it does not make one impossible.
51
-
52
- **`__test__` is scanned like everything else.** Excluding it would be the route around the rule. What keeps the
53
- comment-policy fixtures quiet is `isInComment`, which tracks the opening quote character rather than counting
54
- quotes — the first version counted, and reported three findings that were all string literals inside this project's
55
- own tests. A checker whose findings are noise is a checker somebody switches off, so it was calibrated against a
56
- deliberately injected suppression before being wired in.
57
-
58
- **Four words is arbitrary.** It was chosen because `why: needed` and `hazard: unavoidable` both pass a
59
- non-empty test and neither says what breaks.
60
-
61
- ## Not decided here
62
-
63
- - **Whether biome's own unused-suppression diagnostic should also fail.** It is part of the recommended preset and
64
- now fails through `--error-on-warnings`, so no separate decision was needed.
65
- - **Whether individual non-recommended rules are worth enabling one at a time.** `useConsistentTypeDefinitions`
66
- (8 findings) would force `interface` on a codebase that uses `type` throughout; `noMagicNumbers` accounts for most
67
- of the 3763. Neither was adopted and neither was excluded by name — the preset simply does not include them.
68
- - **Whether the same reason discipline should apply to `.mjs` and shell files.** The checker scans the tracked
69
- TypeScript surface, which is where suppressions have appeared.
@@ -1,69 +0,0 @@
1
- ---
2
- type: Decision
3
- title: "AD-052 — The repository moves to the org and the runtime paths do not"
4
- description: "The project moves to github.com/tech-leads-club/harness-toolkit. Only the identity layer changes — package name, repository URLs, installer source. The CLI, the runtime home and the project policy path stay exactly as AD-002 set them, because renaming those would re-initialise every install for no gain."
5
- tags: [decision, naming, install, delivery]
6
- timestamp: "2026-08-05"
7
- ---
8
-
9
- # AD-052 — The repository moves to the org and the runtime paths do not
10
-
11
- - **status**: active
12
- - **date**: 2026-08-05
13
-
14
- ## Decision
15
-
16
- 1. **The repository is `github.com/tech-leads-club/harness-toolkit`**, superseding that one clause of
17
- [/decisions/ad-002.md](/decisions/ad-002.md). The package is `harness-toolkit`.
18
- 2. **Nothing else in AD-002 changes.** The CLI is still `tlc` with a `harness` subcommand group, the runtime home is
19
- still `~/.tlc/harness/`, and the project source of truth is still `.tlc/harness/config.json`.
20
- 3. **`felipfr/tlc-agent-harness` is frozen as it stands**, not archived and not amended.
21
- 4. **While the repository is private to the org, the documented install fetches the script through `gh`**, because
22
- `raw.githubusercontent.com` is unauthenticated and cannot read it. The public one-liner is documented alongside,
23
- labelled for when it applies.
24
- 5. **A clone that fails names the credential it needs** — `gh auth login`, `gh auth setup-git`, org membership —
25
- rather than passing git's own error through.
26
-
27
- ## Why
28
-
29
- **The paths stay because renaming them costs users and buys nothing.** `TLC` is Tech Leads Club, so `tlc` and
30
- `.tlc/` read as *more* accurate under the org than they did under one person's account. Changing them would move
31
- `~/.tlc/harness`, `TLC_HOME`, `.tlc/harness/config.json` — which is also the literal string the floor's
32
- `policy-surface-write` rule matches — and every already-initialised project would need a manual re-init. Two
33
- colleagues are mid-onboarding on the current layout.
34
-
35
- **The private phase is an install problem, not a code problem.** `install.sh` clones over HTTPS and works against a
36
- private repository as soon as git has a credential; what does not work is the *bootstrap*, because the one-liner
37
- fetches the script from `raw.githubusercontent.com` unauthenticated. `gh api …/contents/install.sh` carries the
38
- credential and reaches the same script, so both phases run identical code and going public later requires no change
39
- at all.
40
-
41
- **The old repository is left alone by choice.** It keeps its history, and it keeps receiving `tlc harness update`
42
- from every install that predates this move — silently, from a repository that has stopped moving. That is a known
43
- consequence: the affected installs have to re-run the installer against the new repository, and being told is the
44
- only mechanism, because a stale install cannot learn about a move on its own. This is the same shape as
45
- [/decisions/ad-048.md](/decisions/ad-048.md), one level up.
46
-
47
- ## Trade-offs
48
-
49
- **Two install routes in the docs at once.** One of them does not work today and the other will not be needed later,
50
- and each is labelled with which. The alternative — documenting only the working one — means the public one-liner has
51
- to be reinstated in a later commit that nobody will remember to make.
52
-
53
- **`gh api … | base64 -d | bash` is not memorable.** It is also the only unattended route into a private repository
54
- that does not ask anyone to paste a token into a shell.
55
-
56
- **A frozen repository under a personal account still answers `git fetch`.** Anyone who never re-installs stays on
57
- the last revision it received, and the harness has no way to tell them so.
58
-
59
- ## Not decided here
60
-
61
- - **Publishing to npm.** It would remove `curl | bash`, the clone, the private-repository problem and the committed
62
- `dist/` in one move — and it replaces the update mechanism that AD-046 and AD-048 are built on, so it is its own
63
- decision rather than part of a move.
64
- - **When the repository becomes public.** The docs carry both routes so the switch is a visibility change and
65
- nothing else.
66
- - **Renaming the local working directory.** It is the operator's filesystem, and a runtime linked to it would follow
67
- the rename only if the link is re-pointed.
68
- - **`projectName` in this repository's own `.tlc/harness/config.json`.** It still reads `tlc-agent-harness`. The
69
- file is on the policy surface, so it is the operator's edit from a terminal ([/decisions/ad-022.md](/decisions/ad-022.md)).
@@ -1,78 +0,0 @@
1
- ---
2
- type: Decision
3
- title: "AD-053 — The harness ships no model allowlist, and a list that names nothing enforces nothing"
4
- description: "An agent read `enforceAllowlist: true` with `allowedModels: []` and concluded that every model was blocked, then offered to switch the rail off. The list it was actually being refused by was shipped by the adapter and already stale. No adapter ships one now, an empty list enforces nothing, doctor reports that combination, and the refusal names the key it came from."
5
- tags: [decision, subagents, policy, defaults]
6
- timestamp: "2026-08-06"
7
- ---
8
-
9
- # AD-053 — The harness ships no model allowlist, and a list that names nothing enforces nothing
10
-
11
- - **status**: active
12
- - **date**: 2026-08-06
13
-
14
- ## Decision
15
-
16
- 1. **`ProviderPolicyDefaults` no longer carries `allowedModels`**, and no adapter declares one. The effective
17
- allowlist is exactly what the project configured.
18
- 2. **An empty or absent list enforces nothing.** `enforceAllowlist` with no entries does not deny.
19
- 3. **`doctor` reports that combination as a fault**, naming `subagents.allowedModels`, and is silent when the rail
20
- is off or the list has entries.
21
- 4. **The refusal names the key the list came from**, and says `inherit` is a value that list may contain when
22
- `inherit` is what was refused.
23
- 5. **`blockedPatterns` keeps its shipped `-fast` entries.** It is the opposite mechanism and stays.
24
-
25
- ## Why
26
-
27
- The report came from another project, where an agent read:
28
-
29
- ```json
30
- "subagents": { "enforceAllowlist": true, "requireModel": false, "allowedModels": [] }
31
- ```
32
-
33
- and concluded that every model fell out, `inherit` and a `-thinking-high` variant included. It offered to set
34
- `enforceAllowlist: false`.
35
-
36
- The conclusion was wrong and the remedy would have switched off a rail. `effectiveAllowedModels` fell back to
37
- `provider.policyDefaults().allowedModels` whenever the project's list was empty, so the spawn was refused by a
38
- **shipped** list — three model slugs on Claude Code, five on Cursor — that appears nowhere in the project. The
39
- requested model genuinely was not on it, because that list had already gone stale.
40
-
41
- That a careful reader got it exactly backwards is the finding, not a footnote. The refusal said `Use one of: <list>`
42
- and named no source. An operator who opens their own config, sees `[]`, and is told a spawn was refused by an
43
- allowlist can only conclude that empty means none.
44
-
45
- **No shipped list at all** is the operator's decision, and it removes the cause rather than the symptom: a list the
46
- harness invents is a list that goes stale, and enforcing one nobody wrote is what made the message unreadable.
47
-
48
- **An empty list cannot deny**, because it says nothing about what is permitted. With the shipped fallback gone, the
49
- old code would have refused every spawn from a rule listing nothing — the behaviour the reader mistook for a bug,
50
- where they would have been right.
51
-
52
- **`inherit` is not a model name.** It means the parent's model, and before this the only occurrences of the string
53
- in this repository were `stdio: "inherit"`. It is now an ordinary value an operator may put on the list, and the
54
- refusal says so instead of answering with slugs.
55
-
56
- ## Trade-offs
57
-
58
- **A project that relied on the shipped list loses it.** `enforceAllowlist: true` with no list now permits every
59
- model instead of the three or five the adapter named. `doctor` fails on exactly that state, which is the only
60
- signal available — a spawn cannot warn without denying, and denying is what this removes.
61
-
62
- **`-fast` protection is unaffected, and the asymmetry is deliberate.** `effectiveBlockedPatterns` concatenates the
63
- project's patterns with the adapter's rather than replacing them, so the `-fast` denial survives an empty project
64
- config. A blocklist that ships entries adds protection; an allowlist that ships entries invents a policy.
65
-
66
- **`doctor` gains a `fail` row that some projects will hit immediately.** It is a fault: a rail declared on and
67
- enforcing nothing is a claim of protection that is not being given.
68
-
69
- ## Not decided here
70
-
71
- - **Resolving `inherit` to the recorded parent model and judging that.** More precise than treating it as a listable
72
- value, and it is inference the operator did not ask for. `blockParentFast` already covers the case that makes
73
- `inherit` risky.
74
- - **Whether the init skill should offer a starting list.** It asks; it does not propose slugs, because proposing
75
- them is shipping a default one conversation later.
76
- - **Whether `requireModel` should default on.** Unchanged by this decision.
77
- - **Prefix or wildcard matching for model families.** `isModelAllowlisted` still matches exactly or on `prefix[`,
78
- so `-thinking-high` needs its own entry. Widening the match is a separate question from who owns the list.
@@ -1,98 +0,0 @@
1
- ---
2
- type: Decision
3
- title: "AD-054 — npm is the distribution, and a merged release PR is the only thing that publishes"
4
- description: "Distribution moves from a git clone of the repository to a published npm package. Release Please computes the version from Conventional Commits and opens a release PR; merging it tags, releases and publishes through npm trusted publishing, with no stored token and no bot commit on main. The runtime path does not move."
5
- tags: [decision, release, npm, distribution, ci]
6
- timestamp: "2026-08-07"
7
- ---
8
-
9
- # AD-054 — npm is the distribution, and a merged release PR is the only thing that publishes
10
-
11
- - **status**: active
12
- - **date**: 2026-08-07
13
-
14
- ## Decision
15
-
16
- 1. **The product is published as `@tech-leads-club/harness-toolkit`.** The unscoped name was already taken on
17
- npm by an unrelated package; the scope is the one `@tech-leads-club/agent-skills` already uses.
18
- 2. **Release Please v4 computes the version** from Conventional Commits, which this repository already writes,
19
- and opens a release PR. It writes no changelog — `skip-changelog` is set, and
20
- [/decisions/ad-055.md](/decisions/ad-055.md) says where the changelog comes from instead.
21
- 3. **Merging that PR is the release.** It tags, creates the GitHub Release, and only then does the publish job
22
- run. An ordinary push to `main` publishes nothing. *(The clause making that merge a human approval is
23
- superseded by [/decisions/ad-057.md](/decisions/ad-057.md): the bot merges it.)*
24
- 4. **Publishing uses npm trusted publishing (OIDC).** No npm token exists anywhere — the workflow requests a
25
- short-lived credential with `id-token: write`, and provenance is generated automatically from a public
26
- repository.
27
- 5. **The publish job rebuilds `dist/` and runs the full gate against the tag** before publishing.
28
- 6. **No bot commits to `main`.** The one generated file a release touches is corrected inside the release PR.
29
- 7. **The runtime path does not move.** `~/.tlc/harness` stays what hooks point at, and what holds `config.json`,
30
- `flags/` and `state/` ([/decisions/ad-002.md](/decisions/ad-002.md),
31
- [/decisions/ad-052.md](/decisions/ad-052.md)).
32
-
33
- ## Why
34
-
35
- **A git clone is not a distribution channel, and this repository has the bug reports to prove it.** Two of the
36
- last five fixes were to the updater: a checkout that dirtied itself on rebuild and broke every later update
37
- ([/decisions/ad-046.md](/decisions/ad-046.md)), and an updater unable to deliver its own fix
38
- ([/decisions/ad-048.md](/decisions/ad-048.md)). Both are properties of running `git` against a user's machine.
39
- A registry has solved fetch, integrity and rollback already, and neither of those failures has an analogue in
40
- `npm install`.
41
-
42
- **Trusted publishing rather than a token, because the deadline is real.** npm is restricting tokens that bypass
43
- 2FA — account changes in August 2026, direct publishing in January 2027 — so a token-based pipeline would need
44
- replacing within months of being built. OIDC also produces the provenance attestation for free, which is the
45
- artifact that says this tarball was built from this commit by this workflow.
46
-
47
- **A release PR rather than publish-on-merge.** semantic-release publishes on every merge to `main` with nothing
48
- in between. Release Please's PR is where the version bump and the rendered changelog are assembled before either
49
- reaches `main`, and it costs nothing to author because the commit convention is already in place. It was also
50
- where a human could approve; [/decisions/ad-057.md](/decisions/ad-057.md) removed that and the bot merges it,
51
- which leaves the PR doing the assembly job it was chosen for.
52
-
53
- **Changesets was the other candidate and lost on shape.** Its strength is monorepos and its cost is a third
54
- artifact per PR — a changeset file, alongside the commit message and the decision record. This is one package
55
- with a decision-record habit already. The pause it sells is a pause this repository already takes.
56
-
57
- **A breaking change stays in `0.x`.** Release Please bumps a breaking change straight to `1.0.0` by default,
58
- even from `0.1.0`, so a single `feat!:` would have declared this product stable on its first release.
59
- `bump-minor-pre-major` turns that into a minor bump. Going to `1.0.0` becomes a decision someone makes, which
60
- is the only way it can mean anything.
61
-
62
- **Version numbers are now a promise, and the number stays below 1.0.0.**
63
- [/decisions/ad-003.md](/decisions/ad-003.md) declined backward compatibility and declined a version for exactly
64
- that reason. A registry requires one. `0.x` is the honest encoding of that stance: it is a version without a
65
- compatibility promise, which is what AD-003 said this product offers.
66
-
67
- ## Trade-offs
68
-
69
- **Publishing makes the source public, whatever the repository's visibility.** The tarball is world-readable, so
70
- the decision to publish is the decision to open the code. Provenance additionally requires a public repository,
71
- so publishing from a private one silently drops the attestation — the one thing the pipeline exists to produce.
72
-
73
- **The licence does not become permissive.** PolyForm Noncommercial 1.0.0 is a valid SPDX identifier and npm
74
- carries it, but a package on a public registry will be installed by people who never read it.
75
-
76
- **Two things must be done by hand, once, and the pipeline cannot do them.** A trusted publisher must be
77
- configured on npmjs.com naming the organisation, the repository and `release.yml`, and the repository must be
78
- public for provenance. A configuration created after 20 May 2026 must also state explicitly which operations it
79
- permits.
80
-
81
- **The version becomes a thing that can be wrong.** A `fix:` commit that changes behaviour ships as a patch and
82
- says compatibility held when it did not. The gate does not check this and nothing here proposes that it could.
83
-
84
- **`dist/` is now built twice** — committed for the Node fallback path and rebuilt in the publish job. The
85
- duplication is deliberate: `check-dist-fresh` proves the committed bundles match `src/`, and a release is the one
86
- moment where building again costs nothing and removes the question.
87
-
88
- ## Not decided here
89
-
90
- - **How an existing install migrates.** Two things were measured as wrong for the npm route: `doctor` reported
91
- `not a git checkout` as a failure, and the runtime cache was written inside the package directory npm replaces
92
- on update. Both are settled in [/decisions/ad-056.md](/decisions/ad-056.md).
93
- - **Whether to publish a library entry point.** Nothing is exported for programmatic use. The package is a CLI
94
- and a runtime; `src/` ships because Bun executes it directly, not as an API.
95
- - **Prereleases and dist-tags.** No `next` channel, no beta flow. One tag, `latest`.
96
- - **Signing beyond provenance.** Provenance attests the build; it is not a signature over the contents by a key
97
- this project holds, and key management stays out of scope for the same reason
98
- [/decisions/ad-028.md](/decisions/ad-028.md) declined it for attestations.
@@ -1,74 +0,0 @@
1
- ---
2
- type: Decision
3
- title: "AD-055 — The changelog is rendered from the decision records, and git says which release each landed in"
4
- description: "CHANGELOG.md is generated from docs/decisions/ rather than from commit messages, and the mapping from decision to version is read out of git rather than recorded anywhere. Release Please writes no changelog. The gate fails when the file drifts."
5
- tags: [decision, release, changelog, docs]
6
- timestamp: "2026-08-07"
7
- ---
8
-
9
- # AD-055 — The changelog is rendered from the decision records, and git says which release each landed in
10
-
11
- - **status**: active
12
- - **date**: 2026-08-07
13
-
14
- ## Decision
15
-
16
- 1. **`CHANGELOG.md` is generated** by `tools/render-changelog.ts` from `docs/decisions/ad-*.md`. It is not
17
- edited by hand and Release Please does not write it (`skip-changelog: true`).
18
- 2. **Which release a decision landed in is read from git** — the commit that added the file, and the tag whose
19
- range contains it. Nothing records it, so nothing can disagree about it.
20
- 3. **A `migration:` note becomes a "Needs your action" line.** Everything else is a title.
21
- 4. **`node tools/render-changelog.ts --check` is a gate step**, so the file cannot drift from the records.
22
- 5. **`--release <version>` renames the pending section**, and the release workflow runs it inside the release
23
- PR so the merged commit already carries the final document.
24
-
25
- ## Why
26
-
27
- [/decisions/ad-031.md](/decisions/ad-031.md) already decided that the substance of a changelog exists here as
28
- decision records — each carrying why, the trade-offs and what was refused — and that a second index would be a
29
- thing to maintain and a thing that can go stale. Publishing to a registry does not change that; it only adds a
30
- requirement that the document exist as a file.
31
-
32
- **Commit messages describe the commit; decision records describe the decision.** Generating from commits would
33
- produce a list where `fix(lessons): the migration line names the file the old boolean is actually in` is an
34
- entry, which tells a reader nothing they can act on. The decision record for the same change says what changed
35
- and what it costs.
36
-
37
- **The decision-to-version mapping is already in git.** Writing a version into each record's frontmatter would be
38
- the second index AD-031 refused, and it would be hand-maintained at exactly the moment — cutting a release —
39
- when nobody is looking at it. Walking the tag ranges answers the same question from the thing that cannot lie.
40
-
41
- **`--release` exists because the release PR bumps the version before the tag is created.** Without it, the first
42
- push after every release would fail its own `--check`: the file on disk would say `Unreleased` while a
43
- regeneration, now seeing the tag, would say `v0.2.0`. A test asserts the two renderings are byte-identical, so
44
- this stays true rather than being a claim in a comment.
45
-
46
- **And `--check` accepts two renderings, because the merge commit is a real window.** `ci.yml` and `release.yml`
47
- both fire on a push to `main` and run in parallel, so on the commit that merges a release PR the gate reads a
48
- document naming `v0.2.0` while the tag is still being created in the other workflow. A plain comparison would
49
- fail there on every release, on a file that is already correct. The tolerance is exactly one state and closes on
50
- its own: the pending rendering is accepted only while no tag of that name exists, so it cannot hold a stale file.
51
- A mutant that removes the closing condition kills a test.
52
-
53
- ## Trade-offs
54
-
55
- **A change with no decision record is invisible in the changelog.** That is the intent — a change worth telling
56
- users about is worth a record — but it means a bug fix that warranted no record leaves no trace here. The commit
57
- history remains the complete account; the GitHub Release carries Release Please's own commit-derived notes for
58
- anyone who wants them.
59
-
60
- **The first release lists every decision at once.** Fifty-odd entries under one version, which AD-031 argues is
61
- the same as no message. It is accepted for exactly one release: subsequent ones carry the handful that landed.
62
-
63
- **Generating from git makes the tool require git.** It shells out to `git log` and `git tag`, so it runs in a
64
- checkout and not from an unpacked tarball. It is a build-time tool and never runs in a hook, so the latency rule
65
- that governs the runtime does not reach it.
66
-
67
- ## Not decided here
68
-
69
- - **Grouping entries by kind** (feature, fix, security). Decision records carry tags and none of them is a
70
- release-note category; inventing one would be a second taxonomy.
71
- - **Linking each entry to its pull request.** The record links to the code it decided about, which is the more
72
- durable pointer.
73
- - **Rendering the changelog for other products.** The harness deliberately reads no project's documentation
74
- convention ([/decisions/ad-035.md](/decisions/ad-035.md)); this tool is this repository's, not a feature.
@@ -1,85 +0,0 @@
1
- ---
2
- type: Decision
3
- title: "AD-056 — The package delivers the runtime, and the runtime path stays where hooks already point"
4
- description: "npm becomes the install route. The published package is a delivery vehicle: `tlc harness install` materialises the code at ~/.tlc/harness, which keeps config.json, state/ and flags/ out of a directory npm replaces. Update bumps the package and re-materialises; no git command runs against an npm-delivered runtime."
5
- tags: [decision, install, update, npm, runtime]
6
- timestamp: "2026-08-07"
7
- ---
8
-
9
- # AD-056 — The package delivers the runtime, and the runtime path stays where hooks already point
10
-
11
- - **status**: active
12
- - **date**: 2026-08-07
13
-
14
- ## Decision
15
-
16
- 1. **`install.sh` installs the package globally and then runs `tlc harness install`.** The clone route stays,
17
- and the installer falls back to it — with a line saying so — whenever npm cannot deliver.
18
- 2. **`tlc harness install` copies a declared payload** from wherever the package sits into `~/.tlc/harness`.
19
- Each entry is replaced wholesale, so a file deleted upstream does not survive. Nothing outside the payload
20
- is touched, which is what keeps `config.json`, `state/` and `flags/` the operator's.
21
- 3. **The runtime path does not move** ([/decisions/ad-002.md](/decisions/ad-002.md),
22
- [/decisions/ad-052.md](/decisions/ad-052.md)). Hooks keep naming `~/.tlc/harness/bin/tlc-exec.mjs`, so the
23
- hot path never resolves anything.
24
- 4. **An npm-installed launcher defers to that runtime** when one is installed there, and runs from the package
25
- only while it is not — the package has to be able to run itself in order to install anything.
26
- 5. **`runtimePathKind` gains `npm`**, told apart by a marker the installer writes rather than guessed from the
27
- contents. `doctor` reports it as `ok`; `update` bumps the package and re-materialises, and runs no git
28
- command against it, for the same reason it runs none against a linked clone.
29
- 6. **The runtime cache is never written into a packaged copy**, and an unwritable home degrades to probing
30
- rather than throwing.
31
-
32
- ## Why
33
-
34
- **A directory npm owns cannot hold state.** `npm install -g` replaces the package directory, so the global
35
- lesson tier, the cross-repository spool and the runtime cache would be deleted by an ordinary update. This was
36
- measured, not predicted: the first packed tarball wrote `runtime-cache.json` inside the package on its first
37
- run, and `doctor` reported `FAIL runtime ownership — not a git checkout` on a perfectly healthy install.
38
-
39
- **Materialising beats splitting the paths.** The alternative was to teach the runtime that code and data live in
40
- different places — a change to `runtimeHome()`, the floor's protected paths, the lesson store, the spool and the
41
- policy loader, all on the hot path, on every machine that already has this installed. Copying four megabytes
42
- once per update buys the same correctness and touches one new file. The conflation of "where the code is" and
43
- "where the data is" was only ever safe because the runtime was a checkout the harness owned; the package
44
- restores that property instead of removing the assumption.
45
-
46
- **The marker, rather than inference.** A directory with no `.git` was classified `unmanaged`, which `doctor`
47
- calls a failure and `update` answers with "re-install with the one-liner". A directory can be many things and
48
- only whatever created it knows which, so the installer says so in a file.
49
-
50
- **The installer falls back rather than failing.** Before the first publish the package genuinely does not
51
- exist, and an installer that breaks because a future route is not ready yet is broken by its own roadmap. The
52
- fallback prints which route it took, so nothing is silent. This was caught by the existing test that pipes the
53
- installer the way the README documents.
54
-
55
- ## Trade-offs
56
-
57
- **The code exists twice.** Roughly four megabytes in global `node_modules` and again under the runtime home.
58
- The duplication buys a stable path for hooks and a directory npm will not delete, and disk is the cheapest
59
- thing being traded here.
60
-
61
- **`~/.tlc/harness` can be stale relative to the installed package.** `npm i -g` alone does not update the
62
- runtime; `tlc harness install` does, and `tlc harness update` runs both. An operator who bumps the package by
63
- hand and stops there keeps running the previous runtime, and nothing currently reports that gap.
64
-
65
- **The npm-installed CLI is not the code that runs.** `tlc` resolves to the package's shim, which defers to the
66
- materialised runtime. That is the same relationship the git route has — the CLI runs whatever is at the runtime
67
- home — but it is surprising the first time.
68
-
69
- **One env var of coupling.** `TLC_ORIGIN` carries where the launcher physically lives, because the resolved home
70
- is no longer that. `TLC_HOME_FROM_ENV` distinguishes an operator's choice from the launcher's own resolution,
71
- and it is derived once by the outermost launcher and inherited after that: deriving it twice made the nested
72
- invocation read the parent's `TLC_HOME` as a choice and install the runtime on top of itself. Measured against
73
- the packed tarball, and now asserted.
74
-
75
- ## Not decided here
76
-
77
- - **Reporting a runtime older than the installed package.** `doctor` could compare the two `package.json`
78
- versions and say so. Worth doing; it is a check, not a distribution decision.
79
- - **Removing the git route.** It stays for contributors and for anyone already on it. Whether it is eventually
80
- retired depends on how the npm route behaves in the field.
81
- - **A postinstall script.** It would remove the second command, and `--ignore-scripts` is common and postinstall
82
- scripts are exactly the supply-chain surface this project should not add to.
83
- - **Windows.** `install.ps1` is unchanged and still clones. The CLI half works — `bin/tlc.mjs` is why the
84
- published entry points are `.mjs` rather than the shell wrappers — but the installer is not converted and
85
- neither is CI's coverage of it.