@codewithjuber/forgekit 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +8 -0
- package/ARCHITECTURE.md +314 -0
- package/CHANGELOG.md +467 -0
- package/LICENSE +21 -0
- package/ONBOARDING.md +180 -0
- package/README.md +286 -0
- package/bin/claude-init.sh +90 -0
- package/bin/claude-taste.sh +33 -0
- package/bin/learn-consolidate.sh +51 -0
- package/brand.json +15 -0
- package/global/CLAUDE.md +31 -0
- package/global/crew/frontend-verifier.md +36 -0
- package/global/crew/independent-reviewer.md +29 -0
- package/global/crew/scout.md +24 -0
- package/global/crew/verifier.md +28 -0
- package/global/guards/_guardlib.sh +36 -0
- package/global/guards/cortex.sh +14 -0
- package/global/guards/cost-budget.sh +41 -0
- package/global/guards/doom-loop.sh +25 -0
- package/global/guards/format-on-edit.sh +32 -0
- package/global/guards/lean-guard.sh +20 -0
- package/global/guards/protect-paths.sh +45 -0
- package/global/guards/recall-load.sh +22 -0
- package/global/guards/secret-redact.sh +18 -0
- package/global/guards/session-learner.sh +72 -0
- package/global/recall/MEMORY.md +7 -0
- package/global/rules/self-correction.md +17 -0
- package/global/rules/stack-notes.md +24 -0
- package/global/rules/tech-currency.md +19 -0
- package/global/settings.template.json +183 -0
- package/global/statusline.sh +51 -0
- package/global/taste/brutalist.json +9 -0
- package/global/taste/brutalist.md +19 -0
- package/global/taste/corporate.json +9 -0
- package/global/taste/corporate.md +19 -0
- package/global/taste/editorial.json +9 -0
- package/global/taste/editorial.md +19 -0
- package/global/taste/minimalist.json +9 -0
- package/global/taste/minimalist.md +20 -0
- package/global/taste/playful.json +9 -0
- package/global/taste/playful.md +19 -0
- package/global/tools/atlas/SKILL.md +27 -0
- package/global/tools/code-modernization/SKILL.md +275 -0
- package/global/tools/code-modernization/references/cost-impact-preflight.md +54 -0
- package/global/tools/code-modernization/references/design-patterns-cheatsheet.md +24 -0
- package/global/tools/code-modernization/references/research-protocol.md +42 -0
- package/global/tools/code-modernization/scripts/preflight_scan.py +190 -0
- package/global/tools/cognitive-substrate/SKILL.md +56 -0
- package/global/tools/cognitive-substrate/references/capability-map.md +17 -0
- package/global/tools/cost-guard/SKILL.md +50 -0
- package/global/tools/design-md/SKILL.md +54 -0
- package/global/tools/dev-radar/SKILL.md +56 -0
- package/global/tools/explore-plan-code/SKILL.md +24 -0
- package/global/tools/lean/SKILL.md +41 -0
- package/global/tools/recall/SKILL.md +31 -0
- package/global/tools/reuse-first/SKILL.md +64 -0
- package/global/tools/self-improve/SKILL.md +44 -0
- package/global/tools/taste/SKILL.md +26 -0
- package/global/tools/tech-selector/SKILL.md +35 -0
- package/global/tools/ui-workflow/SKILL.md +44 -0
- package/hooks/hooks.json +107 -0
- package/install.sh +88 -0
- package/package.json +93 -0
- package/public/index.html +45 -0
- package/scripts/build-pages.mjs +180 -0
- package/scripts/bump.mjs +322 -0
- package/skills/cognitive-substrate/SKILL.md +56 -0
- package/skills/cognitive-substrate/references/capability-map.md +17 -0
- package/source/mcp.json +10 -0
- package/source/rules.json +106 -0
- package/source/substrate.json +41 -0
- package/src/adjudicate.js +84 -0
- package/src/anchor.js +210 -0
- package/src/atlas.js +487 -0
- package/src/brain.js +84 -0
- package/src/brand.js +25 -0
- package/src/cli.js +1509 -0
- package/src/context.js +273 -0
- package/src/cortex.js +251 -0
- package/src/cortex_distill.js +55 -0
- package/src/cortex_features.js +81 -0
- package/src/cortex_hook.js +197 -0
- package/src/cortex_hook_main.js +139 -0
- package/src/cortex_mcp.js +352 -0
- package/src/cost_report.js +271 -0
- package/src/dash.html +396 -0
- package/src/dash.js +220 -0
- package/src/diagnose.js +0 -0
- package/src/doctor.js +315 -0
- package/src/embed.js +244 -0
- package/src/emit/_shared.js +39 -0
- package/src/emit/aider.js +22 -0
- package/src/emit/claude.js +44 -0
- package/src/emit/codex.js +17 -0
- package/src/emit/continue.js +28 -0
- package/src/emit/copilot.js +12 -0
- package/src/emit/cursor.js +23 -0
- package/src/emit/gemini.js +40 -0
- package/src/emit/mcp.js +94 -0
- package/src/emit/windsurf.js +22 -0
- package/src/emit/zed.js +34 -0
- package/src/eval.js +47 -0
- package/src/extract.js +82 -0
- package/src/harden.js +44 -0
- package/src/imagine.js +301 -0
- package/src/init.js +178 -0
- package/src/lean.js +149 -0
- package/src/ledger.js +475 -0
- package/src/ledger_bridge.js +279 -0
- package/src/ledger_read.js +152 -0
- package/src/ledger_store.js +360 -0
- package/src/lessons.js +185 -0
- package/src/lessons_store.js +137 -0
- package/src/metrics.js +54 -0
- package/src/model_tiers.js +17 -0
- package/src/model_tiers.json +39 -0
- package/src/predictor.js +143 -0
- package/src/preflight.js +410 -0
- package/src/providers.js +320 -0
- package/src/recall.js +103 -0
- package/src/reuse.js +0 -0
- package/src/route.js +323 -0
- package/src/scope.js +122 -0
- package/src/skillgate.js +89 -0
- package/src/speclock.js +64 -0
- package/src/substrate.js +492 -0
- package/src/sync.js +132 -0
- package/src/taste.js +55 -0
- package/src/uicheck.js +96 -0
- package/src/uifingerprint.js +861 -0
- package/src/uivisual.js +334 -0
- package/src/util.js +71 -0
- package/src/verify.js +117 -0
- package/templates/project-layer/.claude/settings.json +22 -0
- package/templates/project-layer/.claude/skills/hostlelo-deploy/SKILL.md +38 -0
- package/templates/project-layer/AGENTS.md +28 -0
- package/templates/project-layer/CLAUDE.md +40 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// forge cost report — the P8 measured-stage report (docs/plans/substrate-v2/05-cost-model.md).
|
|
2
|
+
// The cost model is multiplicative: C = C₀ · Π(1 − fᵢ) over independent stages. The discipline
|
|
3
|
+
// this module enforces is the paper's (§4, C6): a number is an assumption until measured. Every
|
|
4
|
+
// factor here is ARITHMETIC over .forge/metrics.jsonl lines that stages actually emitted; a
|
|
5
|
+
// stage with no events reports measured:false and value:null — it is never guessed, defaulted,
|
|
6
|
+
// or backfilled from a target. The ~90 % figure in the plan stays a TARGET everywhere in this
|
|
7
|
+
// module's output; the only measured external figure (62 % routing, paper §9) is cited as
|
|
8
|
+
// context, clearly labeled as not-local.
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { read, record } from "./metrics.js";
|
|
13
|
+
import { MODELS } from "./model_tiers.js";
|
|
14
|
+
|
|
15
|
+
/** Saving weight per cache-hit tier — must stay consistent with reuse.js savedEstimate
|
|
16
|
+
* (exact = full regeneration avoided; near/adapt still spend adaptation tokens). */
|
|
17
|
+
export const CACHE_TIER_SAVINGS = { hit_exact: 1.0, hit_near: 0.85, hit_adapt: 0.5 };
|
|
18
|
+
|
|
19
|
+
// The route factor's baseline is "always-premium": the tier an unrouted agent defaults to.
|
|
20
|
+
// That is the complex tier (opus), NOT the extreme tier — pricing the baseline at the
|
|
21
|
+
// rarely-justified top model would flatter the savings, and honesty is the point.
|
|
22
|
+
const ROUTE_BASELINE_KEY = "opus";
|
|
23
|
+
|
|
24
|
+
/** Resolve a metrics `tier` field to a pricing row — accepts the model key ("haiku")
|
|
25
|
+
* or the tier name ("simple"), since both appear in route results. */
|
|
26
|
+
const modelForTier = (tier) =>
|
|
27
|
+
MODELS[tier] ?? Object.values(MODELS).find((m) => m.tier === tier) ?? null;
|
|
28
|
+
|
|
29
|
+
const tokenCost = (m, tokensIn, tokensOut) => tokensIn * m.inCost + tokensOut * m.outCost;
|
|
30
|
+
|
|
31
|
+
const unmeasured = () => ({ measured: false, value: null, events: 0 });
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per-stage measured factors from .forge/metrics.jsonl. Each factor is
|
|
35
|
+
* {measured:boolean, value:number|null, events:number} — `events` counts only the lines
|
|
36
|
+
* usable for that stage's arithmetic, and a stage with none reports measured:false /
|
|
37
|
+
* value:null. NEVER invents a number.
|
|
38
|
+
*
|
|
39
|
+
* - gate: fraction of "gate" events with outcome "halt" — requests where spend was avoided
|
|
40
|
+
* entirely. (This measures h_gate; the plan's g ≈ per-halt spend share is taken as 1 —
|
|
41
|
+
* i.e. a halted task is assumed to have cost an average task, the simplest honest reading
|
|
42
|
+
* until the paired harness prices halts individually.)
|
|
43
|
+
* - cache: hit rate weighted by tier savings (exact 1.0 / near 0.85 / adapt 0.5); a miss
|
|
44
|
+
* contributes 0 to the numerator but counts in the denominator.
|
|
45
|
+
* - route: 1 − (actual token cost / always-premium cost) over "route" events that carry a
|
|
46
|
+
* resolvable tier AND real token counts — events without tokens can't be priced and are
|
|
47
|
+
* excluded rather than estimated.
|
|
48
|
+
* - context: fraction of would-have-been input tokens avoided, Σsaved / (Σsaved + Σactual),
|
|
49
|
+
* over "context" events carrying both savedEstimate and tokensIn.
|
|
50
|
+
* @param {string} root
|
|
51
|
+
*/
|
|
52
|
+
export function stageFactors(root) {
|
|
53
|
+
const gateEvents = read(root, { stage: "gate" });
|
|
54
|
+
const gate = gateEvents.length
|
|
55
|
+
? {
|
|
56
|
+
measured: true,
|
|
57
|
+
value: gateEvents.filter((e) => e.outcome === "halt").length / gateEvents.length,
|
|
58
|
+
events: gateEvents.length,
|
|
59
|
+
}
|
|
60
|
+
: unmeasured();
|
|
61
|
+
|
|
62
|
+
const cacheEvents = read(root, { stage: "cache" });
|
|
63
|
+
const cache = cacheEvents.length
|
|
64
|
+
? {
|
|
65
|
+
measured: true,
|
|
66
|
+
value:
|
|
67
|
+
cacheEvents.reduce((s, e) => s + (CACHE_TIER_SAVINGS[e.outcome] ?? 0), 0) /
|
|
68
|
+
cacheEvents.length,
|
|
69
|
+
events: cacheEvents.length,
|
|
70
|
+
}
|
|
71
|
+
: unmeasured();
|
|
72
|
+
|
|
73
|
+
const baseline = MODELS[ROUTE_BASELINE_KEY];
|
|
74
|
+
const routeEvents = read(root, { stage: "route" }).filter(
|
|
75
|
+
(e) =>
|
|
76
|
+
modelForTier(e.tier) &&
|
|
77
|
+
Number.isFinite(e.tokensIn) &&
|
|
78
|
+
Number.isFinite(e.tokensOut) &&
|
|
79
|
+
e.tokensIn + e.tokensOut > 0,
|
|
80
|
+
);
|
|
81
|
+
const route = routeEvents.length
|
|
82
|
+
? {
|
|
83
|
+
measured: true,
|
|
84
|
+
value:
|
|
85
|
+
1 -
|
|
86
|
+
routeEvents.reduce(
|
|
87
|
+
(s, e) => s + tokenCost(modelForTier(e.tier), e.tokensIn, e.tokensOut),
|
|
88
|
+
0,
|
|
89
|
+
) /
|
|
90
|
+
routeEvents.reduce((s, e) => s + tokenCost(baseline, e.tokensIn, e.tokensOut), 0),
|
|
91
|
+
events: routeEvents.length,
|
|
92
|
+
}
|
|
93
|
+
: unmeasured();
|
|
94
|
+
|
|
95
|
+
const ctxEvents = read(root, { stage: "context" }).filter(
|
|
96
|
+
(e) => Number.isFinite(e.savedEstimate) && Number.isFinite(e.tokensIn),
|
|
97
|
+
);
|
|
98
|
+
const ctxSaved = ctxEvents.reduce((s, e) => s + e.savedEstimate, 0);
|
|
99
|
+
const ctxActual = ctxEvents.reduce((s, e) => s + e.tokensIn, 0);
|
|
100
|
+
const context =
|
|
101
|
+
ctxEvents.length && ctxSaved + ctxActual > 0
|
|
102
|
+
? { measured: true, value: ctxSaved / (ctxSaved + ctxActual), events: ctxEvents.length }
|
|
103
|
+
: unmeasured();
|
|
104
|
+
|
|
105
|
+
return { gate, cache, route, context };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The multiplicative composition C = C₀ · Π(1 − fᵢ) over ONLY the measured factors.
|
|
110
|
+
* Honest framing: because unmeasured stages contribute exactly nothing (factor 0, not a
|
|
111
|
+
* target), the result is a LOWER BOUND built from measured stages only — it can only grow
|
|
112
|
+
* as more stages start emitting metrics, and it is never the plan's ~90 % target restated.
|
|
113
|
+
* @param {ReturnType<typeof stageFactors>} factors
|
|
114
|
+
* @returns {{measuredReduction:number, stagesIncluded:string[], stagesMissing:string[]}}
|
|
115
|
+
*/
|
|
116
|
+
export function composedReduction(factors) {
|
|
117
|
+
const stagesIncluded = [];
|
|
118
|
+
const stagesMissing = [];
|
|
119
|
+
let remaining = 1;
|
|
120
|
+
for (const [name, f] of Object.entries(factors)) {
|
|
121
|
+
if (f.measured && typeof f.value === "number") {
|
|
122
|
+
stagesIncluded.push(name);
|
|
123
|
+
remaining *= 1 - f.value;
|
|
124
|
+
} else stagesMissing.push(name);
|
|
125
|
+
}
|
|
126
|
+
return { measuredReduction: 1 - remaining, stagesIncluded, stagesMissing };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Assemble the full report: factors, composition, raw totals, and a caveat per unmeasured
|
|
131
|
+
* stage plus the workload-dependence caveat — the caveats ship WITH the numbers so no
|
|
132
|
+
* consumer can quote the reduction without its conditions.
|
|
133
|
+
* @param {string} root
|
|
134
|
+
*/
|
|
135
|
+
export function report(root) {
|
|
136
|
+
const factors = stageFactors(root);
|
|
137
|
+
const composed = composedReduction(factors);
|
|
138
|
+
const all = read(root);
|
|
139
|
+
const totals = {
|
|
140
|
+
events: all.length,
|
|
141
|
+
savedEstimateTokens: all.reduce(
|
|
142
|
+
(s, e) => s + (Number.isFinite(e.savedEstimate) ? e.savedEstimate : 0),
|
|
143
|
+
0,
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
const caveats = composed.stagesMissing.map(
|
|
147
|
+
(s) =>
|
|
148
|
+
`stage "${s}" has no recorded events — unmeasured, contributes nothing to the composition`,
|
|
149
|
+
);
|
|
150
|
+
caveats.push(
|
|
151
|
+
"stage rates are workload-dependent: these factors describe this repo's recorded traffic, not a general claim (05-cost-model.md §2)",
|
|
152
|
+
"the composed figure is a lower bound from measured stages only; savings are not correctness-guarded until the P8 paired harness runs (05-cost-model.md §3)",
|
|
153
|
+
);
|
|
154
|
+
return { factors, composed, totals, caveats };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pct = (v) => `${(v * 100).toFixed(1)}%`;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Human rendering. Register matters as much as arithmetic: measured numbers print as
|
|
161
|
+
* measurements, the paper's 62 % routing figure prints as CONTEXT (a citation, not a local
|
|
162
|
+
* result), and the ~90 % figure appears only with the word "target" in front of it — this
|
|
163
|
+
* report never claims it as achieved.
|
|
164
|
+
* @param {ReturnType<typeof report>} r
|
|
165
|
+
*/
|
|
166
|
+
export function renderCostReport(r) {
|
|
167
|
+
const lines = ["Forge cost — measured stage factors (.forge/metrics.jsonl)", ""];
|
|
168
|
+
lines.push(` ${"stage".padEnd(9)} ${"factor".padEnd(10)} events`);
|
|
169
|
+
for (const [name, f] of Object.entries(r.factors)) {
|
|
170
|
+
const shown = f.measured && typeof f.value === "number" ? pct(f.value) : "no data";
|
|
171
|
+
lines.push(` ${name.padEnd(9)} ${shown.padEnd(10)} ${f.events}`);
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push(
|
|
175
|
+
r.composed.stagesIncluded.length
|
|
176
|
+
? ` composed measured reduction: ${pct(r.composed.measuredReduction)} (from: ${r.composed.stagesIncluded.join(", ")}) — lower bound, measured stages only`
|
|
177
|
+
: " composed measured reduction: 0.0% — no stage has recorded events yet",
|
|
178
|
+
);
|
|
179
|
+
lines.push(
|
|
180
|
+
` totals: ${r.totals.events} metric event(s) · ~${r.totals.savedEstimateTokens} tokens saved (stage self-estimates)`,
|
|
181
|
+
);
|
|
182
|
+
lines.push(
|
|
183
|
+
"",
|
|
184
|
+
" context (not a local measurement): the paper measured a 62% routing saving on live tokens (paper §9)",
|
|
185
|
+
" target (unmet until measured): the plan's composed target is ~90% (docs/plans/substrate-v2/05-cost-model.md)",
|
|
186
|
+
);
|
|
187
|
+
lines.push("", " caveats:");
|
|
188
|
+
for (const c of r.caveats) lines.push(` - ${c}`);
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Emit-side helpers — one obvious call per stage, so future wiring (context assembly,
|
|
194
|
+
// real route execution) adds a single line instead of re-deriving the schema. Thin
|
|
195
|
+
// wrappers over metrics.record, which is already best-effort (never throws).
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/** Record one assumption-gate decision: halted = spend avoided.
|
|
199
|
+
* @param {string} root
|
|
200
|
+
* @param {{halted?: boolean, ref?: string}} [opts] */
|
|
201
|
+
export function recordGate(root, { halted, ref } = {}) {
|
|
202
|
+
return record(root, { stage: "gate", outcome: halted ? "halt" : "pass", ref });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Record one routed generation with its tier and real token counts.
|
|
206
|
+
* @param {string} root
|
|
207
|
+
* @param {{tier?: string, tokensIn?: number, tokensOut?: number, ref?: string}} [opts] */
|
|
208
|
+
export function recordRoute(root, { tier, tokensIn, tokensOut, ref } = {}) {
|
|
209
|
+
return record(root, { stage: "route", tier, tokensIn, tokensOut, ref });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Fallback spend estimation from Claude's native JSONL session logs when ccusage
|
|
214
|
+
* is unavailable. Scans ~/.claude/projects/ for session files and computes cost
|
|
215
|
+
* from token counts x model_tiers pricing. Best-effort, never throws.
|
|
216
|
+
*/
|
|
217
|
+
export function estimateSpendFromLogs() {
|
|
218
|
+
try {
|
|
219
|
+
const projectsDir = join(homedir(), ".claude", "projects");
|
|
220
|
+
if (!existsSync(projectsDir)) return null;
|
|
221
|
+
const pricingPerM = {};
|
|
222
|
+
for (const [, m] of Object.entries(MODELS)) {
|
|
223
|
+
pricingPerM[m.id] = { inCost: m.inCost, outCost: m.outCost };
|
|
224
|
+
}
|
|
225
|
+
const byModel = {};
|
|
226
|
+
let sessions = 0;
|
|
227
|
+
for (const project of readdirSync(projectsDir)) {
|
|
228
|
+
const pDir = join(projectsDir, project);
|
|
229
|
+
let files;
|
|
230
|
+
try {
|
|
231
|
+
files = readdirSync(pDir);
|
|
232
|
+
} catch {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
for (const f of files) {
|
|
236
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
237
|
+
sessions++;
|
|
238
|
+
try {
|
|
239
|
+
const lines = readFileSync(join(pDir, f), "utf8").split("\n");
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
if (!line.includes('"usage"')) continue;
|
|
242
|
+
try {
|
|
243
|
+
const entry = JSON.parse(line);
|
|
244
|
+
const usage = entry.usage || entry.message?.usage;
|
|
245
|
+
const model = entry.model || entry.message?.model || "";
|
|
246
|
+
if (!usage) continue;
|
|
247
|
+
const inTok = usage.input_tokens || 0;
|
|
248
|
+
const outTok = usage.output_tokens || 0;
|
|
249
|
+
if (!byModel[model]) byModel[model] = { inTokens: 0, outTokens: 0 };
|
|
250
|
+
byModel[model].inTokens += inTok;
|
|
251
|
+
byModel[model].outTokens += outTok;
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
let totalCost = 0;
|
|
258
|
+
const modelBreakdown = [];
|
|
259
|
+
for (const [model, usage] of Object.entries(byModel)) {
|
|
260
|
+
const pricing = pricingPerM[model] || { inCost: 3, outCost: 15 };
|
|
261
|
+
const cost =
|
|
262
|
+
(usage.inTokens * pricing.inCost + usage.outTokens * pricing.outCost) / 1_000_000;
|
|
263
|
+
totalCost += cost;
|
|
264
|
+
modelBreakdown.push({ model, cost, inTokens: usage.inTokens, outTokens: usage.outTokens });
|
|
265
|
+
}
|
|
266
|
+
modelBreakdown.sort((a, b) => b.cost - a.cost);
|
|
267
|
+
return { totalCost, sessions, byModel: modelBreakdown };
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
package/src/dash.html
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>forge dash</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Taste discipline (07-ui-quality-gate.md): ONE accent (forge ember, the brand's
|
|
9
|
+
own — not framework blue), a 4px-based spacing scale, exactly two radius levels,
|
|
10
|
+
system sans + one mono. Palette matches the landing page = close to home. */
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #171310;
|
|
13
|
+
--panel: #201a15;
|
|
14
|
+
--panel-2: #272019;
|
|
15
|
+
--line: #372c22;
|
|
16
|
+
--fg: #f2ede7;
|
|
17
|
+
--muted: #a99e90;
|
|
18
|
+
--faint: #7d7263;
|
|
19
|
+
--ember: #f26430;
|
|
20
|
+
--ember-wash: rgba(242, 100, 48, 0.14);
|
|
21
|
+
--r-s: 4px; /* radius level 1: chips, bars, inputs */
|
|
22
|
+
--r-m: 10px; /* radius level 2: panels */
|
|
23
|
+
--s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s6: 24px; --s8: 32px;
|
|
24
|
+
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
25
|
+
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
26
|
+
}
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
body {
|
|
29
|
+
margin: 0;
|
|
30
|
+
background: var(--bg);
|
|
31
|
+
color: var(--fg);
|
|
32
|
+
font: 13px/1.55 var(--sans);
|
|
33
|
+
-webkit-font-smoothing: antialiased;
|
|
34
|
+
}
|
|
35
|
+
.mono { font-family: var(--mono); }
|
|
36
|
+
.muted { color: var(--muted); }
|
|
37
|
+
.faint { color: var(--faint); }
|
|
38
|
+
|
|
39
|
+
header {
|
|
40
|
+
position: sticky; top: 0; z-index: 5;
|
|
41
|
+
display: flex; align-items: center; gap: var(--s3);
|
|
42
|
+
padding: var(--s3) var(--s6);
|
|
43
|
+
background: rgba(23, 19, 16, 0.85); backdrop-filter: blur(8px);
|
|
44
|
+
border-bottom: 1px solid var(--line);
|
|
45
|
+
}
|
|
46
|
+
.spark {
|
|
47
|
+
width: 8px; height: 8px; border-radius: var(--r-s);
|
|
48
|
+
background: var(--ember);
|
|
49
|
+
}
|
|
50
|
+
.brand { font-family: var(--mono); font-size: 13px; letter-spacing: 0.02em; }
|
|
51
|
+
.brand b { color: var(--ember); font-weight: 600; }
|
|
52
|
+
#hdr-meta { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
|
53
|
+
button {
|
|
54
|
+
font: 12px var(--mono); color: var(--fg); cursor: pointer;
|
|
55
|
+
background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-s);
|
|
56
|
+
padding: var(--s1) var(--s3);
|
|
57
|
+
}
|
|
58
|
+
button:hover { border-color: var(--ember); color: var(--ember); }
|
|
59
|
+
|
|
60
|
+
main {
|
|
61
|
+
max-width: 1180px; margin: 0 auto; padding: var(--s6);
|
|
62
|
+
display: grid; grid-template-columns: 1fr 1fr; gap: var(--s4);
|
|
63
|
+
}
|
|
64
|
+
.panel {
|
|
65
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-m);
|
|
66
|
+
padding: var(--s4); min-width: 0;
|
|
67
|
+
}
|
|
68
|
+
.panel.wide { grid-column: 1 / -1; }
|
|
69
|
+
@media (max-width: 880px) { main { grid-template-columns: 1fr; } }
|
|
70
|
+
.panel h2 {
|
|
71
|
+
margin: 0 0 var(--s3); font: 600 11px var(--mono);
|
|
72
|
+
text-transform: uppercase; letter-spacing: 0.14em; color: var(--muted);
|
|
73
|
+
}
|
|
74
|
+
.panel h2 .cmd { text-transform: none; letter-spacing: 0; color: var(--faint); float: right; }
|
|
75
|
+
h3 {
|
|
76
|
+
margin: var(--s6) 0 var(--s2); font: 600 11px var(--mono);
|
|
77
|
+
letter-spacing: 0.08em; color: var(--faint); text-transform: uppercase;
|
|
78
|
+
}
|
|
79
|
+
.empty { color: var(--faint); font-family: var(--mono); font-size: 12px; padding: var(--s2) 0; }
|
|
80
|
+
|
|
81
|
+
/* stat tiles */
|
|
82
|
+
.stats { display: flex; flex-wrap: wrap; gap: var(--s2); margin-bottom: var(--s3); }
|
|
83
|
+
.stat {
|
|
84
|
+
background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-s);
|
|
85
|
+
padding: var(--s2) var(--s3); min-width: 84px;
|
|
86
|
+
}
|
|
87
|
+
.stat .n { font: 600 18px var(--mono); display: block; }
|
|
88
|
+
.stat .n.ember { color: var(--ember); }
|
|
89
|
+
.stat .l { font-size: 11px; color: var(--faint); }
|
|
90
|
+
|
|
91
|
+
/* tables */
|
|
92
|
+
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
93
|
+
th {
|
|
94
|
+
text-align: left; font: 600 10px var(--mono); text-transform: uppercase;
|
|
95
|
+
letter-spacing: 0.1em; color: var(--faint);
|
|
96
|
+
padding: var(--s1) var(--s2); border-bottom: 1px solid var(--line);
|
|
97
|
+
}
|
|
98
|
+
td { padding: var(--s1) var(--s2); border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
99
|
+
tr:last-child td { border-bottom: 0; }
|
|
100
|
+
tr.tomb td { opacity: 0.45; }
|
|
101
|
+
tr.tomb .text { text-decoration: line-through; }
|
|
102
|
+
td.num { font-family: var(--mono); text-align: right; }
|
|
103
|
+
.tag {
|
|
104
|
+
font: 11px var(--mono); color: var(--muted);
|
|
105
|
+
background: var(--panel-2); border: 1px solid var(--line);
|
|
106
|
+
border-radius: var(--r-s); padding: 0 var(--s1);
|
|
107
|
+
display: inline-block; white-space: nowrap;
|
|
108
|
+
}
|
|
109
|
+
.tag.contested { color: var(--ember); border-color: var(--ember); background: var(--ember-wash); }
|
|
110
|
+
|
|
111
|
+
/* val / trust / confidence bars — width IS the number; label repeats it */
|
|
112
|
+
.bar {
|
|
113
|
+
display: inline-block; width: 64px; height: 6px; vertical-align: middle;
|
|
114
|
+
background: var(--panel-2); border-radius: var(--r-s); overflow: hidden; margin-right: var(--s2);
|
|
115
|
+
}
|
|
116
|
+
.bar i { display: block; height: 100%; background: var(--ember); border-radius: var(--r-s); }
|
|
117
|
+
.bar.dim i { background: var(--faint); }
|
|
118
|
+
.barnum { font-family: var(--mono); font-size: 11px; }
|
|
119
|
+
|
|
120
|
+
/* the provenance affordance: every number traces to a blame command */
|
|
121
|
+
.blame {
|
|
122
|
+
font: 11px var(--mono); color: var(--faint); cursor: copy; white-space: nowrap;
|
|
123
|
+
}
|
|
124
|
+
.blame:hover { color: var(--ember); }
|
|
125
|
+
.blame.copied { color: var(--ember); }
|
|
126
|
+
|
|
127
|
+
/* the two writes (spec §2): small per-row actions, append-only on the server */
|
|
128
|
+
.act {
|
|
129
|
+
font: 11px var(--mono); color: var(--faint); cursor: pointer;
|
|
130
|
+
border: 1px solid var(--line); border-radius: var(--r-s);
|
|
131
|
+
padding: 0 var(--s1); white-space: nowrap; display: inline-block;
|
|
132
|
+
}
|
|
133
|
+
.act:hover { color: var(--ember); border-color: var(--ember); background: var(--ember-wash); }
|
|
134
|
+
td.acts { white-space: nowrap; }
|
|
135
|
+
|
|
136
|
+
select, input[type="text"] {
|
|
137
|
+
font: 12px var(--mono); color: var(--fg);
|
|
138
|
+
background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-s);
|
|
139
|
+
padding: var(--s1) var(--s2);
|
|
140
|
+
}
|
|
141
|
+
input[type="text"]:focus, select:focus { outline: none; border-color: var(--ember); }
|
|
142
|
+
.toolbar { display: flex; align-items: center; gap: var(--s2); margin-bottom: var(--s3); flex-wrap: wrap; }
|
|
143
|
+
.toolbar .grow { flex: 1; }
|
|
144
|
+
|
|
145
|
+
.log { font: 11px/1.7 var(--mono); color: var(--muted); margin: 0; padding: 0; list-style: none; }
|
|
146
|
+
.log .st { color: var(--fg); }
|
|
147
|
+
.log .plus { color: var(--ember); }
|
|
148
|
+
footer {
|
|
149
|
+
max-width: 1180px; margin: 0 auto; padding: 0 var(--s6) var(--s8);
|
|
150
|
+
font: 11px var(--mono); color: var(--faint);
|
|
151
|
+
}
|
|
152
|
+
</style>
|
|
153
|
+
</head>
|
|
154
|
+
<body>
|
|
155
|
+
<header>
|
|
156
|
+
<span class="spark"></span>
|
|
157
|
+
<span class="brand"><b>forge</b> dash <span id="hdr-repo" class="muted"></span></span>
|
|
158
|
+
<span id="hdr-meta"></span>
|
|
159
|
+
<button id="refresh" type="button">refresh</button>
|
|
160
|
+
</header>
|
|
161
|
+
|
|
162
|
+
<main>
|
|
163
|
+
<section class="panel wide" id="ledger-panel">
|
|
164
|
+
<h2>Ledger — proof-carrying memory <span class="cmd">.forge/ledger/</span></h2>
|
|
165
|
+
<div class="stats" id="ledger-stats"></div>
|
|
166
|
+
<div class="toolbar">
|
|
167
|
+
<label class="faint mono" for="kind-filter">kind</label>
|
|
168
|
+
<select id="kind-filter"><option value="">all</option></select>
|
|
169
|
+
<span class="grow"></span>
|
|
170
|
+
<span id="claim-count" class="mono faint"></span>
|
|
171
|
+
</div>
|
|
172
|
+
<div id="claims"></div>
|
|
173
|
+
<h3>Contested — val ∈ [0.40, 0.60] with ≥1 contradiction</h3>
|
|
174
|
+
<div id="contested"></div>
|
|
175
|
+
<h3>Author trust — earned from oracle outcomes, floored at 0.50</h3>
|
|
176
|
+
<div id="trust"></div>
|
|
177
|
+
</section>
|
|
178
|
+
|
|
179
|
+
<section class="panel" id="cost-panel">
|
|
180
|
+
<h2>Cost / Cache — measured, not asserted <span class="cmd">.forge/metrics.jsonl</span></h2>
|
|
181
|
+
<div class="stats" id="cost-stats"></div>
|
|
182
|
+
<div id="spend-section"></div>
|
|
183
|
+
<div id="stages"></div>
|
|
184
|
+
<h3>Recent events</h3>
|
|
185
|
+
<ul class="log" id="recent"></ul>
|
|
186
|
+
</section>
|
|
187
|
+
|
|
188
|
+
<section class="panel" id="impact-panel">
|
|
189
|
+
<h2>Impact — blast radius <span class="cmd">.forge/atlas.json</span></h2>
|
|
190
|
+
<div class="toolbar">
|
|
191
|
+
<input type="text" id="impact-target" placeholder="symbol or file — e.g. dashData" style="flex:1" />
|
|
192
|
+
<button id="impact-go" type="button">trace</button>
|
|
193
|
+
</div>
|
|
194
|
+
<div id="impact-out" class="empty">pick a symbol to see what a change would touch</div>
|
|
195
|
+
</section>
|
|
196
|
+
</main>
|
|
197
|
+
|
|
198
|
+
<footer>lens on .forge/ — every number traces to <span class="mono">forge ledger blame <id></span>. the only writes are ratify/retract (append-only; CLI twins: <span class="mono">forge ledger ratify|retract</span>).</footer>
|
|
199
|
+
|
|
200
|
+
<script>
|
|
201
|
+
"use strict";
|
|
202
|
+
const $ = (id) => document.getElementById(id);
|
|
203
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
|
|
204
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
205
|
+
|
|
206
|
+
const bar = (v, { dim = false } = {}) =>
|
|
207
|
+
'<span class="bar' + (dim ? " dim" : "") + '"><i style="width:' +
|
|
208
|
+
Math.round(Math.max(0, Math.min(1, v)) * 100) + '%"></i></span><span class="barnum">' +
|
|
209
|
+
v.toFixed(2) + "</span>";
|
|
210
|
+
|
|
211
|
+
// Every claim row carries its provenance affordance — click to copy the exact command.
|
|
212
|
+
const blameCell = (id8) =>
|
|
213
|
+
'<td><span class="blame" data-id8="' + esc(id8) + '">forge ledger blame ' + esc(id8) + "</span></td>";
|
|
214
|
+
|
|
215
|
+
// The two writes (spec §2), per live row. Ratify = human-only hikma promotion (the
|
|
216
|
+
// server stamps YOUR git identity as author); retract = tombstone with a reason.
|
|
217
|
+
// A tombstoned row gets neither — retraction is already the terminal append.
|
|
218
|
+
const actionsCell = (c) => c.tombstoned ? "<td></td>" :
|
|
219
|
+
'<td class="acts"><span class="act" data-act="ratify" data-id8="' + esc(c.id8) +
|
|
220
|
+
'" title="promote to hikma — mints a decision claim with you as author">ratify ▸</span> ' +
|
|
221
|
+
'<span class="act" data-act="retract" data-id8="' + esc(c.id8) +
|
|
222
|
+
'" title="tombstone this claim with a reason (append-only)">retract ✕</span></td>';
|
|
223
|
+
|
|
224
|
+
const claimRows = (claims) => claims.map((c) =>
|
|
225
|
+
'<tr class="' + (c.tombstoned ? "tomb" : "") + '">' +
|
|
226
|
+
'<td class="mono faint">' + esc(c.id8) + "</td>" +
|
|
227
|
+
'<td><span class="tag">' + esc(c.kind) + "</span>" +
|
|
228
|
+
(c.tombstoned ? ' <span class="tag">retracted</span>' : "") + "</td>" +
|
|
229
|
+
"<td>" + bar(c.val, { dim: c.val < 0.35 }) + "</td>" +
|
|
230
|
+
'<td class="num">' + c.evidenceCount + "</td>" +
|
|
231
|
+
'<td class="muted">' + esc(c.author || "—") + "</td>" +
|
|
232
|
+
'<td class="text">' + esc(c.text) + "</td>" +
|
|
233
|
+
blameCell(c.id8) + actionsCell(c) + "</tr>").join("");
|
|
234
|
+
|
|
235
|
+
const claimTable = (claims) =>
|
|
236
|
+
"<table><thead><tr><th>id</th><th>kind</th><th>val</th><th>evid</th><th>author</th>" +
|
|
237
|
+
"<th>claim</th><th>provenance</th><th>actions</th></tr></thead><tbody>" + claimRows(claims) + "</tbody></table>";
|
|
238
|
+
|
|
239
|
+
let DATA = null;
|
|
240
|
+
|
|
241
|
+
function renderLedger(d) {
|
|
242
|
+
const L = d.ledger;
|
|
243
|
+
$("hdr-repo").textContent = "· " + d.repo;
|
|
244
|
+
$("hdr-meta").textContent =
|
|
245
|
+
L.stats.total + " claims · " + L.stats.tombstoned + " tombstoned · day " + d.nowDay;
|
|
246
|
+
$("ledger-stats").innerHTML =
|
|
247
|
+
'<span class="stat"><span class="n">' + L.stats.total + '</span><span class="l">claims</span></span>' +
|
|
248
|
+
'<span class="stat"><span class="n ember">' + L.stats.val.trusted + '</span><span class="l">trusted ≥0.65</span></span>' +
|
|
249
|
+
'<span class="stat"><span class="n">' + L.stats.val.uncertain + '</span><span class="l">uncertain</span></span>' +
|
|
250
|
+
'<span class="stat"><span class="n">' + L.stats.val.dormant + '</span><span class="l">dormant <0.35</span></span>' +
|
|
251
|
+
Object.entries(L.stats.byKind).map(([k, n]) =>
|
|
252
|
+
'<span class="stat"><span class="n">' + n + '</span><span class="l">' + esc(k) + "</span></span>").join("");
|
|
253
|
+
|
|
254
|
+
const kinds = Object.keys(L.stats.byKind).sort();
|
|
255
|
+
const sel = $("kind-filter");
|
|
256
|
+
const keep = sel.value;
|
|
257
|
+
sel.innerHTML = '<option value="">all</option>' +
|
|
258
|
+
kinds.map((k) => '<option value="' + esc(k) + '">' + esc(k) + "</option>").join("");
|
|
259
|
+
if (kinds.includes(keep)) sel.value = keep;
|
|
260
|
+
|
|
261
|
+
const shown = L.claims.filter((c) => !sel.value || c.kind === sel.value);
|
|
262
|
+
$("claim-count").textContent = shown.length + " of " + L.stats.total + " shown";
|
|
263
|
+
$("claims").innerHTML = shown.length ? claimTable(shown)
|
|
264
|
+
: '<div class="empty">no claims yet — mint one: forge remember "<name>" "<fact>"</div>';
|
|
265
|
+
|
|
266
|
+
$("contested").innerHTML = L.contested.length
|
|
267
|
+
? '<span class="tag contested">' + L.contested.length + " contested</span>" + claimTable(L.contested)
|
|
268
|
+
: '<div class="empty">none — no live claim is both uncertain and contradicted</div>';
|
|
269
|
+
|
|
270
|
+
const trust = Object.entries(L.trust).sort((a, b) => b[1] - a[1]);
|
|
271
|
+
$("trust").innerHTML = trust.length
|
|
272
|
+
? "<table><tbody>" + trust.map(([a, u]) =>
|
|
273
|
+
'<tr><td class="muted">' + esc(a) + "</td><td>" + bar(u) + "</td></tr>").join("") +
|
|
274
|
+
"</tbody></table>"
|
|
275
|
+
: '<div class="empty">no authored claims yet</div>';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderCost(d) {
|
|
279
|
+
const stages = Object.entries(d.metrics.stages);
|
|
280
|
+
const saved = stages.reduce((n, [, s]) => n + (s.savedEstimate || 0), 0);
|
|
281
|
+
const events = stages.reduce((n, [, s]) => n + (s.events || 0), 0);
|
|
282
|
+
const spend = d.spend;
|
|
283
|
+
$("cost-stats").innerHTML =
|
|
284
|
+
(spend && spend.totalCost > 0
|
|
285
|
+
? '<span class="stat"><span class="n ember">$' + spend.totalCost.toFixed(2) + '</span><span class="l">real spend</span></span>' +
|
|
286
|
+
'<span class="stat"><span class="n">' + spend.sessions + '</span><span class="l">sessions</span></span>'
|
|
287
|
+
: '') +
|
|
288
|
+
'<span class="stat"><span class="n' + (spend && spend.totalCost > 0 ? '' : ' ember') + '">' + saved.toLocaleString() + '</span><span class="l">est. tokens saved</span></span>' +
|
|
289
|
+
'<span class="stat"><span class="n">' + events + '</span><span class="l">stage events</span></span>';
|
|
290
|
+
if (spend && spend.byModel && spend.byModel.length) {
|
|
291
|
+
$("spend-section").innerHTML = '<h3>Spend by model</h3>' +
|
|
292
|
+
'<table><thead><tr><th>model</th><th>cost</th><th>in tok</th><th>out tok</th></tr></thead><tbody>' +
|
|
293
|
+
spend.byModel.map((m) => '<tr><td class="mono">' + esc(m.model) + '</td><td class="num">$' + m.cost.toFixed(4) +
|
|
294
|
+
'</td><td class="num">' + (m.inTokens || 0).toLocaleString() +
|
|
295
|
+
'</td><td class="num">' + (m.outTokens || 0).toLocaleString() + '</td></tr>').join('') + '</tbody></table>';
|
|
296
|
+
} else {
|
|
297
|
+
$("spend-section").innerHTML = '';
|
|
298
|
+
}
|
|
299
|
+
$("stages").innerHTML = stages.length
|
|
300
|
+
? "<table><thead><tr><th>stage</th><th>events</th><th>outcomes</th><th>saved</th></tr></thead><tbody>" +
|
|
301
|
+
stages.map(([name, s]) =>
|
|
302
|
+
'<tr><td class="mono">' + esc(name) + '</td><td class="num">' + s.events + "</td><td>" +
|
|
303
|
+
Object.entries(s.byOutcome || {}).map(([o, n]) =>
|
|
304
|
+
'<span class="tag">' + esc(o) + " " + n + "</span>").join(" ") +
|
|
305
|
+
'</td><td class="num">' + (s.savedEstimate || 0).toLocaleString() + "</td></tr>").join("") +
|
|
306
|
+
"</tbody></table>"
|
|
307
|
+
: '<div class="empty">no metrics yet — stages append to .forge/metrics.jsonl as you work</div>';
|
|
308
|
+
$("recent").innerHTML = d.metrics.recent.slice().reverse().map((e) =>
|
|
309
|
+
"<li>" + new Date(e.t).toLocaleTimeString() + ' <span class="st">' + esc(e.stage) + "</span> " +
|
|
310
|
+
esc(e.outcome || "") + (e.savedEstimate ? ' <span class="plus">+' + e.savedEstimate + "</span>" : "") +
|
|
311
|
+
"</li>").join("") || '<li class="empty">—</li>';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function runImpact() {
|
|
315
|
+
const target = $("impact-target").value.trim();
|
|
316
|
+
const out = $("impact-out");
|
|
317
|
+
if (!target) return;
|
|
318
|
+
if (DATA && !DATA.atlas.built) {
|
|
319
|
+
out.innerHTML = '<div class="empty">no atlas — run: forge atlas build</div>';
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
out.innerHTML = '<div class="empty">tracing…</div>';
|
|
323
|
+
const res = await fetch("/api/impact?target=" + encodeURIComponent(target));
|
|
324
|
+
const r = await res.json();
|
|
325
|
+
if (!res.ok) { out.innerHTML = '<div class="empty">' + esc(r.error || "error") + "</div>"; return; }
|
|
326
|
+
if (!r.found) {
|
|
327
|
+
out.innerHTML = '<div class="empty">"' + esc(target) + '" not found in the atlas (' +
|
|
328
|
+
r.totalGraphNodes + " nodes)</div>";
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
out.innerHTML =
|
|
332
|
+
'<div class="stats">' +
|
|
333
|
+
'<span class="stat"><span class="n ember">' + r.impactedFiles.length + '</span><span class="l">impacted files</span></span>' +
|
|
334
|
+
'<span class="stat"><span class="n">' + r.impacted.length + '</span><span class="l">nodes</span></span></div>' +
|
|
335
|
+
"<table><thead><tr><th>confidence</th><th>hop</th><th>node</th><th>file</th></tr></thead><tbody>" +
|
|
336
|
+
r.impacted.slice(0, 40).map((x) =>
|
|
337
|
+
"<tr><td>" + bar(x.confidence) + '</td><td class="num">' + (x.hopDistance ?? "llm") +
|
|
338
|
+
'</td><td class="mono">' + esc(x.node.name || x.id) + '</td><td class="muted">' +
|
|
339
|
+
esc(x.node.file || "") + "</td></tr>").join("") +
|
|
340
|
+
"</tbody></table>" +
|
|
341
|
+
(r.impacted.length > 40 ? '<div class="empty">… ' + (r.impacted.length - 40) + " more</div>" : "") +
|
|
342
|
+
'<div class="empty" style="margin-top:8px">same trace on the CLI: forge impact ' + esc(target) + "</div>";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function refresh() {
|
|
346
|
+
const res = await fetch("/api/data");
|
|
347
|
+
DATA = await res.json();
|
|
348
|
+
renderLedger(DATA);
|
|
349
|
+
renderCost(DATA);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// The two writes: POST then refresh — the ledger on disk is the only state, the page
|
|
353
|
+
// just re-reads it. retract prompts for its reason (spec: tombstone WITH a reason).
|
|
354
|
+
async function runWrite(act, id8) {
|
|
355
|
+
const body = { id: id8 };
|
|
356
|
+
if (act === "retract") {
|
|
357
|
+
const reason = prompt(`retract ${id8} — reason (recorded in the tombstone):`);
|
|
358
|
+
if (reason === null) return; // cancelled
|
|
359
|
+
body.reason = reason;
|
|
360
|
+
}
|
|
361
|
+
const res = await fetch(`/api/${act}`, {
|
|
362
|
+
method: "POST",
|
|
363
|
+
headers: { "content-type": "application/json" },
|
|
364
|
+
body: JSON.stringify(body),
|
|
365
|
+
});
|
|
366
|
+
const r = await res.json().catch(() => ({}));
|
|
367
|
+
if (!res.ok) alert(`${act} failed: ${r.reason || r.error || res.status}`);
|
|
368
|
+
await refresh();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
document.addEventListener("click", (ev) => {
|
|
372
|
+
const act = ev.target.closest(".act");
|
|
373
|
+
if (act) {
|
|
374
|
+
runWrite(act.dataset.act, act.dataset.id8).catch(() => {});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const el = ev.target.closest(".blame");
|
|
378
|
+
if (!el) return;
|
|
379
|
+
navigator.clipboard?.writeText("forge ledger blame " + el.dataset.id8).then(() => {
|
|
380
|
+
el.classList.add("copied");
|
|
381
|
+
const prev = el.textContent;
|
|
382
|
+
el.textContent = "copied ✓";
|
|
383
|
+
setTimeout(() => { el.textContent = prev; el.classList.remove("copied"); }, 900);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
$("refresh").addEventListener("click", refresh);
|
|
387
|
+
$("kind-filter").addEventListener("change", () => DATA && renderLedger(DATA));
|
|
388
|
+
$("impact-go").addEventListener("click", runImpact);
|
|
389
|
+
$("impact-target").addEventListener("keydown", (e) => { if (e.key === "Enter") runImpact(); });
|
|
390
|
+
|
|
391
|
+
refresh().catch(() => {
|
|
392
|
+
$("hdr-meta").textContent = "could not load /api/data";
|
|
393
|
+
});
|
|
394
|
+
</script>
|
|
395
|
+
</body>
|
|
396
|
+
</html>
|