@event4u/agent-config 8.8.0 → 8.9.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 +1 -1
- package/CHANGELOG.md +109 -274
- package/README.md +2 -2
- package/dist/agent-src/commands/agent-handoff.md +39 -2
- package/dist/agent-src/commands/research/deep.md +8 -0
- package/dist/agent-src/commands/research/report.md +7 -0
- package/dist/agent-src/commands/roadmap/create.md +12 -0
- package/dist/agent-src/commands/roadmap/process-full.md +31 -5
- package/dist/agent-src/contexts/communication/rules-auto/think-before-action-mechanics.md +30 -1
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +21 -5
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +37 -0
- package/dist/agent-src/contexts/execution/subagent-topologies.md +34 -0
- package/dist/agent-src/contexts/execution/verification-mechanics.md +9 -9
- package/dist/agent-src/rules/agent-authority.md +1 -1
- package/dist/agent-src/rules/communication-through-line.md +1 -1
- package/dist/agent-src/rules/delegation-policy.md +5 -0
- package/dist/agent-src/rules/direct-answers.md +1 -1
- package/dist/agent-src/rules/downstream-changes.md +1 -0
- package/dist/agent-src/rules/external-code-graph-interop.md +65 -0
- package/dist/agent-src/rules/git-history-discipline.md +18 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +6 -1
- package/dist/agent-src/rules/minimal-safe-diff.md +58 -10
- package/dist/agent-src/rules/non-destructive-by-default.md +3 -0
- package/dist/agent-src/rules/question-not-instruction.md +76 -0
- package/dist/agent-src/rules/senior-engineering-discipline.md +1 -1
- package/dist/agent-src/rules/think-before-action.md +1 -0
- package/dist/agent-src/rules/tool-safety.md +8 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +87 -22
- package/dist/agent-src/scripts/update_roadmap_progress.ts +2 -1
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +19 -0
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +10 -3
- package/dist/agent-src/skills/code-review/SKILL.md +2 -0
- package/dist/agent-src/skills/command-writing/SKILL.md +53 -40
- package/dist/agent-src/skills/dcf-modeling/evals/domain-truth.json +29 -0
- package/dist/agent-src/skills/deep-reading-analyst/SKILL.md +1 -0
- package/dist/agent-src/skills/design-intelligence/SKILL.md +49 -12
- package/dist/agent-src/skills/design-review/SKILL.md +29 -15
- package/dist/agent-src/skills/design-system-capture/SKILL.md +63 -24
- package/dist/agent-src/skills/forecasting/evals/domain-truth.json +29 -0
- package/dist/agent-src/skills/frontend-render-security/SKILL.md +2 -2
- package/dist/agent-src/skills/markitdown/SKILL.md +1 -0
- package/dist/agent-src/skills/mcp/SKILL.md +24 -0
- package/dist/agent-src/skills/mcp-builder/SKILL.md +9 -1
- package/dist/agent-src/skills/memory-consolidation/SKILL.md +55 -4
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +39 -5
- package/dist/agent-src/skills/runway-cognition/evals/domain-truth.json +29 -0
- package/dist/agent-src/skills/scenario-modeling/evals/domain-truth.json +29 -0
- package/dist/agent-src/skills/secrets-management/SKILL.md +7 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +12 -3
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +27 -33
- package/dist/agent-src/skills/supply-chain-intake/SKILL.md +12 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +29 -0
- package/dist/agent-src/skills/ui-component-architect/SKILL.md +9 -0
- package/dist/agent-src/skills/unit-economics-modeling/evals/domain-truth.json +40 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +36 -0
- package/dist/cli/registry.js +3 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +90 -48
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -9
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +9 -7
- package/dist/install/install.mjs +136 -1
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/claude_builtin_names.js +145 -0
- package/dist/scripts/_lib/claude_builtin_names.js.map +1 -0
- package/dist/scripts/condense.js +14 -0
- package/dist/scripts/condense.js.map +1 -1
- package/docs/CLAIMS.md +34 -6
- package/docs/architecture.md +2 -2
- package/docs/archive/CHANGELOG-pre-8.9.0.md +306 -0
- package/docs/benchmark.md +113 -0
- package/docs/catalog.md +20 -7
- package/docs/contracts/command-clusters.md +18 -0
- package/docs/contracts/design-artifact-verification.md +124 -0
- package/docs/contracts/injected-block-authenticity-model.md +66 -0
- package/docs/contracts/mcp-tool-inventory.md +28 -28
- package/docs/decisions/ADR-061-corpus-grounding-layer.md +13 -0
- package/docs/decisions/ADR-116-memory-tripwire-activation-path.md +9 -0
- package/docs/getting-started-by-role.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/governance-advantage.md +1 -1
- package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +27 -0
- package/docs/guidelines/agent-infra/emphasis-budget.md +45 -0
- package/docs/guidelines/agent-infra/simplicity-and-goal-demos.md +151 -0
- package/docs/guidelines/agent-infra/tool-description-as-policy.md +35 -0
- package/docs/guidelines/design-canon.md +1 -0
- package/docs/proof.md +36 -21
- package/docs/second-brain-scope.md +69 -26
- package/package.json +1 -1
- package/src/agent-src/scripts/update_roadmap_progress.ts +2 -1
- package/src/scripts/_dispatch.bash +27 -0
- package/src/scripts/_lib/claude_builtin_names.ts +148 -0
- package/src/scripts/_lib/file_slicer.ts +117 -0
- package/src/scripts/_lib/lexical_index.ts +155 -0
- package/src/scripts/_lib/orchestration_record.ts +170 -0
- package/src/scripts/_lib/orchestration_savings.ts +62 -9
- package/src/scripts/_lib/retrieval_sanitize.ts +71 -0
- package/src/scripts/_lib/security_lint.ts +1 -0
- package/src/scripts/_lib/stat_index.ts +98 -0
- package/src/scripts/benchmark.ts +122 -0
- package/src/scripts/build_proof.ts +30 -17
- package/src/scripts/check_memory.ts +2 -0
- package/src/scripts/check_quality_regression.ts +51 -0
- package/src/scripts/check_roadmap_trackable.ts +3 -2
- package/src/scripts/condense.ts +14 -0
- package/src/scripts/discovery_graph.ts +0 -0
- package/src/scripts/external_sources_denylist.json +4 -1
- package/src/scripts/generate_index.ts +5 -2
- package/src/scripts/install-hooks.sh +25 -0
- package/src/scripts/install.ts +35 -2
- package/src/scripts/learning_sidecar.ts +0 -0
- package/src/scripts/lint_agent_skill_names.ts +29 -0
- package/src/scripts/lint_knowledge_scale.ts +4 -3
- package/src/scripts/lint_roadmap_complexity.ts +124 -0
- package/src/scripts/lint_versioned_cache.ts +195 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +2 -1
- package/src/scripts/mcp_server/tools.ts +24 -1
- package/src/scripts/measure_lexical_ranking.ts +173 -0
- package/src/scripts/memory_lookup.ts +0 -0
- package/src/scripts/orchestration_record.ts +140 -0
- package/src/scripts/orchestration_savings_report.ts +29 -5
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/score_domain_truth.ts +192 -0
- package/src/scripts/second_brain_retrieval.ts +278 -0
- package/src/scripts/second_brain_run.ts +341 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled, dependency-free lexical index for memory/knowledge retrieval
|
|
3
|
+
* (road-to-retrieval-substrate-hardening B2).
|
|
4
|
+
*
|
|
5
|
+
* The current `memory_lookup._score` gives every keyword match the SAME coarse
|
|
6
|
+
* bucket (0.8 glob / 0.6 substring), so several entries tie at the top and
|
|
7
|
+
* retrieval "recalls but does not rank" (the mean-tie-set 3.3 finding from
|
|
8
|
+
* road-to-second-brain-retrieval-precision). This module replaces that bucket
|
|
9
|
+
* with a continuous BM25 score over an IDF-weighted term index, plus a
|
|
10
|
+
* character-trigram candidate prefilter so it stays cheap at corpus scale.
|
|
11
|
+
*
|
|
12
|
+
* It is the BM25 core ADR-061 already sanctions — pure Node stdlib, NO engine
|
|
13
|
+
* fork, NO minisearch-class dependency. `_score` stays as the below-tripwire
|
|
14
|
+
* mini-corpus fallback; this index activates at the `lint_knowledge_scale`
|
|
15
|
+
* tripwire. Deterministic: identical docs + query → identical scores + order.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** BM25 term-frequency saturation. */
|
|
19
|
+
export const BM25_K1 = 1.5;
|
|
20
|
+
/** BM25 length-normalisation strength. */
|
|
21
|
+
export const BM25_B = 0.75;
|
|
22
|
+
/** Minimum token length kept (drops single chars / punctuation noise). */
|
|
23
|
+
export const MIN_TOKEN_LEN = 2;
|
|
24
|
+
|
|
25
|
+
export interface IndexDoc {
|
|
26
|
+
id: string;
|
|
27
|
+
/** All searchable text of the entry, already concatenated. */
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DocStats {
|
|
32
|
+
id: string;
|
|
33
|
+
len: number; // token count
|
|
34
|
+
tf: Map<string, number>; // term → frequency in this doc
|
|
35
|
+
trigrams: Set<string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Lowercase, split on non-alphanumeric, keep tokens ≥ MIN_TOKEN_LEN. */
|
|
39
|
+
export function tokenize(s: string): string[] {
|
|
40
|
+
const out: string[] = [];
|
|
41
|
+
for (const raw of s.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
42
|
+
if (raw.length >= MIN_TOKEN_LEN) out.push(raw);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Character trigrams of a normalised string (alphanumerics + single spaces). */
|
|
48
|
+
export function trigrams(s: string): Set<string> {
|
|
49
|
+
const norm = s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
50
|
+
const out = new Set<string>();
|
|
51
|
+
if (norm.length < 3) {
|
|
52
|
+
if (norm.length > 0) out.add(norm);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
for (let i = 0; i + 3 <= norm.length; i++) {
|
|
56
|
+
out.add(norm.slice(i, i + 3));
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An in-memory BM25 index over a fixed document set. Built once, queried many
|
|
63
|
+
* times. Stateless after construction — `rank()` / `score()` never mutate it,
|
|
64
|
+
* so a cached instance is safe to reuse until the underlying corpus changes.
|
|
65
|
+
*/
|
|
66
|
+
export class LexicalIndex {
|
|
67
|
+
private readonly docs: Map<string, DocStats> = new Map();
|
|
68
|
+
private readonly df: Map<string, number> = new Map();
|
|
69
|
+
private readonly n: number;
|
|
70
|
+
private readonly avgdl: number;
|
|
71
|
+
|
|
72
|
+
constructor(documents: readonly IndexDoc[]) {
|
|
73
|
+
let totalLen = 0;
|
|
74
|
+
for (const d of documents) {
|
|
75
|
+
const tokens = tokenize(d.text);
|
|
76
|
+
const tf = new Map<string, number>();
|
|
77
|
+
for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
78
|
+
for (const t of tf.keys()) this.df.set(t, (this.df.get(t) ?? 0) + 1);
|
|
79
|
+
this.docs.set(d.id, { id: d.id, len: tokens.length, tf, trigrams: trigrams(d.text) });
|
|
80
|
+
totalLen += tokens.length;
|
|
81
|
+
}
|
|
82
|
+
this.n = documents.length;
|
|
83
|
+
this.avgdl = this.n > 0 ? totalLen / this.n : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Robertson–Spärck-Jones IDF, floored at 0 so common terms never subtract. */
|
|
87
|
+
private idf(term: string): number {
|
|
88
|
+
const df = this.df.get(term) ?? 0;
|
|
89
|
+
if (df === 0) return 0;
|
|
90
|
+
return Math.max(0, Math.log(1 + (this.n - df + 0.5) / (df + 0.5)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** BM25 score of one document against the tokenised query terms. */
|
|
94
|
+
score(queryTerms: readonly string[], docId: string): number {
|
|
95
|
+
const doc = this.docs.get(docId);
|
|
96
|
+
if (doc === undefined || doc.len === 0) return 0;
|
|
97
|
+
let s = 0;
|
|
98
|
+
for (const term of queryTerms) {
|
|
99
|
+
const tf = doc.tf.get(term);
|
|
100
|
+
if (tf === undefined) continue;
|
|
101
|
+
const idf = this.idf(term);
|
|
102
|
+
if (idf === 0) continue;
|
|
103
|
+
const denom = tf + BM25_K1 * (1 - BM25_B + (BM25_B * doc.len) / (this.avgdl || 1));
|
|
104
|
+
s += (idf * (tf * (BM25_K1 + 1))) / denom;
|
|
105
|
+
}
|
|
106
|
+
return s;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Rank documents for a set of query keys (phrases are tokenised). A
|
|
111
|
+
* character-trigram prefilter selects candidates cheaply; the guard keeps
|
|
112
|
+
* any doc sharing a query TOKEN even when no trigram overlaps, so the
|
|
113
|
+
* prefilter never drops a genuine lexical match. Ties break by id for a
|
|
114
|
+
* stable, deterministic order.
|
|
115
|
+
*/
|
|
116
|
+
rank(queryKeys: readonly string[]): Array<{ id: string; score: number }> {
|
|
117
|
+
const queryText = queryKeys.join(' ');
|
|
118
|
+
const qTerms = tokenize(queryText);
|
|
119
|
+
const qTermSet = new Set(qTerms);
|
|
120
|
+
const qTrigrams = trigrams(queryText);
|
|
121
|
+
|
|
122
|
+
const candidates: string[] = [];
|
|
123
|
+
for (const [id, doc] of this.docs) {
|
|
124
|
+
let hit = false;
|
|
125
|
+
for (const t of qTrigrams) {
|
|
126
|
+
if (doc.trigrams.has(t)) {
|
|
127
|
+
hit = true;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!hit) {
|
|
132
|
+
// Guard: keep a doc that shares an exact query token even with
|
|
133
|
+
// no trigram overlap (short tokens / edge normalisation).
|
|
134
|
+
for (const t of qTermSet) {
|
|
135
|
+
if (doc.tf.has(t)) {
|
|
136
|
+
hit = true;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (hit) candidates.push(id);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const scored = candidates
|
|
145
|
+
.map((id) => ({ id, score: this.score(qTerms, id) }))
|
|
146
|
+
.filter((r) => r.score > 0);
|
|
147
|
+
scored.sort((a, b) => (b.score - a.score) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
148
|
+
return scored;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Document count — for the caller's tripwire / diagnostics. */
|
|
152
|
+
get size(): number {
|
|
153
|
+
return this.n;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestration telemetry record builder (pure, no-I/O).
|
|
3
|
+
*
|
|
4
|
+
* Builds ONE validated audit-log-v1 line carrying an `orchestration`
|
|
5
|
+
* sub-object, from the counts the orchestrating agent already has after an
|
|
6
|
+
* auto-dispatch. This is the agent-behavioral capture path decided 2026-06-30
|
|
7
|
+
* (maintainer decision: no hook/daemon — the agent writes directly), made
|
|
8
|
+
* reliable + one-command here instead of hand-authored JSON.
|
|
9
|
+
*
|
|
10
|
+
* Contracts:
|
|
11
|
+
* - `docs/contracts/audit-log-v1.md` (the envelope)
|
|
12
|
+
* - `src/agent-src/contexts/execution/orchestration-telemetry.md` (the object)
|
|
13
|
+
*
|
|
14
|
+
* PRIVACY BY CONSTRUCTION: every field is a count, enum, or id — there is NO
|
|
15
|
+
* field capable of holding free-form content, prompt text, or a body. Do not
|
|
16
|
+
* add one. (Mirrors `domain-safety-pii` § Surface 2 / artifact-engagement.)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type DispatchOutcome = 'DONE' | 'DONE_WITH_CONCERNS' | 'NEEDS_CONTEXT' | 'BLOCKED' | 'killed';
|
|
20
|
+
export type VerifyMode = 'deterministic' | 'judge' | 'none';
|
|
21
|
+
export type Provenance = 'measured' | 'estimated';
|
|
22
|
+
export type TierChosen = 'lite' | 'medium' | 'high';
|
|
23
|
+
export type TierSource = 'static' | 'inferred' | 'inherit';
|
|
24
|
+
export type Band = 'low' | 'medium' | 'high';
|
|
25
|
+
export type LinePhase = 'refine' | 'memory' | 'analyze' | 'plan' | 'implement' | 'test' | 'verify' | 'report';
|
|
26
|
+
export type LineOutcome = 'success' | 'blocked' | 'skipped' | 'error';
|
|
27
|
+
|
|
28
|
+
/** Inputs the agent supplies about one auto-dispatch. */
|
|
29
|
+
export interface RecordInput {
|
|
30
|
+
// Orchestration object
|
|
31
|
+
spawn_count: number;
|
|
32
|
+
token_delta: number;
|
|
33
|
+
// Optionals accept explicit `undefined` (CLI parsing yields it); the
|
|
34
|
+
// builder defaults every one. Compatible with exactOptionalPropertyTypes.
|
|
35
|
+
token_delta_provenance?: Provenance | undefined;
|
|
36
|
+
tiers?: string[] | undefined;
|
|
37
|
+
tier_chosen?: TierChosen | null | undefined;
|
|
38
|
+
tier_source?: TierSource | null | undefined;
|
|
39
|
+
task_class?: string | null | undefined;
|
|
40
|
+
task_size_estimate?: number | undefined;
|
|
41
|
+
wall_clock_ms?: number | undefined;
|
|
42
|
+
/** Absolute measured tokens the dispatched slice consumed (feeds the modeled cost-%). */
|
|
43
|
+
dispatch_tokens?: number | null | undefined;
|
|
44
|
+
/** The orchestrator's own tier — the baseline the downshift cost-% measures against. */
|
|
45
|
+
session_tier?: string | null | undefined;
|
|
46
|
+
dispatch_outcome?: DispatchOutcome | undefined;
|
|
47
|
+
verify_mode?: VerifyMode | undefined;
|
|
48
|
+
// Audit-log envelope (sensible defaults for a dispatch record)
|
|
49
|
+
phase?: LinePhase | undefined;
|
|
50
|
+
outcome?: LineOutcome | undefined;
|
|
51
|
+
confidence_band?: Band | undefined;
|
|
52
|
+
risk_class?: Band | undefined;
|
|
53
|
+
persona?: string | null | undefined;
|
|
54
|
+
work_id?: string | undefined;
|
|
55
|
+
/** ISO-8601 UTC timestamp; caller supplies (keeps this fn pure/deterministic). */
|
|
56
|
+
ts: string;
|
|
57
|
+
/** Stable id (ULID or content hash); caller supplies. */
|
|
58
|
+
id: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface BuiltLine {
|
|
62
|
+
line: Record<string, unknown> | null;
|
|
63
|
+
errors: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DISPATCH_OUTCOMES: readonly DispatchOutcome[] = ['DONE', 'DONE_WITH_CONCERNS', 'NEEDS_CONTEXT', 'BLOCKED', 'killed'];
|
|
67
|
+
const VERIFY_MODES: readonly VerifyMode[] = ['deterministic', 'judge', 'none'];
|
|
68
|
+
const PROVENANCES: readonly Provenance[] = ['measured', 'estimated'];
|
|
69
|
+
const TIERS_CHOSEN: readonly TierChosen[] = ['lite', 'medium', 'high'];
|
|
70
|
+
const TIER_SOURCES: readonly TierSource[] = ['static', 'inferred', 'inherit'];
|
|
71
|
+
const BANDS: readonly Band[] = ['low', 'medium', 'high'];
|
|
72
|
+
const PHASES: readonly LinePhase[] = ['refine', 'memory', 'analyze', 'plan', 'implement', 'test', 'verify', 'report'];
|
|
73
|
+
|
|
74
|
+
/** Map a dispatch outcome to the audit-log envelope outcome enum. */
|
|
75
|
+
function envelopeOutcome(d: DispatchOutcome): LineOutcome {
|
|
76
|
+
if (d === 'BLOCKED') return 'blocked';
|
|
77
|
+
if (d === 'NEEDS_CONTEXT') return 'skipped';
|
|
78
|
+
if (d === 'killed') return 'error';
|
|
79
|
+
return 'success'; // DONE / DONE_WITH_CONCERNS
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isInt(n: unknown): n is number {
|
|
83
|
+
return typeof n === 'number' && Number.isFinite(n) && Number.isInteger(n);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build + validate the audit-log-v1 line. Returns `{line, errors}`; when
|
|
88
|
+
* `errors` is non-empty, `line` is null and the caller must NOT write.
|
|
89
|
+
*/
|
|
90
|
+
export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
91
|
+
const errors: string[] = [];
|
|
92
|
+
|
|
93
|
+
if (!isInt(input.spawn_count) || input.spawn_count < 1) {
|
|
94
|
+
errors.push('spawn_count must be an integer ≥ 1 (0 = handled in-session, not a dispatch — do not record)');
|
|
95
|
+
}
|
|
96
|
+
if (!isInt(input.token_delta)) errors.push('token_delta must be an integer (negative = net saved vs the in-session baseline)');
|
|
97
|
+
if (input.task_size_estimate !== undefined && (!isInt(input.task_size_estimate) || input.task_size_estimate < 0)) {
|
|
98
|
+
errors.push('task_size_estimate must be a non-negative integer');
|
|
99
|
+
}
|
|
100
|
+
if (input.wall_clock_ms !== undefined && (!isInt(input.wall_clock_ms) || input.wall_clock_ms < 0)) {
|
|
101
|
+
errors.push('wall_clock_ms must be a non-negative integer');
|
|
102
|
+
}
|
|
103
|
+
if (input.dispatch_tokens != null && (!isInt(input.dispatch_tokens) || input.dispatch_tokens < 0)) {
|
|
104
|
+
errors.push('dispatch_tokens must be a non-negative integer (absolute tokens the dispatched slice consumed)');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const prov: Provenance = input.token_delta_provenance ?? 'estimated';
|
|
108
|
+
if (!PROVENANCES.includes(prov)) errors.push(`token_delta_provenance must be one of ${PROVENANCES.join(' | ')}`);
|
|
109
|
+
|
|
110
|
+
const dOutcome: DispatchOutcome = input.dispatch_outcome ?? 'DONE';
|
|
111
|
+
if (!DISPATCH_OUTCOMES.includes(dOutcome)) errors.push(`dispatch_outcome must be one of ${DISPATCH_OUTCOMES.join(' | ')}`);
|
|
112
|
+
|
|
113
|
+
const vMode: VerifyMode = input.verify_mode ?? 'deterministic';
|
|
114
|
+
if (!VERIFY_MODES.includes(vMode)) errors.push(`verify_mode must be one of ${VERIFY_MODES.join(' | ')}`);
|
|
115
|
+
|
|
116
|
+
if (input.tier_chosen != null && !TIERS_CHOSEN.includes(input.tier_chosen)) {
|
|
117
|
+
errors.push(`tier_chosen must be one of ${TIERS_CHOSEN.join(' | ')} (or omitted)`);
|
|
118
|
+
}
|
|
119
|
+
if (input.tier_source != null && !TIER_SOURCES.includes(input.tier_source)) {
|
|
120
|
+
errors.push(`tier_source must be one of ${TIER_SOURCES.join(' | ')} (or omitted)`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const band = input.confidence_band ?? 'medium';
|
|
124
|
+
if (!BANDS.includes(band)) errors.push(`confidence_band must be one of ${BANDS.join(' | ')}`);
|
|
125
|
+
const risk = input.risk_class ?? 'low';
|
|
126
|
+
if (!BANDS.includes(risk)) errors.push(`risk_class must be one of ${BANDS.join(' | ')}`);
|
|
127
|
+
const phase = input.phase ?? 'implement';
|
|
128
|
+
if (!PHASES.includes(phase)) errors.push(`phase must be one of ${PHASES.join(' | ')}`);
|
|
129
|
+
|
|
130
|
+
if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
|
|
131
|
+
if (!input.id) errors.push('id (ULID or content hash) is required');
|
|
132
|
+
|
|
133
|
+
if (errors.length) return { line: null, errors };
|
|
134
|
+
|
|
135
|
+
const orchestration: Record<string, unknown> = {
|
|
136
|
+
task_size_estimate: input.task_size_estimate ?? 0,
|
|
137
|
+
spawn_count: input.spawn_count,
|
|
138
|
+
tiers: input.tiers ?? [],
|
|
139
|
+
token_delta: input.token_delta,
|
|
140
|
+
token_delta_provenance: prov,
|
|
141
|
+
wall_clock_ms: input.wall_clock_ms ?? 0,
|
|
142
|
+
outcome: dOutcome,
|
|
143
|
+
verify_mode: vMode,
|
|
144
|
+
task_class: input.task_class ?? null,
|
|
145
|
+
tier_chosen: input.tier_chosen ?? null,
|
|
146
|
+
tier_source: input.tier_source ?? null,
|
|
147
|
+
dispatch_tokens: input.dispatch_tokens ?? null,
|
|
148
|
+
session_tier: input.session_tier ?? null,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const line: Record<string, unknown> = {
|
|
152
|
+
schema_version: 1,
|
|
153
|
+
id: input.id,
|
|
154
|
+
ts: input.ts,
|
|
155
|
+
work_id: input.work_id ?? `orchestration-${input.ts}`,
|
|
156
|
+
phase,
|
|
157
|
+
outcome: input.outcome ?? envelopeOutcome(dOutcome),
|
|
158
|
+
confidence_band: band,
|
|
159
|
+
risk_class: risk,
|
|
160
|
+
memory: { asks: 0, hits: 0 },
|
|
161
|
+
verify: { claims: 0, first_try_passes: 0 },
|
|
162
|
+
rules_applied: ['delegation-policy'],
|
|
163
|
+
persona: input.persona ?? null,
|
|
164
|
+
input_kind: 'orchestration',
|
|
165
|
+
type: 'phase',
|
|
166
|
+
orchestration,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return { line, errors: [] };
|
|
170
|
+
}
|
|
@@ -5,16 +5,28 @@
|
|
|
5
5
|
* Consumes the `orchestration` sub-object of audit-log-v1 JSONL lines
|
|
6
6
|
* (contract: `src/agent-src/contexts/execution/orchestration-telemetry.md`).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* TWO metrics, kept distinct on purpose:
|
|
9
|
+
* 1. MEASURED token counts — `token_delta` (net token-COUNT delta vs the
|
|
10
|
+
* in-session baseline; negative = fewer tokens). Real, but blind to the
|
|
11
|
+
* downshift RATE win: the same work on a cheaper tier uses ~the same token
|
|
12
|
+
* COUNT, so token_delta ≈ 0 for a pure downshift even though cost dropped.
|
|
13
|
+
* 2. MODELED cost-% (`modeled_cost`) — captures exactly that downshift win via
|
|
14
|
+
* provider-neutral tier weights + absolute `dispatch_tokens`. It is a MODEL
|
|
15
|
+
* (weights + a same-token-count assumption), NOT a measured $ figure, and is
|
|
16
|
+
* clearly labelled as such in the report notes.
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
export type TokenDeltaProvenance = 'measured' | 'estimated';
|
|
17
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Relative, provider-NEUTRAL cost weights per model tier — a tunable ratio, not
|
|
23
|
+
* $ prices. Default reflects the ~5×/~15× cheap-vs-frontier ratios from the
|
|
24
|
+
* cost-downshift council; override per project via the report's `--weights`.
|
|
25
|
+
* Used ONLY for the modeled cost-%, never for the measured token counts.
|
|
26
|
+
*/
|
|
27
|
+
export type TierWeights = Record<string, number>;
|
|
28
|
+
export const DEFAULT_TIER_WEIGHTS: TierWeights = { lite: 1, medium: 5, high: 15 };
|
|
29
|
+
|
|
18
30
|
/** The `orchestration` sub-object shape we read (subset — additive fields only). */
|
|
19
31
|
export interface OrchestrationRecord {
|
|
20
32
|
spawn_count?: number;
|
|
@@ -23,6 +35,10 @@ export interface OrchestrationRecord {
|
|
|
23
35
|
tier_chosen?: string | null;
|
|
24
36
|
task_class?: string | null;
|
|
25
37
|
outcome?: string;
|
|
38
|
+
/** Absolute measured tokens the dispatched slice consumed (for the modeled cost-%). */
|
|
39
|
+
dispatch_tokens?: number | null;
|
|
40
|
+
/** The orchestrator's own tier — the baseline the downshift is measured against. */
|
|
41
|
+
session_tier?: string | null;
|
|
26
42
|
}
|
|
27
43
|
|
|
28
44
|
/** A parsed audit-log-v1 line (only the fields this aggregator touches). */
|
|
@@ -37,6 +53,18 @@ export interface ProvenanceStat {
|
|
|
37
53
|
net_token_delta: number;
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
export interface ModeledCost {
|
|
57
|
+
/** Dispatches with dispatch_tokens + session_tier + tier_chosen all weight-resolvable. */
|
|
58
|
+
covered_dispatches: number;
|
|
59
|
+
/** Σ dispatch_tokens × weight(session_tier) — same tokens costed on the baseline tier. */
|
|
60
|
+
baseline_cost_units: number;
|
|
61
|
+
/** Σ dispatch_tokens × weight(tier_chosen) — modeled cost of the downshifted run. */
|
|
62
|
+
delegated_cost_units: number;
|
|
63
|
+
/** (baseline − delegated) / baseline, 0..1; null when no covered dispatches. MODELED, not measured $. */
|
|
64
|
+
cost_reduction_pct: number | null;
|
|
65
|
+
weights: TierWeights;
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
export interface SavingsReport {
|
|
41
69
|
/** Lines carrying an orchestration object with spawn_count > 0. */
|
|
42
70
|
dispatches: number;
|
|
@@ -54,6 +82,8 @@ export interface SavingsReport {
|
|
|
54
82
|
by_task_class: Record<string, number>;
|
|
55
83
|
/** measured dispatches / dispatches, 0..1 (0 when no dispatches). */
|
|
56
84
|
measured_share: number;
|
|
85
|
+
/** Modeled downshift cost-% (tier-weight based). Distinct from the measured token counts. */
|
|
86
|
+
modeled_cost: ModeledCost;
|
|
57
87
|
/** Honest caveats about what the number does and does not mean. */
|
|
58
88
|
notes: string[];
|
|
59
89
|
}
|
|
@@ -70,7 +100,10 @@ function emptyProvenance(): ProvenanceStat {
|
|
|
70
100
|
* dispatch). Unknown/absent fields default safely (token_delta → 0, provenance
|
|
71
101
|
* → estimated) so a pre-extension line never throws.
|
|
72
102
|
*/
|
|
73
|
-
export function aggregateOrchestrationSavings(
|
|
103
|
+
export function aggregateOrchestrationSavings(
|
|
104
|
+
lines: AuditLine[],
|
|
105
|
+
weights: TierWeights = DEFAULT_TIER_WEIGHTS,
|
|
106
|
+
): SavingsReport {
|
|
74
107
|
const report: SavingsReport = {
|
|
75
108
|
dispatches: 0,
|
|
76
109
|
total_spawns: 0,
|
|
@@ -81,6 +114,7 @@ export function aggregateOrchestrationSavings(lines: AuditLine[]): SavingsReport
|
|
|
81
114
|
by_tier: {},
|
|
82
115
|
by_task_class: {},
|
|
83
116
|
measured_share: 0,
|
|
117
|
+
modeled_cost: { covered_dispatches: 0, baseline_cost_units: 0, delegated_cost_units: 0, cost_reduction_pct: null, weights },
|
|
84
118
|
notes: [],
|
|
85
119
|
};
|
|
86
120
|
|
|
@@ -109,18 +143,37 @@ export function aggregateOrchestrationSavings(lines: AuditLine[]): SavingsReport
|
|
|
109
143
|
|
|
110
144
|
const cls = o.task_class ?? 'unclassified';
|
|
111
145
|
report.by_task_class[cls] = (report.by_task_class[cls] ?? 0) + delta;
|
|
146
|
+
|
|
147
|
+
// Modeled cost — the downshift RATE win token_delta (counts) cannot see:
|
|
148
|
+
// the same dispatch_tokens on a cheaper tier cost less by the weight ratio.
|
|
149
|
+
const dt = o.dispatch_tokens ?? 0;
|
|
150
|
+
const wChosen = o.tier_chosen != null ? weights[o.tier_chosen] : undefined;
|
|
151
|
+
const wSession = o.session_tier != null ? weights[o.session_tier] : undefined;
|
|
152
|
+
if (dt > 0 && wChosen !== undefined && wSession !== undefined) {
|
|
153
|
+
report.modeled_cost.covered_dispatches += 1;
|
|
154
|
+
report.modeled_cost.baseline_cost_units += dt * wSession;
|
|
155
|
+
report.modeled_cost.delegated_cost_units += dt * wChosen;
|
|
156
|
+
}
|
|
112
157
|
}
|
|
113
158
|
|
|
114
159
|
report.measured_share = report.dispatches === 0 ? 0 : report.by_provenance.measured.dispatches / report.dispatches;
|
|
115
160
|
|
|
161
|
+
const mc = report.modeled_cost;
|
|
162
|
+
mc.cost_reduction_pct = mc.baseline_cost_units > 0 ? (mc.baseline_cost_units - mc.delegated_cost_units) / mc.baseline_cost_units : null;
|
|
163
|
+
|
|
116
164
|
// Honest caveats — always attached so a consumer never over-reads the number.
|
|
117
165
|
if (report.dispatches === 0) {
|
|
118
|
-
report.notes.push('No orchestration telemetry yet (0 dispatches).
|
|
166
|
+
report.notes.push('No orchestration telemetry yet (0 dispatches). Data accrues as `subagents.auto: on` delegates real slices (recorded via orchestration_record).');
|
|
119
167
|
} else {
|
|
120
|
-
report.notes.push('
|
|
168
|
+
report.notes.push('Token counts are MEASURED but blind to the downshift rate win (same tokens on a cheaper tier ≈ same count). The MODELED cost-% below captures that axis.');
|
|
121
169
|
if (report.by_provenance.estimated.dispatches > 0) {
|
|
122
170
|
report.notes.push(`${report.by_provenance.estimated.dispatches}/${report.dispatches} dispatch(es) use ESTIMATED token_delta (chars/4, lossy). Prefer measured (host usage metadata).`);
|
|
123
171
|
}
|
|
172
|
+
if (mc.covered_dispatches > 0 && mc.cost_reduction_pct !== null) {
|
|
173
|
+
report.notes.push(`MODELED cost reduction ${(mc.cost_reduction_pct * 100).toFixed(0)}% over ${mc.covered_dispatches}/${report.dispatches} dispatch(es) that carry tier data — from provider-neutral tier weights ${JSON.stringify(mc.weights)} + a same-token-count assumption. A MODEL, NOT a measured $ figure; tune with --weights.`);
|
|
174
|
+
} else {
|
|
175
|
+
report.notes.push('MODELED cost-% unavailable: no dispatch yet carries dispatch_tokens + session_tier + tier_chosen. Record those (orchestration_record --dispatch-tokens/--session-tier/--tier-chosen) to populate it.');
|
|
176
|
+
}
|
|
124
177
|
}
|
|
125
178
|
|
|
126
179
|
return report;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize floor for retrieval read-surfaces
|
|
3
|
+
* (road-to-retrieval-substrate-hardening B6).
|
|
4
|
+
*
|
|
5
|
+
* Corpus content — especially user-ingested knowledge chunks under
|
|
6
|
+
* `agents/memory/knowledge/` — flows into the agent context via `retrieve_v1`
|
|
7
|
+
* / `memory_get_v1`. An attacker (or a compromised ingested source) can embed
|
|
8
|
+
* hidden-instruction vectors (bidi controls, zero-width chars, Unicode Tag
|
|
9
|
+
* block) or control-char noise that the model may act on — the untrusted-input
|
|
10
|
+
* / lethal-trifecta injection surface. This floor strips those vectors from
|
|
11
|
+
* every string field before it is emitted, treating retrieved content as DATA.
|
|
12
|
+
*
|
|
13
|
+
* It deliberately does NOT rewrite visible text (that would corrupt legitimate
|
|
14
|
+
* rule/chunk bodies and code snippets) — the injection risk is the *invisible*
|
|
15
|
+
* layer + unbounded length; visible content is the agent's to read as data.
|
|
16
|
+
* Codepoint classes are shared with `lint_hidden_unicode` (one source of truth
|
|
17
|
+
* for what counts as a hidden-instruction vector).
|
|
18
|
+
*/
|
|
19
|
+
import { _classify } from '../lint_hidden_unicode.js';
|
|
20
|
+
|
|
21
|
+
/** Hard per-field length cap — bounds a runaway/adversarial body. */
|
|
22
|
+
export const MAX_FIELD_CHARS = 8192;
|
|
23
|
+
|
|
24
|
+
/** True for a C0/C1 control char that is NOT a benign whitespace (\t, \n). */
|
|
25
|
+
function _isStrippableControl(cp: number): boolean {
|
|
26
|
+
if (cp === 0x09 || cp === 0x0a) return false; // keep tab + newline
|
|
27
|
+
if (cp <= 0x1f) return true; // C0 controls (incl. CR 0x0d — normalised out)
|
|
28
|
+
if (cp === 0x7f) return true; // DEL
|
|
29
|
+
if (cp >= 0x80 && cp <= 0x9f) return true; // C1 controls
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Strip hidden-instruction vectors (bidi / zero-width / Unicode-tag /
|
|
35
|
+
* deprecated-format, per `_classify`) and stray control chars from a string,
|
|
36
|
+
* then cap its length. Preserves all visible content + tabs/newlines.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitize_text(s: string): string {
|
|
39
|
+
let out = '';
|
|
40
|
+
for (const ch of s) {
|
|
41
|
+
const cp = ch.codePointAt(0);
|
|
42
|
+
if (cp === undefined) continue;
|
|
43
|
+
if (_classify(cp) !== null) continue; // hidden-instruction vector → drop
|
|
44
|
+
if (_isStrippableControl(cp)) continue; // control-char noise → drop
|
|
45
|
+
out += ch;
|
|
46
|
+
}
|
|
47
|
+
return out.length > MAX_FIELD_CHARS ? out.slice(0, MAX_FIELD_CHARS) : out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Return a shallow copy of a retrieval entry with every string value (and
|
|
52
|
+
* one level of string-array values) sanitized. Non-string values pass through
|
|
53
|
+
* unchanged (numbers, booleans, the PyFloat marker, nested plain objects are
|
|
54
|
+
* sanitized recursively).
|
|
55
|
+
*/
|
|
56
|
+
export function sanitize_entry(entry: Record<string, unknown>): Record<string, unknown> {
|
|
57
|
+
const out: Record<string, unknown> = {};
|
|
58
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
59
|
+
out[k] = _sanitize_value(v);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _sanitize_value(v: unknown): unknown {
|
|
65
|
+
if (typeof v === 'string') return sanitize_text(v);
|
|
66
|
+
if (Array.isArray(v)) return v.map((x) => (typeof x === 'string' ? sanitize_text(x) : _sanitize_value(x)));
|
|
67
|
+
if (v && typeof v === 'object' && v.constructor === Object) {
|
|
68
|
+
return sanitize_entry(v as Record<string, unknown>);
|
|
69
|
+
}
|
|
70
|
+
return v; // numbers, booleans, null, marker classes (PyFloat/PyTimestamp) untouched
|
|
71
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stat-index — skip an expensive scan when the input tree is unchanged
|
|
3
|
+
* (road-to-retrieval-substrate-hardening B5a).
|
|
4
|
+
*
|
|
5
|
+
* The memory / knowledge / discovery scanners re-read their whole file set on
|
|
6
|
+
* every `stop` / `session_start` hook, even when nothing changed. A stat-index
|
|
7
|
+
* captures a cheap signature of the input files (size + mtime_ns) and caches
|
|
8
|
+
* the scan's result beside it; a later call with the same signature returns the
|
|
9
|
+
* cached payload without re-scanning. The cache is version-namespaced (so a
|
|
10
|
+
* tool bump invalidates cleanly, versioned-cache lint B5b) and written
|
|
11
|
+
* atomically (pid-temp + rename) so concurrent hook invocations stay
|
|
12
|
+
* idempotent (council Q3).
|
|
13
|
+
*
|
|
14
|
+
* `size + mtime_ns` is sufficient for gating rebuild decisions (the council
|
|
15
|
+
* withdrew the crypto-binding demand as a non-problem); a `--force` bypass and
|
|
16
|
+
* an explicit `clear()` cover the rare stale-mtime case (e.g. `cp -p`).
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import process from 'node:process';
|
|
21
|
+
|
|
22
|
+
/** A deterministic signature over a file set: `<count>:<Σsize>:<maxMtimeNs>:<hashOfPaths>`. */
|
|
23
|
+
export function statSignature(files: readonly string[]): string {
|
|
24
|
+
let count = 0;
|
|
25
|
+
let totalSize = 0;
|
|
26
|
+
let maxMtimeNs = 0n;
|
|
27
|
+
const sortedPaths = [...files].sort();
|
|
28
|
+
for (const f of sortedPaths) {
|
|
29
|
+
let st: fs.Stats;
|
|
30
|
+
try {
|
|
31
|
+
st = fs.statSync(f, { bigint: true }) as unknown as fs.Stats;
|
|
32
|
+
} catch {
|
|
33
|
+
continue; // missing file → excluded from the signature
|
|
34
|
+
}
|
|
35
|
+
count += 1;
|
|
36
|
+
totalSize += Number((st as unknown as { size: bigint }).size);
|
|
37
|
+
const m = (st as unknown as { mtimeNs: bigint }).mtimeNs;
|
|
38
|
+
if (m > maxMtimeNs) maxMtimeNs = m;
|
|
39
|
+
}
|
|
40
|
+
// A stable hash of the path list so add/remove/rename shifts the signature
|
|
41
|
+
// even when count + size + mtime coincide.
|
|
42
|
+
let h = 5381;
|
|
43
|
+
for (const p of sortedPaths) {
|
|
44
|
+
for (let i = 0; i < p.length; i++) h = ((h * 33) ^ p.charCodeAt(i)) >>> 0;
|
|
45
|
+
}
|
|
46
|
+
return `${count}:${totalSize}:${maxMtimeNs.toString()}:${h.toString(16)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface CacheFile<T> {
|
|
50
|
+
schema_version: number;
|
|
51
|
+
signature: string;
|
|
52
|
+
payload: T;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Return the cached payload when the file-set signature is unchanged; otherwise
|
|
57
|
+
* run `compute`, persist the result under `signature`, and return it.
|
|
58
|
+
*
|
|
59
|
+
* @param cachePath version-namespaced cache file (e.g. `…-index-v1.json`)
|
|
60
|
+
* @param files the input file set whose stat signature gates the cache
|
|
61
|
+
* @param compute the expensive scan, run only on a miss
|
|
62
|
+
* @param force bypass the cache and always recompute (then persist)
|
|
63
|
+
*/
|
|
64
|
+
export function scanCached<T>(
|
|
65
|
+
cachePath: string,
|
|
66
|
+
files: readonly string[],
|
|
67
|
+
compute: () => T,
|
|
68
|
+
force = false,
|
|
69
|
+
): T {
|
|
70
|
+
const signature = statSignature(files);
|
|
71
|
+
if (!force) {
|
|
72
|
+
try {
|
|
73
|
+
const cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as CacheFile<T>;
|
|
74
|
+
if (cached.signature === signature) return cached.payload;
|
|
75
|
+
} catch {
|
|
76
|
+
/* miss → recompute */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const payload = compute();
|
|
80
|
+
writeCacheAtomic<T>(cachePath, { schema_version: 1, signature, payload });
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeCacheAtomic<T>(cachePath: string, data: CacheFile<T>): void {
|
|
85
|
+
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
86
|
+
const tmp = `${cachePath}.${process.pid}.tmp`;
|
|
87
|
+
fs.writeFileSync(tmp, JSON.stringify(data));
|
|
88
|
+
fs.renameSync(tmp, cachePath);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Drop a stat-index cache file (for `--force`-style resets / tests). */
|
|
92
|
+
export function clear(cachePath: string): void {
|
|
93
|
+
try {
|
|
94
|
+
fs.rmSync(cachePath);
|
|
95
|
+
} catch {
|
|
96
|
+
/* already absent */
|
|
97
|
+
}
|
|
98
|
+
}
|