@tech-leads-club/harness-toolkit 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/generate-schema.ts +67 -0
  2. package/bin/tlc-build.mjs +26 -1
  3. package/bin/tlc-cli.ts +43 -11
  4. package/config.example.json +1 -0
  5. package/dist/compact-before.mjs +83 -83
  6. package/dist/doctor.mjs +79 -79
  7. package/dist/init-project.mjs +87 -87
  8. package/dist/install-runtime.mjs +82 -82
  9. package/dist/lessons-cli.mjs +86 -86
  10. package/dist/obs-cli.mjs +79 -79
  11. package/dist/prompt-submit.mjs +83 -83
  12. package/dist/refresh-model-prices.mjs +82 -82
  13. package/dist/response-after.mjs +83 -83
  14. package/dist/run.mjs +83 -83
  15. package/dist/session-end.mjs +89 -89
  16. package/dist/session-start.mjs +91 -91
  17. package/dist/shim.mjs +81 -81
  18. package/dist/stop.mjs +89 -89
  19. package/dist/subagent-start.mjs +83 -83
  20. package/dist/subagent-stop.mjs +84 -84
  21. package/dist/support.mjs +87 -87
  22. package/dist/tlc-cli.mjs +103 -102
  23. package/dist/tool-after.mjs +83 -83
  24. package/dist/tool-before.mjs +85 -85
  25. package/dist/tool-failure.mjs +83 -83
  26. package/dist/uninstall-runtime.mjs +4 -4
  27. package/docs/concepts.md +4 -1
  28. package/docs/log.md +8 -0
  29. package/package.json +4 -2
  30. package/schema.json +545 -0
  31. package/src/core/capability/capability.store.ts +27 -1
  32. package/src/core/comment-policy/comment-policy.service.ts +3 -8
  33. package/src/core/core.facade.ts +7 -1
  34. package/src/core/floor/floor.policy-surface.ts +1 -1
  35. package/src/core/handoff/handoff.service.ts +30 -0
  36. package/src/core/handoff/handoff.types.ts +0 -2
  37. package/src/core/lesson/lesson.types.ts +5 -0
  38. package/src/core/policy/policy.loader.ts +5 -1
  39. package/src/core/policy/policy.shadow.ts +68 -2
  40. package/src/core/turn/turn.failure-signals.ts +3 -1
  41. package/src/entrypoints/stop.ts +16 -14
  42. package/src/entrypoints/subagent-stop.ts +8 -6
  43. package/tools/doctor.ts +48 -0
  44. package/tools/init-project.ts +19 -1
@@ -0,0 +1,67 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import * as TJS from "typescript-json-schema";
4
+
5
+ /**
6
+ * why: `PartialPolicy`, not `Policy` — a human-written config is always partial, and `Policy`'s every
7
+ * field being non-optional would mark a minimal config invalid in every editor.
8
+ */
9
+ const ROOT_TYPE = "PartialPolicy";
10
+ const SOURCE_FILE = "src/core/policy/policy.types.ts";
11
+
12
+ function compilerOptionsFrom(root: string): Record<string, unknown> {
13
+ const raw = JSON.parse(readFileSync(join(root, "tsconfig.json"), "utf8")) as {
14
+ compilerOptions: Record<string, unknown>;
15
+ };
16
+ return raw.compilerOptions;
17
+ }
18
+
19
+ /**
20
+ * invariant: `noExtraProps` is what makes an unknown key (the `format` class of bug) a schema
21
+ * violation instead of something the schema silently accepts.
22
+ */
23
+ export function generateConfigSchema(rawRoot: string): Record<string, unknown> {
24
+ // why: TypeScript prints an import() type query against its own canonicalised path — a `.` segment
25
+ // or backslashes in the caller's spelling would silently defeat redactBuildPath's string match below.
26
+ const root = resolve(rawRoot);
27
+ const program = TJS.getProgramFromFiles([join(root, SOURCE_FILE)], compilerOptionsFrom(root), root);
28
+ const schema = TJS.generateSchema(program, ROOT_TYPE, {
29
+ required: true,
30
+ noExtraProps: true,
31
+ strictNullChecks: true,
32
+ });
33
+ if (schema === null) {
34
+ throw new Error(`typescript-json-schema produced no schema for ${ROOT_TYPE} in ${SOURCE_FILE}`);
35
+ }
36
+ const properties = (schema as { properties?: Record<string, unknown> }).properties ?? {};
37
+ const withSchemaProp = {
38
+ ...schema,
39
+ properties: {
40
+ // why: a JSON Schema meta-key, not a PartialPolicy field — typescript-json-schema never emits
41
+ // it, and `noExtraProps` would otherwise make every real config with a `$schema` line invalid.
42
+ $schema: { type: "string" },
43
+ ...properties,
44
+ },
45
+ };
46
+ return redactBuildPath(withSchemaProp, root);
47
+ }
48
+
49
+ /**
50
+ * hazard: a field typed `Partial<Policy["grind"]>` (an indexed-access type, not its own named alias)
51
+ * has no clean name to give its `$ref`, so the generator falls back to printing the full structural
52
+ * type — including an `import("<absolute path>")` type query for every cross-file reference inside
53
+ * it. That absolute path is the machine that ran the build, encoded twice: once raw in `$ref` targets
54
+ * that are also definition keys, once URI-percent-encoded in the `$ref` string itself. Published
55
+ * as-is, it would leak the CI runner's (or a contributor's) filesystem layout into a public schema.
56
+ */
57
+ // why: TypeScript always prints module specifiers with forward slashes, regardless of host OS — on
58
+ // Windows `root` itself is backslash-separated, so the raw string alone never matches what got printed.
59
+ function redactBuildPath(schema: Record<string, unknown>, root: string): Record<string, unknown> {
60
+ const posixRoot = root.replace(/\\/g, "/");
61
+ const candidates = [root, posixRoot, encodeURIComponent(root), encodeURIComponent(posixRoot)];
62
+ let serialized = JSON.stringify(schema);
63
+ for (const candidate of candidates) {
64
+ serialized = serialized.split(candidate).join(".");
65
+ }
66
+ return JSON.parse(serialized) as Record<string, unknown>;
67
+ }
package/bin/tlc-build.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  * entrypoint, and the missing bundle only surfaces when a hook fires on somebody's machine.
17
17
  */
