pan-wizard 3.27.0 → 3.28.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.
@@ -449,7 +449,7 @@ function convertClaudeCommandToCodexSkill(content, skillName) {
449
449
  * tree is read by every runtime, so invocation, delegation, and interaction
450
450
  * guidance are phrased in terms of "your runtime's native mechanism".
451
451
  */
452
- function getUnifiedSkillAdapterHeader(skillName) {
452
+ function getUnifiedSkillAdapterHeader(skillName, note) {
453
453
  return `<pan_skill_adapter>
454
454
  PAN unified skill (Agent Skills standard, shared .agents/skills/ tree):
455
455
  - This skill is invoked through your runtime's skill mechanism — slash command (\`/${skillName}\`), mention (\`$${skillName}\`), or skill picker.
@@ -465,7 +465,7 @@ User interaction (runtimes without a native question tool):
465
465
  - Ask one question at a time; show numbered options; mark the recommended option with **(recommended)**.
466
466
  - Accept numbers ("1"), labels, or free-text descriptions as valid answers.
467
467
  - Native interaction tools (e.g. AskUserQuestion blocks), where supported by your runtime, take precedence over this fallback.
468
- </pan_skill_adapter>`;
468
+ ${note ? `\n${note}\n` : ''}</pan_skill_adapter>`;
469
469
  }
470
470
 
471
471
  /** Claude command → runtime-neutral SKILL.md (ADR-0028 Phase 1) */
@@ -476,7 +476,7 @@ User interaction (runtimes without a native question tool):
476
476
  */
477
477
  const SKILL_COMPATIBILITY = 'Requires Node.js (skills invoke the bundled pan-tools CLI) and a project with a .planning/ directory, created by /pan-new-project or /pan-map-codebase.';
478
478
 
479
- function convertClaudeCommandToUnifiedSkill(content, skillName) {
479
+ function convertClaudeCommandToUnifiedSkill(content, skillName, opts = {}) {
480
480
  // Normalize command mentions to the readable /pan-<name> form; the adapter
481
481
  // header tells each runtime to map that onto its own invocation syntax.
482
482
  let converted = convertSlashCommandsToCopilotSkillMentions(content);
@@ -489,7 +489,10 @@ function convertClaudeCommandToUnifiedSkill(content, skillName) {
489
489
  }
490
490
  description = toSingleLine(description);
491
491
  const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
492
- const adapter = getUnifiedSkillAdapterHeader(skillName);
492
+ // `opts.adapterNote` (Agent Plugins bundle, ADR-0045) appends a consumer-
493
+ // specific paragraph inside the adapter block; absent, the header is
494
+ // byte-identical to what every install has shipped since ADR-0028.
495
+ const adapter = getUnifiedSkillAdapterHeader(skillName, opts.adapterNote);
493
496
  // `compatibility` is the spec's optional field for stating environment
494
497
  // requirements, and PAN has real ones: the skill bodies shell out to
495
498
  // `pan-tools` (Node) and every workflow reads/writes `.planning/`. Declaring
@@ -500,6 +503,202 @@ function convertClaudeCommandToUnifiedSkill(content, skillName) {
500
503
  return `---\nname: ${yamlQuote(skillName)}\ndescription: ${yamlQuote(description)}\ncompatibility: ${yamlQuote(SKILL_COMPATIBILITY)}\nmetadata:\n short-description: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
501
504
  }
502
505
 
506
+ // ─── Unified-skill content rewrites (extracted from bin/install.js, 2026-09) ──
507
+ //
508
+ // The installer's --unified-skills path and the Agent Plugins bundle builder
509
+ // (ADR-0045) need the SAME rewrite of a Claude-flavoured PAN document — the
510
+ // rule from ADR-0028 is one converter, several call sites, never a second copy.
511
+ // Each function below reproduces its installer sequence exactly, in order; the
512
+ // installer now calls these, and `tests/unified-skills-install.test.cjs` pins
513
+ // the output it has always produced.
514
+ //
515
+ // Options (all strings):
516
+ // corePrefix where `pan-wizard-core/` lives for the consumer, with a
517
+ // trailing slash — `./.agents/` (local unified install),
518
+ // `<abs>/.agents/` (global), `{{PAN_PLUGIN_ROOT}}/` (bundle)
519
+ // pathPrefix replacement for a residual `~/.claude/` reference
520
+ // projectDirPrefix replacement for a residual `./.claude/` reference —
521
+ // `./<runtime dir>/` on an install, the root token in a bundle
522
+ // attribution processAttribution() setting: null remove, undefined keep,
523
+ // string replace
524
+
525
+ /**
526
+ * Rewrite a PAN command document's paths for a unified-skills consumer. Does
527
+ * NOT convert it to SKILL.md form — call convertClaudeCommandToUnifiedSkill()
528
+ * on the result, exactly as the installer does.
529
+ */
530
+ function rewriteUnifiedSkillCommandContent(content, { corePrefix, pathPrefix, projectDirPrefix, attribution }) {
531
+ // Core + agent-definition references → the shared copies (specific, before
532
+ // the generic rewrites); everything else .claude-scoped → the consumer. Agent
533
+ // refs point at the canonical reference copies shipped with the shared core —
534
+ // a runtime's own agents dir may carry a different format (Codex TOML,
535
+ // Copilot .agent.md).
536
+ content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
537
+ content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
538
+ content = content.replace(/~\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
539
+ content = content.replace(/\.\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
540
+ content = content.replace(/~\/\.claude\//g, pathPrefix);
541
+ content = content.replace(/\.\/\.claude\//g, projectDirPrefix);
542
+ // Not every runtime puts a `pan-tools` bin on PATH — invoke via node.
543
+ const panToolsPath = `${corePrefix}pan-wizard-core/bin/pan-tools.cjs`;
544
+ content = content.replace(/\bpan-tools\b(?=\s+[a-z])/g, `node ${panToolsPath}`);
545
+ return processAttribution(content, attribution);
546
+ }
547
+
548
+ /**
549
+ * Rewrite a markdown file inside a shared copy of pan-wizard-core (workflows,
550
+ * templates, references, learnings) for a unified-skills consumer.
551
+ */
552
+ function rewriteSharedCoreMarkdown(content, { corePrefix, pathPrefix, projectDirPrefix, attribution }) {
553
+ content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
554
+ content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
555
+ // Agent-definition refs → the canonical reference copies in the shared core
556
+ // (runtime agents dirs carry runtime-specific formats).
557
+ content = content.replace(/~\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
558
+ content = content.replace(/\.\/\.claude\/agents\//g, `${corePrefix}pan-wizard-core/agents/`);
559
+ content = content.replace(/~\/\.claude\//g, pathPrefix);
560
+ content = content.replace(/\.\/\.claude\//g, projectDirPrefix);
561
+ content = processAttribution(content, attribution);
562
+ return convertSlashCommandsToCopilotSkillMentions(content);
563
+ }
564
+
565
+ /**
566
+ * Rewrite an agent definition for the canonical reference copy that ships
567
+ * under `<shared core>/agents/` — reading material for agents, not a runtime
568
+ * registration (ADR-0028 agent-ref canonicalization).
569
+ */
570
+ function rewriteAgentReferenceCopy(content, corePrefix) {
571
+ content = content.replace(/~\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
572
+ content = content.replace(/\.\/\.claude\/pan-wizard-core\//g, `${corePrefix}pan-wizard-core/`);
573
+ return convertSlashCommandsToCopilotSkillMentions(content);
574
+ }
575
+
576
+ /**
577
+ * Drop internal-scoped topics from a parsed learnings/index.json and recompute
578
+ * its totals exactly (each topic carries its own size fields). Pure: returns
579
+ * the rewritten object, or null when there was nothing internal to drop or
580
+ * the input is not an index. The installer and the bundle builders share it so
581
+ * a shipped index never lists files the package deliberately withholds.
582
+ */
583
+ function stripInternalLearningsTopics(parsed) {
584
+ if (!parsed || !Array.isArray(parsed.topics)) return null;
585
+ const kept = parsed.topics.filter(t => t && t.scope !== 'internal');
586
+ if (kept.length === parsed.topics.length) return null;
587
+ const out = { ...parsed, topics: kept };
588
+ if (parsed.totals && typeof parsed.totals === 'object') {
589
+ out.totals = {
590
+ ...parsed.totals,
591
+ topics: kept.length,
592
+ patterns: kept.reduce((n, t) => n + (Array.isArray(t.patterns) ? t.patterns.length : 0), 0),
593
+ size_bytes: kept.reduce((n, t) => n + (t.size_bytes || 0), 0),
594
+ size_tokens_est: kept.reduce((n, t) => n + (t.size_tokens_est || 0), 0),
595
+ };
596
+ }
597
+ return out;
598
+ }
599
+
600
+ // ─── Agent Plugins bundle (ADR-0045, 2026-09) ────────────────────────────────
601
+ //
602
+ // A vendor-neutral package: `plugin.json` + `skills/` + `mcp.json` at the root,
603
+ // loaded natively by Copilot CLI / VS Code, Codex, Cursor and Kiro. Every
604
+ // constant here is quoted from the pinned schemas in tests/fixtures/agent-plugins/
605
+ // (read from agent-plugins.org on 2026-09-10) — the manifest schema is CLOSED,
606
+ // so an unlisted key is a fatal plugin rejection, not a warning.
607
+
608
+ const AGENT_PLUGINS_VERSION = '1.0.0';
609
+ const AGENT_PLUGIN_MANIFEST_SCHEMA = `https://agent-plugins.org/schemas/${AGENT_PLUGINS_VERSION}/plugin.schema.json`;
610
+ const AGENT_PLUGIN_MCP_SCHEMA = `https://agent-plugins.org/schemas/${AGENT_PLUGINS_VERSION}/mcp.schema.json`;
611
+
612
+ // Path token inside bundled skill and core markdown. Agent Plugins expands
613
+ // `${PLUGIN_ROOT}` ONLY in mcp.json fields, and Claude's `${CLAUDE_PLUGIN_ROOT}`
614
+ // substitution in content is Claude-specific — so bundle content carries PAN's
615
+ // own token, in the style of `{{PAN_ARGS}}`, and the adapter note defines it.
616
+ const AGENT_PLUGIN_ROOT_TOKEN = '{{PAN_PLUGIN_ROOT}}';
617
+ // A few PAN documents refer to the RUNTIME's own configuration directories —
618
+ // its `settings.json`, its `commands/`, PAN's update-check cache, the local
619
+ // patches dir. The installer maps those to the installing runtime (`~/.codex/`,
620
+ // `./.gemini/`, …); a bundle is built for no runtime in particular, so it
621
+ // carries two more tokens the adapter note defines: the user-level and the
622
+ // project-level runtime directory.
623
+ const AGENT_PLUGIN_RUNTIME_HOME_TOKEN = '{{PAN_RUNTIME_HOME}}';
624
+ const AGENT_PLUGIN_RUNTIME_DIR_TOKEN = '{{PAN_RUNTIME_DIR}}';
625
+
626
+ /** Agent Plugins `plugin.json` — closed schema; mirrors package.json like the Claude manifest. */
627
+ function buildAgentPluginManifest(pkg) {
628
+ return {
629
+ $schema: AGENT_PLUGIN_MANIFEST_SCHEMA,
630
+ name: 'pan-wizard',
631
+ version: pkg.version,
632
+ description: pkg.description || 'Structured, phase-based planning and execution for AI coding agents.',
633
+ author: { name: 'PAN Wizard contributors', url: 'https://github.com/oharms/PanWizard' },
634
+ homepage: 'https://github.com/oharms/PanWizard',
635
+ repository: 'https://github.com/oharms/PanWizard',
636
+ license: pkg.license || 'MIT',
637
+ keywords: ['planning', 'workflow', 'agents', 'phases'],
638
+ };
639
+ }
640
+
641
+ /**
642
+ * Agent Plugins `mcp.json` declaring the bundled bridge. `command` must be a
643
+ * single executable token with NO placeholder (spec), so the server is launched
644
+ * as `node` with the `${PLUGIN_ROOT}`-anchored script in `args`, where expansion
645
+ * is defined. No `env`: a plugin serves whatever project the session is in, and
646
+ * `env` may not name PLUGIN_ROOT/PLUGIN_DATA anyway. The default working
647
+ * directory for a stdio server is the PLUGIN ROOT (spec) — which is why the
648
+ * bridge must take the project root per call (ADR-0045 D6, plan item 4g).
649
+ */
650
+ function buildAgentPluginMcpConfig() {
651
+ return {
652
+ $schema: AGENT_PLUGIN_MCP_SCHEMA,
653
+ mcpServers: {
654
+ pan: {
655
+ type: 'stdio',
656
+ command: 'node',
657
+ args: ['${PLUGIN_ROOT}/pan-wizard-core/mcp/server.cjs'],
658
+ },
659
+ },
660
+ };
661
+ }
662
+
663
+ /**
664
+ * Copilot vendor-directory hooks for an Agent Plugins bundle —
665
+ * `com.github.copilot/hooks/hooks.json` (ADR-0045 D5).
666
+ *
667
+ * Shape from code.visualstudio.com/docs/agent-customization/agent-plugins (read
668
+ * 2026-09-10): the FLAT plugin format — PascalCase lifecycle events, each an
669
+ * array of `{ type: 'command', command }` — with `${CLAUDE_PLUGIN_ROOT}` expanded
670
+ * to the plugin root at runtime and also exported to the hook process. That is
671
+ * VS-Code-verified. Copilot CLI's own hooks how-to describes WORKSPACE hooks
672
+ * (camelCase events, `bash`/`powershell` keys) and does not cover plugins, so a
673
+ * live `copilot plugin install` is the gate before relying on this shape there.
674
+ * The observers-vs-monitor split mirrors the Codex builder: no async flag exists
675
+ * in this format, so nothing is marked.
676
+ *
677
+ * @param {{updateCheckCommand?:string, contextMonitorCommand?:string, costLoggerCommand?:string, traceLoggerCommand?:string}} commands
678
+ */
679
+ function buildCopilotPluginHooksConfig(commands) {
680
+ const { updateCheckCommand, contextMonitorCommand, costLoggerCommand, traceLoggerCommand } = commands || {};
681
+ const hooks = {};
682
+ if (updateCheckCommand) hooks.SessionStart = [{ type: 'command', command: updateCheckCommand }];
683
+ if (contextMonitorCommand) hooks.PostToolUse = [{ type: 'command', command: contextMonitorCommand }];
684
+ const subagentStop = [];
685
+ if (costLoggerCommand) subagentStop.push({ type: 'command', command: costLoggerCommand });
686
+ if (traceLoggerCommand) subagentStop.push({ type: 'command', command: traceLoggerCommand });
687
+ if (subagentStop.length > 0) hooks.SubagentStop = subagentStop;
688
+ return { hooks };
689
+ }
690
+
691
+ /** Copilot's reverse-domain extension namespace — the top-level directory its plugin components live in. */
692
+ const COPILOT_PLUGIN_NAMESPACE = 'com.github.copilot';
693
+
694
+ /** The adapter paragraph appended to every bundled skill (ADR-0045 D3). */
695
+ function agentPluginSkillAdapterNote() {
696
+ return `Plugin bundle (Agent Plugins format):
697
+ - \`${AGENT_PLUGIN_ROOT_TOKEN}\` in this skill is the directory that holds this plugin's \`plugin.json\` — two levels above this SKILL.md. Your runtime reports this skill's file location when it loads it; derive the root from that path and substitute it wherever \`${AGENT_PLUGIN_ROOT_TOKEN}\` appears before running a command.
698
+ - Prefer the \`pan\` MCP server's tools when your runtime has connected this plugin's \`mcp.json\`, and pass the project's absolute path as each tool's \`cwd\` argument — the server is started in the plugin's directory, which is never the project. Otherwise run \`node ${AGENT_PLUGIN_ROOT_TOKEN}/pan-wizard-core/bin/pan-tools.cjs <verb>\` from the project root.
699
+ - \`${AGENT_PLUGIN_RUNTIME_HOME_TOKEN}\` is your runtime's user-level configuration directory (for example \`~/.claude\`, \`~/.codex\`, \`~/.gemini\`, \`~/.config/opencode\`, \`~/.copilot\`) and \`${AGENT_PLUGIN_RUNTIME_DIR_TOKEN}\` its project-level directory (\`.claude\`, \`.codex\`, \`.gemini\`, \`.opencode\`, \`.github\`). Substitute the one that applies to the runtime you are.`;
700
+ }
701
+
503
702
  /** Generate Copilot CLI skill adapter header */
504
703
  function getCopilotSkillAdapterHeader(skillName) {
505
704
  const invocation = `/pan-${skillName.replace(/^pan-/, '')}`;
@@ -540,7 +739,18 @@ function convertClaudeCommandToCopilotSkill(content, skillName) {
540
739
  }
541
740
 
542
741
  /** Claude agent → Copilot .agent.md */
543
- function convertClaudeToCopilotAgent(content) {
742
+ /**
743
+ * @param {string} content - Claude agent markdown
744
+ * @param {object} [opts]
745
+ * @param {Record<string,string[]>} [opts.modelLists] - Copilot CLI (>= 1.0.83) accepts a
746
+ * `model:` LIST tried in order plus `model-policy`. When a PAN agent pins `model:
747
+ * <alias>` and this map has an entry for the alias, the Copilot agent gets that list
748
+ * and `model-policy: prefer` (degrade gracefully; `required` would refuse to run).
749
+ * NOT wired into the installer yet: the Copilot model ids must be verified on a live
750
+ * CLI first (ADR-0028's rule; harness/scenarios/live-gate-copilot.json carries the
751
+ * probe). Reality check RC15 / plan item R13, 2026-09-10.
752
+ */
753
+ function convertClaudeToCopilotAgent(content, opts = {}) {
544
754
  const converted = convertClaudeToCopilotMarkdown(content);
545
755
  const { frontmatter, body } = extractFrontmatterAndBody(converted);
546
756
  let name = '';
@@ -589,7 +799,17 @@ function convertClaudeToCopilotAgent(content) {
589
799
  const toolsYaml = copilotTools.length > 0
590
800
  ? `\ntools:\n${copilotTools.map(t => ` - ${yamlQuote(t)}`).join('\n')}`
591
801
  : '';
592
- return `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(description)}${toolsYaml}\n---\n${body}`;
802
+ // R13: optional model fallback list for agents that pin a model alias.
803
+ let modelYaml = '';
804
+ const lists = opts && opts.modelLists;
805
+ if (lists && frontmatter) {
806
+ const pinned = extractFrontmatterField(frontmatter, 'model');
807
+ const list = pinned && Array.isArray(lists[pinned]) ? lists[pinned].filter(Boolean) : null;
808
+ if (list && list.length) {
809
+ modelYaml = `\nmodel:\n${list.map(m => ` - ${yamlQuote(m)}`).join('\n')}\nmodel-policy: prefer`;
810
+ }
811
+ }
812
+ return `---\nname: ${yamlQuote(name)}\ndescription: ${yamlQuote(description)}${toolsYaml}${modelYaml}\n---\n${body}`;
593
813
  }
594
814
 
595
815
  // ─── Attribution Processing ─────────────────────────────────────────────────
@@ -928,7 +1148,7 @@ const MCP_REGISTRATION = Object.freeze({
928
1148
  }),
929
1149
  opencode: Object.freeze({
930
1150
  register: true, key: 'mcp', localPath: 'opencode.json', globalPath: 'opencode.json',
931
- why: 'opencode.ai/docs: opencode.json `mcp` block, type "local", command as one array, env block named `environment`. PAN already writes this file.',
1151
+ why: 'opencode.ai/docs/mcp-servers: opencode.json `mcp` block, type "local", command as one array, env block named `environment`. PAN already writes this file. LOCATION: the docs page (opencode.ai/docs/config) lists only a repo-root opencode.json; the .opencode/opencode.json PAN writes for local installs is read by the loader SOURCE — packages/opencode/src/config/config.ts, the branch for directories ending in .opencode reads opencode.json and opencode.jsonc (read 2026-09-10). Live but undocumented: re-check the loader on OpenCode upgrades (harness/scenarios/live-gate-opencode.json asks the CLI).',
932
1152
  }),
933
1153
  codex: Object.freeze({
934
1154
  // Config-dir-relative like the others (resolves to `.codex/config.toml`).
@@ -1041,21 +1261,46 @@ function mergeCodexHooksConfig(existing, commands) {
1041
1261
  const config = (existing && typeof existing === 'object') ? existing : {};
1042
1262
  if (!config.hooks || typeof config.hooks !== 'object') config.hooks = {};
1043
1263
 
1264
+ // The fourth column is Codex's `async` flag (command handlers, Codex CLI
1265
+ // 0.148+, changelog 2026-08-17): an async handler runs off the agent's critical
1266
+ // path and CANNOT block, approve, deny, or inject — its output is deferred to
1267
+ // the next turn. So it is right for pure observers and wrong for anything the
1268
+ // model must read now:
1269
+ // - cost-logger / trace-logger append ledger rows and print nothing → async.
1270
+ // - check-update spawns a detached child and prints nothing → async.
1271
+ // - context-monitor returns `additionalContext` the model must see THIS
1272
+ // turn → stays synchronous.
1273
+ // Codex-only: Claude Code and Copilot hook schemas were not checked for an
1274
+ // equivalent flag (plan item 2 gate) — do not copy this column into their
1275
+ // builders without reading their docs first.
1044
1276
  const wanted = [
1045
- ['SessionStart', updateCheckCommand, 'pan-check-update'],
1046
- ['PostToolUse', contextMonitorCommand, 'pan-context-monitor'],
1047
- ['SubagentStop', costLoggerCommand, 'pan-cost-logger'],
1048
- ['SubagentStop', traceLoggerCommand, 'pan-trace-logger'],
1277
+ ['SessionStart', updateCheckCommand, 'pan-check-update', true],
1278
+ ['PostToolUse', contextMonitorCommand, 'pan-context-monitor', false],
1279
+ ['SubagentStop', costLoggerCommand, 'pan-cost-logger', true],
1280
+ ['SubagentStop', traceLoggerCommand, 'pan-trace-logger', true],
1049
1281
  ];
1050
1282
 
1051
- for (const [event, command, marker] of wanted) {
1283
+ for (const [event, command, marker, async] of wanted) {
1052
1284
  if (!command) continue;
1053
1285
  if (!Array.isArray(config.hooks[event])) config.hooks[event] = [];
1054
- const present = config.hooks[event].some(group =>
1055
- Array.isArray(group.hooks) && group.hooks.some(h => h.command && h.command.includes(marker)));
1056
- if (!present) {
1057
- config.hooks[event].push({ hooks: [{ type: 'command', command }] });
1286
+ let existingHandler = null;
1287
+ for (const group of config.hooks[event]) {
1288
+ if (!Array.isArray(group.hooks)) continue;
1289
+ existingHandler = group.hooks.find(h => h && h.command && h.command.includes(marker)) || null;
1290
+ if (existingHandler) break;
1291
+ }
1292
+ if (existingHandler) {
1293
+ // Upgrade path: a hooks.json written before the async column keeps its
1294
+ // handler (and any command edits) but must pick up the flag — and lose it
1295
+ // if the column ever says synchronous. Otherwise an install upgraded from
1296
+ // 3.27 would run the observers on the critical path forever.
1297
+ if (async) existingHandler.async = true;
1298
+ else delete existingHandler.async;
1299
+ continue;
1058
1300
  }
1301
+ const handler = { type: 'command', command };
1302
+ if (async) handler.async = true;
1303
+ config.hooks[event].push({ hooks: [handler] });
1059
1304
  }
1060
1305
  return config;
1061
1306
  }
@@ -1475,7 +1720,7 @@ function buildPluginHooksConfig() {
1475
1720
  * rather than hardcoded so this file stays the single source of that string.
1476
1721
  * @returns {string} markdown for `commands/pan-plugin-selftest.md` in the plugin
1477
1722
  */
1478
- function buildPluginSelfTestCommand(placeholder = '${CLAUDE_PLUGIN_ROOT}') {
1723
+ function buildPluginSelfTestCommand(placeholder = '${CLAUDE_PLUGIN_ROOT}', pluginName = 'pan-wizard') {
1479
1724
  // Sentinels the agent quotes between. Deliberately ugly so they cannot occur
1480
1725
  // naturally in surrounding prose or be mistaken for instructions.
1481
1726
  const OPEN = 'PAN_PROBE_BEGIN>>>';
@@ -1486,7 +1731,7 @@ description: Diagnose whether the plugin-root placeholder expands in plugin comm
1486
1731
 
1487
1732
  # PAN plugin self-test
1488
1733
 
1489
- Answer three questions and print the verdict table. **Do not fix anything.** This
1734
+ Answer four questions and print the verdict table. **Do not fix anything.** This
1490
1735
  command is a measurement; a "fail" here is the result, not a problem to repair.
1491
1736
 
1492
1737
  ## Probe 1 — textual substitution in markdown (the question that matters)
@@ -1516,6 +1761,18 @@ Run this and record whether it prints JSON or errors, as \`probe3\`:
1516
1761
  node "${placeholder}/pan-wizard-core/bin/pan-tools.cjs" --help
1517
1762
  \`\`\`
1518
1763
 
1764
+ ## Probe 4 — do this plugin's agents and workflows load under the scoped name
1765
+
1766
+ The plugin ships its agents under \`${pluginName}:<agent>\` and its native workflow
1767
+ scripts spawn them by that scoped name. Measure, do not assume:
1768
+
1769
+ - From the list of agent types available to you in this session (the Agent tool's
1770
+ own list — do not run a shell), record as \`probe4a\` how many names begin with
1771
+ \`${pluginName}:pan-\`, followed by the first three such names verbatim. If none,
1772
+ record any names that begin with \`pan-\` instead and say so.
1773
+ - Record as \`probe4b\` whether a slash command named \`/${pluginName}:pan-review-pipeline\`
1774
+ is available to you. If you cannot tell, write "unknown" — that is a valid answer.
1775
+
1519
1776
  ## Verdict
1520
1777
 
1521
1778
  Print this table, filled in:
@@ -1525,6 +1782,8 @@ Print this table, filled in:
1525
1782
  | 1 — markdown substitution | \`probe1\` verbatim |
1526
1783
  | 2 — env var | \`probe2\` or "(empty)" |
1527
1784
  | 3 — engine through placeholder | ok / failed, with the error's first line |
1785
+ | 4a — scoped agent names | count and first three names, or the bare names seen |
1786
+ | 4b — scoped workflow command | available / not available / unknown |
1528
1787
 
1529
1788
  Then state which case holds:
1530
1789
 
@@ -1544,6 +1803,13 @@ Then state which case holds:
1544
1803
  address the plugin root at all. PAN would need content that resolves paths at
1545
1804
  runtime instead, and marketplace publishing stays gated.
1546
1805
 
1806
+ Probe 4 does not change the case letter — it measures a separate premise: the
1807
+ plugin's \`workflows/\` scripts were written to spawn \`${pluginName}:pan-…\` because
1808
+ plugin agents are documented to load under the scoped name. If \`probe4a\` reports
1809
+ bare \`pan-…\` names and none scoped, that premise is false on this build and the
1810
+ workflow scripts inside the plugin would not resolve their agents. Report it as a
1811
+ separate line, exactly like \`AGENT_SCOPE: scoped\` or \`AGENT_SCOPE: bare\`.
1812
+
1547
1813
  Finish with the case letter on its own line, exactly like \`VERDICT: case A\`,
1548
1814
  so the result is greppable out of the transcript.
1549
1815
  `;
@@ -1576,6 +1842,27 @@ function buildPluginMcpConfig() {
1576
1842
  };
1577
1843
  }
1578
1844
 
1845
+ /**
1846
+ * Rewrite the `agentType` values in a native workflow script for a PLUGIN copy.
1847
+ *
1848
+ * Plugin agents load under a scoped name: `agents/pan-reviewer.md` inside a
1849
+ * plugin named `pan-wizard` is `pan-wizard:pan-reviewer`
1850
+ * (code.claude.com/docs/en/plugins-reference, read 2026-09-10). The scripts
1851
+ * `buildNativeWorkflowScripts()` emits are written for a loose-file install,
1852
+ * where the bare name resolves, so the plugin builder runs them through this
1853
+ * before writing `workflows/`. Idempotent: an already-scoped name (contains
1854
+ * ':') is left alone, and nothing outside `agentType: '…'` is touched.
1855
+ *
1856
+ * @param {string} content - emitted script source
1857
+ * @param {string} pluginName - the manifest `name`
1858
+ * @returns {string}
1859
+ */
1860
+ function namespaceWorkflowAgentTypes(content, pluginName) {
1861
+ if (typeof content !== 'string' || !pluginName) return content;
1862
+ return content.replace(/agentType:(\s*)'([^':]+)'/g,
1863
+ (_m, ws, name) => `agentType:${ws}'${pluginName}:${name}'`);
1864
+ }
1865
+
1579
1866
  // ─── Native Claude Code workflows (2026-06) ─────────────────────────────────
1580
1867
  //
1581
1868
  // Claude Code discovers deterministic orchestration scripts in
@@ -1599,6 +1886,7 @@ function buildNativeWorkflowScripts() {
1599
1886
  { title: 'Merge', detail: 'meta-reviewer dedupes, disputes, and issues the verdict' },
1600
1887
  ],
1601
1888
  }
1889
+ // twin: commands/pan/review-deep.md
1602
1890
 
1603
1891
  const target = (typeof args === 'string' && args.trim())
1604
1892
  ? args.trim()
@@ -1664,6 +1952,7 @@ return merged
1664
1952
  { title: 'Synthesize', detail: 'merge area maps into one codebase overview' },
1665
1953
  ],
1666
1954
  }
1955
+ // twin: pan-wizard-core/workflows/map-codebase.md
1667
1956
 
1668
1957
  phase('Scan')
1669
1958
  const AREAS = {
@@ -1700,11 +1989,238 @@ const synthesis = await agent(
1700
1989
  { label: 'synthesize', phase: 'Synthesize' })
1701
1990
 
1702
1991
  return { areas_mapped: maps.filter(Boolean).length, synthesis }
1992
+ `;
1993
+
1994
+ // ── §3.2 ports (2026-09, plan item 5a). Selection rule: a protocol becomes a
1995
+ // script when its control flow is knowable BEFORE the run — a fan-out whose
1996
+ // width the engine reports, waves that are genuinely barriers. It stays
1997
+ // markdown when the next step depends on reading the last result. exec-phase's
1998
+ // wave dispatch and diagnose-issues' per-gap fan-out qualify; verify-phase and
1999
+ // milestone-gaps (single-agent judgment) do not, whatever the plan first guessed.
2000
+ // A script also cannot pause for a human (only agent permission prompts pause a
2001
+ // run), so the wave script REFUSES phases with checkpoint plans instead of
2002
+ // pretending. Each script names its markdown twin; the drift test pins the pair.
2003
+ //
2004
+ // Paths: the engine is invoked by AGENTS (scripts have no shell), so prompts
2005
+ // describe where pan-tools lives rather than hard-coding one install layout.
2006
+ const execWaves = `export const meta = {
2007
+ name: 'pan-exec-waves',
2008
+ description: 'PAN phase execution: wave-grouped executor fan-out for a checkpoint-free phase, then verification',
2009
+ whenToUse: 'Deterministic version of the /pan-exec-phase wave dispatch. Pass the phase number as args. Refuses a phase that contains checkpoint plans (a workflow cannot pause for a human) — run /pan-exec-phase for those.',
2010
+ phases: [
2011
+ { title: 'Index', detail: 'plan inventory with wave grouping, from the PAN engine' },
2012
+ { title: 'Execute', detail: 'one executor per plan; waves in order, plans within a wave in parallel' },
2013
+ { title: 'Verify', detail: 'the phase verifier over the completed plans' },
2014
+ ],
2015
+ }
2016
+ // twin: pan-wizard-core/workflows/exec-phase.md
2017
+
2018
+ const PAN_TOOLS = 'PAN engine (pan-tools): node <PAN core>/bin/pan-tools.cjs — the PAN core is .claude/pan-wizard-core in a project install, ~/.claude/pan-wizard-core in a global install, or pan-wizard-core under the plugin root when PAN runs as a plugin.'
2019
+ const CORE_DOCS = 'PAN core documents (same core directory): workflows/execute-plan.md, templates/summary.md, references/checkpoints.md, references/tdd.md.'
2020
+
2021
+ const phaseArg = (typeof args === 'string' && args.trim())
2022
+ ? args.trim()
2023
+ : (args && typeof args === 'object' && args.phase != null ? String(args.phase) : '')
2024
+ if (!phaseArg) return { error: 'pass the phase number as args, e.g. /pan-exec-waves 3' }
2025
+
2026
+ phase('Index')
2027
+ const INDEX = {
2028
+ type: 'object',
2029
+ properties: {
2030
+ phase_found: { type: 'boolean' },
2031
+ phase_number: { type: 'string' },
2032
+ phase_name: { type: 'string' },
2033
+ phase_dir: { type: 'string' },
2034
+ parallelization: { type: 'boolean' },
2035
+ has_checkpoints: { type: 'boolean' },
2036
+ plans: {
2037
+ type: 'array',
2038
+ items: {
2039
+ type: 'object',
2040
+ properties: {
2041
+ id: { type: 'string' },
2042
+ file: { type: 'string' },
2043
+ wave: { type: 'integer' },
2044
+ autonomous: { type: 'boolean' },
2045
+ has_summary: { type: 'boolean' },
2046
+ objective: { type: 'string' },
2047
+ },
2048
+ required: ['id', 'wave', 'autonomous', 'has_summary'],
2049
+ },
2050
+ },
2051
+ },
2052
+ required: ['phase_found', 'has_checkpoints', 'plans'],
2053
+ }
2054
+ const index = await agent(
2055
+ 'Index phase ' + phaseArg + ' for execution using the ' + PAN_TOOLS + ' Run two verbs and merge their JSON: (1) init execute-phase ' + phaseArg + ' — take phase_found, phase_number, phase_name, phase_dir, parallelization; (2) phase-plan-index ' + phaseArg + ' — take has_checkpoints and plans[] (id, wave, autonomous, has_summary, objective; include each plan file path as file). Do not execute anything; return only the merged index.',
2056
+ { label: 'index', phase: 'Index', schema: INDEX })
2057
+ if (!index || !index.phase_found) return { error: 'phase ' + phaseArg + ' not found' }
2058
+ if (index.has_checkpoints) {
2059
+ return { error: 'phase ' + phaseArg + ' contains checkpoint plans (autonomous: false). A workflow cannot pause for a human — run /pan-exec-phase ' + phaseArg + ' instead.', plans: index.plans.map(p => p.id) }
2060
+ }
2061
+ const pending = index.plans.filter(p => !p.has_summary)
2062
+ if (pending.length === 0) return { phase: phaseArg, done: true, message: 'every plan already has a summary — nothing to execute' }
2063
+ const waveNumbers = [...new Set(pending.map(p => p.wave))].sort((a, b) => a - b)
2064
+ const parallelWithinWave = index.parallelization !== false
2065
+ log(pending.length + ' plans across ' + waveNumbers.length + ' wave(s)' + (parallelWithinWave ? '' : ', sequential within waves'))
2066
+
2067
+ phase('Execute')
2068
+ const EXEC_RESULT = {
2069
+ type: 'object',
2070
+ properties: {
2071
+ plan_id: { type: 'string' },
2072
+ status: { type: 'string', enum: ['complete', 'failed', 'checkpoint'] },
2073
+ summary_path: { type: 'string' },
2074
+ commits: { type: 'integer' },
2075
+ self_check: { type: 'string', enum: ['passed', 'failed', 'unknown'] },
2076
+ notes: { type: 'string' },
2077
+ },
2078
+ required: ['plan_id', 'status', 'self_check'],
2079
+ }
2080
+ const executorPrompt = (p) =>
2081
+ 'Execute plan ' + p.id + ' of phase ' + (index.phase_number || phaseArg) + (index.phase_name ? '-' + index.phase_name : '') + '. Commit each task atomically. Create summary.md. Update state.md and roadmap.md (via roadmap update-plan-progress).\\n\\n'
2082
+ + 'Read first, in this order: ' + CORE_DOCS + '\\n\\n'
2083
+ + 'Then read: ' + (p.file || (index.phase_dir + '/' + p.id)) + ' (the plan), .planning/state.md, .planning/config.json (if present), ./CLAUDE.md (if present — follow its conventions), .agents/skills/ (if present — follow relevant skills), and every .planning/memory/*.md (apply every rule without exception).\\n\\n'
2084
+ + 'Report plan_id, status (complete | failed | checkpoint), summary_path, the number of commits you made, and self_check (passed if your summary carries no "Self-Check: FAILED" marker).'
2085
+ const executed = []
2086
+ let halted = null
2087
+ for (const w of waveNumbers) {
2088
+ const wavePlans = pending.filter(p => p.wave === w)
2089
+ log('wave ' + w + ': ' + wavePlans.map(p => p.id).join(', '))
2090
+ let results
2091
+ if (parallelWithinWave) {
2092
+ results = await parallel(wavePlans.map(p => () =>
2093
+ agent(executorPrompt(p), { agentType: 'pan-executor', label: 'exec:' + p.id, phase: 'Execute', schema: EXEC_RESULT })))
2094
+ } else {
2095
+ results = []
2096
+ for (const p of wavePlans) {
2097
+ results.push(await agent(executorPrompt(p), { agentType: 'pan-executor', label: 'exec:' + p.id, phase: 'Execute', schema: EXEC_RESULT }))
2098
+ }
2099
+ }
2100
+ const settled = results.filter(Boolean)
2101
+ executed.push(...settled)
2102
+ const bad = settled.filter(r => r.status !== 'complete' || r.self_check === 'failed')
2103
+ const dropped = wavePlans.length - settled.length
2104
+ if (bad.length > 0 || dropped > 0) {
2105
+ // Mirror exec-phase's failure handler without the question it asks: stop
2106
+ // before the next wave and return what happened, so a human decides.
2107
+ halted = { wave: w, failed: bad.map(r => r.plan_id), unanswered: dropped }
2108
+ break
2109
+ }
2110
+ }
2111
+ if (halted) {
2112
+ return { phase: phaseArg, halted, executed, next: 'Inspect the failed plan(s), then re-run /pan-exec-waves ' + phaseArg + ' (completed plans are skipped) or fall back to /pan-exec-phase ' + phaseArg }
2113
+ }
2114
+
2115
+ phase('Verify')
2116
+ const VERIFY = {
2117
+ type: 'object',
2118
+ properties: {
2119
+ status: { type: 'string', enum: ['passed', 'gaps_found', 'human_needed', 'failed'] },
2120
+ verification_path: { type: 'string' },
2121
+ gaps: { type: 'array', items: { type: 'string' } },
2122
+ summary: { type: 'string' },
2123
+ },
2124
+ required: ['status', 'summary'],
2125
+ }
2126
+ const verdict = await agent(
2127
+ 'Verify phase ' + phaseArg + ' following the PAN verify-phase protocol (PAN core: workflows/verify-phase.md — ' + PAN_TOOLS + '). Check the phase goals against what the plans delivered, write the verification file the protocol prescribes, and report status (passed | gaps_found | human_needed | failed), the verification file path, any gaps, and a summary. Do not mark the phase complete or advance state — that decision stays with the user.',
2128
+ { agentType: 'pan-verifier', label: 'verify', phase: 'Verify', schema: VERIFY })
2129
+
2130
+ return { phase: phaseArg, waves_run: waveNumbers.length, plans_complete: executed.length, verification: verdict, next: 'Review the verification, then continue with /pan-exec-phase ' + phaseArg + ' (transition) or /pan-plan-phase for the next phase' }
2131
+ `;
2132
+
2133
+ const diagnoseIssues = `export const meta = {
2134
+ name: 'pan-diagnose-issues',
2135
+ description: 'PAN UAT diagnosis: one debugger per failed UAT truth, in parallel, then root causes written back',
2136
+ whenToUse: 'Deterministic version of /pan-diagnose-issues. Pass the phase number as args. Investigates only — fixes come from /pan-plan-phase --gaps.',
2137
+ phases: [
2138
+ { title: 'Gaps', detail: 'read the phase UAT file and list the failed truths' },
2139
+ { title: 'Diagnose', detail: 'one pan-debugger per gap, in parallel, root cause only' },
2140
+ { title: 'Record', detail: 'write root causes and artifacts back into the UAT gaps' },
2141
+ ],
2142
+ }
2143
+ // twin: pan-wizard-core/workflows/diagnose-issues.md
2144
+
2145
+ const PAN_TOOLS = 'PAN engine (pan-tools): node <PAN core>/bin/pan-tools.cjs — the PAN core is .claude/pan-wizard-core in a project install, ~/.claude/pan-wizard-core in a global install, or pan-wizard-core under the plugin root when PAN runs as a plugin.'
2146
+
2147
+ const phaseArg = (typeof args === 'string' && args.trim())
2148
+ ? args.trim()
2149
+ : (args && typeof args === 'object' && args.phase != null ? String(args.phase) : '')
2150
+ if (!phaseArg) return { error: 'pass the phase number as args, e.g. /pan-diagnose-issues 3' }
2151
+
2152
+ phase('Gaps')
2153
+ const GAPS = {
2154
+ type: 'object',
2155
+ properties: {
2156
+ phase_dir: { type: 'string' },
2157
+ uat_path: { type: 'string' },
2158
+ gaps: {
2159
+ type: 'array',
2160
+ items: {
2161
+ type: 'object',
2162
+ properties: {
2163
+ test_num: { type: 'integer' },
2164
+ truth: { type: 'string' },
2165
+ severity: { type: 'string' },
2166
+ reason: { type: 'string' },
2167
+ expected: { type: 'string' },
2168
+ },
2169
+ required: ['test_num', 'truth', 'severity'],
2170
+ },
2171
+ },
2172
+ },
2173
+ required: ['uat_path', 'gaps'],
2174
+ }
2175
+ const found = await agent(
2176
+ 'Locate phase ' + phaseArg + ' with the ' + PAN_TOOLS + ' (find-phase ' + phaseArg + ' gives the phase directory) and read its UAT file ({phase_dir}/{phase}-uat.md). List every gap in the Gaps section whose status is failed: test number, the truth that failed, severity, the reason the user reported, and the expected behaviour from the matching test. Do not investigate anything; return the list.',
2177
+ { label: 'gaps', phase: 'Gaps', schema: GAPS })
2178
+ if (!found || !found.uat_path) return { error: 'no UAT file found for phase ' + phaseArg }
2179
+ const gaps = (found.gaps || []).filter(Boolean)
2180
+ if (gaps.length === 0) return { phase: phaseArg, uat_path: found.uat_path, gaps: 0, message: 'no failed truths to diagnose' }
2181
+ log(gaps.length + ' gap(s) to diagnose')
2182
+
2183
+ phase('Diagnose')
2184
+ const DIAGNOSIS = {
2185
+ type: 'object',
2186
+ properties: {
2187
+ issue_id: { type: 'string' },
2188
+ status: { type: 'string', enum: ['root_cause_found', 'inconclusive'] },
2189
+ root_cause: { type: 'string' },
2190
+ evidence: { type: 'array', items: { type: 'string' } },
2191
+ files: { type: 'array', items: { type: 'string' } },
2192
+ suggested_fix: { type: 'string' },
2193
+ debug_path: { type: 'string' },
2194
+ remaining_possibilities: { type: 'array', items: { type: 'string' } },
2195
+ },
2196
+ required: ['issue_id', 'status'],
2197
+ }
2198
+ const diagnoses = await parallel(gaps.map(g => () => agent(
2199
+ 'Debug issue UAT-' + g.test_num + ' for phase ' + phaseArg + ' — root cause ONLY, do not fix (fixes come from /pan-plan-phase --gaps).\\n\\n'
2200
+ + 'Symptoms (pre-filled from UAT, treat as given): expected: ' + (g.expected || g.truth) + '. actual: ' + (g.reason || 'not recorded') + '. reproduction: test ' + g.test_num + ' in ' + found.uat_path + '. severity: ' + g.severity + '.\\n\\n'
2201
+ + 'Follow the PAN debugger protocol: create the debug session file under .planning/debug/ named from the issue, investigate autonomously (read code, form hypotheses, test them), and report issue_id, status (root_cause_found | inconclusive), root_cause with evidence, files involved, a suggested fix direction, and the debug session path. If inconclusive, list the remaining possibilities. Also read ' + found.uat_path + ' and .planning/state.md for context.',
2202
+ { agentType: 'pan-debugger', label: 'debug:UAT-' + g.test_num, phase: 'Diagnose', schema: DIAGNOSIS })))
2203
+ const results = diagnoses.filter(Boolean)
2204
+ log(results.filter(r => r.status === 'root_cause_found').length + ' root cause(s) found, ' + results.filter(r => r.status === 'inconclusive').length + ' inconclusive')
2205
+
2206
+ phase('Record')
2207
+ const RECORDED = {
2208
+ type: 'object',
2209
+ properties: { uat_path: { type: 'string' }, gaps_updated: { type: 'integer' } },
2210
+ required: ['uat_path', 'gaps_updated'],
2211
+ }
2212
+ const recorded = await agent(
2213
+ 'Update the Gaps section of ' + found.uat_path + ' with these diagnoses, following the PAN diagnose-issues protocol: for each gap add root_cause, artifacts (the debug session path), the files involved, and the suggested fix direction; mark inconclusive ones as needing manual review with their remaining possibilities. Edit in place — do not rewrite unrelated content. Report the path and how many gaps you updated.\\n\\nDiagnoses:\\n' + JSON.stringify(results, null, 2),
2214
+ { label: 'record', phase: 'Record', schema: RECORDED })
2215
+
2216
+ return { phase: phaseArg, uat_path: found.uat_path, gaps: gaps.length, root_causes_found: results.filter(r => r.status === 'root_cause_found').length, inconclusive: results.filter(r => r.status === 'inconclusive').length, recorded, next: 'Run /pan-plan-phase ' + phaseArg + ' --gaps to plan the fixes' }
1703
2217
  `;
1704
2218
 
1705
2219
  return [
1706
2220
  { name: 'pan-review-pipeline.js', content: reviewPipeline },
1707
2221
  { name: 'pan-map-codebase.js', content: mapCodebase },
2222
+ { name: 'pan-exec-waves.js', content: execWaves },
2223
+ { name: 'pan-diagnose-issues.js', content: diagnoseIssues },
1708
2224
  ];
1709
2225
  }
1710
2226
 
@@ -1792,6 +2308,22 @@ module.exports = {
1792
2308
  buildCodexMcpSnippet,
1793
2309
  removeCodexPanHooks,
1794
2310
  buildNativeWorkflowScripts,
2311
+ namespaceWorkflowAgentTypes,
2312
+ rewriteUnifiedSkillCommandContent,
2313
+ rewriteSharedCoreMarkdown,
2314
+ rewriteAgentReferenceCopy,
2315
+ stripInternalLearningsTopics,
2316
+ AGENT_PLUGINS_VERSION,
2317
+ AGENT_PLUGIN_MANIFEST_SCHEMA,
2318
+ AGENT_PLUGIN_MCP_SCHEMA,
2319
+ AGENT_PLUGIN_ROOT_TOKEN,
2320
+ AGENT_PLUGIN_RUNTIME_HOME_TOKEN,
2321
+ AGENT_PLUGIN_RUNTIME_DIR_TOKEN,
2322
+ buildAgentPluginManifest,
2323
+ buildAgentPluginMcpConfig,
2324
+ agentPluginSkillAdapterNote,
2325
+ buildCopilotPluginHooksConfig,
2326
+ COPILOT_PLUGIN_NAMESPACE,
1795
2327
  buildPluginManifest,
1796
2328
  buildPluginHooksConfig,
1797
2329
  buildPluginMcpConfig,
@@ -1807,3 +2339,33 @@ module.exports = {
1807
2339
  PAN_AGENTS_BEGIN,
1808
2340
  PAN_AGENTS_END,
1809
2341
  };
2342
+
2343
+ /**
2344
+ * Content digest of a directory tree: sha256 over the sorted list of
2345
+ * `<relative posix path>:<sha256 of bytes>` lines. Order-independent, content-
2346
+ * sensitive, ignores mtimes. Used by release-check Gate 8 to refuse a stale
2347
+ * dist/pan-agent-plugin — the Codex and Copilot marketplaces install from that path
2348
+ * with no rebuild-on-resolve, so a stale bundle would ship silently (reality check
2349
+ * RC12 / plan item R10, 2026-09-10; two fresh builds were measured byte-identical).
2350
+ * Pure apart from reading the tree; throws if `dir` is not a directory.
2351
+ */
2352
+ function dirDigest(dir) {
2353
+ // Local requires: install-lib keeps no module-level filesystem imports (its top
2354
+ // level is pure); this helper is the one export that reads a tree.
2355
+ const fs = require('fs');
2356
+ const path = require('path');
2357
+ const crypto = require('crypto');
2358
+ const lines = [];
2359
+ const walk = (abs, rel) => {
2360
+ const entries = fs.readdirSync(abs, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
2361
+ for (const e of entries) {
2362
+ const childAbs = path.join(abs, e.name);
2363
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
2364
+ if (e.isDirectory()) walk(childAbs, childRel);
2365
+ else lines.push(`${childRel}:${crypto.createHash('sha256').update(fs.readFileSync(childAbs)).digest('hex')}`);
2366
+ }
2367
+ };
2368
+ walk(dir, '');
2369
+ return crypto.createHash('sha256').update(lines.join('\n')).digest('hex');
2370
+ }
2371
+ module.exports.dirDigest = dirDigest;