docs(#1672): add ADR-1671 dynamic context management platform + reference example (#1674)

Phase 0 of the Dynamic Context Management epic (#1671): the ADR (documentation) plus a non-shipping reference example under examples/dynamic-context-management/ — the Option-E predicate fact-store parser/selector, a self-contained --check/--write/--select index generator, a sample index, and a runnable demo.

The example is intentionally excluded from the build (src/->bin/lib/), the npm package files[], the installer, and the CI test suite (tests/) — nothing here is compiled into or installed with GSD. Production implementation lands in a later phase.

Resolves #1672

Refs #1671

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-24 19:48:51 -04:00
committed by GitHub
parent dba1ead553
commit cc2e33b098
6 changed files with 2944 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
# Dynamic context management — Option-E reference example
Reference example for [ADR-1671](../../docs/adr/1671-dynamic-context-management-platform.md),
"Dynamic context management platform."
> **This is a non-shipping reference example.** It lives outside the build
> (`src/` → `bin/lib/`), the npm package `files[]`, the installer, and the CI
> test suite (`tests/`). Nothing here is compiled into or installed with GSD.
> The production implementation lands in a later phase of the
> [Dynamic Context Management epic (#1671)](https://github.com/open-gsd/gsd-core/issues/1671).
## What it demonstrates
The **predicate fact-store → JIT selector** slice of the platform: parse the
repo-root `CONTEXT.md` `CLASS.subkey=value` predicates into structured records,
drift-guard a generated index, and select the relevant predicate subset for a
task — the building block for just-in-time agent-brief assembly instead of
hand-citing a 200 KB file.
## Files
- `context-predicates.cjs` — parser + selector + deterministic index builder (self-contained).
- `gen-context-index.cjs` — `--check` / `--write` drift-guarded generator + `--select`.
- `CONTEXT-INDEX.json` — sample generated output (393 predicates, 18 classes).
- `demo.cjs` — runnable usage example.
## Run (from the repo root)
```sh
node examples/dynamic-context-management/demo.cjs
node examples/dynamic-context-management/gen-context-index.cjs --select PRED.k320
node examples/dynamic-context-management/gen-context-index.cjs --check
```
## Validation
During research this slice was validated with 42 behavioral tests — predicate
forms, fenced-code / prose skipping, duplicate-id detection, the selector, a
deterministic index, and a fast-check property test. Those return as CI tests
under `tests/` when the production implementation lands.
It also surfaced 3 latent duplicate predicate IDs in `CONTEXT.md`
(`RULESET.WORKFLOW_MARKDOWN.FENCES`, `RULESET.GEMINI.TOOLS.ask_user`,
`RULESET.GEMINI.TEST_SENTINEL`), recorded in the index `duplicates` field.

View File

@@ -0,0 +1,248 @@
'use strict';
/**
* context-predicates.cjs — CONTEXT.md predicate fact-store parser.
*
* Self-contained CommonJS module (no dependency on build:lib output).
*
* Exports:
* parsePredicates(markdown) -> { predicates, duplicates, skippedSections }
* selectPredicates(predicates, { klass, prefix, contains }) -> filtered array
* buildIndex(predicates) -> deterministic plain object
*
* Grammar (from discovery facts):
* Two line forms, each on exactly one source line:
* 1. Bare backtick-wrapped: `ID=value`
* 2. List-item backtick: - `ID=value`
*
* ID grammar: CLASS(.subkey)* where CLASS = first dot-separated segment.
* ID chars: [A-Za-z0-9._-] (CLASS always uppercase; subkeys may be mixed).
* Split on FIRST '=' only; everything before is the ID, everything after is
* the value (up to the closing backtick).
*
* Skip:
* - Fenced code blocks (toggle on triple-backtick lines)
* - Prose lines (headings, blank lines, list items without a predicate)
* - The "PR fix discipline" section (pure prose, no predicates)
* - Session-log blockquote preamble
*/
// Regex matching the predicate ID grammar: one or more dot-separated segments.
// First segment must start with an uppercase letter (CLASS).
// Subsequent segments may start with letter/digit and include hyphens/underscores.
// We intentionally allow lowercase-starting sub-segments (e.g. PRED.k320.rule).
const ID_RE = /^([A-Z][A-Z0-9_-]*(?:\.[A-Za-z0-9_.-]+)*)=(.+)$/;
/**
* Parse a single source line and return a raw {id, value} if it is a predicate,
* or null otherwise. Handles both line forms after stripping list markers.
*
* @param {string} raw - the original source line (with newline stripped)
* @returns {{ id: string, value: string } | null}
*/
function extractPredicate(raw) {
const line = raw.trimEnd();
// Form 1: `ID=value` (starts with backtick at column 0)
// Form 2: - `ID=value` (list-item with leading "- ")
// Also tolerate " - `ID=value`" (indented list item — observed in CONTEXT.md).
let inner = null;
if (line.startsWith('`') && line.endsWith('`') && line.length > 2) {
// bare backtick line
inner = line.slice(1, -1);
} else {
// strip optional leading whitespace + "- " then check for backtick wrapping
const stripped = line.replace(/^\s*-\s+/, '');
if (stripped.startsWith('`') && stripped.endsWith('`') && stripped.length > 2) {
inner = stripped.slice(1, -1);
}
}
if (inner === null) return null;
// Now match the ID grammar. Split on FIRST '=' only.
const eqIdx = inner.indexOf('=');
if (eqIdx < 1) return null;
const id = inner.slice(0, eqIdx);
const value = inner.slice(eqIdx + 1);
// Validate ID — must match the grammar (no spaces, correct char set).
if (!ID_RE.test(inner)) return null;
return { id, value };
}
/**
* Parse all predicates from a CONTEXT.md markdown string.
*
* @param {string} markdown
* @returns {{
* predicates: Array<{ id: string, klass: string, value: string, line: number, section: string }>,
* duplicates: Array<{ id: string, lines: number[] }>,
* skippedSections: string[]
* }}
*/
function parsePredicates(markdown) {
const lines = markdown.split('\n');
const predicates = [];
// Track id -> list of line numbers for duplicate detection
const idLines = new Map(); // id -> number[]
let inFencedCode = false;
let currentSection = '';
const allSections = [];
const seenSections = new Set();
// Section names that are known pure-prose (0 predicates) — we still scan them
// but track them as skipped if nothing is found. The parser is tolerant; it
// simply won't find predicates in prose sections.
// We do NOT hard-skip any section except fenced code — the grammar says "scan
// for backtick predicates everywhere but skip fenced code".
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const lineNo = i + 1; // 1-based
// Track fenced code blocks (triple-backtick toggle).
// A fenced-code fence starts with ``` possibly followed by a language token.
// We use a simple heuristic: a line trimmed to /^```/ triggers the toggle.
const trimmed = raw.trimStart();
if (trimmed.startsWith('```')) {
inFencedCode = !inFencedCode;
continue;
}
if (inFencedCode) continue;
// Track section headings for the section field.
if (raw.startsWith('#')) {
currentSection = raw.replace(/^#+\s*/, '').trim();
if (currentSection && !seenSections.has(currentSection)) {
seenSections.add(currentSection);
allSections.push(currentSection);
}
continue;
}
// Blockquote lines (start with ">") are prose — skip.
if (trimmed.startsWith('>')) continue;
// Attempt extraction.
const pred = extractPredicate(raw);
if (!pred) continue;
const klass = pred.id.split('.')[0];
predicates.push({
id: pred.id,
klass,
value: pred.value,
line: lineNo,
section: currentSection,
});
const existing = idLines.get(pred.id);
if (existing) {
existing.push(lineNo);
} else {
idLines.set(pred.id, [lineNo]);
}
}
// Build duplicates list: ids with >1 occurrence.
const duplicates = [];
for (const [id, lns] of idLines) {
if (lns.length > 1) {
duplicates.push({ id, lines: lns });
}
}
// Sort duplicates by id for determinism.
duplicates.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
// Skipped sections: headings that yielded zero predicates (pure prose).
const activeSections = new Set(predicates.map((p) => p.section));
const skippedSections = allSections.filter((s) => !activeSections.has(s));
return { predicates, duplicates, skippedSections };
}
/**
* Select predicates by one or more optional criteria (ANDed together).
*
* @param {Array<{ id: string, klass: string, value: string, line: number, section: string }>} predicates
* @param {{ klass?: string, prefix?: string, contains?: string }} opts
* @returns {Array<{ id: string, klass: string, value: string, line: number, section: string }>}
*/
function selectPredicates(predicates, opts = {}) {
const { klass, prefix, contains } = opts;
const containsLower = contains ? contains.toLowerCase() : null;
return predicates.filter((p) => {
if (klass !== undefined && p.klass !== klass) return false;
if (prefix !== undefined && !p.id.startsWith(prefix)) return false;
if (containsLower !== null) {
const haystack = (p.id + ' ' + p.value).toLowerCase();
if (!haystack.includes(containsLower)) return false;
}
return true;
});
}
/**
* Build a deterministic index object from a parsed predicates array.
*
* @param {Array<{ id: string, klass: string, value: string, line: number }>} predicates
* @returns {{
* schemaVersion: 1,
* count: number,
* classes: Record<string, number>,
* predicates: Array<{ id: string, klass: string, value: string, line: number }>,
* duplicates: Array<{ id: string, lines: number[] }>
* }}
*/
function buildIndex(predicates) {
// Count per class.
const classCounts = {};
for (const p of predicates) {
classCounts[p.klass] = (classCounts[p.klass] || 0) + 1;
}
// Sort classes object by key for determinism.
const classes = {};
for (const k of Object.keys(classCounts).sort()) {
classes[k] = classCounts[k];
}
// Sort predicates by id then by line number.
const sortedPredicates = predicates
.map(({ id, klass, value, line }) => ({ id, klass, value, line }))
.sort((a, b) => {
if (a.id < b.id) return -1;
if (a.id > b.id) return 1;
return a.line - b.line;
});
// Rebuild duplicates from sorted predicates for determinism.
const idToLines = new Map();
for (const p of sortedPredicates) {
const arr = idToLines.get(p.id);
if (arr) arr.push(p.line);
else idToLines.set(p.id, [p.line]);
}
const duplicates = [];
for (const [id, lines] of idToLines) {
if (lines.length > 1) duplicates.push({ id, lines });
}
duplicates.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
return {
schemaVersion: 1,
count: predicates.length,
classes,
predicates: sortedPredicates,
duplicates,
};
}
module.exports = { parsePredicates, selectPredicates, buildIndex };

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env node
'use strict';
/**
* Runnable usage example for the Option-E predicate fact-store (ADR-1671).
* Reference example only — not shipped, not part of the CI suite.
*
* node examples/dynamic-context-management/demo.cjs
*/
const fs = require('node:fs');
const path = require('node:path');
const { parsePredicates, selectPredicates } = require('./context-predicates.cjs');
const md = fs.readFileSync(path.resolve(__dirname, '..', '..', 'CONTEXT.md'), 'utf8');
const { predicates, duplicates } = parsePredicates(md);
const classes = new Set(predicates.map((p) => p.klass));
process.stdout.write(
`Parsed ${predicates.length} predicates across ${classes.size} classes; ` +
`${duplicates.length} duplicate id(s).\n`,
);
const slice = selectPredicates(predicates, { prefix: 'PRED.k320' });
process.stdout.write(
`\nselectPredicates({ prefix: 'PRED.k320' }) -> ${slice.length} matches ` +
`(a JIT brief slice):\n`,
);
for (const p of slice) process.stdout.write(` ${p.id} = ${p.value}\n`);

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env node
'use strict';
/**
* Reference example (NOT shipped, NOT compiled, NOT installed) for ADR-1671,
* "Dynamic context management platform" — the Option-E predicate fact-store.
*
* Builds a deterministic, drift-guarded index of every predicate fact in the
* repo-root CONTEXT.md, and demonstrates a JIT "task -> relevant predicates"
* selector. Self-contained: depends only on the sibling context-predicates.cjs.
*
* Usage (run from the repo root):
* node examples/dynamic-context-management/gen-context-index.cjs # print index to stdout
* node examples/dynamic-context-management/gen-context-index.cjs --write # write CONTEXT-INDEX.json (next to this file)
* node examples/dynamic-context-management/gen-context-index.cjs --check # exit 1 if the committed sample is stale
* node examples/dynamic-context-management/gen-context-index.cjs --select <query>
*
* --select <query> tries, in order: exact class ("PRED"), dotted prefix
* ("PRED.k320"), then free-text contains — the first non-empty match wins.
*/
const fs = require('node:fs');
const path = require('node:path');
const { parsePredicates, selectPredicates, buildIndex } = require('./context-predicates.cjs');
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const CONTEXT_PATH = path.join(REPO_ROOT, 'CONTEXT.md');
const INDEX_PATH = path.join(__dirname, 'CONTEXT-INDEX.json');
function buildFreshIndex() {
const markdown = fs.readFileSync(CONTEXT_PATH, 'utf8');
const { predicates } = parsePredicates(markdown);
return buildIndex(predicates);
}
function main(args) {
const flag = args[0];
if (flag === '--check') {
const committed = JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8'));
const live = buildFreshIndex();
if (JSON.stringify(committed, null, 2) !== JSON.stringify(live, null, 2)) {
process.stderr.write(
'CONTEXT-INDEX.json is stale. Run:\n' +
' node examples/dynamic-context-management/gen-context-index.cjs --write\n',
);
return 1;
}
process.stdout.write('CONTEXT-INDEX.json is up to date.\n');
return 0;
}
if (flag === '--write') {
const index = buildFreshIndex();
fs.writeFileSync(INDEX_PATH, JSON.stringify(index, null, 2) + '\n');
const dupNote = index.duplicates.length > 0
? ` (${index.duplicates.length} duplicate id${index.duplicates.length !== 1 ? 's' : ''})`
: '';
process.stdout.write(
`Wrote ${path.relative(REPO_ROOT, INDEX_PATH)}\n` +
` ${index.count} predicates, ${Object.keys(index.classes).length} classes${dupNote}\n`,
);
return 0;
}
if (flag === '--select') {
const query = args[1];
if (!query) {
process.stderr.write('Usage: gen-context-index.cjs --select <query>\n');
return 1;
}
const { predicates } = parsePredicates(fs.readFileSync(CONTEXT_PATH, 'utf8'));
let results = selectPredicates(predicates, { klass: query });
if (results.length === 0) results = selectPredicates(predicates, { prefix: query });
if (results.length === 0) results = selectPredicates(predicates, { contains: query });
if (results.length === 0) {
process.stdout.write(`No predicates matched: ${query}\n`);
return 0;
}
for (const p of results) process.stdout.write(`${p.id} = ${p.value}\n`);
process.stdout.write(`\n(${results.length} predicate${results.length !== 1 ? 's' : ''} matched)\n`);
return 0;
}
process.stdout.write(JSON.stringify(buildFreshIndex(), null, 2) + '\n');
return 0;
}
process.exitCode = main(process.argv.slice(2));