SLOPSHOPPER

context-meter

Live context meter for the planner session: context size, colour-stepped bar, session token totals, and compact / clear / hand-off actions (AISDLC-743)

newband
★ 414v0.1.0Apache-2.0updated 2026-10-16ai-sdlc-framework/ai-sdlc/ai-sdlc-plugin/mods/context-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-meter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ctx 97.4k / 200k (48.7%) ████████████████████ clear now session in 2.1k / cache-write 4.3k / cache-read 91.0k / out 1.5k · weight ~24.0k input-eq · $0.42 [ Compact ][ Clear ][ Hand off + clear ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 97.4k / 200k (48.7%) ████████████████████ clear now session in 2.1k / cache-write 4.3k / cache-read 91.0k / out 1.5k · weight ~24.0k input-eq · $0.42 [ Compact ][ Clear ][ Hand off + clear ]
README

AI-SDLC Framework

The Decision Engine for spec-driven AI workflows

License: Apache 2.0 CI Spec Version Coverage Docs PRs Welcome

Website · Documentation · Specification · Getting Started · Vision · Contributing


What this is

AI-SDLC is the Decision Engine for spec-driven AI workflows — the execution-and-governance half of a spec-driven development stack. Operators frontload the load-bearing decisions through a Definition-of-Ready gate; an autonomous orchestrator dispatches developer subagents through the dependency graph; cross-harness reviewers (Claude × Codex × …) verify the work in parallel; DSSE attestations seal every change; pull requests open themselves.

The leverage move is cost asymmetry: operator decisions made upfront — with full context, time to think, and access to stakeholders — are cheap and (mostly) correct. AI decisions made mid-execution under uncertainty are expensive and often wrong. The framework's value is not "AI writes code"; it's "AI executes well-specified contracts deterministically." Those are different products with different reliability profiles.

The operator's role shifts to decision steward — frame open questions, resolve them, sign off on resolutions, monitor the pipeline. Typing and rubber-stamping go to the framework.

AI-SDLC covers the contract → shipped half of the spec-driven funnel. Front-of-funnel tools like GitHub Spec Kit cover idea → contract; the two systems compose at the seam where a spec artifact becomes a backlog task ready for DoR. See RFC-0036 for the bridge design and adopter authoring model.

Read first: VISION.md is the organizing thesis — the design philosophy that grounds every RFC, every CLI, every gate. If something in this repo doesn't trace back to one of its principles, that's a signal we've drifted.


The problem AI-SDLC solves

AI agents can build small greenfield projects, but software falls apart as it grows. The data is consistent:

  • Productivity paradox — Experienced developers using AI tools are 19% slower on mature codebases, despite believing they are 20% faster (METR 2025)
  • Quality decline — Refactoring dropped from 25% to 10% of changes; code churn rose from 5.5% to 7.9% (GitClear 2024)
  • Stability regression — Every 25% increase in AI adoption correlates with a 7.2% drop in system stability (Google DORA 2024)
  • Trust gap — Only 3% of developers express high trust in AI output (Stack Overflow 2025)

The root cause is not that AI agents write bad code. It is that nobody orchestrates how they work as the codebase grows — every decision gets deferred to the worst possible moment, made by the actor with the least context. AI-SDLC flips that.


The five pillars

The framework is one cohesive system, but it ships as five pillars you can adopt incrementally.

1. The Decision Engine — RFC-0011 · RFC-0035

A Definition-of-Ready gate (RFC-0011) refuses to dispatch tasks the operator hasn't actually decided yet. A forthcoming Decision Catalog (RFC-0035, Draft) makes the operator's open-question queue a first-class resource — ranked by leverage, routed to the right actor, surfaced with framework recommendations + counter-arguments + sub-decision graphs. The framework recommends; the operator decides; the orchestrator executes. → Concept page

2. Autonomous Pipeline Orchestrator — RFC-0015

cli-orchestrator tick walks the dependency graph (RFC-0014), runs admission filters (blocked, in-flight, DoR, dispatchability), dispatches admitted tasks into isolated git worktrees, runs the Step 0-13 pipeline (dev agent → 3 reviewers → attestation sign → PR open), quarantines failures, and resumes from checkpoint commits on interruption. Operators monitor; they don't type. Feature flag: AI_SDLC_AUTONOMOUS_ORCHESTRATOR=experimental. → Concept page · Runbook

3. Cross-Harness Review — RFC-0010 §13

Three reviewer subagents run in parallel on every change. DSSE envelopes carry a harness field that identifies the execution harness behind each review, and verify-attestation enforces independence by construction: if Claude implemented, Claude cannot also be the code or test reviewer. Codex reviews Claude's work and vice versa. Reviewer collusion is mechanically impossible. → Concept page · Runbook

4. Operator TUI — RFC-0023

A live terminal interface with five panes: decisions-pending (RFC-0035), pipeline + PRs, dependency graph, configuration, and analytics. Foregrounds load-bearing decisions and stays out of the way for the rest. → Concept page

5. Declarative Governance

Declarative resources for the whole lifecycle: Pipeline, Decision, AgentRole, QualityGate, AutonomyPolicy, AdapterBinding — all with JSON Schema (draft 2020-12) under spec/schemas/. Quality gates run advisory → soft-mandatory → hard-mandatory with cross-harness review and DSSE attestation. Adopters declare a compliance posture (RFC-0022) and the framework derives gate defaults — EU AI Act, NIST AI RMF, ISO 42001. → Specification


Getting started

# 1. Install the Claude Code plugin (recommended)
/plugin marketplace add ai-sdlc-framework/ai-sdlc
/plugin install ai-sdlc@ai-sdlc
/reload-plugins

# 2. Scaffold your repository
ai-sdlc init

# 3. Check the install and configuration are healthy
ai-sdlc doctor

# 4. Dispatch your first task
/ai-sdlc execute AISDLC-42

Full setup, runner configuration, agent-runner reference, and the autonomous-orchestrator opt-in are in the documentation:

→ Getting Started · Tutorials · API Reference · Operations Runbooks

The framework is agent-agnostic — Claude Code, Codex, Cursor, Copilot, Aider, or any OpenAI-compatible API. See the Agent Runner Reference.


For AI agents and human contributors discovering this repo

If you are an AI agent or a new contributor coming to this codebase for the first time, read these documents in order. Each is canonical for its concern:

  1. VISION.md — the organizing thesis (Decision Engine, cost asymmetry, operator-as-decision-steward, anti-patterns ruled out). Every decision in this repo should trace back here.
  2. CLAUDE.md — operating conventions for any agent or contributor working in this repo: git flow (always rebase, never merge), branch + commit conventions, pre-push hooks, attestation requirements, backlog workflow, Pattern-C worktree isolation, plugin MCP routing. Load this before doing any work.
  3. CHARTER.md — project governance, IP policy, CNCF alignment.
  4. spec/rfcs/README.md — the architectural decisions registry. Every load-bearing design choice lives as an RFC. The registry table is the canonical lookup for numbers and lifecycle states; the Critical Path section traces dependencies.
  5. spec/spec.md + spec/ — the normative specification: resource model, policy enforcement, autonomy, agents, adapters, metrics.

Canonical execution paths (when working inside a Claude Code session):

Use caseCommandBilling
Internal dogfood (backlog tasks)/ai-sdlc execute <task-id>Subscription
Manual cleanup/ai-sdlc cleanup [<task-id>]n/a
Shell-driven autonomous tickcli-orchestrator tick --spawner claudeSubscription
GitHub issue / unattended / CIpnpm --filter @ai-sdlc/dogfood watch --issue <id>API key

Rules of thumb to internalize before pushing code:

  • Merging follows governance.allowMerge in .ai-sdlc/agent-role.yaml, rendered into the session hard rules; when it permits merging, the only path is cli-merge-if-eligible; never run the raw merge command.
  • Always rebase feature branches onto main. Never merge main in.
  • Pattern C: the parent working tree is read-only. All code work happens in .worktrees/<task-id>/. /ai-sdlc execute sets this up automatically.
  • Attestation is required on main. Code PRs that touch source must carry a DSSE envelope signed by the reviewer chain. Docs-only PRs bypass. See the review policy for what counts as a review, and the independence policy (.ai-sdlc/independence-policy.yaml requiredTier) to enforce reviewer independence.
  • Cross-repo writes go through permittedExternalPaths in the task frontmatter.

The plugin's slash commands and MCP tools are documented in ai-sdlc-plugin/README.md. The Step 0-13 pipeline is in pipeline-cli/README.md.


Packages

PackagePathPurpose
@ai-sdlc/orchestratororchestrator/Orchestrator runtime — CLI, runners, admission, state store
@ai-sdlc/pipeline-clipipeline-cli/Step 0-13 pipeline runtime; cli-orchestrator, cli-deps, cli-decisions, cli-tui
ai-sdlc-pluginai-sdlc-plugin/Claude Code plugin — hooks, slash commands, reviewer subagents, MCP server
@ai-sdlc/sdksdk-typescript/TypeScript SDK
ai-sdlc-frameworksdk-python/Python SDK (pip install ai-sdlc-framework)
sdk-gosdk-go/Go SDK + Kubernetes-style operator CRDs
@ai-sdlc/conformanceconformance/Language-agnostic conformance test suite
spec/spec/Formal specification, RFCs, JSON schemas

For development setup (pnpm install, build, test, schema validation), see docs/getting-started/ and CONTRIBUTING.md.


Specification & versioning

The specification follows Kubernetes-style API maturity: v1alpha1 today; v1beta1 follows a 9-month deprecation window; v1 follows a 12-month window. Resource types, policy enforcement levels, autonomy, agents, and adapters all live under spec/ with JSON Schema (draft 2020-12) under spec/schemas/.

Architectural changes go through the RFC process. The registry there is the canonical lookup for every RFC number — active, reserved, withdrawn, and implemented.


Contributing, governance, license


Website · Documentation · Specification · Vision · Pricing

If you find this project useful, please consider giving it a star.

Source 3 files
hooks/register.tsx 190 lines
1import { atom, read, update } from 'claude-code';
2import type { Register } from 'claude-code';
3
4import type { MeterAction, Totals } from '../types';
5import {
6  EMPTY_TOTALS,
7  IDLE_HANDOFF,
8  LEVEL_COLOR,
9  addUsage,
10  bar,
11  costWeight,
12  formatTokens,
13  handoffStep,
14  levelFor,
15  percentOfWindow,
16  resolveThresholds,
17} from './meter.ts';
18
19const totalsAtom = atom({ plugin: 'context-meter', key: 'totals' } as const, EMPTY_TOTALS);
20const confirmAtom = atom({ plugin: 'context-meter', key: 'confirm' } as const, null);
21const handoffAtom = atom({ plugin: 'context-meter', key: 'handoff' } as const, IDLE_HANDOFF);
22const noteAtom = atom({ plugin: 'context-meter', key: 'note' } as const, null);
23
24const HANDOFF_PROMPT =
25  'Write your dated handoff memory file now (state, open work, next steps; index it in MEMORY.md), ' +
26  'then reply "handoff written". Do no other work; the session will be cleared after this reply.';
27
28const LABELS: Record<MeterAction, string> = {
29  compact: '/compact',
30  clear: '/clear',
31  handoff: 'hand off, then /clear',
32};
33
34export const register: Register = (on, options) => {
35  const thresholds = resolveThresholds(options as Record<string, unknown>);
36
37  on('turn.complete', async ($, e, next) => {
38    if (e.usage) {
39      await update($, totalsAtom, (t: Totals) => addUsage(t, e.usage!));
40    }
41
42    // A pending confirmation does not outlive the turn it was asked in.
43    await update($, confirmAtom, () => null);
44
45    const step = handoffStep(
46      await read($, handoffAtom),
47      { kind: 'turn.complete', turnId: e.turnId, reason: e.reason, agentId: e.agentId },
48      HANDOFF_PROMPT,
49    );
50    await update($, handoffAtom, () => step.state);
51    if (step.clear) {
52      void $.command.run({ command: 'clear' });
53    }
54
55    return next(e);
56  });
57
58  // Only the handoff prompt's own turn arms the clear; match it by its text.
59  on('turn.start', async ($, e, next) => {
60    const step = handoffStep(
61      await read($, handoffAtom),
62      { kind: 'turn.start', turnId: e.turnId, text: e.text },
63      HANDOFF_PROMPT,
64    );
65    await update($, handoffAtom, () => step.state);
66    return next(e);
67  });
68
69  // /clear starts a fresh session: totals count from there.
70  on('session.end', async ($, e, next) => {
71    await update($, confirmAtom, () => null);
72    await update($, handoffAtom, () => IDLE_HANDOFF);
73    await update($, noteAtom, () => null);
74    if (e.reason === 'clear') {
75      await update($, totalsAtom, () => EMPTY_TOTALS);
76    }
77
78    return next(e);
79  });
80
81  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82    if (e.props.hasSurvey) {
83      return next(e);
84    }
85
86    // Degrade to no output, never an error, when the host exposes no metrics.
87    let tokens: number | undefined;
88    let window: number | undefined;
89    let usd: number | undefined;
90    try {
91      const usage = await $.session.usage();
92      tokens = usage.context.tokens;
93      window = usage.context.window;
94      usd = usage.cost?.usd;
95    } catch {
96      return next(e);
97    }
98
99    const percent = percentOfWindow(tokens, window);
100    const totals = await read($, totalsAtom);
101    if (percent === null || tokens === undefined) {
102      return next(e);
103    }
104
105    const confirm = await read($, confirmAtom);
106    const note = await read($, noteAtom);
107    const level = levelFor(percent, thresholds);
108    const color = LEVEL_COLOR[level];
109    const { Box, Button, Text } = $.ui.resolve(e);
110    const weight = costWeight(totals);
111
112    return (
113      <Box flexDirection="column">
114        <Box>
115          <Text color={color}>
116            ctx {formatTokens(tokens)} / {formatTokens(window ?? 0)} ({percent.toFixed(1)}%){' '}
117            {bar(percent, thresholds)}
118            {level === 'hot' ? ' hand off soon' : ''}
119            {level === 'red' ? ' clear now' : ''}
120          </Text>
121        </Box>
122        <Box>
123          <Text dimColor>
124            session in {formatTokens(totals.input)} / cache-write {formatTokens(totals.cacheWrite)}{' '}
125            / cache-read {formatTokens(totals.cacheRead)} / out {formatTokens(totals.output)} ·
126            weight ~{formatTokens(weight)} input-eq
127            {usd === undefined ? '' : ` · $${usd.toFixed(2)}`}{' '}
128          </Text>
129        </Box>
130        {note === null ? null : (
131          <Box>
132            <Text color="red">{note}</Text>
133          </Box>
134        )}
135        {confirm === null ? (
136          <Box>
137            <Button
138              key="compact"
139              label="Compact"
140              onPress={() => update($, confirmAtom, () => 'compact')}
141            />
142            <Button
143              key="clear"
144              label="Clear"
145              onPress={() => update($, confirmAtom, () => 'clear')}
146            />
147            <Button
148              key="handoff"
149              label="Hand off + clear"
150              onPress={() => update($, confirmAtom, () => 'handoff')}
151            />
152          </Box>
153        ) : (
154          <Box>
155            <Text color="yellow">Run {LABELS[confirm]}? </Text>
156            <Button
157              key="yes"
158              label="Yes"
159              onPress={async () => {
160                await update($, confirmAtom, () => null);
161                await update($, noteAtom, () => null);
162                try {
163                  if (confirm === 'compact') {
164                    await $.session.compact();
165                  } else if (confirm === 'clear') {
166                    await $.command.run({ command: 'clear' });
167                  } else {
168                    const armed = handoffStep(IDLE_HANDOFF, { kind: 'submit' }, HANDOFF_PROMPT);
169                    await update($, handoffAtom, () => armed.state);
170                    try {
171                      await $.prompt.submit({ text: HANDOFF_PROMPT });
172                    } catch (err) {
173                      await update($, handoffAtom, () => IDLE_HANDOFF);
174                      throw err;
175                    }
176                  }
177                } catch (err) {
178                  const msg = err instanceof Error ? err.message : String(err);
179                  await update($, noteAtom, () => `${LABELS[confirm]} failed: ${msg}`);
180                }
181              }}
182            />
183            <Button key="no" label="Cancel" onPress={() => update($, confirmAtom, () => null)} />
184          </Box>
185        )}
186      </Box>
187    );
188  });
189};
190
hooks/meter.ts 148 lines
1/**
2 * Pure logic for the context-meter mod (AISDLC-743): thresholds, colour
3 * mapping, token totals and cost weight. No host imports, so it runs under
4 * plain `node --test`.
5 */
6
7export type Thresholds = {
8  /** Percent of the window where the bar turns amber. */
9  amber: number;
10  /** Percent where the band says to hand off soon. */
11  hot: number;
12  /** Percent where the bar turns red (the clearing level). */
13  red: number;
14};
15
16/** The one place the defaults live: 10 / 13 / 15 percent of the window. */
17export const DEFAULT_THRESHOLDS: Thresholds = { amber: 10, hot: 13, red: 15 };
18
19export type Level = 'ok' | 'amber' | 'hot' | 'red';
20
21export const LEVEL_COLOR: Record<Level, string> = {
22  ok: 'green',
23  amber: 'yellow',
24  hot: 'yellow',
25  red: 'red',
26};
27
28export type Totals = { input: number; cacheWrite: number; cacheRead: number; output: number };
29
30export type UsageLike = {
31  input_tokens?: number;
32  cache_creation_input_tokens?: number;
33  cache_read_input_tokens?: number;
34  output_tokens?: number;
35};
36
37export const EMPTY_TOTALS: Totals = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
38
39/**
40 * Read the three thresholds from userConfig options. All-or-nothing: an
41 * invalid field falls back to its own default, but if the resulting trio is
42 * not ordered amber <= hot <= red, every override is discarded.
43 */
44export function resolveThresholds(options: Record<string, unknown> | undefined): Thresholds {
45  const num = (v: unknown, d: number) =>
46    typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : d;
47  const t: Thresholds = {
48    amber: num(options?.amberAtPercent, DEFAULT_THRESHOLDS.amber),
49    hot: num(options?.hotAtPercent, DEFAULT_THRESHOLDS.hot),
50    red: num(options?.redAtPercent, DEFAULT_THRESHOLDS.red),
51  };
52  return t.amber <= t.hot && t.hot <= t.red ? t : { ...DEFAULT_THRESHOLDS };
53}
54
55/**
56 * "Hand off, then clear" state. `armed`: the handoff prompt was submitted and
57 * its own turn has not started yet. `running`: that turn started with `turnId`.
58 */
59export type HandoffState =
60  | { phase: 'idle' }
61  | { phase: 'armed' }
62  | { phase: 'running'; turnId: string };
63
64export type HandoffEvent =
65  | { kind: 'submit' }
66  | { kind: 'turn.start'; turnId: string; text: string }
67  | { kind: 'turn.complete'; turnId: string; reason: string; agentId?: string }
68  | { kind: 'reset' };
69
70export const IDLE_HANDOFF: HandoffState = { phase: 'idle' };
71
72/**
73 * Pure transition for the hand-off flow. The clear fires only when the
74 * handoff prompt's own turn ends with an answer: an earlier in-flight turn
75 * completing while armed is ignored, and a handoff turn that ends any other
76 * way (aborted, refusal, error) disarms without clearing.
77 */
78export function handoffStep(
79  state: HandoffState,
80  event: HandoffEvent,
81  handoffText: string,
82): { state: HandoffState; clear: boolean } {
83  switch (event.kind) {
84    case 'submit':
85      return { state: { phase: 'armed' }, clear: false };
86    case 'reset':
87      return { state: IDLE_HANDOFF, clear: false };
88    case 'turn.start':
89      if (state.phase === 'armed' && event.text === handoffText) {
90        return { state: { phase: 'running', turnId: event.turnId }, clear: false };
91      }
92      return { state, clear: false };
93    case 'turn.complete':
94      if (
95        state.phase !== 'running' ||
96        event.agentId !== undefined ||
97        event.turnId !== state.turnId
98      ) {
99        return { state, clear: false };
100      }
101      return { state: IDLE_HANDOFF, clear: event.reason === 'answer' };
102  }
103}
104
105/** Share of the window in use, as a percentage; null when it cannot be computed. */
106export function percentOfWindow(tokens: number | undefined, window: number | undefined) {
107  if (tokens === undefined || !window || window <= 0) return null;
108  return (tokens / window) * 100;
109}
110
111export function levelFor(percent: number, t: Thresholds = DEFAULT_THRESHOLDS): Level {
112  if (percent >= t.red) return 'red';
113  if (percent >= t.hot) return 'hot';
114  if (percent >= t.amber) return 'amber';
115  return 'ok';
116}
117
118/** Add one response's usage into running totals (missing counts read as zero). */
119export function addUsage(totals: Totals, usage: UsageLike): Totals {
120  return {
121    input: totals.input + (usage.input_tokens ?? 0),
122    cacheWrite: totals.cacheWrite + (usage.cache_creation_input_tokens ?? 0),
123    cacheRead: totals.cacheRead + (usage.cache_read_input_tokens ?? 0),
124    output: totals.output + (usage.output_tokens ?? 0),
125  };
126}
127
128/**
129 * Approximate cost weight in "input-token equivalents", using list-price
130 * ratios against uncached input: cache write 1.25x, cache read 0.1x, output 5x.
131 * A relative weight, not dollars.
132 */
133export function costWeight(totals: Totals): number {
134  return totals.input + totals.cacheWrite * 1.25 + totals.cacheRead * 0.1 + totals.output * 5;
135}
136
137export function formatTokens(n: number): string {
138  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
139  if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`;
140  return String(Math.round(n));
141}
142
143/** Text progress bar, `width` cells, filled up to the share of the red threshold. */
144export function bar(percent: number, t: Thresholds = DEFAULT_THRESHOLDS, width = 20): string {
145  const filled = Math.min(width, Math.max(0, Math.round((percent / t.red) * width)));
146  return '█'.repeat(filled) + '░'.repeat(width - filled);
147}
148
types/index.d.ts 25 lines
1export type Totals = {
2  input: number;
3  cacheWrite: number;
4  cacheRead: number;
5  output: number;
6};
7
8export type HandoffState =
9  | { phase: 'idle' }
10  | { phase: 'armed' }
11  | { phase: 'running'; turnId: string };
12
13export type MeterAction = 'compact' | 'clear' | 'handoff';
14
15declare module 'claude-code' {
16  interface PluginState {
17    'context-meter': {
18      totals: Totals;
19      confirm: MeterAction | null;
20      handoff: HandoffState;
21      note: string | null;
22    };
23  }
24}
25