@dzhechkov/harness-core 0.4.3 → 0.4.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.
- package/.dz-manifest.json +3 -3
- package/README.md +12 -2
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +1 -0
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts +6 -2
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +54 -0
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan.d.ts +17 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +5 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +8 -0
- package/dist/loop-render.js.map +1 -1
- package/dist/skills-verify.d.ts +40 -0
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +80 -10
- package/dist/skills-verify.js.map +1 -1
- package/package.json +18 -18
- package/sbom.json +2 -2
- package/src/loop-blobs.generated.ts +1 -0
- package/src/loop-lint.ts +55 -1
- package/src/loop-plan.ts +22 -0
- package/src/loop-render.ts +8 -0
- package/src/skills-verify.ts +108 -10
- package/LICENSE +0 -21
package/src/skills-verify.ts
CHANGED
|
@@ -443,6 +443,16 @@ export interface InitPlugin {
|
|
|
443
443
|
export interface InitFacts {
|
|
444
444
|
/** Registered skill names. `null` means the key was ABSENT (schema drift) — never "none". */
|
|
445
445
|
readonly skills: readonly string[] | null;
|
|
446
|
+
/**
|
|
447
|
+
* Registered slash-command names, e.g. `loop-designer:init` (MEASURED on Claude Code 2.1.233: the
|
|
448
|
+
* `system/init` event carries a `slash_commands` array, and a plugin command registers under
|
|
449
|
+
* `<plugin>:<file basename>` — its frontmatter `name:` does NOT rename it).
|
|
450
|
+
*
|
|
451
|
+
* `null` means the key was ABSENT or not all strings — schema drift, never "no commands". A
|
|
452
|
+
* plugin whose commands silently failed to load and a Claude Code build that stopped emitting the
|
|
453
|
+
* key are indistinguishable from `[]`, so `[]` is never synthesised here.
|
|
454
|
+
*/
|
|
455
|
+
readonly slashCommands: readonly string[] | null;
|
|
446
456
|
readonly plugins: readonly InitPlugin[];
|
|
447
457
|
/** False when the `plugins` key was absent or not an array — unreadable, not empty (QE6 #7). */
|
|
448
458
|
readonly pluginsReadable: boolean;
|
|
@@ -508,6 +518,15 @@ function parseStream(streamText: string): StreamParse {
|
|
|
508
518
|
: null
|
|
509
519
|
: null;
|
|
510
520
|
|
|
521
|
+
// Same treatment as `skills`, for the same reason: an ABSENT key is unreadable schema, and a
|
|
522
|
+
// partially-unparseable array must not be narrowed to the strings it happens to contain.
|
|
523
|
+
const rawCommands = obj.slash_commands;
|
|
524
|
+
const slashCommands = Array.isArray(rawCommands)
|
|
525
|
+
? rawCommands.every((s) => typeof s === 'string')
|
|
526
|
+
? (rawCommands as string[])
|
|
527
|
+
: null
|
|
528
|
+
: null;
|
|
529
|
+
|
|
511
530
|
const rawPlugins = obj.plugins;
|
|
512
531
|
// An ABSENT or non-array `plugins` key is unreadable schema, not proof that nothing loaded —
|
|
513
532
|
// with containers present that difference decides FAIL vs INCONCLUSIVE (QE6 #7).
|
|
@@ -529,6 +548,7 @@ function parseStream(streamText: string): StreamParse {
|
|
|
529
548
|
|
|
530
549
|
found.push({
|
|
531
550
|
skills,
|
|
551
|
+
slashCommands,
|
|
532
552
|
plugins,
|
|
533
553
|
pluginsReadable,
|
|
534
554
|
// An empty or relative cwd testifies to nothing — `resolve("")` silently becomes the caller's
|
|
@@ -549,6 +569,8 @@ export interface RegistrationControls {
|
|
|
549
569
|
readonly cwdMatched: boolean | null;
|
|
550
570
|
/** Was the `skills` key present at all? A missing key is schema drift, not "nothing registered". */
|
|
551
571
|
readonly skillsListPresent: boolean;
|
|
572
|
+
/** Same question for `slash_commands`. `false` when absent/unreadable — never "no commands". */
|
|
573
|
+
readonly commandsListPresent: boolean;
|
|
552
574
|
}
|
|
553
575
|
|
|
554
576
|
export interface RegistrationResult {
|
|
@@ -556,6 +578,10 @@ export interface RegistrationResult {
|
|
|
556
578
|
readonly reason: string;
|
|
557
579
|
readonly expected: readonly string[];
|
|
558
580
|
readonly missing: readonly string[];
|
|
581
|
+
/** The `--expect-commands` set this verdict was measured against (empty when not asked). */
|
|
582
|
+
readonly expectedCommands: readonly string[];
|
|
583
|
+
/** Expected commands absent from the live `slash_commands` listing. */
|
|
584
|
+
readonly missingCommands: readonly string[];
|
|
559
585
|
readonly registeredCount: number | null;
|
|
560
586
|
readonly clientVersion: string | null;
|
|
561
587
|
readonly plugins: readonly InitPlugin[];
|
|
@@ -583,6 +609,12 @@ export interface RegistrationEvidence {
|
|
|
583
609
|
readonly provenance: { readonly checked: boolean; readonly ambiguous: readonly string[] };
|
|
584
610
|
/** Explicit `--expect` list; when absent the expectation is the scan's registrable set. */
|
|
585
611
|
readonly expected?: readonly string[];
|
|
612
|
+
/**
|
|
613
|
+
* Explicit `--expect-commands` list, e.g. `loop-designer:init`. Absent/empty ⇒ commands are not
|
|
614
|
+
* part of this vehicle's expectation (a bare skill has no commands BY DESIGN — ADR-003 D-2 — so
|
|
615
|
+
* demanding them there would be a false FAIL, not a stronger gate).
|
|
616
|
+
*/
|
|
617
|
+
readonly expectedCommands?: readonly string[];
|
|
586
618
|
}
|
|
587
619
|
|
|
588
620
|
export interface SkillsVerifyOptions {
|
|
@@ -603,11 +635,13 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
603
635
|
reason,
|
|
604
636
|
expected: [],
|
|
605
637
|
missing: [],
|
|
638
|
+
expectedCommands: [],
|
|
639
|
+
missingCommands: [],
|
|
606
640
|
registeredCount: null,
|
|
607
641
|
clientVersion: null,
|
|
608
642
|
plugins: [],
|
|
609
643
|
advisories: [],
|
|
610
|
-
controls: { cwdMatched: null, skillsListPresent: false },
|
|
644
|
+
controls: { cwdMatched: null, skillsListPresent: false, commandsListPresent: false },
|
|
611
645
|
layout: [],
|
|
612
646
|
});
|
|
613
647
|
if (!evidence || typeof evidence !== 'object') return bad('no evidence supplied');
|
|
@@ -632,6 +666,10 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
632
666
|
}
|
|
633
667
|
});
|
|
634
668
|
const expected = evidence.expected ?? scan.registrable;
|
|
669
|
+
// Unlike `expected`, this has NO fallback to a discovered set: there is nothing on disk that
|
|
670
|
+
// proves which command names a session should surface, and a guessed expectation is how a gate
|
|
671
|
+
// starts passing for the wrong reason. Not asked ⇒ not checked, and the report says so.
|
|
672
|
+
const expectedCommands = evidence.expectedCommands ?? [];
|
|
635
673
|
const layout = scan.findings;
|
|
636
674
|
|
|
637
675
|
const fail = (
|
|
@@ -640,13 +678,15 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
640
678
|
extra: Partial<RegistrationResult> = {},
|
|
641
679
|
): RegistrationResult => ({
|
|
642
680
|
expected,
|
|
681
|
+
expectedCommands,
|
|
682
|
+
missingCommands: [],
|
|
643
683
|
layout,
|
|
644
684
|
advisories: scan.advisories,
|
|
645
685
|
plugins: [],
|
|
646
686
|
clientVersion: null,
|
|
647
687
|
missing: [],
|
|
648
688
|
registeredCount: null,
|
|
649
|
-
controls: { cwdMatched: null, skillsListPresent: false },
|
|
689
|
+
controls: { cwdMatched: null, skillsListPresent: false, commandsListPresent: false },
|
|
650
690
|
...extra,
|
|
651
691
|
verdict,
|
|
652
692
|
reason,
|
|
@@ -713,7 +753,7 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
713
753
|
return fail('inconclusive', `session read ${facts.cwd}, not ${projectDir} — its listing does not describe this project`, {
|
|
714
754
|
...withFacts,
|
|
715
755
|
registeredCount: facts.skills?.length ?? null,
|
|
716
|
-
controls: { cwdMatched: false, skillsListPresent: facts.skills !== null },
|
|
756
|
+
controls: { cwdMatched: false, skillsListPresent: facts.skills !== null, commandsListPresent: facts.slashCommands !== null },
|
|
717
757
|
});
|
|
718
758
|
}
|
|
719
759
|
|
|
@@ -721,14 +761,29 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
721
761
|
if (facts.skills === null) {
|
|
722
762
|
return fail('inconclusive', 'the init listing has no readable `skills` array (absent or not all strings) — cannot read it', {
|
|
723
763
|
...withFacts,
|
|
724
|
-
controls: { cwdMatched: true, skillsListPresent: false },
|
|
764
|
+
controls: { cwdMatched: true, skillsListPresent: false, commandsListPresent: facts.slashCommands !== null },
|
|
725
765
|
});
|
|
726
766
|
}
|
|
727
767
|
|
|
728
|
-
|
|
768
|
+
// 5b. …and so must the COMMAND listing, whenever commands were expected. An absent
|
|
769
|
+
// `slash_commands` key is schema drift, exactly like an absent `skills` key: treating it as
|
|
770
|
+
// `[]` would report "your five commands did not register" on a Claude Code build that simply
|
|
771
|
+
// stopped emitting the key, and treating it as "fine" would pass a plugin whose commands
|
|
772
|
+
// really are missing. Neither is observable ⇒ inconclusive.
|
|
773
|
+
if (expectedCommands.length > 0 && facts.slashCommands === null) {
|
|
774
|
+
return fail(
|
|
775
|
+
'inconclusive',
|
|
776
|
+
'the init listing has no readable `slash_commands` array (absent or not all strings) — command registration cannot be observed',
|
|
777
|
+
{ ...withFacts, controls: { cwdMatched: true, skillsListPresent: true, commandsListPresent: false } },
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const controls: RegistrationControls = { cwdMatched: true, skillsListPresent: true, commandsListPresent: facts.slashCommands !== null };
|
|
729
782
|
const registered = new Set(facts.skills);
|
|
730
783
|
const missing = expected.filter((name) => !registered.has(name));
|
|
731
|
-
const
|
|
784
|
+
const registeredCommands = new Set(facts.slashCommands ?? []);
|
|
785
|
+
const missingCommands = expectedCommands.filter((name) => !registeredCommands.has(name));
|
|
786
|
+
const common = { ...withFacts, expectedCommands, missingCommands, registeredCount: facts.skills.length, controls };
|
|
732
787
|
|
|
733
788
|
// 6. Load-blocking layout problems fail on their own — one healthy skill must not mask them.
|
|
734
789
|
if (layout.length > 0) {
|
|
@@ -832,7 +887,12 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
832
887
|
}));
|
|
833
888
|
const allAdvisories = [...scan.advisories, ...containerAdvisories];
|
|
834
889
|
|
|
835
|
-
if (missing.length > 0) {
|
|
890
|
+
if (missing.length > 0 || missingCommands.length > 0) {
|
|
891
|
+
const parts: string[] = [];
|
|
892
|
+
if (missing.length > 0) parts.push(`${missing.length} of ${expected.length} expected skill(s) did NOT register`);
|
|
893
|
+
if (missingCommands.length > 0) {
|
|
894
|
+
parts.push(`${missingCommands.length} of ${expectedCommands.length} expected command(s) did NOT register (${missingCommands.join(', ')})`);
|
|
895
|
+
}
|
|
836
896
|
return {
|
|
837
897
|
expected,
|
|
838
898
|
layout,
|
|
@@ -840,7 +900,7 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
840
900
|
...common,
|
|
841
901
|
missing,
|
|
842
902
|
verdict: 'fail',
|
|
843
|
-
reason:
|
|
903
|
+
reason: parts.join('; '),
|
|
844
904
|
};
|
|
845
905
|
}
|
|
846
906
|
|
|
@@ -852,9 +912,11 @@ export function verifyRegistration(evidence: RegistrationEvidence, options: Skil
|
|
|
852
912
|
missing: [],
|
|
853
913
|
verdict: 'pass',
|
|
854
914
|
reason:
|
|
855
|
-
expected.length === 0
|
|
915
|
+
expected.length === 0 && expectedCommands.length === 0
|
|
856
916
|
? 'nothing was expected to register; the session listing was read successfully'
|
|
857
|
-
: `all ${expected.length} expected skill(s)
|
|
917
|
+
: `all ${expected.length} expected skill(s)` +
|
|
918
|
+
(expectedCommands.length > 0 ? ` and all ${expectedCommands.length} expected command(s)` : '') +
|
|
919
|
+
' are registered',
|
|
858
920
|
};
|
|
859
921
|
}
|
|
860
922
|
|
|
@@ -892,6 +954,13 @@ export function renderRegistrationReport(result: RegistrationResult, scan?: Stat
|
|
|
892
954
|
out.push(' MISSING (expected but not registered):');
|
|
893
955
|
for (const name of result.missing) out.push(` - ${name}`);
|
|
894
956
|
}
|
|
957
|
+
if (result.expectedCommands.length) {
|
|
958
|
+
out.push(
|
|
959
|
+
` commands: ${result.expectedCommands.length} expected` +
|
|
960
|
+
(result.controls.commandsListPresent ? '' : ' · slash_commands listing UNREADABLE'),
|
|
961
|
+
);
|
|
962
|
+
for (const name of result.missingCommands) out.push(` - MISSING ${name}`);
|
|
963
|
+
}
|
|
895
964
|
if (result.layout.length) {
|
|
896
965
|
out.push(' layout problems (these can never register):');
|
|
897
966
|
for (const f of result.layout) out.push(` [${f.kind}] ${f.detail}`);
|
|
@@ -906,6 +975,35 @@ export function renderRegistrationReport(result: RegistrationResult, scan?: Stat
|
|
|
906
975
|
return out.join('\n');
|
|
907
976
|
}
|
|
908
977
|
|
|
978
|
+
/**
|
|
979
|
+
* The names a plugin's OWN manifest says its commands and skills should register under, so a
|
|
980
|
+
* `--plugin-dir` gate run can default its expectation to the manifest instead of to a hand-typed
|
|
981
|
+
* list that drifts from it.
|
|
982
|
+
*
|
|
983
|
+
* The naming rules are MEASURED, not assumed (Claude Code 2.1.233, fixture probe 2026-08-17):
|
|
984
|
+
* · a command registers as `<plugin>:<file basename without .md>` — its frontmatter `name:` does
|
|
985
|
+
* NOT rename it (a fixture declaring `name: renamed-second` registered as `probeplug:second`);
|
|
986
|
+
* · a skill registers as `<plugin>:<directory basename>` — likewise not its frontmatter name.
|
|
987
|
+
*
|
|
988
|
+
* Returns `null` when the manifest is missing/unreadable/nameless: an unreadable manifest must not
|
|
989
|
+
* silently become an EMPTY expectation, which is the shape that passes without checking anything.
|
|
990
|
+
*/
|
|
991
|
+
export function declaredPluginSurface(pluginDir: string): { name: string; skills: string[]; commands: string[] } | null {
|
|
992
|
+
let obj: Record<string, unknown>;
|
|
993
|
+
try {
|
|
994
|
+
obj = JSON.parse(readFileSync(join(pluginDir, '.claude-plugin', 'plugin.json'), 'utf8')) as Record<string, unknown>;
|
|
995
|
+
} catch {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
const name = typeof obj.name === 'string' && obj.name ? obj.name : null;
|
|
999
|
+
if (name === null) return null;
|
|
1000
|
+
const list = (value: unknown): string[] =>
|
|
1001
|
+
Array.isArray(value) ? value.filter((x): x is string => typeof x === 'string') : [];
|
|
1002
|
+
const skills = list(obj.skills).map((rel) => `${name}:${basename(rel.replace(/\/+$/, ''))}`);
|
|
1003
|
+
const commands = list(obj.commands).map((rel) => `${name}:${basename(rel).replace(/\.md$/i, '')}`);
|
|
1004
|
+
return { name, skills, commands };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
909
1007
|
// ── The publish-time guard fact ─────────────────────────────────────
|
|
910
1008
|
|
|
911
1009
|
/**
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 dzhechko
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|