@tech-leads-club/harness-toolkit 0.5.1 → 0.6.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.
- package/bin/generate-schema.ts +67 -0
- package/bin/tlc-build.mjs +26 -1
- package/config.example.json +1 -0
- package/dist/compact-before.mjs +83 -83
- package/dist/doctor.mjs +79 -79
- package/dist/init-project.mjs +87 -87
- package/dist/install-runtime.mjs +82 -82
- package/dist/lessons-cli.mjs +86 -86
- package/dist/obs-cli.mjs +79 -79
- package/dist/prompt-submit.mjs +83 -83
- package/dist/refresh-model-prices.mjs +82 -82
- package/dist/response-after.mjs +83 -83
- package/dist/run.mjs +83 -83
- package/dist/session-end.mjs +89 -89
- package/dist/session-start.mjs +91 -91
- package/dist/shim.mjs +81 -81
- package/dist/stop.mjs +89 -89
- package/dist/subagent-start.mjs +83 -83
- package/dist/subagent-stop.mjs +84 -84
- package/dist/support.mjs +87 -87
- package/dist/tlc-cli.mjs +100 -100
- package/dist/tool-after.mjs +83 -83
- package/dist/tool-before.mjs +83 -83
- package/dist/tool-failure.mjs +83 -83
- package/dist/uninstall-runtime.mjs +3 -3
- package/package.json +4 -2
- package/schema.json +545 -0
- package/src/core/capability/capability.store.ts +27 -1
- package/src/core/comment-policy/comment-policy.service.ts +3 -8
- package/src/core/core.facade.ts +5 -1
- package/src/core/policy/policy.loader.ts +5 -1
- package/src/core/policy/policy.shadow.ts +68 -2
- package/src/entrypoints/stop.ts +5 -7
- package/tools/doctor.ts +48 -0
- package/tools/init-project.ts +19 -1
|
@@ -13,10 +13,35 @@
|
|
|
13
13
|
/** A leaf the project config names, and the value it would have had without naming it. */
|
|
14
14
|
export type ShadowedKey = { path: string; value: unknown };
|
|
15
15
|
|
|
16
|
+
/** A leaf the project config names that `resolved` (the shipped `Policy` shape) has no counterpart for. */
|
|
17
|
+
export type UnknownKey = { path: string; value: unknown };
|
|
18
|
+
|
|
19
|
+
/** A leaf present in both, whose runtime shape disagrees with the default's. */
|
|
20
|
+
export type TypeMismatch = { path: string; expected: string; actual: string };
|
|
21
|
+
|
|
16
22
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
17
23
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
/**
|
|
27
|
+
* why: `typeof null` is `"object"`, indistinguishable from a real object without this. A field whose
|
|
28
|
+
* default is `null` (`evidenceDir`, `lintCommand`, `minEffort`, ...) is a `T | null` union in `Policy`
|
|
29
|
+
* — comparing `typeof` directly against that default would flag every valid non-null override as a
|
|
30
|
+
* mismatch, which is why the mismatch check below exempts `below === null` entirely.
|
|
31
|
+
*/
|
|
32
|
+
function typeLabel(value: unknown): string {
|
|
33
|
+
if (value === null) {
|
|
34
|
+
return "null";
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
return "array";
|
|
38
|
+
}
|
|
39
|
+
if (isPlainObject(value)) {
|
|
40
|
+
return "object";
|
|
41
|
+
}
|
|
42
|
+
return typeof value;
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
/**
|
|
21
46
|
* why a JSON comparison rather than a deep walk of its own: the values are config leaves — scalars and small
|
|
22
47
|
* arrays — and `codePaths: ["src"]` has to compare equal to `codePaths: ["src"]`. A second structural comparator
|
|
@@ -36,13 +61,22 @@ function sameValue(a: unknown, b: unknown): boolean {
|
|
|
36
61
|
* `resolved` is the policy as it would be with the project config absent: the shipped defaults merged with this
|
|
37
62
|
* machine's tier.
|
|
38
63
|
*/
|
|
64
|
+
type WalkResult = {
|
|
65
|
+
shadowed: ShadowedKey[];
|
|
66
|
+
kept: Record<string, unknown>;
|
|
67
|
+
unknown: UnknownKey[];
|
|
68
|
+
mismatched: TypeMismatch[];
|
|
69
|
+
};
|
|
70
|
+
|
|
39
71
|
function walk(
|
|
40
72
|
project: Record<string, unknown>,
|
|
41
73
|
resolved: Record<string, unknown>,
|
|
42
74
|
prefix: string,
|
|
43
|
-
):
|
|
75
|
+
): WalkResult {
|
|
44
76
|
const shadowed: ShadowedKey[] = [];
|
|
45
77
|
const kept: Record<string, unknown> = {};
|
|
78
|
+
const unknown: UnknownKey[] = [];
|
|
79
|
+
const mismatched: TypeMismatch[] = [];
|
|
46
80
|
for (const [key, value] of Object.entries(project)) {
|
|
47
81
|
// why kept and never reported: `version` marks the config's shape rather than a setting, so naming it is
|
|
48
82
|
// required rather than redundant.
|
|
@@ -52,9 +86,17 @@ function walk(
|
|
|
52
86
|
}
|
|
53
87
|
const path = prefix === "" ? key : `${prefix}.${key}`;
|
|
54
88
|
const below = resolved[key];
|
|
89
|
+
// why: `projectName` is exempt — a real, optional `Policy` field `DEFAULTS` never sets, so it is
|
|
90
|
+
// legitimately absent from `resolved`'s own keys. Kept separate from the branches below: a key
|
|
91
|
+
// absent from `resolved` still needs `shadowed`/`kept` judged as before; this only adds to `unknown`.
|
|
92
|
+
if (!(key in resolved) && !(prefix === "" && key === "projectName")) {
|
|
93
|
+
unknown.push({ path, value });
|
|
94
|
+
}
|
|
55
95
|
if (isPlainObject(value) && isPlainObject(below)) {
|
|
56
96
|
const inner = walk(value, below, path);
|
|
57
97
|
shadowed.push(...inner.shadowed);
|
|
98
|
+
unknown.push(...inner.unknown);
|
|
99
|
+
mismatched.push(...inner.mismatched);
|
|
58
100
|
// why empty blocks go: `{ shipGate: {} }` decides nothing, and leaving it behind would make a pruned config
|
|
59
101
|
// read as though it had opinions.
|
|
60
102
|
if (Object.keys(inner.kept).length > 0) {
|
|
@@ -62,13 +104,18 @@ function walk(
|
|
|
62
104
|
}
|
|
63
105
|
continue;
|
|
64
106
|
}
|
|
107
|
+
// why: `below === null` is exempt — see `typeLabel`'s doc, a `T | null` default cannot be told
|
|
108
|
+
// apart from a real mismatch by `typeof` alone, and a false positive here is worse than a miss.
|
|
109
|
+
if (below !== null && below !== undefined && typeLabel(value) !== typeLabel(below)) {
|
|
110
|
+
mismatched.push({ path, expected: typeLabel(below), actual: typeLabel(value) });
|
|
111
|
+
}
|
|
65
112
|
if (sameValue(value, below)) {
|
|
66
113
|
shadowed.push({ path, value });
|
|
67
114
|
} else {
|
|
68
115
|
kept[key] = value;
|
|
69
116
|
}
|
|
70
117
|
}
|
|
71
|
-
return { shadowed, kept };
|
|
118
|
+
return { shadowed, kept, unknown, mismatched };
|
|
72
119
|
}
|
|
73
120
|
|
|
74
121
|
/** What `doctor` reports: every leaf whose value the tiers below already resolve to. */
|
|
@@ -79,6 +126,25 @@ export function shadowedKeys(
|
|
|
79
126
|
return walk(project, resolved, "").shadowed;
|
|
80
127
|
}
|
|
81
128
|
|
|
129
|
+
/**
|
|
130
|
+
* What `doctor` reports: every leaf `resolved` (the shipped `Policy` shape) has no counterpart for —
|
|
131
|
+
* the `format` bug's exact shape, a key that does nothing because nothing reads it.
|
|
132
|
+
*/
|
|
133
|
+
export function unknownKeys(
|
|
134
|
+
project: Record<string, unknown>,
|
|
135
|
+
resolved: Record<string, unknown>,
|
|
136
|
+
): UnknownKey[] {
|
|
137
|
+
return walk(project, resolved, "").unknown;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What `doctor` reports: every leaf present in both, whose runtime shape disagrees with the default's. */
|
|
141
|
+
export function typeMismatches(
|
|
142
|
+
project: Record<string, unknown>,
|
|
143
|
+
resolved: Record<string, unknown>,
|
|
144
|
+
): TypeMismatch[] {
|
|
145
|
+
return walk(project, resolved, "").mismatched;
|
|
146
|
+
}
|
|
147
|
+
|
|
82
148
|
/**
|
|
83
149
|
* What `init` writes: the same config with every restatement removed.
|
|
84
150
|
*
|
package/src/entrypoints/stop.ts
CHANGED
|
@@ -470,16 +470,14 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
470
470
|
const changedFiles = await listChangedRepoFiles(root, turnBase);
|
|
471
471
|
const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
|
|
472
472
|
const testTargets = filterTestTargets(changedFiles);
|
|
473
|
-
// why: the comment rail scopes by the syntax catalog (40+ languages), not
|
|
474
|
-
// regex
|
|
475
|
-
//
|
|
476
|
-
// than inside `filterCommentTargets` itself, which would need `policy.loader.ts` and create an import cycle
|
|
477
|
-
// through `duplication.service.ts` (see the invariant on `filterCommentTargets`).
|
|
473
|
+
// why: the comment rail scopes by the syntax catalog (40+ languages), not `codeTargets`'s nine-extension
|
|
474
|
+
// regex — a Terraform/SQL-heavy project changed no file that regex would keep, so the rail never ran.
|
|
475
|
+
// Scoped to `codePaths` here, not inside `filterCommentTargets`, to avoid an import cycle through `policy.loader.ts`.
|
|
478
476
|
const commentScope = changedFiles.filter((file) =>
|
|
479
477
|
coreFacade.policy.isUnderCodePaths(file, policy.codePaths),
|
|
480
478
|
);
|
|
481
|
-
// why: `unknown_extensions` reports over `commentScope
|
|
482
|
-
//
|
|
479
|
+
// why: `unknown_extensions` reports over `commentScope` (broader), not this — a language gap is still named
|
|
480
|
+
// even after this excludes it from the scan.
|
|
483
481
|
const commentTargets = coreFacade.commentPolicy.filterCommentTargets(commentScope);
|
|
484
482
|
// why: read from the snapshot taken before this handler patches anything, so a credit written by the previous
|
|
485
483
|
// stop is still visible when the gate it belongs to runs below.
|
package/tools/doctor.ts
CHANGED
|
@@ -578,6 +578,53 @@ export function checkShadowedPolicy(root: string): Check[] {
|
|
|
578
578
|
];
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
+
/**
|
|
582
|
+
* invariant: a malformed config is reported distinctly from an absent one. `readProjectPolicyRaw` used to
|
|
583
|
+
* collapse both to `null` — the silent half of the bug this closes, since a trailing comma degrades the
|
|
584
|
+
* whole project tier to defaults with nothing telling the operator why.
|
|
585
|
+
*/
|
|
586
|
+
export function checkConfigKeys(root: string): Check[] {
|
|
587
|
+
const status = coreFacade.capability.readProjectPolicyStatus(root);
|
|
588
|
+
if (status.status === "absent") {
|
|
589
|
+
return [];
|
|
590
|
+
}
|
|
591
|
+
if (status.status === "malformed") {
|
|
592
|
+
return [
|
|
593
|
+
{
|
|
594
|
+
level: "warn",
|
|
595
|
+
name: "project policy parses",
|
|
596
|
+
detail: `${projectConfigPath(root)} failed to parse: ${status.error}. Until fixed, this tier silently falls back to defaults.`,
|
|
597
|
+
},
|
|
598
|
+
];
|
|
599
|
+
}
|
|
600
|
+
// why: DEFAULTS, not resolvedWithoutProjectTier() — the machine tier is operator content, not the shape
|
|
601
|
+
// the harness reads. An unknown key present in both tiers used to recurse into it instead of naming it,
|
|
602
|
+
// because resolvedWithoutProjectTier() carries the machine tier's own keys, unknown or not, straight through.
|
|
603
|
+
const resolved = coreFacade.policy.DEFAULTS as unknown as Record<string, unknown>;
|
|
604
|
+
const unknown = coreFacade.policy.unknownKeys(status.value, resolved);
|
|
605
|
+
const mismatched = coreFacade.policy.typeMismatches(status.value, resolved);
|
|
606
|
+
const checks: Check[] = [];
|
|
607
|
+
if (unknown.length > 0) {
|
|
608
|
+
const shown = unknown.slice(0, 6).map((key) => key.path);
|
|
609
|
+
const rest = unknown.length - shown.length;
|
|
610
|
+
checks.push({
|
|
611
|
+
level: "warn",
|
|
612
|
+
name: "project policy has an unknown key",
|
|
613
|
+
detail: `${plural(unknown.length, "key")} in this project's config ${unknown.length === 1 ? "matches" : "match"} nothing the harness reads: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}. Delete ${unknown.length === 1 ? "it" : "them"}, or check for a typo.`,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
if (mismatched.length > 0) {
|
|
617
|
+
const shown = mismatched.slice(0, 6).map((m) => `${m.path} (expected ${m.expected}, got ${m.actual})`);
|
|
618
|
+
const rest = mismatched.length - shown.length;
|
|
619
|
+
checks.push({
|
|
620
|
+
level: "warn",
|
|
621
|
+
name: "project policy has a type mismatch",
|
|
622
|
+
detail: `${plural(mismatched.length, "key")} in this project's config ${mismatched.length === 1 ? "does" : "do"} not match the type the harness expects: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}.`,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
return checks;
|
|
626
|
+
}
|
|
627
|
+
|
|
581
628
|
export function checkLessonBudget(root: string): Check[] {
|
|
582
629
|
const policy = coreFacade.policy.loadPolicy(root);
|
|
583
630
|
const config = policy.intelligence.lessons;
|
|
@@ -751,6 +798,7 @@ export function checkProjectPolicy(root: string): Check[] {
|
|
|
751
798
|
...checkLessonHealth(root),
|
|
752
799
|
...checkLessonBudget(root),
|
|
753
800
|
...checkShadowedPolicy(root),
|
|
801
|
+
...checkConfigKeys(root),
|
|
754
802
|
...checkSubagentAllowlist(root),
|
|
755
803
|
...checkPolicyDivergence(root),
|
|
756
804
|
...checkGateScope(root),
|
package/tools/init-project.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, sep } from "node:path";
|
|
3
|
+
import { NPM_PACKAGE, runtimeVersion } from "../bin/tlc-cli.ts";
|
|
3
4
|
import { applyCursorWiring, renderCursorHooksDocument } from "../bin/write-user-hooks.mjs";
|
|
4
5
|
import type { WiringEntry } from "../src/contracts/index.ts";
|
|
5
6
|
import { coreFacade } from "../src/core/index.ts";
|
|
@@ -234,6 +235,20 @@ export function configLine(outcome: ApplyOutcome): string {
|
|
|
234
235
|
: `wrote ${outcome.configPath}`;
|
|
235
236
|
}
|
|
236
237
|
|
|
238
|
+
/**
|
|
239
|
+
* why: major.minor, not bare major — this package is pre-1.0, where a 0.x minor bump can also break,
|
|
240
|
+
* so pinning only the major would silently serve a schema from the wrong minor. An unreadable
|
|
241
|
+
* `package.json` falls back to unpkg's latest resolution rather than a version string that goes stale.
|
|
242
|
+
*/
|
|
243
|
+
function schemaUrl(): string {
|
|
244
|
+
const version = runtimeVersion(runtimeHome());
|
|
245
|
+
if (version === null) {
|
|
246
|
+
return `https://unpkg.com/${NPM_PACKAGE}/schema.json`;
|
|
247
|
+
}
|
|
248
|
+
const [major, minor] = version.split(".");
|
|
249
|
+
return `https://unpkg.com/${NPM_PACKAGE}@${major}.${minor}/schema.json`;
|
|
250
|
+
}
|
|
251
|
+
|
|
237
252
|
export function applyPlan(
|
|
238
253
|
root: string,
|
|
239
254
|
flags: InitFlags,
|
|
@@ -254,7 +269,10 @@ export function applyPlan(
|
|
|
254
269
|
const kept = existsSync(configPath) && !flags.stdinJson;
|
|
255
270
|
if (!kept) {
|
|
256
271
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
257
|
-
writeFileSync(
|
|
272
|
+
writeFileSync(
|
|
273
|
+
configPath,
|
|
274
|
+
`${JSON.stringify({ $schema: schemaUrl(), ...(policy as Record<string, unknown>) }, null, 2)}\n`,
|
|
275
|
+
);
|
|
258
276
|
}
|
|
259
277
|
|
|
260
278
|
const launcher = launcherPath();
|