@tech-leads-club/harness-toolkit 0.5.0 → 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 +78 -78
- 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 +78 -78
- package/dist/prompt-submit.mjs +78 -78
- package/dist/refresh-model-prices.mjs +82 -82
- package/dist/response-after.mjs +78 -78
- package/dist/run.mjs +78 -78
- package/dist/session-end.mjs +83 -83
- package/dist/session-start.mjs +85 -85
- package/dist/shim.mjs +81 -81
- package/dist/stop.mjs +84 -84
- package/dist/subagent-start.mjs +78 -78
- package/dist/subagent-stop.mjs +79 -79
- package/dist/support.mjs +82 -82
- package/dist/tlc-cli.mjs +100 -100
- package/dist/tool-after.mjs +77 -77
- package/dist/tool-before.mjs +78 -78
- package/dist/tool-failure.mjs +78 -78
- 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 +9 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +7 -2
- package/src/core/comment-policy/comment-syntax.store.ts +9 -0
- package/src/core/core.facade.ts +7 -1
- package/src/core/policy/policy.loader.ts +5 -1
- package/src/core/policy/policy.shadow.ts +68 -2
- package/src/entrypoints/stop.ts +16 -5
- package/tools/doctor.ts +48 -0
- package/tools/init-project.ts +19 -1
|
@@ -11,7 +11,11 @@ function readJsonFile<T>(path: string): T | null {
|
|
|
11
11
|
return null;
|
|
12
12
|
}
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
15
|
+
// why: a JSON Schema meta-key, not a Policy field — deepMerge's `...patch` spread would otherwise
|
|
16
|
+
// carry it straight into the merged runtime Policy object.
|
|
17
|
+
delete parsed.$schema;
|
|
18
|
+
return parsed as T;
|
|
15
19
|
} catch {
|
|
16
20
|
return null;
|
|
17
21
|
}
|
|
@@ -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,6 +470,15 @@ 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 `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`.
|
|
476
|
+
const commentScope = changedFiles.filter((file) =>
|
|
477
|
+
coreFacade.policy.isUnderCodePaths(file, policy.codePaths),
|
|
478
|
+
);
|
|
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.
|
|
481
|
+
const commentTargets = coreFacade.commentPolicy.filterCommentTargets(commentScope);
|
|
473
482
|
// why: read from the snapshot taken before this handler patches anything, so a credit written by the previous
|
|
474
483
|
// stop is still visible when the gate it belongs to runs below.
|
|
475
484
|
const pendingCredit = handoff.pending_lesson_credit;
|
|
@@ -619,12 +628,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
619
628
|
// rate cannot — was the rule ever needed — by running the checker while the prose is absent. A measurement that
|
|
620
629
|
// can change what it measures is not a measurement ([/decisions/ad-027.md](/decisions/ad-027.md)).
|
|
621
630
|
if (
|
|
622
|
-
|
|
631
|
+
commentTargets.length > 0 &&
|
|
623
632
|
coreFacade.observe.shouldObserve(policy.observe, "comments", policy.comments.enabled)
|
|
624
633
|
) {
|
|
625
634
|
const hits = await coreFacade.commentPolicy.scanAddedComments(
|
|
626
635
|
root,
|
|
627
|
-
|
|
636
|
+
commentTargets,
|
|
628
637
|
policy.comments.mode,
|
|
629
638
|
turnBase,
|
|
630
639
|
);
|
|
@@ -641,15 +650,17 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
641
650
|
rule: "comments",
|
|
642
651
|
// why: a language the catalog does not carry produces no findings, which reads identically to "the
|
|
643
652
|
// property held". Naming the extensions is the difference between a clean reading and a blind spot.
|
|
644
|
-
|
|
653
|
+
// Scoped to `commentScope`, not `commentTargets` — the target set is already syntax-known by
|
|
654
|
+
// construction, so it could never report the gap it exists to name.
|
|
655
|
+
unknown_extensions: coreFacade.commentPolicy.unknownExtensions(commentScope).join(",") || "none",
|
|
645
656
|
},
|
|
646
657
|
});
|
|
647
658
|
}
|
|
648
659
|
|
|
649
|
-
if (policy.comments.enabled && policy.comments.onViolation === "followup" &&
|
|
660
|
+
if (policy.comments.enabled && policy.comments.onViolation === "followup" && commentTargets.length > 0) {
|
|
650
661
|
const hits = await coreFacade.commentPolicy.scanAddedComments(
|
|
651
662
|
root,
|
|
652
|
-
|
|
663
|
+
commentTargets,
|
|
653
664
|
policy.comments.mode,
|
|
654
665
|
turnBase,
|
|
655
666
|
);
|
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();
|