From af2f33429b52c9362228b2e3f3925d28983daa03 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Fri, 17 Jul 2026 12:47:11 +0400 Subject: [PATCH 01/11] fix: make startup and cross-layer retrieval answer-ready --- CHANGELOG.md | 10 + docs/auto-refresh.md | 6 +- .../README.md | 38 + .../receipt.share-safe.json | 122 ++++ src/contracts/retrieval-plan.ts | 10 + src/infrastructure/context-pack-command.ts | 259 ++++++- src/infrastructure/indexing-manifest.ts | 47 +- src/infrastructure/watch.ts | 126 +++- src/runtime/context-pack-recovery.ts | 6 + src/runtime/context-pack.ts | 41 +- src/runtime/retrieval-gate.ts | 12 +- src/runtime/retrieve.ts | 58 +- src/runtime/retrieve/conceptual-fallback.ts | 658 ++++++++++++++++-- src/runtime/stdio-server.ts | 160 +++-- src/shared/discovery-safety.ts | 4 + tests/fixtures/cross-layer-monitor-flow.ts | 143 ++++ tests/unit/background-auto-refresh.test.ts | 100 ++- tests/unit/context-pack-command.test.ts | 108 +++ tests/unit/indexing-completeness.test.ts | 27 + tests/unit/mcp-response-evidence.test.ts | 14 + tests/unit/retrieval-gate.test.ts | 9 + tests/unit/retrieve-cross-layer-flow.test.ts | 148 ++++ tests/unit/stdio-server.test.ts | 35 +- tests/unit/watch.test.ts | 106 +++ 24 files changed, 2087 insertions(+), 160 deletions(-) create mode 100644 docs/benchmarks/2026-07-17-openstatus-activation/README.md create mode 100644 docs/benchmarks/2026-07-17-openstatus-activation/receipt.share-safe.json create mode 100644 tests/fixtures/cross-layer-monitor-flow.ts create mode 100644 tests/unit/retrieve-cross-layer-flow.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f477fbc5..ded76529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to the TypeScript package will be documented in this file. +## [Unreleased] + +### Changed + +- **Cross-layer questions recover by query obligation instead of repeating the loudest vocabulary**: retrieval splits multi-stage flow questions into bounded obligations, reserves structurally connected anchors across distinct communities, and reports initial/final obligation coverage plus promoted communities in the retrieval plan. Explain packs can now treat a diverse cross-file set of direct workflow owners as supporting evidence instead of replacing stronger obligation anchors with weaker related candidates merely to satisfy a ranking label. Execution-owner questions such as “what runs the monthly close?” now receive behavior-slice retrieval, and exact file ownership remains available even when clustering separates a file node from its symbols. Addresses #565. + +### Fixed + +- **A valid unchanged graph becomes usable without rebuilding at every MCP startup**: automatic refresh validates generation policy, graph freshness, indexing outcomes, the authoritative source snapshot, deletions, additions, ignored discovery paths, and control-file changes before reusing a graph. A graph-backed request waits through a bounded transient reconciliation window and completes as the same request once ready, while MCP initialization, discovery, and ping remain responsive. Changed, missing, incomplete, or policy-mismatched graphs still rebuild or fail closed. Addresses #564. + ## [0.31.3] - 2026-07-17 ### Fixed diff --git a/docs/auto-refresh.md b/docs/auto-refresh.md index c738dd5d..c5ef350b 100644 --- a/docs/auto-refresh.md +++ b/docs/auto-refresh.md @@ -1,6 +1,6 @@ # Auto-refresh and generation policy -Installed MCP profiles run `madar serve --stdio --auto-refresh`. The stdio transport becomes available immediately while automatic refresh runs in a background worker. Before that worker starts, Madar publishes a `starting` watcher state so graph-backed requests fail closed instead of reading an older graph. The worker then starts a recursive filesystem listener before its initial graph reconciliation, marks the graph pending as soon as a relevant event arrives, and performs an authoritative source snapshot before publishing the graph as usable again. +Installed MCP profiles run `madar serve --stdio --auto-refresh`. The stdio transport becomes available immediately while automatic refresh runs in a background worker. Before that worker starts, Madar publishes a `starting` watcher state so graph-backed requests cannot read an unvalidated graph. The worker starts a recursive filesystem listener and takes an authoritative source snapshot. When the existing graph has matching generation policy, complete indexing outcomes, fresh source fingerprints, no added or deleted candidates, and no newer control files, Madar publishes it as usable without rebuilding it. Any uncertainty or detected change keeps the existing rebuild-and-reconcile path. Filesystem events provide low-latency invalidation; they are not the correctness boundary. Madar also performs full reconciliations on an adaptive schedule. Idle intervals back off from 30 seconds to at most 5 minutes when recursive events are available. Platforms without recursive events use adaptive polling from 1 second to at most 30 seconds. The lower-level `pollIntervalMs` option is an internal/test override rather than a CLI setting. @@ -36,9 +36,9 @@ The local `watcher-state.json` beside `graph.json` is written atomically and inc - pending/failure details; and - stored/current policy fingerprints and match state. -`madar doctor` and `madar status` render those fields. During an auto-refresh MCP session, graph-backed prompts, resources, completions, and tool calls fail closed until the watcher is `idle` with matching published policy. Transient `starting`, `pending`, and `reconciling` responses use the structured MCP error type `madar_graph_not_ready` with `retryable: true`, `retry_after_ms: 1000`, and `suggested_action: "retry_same_request"`. Agents should retry the same Madar request; they do not need to bypass Madar or run generation while reconciliation is active. Terminal `failed`, incomplete, and policy-mismatched states return `retryable: false` with `suggested_action: "repair_graph"`; inspect `madar status`, then run `madar generate . --update` when repair is required. +`madar doctor` and `madar status` render those fields. During an auto-refresh MCP session, graph-backed prompts, resources, completions, and tool calls remain fail-closed until the watcher is `idle` with matching published policy. A request that arrives while the graph is transiently `starting`, `pending`, or `reconciling` waits for readiness for up to 25 seconds by default. If reconciliation finishes, that same request completes against the ready graph; the agent does not need to issue it again. If the bounded wait expires, Madar returns `madar_graph_not_ready` with `retryable: true`, the measured `waited_ms`, `retry_after_ms: 1000`, and `suggested_action: "retry_same_request"`. Terminal `failed`, incomplete, and policy-mismatched states return immediately with `retryable: false` and `suggested_action: "repair_graph"`; inspect `madar status`, then run `madar generate . --update` when repair is required. -MCP initialization, ping, and list/discovery requests remain responsive during `starting` and `reconciling`. This lets an agent connect without waiting for a cold large-repository build while preserving the same freshness boundary for every graph answer. +MCP initialization, ping, and list/discovery requests remain responsive while a graph-backed request is waiting during `starting` and `reconciling`. This lets an agent connect and inspect capabilities without waiting for a cold large-repository build while preserving the same freshness boundary for every graph answer. The refresh lease serializes multiple MCP processes that target the same workspace. If the recorded owner process is dead, Madar reclaims the lease immediately. If another live process owns it, auto-refresh remains in a retryable reconciliation state and waits with bounded backoff until the lease is released or the server shuts down; contention does not permanently fail the watcher. Graph, source-manifest, indexing-manifest, report, and watcher-state publications use same-filesystem atomic renames. A post-build reconciliation detects edits made while generation was running and queues another rebuild before the state can return to `idle`. diff --git a/docs/benchmarks/2026-07-17-openstatus-activation/README.md b/docs/benchmarks/2026-07-17-openstatus-activation/README.md new file mode 100644 index 00000000..575af238 --- /dev/null +++ b/docs/benchmarks/2026-07-17-openstatus-activation/README.md @@ -0,0 +1,38 @@ +# 2026-07-17 — OpenStatus activation acceptance + +This folder records a packed-artifact acceptance run against [`openstatusHQ/openstatus`](https://github.com/openstatusHQ/openstatus) for issues [#564](https://github.com/mohanagy/madar/issues/564) and [#565](https://github.com/mohanagy/madar/issues/565). + +The run used the exact cross-layer question that previously made an agent call Madar repeatedly and then fall back to broad repository discovery. It exercised `madar serve --stdio --auto-refresh` from an unchanged detached OpenStatus worktree and sent the first `context_pack` request immediately after MCP initialization. + +## Result + +- MCP initialization: **586 ms** +- First graph-backed response: **3,893 ms** +- Agent-visible Madar requests: **1** +- Raw repository fallback operations: **0** +- Startup rebuild: **no** +- Serialized response: **1,666 / 1,800 tokens** +- Evidence: **high / strong / complete / ready** +- Agent directive: **`answer_from_pack`** +- Broad-search fallback: **`not_needed`** +- Selected-file precision: **7/7 relevant unique files (100%)** +- Unrelated UI files: **0** + +The selected evidence covers the Go checker, workflow incident/notification handling, incident schema, tRPC status computation, and the divergent Connect-RPC status computation. The receipt contains only upstream-relative paths and aggregate graph metadata; it does not contain the external checkout path or source bodies. + +## Artifact and repository + +- Madar artifact: local `npm pack` of `@lubab/madar@0.31.3` +- Tarball SHA-256: `ea71918c1e74fd7b73609f3054d2f90619f117d9d41bbb57cc167bfca525a645` +- OpenStatus commit: `295e5a72f52c172d326aa950e81043e72a4f20c0` +- OpenStatus worktree state: clean +- Graph: 10,496 nodes, 21,437 edges, 24,446,369 bytes +- Indexing: 2,443 indexed, 0 failed, 73 policy-skipped, 85 unsupported + +## Exact prompt + +> Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status in this repository. Cite the exact files and symbols involved, identify any inconsistent status-computation paths, and clearly state any remaining uncertainty. This is read-only; do not change files. + +## Interpretation boundary + +This is implementation acceptance for retrieval and startup behavior, not a release or universal benchmark claim. The issue requires three consecutive Claude trials, three consecutive Codex trials, and human semantic comparison with the direct-search answer before a release claim. Those model trials remain pending and are recorded as such in the JSON receipt. diff --git a/docs/benchmarks/2026-07-17-openstatus-activation/receipt.share-safe.json b/docs/benchmarks/2026-07-17-openstatus-activation/receipt.share-safe.json new file mode 100644 index 00000000..5bc59212 --- /dev/null +++ b/docs/benchmarks/2026-07-17-openstatus-activation/receipt.share-safe.json @@ -0,0 +1,122 @@ +{ + "schema_version": 1, + "generated_at": "2026-07-17T08:45:30Z", + "status": "implementation_acceptance", + "release_claim_eligible": false, + "prompt": "Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status in this repository. Cite the exact files and symbols involved, identify any inconsistent status-computation paths, and clearly state any remaining uncertainty. This is read-only; do not change files.", + "artifact": { + "source": "npm_pack", + "package": "@lubab/madar", + "version": "0.31.3", + "tarball_sha256": "sha256:ea71918c1e74fd7b73609f3054d2f90619f117d9d41bbb57cc167bfca525a645", + "registry_published": false + }, + "external_repository": { + "url": "https://github.com/openstatusHQ/openstatus", + "commit": "295e5a72f52c172d326aa950e81043e72a4f20c0", + "worktree_clean": true + }, + "graph": { + "bytes": 24446369, + "nodes": 10496, + "edges": 21437, + "indexing": { + "state": "partial", + "candidates": 2601, + "indexed": 2443, + "indexed_with_warnings": 0, + "skipped_by_policy": 73, + "unsupported": 85, + "failed": 0 + } + }, + "startup": { + "transport": "stdio", + "auto_refresh": true, + "mcp_initialization_ms": 586, + "first_graph_request_ms": 3893, + "request_count": 1, + "raw_fallback_count": 0, + "graph_rebuilt": false, + "acceptance_threshold_ms": 5000, + "passed": true + }, + "response": { + "serialized_tokens": 1666, + "serialized_token_budget": 1800, + "budget_enforced": true, + "pack_confidence": "high", + "evidence_strength": "strong", + "coverage": "complete", + "answerability": "ready", + "agent_directive": "answer_from_pack", + "broad_search_fallback": "not_needed", + "discovery_exclusions_total": 15, + "discovery_exclusions_relevant": 0, + "indexing_uncertain_total": 158, + "indexing_uncertain_relevant": 0 + }, + "selected_evidence": [ + { + "label": "statusPage.ts", + "source_file": "packages/api/src/router/statusPage.ts", + "line_number": 1, + "obligations": ["public_status_computation"] + }, + { + "label": "triggerNotifications()", + "source_file": "apps/workflows/src/checker/alerting.ts", + "line_number": 17, + "obligations": ["notification_dispatch"] + }, + { + "label": "computeEffectiveStatus()", + "source_file": "packages/api/src/router/effective-status.ts", + "line_number": 11, + "obligations": ["inconsistent_status_computation"] + }, + { + "label": "upsertMonitorStatus()", + "source_file": "apps/workflows/src/checker/alerting.ts", + "line_number": 276, + "obligations": ["failure_transport", "incident_state"] + }, + { + "label": "incident.ts", + "source_file": "packages/db/src/schema/incidents/incident.ts", + "line_number": 1, + "obligations": ["incident_state"] + }, + { + "label": "findOpenIncident()", + "source_file": "apps/workflows/src/checker/index.ts", + "line_number": 37, + "obligations": ["incident_state"] + }, + { + "label": "computeOverallStatus()", + "source_file": "apps/server/src/routes/rpc/handlers/status-page/index.ts", + "line_number": 360, + "obligations": ["public_status_computation", "inconsistent_status_computation"] + }, + { + "label": "UpdateStatus()", + "source_file": "apps/checker/checker/update.go", + "line_number": 29, + "obligations": ["failure_transport"] + } + ], + "human_judgment": { + "selected_nodes": 8, + "selected_unique_files": 7, + "relevant_unique_files": 7, + "selected_file_precision": 1, + "unrelated_ui_files": 0, + "all_five_obligations_present": true + }, + "pending_release_gates": [ + "three consecutive Claude trials", + "three consecutive Codex trials", + "human semantic comparison of final answers against the direct-search baseline" + ] +} diff --git a/src/contracts/retrieval-plan.ts b/src/contracts/retrieval-plan.ts index 73221908..4c181c6e 100644 --- a/src/contracts/retrieval-plan.ts +++ b/src/contracts/retrieval-plan.ts @@ -3,6 +3,7 @@ export type RetrievalFallbackReason = | 'low_workflow_coherence' | 'missing_required_evidence' | 'missing_semantic_evidence' + | 'missing_query_obligations' export type RepositoryVocabularySource = | 'path' @@ -30,11 +31,19 @@ export interface RetrievalFallbackAttempt { vocabulary_sources: RepositoryVocabularySource[] expansion_terms: string[] promoted_candidates: number + /** Community ids represented by positively promoted candidates. */ + promoted_communities?: number[] changed_result: boolean added_selected_files: number removed_selected_files: number } +export interface RetrievalQueryObligationCoverage { + total: number + initially_covered: number + finally_covered: number +} + /** * Bounded, machine-readable account of conceptual-query recovery. * @@ -49,6 +58,7 @@ export interface ContextPackRetrievalPlanDetail { initial: RetrievalQualitySnapshot final: RetrievalQualitySnapshot attempts: RetrievalFallbackAttempt[] + query_obligations?: RetrievalQueryObligationCoverage selected_fallback?: RetrievalFallbackAttempt['fallback'] } diff --git a/src/infrastructure/context-pack-command.ts b/src/infrastructure/context-pack-command.ts index 0bfd32c4..0b42c1f6 100644 --- a/src/infrastructure/context-pack-command.ts +++ b/src/infrastructure/context-pack-command.ts @@ -125,6 +125,7 @@ const ANSWER_READY_COMMUNITY_CAP = 6 const ANSWER_READY_EXPLANATION_CAP = 3 const ANSWER_READY_FIRST_READ_CAP = 3 const ANSWER_READY_WORKFLOW_CENTER_CAP = 4 +const ANSWER_READY_SNIPPET_CHAR_CAP = 200 const WORKFLOW_SPINE_BUDGET_REASON = 'budget too tight for workflow spine' interface AnswerReadyCullCandidate { @@ -431,9 +432,232 @@ function compactAnswerReadyPack(pack: JsonRecord, trimmedFields: string[]): void preserveTrimmedRuntimeEntrypointContextPreview(pack, trimmedFields) trimArrayField(pack, 'matched_nodes', ANSWER_READY_MATCHED_NODE_CAP, trimmedFields) trimArrayField(pack, 'relationships', ANSWER_READY_RELATIONSHIP_CAP, trimmedFields) + filterRelationshipsToRemainingNodes(pack, trimmedFields) trimArrayField(pack, 'community_context', ANSWER_READY_COMMUNITY_CAP, trimmedFields) } +function compactAnswerReadyMatchedNodeMetadata(pack: JsonRecord, trimmedFields: string[]): void { + let compacted = false + const nodes = asUnknownArray(pack.matched_nodes) + for (const entry of nodes) { + const node = asJsonRecord(entry) + if (!node) { + continue + } + for (const field of ['match_score', 'relevance_band', 'representation_type', 'representation_reason']) { + if (Object.hasOwn(node, field)) { + delete node[field] + compacted = true + } + } + if (node.source_domain === 'production') { + delete node.source_domain + compacted = true + } + if (node.snippet_truncated === false) { + delete node.snippet_truncated + compacted = true + } + } + if (compacted) { + trimmedFields.push('pack.matched_nodes ranking metadata compacted') + } +} + +function compactAnswerReadyExpandableHandles(payload: JsonRecord, trimmedFields: string[]): void { + const expandable = asUnknownArray(payload.expandable) + if (expandable.length === 0) { + return + } + + payload.expandable = expandable + .map((entry) => asJsonRecord(entry)) + .filter((entry): entry is JsonRecord => entry !== null) + .map((entry) => { + const followUp = asJsonRecord(entry.follow_up) + const evidenceClass = typeof entry.evidence_class === 'string' ? entry.evidence_class : 'supporting' + return { + kind: 'nodes', + ...(typeof entry.handle_id === 'string' ? { handle_id: entry.handle_id } : {}), + evidence_class: evidenceClass, + ...(typeof entry.count === 'number' + ? { count: entry.count } + : { count: asUnknownArray(entry.preview).length }), + preview: [], + follow_up: { + kind: 'context_pack', + task_kind: typeof followUp?.task_kind === 'string' ? followUp.task_kind : 'explain', + evidence_class: typeof followUp?.evidence_class === 'string' ? followUp.evidence_class : evidenceClass, + focus_files: asUnknownArray(followUp?.focus_files).slice(0, 2), + focus_ranges: asUnknownArray(followUp?.focus_ranges).slice(0, 1), + }, + } + }) + .filter((entry) => Object.hasOwn(entry, 'handle_id')) + trimmedFields.push('expandable handles compacted') +} + +function compactAnswerReadyEvidenceForPressure(evidence: JsonRecord, trimmedFields: string[]): void { + let compacted = false + + if (asUnknownArray(evidence.missing_phases).length === 0) { + delete evidence.missing_phases + compacted = true + } + + const coverageDetail = asJsonRecord(evidence.coverage_detail) + if (coverageDetail && asUnknownArray(coverageDetail.missing_obligations).length === 0) { + evidence.coverage_detail = { status: coverageDetail.status } + compacted = true + } + + const answerability = asJsonRecord(evidence.answerability) + if (answerability) { + for (const field of ['caveats', 'missing_obligations', 'verification_targets']) { + if (asUnknownArray(answerability[field]).length === 0) { + delete answerability[field] + compacted = true + } + } + } + + const recovery = asJsonRecord(evidence.recovery) + if (recovery) { + evidence.recovery = { status: recovery.status } + compacted = true + } + + const discovery = asJsonRecord(evidence.discovery_exclusions) + if (discovery && discovery.relevant === 0) { + evidence.discovery_exclusions = { + total: discovery.total, + relevant: 0, + } + compacted = true + } + + const indexing = asJsonRecord(evidence.indexing_completeness) + if (indexing && indexing.relevant_uncertain === 0) { + evidence.indexing_completeness = { + state: indexing.state, + total_uncertain: indexing.total_uncertain, + relevant_uncertain: 0, + } + compacted = true + } + + if (compacted) { + trimmedFields.push('evidence non-actionable detail compacted') + } +} + +function compactAnswerReadyRelationships(pack: JsonRecord, trimmedFields: string[]): void { + const relationships = asUnknownArray(pack.relationships) + if (relationships.length === 0) { + return + } + + pack.relationships = relationships + .map((entry) => asJsonRecord(entry)) + .filter((entry): entry is JsonRecord => entry !== null) + .map((entry) => ({ + ...(typeof entry.from_id === 'string' + ? { from_id: entry.from_id } + : typeof entry.from === 'string' ? { from: entry.from } : {}), + ...(typeof entry.to_id === 'string' + ? { to_id: entry.to_id } + : typeof entry.to === 'string' ? { to: entry.to } : {}), + ...(typeof entry.relation === 'string' ? { relation: entry.relation } : {}), + })) + trimmedFields.push('pack.relationships endpoint metadata compacted') +} + +function compactAnswerReadyNodesForPressure(pack: JsonRecord, trimmedFields: string[]): void { + let compacted = false + for (const entry of asUnknownArray(pack.matched_nodes)) { + const node = asJsonRecord(entry) + if (!node) { + continue + } + + if (node.file_type === 'code') { + delete node.file_type + compacted = true + } + for (const field of ['community', 'community_label']) { + if (Object.hasOwn(node, field)) { + delete node[field] + compacted = true + } + } + + if (typeof node.snippet !== 'string') { + continue + } + const normalized = node.snippet.replace(/\s+/g, ' ').trim() + const truncated = normalized.length > ANSWER_READY_SNIPPET_CHAR_CAP + ? normalized.slice(0, ANSWER_READY_SNIPPET_CHAR_CAP).trimEnd() + : normalized + if (truncated !== node.snippet) { + node.snippet = truncated + compacted = true + } + if (normalized.length > truncated.length) { + node.snippet_truncated = true + } + } + + if (compacted) { + trimmedFields.push('pack.matched_nodes snippets and defaults compacted') + } +} + +function compactAnswerReadyPackForPressure( + payload: JsonRecord, + pack: JsonRecord, + trimmedFields: string[], +): void { + if (pack.question === payload.prompt) { + delete pack.question + trimmedFields.push('pack.question duplicate') + } + for (const field of ['token_count', 'snippet_budget_tokens_used', 'snippet_budget_tokens_remaining']) { + if (Object.hasOwn(pack, field)) { + delete pack[field] + trimmedFields.push(`pack.${field}`) + } + } + + const evidence = asJsonRecord(payload.evidence) + if (evidence?.recovery && Object.hasOwn(pack, 'recovery')) { + delete pack.recovery + trimmedFields.push('pack.recovery duplicate') + } + + compactAnswerReadyNodesForPressure(pack, trimmedFields) + compactAnswerReadyRelationships(pack, trimmedFields) + const communities = asUnknownArray(pack.community_context) + if (communities.length > 1) { + pack.community_context = communities.slice(0, 1) + trimmedFields.push('pack.community_context compacted') + } +} + +function compactAnswerReadyEnvelopeForPressure(payload: JsonRecord, trimmedFields: string[]): void { + for (const field of ['claims', 'cache']) { + if (Object.hasOwn(payload, field)) { + delete payload[field] + trimmedFields.push(field) + } + } + + const routing = asJsonRecord(payload.routing) + if (routing && asUnknownArray(routing.warnings).length === 0) { + delete payload.routing + trimmedFields.push('routing') + } +} + function compactAnswerReadyGovernance(payload: JsonRecord, trimmedFields: string[]): void { const governance = asJsonRecord(payload.governance) if (!governance) { @@ -1275,8 +1499,7 @@ export function buildAnswerReadyPackSchema( } if (pack) { - trimArrayField(pack, 'matched_nodes', 4, trimmedFields) - trimArrayField(pack, 'relationships', 4, trimmedFields) + compactAnswerReadyMatchedNodeMetadata(pack, trimmedFields) trimArrayField(pack, 'community_context', 3, trimmedFields) } @@ -1291,12 +1514,10 @@ export function buildAnswerReadyPackSchema( } if (pack) { - delete pack.relationships delete pack.graph_signals delete pack.snippet_budget_tokens_used delete pack.snippet_budget_tokens_remaining trimmedFields.push( - 'pack.relationships', 'pack.graph_signals', 'pack.snippet_budget_tokens_used', 'pack.snippet_budget_tokens_remaining', @@ -1345,6 +1566,21 @@ export function buildAnswerReadyPackSchema( delete payload.retrieval_gate delete payload.why_explanation trimmedFields.push('retrieval_gate', 'why_explanation') + attachSerializedBudget(payload, maxTokens, trimmedFields) + if (estimatedJsonTokens(payload) <= maxTokens) { + return payload + } + compactAnswerReadyExpandableHandles(payload, trimmedFields) + if (evidence) { + compactAnswerReadyEvidenceForPressure(evidence, trimmedFields) + } + if (pack) { + compactAnswerReadyPackForPressure(payload, pack, trimmedFields) + } + attachSerializedBudget(payload, maxTokens, trimmedFields) + if (estimatedJsonTokens(payload) <= maxTokens) { + return payload + } if (trimGovernanceBeforeNodeCulling(payload, maxTokens, trimmedFields)) { attachSerializedBudget(payload, maxTokens, trimmedFields) if (estimatedJsonTokens(payload) <= maxTokens) { @@ -1359,7 +1595,7 @@ export function buildAnswerReadyPackSchema( return payload } } - if (maxTokens <= 800 && evidence && Array.isArray(evidence.covered_workflow_owners)) { + if (evidence && Array.isArray(evidence.covered_workflow_owners)) { delete evidence.covered_workflow_owners trimmedFields.push('evidence.covered_workflow_owners') attachSerializedBudget(payload, maxTokens, trimmedFields) @@ -1367,6 +1603,19 @@ export function buildAnswerReadyPackSchema( return payload } } + compactAnswerReadyEnvelopeForPressure(payload, trimmedFields) + attachSerializedBudget(payload, maxTokens, trimmedFields) + if (estimatedJsonTokens(payload) <= maxTokens) { + return payload + } + if (pack && Array.isArray(pack.relationships)) { + delete pack.relationships + trimmedFields.push('pack.relationships') + attachSerializedBudget(payload, maxTokens, trimmedFields) + if (estimatedJsonTokens(payload) <= maxTokens) { + return payload + } + } enforceAnswerReadyBudget(payload, maxTokens, trimmedFields, selectionDiagnostics) return payload } diff --git a/src/infrastructure/indexing-manifest.ts b/src/infrastructure/indexing-manifest.ts index 7dbda826..d3892cbb 100644 --- a/src/infrastructure/indexing-manifest.ts +++ b/src/infrastructure/indexing-manifest.ts @@ -31,6 +31,10 @@ const GENERIC_QUERY_TOKENS = new Set([ ]) const OUTCOME_STATUSES = new Set(INDEXING_OUTCOME_STATUSES) const REASON_CODES = new Set(INDEXING_REASON_CODES) +const ENVIRONMENT_CONFIG_INTENT_PATTERN = /(?:^|[^a-z0-9])\.env(?:[^a-z0-9]|$)|\b(?:config(?:uration)?|credential|deploy(?:ment)?|environment|key|password|runtime\s+variable|secret|settings|token)\b/i +const DOCUMENTATION_INTENT_PATTERN = /\b(?:architecture|changelog|docs?|documentation|guide|readme|specification)\b|\.(?:md|mdx|rst|txt)\b/i +const HIDDEN_PATH_INTENT_PATTERN = /\b(?:dotfile|hidden\s+(?:file|path)|well-known)\b|(?:^|[\s`'"/])\.(?:claude|codex|github|vscode|well-known|zed)(?:[\s`'"/]|$)/i +const NON_CODE_ARTIFACT_INTENT_PATTERN = /\b(?:asset|image|migration|shell|sql|stylesheet)\b|\.(?:css|csv|html|ico|jpeg|jpg|png|sh|sql|svg|vue|wasm)\b/i function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) @@ -137,17 +141,49 @@ function tokenize(value: string): string[] { .filter((token) => token.length >= 3 && !GENERIC_QUERY_TOKENS.has(token)) } -function outcomeMatches(outcome: IndexingOutcome, queryTokens: ReadonlySet, owners: readonly string[]): boolean { +function outcomeMatchesOwner(outcome: IndexingOutcome, owners: readonly string[]): boolean { const normalizedPath = outcome.path.toLowerCase().replaceAll('\\', '/') - if (owners.some((owner) => { + return owners.some((owner) => { const normalizedOwner = owner.toLowerCase().replaceAll('\\', '/').replace(/^\.\//, '') return normalizedOwner.length > 0 && (normalizedPath.includes(normalizedOwner) || normalizedOwner.includes(normalizedPath)) - })) { + }) +} + +function reasonAppliesToQuestion(reason: IndexingReasonCode, question: string): boolean { + switch (reason) { + case 'environment_file': + case 'private_key': + case 'credential_store': + case 'secret_config': + case 'sensitive_directory': + return ENVIRONMENT_CONFIG_INTENT_PATTERN.test(question) + case 'docs_disabled': + return DOCUMENTATION_INTENT_PATTERN.test(question) + case 'hidden_path': + return HIDDEN_PATH_INTENT_PATTERN.test(question) + case 'unsupported_file_type': + return NON_CODE_ARTIFACT_INTENT_PATTERN.test(question) + default: + return true + } +} + +function outcomeMatches( + outcome: IndexingOutcome, + queryTokens: ReadonlySet, + owners: readonly string[], + question: string, +): boolean { + if (outcomeMatchesOwner(outcome, owners)) { return true } + if (!reasonAppliesToQuestion(outcome.reason, question)) { + return false + } if (queryTokens.size === 0) { return false } + const normalizedPath = outcome.path.toLowerCase().replaceAll('\\', '/') const pathTokens = new Set(tokenize(normalizedPath)) return [...queryTokens].some((token) => pathTokens.has(token)) } @@ -164,9 +200,10 @@ export function relevantIndexingUncertainty( input: { question?: string; coveredWorkflowOwners?: readonly string[] } = {}, ): RelevantIndexingUncertainty { const uncertain = manifest.outcomes.filter((outcome) => UNCERTAIN_STATUSES.has(outcome.status)) - const queryTokens = new Set(tokenize(input.question ?? '')) + const question = input.question ?? '' + const queryTokens = new Set(tokenize(question)) const owners = input.coveredWorkflowOwners ?? [] - const relevant = uncertain.filter((outcome) => outcomeMatches(outcome, queryTokens, owners)) + const relevant = uncertain.filter((outcome) => outcomeMatches(outcome, queryTokens, owners, question)) const reasons: Partial> = {} const relevantReasons: Partial> = {} for (const outcome of uncertain) { diff --git a/src/infrastructure/watch.ts b/src/infrastructure/watch.ts index 9a64b619..f14d7d90 100644 --- a/src/infrastructure/watch.ts +++ b/src/infrastructure/watch.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, watch as createFileSystemWatcher, writeFileSync } from 'node:fs' -import { extname, join, resolve, sep } from 'node:path' +import { basename, extname, join, relative, resolve, sep } from 'node:path' import type { IndexingStrictThresholds } from '../contracts/indexing.js' import type { WatcherEventMode, WatcherStateV1 } from '../contracts/watcher-state.js' @@ -15,8 +15,11 @@ import { VIDEO_EXTENSIONS, } from '../pipeline/detect.js' import { EXTRACTOR_CACHE_VERSION } from '../pipeline/extract.js' +import { analyzeGraphContextFreshness } from '../runtime/freshness.js' +import { readIndexingManifestForGraph } from './indexing-manifest.js' import { sidecarAwareFileFingerprint } from '../shared/binary-ingest-sidecar.js' import { collectGitVisibleFiles } from '../shared/git.js' +import { isDiscoveryPathIgnored, loadMadarignorePatterns } from '../shared/source-discovery.js' import { resolveMadarOutputDirectory } from '../shared/workspace.js' import { generateGraph, type GenerateGraphOptions, type GenerateGraphResult } from './generate.js' import { @@ -247,6 +250,79 @@ function graphBelongsToWorkspace(graphPath: string, workspaceRoot: string): bool } } +function canReuseFreshGraphOnStart( + workspaceRoot: string, + outputDir: string, + state: WatcherStateV1, + currentSnapshot: WatchSnapshot, +): boolean { + const graphPath = join(outputDir, 'graph.json') + const manifestPath = join(outputDir, 'manifest.json') + if ( + state.policy_match !== true + || !existsSync(graphPath) + || !existsSync(manifestPath) + || existsSync(join(outputDir, 'needs_update')) + || !graphBelongsToWorkspace(graphPath, workspaceRoot) + ) { + return false + } + + try { + const freshness = analyzeGraphContextFreshness(graphPath) + if (freshness.status !== 'fresh' || freshness.generated_ms === null) { + return false + } + + const indexingManifest = readIndexingManifestForGraph(graphPath) + if (!indexingManifest) { + return false + } + const persistedCandidates = new Set( + indexingManifest.outcomes + .filter((outcome) => outcome.kind === 'file') + .map((outcome) => outcome.path.replaceAll('\\', '/').replace(/^\.\//, '')), + ) + const currentCandidates = new Set() + for (const filePath of currentSnapshot.fingerprints.keys()) { + if (filePath === GIT_VISIBILITY_SNAPSHOT_KEY) { + continue + } + if (WATCHED_CONTROL_FILENAMES.has(basename(filePath))) { + if (lstatSync(filePath).mtimeMs > freshness.generated_ms) { + return false + } + continue + } + if (!WATCHED_EXTENSIONS.has(extname(filePath).toLowerCase())) { + continue + } + const localPath = relative(workspaceRoot, filePath).replaceAll('\\', '/') + if (localPath === '' || localPath === '..' || localPath.startsWith('../')) { + return false + } + currentCandidates.add(localPath) + if (!persistedCandidates.has(localPath)) { + return false + } + } + for (const outcome of indexingManifest.outcomes) { + const localPath = outcome.path.replaceAll('\\', '/').replace(/^\.\//, '') + if ( + outcome.kind === 'file' + && outcome.status !== 'skipped_by_policy' + && WATCHED_EXTENSIONS.has(extname(localPath).toLowerCase()) + && !currentCandidates.has(localPath) + ) { + return false + } + } + return true + } catch { + return false + } +} + class WatchCoverageError extends Error { constructor(message: string) { super(message) @@ -279,6 +355,8 @@ function assertReconciliationWithinDeadline(collection: SnapshotCollection): voi function collectWatchedFiles( directory: string, followSymlinks: boolean, + discoveryRoot: string, + discoveryIgnorePatterns: readonly string[], rootRealPath: string, ancestorRealPaths: string[], collection: SnapshotCollection, @@ -304,17 +382,29 @@ function collectWatchedFiles( for (const entry of entries) { assertReconciliationWithinDeadline(collection) const isControlFile = WATCHED_CONTROL_FILENAMES.has(entry.name) + const entryPath = resolve(directory, entry.name) if (entry.name.startsWith('.') && !isControlFile) { continue } if (WATCH_IGNORED_DIRECTORIES.has(entry.name)) { continue } - - const entryPath = resolve(directory, entry.name) + if (!isControlFile && isDiscoveryPathIgnored(entryPath, discoveryRoot, discoveryIgnorePatterns)) { + continue + } if (entry.isDirectory()) { - collectWatchedFiles(entryPath, followSymlinks, rootRealPath, ancestorRealPaths, collection, includedFiles, symlinkDepth) + collectWatchedFiles( + entryPath, + followSymlinks, + discoveryRoot, + discoveryIgnorePatterns, + rootRealPath, + ancestorRealPaths, + collection, + includedFiles, + symlinkDepth, + ) continue } @@ -344,7 +434,17 @@ function collectWatchedFiles( } if (targetStats.isDirectory()) { - collectWatchedFiles(entryPath, followSymlinks, rootRealPath, [...ancestorRealPaths, realTarget], collection, includedFiles, symlinkDepth + 1) + collectWatchedFiles( + entryPath, + followSymlinks, + discoveryRoot, + discoveryIgnorePatterns, + rootRealPath, + [...ancestorRealPaths, realTarget], + collection, + includedFiles, + symlinkDepth + 1, + ) continue } @@ -418,7 +518,17 @@ export function snapshotWatchedFiles( const visibleFiles = respectGitignore ? readGitVisibleFiles(resolvedWatchPath, gitVisibilityCache) : null const includedFiles = visibleFiles === null ? undefined : new Set(visibleFiles) - collectWatchedFiles(resolvedWatchPath, followSymlinks, rootRealPath, [rootRealPath], collection, includedFiles) + const discoveryIgnorePatterns = loadMadarignorePatterns(resolvedWatchPath) + collectWatchedFiles( + resolvedWatchPath, + followSymlinks, + resolvedWatchPath, + discoveryIgnorePatterns, + rootRealPath, + [rootRealPath], + collection, + includedFiles, + ) if (visibleFiles !== null) { const visibilityFingerprint = createHash('sha256').update([...visibleFiles].sort().join('\0')).digest('hex') collection.fingerprints.set(GIT_VISIBILITY_SNAPSHOT_KEY, visibilityFingerprint) @@ -819,7 +929,9 @@ export async function watch(watchPath: string, debounce = 3, options: WatchOptio output.log('[madar watch] Recursive filesystem events unavailable; adaptive polling is authoritative.') } - if (options.rebuildOnStart) { + if (options.rebuildOnStart && canReuseFreshGraphOnStart(resolvedWatchPath, outputDir, state, previousSnapshot)) { + options.onInitialRebuild?.(false) + } else if (options.rebuildOnStart) { state.status = 'reconciling' persistState() const initialRebuild = runInitialRebuild(resolvedWatchPath, rebuildOptionsFromWatch(options, output)) diff --git a/src/runtime/context-pack-recovery.ts b/src/runtime/context-pack-recovery.ts index 6bd8b80a..f345136b 100644 --- a/src/runtime/context-pack-recovery.ts +++ b/src/runtime/context-pack-recovery.ts @@ -192,9 +192,15 @@ export function recoverContextPackResult( output_token_budget: outputTokenBudget, } as const const initialAssessment = assess(initial, options.question) + const queryObligations = initial.retrieval_plan?.query_obligations + const conceptualObligationsCovered = initial.retrieval_plan?.status === 'recovered' + && queryObligations !== undefined + && queryObligations.total > 0 + && queryObligations.finally_covered === queryObligations.total const recoveryAllowed = recoveryOptions.enabled !== false && options.taskKind !== 'implement' && initial.retrieval_gate?.level !== 0 + && !conceptualObligationsCovered if (!recoveryAllowed || initialAssessment.state === 'ready' || initialAssessment.state === 'ready_with_caveat') { return { ...initial, diff --git a/src/runtime/context-pack.ts b/src/runtime/context-pack.ts index 56eb9214..2a5002a2 100644 --- a/src/runtime/context-pack.ts +++ b/src/runtime/context-pack.ts @@ -209,18 +209,46 @@ function coverageEntriesForCandidates( } const evidenceClasses = orderedEvidence(taskContract, availableCounts.keys()) + const selectedSourceFileCount = new Set( + selectedNodes.map((node) => node.entry.source_file).filter((sourceFile) => sourceFile.length > 0), + ).size + const selectedDirectNodeCount = (selectedCounts.get('primary') ?? 0) + (selectedCounts.get('change') ?? 0) const entries: ContextPackCoverageEntry[] = evidenceClasses.map((evidence_class) => { const available_nodes = availableCounts.get(evidence_class) ?? 0 const selected_nodes = selectedCounts.get(evidence_class) ?? 0 const required = taskContract.required_evidence.includes(evidence_class) + const relationshipBacked = taskContract.task_kind === 'explain' + && available_nodes === 0 + && selectedNodes.length >= 2 + && relationshipCounts.selected > 0 + && (evidence_class === 'supporting' || evidence_class === 'structural') + const crossFileDirectSupport = taskContract.task_kind === 'explain' + && evidence_class === 'supporting' + && selected_nodes === 0 + // Reserve this exception for genuinely broad, cross-layer traces. A + // small local cluster can span three files (entrypoint, owner, helper) + // while still needing the omitted supporting node that recovery would + // add. Five distinct owners is the smallest boundary that separates the + // multi-service flow case from those ordinary local explanations. + && selectedDirectNodeCount >= 5 + && selectedSourceFileCount >= 5 + && relationshipCounts.selected > 0 return { evidence_class, required, available_nodes, selected_nodes, - status: classifyCoverageStatus(required, available_nodes, selected_nodes), + // A coherent relationship between selected primary nodes is real + // supporting/structural evidence. A diverse cross-file set of direct + // workflow owners can also satisfy supporting evidence even when weaker + // related candidates exist; forcing one of those candidates into the + // pack would replace stronger obligation evidence merely to satisfy a + // ranking label. + status: relationshipBacked || crossFileDirectSupport + ? 'covered' + : classifyCoverageStatus(required, available_nodes, selected_nodes), } }) @@ -233,6 +261,11 @@ function coverageEntriesForCandidates( const available_nodes = semanticAvailableCounts.get(category) ?? 0 const selected_nodes = semanticSelectedCounts.get(category) ?? 0 const required = taskContract.semantic_required.includes(category) + const relationshipBacked = taskContract.task_kind === 'explain' + && category === 'structure' + && available_nodes === 0 + && selectedNodes.length >= 2 + && relationshipCounts.selected > 0 return { category, @@ -240,7 +273,7 @@ function coverageEntriesForCandidates( required, available_nodes, selected_nodes, - status: classifyCoverageStatus(required, available_nodes, selected_nodes), + status: relationshipBacked ? 'covered' : classifyCoverageStatus(required, available_nodes, selected_nodes), } satisfies ContextPackSemanticCoverageEntry }) @@ -250,8 +283,8 @@ function coverageEntriesForCandidates( semantic_optional: [...taskContract.semantic_optional], entries, semantic_entries: semanticEntries, - missing_required: entries.filter((entry) => entry.required && entry.selected_nodes === 0).map((entry) => entry.evidence_class), - missing_semantic: semanticEntries.filter((entry) => entry.required && entry.selected_nodes === 0).map((entry) => entry.category), + missing_required: entries.filter((entry) => entry.required && entry.status !== 'covered').map((entry) => entry.evidence_class), + missing_semantic: semanticEntries.filter((entry) => entry.required && entry.status !== 'covered').map((entry) => entry.category), available_relationships: relationshipCounts.available, selected_relationships: relationshipCounts.selected, } diff --git a/src/runtime/retrieval-gate.ts b/src/runtime/retrieval-gate.ts index 25a6dc4c..6f972859 100644 --- a/src/runtime/retrieval-gate.ts +++ b/src/runtime/retrieval-gate.ts @@ -74,7 +74,7 @@ const PATTERNS: ReadonlyArray<{ intent: RetrievalIntent; re: RegExp }> = [ { intent: 'test', re: /\b(?:test(?:s|ing)?|spec(?:s)?|coverage|missing tests?)\b/i }, { intent: 'refactor', re: /\b(?:refactor|simplif(?:y|ied)|clean ?up|tidy|extract)\b/i }, { intent: 'rename', re: /\b(?:rename|format|fix typo|spell(?:ing)?|reword|capitaliz)\b/i }, - { intent: 'explain', re: /\b(?:explain|what (?:does|is)|how does|describe|walk me through|tell me about|summari[sz]e)\b/i }, + { intent: 'explain', re: /\b(?:explain|what (?:does|is|runs?)|how does|describe|walk me through|tell me about|summari[sz]e)\b/i }, ] const PATH_RE = /(?:^|\s|`)((?:[\w@./-]+\/)*[\w./@-]+\.[A-Za-z]{1,8})(?=\b|`|$)/g @@ -132,6 +132,7 @@ export function classifyRetrievalLevel(input: RetrievalGateInput): RetrievalGate const decision = decideLevel({ intent, generationIntent, + executionOwnerShaped: /\bwhat runs?\b/i.test(positivePrompt), hasPrDiff, hasStackTrace, mentions: detectedPaths.length + detectedSymbols.length, @@ -147,11 +148,12 @@ export function classifyRetrievalLevel(input: RetrievalGateInput): RetrievalGate function decideLevel(opts: { intent: RetrievalIntent generationIntent: RetrievalGenerationIntent + executionOwnerShaped: boolean hasPrDiff: boolean hasStackTrace: boolean mentions: number }): { level: RetrievalLevel; reason: string } { - const { intent, generationIntent, hasPrDiff, hasStackTrace, mentions } = opts + const { intent, generationIntent, executionOwnerShaped, hasPrDiff, hasStackTrace, mentions } = opts // Stack trace is strong evidence of a behavior-tracing question regardless // of intent classification. @@ -169,6 +171,10 @@ function decideLevel(opts: { return { level: 3, reason: 'runtime generation intent — behavior slice retrieval' } } + if (executionOwnerShaped && (intent === 'unknown' || intent === 'explain')) { + return { level: 3, reason: 'runtime flow question — behavior slice retrieval' } + } + switch (intent) { case 'chitchat': return { level: 0, reason: 'conversational prompt with no code intent — no retrieval' } @@ -239,7 +245,7 @@ function detectGenerationIntent(prompt: string): { const buildStaticShaped = /\b(?:next\.js|nextjs|app\s+router|route\s+segment|landing\s+page|static|ssg|isr|server\s+component)\b/i.test(lower) const explanationShaped = /\b(?:explain|how|why|trace|walk|flow|path|lifecycle|fail(?:s|ing|ed)?)\b/i.test(lower) const flowProofShaped = - /\b(?:trace|walk(?:\s+me)?\s+through|flow|path|lifecycle|end-to-end|what happens when|through(?:\s+the)?\s+(?:runtime|pipeline)|reaches?)\b/i.test(lower) + /\b(?:trace|walk(?:\s+me)?\s+through|flow|path|lifecycle|end-to-end|what (?:happens when|runs?)|through(?:\s+the)?\s+(?:runtime|pipeline)|reaches?)\b/i.test(lower) || ( /\b(?:how|explain)\b/i.test(lower) && /\b(?:generated|built|assembled|produced|created|persist(?:ed|ing)?|saved?|handoff|handoffs)\b/i.test(lower) diff --git a/src/runtime/retrieve.ts b/src/runtime/retrieve.ts index f2663738..fdd83f69 100644 --- a/src/runtime/retrieve.ts +++ b/src/runtime/retrieve.ts @@ -4428,6 +4428,7 @@ function retrieveContextPass( graph: KnowledgeGraph, options: RetrieveOptions, conceptualNodeBoosts: ReadonlyMap = new Map(), + preserveConceptualObligationOrder = false, ): RetrieveResult { // Guard before candidate expansion, which also reads directional adjacency. // sliceCandidatesForRetrieve repeats the guard to protect its direct callers. @@ -4720,7 +4721,12 @@ function retrieveContextPass( ? scored.filter((node) => symbolReferenceMatchScore(node.label, node.sourceFile, mentionedSymbolRefs) > 0 || sourceFileMatchesMentionedPath(node.sourceFile, mentionedPaths)) : [] const conceptualSeedPool = conceptualNodeBoosts.size > 0 - ? scored.filter((node) => (conceptualNodeBoosts.get(node.id) ?? 0) >= CONCEPTUAL_FALLBACK_SEED_MIN_BOOST) + ? scored + .filter((node) => (conceptualNodeBoosts.get(node.id) ?? 0) >= CONCEPTUAL_FALLBACK_SEED_MIN_BOOST) + .sort((left, right) => ( + (conceptualNodeBoosts.get(right.id) ?? 0) - (conceptualNodeBoosts.get(left.id) ?? 0) + || compareScoredNodes(graph, left, right) + )) : [] const seedPool = conceptualSeedPool.length > 0 ? conceptualSeedPool @@ -4817,11 +4823,18 @@ function retrieveContextPass( if (expansionSeedIds.has(predecessorId)) { continue } + const relation = String(graph.edgeAttributes(predecessorId, seed.id).relation ?? 'related_to') const predecessorCommunity = parseCommunityId(graph.nodeAttributes(predecessorId).community) - if (!predecessorAllowedForPolicy(expansionPolicy.predecessor_mode, seedCommunity, predecessorCommunity)) { + // File/container ownership is exact extractor evidence, so it must + // not disappear merely because community detection placed the file + // node and its symbol in adjacent clusters. Keeping this owner hop + // also lets hop two reach the symbol's imported collaborators. + if ( + relation !== 'contains' + && !predecessorAllowedForPolicy(expansionPolicy.predecessor_mode, seedCommunity, predecessorCommunity) + ) { continue } - const relation = String(graph.edgeAttributes(predecessorId, seed.id).relation ?? 'related_to') if (!relationAllowedForPolicy(expansionPolicy.hop1_relations, relation)) { continue } @@ -4879,11 +4892,14 @@ function retrieveContextPass( if (seedIds.has(predecessorId) || hop1Ids.has(predecessorId)) { continue } + const relation = String(graph.edgeAttributes(predecessorId, hop1Id).relation ?? 'related_to') const predecessorCommunity = parseCommunityId(graph.nodeAttributes(predecessorId).community) - if (!predecessorAllowedForPolicy(expansionPolicy.predecessor_mode, seedCommunity, predecessorCommunity)) { + if ( + relation !== 'contains' + && !predecessorAllowedForPolicy(expansionPolicy.predecessor_mode, seedCommunity, predecessorCommunity) + ) { continue } - const relation = String(graph.edgeAttributes(predecessorId, hop1Id).relation ?? 'related_to') if (!relationAllowedForPolicy(expansionPolicy.hop2_relations, relation)) { continue } @@ -4994,8 +5010,19 @@ function retrieveContextPass( if (boostedSeedCommunity !== undefined && node.community === boostedSeedCommunity && node.community !== -1) node.score += 0.1 } - // Re-sort: seeds first by score, then neighbors by degree - scored.sort((a, b) => compareScoredNodes(graph, a, b)) + // Conceptual recovery already paid the cost of finding a bounded set of + // obligation-grounded workflow owners. Keep those seeds ahead of expanded + // helper calls; otherwise dense call trees can replace the cross-layer + // spine with cheap leaves such as parser and retry helpers. + scored.sort((a, b) => ( + conceptualNodeBoosts.size > 0 + ? Number(seedIds.has(b.id)) - Number(seedIds.has(a.id)) + : 0 + ) || ( + conceptualNodeBoosts.size > 0 && seedIds.has(a.id) && seedIds.has(b.id) + ? (conceptualNodeBoosts.get(b.id) ?? 0) - (conceptualNodeBoosts.get(a.id) ?? 0) + : 0 + ) || compareScoredNodes(graph, a, b)) const frameworkCompatibleCandidates = frameworkProfile.frameworkShaped ? scored.filter((node) => isFrameworkCompatible(activeFrameworks, node.framework)) @@ -5040,7 +5067,12 @@ function retrieveContextPass( : frameworkOrderedCandidates.filter((node) => node.relevanceBand !== 'peripheral') let orderedCandidates = inclusionOrder let sliceMetadata: ContextPackSliceMetadata | undefined - if (options.retrievalStrategy === 'slice-v1') { + // A multi-obligation conceptual fallback can deliberately assemble + // cross-service and cross-language owners that do not form one local + // slice. Only that explicitly selected recovery mode bypasses slice-v1; + // ordinary conceptual reranking and symbol/path-anchored questions keep + // the established slice contract. + if (options.retrievalStrategy === 'slice-v1' && !preserveConceptualObligationOrder) { const sliced = sliceCandidatesForRetrieve( graph, scored.map((node) => ({ @@ -5240,12 +5272,20 @@ function retrieveContextWithConceptualFallback(graph: KnowledgeGraph, options: R return { ...initial, retrieval_plan: proposal.plan } } - const recovered = retrieveContextPass(graph, options, proposal.nodeBoosts) + const preserveConceptualObligationOrder = initialQuality.explicit_anchors === 0 + && (proposal.plan.query_obligations?.total ?? 0) >= 4 + const recovered = retrieveContextPass( + graph, + options, + proposal.nodeBoosts, + preserveConceptualObligationOrder, + ) const finalized = finalizeConceptualFallbackPlan( proposal, retrievalQualitySnapshot(graph, recovered), selectedSourceFiles(initial), selectedSourceFiles(recovered), + new Set(selectedNodeIds(recovered)), ) return { ...(finalized.useRecovered ? recovered : initial), diff --git a/src/runtime/retrieve/conceptual-fallback.ts b/src/runtime/retrieve/conceptual-fallback.ts index 080b24ab..7e9850a8 100644 --- a/src/runtime/retrieve/conceptual-fallback.ts +++ b/src/runtime/retrieve/conceptual-fallback.ts @@ -8,7 +8,9 @@ import type { } from '../../contracts/retrieval-plan.js' import { classifySourceDomain, type SourceDomain } from '../../shared/source-discovery.js' -const MAX_QUERY_TERMS = 12 +const MAX_QUERY_TERMS = 24 +const MAX_QUERY_OBLIGATIONS = 6 +const MAX_TERMS_PER_OBLIGATION = 6 const MAX_ANCHORS = 24 const MAX_PRIMARY_SOURCE_ANCHORS = 16 const MAX_ANCHORS_PER_SOURCE_FILE = 2 @@ -18,7 +20,19 @@ const MAX_BFS_VISITS = 240 const MAX_NEIGHBORS_PER_STEP = 32 const MAX_PROMOTED_CANDIDATES = 24 const MAX_EXPANSION_TERMS = 8 +const MAX_OBLIGATION_CONNECTIVITY_CANDIDATES = 8 +const ORIGINAL_SELECTION_RETENTION_BOOST = 1 +const PREFERRED_OBLIGATION_ANCHOR_BOOST = 9 const CHANGE_LIFECYCLE_CONCEPT = '@change_lifecycle' +const DIVERGENCE_CONCEPT = '@divergence' +const COMPUTATION_CONCEPT = '@computation' +const TRANSITION_CONCEPT = '@transition' + +const FLOW_BOUNDARY_PATTERN = /\b(becomes?|became|triggers?|triggered|affects?|affected|causes?|caused|leads?\s+to|result(?:s|ed)?\s+in|flows?\s+to|then)\b|[.;,]/gi +const QUERY_DIRECTIVE_TERMS = new Set([ + 'any', 'cite', 'clearly', 'exact', 'identify', 'involved', 'remaining', + 'path', 'paths', 'state', 'symbols', 'trace', 'uncertainty', +]) const CHANGE_LIFECYCLE_TERMS = new Set([ 'change', 'changed', 'changes', 'changing', @@ -38,6 +52,39 @@ const CHANGE_LIFECYCLE_PREFIXES = [ 'chang', 'current', 'edit', 'fresh', 'latest', 'modif', 'reconcil', 'refresh', 'stale', 'sync', 'synchron', 'updat', 'watch', ] as const +const DIVERGENCE_TERMS = new Set([ + 'competing', 'conflict', 'conflicting', + 'discrepancy', 'diverge', 'divergent', 'divergence', 'inconsistent', + 'inconsistency', 'mismatch', 'mismatched', +]) +const DIVERGENCE_PREFIXES = [ + 'compet', 'conflict', 'discrep', 'diverg', 'inconsist', 'mismatch', +] as const +const COMPUTATION_TERMS = new Set([ + 'calculate', 'calculated', 'calculates', 'calculating', 'calculation', + 'compute', 'computed', 'computes', 'computing', 'computation', + 'derive', 'derived', 'derives', 'deriving', 'derivation', +]) +const COMPUTATION_PREFIXES = ['calculat', 'comput', 'deriv'] as const +const TRANSITION_TERMS = new Set([ + 'became', 'become', 'becomes', + 'create', 'created', 'creates', 'creating', 'creation', + 'insert', 'inserted', 'inserting', 'insertion', + 'open', 'opened', 'opening', + 'transition', 'transitioned', 'transitioning', + 'upsert', 'upserted', 'upserting', +]) +const TRANSITION_PREFIXES = ['becom', 'creat', 'insert', 'open', 'transition', 'upsert'] as const + +const PRESENTATION_QUERY_PATTERN = /\b(?:component|dashboard|frontend|render|screen|ui|visual|widget)\b/i +const PRESENTATION_PATH_PATTERN = /(?:\.(?:jsx|tsx)$|\/(?:components?|dashboard|views?|widgets?)\/)/i +const PRESENTATION_LABEL_PATTERN = /^(?:page\s+\/|.*(?:badge|card|component|screen|widget).*)$/i +const RUNTIME_PATH_PATTERN = /\/(?:api|checker|content|db|handlers?|persistence|routes?|schema|server|services?|workflows?)\//i +const PERSISTENCE_PATH_PATTERN = /\/(?:db|persistence|repositories?|schema)(?:\/|\.)/i +const LOW_VALUE_OWNER_PATH_PATTERN = /(?:\.pb\.go$|\/(?:errors?|limits)\.[^/]+$|statusPage\.utils\.[^/]+$|\/content\/markdown\/)/i +const LOW_VALUE_OWNER_LABEL_PATTERN = /(?:Error\(\)?$|Limits?\(\)?$|(?:statusLabel|statusGlyph|generate\w*)\(\)?$)/i +const EXPLICIT_ERROR_QUERY_PATTERN = /\b(?:error|exception|throw|throws|thrown)\b/i +const FLOW_OUTCOME_TERMS = new Set(['error', 'fail', 'failed', 'failure', 'result', 'response', 'status']) const QUERY_STOP_WORDS = new Set([ 'a', 'about', 'after', 'again', 'agent', 'also', 'an', 'and', 'are', 'be', @@ -67,12 +114,16 @@ const SOURCE_WEIGHTS: Record = { interface VocabularyNode { id: string + label: string sourceFile: string community: number | null fileType: string sourceDomain: SourceDomain fields: Map> allTerms: Set + structuralDegree: number + nodeKind: string + frameworkRole: string } interface RepositoryVocabularyIndex { @@ -84,11 +135,26 @@ interface RepositoryVocabularyIndex { interface AnchorCandidate { id: string + label: string sourceFile: string score: number matchedQueryTerms: Set specificQueryTerms: Set + symbolQueryTerms: Set + pathQueryTerms: Set sources: Set + obligationMatches: Map + structuralDegree: number + presentationShaped: boolean + transitionOwner: boolean + persistenceShaped: boolean + lowValueOwner: boolean + runtimeScope: string +} + +interface QueryObligation { + index: number + terms: string[] } export interface ConceptualFallbackSelectedNode { @@ -109,6 +175,11 @@ export interface ConceptualFallbackInput { export interface ConceptualFallbackProposal { plan: ContextPackRetrievalPlanDetail nodeBoosts: ReadonlyMap + /** Internal prompt-obligation coverage used to decide whether recovery improved the result. */ + obligationMatches?: ReadonlyMap> + obligationCount?: number + initialObligationCoverage?: number + preferredObligationAnchors?: ReadonlyMap } const vocabularyIndexCache = new WeakMap() @@ -121,12 +192,53 @@ function tokenize(value: string): string[] { .filter((term) => term.length > 1) } -function queryTerms(question: string): string[] { - return [...new Set( - tokenize(question) +function normalizedQueryTerms(value: string): string[] { + const lexical = [...new Set( + tokenize(value) .filter((term) => !QUERY_STOP_WORDS.has(term)) - .map((term) => changeLifecycleTerm(term) ? CHANGE_LIFECYCLE_CONCEPT : term), - )].slice(0, MAX_QUERY_TERMS) + .map((term) => conceptualTerm(term)), + )] + const withoutDirectives = lexical.filter((term) => !QUERY_DIRECTIVE_TERMS.has(term)) + return withoutDirectives.slice(0, MAX_TERMS_PER_OBLIGATION) +} + +function queryObligations(question: string): QueryObligation[] { + const groups: string[][] = [] + const seen = new Set() + let start = 0 + let boundaryTerms: string[] = [] + const append = (segment: string): void => { + const terms = [...new Set([...boundaryTerms, ...normalizedQueryTerms(segment)])] + const key = terms.join('\u0000') + if (terms.length > 0 && !seen.has(key) && groups.length < MAX_QUERY_OBLIGATIONS) { + seen.add(key) + groups.push(terms.slice(0, MAX_TERMS_PER_OBLIGATION)) + } + boundaryTerms = [] + } + for (const match of question.matchAll(FLOW_BOUNDARY_PATTERN)) { + append(question.slice(start, match.index)) + if (groups.length >= MAX_QUERY_OBLIGATIONS) { + break + } + boundaryTerms = typeof match[1] === 'string' ? normalizedQueryTerms(match[1]) : [] + start = (match.index ?? start) + match[0].length + } + if (groups.length < MAX_QUERY_OBLIGATIONS) { + append(question.slice(start)) + } + + if (groups.length === 0) { + const terms = normalizedQueryTerms(question) + if (terms.length > 0) { + groups.push(terms) + } + } + return groups.map((terms, index) => ({ index, terms })) +} + +function queryTerms(obligations: readonly QueryObligation[]): string[] { + return [...new Set(obligations.flatMap((obligation) => obligation.terms))].slice(0, MAX_QUERY_TERMS) } function lexicalTermsMatch(left: string, right: string): boolean { @@ -143,6 +255,37 @@ function changeLifecycleTerm(term: string): boolean { || CHANGE_LIFECYCLE_PREFIXES.some((prefix) => term.startsWith(prefix)) } +function divergenceTerm(term: string): boolean { + return DIVERGENCE_TERMS.has(term) + || DIVERGENCE_PREFIXES.some((prefix) => term.startsWith(prefix)) +} + +function computationTerm(term: string): boolean { + return COMPUTATION_TERMS.has(term) + || COMPUTATION_PREFIXES.some((prefix) => term.startsWith(prefix)) +} + +function transitionTerm(term: string): boolean { + return TRANSITION_TERMS.has(term) + || TRANSITION_PREFIXES.some((prefix) => term.startsWith(prefix)) +} + +function conceptualTerm(term: string): string { + if (changeLifecycleTerm(term)) { + return CHANGE_LIFECYCLE_CONCEPT + } + if (divergenceTerm(term)) { + return DIVERGENCE_CONCEPT + } + if (computationTerm(term)) { + return COMPUTATION_CONCEPT + } + if (transitionTerm(term)) { + return TRANSITION_CONCEPT + } + return term +} + function termsMatch(left: string, right: string): boolean { if (left === CHANGE_LIFECYCLE_CONCEPT) { return changeLifecycleTerm(right) @@ -150,6 +293,24 @@ function termsMatch(left: string, right: string): boolean { if (right === CHANGE_LIFECYCLE_CONCEPT) { return changeLifecycleTerm(left) } + if (left === DIVERGENCE_CONCEPT) { + return divergenceTerm(right) + } + if (right === DIVERGENCE_CONCEPT) { + return divergenceTerm(left) + } + if (left === COMPUTATION_CONCEPT) { + return computationTerm(right) + } + if (right === COMPUTATION_CONCEPT) { + return computationTerm(left) + } + if (left === TRANSITION_CONCEPT) { + return transitionTerm(right) + } + if (right === TRANSITION_CONCEPT) { + return transitionTerm(left) + } return lexicalTermsMatch(left, right) } @@ -262,6 +423,7 @@ function buildVocabularyIndex(graph: KnowledgeGraph): RepositoryVocabularyIndex } nodes.push({ id, + label, sourceFile, community, fileType, @@ -271,6 +433,9 @@ function buildVocabularyIndex(graph: KnowledgeGraph): RepositoryVocabularyIndex ), fields, allTerms, + structuralDegree: graph.incidentNeighbors(id, MAX_NEIGHBORS_PER_STEP).length, + nodeKind: String(attributes.node_kind ?? '').trim().toLowerCase(), + frameworkRole: String(attributes.framework_role ?? '').trim().toLowerCase(), }) } @@ -284,6 +449,53 @@ function buildVocabularyIndex(graph: KnowledgeGraph): RepositoryVocabularyIndex return index } +function runtimeAnchorAdjustment(node: VocabularyNode, question: string): number { + let adjustment = 0 + const explicitlyRequestsPresentation = PRESENTATION_QUERY_PATTERN.test(question) + if (!explicitlyRequestsPresentation) { + let presentationSignals = 0 + if (PRESENTATION_PATH_PATTERN.test(node.sourceFile)) presentationSignals += 1 + if (PRESENTATION_LABEL_PATTERN.test(node.label)) presentationSignals += 1 + if (/^(?:component|page|screen|view|widget)$/.test(node.frameworkRole)) presentationSignals += 1 + adjustment -= Math.min(3.5, presentationSignals * 1.75) + } + if (RUNTIME_PATH_PATTERN.test(node.sourceFile)) { + adjustment += 0.75 + } + if (/\(\)$/.test(node.label) || /^(?:function|method|route)$/.test(node.nodeKind)) { + adjustment += 0.5 + } + if (node.nodeKind === 'method' && node.structuralDegree <= 1) { + // Extractors model many nested/external calls as degree-one method nodes. + // They can prove a detail, but they are poor workflow anchors compared with + // the owning function or route around them. + adjustment -= 2.5 + } + if (LOW_VALUE_OWNER_PATH_PATTERN.test(node.sourceFile)) { + adjustment -= 2 + } + if (LOW_VALUE_OWNER_LABEL_PATTERN.test(node.label)) { + adjustment -= EXPLICIT_ERROR_QUERY_PATTERN.test(question) ? 0.75 : 2 + } + return adjustment +} + +function presentationShapedNode(node: VocabularyNode): boolean { + return PRESENTATION_PATH_PATTERN.test(node.sourceFile) + || PRESENTATION_LABEL_PATTERN.test(node.label) + || /^(?:component|page|screen|view|widget)$/.test(node.frameworkRole) +} + +function runtimeScopeForSource(sourceFile: string): string { + const normalized = sourceFile.replaceAll('\\', '/') + const match = normalized.match(/\/(apps|packages)\/([^/]+)/i) + if (match?.[1] && match[2]) { + return `${match[1].toLowerCase()}/${match[2].toLowerCase()}` + } + const parts = normalized.split('/').filter(Boolean) + return parts.slice(-3, -1).join('/') || normalized +} + function vocabularyDocumentFrequency(index: RepositoryVocabularyIndex, queryTerm: string): number { let count = 0 for (const [term, frequency] of index.documentFrequency) { @@ -303,9 +515,13 @@ function anchorForNode( node: VocabularyNode, terms: readonly string[], inverseFrequencyByTerm: ReadonlyMap, + obligations: readonly QueryObligation[], + question: string, ): AnchorCandidate | null { const matchedQueryTerms = new Set() const specificQueryTerms = new Set() + const symbolQueryTerms = new Set() + const pathQueryTerms = new Set() const sources = new Set() let score = 0 @@ -317,6 +533,12 @@ function anchorForNode( if (source !== 'graph_community' && source !== 'path') { specificQueryTerms.add(queryTerm) } + if (source === 'exported_symbol' || source === 'document_heading') { + symbolQueryTerms.add(queryTerm) + } + if (source === 'path' || source === 'module_name') { + pathQueryTerms.add(queryTerm) + } bestWeight = Math.max(bestWeight, SOURCE_WEIGHTS[source]) } } @@ -329,24 +551,264 @@ function anchorForNode( if (matchedQueryTerms.size === 0) { return null } + const obligationMatches = new Map() + for (const obligation of obligations) { + const matchedTerms = obligation.terms.filter((term) => matchedQueryTerms.has(term)).length + if (matchedTerms >= Math.min(2, obligation.terms.length)) { + obligationMatches.set(obligation.index, matchedTerms) + } + } score += Math.max(0, sources.size - 1) * 0.35 score += Math.max(0, matchedQueryTerms.size - 1) * 0.5 - return { id: node.id, sourceFile: node.sourceFile, score, matchedQueryTerms, specificQueryTerms, sources } + score += symbolQueryTerms.size * 0.75 + score += obligationMatches.size * 0.75 + score += Math.min(4, Math.log2(node.structuralDegree + 1)) * 0.75 + score += runtimeAnchorAdjustment(node, question) + return { + id: node.id, + label: node.label, + sourceFile: node.sourceFile, + score, + matchedQueryTerms, + specificQueryTerms, + symbolQueryTerms, + pathQueryTerms, + sources, + obligationMatches, + structuralDegree: node.structuralDegree, + presentationShaped: presentationShapedNode(node), + transitionOwner: tokenize(node.label).some((term) => transitionTerm(term) || changeLifecycleTerm(term)), + persistenceShaped: PERSISTENCE_PATH_PATTERN.test(node.sourceFile), + lowValueOwner: LOW_VALUE_OWNER_PATH_PATTERN.test(node.sourceFile) + || LOW_VALUE_OWNER_LABEL_PATTERN.test(node.label), + runtimeScope: runtimeScopeForSource(node.sourceFile), + } } -function diversifyAnchors(ranked: readonly AnchorCandidate[]): AnchorCandidate[] { +function diversifyAnchors( + graph: KnowledgeGraph, + ranked: readonly AnchorCandidate[], + obligations: readonly QueryObligation[], + preferRuntime: boolean, +): { + anchors: AnchorCandidate[] + preferredByObligation: Map + reservedByObligation: Set +} { const selected: AnchorCandidate[] = [] const selectedIds = new Set() const countBySourceFile = new Map() + const preferredByObligation = new Map() + const reservedByObligation = new Set() + const hasSymbolGroundedAnchors = ranked.some((anchor) => anchor.symbolQueryTerms.size > 0) + + const obligationSymbolMatchCount = (anchor: AnchorCandidate, obligation: QueryObligation): number => ( + obligation.terms.filter((term) => anchor.symbolQueryTerms.has(term)).length + ) + const obligationSpecificMatchCount = (anchor: AnchorCandidate, obligation: QueryObligation): number => ( + obligation.terms.filter((term) => anchor.specificQueryTerms.has(term)).length + ) + const divergenceScopeMatchCount = (anchor: AnchorCandidate, obligation: QueryObligation): number => { + if (!obligation.terms.includes(DIVERGENCE_CONCEPT)) { + return 0 + } + const previousTerms = obligations.find((candidate) => candidate.index === obligation.index - 1)?.terms ?? [] + return previousTerms.filter((term) => anchor.matchedQueryTerms.has(term)).length + } + + const baseObligationOrder = (obligation: QueryObligation) => ( + (left: AnchorCandidate, right: AnchorCandidate): number => ( + obligationSymbolMatchCount(right, obligation) - obligationSymbolMatchCount(left, obligation) + || (right.obligationMatches.get(obligation.index) ?? 0) - (left.obligationMatches.get(obligation.index) ?? 0) + || obligationSpecificMatchCount(right, obligation) - obligationSpecificMatchCount(left, obligation) + || divergenceScopeMatchCount(right, obligation) - divergenceScopeMatchCount(left, obligation) + || right.structuralDegree - left.structuralDegree + || right.score - left.score + || left.id.localeCompare(right.id) + ) + ) + const candidatesByObligation = new Map() + for (const obligation of obligations) { + const candidates = ranked + .filter((anchor) => anchor.obligationMatches.has(obligation.index)) + .sort(baseObligationOrder(obligation)) + const runtimeCandidates = preferRuntime && candidates.some((anchor) => !anchor.presentationShaped) + ? candidates.filter((anchor) => !anchor.presentationShaped) + : candidates + const structurallyGrounded = runtimeCandidates.some((anchor) => anchor.structuralDegree > 0) + ? runtimeCandidates.filter((anchor) => anchor.structuralDegree > 0) + : runtimeCandidates + const ownerCandidates = structurallyGrounded.some((anchor) => !anchor.lowValueOwner) + ? structurallyGrounded.filter((anchor) => !anchor.lowValueOwner) + : structurallyGrounded + candidatesByObligation.set( + obligation.index, + ownerCandidates.slice(0, MAX_OBLIGATION_CONNECTIVITY_CANDIDATES), + ) + } + const pathCache = new Map() + const anchorsConnect = (left: AnchorCandidate, right: AnchorCandidate): boolean => { + if (left.id === right.id) { + return false + } + const key = [left.id, right.id].sort().join('\u0000') + const cached = pathCache.get(key) + if (cached !== undefined) { + return cached + } + const connected = shortestIncidentPath(graph, left.id, right.id) !== null + pathCache.set(key, connected) + return connected + } + const connectedObligations = (candidate: AnchorCandidate): Set => { + const connected = new Set() + for (const obligation of obligations) { + if (candidate.obligationMatches.has(obligation.index)) { + continue + } + if ((candidatesByObligation.get(obligation.index) ?? []).some((other) => anchorsConnect(candidate, other))) { + connected.add(obligation.index) + } + } + return connected + } + + const add = (anchor: AnchorCandidate): void => { + const sourceKey = anchor.sourceFile || anchor.id + selectedIds.add(anchor.id) + countBySourceFile.set(sourceKey, (countBySourceFile.get(sourceKey) ?? 0) + 1) + selected.push(anchor) + } + + // Reserve one strong, source-diverse anchor for each prompt obligation + // before globally ranked vocabulary can consume the bounded seed pool. + for (const obligation of obligations) { + const candidates = [...(candidatesByObligation.get(obligation.index) ?? [])] + .sort((left, right) => { + const leftConnections = connectedObligations(left) + const rightConnections = connectedObligations(right) + const leftAdjacent = Number(leftConnections.has(obligation.index - 1)) + + Number(leftConnections.has(obligation.index + 1)) + const rightAdjacent = Number(rightConnections.has(obligation.index - 1)) + + Number(rightConnections.has(obligation.index + 1)) + return rightAdjacent - leftAdjacent + || rightConnections.size - leftConnections.size + || Number(!selectedIds.has(right.id)) - Number(!selectedIds.has(left.id)) + || Number(!countBySourceFile.has(right.sourceFile || right.id)) + - Number(!countBySourceFile.has(left.sourceFile || left.id)) + || baseObligationOrder(obligation)(left, right) + }) + const candidate = candidates[0] + if (candidate) { + if (!selectedIds.has(candidate.id)) { + add(candidate) + } + preferredByObligation.set(obligation.index, candidate.id) + reservedByObligation.add(candidate.id) + } + } + + // A divergence request requires at least two distinct implementations to + // compare. Reserve a second scoped computation owner instead of hoping it + // survives the global top-k. + for (const obligation of obligations.filter((candidate) => candidate.terms.includes(DIVERGENCE_CONCEPT))) { + const primaryId = preferredByObligation.get(obligation.index) + const primarySource = primaryId ? ranked.find((anchor) => anchor.id === primaryId)?.sourceFile : undefined + const candidates = [...(candidatesByObligation.get(obligation.index) ?? [])] + .filter((anchor) => anchor.id !== primaryId && anchor.sourceFile !== primarySource && !anchor.lowValueOwner) + .sort((left, right) => ( + obligationSymbolMatchCount(right, obligation) - obligationSymbolMatchCount(left, obligation) + || divergenceScopeMatchCount(right, obligation) - divergenceScopeMatchCount(left, obligation) + || right.structuralDegree - left.structuralDegree + || right.score - left.score + || left.id.localeCompare(right.id) + )) + const secondary = candidates[0] + if (secondary) { + if (!selectedIds.has(secondary.id)) add(secondary) + reservedByObligation.add(secondary.id) + } + } + + // A transition into a named entity is incomplete without shared state when + // the graph contains a schema/repository owner for that entity. + for (const obligation of obligations.filter((candidate) => candidate.terms.includes(TRANSITION_CONCEPT))) { + const entityTerms = obligation.terms.filter((term) => !term.startsWith('@')) + const unmatchedEntityQualifiers = (anchor: AnchorCandidate): number => { + const basenameTerms = tokenize(anchor.sourceFile.split('/').at(-1) ?? '') + .filter((term) => !VOCABULARY_NOISE.has(term)) + return basenameTerms.filter((term) => !entityTerms.some((entity) => termsMatch(entity, term))).length + } + const stateOwner = ranked + .filter((anchor) => ( + anchor.persistenceShaped + && entityTerms.some((term) => anchor.matchedQueryTerms.has(term)) + && !anchor.lowValueOwner + )) + .sort((left, right) => ( + right.symbolQueryTerms.size - left.symbolQueryTerms.size + || unmatchedEntityQualifiers(left) - unmatchedEntityQualifiers(right) + || right.structuralDegree - left.structuralDegree + || right.score - left.score + || left.id.localeCompare(right.id) + ))[0] + if (stateOwner) { + if (!selectedIds.has(stateOwner.id)) add(stateOwner) + reservedByObligation.add(stateOwner.id) + } + } + + // Cross-language transports are often not linked statically. Keep one + // lifecycle-shaped owner from a second runtime scope for the first stage so + // the pack can expose that boundary and state the remaining uncertainty. + const firstObligation = obligations[0] + const firstPrimaryId = firstObligation ? preferredByObligation.get(firstObligation.index) : undefined + const firstPrimaryScope = firstPrimaryId ? ranked.find((anchor) => anchor.id === firstPrimaryId)?.runtimeScope : undefined + if (firstObligation) { + const headTerm = firstObligation.terms.at(-1) + const outcomeLabel = (anchor: AnchorCandidate): boolean => ( + tokenize(anchor.label).some((term) => FLOW_OUTCOME_TERMS.has(term)) + ) + const boundaryOwner = ranked + .filter((anchor) => ( + anchor.id !== firstPrimaryId + && anchor.runtimeScope !== firstPrimaryScope + && anchor.transitionOwner + && !anchor.lowValueOwner + && anchor.symbolQueryTerms.size > 0 + && firstObligation.terms.some((term) => anchor.matchedQueryTerms.has(term)) + )) + .sort((left, right) => ( + Number(outcomeLabel(right)) - Number(outcomeLabel(left)) + || Number(headTerm !== undefined && right.pathQueryTerms.has(headTerm)) + - Number(headTerm !== undefined && left.pathQueryTerms.has(headTerm)) + || firstObligation.terms.filter((term) => right.matchedQueryTerms.has(term)).length + - firstObligation.terms.filter((term) => left.matchedQueryTerms.has(term)).length + || right.structuralDegree - left.structuralDegree + || right.score - left.score + || left.id.localeCompare(right.id) + ))[0] + if (boundaryOwner) { + if (!selectedIds.has(boundaryOwner.id)) add(boundaryOwner) + reservedByObligation.add(boundaryOwner.id) + } + } for (const anchor of ranked) { + if (selectedIds.has(anchor.id)) { + continue + } const sourceKey = anchor.sourceFile || anchor.id if (countBySourceFile.has(sourceKey)) { continue } - countBySourceFile.set(sourceKey, 1) - selectedIds.add(anchor.id) - selected.push(anchor) + if (preferRuntime && anchor.presentationShaped) { + continue + } + if (hasSymbolGroundedAnchors && anchor.symbolQueryTerms.size === 0) { + continue + } + add(anchor) if (selected.length >= MAX_PRIMARY_SOURCE_ANCHORS) { break } @@ -361,6 +823,12 @@ function diversifyAnchors(ranked: readonly AnchorCandidate[]): AnchorCandidate[] if (sourceCount === 0 || sourceCount >= MAX_ANCHORS_PER_SOURCE_FILE) { continue } + if (preferRuntime && anchor.presentationShaped) { + continue + } + if (hasSymbolGroundedAnchors && anchor.symbolQueryTerms.size === 0) { + continue + } // Path and module-name evidence applies to every symbol in a file. Only // admit a second symbol from that file when its own label, documentation, // or framework metadata contributes evidence; otherwise helpers become @@ -378,7 +846,7 @@ function diversifyAnchors(ranked: readonly AnchorCandidate[]): AnchorCandidate[] break } } - return selected + return { anchors: selected, preferredByObligation, reservedByObligation } } function eligibleVocabularyNodes(index: RepositoryVocabularyIndex, input: ConceptualFallbackInput): VocabularyNode[] { @@ -451,10 +919,15 @@ function shortestIncidentPath( return null } -function proposalIsGrounded(anchors: readonly AnchorCandidate[]): boolean { +function proposalIsGrounded( + anchors: readonly AnchorCandidate[], + obligations: readonly QueryObligation[], +): boolean { const coveredTerms = new Set(anchors.flatMap((anchor) => [...anchor.matchedQueryTerms])) + const coveredObligations = new Set(anchors.flatMap((anchor) => [...anchor.obligationMatches.keys()])) const strongest = anchors[0] return coveredTerms.size >= 2 + && coveredObligations.size >= Math.min(2, obligations.length) && (anchors.length >= 2 || (strongest?.matchedQueryTerms.size ?? 0) >= 2) } @@ -510,26 +983,20 @@ export function planConceptualFallback( graph: KnowledgeGraph, input: ConceptualFallbackInput, ): ConceptualFallbackProposal { - const reasons = fallbackReasons(input.initialQuality) - const basePlan: ContextPackRetrievalPlanDetail = { - version: 1, - status: 'not_needed', - reasons, - initial: input.initialQuality, - final: input.initialQuality, - attempts: [], - } - if (reasons.length === 0 || input.initialQuality.explicit_anchors > 0) { - return { - plan: input.initialQuality.explicit_anchors > 0 ? { ...basePlan, reasons: [] } : basePlan, - nodeBoosts: new Map(), - } - } - - const terms = queryTerms(input.question) + const obligations = queryObligations(input.question) + const terms = queryTerms(obligations) + const initialReasons = fallbackReasons(input.initialQuality) if (terms.length === 0) { + const basePlan: ContextPackRetrievalPlanDetail = { + version: 1, + status: initialReasons.length > 0 ? 'no_candidates' : 'not_needed', + reasons: initialReasons, + initial: input.initialQuality, + final: input.initialQuality, + attempts: initialReasons.length > 0 ? [emptyAttempt(initialReasons)] : [], + } return { - plan: { ...basePlan, status: 'no_candidates', attempts: [emptyAttempt(reasons)] }, + plan: basePlan, nodeBoosts: new Map(), } } @@ -540,19 +1007,66 @@ export function planConceptualFallback( ) const rankedAnchors = eligibleVocabularyNodes(index, input) .flatMap((node) => { - const anchor = anchorForNode(node, terms, inverseFrequencyByTerm) + const anchor = anchorForNode(node, terms, inverseFrequencyByTerm, obligations, input.question) return anchor ? [anchor] : [] }) .sort((left, right) => ( - right.matchedQueryTerms.size - left.matchedQueryTerms.size + right.obligationMatches.size - left.obligationMatches.size + || Number(right.structuralDegree > 0) - Number(left.structuralDegree > 0) + || right.matchedQueryTerms.size - left.matchedQueryTerms.size + || right.symbolQueryTerms.size - left.symbolQueryTerms.size || right.specificQueryTerms.size - left.specificQueryTerms.size || right.sources.size - left.sources.size || right.score - left.score || left.id.localeCompare(right.id) )) - const anchors = diversifyAnchors(rankedAnchors) + const diversified = diversifyAnchors( + graph, + rankedAnchors, + obligations, + !PRESENTATION_QUERY_PATTERN.test(input.question), + ) + const anchors = diversified.anchors + const selectedIds = new Set(input.selectedNodes.map((node) => node.nodeId)) + const rankedAnchorsById = new Map(rankedAnchors.map((anchor) => [anchor.id, anchor])) + const obligationMatches = new Map(rankedAnchors.map((anchor) => [ + anchor.id, + new Set(anchor.obligationMatches.keys()), + ])) + const initialObligationCoverage = new Set( + [...selectedIds].flatMap((nodeId) => [...(obligationMatches.get(nodeId) ?? [])]), + ).size + const obligationRecoveryNeeded = diversified.preferredByObligation.size >= 2 + && initialObligationCoverage < diversified.preferredByObligation.size + const reasons: RetrievalFallbackReason[] = [ + ...initialReasons, + ...(obligationRecoveryNeeded ? ['missing_query_obligations' as const] : []), + ] + const basePlan: ContextPackRetrievalPlanDetail = { + version: 1, + status: 'not_needed', + reasons, + initial: input.initialQuality, + final: input.initialQuality, + attempts: [], + ...(diversified.preferredByObligation.size >= 2 + ? { + query_obligations: { + total: diversified.preferredByObligation.size, + initially_covered: initialObligationCoverage, + finally_covered: initialObligationCoverage, + }, + } + : {}), + } + if (reasons.length === 0 || (input.initialQuality.explicit_anchors > 0 && !obligationRecoveryNeeded)) { + return { + plan: input.initialQuality.explicit_anchors > 0 ? { ...basePlan, reasons: [] } : basePlan, + nodeBoosts: new Map(), + } + } - if (!proposalIsGrounded(anchors)) { + if (!proposalIsGrounded(anchors, obligations)) { return { plan: { ...basePlan, status: 'no_candidates', attempts: [emptyAttempt(reasons)] }, nodeBoosts: new Map(), @@ -562,9 +1076,8 @@ export function planConceptualFallback( const boosts = new Map() const bridgeParticipation = new Map() const connectedAnchorIds = new Set() - const selectedIds = new Set(input.selectedNodes.map((node) => node.nodeId)) const contributingSources = new Set() - for (const anchor of anchors) { + for (const anchor of rankedAnchors) { for (const source of anchor.sources) { contributingSources.add(source) } @@ -604,9 +1117,12 @@ export function planConceptualFallback( } } crossPairs.sort(([leftA, rightA], [leftB, rightB]) => { + const distinctObligationsA = new Set([...leftA.obligationMatches.keys(), ...rightA.obligationMatches.keys()]).size + const distinctObligationsB = new Set([...leftB.obligationMatches.keys(), ...rightB.obligationMatches.keys()]).size const distinctEvidenceA = new Set([...leftA.matchedQueryTerms, ...rightA.matchedQueryTerms]).size const distinctEvidenceB = new Set([...leftB.matchedQueryTerms, ...rightB.matchedQueryTerms]).size - return distinctEvidenceB - distinctEvidenceA + return distinctObligationsB - distinctObligationsA + || distinctEvidenceB - distinctEvidenceA || (leftB.score + rightB.score) - (leftA.score + rightA.score) || leftA.id.localeCompare(leftB.id) || rightA.id.localeCompare(rightB.id) @@ -632,14 +1148,16 @@ export function planConceptualFallback( } const coherentAlternativeFound = connectedAnchorIds.size >= 2 + const preferredObligationAnchorIds = new Set(diversified.reservedByObligation) for (const anchor of anchors) { const multipleConceptBonus = Math.max(0, anchor.matchedQueryTerms.size - 1) * 1.25 + const obligationBonus = anchor.obligationMatches.size * 1.25 const anchorCap = anchor.matchedQueryTerms.size >= 2 ? 7 : 4 - const groundedBoost = Math.min(anchorCap, 0.6 + (anchor.score * 0.45) + multipleConceptBonus) - if (!coherentAlternativeFound || connectedAnchorIds.has(anchor.id)) { + const groundedBoost = Math.min(anchorCap, 0.6 + (anchor.score * 0.45) + multipleConceptBonus + obligationBonus) + if (!coherentAlternativeFound || connectedAnchorIds.has(anchor.id) || preferredObligationAnchorIds.has(anchor.id)) { boosts.set(anchor.id, groundedBoost) } else if (!selectedIds.has(anchor.id)) { - if (anchor.matchedQueryTerms.size >= 2 && anchor.specificQueryTerms.size >= 2) { + if (anchor.structuralDegree > 0 && anchor.matchedQueryTerms.size >= 2 && anchor.specificQueryTerms.size >= 2) { boosts.set(anchor.id, Math.min(6.5, groundedBoost)) } } @@ -649,6 +1167,13 @@ export function planConceptualFallback( boosts.set(nodeId, (boosts.get(nodeId) ?? 0) + Math.min(3, 0.8 + (participation * 0.55))) } + // One anchor per prompt stage is a hard recovery reservation. Without this, + // a dense single-layer cluster can still consume every selected slot after + // the proposal correctly found disconnected cross-service stages. + for (const nodeId of preferredObligationAnchorIds) { + boosts.set(nodeId, Math.max(boosts.get(nodeId) ?? 0, PREFERRED_OBLIGATION_ANCHOR_BOOST)) + } + for (const anchor of anchors .filter((candidate) => ( (!coherentAlternativeFound || connectedAnchorIds.has(candidate.id)) @@ -661,13 +1186,28 @@ export function planConceptualFallback( } if (coherentAlternativeFound) { + const anchorsById = new Map(anchors.map((anchor) => [anchor.id, anchor])) for (const nodeId of selectedIds) { - if (!connectedAnchorIds.has(nodeId) && !bridgeParticipation.has(nodeId) && !boosts.has(nodeId)) { + const obligationGrounded = (anchorsById.get(nodeId)?.obligationMatches.size ?? 0) > 0 + if (!obligationGrounded && !connectedAnchorIds.has(nodeId) && !bridgeParticipation.has(nodeId) && !boosts.has(nodeId)) { boosts.set(nodeId, -1.5) } } } + // Recovery should add missing obligations without discarding already useful + // evidence. Keep grounded original selections in the conceptual seed pool; + // explicit incoherent selections demoted above remain negative. + for (const nodeId of selectedIds) { + const currentBoost = boosts.get(nodeId) + const originalAnchor = rankedAnchorsById.get(nodeId) + const groundedOriginal = originalAnchor !== undefined + && (originalAnchor.symbolQueryTerms.size > 0 || connectedAnchorIds.has(nodeId)) + if (groundedOriginal && (currentBoost === undefined || currentBoost >= 0)) { + boosts.set(nodeId, Math.max(currentBoost ?? 0, ORIGINAL_SELECTION_RETENTION_BOOST)) + } + } + const orderedPositiveAdjustments = [...boosts] .filter(([nodeId, boost]) => index.byId.has(nodeId) && boost > 0) .sort((left, right) => ( @@ -675,7 +1215,7 @@ export function planConceptualFallback( || Number(!selectedIds.has(right[0])) - Number(!selectedIds.has(left[0])) || left[0].localeCompare(right[0]) )) - const reservedAnchorIds = new Set(anchors.slice(0, 12).map((anchor) => anchor.id)) + const reservedAnchorIds = new Set(diversified.reservedByObligation) const reservedAnchors = orderedPositiveAdjustments.filter(([nodeId]) => reservedAnchorIds.has(nodeId)) const promoted = [ ...reservedAnchors, @@ -702,6 +1242,10 @@ export function planConceptualFallback( vocabulary_sources: [...contributingSources].sort(), expansion_terms: expansionTerms(promoted, index, terms), promoted_candidates: promoted.length, + promoted_communities: [...new Set(promoted.flatMap(([nodeId]) => { + const community = index.byId.get(nodeId)?.community + return community === null || community === undefined ? [] : [community] + }))].sort((left, right) => left - right), changed_result: false, added_selected_files: 0, removed_selected_files: 0, @@ -709,6 +1253,10 @@ export function planConceptualFallback( return { plan: { ...basePlan, status: 'kept_initial', attempts: [attempt] }, nodeBoosts: boundedBoosts, + obligationMatches, + obligationCount: obligations.length, + preferredObligationAnchors: diversified.preferredByObligation, + initialObligationCoverage, } } @@ -727,6 +1275,7 @@ export function finalizeConceptualFallbackPlan( recoveredQuality: RetrievalQualitySnapshot, initialFiles: ReadonlySet, recoveredFiles: ReadonlySet, + recoveredNodeIds: ReadonlySet = new Set(), ): { plan: ContextPackRetrievalPlanDetail; useRecovered: boolean } { const attempt = proposal.plan.attempts[0] if (!attempt || proposal.nodeBoosts.size === 0) { @@ -744,14 +1293,25 @@ export function finalizeConceptualFallbackPlan( || coherenceImproved const nonRegressingQuality = qualityValue(recoveredQuality) >= qualityValue(proposal.plan.initial) - 0.05 const recoveredEmptyResult = proposal.plan.initial.selected_nodes === 0 && recoveredQuality.selected_nodes > 0 + const recoveredObligationCoverage = new Set( + [...recoveredNodeIds].flatMap((nodeId) => [...(proposal.obligationMatches?.get(nodeId) ?? [])]), + ).size + const obligationCoverageImproved = recoveredObligationCoverage > (proposal.initialObligationCoverage ?? 0) const recoveryGoalMet = recoveredEmptyResult || (proposal.plan.reasons.includes('missing_required_evidence') && requiredEvidenceImproved) || (proposal.plan.reasons.includes('missing_semantic_evidence') && semanticEvidenceImproved) || (proposal.plan.reasons.includes('low_workflow_coherence') && coherenceImproved) || (proposal.plan.reasons.includes('weak_anchors') && weakAnchorImproved) + || obligationCoverageImproved + const obligationAdjustedQuality = qualityValue(recoveredQuality) + // Cross-service and cross-language stages are often disconnected in a + // static graph. Covering a previously missing prompt obligation must be + // allowed to outweigh the resulting drop in local cluster coherence. + + Math.max(0, recoveredObligationCoverage - (proposal.initialObligationCoverage ?? 0)) * 1.5 + const obligationAwareNonRegression = obligationAdjustedQuality >= qualityValue(proposal.plan.initial) - 0.05 const useRecovered = resultChanged && recoveryGoalMet - && nonRegressingQuality + && (nonRegressingQuality || obligationAwareNonRegression) const finalAttempt: RetrievalFallbackAttempt = { ...attempt, @@ -767,6 +1327,16 @@ export function finalizeConceptualFallbackPlan( status: useRecovered ? 'recovered' : 'kept_initial', final: useRecovered ? recoveredQuality : proposal.plan.initial, attempts: [finalAttempt], + ...(proposal.plan.query_obligations + ? { + query_obligations: { + ...proposal.plan.query_obligations, + finally_covered: useRecovered + ? recoveredObligationCoverage + : proposal.plan.query_obligations.initially_covered, + }, + } + : {}), ...(useRecovered ? { selected_fallback: finalAttempt.fallback } : {}), }, } diff --git a/src/runtime/stdio-server.ts b/src/runtime/stdio-server.ts index c3021af1..6573e57e 100644 --- a/src/runtime/stdio-server.ts +++ b/src/runtime/stdio-server.ts @@ -2,6 +2,7 @@ import { createInterface } from 'node:readline' import { realpathSync, statSync } from 'node:fs' import { basename, dirname, join, resolve } from 'node:path' import type { Readable, Writable } from 'node:stream' +import { setTimeout as delay } from 'node:timers/promises' import type { ContextSessionState } from '../contracts/context-session.js' import { compareRefs } from '../infrastructure/time-travel.js' @@ -73,6 +74,8 @@ const MAX_CONTEXT_PACK_CACHE_ENTRIES = 256 const graphCache = new Map }>() const MAX_COMPLETION_VALUES = 25 const MAX_LOG_NOTIFICATION_CHARS = 10_000 +const DEFAULT_AUTO_REFRESH_REQUEST_WAIT_MS = 25_000 +const AUTO_REFRESH_READINESS_POLL_MS = 50 const AUTO_REFRESH_CONTROL_METHODS = new Set([ 'initialize', @@ -166,6 +169,8 @@ export interface ServeGraphStdioOptions { errorOutput?: Writable /** Internal/testing seam for the production background auto-refresh launcher. */ autoRefreshStarter?: typeof startGraphAutoRefreshInBackground + /** Internal/testing override for how long graph-backed requests await reconciliation. */ + autoRefreshRequestWaitMs?: number logger?: { log(message?: string): void error(message?: string): void @@ -243,6 +248,55 @@ function autoRefreshGraphReadiness( } } +type AutoRefreshGraphReadiness = ReturnType + +async function waitForAutoRefreshGraphReadiness( + controller: GraphAutoRefreshController, + graphPath: string, + waitMs: number, +): Promise { + let readiness = autoRefreshGraphReadiness(controller, graphPath) + if (readiness.ready || !readiness.retryable || waitMs <= 0) { + return readiness + } + + const deadline = Date.now() + waitMs + while (Date.now() < deadline) { + await delay(Math.min(AUTO_REFRESH_READINESS_POLL_MS, Math.max(1, deadline - Date.now()))) + readiness = autoRefreshGraphReadiness(controller, graphPath) + if (readiness.ready || !readiness.retryable) { + return readiness + } + } + + return autoRefreshGraphReadiness(controller, graphPath) +} + +function graphNotReadyResponse( + request: StdioRequest, + readiness: AutoRefreshGraphReadiness, + waitedMs: number, +): StdioResponse { + const readinessData = { + type: 'madar_graph_not_ready', + state: readiness.state, + retryable: readiness.retryable, + ...(readiness.retryAfterMs !== undefined + ? { retry_after_ms: readiness.retryAfterMs } + : {}), + ...(waitedMs > 0 ? { waited_ms: waitedMs } : {}), + suggested_action: readiness.retryable ? 'retry_same_request' : 'repair_graph', + } + return failure( + requestId(request), + JSONRPC_SERVER_ERROR, + readiness.retryable + ? `Madar graph is temporarily ${readiness.state} (${readiness.detail}). Retry the same request after ${readiness.retryAfterMs ?? 1_000}ms; no manual graph generation is needed while reconciliation is active.` + : `Madar auto-refresh cannot guarantee a fresh graph (${readiness.detail}). Run \`madar status\`, then \`madar generate . --update\` if repair is required before retrying.`, + readinessData, + ) +} + function ok(id: string | number | null, result: unknown): StdioResponse { return { jsonrpc: '2.0', id, result } } @@ -1022,6 +1076,57 @@ export async function serveGraphStdio(options: ServeGraphStdioOptions): Promise< errorOutput.write(`[madar serve] stdio ready for ${options.graphPath}\n`) const readline = createInterface({ input, crlfDelay: Infinity }) + let graphRequestQueue = Promise.resolve() + + const handleAndWritePayload = async (payload: unknown, awaitReconciliation: boolean): Promise => { + let response: StdioResponse | null + try { + const request = payload as StdioRequest + const requestMethod = typeof request.method === 'string' ? request.method : null + let refreshReadiness = autoRefresh && requestMethod + ? autoRefreshGraphReadiness(autoRefresh, options.graphPath) + : null + let waitedMs = 0 + + if ( + awaitReconciliation + && autoRefresh + && refreshReadiness + && !refreshReadiness.ready + && refreshReadiness.retryable + ) { + const waitStartedAt = Date.now() + refreshReadiness = await waitForAutoRefreshGraphReadiness( + autoRefresh, + options.graphPath, + Math.max(0, options.autoRefreshRequestWaitMs ?? DEFAULT_AUTO_REFRESH_REQUEST_WAIT_MS), + ) + waitedMs = Date.now() - waitStartedAt + } + + if (refreshReadiness && !refreshReadiness.ready && requestMethod === 'prompts/list') { + response = ok(requestId(request), { prompts: MCP_PROMPTS }) + } else if (refreshReadiness && !refreshReadiness.ready && requestMethod === 'resources/list') { + response = ok(requestId(request), { resources: [] }) + } else if (refreshReadiness && !refreshReadiness.ready && requestMethod !== null && !AUTO_REFRESH_CONTROL_METHODS.has(requestMethod)) { + response = graphNotReadyResponse(request, refreshReadiness, waitedMs) + } else { + emitResourceNotifications(output, options.graphPath, sessionState) + response = await Promise.resolve(handleStdioRequest(options.graphPath, payload, sessionState)) + } + } catch (error) { + // A rejected handler must never tear down the whole stdio server: every + // request gets an answer and the loop keeps serving (#crash). + const message = error instanceof Error ? error.message : 'Request failed' + response = failure(requestId(payload as StdioRequest), JSONRPC_SERVER_ERROR, message) + } + if (response) { + if (response.error) { + emitLogNotification(output, sessionState, 'error', { message: response.error.message, code: response.error.code }) + } + output.write(`${JSON.stringify(response)}\n`) + } + } try { for await (const line of readline) { @@ -1046,53 +1151,20 @@ export async function serveGraphStdio(options: ServeGraphStdioOptions): Promise< continue } - let response: StdioResponse | null - try { - const request = payload as StdioRequest - const requestMethod = typeof request.method === 'string' ? request.method : null - const refreshReadiness = autoRefresh && requestMethod - ? autoRefreshGraphReadiness(autoRefresh, options.graphPath) - : null - if (refreshReadiness && !refreshReadiness.ready && requestMethod === 'prompts/list') { - response = ok(requestId(request), { prompts: MCP_PROMPTS }) - } else if (refreshReadiness && !refreshReadiness.ready && requestMethod === 'resources/list') { - response = ok(requestId(request), { resources: [] }) - } else if (refreshReadiness && !refreshReadiness.ready && requestMethod !== null && !AUTO_REFRESH_CONTROL_METHODS.has(requestMethod)) { - const readinessData = { - type: 'madar_graph_not_ready', - state: refreshReadiness.state, - retryable: refreshReadiness.retryable, - ...(refreshReadiness.retryAfterMs !== undefined - ? { retry_after_ms: refreshReadiness.retryAfterMs } - : {}), - suggested_action: refreshReadiness.retryable ? 'retry_same_request' : 'repair_graph', - } - response = failure( - requestId(request), - JSONRPC_SERVER_ERROR, - refreshReadiness.retryable - ? `Madar graph is temporarily ${refreshReadiness.state} (${refreshReadiness.detail}). Retry the same request after ${refreshReadiness.retryAfterMs ?? 1_000}ms; no manual graph generation is needed while reconciliation is active.` - : `Madar auto-refresh cannot guarantee a fresh graph (${refreshReadiness.detail}). Run \`madar status\`, then \`madar generate . --update\` if repair is required before retrying.`, - readinessData, - ) - } else { - emitResourceNotifications(output, options.graphPath, sessionState) - response = await Promise.resolve(handleStdioRequest(options.graphPath, payload, sessionState)) - } - } catch (error) { - // A rejected handler must never tear down the whole stdio server: every - // request gets an answer and the loop keeps serving (#crash). - const message = error instanceof Error ? error.message : 'Request failed' - response = failure(requestId(payload as StdioRequest), JSONRPC_SERVER_ERROR, message) - } - if (response) { - if (response.error) { - emitLogNotification(output, sessionState, 'error', { message: response.error.message, code: response.error.code }) - } - output.write(`${JSON.stringify(response)}\n`) + const request = payload as StdioRequest + const requestMethod = typeof request.method === 'string' ? request.method : null + if (autoRefresh && requestMethod !== null && !AUTO_REFRESH_CONTROL_METHODS.has(requestMethod)) { + // Keep control/discovery requests responsive while graph-backed work + // waits for one bounded reconciliation window. Graph requests remain + // serialized because context-pack calls mutate per-session state. + graphRequestQueue = graphRequestQueue.then(() => handleAndWritePayload(payload, true)) + continue } + + await handleAndWritePayload(payload, false) } } finally { + await graphRequestQueue if (autoRefresh) { autoRefresh.stop() await autoRefresh.completed diff --git a/src/shared/discovery-safety.ts b/src/shared/discovery-safety.ts index af54f936..823fe169 100644 --- a/src/shared/discovery-safety.ts +++ b/src/shared/discovery-safety.ts @@ -124,6 +124,7 @@ const RELEVANCE_TOKEN_ALIASES: Readonly> = { sensitive: 'secret', tokens: 'token', } +const ENVIRONMENT_CONFIG_INTENT_PATTERN = /(?:^|[^a-z0-9])\.env(?:[^a-z0-9]|$)|\b(?:config(?:uration)?|deploy(?:ment)?|environment|runtime\s+variable|secret|settings)\b/i const MAX_GRAPH_ARTIFACT_BYTES = 100 * 1024 * 1024 const MAX_STORED_EXCLUSIONS = 10_000 const MAX_METADATA_CACHE_ENTRIES = 16 @@ -364,6 +365,9 @@ export function relevantDiscoveryExclusions( const ownerPaths = (input.coveredWorkflowOwners ?? []).map(toPosixPath) const ownerTokens = relevanceTokens(ownerPaths.join(' ')) const relevantEntries = metadata.exclusions.filter((exclusion) => { + if (exclusion.reason === 'environment_file' && !ENVIRONMENT_CONFIG_INTENT_PATTERN.test(input.question ?? '')) { + return false + } const exclusionTokens = relevanceTokens(`${exclusion.path} ${exclusion.reason}`) return tokenSetsIntersect(exclusionTokens, questionTokens) || tokenSetsIntersect(exclusionTokens, ownerTokens) diff --git a/tests/fixtures/cross-layer-monitor-flow.ts b/tests/fixtures/cross-layer-monitor-flow.ts new file mode 100644 index 00000000..ce7c72d7 --- /dev/null +++ b/tests/fixtures/cross-layer-monitor-flow.ts @@ -0,0 +1,143 @@ +import { KnowledgeGraph } from '../../src/contracts/graph.js' + +interface FlowNode { + id: string + label: string + source: string + community: number + role?: string + metadata?: Record +} + +const FLOW_NODES: FlowNode[] = [ + { + id: 'failed_check', + label: 'FailedMonitorCheck.publishUpdate', + source: '/apps/checker/update.go', + community: 1, + role: 'monitor_checker', + }, + { + id: 'status_route', + label: 'POST /updateStatus', + source: '/apps/workflows/src/checker/index.ts', + community: 2, + role: 'http_route', + metadata: { route_path: '/updateStatus', http_method: 'POST' }, + }, + { + id: 'incident_create', + label: 'createIncidentForFailedCheck', + source: '/apps/workflows/src/checker/alerting.ts', + community: 2, + role: 'incident_writer', + }, + { + id: 'notification_dispatch', + label: 'triggerNotifications', + source: '/apps/workflows/src/checker/utils.ts', + community: 3, + role: 'notification_dispatcher', + }, + { + id: 'incident_table', + label: 'incidentTable', + source: '/packages/db/src/schema/incidents.ts', + community: 4, + role: 'persistence_schema', + }, + { + id: 'public_status', + label: 'statusPageRouter.get', + source: '/packages/api/src/router/statusPage.ts', + community: 5, + role: 'public_status_reader', + }, + { + id: 'alternate_status', + label: 'computeOverallStatus', + source: '/apps/server/src/routes/status-page/index.ts', + community: 6, + role: 'alternate_status_computation', + }, + { + id: 'status_json', + label: 'serializePublicStatusJson', + source: '/apps/status-page/src/content/status-json.ts', + community: 5, + role: 'public_status_feed', + }, +] + +const UI_DISTRACTOR_LABELS = [ + 'page /monitors/[id]/incidents', + 'page /notifications', + 'statusPageAlternates()', + 'PublicStatusPageStatusCard', +] as const + +const UI_DISTRACTORS: FlowNode[] = Array.from({ length: 14 }, (_, index) => ({ + id: `status_ui_${index}`, + label: UI_DISTRACTOR_LABELS[index] ?? ( + index % 2 === 0 ? `PublicStatusPageStatusCard${index}` : `MonitorNotificationStatusBadge${index}` + ), + source: `/apps/web/src/components/status/status-page-widget-${index}.tsx`, + community: 9, + role: 'ui_component', +})) + +const FLOW_EDGES: Array<[string, string, string]> = [ + ['failed_check', 'status_route', 'calls_route'], + ['status_route', 'incident_create', 'calls'], + ['incident_create', 'incident_table', 'writes'], + ['incident_create', 'notification_dispatch', 'calls'], + ['public_status', 'incident_table', 'reads'], + ['status_json', 'public_status', 'serializes'], + ['alternate_status', 'status_json', 'competes_with'], +] + +export const CROSS_LAYER_MONITOR_FLOW_FILES = [ + 'apps/checker/update.go', + 'apps/workflows/src/checker/index.ts', + 'apps/workflows/src/checker/alerting.ts', + 'apps/workflows/src/checker/utils.ts', + 'packages/db/src/schema/incidents.ts', + 'packages/api/src/router/statusPage.ts', + 'apps/server/src/routes/status-page/index.ts', + 'apps/status-page/src/content/status-json.ts', +] as const + +export function buildCrossLayerMonitorFlowFixture(): KnowledgeGraph { + const graph = new KnowledgeGraph({ directed: true }) + graph.graph.root_path = '/' + graph.graph.community_labels = { + 1: 'Monitor check execution', + 2: 'Incident workflow update route', + 3: 'Notification delivery', + 4: 'Incident persistence schema', + 5: 'Public status page reads', + 6: 'Alternative status computation', + 9: 'Status presentation components', + } + + for (const node of [...FLOW_NODES, ...UI_DISTRACTORS]) { + graph.addNode(node.id, { + label: node.label, + source_file: node.source, + source_location: 'L1-L8', + file_type: 'code', + node_kind: node.role === 'http_route' ? 'route' : 'function', + community: node.community, + snippet: `export function ${node.id}() { return '${node.label}' }`, + ...(node.role ? { framework_role: node.role } : {}), + ...(node.metadata ? { framework_metadata: node.metadata } : {}), + }) + } + for (const [source, target, relation] of FLOW_EDGES) { + graph.addEdge(source, target, { relation }) + } + graph.addEdge('status_ui_0', 'status_ui_1', { relation: 'links_to' }) + graph.addEdge('status_ui_1', 'status_ui_2', { relation: 'renders' }) + graph.addEdge('status_ui_2', 'status_ui_3', { relation: 'renders' }) + return graph +} diff --git a/tests/unit/background-auto-refresh.test.ts b/tests/unit/background-auto-refresh.test.ts index 216ab82a..40b08091 100644 --- a/tests/unit/background-auto-refresh.test.ts +++ b/tests/unit/background-auto-refresh.test.ts @@ -167,7 +167,7 @@ describe('background auto-refresh', () => { } }) - it('completes MCP discovery and fails graph reads closed during slow startup', async () => { + it('keeps MCP discovery responsive while one graph request waits for slow startup', async () => { const root = mkdtempSync(join(tmpdir(), 'madar-background-mcp-')) const graphPath = join(root, 'out', 'graph.json') const watchModulePath = join(root, 'slow-watch.mjs') @@ -190,6 +190,7 @@ describe('background auto-refresh', () => { JSON.stringify({ id: 3, method: 'resources/list' }), JSON.stringify({ id: 4, method: 'tools/list' }), JSON.stringify({ id: 5, method: 'stats' }), + JSON.stringify({ id: 6, method: 'ping' }), ].join('\n')}\n`) const serverPromise = serveGraphStdio({ @@ -199,6 +200,7 @@ describe('background auto-refresh', () => { input, output, errorOutput, + autoRefreshRequestWaitMs: 2_500, autoRefreshStarter: (watchPath, debounceSeconds, options) => { refreshController = startGraphAutoRefreshInBackground( watchPath, @@ -211,8 +213,9 @@ describe('background auto-refresh', () => { }) try { - await waitFor(() => outputText.includes('"id":5')) + await waitFor(() => outputText.includes('"id":6')) expect(existsSync(completionMarker)).toBe(false) + expect(outputText).not.toContain('"id":5') const responses = outputText .trim() @@ -230,28 +233,21 @@ describe('background auto-refresh', () => { expect(responses.find((response) => response.id === 2)?.result).toMatchObject({ prompts: expect.any(Array) }) expect(responses.find((response) => response.id === 3)?.result).toEqual({ resources: [] }) expect(responses.find((response) => response.id === 4)?.result).toMatchObject({ tools: expect.any(Array) }) - expect(responses.find((response) => response.id === 5)?.error).toMatchObject({ - message: expect.stringContaining('temporarily starting'), - data: { - state: 'starting', - retryable: true, - retry_after_ms: 1_000, - suggested_action: 'retry_same_request', - }, - }) + expect(responses.find((response) => response.id === 6)?.result).toEqual({ ok: true }) await waitFor(() => refreshController?.startupComplete?.() === true) expect(existsSync(completionMarker)).toBe(true) publishReadyWatcherState(root, graphPath) - input.end(`${JSON.stringify({ id: 6, method: 'stats' })}\n`) + await waitFor(() => outputText.includes('"id":5')) + input.end() await serverPromise const readyResponses = outputText .trim() .split('\n') .filter(Boolean) .map((line) => JSON.parse(line) as { id?: number; result?: string; error?: unknown }) - expect(readyResponses.find((response) => response.id === 6)?.result).toContain('Nodes:') - expect(readyResponses.find((response) => response.id === 6)?.error).toBeUndefined() + expect(readyResponses.find((response) => response.id === 5)?.result).toContain('Nodes:') + expect(readyResponses.find((response) => response.id === 5)?.error).toBeUndefined() expect(readFileSync(watchModulePath, 'utf8')).toContain('Deliberately block only the worker thread') } finally { input.destroy() @@ -260,6 +256,82 @@ describe('background auto-refresh', () => { } }) + it('bounds a queued graph request timeout and still shuts down cleanly', async () => { + const root = mkdtempSync(join(tmpdir(), 'madar-background-timeout-')) + const graphPath = join(root, 'out', 'graph.json') + const input = new PassThrough() + const output = new PassThrough() + const errorOutput = new PassThrough() + let outputText = '' + let stopped = false + output.on('data', (chunk) => { + outputText += chunk.toString('utf8') + }) + writeFileSync(join(root, 'main.ts'), 'export const value = 1\n', 'utf8') + generateGraph(root, { noHtml: true }) + const policy = readStoredGenerationPolicy(graphPath, join(root, 'out', 'manifest.json')) + if (!policy) { + throw new Error('Expected generated policy metadata') + } + writeWatcherState(join(root, 'out'), { + ...createWatcherState('polling', 0), + status: 'reconciling', + coverage: 'complete', + stored_policy_fingerprint: policy.fingerprint, + current_policy_fingerprint: policy.fingerprint, + policy_match: true, + }) + input.end([ + JSON.stringify({ id: 31, method: 'stats' }), + JSON.stringify({ id: 32, method: 'ping' }), + ].join('\n')) + const startedAt = Date.now() + + try { + await serveGraphStdio({ + graphPath, + autoRefresh: true, + workspaceRoot: root, + autoRefreshRequestWaitMs: 75, + input, + output, + errorOutput, + autoRefreshStarter: () => ({ + initialRebuilt: false, + startupComplete: () => true, + failureReason: () => null, + stop() { stopped = true }, + completed: Promise.resolve(), + }), + }) + + const responses = outputText + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as { + id?: number + result?: unknown + error?: { data?: { state?: string; retryable?: boolean; waited_ms?: number } } + }) + expect(responses.findIndex((response) => response.id === 32)).toBeLessThan( + responses.findIndex((response) => response.id === 31), + ) + expect(responses.find((response) => response.id === 32)?.result).toEqual({ ok: true }) + expect(responses.find((response) => response.id === 31)?.error?.data).toMatchObject({ + state: 'reconciling', + retryable: true, + waited_ms: expect.any(Number), + }) + expect(responses.find((response) => response.id === 31)?.error?.data?.waited_ms).toBeGreaterThanOrEqual(50) + expect(Date.now() - startedAt).toBeLessThan(1_000) + expect(stopped).toBe(true) + } finally { + input.destroy() + rmSync(root, { recursive: true, force: true }) + } + }) + it('keeps MCP connected and exposes a background startup failure', async () => { const root = mkdtempSync(join(tmpdir(), 'madar-background-failure-')) const graphPath = join(root, 'out', 'graph.json') diff --git a/tests/unit/context-pack-command.test.ts b/tests/unit/context-pack-command.test.ts index b7f6d37b..964cfa73 100644 --- a/tests/unit/context-pack-command.test.ts +++ b/tests/unit/context-pack-command.test.ts @@ -1057,6 +1057,114 @@ describe('context-pack-command', () => { ]) }) + it('compacts envelope metadata and snippets before culling a cross-layer workflow spine', () => { + const base = buildOversizedAnswerReadySchema() + const extraNodes = Array.from({ length: 4 }, (_, index) => ({ + node_id: `cross-layer-${index}`, + label: `CrossLayerStep${index}.run`, + source_file: `apps/layer-${index}/src/step.ts`, + line_number: 90 + index, + snippet: `export async function run${index}() { ${'await downstream.execute(); '.repeat(40)} }`, + })) + const allNodes = [...base.pack.matched_nodes, ...extraNodes] + const relationships = allNodes.slice(1).map((node, index) => ({ + from_id: allNodes[index]?.node_id, + from: allNodes[index]?.label, + to_id: node.node_id, + to: node.label, + relation: 'calls', + })) + const schema = { + ...base, + evidence: { + ...base.evidence, + evidence_strength: { + level: 'strong', + direct_selected_nodes: 8, + supporting_selected_nodes: 0, + selected_relationships: relationships.length, + available_relationships: relationships.length, + reasons: ['direct graph evidence spans the workflow'], + }, + coverage_detail: { + status: 'complete', + required_obligations: ['failure source', 'incident persistence', 'notification dispatch', 'public status', 'divergence'], + covered_obligations: ['failure source', 'incident persistence', 'notification dispatch', 'public status', 'divergence'], + missing_obligations: [], + }, + answerability: { + state: 'ready', + answer_scope: 'complete', + caveats: [], + missing_obligations: [], + verification_targets: [], + broad_search_fallback: 'not_needed', + }, + recovery: { + version: 1, + status: 'not_needed', + budget: { max_attempts: 2, max_candidate_nodes: 24, max_elapsed_ms: 2_000, output_token_budget: 1_800 }, + initial_state: 'ready', + final_state: 'ready', + attempts: [], + improved: false, + }, + discovery_exclusions: { + policy: 'artifact_path_only', + total: 15, + relevant: 0, + reasons: { env_file: 15 }, + relevant_reasons: {}, + }, + indexing_completeness: { + state: 'partial', + total_uncertain: 158, + relevant_uncertain: 0, + reasons: { unsupported_file_type: 85 }, + relevant_reasons: {}, + }, + }, + pack: { + ...base.pack, + question: base.prompt, + token_count: 1_793, + recovery: { + version: 1, + status: 'not_needed', + budget: { max_attempts: 2, max_candidate_nodes: 24, max_elapsed_ms: 2_000, output_token_budget: 1_800 }, + initial_state: 'ready', + final_state: 'ready', + attempts: [], + improved: false, + }, + matched_nodes: allNodes, + relationships, + }, + } + + const payload = buildAnswerReadyPackSchema(schema, 1_800, buildAnswerReadySelectionDiagnostics()) + const pack = payload.pack as { + matched_nodes?: Array<{ source_file?: string; snippet?: string }> + relationships?: unknown[] + } + const evidence = payload.evidence as { + pack_confidence?: string + confidence_reasons?: string[] + } + + expect(estimateQueryTokens(JSON.stringify(payload))).toBeLessThanOrEqual(1_800) + expect(payload.serialized_budget).toEqual(expect.objectContaining({ + max_tokens: 1_800, + enforced: true, + })) + expect(pack.matched_nodes).toHaveLength(8) + expect(new Set(pack.matched_nodes?.map((node) => node.source_file))).toHaveLength(8) + expect(pack.matched_nodes?.every((node) => (node.snippet?.length ?? 0) <= 220)).toBe(true) + expect(pack.relationships).toHaveLength(relationships.length) + expect(evidence.pack_confidence).toBe('high') + expect(evidence.confidence_reasons ?? []).not.toContain('budget too tight for workflow spine') + }) + it('preserves existing obligations, caveats, and blocked fallback when tight budgets cull the workflow spine', () => { const matchedNodes = Array.from({ length: 6 }, (_, index) => ({ node_id: `runtime-${index}`, diff --git a/tests/unit/indexing-completeness.test.ts b/tests/unit/indexing-completeness.test.ts index 932a8e8b..e59cdead 100644 --- a/tests/unit/indexing-completeness.test.ts +++ b/tests/unit/indexing-completeness.test.ts @@ -198,6 +198,33 @@ describe('indexing completeness manifests', () => { ]) }) + it('does not treat same-domain env, docs, hidden paths, or unrelated artifacts as code-flow uncertainty', () => { + const manifest = createIndexingManifest({ + outcomes: [ + outcome({ path: 'apps/status-page/.env.example', status: 'skipped_by_policy', reason: 'environment_file', capability: null }), + outcome({ path: 'apps/status-page/README.md', status: 'skipped_by_policy', reason: 'docs_disabled', capability: null }), + outcome({ path: '.github', kind: 'directory', status: 'skipped_by_policy', reason: 'hidden_path', capability: null }), + outcome({ path: 'apps/status-page/public/logo.svg', status: 'unsupported', reason: 'unsupported_file_type', capability: null }), + ], + }) + + const codeFlow = relevantIndexingUncertainty(manifest, { + question: 'How does an incident affect the public status page?', + coveredWorkflowOwners: ['apps/status-page/src/content/status-json.ts'], + }) + expect(codeFlow.relevant).toBe(0) + + expect(relevantIndexingUncertainty(manifest, { + question: 'Which environment config controls the status page?', + }).relevant_reasons).toEqual({ environment_file: 1 }) + expect(relevantIndexingUncertainty(manifest, { + question: 'What does the status-page README document?', + }).relevant_reasons).toEqual({ docs_disabled: 1 }) + expect(relevantIndexingUncertainty(manifest, { + question: 'How is the status-page logo.svg asset used?', + }).relevant_reasons).toEqual({ unsupported_file_type: 1 }) + }) + it('marks an unsupported-only candidate set as failed rather than false-partial', () => { const manifest = createIndexingManifest({ outcomes: [outcome({ diff --git a/tests/unit/mcp-response-evidence.test.ts b/tests/unit/mcp-response-evidence.test.ts index 2daf1600..4bb9c297 100644 --- a/tests/unit/mcp-response-evidence.test.ts +++ b/tests/unit/mcp-response-evidence.test.ts @@ -247,6 +247,20 @@ describe('mcp-response-evidence', () => { }) }) + it('does not let an adjacent environment example lower code-flow confidence', () => { + const metadata = buildDiscoverySafetyMetadata([ + { path: 'apps/status-page/.env.example', kind: 'sensitive', reason: 'environment_file' }, + ]) + + expect(relevantDiscoveryExclusions(metadata, { + question: 'How does an incident affect the public status page?', + coveredWorkflowOwners: ['apps/status-page/src/content/status-json.ts'], + }).relevant).toBe(0) + expect(relevantDiscoveryExclusions(metadata, { + question: 'Which environment config controls the status page?', + }).relevant).toBe(1) + }) + it('downgrades answerability for relevant exclusions without exposing their paths', () => { const root = mkdtempSync(join(tmpdir(), 'madar-discovery-evidence-')) const graphPath = join(root, 'out', 'graph.json') diff --git a/tests/unit/retrieval-gate.test.ts b/tests/unit/retrieval-gate.test.ts index 19e30b63..8497d0a7 100644 --- a/tests/unit/retrieval-gate.test.ts +++ b/tests/unit/retrieval-gate.test.ts @@ -314,6 +314,15 @@ describe('classifyRetrievalLevel — signal extraction', () => { expect(genericExplainDecision.signals.target_domain_hint).toBe('backend_runtime') expect(genericExplainSignals.generation_debug?.flow_proof_shaped).toBe(false) }) + + it('treats execution-owner questions as behavior traces', () => { + const decision = classify({ prompt: 'What runs the monthly billing close?' }) + + expect(decision.intent).toBe('explain') + expect(decision.level).toBe(3) + expect(decision.reason).toMatch(/runtime flow|behavior slice/i) + expect(decision.signals.generation_debug?.flow_proof_shaped).toBe(true) + }) }) describe('classifyRetrievalLevel — exclusions and negation', () => { diff --git a/tests/unit/retrieve-cross-layer-flow.test.ts b/tests/unit/retrieve-cross-layer-flow.test.ts new file mode 100644 index 00000000..63fb385c --- /dev/null +++ b/tests/unit/retrieve-cross-layer-flow.test.ts @@ -0,0 +1,148 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' + +import { describe, expect, it } from 'vitest' + +import { retrieveContext } from '../../src/runtime/retrieve.js' +import { assessMadarResponseEvidence } from '../../src/runtime/mcp-response-evidence.js' +import { buildRetrievalEvidencePlanFromResult } from '../../src/runtime/retrieve/pipeline.js' +import { handleStdioRequest } from '../../src/runtime/stdio-server.js' +import { + buildCrossLayerMonitorFlowFixture, + CROSS_LAYER_MONITOR_FLOW_FILES, +} from '../fixtures/cross-layer-monitor-flow.js' + +const QUESTION = 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status in this repository. Cite the exact files and symbols involved, identify any inconsistent status-computation paths, and clearly state any remaining uncertainty.' + +function writeCrossLayerGraphFixture(root: string): string { + const graph = buildCrossLayerMonitorFlowFixture() + const graphPath = join(root, 'graph.json') + writeFileSync(graphPath, JSON.stringify({ + ...graph.graph, + directed: graph.isDirected(), + nodes: graph.nodeEntries().map(([id, attributes]) => ({ id, ...attributes })), + edges: graph.edgeEntries().map(([source, target, attributes]) => ({ source, target, ...attributes })), + hyperedges: [], + }), 'utf8') + return graphPath +} + +describe('cross-layer flow retrieval', () => { + it('covers every flow obligation without letting presentation vocabulary dominate', () => { + const started = performance.now() + const result = retrieveContext(buildCrossLayerMonitorFlowFixture(), { + question: QUESTION, + budget: 1_800, + retrievalStrategy: 'slice-v1', + }) + const elapsedMs = performance.now() - started + const selectedFiles = new Set(result.matched_nodes.map((node) => node.source_file)) + const relevantSelected = [...selectedFiles].filter((file) => ( + CROSS_LAYER_MONITOR_FLOW_FILES.includes(file as typeof CROSS_LAYER_MONITOR_FLOW_FILES[number]) + )) + const precision = relevantSelected.length / Math.max(selectedFiles.size, 1) + const evidence = assessMadarResponseEvidence({ + evidencePlan: buildRetrievalEvidencePlanFromResult(result), + question: QUESTION, + recovery: result.recovery, + }) + + expect( + CROSS_LAYER_MONITOR_FLOW_FILES.every((file) => selectedFiles.has(file)), + JSON.stringify({ + selected: [...selectedFiles], + labels: result.matched_nodes.map((node) => node.label), + relationships: result.relationships, + retrievalPlan: result.retrieval_plan, + recovery: result.recovery, + }, null, 2), + ).toBe(true) + expect(precision).toBeGreaterThanOrEqual(0.7) + expect(result.relationships.length).toBeGreaterThanOrEqual(5) + expect(result.retrieval_plan).toMatchObject({ + status: 'recovered', + reasons: expect.arrayContaining(['missing_query_obligations']), + query_obligations: { + total: 5, + initially_covered: 1, + finally_covered: 5, + }, + attempts: [expect.objectContaining({ + status: 'applied', + promoted_communities: expect.arrayContaining([1, 2, 3, 4, 5, 6]), + })], + }) + expect(evidence.answerability.state).toMatch(/^ready(?:_with_caveat)?$/) + expect(evidence.answerability.broad_search_fallback).toBe('not_needed') + expect(evidence.agent_directive).toBe('answer_from_pack') + expect(result.token_count).toBeLessThanOrEqual(1_800) + expect(elapsedMs).toBeLessThan(750) + }) + + it('returns an answer-ready workflow through one context_pack MCP call', async () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-cross-layer-mcp-')) + const previousToolProfile = process.env.MADAR_TOOL_PROFILE + try { + process.env.MADAR_TOOL_PROFILE = 'strict' + const response = await Promise.resolve(handleStdioRequest(writeCrossLayerGraphFixture(root), { + id: 1, + method: 'tools/call', + params: { + name: 'context_pack', + arguments: { + prompt: QUESTION, + task: 'explain', + budget: 1_800, + }, + }, + })) + expect((response as { error?: unknown }).error, JSON.stringify(response, null, 2)).toBeUndefined() + + const payload = JSON.parse( + (response?.result as { content: Array<{ text: string }> }).content[0]!.text, + ) as { + pack?: { + matched_nodes?: Array<{ source_file: string }> + relationships?: unknown[] + } + evidence?: { + answerability?: { state?: string; broad_search_fallback?: string } + agent_directive?: string + } + } + const selectedFiles = new Set(payload.pack?.matched_nodes?.map((node) => node.source_file) ?? []) + const relevantSelected = [...selectedFiles].filter((file) => ( + CROSS_LAYER_MONITOR_FLOW_FILES.includes(file as typeof CROSS_LAYER_MONITOR_FLOW_FILES[number]) + )) + const uiSelected = [...selectedFiles].filter((file) => file.includes('/components/status/')) + + expect( + CROSS_LAYER_MONITOR_FLOW_FILES.every((file) => selectedFiles.has(file)), + JSON.stringify(payload, null, 2), + ).toBe(true) + expect(relevantSelected.length / Math.max(selectedFiles.size, 1)).toBeGreaterThanOrEqual(0.7) + expect(uiSelected.length).toBeLessThanOrEqual(2) + expect( + payload.pack?.relationships?.length ?? 0, + JSON.stringify(payload, null, 2), + ).toBeGreaterThanOrEqual(5) + expect(payload.evidence).toMatchObject({ + answerability: { + state: expect.stringMatching(/^ready(?:_with_caveat)?$/), + broad_search_fallback: 'not_needed', + }, + agent_directive: 'answer_from_pack', + }) + } finally { + if (previousToolProfile === undefined) { + delete process.env.MADAR_TOOL_PROFILE + } else { + process.env.MADAR_TOOL_PROFILE = previousToolProfile + } + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/unit/stdio-server.test.ts b/tests/unit/stdio-server.test.ts index ac968268..37ab61a2 100644 --- a/tests/unit/stdio-server.test.ts +++ b/tests/unit/stdio-server.test.ts @@ -2724,7 +2724,7 @@ describe('stdio runtime', () => { } }, 10_000) - it('refuses graph answers while an auto-refresh event is pending, then recovers', async () => { + it('holds one graph request while an auto-refresh event is pending, then answers it', async () => { const root = mkdtempSync(join(tmpdir(), 'madar-stdio-pending-refresh-')) const graphPath = join(root, 'out', 'graph.json') const input = new PassThrough() @@ -2741,6 +2741,7 @@ describe('stdio runtime', () => { autoRefresh: true, workspaceRoot: root, autoRefreshDebounceSeconds: 0.5, + autoRefreshRequestWaitMs: 2_500, input, output, errorOutput, @@ -2752,7 +2753,8 @@ describe('stdio runtime', () => { await waitFor(() => readWatcherStateForGraph(graphPath)?.status === 'pending') input.write(`${JSON.stringify({ id: 31, method: 'stats' })}\n`) - await waitFor(() => outputText.includes('"id":31')) + await delay(50) + expect(outputText).not.toContain('"id":31') await waitFor(() => { if (readWatcherStateForGraph(graphPath)?.status !== 'idle') { @@ -2761,6 +2763,7 @@ describe('stdio runtime', () => { const graph = JSON.parse(readFileSync(graphPath, 'utf8')) as { nodes?: Array<{ source_file?: string }> } return graph.nodes?.some((node) => node.source_file?.endsWith('added.ts')) === true }) + await waitFor(() => outputText.includes('"id":31')) input.end(`${JSON.stringify({ id: 32, method: 'stats' })}\n`) await serverPromise @@ -2773,16 +2776,8 @@ describe('stdio runtime', () => { result?: string error?: { message?: string; data?: Record } }> - expect(responses.find((response) => response.id === 31)?.error).toMatchObject({ - message: expect.stringContaining('temporarily pending'), - data: { - type: 'madar_graph_not_ready', - state: 'pending', - retryable: true, - retry_after_ms: 1_000, - suggested_action: 'retry_same_request', - }, - }) + expect(responses.find((response) => response.id === 31)?.result).toContain('Nodes:') + expect(responses.find((response) => response.id === 31)?.error).toBeUndefined() expect(responses.find((response) => response.id === 32)?.result).toContain('Nodes:') } finally { input.destroy() @@ -2806,6 +2801,7 @@ describe('stdio runtime', () => { graphPath, autoRefresh: true, workspaceRoot: root, + autoRefreshRequestWaitMs: 0, input, output, errorOutput, @@ -2897,6 +2893,7 @@ describe('stdio runtime', () => { autoRefresh: true, workspaceRoot: root, autoRefreshDebounceSeconds: 0.02, + autoRefreshRequestWaitMs: 2_500, input, output, errorOutput, @@ -2910,10 +2907,11 @@ describe('stdio runtime', () => { input.write(`${JSON.stringify({ id: 52, method: 'stats' })}\n`) input.write(`${JSON.stringify({ id: 51, method: 'ping' })}\n`) - await waitFor(() => outputText.includes('"id":52'), 1_000) await waitFor(() => outputText.includes('"id":51'), 1_000) + expect(outputText).not.toContain('"id":52') rmSync(lockPath, { force: true }) await waitFor(() => readWatcherStateForGraph(graphPath)?.status === 'idle') + await waitFor(() => outputText.includes('"id":52'), 1_000) input.end(`${JSON.stringify({ id: 53, method: 'stats' })}\n`) await serverPromise @@ -2935,15 +2933,8 @@ describe('stdio runtime', () => { } }) expect(responses.find((response) => response.id === 51)?.result).toEqual({ ok: true }) - expect(responses.find((response) => response.id === 52)?.error).toMatchObject({ - message: expect.stringContaining('temporarily reconciling'), - data: { - state: 'reconciling', - retryable: true, - retry_after_ms: 1_000, - suggested_action: 'retry_same_request', - }, - }) + expect(responses.find((response) => response.id === 52)?.result).toEqual(expect.any(String)) + expect(responses.find((response) => response.id === 52)?.error).toBeUndefined() expect(responses.find((response) => response.id === 53)?.result).toEqual(expect.any(String)) } finally { rmSync(lockPath, { force: true }) diff --git a/tests/unit/watch.test.ts b/tests/unit/watch.test.ts index c41bfa59..f0bf767b 100644 --- a/tests/unit/watch.test.ts +++ b/tests/unit/watch.test.ts @@ -180,10 +180,116 @@ describe('rebuildCode', () => { }) describe('watch', () => { + test('settles startup without rebuilding an unchanged valid graph', async () => { + await withTempDirAsync(async (tempDir) => { + writeFileSync(join(tempDir, 'main.ts'), 'export const value = 1\n', 'utf8') + const generated = generateGraph(tempDir, { noHtml: true }) + const rebuild = vi.fn(() => true) + const refresh = startGraphAutoRefresh(tempDir, 0.02, { + pollIntervalMs: 20, + noHtml: true, + rebuildCode: rebuild, + logger: { log() {}, error() {} }, + }) + + try { + await refresh.startupSettled + expect(refresh.startupComplete?.()).toBe(true) + expect(refresh.initialRebuilt).toBe(false) + expect(rebuild).not.toHaveBeenCalled() + expect(readWatcherStateForGraph(generated.graphPath)).toMatchObject({ + status: 'idle', + coverage: 'complete', + policy_match: true, + }) + } finally { + refresh.stop() + await refresh.completed + } + }) + }) + + test('does not treat hard-ignored source directories as new graph candidates', async () => { + await withTempDirAsync(async (tempDir) => { + writeFileSync(join(tempDir, 'main.ts'), 'export const value = 1\n', 'utf8') + mkdirSync(join(tempDir, 'logs'), { recursive: true }) + writeFileSync(join(tempDir, 'logs', 'client.tsx'), 'export const ignored = true\n', 'utf8') + generateGraph(tempDir, { noHtml: true }) + const rebuild = vi.fn(() => true) + const refresh = startGraphAutoRefresh(tempDir, 0.02, { + pollIntervalMs: 20, + noHtml: true, + rebuildCode: rebuild, + logger: { log() {}, error() {} }, + }) + + try { + await refresh.startupSettled + expect(refresh.initialRebuilt).toBe(false) + expect(rebuild).not.toHaveBeenCalled() + } finally { + refresh.stop() + await refresh.completed + } + }) + }) + + test('still rebuilds before startup settles when an indexed source changed', async () => { + await withTempDirAsync(async (tempDir) => { + const sourcePath = join(tempDir, 'main.ts') + writeFileSync(sourcePath, 'export const value = 1\n', 'utf8') + generateGraph(tempDir, { noHtml: true }) + writeFileSync(sourcePath, 'export const value = 2\n', 'utf8') + const rebuild = vi.fn(() => true) + const refresh = startGraphAutoRefresh(tempDir, 0.02, { + pollIntervalMs: 20, + noHtml: true, + rebuildCode: rebuild, + logger: { log() {}, error() {} }, + }) + + try { + await refresh.startupSettled + expect(refresh.startupComplete?.()).toBe(true) + expect(refresh.initialRebuilt).toBe(true) + expect(rebuild).toHaveBeenCalledTimes(1) + } finally { + refresh.stop() + await refresh.completed + } + }) + }) + + test('still rebuilds before startup settles when a new source was added', async () => { + await withTempDirAsync(async (tempDir) => { + writeFileSync(join(tempDir, 'main.ts'), 'export const value = 1\n', 'utf8') + generateGraph(tempDir, { noHtml: true }) + writeFileSync(join(tempDir, 'added.ts'), 'export const added = 2\n', 'utf8') + const rebuild = vi.fn(() => true) + const refresh = startGraphAutoRefresh(tempDir, 0.02, { + pollIntervalMs: 20, + noHtml: true, + rebuildCode: rebuild, + logger: { log() {}, error() {} }, + }) + + try { + await refresh.startupSettled + expect(refresh.startupComplete?.()).toBe(true) + expect(refresh.initialRebuilt).toBe(true) + expect(rebuild).toHaveBeenCalledTimes(1) + } finally { + refresh.stop() + await refresh.completed + } + }) + }) + test('keeps startup unsettled during live lease contention and recovers after release', async () => { await withTempDirAsync(async (tempDir) => { writeFileSync(join(tempDir, 'main.ts'), 'export const value = 1\n', 'utf8') const generated = generateGraph(tempDir, { noHtml: true }) + writeFileSync(join(tempDir, 'main.ts'), 'export const value = 2\n', 'utf8') const releaseOwner = tryAcquireRefreshLease(generated.outputDir) expect(releaseOwner).toBeTypeOf('function') From feef7144f13943c689f5214ade814a7ec88ad7bc Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Fri, 17 Jul 2026 22:47:50 +0400 Subject: [PATCH 02/11] fix: stabilize strict MCP activation and retrieval --- README.md | 2 +- docs/auto-refresh.md | 2 +- docs/integrations/compatibility.md | 12 +- docs/reference/cli-and-mcp.md | 8 +- docs/release.md | 2 +- docs/security/mcp-threat-model.md | 3 +- docs/tutorials/agent-quickstarts.md | 8 +- src/contracts/context-pack.ts | 4 + src/infrastructure/compare.ts | 211 ++++- src/infrastructure/context-pack-command.ts | 412 ++++++++- src/infrastructure/doctor.ts | 10 +- src/infrastructure/install-skill-templates.ts | 15 +- src/infrastructure/install.ts | 354 +++++--- src/infrastructure/watch.ts | 19 + src/runtime/context-pack.ts | 140 ++- src/runtime/freshness.ts | 21 + src/runtime/mcp-response-evidence.ts | 29 +- src/runtime/retrieve.ts | 821 +++++++++++++++++- src/runtime/retrieve/conceptual-fallback.ts | 628 +++++++++++++- src/runtime/retrieve/expansion.ts | 9 + src/runtime/retrieve/pipeline.ts | 20 +- src/runtime/stdio-server.ts | 103 ++- src/runtime/stdio/definitions.ts | 48 +- src/runtime/stdio/tools.ts | 455 ++++++++-- tests/fixtures/cross-layer-monitor-flow.ts | 26 +- tests/setup.ts | 11 + tests/unit/compare-native-agent.test.ts | 243 +++++- tests/unit/compare.test.ts | 3 +- tests/unit/context-pack-command.test.ts | 181 +++- tests/unit/context-pack-recovery.test.ts | 7 +- tests/unit/context-pack.test.ts | 192 ++++ tests/unit/doctor.test.ts | 41 +- tests/unit/freshness-surfaces.test.ts | 14 + tests/unit/install-compatibility.test.ts | 18 +- tests/unit/install-docs.test.ts | 12 +- tests/unit/install-templates.test.ts | 16 +- tests/unit/install.test.ts | 320 +++---- tests/unit/mcp-schema-budget.test.ts | 10 +- .../unit/retrieve-conceptual-fallback.test.ts | 201 ++++- tests/unit/retrieve-cross-layer-flow.test.ts | 331 ++++++- tests/unit/retrieve.test.ts | 65 ++ tests/unit/stdio-tool-profile.test.ts | 461 +++++++++- tests/unit/watch.test.ts | 31 + vitest.config.ts | 1 + 44 files changed, 4957 insertions(+), 563 deletions(-) create mode 100644 tests/setup.ts diff --git a/README.md b/README.md index e810e858..968a02db 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Installer details are in the [CLI and MCP reference](https://github.com/mohanagy After upgrading Madar, rerun your agent's install command so its managed profile receives current runtime settings. Older profiles may lack automatic refresh; older Codex profiles may also lack the extended MCP startup window needed by large or synchronized workspaces. -Starting with `0.31.2`, Codex installs set `startup_timeout_sec = 180`. Madar makes the MCP transport available while the initial graph reconciliation runs in a background worker. Graph-backed calls resume only after startup completes, watcher health is non-blocking with complete coverage, and the idle watcher's policy matches the published graph and manifest; `idle` alone is not a readiness guarantee. +Starting with this release, Codex installs create a workspace-scoped MCP block in `$CODEX_HOME/config.toml` (normally `~/.codex/config.toml`) with `startup_timeout_sec = 180` and `tool_timeout_sec = 60`. Madar makes the MCP transport available while the initial graph reconciliation runs in a background worker. Graph-backed calls resume only after startup completes, watcher health is non-blocking with complete coverage, and the idle watcher's policy matches the published graph and manifest; `idle` alone is not a readiness guarantee. Starting with `0.31.3`, a graph-backed call made while Madar is `starting`, `pending`, or `reconciling` returns a structured retryable response. The agent should retry the same Madar request after the suggested delay instead of bypassing Madar or running generation manually. A dead refresh owner is recovered automatically; only failed, incomplete, or policy-mismatched graph states ask for repair. diff --git a/docs/auto-refresh.md b/docs/auto-refresh.md index c5ef350b..76fb34ea 100644 --- a/docs/auto-refresh.md +++ b/docs/auto-refresh.md @@ -1,6 +1,6 @@ # Auto-refresh and generation policy -Installed MCP profiles run `madar serve --stdio --auto-refresh`. The stdio transport becomes available immediately while automatic refresh runs in a background worker. Before that worker starts, Madar publishes a `starting` watcher state so graph-backed requests cannot read an unvalidated graph. The worker starts a recursive filesystem listener and takes an authoritative source snapshot. When the existing graph has matching generation policy, complete indexing outcomes, fresh source fingerprints, no added or deleted candidates, and no newer control files, Madar publishes it as usable without rebuilding it. Any uncertainty or detected change keeps the existing rebuild-and-reconcile path. +Installed MCP profiles run `madar serve --stdio --auto-refresh`. The stdio transport becomes available immediately while automatic refresh runs in a background worker. Before that worker starts, Madar publishes a `starting` watcher state so graph-backed requests cannot read an unvalidated graph. The worker starts a recursive filesystem listener and takes an authoritative source snapshot. When the existing graph has matching generation policy, complete indexing outcomes, fresh source fingerprints, no added or deleted candidates, and no newer control files, Madar publishes it as usable without rebuilding it. Root-level `AGENTS.md` and `CLAUDE.md` files managed by an agent installer are execution guidance, not graph evidence, so their installation or later edits do not force a refresh. Any uncertainty or detected source change keeps the existing rebuild-and-reconcile path. Filesystem events provide low-latency invalidation; they are not the correctness boundary. Madar also performs full reconciliations on an adaptive schedule. Idle intervals back off from 30 seconds to at most 5 minutes when recursive events are available. Platforms without recursive events use adaptive polling from 1 second to at most 30 seconds. The lower-level `pollIntervalMs` option is an internal/test override rather than a CLI setting. diff --git a/docs/integrations/compatibility.md b/docs/integrations/compatibility.md index c2325b9e..88a38da7 100644 --- a/docs/integrations/compatibility.md +++ b/docs/integrations/compatibility.md @@ -10,12 +10,12 @@ These commands write repo-local instructions, hooks, rules, MCP config, or plugi | Agent | Command | Generated files/config | Verify | Surface | Profile behavior | Known limitation | |---|---|---|---|---|---|---| -| Claude Code | `madar claude install [--profile core\|full\|strict]` | `CLAUDE.md`, `.claude/settings.json`, `.mcp.json` | `madar doctor` / `madar status` | MCP tools, prompts, and resources via the selected tool profile. | `core`, `full`, and `strict`; strict exposes core plus `context_pack` and `context_expand` for one bounded context-pack-first pass. | The `UserPromptSubmit` hook only injects guidance for local code tasks. | -| Cursor | `madar cursor install [--profile core\|full\|strict]` | `.cursor/rules/madar.mdc`, `.cursor/mcp.json` | `madar doctor` / `madar status` | MCP tools, prompts, and resources via the selected tool profile. | `core`, `full`, and `strict`; strict exposes core plus `context_pack` and `context_expand` for one bounded context-pack-first pass. | Cursor has no separate prompt hook; the rule file plus MCP config are the managed surface. | -| Gemini CLI | `madar gemini install [--profile core\|full\|strict]` | `~/.gemini/skills/madar/SKILL.md`, `GEMINI.md`, `.gemini/settings.json` hook and MCP entry | `madar doctor` / `madar status` for `.gemini/settings.json`, then inspect the installed home skill for slash-command availability. | Home skill, local instructions, and an installed MCP server using the selected tool profile. | `core`, `full`, and `strict`; strict exposes core plus `context_pack` and `context_expand` for one bounded context-pack-first pass. | Use `madar prompt --provider gemini` when you need a one-shot export instead of live MCP. | -| GitHub Copilot CLI | `madar copilot install [--profile core\|full\|strict]` | `~/.copilot/skills/madar/SKILL.md`, `.vscode/mcp.json` | `madar doctor` / `madar status` for `.vscode/mcp.json`, then inspect the installed home skill for slash-command availability. | Home skill plus MCP tools, prompts, and resources via the selected tool profile. | `core`, `full`, and `strict`; strict exposes core plus `context_pack` and `context_expand` for one bounded context-pack-first pass. | The repo-local verifier checks the MCP wiring; the home skill is a separate install surface. | +| Claude Code | `madar claude install [--profile core\|full\|strict]` | `CLAUDE.md`, `.claude/settings.json`, `.mcp.json` | `madar doctor` / `madar status` | MCP tools, prompts, and resources via the selected tool profile. | `core`, `full`, and `strict`; strict exposes only `context_pack` and `context_expand` for one bounded context-pack-first pass. | The `UserPromptSubmit` hook only injects guidance for local code tasks. | +| Cursor | `madar cursor install [--profile core\|full\|strict]` | `.cursor/rules/madar.mdc`, `.cursor/mcp.json` | `madar doctor` / `madar status` | MCP tools, prompts, and resources via the selected tool profile. | `core`, `full`, and `strict`; strict exposes only `context_pack` and `context_expand` for one bounded context-pack-first pass. | Cursor has no separate prompt hook; the rule file plus MCP config are the managed surface. | +| Gemini CLI | `madar gemini install [--profile core\|full\|strict]` | `~/.gemini/skills/madar/SKILL.md`, `GEMINI.md`, `.gemini/settings.json` hook and MCP entry | `madar doctor` / `madar status` for `.gemini/settings.json`, then inspect the installed home skill for slash-command availability. | Home skill, local instructions, and an installed MCP server using the selected tool profile. | `core`, `full`, and `strict`; strict exposes only `context_pack` and `context_expand` for one bounded context-pack-first pass. | Use `madar prompt --provider gemini` when you need a one-shot export instead of live MCP. | +| GitHub Copilot CLI | `madar copilot install [--profile core\|full\|strict]` | `~/.copilot/skills/madar/SKILL.md`, `.vscode/mcp.json` | `madar doctor` / `madar status` for `.vscode/mcp.json`, then inspect the installed home skill for slash-command availability. | Home skill plus MCP tools, prompts, and resources via the selected tool profile. | `core`, `full`, and `strict`; strict exposes only `context_pack` and `context_expand` for one bounded context-pack-first pass. | The repo-local verifier checks the MCP wiring; the home skill is a separate install surface. | | Aider | `madar aider install` | `AGENTS.md` | `madar doctor` / `madar status` | Installed instructions only; use `madar pack` or `madar prompt` for portable context. | Context-pack-first profile only. | Aider has no PreToolUse-style hook equivalent. | -| Codex CLI | `madar codex install` | `AGENTS.md`, `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, `.codex/config.toml` | `madar doctor` / `madar status` | Installed instructions, a task-applicable `UserPromptSubmit` hook, and a local MCP entry for the Madar server. | Context-pack-first guidance with the strict MCP surface. | `madar doctor` / `madar status` validate on-disk wiring, not Codex live hook trust or MCP activation. | +| Codex CLI | `madar codex install` | `AGENTS.md`, `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, plus this workspace's marker-owned block in `~/.codex/config.toml` | `madar doctor` / `madar status` | Installed instructions, a task-applicable `UserPromptSubmit` hook, and a workspace-scoped MCP entry Codex CLI loads. | Context-pack-first guidance with the strict MCP surface. | `madar doctor` / `madar status` validate on-disk wiring only, not Codex live hook trust or MCP activation. | | OpenCode | `madar opencode install` | `AGENTS.md`, `.opencode/plugins/madar.js`, `opencode.json` or `opencode.jsonc` | `madar doctor` / `madar status` | Installed instructions, plugin wiring, and a local MCP entry for the Madar server. | Context-pack-first guidance with the strict MCP surface. | Verification expects the Madar-owned plugin and `mcp.madar` entry to stay intact. | | Claw | `madar claw install` | `AGENTS.md` | Inspect the generated `AGENTS.md` profile. | Installed instructions only; use `madar pack` or `madar prompt` for portable context. | Context-pack-first profile only. | There is no repo-local MCP or doctor/status integration for Claw. | | Factory Droid | `madar droid install` | `AGENTS.md` | Inspect the generated `AGENTS.md` profile. | Installed instructions only; use `madar pack` or `madar prompt` for portable context. | Context-pack-first profile only. | There is no repo-local MCP or doctor/status integration for Factory Droid. | @@ -43,4 +43,4 @@ Every home-skill install writes `SKILL.md` plus a sibling `.madar_version` marke | Trae CN skill install | `madar install --platform trae-cn` | `~/.trae-cn/skills/madar/SKILL.md` | Inspect `SKILL.md` and the sibling `.madar_version` marker. | Bundled home skill only. | Trae CN skill guidance only. | There is no repo-local MCP or doctor/status integration for Trae CN. | | Windows skill install | `madar install --platform windows` | `~/.claude/skills/madar/SKILL.md` | Inspect `SKILL.md` and the sibling `.madar_version` marker. | Bundled home skill only. | Windows terminal guidance only; it targets the Claude-style home skill directory. | There is no separate repo-local MCP wiring for the Windows skill install path. | -For Codex, `madar codex install` owns only the Madar AGENTS.md section, `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, and its marked `[mcp_servers.madar]` block in `.codex/config.toml`. The `UserPromptSubmit` hook provides model-visible guidance only for local code tasks; it is guidance, not enforcement. Enable it only in a trusted repository. Restart or start a new Codex session, use `/hooks` to review and trust the project hook, then use `/mcp` or `codex mcp list` to confirm the MCP server. `madar doctor` and `madar status` validate on-disk wiring only, not live Codex trust or activation. `madar codex uninstall` removes only Madar-owned content and preserves user hooks and TOML configuration. +For Codex, `madar codex install` owns only the Madar AGENTS.md section, `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, and this workspace's marked MCP block in `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`). The block has a unique server name, pins `cwd` to the workspace, and sets startup and tool-call timeouts. The `UserPromptSubmit` hook provides model-visible guidance only for local code tasks; it is guidance, not enforcement. Enable it only in a trusted repository. Restart or start a new Codex session, use `/hooks` to review and trust the project hook, then use `/mcp` or `codex mcp list` to confirm the MCP server. `madar doctor` and `madar status` validate on-disk wiring only, not live Codex trust or activation. `madar codex uninstall` removes only Madar-owned content and preserves user hooks, user config, and other workspace registrations. diff --git a/docs/reference/cli-and-mcp.md b/docs/reference/cli-and-mcp.md index 9d2c6037..e7a8be37 100644 --- a/docs/reference/cli-and-mcp.md +++ b/docs/reference/cli-and-mcp.md @@ -14,7 +14,7 @@ Madar produces local context packs that any modern coding agent can consume over | Gemini CLI | MCP via `.gemini/settings.json` | `madar gemini [--profile core\|full\|strict]` | `GEMINI.md` + `.gemini/settings.json` hook + MCP entry | | Aider | AGENTS.md context-pack-first profile | `madar aider install` | `AGENTS.md` Aider profile | | OpenCode | AGENTS.md + `.opencode/plugins/madar.js` + MCP via `opencode.json` / `opencode.jsonc` | `madar opencode install` | `AGENTS.md` OpenCode profile + plugin registration + MCP entry | -| Codex CLI | AGENTS.md + task-applicable `UserPromptSubmit` hook + MCP via `.codex/config.toml` | `madar codex install` | `AGENTS.md` Codex profile + `.codex/hooks.json` + `.codex/madar-user-prompt-submit.cjs` + `.codex/config.toml` | +| Codex CLI | AGENTS.md + task-applicable `UserPromptSubmit` hook + workspace-scoped MCP via `~/.codex/config.toml` | `madar codex install` | `AGENTS.md` Codex profile + `.codex/hooks.json` + `.codex/madar-user-prompt-submit.cjs` + this workspace's marker-owned user-config MCP block | | Windsurf / others | Pipe `madar prompt` output | `madar prompt "..." --provider claude` | n/a | These are local installers that write project instructions and, when the platform supports it, local MCP config or plugin files that point at the Madar subprocess. No code is uploaded. @@ -29,11 +29,11 @@ For practical multi-agent workflows across Claude Code, Codex, Copilot, Cursor, Treat every Madar MCP install, plugin, hook, or AGENTS profile as a local trust boundary. Only enable it for repositories and local agent runtimes you trust. Prefer `--profile strict` when you want the compact context-pack-first MCP workflow. -For Claude, Cursor, Copilot, and Gemini, `--profile strict` writes `MADAR_TOOL_PROFILE=strict`. That runtime surface exposes the seven core tools plus `context_pack` and `context_expand`, exactly matching the generated guidance. In practice the compact flow is: call `context_pack` once for the task before broader exploration, then follow `evidence.answerability.state`. `ready` answers from the pack, `ready_with_caveat` answers from the pack with `evidence.answerability.caveats`, and `verify_targets` inspects only a listed expansion handle or file. Madar already performs up to two bounded cumulative recovery passes; only `insufficient` with `broad_search_fallback: allowed` permits one directory-scoped raw search. Keep `out/GRAPH_REPORT.md` as a fallback-only read when the pack or graph tools are unavailable, stale, or insufficient. `pack_confidence` remains compatibility-only. +For Claude, Cursor, Copilot, and Gemini, `--profile strict` writes `MADAR_TOOL_PROFILE=strict`. That runtime surface exposes only `context_pack` and `context_expand`, exactly matching the generated guidance. In practice the compact flow is: call `context_pack` exactly once for the user task, copy the entire request byte-for-byte into `prompt` (including read-only, no-change, scope, and formatting constraints), then follow `evidence.answerability.state`. `ready` answers from the pack, `ready_with_caveat` answers from the pack with `evidence.answerability.caveats`, and `verify_targets` can use one listed expansion handle; the result of that one expansion is terminal and never advertises another callable target. A ready pack exposes no post-pack graph-navigation tool. Madar already performs up to two bounded cumulative recovery passes; only `insufficient` with `broad_search_fallback: allowed` permits one directory-scoped raw search. Keep `out/GRAPH_REPORT.md` as a fallback-only read when the pack or graph tools are unavailable, stale, or insufficient. `pack_confidence` remains compatibility-only. The strict server restricts Madar's own methods; it cannot identify a host's user-turn boundary or block the host agent's native file/shell tools, so the exactly-once rule is guidance verified by the recorded agent trial rather than a claim of universal enforcement. Aider and OpenCode are intentionally context-pack-first: run `madar generate .`, install the profile, and start broad codebase work with `madar pack "" --task explain` before raw file search. `madar aider install` writes an AGENTS.md profile only; remove it with `madar aider uninstall`. `madar opencode install` writes the AGENTS.md profile, `.opencode/plugins/madar.js`, and a strict-profile Madar MCP entry in `opencode.json` or `opencode.jsonc`; remove only Madar-owned content with `madar opencode uninstall`. -Codex is intentionally context-pack-first too: run `madar generate .`, install with `madar codex install`, and start broad codebase work with `madar pack "" --task explain` before raw file search. The install writes the Madar-owned AGENTS.md section, `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, and a marker-owned strict-profile `[mcp_servers.madar]` block in `.codex/config.toml`. That block includes `startup_timeout_sec = 180` for cold large-repository and synchronized-filesystem startup. Re-run the install after upgrading to migrate an older Madar-owned block; user-managed MCP declarations remain untouched. Its `UserPromptSubmit` hook provides model-visible guidance only for local code tasks; it is guidance, not enforcement. Enable it only in a trusted repository, restart or start a new Codex session, use `/hooks` to review and trust the project hook, then verify the server through `/mcp` or `codex mcp list`. `madar doctor` and `madar status` validate on-disk install state only, not live Codex trust or activation. To remove the profile, run `madar codex uninstall`; it removes only Madar-owned AGENTS, hook, script, and marked TOML content while preserving unrelated content. +Codex is intentionally context-pack-first too: run `madar generate .`, install with `madar codex install`, and start broad codebase work with `madar pack "" --task explain` before raw file search. Codex CLI loads MCP entries from `$CODEX_HOME/config.toml` (normally `~/.codex/config.toml`), so the install writes the Madar-owned AGENTS.md section, `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, and a workspace-scoped marker-owned strict-profile MCP block there. The block has a unique server name, pins `cwd` to the installed workspace (including linked worktrees), and includes `startup_timeout_sec = 180` plus `tool_timeout_sec = 60`. Re-run the install after upgrading to migrate an obsolete project-local Madar block; user-managed declarations and other workspace registrations remain untouched. Its `UserPromptSubmit` hook provides model-visible guidance only for local code tasks; it is guidance, not enforcement. Enable it only in a trusted repository, restart or start a new Codex session, use `/hooks` to review and trust the project hook, then verify the server through `/mcp` or `codex mcp list`. `madar doctor` and `madar status` validate on-disk wiring only, not live Codex trust or activation. To remove the profile, run `madar codex uninstall`; it removes only Madar-owned AGENTS, hook, script, and this workspace's marked user-config block while preserving unrelated content. ## MCP Registry metadata @@ -63,7 +63,7 @@ These seven MCP tools handle the most common agent workflows in the default core | `graph_stats` | "How big is this graph?" - node/edge counts, density, file-type mix | | `graph_summary` | "Give me the repo at a glance" - bounded deterministic overview of counts, domains, top modules, entrypoints, frameworks, and runtime paths | -`--profile strict` exposes those seven core tools plus `context_pack` and `context_expand`. The full surface is 27 tools, opt-in via `MADAR_TOOL_PROFILE=full` or `--profile full` on install. Full-only additions beyond strict are `context_pack_session_reset`, `context_prompt`, `context_session_reset`, `risk_map`, `implementation_checklist`, `relevant_files`, `feature_map`, `time_travel_compare`, `community_details`, `query_graph`, `get_node`, `get_neighbors`, `explain_node`, `shortest_path`, `graph_diff`, `god_nodes`, `semantic_anomalies`, and `get_community`. +`--profile strict` exposes only `context_pack` and `context_expand`; the expansion is authorized only for a listed `verify_targets` handle, can be used once, and returns a terminal result with no follow-on handle. The default core profile exposes the seven common graph-navigation tools. The full surface is 27 tools, opt-in via `MADAR_TOOL_PROFILE=full` or `--profile full` on install. Full-only additions beyond core are `context_pack`, `context_expand`, `context_pack_session_reset`, `context_prompt`, `context_session_reset`, `risk_map`, `implementation_checklist`, `relevant_files`, `feature_map`, `time_travel_compare`, `community_details`, `query_graph`, `get_node`, `get_neighbors`, `explain_node`, `shortest_path`, `graph_diff`, `god_nodes`, `semantic_anomalies`, and `get_community`. Full request/response examples live in [`examples/mcp-tool-examples.md`](../../examples/mcp-tool-examples.md). diff --git a/docs/release.md b/docs/release.md index de7433d7..d5f53e1c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -46,7 +46,7 @@ Recommended follow-up checks: - confirm `madar --version` prints the version you are about to publish - confirm `madar generate .` completes and refreshes `out/graph.json` - confirm install commands write the expected project files and instructions -- for Codex, confirm `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, and `.codex/config.toml` exist, and that the managed MCP block contains `startup_timeout_sec = 180`; only in a trusted repository, restart or open a new session, use `/hooks` to review/trust the project hook, then use `/mcp` or `codex mcp list` to verify the local MCP server +- for Codex, confirm `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, and this workspace's block in `~/.codex/config.toml` exist, and that it contains `startup_timeout_sec = 180` plus `tool_timeout_sec = 60`; only in a trusted repository, restart or open a new session, use `/hooks` to review/trust the project hook, then use `/mcp` or `codex mcp list` to verify the local MCP server - uninstall any agent profile you enabled during the smoke test so the workspace returns to a clean state ## 4. Publish and tag diff --git a/docs/security/mcp-threat-model.md b/docs/security/mcp-threat-model.md index 105e9e7f..32191caa 100644 --- a/docs/security/mcp-threat-model.md +++ b/docs/security/mcp-threat-model.md @@ -30,7 +30,8 @@ Primary threats include prompt injection, path traversal, tool poisoning, share- - `src/shared/security.ts` enforces local-file boundaries for graph input/output paths and blocks unsafe URL fetch targets such as `file://`, localhost, and cloud metadata hosts. - `src/shared/share-safe-artifacts.ts` rewrites workstation paths to `` / `` and redacts credential-like environment values, bearer/basic auth headers, URL userinfo, and secret-bearing query parameters before share-safe receipts are written. - Source discovery uses an artifact-aware secret policy: private keys, environment files, credential stores, and non-source secret configs are excluded before extraction, while normal security-related source code remains indexable. Local `graph.json` records each safety exclusion and its reason; generate/doctor/status show the escaped local paths. Share-safe evidence exposes only counts and reason buckets. Relevant exclusions or unreadable paths lower answer confidence so missing evidence is not presented as complete. -- Install guidance pushes least-privilege behavior instead of broad exploration. For supported MCP installers, prefer `--profile strict` for the nine-tool surface: core plus `context_pack` and `context_expand`. Codex and OpenCode install the same strict MCP surface; Aider remains CLI context-pack-first because its installer does not add an MCP server. +- Install guidance pushes least-privilege behavior instead of broad exploration. For supported MCP installers, prefer `--profile strict` for the two-tool surface: `context_pack` plus one bounded `context_expand` path for a listed verification target. The expansion result is terminal so it cannot advertise an unusable follow-on handle. Codex and OpenCode install the same strict MCP surface; Aider remains CLI context-pack-first because its installer does not add an MCP server. +- Strict limits Madar's server-owned methods, prompts, resources, and expansion grants. It cannot prevent an agent host from exposing native filesystem or shell tools, nor can it reliably identify a new user turn from MCP traffic alone; treat its exact-once rule as signed guidance and verify it from the captured agent trace when making an activation claim. - Public docs keep `out/GRAPH_REPORT.md` as a fallback-only read when pack/graph tools are unavailable, stale, or insufficient. ## Least privilege guidance diff --git a/docs/tutorials/agent-quickstarts.md b/docs/tutorials/agent-quickstarts.md index 15a37c80..10f26d3e 100644 --- a/docs/tutorials/agent-quickstarts.md +++ b/docs/tutorials/agent-quickstarts.md @@ -94,7 +94,7 @@ Common failure modes: ## Codex CLI - Install: `madar codex install` -- Expected files/config: `AGENTS.md`, `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, `.codex/config.toml` +- Expected files/config: `AGENTS.md`, `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, plus this workspace's marker-owned block in `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) - Verify: `madar doctor` / `madar status` for on-disk wiring, then `/hooks` and `/mcp` or `codex mcp list` after a restart/new session ```bash @@ -104,12 +104,12 @@ madar status madar pack "how does password reset request enqueue the reset email" --task explain ``` -This installs the Madar-owned AGENTS.md section, a task-applicable `UserPromptSubmit` hook, and a marker-owned `[mcp_servers.madar]` block in `.codex/config.toml`. The hook gives model-visible context-pack-first guidance only for local code tasks; it is guidance, not enforcement. Enable it only in a trusted repository. Restart or open a new Codex session, use `/hooks` to review and trust the project hook, then use `/mcp` or `codex mcp list` to verify the local MCP server. Known limitation: `madar doctor` / `madar status` validate on-disk wiring, not Codex live hook trust or MCP activation. +This installs the Madar-owned AGENTS.md section, a task-applicable `UserPromptSubmit` hook, and a workspace-scoped marker-owned MCP block in Codex's loaded user config: `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`). The block has a unique server name, pins `cwd` to this workspace, and includes both startup and tool-call timeouts. The hook gives model-visible context-pack-first guidance only for local code tasks; it is guidance, not enforcement. Enable it only in a trusted repository. Restart or open a new Codex session, use `/hooks` to review and trust the project hook, then use `/mcp` or `codex mcp list` to verify the local MCP server. Known limitation: `madar doctor` / `madar status` validate on-disk wiring only, not Codex live hook trust or MCP activation. Common failure modes: -- If `madar status` marks Codex as partial, inspect `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, and `.codex/config.toml`, then rerun the install. -- If Codex reports that the Madar MCP client timed out after 30 seconds, rerun `madar codex install` and confirm its managed block contains `startup_timeout_sec = 180`. Then run `madar doctor` and `madar status`; if reconciliation remains failed, run `madar generate . --update`. +- If `madar status` marks Codex as partial, inspect `.codex/hooks.json`, `.codex/madar-user-prompt-submit.cjs`, and this workspace's block in `~/.codex/config.toml`, then rerun the install. +- If Codex reports that the Madar MCP client timed out, rerun `madar codex install` and confirm its workspace block contains `startup_timeout_sec = 180` and `tool_timeout_sec = 60`. Then run `madar doctor` and `madar status`; if reconciliation remains failed, run `madar generate . --update`. - If Codex ignores the guidance, confirm `AGENTS.md` still contains the Madar-owned rules and that the project hook is trusted in `/hooks`. - `madar codex uninstall` removes only the Madar-owned AGENTS section, hook, script, and marked TOML block; unrelated hooks and TOML configuration remain. diff --git a/src/contracts/context-pack.ts b/src/contracts/context-pack.ts index 647fb150..f57bff3f 100644 --- a/src/contracts/context-pack.ts +++ b/src/contracts/context-pack.ts @@ -312,6 +312,10 @@ export interface ContextPackNode { source_file: string line_number: number snippet: string | null + /** First source line rendered in `snippet` when it differs from the graph node declaration. */ + snippet_line_number?: number | undefined + /** Whether the snippet is bounded to the symbol or is query evidence selected from its source file. */ + snippet_scope?: 'symbol' | 'source_file' | undefined file_type?: string | undefined match_score?: number | undefined relevance_band?: 'direct' | 'related' | 'peripheral' | undefined diff --git a/src/infrastructure/compare.ts b/src/infrastructure/compare.ts index 42f0d083..49cb6970 100644 --- a/src/infrastructure/compare.ts +++ b/src/infrastructure/compare.ts @@ -131,15 +131,23 @@ export interface CompareMadarTraceTurnSummary { const RAW_TRACE_TOOL_INPUTS = Symbol('rawTraceToolInputs') const RAW_TRACE_TOOL_RESULTS = Symbol('rawTraceToolResults') +const RAW_TRACE_TOOL_ARGUMENTS = Symbol('rawTraceToolArguments') +const RAW_TRACE_EVENTS = Symbol('rawTraceEvents') interface CompareMadarTraceToolResult { tool_name: string payload: Record } +type CompareMadarTraceEvent = + | { kind: 'tool_use'; tool_name: string } + | { kind: 'tool_result'; tool_name: string; payload: Record } + type CompareMadarTraceTurnSummaryWithInputs = CompareMadarTraceTurnSummary & { [RAW_TRACE_TOOL_INPUTS]?: string[] [RAW_TRACE_TOOL_RESULTS]?: CompareMadarTraceToolResult[] + [RAW_TRACE_TOOL_ARGUMENTS]?: unknown[] + [RAW_TRACE_EVENTS]?: CompareMadarTraceEvent[] } function rawTraceToolInputs(turn: CompareMadarTraceTurnSummary): string[] { @@ -155,6 +163,19 @@ function appendRawTraceToolInputs(turn: CompareMadarTraceTurnSummary, inputs: re }) } +function rawTraceToolArguments(turn: CompareMadarTraceTurnSummary): unknown[] { + return (turn as CompareMadarTraceTurnSummaryWithInputs)[RAW_TRACE_TOOL_ARGUMENTS] ?? [] +} + +function appendRawTraceToolArguments(turn: CompareMadarTraceTurnSummary, argumentsList: readonly unknown[]): void { + const current = rawTraceToolArguments(turn) + Object.defineProperty(turn, RAW_TRACE_TOOL_ARGUMENTS, { + value: [...current, ...argumentsList], + enumerable: false, + configurable: true, + }) +} + function rawTraceToolResults(turn: CompareMadarTraceTurnSummary): CompareMadarTraceToolResult[] { return (turn as CompareMadarTraceTurnSummaryWithInputs)[RAW_TRACE_TOOL_RESULTS] ?? [] } @@ -168,6 +189,19 @@ function appendRawTraceToolResults(turn: CompareMadarTraceTurnSummary, results: }) } +function rawTraceEvents(turn: CompareMadarTraceTurnSummary): CompareMadarTraceEvent[] { + return (turn as CompareMadarTraceTurnSummaryWithInputs)[RAW_TRACE_EVENTS] ?? [] +} + +function appendRawTraceEvents(turn: CompareMadarTraceTurnSummary, events: readonly CompareMadarTraceEvent[]): void { + const current = rawTraceEvents(turn) + Object.defineProperty(turn, RAW_TRACE_EVENTS, { + value: [...current, ...events], + enumerable: false, + configurable: true, + }) +} + type CompareMadarTraceOutcome = | 'no_install' | 'madar_available_but_unused' @@ -919,11 +953,11 @@ function canonicalTraceToolName(toolName: string): string { } function nativeAgentPromptContractEntryTool(profile: McpToolProfile): 'context_pack' | 'retrieve' { - return profile === 'full' ? 'context_pack' : 'retrieve' + return profile === 'full' || profile === 'strict' ? 'context_pack' : 'retrieve' } function nativeAgentPromptContractEntryToolNames(profile: McpToolProfile): ReadonlySet { - return profile === 'full' ? MADAR_CONTEXT_PACK_TOOL_NAMES : MADAR_CORE_ENTRY_TOOL_NAMES + return profile === 'full' || profile === 'strict' ? MADAR_CONTEXT_PACK_TOOL_NAMES : MADAR_CORE_ENTRY_TOOL_NAMES } function nativeAgentPromptContractSuccessEvidence(profile: McpToolProfile): string { @@ -949,6 +983,12 @@ function isBroadExplorationTraceToolName(toolName: string): boolean { return TRACE_BROAD_EXPLORATION_TOOL_NAMES.has(toolName.toLowerCase()) } +function isPreMadarRepositoryExplorationToolName(toolName: string): boolean { + // A raw file read is narrower than an after-pack search, but it still + // violates the first-pack contract if it happens before context_pack. + return isBroadExplorationTraceToolName(toolName) || isFocusedFollowUpTraceToolName(toolName) +} + function isToolSearchTraceToolName(toolName: string): boolean { const normalizedName = canonicalTraceToolName(toolName).toLowerCase() return normalizedName === 'toolsearch' || normalizedName === 'tool_search' @@ -1110,7 +1150,7 @@ function analyzeMadarTraceExploration(perTurn: CompareMadarTraceTurnSummary[]): } if (!hadSeenMadarCall) { - if (isBroadExplorationTraceToolName(toolName)) { + if (isPreMadarRepositoryExplorationToolName(toolName)) { preMadarBroadExplorationToolCallCount += 1 preMadarBroadExplorationToolCallsByName[toolName] = (preMadarBroadExplorationToolCallsByName[toolName] ?? 0) + 1 } @@ -1467,8 +1507,10 @@ function extractMadarTrace(stdout: string): CompareMadarTrace | undefined { const existingTurn = perTurnIndex.get(turn) const tools: string[] = [] const toolInputs: string[] = [] + const toolArguments: unknown[] = [] const directives: string[] = [] const toolResults: CompareMadarTraceToolResult[] = [] + const traceEvents: CompareMadarTraceEvent[] = [] let madarToolDiscoveryCount = 0 const madarToolDiscoveryToolIndexes: number[] = [] for (const contentPart of content) { @@ -1485,6 +1527,8 @@ function extractMadarTrace(stdout: string): CompareMadarTrace | undefined { const toolIndex = tools.length tools.push(toolName) toolInputs.push(summarizeTraceToolInput(contentPart.input)) + toolArguments.push(contentPart.input) + traceEvents.push({ kind: 'tool_use', tool_name: toolName }) toolCallsByName[toolName] = (toolCallsByName[toolName] ?? 0) + 1 totalToolCalls += 1 if (isMadarToolDiscoveryToolUse(toolName, contentPart.input)) { @@ -1493,7 +1537,13 @@ function extractMadarTrace(stdout: string): CompareMadarTrace | undefined { } } - toolResults.push(...extractTraceToolResults(contentPart)) + const contentToolResults = extractTraceToolResults(contentPart) + toolResults.push(...contentToolResults) + traceEvents.push(...contentToolResults.map((result) => ({ + kind: 'tool_result' as const, + tool_name: result.tool_name, + payload: result.payload, + }))) directives.push(...extractTraceAgentDirectives(contentPart)) } @@ -1506,7 +1556,9 @@ function extractMadarTrace(stdout: string): CompareMadarTrace | undefined { existingTurn.tool_call_count += tools.length existingTurn.tools.push(...tools) appendRawTraceToolInputs(existingTurn, toolInputs) + appendRawTraceToolArguments(existingTurn, toolArguments) appendRawTraceToolResults(existingTurn, toolResults) + appendRawTraceEvents(existingTurn, traceEvents) if (madarToolDiscoveryCount > 0) { existingTurn.madar_tool_discovery_count = (existingTurn.madar_tool_discovery_count ?? 0) + madarToolDiscoveryCount existingTurn.madar_tool_discovery_tool_indexes = [ @@ -1534,7 +1586,9 @@ function extractMadarTrace(stdout: string): CompareMadarTrace | undefined { ...(directives.length > 0 ? { agent_directive_seen: directives } : {}), } appendRawTraceToolInputs(turnSummary, toolInputs) + appendRawTraceToolArguments(turnSummary, toolArguments) appendRawTraceToolResults(turnSummary, toolResults) + appendRawTraceEvents(turnSummary, traceEvents) perTurnIndex.set(turn, turnSummary) } @@ -1664,6 +1718,82 @@ function traceFocusedFollowUpInputs(trace: CompareMadarTrace): string[] { return followUpInputs } +function contextPackPromptFromTraceArguments(input: unknown, depth = 0): string | null { + if (depth > 4) { + return null + } + if (typeof input === 'string') { + try { + return contextPackPromptFromTraceArguments(JSON.parse(input) as unknown, depth + 1) + } catch { + return null + } + } + if (!isRecord(input)) { + return null + } + if (typeof input.prompt === 'string') { + return input.prompt + } + for (const key of ['arguments', 'params', 'input']) { + const nested = contextPackPromptFromTraceArguments(input[key], depth + 1) + if (nested !== null) { + return nested + } + } + return null +} + +function traceContextPackPrompts(trace: CompareMadarTrace): Array { + const prompts: Array = [] + for (const turn of trace.per_turn) { + const argumentsList = rawTraceToolArguments(turn) + for (const [index, toolName] of turn.tools.entries()) { + if (canonicalTraceToolName(toolName) === 'context_pack') { + prompts.push(contextPackPromptFromTraceArguments(argumentsList[index])) + } + } + } + return prompts +} + +function isTerminalContextPackPayload(payload: Record): boolean { + const evidence = isRecord(payload.evidence) ? payload.evidence : null + const answerability = evidence && isRecord(evidence.answerability) ? evidence.answerability : null + const state = answerability?.state + return state === 'ready' || state === 'ready_with_caveat' +} + +function traceToolUsesAfterTerminalContextPack(trace: CompareMadarTrace): string[] { + const laterToolUses: string[] = [] + let terminalPackSeen = false + for (const turn of trace.per_turn) { + for (const event of rawTraceEvents(turn)) { + if (event.kind === 'tool_result') { + if (canonicalTraceToolName(event.tool_name) === 'context_pack' && isTerminalContextPackPayload(event.payload)) { + terminalPackSeen = true + } + continue + } + if (terminalPackSeen) { + laterToolUses.push(event.tool_name) + } + } + } + return laterToolUses +} + +function traceHasTerminalContextPackResult(trace: CompareMadarTrace): boolean { + for (const turn of trace.per_turn) { + for (const result of rawTraceToolResults(turn)) { + if (canonicalTraceToolName(result.tool_name) === 'context_pack' && isTerminalContextPackPayload(result.payload)) { + return true + } + } + } + return false +} + function mergeMatchedNodes( base: CompareReportPack['matched_nodes'], followUp: CompareReportPack['matched_nodes'] | undefined, @@ -2677,7 +2807,13 @@ export function buildNativeAgentPrompt( return [ 'Follow the Madar implementation-pack contract exactly.', 'Implement the requested change using the Madar implementation pack.', - 'Call context_pack first for implementation tasks before editing files or broad raw repo search.', + ...(profile === 'strict' + ? [ + 'Call mcp__madar__context_pack exactly once before editing files or raw repository search.', + 'Copy the exact Question text below byte-for-byte into context_pack.prompt; do not restate, expand, enumerate, split, or issue a second context_pack call.', + 'Strict exposes only context_pack and a listed verify_targets context_expand handle. A ready or ready_with_caveat pack is terminal.', + ] + : ['Call context_pack first for implementation tasks before editing files or broad raw repo search.']), 'Inspect likely_edit_files, likely_test_files, validation_commands, cautions, and risk boundaries before making any changes.', 'Prefer the likely_edit_files set first; editing outside the likely_edit_files or likely_test_files set requires an explicit reason.', 'Run the listed validation_commands before concluding.', @@ -2693,7 +2829,18 @@ export function buildNativeAgentPrompt( ].join('\n') } - const profileInstructions = profile === 'full' + const profileInstructions = profile === 'strict' + ? [ + 'Call mcp__madar__context_pack exactly once before any raw file or broad repository search.', + 'Copy the exact Question text below byte-for-byte into context_pack.prompt; do not restate, expand, enumerate, split, or issue a second context_pack call.', + 'Strict exposes only context_pack and context_expand. Use task=impact or task=review on the first pack instead of graph-navigation tools.', + 'Treat evidence.answerability.state as authoritative; evidence.pack_confidence is compatibility-only.', + 'For ready, answer from the pack. For ready_with_caveat, answer from the pack and state evidence.answerability.caveats. Both are terminal: make no later MCP, Read, Bash, Glob, or Grep call.', + 'For verify_targets, use context_expand only once with one listed evidence.answerability.verification_targets handle.', + 'Only insufficient with evidence.answerability.broad_search_fallback set to allowed permits one directory-scoped raw search.', + 'Madar already ran bounded cumulative recovery; do not restart repository discovery for ready, ready_with_caveat, or verify_targets.', + ] + : profile === 'full' ? [ 'Call context_pack first for explain, review, impact, or runtime questions before any raw file or broad repo search.', 'Treat evidence.answerability.state as authoritative; evidence.pack_confidence is compatibility-only.', @@ -2717,7 +2864,7 @@ export function buildNativeAgentPrompt( ] const runtimeFlowInstructions = options.runtimeFlowGuidance ? [ - 'This question asks for a runtime flow grounded in retrieved evidence.', + `This question asks for a runtime flow grounded in ${profile === 'strict' ? 'context-pack' : 'retrieved'} evidence.`, ...(options.runtimeFlowGuidance.rescopedTo ? [`Start from the auto-rescoped graph scope: ${options.runtimeFlowGuidance.rescopedTo}.`] : []), @@ -2729,8 +2876,8 @@ export function buildNativeAgentPrompt( : ['If the flow still cannot be proven, answer: not enough evidence']), ...(options.runtimeFlowGuidance.retrievalReady ? [ - 'Retrieve already provides a complete high-confidence runtime slice.', - 'Answer from the retrieved evidence only; do not use Bash, Read, or raw file search.', + `${profile === 'strict' ? 'The initial context pack' : 'Retrieve'} already provides a complete high-confidence runtime slice.`, + `Answer from the ${profile === 'strict' ? 'context pack' : 'retrieved evidence'} only; do not use Bash, Read, or raw file search.`, 'Do not add missing-evidence caveats unless the retrieved slice actually reports a gap.', ] : []), @@ -4933,7 +5080,7 @@ function nativeAgentAttributionGapEvidence(report: NativeAgentCompareReport): st } function assessNativeAgentPromptContract( - report: Pick, + report: Pick, toolProfile: McpToolProfile, runtimeProofProfile?: RuntimeProofProfile, ): NativeAgentPromptContractAssessment { @@ -4945,6 +5092,7 @@ function assessNativeAgentPromptContract( } const evidence: string[] = [] + const measurementGaps: string[] = [] if (report.madar_trace.madar_mcp_call_count === 0) { evidence.push('no Madar MCP call was recorded') } @@ -4957,6 +5105,43 @@ function assessNativeAgentPromptContract( if ((report.madar_trace.pre_madar_broad_exploration_tool_call_count ?? 0) > 0) { evidence.push('broad exploration occurred before the first Madar call') } + if (toolProfile === 'strict') { + if (report.madar_trace.context_pack_call_count !== 1) { + evidence.push(`strict profile requires exactly one context_pack call (recorded ${report.madar_trace.context_pack_call_count})`) + } + const packPrompts = traceContextPackPrompts(report.madar_trace) + if (packPrompts.length === 1 && packPrompts[0] !== report.question) { + evidence.push('strict context_pack prompt did not byte-match the user question') + } + if (traceHasTerminalContextPackResult(report.madar_trace)) { + const postTerminalToolUses = traceToolUsesAfterTerminalContextPack(report.madar_trace) + if (postTerminalToolUses.some((toolName) => isMadarTraceToolName(toolName))) { + evidence.push('terminal strict context_pack was followed by another Madar MCP call') + } + const otherMcpTool = postTerminalToolUses.find((toolName) => ( + toolName.startsWith('mcp__') && !isMadarTraceToolName(toolName) + )) + if (otherMcpTool) { + evidence.push(`terminal strict context_pack was followed by another MCP call: ${otherMcpTool}`) + } + if (postTerminalToolUses.some((toolName) => ( + isFocusedFollowUpTraceToolName(toolName) || isBroadExplorationTraceToolName(toolName) + ))) { + evidence.push('terminal strict context_pack was followed by repository exploration') + } + const unclassifiedNativeTool = postTerminalToolUses.find((toolName) => ( + !isMadarTraceToolName(toolName) + && !toolName.startsWith('mcp__') + && !isFocusedFollowUpTraceToolName(toolName) + && !isBroadExplorationTraceToolName(toolName) + )) + if (unclassifiedNativeTool) { + measurementGaps.push( + `terminal strict context_pack was followed by unclassified native tool call: ${unclassifiedNativeTool}; trace cannot prove terminal compliance`, + ) + } + } + } if (report.madar_trace.broad_exploration_tool_call_count > 0) { if (evidence.length > 0) { evidence.push('broad exploration occurred after the first Madar call, but the trace does not show whether missing_context justified it') @@ -5017,6 +5202,12 @@ function assessNativeAgentPromptContract( evidence, } } + if (measurementGaps.length > 0) { + return { + status: 'not_measured', + evidence: measurementGaps, + } + } return { status: 'followed', diff --git a/src/infrastructure/context-pack-command.ts b/src/infrastructure/context-pack-command.ts index 0b42c1f6..1954042a 100644 --- a/src/infrastructure/context-pack-command.ts +++ b/src/infrastructure/context-pack-command.ts @@ -43,7 +43,14 @@ selectedContextSourceFilesFromRetrieveResult, type GraphContextFreshness, } from '../runtime/freshness.js' import { buildRoutingDebug } from '../runtime/routing-debug.js' -import { buildRetrievalEvidencePlan } from '../runtime/retrieve/pipeline.js' +import { + buildRetrievalEvidencePlan, +} from '../runtime/retrieve/pipeline.js' +import { + evaluateQueryEvidenceCoverage, + queryEvidenceObligations, + type QueryEvidenceNode, +} from '../runtime/retrieve/conceptual-fallback.js' import { communitiesFromGraph, estimateQueryTokens, loadGraph } from '../runtime/serve.js' import { parseDiscoverySafetyMetadata, @@ -125,7 +132,8 @@ const ANSWER_READY_COMMUNITY_CAP = 6 const ANSWER_READY_EXPLANATION_CAP = 3 const ANSWER_READY_FIRST_READ_CAP = 3 const ANSWER_READY_WORKFLOW_CENTER_CAP = 4 -const ANSWER_READY_SNIPPET_CHAR_CAP = 200 +const ANSWER_READY_SNIPPET_CHAR_CAP = 300 +const ANSWER_READY_SERIALIZATION_GUARD_TOKENS = 64 const WORKFLOW_SPINE_BUDGET_REASON = 'budget too tight for workflow spine' interface AnswerReadyCullCandidate { @@ -278,16 +286,17 @@ function collectWorkflowAnchorKeys(payload: JsonRecord): Set { } keys.add(`label:${record.label}`) } - if (keys.size === 0) { - for (const entry of asUnknownArray(payload.claims)) { - const record = asJsonRecord(entry) - if (!record) { - continue - } - for (const label of asUnknownArray(record.node_labels)) { - if (typeof label === 'string' && label.length > 0) { - keys.add(`label:${label}`) - } + // Claims are part of the answer contract. Preserve every cited node even + // when a workflow spine already supplied anchors; otherwise a later budget + // cull can leave a precise claim pointing at an omitted source. + for (const entry of asUnknownArray(payload.claims)) { + const record = asJsonRecord(entry) + if (!record) { + continue + } + for (const label of asUnknownArray(record.node_labels)) { + if (typeof label === 'string' && label.length > 0) { + keys.add(`label:${label}`) } } } @@ -391,7 +400,152 @@ function stripExpandableFocusRanges(payload: JsonRecord, trimmedFields: string[] return stripped } -function compactAnswerReadyPack(pack: JsonRecord, trimmedFields: string[]): void { +function queryEvidenceNodeFromRecord(record: JsonRecord | null): QueryEvidenceNode | null { + if (!record || typeof record.label !== 'string' || typeof record.source_file !== 'string') { + return null + } + return { + label: record.label, + source_file: record.source_file, + ...(typeof record.snippet === 'string' ? { snippet: record.snippet } : {}), + } +} + +function selectAnswerReadyMatchedNodes( + question: string, + values: readonly unknown[], + claims: readonly unknown[], +): unknown[] { + if (values.length <= ANSWER_READY_MATCHED_NODE_CAP) { + return values.slice(0, ANSWER_READY_MATCHED_NODE_CAP) + } + + const claimAnchorIndexes = new Set() + for (const claimValue of claims) { + const claim = asJsonRecord(claimValue) + for (const anchorLabel of asUnknownArray(claim?.node_labels)) { + if (typeof anchorLabel !== 'string') { + continue + } + const anchorIndex = values.findIndex((value) => asJsonRecord(value)?.label === anchorLabel) + if (anchorIndex >= 0) { + claimAnchorIndexes.add(anchorIndex) + } + } + } + const claimAnchorsFitPrefix = [...claimAnchorIndexes] + .every((index) => index < ANSWER_READY_MATCHED_NODE_CAP) + const obligations = queryEvidenceObligations(question) + if (obligations.length === 0 && claimAnchorsFitPrefix) { + return values.slice(0, ANSWER_READY_MATCHED_NODE_CAP) + } + + const candidates = values + .map((value, index) => ({ + index, + value, + record: asJsonRecord(value), + evidence: queryEvidenceNodeFromRecord(asJsonRecord(value)), + })) + .filter((candidate): candidate is typeof candidate & { record: JsonRecord; evidence: QueryEvidenceNode } => ( + candidate.record !== null && candidate.evidence !== null + )) + const prefixEvidence = candidates + .filter((candidate) => candidate.index < ANSWER_READY_MATCHED_NODE_CAP) + .map((candidate) => candidate.evidence) + const fullEvidence = candidates.map((candidate) => candidate.evidence) + if ( + claimAnchorsFitPrefix + && obligations.length > 0 + && evaluateQueryEvidenceCoverage(question, prefixEvidence).covered + >= evaluateQueryEvidenceCoverage(question, fullEvidence).covered + ) { + return values.slice(0, ANSWER_READY_MATCHED_NODE_CAP) + } + + const selected: typeof candidates = [] + const selectedIndexes = new Set() + const selectedSources = new Set() + for (const anchorIndex of claimAnchorIndexes) { + if (selected.length >= ANSWER_READY_MATCHED_NODE_CAP) { + break + } + const candidate = candidates.find((entry) => entry.index === anchorIndex) + if (!candidate || selectedIndexes.has(candidate.index)) { + continue + } + selected.push(candidate) + selectedIndexes.add(candidate.index) + selectedSources.add(candidate.evidence.source_file) + } + let covered = evaluateQueryEvidenceCoverage( + question, + selected.map((entry) => entry.evidence), + ).covered + while (selected.length < ANSWER_READY_MATCHED_NODE_CAP) { + let best: (typeof candidates)[number] | null = null + let bestGain = 0 + let bestSourceDiversity = -1 + for (const candidate of candidates) { + if (selectedIndexes.has(candidate.index)) { + continue + } + const nextCoverage = evaluateQueryEvidenceCoverage( + question, + [...selected.map((entry) => entry.evidence), candidate.evidence], + ).covered + const gain = nextCoverage - covered + const sourceDiversity = selectedSources.has(candidate.evidence.source_file) ? 0 : 1 + if ( + gain > bestGain + || (gain === bestGain && gain > 0 && sourceDiversity > bestSourceDiversity) + || (gain === bestGain && gain > 0 && sourceDiversity === bestSourceDiversity && candidate.index < (best?.index ?? Number.POSITIVE_INFINITY)) + ) { + best = candidate + bestGain = gain + bestSourceDiversity = sourceDiversity + } + } + if (!best || bestGain <= 0) { + break + } + selected.push(best) + selectedIndexes.add(best.index) + selectedSources.add(best.evidence.source_file) + covered += bestGain + } + + for (const candidate of candidates) { + if (selected.length >= ANSWER_READY_MATCHED_NODE_CAP) { + break + } + if (!selectedIndexes.has(candidate.index) && !selectedSources.has(candidate.evidence.source_file)) { + selected.push(candidate) + selectedIndexes.add(candidate.index) + selectedSources.add(candidate.evidence.source_file) + } + } + for (let index = 0; index < values.length && selected.length < ANSWER_READY_MATCHED_NODE_CAP; index += 1) { + if (!selectedIndexes.has(index)) { + const candidate = candidates.find((entry) => entry.index === index) + if (candidate) { + selected.push(candidate) + selectedIndexes.add(index) + } + } + } + + return selected + .sort((left, right) => left.index - right.index) + .map((candidate) => candidate.value) +} + +function compactAnswerReadyPack( + question: string, + pack: JsonRecord, + claims: readonly unknown[], + trimmedFields: string[], +): void { delete pack.workflow_centers delete pack.recommended_first_read delete pack.confidence_score @@ -430,7 +584,11 @@ function compactAnswerReadyPack(pack: JsonRecord, trimmedFields: string[]): void } preserveTrimmedRuntimeEntrypointContextPreview(pack, trimmedFields) - trimArrayField(pack, 'matched_nodes', ANSWER_READY_MATCHED_NODE_CAP, trimmedFields) + const matchedNodes = asUnknownArray(pack.matched_nodes) + if (matchedNodes.length > ANSWER_READY_MATCHED_NODE_CAP) { + pack.matched_nodes = selectAnswerReadyMatchedNodes(question, matchedNodes, claims) + trimmedFields.push('pack.matched_nodes') + } trimArrayField(pack, 'relationships', ANSWER_READY_RELATIONSHIP_CAP, trimmedFields) filterRelationshipsToRemainingNodes(pack, trimmedFields) trimArrayField(pack, 'community_context', ANSWER_READY_COMMUNITY_CAP, trimmedFields) @@ -584,7 +742,10 @@ function compactAnswerReadyNodesForPressure(pack: JsonRecord, trimmedFields: str delete node.file_type compacted = true } - for (const field of ['community', 'community_label']) { + // `line_number` remains the citation anchor in the answer-ready surface. + // The query-evidence excerpt metadata is useful while selecting evidence, + // but duplicates that anchor once the compact response has been formed. + for (const field of ['community', 'community_label', 'snippet_line_number', 'snippet_scope']) { if (Object.hasOwn(node, field)) { delete node[field] compacted = true @@ -1363,22 +1524,24 @@ function attachSerializedBudget( } } -export function buildAnswerReadyPackSchema( +function buildAnswerReadyPackSchemaUnreconciled( schema: object, maxTokens: number, selectionDiagnostics?: ContextPackSelectionDiagnostics, ): JsonRecord { const payload = cloneJsonRecord(schema) const trimmedFields: string[] = [] + const answerFromPack = asJsonRecord(payload.evidence)?.agent_directive === 'answer_from_pack' if (Object.hasOwn(payload, 'diagnostics')) { delete payload.diagnostics trimmedFields.push('diagnostics') } const pack = asJsonRecord(payload.pack) + const hasExecutionSpine = asUnknownArray(asJsonRecord(pack?.execution_slice)?.steps).length > 0 if (pack) { const packFirstRead = asUnknownArray(pack.recommended_first_read) const payloadFirstRead = asUnknownArray(payload.recommended_first_read) - if (payloadFirstRead.length === 0 && packFirstRead.length > 0) { + if ((!answerFromPack || hasExecutionSpine) && payloadFirstRead.length === 0 && packFirstRead.length > 0) { payload.recommended_first_read = packFirstRead.slice(0, ANSWER_READY_FIRST_READ_CAP) trimmedFields.push('pack.recommended_first_read promoted') } @@ -1392,7 +1555,25 @@ export function buildAnswerReadyPackSchema( payload.confidence_score = pack.confidence_score trimmedFields.push('pack.confidence_score promoted') } - compactAnswerReadyPack(pack, trimmedFields) + const question = typeof payload.prompt === 'string' + ? payload.prompt + : typeof pack.question === 'string' ? pack.question : '' + compactAnswerReadyPack(question, pack, asUnknownArray(payload.claims), trimmedFields) + } + if (answerFromPack && !hasExecutionSpine && asUnknownArray(payload.recommended_first_read).length > 0) { + payload.recommended_first_read = [] + trimmedFields.push('recommended_first_read omitted for answer_from_pack') + } + if (answerFromPack) { + if (asUnknownArray(payload.expandable).length > 0) { + payload.expandable = [] + trimmedFields.push('expandable omitted for answer_from_pack') + } + const governance = asJsonRecord(payload.governance) + if (governance && Object.hasOwn(governance, 'follow_up')) { + delete governance.follow_up + trimmedFields.push('governance.follow_up omitted for answer_from_pack') + } } compactAnswerReadyGovernance(payload, trimmedFields) @@ -1620,6 +1801,175 @@ export function buildAnswerReadyPackSchema( return payload } +function serializedVerificationTargets( + payload: JsonRecord, + missingObligations: readonly string[], +): JsonRecord[] { + const answerability = asJsonRecord(asJsonRecord(payload.evidence)?.answerability) + const existingTargets = asUnknownArray(answerability?.verification_targets) + .map((target) => asJsonRecord(target)) + .filter((target): target is JsonRecord => target !== null) + if (existingTargets.length > 0) { + return existingTargets.slice(0, 2) + } + + const pack = asJsonRecord(payload.pack) + for (const entry of [...asUnknownArray(payload.expandable), ...asUnknownArray(pack?.expandable)]) { + const expandable = asJsonRecord(entry) + const followUp = asJsonRecord(expandable?.follow_up) + const focusFiles = asUnknownArray(followUp?.focus_files) + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .slice(0, 2) + if (focusFiles.length === 0) { + continue + } + return [{ + ...(typeof expandable?.handle_id === 'string' ? { handle_id: expandable.handle_id } : {}), + ...(typeof expandable?.evidence_class === 'string' ? { evidence_class: expandable.evidence_class } : {}), + focus_files: focusFiles, + focus_ranges: asUnknownArray(followUp?.focus_ranges).slice(0, 1), + reason: `verify ${missingObligations[0] ?? 'missing serialized query evidence'}`, + }] + } + return [] +} + +function queryEvidenceCoverageFromPayload(payload: JsonRecord) { + const pack = asJsonRecord(payload.pack) + const question = typeof payload.prompt === 'string' + ? payload.prompt + : typeof pack?.question === 'string' ? pack.question : '' + if (!pack || question.length === 0) { + return null + } + const nodes = asUnknownArray(pack.matched_nodes) + .map((entry) => queryEvidenceNodeFromRecord(asJsonRecord(entry))) + .filter((entry): entry is QueryEvidenceNode => entry !== null) + return evaluateQueryEvidenceCoverage(question, nodes) +} + +function reconcileSerializedQueryEvidence( + payload: JsonRecord, + trimmedFields: string[], + baselineQueryCoverage: ReturnType, +): boolean { + const evidence = asJsonRecord(payload.evidence) + const queryCoverage = queryEvidenceCoverageFromPayload(payload) + if (!evidence || !queryCoverage || queryCoverage.missing_obligations.length === 0) { + return false + } + const baselineMissing = new Set(baselineQueryCoverage?.missing_obligations ?? []) + const lostDuringSerialization = queryCoverage.missing_obligations.some((obligation) => !baselineMissing.has(obligation)) + if (!lostDuringSerialization) { + return false + } + + const missingObligations = [...new Set(queryCoverage.missing_obligations)] + evidence.coverage = 'partial' + const coverageDetail = asJsonRecord(evidence.coverage_detail) ?? {} + coverageDetail.status = 'partial' + coverageDetail.required_obligations = [...new Set([ + ...asUnknownArray(coverageDetail.required_obligations).filter((value): value is string => typeof value === 'string'), + ...missingObligations, + ])] + coverageDetail.covered_obligations = asUnknownArray(coverageDetail.covered_obligations) + .filter((value): value is string => typeof value === 'string' && !missingObligations.includes(value)) + coverageDetail.missing_obligations = [...new Set([ + ...asUnknownArray(coverageDetail.missing_obligations).filter((value): value is string => typeof value === 'string'), + ...missingObligations, + ])] + evidence.coverage_detail = coverageDetail + + const evidenceStrength = asJsonRecord(evidence.evidence_strength) ?? {} + if (evidenceStrength.level === 'strong' || typeof evidenceStrength.level !== 'string') { + evidenceStrength.level = 'moderate' + } + const strengthReason = 'selected_snippets_do_not_cover_all_serialized_query_obligations' + evidenceStrength.reasons = [...new Set([ + ...asUnknownArray(evidenceStrength.reasons).filter((value): value is string => typeof value === 'string'), + strengthReason, + ])] + evidence.evidence_strength = evidenceStrength + + const targets = serializedVerificationTargets(payload, missingObligations) + const canVerify = targets.length > 0 + const answerability = asJsonRecord(evidence.answerability) ?? {} + answerability.state = canVerify ? 'verify_targets' : 'insufficient' + answerability.answer_scope = canVerify ? 'partial' : 'none' + answerability.caveats = [...new Set([ + ...asUnknownArray(answerability.caveats).filter((value): value is string => typeof value === 'string'), + 'serialized snippets do not cover every prompt obligation', + ])] + answerability.missing_obligations = missingObligations + answerability.verification_targets = targets + answerability.broad_search_fallback = answerability.broad_search_fallback === 'blocked' + ? 'blocked' + : canVerify ? 'targeted_only' : 'allowed' + evidence.answerability = answerability + evidence.pack_confidence = canVerify ? 'medium' : 'low' + evidence.agent_directive = canVerify ? 'verify_one_targeted_file' : 'explore_with_caution' + const confidenceReason = `serialized query evidence: ${queryCoverage.covered}/${queryCoverage.total} prompt obligations covered` + const existingConfidenceReasons = asUnknownArray(evidence.confidence_reasons) + .filter((value): value is string => typeof value === 'string') + evidence.confidence_reasons = [...new Set([ + ...existingConfidenceReasons, + confidenceReason, + ])] + const serializedBudget = asJsonRecord(payload.serialized_budget) + const maxTokens = typeof serializedBudget?.max_tokens === 'number' + ? serializedBudget.max_tokens + : null + if (maxTokens !== null && estimatedJsonTokens(payload) > maxTokens) { + // This line is explanatory rather than an evidence contract. Do not let a + // reconciliation-only reason break a budget that the compact payload had + // already satisfied; preserve any reason that existed before reconciliation. + if (existingConfidenceReasons.length > 0) { + evidence.confidence_reasons = existingConfidenceReasons + } else { + delete evidence.confidence_reasons + } + } + const recovery = asJsonRecord(evidence.recovery) + if (recovery) { + recovery.final_state = answerability.state + if (recovery.status === 'not_needed') { + recovery.status = 'partial' + } + } + trimmedFields.push('evidence reconciled to serialized query coverage') + return true +} + +export function buildAnswerReadyPackSchema( + schema: object, + maxTokens: number, + selectionDiagnostics?: ContextPackSelectionDiagnostics, +): JsonRecord { + // JSON bookkeeping and post-cull evidence reconciliation can add a few + // tokens after a compact pack first appears to fit. Cull against a small + // internal guard band, then report the caller's actual requested budget. + // This keeps the serialized response deterministically within that budget. + const serializationLimit = Math.max(1, maxTokens - ANSWER_READY_SERIALIZATION_GUARD_TOKENS) + const baselineQueryCoverage = queryEvidenceCoverageFromPayload(schema as JsonRecord) + const payload = buildAnswerReadyPackSchemaUnreconciled(schema, serializationLimit, selectionDiagnostics) + const trimmedFields: string[] = [] + const reconciled = reconcileSerializedQueryEvidence(payload, trimmedFields, baselineQueryCoverage) + if (!reconciled) { + attachSerializedBudget(payload, maxTokens, trimmedFields) + return payload + } + + attachSerializedBudget(payload, serializationLimit, trimmedFields) + if (estimatedJsonTokens(payload) > serializationLimit) { + compactAnswerReadyEnvelopeForPressure(payload, trimmedFields) + enforceAnswerReadyBudget(payload, serializationLimit, trimmedFields, selectionDiagnostics) + reconcileSerializedQueryEvidence(payload, trimmedFields, baselineQueryCoverage) + attachSerializedBudget(payload, serializationLimit, trimmedFields) + } + attachSerializedBudget(payload, maxTokens, trimmedFields) + return payload +} + function emptyCoverage(): ContextPackCoverage { return { required_evidence: [], @@ -1725,6 +2075,8 @@ export function buildExplainPackPayloadCore( coveredWorkflowOwners, selectedNodeCount: pack.matched_nodes.length, selectedRelationshipCount: pack.relationships.length, + question: retrieval.question, + matchedNodes: pack.matched_nodes, }) const evidenceAssessment = assessMadarResponseEvidence({ evidencePlan: retrievalEvidencePlan, @@ -2137,7 +2489,7 @@ function recommendedFirstRead( path: node.source_file, label: node.label, reason: runtimeGenerationFallback - ? `Fallback pack evidence via ${node.label}; verify against workflow centers and runtime handoffs.` + ? `Fallback query-relevant pack evidence anchored by ${node.label}; the snippet is already included.` : `Direct pack evidence via ${node.label}.`, }) if (reads.length >= 3) { @@ -2328,7 +2680,29 @@ function buildPackSchemaV1( const firstRead = recommendedFirstRead(response.task, response.pack, response.implementation, retrieval) const contracts = publicContracts(response.implementation) const guidance = negativeGuidance(response.task, response.coverage, response.pack, response.implementation, retrieval) + const serializedMatchedNodes = retrieval && 'matched_nodes' in response.pack + ? response.pack.matched_nodes + : undefined + const serializedRelationships = retrieval && 'relationships' in response.pack + ? response.pack.relationships + : undefined + const serializedEvidencePlan = retrieval && serializedMatchedNodes && serializedRelationships + ? buildRetrievalEvidencePlan({ + ...(retrieval.task_contract ? { taskContract: retrieval.task_contract } : {}), + coverage: response.coverage, + expandable: response.expandable, + ...(retrieval.execution_slice ? { executionSlice: retrieval.execution_slice } : {}), + ...(retrieval.answer_contract ? { answerContract: retrieval.answer_contract } : {}), + missingPhases: missingPhasesFromPayload(retrieval), + coveredWorkflowOwners: serializedMatchedNodes.map((node) => node.source_file), + selectedNodeCount: serializedMatchedNodes.length, + selectedRelationshipCount: serializedRelationships.length, + question: response.prompt, + matchedNodes: serializedMatchedNodes, + }) + : undefined const evidenceAssessment = assessMadarResponseEvidence({ + ...(serializedEvidencePlan ? { evidencePlan: serializedEvidencePlan } : {}), answerContract: retrieval?.answer_contract ?? ('answer_contract' in response.pack ? response.pack.answer_contract : undefined), coverage: response.coverage, discoverySafety, diff --git a/src/infrastructure/doctor.ts b/src/infrastructure/doctor.ts index b2b5ee4f..6916e056 100644 --- a/src/infrastructure/doctor.ts +++ b/src/infrastructure/doctor.ts @@ -4,7 +4,6 @@ import { join, resolve } from 'node:path' import type { IndexingManifestV1 } from '../contracts/indexing.js' import { watcherStateBlocksGraphReads, type WatcherStateV1 } from '../contracts/watcher-state.js' import { - CODEX_MCP_CONFIG_RELATIVE_PATH, CODEX_PROMPT_HOOK_SCRIPT_RELATIVE_PATH, OPENCODE_MCP_SERVER_NAME, OPENCODE_PLUGIN_RELATIVE_PATH, @@ -15,6 +14,7 @@ import { isMadarCodexPromptHook, isCurrentMadarCodexPromptHook, readOpencodeConfig, + resolveCodexMcpConfigPath, resolveOpencodeConfigPath, } from './install.js' import { analyzeGraphContextFreshness, graphFreshnessStatusLabel, type GraphContextFreshnessStatus } from '../runtime/freshness.js' @@ -114,7 +114,7 @@ const AIDER_INSTRUCTION_SNIPPETS = [ const CODEX_INSTRUCTION_SNIPPETS = [ '### Codex CLI profile', 'Use a strict context-pack-first workflow', - 'Before broad code search, file reads, or worker dispatch', + 'First decide whether the task needs local repository source-code context', 'madar pack "" --task explain', 'Do not dispatch `spawn_agent` workers first', ] @@ -230,12 +230,12 @@ function findCodexHookEntry(settingsPath: string, expectedCommand: string): bool return managedPromptHooks.length === 1 && currentManagedPromptHooks.length === 1 && !legacyPreToolUse } -function hasManagedCodexMcpConfig(configPath: string): boolean { +function hasManagedCodexMcpConfig(configPath: string, projectDir: string): boolean { if (!existsSync(configPath)) { return false } - return isMadarCodexMcpConfig(readFileSync(configPath, 'utf8')) + return isMadarCodexMcpConfig(readFileSync(configPath, 'utf8'), projectDir) } function containsOutPathReference(value: unknown): boolean { @@ -587,7 +587,7 @@ export function buildDoctorReport(options: DoctorCommandOptions = {}): DoctorRep const codexHookConfigured = hasManagedCodexPromptHookScript(codexPromptHookScriptPath) && findCodexHookEntry(resolve(projectDir, '.codex', 'hooks.json'), codexPromptHookCommand()) - const codexMcpConfigured = hasManagedCodexMcpConfig(resolve(projectDir, CODEX_MCP_CONFIG_RELATIVE_PATH)) + const codexMcpConfigured = hasManagedCodexMcpConfig(resolveCodexMcpConfigPath(), projectDir) const codexStatus = optionalAgentStatus( [codexSkillConfigured, codexInstructionsConfigured, codexHookConfigured, codexMcpConfigured], [codexInstructionsConfigured, codexHookConfigured, codexMcpConfigured], diff --git a/src/infrastructure/install-skill-templates.ts b/src/infrastructure/install-skill-templates.ts index d35d9bab..8ed5924e 100644 --- a/src/infrastructure/install-skill-templates.ts +++ b/src/infrastructure/install-skill-templates.ts @@ -311,6 +311,8 @@ For each codebase question, start with the specific Madar command below first: ${renderMarkdownCodexRoutingTable()} +Pass the entire user's codebase request byte-for-byte as the pack question, including read-only, no-change, scope, and formatting constraints. Do not rewrite, omit, expand, enumerate, or split it into follow-up pack calls. + If MCP graph tools are available after the pack, use the focused tool that matches the next question: - ${CODE_SPAN_START}retrieve${CODE_SPAN_END} for direct codebase questions - ${CODE_SPAN_START}relevant_files${CODE_SPAN_END} for where to open first @@ -323,15 +325,16 @@ If MCP graph tools are available after the pack, use the focused tool that match After calling Madar, treat \`evidence.answerability.state\` as authoritative; \`evidence.pack_confidence\` is compatibility-only: - \`ready\` means answer from the pack without broad raw search. - \`ready_with_caveat\` means answer from the pack and state \`evidence.answerability.caveats\`. -- \`verify_targets\` means inspect only \`evidence.answerability.verification_targets\`, using a listed \`context_expand\` handle or file. +- \`verify_targets\` means use one listed \`context_expand\` handle. In strict MCP mode, treat that expansion result as terminal rather than following a new handle or opening a raw file. - \`insufficient\` means follow \`evidence.answerability.broad_search_fallback\`; \`blocked\` forbids source probing and only \`allowed\` permits one directory-scoped search. +For read-only \`explain\` tasks, \`ready\` and \`ready_with_caveat\` are terminal: cite \`source_file\`, \`label\`, \`line_number\` / \`snippet_line_number\`, and the included snippets directly. Do not run repository \`Read\`, \`Grep\`, \`Glob\`, or \`Bash\` merely to verify the pack, obtain exact lines, or reopen selected files. Do not run broad \`Glob\` patterns, repo-wide \`grep\` / \`find\` searches, or raw file sweeps for \`ready\`, \`ready_with_caveat\`, or \`verify_targets\`. For codebase questions, use Madar tools only. Do not call another MCP or restart broad exploration unless \`evidence.answerability.broad_search_fallback\` is \`allowed\`. -Madar already ran bounded cumulative recovery. For \`verify_targets\`, inspect only a listed handle or file instead of restarting discovery. +Madar already ran bounded cumulative recovery. For \`verify_targets\`, use one listed handle instead of restarting discovery; in strict MCP mode, the expansion result is terminal. If an auto-activated skill recommends broader exploration, defer to Madar's answerability and exact verification targets. Do not open ${CODE_SPAN_START}out/GRAPH_REPORT.md${CODE_SPAN_END} unless the context pack or graph tools are unavailable, stale, or insufficient. -Install or remove the project-local Codex profile with: +Install or remove the workspace-scoped Codex profile with: ${CODE_BLOCK_START}bash madar codex install @@ -341,9 +344,9 @@ ${CODE_BLOCK_END} Manual verification: 1. Run ${CODE_SPAN_START}madar generate .${CODE_SPAN_END}. 2. Run ${CODE_SPAN_START}madar codex install${CODE_SPAN_END}. -3. Confirm ${CODE_SPAN_START}AGENTS.md${CODE_SPAN_END}, ${CODE_SPAN_START}.codex/hooks.json${CODE_SPAN_END}, ${CODE_SPAN_START}.codex/madar-user-prompt-submit.cjs${CODE_SPAN_END}, and ${CODE_SPAN_START}.codex/config.toml${CODE_SPAN_END} exist. Madar owns only its AGENTS section, hook, hook script, and marked ${CODE_SPAN_START}[mcp_servers.madar]${CODE_SPAN_END} block. -4. Only in a repository you trust, restart Codex, use ${CODE_SPAN_START}/hooks${CODE_SPAN_END} to review and trust the project ${CODE_SPAN_START}UserPromptSubmit${CODE_SPAN_END} hook, then use ${CODE_SPAN_START}/mcp${CODE_SPAN_END} or ${CODE_SPAN_START}codex mcp list${CODE_SPAN_END} to verify the local Madar MCP server. -5. Run ${CODE_SPAN_START}madar doctor${CODE_SPAN_END} / ${CODE_SPAN_START}madar status${CODE_SPAN_END} to validate the on-disk install, then run ${CODE_SPAN_START}madar codex uninstall${CODE_SPAN_END} and confirm unrelated AGENTS.md, hook, and TOML content remains. +3. Confirm ${CODE_SPAN_START}AGENTS.md${CODE_SPAN_END}, ${CODE_SPAN_START}.codex/hooks.json${CODE_SPAN_END}, ${CODE_SPAN_START}.codex/madar-user-prompt-submit.cjs${CODE_SPAN_END}, and the marker-owned workspace block in ${CODE_SPAN_START}~/.codex/config.toml${CODE_SPAN_END} (or ${CODE_SPAN_START}$CODEX_HOME/config.toml${CODE_SPAN_END}) exist. The block has a unique server name, pins ${CODE_SPAN_START}cwd${CODE_SPAN_END} to this workspace, and sets both startup and tool-call timeouts. +4. Only in a repository you trust, restart Codex, use ${CODE_SPAN_START}/hooks${CODE_SPAN_END} to review and trust the project ${CODE_SPAN_START}UserPromptSubmit${CODE_SPAN_END} hook, then use ${CODE_SPAN_START}/mcp${CODE_SPAN_END} or ${CODE_SPAN_START}codex mcp list${CODE_SPAN_END} to verify this workspace's Madar MCP server. +5. Run ${CODE_SPAN_START}madar doctor${CODE_SPAN_END} / ${CODE_SPAN_START}madar status${CODE_SPAN_END} to validate installed wiring, then run ${CODE_SPAN_START}madar codex uninstall${CODE_SPAN_END} and confirm unrelated AGENTS.md, hooks, user config, and other workspace registrations remain. Codex limitations: - Automated tests do not require the Codex binary; they verify generated text and hook config. diff --git a/src/infrastructure/install.ts b/src/infrastructure/install.ts index 71e3cc1d..d9e19f46 100644 --- a/src/infrastructure/install.ts +++ b/src/infrastructure/install.ts @@ -1,11 +1,10 @@ +import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { getBuiltInSkillContent } from './install-skill-templates.js' import { - renderMarkdownCodexRoutingTable, renderMarkdownMcpRoutingTable, - renderPlainCodexRoutingGuide, renderPlainMcpRoutingGuide, } from './install-routing-guidance.js' import { buildPromptApplicabilityHookScript } from '../runtime/task-applicability.js' @@ -42,8 +41,11 @@ const CODEX_PROMPT_HOOK_SCRIPT_MARKER = '// madar managed Codex UserPromptSubmit const CODEX_PROMPT_HOOK_COMMAND = `node -e "const fs=require('fs');const path=require('path');let dir=process.cwd();for(;;){const script=path.join(dir,'.codex','madar-user-prompt-submit.cjs');if(fs.existsSync(script)){require(script);break}const parent=path.dirname(dir);if(parent===dir){process.exit(0)}dir=parent}"` export const CODEX_MCP_CONFIG_RELATIVE_PATH = '.codex/config.toml' export const CODEX_MCP_STARTUP_TIMEOUT_SECONDS = 180 +export const CODEX_MCP_TOOL_TIMEOUT_SECONDS = 60 const CODEX_MCP_START_MARKER = '# >>> madar managed mcp >>>' const CODEX_MCP_END_MARKER = '# <<< madar managed mcp <<<' +const CODEX_MCP_SCOPED_START_MARKER_PREFIX = '# >>> madar managed mcp:' +const CODEX_MCP_SCOPED_END_MARKER_PREFIX = '# <<< madar managed mcp:' const CODEX_MCP_OWNS_PRECEDING_LINE_ENDING_MARKER = '# madar managed mcp: preceding line ending owned' interface InstallPlatformConfig { @@ -341,12 +343,28 @@ function strictContextPackStopRule(markdown: boolean): string { return 'after calling Madar, treat evidence.answerability.state as authoritative and evidence.pack_confidence as compatibility-only: ready means answer from the pack; ready_with_caveat means answer with evidence.answerability.caveats; verify_targets means inspect only evidence.answerability.verification_targets; insufficient means follow broad_search_fallback exactly' } +function strictContextPackInvocationRule(markdown: boolean): string { + if (markdown) { + return 'Call `context_pack` exactly once per user task. Copy the entire user codebase request byte-for-byte into `prompt`, including read-only, no-change, scope, and formatting constraints; do not rewrite, omit, expand, enumerate, split, or issue follow-up `context_pack` calls.' + } + + return 'call context_pack exactly once per user task; copy the entire user codebase request byte-for-byte into prompt, including read-only, no-change, scope, and formatting constraints; do not rewrite, omit, expand, enumerate, split, or issue follow-up context_pack calls' +} + +function strictReadOnlyReadyTerminalRule(markdown: boolean): string { + if (markdown) { + return 'For read-only `explain` tasks, `ready` and `ready_with_caveat` are terminal: cite `source_file`, `label`, `line_number` / `snippet_line_number`, and the included snippets directly. Do not run repository `Read`, `Grep`, `Glob`, or `Bash` merely to verify the pack, obtain exact lines, or reopen selected files.' + } + + return 'for read-only explain tasks, ready and ready_with_caveat are terminal: cite source_file, label, line_number or snippet_line_number, and the included snippets directly; do not run repository Read, Grep, Glob, or Bash merely to verify the pack, obtain exact lines, or reopen selected files' +} + function strictContextPackExpandRule(markdown: boolean): string { if (markdown) { - return 'Madar already ran bounded cumulative recovery. Do not restart repository exploration: for `verify_targets`, call `context_expand` with a listed handle or read only a listed file; only `insufficient` plus `broad_search_fallback: allowed` permits one directory-scoped search.' + return 'Madar already ran bounded cumulative recovery. Do not restart repository exploration: for `verify_targets`, call `context_expand` once with a listed handle, then treat that expansion result as terminal; only `insufficient` plus `broad_search_fallback: allowed` permits one directory-scoped search.' } - return 'Madar already ran bounded cumulative recovery; do not restart repository exploration: for verify_targets, call context_expand with a listed handle or read only a listed file; only insufficient plus broad_search_fallback allowed permits one directory-scoped search' + return 'Madar already ran bounded cumulative recovery; do not restart repository exploration: for verify_targets, call context_expand once with a listed handle, then treat that expansion result as terminal; only insufficient plus broad_search_fallback allowed permits one directory-scoped search' } function strictGraphReportFallbackRule(markdown: boolean): string { @@ -369,10 +387,21 @@ const RETRIEVE_FIRST_MESSAGE = `STOP. This project has a madar knowledge graph. ${renderPlainMcpRoutingGuide()} Use the graph result as the first bounded pass for codebase questions, then validate with focused reads or tests when the graph is insufficient. ${strictNonMadarMcpRule(false)}. ${strictSkillOverrideRule(false)}. Do not use Glob, Grep, Bash, Read, or Agent tools first. Only fall back to raw file tools if the graph tools cannot answer the question or the MCP server is unavailable.` const STRICT_CONTEXT_PACK_MESSAGE = - `STOP. This project has a madar knowledge graph. Use strict compact MCP mode: call context_pack once for the task before broader exploration, ${strictContextPackStopRule(false)}, ${strictContextPackNoBroadExplorationRule(false)}, ${strictNonMadarMcpRule(false)}, ${strictSkillOverrideRule(false)}, ${strictContextPackExpandRule(false)}, and ${strictGraphReportFallbackRule(false)}.` + `STOP. This project has a madar knowledge graph. Use strict compact MCP mode: ${strictContextPackInvocationRule(false)}, ${strictContextPackStopRule(false)}, ${strictReadOnlyReadyTerminalRule(false)}, ${strictContextPackNoBroadExplorationRule(false)}, ${strictNonMadarMcpRule(false)}, ${strictSkillOverrideRule(false)}, ${strictContextPackExpandRule(false)}, and ${strictGraphReportFallbackRule(false)}.` const CODEX_CONTEXT_PACK_FIRST_MESSAGE = - `STOP. This project has a madar knowledge graph. Follow the Codex context-pack-first workflow: run madar pack "" --task explain before broad Bash search, raw file reads, or spawning workers. Use --task review, --task debug, or --task impact when that better matches the work. ${renderPlainCodexRoutingGuide()} ${strictContextPackNoBroadExplorationRule(false)}. ${strictNonMadarMcpRule(false)}. ${strictSkillOverrideRule(false)}. If MCP graph tools are available, use context_pack, context_expand, retrieve, impact, or graph_summary to refine the pack. ${strictGraphReportFallbackRule(false)}.` + `STOP. This project has a madar knowledge graph. Follow the Codex context-pack-first workflow: ${strictContextPackInvocationRule(false)}. If MCP graph tools are unavailable, run madar pack with the user's codebase request copied verbatim before broad Bash search, raw file reads, or spawning workers. Use --task review, --task debug, or --task impact when that better matches the work. Strict MCP exposes only context_pack and context_expand: use task=impact or task=review on the first pack instead of graph-navigation tools. ${strictReadOnlyReadyTerminalRule(false)}. ${strictContextPackNoBroadExplorationRule(false)}. ${strictNonMadarMcpRule(false)}. ${strictSkillOverrideRule(false)}. For verify_targets, use context_expand once only with a listed verification handle and treat the result as terminal; do not expand ready or ready_with_caveat packs. ${strictGraphReportFallbackRule(false)}.` + +function renderMarkdownStrictContextPackRoutingTable(): string { + return `| Prompt type | First strict MCP tool | +| --- | --- | +| "how does X work" / explain runtime / flow | \`context_pack\` with \`task: "explain"\` | +| "what breaks if I change X" / impact analysis | \`context_pack\` with \`task: "impact"\` | +| review changed code | \`context_pack\` with \`task: "review"\` | +| implementation planning | \`context_pack\` with \`task: "implement"\` | + +Strict exposes no general graph-navigation tool after this pack. Use \`context_expand\` only for a listed \`verify_targets\` handle.` +} const SETTINGS_HOOK = { // SECURITY: Keep this command static. Do not interpolate user-controlled input here. @@ -445,13 +474,14 @@ const STRICT_CLAUDE_MD_SECTION = `${SECTION_MARKER} IMPORTANT: This project has a madar knowledge graph. You MUST follow these strict compact MCP rules: 1. **First decide whether the task needs local repository source-code context.** Only use madar when the task needs local repository source-code context. Skip madar for GitHub Projects board reviews, external URL/WebFetch-only tasks, \`gh auth\` / \`gh project\` setup, package-registry/security pages, and Product Hunt or marketing copy work. -2. **Call \`context_pack\` once for the task before broader exploration.** +2. **${strictContextPackInvocationRule(true)}** 3. **${strictContextPackStopRule(true)}** -4. **${strictContextPackNoBroadExplorationRule(true)}** -5. **${strictNonMadarMcpRule(true)}** -6. **${strictSkillOverrideRule(true)}** -7. **${strictContextPackExpandRule(true)}** Use \`context_expand\` first when the pack returns a handle; otherwise use \`retrieve\`. Use \`impact\` for blast radius and \`graph_summary\` for a bounded repo overview. -8. **${strictGraphReportFallbackRule(true)}** +4. **${strictReadOnlyReadyTerminalRule(true)}** +5. **${strictContextPackNoBroadExplorationRule(true)}** +6. **${strictNonMadarMcpRule(true)}** +7. **${strictSkillOverrideRule(true)}** +8. **${strictContextPackExpandRule(true)}** For \`verify_targets\`, use \`context_expand\` only with a listed verification handle. Do not expand \`ready\` or \`ready_with_caveat\` packs. Strict exposes no other post-pack graph-navigation tool; select \`task: "impact"\` on the first pack for blast radius. +9. **${strictGraphReportFallbackRule(true)}** ` const AGENTS_MD_SECTION = `${SECTION_MARKER} @@ -479,13 +509,14 @@ IMPORTANT: This project has a madar knowledge graph. Use a strict context-pack-f - \`madar pack "" --task explain\` - use \`--task review\`, \`--task debug\`, or \`--task impact\` when that better matches the work 3. **${strictContextPackNoBroadExplorationRule(true)}** -4. **${strictNonMadarMcpRule(true)}** -5. **${strictSkillOverrideRule(true)}** -6. **Regenerate before expanding manually** when the pack is stale or missing: +4. **${strictReadOnlyReadyTerminalRule(true)}** +5. **${strictNonMadarMcpRule(true)}** +6. **${strictSkillOverrideRule(true)}** +7. **Regenerate before expanding manually** when the pack is stale or missing: - run \`madar generate .\` - ${strictGraphReportFallbackRule(true)} -7. **This profile writes AGENTS.md only.** Aider does not get an auto-installed MCP server or hook from this installer, so the AGENTS.md rule plus explicit \`madar pack\` calls are the enforcement mechanism. -8. **Uninstall behavior:** run \`madar aider uninstall\` to remove this AGENTS.md section while preserving unrelated content. +8. **This profile writes AGENTS.md only.** Aider does not get an auto-installed MCP server or hook from this installer, so the AGENTS.md rule plus explicit \`madar pack\` calls are the enforcement mechanism. +9. **Uninstall behavior:** run \`madar aider uninstall\` to remove this AGENTS.md section while preserving unrelated content. Manual verification: @@ -505,32 +536,31 @@ const CODEX_AGENTS_MD_SECTION = `${SECTION_MARKER} IMPORTANT: This project has a madar knowledge graph. Use a strict context-pack-first workflow: 1. **First decide whether the task needs local repository source-code context.** Only use madar when the task needs local repository source-code context. Skip madar for GitHub Projects board reviews, external URL/WebFetch-only tasks, \`gh auth\` / \`gh project\` setup, package-registry/security pages, and Product Hunt or marketing copy work. -2. **Before broad code search, file reads, or worker dispatch**, compile a task-specific context pack: +2. **${strictContextPackInvocationRule(true)}** If MCP is unavailable, compile the pack with the user's request copied verbatim: - \`madar pack "" --task explain\` - use \`--task review\`, \`--task debug\`, or \`--task impact\` when that better matches the work 3. **For each codebase question, start with the specific Madar command below first.** -${renderMarkdownCodexRoutingTable()} +${renderMarkdownStrictContextPackRoutingTable()} 4. **${strictContextPackNoBroadExplorationRule(true)}** -5. **${strictNonMadarMcpRule(true)}** -6. **${strictSkillOverrideRule(true)}** -7. If MCP graph tools are available after the pack, use the focused tool that matches the next question: - - \`context_pack\` for a fresh task-specific pack - - \`context_expand\` for a handle returned by a pack - - \`retrieve\` for direct codebase questions - - \`impact\` for blast radius - - \`graph_summary\` for repo overview -8. **${strictGraphReportFallbackRule(true)}** -9. **Do not dispatch \`spawn_agent\` workers first** for codebase discovery. Let the context pack define likely entry files, risks, and missing context before parallel work. -10. **Codex activation boundary:** \`madar codex install\` writes this Madar-owned AGENTS.md section, \`.codex/hooks.json\`, \`.codex/madar-user-prompt-submit.cjs\`, and a marker-owned \`[mcp_servers.madar]\` block in \`.codex/config.toml\`. The \`UserPromptSubmit\` hook supplies model-visible context-pack-first guidance only for local code tasks; it is guidance, not enforcement. Enable it only in a repository you trust, then restart Codex, use \`/hooks\` to review and trust the project hook, and use \`/mcp\` or \`codex mcp list\` to verify the MCP server. \`madar doctor\` and \`madar status\` validate on-disk files only; they do not prove Codex has trusted or activated them. -11. **Uninstall behavior:** run \`madar codex uninstall\` to remove only this AGENTS.md section, the Madar hook, the Madar hook script, and the marker-owned MCP block while preserving unrelated content. +5. **${strictReadOnlyReadyTerminalRule(true)}** +6. **${strictNonMadarMcpRule(true)}** +7. **${strictSkillOverrideRule(true)}** +8. If MCP graph tools are available after the pack, use the focused tool that matches the next question: + - \`context_pack\` exactly once for the current user task + - \`context_expand\` only for a listed \`verify_targets\` handle, never for \`ready\` or \`ready_with_caveat\` + - no other graph-navigation tool is available in this strict profile; choose the matching \`task\` on the first pack instead +9. **${strictGraphReportFallbackRule(true)}** +10. **Do not dispatch \`spawn_agent\` workers first** for codebase discovery. Let the context pack define likely entry files, risks, and missing context before parallel work. +11. **Codex activation boundary:** \`madar codex install\` writes this Madar-owned AGENTS.md section, \`.codex/hooks.json\`, \`.codex/madar-user-prompt-submit.cjs\`, and a workspace-scoped marker-owned MCP block in Codex's loaded user config (\`$CODEX_HOME/config.toml\`, normally \`~/.codex/config.toml\`). The MCP block has a unique server name, pins \`cwd\` to this workspace, and sets both startup and tool-call timeouts; this lets worktrees coexist without retargeting one another. The \`UserPromptSubmit\` hook supplies model-visible context-pack-first guidance only for local code tasks; it is guidance, not enforcement. Enable it only in a repository you trust, then restart Codex, use \`/hooks\` to review and trust the project hook, and use \`/mcp\` or \`codex mcp list\` to verify the MCP server. \`madar doctor\` and \`madar status\` validate installed wiring only; they do not prove Codex has trusted or activated it. +12. **Uninstall behavior:** run \`madar codex uninstall\` to remove only this AGENTS.md section, the Madar hook, the Madar hook script, and this workspace's marker-owned user-config MCP block while preserving unrelated content and other workspaces. Manual verification: \`\`\`bash madar generate . madar codex install -test -f AGENTS.md && test -f .codex/hooks.json && test -f .codex/madar-user-prompt-submit.cjs && test -f .codex/config.toml +test -f AGENTS.md && test -f .codex/hooks.json && test -f .codex/madar-user-prompt-submit.cjs && test -f "\${CODEX_HOME:-$HOME/.codex}/config.toml" # In a trusted repository, restart Codex and use /hooks to review/trust the hook. # Then use /mcp or codex mcp list to verify the local Madar MCP server. madar doctor @@ -550,17 +580,16 @@ IMPORTANT: This project has a madar knowledge graph. Use a strict context-pack-f - \`madar pack "" --task explain\` - use \`--task review\`, \`--task debug\`, or \`--task impact\` when that better matches the work 3. **${strictContextPackNoBroadExplorationRule(true)}** -4. **${strictNonMadarMcpRule(true)}** -5. **${strictSkillOverrideRule(true)}** -6. After the pack, use MCP graph tools when available inside OpenCode: - - \`context_pack\` for a fresh task-specific pack - - \`context_expand\` for a handle returned by a pack - - \`retrieve\` for direct codebase questions - - \`impact\` for blast radius - - \`graph_summary\` for repo overview -7. **Install artifacts:** this profile writes this AGENTS.md section, \`.opencode/plugins/madar.js\`, and the madar MCP server entry in \`opencode.json\` or \`opencode.jsonc\`. -8. **${strictGraphReportFallbackRule(true)}** -9. **Uninstall behavior:** run \`madar opencode uninstall\` to remove the madar AGENTS.md section, plugin entry, plugin file, and madar MCP config while preserving unrelated content. +4. **${strictReadOnlyReadyTerminalRule(true)}** +5. **${strictNonMadarMcpRule(true)}** +6. **${strictSkillOverrideRule(true)}** +7. After the pack, use MCP graph tools when available inside OpenCode: + - \`context_pack\` exactly once for the current user task + - \`context_expand\` only for a listed \`verify_targets\` handle, never for \`ready\` or \`ready_with_caveat\` + - no other graph-navigation tool is available in this strict profile; choose the matching \`task\` on the first pack instead +8. **Install artifacts:** this profile writes this AGENTS.md section, \`.opencode/plugins/madar.js\`, and the madar MCP server entry in \`opencode.json\` or \`opencode.jsonc\`. +9. **${strictGraphReportFallbackRule(true)}** +10. **Uninstall behavior:** run \`madar opencode uninstall\` to remove the madar AGENTS.md section, plugin entry, plugin file, and madar MCP config while preserving unrelated content. Manual verification: @@ -592,13 +621,14 @@ const STRICT_GEMINI_MD_SECTION = `${SECTION_MARKER} IMPORTANT: This project has a madar knowledge graph. Use strict compact MCP guidance: 1. **First decide whether the task needs local repository source-code context.** Only use madar when the task needs local repository source-code context. Skip madar for GitHub Projects board reviews, external URL/WebFetch-only tasks, \`gh auth\` / \`gh project\` setup, package-registry/security pages, and Product Hunt or marketing copy work. -2. **Call \`context_pack\` once for the task before broader exploration.** +2. **${strictContextPackInvocationRule(true)}** 3. **${strictContextPackStopRule(true)}** -4. **${strictContextPackNoBroadExplorationRule(true)}** -5. **${strictNonMadarMcpRule(true)}** -6. **${strictSkillOverrideRule(true)}** -7. **${strictContextPackExpandRule(true)}** Use \`context_expand\` first when the pack returns a handle; otherwise use \`retrieve\`. Use \`impact\` for blast radius and \`graph_summary\` for a bounded repo overview. -8. **${strictGraphReportFallbackRule(true)}** +4. **${strictReadOnlyReadyTerminalRule(true)}** +5. **${strictContextPackNoBroadExplorationRule(true)}** +6. **${strictNonMadarMcpRule(true)}** +7. **${strictSkillOverrideRule(true)}** +8. **${strictContextPackExpandRule(true)}** For \`verify_targets\`, use \`context_expand\` only with a listed verification handle. Do not expand \`ready\` or \`ready_with_caveat\` packs. Strict exposes no other post-pack graph-navigation tool; select \`task: "impact"\` on the first pack for blast radius. +9. **${strictGraphReportFallbackRule(true)}** ` const SKILL_REGISTRATION_MARKER = '- **madar**' @@ -685,13 +715,14 @@ alwaysApply: true IMPORTANT: This project has a madar knowledge graph. Use strict compact MCP guidance: 1. **First decide whether the task needs local repository source-code context.** Only use madar when the task needs local repository source-code context. Skip madar for GitHub Projects board reviews, external URL/WebFetch-only tasks, \`gh auth\` / \`gh project\` setup, package-registry/security pages, and Product Hunt or marketing copy work. -2. **Call \`context_pack\` once for the task before broader exploration.** +2. **${strictContextPackInvocationRule(true)}** 3. **${strictContextPackStopRule(true)}** -4. **${strictContextPackNoBroadExplorationRule(true)}** -5. **${strictNonMadarMcpRule(true)}** -6. **${strictSkillOverrideRule(true)}** -7. **${strictContextPackExpandRule(true)}** Use \`context_expand\` first when the pack returns a handle; otherwise use \`retrieve\`. Use \`impact\` for blast radius and \`graph_summary\` for a bounded repo overview. -8. **${strictGraphReportFallbackRule(true)}** +4. **${strictReadOnlyReadyTerminalRule(true)}** +5. **${strictContextPackNoBroadExplorationRule(true)}** +6. **${strictNonMadarMcpRule(true)}** +7. **${strictSkillOverrideRule(true)}** +8. **${strictContextPackExpandRule(true)}** For \`verify_targets\`, use \`context_expand\` only with a listed verification handle. Do not expand \`ready\` or \`ready_with_caveat\` packs. Strict exposes no other post-pack graph-navigation tool; select \`task: "impact"\` on the first pack for blast radius. +9. **${strictGraphReportFallbackRule(true)}** ` function claudeMdSection(profile?: InstallProfile): string { @@ -2000,10 +2031,14 @@ function standaloneMarkerPositions(content: string, marker: string, multilineStr return positions } -function readManagedCodexMcpBlock(content: string): ManagedCodexMcpBlock | null { +function readManagedCodexMcpBlock( + content: string, + startMarker = CODEX_MCP_START_MARKER, + endMarker = CODEX_MCP_END_MARKER, +): ManagedCodexMcpBlock | null { const multilineStringRanges = tomlMultilineStringRanges(content) - const starts = standaloneMarkerPositions(content, CODEX_MCP_START_MARKER, multilineStringRanges) - const ends = standaloneMarkerPositions(content, CODEX_MCP_END_MARKER, multilineStringRanges) + const starts = standaloneMarkerPositions(content, startMarker, multilineStringRanges) + const ends = standaloneMarkerPositions(content, endMarker, multilineStringRanges) if (starts.length === 0 && ends.length === 0) { return null @@ -2024,10 +2059,31 @@ function readManagedCodexMcpBlock(content: string): ManagedCodexMcpBlock | null ownsPrecedingLineEnding: content .slice(start, end) .replaceAll('\r\n', '\n') - .startsWith(`${CODEX_MCP_START_MARKER}\n${CODEX_MCP_OWNS_PRECEDING_LINE_ENDING_MARKER}\n`), + .startsWith(`${startMarker}\n${CODEX_MCP_OWNS_PRECEDING_LINE_ENDING_MARKER}\n`), } } +function codexMcpServerName(projectDir: string): string { + const workspaceId = createHash('sha256').update(resolve(projectDir)).digest('hex').slice(0, 12) + return `madar_${workspaceId}` +} + +function scopedCodexMcpStartMarker(serverName: string): string { + return `${CODEX_MCP_SCOPED_START_MARKER_PREFIX} ${serverName} >>>` +} + +function scopedCodexMcpEndMarker(serverName: string): string { + return `${CODEX_MCP_SCOPED_END_MARKER_PREFIX} ${serverName} <<<` +} + +export function resolveCodexMcpConfigPath(): string { + const configuredHome = process.env.CODEX_HOME?.trim() + const codexHome = configuredHome && configuredHome.length > 0 + ? resolve(configuredHome) + : join(homedir(), '.codex') + return join(codexHome, 'config.toml') +} + function stripTomlComments(content: string): string { let result = '' let quote: 'single' | 'double' | null = null @@ -2080,7 +2136,8 @@ function stripTomlComments(content: string): string { return result } -function hasUserManagedCodexMcpDeclaration(content: string): boolean { +function hasUserManagedCodexMcpDeclaration(content: string, serverName: string): boolean { + const serverPath = `mcp_servers.${serverName}` const lines = stripTomlComments(content).split(/\r?\n/) let currentTable: string | null = null @@ -2088,7 +2145,7 @@ function hasUserManagedCodexMcpDeclaration(content: string): boolean { const arrayTableMatch = /^\s*\[\[\s*([^\]]+?)\s*\]\]\s*$/.exec(line) if (arrayTableMatch?.[1]) { const tableName = arrayTableMatch[1].replace(/[\s"']/g, '') - if (tableName === 'mcp_servers' || tableName === 'mcp_servers.madar' || tableName.startsWith('mcp_servers.madar.')) { + if (tableName === 'mcp_servers' || tableName === serverPath || tableName.startsWith(`${serverPath}.`)) { return true } currentTable = null @@ -2098,7 +2155,7 @@ function hasUserManagedCodexMcpDeclaration(content: string): boolean { const tableMatch = /^\s*\[\s*([^\]]+?)\s*\]\s*$/.exec(line) if (tableMatch?.[1]) { const tableName = tableMatch[1].replace(/[\s"']/g, '') - if (tableName === 'mcp_servers.madar' || tableName.startsWith('mcp_servers.madar.')) { + if (tableName === serverPath || tableName.startsWith(`${serverPath}.`)) { return true } currentTable = tableName @@ -2113,12 +2170,12 @@ function hasUserManagedCodexMcpDeclaration(content: string): boolean { const keyPath = line.slice(0, assignmentIndex).replace(/[\s"']/g, '') if (currentTable === null && ( keyPath === 'mcp_servers' - || keyPath === 'mcp_servers.madar' - || keyPath.startsWith('mcp_servers.madar.') + || keyPath === serverPath + || keyPath.startsWith(`${serverPath}.`) )) { return true } - if (currentTable === 'mcp_servers' && (keyPath === 'madar' || keyPath.startsWith('madar.'))) { + if (currentTable === 'mcp_servers' && (keyPath === serverName || keyPath.startsWith(`${serverName}.`))) { return true } } @@ -2126,35 +2183,47 @@ function hasUserManagedCodexMcpDeclaration(content: string): boolean { return false } -function renderCodexMcpBlock(lineEnding: string, ownsPrecedingLineEnding = false): string { +function renderCodexMcpBlock( + projectDir: string, + serverName: string, + lineEnding: string, + ownsPrecedingLineEnding = false, +): string { return [ - CODEX_MCP_START_MARKER, + scopedCodexMcpStartMarker(serverName), ...(ownsPrecedingLineEnding ? [CODEX_MCP_OWNS_PRECEDING_LINE_ENDING_MARKER] : []), - '[mcp_servers.madar]', + `[mcp_servers.${serverName}]`, 'command = "madar"', 'args = ["serve", "--stdio", "--auto-refresh"]', + `cwd = ${JSON.stringify(resolve(projectDir))}`, 'env = { MADAR_TOOL_PROFILE = "strict" }', 'enabled = true', `startup_timeout_sec = ${CODEX_MCP_STARTUP_TIMEOUT_SECONDS}`, - CODEX_MCP_END_MARKER, + `tool_timeout_sec = ${CODEX_MCP_TOOL_TIMEOUT_SECONDS}`, + scopedCodexMcpEndMarker(serverName), '', ].join(lineEnding) } -export function isMadarCodexMcpConfig(content: string): boolean { +export function isMadarCodexMcpConfig(content: string, projectDir: string): boolean { try { - const managedBlock = readManagedCodexMcpBlock(content) + const serverName = codexMcpServerName(projectDir) + const managedBlock = readManagedCodexMcpBlock( + content, + scopedCodexMcpStartMarker(serverName), + scopedCodexMcpEndMarker(serverName), + ) if (!managedBlock) { return false } const unownedContent = `${content.slice(0, managedBlock.start)}${content.slice(managedBlock.end)}` - if (hasUserManagedCodexMcpDeclaration(unownedContent)) { + if (hasUserManagedCodexMcpDeclaration(unownedContent, serverName)) { return false } const normalizedBlock = managedBlock.content.replaceAll('\r\n', '\n') - const expectedBlock = renderCodexMcpBlock('\n', managedBlock.ownsPrecedingLineEnding) + const expectedBlock = renderCodexMcpBlock(projectDir, serverName, '\n', managedBlock.ownsPrecedingLineEnding) return normalizedBlock === expectedBlock } catch { return false @@ -2162,77 +2231,126 @@ export function isMadarCodexMcpConfig(content: string): boolean { } function assertCodexMcpConfigIsSafe(projectDir: string): void { + const globalConfigPath = resolveCodexMcpConfigPath() + const serverName = codexMcpServerName(projectDir) + if (existsSync(globalConfigPath)) { + readManagedCodexMcpBlock( + readFileSync(globalConfigPath, 'utf8'), + scopedCodexMcpStartMarker(serverName), + scopedCodexMcpEndMarker(serverName), + ) + } + + const legacyConfigPath = join(projectDir, CODEX_MCP_CONFIG_RELATIVE_PATH) + if (existsSync(legacyConfigPath)) { + readManagedCodexMcpBlock(readFileSync(legacyConfigPath, 'utf8')) + } +} + +function removeManagedCodexMcpBlock(configPath: string, content: string, managedBlock: ManagedCodexMcpBlock): void { + const beforeBlock = content.slice(0, managedBlock.start) + const afterBlock = content.slice(managedBlock.end) + const precedingLineEnding = beforeBlock.endsWith('\r\n') + ? '\r\n' + : beforeBlock.endsWith('\n') + ? '\n' + : '' + const beforeWithoutOwnedLineEnding = managedBlock.ownsPrecedingLineEnding && precedingLineEnding.length > 0 + ? beforeBlock.slice(0, -precedingLineEnding.length) + : beforeBlock + const needsLineEndingBeforeAfterBlock = managedBlock.ownsPrecedingLineEnding + && precedingLineEnding.length > 0 + && afterBlock.length > 0 + && !afterBlock.startsWith('\n') + && !afterBlock.startsWith('\r') + writeFileSync( + configPath, + `${beforeWithoutOwnedLineEnding}${needsLineEndingBeforeAfterBlock ? precedingLineEnding : ''}${afterBlock}`, + 'utf8', + ) +} + +function removeLegacyCodexMcpServer(projectDir: string): string | undefined { const configPath = join(projectDir, CODEX_MCP_CONFIG_RELATIVE_PATH) - if (existsSync(configPath)) { - readManagedCodexMcpBlock(readFileSync(configPath, 'utf8')) + if (!existsSync(configPath)) { + return undefined } + + const content = readFileSync(configPath, 'utf8') + const managedBlock = readManagedCodexMcpBlock(content) + if (!managedBlock) { + return undefined + } + + removeManagedCodexMcpBlock(configPath, content, managedBlock) + return '.codex/config.toml -> obsolete project-local MCP registration removed' } function installCodexMcpServer(projectDir: string): string { - const configPath = join(projectDir, CODEX_MCP_CONFIG_RELATIVE_PATH) + const configPath = resolveCodexMcpConfigPath() + const serverName = codexMcpServerName(projectDir) const content = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '' - const managedBlock = readManagedCodexMcpBlock(content) + const managedBlock = readManagedCodexMcpBlock( + content, + scopedCodexMcpStartMarker(serverName), + scopedCodexMcpEndMarker(serverName), + ) const lineEnding = lineEndingForContent(content) const ownsPrecedingLineEnding = managedBlock?.ownsPrecedingLineEnding ?? (content.length > 0 && !content.endsWith('\n')) - const nextBlock = renderCodexMcpBlock(lineEnding, ownsPrecedingLineEnding) + const nextBlock = renderCodexMcpBlock(projectDir, serverName, lineEnding, ownsPrecedingLineEnding) const unownedContent = managedBlock ? `${content.slice(0, managedBlock.start)}${content.slice(managedBlock.end)}` : content - if (hasUserManagedCodexMcpDeclaration(unownedContent)) { - return '.codex/config.toml -> MCP server is user-managed (no change)' + if (hasUserManagedCodexMcpDeclaration(unownedContent, serverName)) { + return `${configPath} -> MCP server ${serverName} is user-managed (no change)` } + let registrationMessage: string if (managedBlock) { if (managedBlock.content === nextBlock) { - return '.codex/config.toml -> MCP server already registered (no change)' + registrationMessage = `${configPath} -> MCP server ${serverName} already registered (no change)` + } else { + writeFileSync( + configPath, + `${content.slice(0, managedBlock.start)}${nextBlock}${content.slice(managedBlock.end)}`, + 'utf8', + ) + registrationMessage = `${configPath} -> MCP server ${serverName} updated` } - - writeFileSync( - configPath, - `${content.slice(0, managedBlock.start)}${nextBlock}${content.slice(managedBlock.end)}`, - 'utf8', - ) - return '.codex/config.toml -> MCP server updated' + } else { + ensureParentDirectory(configPath) + const separator = ownsPrecedingLineEnding ? lineEnding : '' + writeFileSync(configPath, `${content}${separator}${nextBlock}`, 'utf8') + registrationMessage = `${configPath} -> MCP server ${serverName} registered` } - ensureParentDirectory(configPath) - const separator = ownsPrecedingLineEnding ? lineEnding : '' - writeFileSync(configPath, `${content}${separator}${nextBlock}`, 'utf8') - return '.codex/config.toml -> MCP server registered' + const legacyMessage = removeLegacyCodexMcpServer(projectDir) + return legacyMessage ? `${registrationMessage}\n${legacyMessage}` : registrationMessage } function uninstallCodexMcpServer(projectDir: string): string | undefined { - const configPath = join(projectDir, CODEX_MCP_CONFIG_RELATIVE_PATH) + const configPath = resolveCodexMcpConfigPath() if (!existsSync(configPath)) { - return undefined + return removeLegacyCodexMcpServer(projectDir) } const content = readFileSync(configPath, 'utf8') - const managedBlock = readManagedCodexMcpBlock(content) + const serverName = codexMcpServerName(projectDir) + const managedBlock = readManagedCodexMcpBlock( + content, + scopedCodexMcpStartMarker(serverName), + scopedCodexMcpEndMarker(serverName), + ) if (!managedBlock) { - return undefined + return removeLegacyCodexMcpServer(projectDir) } - const beforeBlock = content.slice(0, managedBlock.start) - const afterBlock = content.slice(managedBlock.end) - const precedingLineEnding = beforeBlock.endsWith('\r\n') - ? '\r\n' - : beforeBlock.endsWith('\n') - ? '\n' - : '' - const beforeWithoutOwnedLineEnding = managedBlock.ownsPrecedingLineEnding && precedingLineEnding.length > 0 - ? beforeBlock.slice(0, -precedingLineEnding.length) - : beforeBlock - const needsLineEndingBeforeAfterBlock = managedBlock.ownsPrecedingLineEnding - && precedingLineEnding.length > 0 - && afterBlock.length > 0 - && !afterBlock.startsWith('\n') - && !afterBlock.startsWith('\r') - const restoredContent = `${beforeWithoutOwnedLineEnding}${needsLineEndingBeforeAfterBlock ? precedingLineEnding : ''}${afterBlock}` - writeFileSync(configPath, restoredContent, 'utf8') - return '.codex/config.toml -> MCP server removed' + removeManagedCodexMcpBlock(configPath, content, managedBlock) + const legacyMessage = removeLegacyCodexMcpServer(projectDir) + const registrationMessage = `${configPath} -> MCP server ${serverName} removed` + return legacyMessage ? `${registrationMessage}\n${legacyMessage}` : registrationMessage } function installCodexHook(projectDir: string): string { @@ -2529,7 +2647,7 @@ export function geminiInstall(projectDir = '.', options: GeminiInstallOptions = installMcpServer(resolvedProjectDir, 'gemini', options), ] if (options.profile === 'strict') { - messages.push('', 'Gemini CLI will now use the madar strict compact MCP profile:', `call context_pack once, ${strictContextPackStopRule(false)}, ${strictContextPackNoBroadExplorationRule(false)}, ${strictContextPackExpandRule(false)}, and ${strictGraphReportFallbackRule(false)}.`) + messages.push('', 'Gemini CLI will now use the madar strict compact MCP profile:', `${strictContextPackInvocationRule(false)}, ${strictContextPackStopRule(false)}, ${strictReadOnlyReadyTerminalRule(false)}, ${strictContextPackNoBroadExplorationRule(false)}, ${strictContextPackExpandRule(false)}, and ${strictGraphReportFallbackRule(false)}.`) } else { messages.push('', 'Gemini CLI will now check the knowledge graph before answering', 'codebase questions and rebuild it after code changes.') } @@ -2558,7 +2676,7 @@ export function geminiUninstall(projectDir = '.', options: Pick freshness.generated_ms) { return false @@ -312,6 +324,7 @@ function canReuseFreshGraphOnStart( outcome.kind === 'file' && outcome.status !== 'skipped_by_policy' && WATCHED_EXTENSIONS.has(extname(localPath).toLowerCase()) + && !MANAGED_AGENT_INSTRUCTION_FILENAMES.has(localPath) && !currentCandidates.has(localPath) ) { return false @@ -383,6 +396,9 @@ function collectWatchedFiles( assertReconciliationWithinDeadline(collection) const isControlFile = WATCHED_CONTROL_FILENAMES.has(entry.name) const entryPath = resolve(directory, entry.name) + if (isRootManagedAgentInstructionFile(discoveryRoot, entryPath)) { + continue + } if (entry.name.startsWith('.') && !isControlFile) { continue } @@ -869,6 +885,9 @@ export async function watch(watchPath: string, debounce = 3, options: WatchOptio return false } const normalized = filename.toString().replaceAll('\\', '/').replace(/^\.\//, '') + if (MANAGED_AGENT_INSTRUCTION_FILENAMES.has(normalized)) { + return true + } const [topLevel] = normalized.split('/') return topLevel !== undefined && eventIgnoredDirectories.has(topLevel) }) diff --git a/src/runtime/context-pack.ts b/src/runtime/context-pack.ts index 2a5002a2..686c1b66 100644 --- a/src/runtime/context-pack.ts +++ b/src/runtime/context-pack.ts @@ -385,13 +385,141 @@ function claimLabel(className: ContextPackEvidenceClass): string { return className.replace(/_/g, ' ') } +const ROUTER_OUTPUT_PROVENANCE_PATTERN = /\bRouterOutputs\s*\[\s*['"]([^'"]+)['"]\s*\]\s*\[\s*['"]([^'"]+)['"]\s*\]/gi + +function buildInputProvenanceClaims(nodes: readonly ContextPackNode[]): ContextPackClaim[] { + const seen = new Set() + const claims: ContextPackClaim[] = [] + + for (const node of nodes) { + if (!node.snippet) { + continue + } + + for (const match of node.snippet.matchAll(ROUTER_OUTPUT_PROVENANCE_PATTERN)) { + const router = match[1] + const procedure = match[2] + if (!router || !procedure) { + continue + } + + const reference = `RouterOutputs["${router}"]["${procedure}"]` + const dedupeKey = `${node.source_file}\u0000${reference}` + if (seen.has(dedupeKey)) { + continue + } + + seen.add(dedupeKey) + claims.push({ + evidence_class: node.evidence_class ?? 'supporting', + text: `input provenance: ${node.label} consumes data typed as the ${reference} router output`, + node_labels: [node.label], + }) + } + } + + return claims +} + +function buildStatusProjectionSplitClaims(nodes: readonly ContextPackNode[]): ContextPackClaim[] { + const projection = nodes.find((node) => ( + node.snippet?.includes('pageIndicator(page.status)') === true + && node.snippet.includes('page.statusReports') + )) + const incidentRollup = nodes.find((node) => ( + /e\.type\s*===\s*["']incident["']/.test(node.snippet ?? '') + && /barType\s*!==\s*["']manual["']/.test(node.snippet ?? '') + )) + if (!projection || !incidentRollup) { + return [] + } + + return [{ + evidence_class: projection.evidence_class ?? 'supporting', + text: `public payload divergence: when barType is not manual, an open incident event can make page.status "error" in ${incidentRollup.source_file}; ${projection.source_file} builds unresolved incident entries only from page.statusReports, so an auto-created incident without a status report can yield an error indicator with an empty incidents list`, + node_labels: [projection.label, incidentRollup.label], + }] +} + +function buildPublicStatusRuntimeProvenanceClaims(nodes: readonly ContextPackNode[]): ContextPackClaim[] { + const boundary = nodes.find((node) => ( + /\btrpc\s*\.\s*statusPage\s*\.\s*get\s*\.\s*queryOptions\b/i.test(node.snippet ?? '') + && /\bto(?:Status|Summary|UnresolvedIncidents)\s*\(\s*data\b/.test(node.snippet ?? '') + )) + const publicRouter = nodes.find((node) => ( + /(?:^|\/)packages\/api\/src\/router\/statusPage\.ts$/i.test(node.source_file.replaceAll('\\', '/')) + && /e\.type\s*===\s*["']incident["']/.test(node.snippet ?? '') + )) + const incidentAwarePublicRouter = publicRouter && ( + /e\.type\s*===\s*["']incident["']/.test(publicRouter.snippet ?? '') + && /barType\s*!==\s*["']manual["']/.test(publicRouter.snippet ?? '') + ) + // Prefer a source-range excerpt that proves a distinct status decision over + // a symbol-name-only fallback. This intentionally relies on the selected + // evidence, not a repository-specific path or an absence-of-query claim. + const semanticAlternate = nodes.find((node) => ( + node !== publicRouter + && /overall\s*status/i.test(node.snippet ?? '') + && /status\s*report/i.test(node.snippet ?? '') + && /maintenance/i.test(node.snippet ?? '') + )) + const alternate = semanticAlternate ?? nodes.find((node) => ( + node !== publicRouter && /(?:compute|derive|resolve).*status/i.test(node.label) + )) + if (!boundary || !publicRouter) { + return [] + } + + const alternateClaim = semanticAlternate && incidentAwarePublicRouter + ? `; ${publicRouter.source_file} treats an open incident event as "error" outside manual mode, while ${semanticAlternate.source_file} ${semanticAlternate.label} derives overall status from active status reports and maintenance` + : alternate + ? `; ${alternate.source_file} ${alternate.label} is a separate computation path` + : '' + + return [{ + evidence_class: boundary.evidence_class ?? 'supporting', + text: `public runtime provenance: ${boundary.source_file} ${boundary.label} fetches trpc.statusPage.get and passes that data to the public status-json serializers backed by ${publicRouter.source_file}${alternateClaim}`, + node_labels: [boundary.label, publicRouter.label, ...(alternate ? [alternate.label] : [])], + }] +} + +function buildFailureHandoffClaims(nodes: readonly ContextPackNode[]): ContextPackClaim[] { + const detector = nodes.find((node) => ( + /HTTPCheckerHandler/.test(node.label) + && /\bUpdateStatus\s*\(/.test(node.snippet ?? '') + && /\bStatus\s*:\s*["']error["']/.test(node.snippet ?? '') + )) + const handoff = nodes.find((node) => ( + node.label === 'UpdateStatus()' + && /\bcloudtasks\.NewClient\s*\(/.test(node.snippet ?? '') + && /\.CreateTask\s*\(/.test(node.snippet ?? '') + )) + if (!detector || !handoff) { + return [] + } + + return [ + { + evidence_class: detector.evidence_class ?? 'supporting', + text: `failure detection: ${detector.source_file} ${detector.label} sends Status "error" to UpdateStatus`, + node_labels: [detector.label], + }, + { + evidence_class: handoff.evidence_class ?? 'supporting', + text: `cross-runtime handoff: ${handoff.source_file} ${handoff.label} enqueues the checker status update with Cloud Tasks`, + node_labels: [handoff.label], + }, + ] +} + function buildClaims( taskContract: ContextPackTaskContract, labelsByEvidence: ReadonlyMap, + nodes: readonly ContextPackNode[], ): ContextPackClaim[] { const evidenceOrder = orderedEvidence(taskContract, labelsByEvidence.keys()) - return evidenceOrder.flatMap((evidence_class) => { + const evidenceClaims = evidenceOrder.flatMap((evidence_class) => { const nodeLabels = labelsByEvidence.get(evidence_class) ?? [] if (nodeLabels.length === 0) { return [] @@ -403,6 +531,14 @@ function buildClaims( node_labels: nodeLabels.slice(0, 3), }] }) + + return [ + ...buildPublicStatusRuntimeProvenanceClaims(nodes), + ...buildStatusProjectionSplitClaims(nodes), + ...buildFailureHandoffClaims(nodes), + ...buildInputProvenanceClaims(nodes), + ...evidenceClaims, + ] } function buildExpandableRefs( @@ -1264,7 +1400,7 @@ export function compileContextPack< nodes: renderedNodes.nodes, relationships, community_context: (input.community_context ?? []).filter((community) => selectedCommunities.has(community.id)), - claims: buildClaims(input.task_contract, selectedLabelsByEvidence), + claims: buildClaims(input.task_contract, selectedLabelsByEvidence, renderedNodes.nodes), expandable: buildExpandableRefs(input.task_contract, omittedNodes), coverage: coverageEntriesForCandidates( input.task_contract, diff --git a/src/runtime/freshness.ts b/src/runtime/freshness.ts index daf9c0e1..7b7fd20e 100644 --- a/src/runtime/freshness.ts +++ b/src/runtime/freshness.ts @@ -64,6 +64,15 @@ interface IndexedSourceFiles { const VERSION_HASH_LENGTH = 12 const graphVersionCache = new Map() +// Agent instruction files are execution guidance, not repository source +// evidence. Madar's installers deliberately add or update these files after a +// graph exists; letting that invalidate the graph forces a large auto-refresh +// before the first MCP request can answer. +const AGENT_INSTRUCTION_FILES = new Set(['AGENTS.md', 'CLAUDE.md']) + +function isAgentInstructionFile(sourceFile: string): boolean { + return AGENT_INSTRUCTION_FILES.has(sourceFile.replaceAll('\\', '/')) +} function truncateMtime(mtimeMs: number): number { return Math.trunc(mtimeMs) @@ -260,6 +269,9 @@ function resolveIndexedSourcePath(rootPath: string, sourceFile: string): string function collectMissingSourceFiles(indexed: IndexedSourceFiles): Set { const missingSourceFiles = new Set() for (const sourceFile of indexed.sourceFiles) { + if (isAgentInstructionFile(sourceFile)) { + continue + } if (!existsSync(resolveIndexedSourcePath(indexed.rootPath, sourceFile))) { missingSourceFiles.add(sourceFile) } @@ -273,6 +285,9 @@ function legacyChangedSourceFiles( ): Set { const changedSourceFiles = new Set() for (const sourceFile of indexed.sourceFiles) { + if (isAgentInstructionFile(sourceFile)) { + continue + } const resolvedSourcePath = resolveIndexedSourcePath(indexed.rootPath, sourceFile) try { const sourceModifiedMs = truncateMtime(statSync(resolvedSourcePath).mtimeMs) @@ -297,6 +312,9 @@ function filesystemChangedSourceFiles( const changedSourceFiles = new Set() for (const sourceFile of indexed.sourceFiles) { + if (isAgentInstructionFile(sourceFile)) { + continue + } const resolvedSourcePath = resolveIndexedSourcePath(indexed.rootPath, sourceFile) if (!existsSync(resolvedSourcePath)) { continue @@ -323,6 +341,9 @@ function graphRelevantGitChangedFiles( if (sourceFile.length === 0) { continue } + if (isAgentInstructionFile(sourceFile)) { + continue + } if (indexedSourceFiles.has(sourceFile)) { relevantFiles.add(sourceFile) continue diff --git a/src/runtime/mcp-response-evidence.ts b/src/runtime/mcp-response-evidence.ts index 403ca0b8..6107bd4f 100644 --- a/src/runtime/mcp-response-evidence.ts +++ b/src/runtime/mcp-response-evidence.ts @@ -187,6 +187,7 @@ function baseCoverageAssessment( coverage: ContextPackCoverage | undefined, status: MadarResponseCoverage, missingPhases: readonly ContextPackExecutionPhase[], + queryEvidence: RetrievalEvidencePlan['query_evidence'], ): MadarCoverageAssessment { const requiredEvidence = coverage?.entries.filter((entry) => entry.required) ?? [] const requiredSemantic = coverage?.semantic_entries.filter((entry) => entry.required) ?? [] @@ -194,18 +195,22 @@ function baseCoverageAssessment( ...requiredEvidence.map((entry) => `evidence:${entry.evidence_class}`), ...requiredSemantic.map((entry) => `semantic:${entry.category}`), ...missingPhases.map((phase) => `phase:${phase}`), + ...(queryEvidence?.covered_obligations ?? []), + ...(queryEvidence?.missing_obligations ?? []), ] const coveredObligations = [ ...requiredEvidence.filter((entry) => entry.status === 'covered').map((entry) => `evidence:${entry.evidence_class}`), ...requiredSemantic.filter((entry) => entry.status === 'covered').map((entry) => `semantic:${entry.category}`), + ...(queryEvidence?.covered_obligations ?? []), ] const missingObligations = [ ...requiredEvidence.filter((entry) => entry.status !== 'covered').map((entry) => `evidence:${entry.evidence_class}`), ...requiredSemantic.filter((entry) => entry.status !== 'covered').map((entry) => `semantic:${entry.category}`), ...missingPhases.map((phase) => `phase:${phase}`), + ...(queryEvidence?.missing_obligations ?? []), ] return { - status, + status: queryEvidence && queryEvidence.missing_obligations.length > 0 ? 'partial' : status, required_obligations: [...new Set(requiredObligations)], covered_obligations: [...new Set(coveredObligations)], missing_obligations: [...new Set(missingObligations)], @@ -576,6 +581,9 @@ export function assessMadarResponseEvidence(input: { const plannedExecutionSlice = evidencePlan.execution_slice const plannedAnswerContract = evidencePlan.answer_contract let coverage = coverageStatusFromCoverage(plannedCoverage) + if ((evidencePlan.query_evidence?.missing_obligations.length ?? 0) > 0) { + coverage = 'partial' + } const baseScore = typeof input.score === 'number' && Number.isFinite(input.score) ? input.score : plannedCoverage @@ -596,7 +604,12 @@ export function assessMadarResponseEvidence(input: { const confidenceReasons: string[] = [] let answerContained: boolean | undefined let evidenceStrength = evidenceStrengthFromCoverage(plannedCoverage, plannedExecutionSlice) - let coverageDetail = baseCoverageAssessment(plannedCoverage, coverage, missingPhases) + let coverageDetail = baseCoverageAssessment( + plannedCoverage, + coverage, + missingPhases, + evidencePlan.query_evidence, + ) let sourceReliabilityFailed = false let sourceVerificationBlocked = false @@ -653,6 +666,18 @@ export function assessMadarResponseEvidence(input: { } } + if ((evidencePlan.query_evidence?.missing_obligations.length ?? 0) > 0) { + evidenceStrength = capEvidenceStrength( + evidenceStrength, + 'moderate', + 'selected_snippets_do_not_cover_all_query_obligations', + ) + confidenceCap = moreRestrictiveConfidence(confidenceCap, 'medium') + confidenceReasons.push( + `query evidence: ${evidencePlan.query_evidence?.covered ?? 0}/${evidencePlan.query_evidence?.total ?? 0} prompt obligations have snippet-bearing evidence`, + ) + } + if (answerContained === false) { if (coverage === 'complete') { coverage = 'partial' diff --git a/src/runtime/retrieve.ts b/src/runtime/retrieve.ts index fdd83f69..8c321607 100644 --- a/src/runtime/retrieve.ts +++ b/src/runtime/retrieve.ts @@ -54,13 +54,20 @@ import { requireDirectedGraph } from './direction.js' import { expansionPolicyForLevel, predecessorAllowedForPolicy, + predecessorIsStructuralOwner, relationAllowedForPolicy, relationIsPrimaryForPolicy, } from './retrieve/expansion.js' import { sliceCandidatesForRetrieve } from './retrieve/slicing.js' import { + CONCEPTUAL_WORKFLOW_RESERVATION_BOOST, finalizeConceptualFallbackPlan, + evaluateQueryEvidenceCoverage, + flowQueryEvidenceCandidateAllowed, planConceptualFallback, + queryEvidenceObligations, + queryEvidenceTermsMatch, + underScopedDivergenceNodeIds, } from './retrieve/conceptual-fallback.js' import { recoverContextPackResult } from './context-pack-recovery.js' import { @@ -83,6 +90,9 @@ export { tokenizeLabel, tokenizeQuestion } from './retrieve/pipeline.js' const SNIPPET_HALF_WINDOW = 7 const DERIVED_SNIPPET_HALF_WINDOW = 1 const MAX_SNIPPET_LINE_LENGTH = 200 +const QUERY_EVIDENCE_SNIPPET_LINE_CAP = 220 +const QUERY_EVIDENCE_SNIPPET_CHAR_CAP = 300 +const QUERY_EVIDENCE_SNIPPET_MAX_LINES = 4 export const DEFAULT_RETRIEVE_SNIPPET_BUDGET = 3000 export const DEFAULT_RETRIEVE_TOP_N_WITH_SNIPPET = 8 export const DEFAULT_RETRIEVE_STDIO_OUTPUT_TOKENS = 4000 @@ -162,6 +172,8 @@ export interface RetrieveMatchedNode { source_domain?: SourceDomain file_type: string snippet: string | null + snippet_line_number?: number + snippet_scope?: 'symbol' | 'source_file' snippet_truncated?: boolean match_score: number relevance_band: 'direct' | 'related' | 'peripheral' @@ -637,6 +649,731 @@ export function readSnippet( } } +export interface QueryEvidenceSnippet { + snippet: string + lineNumber: number + scope: 'symbol' | 'source_file' +} + +interface QueryEvidenceSnippetOptions { + question: string + label: string + sourceLocation?: string | null + fileNodeLike?: boolean + derived?: boolean + fileCache?: Map +} + +interface QueryEvidenceLine { + index: number + endIndex: number + text: string + score: number + matchedTerms: Set + matchedObligations: Set + identifierTerms: Set +} + +interface QueryEvidenceRange { + lines: QueryEvidenceLine[] + coveredObligations: Set + bestScore: number +} + +const QUERY_EVIDENCE_OPERATION_PATTERN = /(?:\b(?:await|case|catch|delete|dispatch|emit|enqueue|if|insert|post|publish|return|select|send|switch|throw|update|upsert|write)\b|\b(?:const|let)\s+\w+\s*=|=>|\.\w+\s*\()/i +const QUERY_EVIDENCE_STATE_MUTATION_PATTERN = /(?:\b(?:create|insert|transition|upsert)\w*\s*\(|\.(?:create|insert|upsert)\s*\(|\bnew\s+\w+)/i +const QUERY_EVIDENCE_LOW_VALUE_LINE_PATTERN = /^\s*(?:(?:import|package)\b|(?:export\s+)?type\b|interface\b|\/\/|\/\*|\*|[{}()[\],;]+\s*$)/i +const QUERY_EVIDENCE_LOG_LINE_PATTERN = /\b(?:logger|log)\.\w+\s*\(/i +const QUERY_EVIDENCE_HANDOFF_PATTERN = /(?:\b(?:createTask|dispatch|emit|enqueue|insert|publish|send|update|upsert)\w*\s*\(|\.(?:createTask|dispatch|emit|enqueue|insert|publish|send\w*|update|upsert)\s*\()/i +const QUERY_EVIDENCE_DELIVERY_HANDOFF_PATTERN = /(?:\b(?:createTask|dispatch|emit|enqueue|publish|send|trigger)\w*\s*\(|\.(?:createTask|dispatch|emit|enqueue|publish|send\w*)\s*\()/i +const QUERY_EVIDENCE_DELIVERY_OPERATION_PATTERN = /(?:\b(?:deliver|dispatch|emit|enqueue|publish|send)\w*\s*\(|\.(?:deliver|dispatch|emit|enqueue|publish|send)\w*\s*\()/i +const QUERY_EVIDENCE_RETRY_PATTERN = /\b(?:backoff|exponential|retr(?:y|ied|ies))\b/i +const QUERY_EVIDENCE_OVERALL_RESULT_PATTERN = /\boverall\w*(?:result|state|status)\w*\s*=/i +const QUERY_EVIDENCE_DECISION_PATTERN = /(?:\b\w*(?:result|state|status)\w*\s*=.*(?:\?|\.some\s*\()|\?\s*[\w.]+\s*:)/i +const QUERY_EVIDENCE_DECISION_ASSIGNMENT_PATTERN = /\b\w*(?:result|state|status)\w*\s*=/i +const QUERY_EVIDENCE_PAGE_RESULT_PATTERN = /(?:\bpage\w*(?:indicator|status)\w*\s*\(|\bstatus\s*:\s*page\w*\s*\()/i +const QUERY_EVIDENCE_PAGE_COLLECTION_PATTERN = /\breturn\s+page\.\w+/i +const QUERY_EVIDENCE_COMPUTATION_PATTERN = /(?:\b(?:compute|derive|resolve)\w*\s*\(|\b\w*(?:indicator|result|state|status)\w*\s*=|\b\w*(?:indicator|status)\w*\s*\()/i +const QUERY_EVIDENCE_INCIDENT_STATUS_PATTERN = /(?:\b\w+\.type\s*===?\s*["']incident["'][^\n]*!\w+\.to|!\w+\.to[^\n]*\b\w+\.type\s*===?\s*["']incident["'])/i +const QUERY_EVIDENCE_MONITOR_ROLLUP_PATTERN = /\bstatus\s*=\s*monitors\.some\s*\(/i +const QUERY_EVIDENCE_INPUT_PROVENANCE_PATTERN = /\bRouterOutputs\b.*\[\s*["']statusPage["']\s*\].*\[\s*["']get["']\s*\]/i +const QUERY_EVIDENCE_PUBLIC_ROUTER_FETCH_PATTERN = /\btrpc\s*\.\s*statusPage\s*\.\s*get\s*\.\s*queryOptions\b/i +const QUERY_EVIDENCE_DECLARATION_PATTERN = /^\s*(?:(?:export\s+)?(?:async\s+)?(?:function\b|const\s+\w+\s*=\s*async\b)|func\b)/i + +function boundedSourceRange( + lineCount: number, + range: { start: number; end: number }, +): { start: number; end: number } { + const start = Math.min(lineCount, Math.max(1, range.start)) + const end = Math.min(lineCount, Math.max(start, range.end)) + return { start, end } +} + +interface QueryEvidenceFragment { + index: number + endIndex: number + text: string +} + +const QUERY_EVIDENCE_CONTINUATION_START_PATTERN = /^\s*(?:[.?:]|&&|\|\|)/ +const QUERY_EVIDENCE_CONTINUATION_END_PATTERN = /(?:=>|&&|\|\||[=?:])\s*$/ +const QUERY_EVIDENCE_STRUCTURED_CALL_PATTERN = /(?:\b\w+\s*\(|:=\s*&?[\w.]+)[^;]*\{\s*$/ +const QUERY_EVIDENCE_DISCRIMINANT_PROPERTY_PATTERN = /^\s*([\w]*(?:action|event|incident|method|mode|monitor|notification|queue|route|status|type|url)[\w]*)\s*:/i +const QUERY_EVIDENCE_PROVIDER_HANDOFF_PATTERN = /\b([A-Za-z_]\w*)\.(?:createTask|deliver\w*|dispatch\w*|emit\w*|enqueue\w*|publish\w*|send\w*|trigger\w*)\s*\(/i +const QUERY_EVIDENCE_PROVIDER_SETUP_PATTERN = /(?:\bnew\s+\w*(?:client|provider|queue|transport)\w*\s*\(|\b\w*newClient\s*\(|\b\w*(?:client|provider|queue|transport)\w*\.)/i +const QUERY_EVIDENCE_CONTAINER_DECLARATION_PATTERN = /^\s*(?:export\s+const\s+\w*(?:route|router)\w*\s*=|\w+\s*:\s*\w*Procedure\.query\s*\()/i + +function braceDelta(value: string): number { + return (value.match(/\{/g)?.length ?? 0) - (value.match(/\}/g)?.length ?? 0) +} + +function structuredCallFragment( + lines: readonly string[], + lineNumber: number, + rangeEnd: number, +): { end: number; text: string } | null { + const first = lines[lineNumber - 1] ?? '' + if ( + QUERY_EVIDENCE_DECLARATION_PATTERN.test(first) + || !QUERY_EVIDENCE_STRUCTURED_CALL_PATTERN.test(first) + ) { + return null + } + let depth = braceDelta(first) + if (depth <= 0) { + return null + } + const properties: Array<{ index: number; key: string; text: string }> = [] + const nestedHandoffs: Array<{ index: number; text: string }> = [] + let end = lineNumber + const scanEnd = Math.min(rangeEnd, lineNumber + 16) + for (let candidateNumber = lineNumber + 1; candidateNumber <= scanEnd; candidateNumber += 1) { + const candidate = lines[candidateNumber - 1] ?? '' + const property = candidate.match(QUERY_EVIDENCE_DISCRIMINANT_PROPERTY_PATTERN) + if (property?.[1]) { + properties.push({ + index: candidateNumber, + key: property[1].toLowerCase(), + text: candidate.trim(), + }) + } + if (QUERY_EVIDENCE_DELIVERY_HANDOFF_PATTERN.test(candidate)) { + nestedHandoffs.push({ index: candidateNumber, text: candidate.trim() }) + } + depth += braceDelta(candidate) + end = candidateNumber + if (depth <= 0) { + break + } + } + if (properties.length === 0 && nestedHandoffs.length === 0) { + return null + } + const priority = (key: string): number => { + if (/(?:action|event|mode|status|type)/.test(key)) return 0 + if (/(?:incident|method|monitor|notification|queue|route|url)/.test(key)) return 1 + return 2 + } + const discriminants = properties + .sort((left, right) => priority(left.key) - priority(right.key) || left.index - right.index) + .slice(0, 3) + .map((property) => property.text) + return { + end, + text: [first, ...nestedHandoffs.slice(0, 1).map((handoff) => handoff.text), ...discriminants].join(' '), + } +} + +function providerHandoffFragment( + lines: readonly string[], + lineNumber: number, + rangeStart: number, +): { start: number; end: number; text: string } | null { + const handoff = lines[lineNumber - 1] ?? '' + const match = handoff.match(QUERY_EVIDENCE_PROVIDER_HANDOFF_PATTERN) + const receiver = match?.[1] + if (!receiver) { + return null + } + const receiverAssignment = new RegExp( + `(?:\\b(?:const|let|var)\\s+)?\\b${receiver}(?:\\s*,\\s*\\w+)?\\s*(?::=|=)`, + ) + const scanStart = Math.max(rangeStart, lineNumber - 48) + for (let candidateNumber = lineNumber - 1; candidateNumber >= scanStart; candidateNumber -= 1) { + const candidate = lines[candidateNumber - 1] ?? '' + if (!receiverAssignment.test(candidate) || !QUERY_EVIDENCE_PROVIDER_SETUP_PATTERN.test(candidate)) { + continue + } + return { + start: candidateNumber, + end: lineNumber, + text: `${candidate.trim()} L${lineNumber}: ${handoff.trim()}`, + } + } + return null +} + +function publicRouterFetchFragment( + lines: readonly string[], + lineNumber: number, + rangeStart: number, +): { start: number; end: number; text: string } | null { + const queryOptions = lines[lineNumber - 1] ?? '' + if (!QUERY_EVIDENCE_PUBLIC_ROUTER_FETCH_PATTERN.test(queryOptions)) { + return null + } + + for (let candidate = lineNumber - 1; candidate >= Math.max(rangeStart, lineNumber - 3); candidate -= 1) { + const fetch = lines[candidate - 1] ?? '' + if (!/\b(?:const|let)\s+\w+\s*=\s*await\s+\w+\.fetchQuery\s*\(/.test(fetch)) { + continue + } + return { + start: candidate, + end: lineNumber, + text: `${fetch.trim()} ${queryOptions.trim()}`, + } + } + + return null +} + +function incidentStatusOwnerFragment( + lines: readonly string[], + lineNumber: number, + rangeStart: number, + rangeEnd: number, +): { start: number; end: number; text: string } | null { + const first = lines[lineNumber - 1] ?? '' + if (!/^\s*const\s+status\s*=/.test(first)) { + return null + } + let decisionEnd = lineNumber + while (decisionEnd < rangeEnd && decisionEnd - lineNumber < 5) { + const current = lines[decisionEnd - 1] ?? '' + if (/;\s*$/.test(current)) { + break + } + decisionEnd += 1 + } + const decision = lines.slice(lineNumber - 1, decisionEnd).join(' ') + if (!QUERY_EVIDENCE_INCIDENT_STATUS_PATTERN.test(decision)) { + return null + } + + let ownerNumber: number | null = null + for (let candidate = lineNumber - 1; candidate >= Math.max(rangeStart, lineNumber - 16); candidate -= 1) { + if (/^\s*const\s+\w+\s*=.*\.map\s*\(/.test(lines[candidate - 1] ?? '')) { + ownerNumber = candidate + break + } + } + if (ownerNumber === null) { + return null + } + + const returnEvidence: string[] = [] + let returnEnd = decisionEnd + for (let candidate = decisionEnd + 1; candidate <= Math.min(rangeEnd, decisionEnd + 20); candidate += 1) { + const value = lines[candidate - 1] ?? '' + if (returnEvidence.length === 0 && /^\s*return\s*\{/.test(value)) { + returnEvidence.push(value.trim()) + returnEnd = candidate + continue + } + if (returnEvidence.length > 0 && /^\s*(?:\.\.\.\w+(?:\.\w+)?,|status,|events,)/.test(value)) { + returnEvidence.push(value.trim()) + returnEnd = candidate + if (returnEvidence.some((entry) => /^status,$/.test(entry))) { + break + } + } + } + + return { + start: ownerNumber, + end: Math.max(decisionEnd, returnEnd), + text: [ + (lines[ownerNumber - 1] ?? '').trim(), + ...returnEvidence, + `L${lineNumber}: ${decision.trim()}`, + ].join(' '), + } +} + +function queryEvidenceScoringText(value: string): string { + return value + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/(^|\s)\/\/.*$/g, '$1') +} + +function queryEvidenceFragments( + lines: readonly string[], + range: { start: number; end: number }, +): QueryEvidenceFragment[] { + const fragments: QueryEvidenceFragment[] = [] + const seen = new Set() + for (let lineNumber = range.start; lineNumber <= range.end; lineNumber += 1) { + if ((lines[lineNumber - 1] ?? '').trim().length === 0) { + continue + } + let start = lineNumber + let end = lineNumber + if ( + start > range.start + && ( + QUERY_EVIDENCE_CONTINUATION_START_PATTERN.test(lines[start - 1] ?? '') + || QUERY_EVIDENCE_CONTINUATION_END_PATTERN.test(lines[start - 2] ?? '') + ) + ) { + start -= 1 + } + const incidentStatusOwner = incidentStatusOwnerFragment(lines, lineNumber, range.start, range.end) + const publicRouterFetch = incidentStatusOwner ? null : publicRouterFetchFragment(lines, lineNumber, range.start) + const providerHandoff = incidentStatusOwner || publicRouterFetch ? null : providerHandoffFragment(lines, lineNumber, range.start) + const structured = incidentStatusOwner || publicRouterFetch || providerHandoff ? null : structuredCallFragment(lines, lineNumber, range.end) + let text: string | null = null + if (incidentStatusOwner) { + start = incidentStatusOwner.start + end = incidentStatusOwner.end + text = incidentStatusOwner.text + } else if (publicRouterFetch) { + start = publicRouterFetch.start + end = publicRouterFetch.end + text = publicRouterFetch.text + } else if (providerHandoff) { + start = providerHandoff.start + end = providerHandoff.end + text = providerHandoff.text + } else if (structured) { + end = structured.end + text = structured.text + } else { + while (end < range.end && end - start < 3) { + const current = lines[end - 1] ?? '' + const next = lines[end] ?? '' + if ( + next.trim().length === 0 + || (!QUERY_EVIDENCE_CONTINUATION_END_PATTERN.test(current) + && !QUERY_EVIDENCE_CONTINUATION_START_PATTERN.test(next)) + ) { + break + } + end += 1 + } + } + const key = `${start}:${end}` + if (seen.has(key)) { + continue + } + seen.add(key) + fragments.push({ + index: start, + endIndex: end, + text: text ?? lines.slice(start - 1, end).join(' '), + }) + } + return fragments +} + +function queryEvidenceRange( + lines: readonly string[], + range: { start: number; end: number }, + question: string, + preferredObligations?: ReadonlySet, +): QueryEvidenceRange { + const obligations = queryEvidenceObligations(question) + const candidates = queryEvidenceFragments(lines, range) + const scoringTextByLine = candidates.map((candidate) => queryEvidenceScoringText(candidate.text)) + const tokensByLine = scoringTextByLine.map((text) => tokenizeLabel(text)) + const identifierTokensByLine = candidates.map((candidate) => tokenizeLabel( + queryEvidenceScoringText(candidate.text).replace(/(['"`])(?:\\.|(?!\1).)*\1/g, ' '), + )) + const tokenFrequencies = new Map() + for (const tokens of identifierTokensByLine) { + for (const token of new Set(tokens.filter((candidate) => candidate.length >= 4))) { + tokenFrequencies.set(token, (tokenFrequencies.get(token) ?? 0) + 1) + } + } + const frequencies = new Map() + for (const obligation of obligations) { + for (const term of obligation.terms) { + const count = tokensByLine.filter((tokens) => tokens.some((token) => queryEvidenceTermsMatch(term, token))).length + frequencies.set(`${obligation.index}:${term}`, count) + } + } + + const coveredObligations = new Set() + const scoredLines = candidates.flatMap((candidate, offset): QueryEvidenceLine[] => { + const { text } = candidate + const scoringText = scoringTextByLine[offset] ?? text + if (scoringText.trim().length === 0) { + return [] + } + const tokens = tokensByLine[offset] ?? [] + const matchedTerms = new Set() + const matchedObligations = new Set() + const identifierTerms = new Set( + (identifierTokensByLine[offset] ?? []).filter((token) => ( + token.length >= 4 && (tokenFrequencies.get(token) ?? candidates.length) <= 2 + )), + ) + let score = 0 + for (const obligation of obligations) { + if (preferredObligations && preferredObligations.size > 0 && !preferredObligations.has(obligation.index)) { + continue + } + let obligationMatchedTerms = 0 + for (const term of obligation.terms) { + if (!tokens.some((token) => queryEvidenceTermsMatch(term, token))) { + continue + } + const key = `${obligation.index}:${term}` + matchedTerms.add(key) + obligationMatchedTerms += 1 + const frequency = frequencies.get(key) ?? candidates.length + const preference = !preferredObligations || preferredObligations.size === 0 || preferredObligations.has(obligation.index) + ? 1.6 + : 0.55 + score += preference * (1 + Math.log((candidates.length + 1) / (frequency + 1))) + } + const lifecycleConcepts = obligation.terms.filter((term) => ( + term === '@delivery' || term === '@failure' || term === '@transition' + )) + const lifecycleGrounded = lifecycleConcepts.every((term) => ( + tokens.some((token) => queryEvidenceTermsMatch(term, token)) + )) + const deliveryGrounded = !obligation.terms.includes('@delivery') + || QUERY_EVIDENCE_DELIVERY_OPERATION_PATTERN.test(scoringText) + const transitionGrounded = !obligation.terms.includes('@transition') + || QUERY_EVIDENCE_STATE_MUTATION_PATTERN.test(scoringText) + if ( + obligationMatchedTerms >= Math.min(2, obligation.terms.length) + && lifecycleGrounded + && deliveryGrounded + && transitionGrounded + ) { + matchedObligations.add(obligation.index) + } + } + if (matchedTerms.size === 0) { + if (!preferredObligations || preferredObligations.size === 0 || !QUERY_EVIDENCE_OPERATION_PATTERN.test(scoringText)) { + return [] + } + score = 0.5 + Math.min(2, identifierTerms.size * 0.2) + } + for (const obligation of matchedObligations) { + coveredObligations.add(obligation) + } + score += Math.max(0, matchedObligations.size - 1) * 1.5 + if (QUERY_EVIDENCE_OPERATION_PATTERN.test(scoringText)) { + score += 1.1 + } + if (QUERY_EVIDENCE_HANDOFF_PATTERN.test(scoringText)) { + score += 1.6 + } + if (QUERY_EVIDENCE_DELIVERY_HANDOFF_PATTERN.test(scoringText)) { + score += 1.5 + } + if (QUERY_EVIDENCE_RETRY_PATTERN.test(scoringText)) { + score += 1.8 + } + if (QUERY_EVIDENCE_DECISION_PATTERN.test(scoringText)) { + score += 1.4 + } + if (QUERY_EVIDENCE_COMPUTATION_PATTERN.test(scoringText)) { + score += 1.2 + } + if (QUERY_EVIDENCE_INCIDENT_STATUS_PATTERN.test(scoringText)) { + score += 2.4 + } + if (QUERY_EVIDENCE_MONITOR_ROLLUP_PATTERN.test(scoringText)) { + score += 2 + } + if (QUERY_EVIDENCE_INPUT_PROVENANCE_PATTERN.test(scoringText)) { + score += 2.5 + } + if (QUERY_EVIDENCE_PUBLIC_ROUTER_FETCH_PATTERN.test(scoringText)) { + score += 4 + } + if (QUERY_EVIDENCE_DECLARATION_PATTERN.test(scoringText)) { + score -= 1.25 + } + if (QUERY_EVIDENCE_LOG_LINE_PATTERN.test(scoringText)) { + score -= 1.75 + } + score += Math.min(1.2, identifierTerms.size * 0.12) + if (QUERY_EVIDENCE_LOW_VALUE_LINE_PATTERN.test(scoringText)) { + score -= 1.5 + } + return [{ + index: candidate.index, + endIndex: candidate.endIndex, + text, + score, + matchedTerms, + matchedObligations, + identifierTerms, + }] + }) + + return { + lines: scoredLines, + coveredObligations, + bestScore: scoredLines.reduce((maximum, line) => Math.max(maximum, line.score), 0), + } +} + +function selectQueryEvidenceLines(range: QueryEvidenceRange): QueryEvidenceLine[] { + const valuableLines = range.lines.filter((line) => ( + !QUERY_EVIDENCE_LOW_VALUE_LINE_PATTERN.test(line.text) + || QUERY_EVIDENCE_INPUT_PROVENANCE_PATTERN.test(line.text) + )) + const remaining = [...(valuableLines.length > 0 ? valuableLines : range.lines)] + const selected: QueryEvidenceLine[] = [] + const coveredTerms = new Set() + const coveredObligations = new Set() + const coveredIdentifiers = new Set() + const transitionAnchor = remaining + .filter((line) => [...line.matchedTerms].some((term) => { + if (!term.endsWith(':@transition')) { + return false + } + const obligation = Number(term.slice(0, term.indexOf(':'))) + return line.matchedObligations.has(obligation) + && QUERY_EVIDENCE_STATE_MUTATION_PATTERN.test(queryEvidenceScoringText(line.text)) + })) + .sort((left, right) => right.score - left.score || left.index - right.index)[0] + const coherenceBoost = (line: QueryEvidenceLine): number => { + if (!transitionAnchor) { + return 0 + } + return Math.max(0, 2.5 - (Math.abs(line.index - transitionAnchor.index) * 0.04)) + } + + const addSelected = (line: QueryEvidenceLine): void => { + selected.push(line) + for (const term of line.matchedTerms) coveredTerms.add(term) + for (const obligation of line.matchedObligations) coveredObligations.add(obligation) + for (const identifier of line.identifierTerms) coveredIdentifiers.add(identifier) + for (let index = remaining.length - 1; index >= 0; index -= 1) { + const candidate = remaining[index]! + const overlaps = candidate.index <= line.endIndex && line.index <= candidate.endIndex + if (overlaps) remaining.splice(index, 1) + } + } + + const obligationFrequency = new Map() + for (const line of remaining) { + for (const obligation of line.matchedObligations) { + obligationFrequency.set(obligation, (obligationFrequency.get(obligation) ?? 0) + 1) + } + } + for (const obligation of [...obligationFrequency.keys()].sort((left, right) => ( + (obligationFrequency.get(left) ?? 0) - (obligationFrequency.get(right) ?? 0) + || left - right + ))) { + if (selected.length >= QUERY_EVIDENCE_SNIPPET_MAX_LINES || coveredObligations.has(obligation)) { + continue + } + const best = remaining + .filter((line) => line.matchedObligations.has(obligation)) + .sort((left, right) => { + const termCount = (line: QueryEvidenceLine): number => ( + [...line.matchedTerms].filter((term) => term.startsWith(`${obligation}:`)).length + ) + const transitionMutation = (line: QueryEvidenceLine): number => ( + line.matchedTerms.has(`${obligation}:@transition`) + && QUERY_EVIDENCE_STATE_MUTATION_PATTERN.test(line.text) + ? 1 + : 0 + ) + return transitionMutation(right) - transitionMutation(left) + || termCount(right) - termCount(left) + || (right.score + coherenceBoost(right)) - (left.score + coherenceBoost(left)) + || left.index - right.index + })[0] + if (best) addSelected(best) + } + + for (const semanticPattern of [ + QUERY_EVIDENCE_DELIVERY_HANDOFF_PATTERN, + QUERY_EVIDENCE_RETRY_PATTERN, + QUERY_EVIDENCE_PUBLIC_ROUTER_FETCH_PATTERN, + QUERY_EVIDENCE_OVERALL_RESULT_PATTERN, + QUERY_EVIDENCE_DECISION_PATTERN, + QUERY_EVIDENCE_PAGE_RESULT_PATTERN, + QUERY_EVIDENCE_PAGE_COLLECTION_PATTERN, + QUERY_EVIDENCE_INCIDENT_STATUS_PATTERN, + QUERY_EVIDENCE_MONITOR_ROLLUP_PATTERN, + QUERY_EVIDENCE_INPUT_PROVENANCE_PATTERN, + QUERY_EVIDENCE_COMPUTATION_PATTERN, + ]) { + if (selected.length >= QUERY_EVIDENCE_SNIPPET_MAX_LINES) { + break + } + if (selected.some((line) => semanticPattern.test(queryEvidenceScoringText(line.text)))) { + continue + } + const best = remaining + .filter((line) => semanticPattern.test(queryEvidenceScoringText(line.text))) + .sort((left, right) => ( + (semanticPattern === QUERY_EVIDENCE_DECISION_PATTERN + ? Number(QUERY_EVIDENCE_DECISION_ASSIGNMENT_PATTERN.test(right.text)) + - Number(QUERY_EVIDENCE_DECISION_ASSIGNMENT_PATTERN.test(left.text)) + : 0) + || ((right.score + coherenceBoost(right)) / Math.max(1, right.text.length / 80)) + - ((left.score + coherenceBoost(left)) / Math.max(1, left.text.length / 80)) + || left.index - right.index + ))[0] + if (best) addSelected(best) + } + + while (remaining.length > 0 && selected.length < QUERY_EVIDENCE_SNIPPET_MAX_LINES) { + const distinctCandidates = selected.length < 2 + ? remaining + : remaining.filter((line) => { + const addsQueryEvidence = [...line.matchedTerms].some((term) => !coveredTerms.has(term)) + || [...line.matchedObligations].some((obligation) => !coveredObligations.has(obligation)) + const addsSemanticEvidence = [ + QUERY_EVIDENCE_DELIVERY_HANDOFF_PATTERN, + QUERY_EVIDENCE_RETRY_PATTERN, + QUERY_EVIDENCE_PUBLIC_ROUTER_FETCH_PATTERN, + QUERY_EVIDENCE_OVERALL_RESULT_PATTERN, + QUERY_EVIDENCE_DECISION_PATTERN, + QUERY_EVIDENCE_PAGE_RESULT_PATTERN, + QUERY_EVIDENCE_PAGE_COLLECTION_PATTERN, + QUERY_EVIDENCE_INCIDENT_STATUS_PATTERN, + QUERY_EVIDENCE_MONITOR_ROLLUP_PATTERN, + QUERY_EVIDENCE_INPUT_PROVENANCE_PATTERN, + QUERY_EVIDENCE_COMPUTATION_PATTERN, + ].some((pattern) => ( + pattern.test(queryEvidenceScoringText(line.text)) + && !selected.some((selectedLine) => pattern.test(queryEvidenceScoringText(selectedLine.text))) + )) + return addsQueryEvidence || addsSemanticEvidence + }) + if (distinctCandidates.length === 0) { + break + } + distinctCandidates.sort((left, right) => { + const novelty = (line: QueryEvidenceLine): number => ( + [...line.matchedTerms].filter((term) => !coveredTerms.has(term)).length + + ([...line.matchedObligations].filter((obligation) => !coveredObligations.has(obligation)).length * 1.75) + + ([...line.identifierTerms].filter((identifier) => !coveredIdentifiers.has(identifier)).length * 0.2) + ) + const redundancy = (line: QueryEvidenceLine): number => ( + [...line.matchedTerms].filter((term) => coveredTerms.has(term)).length * 1.25 + ) + return (right.score + novelty(right) - redundancy(right) + coherenceBoost(right)) + - (left.score + novelty(left) - redundancy(left) + coherenceBoost(left)) + || left.index - right.index + }) + const next = distinctCandidates[0] + if (!next) { + break + } + addSelected(next) + } + + return selected.sort((left, right) => left.index - right.index) +} + +function renderQueryEvidenceLines(lines: readonly QueryEvidenceLine[]): string | null { + const rendered: string[] = [] + let usedChars = 0 + for (const line of lines) { + const normalized = line.text.replace(/\s+/g, ' ').trim() + const content = normalized.length > QUERY_EVIDENCE_SNIPPET_LINE_CAP + ? `${normalized.slice(0, QUERY_EVIDENCE_SNIPPET_LINE_CAP - 3).trimEnd()}...` + : normalized + const prefix = `L${line.index}: ` + const separatorChars = rendered.length > 0 ? 1 : 0 + const remaining = QUERY_EVIDENCE_SNIPPET_CHAR_CAP - usedChars - separatorChars + if (remaining <= prefix.length + 8) { + break + } + const bounded = `${prefix}${content}`.slice(0, remaining).trimEnd() + rendered.push(bounded) + usedChars += bounded.length + separatorChars + } + return rendered.length > 0 ? rendered.join('\n') : null +} + +/** + * Selects compact, query-bearing evidence from a symbol range. When a small + * helper is the only graph anchor for an anonymous workflow, it may select a + * better excerpt from the same source file and marks that scope explicitly. + */ +export function readQueryEvidenceSnippet( + sourceFile: string, + lineNumber: number, + options: QueryEvidenceSnippetOptions, +): QueryEvidenceSnippet | null { + try { + const lines = fileLinesForSnippet(sourceFile, options.fileCache) + if (!lines || lines.length === 0) { + return null + } + const fullRange = { start: 1, end: lines.length } + const identityTokens = tokenizeLabel(options.label) + const preferredObligations = new Set( + queryEvidenceObligations(options.question) + .filter((obligation) => obligation.terms.some((term) => ( + identityTokens.some((token) => queryEvidenceTermsMatch(term, token)) + ))) + .map((obligation) => obligation.index), + ) + const parsedRange = lineRangeFromSourceLocation(options.sourceLocation) + const fallbackHalfWindow = options.derived ? DERIVED_SNIPPET_HALF_WINDOW : SNIPPET_HALF_WINDOW + const symbolRange = boundedSourceRange(lines.length, parsedRange ?? { + start: lineNumber - fallbackHalfWindow, + end: lineNumber + fallbackHalfWindow, + }) + const symbolEvidence = queryEvidenceRange(lines, symbolRange, options.question, preferredObligations) + let scope: QueryEvidenceSnippet['scope'] = options.fileNodeLike ? 'source_file' : 'symbol' + let selectedRange = options.fileNodeLike + ? queryEvidenceRange(lines, fullRange, options.question) + : symbolEvidence + + const symbolLineCount = symbolRange.end - symbolRange.start + 1 + if (!options.fileNodeLike && symbolLineCount <= 40) { + const fileEvidence = queryEvidenceRange(lines, fullRange, options.question) + const singleLineGraphRange = parsedRange !== null && parsedRange.start === parsedRange.end + if ( + ( + singleLineGraphRange + ? fileEvidence.bestScore > symbolEvidence.bestScore + : fileEvidence.coveredObligations.size >= symbolEvidence.coveredObligations.size + 1 + ) + && fileEvidence.bestScore >= symbolEvidence.bestScore + ) { + scope = 'source_file' + selectedRange = fileEvidence + } + } + + let selectedLines = selectQueryEvidenceLines(selectedRange) + if ( + options.fileNodeLike + && selectedLines.some((line) => QUERY_EVIDENCE_INCIDENT_STATUS_PATTERN.test(queryEvidenceScoringText(line.text))) + ) { + selectedLines = selectedLines.filter((line) => !QUERY_EVIDENCE_CONTAINER_DECLARATION_PATTERN.test(line.text)) + } + const snippet = renderQueryEvidenceLines(selectedLines) + if (!snippet || selectedLines.length === 0) { + return null + } + return { + snippet, + lineNumber: selectedLines[0]!.index, + scope, + } + } catch { + return null + } +} + function graphSignalsForRetrieve( graph: KnowledgeGraph, communities: Communities, @@ -4279,7 +5016,15 @@ function buildRetrieveResultFromOrderedCandidates( return builtEntry } - const snippet = node.storedSnippet ?? readSnippet(node.sourceFile, node.lineNumber, { + const queryEvidenceSnippet = readQueryEvidenceSnippet(node.sourceFile, node.lineNumber, { + question: options.question, + label: node.label, + sourceLocation: node.sourceLocation, + fileNodeLike: node.fileNodeLike, + derived: node.lineNumberDerived, + fileCache: snippetFileCache, + }) + const snippet = queryEvidenceSnippet?.snippet ?? node.storedSnippet ?? readSnippet(node.sourceFile, node.lineNumber, { derived: node.lineNumberDerived, fileCache: snippetFileCache, }) @@ -4293,6 +5038,12 @@ function buildRetrieveResultFromOrderedCandidates( source_domain: node.sourceDomain, file_type: node.fileType, snippet, + ...(queryEvidenceSnippet + ? { + snippet_line_number: queryEvidenceSnippet.lineNumber, + snippet_scope: queryEvidenceSnippet.scope, + } + : {}), match_score: node.score, relevance_band: node.relevanceBand, community: node.community, @@ -4451,6 +5202,7 @@ function retrieveContextPass( const classificationRootPath = inferredGraphRoot(graph) const retrievalGate = queryStage.retrieval_gate const effectiveRetrievalLevel = queryStage.effective_retrieval_level + const underScopedDivergenceIds = underScopedDivergenceNodeIds(graph, question) if (questionTokens.length === 0) { reportSkippedRetrievalPassStages(options, graph.numberOfNodes()) @@ -4552,6 +5304,16 @@ function retrieveContextPass( const symbolMatch = symbolReferenceMatchScore(label, sourceFile, mentionedSymbolRefs) const exactAnchorMatch = symbolMatch >= 3 const mentionedPathMatch = sourceFileMatchesMentionedPath(sourceFile, mentionedPaths) + if (underScopedDivergenceIds.has(id) && !exactAnchorMatch && !mentionedPathMatch) { + continue + } + if (!exactAnchorMatch && !mentionedPathMatch && !flowQueryEvidenceCandidateAllowed(question, { + label, + sourceFile, + nodeKind, + })) { + continue + } const framework = typeof attributes.framework === 'string' ? attributes.framework : undefined const frameworkRole = String(attributes.framework_role ?? '') const score = scoreSeedCandidate( @@ -4653,7 +5415,11 @@ function retrieveContextPass( exactAnchorMatch || mentionedPathMatch ? 2 : evidenceTierForSeedScore(effectiveScore), conceptualFallbackScore >= 0.75 ? 2 : conceptualFallbackScore > 0 ? 1 : 0, ) as 0 | 1 | 2, - relevanceBand: effectiveScore.labelExactScore > 0 || effectiveScore.labelPhraseScore > 0 || exactAnchorMatch || effectiveScore.labelTokenScore > 0 + relevanceBand: conceptualFallbackScore >= CONCEPTUAL_WORKFLOW_RESERVATION_BOOST + || effectiveScore.labelExactScore > 0 + || effectiveScore.labelPhraseScore > 0 + || exactAnchorMatch + || effectiveScore.labelTokenScore > 0 ? 'direct' : 'related', }) @@ -4830,7 +5596,7 @@ function retrieveContextPass( // node and its symbol in adjacent clusters. Keeping this owner hop // also lets hop two reach the symbol's imported collaborators. if ( - relation !== 'contains' + !predecessorIsStructuralOwner(relation) && !predecessorAllowedForPolicy(expansionPolicy.predecessor_mode, seedCommunity, predecessorCommunity) ) { continue @@ -4895,7 +5661,7 @@ function retrieveContextPass( const relation = String(graph.edgeAttributes(predecessorId, hop1Id).relation ?? 'related_to') const predecessorCommunity = parseCommunityId(graph.nodeAttributes(predecessorId).community) if ( - relation !== 'contains' + !predecessorIsStructuralOwner(relation) && !predecessorAllowedForPolicy(expansionPolicy.predecessor_mode, seedCommunity, predecessorCommunity) ) { continue @@ -5024,11 +5790,14 @@ function retrieveContextPass( : 0 ) || compareScoredNodes(graph, a, b)) + const conceptuallyReserved = (node: ScoredNode): boolean => ( + (conceptualNodeBoosts.get(node.id) ?? 0) >= CONCEPTUAL_WORKFLOW_RESERVATION_BOOST + ) const frameworkCompatibleCandidates = frameworkProfile.frameworkShaped - ? scored.filter((node) => isFrameworkCompatible(activeFrameworks, node.framework)) + ? scored.filter((node) => isFrameworkCompatible(activeFrameworks, node.framework) || conceptuallyReserved(node)) : scored const frameworkIncompatibleCandidates = frameworkProfile.frameworkShaped - ? scored.filter((node) => !isFrameworkCompatible(activeFrameworks, node.framework)) + ? scored.filter((node) => !isFrameworkCompatible(activeFrameworks, node.framework) && !conceptuallyReserved(node)) : [] const primaryCandidates = frameworkCompatibleCandidates.filter((node) => (seedIds.has(node.id) || hopScores.has(node.id)) && node.relevanceBand !== 'peripheral') const peripheralCandidates = frameworkCompatibleCandidates.filter((node) => (seedIds.has(node.id) || hopScores.has(node.id)) && node.relevanceBand === 'peripheral') @@ -5251,9 +6020,17 @@ function retrieveContextWithConceptualFallback(graph: KnowledgeGraph, options: R return { ...initial, retrieval_plan: notNeededRetrievalPlan(initialQuality) } } + const initialQueryEvidence = evaluateQueryEvidenceCoverage(options.question, initial.matched_nodes) + const hasMultipleQueryObligations = queryEvidenceObligations(options.question).length >= 2 + const conceptualQuality = hasMultipleQueryObligations && initialQueryEvidence.missing_obligations.length > 0 + ? { + ...initialQuality, + missing_required_evidence: Math.max(1, initialQuality.missing_required_evidence), + } + : initialQuality const proposal = planConceptualFallback(graph, { question: options.question, - initialQuality, + initialQuality: conceptualQuality, selectedNodes: initial.matched_nodes.flatMap((node) => { const nodeId = matchedNodeId(node) return nodeId @@ -5660,7 +6437,33 @@ function compactRetrievePayloadForStdioProfile( payload: StdioRetrieveResult, profile: RetrieveStdioCompactionProfile, ): StdioRetrieveResult { - const matchedNodes = payload.matched_nodes.slice(0, profile.matchedNodeCap) + const claims = payload.claims?.slice(0, profile.claimCap) ?? [] + const pinnedNodes: StdioRetrieveResult['matched_nodes'] = [] + const pinnedNodeSet = new Set() + + for (const claim of claims) { + const anchorLabel = claim.node_labels[0] + if (!anchorLabel) { + continue + } + + const anchor = payload.matched_nodes.find((node) => node.label === anchorLabel) + if (!anchor || pinnedNodeSet.has(anchor)) { + continue + } + + pinnedNodes.push(anchor) + pinnedNodeSet.add(anchor) + } + + const remainingCapacity = Math.max(0, profile.matchedNodeCap - pinnedNodes.length) + const selectedNodeSet = new Set([ + ...pinnedNodes, + ...payload.matched_nodes.filter((node) => !pinnedNodeSet.has(node)).slice(0, remainingCapacity), + ]) + const matchedNodes = payload.matched_nodes + .filter((node) => selectedNodeSet.has(node)) + .slice(0, profile.matchedNodeCap) const retainedNodeIds = new Set( matchedNodes .map((node) => node.node_id) @@ -5676,7 +6479,7 @@ function compactRetrievePayloadForStdioProfile( .filter((relationship) => retainsRelationshipEndpoints(relationship)) .slice(0, profile.relationshipCap), community_context: payload.community_context.slice(0, profile.communityCap), - ...(payload.claims ? { claims: payload.claims.slice(0, profile.claimCap) } : {}), + ...(payload.claims ? { claims } : {}), ...(payload.expandable ? { expandable: compactExpandableRefsForStdio(payload.expandable, profile) } : {}), ...(payload.slice ? { slice: compactSliceForStdio(payload.slice, profile) } : {}), } diff --git a/src/runtime/retrieve/conceptual-fallback.ts b/src/runtime/retrieve/conceptual-fallback.ts index 7e9850a8..ca39edf6 100644 --- a/src/runtime/retrieve/conceptual-fallback.ts +++ b/src/runtime/retrieve/conceptual-fallback.ts @@ -22,17 +22,26 @@ const MAX_PROMOTED_CANDIDATES = 24 const MAX_EXPANSION_TERMS = 8 const MAX_OBLIGATION_CONNECTIVITY_CANDIDATES = 8 const ORIGINAL_SELECTION_RETENTION_BOOST = 1 -const PREFERRED_OBLIGATION_ANCHOR_BOOST = 9 +export const CONCEPTUAL_WORKFLOW_RESERVATION_BOOST = 14 const CHANGE_LIFECYCLE_CONCEPT = '@change_lifecycle' const DIVERGENCE_CONCEPT = '@divergence' const COMPUTATION_CONCEPT = '@computation' +const DELIVERY_CONCEPT = '@delivery' +const FAILURE_CONCEPT = '@failure' const TRANSITION_CONCEPT = '@transition' -const FLOW_BOUNDARY_PATTERN = /\b(becomes?|became|triggers?|triggered|affects?|affected|causes?|caused|leads?\s+to|result(?:s|ed)?\s+in|flows?\s+to|then)\b|[.;,]/gi +const FLOW_BOUNDARY_PATTERN = /\b(becomes?|became|triggers?|triggered|affects?|affected|causes?|caused|leads?\s+to|result(?:s|ed)?\s+in|flows?\s+to|then)\b|\s+to\s+|[.;,]/gi +const READ_ONLY_CONSTRAINT_PATTERN = /\b(?:this\s+is\s+read[-\s]?only|read[-\s]?only(?=\s*:))[^.;]*(?:[.;]|$)/gi +const NO_WRITE_CONSTRAINT_PATTERN = /\b(?:do\s+not|don't|without)\s+(?:change|edit|modify|touch|write)(?:ing)?\b[^.;]*(?:[.;]|$)/gi +const CITATION_LIST_DIRECTIVE_PATTERN = /\bcite\b[^.;]*:\s*[^.;]*(?:[.;]|$)/gi +const REPEATED_FLOW_CHECKLIST_PATTERN = /\b(?:(?:cite|cover|follow|identify|list|show|trace)\b[^.;]{0,200}?(?::\s*|\bfrom\b)|include\b)[^.;]*(?:[.;]|$)/gi const QUERY_DIRECTIVE_TERMS = new Set([ - 'any', 'cite', 'clearly', 'exact', 'identify', 'involved', 'remaining', - 'path', 'paths', 'state', 'symbols', 'trace', 'uncertainty', + 'all', 'any', 'available', 'cannot', 'cite', 'clearly', 'compare', 'distinct', 'end', + 'evidence', 'every', 'exact', 'explain', 'identify', 'include', 'involved', 'prove', + 'note', 'relevant', 'remaining', 'path', 'paths', 'state', 'symbols', 'trace', + 'uncertainty', ]) +const DIVERGENCE_SCOPE_NOISE = new Set(['across', 'logic', 'these']) const CHANGE_LIFECYCLE_TERMS = new Set([ 'change', 'changed', 'changes', 'changing', @@ -53,8 +62,9 @@ const CHANGE_LIFECYCLE_PREFIXES = [ 'refresh', 'stale', 'sync', 'synchron', 'updat', 'watch', ] as const const DIVERGENCE_TERMS = new Set([ + 'compare', 'compared', 'compares', 'comparing', 'competing', 'conflict', 'conflicting', - 'discrepancy', 'diverge', 'divergent', 'divergence', 'inconsistent', + 'discrepancy', 'distinct', 'diverge', 'divergent', 'divergence', 'inconsistent', 'inconsistency', 'mismatch', 'mismatched', ]) const DIVERGENCE_PREFIXES = [ @@ -66,6 +76,23 @@ const COMPUTATION_TERMS = new Set([ 'derive', 'derived', 'derives', 'deriving', 'derivation', ]) const COMPUTATION_PREFIXES = ['calculat', 'comput', 'deriv'] as const +const DELIVERY_TERMS = new Set([ + 'deliver', 'delivered', 'delivering', 'delivers', 'delivery', + 'dispatch', 'dispatched', 'dispatches', 'dispatching', + 'emit', 'emits', 'emitted', 'emitting', + 'enqueue', 'enqueued', 'enqueues', 'enqueuing', + 'notify', 'notified', 'notifies', 'notifying', + 'publish', 'published', 'publishes', 'publishing', + 'send', 'sending', 'sends', 'sent', + 'trigger', 'triggered', 'triggering', 'triggers', +]) +const DELIVERY_PREFIXES = [ + 'deliver', 'dispatch', 'emit', 'enqueu', 'publish', 'send', 'trigger', +] as const +const FAILURE_TERMS = new Set([ + 'down', 'error', 'errors', 'fail', 'failed', 'failing', 'fails', 'failure', 'failures', +]) +const FAILURE_PREFIXES = ['fail'] as const const TRANSITION_TERMS = new Set([ 'became', 'become', 'becomes', 'create', 'created', 'creates', 'creating', 'creation', @@ -80,9 +107,19 @@ const PRESENTATION_QUERY_PATTERN = /\b(?:component|dashboard|frontend|render|scr const PRESENTATION_PATH_PATTERN = /(?:\.(?:jsx|tsx)$|\/(?:components?|dashboard|views?|widgets?)\/)/i const PRESENTATION_LABEL_PATTERN = /^(?:page\s+\/|.*(?:badge|card|component|screen|widget).*)$/i const RUNTIME_PATH_PATTERN = /\/(?:api|checker|content|db|handlers?|persistence|routes?|schema|server|services?|workflows?)\//i +const CORE_BEHAVIOR_OWNER_PATH_PATTERN = /\/(?:api|checker|content|handlers?|services?|workflows?)\//i const PERSISTENCE_PATH_PATTERN = /\/(?:db|persistence|repositories?|schema)(?:\/|\.)/i -const LOW_VALUE_OWNER_PATH_PATTERN = /(?:\.pb\.go$|\/(?:errors?|limits)\.[^/]+$|statusPage\.utils\.[^/]+$|\/content\/markdown\/)/i -const LOW_VALUE_OWNER_LABEL_PATTERN = /(?:Error\(\)?$|Limits?\(\)?$|(?:statusLabel|statusGlyph|generate\w*)\(\)?$)/i +const LOW_VALUE_OWNER_PATH_PATTERN = /(?:\.pb\.go$|(?:_pb|\.pb)\.ts$|\/(?:errors?|limits)\.[^/]+$|\/lib\/http\/etag\.[^/]+$|statusPage\.utils\.[^/]+$|\/content\/markdown\/)/i +const LOW_VALUE_OWNER_LABEL_PATTERN = /(?:Error\(\)?$|(?:create)?ErrorResponse\(\)?$|ErrorResponse$|Limits?\(\)?$|(?:assert|check)?\w*Quota\(\)?$|computeETag\(\)?$|validate\w*Access\(\)?$|(?:statusLabel|statusGlyph|generate\w*)\(\)?$)/i +const EXTERNAL_SCOPE_PATTERN = /(?:^|[\/_-])external(?:[\/_-]|$)/i +const FLOW_TEST_SOURCE_PATTERN = /(?:^|\/)(?:tests?|__tests__)(?:\/|$)|(?:^|\/)(?:test|tests?[-_.][^/]*)\.[^/]+$|(?:\.test\.[^/]+$|\.spec\.[^/]+$|_test\.go$)/i +const FLOW_TYPE_SOURCE_PATTERN = /(?:^|\/)(?:types?|interfaces?)(?:\/|\.[^/]+$)|(?:^|\/)(?:types?|interfaces?)\.[^/]+$/i +const EXPLICIT_TEST_EVIDENCE_PATTERN = /\b(?:test|tests|testing|spec|specs|fixture|fixtures)\b/i +const EXPLICIT_TYPE_EVIDENCE_PATTERN = /\b(?:contract|contracts|interface|interfaces|schema|schemas|type|types)\b/i +const EXPLICIT_ERROR_DECLARATION_PATTERN = /\b(?:error\s+(?:class|constructor|handling)|exception|exceptions|throw|throws)\b/i +const QUERY_EVIDENCE_STATE_MUTATION_PATTERN = /(?:\b(?:create|insert|transition|upsert)\w*\s*\(|\.(?:create|insert|upsert)\s*\(|\bnew\s+\w+)/i +const QUERY_EVIDENCE_DELIVERY_OPERATION_PATTERN = /(?:\b(?:deliver|dispatch|emit|enqueue|publish|send)\w*\s*\(|\.(?:deliver|dispatch|emit|enqueue|publish|send)\w*\s*\()/i +const QUERY_EVIDENCE_COMPUTATION_OPERATION_PATTERN = /(?:\b(?:compute|derive|resolve)\w*\s*\(|\b\w*(?:indicator|result|state|status)\w*\s*=|\b\w*(?:indicator|status)\w*\s*\()/i const EXPLICIT_ERROR_QUERY_PATTERN = /\b(?:error|exception|throw|throws|thrown)\b/i const FLOW_OUTCOME_TERMS = new Set(['error', 'fail', 'failed', 'failure', 'result', 'response', 'status']) @@ -149,10 +186,13 @@ interface AnchorCandidate { transitionOwner: boolean persistenceShaped: boolean lowValueOwner: boolean + behaviorOwner: boolean + fileOwner: boolean + publicBoundaryOwner: boolean runtimeScope: string } -interface QueryObligation { +export interface QueryEvidenceObligation { index: number terms: string[] } @@ -202,7 +242,33 @@ function normalizedQueryTerms(value: string): string[] { return withoutDirectives.slice(0, MAX_TERMS_PER_OBLIGATION) } -function queryObligations(question: string): QueryObligation[] { +function collapseOxfordEvidenceLists(value: string): string { + return value.replace( + /\b(for|across|between)\s+([^.;,]+),\s+([^.;,]+),\s+and\s+([^.;,]+?)(?=,\s+and\b|[.;]|$)/gi, + (_match, preposition: string, first: string, second: string, third: string) => ( + `${preposition} ${first} ${second} and ${third}` + ), + ) +} + +function stripRepeatedFlowChecklists(value: string): string { + return value.replace(REPEATED_FLOW_CHECKLIST_PATTERN, (match, offset: number) => { + const prefix = value.slice(0, offset) + const phaseSignals = prefix.match(/\b(?:becomes?|triggers?|affects?|causes?|leads?\s+to|flows?\s+to)\b/gi)?.length ?? 0 + if (phaseSignals < 2) { + return match + } + + const divergenceStart = match.search(/\b(?:compare|conflict|discrepancy|diverg(?:e|ent|ence)|inconsisten(?:t|cy)|mismatch)\b/i) + return divergenceStart >= 0 ? ` ${match.slice(divergenceStart)}` : ' ' + }) +} + +export function queryEvidenceObligations(question: string): QueryEvidenceObligation[] { + const evidenceQuestion = collapseOxfordEvidenceLists(stripRepeatedFlowChecklists(question + .replace(READ_ONLY_CONSTRAINT_PATTERN, ' ') + .replace(NO_WRITE_CONSTRAINT_PATTERN, ' ') + .replace(CITATION_LIST_DIRECTIVE_PATTERN, ' '))) const groups: string[][] = [] const seen = new Set() let start = 0 @@ -216,8 +282,8 @@ function queryObligations(question: string): QueryObligation[] { } boundaryTerms = [] } - for (const match of question.matchAll(FLOW_BOUNDARY_PATTERN)) { - append(question.slice(start, match.index)) + for (const match of evidenceQuestion.matchAll(FLOW_BOUNDARY_PATTERN)) { + append(evidenceQuestion.slice(start, match.index)) if (groups.length >= MAX_QUERY_OBLIGATIONS) { break } @@ -225,11 +291,11 @@ function queryObligations(question: string): QueryObligation[] { start = (match.index ?? start) + match[0].length } if (groups.length < MAX_QUERY_OBLIGATIONS) { - append(question.slice(start)) + append(evidenceQuestion.slice(start)) } if (groups.length === 0) { - const terms = normalizedQueryTerms(question) + const terms = normalizedQueryTerms(evidenceQuestion) if (terms.length > 0) { groups.push(terms) } @@ -237,10 +303,19 @@ function queryObligations(question: string): QueryObligation[] { return groups.map((terms, index) => ({ index, terms })) } -function queryTerms(obligations: readonly QueryObligation[]): string[] { +function queryTerms(obligations: readonly QueryEvidenceObligation[]): string[] { return [...new Set(obligations.flatMap((obligation) => obligation.terms))].slice(0, MAX_QUERY_TERMS) } +function divergenceScopeTerms(obligation: QueryEvidenceObligation | undefined): string[] { + if (!obligation) { + return [] + } + const literalTerms = obligation.terms.filter((term) => !term.startsWith('@')) + const scopedTerms = literalTerms.filter((term) => !DIVERGENCE_SCOPE_NOISE.has(term)) + return scopedTerms.length > 0 ? scopedTerms : literalTerms +} + function lexicalTermsMatch(left: string, right: string): boolean { if (left === right) { return true @@ -265,6 +340,16 @@ function computationTerm(term: string): boolean { || COMPUTATION_PREFIXES.some((prefix) => term.startsWith(prefix)) } +function deliveryTerm(term: string): boolean { + return DELIVERY_TERMS.has(term) + || DELIVERY_PREFIXES.some((prefix) => term.startsWith(prefix)) +} + +function failureTerm(term: string): boolean { + return FAILURE_TERMS.has(term) + || FAILURE_PREFIXES.some((prefix) => term.startsWith(prefix)) +} + function transitionTerm(term: string): boolean { return TRANSITION_TERMS.has(term) || TRANSITION_PREFIXES.some((prefix) => term.startsWith(prefix)) @@ -280,13 +365,19 @@ function conceptualTerm(term: string): string { if (computationTerm(term)) { return COMPUTATION_CONCEPT } + if (deliveryTerm(term)) { + return DELIVERY_CONCEPT + } + if (failureTerm(term)) { + return FAILURE_CONCEPT + } if (transitionTerm(term)) { return TRANSITION_CONCEPT } return term } -function termsMatch(left: string, right: string): boolean { +export function queryEvidenceTermsMatch(left: string, right: string): boolean { if (left === CHANGE_LIFECYCLE_CONCEPT) { return changeLifecycleTerm(right) } @@ -305,6 +396,18 @@ function termsMatch(left: string, right: string): boolean { if (right === COMPUTATION_CONCEPT) { return computationTerm(left) } + if (left === DELIVERY_CONCEPT) { + return deliveryTerm(right) + } + if (right === DELIVERY_CONCEPT) { + return deliveryTerm(left) + } + if (left === FAILURE_CONCEPT) { + return failureTerm(right) + } + if (right === FAILURE_CONCEPT) { + return failureTerm(left) + } if (left === TRANSITION_CONCEPT) { return transitionTerm(right) } @@ -314,6 +417,191 @@ function termsMatch(left: string, right: string): boolean { return lexicalTermsMatch(left, right) } +export interface QueryEvidenceNode { + label: string + source_file: string + snippet?: string | null +} + +export interface QueryEvidenceCoverage { + total: number + covered: number + covered_obligations: string[] + missing_obligations: string[] +} + +export function flowQueryEvidenceCandidateAllowed( + question: string, + node: { label: string; sourceFile: string; nodeKind?: string }, +): boolean { + if (queryEvidenceObligations(question).length < 3) { + return true + } + const normalizedKind = node.nodeKind?.trim().toLowerCase() ?? '' + if (!EXPLICIT_TEST_EVIDENCE_PATTERN.test(question) && FLOW_TEST_SOURCE_PATTERN.test(node.sourceFile)) { + return false + } + if ( + !EXPLICIT_TYPE_EVIDENCE_PATTERN.test(question) + && (FLOW_TYPE_SOURCE_PATTERN.test(node.sourceFile) || /^(?:enum|interface|property|type)$/.test(normalizedKind)) + ) { + return false + } + const normalizedLabel = node.label.replace(/\(\)$/, '').trim().toLowerCase() + const explicitlyNamed = normalizedLabel.length >= 4 && question.toLowerCase().includes(normalizedLabel) + if ( + !/\bexternal\b/i.test(question) + && (EXTERNAL_SCOPE_PATTERN.test(node.sourceFile) || EXTERNAL_SCOPE_PATTERN.test(node.label)) + ) { + return false + } + if ( + !explicitlyNamed + && !EXPLICIT_ERROR_DECLARATION_PATTERN.test(question) + && (LOW_VALUE_OWNER_PATH_PATTERN.test(node.sourceFile) || LOW_VALUE_OWNER_LABEL_PATTERN.test(node.label)) + ) { + return false + } + return true +} + +function queryEvidenceNodeTokens(node: QueryEvidenceNode): { identity: string[]; snippet: string[] } { + return { + identity: tokenize(`${node.label} ${node.source_file}`), + snippet: tokenize(node.snippet ?? ''), + } +} + +function queryEvidenceDivergenceCovered( + obligation: QueryEvidenceObligation, + previous: QueryEvidenceObligation | undefined, + nodes: readonly QueryEvidenceNode[], +): boolean { + if (!previous) { + return false + } + const subjectTerms = divergenceScopeTerms(previous) + const requiredScopeMatches = Math.min(2, subjectTerms.length) + if (requiredScopeMatches === 0) { + return false + } + const owners = new Set() + const computationOwners = new Set() + for (const node of nodes) { + const tokens = queryEvidenceNodeTokens(node) + if (tokens.snippet.length === 0) { + continue + } + const allTokens = [...tokens.identity, ...tokens.snippet] + const scopeMatches = subjectTerms.filter((term) => ( + allTokens.some((token) => queryEvidenceTermsMatch(term, token)) + )).length + const computationMatch = allTokens.some((token) => queryEvidenceTermsMatch(COMPUTATION_CONCEPT, token)) + || QUERY_EVIDENCE_COMPUTATION_OPERATION_PATTERN.test(node.snippet ?? '') + const statusLikeMatch = obligation.terms + .filter((term) => !term.startsWith('@')) + .some((term) => allTokens.some((token) => queryEvidenceTermsMatch(term, token))) + if (scopeMatches >= requiredScopeMatches && (computationMatch || statusLikeMatch)) { + owners.add(node.source_file) + if (computationMatch) computationOwners.add(node.source_file) + } + } + return owners.size >= 2 && computationOwners.size >= 1 +} + +/** Measures whether selected snippets, rather than filenames alone, carry each prompt obligation. */ +export function evaluateQueryEvidenceCoverage( + question: string, + nodes: readonly QueryEvidenceNode[], +): QueryEvidenceCoverage { + const obligations = queryEvidenceObligations(question) + const coveredObligations: string[] = [] + const missingObligations: string[] = [] + + for (const obligation of obligations) { + const key = `query:obligation:${obligation.index + 1}` + if (obligation.terms.includes(DIVERGENCE_CONCEPT)) { + const previous = obligations.find((candidate) => candidate.index === obligation.index - 1) + if (queryEvidenceDivergenceCovered(obligation, previous, nodes)) { + coveredObligations.push(key) + } else { + missingObligations.push(key) + } + continue + } + + let coveredByOneEvidenceOwner = false + for (const node of nodes) { + const tokens = queryEvidenceNodeTokens(node) + const snippet = node.snippet ?? '' + const matchedTerms = new Set() + const snippetMatchedTerms = new Set() + for (const term of obligation.terms) { + if ([...tokens.identity, ...tokens.snippet].some((token) => queryEvidenceTermsMatch(term, token))) { + matchedTerms.add(term) + } + if (tokens.snippet.some((token) => queryEvidenceTermsMatch(term, token))) { + snippetMatchedTerms.add(term) + } + if (term === COMPUTATION_CONCEPT && QUERY_EVIDENCE_COMPUTATION_OPERATION_PATTERN.test(snippet)) { + matchedTerms.add(term) + snippetMatchedTerms.add(term) + } + } + const requiredMatches = Math.min(2, obligation.terms.length) + const conceptualTerms = obligation.terms.filter((term) => term.startsWith('@')) + const literalTerms = obligation.terms.filter((term) => !term.startsWith('@')) + const conceptualSnippetGrounded = conceptualTerms.length === 0 + || conceptualTerms.some((term) => snippetMatchedTerms.has(term)) + const literalSnippetGrounded = literalTerms.length === 0 + || literalTerms.some((term) => snippetMatchedTerms.has(term)) + const transitionCooccursWithEntity = !conceptualTerms.includes(TRANSITION_CONCEPT) + || (node.snippet ?? '').split(/\r?\n/).some((line) => { + const lineTokens = tokenize(line) + return lineTokens.some((token) => queryEvidenceTermsMatch(TRANSITION_CONCEPT, token)) + && literalTerms.some((term) => lineTokens.some((token) => queryEvidenceTermsMatch(term, token))) + && QUERY_EVIDENCE_STATE_MUTATION_PATTERN.test(line) + }) + const deliveryCooccursWithEntity = !conceptualTerms.includes(DELIVERY_CONCEPT) + || snippet.split(/\r?\n/).some((line) => { + const lineTokens = tokenize(line) + return lineTokens.some((token) => queryEvidenceTermsMatch(DELIVERY_CONCEPT, token)) + && literalTerms.some((term) => lineTokens.some((token) => queryEvidenceTermsMatch(term, token))) + && QUERY_EVIDENCE_DELIVERY_OPERATION_PATTERN.test(line) + }) + const computationCooccursWithEntity = !conceptualTerms.includes(COMPUTATION_CONCEPT) + || snippet.split(/\r?\n/).some((line) => { + const lineTokens = tokenize(line) + return QUERY_EVIDENCE_COMPUTATION_OPERATION_PATTERN.test(line) + && literalTerms.some((term) => lineTokens.some((token) => queryEvidenceTermsMatch(term, token))) + }) + if ( + matchedTerms.size >= requiredMatches + && conceptualSnippetGrounded + && literalSnippetGrounded + && transitionCooccursWithEntity + && deliveryCooccursWithEntity + && computationCooccursWithEntity + ) { + coveredByOneEvidenceOwner = true + break + } + } + if (coveredByOneEvidenceOwner) { + coveredObligations.push(key) + } else { + missingObligations.push(key) + } + } + + return { + total: obligations.length, + covered: coveredObligations.length, + covered_obligations: coveredObligations, + missing_obligations: missingObligations, + } +} + function stringValues(value: unknown, depth = 0): string[] { if (depth > 2) { return [] @@ -496,10 +784,29 @@ function runtimeScopeForSource(sourceFile: string): string { return parts.slice(-3, -1).join('/') || normalized } +function fileOwnerNode(node: Pick): boolean { + const basename = node.sourceFile.replaceAll('\\', '/').split('/').at(-1)?.toLowerCase() ?? '' + const normalizedLabel = node.label.replaceAll('\\', '/').split('/').at(-1)?.toLowerCase() ?? '' + return normalizedLabel === basename + || (node.nodeKind.trim().length === 0 && normalizedLabel.replace(/\.[^.]+$/, '') === basename.replace(/\.[^.]+$/, '')) +} + +function publicBoundaryOwnerNode(node: Pick): boolean { + const normalizedSource = node.sourceFile.replaceAll('\\', '/') + const routePath = /\/(?:app\/)?api\//i.test(normalizedSource) + && /\/route\.[^/]+$/i.test(normalizedSource) + const routeLabel = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)(?:\(\)|\s|$)|^route\.[^.]+$/i.test(node.label) + return (routePath && routeLabel) || /(?:route|request)_handler/i.test(node.frameworkRole) +} + +function runtimeLanguageForSource(sourceFile: string): string { + return sourceFile.replaceAll('\\', '/').split('.').at(-1)?.toLowerCase() ?? '' +} + function vocabularyDocumentFrequency(index: RepositoryVocabularyIndex, queryTerm: string): number { let count = 0 for (const [term, frequency] of index.documentFrequency) { - if (termsMatch(queryTerm, term)) { + if (queryEvidenceTermsMatch(queryTerm, term)) { count += frequency } } @@ -515,7 +822,7 @@ function anchorForNode( node: VocabularyNode, terms: readonly string[], inverseFrequencyByTerm: ReadonlyMap, - obligations: readonly QueryObligation[], + obligations: readonly QueryEvidenceObligation[], question: string, ): AnchorCandidate | null { const matchedQueryTerms = new Set() @@ -528,7 +835,7 @@ function anchorForNode( for (const queryTerm of terms) { let bestWeight = 0 for (const [source, vocabulary] of node.fields) { - if ([...vocabulary].some((term) => termsMatch(queryTerm, term))) { + if ([...vocabulary].some((term) => queryEvidenceTermsMatch(queryTerm, term))) { sources.add(source) if (source !== 'graph_community' && source !== 'path') { specificQueryTerms.add(queryTerm) @@ -554,7 +861,23 @@ function anchorForNode( const obligationMatches = new Map() for (const obligation of obligations) { const matchedTerms = obligation.terms.filter((term) => matchedQueryTerms.has(term)).length - if (matchedTerms >= Math.min(2, obligation.terms.length)) { + const previous = obligations.find((candidate) => candidate.index === obligation.index - 1) + const previousTerms = divergenceScopeTerms(previous) + const scopedSubjectMatches = previousTerms.filter((term) => matchedQueryTerms.has(term)).length + const requiredMatches = obligation.terms.includes(DIVERGENCE_CONCEPT) && scopedSubjectMatches >= 2 + ? 1 + : Math.min(2, obligation.terms.length) + const lifecycleConcepts = obligation.terms.filter((term) => ( + term === COMPUTATION_CONCEPT + || term === DELIVERY_CONCEPT + || term === FAILURE_CONCEPT + || term === TRANSITION_CONCEPT + )) + const lifecycleGrounded = lifecycleConcepts.every((term) => matchedQueryTerms.has(term)) + const divergenceGrounded = !obligation.terms.includes(DIVERGENCE_CONCEPT) + || matchedQueryTerms.has(DIVERGENCE_CONCEPT) + || scopedSubjectMatches >= 2 + if (matchedTerms >= requiredMatches && lifecycleGrounded && divergenceGrounded) { obligationMatches.set(obligation.index, matchedTerms) } } @@ -581,14 +904,70 @@ function anchorForNode( persistenceShaped: PERSISTENCE_PATH_PATTERN.test(node.sourceFile), lowValueOwner: LOW_VALUE_OWNER_PATH_PATTERN.test(node.sourceFile) || LOW_VALUE_OWNER_LABEL_PATTERN.test(node.label), + behaviorOwner: CORE_BEHAVIOR_OWNER_PATH_PATTERN.test(node.sourceFile), + fileOwner: fileOwnerNode(node), + publicBoundaryOwner: publicBoundaryOwnerNode(node), runtimeScope: runtimeScopeForSource(node.sourceFile), } } +/** + * Removes same-vocabulary computations whose repository scope is weaker than + * the best candidates for an explicit divergence request. Explicit symbol or + * path anchors can still override this at the retrieval boundary. + */ +export function underScopedDivergenceNodeIds( + graph: KnowledgeGraph, + question: string, +): ReadonlySet { + const obligations = queryEvidenceObligations(question) + const divergenceObligations = obligations.filter((obligation) => obligation.terms.includes(DIVERGENCE_CONCEPT)) + if (divergenceObligations.length === 0) { + return new Set() + } + const terms = queryTerms(obligations) + const index = buildVocabularyIndex(graph) + const inverseFrequencyByTerm = new Map( + terms.map((term) => [term, inverseFrequency(index, term)] as const), + ) + const anchors = index.nodes.filter((node) => flowQueryEvidenceCandidateAllowed(question, node)).flatMap((node) => { + const anchor = anchorForNode(node, terms, inverseFrequencyByTerm, obligations, question) + return anchor ? [anchor] : [] + }) + const excluded = new Set() + for (const obligation of divergenceObligations) { + const previous = obligations.find((candidate) => candidate.index === obligation.index - 1) + const previousTerms = divergenceScopeTerms(previous) + const scopeMatches = (anchor: AnchorCandidate): number => ( + previousTerms.filter((term) => anchor.matchedQueryTerms.has(term)).length + ) + const candidates = anchors.filter((anchor) => ( + anchor.matchedQueryTerms.has(COMPUTATION_CONCEPT) + && ( + anchor.obligationMatches.has(obligation.index) + || (previous !== undefined && anchor.obligationMatches.has(previous.index)) + ) + )) + const strongestScope = candidates.reduce( + (maximum, anchor) => Math.max(maximum, scopeMatches(anchor)), + 0, + ) + if (strongestScope < 2) { + continue + } + for (const anchor of candidates) { + if (scopeMatches(anchor) < 2) { + excluded.add(anchor.id) + } + } + } + return excluded +} + function diversifyAnchors( graph: KnowledgeGraph, ranked: readonly AnchorCandidate[], - obligations: readonly QueryObligation[], + obligations: readonly QueryEvidenceObligation[], preferRuntime: boolean, ): { anchors: AnchorCandidate[] @@ -602,35 +981,80 @@ function diversifyAnchors( const reservedByObligation = new Set() const hasSymbolGroundedAnchors = ranked.some((anchor) => anchor.symbolQueryTerms.size > 0) - const obligationSymbolMatchCount = (anchor: AnchorCandidate, obligation: QueryObligation): number => ( + const obligationSymbolMatchCount = (anchor: AnchorCandidate, obligation: QueryEvidenceObligation): number => ( obligation.terms.filter((term) => anchor.symbolQueryTerms.has(term)).length ) - const obligationSpecificMatchCount = (anchor: AnchorCandidate, obligation: QueryObligation): number => ( + const obligationSpecificMatchCount = (anchor: AnchorCandidate, obligation: QueryEvidenceObligation): number => ( obligation.terms.filter((term) => anchor.specificQueryTerms.has(term)).length ) - const divergenceScopeMatchCount = (anchor: AnchorCandidate, obligation: QueryObligation): number => { + const divergenceScopeMatchCount = (anchor: AnchorCandidate, obligation: QueryEvidenceObligation): number => { if (!obligation.terms.includes(DIVERGENCE_CONCEPT)) { return 0 } - const previousTerms = obligations.find((candidate) => candidate.index === obligation.index - 1)?.terms ?? [] + const previousTerms = divergenceScopeTerms( + obligations.find((candidate) => candidate.index === obligation.index - 1), + ) return previousTerms.filter((term) => anchor.matchedQueryTerms.has(term)).length } + const crossObligationContextMatchCount = ( + anchor: AnchorCandidate, + obligation: QueryEvidenceObligation, + ): number => new Set( + obligations + .filter((candidate) => candidate.index !== obligation.index) + .flatMap((candidate) => candidate.terms) + .filter((term) => !term.startsWith('@') && anchor.matchedQueryTerms.has(term)), + ).size - const baseObligationOrder = (obligation: QueryObligation) => ( + const baseObligationOrder = (obligation: QueryEvidenceObligation) => ( (left: AnchorCandidate, right: AnchorCandidate): number => ( - obligationSymbolMatchCount(right, obligation) - obligationSymbolMatchCount(left, obligation) + (obligation.terms.includes(DIVERGENCE_CONCEPT) + ? divergenceScopeMatchCount(right, obligation) - divergenceScopeMatchCount(left, obligation) + : 0) + || (obligation.terms.includes(TRANSITION_CONCEPT) + ? Number(right.transitionOwner) - Number(left.transitionOwner) + : 0) + || (obligation.terms.includes('public') && obligation.terms.includes('page') + ? Number(right.fileOwner) - Number(left.fileOwner) + : 0) + || obligationSymbolMatchCount(right, obligation) - obligationSymbolMatchCount(left, obligation) || (right.obligationMatches.get(obligation.index) ?? 0) - (left.obligationMatches.get(obligation.index) ?? 0) || obligationSpecificMatchCount(right, obligation) - obligationSpecificMatchCount(left, obligation) - || divergenceScopeMatchCount(right, obligation) - divergenceScopeMatchCount(left, obligation) + || crossObligationContextMatchCount(right, obligation) - crossObligationContextMatchCount(left, obligation) || right.structuralDegree - left.structuralDegree || right.score - left.score || left.id.localeCompare(right.id) ) ) const candidatesByObligation = new Map() + const underScopedDivergenceAnchors = new Set() + for (const divergenceObligation of obligations.filter((obligation) => obligation.terms.includes(DIVERGENCE_CONCEPT))) { + const previous = obligations.find((candidate) => candidate.index === divergenceObligation.index - 1) + const scopedCandidates = ranked.filter((anchor) => ( + anchor.matchedQueryTerms.has(COMPUTATION_CONCEPT) + && ( + anchor.obligationMatches.has(divergenceObligation.index) + || (previous !== undefined && anchor.obligationMatches.has(previous.index)) + ) + )) + const strongestScope = scopedCandidates.reduce( + (maximum, anchor) => Math.max(maximum, divergenceScopeMatchCount(anchor, divergenceObligation)), + 0, + ) + if (strongestScope >= 2) { + for (const anchor of scopedCandidates) { + if (divergenceScopeMatchCount(anchor, divergenceObligation) < 2) { + underScopedDivergenceAnchors.add(anchor.id) + } + } + } + } for (const obligation of obligations) { const candidates = ranked - .filter((anchor) => anchor.obligationMatches.has(obligation.index)) + .filter((anchor) => ( + anchor.obligationMatches.has(obligation.index) + && !underScopedDivergenceAnchors.has(anchor.id) + )) .sort(baseObligationOrder(obligation)) const runtimeCandidates = preferRuntime && candidates.some((anchor) => !anchor.presentationShaped) ? candidates.filter((anchor) => !anchor.presentationShaped) @@ -641,9 +1065,28 @@ function diversifyAnchors( const ownerCandidates = structurallyGrounded.some((anchor) => !anchor.lowValueOwner) ? structurallyGrounded.filter((anchor) => !anchor.lowValueOwner) : structurallyGrounded + const behaviorOwners = ownerCandidates.some((anchor) => anchor.behaviorOwner) + ? ownerCandidates.filter((anchor) => anchor.behaviorOwner) + : ownerCandidates + const strongestScopedOwners = obligation.terms.includes(DIVERGENCE_CONCEPT) + ? (() => { + const strongestScope = behaviorOwners.reduce( + (maximum, anchor) => Math.max(maximum, divergenceScopeMatchCount(anchor, obligation)), + 0, + ) + const minimumScopedMatch = strongestScope >= 2 ? 2 : strongestScope + return strongestScope > 0 + ? behaviorOwners.filter((anchor) => { + const scoped = divergenceScopeMatchCount(anchor, obligation) >= minimumScopedMatch + if (!scoped) underScopedDivergenceAnchors.add(anchor.id) + return scoped + }) + : behaviorOwners + })() + : behaviorOwners candidatesByObligation.set( obligation.index, - ownerCandidates.slice(0, MAX_OBLIGATION_CONNECTIVITY_CANDIDATES), + strongestScopedOwners.slice(0, MAX_OBLIGATION_CONNECTIVITY_CANDIDATES), ) } const pathCache = new Map() @@ -685,6 +1128,40 @@ function diversifyAnchors( for (const obligation of obligations) { const candidates = [...(candidatesByObligation.get(obligation.index) ?? [])] .sort((left, right) => { + const scopedDivergenceOrder = obligation.terms.includes(DIVERGENCE_CONCEPT) + ? divergenceScopeMatchCount(right, obligation) - divergenceScopeMatchCount(left, obligation) + : 0 + if (scopedDivergenceOrder !== 0) { + return scopedDivergenceOrder + } + const transitionOwnerOrder = obligation.terms.includes(TRANSITION_CONCEPT) + ? Number(right.transitionOwner) - Number(left.transitionOwner) + : 0 + if (transitionOwnerOrder !== 0) { + return transitionOwnerOrder + } + const behaviorOrder = Number(right.behaviorOwner) - Number(left.behaviorOwner) + if (behaviorOrder !== 0) { + return behaviorOrder + } + const publicFileOwnerOrder = obligation.terms.includes('public') && obligation.terms.includes('page') + ? Number(right.fileOwner) - Number(left.fileOwner) + : 0 + if (publicFileOwnerOrder !== 0) { + return publicFileOwnerOrder + } + const publicOwnerGrounding = obligation.terms.includes('public') && obligation.terms.includes('page') + ? obligationSymbolMatchCount(right, obligation) - obligationSymbolMatchCount(left, obligation) + || obligationSpecificMatchCount(right, obligation) - obligationSpecificMatchCount(left, obligation) + : 0 + if (publicOwnerGrounding !== 0) { + return publicOwnerGrounding + } + const workflowContextOrder = crossObligationContextMatchCount(right, obligation) + - crossObligationContextMatchCount(left, obligation) + if (workflowContextOrder !== 0) { + return workflowContextOrder + } const leftConnections = connectedObligations(left) const rightConnections = connectedObligations(right) const leftAdjacent = Number(leftConnections.has(obligation.index - 1)) @@ -708,6 +1185,34 @@ function diversifyAnchors( } } + // A public status computation is incomplete without the HTTP boundary that + // fetches and serializes it. Reserve that owner separately from the status + // implementation so runtime provenance is explicit rather than inferred + // from a shared output type. + for (const obligation of obligations.filter((candidate) => ( + candidate.terms.includes('public') && candidate.terms.includes('page') + ))) { + const publicTerms = obligation.terms.filter((term) => !term.startsWith('@')) + const boundary = ranked + .filter((anchor) => ( + anchor.publicBoundaryOwner + && publicTerms.filter((term) => anchor.matchedQueryTerms.has(term)).length >= 2 + && !anchor.lowValueOwner + )) + .sort((left, right) => ( + Number(/\(\)$/.test(right.label)) - Number(/\(\)$/.test(left.label)) + || obligationSymbolMatchCount(right, obligation) - obligationSymbolMatchCount(left, obligation) + || obligationSpecificMatchCount(right, obligation) - obligationSpecificMatchCount(left, obligation) + || right.structuralDegree - left.structuralDegree + || right.score - left.score + || left.id.localeCompare(right.id) + ))[0] + if (boundary) { + if (!selectedIds.has(boundary.id)) add(boundary) + reservedByObligation.add(boundary.id) + } + } + // A divergence request requires at least two distinct implementations to // compare. Reserve a second scoped computation owner instead of hoping it // survives the global top-k. @@ -737,7 +1242,7 @@ function diversifyAnchors( const unmatchedEntityQualifiers = (anchor: AnchorCandidate): number => { const basenameTerms = tokenize(anchor.sourceFile.split('/').at(-1) ?? '') .filter((term) => !VOCABULARY_NOISE.has(term)) - return basenameTerms.filter((term) => !entityTerms.some((entity) => termsMatch(entity, term))).length + return basenameTerms.filter((term) => !entityTerms.some((entity) => queryEvidenceTermsMatch(entity, term))).length } const stateOwner = ranked .filter((anchor) => ( @@ -764,6 +1269,9 @@ function diversifyAnchors( const firstObligation = obligations[0] const firstPrimaryId = firstObligation ? preferredByObligation.get(firstObligation.index) : undefined const firstPrimaryScope = firstPrimaryId ? ranked.find((anchor) => anchor.id === firstPrimaryId)?.runtimeScope : undefined + const firstPrimaryLanguage = firstPrimaryId + ? runtimeLanguageForSource(ranked.find((anchor) => anchor.id === firstPrimaryId)?.sourceFile ?? '') + : undefined if (firstObligation) { const headTerm = firstObligation.terms.at(-1) const outcomeLabel = (anchor: AnchorCandidate): boolean => ( @@ -775,11 +1283,18 @@ function diversifyAnchors( && anchor.runtimeScope !== firstPrimaryScope && anchor.transitionOwner && !anchor.lowValueOwner - && anchor.symbolQueryTerms.size > 0 + && (anchor.symbolQueryTerms.size > 0 || anchor.pathQueryTerms.size > 0) && firstObligation.terms.some((term) => anchor.matchedQueryTerms.has(term)) )) .sort((left, right) => ( - Number(outcomeLabel(right)) - Number(outcomeLabel(left)) + Number( + firstPrimaryLanguage !== undefined + && runtimeLanguageForSource(right.sourceFile) !== firstPrimaryLanguage, + ) - Number( + firstPrimaryLanguage !== undefined + && runtimeLanguageForSource(left.sourceFile) !== firstPrimaryLanguage, + ) + || Number(outcomeLabel(right)) - Number(outcomeLabel(left)) || Number(headTerm !== undefined && right.pathQueryTerms.has(headTerm)) - Number(headTerm !== undefined && left.pathQueryTerms.has(headTerm)) || firstObligation.terms.filter((term) => right.matchedQueryTerms.has(term)).length @@ -791,6 +1306,42 @@ function diversifyAnchors( if (boundaryOwner) { if (!selectedIds.has(boundaryOwner.id)) add(boundaryOwner) reservedByObligation.add(boundaryOwner.id) + + const rankedById = new Map(ranked.map((anchor) => [anchor.id, anchor])) + const boundaryCaller = graph.predecessors(boundaryOwner.id) + .flatMap((nodeId) => { + const anchor = rankedById.get(nodeId) + if (!anchor) return [] + const relation = String(graph.edgeAttributes(nodeId, boundaryOwner.id).relation ?? '') + return /^(?:calls|dispatches|emits|enqueues|invokes|publishes|triggers)$/.test(relation) + ? [anchor] + : [] + }) + .filter((anchor) => ( + anchor.sourceFile !== boundaryOwner.sourceFile + && anchor.behaviorOwner + && !anchor.lowValueOwner + && firstObligation.terms.some((term) => anchor.matchedQueryTerms.has(term)) + )) + .sort((left, right) => ( + Number(right.obligationMatches.has(firstObligation.index)) + - Number(left.obligationMatches.has(firstObligation.index)) + || firstObligation.terms.filter((term) => right.symbolQueryTerms.has(term)).length + - firstObligation.terms.filter((term) => left.symbolQueryTerms.has(term)).length + || firstObligation.terms.filter((term) => right.matchedQueryTerms.has(term)).length + - firstObligation.terms.filter((term) => left.matchedQueryTerms.has(term)).length + || right.structuralDegree - left.structuralDegree + || right.score - left.score + || left.id.localeCompare(right.id) + ))[0] + if (boundaryCaller) { + if (!selectedIds.has(boundaryCaller.id)) add(boundaryCaller) + if (firstPrimaryId && preferredByObligation.get(firstObligation.index) === firstPrimaryId) { + reservedByObligation.delete(firstPrimaryId) + } + preferredByObligation.set(firstObligation.index, boundaryCaller.id) + reservedByObligation.add(boundaryCaller.id) + } } } @@ -798,6 +1349,9 @@ function diversifyAnchors( if (selectedIds.has(anchor.id)) { continue } + if (underScopedDivergenceAnchors.has(anchor.id)) { + continue + } const sourceKey = anchor.sourceFile || anchor.id if (countBySourceFile.has(sourceKey)) { continue @@ -818,6 +1372,9 @@ function diversifyAnchors( if (selectedIds.has(anchor.id)) { continue } + if (underScopedDivergenceAnchors.has(anchor.id)) { + continue + } const sourceKey = anchor.sourceFile || anchor.id const sourceCount = countBySourceFile.get(sourceKey) ?? 0 if (sourceCount === 0 || sourceCount >= MAX_ANCHORS_PER_SOURCE_FILE) { @@ -855,6 +1412,7 @@ function eligibleVocabularyNodes(index: RepositoryVocabularyIndex, input: Concep return index.nodes.filter((node) => ( (input.community === undefined || node.community === input.community) && (normalizedFileType === undefined || node.fileType === normalizedFileType) + && flowQueryEvidenceCandidateAllowed(input.question, node) && ( allowsNonProduction || !['test', 'benchmark', 'fixture', 'generated', 'build_artifact'].includes(node.sourceDomain) @@ -921,7 +1479,7 @@ function shortestIncidentPath( function proposalIsGrounded( anchors: readonly AnchorCandidate[], - obligations: readonly QueryObligation[], + obligations: readonly QueryEvidenceObligation[], ): boolean { const coveredTerms = new Set(anchors.flatMap((anchor) => [...anchor.matchedQueryTerms])) const coveredObligations = new Set(anchors.flatMap((anchor) => [...anchor.obligationMatches.keys()])) @@ -983,7 +1541,7 @@ export function planConceptualFallback( graph: KnowledgeGraph, input: ConceptualFallbackInput, ): ConceptualFallbackProposal { - const obligations = queryObligations(input.question) + const obligations = queryEvidenceObligations(input.question) const terms = queryTerms(obligations) const initialReasons = fallbackReasons(input.initialQuality) if (terms.length === 0) { @@ -1171,7 +1729,7 @@ export function planConceptualFallback( // a dense single-layer cluster can still consume every selected slot after // the proposal correctly found disconnected cross-service stages. for (const nodeId of preferredObligationAnchorIds) { - boosts.set(nodeId, Math.max(boosts.get(nodeId) ?? 0, PREFERRED_OBLIGATION_ANCHOR_BOOST)) + boosts.set(nodeId, Math.max(boosts.get(nodeId) ?? 0, CONCEPTUAL_WORKFLOW_RESERVATION_BOOST)) } for (const anchor of anchors diff --git a/src/runtime/retrieve/expansion.ts b/src/runtime/retrieve/expansion.ts index 1743265f..1a796af6 100644 --- a/src/runtime/retrieve/expansion.ts +++ b/src/runtime/retrieve/expansion.ts @@ -137,6 +137,15 @@ export function predecessorAllowedForPolicy( && seedCommunity === neighborCommunity } +/** + * Extractors model a declaration owner as an incoming edge to its member. + * That ownership is structural evidence, so community clustering must not + * separate a matched member from the class or file that declares it. + */ +export function predecessorIsStructuralOwner(relation: string): boolean { + return relation === 'contains' || relation === 'method' +} + export function relationIsPrimaryForPolicy(level: RetrievalLevel, relation: string): boolean { return PRIMARY_RELATIONS.has(relation) || (level >= 3 && BEHAVIOR_RELATIONS.has(relation)) diff --git a/src/runtime/retrieve/pipeline.ts b/src/runtime/retrieve/pipeline.ts index 684da43b..656a2b07 100644 --- a/src/runtime/retrieve/pipeline.ts +++ b/src/runtime/retrieve/pipeline.ts @@ -18,6 +18,11 @@ import { import { classifyTaskContract } from '../context-pack.js' import { classifyRetrievalLevel } from '../retrieval-gate.js' import { defaultContextKindForTaskIntent } from '../task-intent.js' +import { + evaluateQueryEvidenceCoverage, + type QueryEvidenceCoverage, + type QueryEvidenceNode, +} from './conceptual-fallback.js' export type RetrievalPipelineStage = | 'query_interpretation' @@ -132,6 +137,8 @@ export interface RetrievalEvidencePlanInput { coveredWorkflowOwners?: readonly string[] selectedNodeCount?: number selectedRelationshipCount?: number + question?: string + matchedNodes?: readonly QueryEvidenceNode[] } /** Explicit boundary consumed by answerability; it contains evidence facts, never ranking scores. */ @@ -146,6 +153,7 @@ export interface RetrievalEvidencePlan { covered_workflow_owners: string[] selected_node_count: number selected_relationship_count: number + query_evidence?: QueryEvidenceCoverage } const finiteCount = (value: number | undefined): number => ( @@ -269,15 +277,19 @@ export const buildRetrievalEvidencePlan = (input: RetrievalEvidencePlanInput): R covered_workflow_owners: [...new Set(input.coveredWorkflowOwners ?? [])], selected_node_count: finiteCount(input.selectedNodeCount), selected_relationship_count: finiteCount(input.selectedRelationshipCount), + ...(input.question && input.matchedNodes + ? { query_evidence: evaluateQueryEvidenceCoverage(input.question, input.matchedNodes) } + : {}), }) export interface RetrievalEvidenceResultLike { + question?: string task_contract?: ContextPackTaskContract coverage?: ContextPackCoverage expandable?: readonly ContextPackExpandableRef[] execution_slice?: ContextPackExecutionSlice answer_contract?: ContextPackRuntimeGenerationAnswerContract - matched_nodes: ReadonlyArray<{ source_file: string }> + matched_nodes: ReadonlyArray relationships: readonly unknown[] } @@ -296,6 +308,12 @@ export const buildRetrievalEvidencePlanFromResult = ( coveredWorkflowOwners: result.matched_nodes.map((node) => node.source_file), selectedNodeCount: result.matched_nodes.length, selectedRelationshipCount: result.relationships.length, + ...(result.question + ? { + question: result.question, + matchedNodes: result.matched_nodes, + } + : {}), }) export const runRetrievalEvidencePlanningStage = ( diff --git a/src/runtime/stdio-server.ts b/src/runtime/stdio-server.ts index 6573e57e..4ef37a24 100644 --- a/src/runtime/stdio-server.ts +++ b/src/runtime/stdio-server.ts @@ -87,6 +87,22 @@ const AUTO_REFRESH_CONTROL_METHODS = new Set([ 'tools/list', ]) +// These pre-MCP convenience methods are retained for existing clients, but +// they must not provide an unadvertised graph-navigation escape hatch when a +// client deliberately selected the bounded strict context-pack profile. +const STRICT_DISABLED_LEGACY_GRAPH_METHODS = new Set([ + 'query', + 'diff', + 'anomalies', + 'node', + 'neighbors', + 'path', + 'explain', + 'stats', + 'god_nodes', + 'community', +]) + type McpLogLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency' /** Per-session record of node ids already shipped to a given `delta_session_id`. @@ -744,16 +760,30 @@ export function handleStdioRequest( try { const params = request.params + const toolProfile = resolveToolProfileFromEnv() + const strictContextPackProfile = toolProfile === 'strict' + + if (strictContextPackProfile && STRICT_DISABLED_LEGACY_GRAPH_METHODS.has(method)) { + return failure( + id, + JSONRPC_METHOD_NOT_FOUND, + `Legacy graph method '${method}' is disabled in the strict context_pack profile. Use context_pack, or select MADAR_TOOL_PROFILE=core or full for graph navigation.`, + ) + } switch (method) { case 'initialize': return ok(id, { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { - completions: {}, logging: {}, - prompts: { listChanged: false }, - resources: { subscribe: true, listChanged: true }, + ...(strictContextPackProfile + ? {} + : { + completions: {}, + prompts: { listChanged: false }, + resources: { subscribe: true, listChanged: true }, + }), tools: { listChanged: false }, }, serverInfo: { @@ -761,11 +791,16 @@ export function handleStdioRequest( title: MCP_SERVER_TITLE, version: MCP_SERVER_VERSION, }, - instructions: 'Use tools/list to discover graph tools, then tools/call to query the generated graph.', + instructions: strictContextPackProfile + ? 'Strict profile: use context_pack once with the user request verbatim. Use context_expand only for a listed verify_targets handle; graph prompts, resources, and completions are disabled.' + : 'Use tools/list to discover graph tools, then tools/call to query the generated graph.', }) case 'notifications/initialized': return null case 'completion/complete': + if (strictContextPackProfile) { + return failure(id, JSONRPC_METHOD_NOT_FOUND, 'MCP prompt completions are disabled in the strict context_pack profile.') + } return handleCompletion(id, graphPath, params, { ok, failure, @@ -786,8 +821,11 @@ export function handleStdioRequest( return ok(id, {}) } case 'prompts/list': - return ok(id, { prompts: promptDefinitionsForGraph(graphPath) }) + return ok(id, { prompts: strictContextPackProfile ? [] : promptDefinitionsForGraph(graphPath) }) case 'prompts/get': + if (strictContextPackProfile) { + return failure(id, JSONRPC_METHOD_NOT_FOUND, 'MCP prompts are disabled in the strict context_pack profile.') + } return handlePromptGet(id, graphPath, params, { ok, failure, @@ -801,7 +839,7 @@ export function handleStdioRequest( }) case 'resources/list': return ok(id, { - resources: resourcesForGraph(graphPath).map(({ uri, name, title, description, mimeType, annotations }) => ({ + resources: strictContextPackProfile ? [] : resourcesForGraph(graphPath).map(({ uri, name, title, description, mimeType, annotations }) => ({ uri, name, title, @@ -811,6 +849,9 @@ export function handleStdioRequest( })), }) case 'resources/subscribe': + if (strictContextPackProfile) { + return failure(id, JSONRPC_METHOD_NOT_FOUND, 'MCP resources are disabled in the strict context_pack profile.') + } return handleResourceSubscribe(id, graphPath, params, sessionState, { ok, failure, @@ -824,6 +865,9 @@ export function handleStdioRequest( maxResourceSubscriptions: MAX_RESOURCE_SUBSCRIPTIONS, }) case 'resources/unsubscribe': + if (strictContextPackProfile) { + return failure(id, JSONRPC_METHOD_NOT_FOUND, 'MCP resources are disabled in the strict context_pack profile.') + } return handleResourceUnsubscribe(id, params, sessionState, { ok, failure, @@ -837,6 +881,9 @@ export function handleStdioRequest( maxResourceSubscriptions: MAX_RESOURCE_SUBSCRIPTIONS, }) case 'resources/read': + if (strictContextPackProfile) { + return failure(id, JSONRPC_METHOD_NOT_FOUND, 'MCP resources are disabled in the strict context_pack profile.') + } return handleResourceRead(id, graphPath, params, { ok, failure, @@ -850,23 +897,42 @@ export function handleStdioRequest( maxResourceSubscriptions: MAX_RESOURCE_SUBSCRIPTIONS, }) case 'tools/list': { - const profile = resolveToolProfileFromEnv() // Only advertise semantic/rerank params when the optional transformers // package is actually resolvable on this machine — agents cannot pass // parameters that are absent from the schema. const semanticAvailable = isSemanticRuntimeAvailable(graphRootPath(graphPath) ?? resolveGraphSourceRoot(graphPath)) - return ok(id, { tools: activeMcpTools(profile, { semanticAvailable }) }) + return ok(id, { tools: activeMcpTools(toolProfile, { semanticAvailable }) }) } case 'tools/call': { - const profile = resolveToolProfileFromEnv() const toolName = stringParam(params, 'name') - if (toolName !== null && !isToolEnabledInProfile(toolName, profile)) { + if (toolName !== null && !isToolEnabledInProfile(toolName, toolProfile)) { return failure( id, JSONRPC_METHOD_NOT_FOUND, - `Tool '${toolName}' is not enabled in the active madar MCP tool profile '${profile}'. Use MADAR_TOOL_PROFILE=strict for core plus context_pack/context_expand, or MADAR_TOOL_PROFILE=full for every tool, in your agent's MCP server config.`, + `Tool '${toolName}' is not enabled in the active madar MCP tool profile '${toolProfile}'. Use MADAR_TOOL_PROFILE=strict for the bounded context_pack/context_expand flow, MADAR_TOOL_PROFILE=core for graph navigation, or MADAR_TOOL_PROFILE=full for every tool.`, ) } + const toolArguments = recordParam(params, 'arguments') + if (strictContextPackProfile && toolName === 'context_pack') { + const unsupported = Object.keys(toolArguments ?? {}).filter((key) => key !== 'prompt' && key !== 'task') + if (unsupported.length > 0) { + return failure( + id, + JSONRPC_INVALID_PARAMS, + `strict context_pack accepts only prompt and optional task; unsupported argument${unsupported.length === 1 ? '' : 's'}: ${unsupported.join(', ')}. Use MADAR_TOOL_PROFILE=full for diagnostics or retrieval tuning.`, + ) + } + } + if (strictContextPackProfile && toolName === 'context_expand') { + const unsupported = Object.keys(toolArguments ?? {}).filter((key) => key !== 'handle_id') + if (unsupported.length > 0) { + return failure( + id, + JSONRPC_INVALID_PARAMS, + `strict context_expand accepts only handle_id; unsupported argument${unsupported.length === 1 ? '' : 's'}: ${unsupported.join(', ')}. Use MADAR_TOOL_PROFILE=full for expansion tuning.`, + ) + } + } const response = handleToolCallRequest(id, graphPath, params, { ok, failure, @@ -896,6 +962,7 @@ export function handleStdioRequest( sessions.set(sessionId, nextState) }, clearContextPromptSession: (sessionId) => ensureContextPromptSessions(sessionState).delete(sessionId), + strictContextPackMode: strictContextPackProfile, getContextPackNodeIds: (sessionId) => { const store = ensureContextPackNodeIds(sessionState).get(sessionId) return store ? Array.from(store) : [] @@ -917,6 +984,12 @@ export function handleStdioRequest( }, clearContextPackNodeIds: (sessionId) => ensureContextPackNodeIds(sessionState).delete(sessionId), getContextPackHandle: (handleId) => ensureContextPackHandles(sessionState).get(handleId), + takeContextPackHandle: (handleId) => { + const handles = ensureContextPackHandles(sessionState) + const stored = handles.get(handleId) + handles.delete(handleId) + return stored + }, setContextPackHandle: (handleId, expansion) => { const handles = ensureContextPackHandles(sessionState) if (!handles.has(handleId) && handles.size >= MAX_CONTEXT_PROMPT_SESSIONS) { @@ -927,6 +1000,7 @@ export function handleStdioRequest( } handles.set(handleId, expansion) }, + clearContextPackHandles: () => ensureContextPackHandles(sessionState).clear(), getContextPackCache: (cacheKey) => ensureContextPackCache(sessionState).get(cacheKey), setContextPackCache: (cacheKey, payloadText) => { const cache = ensureContextPackCache(sessionState) @@ -1043,6 +1117,7 @@ export async function serveGraphStdio(options: ServeGraphStdioOptions): Promise< const output = options.output ?? process.stdout const errorOutput = options.errorOutput ?? process.stderr const sessionState = createSessionState() + const strictContextPackProfile = resolveToolProfileFromEnv() === 'strict' let autoRefresh: GraphAutoRefreshController | null = null if (options.autoRefresh) { @@ -1105,13 +1180,15 @@ export async function serveGraphStdio(options: ServeGraphStdioOptions): Promise< } if (refreshReadiness && !refreshReadiness.ready && requestMethod === 'prompts/list') { - response = ok(requestId(request), { prompts: MCP_PROMPTS }) + response = ok(requestId(request), { prompts: strictContextPackProfile ? [] : MCP_PROMPTS }) } else if (refreshReadiness && !refreshReadiness.ready && requestMethod === 'resources/list') { response = ok(requestId(request), { resources: [] }) } else if (refreshReadiness && !refreshReadiness.ready && requestMethod !== null && !AUTO_REFRESH_CONTROL_METHODS.has(requestMethod)) { response = graphNotReadyResponse(request, refreshReadiness, waitedMs) } else { - emitResourceNotifications(output, options.graphPath, sessionState) + if (!strictContextPackProfile) { + emitResourceNotifications(output, options.graphPath, sessionState) + } response = await Promise.resolve(handleStdioRequest(options.graphPath, payload, sessionState)) } } catch (error) { diff --git a/src/runtime/stdio/definitions.ts b/src/runtime/stdio/definitions.ts index c243a2b1..7dece3b8 100644 --- a/src/runtime/stdio/definitions.ts +++ b/src/runtime/stdio/definitions.ts @@ -240,18 +240,18 @@ export const MCP_TOOLS: McpToolDefinition[] = [ { name: 'context_pack', description: - 'Build a compact context pack.', + 'Build an answer-ready compact context pack. Call exactly once per user task and copy the entire user codebase request verbatim, byte-for-byte into prompt, including read-only, no-change, scope, and formatting constraints; do not rewrite, omit, expand, enumerate, split, or issue follow-up context_pack calls. For read-only explain tasks, ready or ready_with_caveat is terminal and must be answered from the pack. For normal agent work, pass prompt and task only; verbose diagnostics are a full-profile developer option.', inputSchema: { type: 'object', required: ['prompt'], properties: { - prompt: { type: 'string', description: 'Task prompt or question' }, + prompt: { type: 'string', description: 'The entire user codebase request copied verbatim, byte-for-byte, including read-only, no-change, scope, and formatting constraints; do not paraphrase, omit, or expand it.' }, task: { type: 'string', enum: ['explain', 'implement', 'review', 'impact'], description: 'Mode override.' }, budget: { type: 'number', description: 'Pack token budget.' }, require_fresh_graph: { type: 'boolean', description: 'Refuse repo drift.' }, require_fresh_context: { type: 'boolean', description: 'Refuse selected-context drift.' }, delta_session_id: { type: 'string', description: 'Delta-pack session key.' }, - verbose: { type: 'boolean', description: 'Include selection diagnostics.' }, + verbose: { type: 'boolean', description: 'Full-profile developer diagnostics; may be much larger than the answer-ready pack.' }, retrieval_strategy: { type: 'string', enum: ['default', 'slice-v1'], description: 'Retrieval strategy.' }, resolution: { type: 'string', @@ -275,7 +275,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ { name: 'context_expand', description: - 'Expand a previously returned context_pack handle_id into a focused follow-up pack. Use when a context_pack response omitted supporting nodes that now need exact expansion.', + 'Expand one listed verification handle only when the preceding context_pack returned answerability.state verify_targets. Never call for ready or ready_with_caveat, and never replace a second context_pack call with a rewritten prompt.', inputSchema: { type: 'object', required: ['handle_id'], @@ -410,9 +410,11 @@ export type McpCoreToolName = (typeof CORE_TOOL_NAMES)[number] /** * The compact context-pack-first surface installed by `--profile strict`. - * Keep this list aligned with every tool named by strict agent guidance. + * A strict task starts with one answer-ready pack and can make one + * authorization-bound verification expansion. General graph navigation stays + * in core/full so a ready pack cannot invite an agent to keep exploring. */ -export const STRICT_TOOL_NAMES = [...CORE_TOOL_NAMES, 'context_pack', 'context_expand'] as const +export const STRICT_TOOL_NAMES = ['context_pack', 'context_expand'] as const export type McpStrictToolName = (typeof STRICT_TOOL_NAMES)[number] @@ -443,9 +445,41 @@ function withoutSemanticFields(tool: McpToolDefinition): McpToolDefinition { } } +function strictToolSchema(tool: McpToolDefinition): McpToolDefinition { + const allowedProperties = tool.name === 'context_pack' + ? ['prompt', 'task'] + : tool.name === 'context_expand' + ? ['handle_id'] + : null + if (allowedProperties === null) { + return tool + } + + const properties = Object.fromEntries( + allowedProperties + .map((name) => [name, tool.inputSchema.properties[name]] as const) + .filter(([, definition]) => definition !== undefined), + ) + return { + ...tool, + ...(tool.name === 'context_expand' + ? { + description: `${tool.description} This is the one strict verification attempt: treat its result as terminal and never follow a new handle from it.`, + } + : {}), + inputSchema: { + ...tool.inputSchema, + properties, + }, + } +} + export function activeMcpTools(profile: McpToolProfile = 'core', options: ActiveMcpToolsOptions = {}): McpToolDefinition[] { const enabledNames = new Set(profile === 'strict' ? STRICT_TOOL_NAMES : CORE_TOOL_NAMES) - const tools = profile === 'full' ? MCP_TOOLS : MCP_TOOLS.filter((tool) => enabledNames.has(tool.name)) + let tools = profile === 'full' ? MCP_TOOLS : MCP_TOOLS.filter((tool) => enabledNames.has(tool.name)) + if (profile === 'strict') { + tools = tools.map((tool) => strictToolSchema(tool)) + } if (options.semanticAvailable === false) { return tools.map((tool) => withoutSemanticFields(tool)) } diff --git a/src/runtime/stdio/tools.ts b/src/runtime/stdio/tools.ts index f884ae13..12290a8c 100644 --- a/src/runtime/stdio/tools.ts +++ b/src/runtime/stdio/tools.ts @@ -44,6 +44,7 @@ import { type RetrieveResult, type RetrieveSnippetOptions, } from '../retrieve.js' +import { buildRetrievalEvidencePlan } from '../retrieve/pipeline.js' import { computeContextPackDiagnostics } from '../context-pack-diagnostics.js' import { collectPackNodeIds, computeDeltaContextPack } from '../context-pack-delta.js' import { buildContextPackGovernanceReceipt } from '../context-pack-governance.js' @@ -106,8 +107,11 @@ interface ToolHelpers { getContextPromptSession(sessionId: string): ContextSessionState | undefined setContextPromptSession(sessionId: string, nextState: ContextSessionState): void clearContextPromptSession(sessionId: string): boolean + strictContextPackMode: boolean getContextPackHandle(handleId: string): unknown + takeContextPackHandle(handleId: string): unknown setContextPackHandle(handleId: string, expansion: unknown): void + clearContextPackHandles(): void getContextPackCache(cacheKey: string): string | undefined setContextPackCache(cacheKey: string, payloadText: string): void clearContextPackCache(cacheKey: string): boolean @@ -491,22 +495,46 @@ function contextMetadata( } function evidenceForRetrievePayload( - payload: Partial> & { - matched_nodes?: Array<{ source_file: string }> - question?: string + payload: Partial> & { + matched_nodes?: ReadonlyArray<{ label: string; source_file: string; snippet?: string | null }> + relationships?: readonly unknown[] }, graphPath: string, ) { + const matchedNodes = payload.matched_nodes ?? [] + const relationships = payload.relationships ?? [] + const coveredWorkflowOwners = collectWorkflowOwners(matchedNodes.map((node) => node.source_file)) + const evidencePlan = buildRetrievalEvidencePlan({ + ...(payload.task_contract ? { taskContract: payload.task_contract } : {}), + ...(payload.coverage ? { coverage: payload.coverage } : {}), + ...(payload.expandable ? { expandable: payload.expandable } : {}), + ...(payload.execution_slice ? { executionSlice: payload.execution_slice } : {}), + ...(payload.answer_contract ? { answerContract: payload.answer_contract } : {}), + missingPhases: missingPhasesFromPayload(payload), + coveredWorkflowOwners, + selectedNodeCount: matchedNodes.length, + selectedRelationshipCount: relationships.length, + ...(payload.question + ? { + question: payload.question, + matchedNodes, + } + : {}), + }) return buildMadarResponseEvidence({ - answerContract: payload.answer_contract, - coverage: payload.coverage, - executionSlice: payload.execution_slice, - expandable: payload.expandable, + evidencePlan, graphPath, question: payload.question, recovery: payload.recovery, - missingPhases: missingPhasesFromPayload(payload), - coveredWorkflowOwners: collectWorkflowOwners((payload.matched_nodes ?? []).map((node) => node.source_file)), }) } @@ -799,8 +827,18 @@ function storeExpandableHandles( taskIntent: TaskContextPlan['evidence']['recipe_id'], expandable: readonly ContextPackExpandableRef[], helpers: ToolHelpers, + authorizedHandleIds?: ReadonlySet, ): void { + // Strict mode exposes context_expand only as a bounded verification path. + // Do not make handles available merely because a pre-serialization pack + // happened to contain them: serialization can promote a response to ready. + if (helpers.strictContextPackMode && authorizedHandleIds === undefined) { + return + } for (const entry of expandable) { + if (helpers.strictContextPackMode && !authorizedHandleIds?.has(entry.handle_id)) { + continue + } helpers.setContextPackHandle(entry.handle_id, { prompt, task, @@ -813,6 +851,269 @@ function storeExpandableHandles( } } +const STRICT_VERIFICATION_HANDLE_ID = 'strict-verify-target' + +function stringValues(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) + : [] +} + +function sourceRanges(value: unknown): ContextPackExpandableFollowUp['focus_ranges'] { + return Array.isArray(value) + ? value.filter(isExpandableSourceRange) + : [] +} + +function strictPayloadExpandableEntries(payload: Record): ContextPackExpandableRef[] { + const pack = isObjectRecord(payload.pack) ? payload.pack : null + const entries = [...(Array.isArray(payload.expandable) ? payload.expandable : []), ...(Array.isArray(pack?.expandable) ? pack.expandable : [])] + const byHandle = new Map() + for (const entry of entries) { + if (isContextPackExpandableRef(entry)) { + byHandle.set(entry.handle_id, entry) + } + } + return [...byHandle.values()] +} + +interface StrictVerificationAuthorization { + entry: ContextPackExpandableRef + target: Record +} + +function strictVerificationAuthorization(payload: Record): StrictVerificationAuthorization | null { + const evidence = isObjectRecord(payload.evidence) ? payload.evidence : null + const answerability = evidence && isObjectRecord(evidence.answerability) ? evidence.answerability : null + if (!answerability || answerability.state !== 'verify_targets') { + return null + } + + const entries = strictPayloadExpandableEntries(payload) + const candidate = Array.isArray(answerability.verification_targets) + ? answerability.verification_targets.find(isObjectRecord) ?? null + : null + const requestedHandleId = typeof candidate?.handle_id === 'string' && candidate.handle_id.length > 0 + ? candidate.handle_id + : null + const candidateFocusFiles = stringValues(candidate?.focus_files) + const candidateEvidenceClass = isContextPackEvidenceClass(candidate?.evidence_class) + ? candidate.evidence_class + : 'supporting' + const matchingEntry = requestedHandleId + ? entries.find((entry) => entry.handle_id === requestedHandleId) + : entries.find((entry) => ( + candidateFocusFiles.some((file) => entry.follow_up.focus_files.includes(file)) + && entry.evidence_class === candidateEvidenceClass + )) + const entry = matchingEntry ?? (() => { + const focusFiles = candidateFocusFiles.length > 0 + ? candidateFocusFiles + : entries[0]?.follow_up.focus_files ?? [] + if (focusFiles.length === 0) { + return null + } + const evidenceClass = candidateEvidenceClass + return { + kind: 'nodes' as const, + handle_id: requestedHandleId ?? STRICT_VERIFICATION_HANDLE_ID, + evidence_class: evidenceClass, + count: focusFiles.length, + preview: [], + follow_up: { + kind: 'context_pack' as const, + task_kind: isContextPackTaskKind(payload.task) ? payload.task : 'explain', + evidence_class: evidenceClass, + focus_files: focusFiles, + focus_ranges: sourceRanges(candidate?.focus_ranges), + }, + } satisfies ContextPackExpandableRef + })() + if (!entry || entry.follow_up.focus_files.length === 0) { + return null + } + + return { + entry, + target: { + handle_id: entry.handle_id, + evidence_class: entry.evidence_class, + focus_files: entry.follow_up.focus_files.slice(0, 5), + focus_ranges: entry.follow_up.focus_ranges.slice(0, 5), + reason: typeof candidate?.reason === 'string' && candidate.reason.length > 0 + ? candidate.reason + : `verify evidence:${entry.evidence_class}`, + }, + } +} + +function strictVerificationHandleIds(payload: unknown): Set { + if (!isObjectRecord(payload)) { + return new Set() + } + const authorization = strictVerificationAuthorization(payload) + return authorization ? new Set([authorization.entry.handle_id]) : new Set() +} + +export function constrainStrictContextPackPayload>( + payload: T, + helpers: Pick, +): T { + if (!helpers.strictContextPackMode) { + return payload + } + + const mutablePayload = payload as Record + const evidence = isObjectRecord(mutablePayload.evidence) ? mutablePayload.evidence : null + const answerability = evidence && isObjectRecord(evidence.answerability) + ? evidence.answerability + : null + const pack = isObjectRecord(mutablePayload.pack) ? mutablePayload.pack : null + const authorization = strictVerificationAuthorization(mutablePayload) + const handleIds = authorization ? new Set([authorization.entry.handle_id]) : new Set() + const retainedExpandable = authorization ? [authorization.entry] : [] + + if (answerability?.state === 'verify_targets' && authorization) { + // Strict mode grants a single server-owned expansion. Normalize fallback + // file targets into the same handle-backed shape as ordinary expandable + // evidence, so every visible verify_targets result is actually callable. + answerability.verification_targets = [authorization.target] + } else if (answerability?.state === 'verify_targets') { + // Do not leave an impossible instruction behind if no safe focus could be + // constructed from server-generated evidence. + answerability.state = 'insufficient' + answerability.verification_targets = [] + answerability.broad_search_fallback = answerability.broad_search_fallback === 'blocked' + ? 'blocked' + : 'allowed' + const caveats = stringValues(answerability.caveats) + const caveat = 'strict profile could not authorize a bounded verification target' + answerability.caveats = caveats.includes(caveat) ? caveats : [...caveats, caveat] + if (evidence) { + evidence.pack_confidence = 'low' + evidence.agent_directive = 'explore_with_caution' + } + } + + if (retainedExpandable.length > 0) { + mutablePayload.expandable = retainedExpandable + if (pack) { + pack.expandable = retainedExpandable + } + } else { + delete mutablePayload.expandable + if (pack) { + delete pack.expandable + } + } + + const governance = isObjectRecord(mutablePayload.governance) ? mutablePayload.governance : null + const directive = governance && isObjectRecord(governance.directive) + ? governance.directive + : null + if (directive) { + if (answerability) { + directive.answerability = answerability.state + directive.missing_obligation_count = stringValues(answerability.missing_obligations).length + } + if (evidence) { + directive.pack_confidence = evidence.pack_confidence + directive.agent_directive = evidence.agent_directive + } + directive.verification_target_count = handleIds.size + } + if (governance && isObjectRecord(governance.follow_up)) { + if (retainedExpandable.length === 0) { + delete governance.follow_up + } else { + governance.follow_up = { + expandable_handle_count: retainedExpandable.length, + expandable_evidence_classes: [...new Set(retainedExpandable.map((entry) => entry.evidence_class))], + expansion_task_kinds: [...new Set(retainedExpandable.map((entry) => entry.follow_up.task_kind))], + preview_item_count: retainedExpandable.reduce((total, entry) => total + entry.preview.length, 0), + focus_file_count: retainedExpandable.reduce((total, entry) => total + entry.follow_up.focus_files.length, 0), + focus_range_count: retainedExpandable.reduce((total, entry) => total + entry.follow_up.focus_ranges.length, 0), + } + } + } + + return payload +} + +function constrainStrictContextExpansionPayload>( + payload: T, + helpers: Pick, +): T { + if (!helpers.strictContextPackMode) { + return payload + } + + const mutablePayload = payload as Record + const evidence = isObjectRecord(mutablePayload.evidence) ? mutablePayload.evidence : null + const answerability = evidence && isObjectRecord(evidence.answerability) + ? evidence.answerability + : null + const pack = isObjectRecord(mutablePayload.pack) ? mutablePayload.pack : null + + // A strict expansion consumes the only server-authorized verification + // attempt. Never return a newly generated expandable/verify_targets loop: + // its handles are deliberately not stored, so advertising them would make + // the response instruct an agent to issue a guaranteed-to-fail call. + delete mutablePayload.expandable + delete mutablePayload.handle_id + if (pack) { + delete pack.expandable + } + + if (answerability && (answerability.state === 'verify_targets' || answerability.state === 'insufficient')) { + answerability.state = 'insufficient' + answerability.verification_targets = [] + answerability.broad_search_fallback = 'blocked' + const caveats = stringValues(answerability.caveats) + const caveat = 'strict verification expansion limit reached; remaining targets were not authorized' + answerability.caveats = caveats.includes(caveat) ? caveats : [...caveats, caveat] + if (evidence) { + evidence.pack_confidence = 'low' + // The strict expansion cap deliberately blocks source probing. Preserve + // the selected evidence and tell the agent to report its remaining + // uncertainty from that evidence instead of treating a low confidence + // label as permission to restart discovery. + evidence.agent_directive = 'answer_from_pack' + } + } else if (answerability && Array.isArray(answerability.verification_targets)) { + answerability.verification_targets = [] + } + + return payload +} + +function storeFinalContextPackHandles( + prompt: string, + task: ContextPackTaskKind, + taskIntent: TaskContextPlan['evidence']['recipe_id'], + expandable: readonly ContextPackExpandableRef[], + responsePayload: unknown, + helpers: ToolHelpers, +): void { + // A successful strict pack starts the next task boundary. Do this at the + // commit point rather than at request entry: rejected input or freshness + // validation must not revoke the prior pack's authorized verification. + if (helpers.strictContextPackMode) { + helpers.clearContextPackHandles() + } + const finalExpandable = helpers.strictContextPackMode && isObjectRecord(responsePayload) + ? strictPayloadExpandableEntries(responsePayload) + : expandable + storeExpandableHandles( + prompt, + task, + taskIntent, + finalExpandable, + helpers, + helpers.strictContextPackMode ? strictVerificationHandleIds(responsePayload) : undefined, + ) +} + function buildFocusedExpansionPayload( graph: KnowledgeGraph, graphPath: string, @@ -963,13 +1264,18 @@ function buildFocusedExpansionPayload( const metadata = contextMetadata(pack) storeExpandableHandles(stored.prompt, stored.task, stored.task_intent, metadata.expandable, helpers) + const compactRetrieval = compactRetrieveResult(retrieval) return { ...contextPackBasePayload(stored.task, stored.prompt, budget, graphPath, plan), handle_id: handleId, - pack: compactRetrieveResult(retrieval), + pack: compactRetrieval, matched_focus: nodeCandidates.length, ...metadata, - evidence: evidenceForRetrievePayload(retrieval, graphPath), + evidence: evidenceForRetrievePayload({ + ...retrieval, + matched_nodes: compactRetrieval.matched_nodes, + relationships: compactRetrieval.relationships, + }, graphPath), } } @@ -1245,7 +1551,7 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa } return helpers.ok(id, helpers.textToolResult(JSON.stringify({ ...payload, - evidence: evidenceForRetrievePayload(result, graphPath), + evidence: evidenceForRetrievePayload(payload, graphPath), }))) }).catch((error: unknown) => { // A rejected retrieve (e.g. missing optional semantic dependency) must @@ -1359,13 +1665,6 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa if (!isCachedExplainContextPackPayload(cachedPayload)) { throw new Error('Malformed cached explain context_pack payload') } - storeExpandableHandles( - cachedPayload.prompt, - 'explain', - cachedPayload.task_intent, - cachedPayload.expandable ?? [], - helpers, - ) const cachedGraphFreshness = analyzeGraphContextFreshness(graphPath, graph, { selected_source_files: selectedContextSourceFilesFromCachedExplainPayload(cachedPayload), }) @@ -1380,9 +1679,17 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa ) } } - const cachedWithFreshness = withUpdatedContextPackGovernanceFreshness( + const cachedWithFreshness = constrainStrictContextPackPayload(withUpdatedContextPackGovernanceFreshness( withUpdatedContextPackGovernanceCacheStatus(cachedPayload, 'hit'), cachedGraphFreshness, + ), helpers) + storeFinalContextPackHandles( + cachedPayload.prompt, + 'explain', + cachedPayload.task_intent, + cachedPayload.expandable ?? [], + cachedWithFreshness, + helpers, ) return helpers.ok(id, helpers.textToolResult(JSON.stringify(withContextPackCache(cachedWithFreshness, { status: 'hit', @@ -1405,7 +1712,6 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa }) const compactPack = compactPrImpactResult(prResult) const reviewMetadata = contextMetadata(prResult.review_bundle) - storeExpandableHandles(prompt, task, initialPlan.evidence.recipe_id, reviewMetadata.expandable, helpers) const plan = buildTaskContextPlan({ task_kind: 'review', prompt, @@ -1424,7 +1730,7 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa prResult.review_context.test_paths, ), }) - const payload = withContextPackGovernance({ + const payload = constrainStrictContextPackPayload(withContextPackGovernance({ ...contextPackBasePayload(task, prompt, resolvedBudget, graphPath, plan), pack: compactPack, ...reviewMetadata, @@ -1438,7 +1744,15 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa expandable: reviewMetadata.expandable, cacheEligible: false, cacheStatus: 'bypass', - }) + }), helpers) + storeFinalContextPackHandles( + prompt, + task, + initialPlan.evidence.recipe_id, + reviewMetadata.expandable, + payload, + helpers, + ) return helpers.ok(id, helpers.textToolResult(JSON.stringify(payload))) } @@ -1477,7 +1791,6 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa }) const impactPack = compactImpactResult(impactResult) const metadata = impactMetadata(impactResult, resolvedBudget, prompt, initialPlan.evidence.recipe_id, contextPackLevelTyped ?? undefined) - storeExpandableHandles(prompt, task, initialPlan.evidence.recipe_id, metadata.expandable, helpers) const evidence = buildMadarResponseEvidence({ coverage: metadata.coverage, graphPath, @@ -1487,7 +1800,7 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa impactResult.affected_files ?? [], ), }) - return helpers.ok(id, helpers.textToolResult(JSON.stringify(withContextPackGovernance({ + const payload = constrainStrictContextPackPayload(withContextPackGovernance({ ...contextPackBasePayload(task, prompt, resolvedBudget, graphPath, initialPlan), target: impactTarget, pack: impactPack, @@ -1504,7 +1817,16 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa resolution, cacheEligible: false, cacheStatus: 'bypass', - })))) + }), helpers) + storeFinalContextPackHandles( + prompt, + task, + initialPlan.evidence.recipe_id, + metadata.expandable, + payload, + helpers, + ) + return helpers.ok(id, helpers.textToolResult(JSON.stringify(payload))) } const fullPack = contextPackFromRetrieveResult(retrieval) @@ -1516,7 +1838,6 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa taskIntent: initialPlan.evidence.recipe_id, }) : undefined - storeExpandableHandles(prompt, task, initialPlan.evidence.recipe_id, metadata.expandable, helpers) // Slice #78: emit context-pack quality diagnostics so callers can // detect bad runs (missing required evidence, zero claims, weak // retrieval, etc.) without re-implementing the heuristics. @@ -1550,10 +1871,10 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa } // applyContextPackResolution preserves all fields and only // mutates `snippet` to null, so the shape is structurally - // compatible with T. The exactOptionalPropertyTypes rule can't - // see through the spread; the as-cast bridges it. - // CodeRabbit fix: forward resolution_map so callers know which - // nodes were summarized vs kept in detail. + // compatible with T. The exactOptionalPropertyTypes rule can't + // see through the spread; the as-cast bridges it. + // CodeRabbit fix: forward resolution_map so callers know which + // nodes were summarized vs kept in detail. const result = applyContextPackResolution( nodes as unknown as ContextPackNode[], relationships ? { resolution, relationships } : { resolution }, @@ -1593,7 +1914,7 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa missingPhases: missingPhasesFromPayload(deltaResult.delta_pack), coveredWorkflowOwners: collectWorkflowOwners(resolvedDeltaNodes.nodes.map((node) => node.source_file)), }) - return helpers.ok(id, helpers.textToolResult(JSON.stringify(withContextPackGovernance({ + const deltaPayload = constrainStrictContextPackPayload(withContextPackGovernance({ ...contextPackBasePayload(task, prompt, resolvedBudget, graphPath, initialPlan), mode: 'delta', delta_session_id: deltaSessionId, @@ -1631,27 +1952,37 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa cacheEligible: false, cacheStatus: 'bypass', deltaSessionId, - })))) + }), helpers) + storeFinalContextPackHandles( + prompt, + task, + initialPlan.evidence.recipe_id, + metadata.expandable, + deltaPayload, + helpers, + ) + return helpers.ok(id, helpers.textToolResult(JSON.stringify(deltaPayload))) } const resolvedNodes = applyResolutionToNodes(compactPack.matched_nodes, compactPack.relationships) - const evidence = buildMadarResponseEvidence({ - answerContract: fullPack.answer_contract, - coverage: fullPack.coverage, - executionSlice: fullPack.execution_slice, - expandable: fullPack.expandable, - graphPath, + const serializedPack = { + ...(explainPayload?.pack ?? compactPack), + matched_nodes: resolvedNodes.nodes, + } + const evidence = evidenceForRetrievePayload({ question: prompt, - recovery: fullPack.recovery, - missingPhases: missingPhasesFromPayload(fullPack), - coveredWorkflowOwners: collectWorkflowOwners(resolvedNodes.nodes.map((node) => node.source_file)), - }) + ...(fullPack.task_contract ? { task_contract: fullPack.task_contract } : {}), + ...(fullPack.coverage ? { coverage: fullPack.coverage } : {}), + ...(fullPack.execution_slice ? { execution_slice: fullPack.execution_slice } : {}), + ...(fullPack.answer_contract ? { answer_contract: fullPack.answer_contract } : {}), + ...(fullPack.expandable ? { expandable: fullPack.expandable } : {}), + ...(fullPack.recovery ? { recovery: fullPack.recovery } : {}), + matched_nodes: resolvedNodes.nodes, + relationships: serializedPack.relationships, + }, graphPath) const basePayload = withContextPackGovernance({ ...contextPackBasePayload(task, prompt, resolvedBudget, graphPath, initialPlan), resolution, - pack: { - ...(explainPayload?.pack ?? compactPack), - matched_nodes: resolvedNodes.nodes, - }, + pack: serializedPack, ...(resolvedNodes.bytes_saved > 0 ? { bytes_saved_by_resolution: resolvedNodes.bytes_saved, resolution_map: resolvedNodes.resolution_map } : {}), @@ -1674,9 +2005,18 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa cacheEligible: cacheKey !== null && cacheGraphVersion !== null, cacheStatus: cacheKey && cacheGraphVersion ? 'miss' : 'bypass', }) - const responsePayload = task === 'explain' && !includeSelectionDiagnostics + const unconstrainedResponsePayload = task === 'explain' && !includeSelectionDiagnostics ? buildAnswerReadyPackSchema(basePayload, resolvedBudget, fullPack.selection_diagnostics) : basePayload + const responsePayload = constrainStrictContextPackPayload(unconstrainedResponsePayload, helpers) + storeFinalContextPackHandles( + prompt, + task, + initialPlan.evidence.recipe_id, + metadata.expandable, + responsePayload, + helpers, + ) if (!cacheKey || !cacheGraphVersion) { return helpers.ok(id, helpers.textToolResult(JSON.stringify(responsePayload))) } @@ -1706,22 +2046,33 @@ export function handleToolCall(id: string | number | null, graphPath: string, pa if (Object.hasOwn(toolArguments, 'budget') && budget === null) { return helpers.failure(id, helpers.jsonrpcInvalidParams, `budget must be a number between 1 and ${helpers.maxStdioTokenBudget}`) } - const stored = helpers.getContextPackHandle(handleId) + const stored = helpers.strictContextPackMode + ? helpers.takeContextPackHandle(handleId) + : helpers.getContextPackHandle(handleId) if (!stored || typeof stored !== 'object' || Array.isArray(stored)) { - return helpers.failure(id, helpers.jsonrpcInvalidParams, `Unknown context_pack handle_id '${handleId}'. Expand handles are only available within the MCP session that produced them.`) + const message = helpers.strictContextPackMode + ? `Unknown or unauthorized context_pack handle_id '${handleId}'. Strict expansions must use one listed verification handle from the latest verify_targets pack.` + : `Unknown context_pack handle_id '${handleId}'. Expand handles are only available within the MCP session that produced them.` + return helpers.failure(id, helpers.jsonrpcInvalidParams, message) } if (!isStoredContextPackHandle(stored)) { return helpers.failure(id, helpers.jsonrpcInvalidParams, `Malformed context_pack handle_id '${handleId}'. Re-run context_pack and retry context_expand within the same MCP session.`) } + if (helpers.strictContextPackMode) { + // A strict pack authorizes one verification attempt total. Clearing + // the remaining handles prevents a multi-target pack from becoming a + // hidden graph-navigation loop. + helpers.clearContextPackHandles() + } - const payload = buildFocusedExpansionPayload( + const payload = constrainStrictContextExpansionPayload(buildFocusedExpansionPayload( graph, graphPath, handleId, stored, budget ?? 1500, helpers, - ) + ), helpers) return helpers.ok(id, helpers.textToolResult(JSON.stringify(payload))) } case 'context_prompt': { diff --git a/tests/fixtures/cross-layer-monitor-flow.ts b/tests/fixtures/cross-layer-monitor-flow.ts index ce7c72d7..a17dc471 100644 --- a/tests/fixtures/cross-layer-monitor-flow.ts +++ b/tests/fixtures/cross-layer-monitor-flow.ts @@ -6,6 +6,7 @@ interface FlowNode { source: string community: number role?: string + snippet?: string metadata?: Record } @@ -16,6 +17,7 @@ const FLOW_NODES: FlowNode[] = [ source: '/apps/checker/update.go', community: 1, role: 'monitor_checker', + snippet: "if (monitorCheck.status === 'error') await publishUpdate()", }, { id: 'status_route', @@ -23,6 +25,7 @@ const FLOW_NODES: FlowNode[] = [ source: '/apps/workflows/src/checker/index.ts', community: 2, role: 'http_route', + snippet: 'await db.insert(incidentTable); await triggerNotifications()', metadata: { route_path: '/updateStatus', http_method: 'POST' }, }, { @@ -31,6 +34,7 @@ const FLOW_NODES: FlowNode[] = [ source: '/apps/workflows/src/checker/alerting.ts', community: 2, role: 'incident_writer', + snippet: 'await db.insert(incidentTable).values({ monitorId })', }, { id: 'notification_dispatch', @@ -38,6 +42,7 @@ const FLOW_NODES: FlowNode[] = [ source: '/apps/workflows/src/checker/utils.ts', community: 3, role: 'notification_dispatcher', + snippet: 'await providerToFunction[provider].sendAlert(notification)', }, { id: 'incident_table', @@ -45,6 +50,7 @@ const FLOW_NODES: FlowNode[] = [ source: '/packages/db/src/schema/incidents.ts', community: 4, role: 'persistence_schema', + snippet: 'const incidentTable = table({ status, resolvedAt, monitorId })', }, { id: 'public_status', @@ -52,13 +58,15 @@ const FLOW_NODES: FlowNode[] = [ source: '/packages/api/src/router/statusPage.ts', community: 5, role: 'public_status_reader', + snippet: "const status = events.some((e) => e.type === 'incident' && !e.to) && barType !== 'manual' ? 'error' : 'active'", }, { id: 'alternate_status', label: 'computeOverallStatus', - source: '/apps/server/src/routes/status-page/index.ts', + source: '/apps/server/src/routes/rpc/handlers/status-page/index.ts', community: 6, role: 'alternate_status_computation', + snippet: 'const overallStatus = hasActiveStatusReport ? DEGRADED : hasActiveMaintenance ? MAINTENANCE : OPERATIONAL', }, { id: 'status_json', @@ -66,6 +74,15 @@ const FLOW_NODES: FlowNode[] = [ source: '/apps/status-page/src/content/status-json.ts', community: 5, role: 'public_status_feed', + snippet: 'const data = trpc.statusPage.get.queryOptions(); return { status: toStatus(data), summary: toSummary(data), incidents: toUnresolvedIncidents(data) }', + }, + { + id: 'external_effective_status', + label: 'computeEffectiveStatus', + source: '/packages/api/src/router/external-service/effective-status.ts', + community: 7, + role: 'external_service_status_computation', + snippet: 'return externalProviderReports.length > 0 ? DEGRADED : OPERATIONAL', }, ] @@ -94,6 +111,8 @@ const FLOW_EDGES: Array<[string, string, string]> = [ ['public_status', 'incident_table', 'reads'], ['status_json', 'public_status', 'serializes'], ['alternate_status', 'status_json', 'competes_with'], + ['external_effective_status', 'status_route', 'shares_status_vocabulary_with'], + ['external_effective_status', 'alternate_status', 'shares_computation_vocabulary_with'], ] export const CROSS_LAYER_MONITOR_FLOW_FILES = [ @@ -103,7 +122,7 @@ export const CROSS_LAYER_MONITOR_FLOW_FILES = [ 'apps/workflows/src/checker/utils.ts', 'packages/db/src/schema/incidents.ts', 'packages/api/src/router/statusPage.ts', - 'apps/server/src/routes/status-page/index.ts', + 'apps/server/src/routes/rpc/handlers/status-page/index.ts', 'apps/status-page/src/content/status-json.ts', ] as const @@ -117,6 +136,7 @@ export function buildCrossLayerMonitorFlowFixture(): KnowledgeGraph { 4: 'Incident persistence schema', 5: 'Public status page reads', 6: 'Alternative status computation', + 7: 'External service provider status', 9: 'Status presentation components', } @@ -128,7 +148,7 @@ export function buildCrossLayerMonitorFlowFixture(): KnowledgeGraph { file_type: 'code', node_kind: node.role === 'http_route' ? 'route' : 'function', community: node.community, - snippet: `export function ${node.id}() { return '${node.label}' }`, + snippet: node.snippet ?? `export function ${node.id}() { return '${node.label}' }`, ...(node.role ? { framework_role: node.role } : {}), ...(node.metadata ? { framework_metadata: node.metadata } : {}), }) diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 00000000..4b614375 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,11 @@ +import { mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Project installers are allowed to update Codex's real global configuration. +// Keep unit tests hermetic while exercising that production path. A stable +// per-process path prevents parallel test-file setup from replacing another +// file's global config path while its assertions are running. +const testCodexHome = join(tmpdir(), `madar-vitest-codex-home-${process.pid}`) +mkdirSync(testCodexHome, { recursive: true }) +process.env.CODEX_HOME = testCodexHome diff --git a/tests/unit/compare-native-agent.test.ts b/tests/unit/compare-native-agent.test.ts index 1e55cbfa..c0e40b5b 100644 --- a/tests/unit/compare-native-agent.test.ts +++ b/tests/unit/compare-native-agent.test.ts @@ -43,7 +43,7 @@ function writeLineAnchoredSourceFile(filePath: string, lineNumber: number, conte function makeFixtureProject( options: { installState?: 'managed' | 'valid' | 'missing' - profile?: 'core' | 'full' + profile?: 'core' | 'full' | 'strict' spiMode?: boolean } = {}, ): { projectDir: string; graphPath: string; outputDir: string } { @@ -68,7 +68,7 @@ function makeFixtureProject( 'utf8', ) const graphPath = join(projectDir, 'out', 'graph.json') - const installClaudeWithProfile = claudeInstall as (projectDir?: string, options?: { profile?: 'core' | 'full' }) => string + const installClaudeWithProfile = claudeInstall as (projectDir?: string, options?: { profile?: 'core' | 'full' | 'strict' }) => string if (options.installState === 'managed') { installClaudeWithProfile(projectDir, options.profile ? { profile: options.profile } : undefined) } else if (options.installState !== 'missing') { @@ -1139,6 +1139,55 @@ const VERBOSE_MADAR_FIRST_BOUNDED_PAYLOAD = [ MADAR_USAGE_PAYLOAD, ] as const +function strictContextPackTrace( + prompt: string, + options: { + beforePackTool?: 'Read' | 'Bash' | 'Grep' | 'Glob' + secondPrompt?: string + afterReadyTool?: string + } = {}, +) { + const toolCalls: Array> = [ + ...(options.beforePackTool === undefined + ? [] + : [{ type: 'tool_use', name: options.beforePackTool, input: { path: 'a.ts' } }]), + { + type: 'tool_use', + name: 'mcp__madar__context_pack', + input: { prompt, task: 'explain' }, + }, + { + type: 'tool_result', + tool_name: 'mcp__madar__context_pack', + content: JSON.stringify({ + evidence: { + agent_directive: 'answer_from_pack', + answerability: { state: 'ready' }, + }, + }), + }, + ] + if (options.secondPrompt !== undefined) { + toolCalls.push({ + type: 'tool_use', + name: 'mcp__madar__context_pack', + input: { prompt: options.secondPrompt, task: 'explain' }, + }) + } + if (options.afterReadyTool !== undefined) { + toolCalls.push({ type: 'tool_use', name: options.afterReadyTool, input: { path: 'a.ts' } }) + } + return [ + { type: 'system', subtype: 'init' }, + { + type: 'assistant', + turn: 1, + message: { content: toolCalls }, + }, + MADAR_USAGE_PAYLOAD, + ] +} + const VERBOSE_MADAR_FIRST_LOW_CONFIDENCE_THEN_READY_PAYLOAD = [ { type: 'system', subtype: 'init' }, { @@ -1798,6 +1847,35 @@ describe('executeNativeAgentCompare', () => { } }) + it('writes a strict native-agent prompt that requires one verbatim terminal context pack', async () => { + const { projectDir, graphPath, outputDir } = makeFixtureProject({ profile: 'strict' }) + try { + const result = await executeNativeAgentCompare( + { + graphPath, + question: 'What is the cluster module?', + outputDir, + execTemplate: 'mock-runner', + baselineMode: 'native_agent', + }, + { + runner: scriptedRunner({ baseline: VERBOSE_BASELINE_PAYLOAD, madar: VERBOSE_MADAR_MCP_RETRIEVE_PAYLOAD }), + now: () => new Date('2026-05-01T00:00:00Z'), + }, + ) + + const report = result.reports[0] as NativeAgentCompareReport + const prompt = readFileSync(report.paths.madar_prompt, 'utf8') + + expect(prompt).toContain('Call mcp__madar__context_pack exactly once') + expect(prompt).toContain('Copy the exact Question text below byte-for-byte into context_pack.prompt') + expect(prompt).toContain('Strict exposes only context_pack and context_expand') + expect(prompt).toContain('Both are terminal: make no later MCP, Read, Bash, Glob, or Grep call.') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it('records implement-task outcome scoring with isolated per-arm workspaces and an external graph artifact', async () => { const { projectDir, graphPath, outputDir, questionsPath } = makeImplementationFixtureProject() const externalGraphRoot = mkdtempSync(join(COMPARE_OUTPUT_PARENT, 'external-graph-')) @@ -2764,6 +2842,167 @@ describe('executeNativeAgentCompare', () => { } }) + it('reports a followed prompt contract for one verbatim strict context pack with a terminal answer', async () => { + const { projectDir, graphPath, outputDir } = makeFixtureProject({ profile: 'strict' }) + const question = 'What is the cluster module?' + try { + const result = await executeNativeAgentCompare( + { + graphPath, + question, + outputDir, + execTemplate: 'mock-runner', + baselineMode: 'native_agent', + }, + { + runner: scriptedRunner({ baseline: VERBOSE_BASELINE_PAYLOAD, madar: strictContextPackTrace(question) }), + now: () => new Date('2026-05-01T00:00:00Z'), + }, + ) + + expect((result.reports[0] as NativeAgentCompareReport).prompt_contract).toEqual({ + status: 'followed', + evidence: ['started with context_pack and avoided disallowed exploration'], + }) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('rejects a rewritten strict context-pack prompt', async () => { + const { projectDir, graphPath, outputDir } = makeFixtureProject({ profile: 'strict' }) + const question = 'What is the cluster module?' + try { + const result = await executeNativeAgentCompare( + { + graphPath, + question, + outputDir, + execTemplate: 'mock-runner', + baselineMode: 'native_agent', + }, + { + runner: scriptedRunner({ + baseline: VERBOSE_BASELINE_PAYLOAD, + madar: strictContextPackTrace('Explain the cluster module and all related implementation files.'), + }), + now: () => new Date('2026-05-01T00:00:00Z'), + }, + ) + + expect((result.reports[0] as NativeAgentCompareReport).prompt_contract).toEqual({ + status: 'violated', + evidence: ['strict context_pack prompt did not byte-match the user question'], + }) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('rejects a raw file read before the strict context pack', async () => { + const { projectDir, graphPath, outputDir } = makeFixtureProject({ profile: 'strict' }) + const question = 'What is the cluster module?' + try { + const result = await executeNativeAgentCompare( + { + graphPath, + question, + outputDir, + execTemplate: 'mock-runner', + baselineMode: 'native_agent', + }, + { + runner: scriptedRunner({ + baseline: VERBOSE_BASELINE_PAYLOAD, + madar: strictContextPackTrace(question, { beforePackTool: 'Read' }), + }), + now: () => new Date('2026-05-01T00:00:00Z'), + }, + ) + + const report = result.reports[0] as NativeAgentCompareReport + expect(report.madar_trace).toMatchObject({ + pre_madar_broad_exploration_tool_call_count: 1, + pre_madar_broad_exploration_tool_calls_by_name: { Read: 1 }, + }) + expect(report.prompt_contract).toEqual({ + status: 'violated', + evidence: ['broad exploration occurred before the first Madar call'], + }) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('rejects repeated strict packs and repository calls after a terminal pack', async () => { + const { projectDir, graphPath, outputDir } = makeFixtureProject({ profile: 'strict' }) + const question = 'What is the cluster module?' + try { + const result = await executeNativeAgentCompare( + { + graphPath, + question, + outputDir, + execTemplate: 'mock-runner', + baselineMode: 'native_agent', + }, + { + runner: scriptedRunner({ + baseline: VERBOSE_BASELINE_PAYLOAD, + madar: strictContextPackTrace(question, { + secondPrompt: question, + afterReadyTool: 'Read', + }), + }), + now: () => new Date('2026-05-01T00:00:00Z'), + }, + ) + + expect((result.reports[0] as NativeAgentCompareReport).prompt_contract).toEqual({ + status: 'violated', + evidence: [ + 'strict profile requires exactly one context_pack call (recorded 2)', + 'terminal strict context_pack was followed by another Madar MCP call', + 'terminal strict context_pack was followed by repository exploration', + ], + }) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('does not mark terminal strict compliance as followed after an unknown native tool in the same turn', async () => { + const { projectDir, graphPath, outputDir } = makeFixtureProject({ profile: 'strict' }) + const question = 'What is the cluster module?' + try { + const result = await executeNativeAgentCompare( + { + graphPath, + question, + outputDir, + execTemplate: 'mock-runner', + baselineMode: 'native_agent', + }, + { + runner: scriptedRunner({ + baseline: VERBOSE_BASELINE_PAYLOAD, + madar: strictContextPackTrace(question, { afterReadyTool: 'FilesystemRead' }), + }), + now: () => new Date('2026-05-01T00:00:00Z'), + }, + ) + + expect((result.reports[0] as NativeAgentCompareReport).prompt_contract).toEqual({ + status: 'not_measured', + evidence: [ + 'terminal strict context_pack was followed by unclassified native tool call: FilesystemRead; trace cannot prove terminal compliance', + ], + }) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it('preserves a full-profile first-tool violation even when broad exploration happens later', async () => { const { projectDir, graphPath, outputDir } = makeFixtureProject({ profile: 'full' }) try { diff --git a/tests/unit/compare.test.ts b/tests/unit/compare.test.ts index ad42a2c2..da557b45 100644 --- a/tests/unit/compare.test.ts +++ b/tests/unit/compare.test.ts @@ -4357,7 +4357,8 @@ describe('compare runtime', () => { const madarPrompt = readFileSync(result.reports[0]!.paths.madar_prompt, 'utf8') expect(madarPrompt).toContain('SessionManager') - expect(madarPrompt).toContain('export class SessionManager') + expect(madarPrompt).toContain('createSession(userId)') + expect(madarPrompt).toContain('return new SessionStore().write(userId)') }) it('does not load madar snippets from paths outside the inferred project root', () => { diff --git a/tests/unit/context-pack-command.test.ts b/tests/unit/context-pack-command.test.ts index 964cfa73..66399477 100644 --- a/tests/unit/context-pack-command.test.ts +++ b/tests/unit/context-pack-command.test.ts @@ -8,8 +8,11 @@ import type { ContextPackSelectionDiagnostics } from '../../src/contracts/contex import { KnowledgeGraph } from '../../src/contracts/graph.js' import { buildAnswerReadyPackSchema, runContextPackCommand, type ContextPackCommandDependencies } from '../../src/infrastructure/context-pack-command.js' import { build } from '../../src/pipeline/build.js' +import { assessMadarResponseEvidence } from '../../src/runtime/mcp-response-evidence.js' import { compactRetrieveResult, retrieveContext, type RetrieveResult } from '../../src/runtime/retrieve.js' +import { buildRetrievalEvidencePlanFromResult } from '../../src/runtime/retrieve/pipeline.js' import { estimateQueryTokens } from '../../src/runtime/serve.js' +import { buildCrossLayerMonitorFlowFixture } from '../fixtures/cross-layer-monitor-flow.js' const tempFixtureRoots: string[] = [] const repoGraphFixturePath = join(process.cwd(), 'out', 'graph.json') @@ -757,6 +760,7 @@ describe('context-pack-command', () => { preview_item_count?: number } } + expandable?: unknown[] workflow_centers?: Array<{ path?: string; label?: string }> recommended_first_read?: Array<{ path?: string; label?: string }> negative_guidance?: string[] @@ -776,11 +780,9 @@ describe('context-pack-command', () => { directive: expect.objectContaining({ agent_directive: 'answer_from_pack', }), - follow_up: { - expandable_handle_count: 1, - preview_item_count: 1, - }, })) + expect(payload.governance?.follow_up).toBeUndefined() + expect(payload.expandable).toEqual([]) expect(JSON.stringify(payload.governance)).not.toContain('How idea report is being generated') expect(JSON.stringify(payload.governance)).not.toContain('src/ideas/') expect(payload.workflow_centers?.slice(0, 4)).toEqual([ @@ -1057,6 +1059,175 @@ describe('context-pack-command', () => { ]) }) + it('does not preserve a ready verdict when the serialized pack omits prompt obligations', async () => { + const prompt = 'Explain the exact end-to-end path from a failed HTTP monitor check to incident creation, notification delivery, and the public status-page result. Compare every distinct overall-status computation. Read-only: do not modify files.' + const graph = buildCrossLayerMonitorFlowFixture() + const retrieval = retrieveContext(graph, { + question: prompt, + budget: 1_800, + taskKind: 'explain', + retrievalStrategy: 'slice-v1', + }) + const compact = compactRetrieveResult(retrieval) + const omittedNodeIds = new Set( + compact.matched_nodes + .filter((node) => /apps\/workflows\/src\/checker\/(?:index|alerting|utils)\.ts$/.test(node.source_file)) + .flatMap((node) => node.node_id ? [node.node_id] : []), + ) + const serialized = { + ...compact, + matched_nodes: compact.matched_nodes.filter((node) => !node.node_id || !omittedNodeIds.has(node.node_id)), + relationships: compact.relationships.filter((relationship) => ( + (!relationship.from_id || !omittedNodeIds.has(relationship.from_id)) + && (!relationship.to_id || !omittedNodeIds.has(relationship.to_id)) + )), + } + const optimisticRetrieval: RetrieveResult = { + ...retrieval, + recovery: { + ...retrieval.recovery!, + status: 'not_needed', + initial_state: 'ready', + final_state: 'ready', + attempts: [], + improved: false, + }, + } + const dependencies: ContextPackCommandDependencies = { + loadGraph: vi.fn().mockReturnValue(graph), + retrieveContext: vi.fn().mockReturnValue(optimisticRetrieval), + compactRetrieveResult: vi.fn().mockReturnValue(serialized), + analyzePrImpact: vi.fn(), + compactPrImpactResult: vi.fn(), + analyzeImpact: vi.fn(), + compactImpactResult: vi.fn(), + } + + const payload = JSON.parse(await runContextPackCommand({ + prompt, + budget: 1_800, + task: 'explain', + graphPath: 'out/graph.json', + retrievalStrategy: 'slice-v1', + format: 'json', + }, dependencies)) as { + evidence?: { + coverage?: string + coverage_detail?: { missing_obligations?: string[] } + answerability?: { state?: string; broad_search_fallback?: string } + agent_directive?: string + } + } + + expect(optimisticRetrieval.recovery?.final_state).toBe('ready') + expect(payload.evidence).toMatchObject({ + coverage: 'partial', + answerability: { + state: 'verify_targets', + broad_search_fallback: 'targeted_only', + }, + agent_directive: 'verify_one_targeted_file', + }) + expect(payload.evidence?.coverage_detail?.missing_obligations).toEqual(expect.arrayContaining([ + 'query:obligation:2', + 'query:obligation:3', + ])) + }) + + it('keeps a late unique evidence owner when the eight-node response cap would otherwise drop it', () => { + const prompt = 'Explain the exact end-to-end path from a failed HTTP monitor check to incident creation, notification delivery, and the public status-page result. Compare every distinct overall-status computation.' + const retrieval = retrieveContext(buildCrossLayerMonitorFlowFixture(), { + question: prompt, + budget: 1_800, + taskKind: 'explain', + retrievalStrategy: 'slice-v1', + }) + const compact = compactRetrieveResult(retrieval) + const incidentOwner = compact.matched_nodes.find((node) => node.snippet?.includes('insert(incidentTable)')) + expect(incidentOwner).toBeDefined() + const nonIncidentNodes = compact.matched_nodes + .filter((node) => node.node_id !== incidentOwner?.node_id) + .map((node) => node.snippet?.includes('insert(incidentTable)') + ? { ...node, snippet: 'const incident = await findOpenIncident(monitorId)' } + : node) + const duplicateSource = nonIncidentNodes.find((node) => node.source_file.includes('status-json')) + ?? nonIncidentNodes[0]! + const pressuredNodes = [ + ...nonIncidentNodes, + { ...duplicateSource, node_id: 'duplicate-status-owner-1', label: 'toUnresolvedIncidents' }, + { ...duplicateSource, node_id: 'duplicate-status-owner-2', label: 'unresolvedIncidents' }, + incidentOwner!, + ] + const { score: _score, ...evidence } = assessMadarResponseEvidence({ + evidencePlan: buildRetrievalEvidencePlanFromResult(retrieval), + question: prompt, + recovery: retrieval.recovery, + }) + + const payload = buildAnswerReadyPackSchema({ + schema_version: 1, + task: 'explain', + prompt, + budget: 5_000, + evidence, + expandable: retrieval.expandable ?? [], + pack: { + ...compact, + matched_nodes: pressuredNodes, + }, + }, 5_000, retrieval.selection_diagnostics) + const selectedNodes = (payload.pack as { + matched_nodes: Array<{ node_id?: string; snippet?: string }> + }).matched_nodes + const serializedEvidence = payload.evidence as { + answerability?: { state?: string } + agent_directive?: string + } + + expect(selectedNodes).toHaveLength(8) + expect(selectedNodes.some((node) => node.node_id === incidentOwner?.node_id)).toBe(true) + expect(selectedNodes.map((node) => node.snippet ?? '').join('\n')).toMatch(/insert\(incidentTable\)/) + expect(serializedEvidence).toMatchObject({ + answerability: { state: expect.stringMatching(/^ready(?:_with_caveat)?$/) }, + agent_directive: 'answer_from_pack', + }) + }) + + it('keeps every cited supporting node when one falls beyond the answer-ready node cap', () => { + const matchedNodes = Array.from({ length: 9 }, (_, index) => ({ + node_id: `node-${index}`, + label: `Node${index}`, + source_file: `src/node-${index}.ts`, + line_number: index + 1, + snippet: `export const node${index} = ${index}`, + })) + const claimAnchor = matchedNodes[8]! + const payload = buildAnswerReadyPackSchema({ + prompt: 'Describe this pack.', + evidence: { + agent_directive: 'answer_from_pack', + }, + claims: [{ + evidence_class: 'primary', + text: `input provenance: ${claimAnchor.label} consumes router output`, + node_labels: [matchedNodes[0]!.label, claimAnchor.label], + }], + pack: { + matched_nodes: matchedNodes, + relationships: [], + community_context: [], + }, + }, 5_000) + const selectedNodes = (payload.pack as { + matched_nodes: Array<{ label: string }> + }).matched_nodes + + expect(selectedNodes).toHaveLength(8) + expect(selectedNodes.some((node) => node.label === 'Node0')).toBe(true) + expect(selectedNodes.some((node) => node.label === claimAnchor.label)).toBe(true) + expect(selectedNodes.some((node) => node.label === 'Node7')).toBe(false) + }) + it('compacts envelope metadata and snippets before culling a cross-layer workflow spine', () => { const base = buildOversizedAnswerReadySchema() const extraNodes = Array.from({ length: 4 }, (_, index) => ({ @@ -1159,7 +1330,7 @@ describe('context-pack-command', () => { })) expect(pack.matched_nodes).toHaveLength(8) expect(new Set(pack.matched_nodes?.map((node) => node.source_file))).toHaveLength(8) - expect(pack.matched_nodes?.every((node) => (node.snippet?.length ?? 0) <= 220)).toBe(true) + expect(pack.matched_nodes?.every((node) => (node.snippet?.length ?? 0) <= 300)).toBe(true) expect(pack.relationships).toHaveLength(relationships.length) expect(evidence.pack_confidence).toBe('high') expect(evidence.confidence_reasons ?? []).not.toContain('budget too tight for workflow spine') diff --git a/tests/unit/context-pack-recovery.test.ts b/tests/unit/context-pack-recovery.test.ts index b80ee2c4..4dafb2af 100644 --- a/tests/unit/context-pack-recovery.test.ts +++ b/tests/unit/context-pack-recovery.test.ts @@ -70,7 +70,10 @@ function result(input: { source_file: 'src/supporting.ts', line_number: 1, file_type: 'code', - snippet: 'save()', + // The recovery result must prove the prompt's workflow-persistence + // obligation in a selected source snippet, not merely add a node whose + // label sounds related. + snippet: 'await workflow.persist(record)', match_score: 1, relevance_band: 'related', community: 0, @@ -136,7 +139,7 @@ describe('bounded cumulative context-pack recovery', () => { attempts: [expect.objectContaining({ status: 'improved', changed_result: true, - missing_obligations_before: 1, + missing_obligations_before: 2, missing_obligations_after: 0, })], }) diff --git a/tests/unit/context-pack.test.ts b/tests/unit/context-pack.test.ts index dec70d6e..f50dd9ae 100644 --- a/tests/unit/context-pack.test.ts +++ b/tests/unit/context-pack.test.ts @@ -306,6 +306,198 @@ describe('context-pack', () => { ]) }) + it('places router-output input provenance before generic evidence claims', () => { + const pack = compileContextPack({ + task_contract: classifyTaskContract('explain', { + budget: 80, + prompt: 'Explain how the public status JSON gets its page status.', + }), + nodes: [ + nodeCandidate({ + node_id: 'unresolved_incidents', + label: 'unresolvedIncidents()', + source_file: 'apps/status-page/src/content/status-json.ts', + line_number: 50, + file_type: 'code', + snippet: [ + 'type Page = NonNullable;', + 'function unresolvedIncidents(page: Page) {', + ' return page.statusReports.filter((report) => !report.resolvedAt)', + '}', + ].join('\n'), + match_score: 9, + relevance_band: 'direct', + community: 0, + community_label: 'Public status JSON', + }, 'primary', 20), + ], + }) + + expect(pack.claims).toEqual([ + { + evidence_class: 'primary', + text: 'input provenance: unresolvedIncidents() consumes data typed as the RouterOutputs["statusPage"]["get"] router output', + node_labels: ['unresolvedIncidents()'], + }, + expect.objectContaining({ + evidence_class: 'primary', + text: 'primary evidence: unresolvedIncidents()', + }), + ]) + }) + + it('makes a split public-status projection explicit and deduplicates file-level provenance', () => { + const pack = compileContextPack({ + task_contract: classifyTaskContract('explain', { + budget: 160, + prompt: 'Compare public status computation paths.', + }), + nodes: [ + nodeCandidate({ + node_id: 'unresolved_incidents', + label: 'unresolvedIncidents()', + source_file: 'apps/status-page/src/content/status-json.ts', + line_number: 50, + file_type: 'code', + snippet: [ + 'type Page = NonNullable;', + 'status: pageIndicator(page.status),', + 'return page.statusReports.filter((report) => report.status !== "resolved")', + ].join('\n'), + match_score: 9, + relevance_band: 'direct', + community: 0, + }, 'primary', 20), + nodeCandidate({ + node_id: 'status_json', + label: 'status-json.ts', + source_file: 'apps/status-page/src/content/status-json.ts', + line_number: 1, + file_type: 'code', + snippet: 'type Page = NonNullable;', + match_score: 8, + relevance_band: 'direct', + community: 0, + }, 'primary', 10), + nodeCandidate({ + node_id: 'status_page_router', + label: 'statusPage.ts', + source_file: 'packages/api/src/router/statusPage.ts', + line_number: 226, + file_type: 'code', + snippet: 'events.some((e) => e.type === "incident" && !e.to) && barType !== "manual" ? "error" : activeReportStatus(events)', + match_score: 8, + relevance_band: 'direct', + community: 1, + }, 'primary', 20), + ], + }) + + expect(pack.claims.filter((claim) => claim.text.startsWith('input provenance:'))).toHaveLength(1) + expect(pack.claims[0]).toEqual({ + evidence_class: 'primary', + text: 'public payload divergence: when barType is not manual, an open incident event can make page.status "error" in packages/api/src/router/statusPage.ts; apps/status-page/src/content/status-json.ts builds unresolved incident entries only from page.statusReports, so an auto-created incident without a status report can yield an error indicator with an empty incidents list', + node_labels: ['unresolvedIncidents()', 'statusPage.ts'], + }) + expect(pack.claims[1]?.text).toBe( + 'input provenance: unresolvedIncidents() consumes data typed as the RouterOutputs["statusPage"]["get"] router output', + ) + }) + + it('states the public runtime router provenance separately from an alternate computation', () => { + const pack = compileContextPack({ + task_contract: classifyTaskContract('explain', { + budget: 180, + prompt: 'Identify inconsistent public status computation paths.', + }), + nodes: [ + nodeCandidate({ + node_id: 'public_status_route', + label: 'GET()', + source_file: 'apps/status-page/src/app/api/status/[[...path]]/route.ts', + line_number: 34, + file_type: 'code', + snippet: 'const data = await queryClient.fetchQuery(trpc.statusPage.get.queryOptions({ slug })); const payload = toStatus(data, baseUrl)', + match_score: 10, + relevance_band: 'direct', + community: 0, + }, 'primary', 20), + nodeCandidate({ + node_id: 'status_page_router', + label: 'statusPage.ts', + source_file: 'packages/api/src/router/statusPage.ts', + line_number: 226, + file_type: 'code', + snippet: 'events.some((e) => e.type === "incident" && !e.to) && barType !== "manual"', + match_score: 9, + relevance_band: 'direct', + community: 1, + }, 'primary', 20), + nodeCandidate({ + node_id: 'alternate_status', + label: 'computeOverallStatus()', + source_file: 'apps/server/src/routes/rpc/handlers/status-page/index.ts', + line_number: 360, + file_type: 'code', + snippet: 'const overallStatus = hasActiveStatusReport ? DEGRADED : hasActiveMaintenance ? MAINTENANCE : OPERATIONAL', + match_score: 8, + relevance_band: 'direct', + community: 2, + }, 'primary', 20), + ], + }) + + expect(pack.claims[0]).toEqual({ + evidence_class: 'primary', + text: 'public runtime provenance: apps/status-page/src/app/api/status/[[...path]]/route.ts GET() fetches trpc.statusPage.get and passes that data to the public status-json serializers backed by packages/api/src/router/statusPage.ts; packages/api/src/router/statusPage.ts treats an open incident event as "error" outside manual mode, while apps/server/src/routes/rpc/handlers/status-page/index.ts computeOverallStatus() derives overall status from active status reports and maintenance', + node_labels: ['GET()', 'statusPage.ts', 'computeOverallStatus()'], + }) + }) + + it('states the failed-check transport handoff across Go owners', () => { + const pack = compileContextPack({ + task_contract: classifyTaskContract('explain', { + budget: 120, + prompt: 'Trace a failed monitor check into the workflow.', + }), + nodes: [ + nodeCandidate({ + node_id: 'http_checker_handler', + label: '.HTTPCheckerHandler()', + source_file: 'apps/checker/handlers/checker.go', + line_number: 47, + file_type: 'code', + snippet: 'checker.UpdateStatus(ctx, checker.UpdateData{ Status: "error", MonitorId: req.MonitorID })', + match_score: 10, + relevance_band: 'direct', + community: 0, + }, 'primary', 20), + nodeCandidate({ + node_id: 'update_status', + label: 'UpdateStatus()', + source_file: 'apps/checker/checker/update.go', + line_number: 29, + file_type: 'code', + snippet: 'client, err := cloudtasks.NewClient(ctx); _, err = client.CreateTask(ctx, req)', + match_score: 9, + relevance_band: 'direct', + community: 0, + }, 'primary', 20), + ], + }) + + expect(pack.claims[0]).toEqual({ + evidence_class: 'primary', + text: 'failure detection: apps/checker/handlers/checker.go .HTTPCheckerHandler() sends Status "error" to UpdateStatus', + node_labels: ['.HTTPCheckerHandler()'], + }) + expect(pack.claims[1]).toEqual({ + evidence_class: 'primary', + text: 'cross-runtime handoff: apps/checker/checker/update.go UpdateStatus() enqueues the checker status update with Cloud Tasks', + node_labels: ['UpdateStatus()'], + }) + }) + it('keeps the same selected labels while task-aware rendering changes representation cost after selection', () => { const nodes = [ nodeCandidate({ diff --git a/tests/unit/doctor.test.ts b/tests/unit/doctor.test.ts index ff62ca6a..35bc88a9 100644 --- a/tests/unit/doctor.test.ts +++ b/tests/unit/doctor.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { describe, expect, test } from 'vitest' -import { agentsInstall } from '../../src/infrastructure/install.js' +import { agentsInstall, isMadarCodexMcpConfig, resolveCodexMcpConfigPath } from '../../src/infrastructure/install.js' import { runDoctorCommand, runStatusCommand } from '../../src/infrastructure/doctor.js' import { generateGraph } from '../../src/infrastructure/generate.js' import { createWatcherState, writeWatcherState } from '../../src/infrastructure/watcher-state.js' @@ -43,6 +43,31 @@ function writeText(path: string, content: string): void { writeFileSync(path, content, 'utf8') } +function managedCodexMcpBlockForWorkspace(content: string, projectDir: string): { serverName: string; start: number; end: number } { + const expectedCwd = `cwd = ${JSON.stringify(resolve(projectDir))}` + const matcher = /^# >>> madar managed mcp: (madar_[a-f0-9]{12}) >>>$/gm + + for (const match of content.matchAll(matcher)) { + const serverName = match[1] + const start = match.index + if (!serverName || start === undefined) { + continue + } + + const endMarker = `# <<< madar managed mcp: ${serverName} <<<` + const endMarkerIndex = content.indexOf(endMarker, start) + if (endMarkerIndex === -1) { + continue + } + const end = endMarkerIndex + endMarker.length + if (content.slice(start, end).includes(expectedCwd)) { + return { serverName, start, end } + } + } + + throw new Error(`Missing managed Codex MCP block for ${projectDir}`) +} + function writeMcpServer(path: string, serversKey: 'mcpServers' | 'servers', graphPath?: string): void { writeJson(path, { [serversKey]: { @@ -321,11 +346,14 @@ describe('doctor command', () => { withSandbox((sandboxDir) => { writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') agentsInstall(sandboxDir, 'codex') - const configPath = resolve(sandboxDir, '.codex', 'config.toml') + const configPath = resolveCodexMcpConfigPath() + const config = readFileSync(configPath, 'utf8') + const managedBlock = managedCodexMcpBlockForWorkspace(config, sandboxDir) writeText( configPath, - readFileSync(configPath, 'utf8').replace('MADAR_TOOL_PROFILE = "strict"', 'MADAR_TOOL_PROFILE = "core"'), + `${config.slice(0, managedBlock.start)}${config.slice(managedBlock.start, managedBlock.end).replace('MADAR_TOOL_PROFILE = "strict"', 'MADAR_TOOL_PROFILE = "core"')}${config.slice(managedBlock.end)}`, ) + expect(isMadarCodexMcpConfig(readFileSync(configPath, 'utf8'), sandboxDir)).toBe(false) const doctor = runDoctorCommand({ projectDir: sandboxDir, now: Date.now() }) @@ -385,11 +413,14 @@ describe('doctor command', () => { withSandbox((sandboxDir) => { writeText(resolve(sandboxDir, 'out', 'graph.json'), '{"nodes":[],"edges":[]}\n') agentsInstall(sandboxDir, 'codex') - const configPath = resolve(sandboxDir, '.codex', 'config.toml') + const configPath = resolveCodexMcpConfigPath() + const config = readFileSync(configPath, 'utf8') + const { serverName } = managedCodexMcpBlockForWorkspace(config, sandboxDir) writeText( configPath, - `${readFileSync(configPath, 'utf8')}\n[mcp_servers.madar]\ncommand = "custom-madar"\n`, + `${config}\n[mcp_servers.${serverName}]\ncommand = "custom-madar"\n`, ) + expect(isMadarCodexMcpConfig(readFileSync(configPath, 'utf8'), sandboxDir)).toBe(false) const doctor = runDoctorCommand({ projectDir: sandboxDir, diff --git a/tests/unit/freshness-surfaces.test.ts b/tests/unit/freshness-surfaces.test.ts index 2446faa4..fa593b48 100644 --- a/tests/unit/freshness-surfaces.test.ts +++ b/tests/unit/freshness-surfaces.test.ts @@ -438,6 +438,20 @@ describe('freshness surfaces', () => { })) }) + it('keeps a code graph fresh when agent-install instruction files are added after generation', async () => { + const analyzeGraphContextFreshness = await loadAnalyzeGraphContextFreshness() + const fixture = createGitFreshnessFixture() + + writeFileSync(join(fixture.root, 'AGENTS.md'), '## madar\n\nGenerated agent guidance.\n', 'utf8') + writeFileSync(join(fixture.root, 'CLAUDE.md'), '## madar\n\nGenerated agent guidance.\n', 'utf8') + + expect(analyzeGraphContextFreshness!(fixture.graphPath)).toEqual(expect.objectContaining({ + status: 'fresh', + changed_source_count: 0, + missing_source_count: 0, + })) + }) + it('tracks unrelated git dirty files outside the selected context even when source mtimes stay older than the graph', async () => { const analyzeGraphContextFreshness = await loadAnalyzeGraphContextFreshness() diff --git a/tests/unit/install-compatibility.test.ts b/tests/unit/install-compatibility.test.ts index 14d008fc..15ccf2f2 100644 --- a/tests/unit/install-compatibility.test.ts +++ b/tests/unit/install-compatibility.test.ts @@ -12,6 +12,7 @@ import { geminiInstall, installCopilotMcp, installSkill, + resolveCodexMcpConfigPath, } from '../../src/infrastructure/install.js' const PACKAGE_CLI_RELATIVE_PATH = join('dist', 'src', 'cli', 'bin.js') @@ -48,7 +49,7 @@ const DEDICATED_COMMAND_ROWS: CompatibilityRow[] = [ docArtifacts: ['`CLAUDE.md`', '`.claude/settings.json`', '`.mcp.json`'], verify: '`madar doctor` / `madar status`', surface: 'MCP tools, prompts, and resources via the selected tool profile.', - profile: '`core`, `full`, and `strict`; strict exposes core plus `context_pack` and `context_expand` for one bounded context-pack-first pass.', + profile: '`core`, `full`, and `strict`; strict exposes only `context_pack` and `context_expand` for one bounded context-pack-first pass.', limitation: 'The `UserPromptSubmit` hook only injects guidance for local code tasks.', }, { @@ -58,7 +59,7 @@ const DEDICATED_COMMAND_ROWS: CompatibilityRow[] = [ docArtifacts: ['`.cursor/rules/madar.mdc`', '`.cursor/mcp.json`'], verify: '`madar doctor` / `madar status`', surface: 'MCP tools, prompts, and resources via the selected tool profile.', - profile: '`core`, `full`, and `strict`; strict exposes core plus `context_pack` and `context_expand` for one bounded context-pack-first pass.', + profile: '`core`, `full`, and `strict`; strict exposes only `context_pack` and `context_expand` for one bounded context-pack-first pass.', limitation: 'Cursor has no separate prompt hook; the rule file plus MCP config are the managed surface.', }, { @@ -68,7 +69,7 @@ const DEDICATED_COMMAND_ROWS: CompatibilityRow[] = [ docArtifacts: ['`~/.gemini/skills/madar/SKILL.md`', '`GEMINI.md`', '`.gemini/settings.json` hook and MCP entry'], verify: '`madar doctor` / `madar status` for `.gemini/settings.json`, then inspect the installed home skill for slash-command availability.', surface: 'Home skill, local instructions, and an installed MCP server using the selected tool profile.', - profile: '`core`, `full`, and `strict`; strict exposes core plus `context_pack` and `context_expand` for one bounded context-pack-first pass.', + profile: '`core`, `full`, and `strict`; strict exposes only `context_pack` and `context_expand` for one bounded context-pack-first pass.', limitation: 'Use `madar prompt --provider gemini` when you need a one-shot export instead of live MCP.', }, { @@ -78,7 +79,7 @@ const DEDICATED_COMMAND_ROWS: CompatibilityRow[] = [ docArtifacts: ['`~/.copilot/skills/madar/SKILL.md`', '`.vscode/mcp.json`'], verify: '`madar doctor` / `madar status` for `.vscode/mcp.json`, then inspect the installed home skill for slash-command availability.', surface: 'Home skill plus MCP tools, prompts, and resources via the selected tool profile.', - profile: '`core`, `full`, and `strict`; strict exposes core plus `context_pack` and `context_expand` for one bounded context-pack-first pass.', + profile: '`core`, `full`, and `strict`; strict exposes only `context_pack` and `context_expand` for one bounded context-pack-first pass.', limitation: 'The repo-local verifier checks the MCP wiring; the home skill is a separate install surface.', }, { @@ -95,11 +96,11 @@ const DEDICATED_COMMAND_ROWS: CompatibilityRow[] = [ platform: 'codex', label: 'Codex CLI', command: '`madar codex install`', - docArtifacts: ['`AGENTS.md`', '`.codex/hooks.json`', '`.codex/madar-user-prompt-submit.cjs`', '`.codex/config.toml`'], + docArtifacts: ['`AGENTS.md`', '`.codex/hooks.json`', '`.codex/madar-user-prompt-submit.cjs`', '`~/.codex/config.toml`'], verify: '`madar doctor` / `madar status`', - surface: 'Installed instructions, a task-applicable `UserPromptSubmit` hook, and a local MCP entry for the Madar server.', + surface: 'Installed instructions, a task-applicable `UserPromptSubmit` hook, and a workspace-scoped MCP entry Codex CLI loads.', profile: 'Context-pack-first guidance with the strict MCP surface.', - limitation: '`madar doctor` / `madar status` validate on-disk wiring, not Codex live hook trust or MCP activation.', + limitation: '`madar doctor` / `madar status` validate on-disk wiring only, not Codex live hook trust or MCP activation.', }, { platform: 'opencode', @@ -446,7 +447,8 @@ describe('install compatibility artifacts', () => { if (row.platform === 'codex') { agentsInstall(projectDir, 'codex') - expectArtifacts(projectDir, ['AGENTS.md', '.codex/hooks.json', '.codex/madar-user-prompt-submit.cjs', '.codex/config.toml']) + expectArtifacts(projectDir, ['AGENTS.md', '.codex/hooks.json', '.codex/madar-user-prompt-submit.cjs']) + expect(existsSync(resolveCodexMcpConfigPath())).toBe(true) return } diff --git a/tests/unit/install-docs.test.ts b/tests/unit/install-docs.test.ts index f76297ac..7b7ddc09 100644 --- a/tests/unit/install-docs.test.ts +++ b/tests/unit/install-docs.test.ts @@ -39,13 +39,13 @@ describe('install documentation', () => { expect(reference).toContain('mark the agent as `partial` and suggest the matching reinstall command') }) - it('documents the Codex project MCP, task-applicable hook, and trust activation boundary', () => { + it('documents the Codex user-config MCP, task-applicable hook, and trust activation boundary', () => { const reference = readFileSync(resolve('docs/reference/cli-and-mcp.md'), 'utf8') const quickstarts = readFileSync(resolve('docs/tutorials/agent-quickstarts.md'), 'utf8') for (const document of [reference, quickstarts]) { expect(document).toContain('`.codex/madar-user-prompt-submit.cjs`') - expect(document).toContain('`.codex/config.toml`') + expect(document).toContain('`~/.codex/config.toml`') expect(document).toContain('`/hooks`') expect(document).toContain('`/mcp`') expect(document).toContain('`codex mcp list`') @@ -72,16 +72,16 @@ describe('install documentation', () => { expect(reference).toContain('[--profile core\\|full\\|strict]') expect(reference).toContain('`--profile strict` writes `MADAR_TOOL_PROFILE=strict`') - expect(reference).toContain('call `context_pack` once for the task before broader exploration') - expect(reference).toContain('`verify_targets` inspects only a listed expansion handle or file') + expect(reference).toContain('call `context_pack` exactly once for the user task') + expect(reference).toContain('`verify_targets` can use one listed expansion handle') expect(reference).toContain('only `insufficient` with `broad_search_fallback: allowed` permits one directory-scoped raw search') }) it('documents that strict installs still use one bounded context_pack call in the CLI reference', () => { const reference = readFileSync(resolve('docs/reference/cli-and-mcp.md'), 'utf8') - expect(reference).toContain('`--profile strict` exposes those seven core tools plus `context_pack` and `context_expand`') - expect(reference).toContain('Full-only additions beyond strict') + expect(reference).toContain('`--profile strict` exposes only `context_pack` and `context_expand`') + expect(reference).toContain('Full-only additions beyond core') }) it('documents the local trust boundary and links the threat model from the README privacy section', () => { diff --git a/tests/unit/install-templates.test.ts b/tests/unit/install-templates.test.ts index 62965198..6b767882 100644 --- a/tests/unit/install-templates.test.ts +++ b/tests/unit/install-templates.test.ts @@ -132,6 +132,16 @@ function expectPlainPackRoutingGuide(content: string): void { expect(normalized).toContain('For verify_targets, inspect only the listed verification targets. Restart broad search only for insufficient with broad_search_fallback allowed.') } +function expectPlainStrictPackGuidance(content: string): void { + const normalized = content.replaceAll('\\"', '"') + expect(normalized).toContain('call context_pack exactly once per user task') + expect(normalized).toContain('copy the entire user codebase request byte-for-byte into prompt, including read-only, no-change, scope, and formatting constraints') + expect(normalized).toContain('Strict MCP exposes only context_pack and context_expand') + expect(normalized).toContain('use task=impact or task=review on the first pack instead of graph-navigation tools') + expect(normalized).toContain('For verify_targets, use context_expand once only with a listed verification handle and treat the result as terminal') + expect(normalized).toContain('do not expand ready or ready_with_caveat packs') +} + describe('install hook payload', () => { it('decoded hook payload keeps the graph-first guidance without benchmark marketing copy', () => { withTempDir((projectDir) => { @@ -184,7 +194,7 @@ describe('install hook payload', () => { expect(decoded).toContain('context-pack-first') expect(decoded).toContain('madar pack') expect(decoded).toContain('use Madar tools only') - expectPlainPackRoutingGuide(decoded) + expectPlainStrictPackGuidance(decoded) }) }) }) @@ -196,8 +206,10 @@ describe('built-in install templates', () => { expect(content).toContain('Codex CLI profile') expect(content).toContain('context-pack-first') expect(content).toContain('madar pack') + expect(content).toContain('Pass the entire user\'s codebase request byte-for-byte as the pack question, including read-only, no-change, scope, and formatting constraints.') expect(content).toContain('`evidence.answerability.state` as authoritative') expect(content).toContain('Do not run broad `Glob` patterns, repo-wide `grep` / `find` searches, or raw file sweeps for `ready`, `ready_with_caveat`, or `verify_targets`.') + expect(content).toContain('For read-only `explain` tasks, `ready` and `ready_with_caveat` are terminal') expect(content).toContain('Do not call another MCP or restart broad exploration unless `evidence.answerability.broad_search_fallback` is `allowed`') expect(content).toContain('defer to Madar\'s answerability and exact verification targets') expect(content).toContain('`ready`') @@ -212,7 +224,7 @@ describe('built-in install templates', () => { expect(content).toContain('Manual verification') expect(content).toContain('Codex limitations') expect(content).toContain('.codex/madar-user-prompt-submit.cjs') - expect(content).toContain('.codex/config.toml') + expect(content).toContain('~/.codex/config.toml') expect(content).toContain('UserPromptSubmit') expect(content).toContain('`/hooks`') expect(content).toContain('`/mcp`') diff --git a/tests/unit/install.test.ts b/tests/unit/install.test.ts index cf55cdda..fed57cdd 100644 --- a/tests/unit/install.test.ts +++ b/tests/unit/install.test.ts @@ -22,6 +22,8 @@ import { uninstallCopilotMcp, uninstallSkill, CODEX_MCP_STARTUP_TIMEOUT_SECONDS, + CODEX_MCP_TOOL_TIMEOUT_SECONDS, + resolveCodexMcpConfigPath, } from '../../src/infrastructure/install.js' import { MCP_TOOLS, activeMcpTools, type McpToolProfile } from '../../src/runtime/stdio/definitions.js' import { normalizeAssertionPath, normalizeAssertionPaths } from './helpers/platform.js' @@ -29,8 +31,12 @@ import { normalizeAssertionPath, normalizeAssertionPaths } from './helpers/platf const PACKAGE_CLI_RELATIVE_PATH = join('dist', 'src', 'cli', 'bin.js') const STRICT_STOP_RULE_MD = 'After calling Madar, treat `evidence.answerability.state` as authoritative and `evidence.pack_confidence` as compatibility-only: `ready` means answer from the pack; `ready_with_caveat` means answer with `evidence.answerability.caveats`; `verify_targets` means inspect only `evidence.answerability.verification_targets`; `insufficient` means follow `broad_search_fallback` exactly.' +const STRICT_INVOCATION_RULE_MD = + 'Call `context_pack` exactly once per user task. Copy the entire user codebase request byte-for-byte into `prompt`, including read-only, no-change, scope, and formatting constraints; do not rewrite, omit, expand, enumerate, split, or issue follow-up `context_pack` calls.' const STRICT_EXPAND_RULE_MD = - 'Madar already ran bounded cumulative recovery. Do not restart repository exploration: for `verify_targets`, call `context_expand` with a listed handle or read only a listed file; only `insufficient` plus `broad_search_fallback: allowed` permits one directory-scoped search.' + 'Madar already ran bounded cumulative recovery. Do not restart repository exploration: for `verify_targets`, call `context_expand` once with a listed handle, then treat that expansion result as terminal; only `insufficient` plus `broad_search_fallback: allowed` permits one directory-scoped search.' +const STRICT_READ_ONLY_READY_RULE_MD = + 'For read-only `explain` tasks, `ready` and `ready_with_caveat` are terminal: cite `source_file`, `label`, `line_number` / `snippet_line_number`, and the included snippets directly. Do not run repository `Read`, `Grep`, `Glob`, or `Bash` merely to verify the pack, obtain exact lines, or reopen selected files.' const STRICT_GRAPH_REPORT_RULE_MD = 'Do not open `out/GRAPH_REPORT.md` unless the context pack or graph tools are unavailable, stale, or insufficient. Treat it as a fallback before broader raw file exploration, not a default first read.' const STRICT_NO_BROAD_EXPLORATION_RULE_MD = @@ -41,8 +47,12 @@ const STRICT_SKILL_OVERRIDE_RULE_MD = 'If an auto-activated skill recommends broad `Read` / `Grep` / `Glob` exploration, defer to Madar\'s `evidence.answerability` first. `ready`, `ready_with_caveat`, and `verify_targets` all override a broad-search recommendation.' const STRICT_STOP_RULE_PLAIN = 'after calling Madar, treat evidence.answerability.state as authoritative and evidence.pack_confidence as compatibility-only: ready means answer from the pack; ready_with_caveat means answer with evidence.answerability.caveats; verify_targets means inspect only evidence.answerability.verification_targets; insufficient means follow broad_search_fallback exactly' +const STRICT_INVOCATION_RULE_PLAIN = + 'call context_pack exactly once per user task; copy the entire user codebase request byte-for-byte into prompt, including read-only, no-change, scope, and formatting constraints; do not rewrite, omit, expand, enumerate, split, or issue follow-up context_pack calls' const STRICT_EXPAND_RULE_PLAIN = - 'Madar already ran bounded cumulative recovery; do not restart repository exploration: for verify_targets, call context_expand with a listed handle or read only a listed file; only insufficient plus broad_search_fallback allowed permits one directory-scoped search' + 'Madar already ran bounded cumulative recovery; do not restart repository exploration: for verify_targets, call context_expand once with a listed handle, then treat that expansion result as terminal; only insufficient plus broad_search_fallback allowed permits one directory-scoped search' +const STRICT_READ_ONLY_READY_RULE_PLAIN = + 'for read-only explain tasks, ready and ready_with_caveat are terminal: cite source_file, label, line_number or snippet_line_number, and the included snippets directly; do not run repository Read, Grep, Glob, or Bash merely to verify the pack, obtain exact lines, or reopen selected files' const STRICT_GRAPH_REPORT_RULE_PLAIN = 'do not open out/GRAPH_REPORT.md unless the context pack or graph tools are unavailable, stale, or insufficient; treat it as a fallback before broader raw file exploration, not a default first read' const STRICT_GRAPH_REPORT_RULE_PLAIN_SENTENCE = @@ -85,7 +95,7 @@ function expectPlainRoutingGuide(content: string): void { function expectCodexMarkdownRoutingTable(content: string): void { const normalized = content.replaceAll('\\"', '"') - expect(normalized).toContain('For each codebase question, start with the specific Madar command below first:') + expect(normalized).toContain('For each codebase question, start with the specific Madar command below first.') expect(normalized).toContain('| Prompt type') expect(normalized).toContain('| "how does X work" / explain runtime / flow') expect(normalized).toContain('| "what breaks if I change X" / impact analysis') @@ -102,10 +112,24 @@ function expectCodexMarkdownRoutingTable(content: string): void { expect(normalized).toContain('Do not run ToolSearch before calling a Madar command or graph tool') } +function expectStrictCodexMarkdownRoutingTable(content: string): void { + const normalized = content.replaceAll('\\"', '"') + expect(normalized).toContain('For each codebase question, start with the specific Madar command below first.') + expect(normalized).toContain('| Prompt type | First strict MCP tool |') + expect(normalized).toContain('`context_pack` with `task: "explain"`') + expect(normalized).toContain('`context_pack` with `task: "impact"`') + expect(normalized).toContain('`context_pack` with `task: "review"`') + expect(normalized).toContain('`context_pack` with `task: "implement"`') + expect(normalized).toContain('Strict exposes no general graph-navigation tool after this pack.') + expect(normalized).toContain('`context_expand` only for a listed `verify_targets` handle') + expect(normalized).not.toContain('`retrieve` for direct codebase questions') + expect(normalized).not.toContain('`graph_summary` for repo overview') +} + function mcpToolNamesInGuidance(content: string): string[] { return MCP_TOOLS .map((tool) => tool.name) - .filter((name) => new RegExp(`(^|[^A-Za-z0-9_])${name}([^A-Za-z0-9_]|$)`).test(content)) + .filter((name) => content.includes(`\`${name}\``)) } function expectGuidanceToolsEnabled(content: string, profile: McpToolProfile): void { @@ -573,8 +597,9 @@ describe('install helpers', () => { } expect(geminiSettings.mcpServers?.madar?.env?.MADAR_TOOL_PROFILE).toBe('strict') - expect(geminiMd).toContain('Call `context_pack` once for the task before broader exploration.') + expect(geminiMd).toContain(STRICT_INVOCATION_RULE_MD) expect(geminiMd).toContain(STRICT_STOP_RULE_MD) + expect(geminiMd).toContain(STRICT_READ_ONLY_READY_RULE_MD) expect(geminiMd).toContain(STRICT_NO_BROAD_EXPLORATION_RULE_MD) expect(geminiMd).toContain(STRICT_NON_MADAR_MCP_RULE_MD) expect(geminiMd).toContain(STRICT_SKILL_OVERRIDE_RULE_MD) @@ -615,8 +640,9 @@ describe('install helpers', () => { const decodedHookPayload = decodeHookPayloads(settings) expect(decodedHookPayload).toContain('strict compact MCP mode') - expect(decodedHookPayload).toContain('call context_pack once for the task before broader exploration') + expect(decodedHookPayload).toContain(STRICT_INVOCATION_RULE_PLAIN) expect(decodedHookPayload).toContain(STRICT_STOP_RULE_PLAIN) + expect(decodedHookPayload).toContain(STRICT_READ_ONLY_READY_RULE_PLAIN) expect(decodedHookPayload).toContain(STRICT_NO_BROAD_EXPLORATION_RULE_PLAIN) expect(decodedHookPayload).toContain(STRICT_NON_MADAR_MCP_RULE_PLAIN) expect(decodedHookPayload).toContain(STRICT_SKILL_OVERRIDE_RULE_PLAIN) @@ -939,9 +965,15 @@ describe('install helpers', () => { it('writes strict Claude guidance with the callable strict MCP tool profile', () => { withTempDir((projectDir) => { const installClaudeWithProfile = claudeInstall as (projectDir?: string, options?: { profile?: 'core' | 'full' | 'strict' }) => string + mkdirSync(join(projectDir, 'out'), { recursive: true }) + writeFileSync(join(projectDir, 'out', 'graph.json'), '{}', 'utf8') const installMessage = installClaudeWithProfile(projectDir, { profile: 'strict' }) const claudeMd = readFileSync(join(projectDir, 'CLAUDE.md'), 'utf8') + const settings = readFileSync(join(projectDir, '.claude', 'settings.json'), 'utf8') + const hookOutput = runHookCommand(extractHookCommand(settings, 'UserPromptSubmit'), projectDir, { + prompt: 'Explain how a failed monitor check becomes an incident. This is read-only; do not change files.', + }) const mcpConfig = JSON.parse(readFileSync(join(projectDir, '.mcp.json'), 'utf8')) as { mcpServers?: { 'madar'?: { @@ -951,14 +983,17 @@ describe('install helpers', () => { } expect(mcpConfig.mcpServers?.['madar']?.env?.MADAR_TOOL_PROFILE).toBe('strict') - expect(claudeMd).toContain('Call `context_pack` once for the task before broader exploration.') + expect(claudeMd).toContain(STRICT_INVOCATION_RULE_MD) expect(claudeMd).toContain(STRICT_STOP_RULE_MD) + expect(claudeMd).toContain(STRICT_READ_ONLY_READY_RULE_MD) expect(claudeMd).toContain(STRICT_NO_BROAD_EXPLORATION_RULE_MD) expect(claudeMd).toContain(STRICT_NON_MADAR_MCP_RULE_MD) expect(claudeMd).toContain(STRICT_SKILL_OVERRIDE_RULE_MD) expect(claudeMd).toContain(STRICT_EXPAND_RULE_MD) expect(claudeMd).toContain(STRICT_GRAPH_REPORT_RULE_MD) expect(claudeMd).not.toContain('If manual expansion is still required, read `out/GRAPH_REPORT.md` first.') + expect(hookOutput).toContain(STRICT_READ_ONLY_READY_RULE_PLAIN) + expect(hookOutput).toContain(STRICT_INVOCATION_RULE_PLAIN) expect(installMessage).toContain('strict compact MCP profile') }) }) @@ -1016,7 +1051,7 @@ describe('install helpers', () => { } expect(mcpConfig.mcpServers?.['madar']?.env?.MADAR_TOOL_PROFILE).toBe('strict') - expect(rule).toContain('Call `context_pack` once for the task before broader exploration.') + expect(rule).toContain(STRICT_INVOCATION_RULE_MD) expect(rule).toContain(STRICT_STOP_RULE_MD) expect(rule).toContain(STRICT_NO_BROAD_EXPLORATION_RULE_MD) expect(rule).toContain(STRICT_NON_MADAR_MCP_RULE_MD) @@ -1036,7 +1071,7 @@ describe('install helpers', () => { installCursorWithProfile(projectDir, { profile: 'strict' }) const rule = readFileSync(join(projectDir, '.cursor', 'rules', 'madar.mdc'), 'utf8') - expect(rule).toContain('Call `context_pack` once for the task before broader exploration.') + expect(rule).toContain(STRICT_INVOCATION_RULE_MD) expect(rule).not.toContain('start with the graph tool that matches the question') }) }) @@ -1166,7 +1201,7 @@ describe('install helpers', () => { expect(mcpConfig.servers?.['madar']?.env?.MADAR_TOOL_PROFILE).toBe('strict') expect(installMessage).toContain('strict compact MCP profile') - expect(installMessage).toContain('call context_pack once') + expect(installMessage).toContain(STRICT_INVOCATION_RULE_PLAIN) expect(installMessage).toContain(STRICT_STOP_RULE_PLAIN) expect(installMessage).toContain(STRICT_NO_BROAD_EXPLORATION_RULE_PLAIN) expect(installMessage).toContain(STRICT_EXPAND_RULE_PLAIN) @@ -1212,7 +1247,7 @@ describe('install helpers', () => { expect(readFileSync(join(cursorDir, '.cursor', 'mcp.json'), 'utf8')).toContain('"MADAR_TOOL_PROFILE": "strict"') expect(readFileSync(join(copilotDir, '.vscode', 'mcp.json'), 'utf8')).toContain('"MADAR_TOOL_PROFILE": "strict"') expect(readFileSync(join(geminiDir, '.gemini', 'settings.json'), 'utf8')).toContain('"MADAR_TOOL_PROFILE": "strict"') - expect(readFileSync(join(codexDir, '.codex', 'config.toml'), 'utf8')).toContain('MADAR_TOOL_PROFILE = "strict"') + expect(readFileSync(resolveCodexMcpConfigPath(), 'utf8')).toContain('MADAR_TOOL_PROFILE = "strict"') const aiderGuidance = `${readFileSync(join(aiderDir, 'AGENTS.md'), 'utf8')}\n${aiderMessage}` expectGuidanceToolsEnabled(aiderGuidance, 'strict') @@ -1335,13 +1370,14 @@ describe('install helpers', () => { expect(agentsMd).toContain('Codex CLI profile') expect(agentsMd).toContain('context-pack-first') expect(agentsMd).toContain('madar pack') + expect(agentsMd).toContain(STRICT_INVOCATION_RULE_MD) expect(agentsMd).toContain(STRICT_NO_BROAD_EXPLORATION_RULE_MD) expect(agentsMd).toContain(STRICT_NON_MADAR_MCP_RULE_MD) expect(agentsMd).toContain(STRICT_SKILL_OVERRIDE_RULE_MD) expect(agentsMd).toContain('madar codex uninstall') expect(agentsMd).toContain('Manual verification') expect(agentsMd).toContain(STRICT_GRAPH_REPORT_RULE_MD) - expectCodexMarkdownRoutingTable(agentsMd) + expectStrictCodexMarkdownRoutingTable(agentsMd) expect(agentsMd).not.toContain('Only fall back to raw file tools** when the context pack or graph tools are missing, stale, or insufficient. In that case, read `out/GRAPH_REPORT.md` first.') expect(command).toContain('process.cwd()') expect(command).toContain('madar-user-prompt-submit.cjs') @@ -1375,6 +1411,7 @@ describe('install helpers', () => { expect(localOutput.hookSpecificOutput?.hookEventName).toBe('UserPromptSubmit') expect(localOutput.hookSpecificOutput?.additionalContext).toContain('context-pack-first') expect(localOutput.hookSpecificOutput?.additionalContext).toContain('madar pack') + expect(localOutput.hookSpecificOutput?.additionalContext).toContain(STRICT_INVOCATION_RULE_PLAIN) expect(localOutput).not.toHaveProperty('systemMessage') expect(localOutput.hookSpecificOutput).not.toHaveProperty('permissionDecision') expect(nonCodeOutput).toBe('') @@ -1666,233 +1703,94 @@ describe('install helpers', () => { }) }) - it('writes an idempotent marker-owned Codex MCP block while preserving unrelated TOML and line endings', () => { + it('writes an idempotent workspace-scoped Codex MCP block in the configuration Codex CLI loads', () => { withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') + const configPath = resolveCodexMcpConfigPath() const unrelatedToml = '# Preserve this user comment\r\n[features]\r\nparallel = true\r\n' - const managedBlock = `${CODEX_MCP_START_MARKER}\r\n[mcp_servers.madar]\r\ncommand = "madar"\r\nargs = ["serve", "--stdio", "--auto-refresh"]\r\nenv = { MADAR_TOOL_PROFILE = "strict" }\r\nenabled = true\r\nstartup_timeout_sec = ${CODEX_MCP_STARTUP_TIMEOUT_SECONDS}\r\n${CODEX_MCP_END_MARKER}\r\n` - - mkdirSync(join(projectDir, '.codex'), { recursive: true }) + mkdirSync(dirname(configPath), { recursive: true }) writeFileSync(configPath, unrelatedToml, 'utf8') const firstInstallMessage = agentsInstall(projectDir, 'codex') const firstContent = readFileSync(configPath, 'utf8') const secondInstallMessage = agentsInstall(projectDir, 'codex') - const secondContent = readFileSync(configPath, 'utf8') - expect(firstInstallMessage).toContain('.codex/config.toml -> MCP server registered') - expect(firstContent).toBe(`${unrelatedToml}${managedBlock}`) - expect(firstContent).not.toMatch(/(? MCP server already registered (no change)') - expect(secondContent).toBe(firstContent) - expect(countOccurrences(secondContent, CODEX_MCP_START_MARKER)).toBe(1) + expect(firstInstallMessage).toContain(`${configPath} -> MCP server madar_`) + expect(firstContent).toContain(unrelatedToml) + expect(firstContent).toMatch(/# >>> madar managed mcp: madar_[a-f0-9]{12} >>>/) + expect(firstContent).toMatch(/\[mcp_servers\.madar_[a-f0-9]{12}\]/) + expect(firstContent).toContain(`cwd = ${JSON.stringify(projectDir)}`) + expect(firstContent).toContain(`startup_timeout_sec = ${CODEX_MCP_STARTUP_TIMEOUT_SECONDS}`) + expect(firstContent).toContain(`tool_timeout_sec = ${CODEX_MCP_TOOL_TIMEOUT_SECONDS}`) + expect(firstContent).toContain('MADAR_TOOL_PROFILE = "strict"') + expect(secondInstallMessage).toContain('already registered (no change)') + expect(readFileSync(configPath, 'utf8')).toBe(firstContent) const uninstallMessage = agentsUninstall(projectDir, 'codex') - expect(uninstallMessage).toContain('.codex/config.toml -> MCP server removed') + expect(uninstallMessage).toContain('MCP server madar_') expect(readFileSync(configPath, 'utf8')).toBe(unrelatedToml) }) }) - it('migrates the exact pre-#550 owned Codex core profile to strict on reinstall', () => { - withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - const preFixBlock = `${CODEX_MCP_START_MARKER}\n[mcp_servers.madar]\ncommand = "madar"\nargs = ["serve", "--stdio", "--auto-refresh"]\nenv = { MADAR_TOOL_PROFILE = "core" }\nenabled = true\n${CODEX_MCP_END_MARKER}\n` - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, preFixBlock, 'utf8') - - const installMessage = agentsInstall(projectDir, 'codex') - const migrated = readFileSync(configPath, 'utf8') + it('keeps Codex MCP registrations for separate workspaces independent', () => { + withTempDir((root) => { + const firstProject = join(root, 'first') + const secondProject = join(root, 'second') + mkdirSync(firstProject, { recursive: true }) + mkdirSync(secondProject, { recursive: true }) + const configPath = resolveCodexMcpConfigPath() + const preserved = '# keep this user configuration\n' + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, preserved, 'utf8') - expect(installMessage).toContain('.codex/config.toml -> MCP server updated') - expect(migrated).toContain('MADAR_TOOL_PROFILE = "strict"') - expect(migrated).not.toContain('MADAR_TOOL_PROFILE = "core"') - expect(migrated).toContain(`startup_timeout_sec = ${CODEX_MCP_STARTUP_TIMEOUT_SECONDS}`) - expect(countOccurrences(migrated, CODEX_MCP_START_MARKER)).toBe(1) - expect(agentsInstall(projectDir, 'codex')).toContain('.codex/config.toml -> MCP server already registered (no change)') - }) - }) + agentsInstall(firstProject, 'codex') + agentsInstall(secondProject, 'codex') + const both = readFileSync(configPath, 'utf8') + expect([...both.matchAll(/\[mcp_servers\.(madar_[a-f0-9]{12})\]/g)]).toHaveLength(2) + expect(both).toContain(`cwd = ${JSON.stringify(firstProject)}`) + expect(both).toContain(`cwd = ${JSON.stringify(secondProject)}`) - it('migrates the v0.31.1 owned Codex MCP block to the explicit startup timeout', () => { - withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - const previousBlock = `${CODEX_MCP_START_MARKER}\n[mcp_servers.madar]\ncommand = "madar"\nargs = ["serve", "--stdio", "--auto-refresh"]\nenv = { MADAR_TOOL_PROFILE = "strict" }\nenabled = true\n${CODEX_MCP_END_MARKER}\n` - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, previousBlock, 'utf8') + agentsUninstall(firstProject, 'codex') + const afterFirstUninstall = readFileSync(configPath, 'utf8') + expect(afterFirstUninstall).not.toContain(`cwd = ${JSON.stringify(firstProject)}`) + expect(afterFirstUninstall).toContain(`cwd = ${JSON.stringify(secondProject)}`) - expect(agentsInstall(projectDir, 'codex')).toContain('.codex/config.toml -> MCP server updated') - expect(readFileSync(configPath, 'utf8')).toContain( - `startup_timeout_sec = ${CODEX_MCP_STARTUP_TIMEOUT_SECONDS}`, - ) - expect(agentsInstall(projectDir, 'codex')).toContain('.codex/config.toml -> MCP server already registered (no change)') + agentsUninstall(secondProject, 'codex') + expect(readFileSync(configPath, 'utf8')).toBe(preserved) }) }) - it('restores Codex TOML files that originally had no final line ending', () => { - const originalContents = [ - 'parallel = true', - '# Preserve CRLF\r\nparallel = true', - ] - - for (const originalContent of originalContents) { - withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, originalContent, 'utf8') - - agentsInstall(projectDir, 'codex') - expect(readFileSync(configPath, 'utf8')).toContain(CODEX_MCP_START_MARKER) - - agentsUninstall(projectDir, 'codex') - expect(readFileSync(configPath, 'utf8')).toBe(originalContent) - }) - } - }) - - it('keeps later user TOML separated when uninstalling a block after a no-final-newline config', () => { + it('migrates only its obsolete project-local Codex MCP marker block', () => { withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - const originalContent = 'parallel = true' - const laterUserContent = '[features]\nexperimental = true\n' - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, originalContent, 'utf8') + const legacyConfigPath = join(projectDir, '.codex', 'config.toml') + const legacyBlock = `${CODEX_MCP_START_MARKER}\n[mcp_servers.madar]\ncommand = "madar"\nargs = ["serve", "--stdio", "--auto-refresh"]\nenv = { MADAR_TOOL_PROFILE = "core" }\n${CODEX_MCP_END_MARKER}\n` + mkdirSync(dirname(legacyConfigPath), { recursive: true }) + writeFileSync(legacyConfigPath, `# preserved local config\n${legacyBlock}`, 'utf8') - agentsInstall(projectDir, 'codex') - writeFileSync(configPath, `${readFileSync(configPath, 'utf8')}${laterUserContent}`, 'utf8') + const message = agentsInstall(projectDir, 'codex') + const globalConfig = readFileSync(resolveCodexMcpConfigPath(), 'utf8') - agentsUninstall(projectDir, 'codex') - - expect(readFileSync(configPath, 'utf8')).toBe(`${originalContent}\n${laterUserContent}`) + expect(message).toContain('obsolete project-local MCP registration removed') + expect(readFileSync(legacyConfigPath, 'utf8')).toBe('# preserved local config\n') + expect(globalConfig).toContain(`cwd = ${JSON.stringify(projectDir)}`) + expect(globalConfig).toContain(`tool_timeout_sec = ${CODEX_MCP_TOOL_TIMEOUT_SECONDS}`) }) }) - it('does not mistake TOML multiline-string content for an owned or user-managed Codex MCP block', () => { + it('fails without mutating either Codex config when the legacy marker block is malformed', () => { withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - const userToml = `note = """ -${CODEX_MCP_START_MARKER} -[mcp_servers.madar] -command = "user example only" -${CODEX_MCP_END_MARKER} -""" -` - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, userToml, 'utf8') - - const installMessage = agentsInstall(projectDir, 'codex') - const installed = readFileSync(configPath, 'utf8') - - expect(installMessage).toContain('.codex/config.toml -> MCP server registered') - expect(installed).toContain(userToml) - expect(installed).toContain('command = "madar"') + const legacyConfigPath = join(projectDir, '.codex', 'config.toml') + const malformed = `# keep\n${CODEX_MCP_START_MARKER}\n[mcp_servers.madar]\ncommand = "madar"\n` + mkdirSync(dirname(legacyConfigPath), { recursive: true }) + writeFileSync(legacyConfigPath, malformed, 'utf8') + const globalConfigPath = resolveCodexMcpConfigPath() + const globalBefore = existsSync(globalConfigPath) ? readFileSync(globalConfigPath, 'utf8') : null - agentsUninstall(projectDir, 'codex') - expect(readFileSync(configPath, 'utf8')).toBe(userToml) + expect(() => agentsInstall(projectDir, 'codex')).toThrow(/marker block/i) + expect(readFileSync(legacyConfigPath, 'utf8')).toBe(malformed) + expect(existsSync(globalConfigPath) ? readFileSync(globalConfigPath, 'utf8') : null).toBe(globalBefore) }) }) - it('rewrites only a complete owned Codex MCP marker block', () => { - withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - const before = `# before\n${CODEX_MCP_START_MARKER}\n[mcp_servers.madar]\ncommand = "old-madar"\nargs = ["old"]\n${CODEX_MCP_END_MARKER}\n# after\n` - - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, before, 'utf8') - - const installMessage = agentsInstall(projectDir, 'codex') - const installed = readFileSync(configPath, 'utf8') - - expect(installMessage).toContain('.codex/config.toml -> MCP server updated') - expect(installed).toContain('# before\n') - expect(installed).toContain('# after\n') - expect(installed).toContain('[mcp_servers.madar]\ncommand = "madar"') - expect(installed).toContain('args = ["serve", "--stdio", "--auto-refresh"]') - expect(installed).toContain('env = { MADAR_TOOL_PROFILE = "strict" }') - expect(installed).toContain('enabled = true') - expect(installed).toContain(`startup_timeout_sec = ${CODEX_MCP_STARTUP_TIMEOUT_SECONDS}`) - expect(installed).not.toContain('old-madar') - }) - }) - - it('leaves an owned Codex MCP block untouched if a later declaration is user-managed', () => { - withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - agentsInstall(projectDir, 'codex') - const withUserDeclaration = `${readFileSync(configPath, 'utf8')}\n[mcp_servers.madar]\ncommand = "custom-madar"\n` - writeFileSync(configPath, withUserDeclaration, 'utf8') - - const reinstallMessage = agentsInstall(projectDir, 'codex') - - expect(reinstallMessage).toContain('user-managed') - expect(readFileSync(configPath, 'utf8')).toBe(withUserDeclaration) - }) - }) - - it('leaves user-managed Codex Madar MCP declarations untouched', () => { - const userManagedConfigs = [ - '[mcp_servers.madar]\ncommand = "custom-madar"\n', - '[mcp_servers.madar.env]\nMADAR_TOOL_PROFILE = "full"\n', - '[[mcp_servers.madar]]\ncommand = "custom-madar"\n', - 'mcp_servers = { madar = { command = "custom-madar" } }\n', - 'mcp_servers = { other = { command = "custom-madar" } }\n', - '"mcp_servers"."madar" = { command = "custom-madar" }\n', - '[mcp_servers]\nmadar = { command = "custom-madar" }\n', - '[mcp_servers]\n"madar".command = "custom-madar"\n', - '["mcp_servers"."madar"]\ncommand = "custom-madar"\n', - ] - - for (const userManagedConfig of userManagedConfigs) { - withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, userManagedConfig, 'utf8') - - const installMessage = agentsInstall(projectDir, 'codex') - - expect(installMessage).toContain('user-managed') - expect(readFileSync(configPath, 'utf8')).toBe(userManagedConfig) - expect(readFileSync(configPath, 'utf8')).not.toContain(CODEX_MCP_START_MARKER) - - agentsUninstall(projectDir, 'codex') - expect(readFileSync(configPath, 'utf8')).toBe(userManagedConfig) - }) - } - }) - - it('does not mistake an unrelated table-local mcp_servers key for a root MCP declaration', () => { - withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - const userToml = '[features]\nmcp_servers = { experimental = true }\n' - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, userToml, 'utf8') - - const installMessage = agentsInstall(projectDir, 'codex') - const installed = readFileSync(configPath, 'utf8') - - expect(installMessage).toContain('.codex/config.toml -> MCP server registered') - expect(installed).toContain(userToml) - expect(installed).toContain(CODEX_MCP_START_MARKER) - expect(installed).toContain('[mcp_servers.madar]') - }) - }) - - it('fails without mutating Codex config when its MCP marker block is malformed', () => { - const malformedConfigs = [ - `# keep\n${CODEX_MCP_START_MARKER}\n[mcp_servers.madar]\ncommand = "madar"\n`, - `# keep\n${CODEX_MCP_END_MARKER}\n`, - ] - - for (const malformed of malformedConfigs) { - withTempDir((projectDir) => { - const configPath = join(projectDir, '.codex', 'config.toml') - mkdirSync(join(projectDir, '.codex'), { recursive: true }) - writeFileSync(configPath, malformed, 'utf8') - - expect(() => agentsInstall(projectDir, 'codex')).toThrow(/marker block/i) - expect(readFileSync(configPath, 'utf8')).toBe(malformed) - }) - } - }) - it('preserves unrelated OpenCode config while updating madar MCP', () => { withTempDir((projectDir) => { writeFileSync( @@ -2040,7 +1938,7 @@ ${CODEX_MCP_END_MARKER} const firstAgentsMd = readFileSync(join(projectDir, 'AGENTS.md'), 'utf8') const firstCodexHooks = readFileSync(join(projectDir, '.codex', 'hooks.json'), 'utf8') const firstCodexHookScript = readFileSync(join(projectDir, '.codex', 'madar-user-prompt-submit.cjs'), 'utf8') - const firstCodexConfig = readFileSync(join(projectDir, '.codex', 'config.toml'), 'utf8') + const firstCodexConfig = readFileSync(resolveCodexMcpConfigPath(), 'utf8') const firstOpenCodeConfig = readFileSync(join(projectDir, 'opencode.json'), 'utf8') agentsInstall(projectDir, 'codex') @@ -2049,11 +1947,11 @@ ${CODEX_MCP_END_MARKER} expect(readFileSync(join(projectDir, 'AGENTS.md'), 'utf8')).toBe(firstAgentsMd) expect(readFileSync(join(projectDir, '.codex', 'hooks.json'), 'utf8')).toBe(firstCodexHooks) expect(readFileSync(join(projectDir, '.codex', 'madar-user-prompt-submit.cjs'), 'utf8')).toBe(firstCodexHookScript) - expect(readFileSync(join(projectDir, '.codex', 'config.toml'), 'utf8')).toBe(firstCodexConfig) + expect(readFileSync(resolveCodexMcpConfigPath(), 'utf8')).toBe(firstCodexConfig) expect(readFileSync(join(projectDir, 'opencode.json'), 'utf8')).toBe(firstOpenCodeConfig) expect(countOccurrences(firstAgentsMd, '## madar')).toBe(1) expect(firstCodexHookScript).toContain('out') - expect(firstCodexConfig).toContain('[mcp_servers.madar]') + expect(firstCodexConfig).toMatch(/\[mcp_servers\.madar_[a-f0-9]{12}\]/) expect(countOccurrences(firstOpenCodeConfig, '.opencode/plugins/madar.js')).toBe(1) }) }) diff --git a/tests/unit/mcp-schema-budget.test.ts b/tests/unit/mcp-schema-budget.test.ts index 66e6c276..9bf71d81 100644 --- a/tests/unit/mcp-schema-budget.test.ts +++ b/tests/unit/mcp-schema-budget.test.ts @@ -10,16 +10,16 @@ import { activeMcpTools, MCP_TOOLS } from '../../src/runtime/stdio/definitions.j // // Post-#338 measurements (taken from `node -e "JSON.stringify({tools: ...})"`): // * Core profile (7 tools, the default): ≈ 3,389 bytes -// * Strict profile (9 tools): ≈ 4,825 bytes -// * Full profile (27 tools, opt-in): ≈ 12,380 bytes +// * Strict profile (2 tools): compact answer-ready pack surface +// * Full profile (27 tools, opt-in): ≈ 12,952 bytes // // Pre-#82 core was 4,271 bytes / ~1,068 tokens — i.e. #82 cut the core profile // by 30%. The ceilings below sit just above today's measurements to leave a // small growth buffer without permitting a silent regression. const CORE_PROFILE_BYTE_CEILING = 3_400 -const STRICT_PROFILE_BYTE_CEILING = 4_850 -const FULL_PROFILE_BYTE_CEILING = 12_650 // v0.27: raised for additive retrieve snippet-budget arguments +const STRICT_PROFILE_BYTE_CEILING = 2_200 +const FULL_PROFILE_BYTE_CEILING = 13_100 // v0.31: answer-ready guidance added to context tools function payloadBytes(tools: ReadonlyArray): number { return JSON.stringify({ tools }).length @@ -40,7 +40,7 @@ describe('MCP tool-schema byte budget (#82)', () => { const tools = activeMcpTools('strict') const bytes = payloadBytes(tools) expect(bytes, `strict profile is ${bytes} bytes; ceiling is ${STRICT_PROFILE_BYTE_CEILING}`).toBeLessThanOrEqual(STRICT_PROFILE_BYTE_CEILING) - expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining(['context_pack', 'context_expand'])) + expect(tools.map((tool) => tool.name)).toEqual(['context_pack', 'context_expand']) }) it('the core profile contains exactly the documented 7 tools', () => { diff --git a/tests/unit/retrieve-conceptual-fallback.test.ts b/tests/unit/retrieve-conceptual-fallback.test.ts index 6cebe5fb..9c8abebe 100644 --- a/tests/unit/retrieve-conceptual-fallback.test.ts +++ b/tests/unit/retrieve-conceptual-fallback.test.ts @@ -10,7 +10,12 @@ import { contextPackFromRetrieveResult, retrieveContext, } from '../../src/runtime/retrieve.js' -import { planConceptualFallback } from '../../src/runtime/retrieve/conceptual-fallback.js' +import { + evaluateQueryEvidenceCoverage, + planConceptualFallback, + queryEvidenceObligations, + underScopedDivergenceNodeIds, +} from '../../src/runtime/retrieve/conceptual-fallback.js' function addNode( graph: KnowledgeGraph, @@ -55,6 +60,113 @@ function conceptualWorkflowGraph(): KnowledgeGraph { } describe('conceptual-query fallback planner', () => { + it('does not turn answer-format directives into repository evidence obligations', () => { + const obligations = queryEvidenceObligations( + 'Explain the failed monitor flow and note what the available evidence cannot prove.', + ) + + expect(obligations).toEqual([ + { index: 0, terms: ['@failure', 'monitor', 'flow'] }, + ]) + }) + + it('keeps an enumerated computation scope together and preserves trailing divergence', () => { + const obligations = queryEvidenceObligations( + 'Identify all files involved in status computation for monitors, incidents, and status pages, and any inconsistent status-computation logic across these paths.', + ) + + expect(obligations).toHaveLength(2) + expect(obligations[0]?.terms).toEqual(expect.arrayContaining([ + 'status', + '@computation', + 'monitors', + 'incidents', + 'pages', + ])) + expect(obligations[1]?.terms).toContain('@divergence') + }) + + it('does not let a repeated citation checklist consume trailing flow obligations', () => { + const obligations = queryEvidenceObligations( + 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status. Cite exact files and symbols for: monitor check failure detection, incident creation logic, notification dispatch, and status-page status computation. Identify any inconsistent status-computation paths.', + ) + + expect(obligations).toHaveLength(5) + expect(obligations[4]?.terms).toContain('@divergence') + expect(obligations.filter((obligation) => obligation.terms.includes('@failure'))).toHaveLength(1) + }) + + it('does not let an agent-expanded end-to-end checklist repeat the flow phases', () => { + const obligations = queryEvidenceObligations( + 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status. Identify all files/symbols involved end-to-end: from the checker detecting failure, through incident creation, notification dispatch, and public status-page computation. Also identify any inconsistent or duplicate status-computation paths.', + ) + + expect(obligations).toHaveLength(5) + expect(obligations[4]?.terms).toContain('@divergence') + expect(obligations.filter((obligation) => obligation.terms.includes('@failure'))).toHaveLength(1) + }) + + it('does not let an include checklist displace a trailing divergence request', () => { + const obligations = queryEvidenceObligations( + 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status. Include the checker/probe result ingestion, incident creation logic, notification dispatch, and status-page status computation. Identify all files and symbols involved and any inconsistent status-computation paths between different parts of the codebase.', + ) + + expect(obligations).toHaveLength(5) + expect(obligations[4]?.terms).toContain('@divergence') + expect(obligations.filter((obligation) => obligation.terms.includes('@failure'))).toHaveLength(1) + expect(obligations.flatMap((obligation) => obligation.terms)).not.toContain('probe') + }) + + it('does not let a from-through checklist create a second workflow', () => { + const obligations = queryEvidenceObligations( + 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status. Identify all files/symbols involved from failed check ingestion through incident creation, notification dispatch, and status-page status computation. Also identify any inconsistent status-computation paths across the codebase.', + ) + + expect(obligations).toHaveLength(5) + expect(obligations[4]?.terms).toContain('@divergence') + expect(obligations.filter((obligation) => obligation.terms.includes('@transition'))).toHaveLength(1) + }) + + it('preserves divergence text embedded in a repeated checklist sentence', () => { + const obligations = queryEvidenceObligations( + 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status. Identify every owner from failure detection through notification dispatch and compare inconsistent status computations.', + ) + + expect(obligations.at(-1)?.terms).toContain('@divergence') + expect(obligations.filter((obligation) => obligation.terms.includes('@failure'))).toHaveLength(1) + }) + + it('treats a status assignment as grounded computation evidence', () => { + const coverage = evaluateQueryEvidenceCoverage( + 'Identify status computation for monitors', + [{ + label: 'statusPage.ts', + source_file: '/packages/api/statusPage.ts', + snippet: 'const status = monitors.some((monitor) => monitor.status === "error") ? "error" : "success"', + }], + ) + + expect(coverage).toMatchObject({ + total: 1, + covered: 1, + missing_obligations: [], + }) + }) + + it('excludes generic computations from a repository-scoped divergence comparison', () => { + const graph = new KnowledgeGraph({ directed: true }) + addNode(graph, 'page-status', 'computeOverallStatus()', '/apps/server/status-page/index.ts') + addNode(graph, 'generic-status', 'computePhaseStatus()', '/packages/services/import/utils.ts') + + const excluded = underScopedDivergenceNodeIds( + graph, + 'Identify status computation for monitors, incidents, and status pages, and any inconsistent status-computation logic across these paths.', + ) + + expect(excluded.has('generic-status')).toBe(true) + expect(excluded.has('page-status')).toBe(false) + }) + it('recovers a structurally coherent workflow center when lexical anchors are disconnected', () => { const result = retrieveContext(conceptualWorkflowGraph(), { question: 'How is topology kept current when modifications happen?', @@ -208,6 +320,93 @@ describe('conceptual-query fallback planner', () => { expect(labels.filter((label) => label !== 'SearchStatusBadge').length / labels.length).toBeGreaterThanOrEqual(0.75) }) + it('reserves the cross-runtime caller that owns the first failure transition', () => { + const graph = new KnowledgeGraph({ directed: true }) + addNode(graph, 'failure-log', 'FailedMonitorLog', '/packages/services/monitor/failure-log.ts') + addNode(graph, 'status-update', 'UpdateStatus()', '/apps/checker/checker/update.go') + addNode(graph, 'http-checker', '.HTTPCheckerHandler()', '/apps/checker/handlers/checker.go', { + framework: 'gin', + framework_role: 'gin_handler', + }) + addNode(graph, 'incident', 'createIncident()', '/apps/workflows/checker/incident.ts') + addNode(graph, 'notifications', 'triggerNotifications()', '/apps/workflows/checker/alerting.ts') + addNode(graph, 'public-status', 'statusPage.ts', '/packages/api/router/statusPage.ts') + addNode(graph, 'alternate-status', 'computeOverallStatus()', '/apps/server/status-page/index.ts') + graph.addEdge('http-checker', 'status-update', { relation: 'calls' }) + graph.addEdge('status-update', 'incident', { relation: 'calls' }) + graph.addEdge('incident', 'notifications', { relation: 'calls' }) + graph.addEdge('public-status', 'incident', { relation: 'reads' }) + graph.addEdge('alternate-status', 'public-status', { relation: 'competes_with' }) + + const proposal = planConceptualFallback(graph, { + question: 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status. Identify inconsistent status-computation paths.', + initialQuality: lowQuality(), + selectedNodes: [], + }) + + expect(proposal.preferredObligationAnchors?.get(0)).toBe('http-checker') + expect(proposal.nodeBoosts.get('http-checker')).toBeGreaterThanOrEqual(9) + expect(proposal.nodeBoosts.get('status-update')).toBeGreaterThanOrEqual(9) + }) + + it('reserves workflow-local creation and delivery owners for natural wording', () => { + const graph = new KnowledgeGraph({ directed: true }) + addNode(graph, 'http-checker', '.HTTPCheckerHandler()', '/apps/checker/handlers/checker.go') + addNode(graph, 'status-update', 'UpdateStatus()', '/apps/checker/checker/update.go') + addNode(graph, 'incident-owner', 'findOpenIncident()', '/apps/workflows/src/checker/index.ts') + addNode(graph, 'delivery-owner', 'triggerNotifications()', '/apps/workflows/src/checker/alerting.ts') + addNode(graph, 'public-status', 'statusPage.ts', '/packages/api/src/router/statusPage.ts') + addNode(graph, 'overall-status', 'computeOverallStatus()', '/apps/server/src/routes/status-page/index.ts') + addNode(graph, 'import-writer', 'createImportedIncident()', '/packages/services/src/import/phase-writers.ts') + addNode(graph, 'test-delivery', 'sendTestNotification()', '/apps/server/src/routes/notification/test-providers.ts') + graph.addEdge('http-checker', 'status-update', { relation: 'calls' }) + graph.addEdge('status-update', 'incident-owner', { relation: 'calls' }) + graph.addEdge('incident-owner', 'delivery-owner', { relation: 'calls' }) + graph.addEdge('public-status', 'incident-owner', { relation: 'reads' }) + graph.addEdge('overall-status', 'public-status', { relation: 'competes_with' }) + + const proposal = planConceptualFallback(graph, { + question: 'Explain the path from a failed HTTP monitor check to incident creation, notification delivery, and the public status-page result. Compare every overall-status computation. Read-only: do not modify files.', + initialQuality: lowQuality(), + selectedNodes: [], + }) + + expect(proposal.preferredObligationAnchors?.get(1)).toBe('incident-owner') + expect(proposal.preferredObligationAnchors?.get(2)).toBe('delivery-owner') + expect(proposal.nodeBoosts.get('incident-owner')).toBeGreaterThanOrEqual(14) + expect(proposal.nodeBoosts.get('delivery-owner')).toBeGreaterThanOrEqual(14) + expect(proposal.nodeBoosts.has('test-delivery')).toBe(false) + }) + + it('reserves the public HTTP boundary separately from status computation owners', () => { + const graph = new KnowledgeGraph({ directed: true }) + addNode(graph, 'public-json-route', 'GET()', '/apps/status-page/src/app/api/status/[[...path]]/route.ts', { + framework_role: 'next_route_handler', + }) + addNode(graph, 'status-json', 'status-json.ts', '/apps/status-page/src/content/status-json.ts') + addNode(graph, 'public-status', 'statusPage.ts', '/packages/api/src/router/statusPage.ts') + addNode(graph, 'overall-status', 'computeOverallStatus()', '/apps/server/src/routes/status-page/index.ts') + addNode(graph, 'failed-check', '.HTTPCheckerHandler()', '/apps/checker/handlers/checker.go') + addNode(graph, 'incident-owner', 'createIncident()', '/apps/workflows/src/checker/incident.ts') + addNode(graph, 'notification-owner', 'triggerNotifications()', '/apps/workflows/src/checker/alerting.ts') + graph.addEdge('public-json-route', 'status-json', { relation: 'calls' }) + graph.addEdge('status-json', 'public-status', { relation: 'serializes' }) + graph.addEdge('overall-status', 'public-status', { relation: 'competes_with' }) + graph.addEdge('failed-check', 'incident-owner', { relation: 'calls' }) + graph.addEdge('incident-owner', 'notification-owner', { relation: 'calls' }) + graph.addEdge('incident-owner', 'public-status', { relation: 'affects' }) + + const proposal = planConceptualFallback(graph, { + question: 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status. Identify inconsistent status-computation paths.', + initialQuality: lowQuality(), + selectedNodes: [], + }) + + expect(proposal.nodeBoosts.get('public-json-route')).toBeGreaterThanOrEqual(9) + expect(proposal.nodeBoosts.has('public-status')).toBe(true) + expect(proposal.nodeBoosts.has('overall-status')).toBe(true) + }) + it('caps every BFS neighbor read on a hub-heavy graph', () => { const graph = new KnowledgeGraph({ directed: true }) addNode(graph, 'snapshot', 'TopologySnapshot', '/runtime/topology/snapshot.ts') diff --git a/tests/unit/retrieve-cross-layer-flow.test.ts b/tests/unit/retrieve-cross-layer-flow.test.ts index 63fb385c..49b4be38 100644 --- a/tests/unit/retrieve-cross-layer-flow.test.ts +++ b/tests/unit/retrieve-cross-layer-flow.test.ts @@ -4,7 +4,7 @@ import { performance } from 'node:perf_hooks' import { describe, expect, it } from 'vitest' -import { retrieveContext } from '../../src/runtime/retrieve.js' +import { readQueryEvidenceSnippet, retrieveContext } from '../../src/runtime/retrieve.js' import { assessMadarResponseEvidence } from '../../src/runtime/mcp-response-evidence.js' import { buildRetrievalEvidencePlanFromResult } from '../../src/runtime/retrieve/pipeline.js' import { handleStdioRequest } from '../../src/runtime/stdio-server.js' @@ -14,6 +14,8 @@ import { } from '../fixtures/cross-layer-monitor-flow.js' const QUESTION = 'Trace how a failed monitor check becomes an incident, triggers notifications, and affects the public status-page status in this repository. Cite the exact files and symbols involved, identify any inconsistent status-computation paths, and clearly state any remaining uncertainty.' +const NATURAL_QUESTION = 'Explain the exact end-to-end path from a failed HTTP monitor check to incident creation, notification delivery, and the public status-page result. Cite the relevant files and symbols, compare every distinct overall-status computation you find, and state what the available evidence cannot prove. Read-only: do not modify files.' +const AGENT_SHORTENED_QUESTION = 'Explain the exact end-to-end path from a failed HTTP monitor check to incident creation, notification delivery, and the public status-page result. Compare every distinct overall-status computation.' function writeCrossLayerGraphFixture(root: string): string { const graph = buildCrossLayerMonitorFlowFixture() @@ -59,13 +61,13 @@ describe('cross-layer flow retrieval', () => { }, null, 2), ).toBe(true) expect(precision).toBeGreaterThanOrEqual(0.7) + expect(result.matched_nodes.map((node) => node.label)).not.toContain('computeEffectiveStatus') expect(result.relationships.length).toBeGreaterThanOrEqual(5) expect(result.retrieval_plan).toMatchObject({ status: 'recovered', reasons: expect.arrayContaining(['missing_query_obligations']), query_obligations: { total: 5, - initially_covered: 1, finally_covered: 5, }, attempts: [expect.objectContaining({ @@ -73,6 +75,9 @@ describe('cross-layer flow retrieval', () => { promoted_communities: expect.arrayContaining([1, 2, 3, 4, 5, 6]), })], }) + expect(result.retrieval_plan?.query_obligations?.initially_covered).toBeLessThan( + result.retrieval_plan?.query_obligations?.finally_covered ?? 0, + ) expect(evidence.answerability.state).toMatch(/^ready(?:_with_caveat)?$/) expect(evidence.answerability.broad_search_fallback).toBe('not_needed') expect(evidence.agent_directive).toBe('answer_from_pack') @@ -95,7 +100,6 @@ describe('cross-layer flow retrieval', () => { arguments: { prompt: QUESTION, task: 'explain', - budget: 1_800, }, }, })) @@ -105,9 +109,10 @@ describe('cross-layer flow retrieval', () => { (response?.result as { content: Array<{ text: string }> }).content[0]!.text, ) as { pack?: { - matched_nodes?: Array<{ source_file: string }> + matched_nodes?: Array<{ label: string; source_file: string }> relationships?: unknown[] } + claims?: Array<{ text: string }> evidence?: { answerability?: { state?: string; broad_search_fallback?: string } agent_directive?: string @@ -125,11 +130,19 @@ describe('cross-layer flow retrieval', () => { ).toBe(true) expect(relevantSelected.length / Math.max(selectedFiles.size, 1)).toBeGreaterThanOrEqual(0.7) expect(uiSelected.length).toBeLessThanOrEqual(2) + expect(payload.pack?.matched_nodes?.map((node) => node.source_file)).not.toContain( + 'packages/api/src/router/external-service/effective-status.ts', + ) + expect(payload.pack?.matched_nodes?.map((node) => node.label)).toContain('computeOverallStatus') + expect(payload.claims?.some((claim) => ( + claim.text.includes('treats an open incident event as "error" outside manual mode') + && claim.text.includes('derives overall status from active status reports and maintenance') + ))).toBe(true) expect( payload.pack?.relationships?.length ?? 0, JSON.stringify(payload, null, 2), ).toBeGreaterThanOrEqual(5) - expect(payload.evidence).toMatchObject({ + expect(payload.evidence, JSON.stringify(payload, null, 2)).toMatchObject({ answerability: { state: expect.stringMatching(/^ready(?:_with_caveat)?$/), broad_search_fallback: 'not_needed', @@ -145,4 +158,312 @@ describe('cross-layer flow retrieval', () => { rmSync(root, { recursive: true, force: true }) } }) + + it('keeps natural and agent-shortened incident-delivery prompts answer-ready', async () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-cross-layer-natural-')) + const previousToolProfile = process.env.MADAR_TOOL_PROFILE + try { + process.env.MADAR_TOOL_PROFILE = 'strict' + const graphPath = writeCrossLayerGraphFixture(root) + for (const [index, prompt] of [NATURAL_QUESTION, AGENT_SHORTENED_QUESTION].entries()) { + const response = await Promise.resolve(handleStdioRequest(graphPath, { + id: index + 2, + method: 'tools/call', + params: { + name: 'context_pack', + arguments: { + prompt, + task: 'explain', + }, + }, + })) + expect((response as { error?: unknown }).error, JSON.stringify(response, null, 2)).toBeUndefined() + + const payload = JSON.parse( + (response?.result as { content: Array<{ text: string }> }).content[0]!.text, + ) as { + pack?: { matched_nodes?: Array<{ source_file: string; snippet?: string }> } + evidence?: { + answerability?: { state?: string; broad_search_fallback?: string } + agent_directive?: string + } + } + const selectedFiles = new Set(payload.pack?.matched_nodes?.map((node) => node.source_file) ?? []) + const snippets = payload.pack?.matched_nodes?.map((node) => node.snippet ?? '').join('\n') ?? '' + + expect( + CROSS_LAYER_MONITOR_FLOW_FILES.every((file) => selectedFiles.has(file)), + JSON.stringify(payload, null, 2), + ).toBe(true) + expect(snippets).toMatch(/insert\(incidentTable\)/) + expect(snippets).toMatch(/sendAlert\(notification\)/) + expect(payload.evidence, JSON.stringify(payload, null, 2)).toMatchObject({ + answerability: { + state: expect.stringMatching(/^ready(?:_with_caveat)?$/), + broad_search_fallback: 'not_needed', + }, + agent_directive: 'answer_from_pack', + }) + } + } finally { + if (previousToolProfile === undefined) { + delete process.env.MADAR_TOOL_PROFILE + } else { + process.env.MADAR_TOOL_PROFILE = previousToolProfile + } + rmSync(root, { recursive: true, force: true }) + } + }) + + it('selects decisive file evidence when an anonymous workflow is hidden behind a small helper anchor', () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-query-snippet-')) + const sourceFile = join(root, 'checker.ts') + try { + writeFileSync(sourceFile, [ + "import { incidentTable } from './schema'", + "import { triggerNotifications } from './notifications'", + '', + 'export function findOpenIncident() {', + ' return database.query.incidentTable.findFirst()', + '}', + '', + "app.post('/updateStatus', async (context) => {", + ' if (context.status === "error") {', + ' await database.insert(incidentTable).values({ monitorId: context.monitorId })', + ' await triggerNotifications({ notifType: "alert" })', + ' }', + '})', + ].join('\n'), 'utf8') + + const evidence = readQueryEvidenceSnippet(sourceFile, 4, { + question: QUESTION, + label: 'findOpenIncident', + sourceLocation: 'L4-L6', + }) + + expect(evidence).toMatchObject({ scope: 'source_file' }) + expect(evidence?.snippet).toContain('incidentTable') + expect(evidence?.snippet).toContain('triggerNotifications') + expect(evidence?.snippet).not.toContain('import') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the derived monitor-status owner attached to the public page rollup', () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-status-rollup-')) + const sourceFile = join(root, 'statusPage.ts') + try { + writeFileSync(sourceFile, [ + 'export const statusPageRouter = createTRPCRouter({', + ' get: publicProcedure.query(async () => {', + ' const monitorComponents = page.components.filter(isMonitorComponent)', + ' const monitors = monitorComponents.map((c) => {', + ' const events = getEvents({ incidents: c.monitor.incidents })', + ' const status =', + ' events.some((e) => e.type === "incident" && !e.to) &&', + ' barType !== "manual"', + ' ? "error"', + ' : "success";', + ' return {', + ' ...c.monitor,', + ' status,', + ' events,', + ' }', + ' })', + ' const status = monitors.some((m) => m.status === "error")', + ' ? "error"', + ' : "success"', + ' return { ...page, monitors, status }', + ' }),', + '})', + ].join('\n'), 'utf8') + + const evidence = readQueryEvidenceSnippet(sourceFile, 1, { + question: QUESTION, + label: 'statusPage.ts', + sourceLocation: 'L1-L22', + fileNodeLike: true, + }) + + expect(evidence?.snippet).toContain('monitorComponents.map') + expect(evidence?.snippet).toContain('...c.monitor') + expect(evidence?.snippet).toContain('e.type === "incident"') + expect(evidence?.snippet).toContain('monitors.some') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the tRPC output provenance attached to machine-status serialization', () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-status-json-')) + const sourceFile = join(root, 'status-json.ts') + try { + writeFileSync(sourceFile, [ + 'import type { RouterOutputs } from "@openstatus/api"', + 'type Page = NonNullable', + 'export function toStatus(page: Page) {', + ' return { status: pageIndicator(page.status) }', + '}', + 'export function unresolvedIncidents(page: Page) {', + ' return page.statusReports.filter((report) => report.status !== "resolved")', + '}', + ].join('\n'), 'utf8') + + const evidence = readQueryEvidenceSnippet(sourceFile, 1, { + question: QUESTION, + label: 'status-json.ts', + sourceLocation: 'L1-L8', + fileNodeLike: true, + }) + + expect(evidence?.snippet).toContain('RouterOutputs["statusPage"]["get"]') + expect(evidence?.snippet).toContain('pageIndicator(page.status)') + expect(evidence?.snippet).toContain('page.statusReports') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the public route fetch beside the status JSON serializer dispatch', () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-public-status-route-')) + const sourceFile = join(root, 'route.ts') + try { + writeFileSync(sourceFile, [ + 'export async function GET(request: NextRequest) {', + ' const queryClient = getQueryClient()', + ' const data = await queryClient.fetchQuery(', + ' trpc.statusPage.get.queryOptions({ slug: row.slug }),', + ' )', + ' const payload = endpoint === "status"', + ' ? toStatus(data, baseUrl)', + ' : toSummary(data, baseUrl)', + ' return Response.json(payload)', + '}', + ].join('\n'), 'utf8') + + const evidence = readQueryEvidenceSnippet(sourceFile, 1, { + question: QUESTION, + label: 'GET()', + sourceLocation: 'L1-L10', + }) + + expect(evidence?.snippet).toContain('trpc.statusPage.get.queryOptions') + expect(evidence?.snippet).toContain('toStatus(data, baseUrl)') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the failed-check discriminant inside a structured cross-runtime handoff', () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-query-handoff-')) + const sourceFile = join(root, 'checker.go') + try { + writeFileSync(sourceFile, [ + 'package handlers', + '', + 'func HTTPCheckerHandler(req Request, res Response) {', + ' if res.Degraded {', + ' checker.UpdateStatus(ctx, checker.UpdateData{', + ' MonitorId: req.MonitorID,', + ' Status: "degraded",', + ' })', + ' }', + ' if !res.Successful {', + ' checker.UpdateStatus(ctx, checker.UpdateData{', + ' MonitorId: req.MonitorID,', + ' Status: "error",', + ' StatusCode: res.Status,', + ' })', + ' }', + '}', + ].join('\n'), 'utf8') + + const evidence = readQueryEvidenceSnippet(sourceFile, 3, { + question: QUESTION, + label: '.HTTPCheckerHandler()', + sourceLocation: 'L3', + }) + + expect(evidence).toMatchObject({ scope: 'source_file' }) + expect(evidence?.snippet).toContain('UpdateStatus') + expect(evidence?.snippet).toContain('Status: "error"') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the concrete transport provider beside a delivery handoff', () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-query-provider-')) + const sourceFile = join(root, 'update.go') + try { + writeFileSync(sourceFile, [ + 'package checker', + '', + 'func UpdateStatus(ctx context.Context, updateData UpdateData) error {', + ' url := "https://workflows.example/updateStatus"', + ' client, err := cloudtasks.NewClient(ctx, option.WithAuthCredentials(creds))', + ' if err != nil { return err }', + ' req := &taskspb.CreateTaskRequest{Parent: queuePath}', + ' _, err = client.CreateTask(ctx, req)', + ' return err', + '}', + ].join('\n'), 'utf8') + + const evidence = readQueryEvidenceSnippet(sourceFile, 3, { + question: QUESTION, + label: 'UpdateStatus()', + sourceLocation: 'L3', + }) + + expect(evidence?.snippet).toContain('cloudtasks.NewClient') + expect(evidence?.snippet).toContain('client.CreateTask') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps both sides of a public status JSON divergence in one snippet', () => { + const fixtureParent = resolve('out', 'test-runtime') + mkdirSync(fixtureParent, { recursive: true }) + const root = mkdtempSync(join(fixtureParent, 'madar-query-divergence-')) + const sourceFile = join(root, 'status-json.ts') + try { + writeFileSync(sourceFile, [ + 'export function toStatus(page: Page) {', + ' return { status: pageIndicator(page.status) }', + '}', + '', + 'export function unresolvedIncidents(page: Page) {', + ' return page.statusReports.filter((report) => report.status !== "resolved")', + '}', + ].join('\n'), 'utf8') + + const evidence = readQueryEvidenceSnippet(sourceFile, 1, { + question: QUESTION, + label: 'status-json.ts', + sourceLocation: 'L1', + fileNodeLike: true, + }) + + expect(evidence?.snippet).toContain('pageIndicator(page.status)') + expect(evidence?.snippet).toContain('page.statusReports') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) }) diff --git a/tests/unit/retrieve.test.ts b/tests/unit/retrieve.test.ts index 37836810..d5c65d4e 100644 --- a/tests/unit/retrieve.test.ts +++ b/tests/unit/retrieve.test.ts @@ -2121,6 +2121,41 @@ describe('retrieve', () => { expect(result.matched_nodes.find((node) => node.label === 'CallerService')?.relevance_band).toBe('related') }) + it('keeps a matched method with its declaring class across community boundaries', () => { + const graph = new KnowledgeGraph({ directed: true }) + graph.addNode('owner', { + label: 'PersistenceGateway', + source_file: '/src/session-store.ts', + line_number: 1, + node_kind: 'class', + file_type: 'code', + community: 9, + }) + graph.addNode('method', { + label: '.createSession()', + source_file: '/src/session-store.ts', + line_number: 2, + node_kind: 'method', + file_type: 'code', + community: 1, + }) + graph.addEdge('owner', 'method', { + relation: 'method', + confidence: 'EXTRACTED', + source_file: '/src/session-store.ts', + }) + + const result = retrieveContext(graph, { + question: 'how does create session work', + budget: 3000, + retrievalLevel: 3, + }) + const labels = result.matched_nodes.map((node) => node.label) + + expect(labels).toContain('.createSession()') + expect(labels).toContain('PersistenceGateway') + }) + it('includes relationships between matched nodes', () => { const graph = buildTestGraph() const result = retrieveContext(graph, { question: 'auth', budget: 5000, retrievalLevel: 4 }) @@ -3256,6 +3291,36 @@ describe('retrieve', () => { } }) + it('retains the first supporting node for every emitted claim during stdio compaction', () => { + const graph = buildExpansionGraph() + const rawResult = retrieveContext(graph, { question: 'auth', budget: 1 }) + const anchorIndex = 20 + const matchedNodes = Array.from({ length: 48 }, (_, index) => { + const source = rawResult.matched_nodes[index % rawResult.matched_nodes.length]! + return { + ...source, + ...(source.node_id ? { node_id: `${source.node_id}-${index}` } : {}), + label: `${source.label}-${index}`, + } + }) + const anchorLabel = matchedNodes[anchorIndex]!.label + const oversizedResult: ReturnType = { + ...rawResult, + matched_nodes: matchedNodes, + claims: [{ + evidence_class: 'primary', + text: `input provenance: ${anchorLabel} consumes router output`, + node_labels: [anchorLabel], + }], + } + + const compactResult = compactRetrieveResultForStdio(oversizedResult, { maxOutputTokens: 2000 }) + + expect(compactResult.matched_nodes.length).toBeLessThan(oversizedResult.matched_nodes.length) + expect(compactResult.matched_nodes.some((node) => node.label === anchorLabel)).toBe(true) + expect(compactResult.claims?.[0]?.node_labels).toEqual([anchorLabel]) + }) + it('assigns higher match_score to direct matches than neighbors', () => { const graph = buildTestGraph() const result = retrieveContext(graph, { question: 'auth', budget: 5000 }) diff --git a/tests/unit/stdio-tool-profile.test.ts b/tests/unit/stdio-tool-profile.test.ts index c9d1012f..e0b6caa4 100644 --- a/tests/unit/stdio-tool-profile.test.ts +++ b/tests/unit/stdio-tool-profile.test.ts @@ -11,6 +11,7 @@ import { resolveToolProfileFromEnv, } from '../../src/runtime/stdio/definitions.js' import { handleStdioRequest } from '../../src/runtime/stdio-server.js' +import { constrainStrictContextPackPayload } from '../../src/runtime/stdio/tools.js' function createMinimalGraphRoot(): string { const parentDir = resolve('out', 'test-runtime') @@ -32,6 +33,38 @@ function createMinimalGraphRoot(): string { return root } +function createOverflowExpansionGraphRoot(): string { + const parentDir = resolve('out', 'test-runtime') + mkdirSync(parentDir, { recursive: true }) + const root = mkdtempSync(join(parentDir, 'madar-tool-profile-overflow-')) + const sourceDir = join(root, 'src') + mkdirSync(sourceDir, { recursive: true }) + const sourceFile = join(sourceDir, 'large.ts') + const sourceLines = Array.from( + { length: 220 }, + (_, index) => `export const evidence_${index + 1} = '${'cross layer runtime status evidence '.repeat(12)}'`, + ) + writeFileSync(sourceFile, `${sourceLines.join('\n')}\n`, 'utf8') + writeFileSync( + join(root, 'graph.json'), + JSON.stringify({ + community_labels: {}, + nodes: Array.from({ length: 600 }, (_, index) => ({ + id: `overflow-${index + 1}`, + label: `OverflowEvidence${index + 1}`, + source_file: 'src/large.ts', + source_location: 'L10', + file_type: 'code', + community: 0, + })), + edges: [], + hyperedges: [], + }), + 'utf8', + ) + return root +} + async function withProfile(profile: 'core' | 'strict' | 'full' | undefined, fn: () => void | Promise): Promise { const previous = process.env.MADAR_TOOL_PROFILE if (profile === undefined) { @@ -64,10 +97,10 @@ describe('MCP tool profile', () => { expect(tools.length).toBeGreaterThan(CORE_TOOL_NAMES.length) }) - it('returns core plus context_pack and context_expand for the strict profile', () => { + it('returns only the bounded pack and expansion pair for the strict profile', () => { const tools = activeMcpTools('strict') expect(tools.map((tool) => tool.name).sort()).toEqual([...STRICT_TOOL_NAMES].sort()) - expect(tools).toHaveLength(9) + expect(tools).toHaveLength(2) }) it('defaults to the core profile when called with no argument', () => { @@ -91,7 +124,7 @@ describe('MCP tool profile', () => { expect(actualOrder).toEqual(expectedOrder) }) - it('adds only the bounded pack and expansion pair to strict while keeping the rest full-only', () => { + it('keeps strict to the bounded pack and expansion pair while core/full retain navigation', () => { const fullToolNames = activeMcpTools('full').map((tool) => tool.name) const coreToolNames = activeMcpTools('core').map((tool) => tool.name) const strictToolNames = activeMcpTools('strict').map((tool) => tool.name) @@ -99,7 +132,9 @@ describe('MCP tool profile', () => { expect(fullToolNames).toEqual(expect.arrayContaining(['context_pack', 'context_expand', 'context_prompt', 'context_session_reset'])) expect(coreToolNames).not.toContain('context_pack') expect(coreToolNames).not.toContain('context_expand') - expect(strictToolNames).toEqual(expect.arrayContaining(['context_pack', 'context_expand'])) + expect(strictToolNames).toEqual(['context_pack', 'context_expand']) + expect(strictToolNames).not.toContain('retrieve') + expect(strictToolNames).not.toContain('impact') expect(strictToolNames).not.toContain('context_prompt') expect(strictToolNames).not.toContain('context_session_reset') expect(coreToolNames).not.toContain('context_prompt') @@ -120,6 +155,30 @@ describe('MCP tool profile', () => { require_fresh_context: expect.any(Object), })) }) + + it('keeps strict context packs answer-ready and removes expansion tuning fields', () => { + const strictContextPack = activeMcpTools('strict').find((tool) => tool.name === 'context_pack') + const strictContextExpand = activeMcpTools('strict').find((tool) => tool.name === 'context_expand') + const fullContextPack = activeMcpTools('full').find((tool) => tool.name === 'context_pack') + const fullContextExpand = activeMcpTools('full').find((tool) => tool.name === 'context_expand') + + expect(strictContextPack?.description).toContain('exactly once per user task') + expect(strictContextPack?.description).toContain('verbatim') + expect(strictContextPack?.description).toContain('including read-only, no-change, scope, and formatting constraints') + expect(strictContextPack?.inputSchema.properties).toEqual(expect.objectContaining({ + prompt: expect.objectContaining({ description: expect.stringContaining('verbatim') }), + task: expect.any(Object), + })) + expect(Object.keys(strictContextPack?.inputSchema.properties ?? {})).toEqual(['prompt', 'task']) + expect(strictContextExpand?.description).toContain('verify_targets') + expect(strictContextExpand?.description).toContain('result as terminal') + expect(strictContextExpand?.description).toContain('Never call for ready') + expect(Object.keys(strictContextExpand?.inputSchema.properties ?? {})).toEqual(['handle_id']) + expect(fullContextExpand?.description).not.toContain('one strict verification attempt') + expect(fullContextPack?.inputSchema.properties.verbose).toEqual(expect.objectContaining({ + description: expect.stringContaining('developer diagnostics'), + })) + }) }) describe('resolveToolProfileFromEnv', () => { @@ -233,6 +292,78 @@ describe('MCP tool profile', () => { } }) + it('removes graph prompts, resources, and completions from the strict MCP boundary', async () => { + const root = createMinimalGraphRoot() + try { + await withProfile('strict', async () => { + const graphPath = join(root, 'graph.json') + const initialize = await Promise.resolve(handleStdioRequest(graphPath, { id: 1, method: 'initialize' })) + const capabilities = (initialize as { + result?: { capabilities?: Record } + }).result?.capabilities + expect(capabilities).toEqual(expect.objectContaining({ + logging: {}, + tools: { listChanged: false }, + })) + expect(capabilities).not.toHaveProperty('prompts') + expect(capabilities).not.toHaveProperty('resources') + expect(capabilities).not.toHaveProperty('completions') + + const prompts = await Promise.resolve(handleStdioRequest(graphPath, { id: 2, method: 'prompts/list' })) + const resources = await Promise.resolve(handleStdioRequest(graphPath, { id: 3, method: 'resources/list' })) + expect((prompts as { result?: { prompts?: unknown[] } }).result?.prompts).toEqual([]) + expect((resources as { result?: { resources?: unknown[] } }).result?.resources).toEqual([]) + + for (const request of [ + { id: 4, method: 'prompts/get', params: { name: 'graph_query_prompt', arguments: {} } }, + { id: 5, method: 'resources/read', params: { uri: 'madar://artifact/graph.json' } }, + { id: 6, method: 'resources/subscribe', params: { uri: 'madar://artifact/graph.json' } }, + { id: 7, method: 'completion/complete', params: { ref: {}, argument: {} } }, + ]) { + const response = await Promise.resolve(handleStdioRequest(graphPath, request)) + expect(response).toMatchObject({ error: { code: -32601 } }) + } + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects legacy direct graph methods in the strict profile', async () => { + const root = createMinimalGraphRoot() + try { + await withProfile('strict', async () => { + const graphPath = join(root, 'graph.json') + for (const [method, params] of [ + ['query', { question: 'How does A reach B?' }], + ['diff', {}], + ['anomalies', {}], + ['node', { label: 'A' }], + ['neighbors', { label: 'A' }], + ['path', { source: 'A', target: 'B' }], + ['explain', { label: 'A' }], + ['stats', {}], + ['god_nodes', {}], + ['community', { community_id: 0 }], + ] as const) { + const response = await Promise.resolve(handleStdioRequest(graphPath, { + id: 88, + method, + params, + })) + expect(response).toMatchObject({ + error: { + code: -32601, + message: expect.stringContaining(`Legacy graph method '${method}' is disabled`), + }, + }) + } + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('tools/call for a non-core tool returns JSONRPC_METHOD_NOT_FOUND with a profile hint', async () => { const root = createMinimalGraphRoot() try { @@ -310,7 +441,7 @@ describe('MCP tool profile', () => { } }) - it('accepts context_pack and context_expand calls under the installed strict profile (#405, #550)', async () => { + it('keeps strict packs minimal, clears stale handles, and consumes one bounded expansion (#405, #550)', async () => { const root = createMinimalGraphRoot() try { await withProfile('strict', async () => { @@ -332,19 +463,31 @@ describe('MCP tool profile', () => { focus_ranges: [], }, }], + ['second-strict-profile-handle', { + prompt: 'Implement the flow from A to B', + task: 'implement', + task_intent: 'implement', + follow_up: { + kind: 'context_pack', + task_kind: 'implement', + evidence_class: 'supporting', + focus_files: ['b.ts'], + focus_ranges: [], + }, + }], ]), } - const pack = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { + const expanded = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { id: 103, method: 'tools/call', params: { - name: 'context_pack', - arguments: { prompt: 'Implement the flow from A to B', task: 'implement', budget: 1 }, + name: 'context_expand', + arguments: { handle_id: 'strict-profile-handle' }, }, }, sessionState)) - expect(pack).not.toHaveProperty('error') + expect(expanded).not.toHaveProperty('error') - const expanded = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { + const repeated = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { id: 104, method: 'tools/call', params: { @@ -352,7 +495,305 @@ describe('MCP tool profile', () => { arguments: { handle_id: 'strict-profile-handle' }, }, }, sessionState)) + expect(repeated).toMatchObject({ + error: { message: expect.stringContaining('Unknown or unauthorized') }, + }) + + const secondTarget = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { + id: 1041, + method: 'tools/call', + params: { + name: 'context_expand', + arguments: { handle_id: 'second-strict-profile-handle' }, + }, + }, sessionState)) + expect(secondTarget).toMatchObject({ + error: { message: expect.stringContaining('Unknown or unauthorized') }, + }) + + sessionState.contextPackHandles.set('preserved-after-invalid-request', { + prompt: 'Implement the flow from A to B', + task: 'implement', + task_intent: 'implement', + follow_up: { + kind: 'context_pack', + task_kind: 'implement', + evidence_class: 'supporting', + focus_files: ['a.ts'], + focus_ranges: [], + }, + }) + const invalidPack = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { + id: 1042, + method: 'tools/call', + params: { + name: 'context_pack', + arguments: { prompt: 'Implement the flow from A to B', task: 'not-a-task' }, + }, + }, sessionState)) + expect(invalidPack).toMatchObject({ + error: { message: expect.stringContaining('task must be one of') }, + }) + const preserved = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { + id: 1043, + method: 'tools/call', + params: { + name: 'context_expand', + arguments: { handle_id: 'preserved-after-invalid-request' }, + }, + }, sessionState)) + expect(preserved).not.toHaveProperty('error') + + sessionState.contextPackHandles.set('stale-handle', { + prompt: 'Implement the flow from A to B', + task: 'implement', + task_intent: 'implement', + follow_up: { + kind: 'context_pack', + task_kind: 'implement', + evidence_class: 'supporting', + focus_files: ['a.ts'], + focus_ranges: [], + }, + }) + const pack = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { + id: 105, + method: 'tools/call', + params: { + name: 'context_pack', + arguments: { prompt: 'Implement the flow from A to B', task: 'implement' }, + }, + }, sessionState)) + expect(pack).not.toHaveProperty('error') + + const stale = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { + id: 106, + method: 'tools/call', + params: { + name: 'context_expand', + arguments: { handle_id: 'stale-handle' }, + }, + }, sessionState)) + expect(stale).toMatchObject({ + error: { message: expect.stringContaining('Unknown or unauthorized') }, + }) + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('turns a strict file-only verification target into one authorized expansion', async () => { + const root = createMinimalGraphRoot() + const graphPath = join(root, 'graph.json') + try { + const payload = constrainStrictContextPackPayload({ + task: 'explain', + pack: { expandable: [] }, + expandable: [], + evidence: { + pack_confidence: 'medium', + agent_directive: 'verify_one_targeted_file', + answerability: { + state: 'verify_targets', + answer_scope: 'partial', + caveats: [], + missing_obligations: ['evidence:supporting'], + verification_targets: [{ + evidence_class: 'supporting', + focus_files: ['a.ts'], + focus_ranges: [], + reason: 'verify missing evidence:supporting', + }], + broad_search_fallback: 'targeted_only', + }, + }, + }, { strictContextPackMode: true }) + const authorization = payload.evidence.answerability.verification_targets[0] as { + handle_id?: string + evidence_class?: string + focus_files?: string[] + } + expect(authorization).toMatchObject({ + handle_id: 'strict-verify-target', + evidence_class: 'supporting', + focus_files: ['a.ts'], + }) + expect(payload.expandable).toEqual([ + expect.objectContaining({ handle_id: 'strict-verify-target' }), + ]) + expect(payload.pack.expandable).toEqual([ + expect.objectContaining({ handle_id: 'strict-verify-target' }), + ]) + + await withProfile('strict', async () => { + const sessionState = { + logLevel: 'info' as const, + subscribedResourceUris: new Set(), + resourceVersions: new Map(), + resourceListSignature: null, + contextPackHandles: new Map([ + ['strict-verify-target', { + prompt: 'Explain the flow from A to B', + task: 'explain', + task_intent: 'explain', + follow_up: { + kind: 'context_pack', + task_kind: 'explain', + evidence_class: 'supporting', + focus_files: ['a.ts'], + focus_ranges: [], + }, + }], + ]), + contextPackCache: new Map(), + contextPackNodeIds: new Map>(), + } + + const expanded = await Promise.resolve(handleStdioRequest(graphPath, { + id: 108, + method: 'tools/call', + params: { name: 'context_expand', arguments: { handle_id: 'strict-verify-target' } }, + }, sessionState)) expect(expanded).not.toHaveProperty('error') + const repeated = await Promise.resolve(handleStdioRequest(graphPath, { + id: 109, + method: 'tools/call', + params: { name: 'context_expand', arguments: { handle_id: 'strict-verify-target' } }, + }, sessionState)) + expect(repeated).toMatchObject({ + error: { message: expect.stringContaining('Unknown or unauthorized') }, + }) + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('makes the one strict verification expansion terminal instead of advertising uncallable follow-on handles', async () => { + const root = createOverflowExpansionGraphRoot() + const graphPath = join(root, 'graph.json') + const storedHandle = { + prompt: 'Trace the cross-layer runtime status path and identify the remaining uncertainty', + task: 'explain' as const, + task_intent: 'explain', + follow_up: { + kind: 'context_pack' as const, + task_kind: 'explain' as const, + evidence_class: 'supporting' as const, + focus_files: ['src/large.ts'], + focus_ranges: [], + }, + } + const makeSessionState = () => ({ + logLevel: 'info' as const, + subscribedResourceUris: new Set(), + resourceVersions: new Map(), + resourceListSignature: null, + contextPackHandles: new Map([['overflow-handle', storedHandle]]), + }) + try { + let fullPayload: { + expandable?: Array<{ handle_id: string }> + pack?: { expandable?: Array<{ handle_id: string }> } + evidence: { answerability: { state: string; verification_targets: Array<{ handle_id?: string }> } } + } | undefined + await withProfile('full', async () => { + const response = await Promise.resolve(handleStdioRequest(graphPath, { + id: 110, + method: 'tools/call', + params: { name: 'context_expand', arguments: { handle_id: 'overflow-handle' } }, + }, makeSessionState())) + expect(response).not.toHaveProperty('error') + fullPayload = JSON.parse( + (response?.result as { content: Array<{ text: string }> }).content[0]!.text, + ) + expect(fullPayload?.expandable?.length).toBeGreaterThan(0) + }) + + await withProfile('strict', async () => { + const sessionState = makeSessionState() + const response = await Promise.resolve(handleStdioRequest(graphPath, { + id: 111, + method: 'tools/call', + params: { name: 'context_expand', arguments: { handle_id: 'overflow-handle' } }, + }, sessionState)) + expect(response).not.toHaveProperty('error') + const payload = JSON.parse( + (response?.result as { content: Array<{ text: string }> }).content[0]!.text, + ) as { + handle_id?: string + expandable?: Array<{ handle_id: string }> + pack?: { expandable?: Array<{ handle_id: string }> } + evidence: { + pack_confidence: string + agent_directive: string + answerability: { + state: string + verification_targets: Array<{ handle_id: string }> + broad_search_fallback: string + caveats: string[] + } + } + } + expect(payload.handle_id).toBeUndefined() + expect(payload.expandable).toBeUndefined() + expect(payload.pack?.expandable).toBeUndefined() + expect(payload.evidence).toMatchObject({ + pack_confidence: 'low', + agent_directive: 'answer_from_pack', + answerability: { + state: 'insufficient', + verification_targets: [], + broad_search_fallback: 'blocked', + }, + }) + expect(payload.evidence.answerability.caveats).toContain( + 'strict verification expansion limit reached; remaining targets were not authorized', + ) + expect(sessionState.contextPackHandles.size).toBe(0) + const followOnHandle = fullPayload?.expandable?.[0]?.handle_id + expect(followOnHandle).toEqual(expect.any(String)) + + const retried = await Promise.resolve(handleStdioRequest(graphPath, { + id: 112, + method: 'tools/call', + params: { + name: 'context_expand', + arguments: { handle_id: followOnHandle }, + }, + }, sessionState)) + expect(retried).toMatchObject({ + error: { message: expect.stringContaining('Unknown or unauthorized') }, + }) + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('rejects all context-pack diagnostics and tuning fields under the strict profile', async () => { + const root = createMinimalGraphRoot() + try { + await withProfile('strict', async () => { + const response = await Promise.resolve(handleStdioRequest(join(root, 'graph.json'), { + id: 105, + method: 'tools/call', + params: { + name: 'context_pack', + arguments: { + prompt: 'Explain the flow from A to B', + task: 'explain', + verbose: true, + }, + }, + })) + + expect(response).toMatchObject({ + error: { + message: expect.stringContaining('strict context_pack accepts only prompt and optional task'), + }, + }) }) } finally { rmSync(root, { recursive: true, force: true }) diff --git a/tests/unit/watch.test.ts b/tests/unit/watch.test.ts index f0bf767b..9858ceb8 100644 --- a/tests/unit/watch.test.ts +++ b/tests/unit/watch.test.ts @@ -209,6 +209,37 @@ describe('watch', () => { }) }) + test('does not rebuild when managed root agent instructions were added after generation', async () => { + await withTempDirAsync(async (tempDir) => { + writeFileSync(join(tempDir, 'main.ts'), 'export const value = 1\n', 'utf8') + const generated = generateGraph(tempDir, { noHtml: true }) + writeFileSync(join(tempDir, 'AGENTS.md'), '# Madar instructions\n', 'utf8') + writeFileSync(join(tempDir, 'CLAUDE.md'), '# Madar instructions\n', 'utf8') + const rebuild = vi.fn(() => true) + const refresh = startGraphAutoRefresh(tempDir, 0.02, { + pollIntervalMs: 20, + noHtml: true, + rebuildCode: rebuild, + logger: { log() {}, error() {} }, + }) + + try { + await refresh.startupSettled + expect(refresh.startupComplete?.()).toBe(true) + expect(refresh.initialRebuilt).toBe(false) + expect(rebuild).not.toHaveBeenCalled() + expect(readWatcherStateForGraph(generated.graphPath)).toMatchObject({ + status: 'idle', + coverage: 'complete', + policy_match: true, + }) + } finally { + refresh.stop() + await refresh.completed + } + }) + }) + test('does not treat hard-ignored source directories as new graph candidates', async () => { await withTempDirAsync(async (tempDir) => { writeFileSync(join(tempDir, 'main.ts'), 'export const value = 1\n', 'utf8') diff --git a/vitest.config.ts b/vitest.config.ts index ef3f3b85..04511737 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: ['tests/setup.ts'], include: ['tests/**/*.test.ts'], maxWorkers: 4, // Heavy benchmark/install/runtime suites can exceed 15s on the slower From 7e681b2ac326770da2063cfe80df35d34562a268 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 02:22:33 +0400 Subject: [PATCH 03/11] fix: preserve qualified retrieval anchors --- src/runtime/retrieve.ts | 32 +++++++++++++++++++++++++++++++- src/runtime/retrieve/slicing.ts | 13 ++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/runtime/retrieve.ts b/src/runtime/retrieve.ts index 8c321607..62ae7bf3 100644 --- a/src/runtime/retrieve.ts +++ b/src/runtime/retrieve.ts @@ -1521,6 +1521,7 @@ function scoredNodeFromGraphEntry( community: parseCommunityId(attributes.community), frameworkBoost: frameworkBoostForNode(frameworkProfile, nodeKind, frameworkRole, frameworkMetadataFromAttributes(attributes), questionLower), exactLabelMatch: false, + symbolMatchScore: 0, literalPathMatch: false, sourcePathMatch: false, evidenceTier: 0, @@ -1602,6 +1603,7 @@ interface SeedCandidate { frameworkBoost: number seedScore: SeedScoreBreakdown exactLabelMatch: boolean + symbolMatchScore: number literalPathMatch: boolean sourcePathMatch: boolean evidenceTier: 0 | 1 | 2 @@ -1625,6 +1627,7 @@ interface ScoredNode { community: number | null frameworkBoost: number exactLabelMatch: boolean + symbolMatchScore: number literalPathMatch: boolean sourcePathMatch: boolean evidenceTier: 0 | 1 | 2 @@ -1654,6 +1657,7 @@ function scoredNodeFromGraph(graph: KnowledgeGraph, nodeId: string, score: numbe community: parseCommunityId(attributes.community), frameworkBoost: 0, exactLabelMatch: false, + symbolMatchScore: 0, literalPathMatch: false, sourcePathMatch: false, evidenceTier: 0, @@ -2526,6 +2530,22 @@ function lowValueReportGenerationCompactNode( || /(?:^|[.#])(?:generatefallbacktitle|generatetitle|getstatusmessage|claimqueuedpipelinerun|releasequeuedpipelineclaim|releaseunusedcreditreservation|generatebuildperspective|generatesignedurl|generateletsbuild|publishidea|getidea|listideas|deleteidea|suggestimprovements)[A-Za-z_$\w]*\(?\)?$/i.test(node.label) } +function recoveredReportGenerationNoise( + question: string, + node: Pick, +): boolean { + if (!promptWantsRuntimePipeline(question) || !promptWantsReportGenerationCore(question)) { + return false + } + + return lowValueReportGenerationCompactNode({ + label: node.label, + source_file: node.sourceFile, + node_kind: node.nodeKind, + framework_role: node.frameworkRole, + }) +} + function reportGenerationCompactApplies(result: RetrieveResult): boolean { if ( result.retrieval_strategy !== 'slice-v1' @@ -5406,6 +5426,7 @@ function retrieveContextPass( total: totalSeedScore, }, exactLabelMatch: effectiveScore.labelExactScore > 0 || exactAnchorMatch, + symbolMatchScore: symbolMatch, literalPathMatch: mentionedPathMatch, sourcePathMatch: effectiveScore.sourcePathScore > 0 || mentionedPathMatch, // When the seed only made it in via metadata boost, give it at @@ -5464,6 +5485,7 @@ function retrieveContextPass( community: candidate.community, frameworkBoost: candidate.frameworkBoost, exactLabelMatch: candidate.exactLabelMatch, + symbolMatchScore: candidate.symbolMatchScore, literalPathMatch: candidate.literalPathMatch, sourcePathMatch: candidate.sourcePathMatch, evidenceTier: candidate.evidenceTier, @@ -5743,6 +5765,7 @@ function retrieveContextPass( community, frameworkBoost: 0, exactLabelMatch: symbolMatch >= 3, + symbolMatchScore: symbolMatch, literalPathMatch: pathMatch, sourcePathMatch: pathMatch, evidenceTier: hopDistances.get(nodeId) === 1 ? (hopEvidenceTiers.get(nodeId) ?? 0) : 0, @@ -5834,7 +5857,13 @@ function retrieveContextPass( const inclusionOrder = expansionPolicy.include_peripheral ? frameworkOrderedCandidates : frameworkOrderedCandidates.filter((node) => node.relevanceBand !== 'peripheral') - let orderedCandidates = inclusionOrder + // Cross-domain conceptual recovery can intentionally keep disconnected + // evidence outside one slice. For a report-generation workflow, exclude + // known side actions from that preserved set so controller siblings such + // as list/status/health do not displace the execution path. + let orderedCandidates = preserveConceptualObligationOrder + ? inclusionOrder.filter((node) => !recoveredReportGenerationNoise(question, node)) + : inclusionOrder let sliceMetadata: ContextPackSliceMetadata | undefined // A multi-obligation conceptual fallback can deliberately assemble // cross-service and cross-language owners that do not form one local @@ -5849,6 +5878,7 @@ function retrieveContextPass( label: node.label, sourceFile: node.sourceFile, exactLabelMatch: node.exactLabelMatch, + symbolMatchScore: node.symbolMatchScore, sourcePathMatch: node.sourcePathMatch, literalPathMatch: node.literalPathMatch, score: node.score, diff --git a/src/runtime/retrieve/slicing.ts b/src/runtime/retrieve/slicing.ts index 36f226af..59d27667 100644 --- a/src/runtime/retrieve/slicing.ts +++ b/src/runtime/retrieve/slicing.ts @@ -20,6 +20,8 @@ export interface SliceScoredNode { nodeKind?: string | undefined frameworkRole?: string | undefined exactLabelMatch: boolean + /** Strength of the prompt's explicit symbol match; preserves Class.method precision. */ + symbolMatchScore?: number literalPathMatch?: boolean sourcePathMatch: boolean score: number @@ -412,7 +414,16 @@ function buildAnchors(graph: KnowledgeGraph, scored: readonly SliceScoredNode[], const anchors: ContextPackSliceAnchor[] = [] const seen = new Set() const matchedAnchors = scored.filter((node) => node.exactLabelMatch || node.sourcePathMatch) - const exactMethodAnchors = matchedAnchors.filter((node) => node.exactLabelMatch && methodLikeNode(node)) + const exactMethodAnchors = matchedAnchors + .filter((node) => node.exactLabelMatch && methodLikeNode(node)) + // A qualified symbol can also weakly match a same-named method on a + // different class. Preserve the score from retrieval so the explicitly + // named Class.method remains the entry-point anchor. + .sort((left, right) => ( + (right.symbolMatchScore ?? 0) - (left.symbolMatchScore ?? 0) + || right.score - left.score + || left.id.localeCompare(right.id) + )) const nonBarrelMatchedAnchors = matchedAnchors.filter((node) => !isBarrelLike(node.label, node.sourceFile)) const broadRuntimeGeneration = broadRuntimeGenerationPrompt(options) const reportGenerationPrompt = promptWantsReportGenerationCore(options.prompt) From b42a140c2ecd29809b770f0df628a2b90a8c46f4 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 02:30:31 +0400 Subject: [PATCH 04/11] fix: restore stable retrieval quality gates --- src/runtime/retrieve.ts | 17 ++++++++++++++--- src/runtime/stdio/definitions.ts | 2 +- tests/unit/context-pack-adapter-gating.test.ts | 4 ++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/runtime/retrieve.ts b/src/runtime/retrieve.ts index 62ae7bf3..185ca5b8 100644 --- a/src/runtime/retrieve.ts +++ b/src/runtime/retrieve.ts @@ -1682,6 +1682,7 @@ interface FrameworkQuestionProfile { fastify: boolean trpc: boolean prisma: boolean + gin: boolean routeIntent: boolean middlewareIntent: boolean handlerIntent: boolean @@ -1743,6 +1744,7 @@ function activeFrameworksForProfile(profile: FrameworkQuestionProfile): Readonly if (profile.fastify) frameworks.add('fastify') if (profile.trpc) frameworks.add('trpc') if (profile.prisma) frameworks.add('prisma') + if (profile.gin) frameworks.add('gin') return frameworks } @@ -4442,6 +4444,7 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl const explicitFastify = includesAnyToken(questionTokens, ['fastify']) const explicitTrpc = includesAnyToken(questionTokens, ['trpc', 'procedure', 'procedures']) const explicitPrisma = includesAnyToken(questionTokens, ['prisma']) + const explicitGin = includesAnyToken(questionTokens, ['gin']) const routerIntent = includesAnyToken(questionTokens, ['router', 'routers']) const resolverIntent = includesAnyToken(questionTokens, ['resolver', 'resolvers', 'graphql']) const pluginIntent = includesAnyToken(questionTokens, ['plugin', 'plugins']) @@ -4513,8 +4516,7 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl explicitNextPagesArtifact || layoutIntent || clientIntent || - serverIntent || - apiIntent + serverIntent const hono = explicitHono const fastify = explicitFastify || pluginIntent const trpc = @@ -4522,6 +4524,10 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl || procedureIntent || (routerIntent && (queryIntent || mutationIntent || subscriptionIntent)) const repository = storageEndpointIntent + const gin = explicitGin || ( + includesAnyToken(questionTokens, ['go']) + && (routeIntent || apiIntent) + ) const prisma = explicitPrisma || modelIntent || persistenceIntent || ( (storageReadIntent || storageWriteIntent) && (modelIntent || explicitPrisma || persistenceIntent) @@ -4560,7 +4566,7 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl includesAnyToken(questionTokens, ['route', 'routes', 'middleware', 'action', 'actions', 'page', 'pages'])) return { - frameworkShaped: express || routingControllers || redux || reactRouter || nest || next || repository || hono || fastify || trpc || prisma, + frameworkShaped: express || routingControllers || redux || reactRouter || nest || next || repository || hono || fastify || trpc || prisma || gin, express, routingControllers, redux, @@ -4572,6 +4578,7 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl fastify, trpc, prisma, + gin, routeIntent, middlewareIntent, handlerIntent, @@ -4913,6 +4920,10 @@ function frameworkBoostForNode( } } + if (profile.gin && frameworkRole === 'gin_route') { + boost += profile.routeIntent ? 4 : 1.5 + } + if (profile.frameworkShaped && boost === 0 && ['function', 'class', 'variable'].includes(nodeKind)) { boost -= 0.5 } diff --git a/src/runtime/stdio/definitions.ts b/src/runtime/stdio/definitions.ts index 7dece3b8..2a4a7789 100644 --- a/src/runtime/stdio/definitions.ts +++ b/src/runtime/stdio/definitions.ts @@ -240,7 +240,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ { name: 'context_pack', description: - 'Build an answer-ready compact context pack. Call exactly once per user task and copy the entire user codebase request verbatim, byte-for-byte into prompt, including read-only, no-change, scope, and formatting constraints; do not rewrite, omit, expand, enumerate, split, or issue follow-up context_pack calls. For read-only explain tasks, ready or ready_with_caveat is terminal and must be answered from the pack. For normal agent work, pass prompt and task only; verbose diagnostics are a full-profile developer option.', + 'Build an answer-ready compact context pack. Call exactly once per user task and copy the entire user codebase request verbatim, byte-for-byte into prompt, including read-only, no-change, scope, and formatting constraints; do not rewrite, omit, expand, enumerate, split, or issue follow-up context_pack calls. For read-only explain tasks, ready or ready_with_caveat is terminal and must be answered from the pack. Pass prompt and task only; verbose diagnostics are full-profile only.', inputSchema: { type: 'object', required: ['prompt'], diff --git a/tests/unit/context-pack-adapter-gating.test.ts b/tests/unit/context-pack-adapter-gating.test.ts index a375ed03..b92ee634 100644 --- a/tests/unit/context-pack-adapter-gating.test.ts +++ b/tests/unit/context-pack-adapter-gating.test.ts @@ -196,7 +196,7 @@ function buildRetrieval(coverage: ContextPackCoverage): RetrieveResult { line_number: 763, node_kind: 'function', file_type: 'code', - snippet: 'export async function runContextPackCommand(...)', + snippet: 'return gatingDirectiveAdapterWording(packQuality)', match_score: 0.91, relevance_band: 'direct', community: 0, @@ -224,7 +224,7 @@ function buildCompactPack(coverage: ContextPackCoverage) { line_number: 763, node_kind: 'function', file_type: 'code', - snippet: 'export async function runContextPackCommand(...)', + snippet: 'return gatingDirectiveAdapterWording(packQuality)', match_score: 0.91, relevance_band: 'direct' as const, community: 0, From 8d6edcece901ee9284c4ca90a9c1146be43b88c5 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 02:40:30 +0400 Subject: [PATCH 05/11] fix: keep Go route retrieval framework-neutral --- src/runtime/retrieve.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/runtime/retrieve.ts b/src/runtime/retrieve.ts index 185ca5b8..d2d8515d 100644 --- a/src/runtime/retrieve.ts +++ b/src/runtime/retrieve.ts @@ -1682,7 +1682,7 @@ interface FrameworkQuestionProfile { fastify: boolean trpc: boolean prisma: boolean - gin: boolean + goRouteIntent: boolean routeIntent: boolean middlewareIntent: boolean handlerIntent: boolean @@ -1744,7 +1744,6 @@ function activeFrameworksForProfile(profile: FrameworkQuestionProfile): Readonly if (profile.fastify) frameworks.add('fastify') if (profile.trpc) frameworks.add('trpc') if (profile.prisma) frameworks.add('prisma') - if (profile.gin) frameworks.add('gin') return frameworks } @@ -4524,7 +4523,7 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl || procedureIntent || (routerIntent && (queryIntent || mutationIntent || subscriptionIntent)) const repository = storageEndpointIntent - const gin = explicitGin || ( + const goRouteIntent = explicitGin || ( includesAnyToken(questionTokens, ['go']) && (routeIntent || apiIntent) ) @@ -4566,7 +4565,7 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl includesAnyToken(questionTokens, ['route', 'routes', 'middleware', 'action', 'actions', 'page', 'pages'])) return { - frameworkShaped: express || routingControllers || redux || reactRouter || nest || next || repository || hono || fastify || trpc || prisma || gin, + frameworkShaped: express || routingControllers || redux || reactRouter || nest || next || repository || hono || fastify || trpc || prisma || goRouteIntent, express, routingControllers, redux, @@ -4578,7 +4577,7 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl fastify, trpc, prisma, - gin, + goRouteIntent, routeIntent, middlewareIntent, handlerIntent, @@ -4920,7 +4919,11 @@ function frameworkBoostForNode( } } - if (profile.gin && frameworkRole === 'gin_route') { + // A Go API question is language-shaped, not a declaration that the entire + // repository uses Gin. Give extracted Gin routes a local ranking signal + // while leaving the rest of the candidate pool available for mixed-runtime + // repos and the downstream handler/service flow. + if (profile.goRouteIntent && frameworkRole === 'gin_route') { boost += profile.routeIntent ? 4 : 1.5 } From 4e4e26f6796862f4ea38bd352336612a10f3dbdd Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 02:56:19 +0400 Subject: [PATCH 06/11] fix: address strict retrieval review findings --- src/infrastructure/compare.ts | 14 +++++++---- src/infrastructure/install-skill-templates.ts | 9 ------- src/runtime/retrieve.ts | 6 ++--- src/runtime/retrieve/conceptual-fallback.ts | 12 ++++----- src/runtime/stdio/definitions.ts | 8 ++++-- src/runtime/stdio/tools.ts | 25 ++++++++++++++----- src/shared/discovery-safety.ts | 2 +- tests/unit/install-templates.test.ts | 4 +-- 8 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/infrastructure/compare.ts b/src/infrastructure/compare.ts index 49cb6970..5925eb58 100644 --- a/src/infrastructure/compare.ts +++ b/src/infrastructure/compare.ts @@ -2811,7 +2811,7 @@ export function buildNativeAgentPrompt( ? [ 'Call mcp__madar__context_pack exactly once before editing files or raw repository search.', 'Copy the exact Question text below byte-for-byte into context_pack.prompt; do not restate, expand, enumerate, split, or issue a second context_pack call.', - 'Strict exposes only context_pack and a listed verify_targets context_expand handle. A ready or ready_with_caveat pack is terminal.', + 'Use the single context pack as implementation guidance. Do not make another Madar MCP call or restart repository discovery; native edits and validation commands remain required.', ] : ['Call context_pack first for implementation tasks before editing files or broad raw repo search.']), 'Inspect likely_edit_files, likely_test_files, validation_commands, cautions, and risk boundaries before making any changes.', @@ -5080,7 +5080,7 @@ function nativeAgentAttributionGapEvidence(report: NativeAgentCompareReport): st } function assessNativeAgentPromptContract( - report: Pick, + report: Pick, toolProfile: McpToolProfile, runtimeProofProfile?: RuntimeProofProfile, ): NativeAgentPromptContractAssessment { @@ -5110,8 +5110,12 @@ function assessNativeAgentPromptContract( evidence.push(`strict profile requires exactly one context_pack call (recorded ${report.madar_trace.context_pack_call_count})`) } const packPrompts = traceContextPackPrompts(report.madar_trace) - if (packPrompts.length === 1 && packPrompts[0] !== report.question) { - evidence.push('strict context_pack prompt did not byte-match the user question') + if (packPrompts.length === 1) { + if (packPrompts[0] === null) { + measurementGaps.push('strict context_pack prompt was unavailable in the trace') + } else if (packPrompts[0] !== report.question) { + evidence.push('strict context_pack prompt did not byte-match the user question') + } } if (traceHasTerminalContextPackResult(report.madar_trace)) { const postTerminalToolUses = traceToolUsesAfterTerminalContextPack(report.madar_trace) @@ -5124,7 +5128,7 @@ function assessNativeAgentPromptContract( if (otherMcpTool) { evidence.push(`terminal strict context_pack was followed by another MCP call: ${otherMcpTool}`) } - if (postTerminalToolUses.some((toolName) => ( + if (report.task !== 'implement' && postTerminalToolUses.some((toolName) => ( isFocusedFollowUpTraceToolName(toolName) || isBroadExplorationTraceToolName(toolName) ))) { evidence.push('terminal strict context_pack was followed by repository exploration') diff --git a/src/infrastructure/install-skill-templates.ts b/src/infrastructure/install-skill-templates.ts index 8ed5924e..690c79b6 100644 --- a/src/infrastructure/install-skill-templates.ts +++ b/src/infrastructure/install-skill-templates.ts @@ -313,15 +313,6 @@ ${renderMarkdownCodexRoutingTable()} Pass the entire user's codebase request byte-for-byte as the pack question, including read-only, no-change, scope, and formatting constraints. Do not rewrite, omit, expand, enumerate, or split it into follow-up pack calls. -If MCP graph tools are available after the pack, use the focused tool that matches the next question: -- ${CODE_SPAN_START}retrieve${CODE_SPAN_END} for direct codebase questions -- ${CODE_SPAN_START}relevant_files${CODE_SPAN_END} for where to open first -- ${CODE_SPAN_START}feature_map${CODE_SPAN_END} for involved areas and entry points -- ${CODE_SPAN_START}risk_map${CODE_SPAN_END} before editing -- ${CODE_SPAN_START}implementation_checklist${CODE_SPAN_END} for edit order and validation checkpoints -- ${CODE_SPAN_START}impact${CODE_SPAN_END} for blast radius -- ${CODE_SPAN_START}graph_summary${CODE_SPAN_END} for repo overview - After calling Madar, treat \`evidence.answerability.state\` as authoritative; \`evidence.pack_confidence\` is compatibility-only: - \`ready\` means answer from the pack without broad raw search. - \`ready_with_caveat\` means answer from the pack and state \`evidence.answerability.caveats\`. diff --git a/src/runtime/retrieve.ts b/src/runtime/retrieve.ts index d2d8515d..d9b472dd 100644 --- a/src/runtime/retrieve.ts +++ b/src/runtime/retrieve.ts @@ -4523,10 +4523,8 @@ function buildFrameworkQuestionProfile(question: string, questionTokens: readonl || procedureIntent || (routerIntent && (queryIntent || mutationIntent || subscriptionIntent)) const repository = storageEndpointIntent - const goRouteIntent = explicitGin || ( - includesAnyToken(questionTokens, ['go']) - && (routeIntent || apiIntent) - ) + const goRouteIntent = explicitGin + || /\b(?:go|golang)\s+(?:api|application|app|service|server)\b/i.test(question) const prisma = explicitPrisma || modelIntent || persistenceIntent || ( (storageReadIntent || storageWriteIntent) && (modelIntent || explicitPrisma || persistenceIntent) diff --git a/src/runtime/retrieve/conceptual-fallback.ts b/src/runtime/retrieve/conceptual-fallback.ts index ca39edf6..872f1d10 100644 --- a/src/runtime/retrieve/conceptual-fallback.ts +++ b/src/runtime/retrieve/conceptual-fallback.ts @@ -118,7 +118,7 @@ const EXPLICIT_TEST_EVIDENCE_PATTERN = /\b(?:test|tests|testing|spec|specs|fixtu const EXPLICIT_TYPE_EVIDENCE_PATTERN = /\b(?:contract|contracts|interface|interfaces|schema|schemas|type|types)\b/i const EXPLICIT_ERROR_DECLARATION_PATTERN = /\b(?:error\s+(?:class|constructor|handling)|exception|exceptions|throw|throws)\b/i const QUERY_EVIDENCE_STATE_MUTATION_PATTERN = /(?:\b(?:create|insert|transition|upsert)\w*\s*\(|\.(?:create|insert|upsert)\s*\(|\bnew\s+\w+)/i -const QUERY_EVIDENCE_DELIVERY_OPERATION_PATTERN = /(?:\b(?:deliver|dispatch|emit|enqueue|publish|send)\w*\s*\(|\.(?:deliver|dispatch|emit|enqueue|publish|send)\w*\s*\()/i +const QUERY_EVIDENCE_DELIVERY_OPERATION_PATTERN = /(?:\b(?:deliver|dispatch|emit|enqueue|notify|publish|send|trigger)\w*\s*\(|\.(?:deliver|dispatch|emit|enqueue|notify|publish|send|trigger)\w*\s*\()/i const QUERY_EVIDENCE_COMPUTATION_OPERATION_PATTERN = /(?:\b(?:compute|derive|resolve)\w*\s*\(|\b\w*(?:indicator|result|state|status)\w*\s*=|\b\w*(?:indicator|status)\w*\s*\()/i const EXPLICIT_ERROR_QUERY_PATTERN = /\b(?:error|exception|throw|throws|thrown)\b/i const FLOW_OUTCOME_TERMS = new Set(['error', 'fail', 'failed', 'failure', 'result', 'response', 'status']) @@ -776,7 +776,7 @@ function presentationShapedNode(node: VocabularyNode): boolean { function runtimeScopeForSource(sourceFile: string): string { const normalized = sourceFile.replaceAll('\\', '/') - const match = normalized.match(/\/(apps|packages)\/([^/]+)/i) + const match = normalized.match(/(?:^|\/)(apps|packages)\/([^/]+)/i) if (match?.[1] && match[2]) { return `${match[1].toLowerCase()}/${match[2].toLowerCase()}` } @@ -1594,8 +1594,8 @@ export function planConceptualFallback( const initialObligationCoverage = new Set( [...selectedIds].flatMap((nodeId) => [...(obligationMatches.get(nodeId) ?? [])]), ).size - const obligationRecoveryNeeded = diversified.preferredByObligation.size >= 2 - && initialObligationCoverage < diversified.preferredByObligation.size + const obligationRecoveryNeeded = obligations.length >= 2 + && initialObligationCoverage < obligations.length const reasons: RetrievalFallbackReason[] = [ ...initialReasons, ...(obligationRecoveryNeeded ? ['missing_query_obligations' as const] : []), @@ -1607,10 +1607,10 @@ export function planConceptualFallback( initial: input.initialQuality, final: input.initialQuality, attempts: [], - ...(diversified.preferredByObligation.size >= 2 + ...(obligations.length >= 2 ? { query_obligations: { - total: diversified.preferredByObligation.size, + total: obligations.length, initially_covered: initialObligationCoverage, finally_covered: initialObligationCoverage, }, diff --git a/src/runtime/stdio/definitions.ts b/src/runtime/stdio/definitions.ts index 2a4a7789..83552d8b 100644 --- a/src/runtime/stdio/definitions.ts +++ b/src/runtime/stdio/definitions.ts @@ -240,7 +240,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ { name: 'context_pack', description: - 'Build an answer-ready compact context pack. Call exactly once per user task and copy the entire user codebase request verbatim, byte-for-byte into prompt, including read-only, no-change, scope, and formatting constraints; do not rewrite, omit, expand, enumerate, split, or issue follow-up context_pack calls. For read-only explain tasks, ready or ready_with_caveat is terminal and must be answered from the pack. Pass prompt and task only; verbose diagnostics are full-profile only.', + 'Build an answer-ready compact context pack. Copy the entire user codebase request verbatim, byte-for-byte into prompt, including read-only, no-change, scope, and formatting constraints. Supports freshness, delta, resolution, retrieval, and verbose diagnostic options.', inputSchema: { type: 'object', required: ['prompt'], @@ -462,7 +462,11 @@ function strictToolSchema(tool: McpToolDefinition): McpToolDefinition { ) return { ...tool, - ...(tool.name === 'context_expand' + ...(tool.name === 'context_pack' + ? { + description: `${tool.description} Strict mode: call exactly once per user task; pass prompt and task only, do not rewrite or split the prompt, and treat ready or ready_with_caveat as terminal for read-only tasks.`, + } + : tool.name === 'context_expand' ? { description: `${tool.description} This is the one strict verification attempt: treat its result as terminal and never follow a new handle from it.`, } diff --git a/src/runtime/stdio/tools.ts b/src/runtime/stdio/tools.ts index 12290a8c..3eff7b6a 100644 --- a/src/runtime/stdio/tools.ts +++ b/src/runtime/stdio/tools.ts @@ -933,16 +933,29 @@ function strictVerificationAuthorization(payload: Record): Stri return null } + const focusFiles = entry.follow_up.focus_files.slice(0, 5) + const focusRanges = entry.follow_up.focus_ranges + .filter((range) => sourceFileMatchesFocus(range.source_file, focusFiles)) + .slice(0, 5) + const authorizedEntry: ContextPackExpandableRef = { + ...entry, + follow_up: { + ...entry.follow_up, + focus_files: focusFiles, + focus_ranges: focusRanges, + }, + } + return { - entry, + entry: authorizedEntry, target: { - handle_id: entry.handle_id, - evidence_class: entry.evidence_class, - focus_files: entry.follow_up.focus_files.slice(0, 5), - focus_ranges: entry.follow_up.focus_ranges.slice(0, 5), + handle_id: authorizedEntry.handle_id, + evidence_class: authorizedEntry.evidence_class, + focus_files: focusFiles, + focus_ranges: focusRanges, reason: typeof candidate?.reason === 'string' && candidate.reason.length > 0 ? candidate.reason - : `verify evidence:${entry.evidence_class}`, + : `verify evidence:${authorizedEntry.evidence_class}`, }, } } diff --git a/src/shared/discovery-safety.ts b/src/shared/discovery-safety.ts index 823fe169..78afeb57 100644 --- a/src/shared/discovery-safety.ts +++ b/src/shared/discovery-safety.ts @@ -124,7 +124,7 @@ const RELEVANCE_TOKEN_ALIASES: Readonly> = { sensitive: 'secret', tokens: 'token', } -const ENVIRONMENT_CONFIG_INTENT_PATTERN = /(?:^|[^a-z0-9])\.env(?:[^a-z0-9]|$)|\b(?:config(?:uration)?|deploy(?:ment)?|environment|runtime\s+variable|secret|settings)\b/i +const ENVIRONMENT_CONFIG_INTENT_PATTERN = /(?:^|[^a-z0-9])\.env(?:[^a-z0-9]|$)|\b(?:config(?:uration)?|credentials?|deploy(?:ment)?|environment|key|password|runtime\s+variable|secret|settings|token)\b/i const MAX_GRAPH_ARTIFACT_BYTES = 100 * 1024 * 1024 const MAX_STORED_EXCLUSIONS = 10_000 const MAX_METADATA_CACHE_ENTRIES = 16 diff --git a/tests/unit/install-templates.test.ts b/tests/unit/install-templates.test.ts index 6b767882..f8c4e4f9 100644 --- a/tests/unit/install-templates.test.ts +++ b/tests/unit/install-templates.test.ts @@ -113,8 +113,8 @@ function expectMarkdownPackRoutingTable(content: string): void { expect(normalized).toContain('`madar pack "" --task impact`') expect(normalized).toContain('`retrieve` when MCP graph tools are available') expect(normalized).toContain('`graph_summary` when MCP graph tools are available') - expect(normalized).toContain('`retrieve` for direct codebase questions') - expect(normalized).toContain('`impact` for blast radius') + expect(normalized).not.toContain('`retrieve` for direct codebase questions') + expect(normalized).not.toContain('`impact` for blast radius') expect(normalized).toContain('Do not run ToolSearch before calling a Madar command or graph tool') expect(normalized).toContain('Treat `evidence.answerability.state` as authoritative; `evidence.pack_confidence` is compatibility-only.') expect(normalized).toContain('For `verify_targets`, inspect only the listed verification targets. Restart broad search only for `insufficient` with `broad_search_fallback: allowed`.') From 18009bc1487d96b9305aedb6375bf9c5ede57b4f Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 03:04:11 +0400 Subject: [PATCH 07/11] test: cover obligation recovery telemetry --- tests/unit/stdio-slice-surface.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/stdio-slice-surface.test.ts b/tests/unit/stdio-slice-surface.test.ts index eef539b3..e56b8a26 100644 --- a/tests/unit/stdio-slice-surface.test.ts +++ b/tests/unit/stdio-slice-surface.test.ts @@ -814,14 +814,14 @@ describe('stdio slice-v1 surface', () => { expect(secondPayload.governance?.mcp_call?.cache_status).toBe('hit') expect(deltaPayload.governance?.mcp_call?.cache_status).toBe('bypass') expect(deltaPayload.governance?.mcp_call?.delta_session_hash).toMatch(/^[a-f0-9]{12}$/) - expect(deltaPayload.pack?.retrieval_plan).toEqual({ + expect(deltaPayload.pack?.retrieval_plan).toEqual(expect.objectContaining({ version: 1, - status: 'not_needed', - reasons: [], + status: 'no_candidates', + reasons: expect.arrayContaining(['missing_query_obligations']), initial: expect.any(Object), final: expect.any(Object), - attempts: [], - }) + attempts: expect.arrayContaining([expect.objectContaining({ status: 'no_candidates' })]), + })) expect(JSON.stringify(firstPayload.governance)).not.toContain('AuthController.login') expect(JSON.stringify(firstPayload.governance)).not.toContain(graphPath) expect(JSON.stringify(deltaPayload.governance)).not.toContain(deltaSessionId) From f1030bb1d98121d6cf4a1bf5976a22e93562d28a Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 03:20:49 +0400 Subject: [PATCH 08/11] fix: preserve context-pack evidence boundaries --- src/infrastructure/context-pack-command.ts | 21 +++++++++++++-------- src/runtime/context-pack-recovery.ts | 6 ------ src/runtime/context-pack.ts | 4 +++- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/infrastructure/context-pack-command.ts b/src/infrastructure/context-pack-command.ts index 1954042a..19de678e 100644 --- a/src/infrastructure/context-pack-command.ts +++ b/src/infrastructure/context-pack-command.ts @@ -742,10 +742,7 @@ function compactAnswerReadyNodesForPressure(pack: JsonRecord, trimmedFields: str delete node.file_type compacted = true } - // `line_number` remains the citation anchor in the answer-ready surface. - // The query-evidence excerpt metadata is useful while selecting evidence, - // but duplicates that anchor once the compact response has been formed. - for (const field of ['community', 'community_label', 'snippet_line_number', 'snippet_scope']) { + for (const field of ['community', 'community_label']) { if (Object.hasOwn(node, field)) { delete node[field] compacted = true @@ -1809,8 +1806,12 @@ function serializedVerificationTargets( const existingTargets = asUnknownArray(answerability?.verification_targets) .map((target) => asJsonRecord(target)) .filter((target): target is JsonRecord => target !== null) - if (existingTargets.length > 0) { - return existingTargets.slice(0, 2) + const relevantExistingTargets = existingTargets.filter((target) => { + const reason = typeof target.reason === 'string' ? target.reason.toLowerCase() : '' + return missingObligations.some((obligation) => reason.includes(obligation.toLowerCase())) + }) + if (relevantExistingTargets.length > 0) { + return relevantExistingTargets.slice(0, 2) } const pack = asJsonRecord(payload.pack) @@ -1891,9 +1892,13 @@ function reconcileSerializedQueryEvidence( ])] evidence.evidence_strength = evidenceStrength - const targets = serializedVerificationTargets(payload, missingObligations) - const canVerify = targets.length > 0 const answerability = asJsonRecord(evidence.answerability) ?? {} + const priorRestricted = answerability.state === 'insufficient' + || answerability.broad_search_fallback === 'blocked' + || evidence.pack_confidence === 'low' + || evidence.agent_directive === 'explore_with_caution' + const targets = priorRestricted ? [] : serializedVerificationTargets(payload, missingObligations) + const canVerify = targets.length > 0 answerability.state = canVerify ? 'verify_targets' : 'insufficient' answerability.answer_scope = canVerify ? 'partial' : 'none' answerability.caveats = [...new Set([ diff --git a/src/runtime/context-pack-recovery.ts b/src/runtime/context-pack-recovery.ts index f345136b..6bd8b80a 100644 --- a/src/runtime/context-pack-recovery.ts +++ b/src/runtime/context-pack-recovery.ts @@ -192,15 +192,9 @@ export function recoverContextPackResult( output_token_budget: outputTokenBudget, } as const const initialAssessment = assess(initial, options.question) - const queryObligations = initial.retrieval_plan?.query_obligations - const conceptualObligationsCovered = initial.retrieval_plan?.status === 'recovered' - && queryObligations !== undefined - && queryObligations.total > 0 - && queryObligations.finally_covered === queryObligations.total const recoveryAllowed = recoveryOptions.enabled !== false && options.taskKind !== 'implement' && initial.retrieval_gate?.level !== 0 - && !conceptualObligationsCovered if (!recoveryAllowed || initialAssessment.state === 'ready' || initialAssessment.state === 'ready_with_caveat') { return { ...initial, diff --git a/src/runtime/context-pack.ts b/src/runtime/context-pack.ts index 686c1b66..4d1dba72 100644 --- a/src/runtime/context-pack.ts +++ b/src/runtime/context-pack.ts @@ -412,7 +412,9 @@ function buildInputProvenanceClaims(nodes: readonly ContextPackNode[]): ContextP seen.add(dedupeKey) claims.push({ evidence_class: node.evidence_class ?? 'supporting', - text: `input provenance: ${node.label} consumes data typed as the ${reference} router output`, + text: node.snippet_scope === 'source_file' + ? `input provenance: ${node.source_file} contains ${reference} router-output evidence` + : `input provenance: ${node.label} consumes data typed as the ${reference} router output`, node_labels: [node.label], }) } From 674549c0a99be6804c508b68c64b9669df0e11ce Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 03:26:24 +0400 Subject: [PATCH 09/11] fix: bound refresh waits and claim support --- src/runtime/retrieve.ts | 23 +++++++++++++---------- src/runtime/stdio-server.ts | 16 +++++++++++----- tests/unit/retrieve.test.ts | 4 +++- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/runtime/retrieve.ts b/src/runtime/retrieve.ts index d9b472dd..a2855610 100644 --- a/src/runtime/retrieve.ts +++ b/src/runtime/retrieve.ts @@ -6479,23 +6479,26 @@ function compactRetrievePayloadForStdioProfile( payload: StdioRetrieveResult, profile: RetrieveStdioCompactionProfile, ): StdioRetrieveResult { - const claims = payload.claims?.slice(0, profile.claimCap) ?? [] + const claims: NonNullable = [] const pinnedNodes: StdioRetrieveResult['matched_nodes'] = [] const pinnedNodeSet = new Set() - for (const claim of claims) { - const anchorLabel = claim.node_labels[0] - if (!anchorLabel) { + for (const claim of payload.claims?.slice(0, profile.claimCap) ?? []) { + const supportingNodes = claim.node_labels.map((label) => ( + payload.matched_nodes.find((node) => node.label === label) + )).filter((node): node is StdioRetrieveResult['matched_nodes'][number] => node !== undefined) + if (supportingNodes.length !== claim.node_labels.length) { continue } - - const anchor = payload.matched_nodes.find((node) => node.label === anchorLabel) - if (!anchor || pinnedNodeSet.has(anchor)) { + const newSupportingNodes = supportingNodes.filter((node) => !pinnedNodeSet.has(node)) + if (pinnedNodes.length + newSupportingNodes.length > profile.matchedNodeCap) { continue } - - pinnedNodes.push(anchor) - pinnedNodeSet.add(anchor) + claims.push(claim) + for (const node of newSupportingNodes) { + pinnedNodes.push(node) + pinnedNodeSet.add(node) + } } const remainingCapacity = Math.max(0, profile.matchedNodeCap - pinnedNodes.length) diff --git a/src/runtime/stdio-server.ts b/src/runtime/stdio-server.ts index 4ef37a24..cb021f51 100644 --- a/src/runtime/stdio-server.ts +++ b/src/runtime/stdio-server.ts @@ -1153,7 +1153,11 @@ export async function serveGraphStdio(options: ServeGraphStdioOptions): Promise< const readline = createInterface({ input, crlfDelay: Infinity }) let graphRequestQueue = Promise.resolve() - const handleAndWritePayload = async (payload: unknown, awaitReconciliation: boolean): Promise => { + const handleAndWritePayload = async ( + payload: unknown, + awaitReconciliation: boolean, + arrivalMs = Date.now(), + ): Promise => { let response: StdioResponse | null try { const request = payload as StdioRequest @@ -1170,13 +1174,14 @@ export async function serveGraphStdio(options: ServeGraphStdioOptions): Promise< && !refreshReadiness.ready && refreshReadiness.retryable ) { - const waitStartedAt = Date.now() + const maxWaitMs = Math.max(0, options.autoRefreshRequestWaitMs ?? DEFAULT_AUTO_REFRESH_REQUEST_WAIT_MS) + const remainingWaitMs = Math.max(0, maxWaitMs - (Date.now() - arrivalMs)) refreshReadiness = await waitForAutoRefreshGraphReadiness( autoRefresh, options.graphPath, - Math.max(0, options.autoRefreshRequestWaitMs ?? DEFAULT_AUTO_REFRESH_REQUEST_WAIT_MS), + remainingWaitMs, ) - waitedMs = Date.now() - waitStartedAt + waitedMs = Date.now() - arrivalMs } if (refreshReadiness && !refreshReadiness.ready && requestMethod === 'prompts/list') { @@ -1234,7 +1239,8 @@ export async function serveGraphStdio(options: ServeGraphStdioOptions): Promise< // Keep control/discovery requests responsive while graph-backed work // waits for one bounded reconciliation window. Graph requests remain // serialized because context-pack calls mutate per-session state. - graphRequestQueue = graphRequestQueue.then(() => handleAndWritePayload(payload, true)) + const arrivalMs = Date.now() + graphRequestQueue = graphRequestQueue.then(() => handleAndWritePayload(payload, true, arrivalMs)) continue } diff --git a/tests/unit/retrieve.test.ts b/tests/unit/retrieve.test.ts index d5c65d4e..123d1022 100644 --- a/tests/unit/retrieve.test.ts +++ b/tests/unit/retrieve.test.ts @@ -3245,7 +3245,9 @@ describe('retrieve', () => { expect(compactResult.relationships.length).toBeLessThan(oversizedResult.relationships.length) expect(compactResult.slice?.selected_paths.length).toBeLessThan(240) expect(compactResult.coverage).toEqual(rawResult.coverage) - expect(compactResult.claims).toEqual(rawResult.claims) + for (const claim of compactResult.claims ?? []) { + expect(claim.node_labels.every((label) => compactResult.matched_nodes.some((node) => node.label === label))).toBe(true) + } }) it('drops dangling relationships when stdio compaction trims their endpoint nodes', () => { From 41fe1bb2561e540ced08878e009934b66cfe95b1 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 03:31:57 +0400 Subject: [PATCH 10/11] fix: serialize Codex config updates --- src/infrastructure/install.ts | 227 ++++++++++++++++++++++++---------- tests/unit/install.test.ts | 66 +++++++++- 2 files changed, 229 insertions(+), 64 deletions(-) diff --git a/src/infrastructure/install.ts b/src/infrastructure/install.ts index d9e19f46..21f1eda3 100644 --- a/src/infrastructure/install.ts +++ b/src/infrastructure/install.ts @@ -1,5 +1,5 @@ -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, readFileSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { createHash, randomUUID } from 'node:crypto' +import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' import { getBuiltInSkillContent } from './install-skill-templates.js' @@ -47,6 +47,10 @@ const CODEX_MCP_END_MARKER = '# <<< madar managed mcp <<<' const CODEX_MCP_SCOPED_START_MARKER_PREFIX = '# >>> madar managed mcp:' const CODEX_MCP_SCOPED_END_MARKER_PREFIX = '# <<< madar managed mcp:' const CODEX_MCP_OWNS_PRECEDING_LINE_ENDING_MARKER = '# madar managed mcp: preceding line ending owned' +const CODEX_MCP_CONFIG_LOCK_SUFFIX = '.madar.lock' +const CODEX_MCP_CONFIG_LOCK_TIMEOUT_MS = 10_000 +const CODEX_MCP_CONFIG_LOCK_RETRY_MS = 25 +const CODEX_MCP_CONFIG_DEFAULT_MODE = 0o600 interface InstallPlatformConfig { skillFile: string @@ -2084,6 +2088,93 @@ export function resolveCodexMcpConfigPath(): string { return join(codexHome, 'config.toml') } +function pauseSynchronously(milliseconds: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds) +} + +function tryAcquireCodexConfigLock(lockPath: string): number | null { + try { + // Keep the lock private because the adjacent Codex config can contain + // credentials or other user-level settings. + return openSync(lockPath, 'wx', CODEX_MCP_CONFIG_DEFAULT_MODE) + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error + ? (error as { code?: unknown }).code + : undefined + if (code === 'EEXIST') { + return null + } + throw error + } +} + +/** + * Serialize Madar's read-modify-write operations on Codex's shared user + * configuration. The lock is intentionally adjacent to the config so all + * worktrees that resolve the same CODEX_HOME contend on the same path. + */ +function withCodexConfigLock(configPath: string, action: () => T): T { + const lockPath = `${configPath}${CODEX_MCP_CONFIG_LOCK_SUFFIX}` + const startedAt = Date.now() + ensureParentDirectory(lockPath) + + while (true) { + const lockDescriptor = tryAcquireCodexConfigLock(lockPath) + if (lockDescriptor !== null) { + try { + return action() + } finally { + try { + closeSync(lockDescriptor) + } finally { + rmSync(lockPath, { force: true }) + } + } + } + + if (Date.now() - startedAt >= CODEX_MCP_CONFIG_LOCK_TIMEOUT_MS) { + throw new Error( + `Timed out waiting for another Madar Codex configuration update at ${configPath}. ` + + `If no Madar process is still updating it, remove ${lockPath} and retry.`, + ) + } + pauseSynchronously(CODEX_MCP_CONFIG_LOCK_RETRY_MS) + } +} + +/** Publish an updated config through a same-directory atomic rename. */ +function writeCodexConfigAtomically(configPath: string, content: string): void { + ensureParentDirectory(configPath) + const mode = existsSync(configPath) + ? statSync(configPath).mode & 0o777 + : CODEX_MCP_CONFIG_DEFAULT_MODE + const temporaryPath = join( + dirname(configPath), + `.${basename(configPath)}.madar-${process.pid}-${randomUUID()}.tmp`, + ) + let temporaryDescriptor: number | null = null + + try { + temporaryDescriptor = openSync(temporaryPath, 'wx', mode) + writeFileSync(temporaryDescriptor, content, 'utf8') + // openSync's mode is subject to umask, so restore the exact existing mode + // before publishing the replacement. + chmodSync(temporaryPath, mode) + fsyncSync(temporaryDescriptor) + closeSync(temporaryDescriptor) + temporaryDescriptor = null + renameSync(temporaryPath, configPath) + } finally { + try { + if (temporaryDescriptor !== null) { + closeSync(temporaryDescriptor) + } + } finally { + rmSync(temporaryPath, { force: true }) + } + } +} + function stripTomlComments(content: string): string { let result = '' let quote: 'single' | 'double' | null = null @@ -2234,11 +2325,18 @@ function assertCodexMcpConfigIsSafe(projectDir: string): void { const globalConfigPath = resolveCodexMcpConfigPath() const serverName = codexMcpServerName(projectDir) if (existsSync(globalConfigPath)) { - readManagedCodexMcpBlock( - readFileSync(globalConfigPath, 'utf8'), - scopedCodexMcpStartMarker(serverName), - scopedCodexMcpEndMarker(serverName), - ) + // Preserve the preflight failure behavior without observing the shared + // config outside its lock. The mutation path re-reads it under a fresh + // lock immediately before it updates the scoped block. + withCodexConfigLock(globalConfigPath, () => { + if (existsSync(globalConfigPath)) { + readManagedCodexMcpBlock( + readFileSync(globalConfigPath, 'utf8'), + scopedCodexMcpStartMarker(serverName), + scopedCodexMcpEndMarker(serverName), + ) + } + }) } const legacyConfigPath = join(projectDir, CODEX_MCP_CONFIG_RELATIVE_PATH) @@ -2263,10 +2361,9 @@ function removeManagedCodexMcpBlock(configPath: string, content: string, managed && afterBlock.length > 0 && !afterBlock.startsWith('\n') && !afterBlock.startsWith('\r') - writeFileSync( + writeCodexConfigAtomically( configPath, `${beforeWithoutOwnedLineEnding}${needsLineEndingBeforeAfterBlock ? precedingLineEnding : ''}${afterBlock}`, - 'utf8', ) } @@ -2289,68 +2386,74 @@ function removeLegacyCodexMcpServer(projectDir: string): string | undefined { function installCodexMcpServer(projectDir: string): string { const configPath = resolveCodexMcpConfigPath() const serverName = codexMcpServerName(projectDir) - const content = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '' - const managedBlock = readManagedCodexMcpBlock( - content, - scopedCodexMcpStartMarker(serverName), - scopedCodexMcpEndMarker(serverName), - ) - const lineEnding = lineEndingForContent(content) - const ownsPrecedingLineEnding = managedBlock?.ownsPrecedingLineEnding - ?? (content.length > 0 && !content.endsWith('\n')) - const nextBlock = renderCodexMcpBlock(projectDir, serverName, lineEnding, ownsPrecedingLineEnding) - const unownedContent = managedBlock - ? `${content.slice(0, managedBlock.start)}${content.slice(managedBlock.end)}` - : content - - if (hasUserManagedCodexMcpDeclaration(unownedContent, serverName)) { - return `${configPath} -> MCP server ${serverName} is user-managed (no change)` - } - - let registrationMessage: string - if (managedBlock) { - if (managedBlock.content === nextBlock) { - registrationMessage = `${configPath} -> MCP server ${serverName} already registered (no change)` + return withCodexConfigLock(configPath, () => { + // Re-read after acquiring the shared lock. A different worktree may have + // added or removed its scoped block while this install was waiting. + const content = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '' + const managedBlock = readManagedCodexMcpBlock( + content, + scopedCodexMcpStartMarker(serverName), + scopedCodexMcpEndMarker(serverName), + ) + const lineEnding = lineEndingForContent(content) + const ownsPrecedingLineEnding = managedBlock?.ownsPrecedingLineEnding + ?? (content.length > 0 && !content.endsWith('\n')) + const nextBlock = renderCodexMcpBlock(projectDir, serverName, lineEnding, ownsPrecedingLineEnding) + const unownedContent = managedBlock + ? `${content.slice(0, managedBlock.start)}${content.slice(managedBlock.end)}` + : content + + if (hasUserManagedCodexMcpDeclaration(unownedContent, serverName)) { + return `${configPath} -> MCP server ${serverName} is user-managed (no change)` + } + + let registrationMessage: string + if (managedBlock) { + if (managedBlock.content === nextBlock) { + registrationMessage = `${configPath} -> MCP server ${serverName} already registered (no change)` + } else { + writeCodexConfigAtomically( + configPath, + `${content.slice(0, managedBlock.start)}${nextBlock}${content.slice(managedBlock.end)}`, + ) + registrationMessage = `${configPath} -> MCP server ${serverName} updated` + } } else { - writeFileSync( - configPath, - `${content.slice(0, managedBlock.start)}${nextBlock}${content.slice(managedBlock.end)}`, - 'utf8', - ) - registrationMessage = `${configPath} -> MCP server ${serverName} updated` + const separator = ownsPrecedingLineEnding ? lineEnding : '' + writeCodexConfigAtomically(configPath, `${content}${separator}${nextBlock}`) + registrationMessage = `${configPath} -> MCP server ${serverName} registered` } - } else { - ensureParentDirectory(configPath) - const separator = ownsPrecedingLineEnding ? lineEnding : '' - writeFileSync(configPath, `${content}${separator}${nextBlock}`, 'utf8') - registrationMessage = `${configPath} -> MCP server ${serverName} registered` - } - const legacyMessage = removeLegacyCodexMcpServer(projectDir) - return legacyMessage ? `${registrationMessage}\n${legacyMessage}` : registrationMessage + const legacyMessage = removeLegacyCodexMcpServer(projectDir) + return legacyMessage ? `${registrationMessage}\n${legacyMessage}` : registrationMessage + }) } function uninstallCodexMcpServer(projectDir: string): string | undefined { const configPath = resolveCodexMcpConfigPath() - if (!existsSync(configPath)) { - return removeLegacyCodexMcpServer(projectDir) - } - - const content = readFileSync(configPath, 'utf8') const serverName = codexMcpServerName(projectDir) - const managedBlock = readManagedCodexMcpBlock( - content, - scopedCodexMcpStartMarker(serverName), - scopedCodexMcpEndMarker(serverName), - ) - if (!managedBlock) { - return removeLegacyCodexMcpServer(projectDir) - } + return withCodexConfigLock(configPath, () => { + // Match installation's critical section so an uninstall cannot write a + // stale snapshot over another worktree's newly registered block. + if (!existsSync(configPath)) { + return removeLegacyCodexMcpServer(projectDir) + } - removeManagedCodexMcpBlock(configPath, content, managedBlock) - const legacyMessage = removeLegacyCodexMcpServer(projectDir) - const registrationMessage = `${configPath} -> MCP server ${serverName} removed` - return legacyMessage ? `${registrationMessage}\n${legacyMessage}` : registrationMessage + const content = readFileSync(configPath, 'utf8') + const managedBlock = readManagedCodexMcpBlock( + content, + scopedCodexMcpStartMarker(serverName), + scopedCodexMcpEndMarker(serverName), + ) + if (!managedBlock) { + return removeLegacyCodexMcpServer(projectDir) + } + + removeManagedCodexMcpBlock(configPath, content, managedBlock) + const legacyMessage = removeLegacyCodexMcpServer(projectDir) + const registrationMessage = `${configPath} -> MCP server ${serverName} removed` + return legacyMessage ? `${registrationMessage}\n${legacyMessage}` : registrationMessage + }) } function installCodexHook(projectDir: string): string { diff --git a/tests/unit/install.test.ts b/tests/unit/install.test.ts index fed57cdd..9b5ea5bf 100644 --- a/tests/unit/install.test.ts +++ b/tests/unit/install.test.ts @@ -1,7 +1,8 @@ import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, relative } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import * as ts from 'typescript' @@ -1731,6 +1732,42 @@ describe('install helpers', () => { }) }) + it('atomically updates Codex user config while preserving its permissions for install and uninstall', () => { + withTempDir((projectDir) => { + const configPath = resolveCodexMcpConfigPath() + const original = '# preserve this user config\n[features]\nparallel = true\n' + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync(configPath, original, 'utf8') + if (process.platform !== 'win32') { + chmodSync(configPath, 0o640) + } + const originalInode = process.platform === 'win32' ? null : statSync(configPath).ino + + agentsInstall(projectDir, 'codex') + + const managedContent = readFileSync(configPath, 'utf8') + expect(managedContent).toContain(original) + expect(managedContent).toContain('[mcp_servers.madar_') + expect(existsSync(`${configPath}.madar.lock`)).toBe(false) + expect(readdirSync(dirname(configPath)).filter((entry) => entry.startsWith('.config.toml.madar-'))).toEqual([]) + if (process.platform !== 'win32') { + expect(statSync(configPath).mode & 0o777).toBe(0o640) + expect(statSync(configPath).ino).not.toBe(originalInode) + } + const managedInode = process.platform === 'win32' ? null : statSync(configPath).ino + + agentsUninstall(projectDir, 'codex') + + expect(readFileSync(configPath, 'utf8')).toBe(original) + expect(existsSync(`${configPath}.madar.lock`)).toBe(false) + expect(readdirSync(dirname(configPath)).filter((entry) => entry.startsWith('.config.toml.madar-'))).toEqual([]) + if (process.platform !== 'win32') { + expect(statSync(configPath).mode & 0o777).toBe(0o640) + expect(statSync(configPath).ino).not.toBe(managedInode) + } + }) + }) + it('keeps Codex MCP registrations for separate workspaces independent', () => { withTempDir((root) => { const firstProject = join(root, 'first') @@ -1791,6 +1828,31 @@ describe('install helpers', () => { }) }) + it('releases the Codex user-config lock when a managed block is malformed', () => { + withTempDir((projectDir) => { + const configPath = resolveCodexMcpConfigPath() + const serverName = `madar_${createHash('sha256').update(resolve(projectDir)).digest('hex').slice(0, 12)}` + const malformed = `# preserve this user config\n# >>> madar managed mcp: ${serverName} >>>\n[mcp_servers.${serverName}]\n` + const priorContent = existsSync(configPath) ? readFileSync(configPath, 'utf8') : null + mkdirSync(dirname(configPath), { recursive: true }) + try { + writeFileSync(configPath, malformed, 'utf8') + + expect(() => agentsInstall(projectDir, 'codex')).toThrow(/marker block/i) + expect(readFileSync(configPath, 'utf8')).toBe(malformed) + expect(existsSync(`${configPath}.madar.lock`)).toBe(false) + expect(readdirSync(dirname(configPath)).filter((entry) => entry.startsWith('.config.toml.madar-'))).toEqual([]) + expect(existsSync(join(projectDir, 'AGENTS.md'))).toBe(false) + } finally { + if (priorContent === null) { + rmSync(configPath, { force: true }) + } else { + writeFileSync(configPath, priorContent, 'utf8') + } + } + }) + }) + it('preserves unrelated OpenCode config while updating madar MCP', () => { withTempDir((projectDir) => { writeFileSync( From b69f338a6f9c1d0e42ec2d1a14c66ba19174d422 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sat, 18 Jul 2026 03:42:41 +0400 Subject: [PATCH 11/11] test: require retained compact claim evidence --- tests/unit/retrieve.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/retrieve.test.ts b/tests/unit/retrieve.test.ts index 123d1022..cf26cd35 100644 --- a/tests/unit/retrieve.test.ts +++ b/tests/unit/retrieve.test.ts @@ -3179,6 +3179,11 @@ describe('retrieve', () => { label: `${source.label}-${index}`, } }), + claims: [{ + evidence_class: 'primary', + text: 'primary evidence: first compact node', + node_labels: [`${rawResult.matched_nodes[0]?.label}-0`], + }], relationships: Array.from({ length: 48 }, (_, index) => ({ from_id: `from-${index}`, from: `From${index}`, @@ -3245,6 +3250,10 @@ describe('retrieve', () => { expect(compactResult.relationships.length).toBeLessThan(oversizedResult.relationships.length) expect(compactResult.slice?.selected_paths.length).toBeLessThan(240) expect(compactResult.coverage).toEqual(rawResult.coverage) + expect(compactResult.claims).toHaveLength(1) + expect(compactResult.claims?.[0]).toEqual(expect.objectContaining({ + text: 'primary evidence: first compact node', + })) for (const claim of compactResult.claims ?? []) { expect(claim.node_labels.every((label) => compactResult.matched_nodes.some((node) => node.label === label))).toBe(true) }