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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,40 @@ Versioning: [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **Fallback safety contract — the graph says "unknown" instead of "safe".**
Every impact answer now carries the spec-wide edge counts (`ledger`): on a
project whose dependency ledger is empty (every freshly adopted project),
`impacted: []` used to be byte-identical to a verified leaf — measured on a
real 196-feature clone, a feature with 10 actual dependents answered
"nothing breaks, coverage 1.0". A blank ledger now answers with explicit
zero-counts plus a hint to fall back to normal code search / the full test
suite; a feature with zero known dependents stops with
`no-known-dependents` and `coverage: null` (never a vacuous 100%), and the
working-set radius carries the denominator. The after-edit impact card
discloses `deps unledgered` on blank ledgers.

### Fixed

- **Hooks no longer gate projects that never adopted cladding.** In a cwd
without spec.yaml (a non-cladding repo, or a subdirectory of a cladding
monorepo), the Stop hook falsely blocked the session once with
"governance absent" findings and wrote `.cladding/` state into the foreign
tree; the PostToolUse nudge did the same. Both now mirror the SessionStart
guard: not under cladding → silence, zero writes. (A present-but-broken
spec keeps its honest one-time block.)
- **A gate that could not run no longer reports GREEN.** The gate footer on
mutating MCP tool results — the only structural channel for hosts without
lifecycle hooks — fabricated `{pass: true}` when the drift engine itself
threw; it now fails closed with `{pass: false, unavailable: true}` and
points at `clad check --strict`.
- **Every graph-tool failure now says how to proceed without the graph.**
Absent spec → the "run `clad init`" guidance (was a raw ENOENT) on all four
graph tools; query misses carry the discovery hint on `clad_get_graph` too,
and discovery hints name the baseline fallback (normal code search). The
SessionStart card renders an unparseable spec with no resolvable counts as
"counts unavailable" instead of a healthy-looking "0 features".
- **The after-edit impact card now actually fires.** Hosts send absolute file
paths while the spec's module index is repo-relative, so the PostToolUse
impact card (0.7.0) never rendered in real usage — 0/361 module paths resolved
Expand Down
530 changes: 265 additions & 265 deletions plugins/claude-code/dist/clad.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ project:

# Auto-maintained by `clad sync` (F-5b9f9f). Do not edit by hand.
inventory:
features: 199
features: 200
scenarios: 2
capabilities: 6
test_files: 170
test_files: 171
last_synced: "2026-07-02"
49 changes: 25 additions & 24 deletions spec/attestation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ attested:
F-041: cddb50fc41e49066
F-042: d1f661281bb9fb6e
F-043: 2c5f4a94e3e57e9b
F-044: e6e44528a461c0f5
F-044: 1bf702635dbbf92a
F-045: b494c9a80442ac20
F-046: 4b563fce74b6bb4b
F-047: baf5a2dbb9bb5a4b
Expand All @@ -75,17 +75,17 @@ attested:
F-067: 6e6dbd05bf314b56
F-068: 058091774fa65ef8
F-069: 9cf62671c6057a36
F-06dfdad6: 40a338458a879a3d
F-06dfdad6: f2046016be8f0937
F-070: e50bb5d3addc7720
F-071: 3183a483a8015d4c
F-072: 44e1d39139c816cc
F-073: 94a40afd6119e9aa
F-074: 086d43ceef38da3f
F-073: 1461ea9027fd955f
F-074: c35e987c6de7f53f
F-075: 4d8346cb6a1bbd14
F-076: 0061ab2d4b4991a8
F-077: 7aac44757146b695
F-078: c6af3d128b98f4e6
F-079: aa032a8dc1704024
F-079: 2450b633a8a11ebc
F-080: 1c19da74d32894e6
F-081: 248f9660cfb1b02b
F-098d3b: 42d61bf806ce462b
Expand All @@ -94,23 +94,23 @@ attested:
F-0f2984d0: a85294b0b4128d5d
F-12d740: 84ad71574d306c81
F-15999130: 894b484b7a93690c
F-16138071: 12d9c7b057fea527
F-16138071: b6dd70f1d2840a50
F-16746b: 2f98a1261b9b1fc2
F-17df0a: 915d13b33258d3fc
F-18e951: d907c170a230e052
F-1c9166: 09dad0dc6b0be87d
F-1d23a6: 1a236a840d631afb
F-1d23a6: 6b019e8fe121c8e7
F-1edb38: 64283112a3ab96ce
F-24062d: 7b14f2aef3ceee4d
F-24062d: 54e8fc1e30006a78
F-245bd5: a8372aeb83acc411
F-2be3e3bb: a85294b0b4128d5d
F-2de65d: 84ad71574d306c81
F-315fd7: c3b042c80fa7c187
F-31eeb8: d88a9880d29ae411
F-32b1e0: 9792422c1c008207
F-32b1e0: 4cc7240778eaae75
F-3788c2: af9778dea8687b29
F-37b4a8: e067655bad681488
F-3a5339: 0dfdeec23abfa841
F-3a5339: 7d790460b97b35a7
F-3b3690: 6a36aad282d36f3a
F-40327b: 8295358f7b813c8a
F-417ff0: dde21c2716b745dc
Expand All @@ -119,41 +119,41 @@ attested:
F-4747ef: c255a18b6849d002
F-4db939: b2c386ca4e18c117
F-50ff43: fac674314685a912
F-551a1c: 30bb1ad68313708f
F-551a1c: b3df5ef23910b386
F-569f4b37: b65f8d46b6e57af7
F-56abaa: 34257a997efcb048
F-570a3f: d424bb82a591d619
F-570a3f: dfb4267629a84295
F-59f093: 26735424fba6308c
F-5b188856: d1e2e4184aa76c12
F-5b9f9f: dcc903a25cdfe77f
F-5b9f9f: c9f8b72b10fa5d7c
F-5d3ed2: 9452eac28760fb99
F-5f6b45: 15323c4f5b619de7
F-64a5c159: a14622fa5f30f985
F-64a5c159: dd42d7d046380184
F-65814a: 2136c8b8c94ef535
F-67d2e9: ad7a8fe2939cd13c
F-67e33f: 77f9d583176bb4f2
F-67e33f: 80a0b17ae9fc4a2b
F-6d943d: e02e71ed007aded2
F-6f80e7: 0c0e5b71ae22cc26
F-7076f7: 7e819e0d440f3ffe
F-7794a6bc: 1eb3a1bcee7017dc
F-7794a6bc: ca831090957472c9
F-77f7ead0: 4fd1ec36a5726fe9
F-78b50d: 688e6afe2352a034
F-7afbd4: 18ce48352bee0fce
F-7ce18e: 7effa760a0661b6a
F-7fa4a7: 19b7709a0b2202e3
F-80d19d: 4988733647f7777d
F-836a90: 2397ddde473a959a
F-836a90: 9e5bbc7ff223634b
F-8f419e: f3473746f4e252bc
F-904495a5: 2edac28aac267ee6
F-904495a5: 1684fb1beb040cab
F-9064ff: 9efbbb6021aadfb9
F-94dda4: 8dfb0267c45534f9
F-95a096: 29bc5fbffce6f117
F-96250595: c095846f30f2eb0e
F-95a096: e8ed4fb958234914
F-96250595: 41b790c427d2b01b
F-96700032: 5ae97b4c82c14acc
F-99c6e5: 8a8724852f1d6059
F-9a3b61: 786eeefef2d25138
F-9b643e: b0672cf024eacf79
F-9d168287: 34d2194d7ab98cf5
F-9d168287: c78b3fdff9219838
F-a04cd9: e65d87671306d305
F-a4b512: d136f2a1bbc4383a
F-a5228c: aaf1d6876a5c7221
Expand All @@ -177,15 +177,16 @@ attested:
F-c2c996: c92ab5bb0fbd1367
F-c48eb2: 9358d7394e88e95c
F-c4c5ae: 18200d79542ae22e
F-c6a32fff: 056320bfd510c090
F-c8aef8: 02e07f929a1d0ded
F-cd0415: 9cf6ce40e2a8b381
F-cfba0c: 077c03b8a96f562b
F-d12edf: c19aa5d1007e0b8f
F-d2c806: 5aa60f71bbd0a136
F-d2c806: 829db2373596bbdd
F-d3bde4: 915d13b33258d3fc
F-d49585: f3ef2fc02122533a
F-d6b93648: 065d493eb44c4b7b
F-d7312b: 9b7db1624caf7302
F-d6b93648: d272c3163ca37592
F-d7312b: cdba5500f011277e
F-d8223c: 0501e9564231899b
F-d980359c: 8f1559276afc5c03
F-dd51b42c: 496eeffa2641169d
Expand Down
2 changes: 1 addition & 1 deletion spec/capabilities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ capabilities:
title: "Knowledge graph (spec↔code↔doc)"
summary: "Always-current, bidirectional graph over the SSoT: reverse-index backlinks, blast-radius impact queries, doc↔spec/doc link integrity, and viewer exports (mermaid/Obsidian/DOT/JSON) + hub stats. Retrieval/traceability, not correctness."
surface: tool
features: [F-ee47fc2b, F-7794a6bc, F-ee5f643e, F-569f4b37, F-02343cd1, F-64a5c159, F-8234ec3c, F-04f50847, F-af45042a]
features: [F-ee47fc2b, F-7794a6bc, F-ee5f643e, F-569f4b37, F-02343cd1, F-64a5c159, F-8234ec3c, F-04f50847, F-af45042a, F-c6a32fff]
71 changes: 71 additions & 0 deletions spec/features/graph-honest-fallback-c6a32fff.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
id: F-c6a32fff
slug: graph-honest-fallback
title: "Fallback safety contract — a graph answer that cannot know says so and points back to baseline search"
status: done
modules:
- src/optimizer/reverse-slice.ts
- src/optimizer/iterative-slice.ts
- src/optimizer/working-set.ts
- src/optimizer/measurement.ts
- src/cli/hook.ts
- src/serve/server.ts
- src/optimizer/context-slice.ts
acceptance_criteria:
- id: AC-30e00a5c
ears: state
condition: "while the spec declares zero depends_on edges (a blank dependency ledger — the state of every freshly adopted project)"
action: "attach a ledger block {depends_on_edges, test_ref_edges} to every impact slice and, when a count is zero, a fallback hint telling the agent the answer means unknown-not-safe and to fall back to normal code search / the full suite"
response: "an empty impacted list on a blank map is distinguishable from a verified leaf on a dense map — the graph never converts \"no data\" into \"nothing breaks\""
text: "While the dependency ledger is empty, the system shall mark impact answers with ledger counts and a fallback hint so blank-map emptiness is distinguishable from verified emptiness."
test_refs:
- "tests/optimizer/reverse-slice.test.ts#BLANK ledger: impacted:[] carries zero-counts + fallback hints — unknown, not safe (F-c6a32fff)"
- "tests/optimizer/reverse-slice.test.ts#DENSE ledger: a verified leaf shows real edge counts and NO hints — distinguishable from blank"
notes: "## Why\nMeasured (vapt clone, 196 features/0 edges): a feature with 10 real dependents answered impacted:[] coverage:1.0 — byte-identical to a verified leaf. 44% of cladding-self features hit the totalKnown=0 path, so this is a mainstream path, not an edge case. Ledger is the SOLE blank-vs-leaf disambiguator (3-case simulation, wf_04e7f5b8)."
- id: AC-67150016
ears: state
condition: "while an impact query resolves to a feature with zero known dependents"
action: "stop the iterative widening with stoppedBy 'no-known-dependents' and coverage null (never a vacuous coverage 1.0), and carry total_known_dependents through the working-set radius"
response: "consumers see \"no dependents are known\" instead of \"100% of the blast radius is covered\""
text: "While a feature has zero known dependents, the iterative impact slice shall report stoppedBy no-known-dependents with coverage null, and the working-set radius shall include total_known_dependents."
test_refs:
- "tests/optimizer/iterative-slice.test.ts#zero known dependents stops honestly: no-known-dependents + coverage null, never a vacuous 1.0 (F-c6a32fff)"
- "tests/optimizer/working-set.test.ts#blank-ledger radius: no-known-dependents, coverage null (not 0), denominator + ledger surfaced (F-c6a32fff)"
notes: "## Decision\ncoverage:null (not 0/1): JS null*100===0, so the working-set rounding site needs an explicit null guard or the radius would show a legitimate-looking FALSE 0% (simulation-caught). measurement.ts excludes null coverages from its median."
- id: AC-85daff2d
ears: unwanted
condition: "if the Stop or PostToolUse hook fires in a cwd that has no spec.yaml (a non-cladding project or an unrelated directory)"
action: "return silence without running the gate and without writing any .cladding/ state — mirroring the SessionStart guard"
response: "cladding never blocks a session or pollutes a tree that did not adopt it"
text: "If spec.yaml is absent from the hook cwd, the Stop and PostToolUse hooks shall print nothing and write nothing."
test_refs:
- "tests/cli/hook.test.ts#Stop in a spec-less cwd → silence, no drift run, no .cladding/ writes"
- "tests/cli/hook.test.ts#PostToolUse in a spec-less cwd → silence, no drift run, no stamp write"
notes: "## Why\nReproduced with the shipped bundle: in a spec-less dir the Stop hook blocked with 4 ABSENCE findings and wrote 3 state files; a monorepo SUBDIR of a valid cladding project behaved identically (hook cwd is process.cwd(), no upward search) — the guard is a repair there, not a regression. Broken-but-present spec keeps its honest one-time block."
- id: AC-c57ea33e
ears: unwanted
condition: "if the drift engine throws while computing the gate footer that rides mutating MCP tool results"
action: "report {pass:false, unavailable:true} with a pointer to clad check --strict instead of fabricating a passing gate"
response: "an engine fault reads as \"gate could not run\", never as a verified GREEN, on the one structural channel hosts without lifecycle hooks can see"
text: "If the gate footer cannot run, the system shall report pass:false with unavailable:true rather than a fabricated pass:true."
test_refs:
- "tests/serve/gate-footer-unavailable.test.ts#a throwing drift engine yields gate {pass:false, unavailable:true} on a mutating tool result"
notes: "## Decision\npass:false stays alongside unavailable:true — removing the pass field would contradict F-570a3f's wire contract (drift pass flag) and hosts branching on gate.pass; explicit false is fail-closed at zero migration cost (simulation: no test pins the catch branch)."
- id: AC-6704a592
ears: event
condition: "when a graph MCP tool cannot load the spec, or a graph query misses"
action: "answer with the loadSpecOrError guidance (run clad init) instead of a raw ENOENT, include the discovery hint on clad_get_graph misses like its sibling tools, and extend discovery hints with the baseline fallback (normal code search)"
response: "every graph-tool failure tells the agent how to proceed WITHOUT the graph"
text: "When a graph tool fails to answer, the system shall include recovery guidance: clad init for a missing spec, and fall-back-to-normal-search wording in the miss discovery hints."
test_refs:
- "tests/serve/server.test.ts#read surfaces degrade gracefully when spec.yaml is absent (no crash)"
notes: "## Why\nclad_list_features already had this precedent (loadSpecOrError); the four graph tools shipped with raw err.message. Zero tests pin the raw ENOENT text (simulation), so this is a text-only change."
- id: AC-10b1a2f8
ears: event
condition: "when the PostToolUse impact card renders on a blank ledger, or the SessionStart card renders over an unparseable spec whose counts could not be resolved from any source"
action: "append a one-token 'deps unledgered' disclosure to the impact card, and replace the counts line with 'spec.yaml present but unparseable — counts unavailable' only when parsing failed AND no count source resolved"
response: "the push surfaces stop rendering unknown states as healthy ones, without adding noise to dense-ledger projects"
text: "When rendering over a blank ledger or an unparseable spec with no resolvable counts, the push cards shall disclose the unknown state instead of rendering it as empty-and-healthy."
test_refs:
- "tests/cli/impact-card.test.ts#a blank ledger discloses itself; a dense ledger does not; the empty-card path stays empty (F-c6a32fff)"
- "tests/cli/hook.test.ts#SessionStart over an unparseable spec with no other count source → honest counts-unavailable line"
notes: "## Decision\nSessionStart line is conditional on parseFailed AND !counted — spec/index.yaml is the primary count source, so an unparseable master with a healthy index still renders true counts (unconditional rendering would be wrong more often than right — simulation)."
1 change: 1 addition & 0 deletions spec/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ features:
F-c2c996: {slug: checkpoint-events, status: done, modules: 3}
F-c48eb2: {slug: scan-source-roots, status: done, modules: 5}
F-c4c5ae: {slug: spec-conformance-oracle-stage, status: done, modules: 8}
F-c6a32fff: {slug: graph-honest-fallback, status: done, modules: 7}
F-c6c3daaf: {slug: kotlin-module-scoped-gate, status: in_progress, modules: 13}
F-c8aef8: {slug: project-context, status: done, modules: 5}
F-cd0415: {slug: spec-load-once, status: done, modules: 2}
Expand Down
23 changes: 20 additions & 3 deletions src/cli/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,11 @@ function renderSessionStartCard(cwd: string): string {
const specPath = join(cwd, 'spec.yaml');
if (!existsSync(specPath)) return '';
let spec: SpecDoc = {};
let parseFailed = false;
try {
spec = (parseYaml(readFileSync(specPath, 'utf8')) as SpecDoc | null) ?? {};
} catch {
/* unparseable spec → counts degrade to the inventory defaults below */
parseFailed = true; // counts may still resolve via spec/index.yaml (the primary source)
}

let total = 0;
Expand Down Expand Up @@ -132,8 +133,13 @@ function renderSessionStartCard(cwd: string): string {
? spec.scenarios.length
: 0;

// An unparseable master with NO other count source must not render as a
// verified-empty "0 features" project (F-c6a32fff). Conditional on !counted:
// sharded projects usually still count fine via spec/index.yaml.
const lines: string[] = [
`cladding: ${total} features (${done} done, ${inProgress.length} in progress) · ${scenarios} scenarios`,
parseFailed && !counted
? 'cladding: spec.yaml present but unparseable — counts unavailable (run clad check)'
: `cladding: ${total} features (${done} done, ${inProgress.length} in progress) · ${scenarios} scenarios`,
];
if (inProgress.length > 0) {
lines.push(`in progress: ${inProgress.slice(0, 3).map((f) => `${f.id} ${f.slug}`).join(', ')}`);
Expand Down Expand Up @@ -282,6 +288,11 @@ function stopBlockPath(cwd: string): string {
*/
function runStopGate(input: unknown, cwd: string): string {
if (asRecord(input).stop_hook_active === true) return '';
// Not under cladding → not our session to gate (SessionStart parity, F-c6a32fff).
// Without this, a spec-less cwd (non-cladding repo, or a SUBDIR of a cladding
// monorepo — the hook cwd is process.cwd(), no upward search) got falsely
// BLOCKED by ABSENCE_OF_GOVERNANCE and had .cladding/ state written into it.
if (!existsSync(join(cwd, 'spec.yaml'))) return '';
const failures: StopFailure[] = [];
try {
const drift = runDrift({strict: true, cwd});
Expand Down Expand Up @@ -380,7 +391,11 @@ export function formatImpactCard(slice: ImpactSlice, filePath: string): string {
const co = owners.length > 1 ? ` (+${owners.length - 1} co-owner${owners.length > 2 ? 's' : ''})` : '';
const breaks = slice.impacted.length > 0 ? ` · breaks ${slice.impacted.length} feature(s)` : '';
const tests = slice.test_refs.length > 0 ? ` · run ${slice.test_refs.length} test(s)` : '';
return `cladding impact: ${filePath} → ${label}${co}${breaks}${tests}`;
// Blank ledger disclosure: empty breaks/tests segments must not read as "verified
// safe" when NO depends_on edge exists project-wide (strict === 0 — old-shaped
// slices without a ledger stay unmarked rather than mis-firing).
const unledgered = slice.ledger?.depends_on_edges === 0 ? ' · deps unledgered' : '';
return `cladding impact: ${filePath} → ${label}${co}${breaks}${tests}${unledgered}`;
}

/**
Expand All @@ -393,6 +408,8 @@ function runPostToolUseDrift(input: unknown, cwd: string): string {
if (!WRITE_TOOLS.has(asString(rec.tool_name))) return '';
const filePath = asString(asRecord(rec.tool_input).file_path);
if (!isWatchedSourcePath(filePath)) return '';
// Not under cladding → no drift nudges and no .cladding/ writes (SessionStart parity).
if (!existsSync(join(cwd, 'spec.yaml'))) return '';
const stampPath = join(cwd, '.cladding', 'hook-drift-ts');
const now = Date.now();
try {
Expand Down
4 changes: 3 additions & 1 deletion src/optimizer/context-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export function buildContextSlice(spec: Spec, query: string): ContextSlice | Con
return {
not_found: query,
accepted_forms: ['feature id (F-…)', 'slug', 'module path (e.g. src/auth/login.ts)'],
discovery: 'grep spec/index.yaml — one line per feature (id, slug, status), regenerated by clad sync',
discovery:
'grep spec/index.yaml — one line per feature (id, slug, status; run clad sync if missing); ' +
'if the query is a file, fall back to normal code search — the graph only knows declared modules',
};
}
const pruned = pruneToFeature(spec, focus.id);
Expand Down
Loading
Loading