18
18
  import { spawnSync } from "node:child_process";
19
- import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
19
+ import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
20
20
  import { basename, dirname, join } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
22
 
@@ -115,3 +115,28 @@ for (const entry of readdirSync(dist, { withFileTypes: true })) {
115
115
  }
116
116
 
117
117
  console.log(`tlc-build: ok (${bundles} bundles)`);
118
+
119
+ // why: dynamic, and a missing module degrades rather than fails — typescript-json-schema is a
120
+ // devDependency, absent from a plain install, and the dist rebuild above is this script's real
121
+ // recovery contract (bin/tlc-exec.mjs's "dist missing, run tlc-build.mjs" message). Never committed
122
+ // (.gitignore) either way, so nothing here needs the freshness gate `dist/` no longer has.
123
+ let generateConfigSchema;
124
+ try {
125
+ ({ generateConfigSchema } = await import("./generate-schema.ts"));
126
+ } catch (error) {
127
+ if (error?.code !== "ERR_MODULE_NOT_FOUND") {
128
+ throw error;
129
+ }
130
+ console.log("tlc-build: schema.json skipped — typescript-json-schema is not installed here");
131
+ }
132
+
133
+ if (generateConfigSchema) {
134
+ try {
135
+ const schema = generateConfigSchema(root);
136
+ writeFileSync(join(root, "schema.json"), `${JSON.stringify(schema, null, 2)}\n`);
137
+ console.log("tlc-build: schema.json ok");
138
+ } catch (error) {
139
+ console.error(`tlc-build: schema.json generation failed — ${error instanceof Error ? error.message : error}`);
140
+ process.exit(1);
141
+ }
142
+ }
package/bin/tlc-cli.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  flagsDir,
21
21
  isOnPath,
22
22
  launcherBinDir,
23
+ loopsDir,
23
24
  machineHome,
24
25
  projectConfigPath,
25
26
  projectStateDir,
@@ -193,6 +194,32 @@ export function setPaused(root: string, on: boolean): string {
193
194
  return "gates ACTIVE again";
194
195
  }
195
196
 
197
+ /**
198
+ * why: the operator's escape hatch for a stuck gate — `blockers` and `previous_gaps` are project-wide,
199
+ * so a grind-cap or stagnation signal from one session can block every later subagent until either a
200
+ * clean stop clears it or this runs. Denied from inside a session by `policy-surface-write`.
201
+ */
202
+ export async function resetStuckState(root: string): Promise<string> {
203
+ const cleared = await coreFacade.handoff.clearStuckSignals(root);
204
+ const dir = loopsDir(root);
205
+ let loopFiles = 0;
206
+ if (existsSync(dir)) {
207
+ loopFiles = readdirSync(dir).length;
208
+ rmSync(dir, { recursive: true, force: true });
209
+ }
210
+ if (cleared.length === 0 && loopFiles === 0) {
211
+ return "nothing stuck — no blockers and no grind-loop state to clear";
212
+ }
213
+ const parts: string[] = [];
214
+ if (cleared.length > 0) {
215
+ parts.push(`cleared blockers for: ${cleared.join(", ")}`);
216
+ }
217
+ if (loopFiles > 0) {
218
+ parts.push(`reset ${loopFiles} grind-loop counter(s)`);
219
+ }
220
+ return parts.join("; ");
221
+ }
222
+
196
223
  // hazard: this used to map `focus` onto a second spelling before writing, so the word the operator typed and the
