@cohortapp/agent-sdk 2.3.1 → 2.4.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/bin/maestro.mjs +37 -50
- package/framework-features.json +30 -0
- package/lib/backlog.mjs +136 -0
- package/lib/cadences.mjs +63 -2
- package/lib/cadences.test.mjs +105 -0
- package/lib/capability/inventory.mjs +542 -0
- package/lib/capability/inventory.test.mjs +232 -0
- package/lib/capability/probe.mjs +255 -0
- package/lib/channels/contract.mjs +37 -1
- package/lib/channels/contract.test.mjs +25 -1
- package/lib/channels/inbox-item.mjs +20 -0
- package/lib/claude-bin.mjs +37 -3
- package/lib/claude-bin.test.mjs +42 -8
- package/lib/execution/disposition.mjs +501 -0
- package/lib/execution/disposition.test.mjs +482 -0
- package/lib/execution/drive.mjs +352 -0
- package/lib/execution/drive.test.mjs +270 -0
- package/lib/execution/effects.mjs +340 -0
- package/lib/execution/effects.test.mjs +193 -0
- package/lib/execution/index.mjs +152 -0
- package/lib/execution/intake.mjs +581 -0
- package/lib/execution/intake.test.mjs +343 -0
- package/lib/execution/journal.mjs +374 -0
- package/lib/execution/journal.test.mjs +261 -0
- package/lib/execution/match.mjs +331 -0
- package/lib/execution/match.test.mjs +235 -0
- package/lib/execution/pipeline.mjs +341 -0
- package/lib/execution/pipeline.test.mjs +389 -0
- package/lib/execution/route.mjs +332 -0
- package/lib/execution/route.test.mjs +186 -0
- package/lib/execution/surface-policy.mjs +446 -0
- package/lib/execution/surface-policy.test.mjs +162 -0
- package/lib/goals/admission.mjs +209 -0
- package/lib/goals/admission.test.mjs +139 -0
- package/lib/goals/classify.mjs +206 -0
- package/lib/goals/classify.test.mjs +109 -0
- package/lib/goals/collaborate.mjs +415 -0
- package/lib/goals/collaborate.test.mjs +324 -0
- package/lib/goals/gaps.mjs +111 -0
- package/lib/goals/gaps.test.mjs +284 -0
- package/lib/goals/loop.mjs +537 -0
- package/lib/goals/loop.test.mjs +719 -0
- package/lib/identity/persona.mjs +247 -0
- package/lib/identity/persona.test.mjs +117 -0
- package/lib/kpi.mjs +469 -0
- package/lib/kpi.test.mjs +244 -0
- package/lib/mandate/audit.mjs +168 -0
- package/lib/mandate/audit.test.mjs +195 -0
- package/lib/mandate/cache.mjs +162 -0
- package/lib/mandate/derive.mjs +317 -0
- package/lib/mandate/derive.test.mjs +224 -0
- package/lib/mandate/model.mjs +352 -0
- package/lib/mandate/model.test.mjs +145 -0
- package/lib/mandate/refresh.mjs +187 -0
- package/lib/mandate/refresh.test.mjs +293 -0
- package/lib/mcp/server.test.mjs +4 -4
- package/lib/org/approvals.mjs +14 -2
- package/lib/org/client.mjs +79 -25
- package/lib/org/client.test.mjs +54 -1
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- package/lib/org/inbound/directedness.mjs +720 -0
- package/lib/org/inbound/directedness.test.mjs +543 -0
- package/lib/org/inbound/facts.mjs +501 -0
- package/lib/org/inbound/facts.test.mjs +375 -0
- package/lib/org/inbound/hydrate.mjs +535 -0
- package/lib/org/inbound/hydrate.test.mjs +326 -0
- package/lib/org/inbound/index.mjs +233 -0
- package/lib/org/inbound/index.test.mjs +324 -0
- package/lib/org/inbound/io.mjs +141 -0
- package/lib/org/inbound/project.mjs +201 -0
- package/lib/org/inbound/project.test.mjs +287 -0
- package/lib/org/inbound/surfaces.mjs +257 -0
- package/lib/org/knowledge.mjs +10 -1
- package/lib/org/knowledge.test.mjs +8 -1
- package/lib/org/leases.mjs +5 -0
- package/lib/org/mesh.mjs +45 -2
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +180 -15
- package/lib/org/messaging.test.mjs +117 -0
- package/lib/org/param-contract.mjs +694 -0
- package/lib/org/param-contract.test.mjs +451 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +8 -0
- package/lib/org/protocol.test.mjs +5 -1
- package/lib/org/push.mjs +1025 -0
- package/lib/org/push.test.mjs +690 -0
- package/lib/org/tool-surface.mjs +138 -38
- package/lib/org/tool-surface.test.mjs +13 -8
- package/lib/org/typing.mjs +341 -0
- package/lib/org/typing.test.mjs +291 -0
- package/lib/plan/compile.mjs +510 -0
- package/lib/plan/compile.test.mjs +286 -0
- package/lib/plan/emit.mjs +256 -0
- package/lib/plan/emit.test.mjs +246 -0
- package/lib/plan/explain.mjs +226 -0
- package/lib/plan/explain.test.mjs +188 -0
- package/lib/plan/schema.mjs +140 -0
- package/lib/resource-governor.mjs +47 -1
- package/lib/resource-governor.test.mjs +21 -1
- package/lib/setup/enroll-from-cohort.mjs +84 -16
- package/lib/setup/enroll-from-cohort.test.mjs +43 -1
- package/lib/setup/sections/identity.mjs +15 -4
- package/lib/setup/sections/identity.test.mjs +94 -0
- package/lib/setup/sections/inventory.mjs +178 -0
- package/lib/setup/sections/inventory.test.mjs +198 -0
- package/lib/setup/sections/mandate.mjs +392 -0
- package/lib/setup/sections/mandate.test.mjs +373 -0
- package/lib/setup/sections/subagents.mjs +427 -0
- package/lib/setup/sections/subagents.test.mjs +429 -0
- package/lib/setup/sections/verify.mjs +121 -0
- package/lib/setup/sections/verify.test.mjs +175 -0
- package/lib/setup/sot.mjs +2 -0
- package/lib/subagents/cli.mjs +463 -0
- package/lib/subagents/cli.test.mjs +389 -0
- package/lib/subagents/client.mjs +373 -0
- package/lib/subagents/client.test.mjs +309 -0
- package/lib/subagents/gap.mjs +268 -0
- package/lib/subagents/gap.test.mjs +234 -0
- package/lib/subagents/lock.mjs +296 -0
- package/lib/subagents/lock.test.mjs +248 -0
- package/lib/subagents/manifest.mjs +224 -0
- package/lib/subagents/manifest.test.mjs +175 -0
- package/lib/subagents/refs.mjs +274 -0
- package/lib/subagents/refs.test.mjs +204 -0
- package/lib/subagents/resolve.mjs +455 -0
- package/lib/subagents/resolve.test.mjs +422 -0
- package/lib/subagents/schema.mjs +467 -0
- package/lib/subagents/schema.test.mjs +306 -0
- package/package.json +9 -4
- package/plugins/maestro-skills/.claude-plugin/marketplace.json +16 -0
- package/policies/ai-disclosure.yaml +42 -2
- package/scaffold/CLAUDE.md +16 -2
- package/schedules/triggers/goal-steward.md +79 -0
- package/scripts/ci/conformance-org-api.mjs +792 -0
- package/scripts/ci/conformance-org-api.test.mjs +417 -0
- package/scripts/daemon/agent-daemon.mjs +70 -11
- package/scripts/daemon/cadence-handlers.mjs +187 -5
- package/scripts/daemon/goal-steward-cadence.test.mjs +243 -0
- package/scripts/daemon/inbox-deferral.mjs +45 -2
- package/scripts/daemon/inbox-deferral.test.mjs +56 -0
- package/scripts/daemon/inbox-wake.mjs +282 -0
- package/scripts/daemon/inbox-wake.test.mjs +199 -0
- package/scripts/daemon/maestro-daemon.mjs +23 -0
- package/scripts/daemon/prompt-builder.mjs +41 -1
- package/scripts/daemon/responder.mjs +56 -0
- package/scripts/daemon/typing-registry.mjs +55 -2
- package/scripts/daemon/typing-registry.test.mjs +25 -0
- package/scripts/local-triggers/generate-plists.test.mjs +5 -5
- package/scripts/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
- package/scripts/setup/gen-subagent-manifest.mjs +95 -0
- package/scripts/setup/gen-subagent-manifest.test.mjs +124 -0
- package/scripts/setup/generate-plan.mjs +108 -0
- package/scripts/setup/init-capability-manifest.mjs +70 -0
- package/scripts/setup/init-skill-marketplace.mjs +155 -0
- package/scripts/setup/init-skill-marketplace.test.mjs +193 -0
package/lib/kpi.mjs
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/kpi.mjs — measurement, the time series, and the gap.
|
|
3
|
+
*
|
|
4
|
+
* This is the missing half of "the agent has objectives": neither repo has ever
|
|
5
|
+
* held a KPI TIME SERIES, so no agent could tell whether it was improving, flat
|
|
6
|
+
* or sliding — which means no agent could decide whether to do more work, ask a
|
|
7
|
+
* question, or leave the number alone. `gapFor()` is that decision's input.
|
|
8
|
+
*
|
|
9
|
+
* Three rules the rest of the system leans on:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Honesty is a property of the SAMPLE, not of the caller.** A sample's
|
|
12
|
+
* `source` is `method` only when a real capability actually executed and
|
|
13
|
+
* returned a number; `human` when a person supplied it; `llm` when a model
|
|
14
|
+
* guessed. An `llm`-sourced sample is advisory and can NEVER create work
|
|
15
|
+
* (enforced in goals/admission.mjs). This is the Goodhart gate.
|
|
16
|
+
* 2. **An unreachable sensor is a measurement, not an exception.** It returns
|
|
17
|
+
* `{ok:false, reason}` and is logged; it never throws into the daemon and
|
|
18
|
+
* it never quietly records a zero.
|
|
19
|
+
* 3. **Idempotent on `(objectiveKey, window)`.** Re-running the steward inside
|
|
20
|
+
* the same week does not double-count; the same window is overwritten only
|
|
21
|
+
* when a better source arrives (`llm` → `human` → `method`).
|
|
22
|
+
*
|
|
23
|
+
* Injectable everywhere: the clock, the sensor registry, and the ledger path all
|
|
24
|
+
* arrive on `deps`, so tests are hermetic and the network is never touched.
|
|
25
|
+
*
|
|
26
|
+
* @module lib/kpi
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
"use strict";
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
32
|
+
import { join, dirname } from "node:path";
|
|
33
|
+
|
|
34
|
+
import { resolveAgentRoot } from "./agent-root.mjs";
|
|
35
|
+
import { appendJsonl } from "./fs-atomic.mjs";
|
|
36
|
+
|
|
37
|
+
export const MEASUREMENTS_REL = join("state", "kpi", "measurements.jsonl");
|
|
38
|
+
export const INTERVENTIONS_REL = join("state", "kpi", "interventions.jsonl");
|
|
39
|
+
|
|
40
|
+
/** Sample sources, weakest → strongest. A stronger source may overwrite a window. */
|
|
41
|
+
export const SOURCE_RANK = Object.freeze({ llm: 0, human: 1, method: 2 });
|
|
42
|
+
|
|
43
|
+
/** Sources a sample must carry to be allowed to CREATE work. */
|
|
44
|
+
export const WORK_CREATING_SOURCES = Object.freeze(["method", "human"]);
|
|
45
|
+
|
|
46
|
+
/** How many periods of no movement counts as "flat". */
|
|
47
|
+
export const FLAT_PERIODS = 2;
|
|
48
|
+
|
|
49
|
+
function nowMs(deps) {
|
|
50
|
+
return deps && typeof deps.now === "function" ? deps.now() : Date.now();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function logOf(deps) {
|
|
54
|
+
return deps && typeof deps.log === "function" ? deps.log : () => {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Windows
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** ISO-8601 week number + ISO week-year for a date. Pure. */
|
|
62
|
+
export function isoWeek(date) {
|
|
63
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
64
|
+
// Thursday of the current week determines the ISO year.
|
|
65
|
+
const dayNum = (d.getUTCDay() + 6) % 7;
|
|
66
|
+
d.setUTCDate(d.getUTCDate() - dayNum + 3);
|
|
67
|
+
const isoYear = d.getUTCFullYear();
|
|
68
|
+
const firstThursday = new Date(Date.UTC(isoYear, 0, 4));
|
|
69
|
+
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
|
|
70
|
+
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
|
|
71
|
+
const week = 1 + Math.round((d - firstThursday) / (7 * 24 * 3600 * 1000));
|
|
72
|
+
return { isoYear, week };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The measurement window key for a cadence at a point in time. This is the
|
|
77
|
+
* idempotency key half of `(objectiveKey, window)`.
|
|
78
|
+
* weekly → "2026-W33"
|
|
79
|
+
* monthly → "2026-08"
|
|
80
|
+
* quarterly → "2026-Q3"
|
|
81
|
+
* daily/any → "2026-08-11"
|
|
82
|
+
* @param {string} cadence @param {number|Date} at @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function windowFor(cadence, at) {
|
|
85
|
+
const d = at instanceof Date ? at : new Date(at || Date.now());
|
|
86
|
+
const y = d.getUTCFullYear();
|
|
87
|
+
switch (String(cadence || "").toLowerCase()) {
|
|
88
|
+
case "weekly": {
|
|
89
|
+
const { isoYear, week } = isoWeek(d);
|
|
90
|
+
return `${isoYear}-W${String(week).padStart(2, "0")}`;
|
|
91
|
+
}
|
|
92
|
+
case "monthly":
|
|
93
|
+
return `${y}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
94
|
+
case "quarterly":
|
|
95
|
+
return `${y}-Q${Math.floor(d.getUTCMonth() / 3) + 1}`;
|
|
96
|
+
default:
|
|
97
|
+
return d.toISOString().slice(0, 10);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Is this objective due to be measured? True when its current window has no
|
|
103
|
+
* sample yet. An objective with no cadence is never due (and is warned about by
|
|
104
|
+
* `model.validateMandateBody`) — silence there would be the bug.
|
|
105
|
+
* @param {object} objective @param {object[]} series @param {object} [deps] { now }
|
|
106
|
+
* @returns {{due:boolean, window:string|null, reason:string}}
|
|
107
|
+
*/
|
|
108
|
+
export function isDue(objective, series, deps) {
|
|
109
|
+
if (!objective || !objective.cadence) {
|
|
110
|
+
return { due: false, window: null, reason: "objective has no measure cadence" };
|
|
111
|
+
}
|
|
112
|
+
const window = windowFor(objective.cadence, nowMs(deps));
|
|
113
|
+
const hit = (series || []).find((s) => s && s.window === window);
|
|
114
|
+
if (hit) return { due: false, window, reason: `already sampled for ${window}` };
|
|
115
|
+
return { due: true, window, reason: `no sample for ${window}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// The ledger
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/** Absolute path of the measurement ledger. */
|
|
123
|
+
export function measurementsPath(agentRoot) {
|
|
124
|
+
return join(resolveAgentRoot(agentRoot), MEASUREMENTS_REL);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Absolute path of the intervention (learning-signal) ledger. */
|
|
128
|
+
export function interventionsPath(agentRoot) {
|
|
129
|
+
return join(resolveAgentRoot(agentRoot), INTERVENTIONS_REL);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readJsonl(path) {
|
|
133
|
+
if (!existsSync(path)) return [];
|
|
134
|
+
let body;
|
|
135
|
+
try { body = readFileSync(path, "utf-8"); } catch { return []; }
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const line of body.split("\n")) {
|
|
138
|
+
if (!line.trim()) continue;
|
|
139
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every sample for an objective, oldest first. When two rows share a window the
|
|
146
|
+
* strongest source wins (a `method` re-measure supersedes an `llm` guess).
|
|
147
|
+
* @param {string} agentRoot @param {string} objectiveKey
|
|
148
|
+
* @returns {object[]}
|
|
149
|
+
*/
|
|
150
|
+
export function readSeries(agentRoot, objectiveKey) {
|
|
151
|
+
const rows = readJsonl(measurementsPath(agentRoot)).filter(
|
|
152
|
+
(r) => r && (!objectiveKey || r.objectiveKey === objectiveKey)
|
|
153
|
+
);
|
|
154
|
+
const byWindow = new Map();
|
|
155
|
+
for (const r of rows) {
|
|
156
|
+
const prev = byWindow.get(r.window);
|
|
157
|
+
if (!prev) { byWindow.set(r.window, r); continue; }
|
|
158
|
+
const better =
|
|
159
|
+
(SOURCE_RANK[r.source] ?? -1) > (SOURCE_RANK[prev.source] ?? -1) ||
|
|
160
|
+
((SOURCE_RANK[r.source] ?? -1) === (SOURCE_RANK[prev.source] ?? -1) &&
|
|
161
|
+
String(r.at || "") >= String(prev.at || ""));
|
|
162
|
+
if (better) byWindow.set(r.window, r);
|
|
163
|
+
}
|
|
164
|
+
return [...byWindow.values()].sort((a, b) => String(a.window).localeCompare(String(b.window)));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Append a sample. Idempotent on `(objectiveKey, window)`: a duplicate of equal
|
|
169
|
+
* or weaker source is refused (and reported — never silently swallowed).
|
|
170
|
+
*
|
|
171
|
+
* @param {string} agentRoot
|
|
172
|
+
* @param {{objectiveKey:string, value:number, window:string, source:string, evidence?:object}} sample
|
|
173
|
+
* @param {object} [deps] { now, log }
|
|
174
|
+
* @returns {{recorded:boolean, reason:string, sample:object|null}}
|
|
175
|
+
*/
|
|
176
|
+
export function recordMeasurement(agentRoot, sample, deps = {}) {
|
|
177
|
+
const key = sample && sample.objectiveKey;
|
|
178
|
+
const value = Number(sample && sample.value);
|
|
179
|
+
if (!key) return { recorded: false, reason: "no objectiveKey", sample: null };
|
|
180
|
+
if (!Number.isFinite(value)) {
|
|
181
|
+
logOf(deps)("warn", `[kpi] refusing a non-numeric sample for "${key}" (value=${sample && sample.value})`);
|
|
182
|
+
return { recorded: false, reason: "non-numeric value", sample: null };
|
|
183
|
+
}
|
|
184
|
+
const source = String((sample && sample.source) || "llm");
|
|
185
|
+
if (!(source in SOURCE_RANK)) {
|
|
186
|
+
logOf(deps)("warn", `[kpi] unknown sample source "${source}" for "${key}" — treated as llm (advisory only)`);
|
|
187
|
+
}
|
|
188
|
+
const window = String((sample && sample.window) || windowFor("daily", nowMs(deps)));
|
|
189
|
+
|
|
190
|
+
const existing = readSeries(agentRoot, key).find((s) => s.window === window);
|
|
191
|
+
if (existing && (SOURCE_RANK[existing.source] ?? -1) >= (SOURCE_RANK[source] ?? -1)) {
|
|
192
|
+
logOf(deps)(
|
|
193
|
+
"info",
|
|
194
|
+
`[kpi] "${key}" already has a ${existing.source} sample for ${window} — refusing a ${source} duplicate (idempotent)`
|
|
195
|
+
);
|
|
196
|
+
return { recorded: false, reason: "duplicate-window", sample: existing };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rec = {
|
|
200
|
+
objectiveKey: key,
|
|
201
|
+
objectiveId: (sample && sample.objectiveId) || null,
|
|
202
|
+
value,
|
|
203
|
+
window,
|
|
204
|
+
at: new Date(nowMs(deps)).toISOString(),
|
|
205
|
+
source: source in SOURCE_RANK ? source : "llm",
|
|
206
|
+
evidence: (sample && sample.evidence) || null,
|
|
207
|
+
};
|
|
208
|
+
try {
|
|
209
|
+
const p = measurementsPath(agentRoot);
|
|
210
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
211
|
+
appendJsonl(p, rec);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
logOf(deps)("error", `[kpi] could not append a measurement for "${key}": ${err && err.message ? err.message : err}`);
|
|
214
|
+
return { recorded: false, reason: "append-failed", sample: null };
|
|
215
|
+
}
|
|
216
|
+
return { recorded: true, reason: existing ? "superseded-weaker-source" : "recorded", sample: rec };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Record the realized delta of an intervention — the learning signal. Step 4 of
|
|
221
|
+
* the loop feeds the last three of these back into the planner so an
|
|
222
|
+
* intervention that made a number WORSE is down-weighted next time.
|
|
223
|
+
* @param {string} agentRoot @param {object} rec @param {object} [deps]
|
|
224
|
+
* @returns {{recorded:boolean, record:object|null}}
|
|
225
|
+
*/
|
|
226
|
+
export function recordIntervention(agentRoot, rec, deps = {}) {
|
|
227
|
+
const row = {
|
|
228
|
+
at: new Date(nowMs(deps)).toISOString(),
|
|
229
|
+
objectiveKey: (rec && rec.objectiveKey) || null,
|
|
230
|
+
taskId: (rec && rec.taskId) || null,
|
|
231
|
+
obligationKey: (rec && rec.obligationKey) || null,
|
|
232
|
+
expectedDelta: Number.isFinite(Number(rec && rec.expectedDelta)) ? Number(rec.expectedDelta) : null,
|
|
233
|
+
realizedDelta: Number.isFinite(Number(rec && rec.realizedDelta)) ? Number(rec.realizedDelta) : null,
|
|
234
|
+
measuredAt: (rec && rec.measuredAt) || null,
|
|
235
|
+
};
|
|
236
|
+
try {
|
|
237
|
+
const p = interventionsPath(agentRoot);
|
|
238
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
239
|
+
appendJsonl(p, row);
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logOf(deps)("error", `[kpi] could not append an intervention: ${err && err.message ? err.message : err}`);
|
|
242
|
+
return { recorded: false, record: null };
|
|
243
|
+
}
|
|
244
|
+
return { recorded: true, record: row };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The last N interventions against an objective, newest last. Fed to the
|
|
249
|
+
* planning sub-session so it can see what it already tried.
|
|
250
|
+
*/
|
|
251
|
+
export function readInterventions(agentRoot, objectiveKey, limit = 3) {
|
|
252
|
+
const rows = readJsonl(interventionsPath(agentRoot)).filter(
|
|
253
|
+
(r) => r && (!objectiveKey || r.objectiveKey === objectiveKey)
|
|
254
|
+
);
|
|
255
|
+
return limit > 0 ? rows.slice(-limit) : rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Measurement
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Execute an objective's sensor.
|
|
264
|
+
*
|
|
265
|
+
* `deps.sensors` is a registry `{ [capabilityId]: async ({params, objective}) =>
|
|
266
|
+
* number | {value, rowCount?, …} }`. `deps.reachable` is the set/array of
|
|
267
|
+
* capability ids the manifest says are actually reachable — an entry that is
|
|
268
|
+
* NOT reachable may never be cited (SPEC §5.1), so it fails here rather than
|
|
269
|
+
* producing a fabricated number.
|
|
270
|
+
*
|
|
271
|
+
* Never throws. Every failure path returns `{ok:false, reason}` AND logs.
|
|
272
|
+
*
|
|
273
|
+
* @param {object} objective
|
|
274
|
+
* @param {object} deps - { sensors, reachable?, now, log }
|
|
275
|
+
* @returns {Promise<{ok:boolean, value:number|null, source:string, evidence:object|null, reason:string}>}
|
|
276
|
+
*/
|
|
277
|
+
export async function measureKpi(objective, deps = {}) {
|
|
278
|
+
const key = objective && objective.key;
|
|
279
|
+
const sensor = objective && objective.sensor;
|
|
280
|
+
if (!sensor || !sensor.capability) {
|
|
281
|
+
const reason = "no-sensor";
|
|
282
|
+
logOf(deps)("info", `[kpi] "${key}" has no method sensor (source=${(sensor && sensor.source) || "none"}) — measurement is a human obligation`);
|
|
283
|
+
return { ok: false, value: null, source: (sensor && sensor.source) || "human", evidence: null, reason };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const reachable = deps.reachable instanceof Set ? deps.reachable : new Set(deps.reachable || []);
|
|
287
|
+
if (deps.reachable !== undefined && !reachable.has(sensor.capability)) {
|
|
288
|
+
logOf(deps)(
|
|
289
|
+
"warn",
|
|
290
|
+
`[kpi] "${key}" cites capability "${sensor.capability}" which is NOT reachable in the manifest — refusing to measure (this is a drift, not a zero)`
|
|
291
|
+
);
|
|
292
|
+
return { ok: false, value: null, source: "method", evidence: null, reason: "unreachable-capability" };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const fn = deps.sensors && deps.sensors[sensor.capability];
|
|
296
|
+
if (typeof fn !== "function") {
|
|
297
|
+
logOf(deps)("warn", `[kpi] no sensor implementation registered for "${sensor.capability}" (objective "${key}")`);
|
|
298
|
+
return { ok: false, value: null, source: "method", evidence: null, reason: "no-implementation" };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let raw;
|
|
302
|
+
try {
|
|
303
|
+
raw = await fn({ params: sensor.params || {}, objective });
|
|
304
|
+
} catch (err) {
|
|
305
|
+
logOf(deps)("error", `[kpi] sensor "${sensor.capability}" threw for "${key}": ${err && err.message ? err.message : err}`);
|
|
306
|
+
return { ok: false, value: null, source: "method", evidence: null, reason: "sensor-threw" };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const value = Number(raw && typeof raw === "object" ? raw.value : raw);
|
|
310
|
+
if (!Number.isFinite(value)) {
|
|
311
|
+
logOf(deps)("warn", `[kpi] sensor "${sensor.capability}" returned a non-numeric result for "${key}"`);
|
|
312
|
+
return { ok: false, value: null, source: "method", evidence: null, reason: "non-numeric" };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
value,
|
|
318
|
+
source: "method",
|
|
319
|
+
evidence: {
|
|
320
|
+
method: sensor.capability,
|
|
321
|
+
params: sensor.params || {},
|
|
322
|
+
rowCount: raw && typeof raw === "object" && Number.isFinite(Number(raw.rowCount)) ? Number(raw.rowCount) : null,
|
|
323
|
+
sessionId: (raw && typeof raw === "object" && raw.sessionId) || null,
|
|
324
|
+
},
|
|
325
|
+
reason: "measured",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// The gap
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/** Signed shortfall for a direction. Positive = "we are short of target". */
|
|
334
|
+
function rawGap(direction, value, target, tolerance) {
|
|
335
|
+
switch (String(direction || "up")) {
|
|
336
|
+
case "down":
|
|
337
|
+
return value - target;
|
|
338
|
+
case "hold":
|
|
339
|
+
case "band":
|
|
340
|
+
return Math.max(0, Math.abs(value - target) - Math.max(0, tolerance || 0));
|
|
341
|
+
case "up":
|
|
342
|
+
default:
|
|
343
|
+
return target - value;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Compute the gap for an objective from its series.
|
|
349
|
+
*
|
|
350
|
+
* @param {object} objective
|
|
351
|
+
* @param {object[]} series - readSeries() output, oldest first
|
|
352
|
+
* @param {object} [deps] { now }
|
|
353
|
+
* @returns {{
|
|
354
|
+
* objectiveKey:string, value:number|null, target:number|null, gap:number|null,
|
|
355
|
+
* normalizedGap:number, withinTolerance:boolean, trend:'improving'|'flat'|'worsening'|'unknown',
|
|
356
|
+
* flatPeriods:number, source:string|null, window:string|null,
|
|
357
|
+
* stalenessPeriods:number|null, stale:boolean, reason:string
|
|
358
|
+
* }}
|
|
359
|
+
*/
|
|
360
|
+
export function gapFor(objective, series, deps = {}) {
|
|
361
|
+
const key = (objective && objective.key) || null;
|
|
362
|
+
// `Number(null)` is 0 — an absent target must NOT read as a target of zero.
|
|
363
|
+
const rawTarget = objective ? objective.target : null;
|
|
364
|
+
const target = rawTarget == null || rawTarget === "" ? NaN : Number(rawTarget);
|
|
365
|
+
const tolerance = Number.isFinite(Number(objective && objective.tolerance)) ? Number(objective.tolerance) : 0;
|
|
366
|
+
const direction = (objective && objective.direction) || "up";
|
|
367
|
+
const rows = (series || []).filter((s) => s && Number.isFinite(Number(s.value)));
|
|
368
|
+
const latest = rows[rows.length - 1] || null;
|
|
369
|
+
|
|
370
|
+
const base = {
|
|
371
|
+
objectiveKey: key,
|
|
372
|
+
value: latest ? Number(latest.value) : null,
|
|
373
|
+
target: Number.isFinite(target) ? target : null,
|
|
374
|
+
gap: null,
|
|
375
|
+
normalizedGap: 0,
|
|
376
|
+
withinTolerance: false,
|
|
377
|
+
trend: "unknown",
|
|
378
|
+
flatPeriods: 0,
|
|
379
|
+
source: latest ? latest.source : null,
|
|
380
|
+
window: latest ? latest.window : null,
|
|
381
|
+
stalenessPeriods: null,
|
|
382
|
+
stale: false,
|
|
383
|
+
reason: "",
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
if (!latest) return { ...base, reason: "no samples" };
|
|
387
|
+
if (!Number.isFinite(target)) return { ...base, reason: "objective has no numeric target" };
|
|
388
|
+
|
|
389
|
+
const value = Number(latest.value);
|
|
390
|
+
const gap = rawGap(direction, value, target, tolerance);
|
|
391
|
+
const denom = Math.max(Math.abs(target), 1e-9);
|
|
392
|
+
const normalizedGap = Math.max(0, gap) / denom;
|
|
393
|
+
|
|
394
|
+
// Trend over the gap, not the raw value, so `direction` is honoured.
|
|
395
|
+
const gaps = rows.slice(-(FLAT_PERIODS + 1)).map((r) => rawGap(direction, Number(r.value), target, tolerance));
|
|
396
|
+
let trend = "unknown";
|
|
397
|
+
let flatPeriods = 0;
|
|
398
|
+
if (gaps.length >= 2) {
|
|
399
|
+
const eps = Math.max(Math.abs(target) * 0.01, 1e-9);
|
|
400
|
+
let improving = 0;
|
|
401
|
+
let worsening = 0;
|
|
402
|
+
for (let i = 1; i < gaps.length; i++) {
|
|
403
|
+
const delta = gaps[i] - gaps[i - 1];
|
|
404
|
+
if (delta < -eps) improving += 1;
|
|
405
|
+
else if (delta > eps) worsening += 1;
|
|
406
|
+
else flatPeriods += 1;
|
|
407
|
+
}
|
|
408
|
+
if (improving > worsening) trend = "improving";
|
|
409
|
+
else if (worsening > improving) trend = "worsening";
|
|
410
|
+
else trend = "flat";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Staleness: how many cadence periods since the latest sample.
|
|
414
|
+
let stalenessPeriods = null;
|
|
415
|
+
let stale = false;
|
|
416
|
+
if (objective && objective.cadence && latest.at) {
|
|
417
|
+
const per = periodSeconds(objective.cadence);
|
|
418
|
+
const age = Math.max(0, (nowMs(deps) - Date.parse(latest.at)) / 1000);
|
|
419
|
+
if (Number.isFinite(age) && per > 0) {
|
|
420
|
+
stalenessPeriods = age / per;
|
|
421
|
+
stale = stalenessPeriods >= 2; // 2× cadence with no sample is itself a gap
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
...base,
|
|
427
|
+
value,
|
|
428
|
+
target,
|
|
429
|
+
gap: +gap.toFixed(6),
|
|
430
|
+
normalizedGap: +normalizedGap.toFixed(6),
|
|
431
|
+
withinTolerance: gap <= tolerance,
|
|
432
|
+
trend,
|
|
433
|
+
flatPeriods,
|
|
434
|
+
stalenessPeriods,
|
|
435
|
+
stale,
|
|
436
|
+
reason: gap <= tolerance ? "within tolerance" : "gap exceeds tolerance",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Seconds in one cadence period. */
|
|
441
|
+
export function periodSeconds(cadence) {
|
|
442
|
+
switch (String(cadence || "").toLowerCase()) {
|
|
443
|
+
case "weekly": return 7 * 24 * 3600;
|
|
444
|
+
case "monthly": return 30 * 24 * 3600;
|
|
445
|
+
case "quarterly": return 91 * 24 * 3600;
|
|
446
|
+
case "daily": return 24 * 3600;
|
|
447
|
+
default: return 24 * 3600;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export default {
|
|
452
|
+
MEASUREMENTS_REL,
|
|
453
|
+
INTERVENTIONS_REL,
|
|
454
|
+
SOURCE_RANK,
|
|
455
|
+
WORK_CREATING_SOURCES,
|
|
456
|
+
FLAT_PERIODS,
|
|
457
|
+
isoWeek,
|
|
458
|
+
windowFor,
|
|
459
|
+
isDue,
|
|
460
|
+
measurementsPath,
|
|
461
|
+
interventionsPath,
|
|
462
|
+
readSeries,
|
|
463
|
+
recordMeasurement,
|
|
464
|
+
recordIntervention,
|
|
465
|
+
readInterventions,
|
|
466
|
+
measureKpi,
|
|
467
|
+
gapFor,
|
|
468
|
+
periodSeconds,
|
|
469
|
+
};
|