From bf8154b4f2ea2020c0bb9a47a6f5d15638131692 Mon Sep 17 00:00:00 2001 From: eris-ths <255252906+eris-ths@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:48:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(ctx):=20phase-2=20chain=20=E2=80=94=20one-?= =?UTF-8?q?hop=20reference=20+=20supersession=20neighborhood?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctx chain shows the records adjacent to a fact in one hop, so a reader follows related observations without grepping the substrate. Four edge kinds from a single scan: outbound (ctx ids the root's prose names, resolved or surfaced as '(referenced but not found)'), inbound (facts that name the root), supersedes, and superseded-by (a fork yields >1). One hop only, like gate chain — deeper walks are the reader re-invoking. The shared extractReferences (also behind gate chain) gains a third id kind: ctx-YYYY-MM-DD-NNN, returned in a new ctxIds field. This also fixes a latent mis-classification — the leading boundary allowed a hyphen, so a ctx-/i- id's digits could leak into requestIds; capturing the prefix keeps the three kinds disjoint. gate chain behavior is unchanged. Docs (verbs.md / AGENT.md / playbook.md / CLAUDE.md) and a changelog fragment updated. Full suite 1859/1859 green. Remaining phase-2: fork / status. --- .changelog/next/added-ctx-chain.md | 19 +++ AGENT.md | 20 ++- CLAUDE.md | 5 +- docs/playbook.md | 17 ++- docs/verbs.md | 48 +++++- src/domain/shared/extractReferences.ts | 30 +++- src/passages/ctx/application/CtxUseCases.ts | 72 +++++++++ src/passages/ctx/interface/handlers/chain.ts | 141 ++++++++++++++++++ src/passages/ctx/interface/index.ts | 42 +++--- src/passages/ctx/interface/verbs.ts | 6 +- tests/domain/extractReferences.test.ts | 60 ++++++++ tests/interface/nearestCommand.test.ts | 12 +- tests/interface/verbs-consistency.test.ts | 2 +- tests/passages/ctx/interface/chain.test.ts | 146 +++++++++++++++++++ 14 files changed, 562 insertions(+), 58 deletions(-) create mode 100644 .changelog/next/added-ctx-chain.md create mode 100644 src/passages/ctx/interface/handlers/chain.ts create mode 100644 tests/domain/extractReferences.test.ts create mode 100644 tests/passages/ctx/interface/chain.test.ts diff --git a/.changelog/next/added-ctx-chain.md b/.changelog/next/added-ctx-chain.md new file mode 100644 index 00000000..d3bb5678 --- /dev/null +++ b/.changelog/next/added-ctx-chain.md @@ -0,0 +1,19 @@ +- **`ctx chain ` — the second phase-2 ctx verb (read-side).** Shows + the one-hop neighborhood of a fact so a reader can follow related + observations without grepping the substrate. It walks four edge kinds + from a single read: **outbound** (ctx ids the root's prose mentions, each + resolved to its fact or surfaced as `(referenced but not found)` rather + than dropped), **inbound** (facts whose prose mentions the root), + **supersedes** (the fact the root corrects), and **superseded by** (the + facts that correct the root — a fork shows more than one). One hop only, + like `gate chain`: to go deeper, run `ctx chain` on a surfaced id. A + missing root is a recoverable not-found; an isolated fact reports an + empty neighborhood rather than an error. +- The shared id scanner `extractReferences` (also behind `gate chain`) now + recognizes the `ctx-YYYY-MM-DD-NNN` id shape as a third kind alongside + request and issue ids, returned in a new `ctxIds` field. This also fixes + a latent mis-classification: the leading boundary allowed a hyphen, so a + `ctx-…` (or `i-…`) id's digits could leak into `requestIds`; capturing + the prefix keeps the three kinds disjoint. `gate chain` behavior is + unchanged (it does not surface ctx ids). Remaining phase-2 verbs: `fork` + / `status`. diff --git a/AGENT.md b/AGENT.md index e19bfb5a..6619e2c6 100644 --- a/AGENT.md +++ b/AGENT.md @@ -589,16 +589,18 @@ is a *new* fact whose `supersedes` points back at the old one — the old record is never mutated; `ctx list` folds it out by default, `--all` keeps it marked, `ctx show ` resolves the reverse `superseded_by` link as an **array** of successor ids — empty while current, more than one when two -corrections fork the same fact), the read-side `list` / `show`, and the OKF -interop pair (`export` / `import`, below). The remaining lifecycle verbs -(`fork` / `chain` / `status`) and schema extensions (`evidence` / `sub_of` -/ `chain_after` / `branch_ref`) land in phase 2. +corrections fork the same fact), the read-side `list` / `show` / `chain` +(one-hop neighborhood: outbound + inbound prose references and the +supersession links), and the OKF interop pair (`export` / `import`, below). +The remaining lifecycle verbs (`fork` / `status`) and schema extensions +(`evidence` / `sub_of` / `chain_after` / `branch_ref`) land in phase 2. ```bash ctx record --fact "" [--tag prefix:value,prefix:value] [--by ] [--format json|text] -ctx list [--tag prefix:value] [--by ] [--format json|text] # read back, newest first +ctx list [--tag prefix:value] [--by ] [--all] [--format json|text] # read back, newest first ctx show [--format json|text] # one fact in full +ctx chain [--format json|text] # one-hop neighborhood ``` ctx records live under `/ctx/`: @@ -668,9 +670,11 @@ for what remains: pinned observation, no closure required. Status: alpha, phase 2 in progress. Write-side is `ctx record` + `ctx supersede`; read-side is `ctx list` (newest-first, `--tag` / `--by` / -`--all` filters) + `ctx show `, plus `ctx export` (OKF projection). -`ctx chain` still arrives in phase 2 and that's the design test -(junk-drawer risk vs principled substrate at the 100-record scale). +`--all` filters), `ctx show `, and `ctx chain ` (one-hop reference +walk), plus `ctx export` (OKF projection). `ctx chain` was the design test +the roadmap flagged (junk-drawer risk vs principled substrate at the +100-record scale); it ships as a one-hop, lexical-only walk — deeper walks +are the reader re-invoking it. Remaining phase-2 verbs: `fork` / `status`. # Tier: Diagnostic diff --git a/CLAUDE.md b/CLAUDE.md index ab8c23d5..4e915c2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,9 @@ file-based な coordination substrate を作っている TypeScript CLI。 の extension lense で judgment 軸にも転用できる (#134 G、ComposedLenseCatalog) - **ctx** — session 跨ぎの fact accumulation (alpha phase 2)。`record` / `supersede` (不変 fact を新 fact で訂正、forward-only link) / `list` (`--all` - で superseded も) / `show` / OKF `export`・`import`。残り phase-2: `fork` / - `chain` / `status` + で superseded も) / `show` / `chain` (1-hop の参照近傍: prose 内 ctx-id + outbound・inbound + supersede リンク) / OKF `export`・`import`。残り phase-2: + `fork` / `status` Clean Architecture: **Domain → Application → Infrastructure → Interface** (`src/` 配下に layer 分割)。`bin/*.mjs` が CLI dispatcher、各 passage の diff --git a/docs/playbook.md b/docs/playbook.md index f2988794..6d46e4b5 100644 --- a/docs/playbook.md +++ b/docs/playbook.md @@ -245,11 +245,12 @@ disagree). > **Status.** ctx ships `record`, `supersede` (correct an immutable > fact with a new one), `list` (`--all` to include superseded), `show`, -> and the OKF `export` / `import` pair. The remaining phase-2 verbs -> (`fork` / `chain` / `status`) land iteratively. So the patterns here -> are still narrow — more recipes appear as the verb surface fills in. -> Substrate written today is forward-compatible with the phase-2 verbs -> (id shape, tag prefix discipline). +> `chain` (one-hop reference + supersession neighborhood), and the OKF +> `export` / `import` pair. The remaining phase-2 verbs (`fork` / +> `status`) land iteratively. So the patterns here are still narrow — +> more recipes appear as the verb surface fills in. Substrate written +> today is forward-compatible with the phase-2 verbs (id shape, tag +> prefix discipline). ### X1: pin a fact future-you needs @@ -267,8 +268,10 @@ the watch loop". The `--tag` shape is `prefix:value` (lowercase, kebab-case). Shared tag prefixes — `topic:`, `scope:`, `iter:`, `observation:` -— make `ctx list --tag` filtering tractable now, and stay useful as -phase-2 `ctx chain` arrives. Plan tags as if they will be queried. +— make `ctx list --tag` filtering tractable. To follow a fact to its +neighbors instead of filtering, `ctx chain ` walks one hop over the +ctx ids its prose names (and the supersession links). Plan tags as if +they will be queried. Distinct from a `gate request` because there is no action implied. Distinct from an `agora move` because there is no diff --git a/docs/verbs.md b/docs/verbs.md index 0c60f62a..bd19a5d9 100644 --- a/docs/verbs.md +++ b/docs/verbs.md @@ -1624,7 +1624,7 @@ outside the in-tree passage. For substrate paths and the persona/lense schema reference, see the `## devil-review` section of [`AGENT.md`](../AGENT.md). -## ctx — the fourth passage (alpha, phase 2: + supersede) +## ctx — the fourth passage (alpha, phase 2: + supersede / chain) `ctx` is the fourth passage under guild, alongside `gate`, `agora`, and `devil`. Where gate carries decisions, agora carries narrative, @@ -1649,8 +1649,8 @@ that don't fit into a verdict, a play, or a review. ### Surface Shipped: `ctx record`, the correction verb `ctx supersede`, the read-side -`list` / `show`, and the OKF interop pair (`export` / `import`, below). -The remaining lifecycle verbs (`fork` / `chain` / `status`) and schema +`list` / `show` / `chain`, and the OKF interop pair (`export` / `import`, +below). The remaining lifecycle verbs (`fork` / `status`) and schema extensions (`evidence` / `sub_of` / `chain_after` / `branch_ref`) land in phase 2. @@ -1691,11 +1691,39 @@ list means the store is genuinely empty. Superseding an id that has no record is a recoverable not-found — a correction must point at something real, so a dangling link is refused rather than written. +#### `ctx chain` — the one-hop neighborhood of a fact + +```bash +ctx chain [--format json|text] +``` + +`ctx chain` shows the records adjacent to a fact in one hop, so a reader +can follow related observations without grepping the substrate. It walks +four edge kinds, all from a single read: + +- **outbound** — ctx ids the root's own prose mentions. Resolved to their + facts; a prose mention of an absent id is surfaced as `(referenced but + not found)` rather than dropped (records-outlive-writers: a dangling + reference is signal). Self-references are ignored. +- **inbound** — other facts whose prose mentions the root id. +- **supersedes / superseded by** — the supersession links, surfaced as + first-class branches instead of left buried in prose. A fork (two + corrections of one fact) shows both under *superseded by*. + +The id scanner is the shared `extractReferences` (also behind +`gate chain`), extended to recognize the `ctx-YYYY-MM-DD-NNN` shape. It +follows **well-formed** ids only — the same single-hop, lexical-only +discipline as `gate chain`: to go deeper, run `ctx chain` on a surfaced +id; the reader drives the depth. A missing root is a recoverable not-found +that names `ctx list`. An isolated fact (no links in or out) reports an +empty neighborhood rather than an error. + ```bash ctx record --fact "" [--tag prefix:value,prefix:value] [--by ] [--format json|text] -ctx list [--tag prefix:value] [--by ] [--format json|text] +ctx list [--tag prefix:value] [--by ] [--all] [--format json|text] ctx show [--format json|text] +ctx chain [--format json|text] ``` ### Reading facts back (`list` / `show`) @@ -1815,10 +1843,14 @@ future second format). ### Status (alpha, phase 2 in progress) Write-side is `ctx record` + `ctx supersede` (corrections as new facts); -read-side is `ctx list` (newest-first, `--tag` / `--by` / `--all` filters) -+ `ctx show `, plus `ctx export` (OKF projection). `ctx chain` (still -phase 2) is the remaining design test — whether the substrate stays -principled or drifts into a junk drawer at the 100-record scale. +read-side is `ctx list` (newest-first, `--tag` / `--by` / `--all` filters), +`ctx show `, and `ctx chain ` (one-hop neighborhood), plus +`ctx export` (OKF projection). `ctx chain` was the design test the roadmap +flagged — whether a reference walk stays principled or drifts into a junk +drawer at the 100-record scale; it ships as a one-hop, lexical-only walk +(deeper walks are the reader re-invoking it), with a persisted reverse +index held in reserve if the flat-scan cost bites. Remaining phase-2 verbs: +`fork` / `status`. For the conceptual framing alongside the other passages, see the `## ctx` section of [`AGENT.md`](../AGENT.md). diff --git a/src/domain/shared/extractReferences.ts b/src/domain/shared/extractReferences.ts index 0505dc75..89690a3b 100644 --- a/src/domain/shared/extractReferences.ts +++ b/src/domain/shared/extractReferences.ts @@ -9,6 +9,7 @@ // References are found by regex: // requests: YYYY-MM-DD-NNN[N] // issues: i-YYYY-MM-DD-NNN[N] +// ctx: ctx-YYYY-MM-DD-NNN[N] // Matching is lexical only. A prose string that happens to look like // an id is followed; false positives are rare because the shape is // distinctive. @@ -16,26 +17,37 @@ // \d{3,4} accepts both legacy (0.1.x) 3-digit and current 4-digit // sequence suffixes. The trailing (?!\d) forbids longer digit runs // so "2026-04-15-00123" (5+ digits) is not partially matched. -const ID_PATTERN = /(?; readonly issueIds: ReadonlyArray; + readonly ctxIds: ReadonlyArray; } /** - * Scan a free-text string for request and issue ids. Returns the + * Scan a free-text string for request, issue, and ctx ids. Returns the * unique set of each, in first-seen order. Order is stable because * duplicate renders are noisy to readers scanning the output. */ export function extractReferences(text: string): ExtractedReferences { const requestIds = new Set(); const issueIds = new Set(); + const ctxIds = new Set(); const requestOrder: string[] = []; const issueOrder: string[] = []; + const ctxOrder: string[] = []; if (typeof text !== 'string' || text.length === 0) { - return { requestIds: [], issueIds: [] }; + return { requestIds: [], issueIds: [], ctxIds: [] }; } // Reset lastIndex on global regex between calls — otherwise the @@ -44,9 +56,15 @@ export function extractReferences(text: string): ExtractedReferences { ID_PATTERN.lastIndex = 0; let match: RegExpExecArray | null; while ((match = ID_PATTERN.exec(text)) !== null) { - const hasIssuePrefix = match[1] === 'i-'; + const prefix = match[1]; const core = match[2]!; - if (hasIssuePrefix) { + if (prefix === 'ctx-') { + const id = `ctx-${core}`; + if (!ctxIds.has(id)) { + ctxIds.add(id); + ctxOrder.push(id); + } + } else if (prefix === 'i-') { const id = `i-${core}`; if (!issueIds.has(id)) { issueIds.add(id); @@ -59,5 +77,5 @@ export function extractReferences(text: string): ExtractedReferences { } } } - return { requestIds: requestOrder, issueIds: issueOrder }; + return { requestIds: requestOrder, issueIds: issueOrder, ctxIds: ctxOrder }; } diff --git a/src/passages/ctx/application/CtxUseCases.ts b/src/passages/ctx/application/CtxUseCases.ts index 01e28200..946f10f2 100644 --- a/src/passages/ctx/application/CtxUseCases.ts +++ b/src/passages/ctx/application/CtxUseCases.ts @@ -2,6 +2,34 @@ import { Ctx, CtxIdCollision, nextCtxId, parseCtxId } from '../domain/Ctx.js'; import { CtxRepository } from './CtxRepository.js'; import { OkfBundlePort, OkfSkippedDoc } from './OkfBundlePort.js'; import { ctxToOkfDocument, okfDocumentToCtxFact } from './OkfCtxMapper.js'; +import { extractReferences } from '../../../domain/shared/extractReferences.js'; + +/** One end of a chain edge: the id, and the fact if it resolves. */ +export interface CtxChainRef { + readonly id: string; + /** The resolved fact, or null when the id is referenced but absent. */ + readonly fact: Ctx | null; +} + +/** + * The one-hop neighborhood around a ctx fact, the read model behind + * `ctx chain`. Mirrors `gate chain`'s shape: outbound (ids the root's + * prose mentions) + inbound (facts whose prose mentions the root), with + * the ctx-specific supersession edges (the link the root carries, and the + * facts that supersede the root) surfaced as first-class branches rather + * than buried in prose. + */ +export interface CtxChain { + readonly root: Ctx; + /** ctx ids the root's fact prose references (one hop, deduped). */ + readonly outbound: readonly CtxChainRef[]; + /** facts whose prose references the root id (one hop). */ + readonly inbound: readonly Ctx[]; + /** the fact the root supersedes, if any (the root's forward link). */ + readonly supersedes: Ctx | null; + /** facts that supersede the root (reverse link; a fork yields >1). */ + readonly supersededBy: readonly Ctx[]; +} /** Summary of an OKF export. */ export interface OkfExportSummary { @@ -166,6 +194,50 @@ export class CtxUseCases { return all.filter((c) => c.supersedes === id).sort(byNewestFirst); } + /** + * Build the one-hop chain neighborhood around `id` — the read model + * behind `ctx chain`. Read-only. Returns null when `id` has no record + * (the caller maps that to a recoverable not-found). + * + * Four edge kinds, all resolved from a single substrate scan: + * - outbound: ctx ids the root's prose mentions (lexical, via the + * shared `extractReferences`), each resolved to its fact or left as + * a dangling ref (referenced-but-absent is surfaced, not dropped — + * records-outlive-writers: a prose mention of a deleted/future id is + * signal). Self-references are dropped. + * - inbound: facts whose prose mentions the root id. + * - supersedes: the fact the root corrects (its forward link). + * - supersededBy: facts that correct the root (reverse link; fork >1). + * + * One hop only, like `gate chain`: deeper walks are the reader calling + * `ctx chain` again on a surfaced id. Cost is one full hydration scan — + * see the `supersededBy` note on the flat-layout scaling tradeoff. + */ + async chain(id: string): Promise { + const rootId = parseCtxId(id); + const all = await this.repo.listAll(); + const byId = new Map(all.map((c) => [c.id, c])); + const root = byId.get(rootId); + if (root === undefined) return null; + + // outbound: ctx ids the root's prose references (deduped, self dropped). + const outbound: CtxChainRef[] = []; + for (const refId of extractReferences(root.fact).ctxIds) { + if (refId === rootId) continue; + outbound.push({ id: refId, fact: byId.get(refId) ?? null }); + } + + // inbound: other facts whose prose references the root id. + const inbound = all + .filter((c) => c.id !== rootId && extractReferences(c.fact).ctxIds.includes(rootId)) + .sort(byNewestFirst); + + const supersedes = root.supersedes !== undefined ? byId.get(root.supersedes) ?? null : null; + const supersededBy = all.filter((c) => c.supersedes === rootId).sort(byNewestFirst); + + return { root, outbound, inbound, supersedes, supersededBy }; + } + /** * Export every ctx fact as an OKF bundle under `dir`. Read-only over * the substrate (writes land outside `content_root`, in the chosen diff --git a/src/passages/ctx/interface/handlers/chain.ts b/src/passages/ctx/interface/handlers/chain.ts new file mode 100644 index 00000000..d3c2ff32 --- /dev/null +++ b/src/passages/ctx/interface/handlers/chain.ts @@ -0,0 +1,141 @@ +import { CtxUseCases } from '../../application/CtxUseCases.js'; +import { GuildConfig } from '../../../../infrastructure/config/GuildConfig.js'; +import { parseFormat } from '../../../../interface/shared/parseFormat.js'; +import { + ParsedArgs, + rejectUnknownFlags, +} from '../../../../interface/shared/parseArgs.js'; +import { DomainError } from '../../../../domain/shared/DomainError.js'; +import { RecoverableError } from '../../../../interface/shared/errorEnvelope.js'; +import { Ctx } from '../../domain/Ctx.js'; + +const CHAIN_KNOWN_FLAGS: ReadonlySet = new Set(['format']); + +export interface ChainCtxDeps { + readonly uc: CtxUseCases; + readonly config: GuildConfig; +} + +/** First non-empty, collapsed, truncated line of a fact — for tree rows. */ +function snippet(fact: string, max = 72): string { + const line = fact + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0); + if (line === undefined) return ''; + const collapsed = line.replace(/\s+/g, ' '); + return collapsed.length > max ? `${collapsed.slice(0, max - 1)}…` : collapsed; +} + +/** One tree row for a resolved fact. */ +function factRow(c: Ctx): string { + return `${c.id} ${c.created_by} ${snippet(c.fact)}`; +} + +/** + * ctx chain — show the one-hop neighborhood around a fact. + * + * Usage: + * ctx chain [--format json|text] + * + * Walks four edge kinds from the root: outbound (ctx ids the root's prose + * mentions), inbound (facts whose prose mentions the root), and the two + * supersession links (the fact the root corrects, and the facts that + * correct the root). One hop only — to go deeper, run `ctx chain` on a + * surfaced id (same single-step discipline as `gate chain`). A dangling + * outbound reference (prose mentions an absent id) is shown as + * `(referenced but not found)` rather than dropped. A missing root is a + * recoverable not-found that names `ctx list` as the recovery path. + */ +export async function chainCtx( + deps: ChainCtxDeps, + args: ParsedArgs, +): Promise { + rejectUnknownFlags(args, CHAIN_KNOWN_FLAGS, 'chain'); + const format = parseFormat(args); + + const id = args.positional[0]; + if (id === undefined || id.length === 0) { + throw new DomainError('chain requires a fact id (ctx chain )', 'id'); + } + + const chain = await deps.uc.chain(id); // chain() validates id shape + + if (chain === null) { + throw new RecoverableError( + `ctx fact ${id} not found.\n` + + ' list the recorded facts to find the right id:\n' + + ' ctx list', + { verb: 'list', args: {}, reason: 'list the recorded facts to find the right id' }, + 'not_found', + ); + } + + if (format === 'json') { + process.stdout.write( + JSON.stringify( + { + ok: true, + root: chain.root.toJSON(), + outbound: chain.outbound.map((r) => ({ + id: r.id, + resolved: r.fact !== null, + ...(r.fact !== null ? { fact: r.fact.toJSON() } : {}), + })), + inbound: chain.inbound.map((c) => c.toJSON()), + supersedes: chain.supersedes !== null ? chain.supersedes.toJSON() : null, + superseded_by: chain.supersededBy.map((c) => c.toJSON()), + }, + null, + 2, + ) + '\n', + ); + return 0; + } + + // text: a one-hop tree rooted at the fact. + const out: string[] = []; + out.push(`${chain.root.id} ${chain.root.created_by} ${snippet(chain.root.fact)}`); + + const empty = + chain.outbound.length === 0 && + chain.inbound.length === 0 && + chain.supersedes === null && + chain.supersededBy.length === 0; + if (empty) { + out.push(' (no chain: no references in or out, no supersession links)'); + process.stdout.write(out.join('\n') + '\n'); + return 0; + } + + if (chain.supersedes !== null) { + out.push('├── supersedes'); + out.push(`│ └── ${factRow(chain.supersedes)}`); + } + if (chain.supersededBy.length > 0) { + out.push('├── superseded by'); + chain.supersededBy.forEach((c, i, a) => { + out.push(`│ ${i === a.length - 1 ? '└──' : '├──'} ${factRow(c)}`); + }); + } + if (chain.outbound.length > 0) { + out.push('├── references (outbound)'); + chain.outbound.forEach((r, i, a) => { + const branch = i === a.length - 1 ? '└──' : '├──'; + out.push( + `│ ${branch} ${ + r.fact !== null ? factRow(r.fact) : `${r.id} (referenced but not found)` + }`, + ); + }); + } + if (chain.inbound.length > 0) { + out.push('└── referenced by (inbound)'); + chain.inbound.forEach((c, i, a) => { + out.push(` ${i === a.length - 1 ? '└──' : '├──'} ${factRow(c)}`); + }); + } + + process.stdout.write(out.join('\n') + '\n'); + return 0; +} diff --git a/src/passages/ctx/interface/index.ts b/src/passages/ctx/interface/index.ts index 119ce31a..2423b0c5 100644 --- a/src/passages/ctx/interface/index.ts +++ b/src/passages/ctx/interface/index.ts @@ -6,8 +6,8 @@ // append-only. See lore/principles/12 for the boundary with adjacent // modules. // -// Surface: record / supersede / list / show + OKF export/import. The -// remaining lifecycle verbs (fork / chain / status) land iteratively in +// Surface: record / supersede / list / show / chain + OKF export/import. +// The remaining lifecycle verbs (fork / status) land iteratively in // phase 2 as use surfaces what shape they need. // // AI-first per principle 11: the substrate is machine-parseable JSON / @@ -26,11 +26,12 @@ import { importCtx, IMPORT_BOOLEAN_FLAGS } from './handlers/importOkf.js'; import { listCtx, LIST_BOOLEAN_FLAGS } from './handlers/list.js'; import { showCtx } from './handlers/show.js'; import { supersedeCtx } from './handlers/supersede.js'; +import { chainCtx } from './handlers/chain.js'; import { withEntryLock } from '../../../infrastructure/lock/withEntryLock.js'; import { resolveGuildActor } from '../../../interface/shared/resolveGuildActor.js'; import { READ_VERBS, WRITE_VERBS, LOCK_EXEMPT_VERBS } from './verbs.js'; -const HELP = `ctx — fact accumulation passage (phase 2: + supersede) +const HELP = `ctx — fact accumulation passage (phase 2: + supersede / chain) Usage: ctx record --fact "" [--tag tech:foo,status:bar] @@ -57,6 +58,12 @@ Usage: Show one fact in full. A superseded fact stays readable, marked with its successor. + ctx chain [--format json|text] + Show the one-hop neighborhood of a fact: + outbound (ctx ids its prose mentions), inbound + (facts that mention it), and supersession links. + One hop — run chain on a surfaced id to go deeper. + ctx export [--as okf] [--force] [--format json|text] Project every fact into an Open Knowledge Format bundle under (one .md per @@ -78,10 +85,10 @@ Usage: ctx --help This help. ctx --version Print version and exit. -Status: \`record\` / \`supersede\` / \`list\` / \`show\` plus the OKF interop -pair (\`export\` / \`import\`). OKF is an interchange *projection* +Status: \`record\` / \`supersede\` / \`list\` / \`show\` / \`chain\` plus the OKF +interop pair (\`export\` / \`import\`). OKF is an interchange *projection* (principle 11), not a storage change — the substrate stays YAML. -Remaining phase-2 verbs: fork / chain / status. +Remaining phase-2 verbs: fork / status. Substrate: shares content_root and members/ with gate; ctx-specific data goes under /ctx/. @@ -94,17 +101,16 @@ Lore upstream: // Mirror of the switch below for did-you-mean suggestions. A new verb // forgotten here loses its typo hint, doesn't crash anything. -const CTX_COMMANDS = ['record', 'supersede', 'export', 'import', 'list', 'show'] as const; +const CTX_COMMANDS = ['record', 'supersede', 'export', 'import', 'list', 'show', 'chain'] as const; // The phase-2 lifecycle verbs named in HELP / AGENT.md / docs as // "arriving in phase 2". A user who read the docs and types one of these // deserves a roadmap-aware message ("planned, not yet implemented") // rather than the same "unknown verb" a typo gets. Keep in sync with the -// HELP "remaining phase-2 verbs" line above (record/supersede/list/show -// shipped — they left this set). +// HELP "remaining phase-2 verbs" line above (record/supersede/list/show/ +// chain shipped — they left this set). const CTX_PHASE2_VERBS: ReadonlySet = new Set([ 'fork', - 'chain', 'status', ]); @@ -115,7 +121,7 @@ export async function main(argv: readonly string[]): Promise { } if (isVersionFlag(argv)) { process.stdout.write( - `ctx (under guild-cli ${getPackageVersion()}) — alpha phase 2 (record / supersede / list / show + OKF export/import)\n`, + `ctx (under guild-cli ${getPackageVersion()}) — alpha phase 2 (record / supersede / list / show / chain + OKF export/import)\n`, ); return 0; } @@ -145,6 +151,8 @@ export async function main(argv: readonly string[]): Promise { return await listCtx({ uc, config }, args); case 'show': return await showCtx({ uc, config }, args); + case 'chain': + return await chainCtx({ uc, config }, args); case 'export': return await exportCtx({ uc, config }, args); case 'import': @@ -156,19 +164,19 @@ export async function main(argv: readonly string[]): Promise { if (cmd !== undefined && CTX_PHASE2_VERBS.has(cmd)) { process.stderr.write( `ctx: '${cmd}' is a planned phase-2 verb, not yet implemented.\n` + - ` current surface: record / supersede / list / show / export / import. See 'ctx --help'.\n`, + ` current surface: record / supersede / list / show / chain / export / import. See 'ctx --help'.\n`, ); return 1; } - // Remaining phase-2 verbs (fork / chain / status) land as use - // surfaces their shape; the catalog grows then. Valid verbs today - // are record / supersede / list / show / export / import — typos - // like `recor` should still get suggested rather than dumping HELP. + // Remaining phase-2 verbs (fork / status) land as use surfaces + // their shape; the catalog grows then. Valid verbs today are + // record / supersede / list / show / chain / export / import — + // typos like `recor` should still get suggested rather than HELP. const hint = nearestCommand(cmd, CTX_COMMANDS); const suggest = hint ? `\n did you mean: ctx ${hint}?` : ''; process.stderr.write( `ctx: unknown verb: ${cmd}${suggest}\n` + - ` see 'ctx --help' for the full verb catalog (record / supersede / list / show / export / import).\n`, + ` see 'ctx --help' for the full verb catalog (record / supersede / list / show / chain / export / import).\n`, ); return 1; } diff --git a/src/passages/ctx/interface/verbs.ts b/src/passages/ctx/interface/verbs.ts index ca3dd356..e17bbc35 100644 --- a/src/passages/ctx/interface/verbs.ts +++ b/src/passages/ctx/interface/verbs.ts @@ -4,10 +4,10 @@ // Surface: `record` / `supersede` (write — both append a new fact); the // OKF interop pair `export` (read — writes a bundle outside content_root, // no substrate mutation) and `import` (write — records facts into -// content_root); and the read-side `list` / `show`. Phase 2 will still add -// fork / chain / status. +// content_root); and the read-side `list` / `show` / `chain`. Phase 2 will +// still add fork / status. -export const READ_VERBS: ReadonlySet = new Set(['export', 'list', 'show']); +export const READ_VERBS: ReadonlySet = new Set(['export', 'list', 'show', 'chain']); export const WRITE_VERBS: ReadonlySet = new Set(['record', 'supersede', 'import']); diff --git a/tests/domain/extractReferences.test.ts b/tests/domain/extractReferences.test.ts new file mode 100644 index 00000000..23278bee --- /dev/null +++ b/tests/domain/extractReferences.test.ts @@ -0,0 +1,60 @@ +// extractReferences — the shared lexical id-scanner over free text. +// +// Direct unit coverage for the three id kinds (request / issue / ctx). +// The ctx- prefix was added for `ctx chain`; these tests pin that it +// classifies correctly AND that adding it did not regress request/issue +// scanning (the boundary-after-hyphen trap that would mis-read a prefixed +// id as a bare request id). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractReferences } from '../../src/domain/shared/extractReferences.js'; + +test('classifies request, issue, and ctx ids into separate buckets', () => { + const r = extractReferences('see 2026-04-14-014 and i-2026-04-14-004 and ctx-2026-05-09-001'); + assert.deepEqual(r.requestIds, ['2026-04-14-014']); + assert.deepEqual(r.issueIds, ['i-2026-04-14-004']); + assert.deepEqual(r.ctxIds, ['ctx-2026-05-09-001']); +}); + +test('a ctx-prefixed id is NOT also counted as a bare request id', () => { + // The core digits of ctx-2026-05-09-001 are 2026-05-09-001; without the + // prefix capture they would leak into requestIds (boundary-after-hyphen). + const r = extractReferences('ctx-2026-05-09-001'); + assert.deepEqual(r.ctxIds, ['ctx-2026-05-09-001']); + assert.deepEqual(r.requestIds, []); + assert.deepEqual(r.issueIds, []); +}); + +test('issue ids still resolve unaffected by the ctx addition', () => { + const r = extractReferences('i-2026-04-14-008 needs follow-up'); + assert.deepEqual(r.issueIds, ['i-2026-04-14-008']); + assert.deepEqual(r.requestIds, []); + assert.deepEqual(r.ctxIds, []); +}); + +test('dedupes within each kind, preserving first-seen order', () => { + const r = extractReferences( + 'ctx-2026-05-09-001 ctx-2026-05-07-002 ctx-2026-05-09-001 ctx-2026-05-07-001', + ); + assert.deepEqual(r.ctxIds, [ + 'ctx-2026-05-09-001', + 'ctx-2026-05-07-002', + 'ctx-2026-05-07-001', + ]); +}); + +test('accepts 3- and 4-digit suffixes, rejects 5+ digit runs', () => { + const r = extractReferences('ctx-2026-05-09-001 and ctx-2026-05-09-0012 and ctx-2026-05-09-00123'); + // 3-digit and 4-digit suffixes are valid ids. + assert.ok(r.ctxIds.includes('ctx-2026-05-09-001')); + assert.ok(r.ctxIds.includes('ctx-2026-05-09-0012')); + // a 5+ digit run is not matched as a ctx id (the (?!\d) guard). + assert.ok(!r.ctxIds.some((id) => id.includes('00123'))); +}); + +test('empty / non-string input yields all-empty buckets', () => { + assert.deepEqual(extractReferences(''), { requestIds: [], issueIds: [], ctxIds: [] }); + // @ts-expect-error exercising the runtime guard for non-string input + assert.deepEqual(extractReferences(null), { requestIds: [], issueIds: [], ctxIds: [] }); +}); diff --git a/tests/interface/nearestCommand.test.ts b/tests/interface/nearestCommand.test.ts index 2097d77d..dc24dca3 100644 --- a/tests/interface/nearestCommand.test.ts +++ b/tests/interface/nearestCommand.test.ts @@ -119,9 +119,9 @@ test('ctx : suggests ctx-prefixed verb (current catalog)', (t) => { assert.match(r.stderr, /did you mean: ctx record\?/); // Verb-catalog callout — flag the available surface at the point a // typo hit it, since the remaining phase-2 lifecycle verbs (fork / - // chain / status) are not yet implemented and a typo for any of those - // would refuse to suggest. - assert.match(r.stderr, /record \/ supersede \/ list \/ show \/ export \/ import/); + // status) are not yet implemented and a typo for either would refuse + // to suggest. + assert.match(r.stderr, /record \/ supersede \/ list \/ show \/ chain \/ export \/ import/); }); test('ctx : roadmap-aware message, not a bare "unknown verb"', (t) => { @@ -129,12 +129,12 @@ test('ctx : roadmap-aware message, not a bare "unknown verb"', (t) t.after(cleanup); run(GATE, root, ['register', '--name', 'alice']); // `fork` is documented as a remaining phase-2 verb (record / supersede / - // list / show shipped); a reader of the docs who types it should be told - // it's planned, not treated like a typo. + // list / show / chain shipped); a reader of the docs who types it should + // be told it's planned, not treated like a typo. const r = run(CTX, root, ['fork']); assert.equal(r.status, 1); assert.match(r.stderr, /planned phase-2 verb, not yet implemented/); - assert.match(r.stderr, /current surface: record \/ supersede \/ list \/ show \/ export \/ import/); + assert.match(r.stderr, /current surface: record \/ supersede \/ list \/ show \/ chain \/ export \/ import/); assert.doesNotMatch(r.stderr, /unknown verb/); }); diff --git a/tests/interface/verbs-consistency.test.ts b/tests/interface/verbs-consistency.test.ts index 4fd16259..cec86f66 100644 --- a/tests/interface/verbs-consistency.test.ts +++ b/tests/interface/verbs-consistency.test.ts @@ -57,7 +57,7 @@ const DEVIL_ALL = [ 'suspend', 'resume', 'ingest', 'conclude', 'schema', ] as const; -const CTX_ALL = ['record', 'supersede', 'list', 'show', 'export', 'import'] as const; +const CTX_ALL = ['record', 'supersede', 'list', 'show', 'chain', 'export', 'import'] as const; const CASES: Case[] = [ { passage: 'gate', verbs: gateVerbs, all: GATE_ALL }, diff --git a/tests/passages/ctx/interface/chain.test.ts b/tests/passages/ctx/interface/chain.test.ts new file mode 100644 index 00000000..74f0434a --- /dev/null +++ b/tests/passages/ctx/interface/chain.test.ts @@ -0,0 +1,146 @@ +// ctx chain (phase-2 verb #2) — end-to-end through the real binary. +// +// chain shows the one-hop neighborhood of a fact: outbound (ctx ids its +// prose mentions), inbound (facts that mention it), and the two +// supersession links. Covers: +// - supersedes / superseded-by links surface as branches +// - inbound: a fact whose prose names the root id +// - outbound: the root's prose naming another ctx id (+ dangling ref) +// - one-hop only (a two-step reference is not transitively walked) +// - empty neighborhood message +// - missing root -> recoverable not-found +// - malformed id -> domain validation error + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const CTX = resolve(here, '../../../../../bin/ctx.mjs'); + +function newRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'ctx-chain-')); + writeFileSync(join(root, 'guild.config.yaml'), 'content_root: .\nhost_names: [human]\n'); + return root; +} + +function runCtx( + cwd: string, + args: string[], + env: Record = {}, +): { stdout: string; stderr: string; status: number } { + const r = spawnSync(process.execPath, [CTX, ...args], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf8', + }); + return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 }; +} + +function record(root: string, fact: string): string { + runCtx(root, ['record', '--fact', fact], { GUILD_ACTOR: 'eris' }); + const env = JSON.parse(runCtx(root, ['list', '--all', '--format', 'json']).stdout); + return env.facts[0].id; // newest first +} + +test('chain surfaces supersession links in both directions', (t) => { + const root = newRoot(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const base = record(root, 'base fact'); + const corr = JSON.parse( + runCtx(root, ['supersede', base, '--fact', 'correction', '--format', 'json'], { GUILD_ACTOR: 'eris' }).stdout, + ); + + const fromBase = JSON.parse(runCtx(root, ['chain', base, '--format', 'json']).stdout); + assert.equal(fromBase.ok, true); + assert.equal(fromBase.superseded_by.length, 1); + assert.equal(fromBase.superseded_by[0].id, corr.id); + assert.equal(fromBase.supersedes, null); + + const fromCorr = JSON.parse(runCtx(root, ['chain', corr.id, '--format', 'json']).stdout); + assert.equal(fromCorr.supersedes.id, base); + assert.equal(fromCorr.superseded_by.length, 0); +}); + +test('chain inbound: a fact whose prose names the root id', (t) => { + const root = newRoot(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const base = record(root, 'the original note'); + const ref = record(root, `followup: see ${base} for context`); + + const r = JSON.parse(runCtx(root, ['chain', base, '--format', 'json']).stdout); + assert.equal(r.inbound.length, 1); + assert.equal(r.inbound[0].id, ref); + + // text surface renders the inbound branch + const txt = runCtx(root, ['chain', base]).stdout; + assert.match(txt, /referenced by \(inbound\)/); + assert.match(txt, new RegExp(ref)); +}); + +test('chain outbound: the root prose naming another ctx id, plus a dangling ref', (t) => { + const root = newRoot(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const target = record(root, 'target fact'); + // root references both a real id and a plausible-but-absent one + const rootFact = record(root, `mentions ${target} and ctx-2020-01-01-001 which does not exist`); + + const r = JSON.parse(runCtx(root, ['chain', rootFact, '--format', 'json']).stdout); + const outIds = r.outbound.map((o: { id: string }) => o.id); + assert.ok(outIds.includes(target), 'resolved outbound ref present'); + assert.ok(outIds.includes('ctx-2020-01-01-001'), 'dangling outbound ref surfaced, not dropped'); + const dangling = r.outbound.find((o: { id: string }) => o.id === 'ctx-2020-01-01-001'); + assert.equal(dangling.resolved, false); + + // text marks the dangling one + const txt = runCtx(root, ['chain', rootFact]).stdout; + assert.match(txt, /referenced but not found/); +}); + +test('chain is one hop — it does not transitively walk', (t) => { + const root = newRoot(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const a = record(root, 'fact A'); + const b = record(root, `fact B references ${a}`); + const c = record(root, `fact C references ${b}`); + + // chain on C surfaces B (one hop), not A (two hops away through B). + const r = JSON.parse(runCtx(root, ['chain', c, '--format', 'json']).stdout); + const outIds = r.outbound.map((o: { id: string }) => o.id); + assert.deepEqual(outIds, [b], 'only the direct reference, not the transitive one'); +}); + +test('chain on an isolated fact reports an empty neighborhood', (t) => { + const root = newRoot(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const lone = record(root, 'a fact with no links'); + const r = JSON.parse(runCtx(root, ['chain', lone, '--format', 'json']).stdout); + assert.equal(r.outbound.length, 0); + assert.equal(r.inbound.length, 0); + assert.equal(r.supersedes, null); + assert.equal(r.superseded_by.length, 0); + assert.match(runCtx(root, ['chain', lone]).stdout, /no chain/); +}); + +test('chain on a missing root is a recoverable not-found', (t) => { + const root = newRoot(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const r = runCtx(root, ['chain', 'ctx-2020-01-01-001', '--format', 'json']); + assert.equal(r.status, 1); + const env = JSON.parse(r.stderr.split('\n')[0]!); + assert.equal(env.ok, false); + assert.equal(env.error.code, 'not_found'); + assert.equal(env.error.recovery.verb, 'list'); +}); + +test('chain with a malformed id is a domain validation error', (t) => { + const root = newRoot(); + t.after(() => rmSync(root, { recursive: true, force: true })); + const r = runCtx(root, ['chain', 'not-an-id']); + assert.equal(r.status, 1); + assert.match(r.stderr, /ctx id must match/); +});