From af7c8117ec77fa0ed3d5a5fdf3dfb156d78f7037 Mon Sep 17 00:00:00 2001 From: Emanuel Friedrich Date: Wed, 30 Sep 2026 08:44:08 -0300 Subject: [PATCH 1/2] promote --topic declares a current document in the catalog (DR-020, draft); validator warns on an unanchored current spec; 0.8.1 Promoting a spec, flow, model or contract set its status but never put it under `current:` in the domain's catalog entry, so the domain's CONTEXT.md did not list it. `promote --by --topic ` now writes that anchor in the same operation, refuses a name held by another document, and can be re-run on a document that is already current. `--topic` on an RFC or task is refused instead of ignored. The validator warns, without failing, when a current or implemented spec is declared in no domain. DR-020 is an agent draft: the human promotes it. --- ADOPTING.md | 4 +- HANDBOOK.md | 2 +- README.md | 2 +- install.sh | 4 +- knowledge/methodology/CONTEXT.md | 3 +- ...he-catalog-anchor-of-a-current-document.md | 45 +++++++++++ .../KDE-SPEC-001-artifacts-and-metadata.md | 4 +- package.json | 2 +- tests/knowledge.test.ts | 81 +++++++++++++++++++ tools/knowledge-check.mts | 16 ++++ tools/knowledge.mts | 67 +++++++++++++-- 11 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 knowledge/methodology/decisions/DR-020-promote-topic-names-the-catalog-anchor-of-a-current-document.md diff --git a/ADOPTING.md b/ADOPTING.md index 41a4006..850d50c 100644 --- a/ADOPTING.md +++ b/ADOPTING.md @@ -23,7 +23,7 @@ What you do **not** copy: HANDBOOK.md, the `methodology` domain, `examples/`, `t ## One-Command Install ```bash -curl -fsSL https://raw.githubusercontent.com/emafriedrich/knowledge-driven-engineering/v0.8.0/install.sh | bash -s -- +curl -fsSL https://raw.githubusercontent.com/emafriedrich/knowledge-driven-engineering/v0.8.1/install.sh | bash -s -- ``` `install.sh` performs the manual steps below, is idempotent, and never overwrites an existing file (it skips and tells you). It detects your package manager (npm, pnpm — workspaces included, yarn, bun) for the `yaml` dependency, and a dependency failure warns instead of aborting the install. Run it from the root of your repository; pass your first domain name as the argument. It refuses to run in a subdirectory of a repository that already runs Knowledge-Driven Engineering — a second catalog nothing reads — and names the directory to run it from. The argument only seeds a fresh install: on a repository that already has `knowledge/index.yaml` the installer creates no domain and points you to `npm run knowledge -- domain add `. Installing, upgrading (`--upgrade`) and adding a domain are three separate actions. Offline installs work from a local clone: `KDE_SOURCE=/path/to/clone bash install.sh `. The script installs the release it belongs to (the tag in its own URL); to install another release, a branch or a commit, set `KDE_VERSION=main` (or `KDE_VERSION=v0.7.2`) in front of the command. @@ -38,7 +38,7 @@ The installer classifies what it writes by owner (DR-011): Every run compares the installed markers with the fetched version and warns when a framework-owned file differs — whether from a newer release upstream or a local edit. To refresh them: ```bash -curl -fsSL https://raw.githubusercontent.com/emafriedrich/knowledge-driven-engineering/v0.8.0/install.sh | bash -s -- --upgrade +curl -fsSL https://raw.githubusercontent.com/emafriedrich/knowledge-driven-engineering/v0.8.1/install.sh | bash -s -- --upgrade ``` `--upgrade` replaces framework-owned files that differ and reports each one (`upgrade tools/knowledge-check.mts (0.1.0 -> 0.2.0)`). A framework-owned file you edited locally is overwritten too, with an explicit `WARN` line naming it. Framework-owned files are not an extension point: if you need different validation behaviour, fork this repository and install from your fork; if the change would help everyone, contributions are welcome. Adopter-owned files are not touched by `--upgrade`. Templates are never overwritten; the installer prints a `note` line for each one that differs from the release, with the upstream URL, so you can decide whether to adopt the newer shape. Without branch protection and CODEOWNERS, human-only promotion is a procedural guarantee: the validator sees that an approver is recorded, not who typed it. New template files that a release adds (as `templates/model.md` and `templates/contract.md` were) arrive on a plain run, because the installer adds any file that does not exist yet. diff --git a/HANDBOOK.md b/HANDBOOK.md index 11a6cc9..b5753e0 100644 --- a/HANDBOOK.md +++ b/HANDBOOK.md @@ -220,7 +220,7 @@ Operational learning can update a spec, add a playbook, or trigger a new RFC. Us Every transition above is one command (DR-013); each ends by running the validator and refreshing the domain manifests, and the transitions roll back if they would leave an error behind. - `npm run knowledge -- new "" [--by agent] [--author <name>]` — copy the template, allocate the next id in the domain's own prefix, fill the metadata. -- `npm run knowledge -- promote <ID> --by <human> [--topic <name>]` — set the active status, record the approver, index a decision under its topic. Refused, with the validator's reason, when the document is not ready. +- `npm run knowledge -- promote <ID> --by <human> [--topic <name>]` — set the active status, record the approver, and name where the document stands: a decision is indexed under its topic, and any document that becomes `current` is declared under that name in its domain's `current:` so `CONTEXT.md` lists it. Without `--topic` a current document is promoted but not declared, and the command says so. Refused, with the validator's reason, when the document is not ready. - `npm run knowledge -- supersede <OLD-ID> --by <NEW-ID> [--approved-by <human>]` — link both records, re-point or retire the topic, promote a draft replacement, list the documents that depend on the old one. - `npm run knowledge -- domain add <name> --description "<text>" [--code-paths a/ b/]` — directory, README, empty decision index, catalog entry, manifest. - `npm run knowledge -- backfill <domain>` — start or resume a brownfield backfill session (DR-018): scaffold the session file and the draft baseline decision, or report what is approved, rejected, and still pending. diff --git a/README.md b/README.md index b8f17d5..21f8c8a 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Requires Node 22.6+ (only for the tools; your project can be any stack). **Existing codebase** — from the repo root: ```bash -curl -fsSL https://raw.githubusercontent.com/emafriedrich/knowledge-driven-engineering/v0.8.0/install.sh | bash -s -- orders +curl -fsSL https://raw.githubusercontent.com/emafriedrich/knowledge-driven-engineering/v0.8.1/install.sh | bash -s -- orders npm run knowledge -- backfill orders ``` diff --git a/install.sh b/install.sh index 39ea808..402fd5c 100755 --- a/install.sh +++ b/install.sh @@ -2,7 +2,7 @@ # Installs Knowledge-Driven Engineering scaffolding into the current repository. # # Usage, from the root of your repository: -# curl -fsSL https://raw.githubusercontent.com/emafriedrich/knowledge-driven-engineering/v0.8.0/install.sh | bash -s -- <first-domain> +# curl -fsSL https://raw.githubusercontent.com/emafriedrich/knowledge-driven-engineering/v0.8.1/install.sh | bash -s -- <first-domain> # # From a local clone (offline / development): # KDE_SOURCE=/path/to/knowledge-driven-engineering bash install.sh <first-domain> @@ -37,7 +37,7 @@ done REPO_URL="https://github.com/emafriedrich/knowledge-driven-engineering" # Release this copy of the installer belongs to; bumped with package.json on # every release so a curl of the script installs the matching tools. -DEFAULT_REF="v0.8.0" +DEFAULT_REF="v0.8.1" REF="${KDE_VERSION:-${KDE_REF:-$DEFAULT_REF}}" say() { printf '%s\n' "$*"; } diff --git a/knowledge/methodology/CONTEXT.md b/knowledge/methodology/CONTEXT.md index fc40134..b715b34 100644 --- a/knowledge/methodology/CONTEXT.md +++ b/knowledge/methodology/CONTEXT.md @@ -13,7 +13,7 @@ Knowledge-Driven Engineering methodology rules, examples, and agent guidance. | --- | --- | --- | --- | | product_vision | KDE-PV-001 | Knowledge-Driven Engineering product vision | 2026-08-30 | | lifecycle_flow | KDE-FLOW-001 | Knowledge evolution lifecycle | 2026-08-30 | -| artifact_spec | KDE-SPEC-001 | Knowledge artifact and metadata rules | 2026-09-21 | +| artifact_spec | KDE-SPEC-001 | Knowledge artifact and metadata rules | 2026-09-30 | | agent_context | KDE-PROMPT-001 | Coding agent context template | 2026-09-30 | | signals_playbook | KDE-PLAYBOOK-001 | Drafting knowledge from external signals | 2026-09-09 | @@ -42,6 +42,7 @@ Knowledge-Driven Engineering methodology rules, examples, and agent guidance. ## Pending — NOT current truth, do not obey +- `DR-020` (draft, agent-drafted): promote --topic names the catalog anchor of a current document; the validator warns on an unanchored current spec - `KDE-RFC-010` (draft, agent-drafted): Distribute framework tooling as an npm package - `KDE-RFC-015` (draft, agent-drafted): Re-anchor agents in-session: the hook returns the domain manifest on code edits diff --git a/knowledge/methodology/decisions/DR-020-promote-topic-names-the-catalog-anchor-of-a-current-document.md b/knowledge/methodology/decisions/DR-020-promote-topic-names-the-catalog-anchor-of-a-current-document.md new file mode 100644 index 0000000..0ce94c3 --- /dev/null +++ b/knowledge/methodology/decisions/DR-020-promote-topic-names-the-catalog-anchor-of-a-current-document.md @@ -0,0 +1,45 @@ +--- +id: DR-020 +title: "promote --topic names the catalog anchor of a current document; the validator warns on an unanchored current spec" +status: draft +created: 2026-09-30 +updated: 2026-09-30 +drafted_by: agent +approved_by: [] +scope: [methodology] +tags: [decision] +depends_on: [] +related: [DR-007, DR-009, DR-013, DR-019, KDE-SPEC-001] +supersedes: [] +superseded_by: [] +--- + +# DR-020: promote --topic names the catalog anchor of a current document; the validator warns on an unanchored current spec + +## Context + +A domain's `CONTEXT.md` lists its current truth from the `current:` map of the domain entry in `knowledge/index.yaml` (DR-009, KDE-SPEC-001). Promoting a spec, flow, model or any other document to `current` only changed the document's own `status`, so nothing put it in that map. The marketplace example shipped two `status: current` specs while its manifest read "Current truth: None yet", and an adopter promoting a spec today hits the same gap: the document says it is current, the manifest an agent reads does not. Deriving the anchors from status instead was rejected in DR-009, because a domain can hold two current specs for one role and the catalog is where a human says which one stands. + +Decision indexes already solve the same problem for Decision Records: `promote --topic <name>` writes the topic in the same operation as the status change. + +## Decision + +1. **`promote <ID> --by <human> --topic <name>` declares the document.** For every type whose promoted status is `current` (spec, flow, ia, design-system, model, contract, playbook, prompt), `--topic` writes `<name>: <ID>` under `current:` in the domain's entry of its catalog, in the same operation that sets the status, and the domain manifest is regenerated. The name is the place the document occupies in the domain, in one or two words (`availability`, `product_vision`). It is the same concept as a decision topic and uses the same flag. +2. **A name held by a different document is refused.** The error names the current holder. Replacing an anchor is done by hand until supersession covers documents other than Decision Records. +3. **Without `--topic`, promotion still succeeds** and says the document is not declared in the catalog, so `CONTEXT.md` will not list it. Some current documents are deliberately not anchors. +4. **Re-running with `--topic` on a document that is already current anchors it** without touching the document, the way a promoted Decision Record missing from its index is indexed. This is how an existing project repairs a spec it promoted earlier. +5. **`--topic` on a type without a current status is refused** (RFC, task), instead of being silently ignored as before. +6. **The validator warns, never fails,** when a `current` spec is not listed under `current:` of its domain. Only specs are checked: the precedence rule names the current spec, and warning on every playbook or flow would bury the signal. + +Anchoring changes what agents treat as truth, so it stays a human act under `--by` (DR-007). + +## Consequences + +- Promoting a spec with `--topic` leaves the catalog, the document and the manifest consistent in one command, and `knowledge:context --check` passes. +- A project upgrading the tools may see new warnings for current specs it never anchored; `promote <ID> --by <human> --topic <name>` clears each one. +- Affected artifacts: `tools/knowledge.mts`, `tools/knowledge-check.mts`, KDE-SPEC-001 (Domain Catalog Fields), HANDBOOK. +- Not covered: supersession between documents other than Decision Records, and shorter default topics for Decision Records. Both stay separate questions. + +## Supersession + +None. diff --git a/knowledge/methodology/specs/KDE-SPEC-001-artifacts-and-metadata.md b/knowledge/methodology/specs/KDE-SPEC-001-artifacts-and-metadata.md index 32f8795..37b3f67 100644 --- a/knowledge/methodology/specs/KDE-SPEC-001-artifacts-and-metadata.md +++ b/knowledge/methodology/specs/KDE-SPEC-001-artifacts-and-metadata.md @@ -3,7 +3,7 @@ id: KDE-SPEC-001 title: Knowledge artifact and metadata rules status: current created: 2026-08-30 -updated: 2026-09-21 +updated: 2026-09-30 authors: [engineering] scope: [methodology] tags: [spec, metadata, artifacts] @@ -78,6 +78,8 @@ Gates on status (DR-007, DR-010): an agent-drafted document cannot hold `accepte A domain entry in `knowledge/index.yaml` may declare `code_paths`: plain repository path prefixes of the implementation the domain governs (DR-008). Consumers use prefix matching; V1 has no glob support. `code_paths` feed the `knowledge:context` command and the CI drift gate. +A domain entry may declare `current`: a map from a short name (the role the document plays, such as `availability`) to the id of the `current` document that stands for it (DR-009). `promote <ID> --by <human> --topic <name>` writes that entry when it promotes a spec, flow, model, contract or other current-status document, refuses a name held by a different document, and can be re-run on a document that is already current to declare it (DR-020). The validator warns when a `current` or `implemented` spec is not declared in any domain's `current`, because its domain's `CONTEXT.md` would not list it. + A domain entry may declare `trackers`: a map from tracker system to the key that holds the domain's tasks (`trackers: { jira: PD }`), so an agent can resolve "the tracker for `orders`" from the catalog instead of from a prompt (DR-014). When a domain's specs are all drafts, the drift gate requires the pull request to declare `implements-draft: <SPEC-ID>` (or `no-behavior-change`) — implementing against a draft is allowed and visible, and the spec still needs promotion (DR-015). The declaration also satisfies the gate for a domain that has a current spec. diff --git a/package.json b/package.json index 51acd86..2cd67d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "knowledge-driven-engineering", - "version": "0.8.0", + "version": "0.8.1", "private": true, "type": "module", "scripts": { diff --git a/tests/knowledge.test.ts b/tests/knowledge.test.ts index 42bc853..18f174d 100644 --- a/tests/knowledge.test.ts +++ b/tests/knowledge.test.ts @@ -152,6 +152,87 @@ test('promote: an accepted Decision Record missing from the index is indexed, ho }); }); +// A spec needs a promoted decision to depend on before `promote` accepts it. +function anchoredSpec(root: string, title: string): string { + if (!existsSync(join(root, 'knowledge/test/decisions'))) { + commandNew(root, 'decision', 'test', 'Anchor'); + commandPromote(root, 'DR-001', { by: 'ema' }); + } + commandNew(root, 'spec', 'test', title, { author: 'ema' }); + const dir = join(root, 'knowledge/test/specs'); + const file = join(dir, readdirSync(dir).filter((name) => name.includes(title.toLowerCase().replace(/ /g, '-')))[0]); + writeFileSync(file, readFileSync(file, 'utf8').replace('depends_on: []', 'depends_on: [DR-001]')); + return file; +} + +test('promote --topic on a spec declares it under current: in the catalog, keeping comments, and the manifest lists it (DR-020)', () => { + withFixture({ 'knowledge/index.yaml': `# Catalog, keep me.\n${CATALOG} current: {}\n` }, (root) => { + const spec = anchoredSpec(root, 'Checkout behavior'); + const result = commandPromote(root, 'SPEC-001', { by: 'ema', topic: 'checkout' }); + assert.equal(front(spec).status, 'current'); + const catalog = readFileSync(join(root, 'knowledge/index.yaml'), 'utf8'); + assert.match(catalog, /^# Catalog, keep me\./); + assert.deepEqual((parseYaml(catalog) as { domains: { test: { current: unknown } } }).domains.test.current, { checkout: 'SPEC-001' }); + assert.match(result.notes.join('\n'), /catalog anchor: test\.current\.checkout/); + assert.ok(result.changed.includes('knowledge/index.yaml')); + assert.match(readFileSync(join(root, 'knowledge/test/CONTEXT.md'), 'utf8'), /checkout[^\n]*SPEC-001/); + const checked = checkKnowledge(root); + assert.deepEqual(checked.errors, []); + assert.deepEqual(checked.warnings.filter((warning) => /declares under current/.test(warning)), []); + }); +}); + +test('promote without --topic still promotes a spec but says it is not declared; re-running with --topic anchors it without touching the spec', () => { + withFixture({}, (root) => { + const spec = anchoredSpec(root, 'Checkout behavior'); + const plain = commandPromote(root, 'SPEC-001', { by: 'ema' }); + assert.match(plain.notes.join('\n'), /not declared in the test catalog entry[\s\S]*--topic <name>/); + assert.match(checkKnowledge(root).warnings.join('\n'), /\(SPEC-001\) is a current spec that no domain declares under current:/); + const before = readFileSync(spec, 'utf8'); + + const anchored = commandPromote(root, 'SPEC-001', { by: 'someone-else', topic: 'checkout' }); + assert.match(anchored.notes.join('\n'), /already current; only the catalog anchor was added/); + assert.equal(readFileSync(spec, 'utf8'), before, 'the spec is untouched'); + assert.match(readFileSync(join(root, 'knowledge/index.yaml'), 'utf8'), /current:\n {6}checkout: SPEC-001\n/); + assert.deepEqual(checkKnowledge(root).warnings.filter((warning) => /declares under current/.test(warning)), []); + + const again = commandPromote(root, 'SPEC-001', { by: 'ema', topic: 'checkout' }); + assert.deepEqual(again.changed, []); + assert.match(again.notes.join('\n'), /already current and anchored as checkout/); + }); +}); + +test('promote --topic refuses a role held by another document, a bad name, and types with no current status, and writes nothing', () => { + withFixture({}, (root) => { + anchoredSpec(root, 'Checkout behavior'); + const second = anchoredSpec(root, 'Refund behavior'); + commandPromote(root, 'SPEC-001', { by: 'ema', topic: 'orders' }); + const catalog = readFileSync(join(root, 'knowledge/index.yaml'), 'utf8'); + + assert.throws(() => commandPromote(root, 'SPEC-002', { by: 'ema', topic: 'orders' }), /test already anchors orders to SPEC-001 — pick another --topic/); + assert.equal(front(second).status, 'draft', 'the spec is left as it was'); + assert.equal(readFileSync(join(root, 'knowledge/index.yaml'), 'utf8'), catalog, 'the catalog is left as it was'); + assert.throws(() => commandPromote(root, 'SPEC-002', { by: 'ema', topic: 'two words' }), /--topic two words must be letters, digits, dashes and underscores/); + + commandNew(root, 'rfc', 'test', 'Some question', { by: 'agent' }); + assert.throws(() => commandPromote(root, 'RFC-001', { by: 'ema', topic: 'x' }), /--topic names a decision topic or the catalog anchor of a current document; rfc documents have neither/); + }); +}); + +test('promote --topic on an implemented spec anchors it and keeps it implemented', () => { + withFixture({}, (root) => { + const spec = anchoredSpec(root, 'Checkout behavior'); + writeFileSync(spec, readFileSync(spec, 'utf8').replace(/```acceptance[\s\S]*?```/, '```acceptance\ntest -f knowledge/index.yaml\n```')); + commandPromote(root, 'SPEC-001', { by: 'ema', to: 'implemented' }); + assert.equal(front(spec).status, 'implemented'); + + commandPromote(root, 'SPEC-001', { by: 'ema', topic: 'checkout' }); + assert.equal(front(spec).status, 'implemented'); + assert.match(readFileSync(join(root, 'knowledge/index.yaml'), 'utf8'), /checkout: SPEC-001/); + assert.deepEqual(checkKnowledge(root).errors, []); + }); +}); + test('promote and supersede edit a decision index written in multi-line flow style, keeping its comments', () => { withFixture({}, (root) => { const indexFile = join(root, 'knowledge/test/decisions/index.yaml'); diff --git a/tools/knowledge-check.mts b/tools/knowledge-check.mts index 583c58a..1f652b2 100644 --- a/tools/knowledge-check.mts +++ b/tools/knowledge-check.mts @@ -580,6 +580,22 @@ export function checkKnowledge(root = process.cwd()): CheckResult { } } + // A current spec no domain declares under `current:` is missing from the domain's + // CONTEXT.md, the retrieval path agents read first (DR-020). Other current documents + // may be left out on purpose, so only specs are checked, and only as a warning. + const anchoredIds = new Set(catalogs.flatMap((catalog) => [...catalog.domains.values()].flatMap((domain) => Object.values(domain.current)))); + for (const document of documents) { + const id = String(document.data.id ?? ''); + const status = String(document.data.status); + if (!id || documentType(document.data) !== 'spec' || (status !== 'current' && status !== 'implemented')) continue; + const catalog = catalogs + .filter((candidate) => document.file.startsWith(candidate.rootDir + '/')) + .sort((a, b) => b.rootDir.length - a.rootDir.length)[0]; + if (catalog && !anchoredIds.has(id)) { + warnings.push(`${relative(root, document.file)} (${id}) is a ${status} spec that no domain declares under current: in ${relative(root, catalog.file)}; declare it: npm run knowledge -- promote ${id} --by <human> --topic <name>`); + } + } + return { errors, warnings, documents }; } diff --git a/tools/knowledge.mts b/tools/knowledge.mts index aa4f94f..7ab9f26 100644 --- a/tools/knowledge.mts +++ b/tools/knowledge.mts @@ -135,11 +135,11 @@ function findDomain(root: string, name: string): { catalog: Catalog; domain: Cat throw new CommandError(`domain ${name} is not in any catalog — create it: npm run knowledge -- domain add ${name} --description "<one line>"`); } -function domainOfFile(root: string, file: string): { name: string; domain: CatalogDomain } | null { +function domainOfFile(root: string, file: string): { name: string; domain: CatalogDomain; catalogFile: string } | null { const rel = relative(root, file).replace(/\\/g, '/'); for (const catalog of loadCatalogs(root)) { for (const [name, domain] of catalog.domains) { - if (domain.path && rel.startsWith(domain.path.replace(/\/$/, '') + '/')) return { name, domain }; + if (domain.path && rel.startsWith(domain.path.replace(/\/$/, '') + '/')) return { name, domain, catalogFile: catalog.file }; } } return null; @@ -313,6 +313,11 @@ export function commandPromote(root: string, id: string, options: { by?: string; if (!type || !spec || !spec.active) { throw new CommandError(`${id} is a ${type ?? 'untyped'} document; promote handles ${Object.entries(TYPES).filter(([, entry]) => entry.active).map(([name]) => name).join(', ')}`); } + // --topic names the decision-index topic of a Decision Record, or the catalog anchor of a document that becomes current (DR-020). + const anchorable = spec.active === 'current'; + if (options.topic !== undefined && type !== 'decision' && !anchorable) { + throw new CommandError(`--topic names a decision topic or the catalog anchor of a current document; ${type} documents have neither`); + } if (options.to !== undefined && options.to !== 'implemented') throw new CommandError(`--to accepts only implemented (the active status is the default)`); const target = options.to ?? spec.active; if (options.to === 'implemented' && type !== 'spec') { @@ -320,8 +325,10 @@ export function commandPromote(root: string, id: string, options: { by?: string; } const status = String(document.data.status); // An accepted Decision Record missing from its index still gets indexed below; nothing else is left to do. - const already = status === target; - if (already && type !== 'decision') return { changed: [], notes: [`${id} is already ${status}`] }; + const anchoring = anchorable && options.topic !== undefined; + // Anchoring an implemented spec must not walk its status back to current. + const already = status === target || (anchoring && type === 'spec' && status === 'implemented'); + if (already && type !== 'decision' && !anchoring) return { changed: [], notes: [`${id} is already ${status}`] }; const acceptance: string[] = []; if (options.to === 'implemented') { // `implemented` earns its meaning (DR-015): the spec's acceptance block must pass here and now. @@ -357,6 +364,26 @@ export function commandPromote(root: string, id: string, options: { by?: string; } } + let anchor: { catalogFile: string; domainName: string; role: string } | null = null; + let unanchoredNote: string | null = null; + if (anchorable) { + const owner = domainOfFile(root, document.file); + if (anchoring) { + const role = options.topic!; + if (!owner) throw new CommandError(`${relative(root, document.file)} is not inside a cataloged domain path`); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(role)) throw new CommandError(`--topic ${role} must be letters, digits, dashes and underscores (one or two words, like availability)`); + const holder = owner.domain.current[role]; + if (holder !== undefined && holder !== id) { + throw new CommandError(`${owner.name} already anchors ${role} to ${holder} — pick another --topic, or replace that anchor by hand (supersession between documents other than Decision Records is not automated, DR-020)`); + } + if (already && holder === id) return { changed: [], notes: [`${id} is already ${status} and anchored as ${role}`] }; + anchor = { catalogFile: owner.catalogFile, domainName: owner.name, role }; + touched.push(owner.catalogFile); + } else if (owner && !Object.values(owner.domain.current).includes(id)) { + unanchoredNote = `${id} is not declared in the ${owner.name} catalog entry, so CONTEXT.md will not list it as current truth; to declare it: npm run knowledge -- promote ${id} --by ${options.by} --topic <name>`; + } + } + const before = checkKnowledge(root).errors; const saved = snapshot(touched); @@ -380,6 +407,12 @@ export function commandPromote(root: string, id: string, options: { by?: string; } } + if (anchor) { + if (upsertCatalogAnchor(anchor.catalogFile, anchor.domainName, anchor.role, id) === 'exists') { + notes.push(`catalog already anchors ${anchor.role} to ${id}`); + } + } + const blocking = blockingErrors(root, before, checkKnowledge(root).errors, touched); if (blocking.length > 0) { restore(saved); @@ -387,8 +420,11 @@ export function commandPromote(root: string, id: string, options: { by?: string; } const changed = [...touched.map((file) => relative(root, file)), ...writeManifests(root)]; - if (already) notes.push(`${id} was already ${status} and missing from the decision index`); + if (already && type === 'decision') notes.push(`${id} was already ${status} and missing from the decision index`); + if (already && anchor) notes.push(`${id} was already ${status}; only the catalog anchor was added`); if (topic) notes.push(`decision index topic: ${topic}`); + if (anchor) notes.push(`catalog anchor: ${anchor.domainName}.current.${anchor.role}`); + if (unanchoredNote) notes.push(unanchoredNote); if (acceptance.length > 0) notes.push(`acceptance checks passed:`, ...acceptance); return { changed, notes }; } @@ -438,6 +474,27 @@ function upsertIndexTopic(indexFile: string, topic: string, id: string): 'added' return 'added'; } +// A document that becomes current is declared under `current:` of its domain entry in the +// catalog (DR-020). Same yaml document API as the decision index: comments and style survive. +function upsertCatalogAnchor(catalogFile: string, domainName: string, role: string, id: string): 'added' | 'exists' { + const doc = parseDocument(readFileSync(catalogFile, 'utf8')); + if (doc.errors.length > 0) throw new CommandError(`${catalogFile} is not valid YAML: ${doc.errors[0].message}`); + const entry = doc.getIn(['domains', domainName]); + if (!isMap(entry)) throw new CommandError(`${catalogFile}: domain ${domainName} must be a mapping`); + const current = entry.get('current'); + if (isMap(current)) { + if (current.get(role) === id) return 'exists'; + current.set(role, id); + current.flow = false; + } else if (current === undefined || current === null) { + entry.set('current', doc.createNode({ [role]: id })); + } else { + throw new CommandError(`${catalogFile}: current of domain ${domainName} must be a mapping of role to document id`); + } + writeFileSync(catalogFile, doc.toString({ lineWidth: 0 })); + return 'added'; +} + // --- supersede ------------------------------------------------------------------ export function commandSupersede(root: string, oldId: string, options: { by?: string; approvedBy?: string }): CommandResult { From 7f1980619e8a5d9d07a3d1556d7bb37ea5c87cc6 Mon Sep 17 00:00:00 2001 From: Emanuel Friedrich <aemanuelfriedrich@gmail.com> Date: Wed, 30 Sep 2026 09:05:31 -0300 Subject: [PATCH 2/2] KDE-RFC-016, 017, 018 (drafts): a mechanical trace for tickets, whether tasks earn their place, decisions across repositories --- knowledge/methodology/CONTEXT.md | 3 ++ ...ve-a-mechanical-trace-a-draft-must-cite.md | 52 ++++++++++++++++++ ...worth-keeping-when-field-use-never-wrot.md | 50 +++++++++++++++++ ...repositories-where-a-cross-service-rule.md | 53 +++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 knowledge/methodology/rfcs/KDE-RFC-016-external-signals-leave-a-mechanical-trace-a-draft-must-cite.md create mode 100644 knowledge/methodology/rfcs/KDE-RFC-017-is-the-task-artifact-worth-keeping-when-field-use-never-wrot.md create mode 100644 knowledge/methodology/rfcs/KDE-RFC-018-decisions-that-span-repositories-where-a-cross-service-rule.md diff --git a/knowledge/methodology/CONTEXT.md b/knowledge/methodology/CONTEXT.md index b715b34..fe2a2ed 100644 --- a/knowledge/methodology/CONTEXT.md +++ b/knowledge/methodology/CONTEXT.md @@ -45,6 +45,9 @@ Knowledge-Driven Engineering methodology rules, examples, and agent guidance. - `DR-020` (draft, agent-drafted): promote --topic names the catalog anchor of a current document; the validator warns on an unanchored current spec - `KDE-RFC-010` (draft, agent-drafted): Distribute framework tooling as an npm package - `KDE-RFC-015` (draft, agent-drafted): Re-anchor agents in-session: the hook returns the domain manifest on code edits +- `KDE-RFC-016` (draft, agent-drafted): External signals leave a mechanical trace: a draft must cite the ticket the work came from +- `KDE-RFC-017` (draft, agent-drafted): Is the task artifact worth keeping when field use never wrote one +- `KDE-RFC-018` (draft, agent-drafted): Decisions that span repositories: where a cross-service rule lives ## Rules diff --git a/knowledge/methodology/rfcs/KDE-RFC-016-external-signals-leave-a-mechanical-trace-a-draft-must-cite.md b/knowledge/methodology/rfcs/KDE-RFC-016-external-signals-leave-a-mechanical-trace-a-draft-must-cite.md new file mode 100644 index 0000000..dda3ed1 --- /dev/null +++ b/knowledge/methodology/rfcs/KDE-RFC-016-external-signals-leave-a-mechanical-trace-a-draft-must-cite.md @@ -0,0 +1,52 @@ +--- +id: KDE-RFC-016 +title: "External signals leave a mechanical trace: a draft must cite the ticket the work came from" +status: draft +created: 2026-09-30 +updated: 2026-09-30 +drafted_by: agent +approved_by: [] +motivated_by: Maintainer question (2026-09-30) — whether turning a ticket into a Decision Record or RFC, and catching a contradiction between the ticket and current truth, is deterministic; it is not, it rests on the agent following KDE-PLAYBOOK-001 and DR-019, and a missed ticket leaves no trace +scope: [methodology] +tags: [rfc, agents, integration] +depends_on: [] +related: [DR-008, DR-014, DR-019, KDE-PLAYBOOK-001, KDE-RFC-015] +--- + +# RFC: External signals leave a mechanical trace: a draft must cite the ticket the work came from + +## Summary + +When a pull request names a ticket from a tracker the domain declares, and it changes code under that domain's `code_paths`, the drift gate requires some knowledge document to cite that ticket, or the pull request to say why none is needed. The agent's judgment (which artifact, whether the ticket contradicts current truth) stays judgment; what becomes mechanical is that a ticket cannot drive code without leaving a trace in the knowledge. + +## Problem + +KDE-PLAYBOOK-001 tells an agent what to do with a ticket: read the domain first, quote rather than obey, and draft one Decision Record, RFC or task per cluster of signals. DR-019 tells it to draft an RFC and stop when the request is ambiguous. Both are prose. An agent that reads a ticket, sees nothing new in it, and goes straight to code leaves nothing behind: the decision someone took in a meeting and wrote in the ticket lives only in the tracker, and the code implements it without citing it. This is the most common way decisions taken outside the repository drift away from it. + +Two parts of the problem cannot be mechanized and this RFC does not try: deciding which artifact fits, and noticing that a ticket contradicts a current spec. Both need reading. What can be checked is the trace. + +## Proposal + +- **The trigger is the pull request, not the conversation.** The gate already reads the pull request body (DR-008, DR-015). If the body or the branch's commit messages contain a key of a tracker the catalog declares for a touched domain (`trackers: { jira: PD }` makes `PD-123` a key), the pull request names a ticket. +- **The obligation.** For each ticket named, some document in the domain must cite it, in `motivated_by` or `external_ref`, either already in the repository or added in the pull request. Otherwise the gate fails and names the ticket. +- **The escape, visible like the others.** `no-knowledge: PD-123 <reason>` in the body satisfies it for that ticket ("typo fix", "the rule is already DR-023"). It is printed in the CI log, the same as `no-behavior-change`. +- **No tracker API.** The gate never calls Jira; it reads text it already has. External systems stay signals (DR-014), and the check works offline. +- **Only for declared trackers.** A domain with no `trackers:` entry is untouched, so the check costs nothing to teams that don't use one. + +## Alternatives + +- **Status quo: the playbook alone.** Works when the agent follows it; leaves no trace when it doesn't, and nobody notices. +- **A hook that detects tracker links in the user's prompt.** Some harnesses offer a prompt hook that could see a pasted link and remind the agent of the playbook. Worth having as a nudge, but not every harness has one, and a conversation is not something the repository or CI can check later. +- **Require a Decision Record for every ticket.** Most tickets are work justified by existing truth; this would flood the repository with records that only restate the ticket. +- **Detect contradictions mechanically**, by comparing the ticket to the spec. Needs reading the ticket and judging meaning; not deterministic, and it would call the tracker. + +## Open Questions + +- **Where tickets are named.** Pull request body only, or commit messages and branch name too? Branch names like `PD-123-fix-search` are common and would catch more; they would also catch tickets the author never meant to cite. +- **Is an existing citation enough?** A ticket already cited by an old record satisfies the check even if the new code does something else. Accept it (the trace exists) or require the citing document to be touched in the pull request? +- **Does a draft count?** A draft RFC citing the ticket is a trace, and implementing against it is already governed by DR-015. Propose yes. +- **Trackers without keys.** Linear and Jira have keys; Notion or a Confluence page has a URL. Match declared URL prefixes too, or keys only for a first version? + +## Outcome + +<!-- Fill after review: accepted, rejected, or deferred, with links to resulting decisions/specs. --> diff --git a/knowledge/methodology/rfcs/KDE-RFC-017-is-the-task-artifact-worth-keeping-when-field-use-never-wrot.md b/knowledge/methodology/rfcs/KDE-RFC-017-is-the-task-artifact-worth-keeping-when-field-use-never-wrot.md new file mode 100644 index 0000000..6215b7a --- /dev/null +++ b/knowledge/methodology/rfcs/KDE-RFC-017-is-the-task-artifact-worth-keeping-when-field-use-never-wrot.md @@ -0,0 +1,50 @@ +--- +id: KDE-RFC-017 +title: Is the task artifact worth keeping when field use never wrote one +status: draft +created: 2026-09-30 +updated: 2026-09-30 +drafted_by: agent +approved_by: [] +motivated_by: Field use (2026-09-30) — the first production adoption has no task documents in any domain, and neither example ships one; the only task artifact in the repository is the template, while DR-014 already lets a tracker own task status +scope: [methodology] +tags: [rfc, lifecycle] +depends_on: [] +related: [DR-013, DR-014, DR-016, KDE-PLAYBOOK-001, KDE-RFC-015] +--- + +# RFC: Is the task artifact worth keeping when field use never wrote one + +## Summary + +Decide whether the task artifact stays a first-class type, becomes only a bridge to a tracker, or is repositioned as a plan an agent keeps across sessions. Field use never wrote one, and the method works without them. + +## Problem + +The artifact set has a task type (`templates/task.md`, `knowledge new task`, `knowledge done`, the `done` status, DR-013, DR-016). In field use no one created a task: the adopter works from Decision Records and specs, and work itself lives elsewhere. Neither example ships one. A type nobody uses still costs: documentation, a template, a status only it may hold, validator rules, and a question every new adopter asks ("do I write tasks too?") that the method answers with "only if you want to". + +The case for tasks is real but narrow. A team without a tracker has nowhere else to put work. And a spec large enough to take several sessions needs a plan that survives the end of a session, which is where fast drift happens (KDE-RFC-015). Neither case is served by today's framing, which presents tasks as the default place for implementation work. + +## Proposal + +No choice is made here; the options are laid out for review. + +- **A. Keep as is.** Tasks remain a first-class type. Document plainly that a team with a tracker does not need them. +- **B. Tracker bridge only.** A task document exists to link a ticket (`external_ref`) to the knowledge that justifies it, and nothing else. `knowledge new task` requires `external_ref`. Teams without a tracker put work in the tool they already use. +- **C. Session plan.** Reposition tasks as the plan an agent writes when a spec needs more than one session: bounded steps, each citing the spec rule it serves, closed with `knowledge done`. Retrieval (CONTEXT.md) lists open tasks so the next session resumes from the plan instead of from memory. +- **D. Remove the type.** Keep `external_ref` on Decision Records and specs for linking tickets, drop the template, the command and the `done` status, with a migration note for anyone who wrote tasks. + +## Alternatives + +Covered by the options above. Waiting for more field evidence is itself an option, and a cheap one: nothing breaks while the question is open. + +## Open Questions + +- Is one adoption without tasks evidence enough to change the artifact set, or does it only justify changing how tasks are presented? +- If C: does the plan belong in the repository at all, or is it scratch state for the harness that should not be committed? +- If D: DR-014 names tasks as the one thing a tracker may own. What replaces that exception, or does it disappear with the type? +- If B or D: what happens to the `done` status and `knowledge done`, which exist only for tasks? + +## Outcome + +<!-- Fill after review: accepted, rejected, or deferred, with links to resulting decisions/specs. --> diff --git a/knowledge/methodology/rfcs/KDE-RFC-018-decisions-that-span-repositories-where-a-cross-service-rule.md b/knowledge/methodology/rfcs/KDE-RFC-018-decisions-that-span-repositories-where-a-cross-service-rule.md new file mode 100644 index 0000000..a973abe --- /dev/null +++ b/knowledge/methodology/rfcs/KDE-RFC-018-decisions-that-span-repositories-where-a-cross-service-rule.md @@ -0,0 +1,53 @@ +--- +id: KDE-RFC-018 +title: "Decisions that span repositories: where a cross-service rule lives" +status: draft +created: 2026-09-30 +updated: 2026-09-30 +drafted_by: agent +approved_by: [] +motivated_by: Maintainer question (2026-09-30) — whether the method fits teams split across services; each repository has its own catalog, so a rule that binds two services (a contract, a shared business rule) has no single home, and DR-014 would treat the other repository as an external signal +scope: [methodology] +tags: [rfc, integration] +depends_on: [] +related: [DR-009, DR-010, DR-014, KDE-SPEC-001] +--- + +# RFC: Decisions that span repositories: where a cross-service rule lives + +## Summary + +Decide where a decision that binds more than one repository lives, and how the others read it as truth rather than as a signal. Today the method is per-repository: a catalog, its domains and their records live in one repository, and nothing can cite a record in another. + +## Problem + +Inside one service the method works as designed. Across services it does not have an answer: + +- **A shared rule has no home.** "An order is refundable for 30 days" may be enforced by orders, payments and support. Each repository can hold its own Decision Record for it, and then there are three copies that drift from each other, which is the problem the method exists to prevent. +- **Contracts are one-sided.** A Contract (DR-010) lives in the repository that implements it. The consumer's code depends on it, but the consumer's drift gate cannot see it change. +- **The precedence rule demotes it.** DR-014 ranks everything outside the repository as a raw signal: cite, never obey. A decision in a sibling repository is, by that rule, a signal, even when it is the accepted decision of the team that owns it. +- **References don't resolve.** `depends_on` and `related` take ids the validator resolves inside one repository; an id from another repository is an error. + +## Proposal + +No choice is made here; the options are laid out for review. + +- **A. Owner-plus-reference.** The owning repository holds the record. Others cite it with a qualified reference (`orders:DR-023@<commit or tag>`) that the validator accepts without resolving, and the manifest lists as "external truth, pinned at …". DR-014 gains a tier: a pinned reference to another repository's accepted record ranks with local decisions. +- **B. Shared knowledge repository.** Cross-service decisions live in one repository with its own catalog; services add it as a submodule or a pinned checkout under `knowledge/shared/`, and the validator treats it as a read-only domain. +- **C. Copy and verify.** Each repository keeps its own copy, marked as a mirror of the owner's record with its source and hash; a CI job compares the hash to the source and fails when the copy is stale. +- **D. Out of scope.** State in HANDBOOK and README's honest limits that the method governs one repository, and that cross-service rules belong in contracts owned by the provider. + +## Alternatives + +A monorepo sidesteps the problem; it is not something the method can ask of an adopter. + +## Open Questions + +- How common is this for the teams the method targets? One service per team with a few shared rules, or many services sharing many rules? +- Which of the options keeps the drift gate meaningful? A and B pin a version, so a consumer can fall behind silently; C fails loudly but needs network access in CI. +- Does a cross-repository reference need the other repository to have adopted the method, or can it point at any document with a stable URL? +- Is D the honest first step, with A or B deferred until an adopter with several services asks for it? + +## Outcome + +<!-- Fill after review: accepted, rejected, or deferred, with links to resulting decisions/specs. -->