@event4u/agent-config 14.21.0 → 14.23.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +164 -187
- package/CREDITS.md +37 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +380 -5
- package/dist/agent-src/commands/analyze/repo.md +4 -2
- package/dist/agent-src/commands/pr/merge.md +47 -38
- package/dist/agent-src/commands/roadmap/next.md +8 -0
- package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +69 -0
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +5 -5
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
- package/dist/agent-src/rules/external-code-graph-interop.md +19 -14
- package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
- package/dist/agent-src/rules/token-efficiency.md +26 -3
- package/dist/agent-src/scripts/_cli_entry.ts +51 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +120 -27
- package/dist/agent-src/scripts/roadmap_gates.ts +4 -20
- package/dist/agent-src/scripts/roadmap_set_step.ts +5 -2
- package/dist/agent-src/scripts/stubs_due.ts +4 -18
- package/dist/agent-src/scripts/update_roadmap_progress.ts +7 -24
- package/dist/agent-src/skills/code-intelligence/SKILL.md +82 -1
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +17 -0
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
- package/dist/agent-src/templates/agent-settings.md +0 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/_cli_entry.js +8 -0
- package/dist/cli-delegate/_lib/pin_resolver.js +170 -0
- package/dist/cli-delegate/archival_sweep.js +8 -0
- package/dist/cli-delegate/archive_completed_roadmaps.js +542 -0
- package/dist/cli-delegate/check_update_banner.js +923 -0
- package/dist/cli-delegate/{chunk-ZVBOHSVF.js → chunk-2KKXJW25.js} +23 -9
- package/dist/cli-delegate/chunk-5EK5WQN6.js +28 -0
- package/dist/cli-delegate/chunk-7XGD2OEU.js +96 -0
- package/dist/cli-delegate/chunk-DBH4AHDK.js +155 -0
- package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
- package/dist/cli-delegate/chunk-JO25G5H7.js +20 -0
- package/dist/cli-delegate/{chunk-643Z7FIC.js → chunk-KCNTUSPI.js} +7 -1
- package/dist/cli-delegate/chunk-LTYVIY76.js +7961 -0
- package/dist/cli-delegate/chunk-QAJS5YWY.js +999 -0
- package/dist/cli-delegate/{chunk-YS6XTLEK.js → chunk-TYSY33MD.js} +56 -6
- package/dist/cli-delegate/chunk-ULET4BYS.js +328 -0
- package/dist/cli-delegate/chunk-YDQSHTD7.js +187 -0
- package/dist/cli-delegate/chunk-YWVXLR44.js +48 -0
- package/dist/cli-delegate/chunk-ZZQBUHFK.js +273 -0
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +45 -13
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +4 -14
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/cli-delegate/dashboard_mode.js +16 -0
- package/dist/cli-delegate/gate_budget.js +24 -0
- package/dist/cli-delegate/gate_execute.js +25 -0
- package/dist/cli-delegate/guarded_baseline.js +18 -0
- package/dist/cli-delegate/resume_probe.js +22 -0
- package/dist/cli-delegate/roadmap_gates.js +638 -0
- package/dist/cli-delegate/roadmap_set_step.js +173 -0
- package/dist/cli-delegate/stub_queue.js +47 -0
- package/dist/cli-delegate/stubs_due.js +38 -0
- package/dist/cli-delegate/ticket_bundles.js +59 -0
- package/dist/cli-delegate/update_roadmap_progress.js +60 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +13 -13
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1813 -946
- package/dist/install/claudeLayerCarriage.js +237 -0
- package/dist/install/claudePathsPlan.js +79 -0
- package/dist/install/hostLayerFingerprint.js +1 -1
- package/dist/install/install.mjs +21 -3
- package/dist/install/partitionEligibility.js +231 -117
- package/dist/install/ruleLayerPartition.js +10 -7
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +10870 -7641
- package/dist/scripts/_lib/lean_projection_mode.js +108 -0
- package/dist/scripts/_lib/router_match.js +243 -0
- package/dist/scripts/_lib/rule_injection.js +216 -0
- package/dist/scripts/condense.js +52 -45
- package/dist/scripts/project_thin_rules.js +139 -19
- package/dist/server/schemas/settings.js +16 -3
- package/docs/MIGRATION.md +105 -1
- package/docs/contracts/settings-classes.md +40 -8
- package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +73 -0
- package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
- package/llms.txt +1 -1
- package/package.json +7 -4
- package/src/agent-src/scripts/_cli_entry.ts +51 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +120 -27
- package/src/agent-src/scripts/roadmap_gates.ts +4 -20
- package/src/agent-src/scripts/roadmap_set_step.ts +5 -2
- package/src/agent-src/scripts/stubs_due.ts +4 -18
- package/src/agent-src/scripts/update_roadmap_progress.ts +7 -24
- package/src/config/agent-settings.template.yml +47 -1
- package/src/config/continuity-surface.json +516 -0
- package/src/config/estate-count-budget.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +29 -8
- package/src/config/gate-violation-baselines.json +8 -8
- package/src/config/gitignore-block.txt +19 -0
- package/src/config/hook-token-budget.json +7 -6
- package/src/config/host-injection-effect.json +73 -0
- package/src/config/packed-binary-manifest.json +33 -0
- package/src/config/preamble-payload-budget.json +112 -105
- package/src/config/publish-surface.json +4 -2
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/install/claudeLayerCarriage.ts +273 -0
- package/src/install/claudePathsPlan.ts +76 -0
- package/src/install/hostLayerFingerprint.ts +1 -1
- package/src/install/partitionEligibility.ts +261 -127
- package/src/install/ruleLayerPartition.ts +19 -23
- package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
- package/src/scripts/_cli/handoff_sessions.ts +14 -44
- package/src/scripts/_dispatch.bash +77 -27
- package/src/scripts/_lib/auto_dispatch.ts +2 -2
- package/src/scripts/_lib/catalogue_layer_parity.ts +146 -0
- package/src/scripts/_lib/continuity_slot.ts +295 -0
- package/src/scripts/_lib/continuity_surface.ts +296 -0
- package/src/scripts/_lib/continuity_writer.ts +253 -0
- package/src/scripts/_lib/corpus_manifest.ts +26 -9
- package/src/scripts/_lib/hook_settings.ts +86 -1
- package/src/scripts/_lib/injection_effect.ts +151 -0
- package/src/scripts/_lib/layer_overlap_notice.ts +50 -6
- package/src/scripts/_lib/lean_projection_mode.ts +127 -0
- package/src/scripts/_lib/packed_binary_predicate.ts +262 -0
- package/src/scripts/_lib/pin_resolver.ts +13 -0
- package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
- package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
- package/src/scripts/_lib/retired_status.ts +74 -0
- package/src/scripts/_lib/session_eol.ts +44 -0
- package/src/scripts/_lib/session_index_trust.ts +346 -0
- package/src/scripts/_lib/skill_catalogue.ts +156 -21
- package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
- package/src/scripts/_lib/subagent_capsule.ts +117 -8
- package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
- package/src/scripts/ai_council/api_transport.ts +5 -0
- package/src/scripts/ai_council/deliberation_metrics.ts +353 -0
- package/src/scripts/ai_council/prompts.ts +9 -0
- package/src/scripts/ai_council/synthesis_disagreement.ts +206 -0
- package/src/scripts/audit_mcp_tools.ts +69 -25
- package/src/scripts/build_mcp_catalog.ts +24 -1
- package/src/scripts/capture_skill_catalogue.ts +24 -11
- package/src/scripts/check_candidate_lines.ts +499 -0
- package/src/scripts/check_continuity_surface.ts +475 -0
- package/src/scripts/check_dependency_floors.ts +13 -6
- package/src/scripts/check_estate_count.ts +40 -79
- package/src/scripts/check_host_tree_parity.ts +291 -0
- package/src/scripts/check_pack_size.ts +262 -23
- package/src/scripts/check_preamble_payload_budget.ts +136 -7
- package/src/scripts/check_roadmap_trackable.ts +19 -16
- package/src/scripts/check_rule_layer_partition.ts +54 -34
- package/src/scripts/check_rule_projection_integrity.ts +102 -2
- package/src/scripts/check_single_delivery.ts +97 -31
- package/src/scripts/check_update_banner.ts +13 -0
- package/src/scripts/ci_settle.ts +205 -5
- package/src/scripts/code_graph/build.ts +217 -15
- package/src/scripts/code_graph/cli.ts +334 -15
- package/src/scripts/code_graph/detect.ts +38 -0
- package/src/scripts/code_graph/extract.ts +11 -0
- package/src/scripts/code_graph/grammar_probe.ts +43 -0
- package/src/scripts/code_graph/loader.ts +88 -14
- package/src/scripts/code_graph/query.ts +144 -11
- package/src/scripts/code_graph/resolution_tiers.ts +238 -0
- package/src/scripts/code_graph/sqlite_store.ts +262 -6
- package/src/scripts/code_graph/types.ts +117 -3
- package/src/scripts/code_graph/validate.ts +26 -1
- package/src/scripts/code_graph/verbs.ts +590 -0
- package/src/scripts/condense.ts +54 -42
- package/src/scripts/corpus_manifest.ts +5 -5
- package/src/scripts/council_cli.ts +11 -12
- package/src/scripts/count_intent_disagreement.ts +426 -0
- package/src/scripts/handoff_context_hook.ts +118 -1
- package/src/scripts/hook_effect_doctor.ts +82 -1
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +87 -12
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
- package/src/scripts/hooks/chain_nudge_hook.ts +282 -0
- package/src/scripts/hooks/code_graph_context_hook.ts +201 -0
- package/src/scripts/hooks/concern_registry.ts +4 -2
- package/src/scripts/hooks/host_semantics.ts +1 -1
- package/src/scripts/hooks/orchestration_record_hook.ts +2 -2
- package/src/scripts/hooks/push_settle_hook.ts +3 -2
- package/src/scripts/hooks/rule_inject_hook.ts +108 -21
- package/src/scripts/hooks/session_eol_hook.ts +120 -2
- package/src/scripts/hooks/skill_route_hook.ts +11 -4
- package/src/scripts/hot_context_hook.ts +62 -7
- package/src/scripts/inbox_source_census.ts +322 -0
- package/src/scripts/install-hooks.sh +82 -0
- package/src/scripts/{lint_carrier_integrity.ts → lint_deferral_integrity.ts} +60 -214
- package/src/scripts/lint_plan_risk_register.ts +13 -17
- package/src/scripts/lint_roadmap_blockers.ts +164 -1
- package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
- package/src/scripts/mcp_server/graph_tools.ts +294 -0
- package/src/scripts/mcp_server/path_util.ts +31 -0
- package/src/scripts/mcp_server/suggest_skill.ts +72 -0
- package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
- package/src/scripts/mcp_server/tools.ts +5 -69
- package/src/scripts/media/lib/adapter-contract.md +7 -2
- package/src/scripts/media/lib/fixtures/flux/asset-0001.png +0 -0
- package/src/scripts/media/lib/fixtures/gemini-image/asset-0001.png +0 -0
- package/src/scripts/media/lib/fixtures/ideogram/asset-0001.png +0 -0
- package/src/scripts/model_rule_injection.ts +61 -4
- package/src/scripts/project_thin_rules.ts +149 -16
- package/src/scripts/rdp_candidates_delta.ts +276 -0
- package/src/scripts/rdp_quality_eval.ts +217 -19
- package/src/scripts/release_mix_taxonomy.json +3 -2
- package/src/scripts/report_host_injection_effect.ts +189 -0
- package/src/scripts/report_layer_overlap.ts +41 -0
- package/src/scripts/report_skill_menu_census.ts +341 -0
- package/src/scripts/report_standing_payload_by_host.ts +650 -0
- package/src/scripts/schemas/agent-settings.schema.json +7 -1
- package/src/scripts/session_memory_index.ts +81 -8
- package/src/scripts/skill_tools/score_skill_relevance.ts +70 -6
- package/src/scripts/skill_tools/suggest_skill_for_task.ts +37 -3
- package/src/server/schemas/settings.ts +23 -4
- package/src/vendor/grammars/README.md +86 -0
- package/src/vendor/grammars/tree-sitter-javascript.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-php.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-typescript.wasm +0 -0
- package/src/scripts/_lib/carrier_status.ts +0 -118
- package/src/scripts/_lib/estate_carrier_cases.ts +0 -72
- package/src/scripts/hooks/code_graph_nudge_hook.ts +0 -191
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trust contract for the session-start memory index.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, AND WHOSE RULING IT IMPLEMENTS.
|
|
5
|
+
*
|
|
6
|
+
* The `memory.session_index` block injects curated memory at `session_start`.
|
|
7
|
+
* Restored memory is UNTRUSTED CONTEXT arriving on an injection surface, and an
|
|
8
|
+
* AI council (2026-09-07, ruling D3, recorded in
|
|
9
|
+
* `road-to-continuity-writer-activation` step 3.1) required six properties —
|
|
10
|
+
* repository and worktree identity, path canonicalization, freshness, ordering,
|
|
11
|
+
* duplicate invocation and size limits — each with a test, BEFORE the restore
|
|
12
|
+
* moves off the `hot-context` concern. Five of the six did not exist: measured
|
|
13
|
+
* at HEAD, `session_memory_index.ts` implemented size limits only. This module
|
|
14
|
+
* is those six in one place, so the relocation has something to move.
|
|
15
|
+
*
|
|
16
|
+
* THE THREAT, STATED CONCRETELY RATHER THAN AS A CATEGORY.
|
|
17
|
+
*
|
|
18
|
+
* `MEMORY_ROOT` in `memory_lookup.ts` is the RELATIVE path `agents/memory`, so
|
|
19
|
+
* it resolves against the process cwd. The hook therefore chdirs to a workspace
|
|
20
|
+
* root it received from a host payload and reads memory from wherever that
|
|
21
|
+
* landed. Nothing checked that the root was the workspace the session belongs
|
|
22
|
+
* to, nothing resolved symlinks before trusting it, and nothing stopped a second
|
|
23
|
+
* `session_start` in one session from injecting the block twice. So the
|
|
24
|
+
* attacker this module answers is not an intruder: it is a wrong or stale root —
|
|
25
|
+
* a sibling checkout, a worktree pointed at another tree, a leftover directory,
|
|
26
|
+
* a symlink out of the workspace — serving another repository's curated memory
|
|
27
|
+
* into this session as though it were this repository's.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IT DOES NOT CLAIM.
|
|
30
|
+
*
|
|
31
|
+
* It does not authenticate the memory's CONTENT. A curated entry that is wrong,
|
|
32
|
+
* or hostile, and sits at the correct path inside the correct workspace passes
|
|
33
|
+
* every property here — that is the review boundary of `agents/memory/`, not a
|
|
34
|
+
* check's. Nor does it make the index trustworthy in the sense that a model may
|
|
35
|
+
* act on it: the block stays spotlighted as DATA, per `untrusted-input-defense`,
|
|
36
|
+
* and this module bounds only WHICH bytes reach that block.
|
|
37
|
+
*/
|
|
38
|
+
import * as fs from 'node:fs';
|
|
39
|
+
import * as path from 'node:path';
|
|
40
|
+
|
|
41
|
+
/** P6 — the row cap. Owned here so all six properties read from one place. */
|
|
42
|
+
export const SESSION_INDEX_ROW_CAP = 30;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* P3 — the abandonment bound, in days.
|
|
46
|
+
*
|
|
47
|
+
* A STATED DEFAULT, NOT A MEASURED OPTIMUM, said plainly because a number that
|
|
48
|
+
* looks derived and is not is worse than an admitted guess. Curated memory is
|
|
49
|
+
* deliberately long-lived — a stable `product-rules.yml` is not stale at 90
|
|
50
|
+
* days — so the bound is set well beyond any legitimate quiet period and exists
|
|
51
|
+
* to catch an ABANDONED root, not an inactive one. The two conditions that
|
|
52
|
+
* would falsify it are recorded in `road-to-continuity-writer-activation`
|
|
53
|
+
* step 3.1 rather than here.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_ROOT_AGE_DAYS = 400;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Curated sources, matching the layouts `memory_lookup._iter_curated_entries`
|
|
59
|
+
* actually reads: `<root>/<type>.yml` AND `<root>/<type>/**\/*.yml`, plus the
|
|
60
|
+
* agent-written `intake/*.jsonl`. Reading only the first of those is what made
|
|
61
|
+
* P3 silently no-op on two of the three layouts — R2 finding 4.
|
|
62
|
+
*/
|
|
63
|
+
const CURATED_SUFFIXES = ['.yml', '.yaml', '.jsonl'] as const;
|
|
64
|
+
|
|
65
|
+
/** Depth bound on the walk. A memory root is shallow; a cycle is not. */
|
|
66
|
+
const MAX_WALK_DEPTH = 8;
|
|
67
|
+
|
|
68
|
+
export type TrustRefusalCode =
|
|
69
|
+
| 'workspace-root-unresolvable'
|
|
70
|
+
| 'memory-root-absent'
|
|
71
|
+
| 'memory-root-escapes-workspace'
|
|
72
|
+
| 'curated-source-escapes-workspace'
|
|
73
|
+
| 'source-mtime-in-future'
|
|
74
|
+
| 'memory-root-abandoned'
|
|
75
|
+
| 'already-injected-this-session';
|
|
76
|
+
|
|
77
|
+
export interface TrustRefusal {
|
|
78
|
+
readonly ok: false;
|
|
79
|
+
readonly code: TrustRefusalCode;
|
|
80
|
+
readonly detail: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface TrustGrant {
|
|
84
|
+
readonly ok: true;
|
|
85
|
+
/** Canonical, containment-checked absolute path to the memory root. */
|
|
86
|
+
readonly memoryRoot: string;
|
|
87
|
+
/** Canonical absolute path to the workspace the root was checked against. */
|
|
88
|
+
readonly workspaceRoot: string;
|
|
89
|
+
/** P3 — mtime of the newest curated source read, epoch ms; 0 when none. */
|
|
90
|
+
readonly newestSourceMs: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type TrustVerdict = TrustGrant | TrustRefusal;
|
|
94
|
+
|
|
95
|
+
function refuse(code: TrustRefusalCode, detail: string): TrustRefusal {
|
|
96
|
+
return { ok: false, code, detail };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* P2 — canonicalize, then compare by SEGMENTS.
|
|
101
|
+
*
|
|
102
|
+
* A string prefix test says `/a/bc` is inside `/a/b`, which is how a
|
|
103
|
+
* containment check passes something it should refuse. Splitting on the
|
|
104
|
+
* separator makes the comparison mean what it says.
|
|
105
|
+
*/
|
|
106
|
+
export function isContained(child: string, parent: string): boolean {
|
|
107
|
+
const rel = path.relative(parent, child);
|
|
108
|
+
if (rel === '') return true;
|
|
109
|
+
if (path.isAbsolute(rel)) return false;
|
|
110
|
+
return !rel.split(path.sep).includes('..');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Canonicalize a path, following symlinks. `null` when it does not resolve. */
|
|
114
|
+
export function canonical(p: string): string | null {
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync(p);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface SourceWalk {
|
|
123
|
+
/** Newest mtime across every curated source read, epoch ms; 0 when none. */
|
|
124
|
+
readonly newestMs: number;
|
|
125
|
+
/** Sources whose canonical path lies outside the workspace. P1's other half. */
|
|
126
|
+
readonly escaped: readonly string[];
|
|
127
|
+
/** How many sources the walk actually read — 0 means "nothing to judge". */
|
|
128
|
+
readonly count: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One recursive walk serving P1's file half and P3's reading, because R2 found
|
|
133
|
+
* both defects in the same omission: the previous version read only `*.yml`
|
|
134
|
+
* directly at the root, which is one of the three layouts
|
|
135
|
+
* `memory_lookup._iter_curated_entries` supports. So freshness silently
|
|
136
|
+
* no-opped on a type-directory corpus (a 3-year-old one was served past the
|
|
137
|
+
* 400-day bound), and containment was never checked below the root at all.
|
|
138
|
+
*
|
|
139
|
+
* The containment half is the sharper of the two. Canonicalizing the ROOT does
|
|
140
|
+
* not stop a symlinked FILE inside it from resolving into another tree, and
|
|
141
|
+
* that is the exact threat this module's docblock names — proven by the
|
|
142
|
+
* reviewer, who read a donor workspace's entry out of a victim session.
|
|
143
|
+
*
|
|
144
|
+
* Per-entry timestamps do not exist (a curated entry is `{id, key, body}`), so
|
|
145
|
+
* freshness stays a property of the source files rather than something invented
|
|
146
|
+
* per row.
|
|
147
|
+
*/
|
|
148
|
+
export function walkCuratedSources(memoryRoot: string, workspaceRoot: string): SourceWalk {
|
|
149
|
+
let newestMs = 0;
|
|
150
|
+
let count = 0;
|
|
151
|
+
const escaped: string[] = [];
|
|
152
|
+
|
|
153
|
+
const visit = (dir: string, depth: number): void => {
|
|
154
|
+
if (depth > MAX_WALK_DEPTH) return;
|
|
155
|
+
let names: fs.Dirent[];
|
|
156
|
+
try {
|
|
157
|
+
names = fs.readdirSync(dir, { withFileTypes: true });
|
|
158
|
+
} catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
for (const ent of names) {
|
|
162
|
+
const full = path.join(dir, ent.name);
|
|
163
|
+
// `withFileTypes` reports a symlink as a symlink, so a directory
|
|
164
|
+
// reached through one is followed only after it canonicalizes
|
|
165
|
+
// inside the workspace — which is the containment check itself.
|
|
166
|
+
const resolved = canonical(full);
|
|
167
|
+
if (resolved === null) continue;
|
|
168
|
+
if (!isContained(resolved, workspaceRoot)) {
|
|
169
|
+
escaped.push(full);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
let st: fs.Stats;
|
|
173
|
+
try {
|
|
174
|
+
st = fs.statSync(resolved);
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (st.isDirectory()) {
|
|
179
|
+
visit(resolved, depth + 1);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!CURATED_SUFFIXES.some((sfx) => ent.name.endsWith(sfx))) continue;
|
|
183
|
+
count += 1;
|
|
184
|
+
if (st.mtimeMs > newestMs) newestMs = st.mtimeMs;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
visit(memoryRoot, 0);
|
|
189
|
+
return { newestMs, escaped, count };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface TrustInput {
|
|
193
|
+
/** The workspace root the session belongs to, as the host reported it. */
|
|
194
|
+
readonly workspaceRoot: string;
|
|
195
|
+
/** Relative memory root, matching `memory_lookup.MEMORY_ROOT`. */
|
|
196
|
+
readonly relativeMemoryRoot?: string;
|
|
197
|
+
/** Injected for tests; defaults to now. */
|
|
198
|
+
readonly now?: Date;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* P1 + P2 + P3 — decide whether a memory root may be read at all.
|
|
203
|
+
*
|
|
204
|
+
* Ordered cheapest-first, and each refusal names itself: a caller that logs the
|
|
205
|
+
* code can tell "this workspace has no memory" from "this root pointed
|
|
206
|
+
* somewhere else", which is the distinction the whole module exists for.
|
|
207
|
+
*/
|
|
208
|
+
export function verifyMemoryRoot(input: TrustInput): TrustVerdict {
|
|
209
|
+
const now = input.now ?? new Date();
|
|
210
|
+
const rel = input.relativeMemoryRoot ?? path.join('agents', 'memory');
|
|
211
|
+
|
|
212
|
+
// P2 — the workspace root is canonicalized FIRST. Everything downstream
|
|
213
|
+
// compares against the resolved form, never the reported one.
|
|
214
|
+
const workspaceRoot = canonical(input.workspaceRoot);
|
|
215
|
+
if (workspaceRoot === null) {
|
|
216
|
+
return refuse('workspace-root-unresolvable', `workspace root does not resolve: ${input.workspaceRoot}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// P1 — identity, checked LEXICALLY first and on the canonical path second.
|
|
220
|
+
// The order is load-bearing and a test found it: a `..` root whose target
|
|
221
|
+
// does not exist would otherwise be reported as merely absent, which reads
|
|
222
|
+
// as "this workspace has no memory" when the truth is "this root pointed
|
|
223
|
+
// outside the tree". The escape is the more important fact, so it is the
|
|
224
|
+
// one reported, whether or not the target happens to exist.
|
|
225
|
+
const lexical = path.resolve(workspaceRoot, rel);
|
|
226
|
+
if (!isContained(lexical, workspaceRoot)) {
|
|
227
|
+
return refuse(
|
|
228
|
+
'memory-root-escapes-workspace',
|
|
229
|
+
`${lexical} is not inside ${workspaceRoot} — refusing to serve another tree's memory`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const memoryRoot = canonical(lexical);
|
|
234
|
+
if (memoryRoot === null) {
|
|
235
|
+
return refuse('memory-root-absent', `no memory root at ${path.join(input.workspaceRoot, rel)}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The second half of P1, and the half a lexical check cannot see: the path
|
|
239
|
+
// is inside the tree and the SYMLINK it resolves through is not.
|
|
240
|
+
if (!isContained(memoryRoot, workspaceRoot)) {
|
|
241
|
+
return refuse(
|
|
242
|
+
'memory-root-escapes-workspace',
|
|
243
|
+
`${memoryRoot} is not inside ${workspaceRoot} — refusing to serve another tree's memory`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// P1's file half + P3, from one walk. R2 findings 3 and 4.
|
|
248
|
+
const walk = walkCuratedSources(memoryRoot, workspaceRoot);
|
|
249
|
+
if (walk.escaped.length > 0) {
|
|
250
|
+
return refuse(
|
|
251
|
+
'curated-source-escapes-workspace',
|
|
252
|
+
`${walk.escaped.length} curated source(s) resolve outside ${workspaceRoot}, first ${walk.escaped[0]} — refusing to serve another tree's memory`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// P3 — freshness, in the two directions it can fail.
|
|
257
|
+
const newestSourceMs = walk.newestMs;
|
|
258
|
+
if (newestSourceMs > now.getTime() + 60_000) {
|
|
259
|
+
return refuse(
|
|
260
|
+
'source-mtime-in-future',
|
|
261
|
+
`newest curated source is dated in the future (${new Date(newestSourceMs).toISOString()}) — clock skew or tampering`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (newestSourceMs > 0) {
|
|
265
|
+
const ageDays = (now.getTime() - newestSourceMs) / 86_400_000;
|
|
266
|
+
if (ageDays > MAX_ROOT_AGE_DAYS) {
|
|
267
|
+
return refuse(
|
|
268
|
+
'memory-root-abandoned',
|
|
269
|
+
`newest curated source is ${ageDays.toFixed(0)}d old, past the ${MAX_ROOT_AGE_DAYS}d abandonment bound`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { ok: true, memoryRoot, workspaceRoot, newestSourceMs };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface OrderableRow {
|
|
278
|
+
readonly id: string;
|
|
279
|
+
readonly title: string;
|
|
280
|
+
readonly tokens_estimate: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* P4 — the declared total order.
|
|
285
|
+
*
|
|
286
|
+
* Ordering is a trust property because the CAP truncates: without a declared
|
|
287
|
+
* order, which 30 of 200 entries reach the model is whatever the filesystem
|
|
288
|
+
* happened to yield, and two runs over the same tree can advertise different
|
|
289
|
+
* corpora. Cheapest row first (`tokens_estimate` ascending) so a fixed cap
|
|
290
|
+
* carries the most entries it can, with `id` ascending as the tie-break that
|
|
291
|
+
* makes the order TOTAL rather than merely defined — a comparator with ties is
|
|
292
|
+
* not deterministic under an unstable sort.
|
|
293
|
+
*/
|
|
294
|
+
export function orderRows<T extends OrderableRow>(rows: readonly T[]): T[] {
|
|
295
|
+
return [...rows].sort((a, b) => {
|
|
296
|
+
if (a.tokens_estimate !== b.tokens_estimate) return a.tokens_estimate - b.tokens_estimate;
|
|
297
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** P6 — apply the cap after ordering, never before. */
|
|
302
|
+
export function capRows<T>(rows: readonly T[], cap: number = SESSION_INDEX_ROW_CAP): T[] {
|
|
303
|
+
const limit = Number.isFinite(cap) && cap > 0 ? Math.min(cap, SESSION_INDEX_ROW_CAP) : SESSION_INDEX_ROW_CAP;
|
|
304
|
+
return rows.slice(0, limit);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Where the P5 latch lives. Gitignored runtime state, one file per session. */
|
|
308
|
+
export function latchPath(workspaceRoot: string, sessionId: string): string {
|
|
309
|
+
const safe = sessionId.replace(/[^A-Za-z0-9._-]/gu, '_').slice(0, 128);
|
|
310
|
+
return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'session-index-latch', `${safe}.json`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* P5 — duplicate invocation.
|
|
315
|
+
*
|
|
316
|
+
* `session_start` can fire more than once for one session (a resume, a
|
|
317
|
+
* host-side reconnect, a compaction boundary on some hosts). Two injections
|
|
318
|
+
* double the fixed cost and can present two different corpora as the same
|
|
319
|
+
* session's memory. The latch is create-exclusive (`wx`), so the FIRST caller
|
|
320
|
+
* wins and every later one refuses — an ordering that matters, because
|
|
321
|
+
* check-then-write would let two concurrent starts both pass.
|
|
322
|
+
*
|
|
323
|
+
* Fails OPEN on an unwritable state directory: a hook that cannot latch must
|
|
324
|
+
* still be able to serve the index once, and refusing there would make a
|
|
325
|
+
* read-only workspace lose the feature entirely. That is a deliberate trade and
|
|
326
|
+
* it is the reason this property is a latch rather than a gate.
|
|
327
|
+
*/
|
|
328
|
+
export function claimSessionOnce(workspaceRoot: string, sessionId: string): TrustVerdict | { ok: true } {
|
|
329
|
+
if (sessionId.length === 0) {
|
|
330
|
+
// No session id to key on — cannot latch, so do not pretend to.
|
|
331
|
+
return { ok: true };
|
|
332
|
+
}
|
|
333
|
+
const target = latchPath(workspaceRoot, sessionId);
|
|
334
|
+
try {
|
|
335
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
336
|
+
fs.writeFileSync(target, JSON.stringify({ claimed_at: new Date().toISOString() }), { flag: 'wx' });
|
|
337
|
+
return { ok: true };
|
|
338
|
+
} catch (exc) {
|
|
339
|
+
const code = (exc as { code?: string }).code;
|
|
340
|
+
if (code === 'EEXIST') {
|
|
341
|
+
return refuse('already-injected-this-session', `session ${sessionId} already received the index`);
|
|
342
|
+
}
|
|
343
|
+
// fail-open — see the docblock
|
|
344
|
+
return { ok: true };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -75,40 +75,146 @@
|
|
|
75
75
|
*/
|
|
76
76
|
|
|
77
77
|
import * as fs from 'node:fs';
|
|
78
|
+
import * as os from 'node:os';
|
|
78
79
|
import * as path from 'node:path';
|
|
79
80
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Catalogue roots under the WORKSPACE, in precedence order.
|
|
83
|
+
*
|
|
84
|
+
* `src/skills` is the authored tree and exists only in a maintainer checkout.
|
|
85
|
+
* `.claude/skills` is the project-scope projection — a consumer's own skills
|
|
86
|
+
* live there, and so do this repository's flat-command wrappers.
|
|
87
|
+
*/
|
|
88
|
+
export const PROJECT_CATALOGUE_ROOTS = ['src/skills', '.claude/skills'] as const;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The catalogue root under the user's HOME — what the host itself delivers.
|
|
92
|
+
*
|
|
93
|
+
* Home-relative rather than workspace-relative, which is why it cannot live in
|
|
94
|
+
* the list above: `~/.claude/skills` is one directory for every project on the
|
|
95
|
+
* machine, and on a consumer machine it is where `agent-config install` puts the
|
|
96
|
+
* shipped skills.
|
|
97
|
+
*/
|
|
98
|
+
export const HOST_CATALOGUE_ROOT = '.claude/skills';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The PROJECTION root, on its own, for the consumers that need exactly it.
|
|
102
|
+
*
|
|
103
|
+
* `capture_skill_catalogue` compares what a host DELIVERED against what was
|
|
104
|
+
* PROJECTED, so the authored tree is the wrong answer for it even though the
|
|
105
|
+
* ranker wants the authored tree first. Both questions were sharing
|
|
106
|
+
* `DEFAULT_CATALOGUE_ROOTS` and the 2026-09-07 reorder silently changed the
|
|
107
|
+
* second one — the finding a neutral review returned the same day. One constant
|
|
108
|
+
* for two questions is the defect; this is the second question, named.
|
|
109
|
+
*/
|
|
110
|
+
export const PROJECTION_CATALOGUE_ROOT = '.claude/skills';
|
|
82
111
|
|
|
83
112
|
/**
|
|
84
|
-
*
|
|
113
|
+
* Every root the resolver looks at, as human-readable LABELS.
|
|
114
|
+
*
|
|
115
|
+
* **Labels, never path inputs — this changed on 2026-09-07.**
|
|
116
|
+
*
|
|
117
|
+
* It used to be the resolver's own candidate list and callers joined it onto a
|
|
118
|
+
* root. It now carries a `~/`-prefixed entry, which `path.join(repo, ...)` turns
|
|
119
|
+
* into `<repo>/~/.claude/skills` — a candidate that can never exist. Use
|
|
120
|
+
* {@link resolveSkillCatalogueRoots} to RESOLVE and this list only to REPORT
|
|
121
|
+
* what was searched.
|
|
122
|
+
*
|
|
123
|
+
* For the `searched:` / `tried:` fields of the no-catalogue answers only — a
|
|
124
|
+
* reader needs to know `~` was consulted, and an absolute home path in a
|
|
125
|
+
* machine-readable payload is both noise and a small privacy leak.
|
|
126
|
+
*/
|
|
127
|
+
export const DEFAULT_CATALOGUE_ROOTS = [
|
|
128
|
+
...PROJECT_CATALOGUE_ROOTS,
|
|
129
|
+
`~/${HOST_CATALOGUE_ROOT}`,
|
|
130
|
+
] as const;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* EVERY readable, non-empty catalogue root, in precedence order.
|
|
134
|
+
*
|
|
135
|
+
* Shared by the `skill-route` concern and the `suggest_skill_for_task` MCP
|
|
136
|
+
* handler on purpose: two resolvers over one catalogue is how a ranker and the
|
|
137
|
+
* tool that exposes it start ranking different trees.
|
|
138
|
+
*
|
|
139
|
+
* **Why a LIST, and not the first hit — owner decision, 2026-09-07.**
|
|
140
|
+
*
|
|
141
|
+
* This returned one root and stopped at the first match, which made the ranker
|
|
142
|
+
* see one tree and the session see another. A consumer project can carry its own
|
|
143
|
+
* skills in `<project>/.claude/skills` while the shipped catalogue sits in
|
|
144
|
+
* `~/.claude/skills`; first-hit-wins ranked the smaller of the two and reported
|
|
145
|
+
* the result as if it were the catalogue. Ranking the UNION ranks what the
|
|
146
|
+
* session can actually reach, which is the only set the answer is about.
|
|
147
|
+
*
|
|
148
|
+
* **The order, and what it is for.**
|
|
85
149
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* `
|
|
89
|
-
*
|
|
90
|
-
*
|
|
150
|
+
* Precedence applies to a NAME COLLISION, never to which roots are read:
|
|
151
|
+
*
|
|
152
|
+
* 1. `src/skills` — the authored tree. In this repository it is the truth, and
|
|
153
|
+
* a maintainer editing a skill must see their edit ranked, not the
|
|
154
|
+
* projection of the last `generate-tools` run.
|
|
155
|
+
* 2. `<project>/.claude/skills` — project scope beats machine scope, the same
|
|
156
|
+
* direction every other per-project override in this suite takes.
|
|
157
|
+
* 3. `~/${HOST_CATALOGUE_ROOT}` — the host-global catalogue.
|
|
158
|
+
*
|
|
159
|
+
* **The two defects this replaced, both real.**
|
|
160
|
+
*
|
|
161
|
+
* It used to try `.claude/skills` FIRST, "because a CONSUMER install carries the
|
|
162
|
+
* former and only a maintainer checkout carries the latter". The premise was
|
|
163
|
+
* right and the conclusion did not follow: a consumer has no `src/skills`, so
|
|
164
|
+
* reading it first costs one `existsSync`.
|
|
165
|
+
*
|
|
166
|
+
* The ADR-236 amendment of 2026-09-07 turned that order into a live defect. The
|
|
167
|
+
* project layer now withholds every skill `~/.claude/skills` carries, so this
|
|
168
|
+
* repository's `.claude/skills` holds only the ~49 flat-command WRAPPERS —
|
|
169
|
+
* non-empty, so the old order resolved it, and each wrapper carries a `SKILL.md`,
|
|
170
|
+
* so nothing downstream could tell it was not the catalogue. Measured the same
|
|
171
|
+
* day: the `skill-route` hook ranked over 49 command wrappers instead of 299
|
|
172
|
+
* skills. Under the union those wrappers are still ranked, which is correct —
|
|
173
|
+
* they are reachable — but they no longer displace the catalogue.
|
|
174
|
+
*
|
|
175
|
+
* The non-empty guard keeps its own separate job: an EMPTY root read as a match
|
|
176
|
+
* made the ranker report an empty catalogue as an empty RESULT, the silent
|
|
177
|
+
* failure `road-to-inbox-harvest-2026-08-f-skill-selection-evidence` is about.
|
|
178
|
+
* Measured in that run: a worktree that had never run `generate-tools` ranked
|
|
179
|
+
* zero skills and exited 0 on a task that scores 47 against `src/skills`.
|
|
180
|
+
*
|
|
181
|
+
* @param home override for the user's home; production callers omit it.
|
|
91
182
|
*/
|
|
92
|
-
export function
|
|
93
|
-
|
|
94
|
-
|
|
183
|
+
export function resolveSkillCatalogueRoots(workspaceRoot: string, home?: string): string[] {
|
|
184
|
+
const userHome = home ?? process.env['HOME'] ?? os.homedir();
|
|
185
|
+
const candidates = [
|
|
186
|
+
...PROJECT_CATALOGUE_ROOTS.map((c) => path.join(workspaceRoot, c)),
|
|
187
|
+
path.join(userHome, HOST_CATALOGUE_ROOT),
|
|
188
|
+
];
|
|
189
|
+
const roots: string[] = [];
|
|
190
|
+
const seen = new Set<string>();
|
|
191
|
+
for (const abs of candidates) {
|
|
192
|
+
// A workspace that IS the home directory would offer `~/.claude/skills`
|
|
193
|
+
// twice and rank every skill in it twice. Cheap to exclude, and the
|
|
194
|
+
// duplicate would be invisible in the output.
|
|
195
|
+
if (seen.has(abs)) continue;
|
|
196
|
+
seen.add(abs);
|
|
95
197
|
try {
|
|
96
198
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) continue;
|
|
97
|
-
// NON-EMPTY, and that qualifier is the whole fix. `.claude/skills`
|
|
98
|
-
// is a gitignored projection, so in a fresh worktree it EXISTS and
|
|
99
|
-
// holds nothing — and an empty root resolved as a match makes the
|
|
100
|
-
// ranker report an empty catalogue as an empty RESULT, which is the
|
|
101
|
-
// silent failure `road-to-inbox-harvest-2026-08-f-skill-selection-evidence`
|
|
102
|
-
// is about, arriving through a second door. Measured in that run: a
|
|
103
|
-
// worktree that had never run `generate-tools` ranked zero skills
|
|
104
|
-
// and exited 0 on a task that scores 47 against `src/skills`.
|
|
105
199
|
if (fs.readdirSync(abs).length === 0) continue;
|
|
106
|
-
|
|
200
|
+
roots.push(abs);
|
|
107
201
|
} catch {
|
|
108
202
|
// Unreadable candidate is not a match; try the next.
|
|
109
203
|
}
|
|
110
204
|
}
|
|
111
|
-
return
|
|
205
|
+
return roots;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The highest-precedence root, or `null` when none is readable.
|
|
210
|
+
*
|
|
211
|
+
* Kept for the surfaces that genuinely name ONE directory — a report line, a
|
|
212
|
+
* `skills_root` field. Anything that RANKS should take
|
|
213
|
+
* {@link resolveSkillCatalogueRoots} instead: ranking the first root only is the
|
|
214
|
+
* defect that function exists to remove.
|
|
215
|
+
*/
|
|
216
|
+
export function resolveSkillsRoot(workspaceRoot: string, home?: string): string | null {
|
|
217
|
+
return resolveSkillCatalogueRoots(workspaceRoot, home)[0] ?? null;
|
|
112
218
|
}
|
|
113
219
|
|
|
114
220
|
/** Where observations accumulate. Append-only, one JSON object per line. */
|
|
@@ -223,6 +329,35 @@ function descriptionOf(frontmatter: string): string {
|
|
|
223
329
|
* the order a host listing follows and therefore the order a positional
|
|
224
330
|
* hypothesis is measured against.
|
|
225
331
|
*/
|
|
332
|
+
/**
|
|
333
|
+
* The catalogue as the RANKER sees it — every readable root, first name winning.
|
|
334
|
+
*
|
|
335
|
+
* Added 2026-09-07 after a second neutral review. `capture_skill_catalogue`'s D-4
|
|
336
|
+
* join says it reads "the catalogue the runtime ranker reads", and once the ranker
|
|
337
|
+
* moved to the union a single-root read stopped being that. Worse, the root the
|
|
338
|
+
* capture had been pointed at — `.claude/skills` — is the one the ADR-236
|
|
339
|
+
* amendment EMPTIED of skills: the join then read 49 command wrappers as the
|
|
340
|
+
* catalogue and published `pointableBare: 0` against `unpointableBare: 16`, a
|
|
341
|
+
* clean-looking verdict off the wrong tree, with the empty-guard silent because
|
|
342
|
+
* 49 is not 0.
|
|
343
|
+
*
|
|
344
|
+
* Positions are re-derived over the merged list, because a position is a property
|
|
345
|
+
* of the catalogue the host is shown, not of the root a name came from.
|
|
346
|
+
*/
|
|
347
|
+
export function readCatalogueAcross(roots: readonly string[]): CatalogueEntry[] {
|
|
348
|
+
const seen = new Set<string>();
|
|
349
|
+
const merged: CatalogueEntry[] = [];
|
|
350
|
+
for (const root of roots) {
|
|
351
|
+
for (const entry of readProjectedCatalogue(root)) {
|
|
352
|
+
if (seen.has(entry.name)) continue;
|
|
353
|
+
seen.add(entry.name);
|
|
354
|
+
merged.push(entry);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
merged.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
358
|
+
return merged.map((e, i) => ({ ...e, position: i + 1 }));
|
|
359
|
+
}
|
|
360
|
+
|
|
226
361
|
export function readProjectedCatalogue(root: string): CatalogueEntry[] {
|
|
227
362
|
// Membership is decided by "does <name>/SKILL.md resolve", never by
|
|
228
363
|
// `Dirent.isDirectory()`. The host-facing projection is a tree of SYMLINKS
|