@cohortapp/agent-sdk 2.4.0 → 2.5.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 +9 -0
- package/lib/backlog.mjs +35 -0
- package/lib/backlog.test.mjs +36 -0
- package/lib/channels/contract.mjs +1 -0
- package/lib/channels/contract.test.mjs +2 -1
- package/lib/channels/inbox-item.mjs +54 -0
- package/lib/comms/send-gate.mjs +56 -1
- package/lib/comms/send-gate.test.mjs +56 -0
- package/lib/execution/disposition.mjs +62 -2
- package/lib/execution/disposition.test.mjs +54 -0
- package/lib/execution/drive.mjs +1 -1
- package/lib/execution/effects.mjs +282 -24
- package/lib/execution/effects.test.mjs +112 -0
- package/lib/execution/index.mjs +1 -0
- package/lib/execution/intake.mjs +43 -9
- package/lib/execution/intake.test.mjs +46 -0
- package/lib/execution/pipeline.mjs +5 -0
- package/lib/execution/surface-policy.mjs +80 -30
- package/lib/goals/classify.mjs +49 -5
- package/lib/goals/classify.test.mjs +58 -0
- package/lib/goals/collaborate.mjs +131 -17
- package/lib/goals/collaborate.test.mjs +16 -4
- package/lib/goals/loop.mjs +160 -9
- package/lib/goals/loop.test.mjs +129 -3
- package/lib/kpi-sensors.mjs +666 -0
- package/lib/kpi-sensors.test.mjs +275 -0
- package/lib/kpi.mjs +23 -0
- package/lib/mandate/audit.mjs +3 -0
- package/lib/mandate/contract.mjs +277 -0
- package/lib/mandate/contract.test.mjs +185 -0
- package/lib/mandate/derive.mjs +49 -5
- package/lib/mandate/derive.test.mjs +7 -1
- package/lib/mandate/model.mjs +10 -1
- package/lib/mandate/model.test.mjs +22 -3
- package/lib/mandate/refresh.mjs +53 -5
- package/lib/mandate/refresh.test.mjs +83 -1
- package/lib/org/doctor.mjs +66 -0
- package/lib/org/doctor.test.mjs +73 -1
- package/lib/org/inbound/directedness.mjs +119 -1
- package/lib/org/inbound/directedness.test.mjs +67 -0
- package/lib/org/inbound/facts.mjs +132 -9
- package/lib/org/inbound/facts.test.mjs +96 -0
- package/lib/org/inbound/hydrate.mjs +40 -0
- package/lib/org/inbound/index.test.mjs +83 -0
- package/lib/org/inbound/project.mjs +8 -0
- package/lib/org/inbound/surfaces.mjs +20 -0
- package/lib/org/param-contract.mjs +16 -2
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +214 -2
- package/lib/org/protocol.test.mjs +11 -2
- package/lib/org/push.mjs +213 -49
- package/lib/org/push.test.mjs +112 -10
- package/lib/plan/compile.mjs +85 -8
- package/lib/plan/compile.test.mjs +82 -0
- package/lib/plan/emit.test.mjs +6 -1
- package/lib/setup/enroll-from-cohort.mjs +22 -2
- package/lib/setup/enroll-from-cohort.test.mjs +25 -0
- package/lib/setup/sections/mandate.mjs +43 -1
- package/lib/subagents/schema.mjs +14 -2
- package/lib/subagents/schema.test.mjs +22 -0
- package/package.json +1 -1
- package/scripts/ci/check-subagent-frontmatter.mjs +139 -0
- package/scripts/ci/check-subagent-frontmatter.test.mjs +124 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/conformance-org-api.mjs +16 -0
- package/scripts/ci/journey-approval-escalation.mjs +341 -0
- package/scripts/daemon/agent-daemon.mjs +582 -28
- package/scripts/daemon/cadence-handlers.mjs +273 -17
- package/scripts/daemon/cadence-handlers.test.mjs +101 -0
- package/scripts/daemon/execution-ladder.test.mjs +430 -0
- package/scripts/daemon/goal-steward-cadence.test.mjs +69 -0
- package/scripts/daemon/maestro-daemon.mjs +53 -0
- package/scripts/daemon/prompt-builder.mjs +47 -0
- package/scripts/daemon/responder.mjs +70 -3
- package/scripts/poller/imap-client.mjs +20 -1
- package/scripts/poller/inbox-scan-poller.mjs +15 -0
- package/scripts/poller/utils.mjs +51 -0
- package/scripts/setup/generate-capability.mjs +120 -11
- package/scripts/setup/generate-capability.test.mjs +134 -0
- package/scripts/setup/generate-plan.mjs +6 -1
- package/scripts/setup/repair-subagent-frontmatter.mjs +231 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mandate/contract.test.mjs — the SDK half of THE MANDATE BODY CONTRACT.
|
|
3
|
+
*
|
|
4
|
+
* The break this closes: hq emitted `{memberId, objectives, proposed,
|
|
5
|
+
* generatedAt}` while `compilePlan` read `{objectives, budgetCentsPerPeriod,
|
|
6
|
+
* collaborators, reactsTo}`. Three keys were absent, `Number.isFinite(undefined)`
|
|
7
|
+
* was false, and every obligation in the fleet took a hardcoded 500c allowance
|
|
8
|
+
* against a hardcoded 10000c envelope — with no error, no log and no drift row.
|
|
9
|
+
*
|
|
10
|
+
* The invariant under test, stated once: a MISSING required key is an ERROR (the
|
|
11
|
+
* body cannot be budgeted from); a key that is PRESENT AND NULL is a WARNING (hq
|
|
12
|
+
* looked and had nothing). Nothing anywhere may substitute a number for either.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
MANDATE_BODY_KEYS,
|
|
23
|
+
MANDATE_CONTRACT_VERSION,
|
|
24
|
+
MANDATE_REQUIRED_KEYS,
|
|
25
|
+
assertMandateContract,
|
|
26
|
+
bodyOf,
|
|
27
|
+
validateMandateContract,
|
|
28
|
+
} from "./contract.mjs";
|
|
29
|
+
|
|
30
|
+
/** A well-formed, FUNDED body in exactly the shape hq publishes. */
|
|
31
|
+
function fundedBody(over = {}) {
|
|
32
|
+
return {
|
|
33
|
+
contractVersion: 1,
|
|
34
|
+
memberId: "mem_self",
|
|
35
|
+
objectives: [
|
|
36
|
+
{ key: "pipeline", state: "active", weight: 3, budgetCentsPerPeriod: 18_750 },
|
|
37
|
+
{ key: "inbox", state: "active", weight: 1, budgetCentsPerPeriod: 6_250 },
|
|
38
|
+
],
|
|
39
|
+
proposed: [],
|
|
40
|
+
budgetCentsPerPeriod: null,
|
|
41
|
+
seatBudgetCents: 25_000,
|
|
42
|
+
budgetPeriod: "monthly",
|
|
43
|
+
budgetSource: "employee.payBasis.meteredBudget",
|
|
44
|
+
collaborators: [{ memberId: "mem_boss", role: "REVIEWER", source: "workforceMember.supervisorId" }],
|
|
45
|
+
reactsTo: [],
|
|
46
|
+
degradations: [],
|
|
47
|
+
generatedAt: "2026-08-11T00:00:00.000Z",
|
|
48
|
+
...over,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test("a funded hq body validates clean and is NOT unmetered", () => {
|
|
53
|
+
const v = validateMandateContract(fundedBody());
|
|
54
|
+
assert.equal(v.ok, true);
|
|
55
|
+
assert.deepEqual(v.missing, []);
|
|
56
|
+
assert.equal(v.contractVersion, MANDATE_CONTRACT_VERSION);
|
|
57
|
+
assert.equal(v.unmetered, false);
|
|
58
|
+
assert.equal(v.errors.length, 0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("THE ORIGINAL BREAK: the pre-contract body is an ERROR, not a shrug", () => {
|
|
62
|
+
// Byte-for-byte the shape hq used to send.
|
|
63
|
+
const legacy = { memberId: "mem_self", objectives: [], proposed: [], generatedAt: "1970-01-01T00:00:00.000Z" };
|
|
64
|
+
const v = validateMandateContract(legacy);
|
|
65
|
+
assert.equal(v.ok, false);
|
|
66
|
+
for (const k of ["budgetCentsPerPeriod", "seatBudgetCents", "collaborators", "reactsTo", "contractVersion"]) {
|
|
67
|
+
assert.ok(v.missing.includes(k), `missing names ${k}`);
|
|
68
|
+
}
|
|
69
|
+
assert.ok(v.errors.some((e) => /PRE-CONTRACT/.test(e)));
|
|
70
|
+
assert.equal(v.unmetered, true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("PRESENT-AND-NULL is a warning, MISSING is an error — the distinction is the point", () => {
|
|
74
|
+
const unfunded = validateMandateContract(
|
|
75
|
+
fundedBody({
|
|
76
|
+
seatBudgetCents: null,
|
|
77
|
+
budgetPeriod: null,
|
|
78
|
+
budgetSource: "none",
|
|
79
|
+
objectives: [{ key: "pipeline", state: "active", weight: 1, budgetCentsPerPeriod: null }],
|
|
80
|
+
degradations: [{ field: "seatBudgetCents", reason: "no Employee row is linked to this member" }],
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
// Well-formed: the body can be trusted. Just genuinely unfunded.
|
|
84
|
+
assert.equal(unfunded.ok, true);
|
|
85
|
+
assert.deepEqual(unfunded.missing, []);
|
|
86
|
+
assert.equal(unfunded.unmetered, true);
|
|
87
|
+
assert.ok(unfunded.warnings.some((w) => /seatBudgetCents is null/.test(w)));
|
|
88
|
+
// hq's own stated reason is echoed, so one log line explains the null.
|
|
89
|
+
assert.ok(unfunded.warnings.some((w) => /hq degradation — seatBudgetCents/.test(w)));
|
|
90
|
+
|
|
91
|
+
// Delete the key instead of nulling it and the verdict flips to untrustworthy.
|
|
92
|
+
const body = fundedBody();
|
|
93
|
+
delete body.seatBudgetCents;
|
|
94
|
+
const gone = validateMandateContract(body);
|
|
95
|
+
assert.equal(gone.ok, false);
|
|
96
|
+
assert.deepEqual(gone.missing, ["seatBudgetCents"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("apportionment that exceeds the envelope is an ERROR, not a rounding note", () => {
|
|
100
|
+
const v = validateMandateContract(
|
|
101
|
+
fundedBody({ objectives: [{ key: "a", budgetCentsPerPeriod: 20_000 }, { key: "b", budgetCentsPerPeriod: 20_000 }] })
|
|
102
|
+
);
|
|
103
|
+
assert.equal(v.ok, false);
|
|
104
|
+
assert.ok(v.errors.some((e) => /apportioned more than it holds/.test(e)));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("junk in the budget fields is rejected rather than coerced", () => {
|
|
108
|
+
for (const bad of ["500", NaN, Infinity, -1, {}]) {
|
|
109
|
+
const v = validateMandateContract(fundedBody({ seatBudgetCents: bad }));
|
|
110
|
+
assert.equal(v.ok, false, `seatBudgetCents=${String(bad)} must be rejected`);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a non-object body fails closed instead of throwing", () => {
|
|
115
|
+
for (const bad of [null, undefined, 42, "body", []]) {
|
|
116
|
+
const v = validateMandateContract(bad);
|
|
117
|
+
assert.equal(v.ok, false);
|
|
118
|
+
assert.equal(v.unmetered, true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a FUTURE contract version warns but still compiles (forward-compat)", () => {
|
|
123
|
+
const v = validateMandateContract(fundedBody({ contractVersion: MANDATE_CONTRACT_VERSION + 1 }));
|
|
124
|
+
assert.equal(v.ok, true);
|
|
125
|
+
assert.ok(v.warnings.some((w) => /upgrade the SDK/.test(w)));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("bodyOf unwraps a cache record and passes a bare body through", () => {
|
|
129
|
+
const body = fundedBody();
|
|
130
|
+
assert.equal(bodyOf({ body, version: 4 }), body);
|
|
131
|
+
assert.equal(bodyOf(body), body);
|
|
132
|
+
assert.equal(bodyOf(null), null);
|
|
133
|
+
// The cache record shape must validate identically to the bare body.
|
|
134
|
+
assert.equal(validateMandateContract({ body, version: 4 }).ok, true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("assertMandateContract LOGS every error and warning — silence is the bug", () => {
|
|
138
|
+
const lines = [];
|
|
139
|
+
const v = assertMandateContract({ memberId: "m" }, (level, msg) => lines.push([level, msg]), "compilePlan");
|
|
140
|
+
assert.equal(v.ok, false);
|
|
141
|
+
assert.ok(lines.some(([l]) => l === "error"), "the violation is logged at error");
|
|
142
|
+
assert.ok(lines.every(([, m]) => m.includes("[compilePlan]")), "every line is tagged with its call site");
|
|
143
|
+
// It must never throw, whatever it is handed — it runs inside the daemon.
|
|
144
|
+
assert.doesNotThrow(() => assertMandateContract(undefined));
|
|
145
|
+
assert.doesNotThrow(() => assertMandateContract({}, "not a function"));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// THE CROSS-REPO DRIFT ALARM
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
test("the key lists match hq's _contract.ts literal for literal", { skip: hqContractPath() ? false : "hq checkout not present" }, () => {
|
|
153
|
+
// The two repos cannot import each other, so the contract is written down
|
|
154
|
+
// twice — and a shape written down twice drifts unless something compares
|
|
155
|
+
// them. This reads hq's TypeScript source and diffs the literals. It is the
|
|
156
|
+
// single mechanical reason this joint cannot silently reopen.
|
|
157
|
+
const src = readFileSync(hqContractPath(), "utf8");
|
|
158
|
+
|
|
159
|
+
const arrayLiteral = (name) => {
|
|
160
|
+
const m = src.match(new RegExp(`export const ${name} = \\[([^\\]]*)\\]`));
|
|
161
|
+
assert.ok(m, `hq's _contract.ts declares ${name}`);
|
|
162
|
+
return m[1].split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(arrayLiteral("MANDATE_BODY_KEYS"), [...MANDATE_BODY_KEYS], "MANDATE_BODY_KEYS drifted between the repos");
|
|
166
|
+
assert.deepEqual(arrayLiteral("MANDATE_REQUIRED_KEYS"), [...MANDATE_REQUIRED_KEYS], "MANDATE_REQUIRED_KEYS drifted between the repos");
|
|
167
|
+
|
|
168
|
+
const ver = src.match(/export const MANDATE_CONTRACT_VERSION = (\d+)/);
|
|
169
|
+
assert.ok(ver, "hq declares MANDATE_CONTRACT_VERSION");
|
|
170
|
+
assert.equal(Number(ver[1]), MANDATE_CONTRACT_VERSION, "the contract VERSION drifted between the repos");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/** hq's contract file, when this checkout sits beside one. */
|
|
174
|
+
function hqContractPath() {
|
|
175
|
+
const p = `${process.env.HOME}/hq/src/server/methods/mandate/_contract.ts`;
|
|
176
|
+
return existsSync(p) ? p : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
test("the required keys are a SUBSET of the body keys (an unreadable key cannot be required)", () => {
|
|
180
|
+
for (const k of MANDATE_REQUIRED_KEYS) {
|
|
181
|
+
assert.ok(MANDATE_BODY_KEYS.includes(k), `${k} is required but not in MANDATE_BODY_KEYS`);
|
|
182
|
+
}
|
|
183
|
+
assert.deepEqual([...MANDATE_BODY_KEYS], [...MANDATE_BODY_KEYS].slice().sort(), "MANDATE_BODY_KEYS must stay sorted");
|
|
184
|
+
assert.deepEqual([...MANDATE_REQUIRED_KEYS], [...MANDATE_REQUIRED_KEYS].slice().sort(), "MANDATE_REQUIRED_KEYS must stay sorted");
|
|
185
|
+
});
|
package/lib/mandate/derive.mjs
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
import { createHash } from "node:crypto";
|
|
33
33
|
|
|
34
|
+
import { MANDATE_CONTRACT_VERSION } from "./contract.mjs";
|
|
35
|
+
|
|
34
36
|
/** Objective kinds (mirrors hq's ObjectiveKind enum). */
|
|
35
37
|
export const OBJECTIVE_KINDS = Object.freeze(["PILLAR", "OBJECTIVE", "GOAL"]);
|
|
36
38
|
/** Metric directions (mirrors hq's MetricDirection enum). */
|
|
@@ -46,8 +48,14 @@ export const CADENCES = Object.freeze(["weekly", "monthly", "quarterly"]);
|
|
|
46
48
|
* @type {Array<{re:RegExp, caps:string[], params?:object, direction?:string, unit?:string}>}
|
|
47
49
|
*/
|
|
48
50
|
export const SENSOR_HINTS = Object.freeze([
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
// `params` here are sent VERBATIM to the capability by lib/kpi-sensors.mjs, so
|
|
52
|
+
// they must be legal at hq. `{stage:"open"}` was not: crm.listDeals takes a
|
|
53
|
+
// strict enum (INBOUND|QUALIFIED|PROPOSAL|NEGOTIATION|CLOSED) and answered every
|
|
54
|
+
// derived pipeline sensor with BAD_REQUEST. Open-ness is a reduction, not a
|
|
55
|
+
// filter — the sensor computes it from the stage field. Likewise books.reports
|
|
56
|
+
// REQUIRES `report`, so a hint that omitted it could never execute.
|
|
57
|
+
{ re: /(pipeline|deal|bookings|revenue|win rate|arr|mrr)/i, caps: ["crm_list_deals", "crm_next_best_action"], params: { metric: "open_pipeline_usd" }, unit: "usd", direction: "up" },
|
|
58
|
+
{ re: /(runway|burn|cash|invoice|billing|tax|spend|cost)/i, caps: ["books_reports", "books_invoices"], params: { report: "cashflow" }, unit: "months", direction: "up" },
|
|
51
59
|
{ re: /(commitment|action item|follow-?through|closure|backlog|task|execution)/i, caps: ["board_ready", "task_update"], unit: "%", direction: "up" },
|
|
52
60
|
{ re: /(decision|governance|approval)/i, caps: ["decision_list", "approval_wait"], unit: "days", direction: "down" },
|
|
53
61
|
{ re: /(meeting|calendar|cadence|agenda)/i, caps: ["calendar_list", "meetings_recap_file"], unit: "%", direction: "up" },
|
|
@@ -228,16 +236,52 @@ export function deriveMandate(input = {}) {
|
|
|
228
236
|
}
|
|
229
237
|
|
|
230
238
|
/**
|
|
231
|
-
* Wrap the derived tree in the mandate
|
|
232
|
-
*
|
|
239
|
+
* Wrap the derived tree in the CANONICAL mandate body (lib/mandate/contract.mjs
|
|
240
|
+
* — the same shape hq publishes, key for key).
|
|
241
|
+
*
|
|
242
|
+
* `budgetCentsPerPeriod` used to default to 500 here. It does not any more, and
|
|
243
|
+
* that is the point: a LOCALLY DERIVED mandate is the offline fallback, and an
|
|
244
|
+
* offline agent has no way whatsoever to know what it is funded for. Emitting
|
|
245
|
+
* 500 made "I never asked" indistinguishable from "I asked and was told 500".
|
|
246
|
+
* Null says the true thing, and `degradations[]` says why — so the plan comes
|
|
247
|
+
* out unmetered-and-loud instead of budgeted-and-wrong.
|
|
248
|
+
*
|
|
249
|
+
* @param {object} o - { memberId, objectives, proposed?, budgetCentsPerPeriod?,
|
|
250
|
+
* seatBudgetCents?, collaborators?, reactsTo?, generatedAt? }
|
|
251
|
+
* @returns {import("./contract.mjs").MandateBody}
|
|
233
252
|
*/
|
|
234
253
|
export function toMandateBody(o = {}) {
|
|
254
|
+
const budgetCentsPerPeriod = Number.isFinite(o.budgetCentsPerPeriod) ? o.budgetCentsPerPeriod : null;
|
|
255
|
+
const seatBudgetCents = Number.isFinite(o.seatBudgetCents) ? o.seatBudgetCents : null;
|
|
256
|
+
const degradations = [];
|
|
257
|
+
if (budgetCentsPerPeriod == null) {
|
|
258
|
+
degradations.push({
|
|
259
|
+
field: "budgetCentsPerPeriod",
|
|
260
|
+
reason:
|
|
261
|
+
"derived locally: the agent cannot know its own funding offline, so every " +
|
|
262
|
+
"obligation compiles unmetered until an adopted snapshot arrives from hq",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (seatBudgetCents == null) {
|
|
266
|
+
degradations.push({
|
|
267
|
+
field: "seatBudgetCents",
|
|
268
|
+
reason: "derived locally: no seat envelope is knowable offline",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
235
271
|
return {
|
|
272
|
+
contractVersion: MANDATE_CONTRACT_VERSION,
|
|
236
273
|
memberId: o.memberId || null,
|
|
237
274
|
objectives: Array.isArray(o.objectives) ? o.objectives : [],
|
|
238
|
-
|
|
275
|
+
proposed: Array.isArray(o.proposed) ? o.proposed : [],
|
|
276
|
+
budgetCentsPerPeriod,
|
|
277
|
+
seatBudgetCents,
|
|
278
|
+
budgetPeriod: seatBudgetCents == null ? null : "monthly",
|
|
279
|
+
budgetSource: "local-derivation",
|
|
239
280
|
collaborators: Array.isArray(o.collaborators) ? o.collaborators : [],
|
|
240
281
|
reactsTo: Array.isArray(o.reactsTo) ? o.reactsTo : [],
|
|
282
|
+
degradations,
|
|
283
|
+
// From the data, never the clock — the body is checksummed.
|
|
284
|
+
generatedAt: typeof o.generatedAt === "string" ? o.generatedAt : "1970-01-01T00:00:00.000Z",
|
|
241
285
|
};
|
|
242
286
|
}
|
|
243
287
|
|
|
@@ -194,7 +194,13 @@ test("toMandateBody produces the shape the cache and compiler consume", () => {
|
|
|
194
194
|
const body = toMandateBody({ memberId: "mem_self", objectives: r.objectives });
|
|
195
195
|
assert.equal(body.memberId, "mem_self");
|
|
196
196
|
assert.ok(Array.isArray(body.objectives));
|
|
197
|
-
|
|
197
|
+
// NOT a number. A locally-derived body is the OFFLINE fallback, and an offline
|
|
198
|
+
// agent cannot know what it is funded for. This assertion used to demand a
|
|
199
|
+
// number and got the hardcoded 500 that made "I never asked" look exactly like
|
|
200
|
+
// "I asked and was told 500". Null is the true answer, and it must be PRESENT.
|
|
201
|
+
assert.ok(Object.prototype.hasOwnProperty.call(body, "budgetCentsPerPeriod"));
|
|
202
|
+
assert.equal(body.budgetCentsPerPeriod, null);
|
|
203
|
+
assert.ok(body.degradations.some((d) => d.field === "budgetCentsPerPeriod"));
|
|
198
204
|
assert.ok(Array.isArray(body.reactsTo));
|
|
199
205
|
// And it must round-trip through the validator the loop uses.
|
|
200
206
|
assert.equal(validateMandateBody(body).ok, true);
|
package/lib/mandate/model.mjs
CHANGED
|
@@ -226,7 +226,16 @@ export function collaboratorsOf(objective) {
|
|
|
226
226
|
const memberId = typeof c === "string" ? c : c.memberId || c.member_id || c.id;
|
|
227
227
|
if (!memberId) continue;
|
|
228
228
|
const role = String((typeof c === "object" && (c.role || c.kind)) || "CONTRIBUTOR").toUpperCase();
|
|
229
|
-
|
|
229
|
+
// `source` is hq's own provenance label for the edge and it is NOT
|
|
230
|
+
// decoration: `src/server/methods/mandate/_shared.ts#projectObjective`
|
|
231
|
+
// stamps "objective.adoptedById" on the APPROVER it synthesises from the
|
|
232
|
+
// adopter, and "workforceMember.supervisorId" on the seat-level REVIEWER.
|
|
233
|
+
// Dropping it here erased the only way a consumer could tell a DERIVED
|
|
234
|
+
// governance edge from a deliberately-named per-task reviewer — which is
|
|
235
|
+
// exactly what made `classifyWorkItem` rule 4 fire on every single
|
|
236
|
+
// candidate (see the note there).
|
|
237
|
+
const source = typeof c === "object" && c.source ? String(c.source) : null;
|
|
238
|
+
out.push({ memberId: String(memberId), role, source });
|
|
230
239
|
}
|
|
231
240
|
return out;
|
|
232
241
|
}
|
|
@@ -133,12 +133,31 @@ test("buildTree nests children and surfaces orphans as roots rather than droppin
|
|
|
133
133
|
|
|
134
134
|
test("collaboratorsOf normalises strings, objects and casing", () => {
|
|
135
135
|
assert.deepEqual(collaboratorsOf({ collaborators: ["m1", { memberId: "m2", role: "reviewer" }, { id: "m3" }, null] }), [
|
|
136
|
-
{ memberId: "m1", role: "CONTRIBUTOR" },
|
|
137
|
-
{ memberId: "m2", role: "REVIEWER" },
|
|
138
|
-
{ memberId: "m3", role: "CONTRIBUTOR" },
|
|
136
|
+
{ memberId: "m1", role: "CONTRIBUTOR", source: null },
|
|
137
|
+
{ memberId: "m2", role: "REVIEWER", source: null },
|
|
138
|
+
{ memberId: "m3", role: "CONTRIBUTOR", source: null },
|
|
139
139
|
]);
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
test("collaboratorsOf PRESERVES hq's provenance `source` on the edge", () => {
|
|
143
|
+
// hq's projectObjective stamps this, and goals/classify.mjs is the consumer:
|
|
144
|
+
// an APPROVER derived from `objective.adoptedById` is a standing governance
|
|
145
|
+
// edge, not per-task sign-off. Dropping `source` made every self-directed
|
|
146
|
+
// candidate classify NEEDS_REVIEW.
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
collaboratorsOf({
|
|
149
|
+
collaborators: [
|
|
150
|
+
{ memberId: "boss", role: "APPROVER", source: "objective.adoptedById" },
|
|
151
|
+
{ memberId: "peer", role: "REVIEWER" },
|
|
152
|
+
],
|
|
153
|
+
}),
|
|
154
|
+
[
|
|
155
|
+
{ memberId: "boss", role: "APPROVER", source: "objective.adoptedById" },
|
|
156
|
+
{ memberId: "peer", role: "REVIEWER", source: null },
|
|
157
|
+
]
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
142
161
|
test("badKeys are excluded from adoptedObjectives so a broken node cannot drive work", () => {
|
|
143
162
|
const body = tree();
|
|
144
163
|
assert.deepEqual(adoptedObjectives(body, { ownerMemberId: ME, badKeys: ["pipeline"] }), []);
|
package/lib/mandate/refresh.mjs
CHANGED
|
@@ -28,14 +28,28 @@
|
|
|
28
28
|
"use strict";
|
|
29
29
|
|
|
30
30
|
import { readCache, writeCache, stalenessTier, checksumOf } from "./cache.mjs";
|
|
31
|
+
import { assertMandateContract } from "./contract.mjs";
|
|
31
32
|
import { deriveMandate, toMandateBody } from "./derive.mjs";
|
|
32
33
|
import { validateMandateBody } from "./model.mjs";
|
|
33
34
|
|
|
34
35
|
/** The protocol method that returns a seat's adopted snapshot. */
|
|
35
36
|
export const GET_METHOD = "mandate.get";
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The degradation logger. Defaults to the CONSOLE, not to a no-op.
|
|
40
|
+
*
|
|
41
|
+
* `() => {}` was the default here, which meant any caller that forgot `deps.log`
|
|
42
|
+
* — and `refreshMandate` is called from the daemon, from setup, and from tests —
|
|
43
|
+
* silently discarded every contract violation and every fallback notice. That is
|
|
44
|
+
* the same failure class as an empty `catch {}`: the code is correct, reports
|
|
45
|
+
* honestly, and nobody ever hears it. Fail-open is fine; silent is not.
|
|
46
|
+
*/
|
|
37
47
|
function logOf(deps) {
|
|
38
|
-
|
|
48
|
+
if (deps && typeof deps.log === "function") return deps.log;
|
|
49
|
+
return (level, msg) => {
|
|
50
|
+
try { (level === "error" ? console.error : console.warn)(msg); }
|
|
51
|
+
catch { /* logging may never throw into the refresh path */ }
|
|
52
|
+
};
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
/**
|
|
@@ -61,7 +75,27 @@ export async function fetchSnapshot(deps = {}) {
|
|
|
61
75
|
}
|
|
62
76
|
if (frame && frame.ok) {
|
|
63
77
|
const r = frame.result || {};
|
|
64
|
-
|
|
78
|
+
// `mandate.get` answers "this seat has NOTHING adopted" with an ok frame
|
|
79
|
+
// carrying `body: null, version: 0` — deliberately, so a brand-new seat's
|
|
80
|
+
// first beat is not a 404 (hq src/server/methods/mandate/get.ts).
|
|
81
|
+
//
|
|
82
|
+
// `body: r.body || r` read that as a hit and cached the RPC ENVELOPE
|
|
83
|
+
// ({memberId, version, checksum, body:null, publishedAt, publishedById}) as
|
|
84
|
+
// the mandate body, with `source:"server"`. Two consequences, both silent
|
|
85
|
+
// until you look: the body carries zero objectives, and `refreshMandate`
|
|
86
|
+
// then refuses forever to let the local derivation supersede it ("adopted
|
|
87
|
+
// beats proposed") — so the cache is pinned to an empty mandate that no
|
|
88
|
+
// amount of re-derivation can dislodge. Verified live against
|
|
89
|
+
// os.cohortapp.com for member cmqh0tcml004ihhl6zev6ocb5.
|
|
90
|
+
//
|
|
91
|
+
// An unadopted seat is a REPORTABLE ABSENCE, not a fetch. The caller falls
|
|
92
|
+
// through to the local derivation, which is exactly the pre-adoption
|
|
93
|
+
// posture: every objective `state:'proposed'`, no obligations, no backlog.
|
|
94
|
+
const body = r.body;
|
|
95
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
96
|
+
return { ok: false, snapshot: null, reason: "no-adopted-mandate" };
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, snapshot: { version: Number(r.version) || 0, checksum: r.checksum || null, body }, reason: "fetched" };
|
|
65
99
|
}
|
|
66
100
|
const code = (frame && frame.error && frame.error.code) || "unknown";
|
|
67
101
|
const msg = (frame && frame.error && frame.error.message) || "";
|
|
@@ -109,13 +143,25 @@ export async function refreshMandate(agentRoot, deps = {}) {
|
|
|
109
143
|
{ version: got.snapshot.version, checksum: local, body, source: "server" },
|
|
110
144
|
{ now: deps.now ? () => new Date(deps.now()).toISOString() : undefined }
|
|
111
145
|
);
|
|
146
|
+
// TWO checks, and they answer different questions. `validateMandateBody`
|
|
147
|
+
// asks "is the objective TREE sound?"; `assertMandateContract` asks "does
|
|
148
|
+
// this body carry the keys the compiler reads at all?" — the second is the
|
|
149
|
+
// one that was missing, which is how a body with three absent keys sailed
|
|
150
|
+
// through as valid and the plan then ran on invented money.
|
|
151
|
+
const contract = assertMandateContract(record, log, "mandate.get");
|
|
112
152
|
const validation = validateMandateBody(record, { ownerMemberId: deps.memberId });
|
|
113
153
|
for (const e of validation.errors) log("warn", `[mandate] adopted snapshot is invalid: ${e}`);
|
|
114
154
|
return {
|
|
115
155
|
ok: true,
|
|
116
156
|
source: "server",
|
|
117
|
-
|
|
118
|
-
|
|
157
|
+
// A body whose contract is broken is still cached (adopted objectives
|
|
158
|
+
// beat nothing), but the caller is told it is degraded rather than being
|
|
159
|
+
// handed a clean-looking record it cannot budget from.
|
|
160
|
+
degraded: !contract.ok,
|
|
161
|
+
contract,
|
|
162
|
+
reason: contract.ok
|
|
163
|
+
? "fetched the adopted snapshot"
|
|
164
|
+
: `fetched the adopted snapshot, but it violates mandate contract v1 (missing: ${contract.missing.join(", ") || "n/a"})`,
|
|
119
165
|
changed: record.checksum !== prevChecksum,
|
|
120
166
|
tier: stalenessTier(record, deps.now ? deps.now() : Date.now()),
|
|
121
167
|
record,
|
|
@@ -126,7 +172,9 @@ export async function refreshMandate(agentRoot, deps = {}) {
|
|
|
126
172
|
"warn",
|
|
127
173
|
got.reason === "protocol-missing-mandate-family"
|
|
128
174
|
? "[mandate] the vendored protocol carries no `mandate` family — falling back to the local derivation. Re-vendor lib/org/protocol.mjs (by hand: sync-protocol is unsafe while maestro main lacks the crm family) to pick up adopted objectives."
|
|
129
|
-
:
|
|
175
|
+
: got.reason === "no-adopted-mandate"
|
|
176
|
+
? "[mandate] hq answered mandate.get with an EMPTY snapshot (version 0, body null): this seat has nothing adopted. Falling back to the local derivation — every objective stays `proposed`, which compiles no obligations and admits no backlog. The goal-steward loop will measure nothing and create nothing until a human adopts objectives for this seat in hq."
|
|
177
|
+
: `[mandate] could not fetch the adopted snapshot (${got.reason}) — falling back to the local derivation`
|
|
130
178
|
);
|
|
131
179
|
}
|
|
132
180
|
|
|
@@ -32,18 +32,39 @@ function cleanup(root) {
|
|
|
32
32
|
try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A body in the shape hq ACTUALLY publishes — contract v1, every key the
|
|
37
|
+
* compiler reads present, budgets sourced and apportioned. This fixture used to
|
|
38
|
+
* carry only `{memberId, objectives}`, which is precisely the pre-contract shape
|
|
39
|
+
* that let `budgetCentsPerPeriod` be `undefined` all the way into the compiler.
|
|
40
|
+
* Keep it in sync with hq's `_contract.ts#MANDATE_BODY_KEYS`.
|
|
41
|
+
*/
|
|
35
42
|
const BODY = {
|
|
43
|
+
contractVersion: 1,
|
|
36
44
|
memberId: "mem_self",
|
|
37
45
|
objectives: [
|
|
38
|
-
{ key: "revenue", kind: "PILLAR", state: "active", charterSectionId: "cs_1", adoptedById: "mem_boss" },
|
|
46
|
+
{ key: "revenue", kind: "PILLAR", state: "active", charterSectionId: "cs_1", adoptedById: "mem_boss", budgetCentsPerPeriod: null },
|
|
39
47
|
{
|
|
40
48
|
key: "pipeline", kind: "OBJECTIVE", parentKey: "revenue", state: "active",
|
|
41
49
|
metric: "pipeline_coverage_x", target: 3, direction: "up", cadence: "weekly",
|
|
42
50
|
sensor: { capability: "crm_list_deals", source: "method" }, adoptedById: "mem_boss",
|
|
51
|
+
weight: 1, budgetCentsPerPeriod: 25_000,
|
|
43
52
|
},
|
|
44
53
|
],
|
|
54
|
+
proposed: [],
|
|
55
|
+
budgetCentsPerPeriod: null,
|
|
56
|
+
seatBudgetCents: 25_000,
|
|
57
|
+
budgetPeriod: "monthly",
|
|
58
|
+
budgetSource: "employee.payBasis.meteredBudget",
|
|
59
|
+
collaborators: [{ memberId: "mem_boss", role: "REVIEWER", source: "workforceMember.supervisorId" }],
|
|
60
|
+
reactsTo: [],
|
|
61
|
+
degradations: [],
|
|
62
|
+
generatedAt: "2026-08-11T00:00:00.000Z",
|
|
45
63
|
};
|
|
46
64
|
|
|
65
|
+
/** The shape hq sent BEFORE the contract. Kept to prove it is now caught. */
|
|
66
|
+
const LEGACY_BODY = { memberId: "mem_self", objectives: BODY.objectives, proposed: [], generatedAt: BODY.generatedAt };
|
|
67
|
+
|
|
47
68
|
const DERIVATION = {
|
|
48
69
|
charterSections: [{ id: "cs_1", kind: "PILLAR", order: 1, title: "Revenue growth", body: "Own revenue." }],
|
|
49
70
|
kpiCategories: [{ id: "pipeline", name: "Pipeline coverage", examples: ["pipeline_coverage_x"] }],
|
|
@@ -172,6 +193,34 @@ test("the online path caches the adopted snapshot and reports it changed", async
|
|
|
172
193
|
} finally { cleanup(root); }
|
|
173
194
|
});
|
|
174
195
|
|
|
196
|
+
test("a PRE-CONTRACT snapshot is cached but reported DEGRADED and LOUD", async () => {
|
|
197
|
+
// The original break: hq sent this exact shape, `refreshMandate` reported
|
|
198
|
+
// `degraded:false, reason:"fetched the adopted snapshot"`, and the compiler
|
|
199
|
+
// then invented 500c for every obligation. Adopted objectives still beat
|
|
200
|
+
// nothing, so it is still cached — but it may never look clean again.
|
|
201
|
+
const root = makeRoot();
|
|
202
|
+
const logs = [];
|
|
203
|
+
try {
|
|
204
|
+
const r = await refreshMandate(root, {
|
|
205
|
+
memberId: "mem_self",
|
|
206
|
+
now: () => NOW,
|
|
207
|
+
log: (level, msg) => logs.push(`${level}: ${msg}`),
|
|
208
|
+
callImpl: async () => ({ ok: true, result: { version: 4, checksum: checksumOf(LEGACY_BODY), body: LEGACY_BODY } }),
|
|
209
|
+
});
|
|
210
|
+
assert.equal(r.ok, true);
|
|
211
|
+
assert.equal(r.source, "server");
|
|
212
|
+
assert.equal(r.degraded, true, "a body the compiler cannot budget from is degraded");
|
|
213
|
+
assert.equal(r.contract.ok, false);
|
|
214
|
+
for (const k of ["budgetCentsPerPeriod", "seatBudgetCents", "collaborators", "reactsTo", "contractVersion"]) {
|
|
215
|
+
assert.ok(r.contract.missing.includes(k), `contract.missing names ${k}`);
|
|
216
|
+
}
|
|
217
|
+
assert.match(r.reason, /violates mandate contract v1/);
|
|
218
|
+
// Still cached — the adopted tree is worth more than nothing.
|
|
219
|
+
assert.equal(readCache(root).source, "server");
|
|
220
|
+
assert.ok(logs.some((l) => l.startsWith("error:")), "the violation is logged, not swallowed");
|
|
221
|
+
} finally { cleanup(root); }
|
|
222
|
+
});
|
|
223
|
+
|
|
175
224
|
test("SERVER UNREACHABLE, no prior cache: falls back to the local derivation, degraded and LOUD", async () => {
|
|
176
225
|
const root = makeRoot();
|
|
177
226
|
try {
|
|
@@ -291,3 +340,36 @@ test("a corrupt cache does not block a fresh server refresh", async () => {
|
|
|
291
340
|
assert.equal(JSON.parse(readFileSync(cachePath(root), "utf-8")).version, 4);
|
|
292
341
|
} finally { cleanup(root); }
|
|
293
342
|
});
|
|
343
|
+
|
|
344
|
+
test("REGRESSION — hq's 'nothing adopted' answer must NOT be cached as a server snapshot", async () => {
|
|
345
|
+
// mandate.get answers an unadopted seat with {version:0, checksum:null,
|
|
346
|
+
// body:null} BY DESIGN (hq src/server/methods/mandate/get.ts). `body: r.body || r`
|
|
347
|
+
// read that as a hit and cached the RPC ENVELOPE as the mandate body with
|
|
348
|
+
// source:"server" — after which "a local derivation never supersedes a server
|
|
349
|
+
// snapshot" pinned the seat to an empty mandate permanently. Verified live
|
|
350
|
+
// against os.cohortapp.com.
|
|
351
|
+
const root = makeRoot();
|
|
352
|
+
try {
|
|
353
|
+
const r = await refreshMandate(root, {
|
|
354
|
+
memberId: "mem_self",
|
|
355
|
+
now: () => NOW,
|
|
356
|
+
log: () => {},
|
|
357
|
+
callImpl: async () => ({
|
|
358
|
+
ok: true,
|
|
359
|
+
result: { memberId: "mem_self", version: 0, checksum: null, body: null, publishedAt: null, publishedById: null },
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
assert.notEqual(r.source, "server", "an empty snapshot is an absence, not a fetch");
|
|
363
|
+
assert.equal(r.source, "local");
|
|
364
|
+
const cached = JSON.parse(readFileSync(cachePath(root), "utf-8"));
|
|
365
|
+
assert.equal(cached.source, "local");
|
|
366
|
+
assert.ok(Array.isArray(cached.body.objectives), "the cached body is a real mandate body, not the RPC envelope");
|
|
367
|
+
assert.equal(cached.body.publishedAt, undefined, "the envelope's own keys never leaked into the body");
|
|
368
|
+
} finally { cleanup(root); }
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("fetchSnapshot names the empty-snapshot case distinctly", async () => {
|
|
372
|
+
const got = await fetchSnapshot({ callImpl: async () => ({ ok: true, result: { version: 0, body: null } }) });
|
|
373
|
+
assert.equal(got.ok, false);
|
|
374
|
+
assert.equal(got.reason, "no-adopted-mandate");
|
|
375
|
+
});
|
package/lib/org/doctor.mjs
CHANGED
|
@@ -80,6 +80,65 @@ function identityHints(agentRoot, env) {
|
|
|
80
80
|
return hints;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* LOCAL probe: is the reactive lane in this agent repo actually wired?
|
|
85
|
+
*
|
|
86
|
+
* An agent repo carries its OWN copy of the framework (`lib/**` and
|
|
87
|
+
* `scripts/daemon/**`), copied in at enrolment and refreshed on upgrade. Those
|
|
88
|
+
* two trees can drift apart, and when they do the failure is completely silent:
|
|
89
|
+
*
|
|
90
|
+
* `scripts/daemon/maestro-daemon.mjs` resolves `./agent-daemon.mjs` from the
|
|
91
|
+
* AGENT's own directory. If that copy predates the execution ladder, then
|
|
92
|
+
* `processItem` never calls `runExecutionLadder`, and `lib/execution/**` —
|
|
93
|
+
* which the same repo may well be carrying, current — is dead code. Every
|
|
94
|
+
* inbound event still gets classified and answered, so the agent LOOKS
|
|
95
|
+
* healthy; what is missing is the entire decision spine: no directedness
|
|
96
|
+
* journal, no obligation binding, no rung, no drift record, nothing written
|
|
97
|
+
* to `state/execution/journal.jsonl`. Observed live on a real seat
|
|
98
|
+
* (2026-08-11): the wide inbound was current to within one ledger row, and
|
|
99
|
+
* the execution journal had not a single row from the daemon itself.
|
|
100
|
+
*
|
|
101
|
+
* Nothing else can catch this. The new build cannot warn about the old one, and
|
|
102
|
+
* the old one does not know the ladder exists — so the check has to be made
|
|
103
|
+
* from `lib/`, which is the half that gets refreshed.
|
|
104
|
+
*
|
|
105
|
+
* Pure + injectable; never throws.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} agentRoot
|
|
108
|
+
* @param {object} [deps] {existsSync, readFileSync} for tests
|
|
109
|
+
* @returns {{level:"ok"|"warn"|"fail", msg:string}|null} null when not applicable
|
|
110
|
+
*/
|
|
111
|
+
export function checkReactiveLaneWiring(agentRoot, deps = {}) {
|
|
112
|
+
const _exists = deps.existsSync || existsSync;
|
|
113
|
+
const _read = deps.readFileSync || readFileSync;
|
|
114
|
+
const daemonPath = join(agentRoot, "scripts", "daemon", "agent-daemon.mjs");
|
|
115
|
+
const pipelinePath = join(agentRoot, "lib", "execution", "pipeline.mjs");
|
|
116
|
+
try {
|
|
117
|
+
// Not an agent repo layout (running from the framework checkout, or a
|
|
118
|
+
// partial install) — nothing to say.
|
|
119
|
+
if (!_exists(daemonPath) || !_exists(pipelinePath)) return null;
|
|
120
|
+
const src = String(_read(daemonPath, "utf8"));
|
|
121
|
+
if (src.includes("runExecutionLadder")) {
|
|
122
|
+
return { level: "ok", msg: "Reactive lane: the daemon runs inbound events through the execution ladder" };
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
level: "fail",
|
|
126
|
+
msg:
|
|
127
|
+
"Reactive lane: scripts/daemon/agent-daemon.mjs in THIS repo never calls runExecutionLadder, but " +
|
|
128
|
+
"lib/execution/ is present and current — the ladder is dead code here. Inbound events are still " +
|
|
129
|
+
"classified and answered, but intake/obligation-match/rung/journal DO NOT RUN and " +
|
|
130
|
+
"state/execution/journal.jsonl will stay empty. The daemon copy is stale: re-run the framework " +
|
|
131
|
+
"upgrade so scripts/daemon/ is refreshed alongside lib/.",
|
|
132
|
+
};
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Fail-open, never silent — a doctor probe must not take doctor down.
|
|
135
|
+
return {
|
|
136
|
+
level: "warn",
|
|
137
|
+
msg: `Reactive lane: could not read ${daemonPath} to check ladder wiring (${err && err.message ? err.message : String(err)})`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
83
142
|
/**
|
|
84
143
|
* Run the Cohort connectivity probes.
|
|
85
144
|
* @param {object} o
|
|
@@ -87,6 +146,7 @@ function identityHints(agentRoot, env) {
|
|
|
87
146
|
* @param {Function} [o.fetchImpl] injectable fetch (tests)
|
|
88
147
|
* @param {object} [o.env] injectable env (tests; default process.env)
|
|
89
148
|
* @param {() => number} [o.now] injectable clock for latency (tests)
|
|
149
|
+
* @param {object} [o.fs] injectable {existsSync, readFileSync} (tests)
|
|
90
150
|
* @returns {Promise<Array<{level:"ok"|"warn"|"fail", msg:string}>>}
|
|
91
151
|
*/
|
|
92
152
|
export async function checkOrgConnectivity(o = {}) {
|
|
@@ -95,6 +155,12 @@ export async function checkOrgConnectivity(o = {}) {
|
|
|
95
155
|
const now = typeof o.now === "function" ? o.now : Date.now;
|
|
96
156
|
const results = [];
|
|
97
157
|
|
|
158
|
+
// ── 0. LOCAL: is the decision spine wired in this repo? ──────────────────
|
|
159
|
+
// Runs before the network probes and before the not-enrolled early return:
|
|
160
|
+
// a stale daemon is worth reporting even on an agent that never enrolled.
|
|
161
|
+
const wiring = checkReactiveLaneWiring(agentRoot, o.fs || {});
|
|
162
|
+
if (wiring) results.push(wiring);
|
|
163
|
+
|
|
98
164
|
// ── 1. config resolution ─────────────────────────────────────────────────
|
|
99
165
|
const cfg = configFromAgent(loadOrgConfig(agentRoot));
|
|
100
166
|
const base = cfg.base || (env.COHORT_BASE ? String(env.COHORT_BASE).replace(/\/+$/, "") : "");
|