@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.
- package/bin/generate-schema.ts +67 -0
- package/bin/tlc-build.mjs +26 -1
- package/bin/tlc-cli.ts +43 -11
- 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 +103 -102
- package/dist/tool-after.mjs +83 -83
- package/dist/tool-before.mjs +85 -85
- package/dist/tool-failure.mjs +83 -83
- package/dist/uninstall-runtime.mjs +4 -4
- package/docs/concepts.md +4 -1
- package/docs/log.md +8 -0
- 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 +7 -1
- package/src/core/floor/floor.policy-surface.ts +1 -1
- package/src/core/handoff/handoff.service.ts +30 -0
- package/src/core/handoff/handoff.types.ts +0 -2
- package/src/core/lesson/lesson.types.ts +5 -0
- package/src/core/policy/policy.loader.ts +5 -1
- package/src/core/policy/policy.shadow.ts +68 -2
- package/src/core/turn/turn.failure-signals.ts +3 -1
- package/src/entrypoints/stop.ts +16 -14
- package/src/entrypoints/subagent-stop.ts +8 -6
- package/tools/doctor.ts +48 -0
- package/tools/init-project.ts +19 -1
|
@@ -25,6 +25,36 @@ export function readHandoff(root: string, provider: string): ResolvedHandoff {
|
|
|
25
25
|
return { ...file.shared, ...slice };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const CLEARED_SLICE: Partial<HandoffProviderSlice> = {
|
|
29
|
+
blockers: undefined,
|
|
30
|
+
previous_gaps: undefined,
|
|
31
|
+
last_failure_category: undefined,
|
|
32
|
+
next_action: undefined,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function hasStuckSignal(slice: HandoffProviderSlice): boolean {
|
|
36
|
+
return (Object.keys(CLEARED_SLICE) as (keyof HandoffProviderSlice)[]).some(
|
|
37
|
+
(field) => slice[field] !== undefined,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* why: an operator's escape hatch. These are the exact fields `subagent-stop.ts` reads as "unfinished
|
|
43
|
+
* work" — clearing anything wider would erase state no gate is stuck on.
|
|
44
|
+
*/
|
|
45
|
+
export async function clearStuckSignals(root: string): Promise<string[]> {
|
|
46
|
+
const file = readHandoffFile(root);
|
|
47
|
+
const cleared: string[] = [];
|
|
48
|
+
for (const [provider, slice] of Object.entries(file.by_provider)) {
|
|
49
|
+
if (!hasStuckSignal(slice)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
await patchHandoff(root, provider, { slice: CLEARED_SLICE });
|
|
53
|
+
cleared.push(provider);
|
|
54
|
+
}
|
|
55
|
+
return cleared;
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
export function readForeignSlices(root: string, provider: string): ForeignSlice[] {
|
|
29
59
|
const file = readHandoffFile(root);
|
|
30
60
|
const foreign: ForeignSlice[] = [];
|
|
@@ -18,8 +18,6 @@ export type HandoffProviderSlice = {
|
|
|
18
18
|
session_key?: string;
|
|
19
19
|
session_narrative?: string;
|
|
20
20
|
completed?: string[];
|
|
21
|
-
in_progress?: string[];
|
|
22
|
-
pending?: string[];
|
|
23
21
|
last_ship_claim_at?: string;
|
|
24
22
|
last_ship_claim_snippet?: string;
|
|
25
23
|
last_ship_claim_kind?: "structured";
|
|
@@ -89,4 +89,9 @@ export type PendingLessonCredit = {
|
|
|
89
89
|
gate: string;
|
|
90
90
|
ids: string[];
|
|
91
91
|
at: string;
|
|
92
|
+
// why: the handoff is per-project, not per-session, so without this a lesson injected for session A's
|
|
93
|
+
// failing gate gets credited by whichever session B next happens to pass the same gate — helped/neutral
|
|
94
|
+
// it never earned. Optional so an on-disk record from before this field existed still credits once,
|
|
95
|
+
// the same legacy fallback AD-038 used for pre-existing lesson records.
|
|
96
|
+
sessionKey?: string;
|
|
92
97
|
};
|
|
@@ -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
|
*
|
|
@@ -104,10 +104,12 @@ export function formatCarriedGaps(gaps: readonly GateGap[], limit = CARRIED_GAP_
|
|
|
104
104
|
return lines.join("\n");
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// why: `current` goes first — it is this turn's fresh result, `prior` is carried history. Iterating
|
|
108
|
+
// prior-first let a saturated 12-item carry silently drop every fresh gap this turn found.
|
|
107
109
|
export function mergeGaps(prior: GateGap[] | undefined, current: GateGap[], max = 12): GateGap[] {
|
|
108
110
|
const seen = new Set<string>();
|
|
109
111
|
const out: GateGap[] = [];
|
|
110
|
-
for (const gap of [...(prior ?? [])
|
|
112
|
+
for (const gap of [...current, ...(prior ?? [])]) {
|
|
111
113
|
const key = `${gap.gate}|${gap.summary}`;
|
|
112
114
|
if (seen.has(key)) {
|
|
113
115
|
continue;
|
package/src/entrypoints/stop.ts
CHANGED
|
@@ -151,20 +151,26 @@ export function stopLockWaitMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
|
151
151
|
* invariant: consumed exactly once. The pending credit is cleared whether or not any lesson matched, so a single
|
|
152
152
|
* injection cannot be graded twice by two later runs of the same gate.
|
|
153
153
|
*
|
|
154
|
-
* hazard: the gate name is compared
|
|
155
|
-
*
|
|
154
|
+
* hazard: the gate name is compared, and the session that earned the credit is compared — without either,
|
|
155
|
+
* lessons injected for `lint` would be credited by whichever gate ran next, or by whichever session next
|
|
156
|
+
* happened to run this gate on the same per-project handoff `blockers` lives on, crediting help a session
|
|
157
|
+
* that never saw the lesson did not give ([/decisions/ad-107.md](/decisions/ad-107.md) is the same family).
|
|
156
158
|
*/
|
|
157
159
|
async function creditPendingLessons(args: {
|
|
158
160
|
root: string;
|
|
159
161
|
provider: string;
|
|
160
162
|
pending: PendingLessonCredit | undefined;
|
|
161
163
|
gate: string;
|
|
164
|
+
sessionKey: string;
|
|
162
165
|
passed: boolean;
|
|
163
166
|
}): Promise<void> {
|
|
164
167
|
const { pending } = args;
|
|
165
168
|
if (!pending || pending.gate !== args.gate || pending.ids.length === 0) {
|
|
166
169
|
return;
|
|
167
170
|
}
|
|
171
|
+
if (pending.sessionKey !== undefined && pending.sessionKey !== args.sessionKey) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
168
174
|
await coreFacade.lesson.creditLessons(args.root, pending.ids, args.passed ? "helped" : "neutral");
|
|
169
175
|
await coreFacade.handoff.patchHandoff(args.root, args.provider, {
|
|
170
176
|
slice: { pending_lesson_credit: undefined },
|
|
@@ -231,6 +237,7 @@ async function runLockedGate(args: {
|
|
|
231
237
|
provider: args.provider,
|
|
232
238
|
pending: args.pendingCredit,
|
|
233
239
|
gate: args.gate,
|
|
240
|
+
sessionKey: args.sessionKey,
|
|
234
241
|
passed: artifact.passed,
|
|
235
242
|
});
|
|
236
243
|
return { kind: "ran", artifact, reused: cached !== null };
|
|
@@ -334,6 +341,7 @@ async function failGate(args: {
|
|
|
334
341
|
gate: args.gate,
|
|
335
342
|
ids: selected.usedIds,
|
|
336
343
|
at: new Date().toISOString(),
|
|
344
|
+
sessionKey: args.sessionKey,
|
|
337
345
|
},
|
|
338
346
|
},
|
|
339
347
|
});
|
|
@@ -470,16 +478,14 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
470
478
|
const changedFiles = await listChangedRepoFiles(root, turnBase);
|
|
471
479
|
const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
|
|
472
480
|
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`).
|
|
481
|
+
// why: the comment rail scopes by the syntax catalog (40+ languages), not `codeTargets`'s nine-extension
|
|
482
|
+
// regex — a Terraform/SQL-heavy project changed no file that regex would keep, so the rail never ran.
|
|
483
|
+
// Scoped to `codePaths` here, not inside `filterCommentTargets`, to avoid an import cycle through `policy.loader.ts`.
|
|
478
484
|
const commentScope = changedFiles.filter((file) =>
|
|
479
485
|
coreFacade.policy.isUnderCodePaths(file, policy.codePaths),
|
|
480
486
|
);
|
|
481
|
-
// why: `unknown_extensions` reports over `commentScope
|
|
482
|
-
//
|
|
487
|
+
// why: `unknown_extensions` reports over `commentScope` (broader), not this — a language gap is still named
|
|
488
|
+
// even after this excludes it from the scan.
|
|
483
489
|
const commentTargets = coreFacade.commentPolicy.filterCommentTargets(commentScope);
|
|
484
490
|
// why: read from the snapshot taken before this handler patches anything, so a credit written by the previous
|
|
485
491
|
// stop is still visible when the gate it belongs to runs below.
|
|
@@ -506,11 +512,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
506
512
|
}
|
|
507
513
|
|
|
508
514
|
const intel = policy.intelligence;
|
|
509
|
-
const unfinishedWork =
|
|
510
|
-
Boolean(handoff.blockers) ||
|
|
511
|
-
Boolean(handoff.previous_gaps?.length) ||
|
|
512
|
-
Boolean(handoff.pending?.length) ||
|
|
513
|
-
Boolean(handoff.in_progress?.length);
|
|
515
|
+
const unfinishedWork = Boolean(handoff.blockers) || Boolean(handoff.previous_gaps?.length);
|
|
514
516
|
if (
|
|
515
517
|
intel.idleTurnGate &&
|
|
516
518
|
coreFacade.turn.endedWithoutActing({
|
|
@@ -4,8 +4,8 @@ import type { Handler, HandlerContext } from "./run.ts";
|
|
|
4
4
|
import { main } from "./run.ts";
|
|
5
5
|
import { observeForRules } from "./support.ts";
|
|
6
6
|
|
|
7
|
-
// why: no legacy predecessor covers subagent.stop verification — this reuses the
|
|
8
|
-
// signal
|
|
7
|
+
// why: no legacy predecessor covers subagent.stop verification — this reuses the blockers/previous_gaps
|
|
8
|
+
// signal already carried on the handoff slice.
|
|
9
9
|
export const subagentStopHandler: Handler = async (
|
|
10
10
|
event: HarnessEvent,
|
|
11
11
|
ctx: HandlerContext,
|
|
@@ -15,11 +15,13 @@ export const subagentStopHandler: Handler = async (
|
|
|
15
15
|
await observeForRules(event, ctx);
|
|
16
16
|
|
|
17
17
|
const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider);
|
|
18
|
+
// why: `blockers` is per-project, and every Task subagent shares the parent's session_id
|
|
19
|
+
// (anthropics/claude-code#7881), so this cannot tell its own subagent apart from another. A "budget"
|
|
20
|
+
// category means the session's turn budget ran out, not that the tree is broken — unlike a gate
|
|
21
|
+
// failure, it is not valid evidence here ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
22
|
+
const isBudgetBlocker = handoff.last_failure_category === "budget";
|
|
18
23
|
const unfinishedWork =
|
|
19
|
-
Boolean(handoff.blockers) ||
|
|
20
|
-
Boolean(handoff.previous_gaps?.length) ||
|
|
21
|
-
Boolean(handoff.pending?.length) ||
|
|
22
|
-
Boolean(handoff.in_progress?.length);
|
|
24
|
+
(Boolean(handoff.blockers) && !isBudgetBlocker) || Boolean(handoff.previous_gaps?.length);
|
|
23
25
|
|
|
24
26
|
if (!unfinishedWork) {
|
|
25
27
|
return { kind: "abstain" };
|
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();
|