197
224
  // word the config field stored were different — and a config written from the documented word then matched no
198
225
  // branch at all. One word per posture, and nothing translates.
@@ -263,14 +290,9 @@ export function handoffScreen(report: HandoffReport): Screen {
263
290
  rows.push({ label, value: String(value), level });
264
291
  }
265
292
  }
266
- for (const [label, list] of [
267
- ["in progress", slice.in_progress],
268
- ["pending", slice.pending],
269
- ["gaps", slice.previous_gaps?.map((gap) => gap.summary)],
270
- ] as const) {
271
- if (list && list.length > 0) {
272
- rows.push({ label, value: list.slice(0, 6).join(" | ") });
273
- }
293
+ const gaps = slice.previous_gaps?.map((gap) => gap.summary);
294
+ if (gaps && gaps.length > 0) {
295
+ rows.push({ label: "gaps", value: gaps.slice(0, 6).join(" | ") });
274
296
  }
275
297
  sections.push({ title: `${name} (updated ${slice.updated_at})`, rows });
276
298
  }
@@ -863,6 +885,7 @@ TOPICS
863
885
 
864
886
  CONTROL
865
887
  tlc harness grind [on|off] tlc harness pause | resume tlc harness mode solo|paired|focus
888
+ tlc harness reset clear a stuck blocker/grind-loop signal from the handoff
866
889
  tlc harness gate test-command <cmd> [args...] tlc harness gate lint-command <cmd> [args...]
867
890
  tlc harness attest tamper-evident record of what each session ran under
868
891
  tlc harness policy show a policy that changed out of band; accept <path> to clear it
@@ -1128,6 +1151,7 @@ export type Action =
1128
1151
  | { kind: "grind"; on: boolean }
1129
1152
  | { kind: "pause" }
1130
1153
  | { kind: "resume" }
1154
+ | { kind: "reset" }
1131
1155
  | { kind: "mode"; value: string }
1132
1156
  | { kind: "gate"; field: GateField; argv: string[] }
1133
1157
  | { kind: "attest" }
@@ -1193,6 +1217,8 @@ export function route(args: string[]): Action {
1193
1217
  case "resume":
1194
1218
  case "r":
1195
1219
  return { kind: "resume" };
1220
+ case "reset":
1221
+ return { kind: "reset" };
1196
1222
  case "mode":
1197
1223
  case "m": {
1198
1224
  const modeArg = args[1];
@@ -1612,13 +1638,13 @@ function runInstall(toolArgs: string[], root: string): never {
1612
1638
  process.exit(r.status ?? 1);
1613
1639
  }
1614
1640
 
1615
- function main(argv: string[]): void {
1641
+ async function main(argv: string[]): Promise<void> {
1616
1642
  const root = resolveProjectRoot();
1617
1643
  const group = (argv[0] ?? "").toLowerCase();
1618
1644
  if (group !== "harness") {
1619
1645
  console.error(`unknown: ${argv[0] ?? ""}`);
1620
1646
  console.error(
1621
- "usage: tlc harness <status|doctor|help|grind|pause|resume|mode|obs|prices|lessons|init|update|test|build>",
1647
+ "usage: tlc harness <status|doctor|help|grind|pause|resume|reset|mode|obs|prices|lessons|init|update|test|build>",
1622
1648
  );
1623
1649
  process.exit(1);
1624
1650
  }
@@ -1744,6 +1770,9 @@ function main(argv: string[]): void {
1744
1770
  case "resume":
1745
1771
  console.log(setPaused(root, false));
1746
1772
  break;
1773
+ case "reset":
1774
+ console.log(await resetStuckState(root));
1775
+ break;
1747
1776
  case "mode":
1748
1777
  try {
1749
1778
  console.log(setMode(root, action.value));
@@ -1794,5 +1823,8 @@ function main(argv: string[]): void {
1794
1823
  }
1795
1824
 
1796
1825
  if (import.meta.main) {
1797
- main(process.argv.slice(2));
1826
+ main(process.argv.slice(2)).catch((error) => {
1827
+ console.error(error instanceof Error ? error.message : String(error));
1828
+ process.exit(1);
1829
+ });
1798
1830
  }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "$schema": "https://unpkg.com/@tech-leads-club/harness-toolkit@0.5/schema.json",
2
3
  "version": 1,
3
4
  "mode": "solo",
4
5
  "subagents": {