@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.
@@ -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
- const controls: RegistrationControls = { cwdMatched: true, skillsListPresent: true };
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 common = { ...withFacts, registeredCount: facts.skills.length, controls };
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: `${missing.length} of ${expected.length} expected skill(s) did NOT register`,
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) are registered`,
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.