diff --git a/RELEASE_EVIDENCE.md b/RELEASE_EVIDENCE.md index 52a5ed3..f98e5bd 100644 --- a/RELEASE_EVIDENCE.md +++ b/RELEASE_EVIDENCE.md @@ -4,6 +4,30 @@ Latest implementation and adoption results are in the [dogfood execution ledger](docs/DOGFOOD-EXECUTION.md). The dated snapshots below remain historical evidence; internal pilot acceptance is separate from G0–G4. +## 0.3.1 shipment, 22 September 2026 + +This patch adds an installed, read-only `doctor` command that verifies the exact +Git checkout, all four repository tools and one source line's generation/hash +through a fresh stdio connection. It reports client acceptance as `not-tested`; +an SDK probe does not establish Claude/Codex task acceptance. + +The raw-evidence benchmark now uses versioned v2 synthetic collections, retaining +every corpus chunk within the existing 128-record and 32-collection bounds. +Each question queries every collection and counts every response. Per-call +budgets and gate thresholds are unchanged; the total per-question budget scales +with the collection count. See [the methodology](benchmarks/README.md). + +The version-bumped 0.3.1 local checks passed 386 tests, independent installed-package smoke, +both benchmark gates, navigation stdio smoke, 22 scale tests and the 10k postings +probe. Raw v2 measured 32.53x and navigation 270.71x, both with required-source +recall 1.0. Commit-specific CI and artifact checksums accompany the GitHub release. No new client, +consumer or whole-task savings acceptance follows from these checks. + +npm authentication returned HTTP 401 on 22 September. Registry publication +remains blocked; matching release tarballs provide the independent installation +route. The next adoption gate is an accepted Claude task with complete usage, +repair and review accounting, alongside continuing Codex use. + ## Current internal adoption review, 21 September 2026 The [ForgeSworn dogfooding plan](docs/FORGESWORN-DOGFOOD-GOALS.md) now tracks diff --git a/benchmarks/README.md b/benchmarks/README.md index cdddff3..0967233 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -16,6 +16,41 @@ record IDs, authors and event IDs. Signature and grant verification are tested elsewhere; excluding fresh signatures prevents random signature bytes from changing tokenizer merges between benchmark runs. +## Raw-evidence benchmark v2: growing corpora + +The original v1 runner rejected a corpus above 128 chunks because it used one +synthetic signed-collection view. The repository grew to 136 chunks while adding +the installation doctor. V2 retains the complete original file selection, +3,600-character chunks, fixed questions and required sources. It partitions the +ordered records into consecutive collections of at most 128 records, within the +existing maximum of 32 collections. Nothing is dropped, and partitioning does +not depend on the query or expected answer. Larger corpora still fail explicitly. +Production signed-collection limits and authorisation are unchanged. + +For every question, v2 calls the existing `retrieveView` once for **every** +collection, each with the original 8,192-byte, four-record response limits and +related expansion disabled. Token and byte totals sum each complete response, +including empty responses, metadata and duplicate evidence. Required-source +recall uses the union of returned sources; accounting never deduplicates or +discards responses. The report exposes each collection's cost, call count, +collection sizes, indexed record count and a corpus digest. + +The per-call budgets are unchanged; the **total per-question budget scales with +the number of collections**. This is an explicit benchmark runner loop, not a +new production aggregate API, global ranking or cross-collection graph traversal. +It measures response payloads only: request tokens, MCP framing, model work, +indexing, signature verification and host/reviewer effort are outside this gate. +The existing 10x regression floor and full required-source recall still apply. +For a single collection the retrieval payload remains identical to v1. Multi- +collection results have a new `forgesworn-context-token-reduction-v2` label and +must not be presented as directly comparable to historical v1 results. The v1 +runner remains reproducible from Git history; locked D5 experiments are untouched. +This synthetic naive-baseline comparison does not establish subscription or cash +savings, or guarantee a sufficient answer merely because a source was returned. + +`npm run test:benchmark-corpus` checks partition boundaries, capacity rejection, +evidence beyond record 128, empty-response accounting and v1 single-view parity. + Run `npm run benchmark:tokens:parity` for the distinct source-navigation gate. It compares the full raw TypeScript corpus with exact compact JSON returned from the deterministic source graph, requiring every predeclared source to be found. diff --git a/benchmarks/collection-corpus.mjs b/benchmarks/collection-corpus.mjs new file mode 100644 index 0000000..f5679c2 --- /dev/null +++ b/benchmarks/collection-corpus.mjs @@ -0,0 +1,133 @@ +// Benchmarks/collection-corpus.mjs — synthetic retrieval fixture driver. + +import { createHash } from 'node:crypto'; +import { retrieveView } from '../packages/context/dist/retrieval.js'; + +export const BENCHMARK_LIMITS = Object.freeze({ + maxCollections: 32, + maxRecordsPerCollection: 128, + maxBytesPerCall: 8192, + maxRecordsPerCall: 4, +}); + +const HEX64 = /^[0-9a-f]{64}$/; +const OWNER = '1d'.repeat(32); +const V1_ID = '2e'.repeat(32); +const V1_HEAD = '3f'.repeat(32); + +function sha64(label) { + return createHash('sha256').update(label).digest('hex'); +} + +function fail(msg) { + throw new Error(`[collection-corpus] ${msg}`); +} + +export function partitionBenchmarkRecords(records, observedAt) { + if (!Array.isArray(records) || records.length === 0) fail('records must be non-empty array'); + if (records.length > 4096) fail('records exceeds 4096'); + if (!Number.isSafeInteger(observedAt) || observedAt < 0) fail('observedAt required'); + + const seen = new Set(); + for (const rec of records) { + if (!rec || typeof rec.id !== 'string' || !HEX64.test(rec.id)) fail('invalid record id'); + if (seen.has(rec.id)) fail(`duplicate record id ${rec.id}`); + seen.add(rec.id); + } + + const slices = []; + for (let i = 0; i < records.length; i += BENCHMARK_LIMITS.maxRecordsPerCollection) { + slices.push(records.slice(i, i + BENCHMARK_LIMITS.maxRecordsPerCollection)); + } + + return slices.map((slice, index) => { + const first = index === 0; + const id = first ? V1_ID : sha64(`context-repo/benchmark/collection/${index}`); + const head = first ? V1_HEAD : sha64(`context-repo/benchmark/head/${index}`); + return Object.freeze({ + id, + owner: OWNER, + title: 'Context repository benchmark', + scope: 'personal', + epoch: 1, + head, + revision: 1, + updatedAt: observedAt, + role: 'write', + uploaded: false, + records: slice, + }); + }); +} + +function validateViews(views) { + if (!Array.isArray(views)) fail('views must be array'); + if (views.length < 1 || views.length > BENCHMARK_LIMITS.maxCollections) { + fail(`views length ${views.length} not in 1..${BENCHMARK_LIMITS.maxCollections}`); + } + const ids = new Set(); + for (const v of views) { + if (!v || typeof v.id !== 'string' || !HEX64.test(v.id)) fail('invalid view.id'); + if (ids.has(v.id)) fail(`duplicate view.id ${v.id}`); + ids.add(v.id); + if (!Array.isArray(v.records) || v.records.length === 0) fail('view.records non-empty'); + if (v.records.length > BENCHMARK_LIMITS.maxRecordsPerCollection) { + fail(`view.records exceeds ${BENCHMARK_LIMITS.maxRecordsPerCollection}`); + } + } +} + +export function measureBenchmarkRetrieval(views, query, countTokens) { + validateViews(views); + if (typeof countTokens !== 'function') fail('countTokens must be function'); + if (typeof query !== 'string') fail('query must be string'); + + let retrievedTokens = 0; + let bytesUsed = 0; + const sources = new Set(); + const collections = []; + + for (const view of views) { + const payload = retrieveView(view, { + query, + maxBytes: BENCHMARK_LIMITS.maxBytesPerCall, + maxRecords: BENCHMARK_LIMITS.maxRecordsPerCall, + includeRelated: false, + }); + const serialized = JSON.stringify(payload); + const bytes = Buffer.byteLength(serialized, 'utf8'); + if (bytes > BENCHMARK_LIMITS.maxBytesPerCall) { + fail(`payload bytes ${bytes} exceeds ${BENCHMARK_LIMITS.maxBytesPerCall}`); + } + if (!Array.isArray(payload.records) || payload.availableRecords !== view.records.length || payload.bytesUsed !== bytes) { + fail('retrieval returned inconsistent coverage or byte accounting'); + } + const recs = payload.records; + if (recs.length > BENCHMARK_LIMITS.maxRecordsPerCall) { + fail(`payload.records ${recs.length} exceeds ${BENCHMARK_LIMITS.maxRecordsPerCall}`); + } + const tokens = countTokens(serialized); + if (!Number.isSafeInteger(tokens) || tokens < 0) fail('countTokens returned invalid'); + + retrievedTokens += tokens; + bytesUsed += bytes; + for (const rec of recs) { + if (rec && typeof rec.source === 'string') sources.add(rec.source); + } + collections.push(Object.freeze({ + collection: view.id, + availableRecords: payload.availableRecords, + returnedRecords: recs.length, + retrievedTokens: tokens, + bytesUsed: bytes, + })); + } + + return Object.freeze({ + retrievalCalls: views.length, + retrievedTokens, + bytesUsed, + returnedSources: Object.freeze([...sources].sort()), + collections: Object.freeze(collections), + }); +} diff --git a/benchmarks/collection-corpus.test.mjs b/benchmarks/collection-corpus.test.mjs new file mode 100644 index 0000000..74919e3 --- /dev/null +++ b/benchmarks/collection-corpus.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createHash } from 'node:crypto' +import { encode } from 'gpt-tokenizer/encoding/o200k_base' +import { retrieveView } from '../packages/context/dist/retrieval.js' +import { partitionBenchmarkRecords, measureBenchmarkRetrieval } from './collection-corpus.mjs' + +const now = 1_800_000_000 +const tokens = text => encode(text).length +const records = count => Array.from({ length: count }, (_, i) => ({ + id: createHash('sha256').update(`record-${i}`).digest('hex'), + kind: 'evidence', text: i === count - 1 ? 'needle evidence 🌳' : 'unrelated background', + source: `fixture-${i}.ts`, observedAt: now, author: '1d'.repeat(32), event: '4e'.repeat(32), +})) + +for (const count of [128, 129, 256, 4096]) { + test(`retains all ${count} records exactly once in deterministic bounded collections`, () => { + const input = records(count) + const before = structuredClone(input) + const views = partitionBenchmarkRecords(input, now) + assert.equal(views.length, Math.ceil(count / 128)) + assert.ok(views.every(view => view.records.length > 0 && view.records.length <= 128)) + assert.equal(new Set(views.map(view => view.id)).size, views.length) + assert.deepEqual(views.flatMap(view => view.records), before) + assert.deepEqual(input, before) + assert.deepEqual(partitionBenchmarkRecords(input, now), views) + }) +} + +test('rejects unsupported capacity and invalid identity without silently dropping records', () => { + assert.throws(() => partitionBenchmarkRecords([], now)) + assert.throws(() => partitionBenchmarkRecords(records(4097), now)) + const [record] = records(1) + assert.throws(() => partitionBenchmarkRecords([record, record], now)) + assert.throws(() => partitionBenchmarkRecords([{ ...record, id: 'invalid' }], now)) +}) + +test('charges empty responses and finds evidence beyond the first 128 records', () => { + const views = partitionBenchmarkRecords(records(129), now) + const seen = [] + const result = measureBenchmarkRetrieval(views, 'needle', text => { + seen.push(text) + return tokens(text) + }) + const expected = views.map(view => JSON.stringify(retrieveView(view, { + query: 'needle', maxBytes: 8192, maxRecords: 4, includeRelated: false, + }))) + assert.deepEqual(seen, expected) + assert.equal(result.retrievalCalls, 2) + assert.deepEqual(result.returnedSources, ['fixture-128.ts']) + assert.equal(result.retrievedTokens, expected.reduce((sum, text) => sum + tokens(text), 0)) + assert.equal(result.bytesUsed, expected.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)) + assert.equal(result.collections[0].returnedRecords, 0) + assert.ok(result.collections[0].retrievedTokens > 0) + assert.equal(result.collections[1].returnedRecords, 1) + assert.ok(result.collections.every(row => row.bytesUsed <= 8192 && row.returnedRecords <= 4)) +}) + +test('one collection retains the original v1 retrieval payload and budget', () => { + const input = records(10) + const legacyView = { id: '2e'.repeat(32), owner: '1d'.repeat(32), title: 'Context repository benchmark', + scope: 'personal', epoch: 1, head: '3f'.repeat(32), revision: 1, updatedAt: now, + role: 'write', uploaded: false, records: input } + const payload = retrieveView(legacyView, { query: 'needle', maxBytes: 8192, maxRecords: 4, includeRelated: false }) + const result = measureBenchmarkRetrieval(partitionBenchmarkRecords(input, now), 'needle', tokens) + assert.equal(result.retrievalCalls, 1) + assert.equal(result.retrievedTokens, tokens(JSON.stringify(payload))) + assert.equal(result.bytesUsed, payload.bytesUsed) +}) + +test('refuses invalid view sets before counting any response', () => { + const [view] = partitionBenchmarkRecords(records(1), now) + const counter = () => { assert.fail('invalid view sets must fail before retrieval') } + for (const views of [[], [view, view], Array.from({ length: 33 }, () => view), + [{ ...view, records: [] }], [{ ...view, records: records(129) }]]) { + assert.throws(() => measureBenchmarkRetrieval(views, 'needle', counter)) + } +}) diff --git a/benchmarks/token-reduction.mjs b/benchmarks/token-reduction.mjs index 9c17352..11f6d5d 100644 --- a/benchmarks/token-reduction.mjs +++ b/benchmarks/token-reduction.mjs @@ -3,7 +3,7 @@ import { readFile, readdir } from 'node:fs/promises' import { relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { encode } from 'gpt-tokenizer/encoding/o200k_base' -import { retrieveView } from '../packages/context/dist/retrieval.js' +import { BENCHMARK_LIMITS, partitionBenchmarkRecords, measureBenchmarkRetrieval } from './collection-corpus.mjs' const root = resolve(fileURLToPath(new URL('..', import.meta.url))) const parityTarget = 71.5 @@ -51,7 +51,6 @@ for (const path of [...sourceFiles, ...documentation].sort()) { const content = await readFile(path, 'utf8') for (const [part, text] of chunks(content).entries()) corpus.push({ source, part, text }) } -if (corpus.length > 128) throw new Error(`Corpus has ${corpus.length} chunks; ContextVault supports at most 128 records.`) const records = corpus.map(item => ({ id: createHash('sha256').update(`${item.source}\0${item.part}\0${item.text}`).digest('hex'), @@ -59,36 +58,37 @@ const records = corpus.map(item => ({ author: '1d'.repeat(32), event: createHash('sha256').update(`event\0${item.source}\0${item.part}\0${item.text}`).digest('hex'), })) -const view = { - id: '2e'.repeat(32), owner: '1d'.repeat(32), title: 'Context repository benchmark', - scope: 'personal', epoch: 1, head: '3f'.repeat(32), revision: 1, - updatedAt: now, role: 'write', uploaded: false, records, -} +const views = partitionBenchmarkRecords(records, now) // The naïve comparator is exactly what an agent would receive if every source // were inserted verbatim, including filenames, without signatures or graph data. const baselinePayload = corpus.map(({ source, part, text }) => ({ source, part, text })) const baselineTokens = tokens(baselinePayload) const results = questions.map(question => { - const payload = retrieveView(view, { query: question.query, maxBytes: 8192, maxRecords: 4, includeRelated: false }) - const retrievedTokens = tokens(payload) - const returned = new Set(payload.records.map(record => record.source)) + const measurement = measureBenchmarkRetrieval(views, question.query, tokens) + const { retrievedTokens } = measurement + const returned = new Set(measurement.returnedSources) const found = question.required.filter(source => returned.has(source)) const recall = found.length / question.required.length return { id: question.id, query: question.query, requiredSources: question.required, - returnedSources: [...returned], baselineTokens, retrievedTokens, + ...measurement, baselineTokens, multiplier: baselineTokens / retrievedTokens, reductionPercent: (1 - retrievedTokens / baselineTokens) * 100, - evidenceRecall: recall, bytesUsed: payload.bytesUsed, + evidenceRecall: recall, } }) const totalBaselineTokens = baselineTokens * results.length const totalRetrievedTokens = results.reduce((sum, result) => sum + result.retrievedTokens, 0) const aggregateMultiplier = totalBaselineTokens / totalRetrievedTokens const report = { - benchmark: 'forgesworn-context-token-reduction-v1', tokenizer: 'o200k_base', - corpus: { files: sourceFiles.length + documentation.length, chunks: corpus.length, baselineTokens }, + benchmark: 'forgesworn-context-token-reduction-v2', tokenizer: 'o200k_base', + contract: 'synthetic-authorised-collections-payload-only', + limits: BENCHMARK_LIMITS, + corpus: { files: sourceFiles.length + documentation.length, chunks: corpus.length, baselineTokens, + sha256: createHash('sha256').update(JSON.stringify(corpus)).digest('hex'), + collections: views.length, collectionSizes: views.map(view => view.records.length), + indexedRecords: views.reduce((sum, view) => sum + view.records.length, 0) }, queries: results.map(result => ({ ...result, multiplier: Number(result.multiplier.toFixed(2)), reductionPercent: Number(result.reductionPercent.toFixed(2)), })), diff --git a/docs/DOGFOOD-EXECUTION.md b/docs/DOGFOOD-EXECUTION.md index 27c5982..a613261 100644 --- a/docs/DOGFOOD-EXECUTION.md +++ b/docs/DOGFOOD-EXECUTION.md @@ -856,3 +856,73 @@ assets at version 0.3.0; the setup guide explains installing both tarballs. CI, merge and release results are recorded by the corresponding GitHub PR/run and release rather than inferred from these local checks. No active Heartwood client binding or personal client configuration was changed. + +## 22 September 2026 — Read-only installation doctor (local, unreleased) + +Added `encrypted-context doctor --term ` +to OS1's installation workflow. It launches the installed CLI with the current +Node executable and verifies all four repository tools over stdio. It checks +canonical root/HEAD, generation, search and exact source-packet agreement; +reports binding details, exclusions and evidence hashes without source text; +and closes its owned process on success or failure. It changes neither client +configuration nor repository files. Actual Claude/Codex acceptance remains +separate and is explicitly `not-tested` in the report. + +One DeepSeek Flash draft (`deepseek-v4.1-flash:cloud`, thinking off) reported +1,082 prompt and 5,209 completion tokens. The draft compiled, but a focused test +caught lost exclusion metadata and host review found executable/environment +and validation defects. A repair dispatch was blocked by the shared endpoint's +busy guard before inference. The host retained and corrected the draft without +interrupting the other request. Private prompt, answer, attempt receipts and +live doctor output remain outside Git. This is partial worker acceptance with +host repairs, not evidence of savings; complete host cost and billing are unknown. + +Validation: build and all 378 tests passed, including 11 new doctor checks. +The independent installed-tarball smoke passed after correcting its macOS +canonical-path assertion. A live doctor probe passed on the Context checkout. +The unchanged navigation benchmark passed with declared-source recall 1.0. + +**Release blocker:** the unchanged raw-evidence benchmark failed because the +expanded source/test corpus has 136 chunks, above signed v1's 128-record limit. +No threshold, corpus selection or collection limit was changed. This needs a +separately reviewed scale/benchmark solution before shipping these changes; +navigation passing does not replace that gate. These local changes are not in +the previously published v0.3.0 assets or npm. Heartwood was not modified, and +no new Claude qualification or application-level consumer acceptance occurred. + +## 22 September 2026 — Doctor benchmark blocker resolved locally + +The preceding raw-evidence benchmark failure is retained as historical evidence. +The v2 runner now partitions the complete deterministic corpus into valid +synthetic collection views instead of requiring the entire repository to fit in +one collection. All 136 chunks from 38 selected files are retained, in collections +of 128 and 8 records. Signed v1's 128-record and 32-collection limits are unchanged. + +Every question queries both collections. All response payloads, including empty +ones, contribute to the token and byte totals. The original questions, required +sources, 8,192-byte/four-record per-call budgets, 10x raw regression floor and +71.5x navigation threshold are unchanged. Aggregate per-question budgets grow +with collection count, so this is explicitly versioned and documented as a new +measurement method, not a reproduction of v1 on the same budget. There is no new +production aggregate retrieval API. Locked D5 experiments remain untouched. + +On this working tree, `npm run check` passed 386 tests and independent packed +package consumers. The raw v2 gate passed at 32.50x against its naive full-corpus +baseline with required-source recall 1.0; the unchanged navigation gate passed +at 270.71x with recall 1.0. Stdio smoke, 22 scale tests and the 10,000-record +posting-index check passed. These synthetic payload comparisons do not establish +whole-task, subscription or monetary savings. Eight new tests cover partition +boundaries through 4,096 records, explicit over-capacity rejection, evidence +beyond record 128, empty-response costs and single-collection v1 payload parity. + +Implementation used one DeepSeek Flash/off draft (551 reported prompt tokens, +1,207 completion tokens). Host review corrected its timestamp type and unintended +async interface and strengthened response-accounting checks before acceptance. +The worker draft is recorded as partial with host integration; no retry or paid +comparison arm ran. Private evidence is outside Git; complete host usage and +billing remain unknown. + +The local release-check blocker is resolved. Doctor and benchmark changes remain +uncommitted and unpublished; this is not new CI, registry or client acceptance. +Next adoption gate: a real Claude source-packet task and usage import. The existing +Heartwood owner retains its application task; no consumer checkout was changed. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 30cd342..a53c83b 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -27,8 +27,8 @@ matching core version: mkdir context-install cd context-install npm init -y -npm install --ignore-scripts /absolute/path/to/forgesworn-context-0.3.0.tgz \ - /absolute/path/to/forgesworn-context-tools-0.3.0.tgz +npm install --ignore-scripts /absolute/path/to/forgesworn-context-0.3.1.tgz \ + /absolute/path/to/forgesworn-context-tools-0.3.1.tgz ``` For that installation, use @@ -87,6 +87,34 @@ so starting it by hand waits for a client rather than printing a scan report. ## 2. Connect your client +With a build that lists `doctor` in `--help`, first check the installed executable +against the selected checkout. Choose a known identifier from an indexed source +file; replace `yourKnownIdentifier` below: + +```sh +"$CONTEXT_NODE" "$CONTEXT_CLI" doctor "$CONTEXT_REPO" --term yourKnownIdentifier +``` + +The command launches this installation's repository server and exercises all four +tools over stdio: status, refresh, search and a one-line source packet. It checks +the canonical Git root, HEAD, generation and matching source hash. It prints JSON +with `ok: true`, the executable/arguments to configure, exclusion counts and the +evidence location/hash. It does not print source text, write client configuration, +change repository files or call a model. Paths and hashes in the report are local +diagnostics; review them before sharing. + +Pass the exact Git worktree root, with an existing commit. A subdirectory is +rejected instead of silently widening the selection. An excluded or absent term +fails the check; choose a known indexed identifier or inspect the selection policy. +Each MCP request has a 15-second timeout and the probe has a 90-second deadline; +a timeout is a diagnostic failure, not permission to remove selection bounds. + +`clientAcceptance: "not-tested"` is intentional: this checks a fresh server +process, not your saved configuration or an already-running Claude/Codex session. +Merge the reported binding using the client-specific instructions below, reconnect, +then complete step 4. Older releases without `doctor` can still follow these +manual setup and verification steps. + Use either or both clients. Each launches its own process and in-memory index. Merge settings with existing configuration; do not overwrite other servers, trust settings, tool approvals or project instructions. If the server name is @@ -246,8 +274,9 @@ stable checkout to bind. | Packet already in progress | Wait for the active request to finish, then issue the next request sequentially | | Git HEAD error | Select a Git checkout with a commit; packet provenance requires it | -There is no automatic watcher, worktree rebinding or setup-and-doctor command -yet. Git ignores and selection policy apply; hidden files, symlinks, generated +There is no automatic watcher, worktree rebinding or configuration-writing setup +command yet. `doctor` checks a fresh process; it cannot retarget a running client. +Git ignores and selection policy apply; hidden files, symlinks, generated directories and unsupported suffixes are excluded. `node_modules` is not indexed, and local navigation does not automatically resolve a published package back to the correct producer source revision. See [navigation policy](NAVIGATION-POLICY.md), diff --git a/package-lock.json b/package-lock.json index a8b8944..4a5ac68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3071,7 +3071,7 @@ }, "packages/context": { "name": "@forgesworn/context", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "@noble/ciphers": "2.1.1", @@ -3089,10 +3089,10 @@ }, "packages/context-tools": { "name": "@forgesworn/context-tools", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { - "@forgesworn/context": "0.3.0", + "@forgesworn/context": "0.3.1", "@modelcontextprotocol/sdk": "1.30.0", "@noble/hashes": "1.8.0", "ignore": "7.0.9", diff --git a/package.json b/package.json index 2bffdbb..1a1f7fc 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "benchmark:tokens:check": "npm run build && node benchmarks/token-reduction.mjs --check", "benchmark:tokens:parity": "npm run build && node benchmarks/source-navigation.mjs --check", "benchmark:navigation": "npm run build && node benchmarks/source-navigation.mjs", - "test": "npm run test --workspace @forgesworn/context && npm run test --workspace @forgesworn/context-tools && npm run test:worker-packets && npm run test:task-costs && npm run test:ecosystem && npm run test:daily-usage", + "test": "npm run test --workspace @forgesworn/context && npm run test --workspace @forgesworn/context-tools && npm run test:worker-packets && npm run test:task-costs && npm run test:ecosystem && npm run test:daily-usage && npm run test:benchmark-corpus", + "test:benchmark-corpus": "node --test benchmarks/collection-corpus.test.mjs", "test:worker-packets": "node --test test/worker-packet.test.mjs test/task-handover.test.mjs", "test:task-costs": "node --test test/task-cost-report.test.mjs", "test:packages": "node test/context-package-smoke.mjs", diff --git a/packages/context-tools/README.md b/packages/context-tools/README.md index 63a679d..a018ac2 100644 --- a/packages/context-tools/README.md +++ b/packages/context-tools/README.md @@ -6,10 +6,12 @@ ESM, Node 24+. Separate from the browser-safe core: filesystem and MCP dependencies never enter its root import. No KithMoot or NanoClaw runtime dependency. -Install the patch release, which depends on the matching core package: +The 0.3.1 release tarballs include the setup doctor and require the matching core +package. Follow the [installation guide](../../docs/GETTING-STARTED.md) to install +both archives together. npm publication is pending; once published, use: ```sh -npm install @forgesworn/context-tools@0.3.0 +npm install @forgesworn/context-tools@0.3.1 ``` The source is maintained in the public Z1P Core workspace. Build with @@ -55,6 +57,24 @@ records. These read-only tools preserve provenance and edge direction, never cross collection boundaries and do not claim that a signed relationship is true. +## Check a repository installation + +```sh +encrypted-context doctor /absolute/path/to/repository --term knownIdentifier +``` + +Use the exact Git checkout/worktree root with an existing commit and an identifier +in an indexed source file. This read-only command starts the installed stdio +server, verifies all four repository tools and checks a source packet against the +search result and Git provenance. The JSON report contains binding details, +exclusions and a source location/hash, without source text. It makes no model +calls and writes no repository files or client settings. + +A passing probe establishes installation health. It does not check saved client +configuration or prove Claude/Codex acceptance: merge the binding for that project, +reconnect and verify the tools in the actual client. See the +[portable setup guide](https://github.com/forgesworn/context/blob/main/docs/GETTING-STARTED.md). + ## Local ecosystem and source scanning For a multi-repository project, create a manifest beneath the common ecosystem diff --git a/packages/context-tools/THIRD_PARTY_NOTICES.md b/packages/context-tools/THIRD_PARTY_NOTICES.md index dadca77..5578d31 100644 --- a/packages/context-tools/THIRD_PARTY_NOTICES.md +++ b/packages/context-tools/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -These notices accompany `@forgesworn/context-tools@0.3.0`. The package itself is +These notices accompany `@forgesworn/context-tools@0.3.1`. The package itself is MIT licensed, Copyright (c) 2026 TheCryptoDonkey; see `LICENSE`. Runtime dependencies are installed separately by the package manager, not @@ -9,9 +9,9 @@ notices below cover direct runtime dependencies; retain the licence files from transitive dependencies as well when redistributing an installation or bundle. Dependency versions and source links refer to this release. -## @forgesworn/context@0.3.0 +## @forgesworn/context@0.3.1 -Declared licence: MIT. Source package: https://www.npmjs.com/package/@forgesworn/context/v/0.3.0 +Declared licence: MIT. Source package: https://www.npmjs.com/package/@forgesworn/context/v/0.3.1 ```text MIT License diff --git a/packages/context-tools/package.json b/packages/context-tools/package.json index b054b26..6e3582b 100644 --- a/packages/context-tools/package.json +++ b/packages/context-tools/package.json @@ -1,6 +1,6 @@ { "name": "@forgesworn/context-tools", - "version": "0.3.0", + "version": "0.3.1", "description": "Local CLI and MCP tools for Z1P Core encrypted context", "license": "MIT", "type": "module", @@ -36,7 +36,7 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "@forgesworn/context": "0.3.0", + "@forgesworn/context": "0.3.1", "@modelcontextprotocol/sdk": "1.30.0", "@noble/hashes": "1.8.0", "ignore": "7.0.9", diff --git a/packages/context-tools/src/context-cli.ts b/packages/context-tools/src/context-cli.ts index 8084138..0fc27d0 100644 --- a/packages/context-tools/src/context-cli.ts +++ b/packages/context-tools/src/context-cli.ts @@ -24,10 +24,12 @@ export async function main(options: ContextCliOptions = {}): Promise { 'max-files': { type: 'string' }, 'max-bytes': { type: 'string' }, 'max-file-bytes': { type: 'string' }, 'max-records': { type: 'string' }, 'max-repositories': { type: 'string' }, + term: { type: 'string' }, } }) if (values.help) { process.stdout.write((options.name ?? 'encrypted-context') + ' mcp|call --identity --expect-pubkey --state --room [--server ...]\n' + (options.name ?? 'encrypted-context') + ' navigate \n' + + (options.name ?? 'encrypted-context') + ' doctor --term \n' + (options.name ?? 'encrypted-context') + ' scan [--max-packages 64] [--max-depth 4] [--observed-at ]\n' + (options.name ?? 'encrypted-context') + ' scan-source [--max-files 64] [--max-depth 8] [--max-bytes 1048576] [--max-file-bytes 262144] [--max-records 128] [--observed-at ]\n' + (options.name ?? 'encrypted-context') + ' scan-broad-source [--max-files 64] [--max-depth 8] [--max-bytes 1048576] [--max-file-bytes 262144] [--max-records 128] [--observed-at ]\n' + @@ -35,6 +37,15 @@ export async function main(options: ContextCliOptions = {}): Promise { return } const integer = (value: string | undefined): number | undefined => value === undefined ? undefined : Number(value) + if (positionals[0] === 'doctor') { + if (positionals.length !== 2 || !values.term || Object.keys(values).some(key => key !== 'term')) { + throw new Error('Choose one repository root and --term . doctor takes no other flags. See --help.') + } + const { diagnoseRepository } = await import('./repository-doctor.js') + process.stdout.write(JSON.stringify(await diagnoseRepository(positionals[1], values.term), null, 2) + '\n') + return + } + if (values.term !== undefined) throw new Error('--term is only supported by doctor. See --help.') if (positionals[0] === 'navigate') { if (positionals.length !== 2) throw new Error('Choose one directory to navigate.') if (Object.keys(values).length > 0) throw new Error('navigate takes no flags. See --help.') diff --git a/packages/context-tools/src/repository-doctor-contract.test.ts b/packages/context-tools/src/repository-doctor-contract.test.ts new file mode 100644 index 0000000..f1e2e27 --- /dev/null +++ b/packages/context-tools/src/repository-doctor-contract.test.ts @@ -0,0 +1,86 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { execFile } from 'node:child_process' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { diagnoseRepository } from './repository-doctor.js' + +const fake = vi.hoisted(() => ({ + root: '', head: '', failure: '', calls: [] as string[], + transportOptions: {} as Record, clientClosed: 0, transportClosed: 0, +})) +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: class { + stderr = null + constructor(options: Record) { fake.transportOptions = options } + async close() { fake.transportClosed++ } + }, +})) +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: class { + async connect() { if (fake.failure === 'connect') throw new Error('fixture handshake failure') } + async close() { fake.clientClosed++ } + async listTools() { + return { tools: ['repository_status', 'repository_refresh', 'repository_search', 'repository_packet'] + .filter(name => !(fake.failure === 'missing' && name === 'repository_packet')) + .map(name => ({ name, inputSchema: { type: 'object', properties: { mode: {}, spec: {}, expectedGeneration: {} } } })) } + } + async callTool({ name }: { name: string }) { + fake.calls.push(name) + const policy = { freshness: 'current' } + const hit = { path: 'example.ts', line: 1, text: 'fixtureToken', sha256: 'a'.repeat(64) } + const value = name === 'repository_packet' + ? { + packet: { canonicalRoot: fake.root, gitHEAD: fake.head, sources: [{ + path: hit.path, sha256: fake.failure === 'hash' ? 'b'.repeat(64) : hit.sha256, + startLine: 1, endLine: 1, lines: [{ line: 1, content: hit.text }], + }] }, navigation: { generation: 'generation', revision: 'revision', policy }, + } + : name === 'repository_search' + ? { generation: fake.failure === 'generation' ? 'other' : 'generation', freshness: 'current', policy, results: [hit] } + : { root: fake.failure === 'root' ? '/wrong/root' : fake.root, generation: 'generation', + revision: 'revision', freshness: 'current', policy, counts: { files: 1 }, exclusions: { symlinks: 0 } } + return { content: [{ type: 'text', text: JSON.stringify(value) }] } + } + }, +})) + +beforeAll(async () => { + fake.root = await realpath(await mkdtemp(join(tmpdir(), 'context-doctor-contract-'))) + const exec = promisify(execFile) + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))) + const git = (...args: string[]) => exec('git', ['-C', fake.root, ...args], { env }) + await git('init', '-q') + await git('-c', 'user.name=Doctor fixture', '-c', 'user.email=doctor@example.invalid', + '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '--allow-empty', '-qm', 'Fixture') + fake.head = (await git('rev-parse', 'HEAD')).stdout.trim() +}) +afterAll(async () => { await rm(fake.root, { recursive: true, force: true }) }) +beforeEach(() => { + fake.failure = ''; fake.calls = []; fake.clientClosed = 0; fake.transportClosed = 0 +}) + +describe('doctor rejects incomplete or inconsistent server evidence', () => { + it('runs the exact reported Node executable without inheriting the full environment', async () => { + const result = await diagnoseRepository(fake.root, 'fixtureToken') + expect(fake.transportOptions.command).toBe(result.binding.command) + expect(fake.transportOptions.args).toEqual(result.binding.args) + expect(fake.transportOptions.env).toBeUndefined() + expect(fake.clientClosed).toBe(1) + expect(fake.transportClosed).toBe(1) + }) + it.each([ + ['missing', /required tool/, []], + ['root', /root/, ['repository_status']], + ['generation', /generation/, ['repository_status', 'repository_refresh', 'repository_search']], + ['hash', /sha256/, ['repository_status', 'repository_refresh', 'repository_search', 'repository_packet']], + ['connect', /handshake/, []], + ] as const)('fails closed on %s and closes its owned transport', async (failure, error, calls) => { + fake.failure = failure + await expect(diagnoseRepository(fake.root, 'fixtureToken')).rejects.toThrow(error) + expect(fake.calls).toEqual(calls) + expect(fake.clientClosed).toBe(1) + expect(fake.transportClosed).toBe(1) + }) +}) diff --git a/packages/context-tools/src/repository-doctor.test.ts b/packages/context-tools/src/repository-doctor.test.ts new file mode 100644 index 0000000..d7bf3ff --- /dev/null +++ b/packages/context-tools/src/repository-doctor.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { execFile } from 'node:child_process' +import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { diagnoseRepository } from './repository-doctor.js' + +const exec = promisify(execFile) +const roots: string[] = [] +const gitEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))) +async function git(root: string, ...args: string[]) { + return exec('git', ['-C', root, ...args], { env: gitEnv }) +} +async function fixture(commit = true) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'context doctor space-'))) + roots.push(root) + await git(root, 'init', '-q') + await writeFile(join(root, 'example.ts'), 'export const doctorToken = "PRIVATE_SOURCE_SENTINEL"\n') + if (commit) { + await git(root, 'add', 'example.ts') + await git(root, '-c', 'user.name=Doctor fixture', '-c', 'user.email=doctor@example.invalid', + '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Fixture') + } + return root +} +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('repository doctor using installed stdio entrypoint', () => { + it('verifies all four tools and source provenance without writes or source text in its report', async () => { + const root = await fixture() + const before = await git(root, 'status', '--porcelain', '--untracked-files=all') + const source = await readFile(join(root, 'example.ts'), 'utf8') + const result = await diagnoseRepository(root, 'doctorToken') + expect(result).toMatchObject({ + version: 1, ok: true, root, clientAcceptance: 'not-tested', + gitHEAD: (await git(root, 'rev-parse', 'HEAD')).stdout.trim(), + binding: { command: process.execPath, args: [expect.stringContaining('encrypted-context.mjs'), 'navigate', root] }, + evidence: { path: 'example.ts', line: 1, sha256: expect.stringMatching(/^[a-f0-9]{64}$/) }, + }) + expect([...result.tools].sort()).toEqual(['repository_packet', 'repository_refresh', 'repository_search', 'repository_status']) + expect(result.exclusions).toMatchObject({ symlinks: expect.any(Number), unsupported: expect.any(Number) }) + expect(JSON.stringify(result)).not.toContain('PRIVATE_SOURCE_SENTINEL') + expect((await git(root, 'status', '--porcelain', '--untracked-files=all')).stdout).toBe(before.stdout) + expect(await readFile(join(root, 'example.ts'), 'utf8')).toBe(source) + }, 20000) + + it('keeps linked worktrees separate even when ambient Git variables point elsewhere', async () => { + const root = await fixture() + const linked = join(root, 'linked worktree') + await git(root, 'worktree', 'add', '--detach', linked, 'HEAD') + await writeFile(join(linked, 'example.ts'), 'export const worktreeToken = 2\n') + const previous = process.env.GIT_WORK_TREE + process.env.GIT_WORK_TREE = root + try { + const result = await diagnoseRepository(linked, 'worktreeToken') + expect(result.root).toBe(await realpath(linked)) + expect(result.binding.args.at(-1)).toBe(await realpath(linked)) + expect(result.evidence.path).toBe('example.ts') + } finally { + if (previous === undefined) delete process.env.GIT_WORK_TREE + else process.env.GIT_WORK_TREE = previous + } + }, 20000) + + it('rejects subdirectories without silently broadening the selected root', async () => { + const root = await fixture() + const child = join(root, 'child') + await mkdir(child) + await expect(diagnoseRepository(child, 'doctorToken')).rejects.toThrow(/root|top.level/i) + }) + + it('requires a committed repository and a valid explicit identifier', async () => { + const root = await fixture(false) + await expect(diagnoseRepository(root, 'doctorToken')).rejects.toThrow(/HEAD|commit/i) + await expect(diagnoseRepository('/does/not/exist', 'two words')).rejects.toThrow(/identifier|term/i) + }) + + it('does not bypass selection exclusions to manufacture a passing search', async () => { + const root = await fixture() + await writeFile(join(root, '.gitignore'), 'example.ts\n') + await expect(diagnoseRepository(root, 'doctorToken')).rejects.toThrow(/identifier|exclu|match/i) + }, 20000) +}) diff --git a/packages/context-tools/src/repository-doctor.ts b/packages/context-tools/src/repository-doctor.ts new file mode 100644 index 0000000..d0570cf --- /dev/null +++ b/packages/context-tools/src/repository-doctor.ts @@ -0,0 +1,592 @@ +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; +import { realpath } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const execFile = promisify(execFileCb); + +const TERM_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; +const REQUIRED_TOOLS = [ + 'repository_status', + 'repository_refresh', + 'repository_search', + 'repository_packet', +] as const; + +const CLI = fileURLToPath(new URL('../bin/encrypted-context.mjs', import.meta.url)); +const CLIENT_NAME = 'context-repository-doctor'; +const CLIENT_VERSION = '1'; +const PER_REQUEST_TIMEOUT_MS = 15_000; +const OVERALL_TIMEOUT_MS = 90_000; +const STDERR_LIMIT = 2048; + +type ToolName = (typeof REQUIRED_TOOLS)[number]; + +interface McpTextContent { + type: 'text'; + text: string; +} + +interface McpCallResult { + isError?: boolean; + content?: unknown; +} + +interface ListToolsResult { + tools?: Array<{ name?: unknown; inputSchema?: unknown }>; +} + +interface StatusPayload { + root: string; + generation: unknown; + freshness: string; + revision: unknown; + policy?: { freshness?: string } | undefined; + counts: unknown; + exclusions: unknown; +} + +interface SearchHit { + path: string; + line: number; + text: string; + sha256: string; +} + +interface SearchPayload { + generation: unknown; + freshness: string; + policy?: { freshness?: string } | undefined; + results: SearchHit[]; +} + +interface DoctorReport { + version: 1; + ok: true; + root: string; + gitHEAD: string; + node: string; + binding: { + command: string; + args: string[]; + enabled_tools: ToolName[]; + }; + tools: ToolName[]; + generation: string; + revision: string; + counts: Record; + exclusions: Record; + evidence: { + path: string; + line: number; + sha256: string; + }; + clientAcceptance: 'not-tested'; + nextStep: string; +} + +class DoctorError extends Error { + constructor(message: string) { + super(message); + this.name = 'DoctorError'; + } +} + +function boundDetail(value: unknown, limit = 400): string { + let s: string; + if (typeof value === 'string') { + s = value; + } else { + try { + s = JSON.stringify(value); + } catch { + s = String(value); + } + } + s = (s ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim(); + return s.length > limit ? `${s.slice(0, limit)}…` : s; +} + +function gitEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) { + if (k.startsWith('GIT_')) continue; + env[k] = v; + } + env.GIT_OPTIONAL_LOCKS = '0'; + env.GIT_NO_LAZY_FETCH = '1'; + env.GIT_TERMINAL_PROMPT = '0'; + return env; +} + +async function runGit(root: string, args: string[]): Promise { + try { + const { stdout } = await execFile('git', ['-C', root, ...args], { + env: gitEnv(), + shell: false, + timeout: 5_000, + maxBuffer: 8_192, + encoding: 'utf8', + }); + return (stdout ?? '').trim(); + } catch (err) { + const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }; + const detail = boundDetail(e.stderr ?? e.stdout ?? e.message ?? 'git failed'); + throw new DoctorError(`git ${args.join(' ')} failed: ${detail}`); + } +} + +function parseTextPayload(result: McpCallResult, toolName: string): unknown { + if (result && result.isError === true) { + const text = Array.isArray(result.content) + ? result.content + .map((c) => + c && typeof c === 'object' && (c as McpTextContent).type === 'text' + ? (c as McpTextContent).text + : '', + ) + .join(' ') + : ''; + throw new DoctorError( + `${toolName} reported error: ${boundDetail(text || 'unknown error')}`, + ); + } + if (!result || !Array.isArray(result.content) || result.content.length !== 1) { + throw new DoctorError(`${toolName} returned unexpected content shape`); + } + const item = result.content[0] as McpTextContent | undefined; + if (!item || item.type !== 'text' || typeof item.text !== 'string') { + throw new DoctorError(`${toolName} returned non-text content`); + } + let parsed: unknown; + try { + parsed = JSON.parse(item.text); + } catch { + throw new DoctorError(`${toolName} returned invalid JSON`); + } + if (!parsed || typeof parsed !== 'object') { + throw new DoctorError(`${toolName} returned non-object payload`); + } + return parsed; +} + +function requireObject(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new DoctorError(`${label} is not an object`); + } + return value as Record; +} + +function requireString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new DoctorError(`${label} missing or not a string`); + } + return value; +} + +function requireNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new DoctorError(`${label} missing or not a number`); + } + return value; +} + +function requireHex(value: unknown, label: string, lens: number[]): string { + const s = requireString(value, label); + if (!lens.includes(s.length) || !/^[0-9a-f]+$/i.test(s)) { + throw new DoctorError(`${label} is not a valid hex hash`); + } + return s; +} + +function packetSchemaOk(schema: unknown): boolean { + const s = schema as { type?: unknown; properties?: unknown } | undefined; + if (!s || s.type !== 'object') return false; + const props = s.properties as Record | undefined; + if (!props || typeof props !== 'object' || Array.isArray(props)) return false; + return 'mode' in props && 'spec' in props && 'expectedGeneration' in props; +} + +export async function diagnoseRepository( + directory: string, + term: string, +): Promise { + if (typeof term !== 'string' || !TERM_RE.test(term)) { + throw new DoctorError( + 'term must match /^[A-Za-z_][A-Za-z0-9_]{0,127}$/', + ); + } + if (typeof directory !== 'string' || directory.length === 0) { + throw new DoctorError('directory must be a non-empty string'); + } + + let root: string; + try { + root = await realpath(directory); + } catch (err) { + throw new DoctorError( + `cannot resolve directory: ${boundDetail((err as Error).message)}`, + ); + } + + const toplevel = await runGit(root, ['rev-parse', '--show-toplevel']); + let canonicalTop: string; + try { + canonicalTop = await realpath(toplevel); + } catch { + canonicalTop = toplevel; + } + if (canonicalTop !== root) { + throw new DoctorError( + `Directory ${root} is not the repository root. Required root is ${canonicalTop}. ` + + `Re-run the command pointing at that root; subdirectories are not auto-retargeted.`, + ); + } + + let gitHEAD: string; + try { + const head = await runGit(root, ['rev-parse', '--verify', 'HEAD']); + gitHEAD = requireHex(head, 'git HEAD (packets currently require SHA-1 repositories)', [40]).toLowerCase(); + } catch (err) { + throw new DoctorError( + `Repository HEAD cannot be verified. Select a repository with an existing commit and supported object format: ` + + `${boundDetail((err as Error).message)}`, + ); + } + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [CLI, 'navigate', root], + stderr: 'pipe', + maxBufferSize: 262144, + }); + + let stderrBuf = ''; + const stderrStream = transport.stderr; + if (stderrStream && typeof stderrStream.on === 'function') { + stderrStream.on('data', (chunk: Buffer | string) => { + if (stderrBuf.length >= STDERR_LIMIT) return; + const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + stderrBuf = (stderrBuf + s).slice(0, STDERR_LIMIT); + }); + } + + const client = new Client({ name: CLIENT_NAME, version: CLIENT_VERSION }); + + const abort = new AbortController(); + const overallTimer = setTimeout(() => abort.abort(), OVERALL_TIMEOUT_MS); + + const requestOpts = () => ({ + timeout: PER_REQUEST_TIMEOUT_MS, + signal: abort.signal, + }); + + + try { + try { + await client.connect(transport, { + timeout: PER_REQUEST_TIMEOUT_MS, + signal: abort.signal, + }); + } catch (err) { + const base = `MCP handshake failed: ${boundDetail((err as Error).message)}`; + throw new DoctorError( + stderrBuf ? `${base} (stderr: ${boundDetail(stderrBuf)})` : base, + ); + } + + let listResult: ListToolsResult; + try { + listResult = (await client.listTools(undefined, requestOpts())) as ListToolsResult; + } catch (err) { + const base = `listTools failed: ${boundDetail((err as Error).message)}`; + throw new DoctorError( + stderrBuf ? `${base} (stderr: ${boundDetail(stderrBuf)})` : base, + ); + } + + const tools = Array.isArray(listResult.tools) ? listResult.tools : []; + const byName = new Map(); + for (const t of tools) { + if (t && typeof t.name === 'string') { + byName.set(t.name, { inputSchema: t.inputSchema }); + } + } + for (const name of REQUIRED_TOOLS) { + if (!byName.has(name)) { + throw new DoctorError( + `required tool ${name} not advertised by server; cannot proceed`, + ); + } + } + const packetTool = byName.get('repository_packet'); + if (!packetSchemaOk(packetTool?.inputSchema)) { + throw new DoctorError( + 'repository_packet schema missing top-level object fields mode/spec/expectedGeneration', + ); + } + + const callTool = async (name: ToolName, args: Record) => { + try { + const res = (await client.callTool( + { name, arguments: args }, + undefined, + requestOpts(), + )) as McpCallResult; + return parseTextPayload(res, name); + } catch (err) { + if (err instanceof DoctorError) throw err; + const base = `${name} call failed: ${boundDetail((err as Error).message)}`; + throw new DoctorError( + stderrBuf ? `${base} (stderr: ${boundDetail(stderrBuf)})` : base, + ); + } + }; + + const statusRaw = requireObject( + await callTool('repository_status', {}), + 'repository_status', + ); + const status: StatusPayload = { + root: requireString(statusRaw.root, 'status.root'), + generation: statusRaw.generation, + freshness: requireString(statusRaw.freshness, 'status.freshness'), + revision: statusRaw.revision, + policy: statusRaw.policy as StatusPayload['policy'], + counts: statusRaw.counts, + exclusions: statusRaw.exclusions, + }; + let statusRoot: string; + try { + statusRoot = await realpath(status.root); + } catch { + statusRoot = status.root; + } + if (statusRoot !== root) { + throw new DoctorError( + `repository_status root ${statusRoot} does not match canonical target ${root}; refusing to proceed.`, + ); + } + + const refreshRaw = requireObject( + await callTool('repository_refresh', {}), + 'repository_refresh', + ); + const refreshRoot = requireString(refreshRaw.root, 'refresh.root'); + let refreshCanonical: string; + try { + refreshCanonical = await realpath(refreshRoot); + } catch { + refreshCanonical = refreshRoot; + } + if (refreshCanonical !== root) { + throw new DoctorError( + `repository_refresh root ${refreshCanonical} does not match canonical target ${root}.`, + ); + } + if (refreshRaw.freshness !== 'current') { + throw new DoctorError('repository_refresh did not report current freshness'); + } + const refreshPolicy = (refreshRaw.policy ?? {}) as { freshness?: unknown }; + if (refreshPolicy.freshness !== 'current') { + throw new DoctorError( + 'repository_refresh did not report current policy freshness', + ); + } + const refreshGeneration = requireString(refreshRaw.generation, 'refresh.generation'); + const refreshRevision = requireString(refreshRaw.revision, 'refresh.revision'); + const refreshCounts = requireObject(refreshRaw.counts, 'refresh.counts'); + const refreshExclusions = requireObject(refreshRaw.exclusions, 'refresh.exclusions'); + + const searchRaw = requireObject( + await callTool('repository_search', { + term, + maxResults: 1, + maxBytes: 8192, + maxVisited: 10000, + }), + 'repository_search', + ); + const search: SearchPayload = { + generation: searchRaw.generation, + freshness: requireString(searchRaw.freshness, 'search.freshness'), + policy: searchRaw.policy as SearchPayload['policy'], + results: Array.isArray(searchRaw.results) + ? (searchRaw.results as SearchHit[]) + : [], + }; + if (search.freshness !== 'current') { + throw new DoctorError('repository_search did not report current freshness'); + } + const searchPolicy = search.policy ?? {}; + if (searchPolicy.freshness !== 'current') { + throw new DoctorError('repository_search policy not current'); + } + if (search.generation !== refreshGeneration) { + throw new DoctorError( + 'repository_search generation does not match refresh generation', + ); + } + if (search.results.length === 0) { + throw new DoctorError( + `repository_search returned no results for term ${term}. Try another known indexed identifier, or inspect repository exclusions in a separate step.`, + ); + } + const hitRaw = requireObject(search.results[0], 'search.results[0]'); + const hit: SearchHit = { + path: requireString(hitRaw.path, 'search.results[0].path'), + line: requireNumber(hitRaw.line, 'search.results[0].line'), + text: requireString(hitRaw.text, 'search.results[0].text'), + sha256: requireHex(hitRaw.sha256, 'search.results[0].sha256', [64]), + }; + + const spec = { + version: 1, + task: 'Verify repository setup', + acceptanceChecks: ['Return one selected source line with matching provenance'], + allowedFiles: [] as string[], + sources: [ + { + path: hit.path, + startLine: hit.line, + endLine: hit.line, + }, + ], + exclusions: ['Read-only setup check'], + unresolvedQuestions: [] as string[], + }; + const packetRaw = requireObject( + await callTool('repository_packet', { + mode: 'build', + spec, + expectedGeneration: refreshGeneration, + maxBytes: 16384, + }), + 'repository_packet', + ); + const packet = requireObject(packetRaw.packet, 'packet.packet'); + const nav = requireObject(packetRaw.navigation, 'packet.navigation'); + const packetRootRaw = requireString(packet.canonicalRoot, 'packet.canonicalRoot'); + let packetRoot: string; + try { + packetRoot = await realpath(packetRootRaw); + } catch { + packetRoot = packetRootRaw; + } + if (packetRoot !== root) { + throw new DoctorError( + `repository_packet canonicalRoot ${packetRoot} does not match canonical target ${root}.`, + ); + } + const packetHEAD = requireHex(packet.gitHEAD, 'packet.gitHEAD', [40]).toLowerCase(); + if (packetHEAD !== gitHEAD) { + throw new DoctorError( + 'repository_packet gitHEAD does not match preflight HEAD', + ); + } + if (nav.generation !== refreshGeneration) { + throw new DoctorError( + 'repository_packet navigation.generation does not match refreshed generation', + ); + } + if (nav.revision !== refreshRevision) { + throw new DoctorError( + 'repository_packet navigation.revision does not match refreshed revision', + ); + } + const navPolicy = (nav.policy ?? {}) as { freshness?: unknown }; + if (navPolicy.freshness !== 'current') { + throw new DoctorError( + 'repository_packet navigation policy freshness is not current', + ); + } + const packetSources = Array.isArray(packet.sources) + ? (packet.sources as unknown[]) + : []; + if (packetSources.length !== 1) { + throw new DoctorError( + `repository_packet sources expected exactly 1, got ${packetSources.length}`, + ); + } + const source = requireObject(packetSources[0], 'packet.sources[0]'); + const sourcePath = requireString(source.path, 'packet.sources[0].path'); + const sourceSha = requireHex(source.sha256, 'packet.sources[0].sha256', [64]); + const startLine = requireNumber(source.startLine, 'packet.sources[0].startLine'); + const endLine = requireNumber(source.endLine, 'packet.sources[0].endLine'); + if (sourcePath !== hit.path) { + throw new DoctorError('packet source path does not match search hit path'); + } + if (sourceSha !== hit.sha256) { + throw new DoctorError('packet source sha256 does not match search hit sha256'); + } + if (startLine !== hit.line || endLine !== hit.line) { + throw new DoctorError( + 'packet source line range does not match search hit line', + ); + } + const sourceLines = Array.isArray(source.lines) ? (source.lines as unknown[]) : []; + if (sourceLines.length !== 1) { + throw new DoctorError( + `packet source lines expected exactly 1, got ${sourceLines.length}`, + ); + } + const lineObj = requireObject(sourceLines[0], 'packet.sources[0].lines[0]'); + const returnedLine = requireNumber(lineObj.line, 'packet.sources[0].lines[0].line'); + const returnedContent = requireString( + lineObj.content, + 'packet.sources[0].lines[0].content', + ); + if (returnedLine !== hit.line) { + throw new DoctorError('packet line number does not match search hit line'); + } + if (returnedContent !== hit.text) { + throw new DoctorError('packet line content does not match search hit text'); + } + + const report: DoctorReport = { + version: 1, + ok: true, + root, + gitHEAD, + node: process.version, + binding: { + command: process.execPath, + args: [CLI, 'navigate', root], + enabled_tools: [...REQUIRED_TOOLS], + }, + tools: [...REQUIRED_TOOLS], + generation: refreshGeneration, + revision: refreshRevision, + counts: refreshCounts, + exclusions: refreshExclusions, + evidence: { + path: hit.path, + line: hit.line, + sha256: hit.sha256, + }, + clientAcceptance: 'not-tested', + nextStep: + 'Merge binding into the selected project client configuration, reconnect, and verify all four tools in that client. Directory changes do not retarget the server.', + }; + + return report; + } catch (err) { + if (err instanceof DoctorError) throw err; + const base = `repository doctor failed: ${boundDetail((err as Error).message)}`; + throw new DoctorError(stderrBuf ? `${base} (stderr: ${boundDetail(stderrBuf)})` : base); + } finally { + clearTimeout(overallTimer); + try { + await client.close(); + } catch { + /* ignore */ + } + try { + await transport.close(); + } catch { + /* ignore */ + } + } +} diff --git a/packages/context/README.md b/packages/context/README.md index a1b4c4d..64b52ac 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -9,10 +9,12 @@ This compatibility package is maintained in the public Z1P Core repository alongside its CLI/MCP adapter. Its established package, API and protocol names remain unchanged during the product rebrand. It has its own manifest, exports, build and distributable tarball. -Install the package: +The matching 0.3.1 packages are distributed as GitHub release tarballs; see the +[installation guide](../../docs/GETTING-STARTED.md). npm publication is pending. +Once the matching version is published, install with: ```sh -npm install @forgesworn/context@0.3.0 +npm install @forgesworn/context@0.3.1 ``` From a source checkout, use `npm ci --ignore-scripts`, diff --git a/packages/context/THIRD_PARTY_NOTICES.md b/packages/context/THIRD_PARTY_NOTICES.md index d5c42c6..c6aee6e 100644 --- a/packages/context/THIRD_PARTY_NOTICES.md +++ b/packages/context/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -These notices accompany `@forgesworn/context@0.3.0`. The package itself is +These notices accompany `@forgesworn/context@0.3.1`. The package itself is MIT licensed, Copyright (c) 2026 TheCryptoDonkey; see `LICENSE`. Runtime dependencies are installed separately by the package manager, not diff --git a/packages/context/package.json b/packages/context/package.json index de11de9..d9468ee 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -1,6 +1,6 @@ { "name": "@forgesworn/context", - "version": "0.3.0", + "version": "0.3.1", "description": "Z1P Core portable signed and encrypted context collections", "license": "MIT", "type": "module", diff --git a/test/context-package-smoke.mjs b/test/context-package-smoke.mjs index 2801f51..c62e8ed 100644 --- a/test/context-package-smoke.mjs +++ b/test/context-package-smoke.mjs @@ -1,6 +1,6 @@ // Install real tarballs outside the workspace. No symlink or source-tree fallback. import { execFileSync } from 'node:child_process' -import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync } from 'node:fs' +import { mkdtempSync, writeFileSync, readFileSync, realpathSync, mkdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import assert from 'node:assert/strict' @@ -79,6 +79,15 @@ export const view = await vault.create({ title: 'Independent consumer', scope: ' git(['init', '--quiet']) git(['add', 'example.ts']) git(['-c', 'user.name=Context package test', '-c', 'user.email=package-test@example.invalid', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', 'commit', '--quiet', '-m', 'Fixture']) + const doctor = JSON.parse(execFileSync(process.execPath, [cli, 'doctor', packetRoot, '--term', 'example'], { encoding: 'utf8', timeout: 30000 })) + assert.equal(doctor.ok, true) + assert.equal(doctor.clientAcceptance, 'not-tested') + assert.equal(doctor.binding.command, process.execPath) + assert.deepEqual(doctor.binding.args, [realpathSync(cli), 'navigate', doctor.root]) + assert.deepEqual(doctor.tools.slice().sort(), ['repository_packet', 'repository_refresh', 'repository_search', 'repository_status']) + assert.equal(doctor.evidence.path, 'example.ts') + assert.equal(doctor.evidence.line, 1) + assert.ok(!JSON.stringify(doctor).includes('export function')) execFileSync(process.execPath, ['--input-type=module', '-e', ` import assert from 'node:assert/strict' import { writeFile } from 'node:fs/promises'