Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changelog/next/added-ctx-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
- **`ctx chain <id>` — 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`.
20 changes: 12 additions & 8 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <old-id>` 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 "<prose>" [--tag prefix:value,prefix:value]
[--by <m>] [--format json|text]
ctx list [--tag prefix:value] [--by <m>] [--format json|text] # read back, newest first
ctx list [--tag prefix:value] [--by <m>] [--all] [--format json|text] # read back, newest first
ctx show <id> [--format json|text] # one fact in full
ctx chain <id> [--format json|text] # one-hop neighborhood
```

ctx records live under `<content_root>/ctx/`:
Expand Down Expand Up @@ -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 <id>`, 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 <id>`, and `ctx chain <id>` (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

Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の
Expand Down
17 changes: 10 additions & 7 deletions docs/playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <id>` 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
Expand Down
48 changes: 40 additions & 8 deletions docs/verbs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

Expand Down Expand Up @@ -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 <id> [--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 "<prose>" [--tag prefix:value,prefix:value]
[--by <m>] [--format json|text]
ctx list [--tag prefix:value] [--by <m>] [--format json|text]
ctx list [--tag prefix:value] [--by <m>] [--all] [--format json|text]
ctx show <id> [--format json|text]
ctx chain <id> [--format json|text]
```

### Reading facts back (`list` / `show`)
Expand Down Expand Up @@ -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 <id>`, 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 <id>`, and `ctx chain <id>` (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).
Expand Down
30 changes: 24 additions & 6 deletions src/domain/shared/extractReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,45 @@
// 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.

// \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 = /(?<!\w)(i-)?(\d{4}-\d{2}-\d{2}-\d{3,4})(?!\d)/g;
//
// The prefix alternation `(ctx-|i-)?` must be matched as part of the id
// so a prefixed id classifies correctly. Without it, the leading
// `(?<!\w)` still allows the boundary after the prefix hyphen, so a bare
// core regex would mis-read `ctx-2026-…` / `i-2026-…` as a *request* id
// (the digits after the hyphen). Capturing the prefix keeps the three
// id kinds disjoint. `ctx-` precedes `i-` in the alternation only for
// readability; they cannot both match the same span.
const ID_PATTERN = /(?<!\w)(ctx-|i-)?(\d{4}-\d{2}-\d{2}-\d{3,4})(?!\d)/g;

export interface ExtractedReferences {
readonly requestIds: ReadonlyArray<string>;
readonly issueIds: ReadonlyArray<string>;
readonly ctxIds: ReadonlyArray<string>;
}

/**
* 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<string>();
const issueIds = new Set<string>();
const ctxIds = new Set<string>();
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
Expand All @@ -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);
Expand All @@ -59,5 +77,5 @@ export function extractReferences(text: string): ExtractedReferences {
}
}
}
return { requestIds: requestOrder, issueIds: issueOrder };
return { requestIds: requestOrder, issueIds: issueOrder, ctxIds: ctxOrder };
}
72 changes: 72 additions & 0 deletions src/passages/ctx/application/CtxUseCases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<CtxChain | null> {
const rootId = parseCtxId(id);
const all = await this.repo.listAll();
const byId = new Map<string, Ctx>(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
Expand Down
Loading
Loading