Corrected by ${esc(who(r.correction.by))}: ${esc(r.correction.reason)}${r.correction.repaired ? ` · ${esc(r.correction.repaired)}` : ''}
` : ''}
${mine ? `
` : ''}
diff --git a/docs/devteam/governance/constitution.md b/docs/devteam/governance/constitution.md
index 908086b..89fe0d0 100644
--- a/docs/devteam/governance/constitution.md
+++ b/docs/devteam/governance/constitution.md
@@ -48,7 +48,7 @@ Some owner decisions can be handed to the supervisor a step at a time, as eviden
"incident-fix": { "ceiling": 3, "start": 2, "about": "open and run a ticket that fixes a production incident the sensors found" },
"production-remediation": { "ceiling": 2, "start": 1, "about": "restart, scale or roll back production with a reviewed remediation script" }
},
- "constitutional": ["merge-word", "approve-feature-plan", "merge-chore", "worker-slots", "replan-within-feature", "add-ticket", "approve-spec", "approve-plan", "approve-design-change", "contract-ruling", "improvement-trial", "release-staging", "goal-readiness", "apply-default", "withdraw-question", "recovery", "carry-answer", "drain", "undrain", "goal-hold", "fleet-drain", "fleet-park", "fleet-undrain", "fleet-unpark", "registry-edit", "goal-impact"],
+ "constitutional": ["merge-word", "approve-feature-plan", "merge-chore", "worker-slots", "replan-within-feature", "add-ticket", "approve-spec", "approve-plan", "approve-design-change", "contract-ruling", "improvement-trial", "release-staging", "goal-readiness", "apply-default", "withdraw-question", "recovery", "carry-answer", "drain", "undrain", "goal-hold", "fleet-drain", "fleet-park", "fleet-undrain", "fleet-unpark", "registry-edit", "goal-impact", "orient-noise"],
"owner_only": ["one-way-door", "check-override", "governance-edit", "process-edit", "gate-change", "goal-change", "strategy-change", "goal-met", "registry-attest", "new-dependency", "artifact-publish"],
"owner_rules": ["gate-change"],
"reasons": [
@@ -66,7 +66,7 @@ Some owner decisions can be handed to the supervisor a step at a time, as eviden
}
```
-- **Three kinds of decision.** `classes` are the owner's decisions that can be delegated, each with a start rung and a ceiling. `constitutional` are the supervisor's own; they are logged as decisions or acts and a correction of one feeds knowledge, never authority. `owner_only` stay at "ask" whatever the evidence; of those, the classes in `owner_rules` (gate-change) may also be settled by a standing rule the owner gave themselves (`answer … --rule … --rule-scope class:` from the dashboard, their terminal or chat), which then approves that class's calls and merges as an answer would. A project may lower a ceiling in its settings (`autonomy_ceilings`), never raise one; `validate` fails if it does. `registry-edit` (a url link added, changed or removed) is constitutional: the owner corrects it like any act, and a correction becomes a lesson scoped `link:`, not a trust drop. If the owner wants trust semantics here later, it becomes a ladder class at start 3, ceiling 3. `goal-impact` (how much a goal matters, judged against the strategy) is constitutional too: the owner's correction sets the goal's impact to the value they give and becomes a lesson scoped `class:goal-impact`, not a trust drop. `registry-attest`, the owner's yes that the registry lists everything the project depends on (`planning.md` §7), is owner_only: never defaulted. `artifact-publish`, the owner making a landed artifact readable by anyone from the cloud box or taking it down, is owner_only and the one class of act the owner writes (`artifact publish|unpublish`, which the gateway's confirm page runs): a record of what the owner did, never a call, so trust, decisions and corrections leave it out, and the log refuses it from anyone else. A preview (a running dev server the owner can open) is run state, approves nothing, and is never a record.
+- **Three kinds of decision.** `classes` are the owner's decisions that can be delegated, each with a start rung and a ceiling. `constitutional` are the supervisor's own; they are logged as decisions or acts and a correction of one feeds knowledge, never authority. `owner_only` stay at "ask" whatever the evidence; of those, the classes in `owner_rules` (gate-change) may also be settled by a standing rule the owner gave themselves (`answer … --rule … --rule-scope class:` from the dashboard, their terminal or chat), which then approves that class's calls and merges as an answer would. A project may lower a ceiling in its settings (`autonomy_ceilings`), never raise one; `validate` fails if it does. `registry-edit` (a url link added, changed or removed) is constitutional: the owner corrects it like any act, and a correction becomes a lesson scoped `link:`, not a trust drop. If the owner wants trust semantics here later, it becomes a ladder class at start 3, ceiling 3. `goal-impact` (how much a goal matters, judged against the strategy) is constitutional too: the owner's correction sets the goal's impact to the value they give and becomes a lesson scoped `class:goal-impact`, not a trust drop. `orient-noise` (a recurring signal ruled noise, citing the observations it explains) is constitutional too: the owner corrects it like any call, a correction brings the signal back to Orient, and it moves no rung. `registry-attest`, the owner's yes that the registry lists everything the project depends on (`planning.md` §7), is owner_only: never defaulted. `artifact-publish`, the owner making a landed artifact readable by anyone from the cloud box or taking it down, is owner_only and the one class of act the owner writes (`artifact publish|unpublish`, which the gateway's confirm page runs): a record of what the owner did, never a call, so trust, decisions and corrections leave it out, and the log refuses it from anyone else. A preview (a running dev server the owner can open) is run state, approves nothing, and is never a record.
- **Why a question was asked.** Every question names its class and carries `why_asked`: the class's rung and ceiling, and reasons from the table. The dashboard shows it first.
- **Moving up.** Only through the owner. When answering, the owner can say how the next decision of that class is handled (ask, ask with a default, act and report), for the whole class or one scope; it applies at once, up to the ceiling. Or the supervisor asks a `trust` question when, of the last ten answered questions in the class, the owner went with its recommendation nine or more times (an answer equal to the recommendation; defaulted answers don't count); `ask --kind trust` refuses without that evidence, and the question carries those question ids and the uncorrected calls since the class last dropped. At most one open per class, two in all, asked at a retro. The supervisor never moves itself up.
- **Decisions and acts.** A judgment call is a `decision` line (`devteam.mjs decide`): what was decided, the options weighed, why, and the authority it rested on, snapshotted when it was made (the class's rung and ceiling, and the answer, default or standing rule it cites). Acts that carry it out cite it (`act --decision `), take its class and stay within its refs. A decision made on the class's own rung lapses if the class drops after it. A mechanical act (a merge the gates passed, a worker started for a ready ticket) needs no decision line and is its own decision; so is every act logged before decisions existed.
diff --git a/docs/devteam/process/supervisor.md b/docs/devteam/process/supervisor.md
index 3acdd68..1ea9d69 100644
--- a/docs/devteam/process/supervisor.md
+++ b/docs/devteam/process/supervisor.md
@@ -26,12 +26,15 @@ Two loops run through you. **Goal → design → roadmap → graph → tickets:*
Run it about every 10 minutes when PRs are converging, 30 when quiet. Between ticks, wait with `devteam.mjs wait [--minutes n]` (in the background where your harness can be woken by a finished command): it returns when a worker messages you, the owner does anything (an answer, a goal decision, an ack), a new high finding appears, or the tick is due (`tick_minutes`, default 15), and says which. Don't write your own watch loop: one that wakes only on messages and answers misses a worker that stopped silently on an API error, since a stopped worker sends nothing. A high finding already open when `wait` starts returns at once: act on it, or `ack` it with a reason. Each tick is one pass of the loop:
**Observe**
+
+A reading (a sensor's daily rollup, a goal reading, a `spend` line, a `gate` run) is a measurement: it shows on charts and in Health, and is never a signal Orient groups. An observation is a reading escalated because there is cause for action, or something noticed; where it came from (`provenance`: one of the schema's `x-ooda.sources`, such as `sensor`, `metrics`, `owner-answer`, `worker`) and its stage in the loop are computed when it is read, so read `provenance.from`, not `source`, which keeps whatever its writer gave. When what you record is the owner's words (something they said in your session or a thread that isn't itself an answer), `observe --source owner-chat --ref ` (or `owner-answer`, `owner-thread`), naming the record that holds them: the question they answered, or their turn or thread. Without such a record it is your observation, and reads as yours.
+
1. `git fetch origin` (home's), then step 2's `devteam.mjs observe-metrics`, which fetches each repo link's main (four at a time, inside the tick's 20 s network budget, before its probes) so everything after reads every repo as its origin has it now; then `devteam.mjs status --json`; note new merges. `devteam.mjs next` prints the owner's next best step: the top of the Inbox, and why it is there. A sibling whose main it can't fetch is a `repo-unreachable` finding naming the link; its main stays as last fetched until a later tick's fetch succeeds.
2. `devteam.mjs observe-metrics`: first it fetches each repo link's main (step 1), then it records what every agent spent since the last tick as `spend` lines (from the harnesses' session logs, which get cleaned up; the log keeps it), then each new finding (flaky gate, slow test, blocked ticket, stale worker, stalled inbox, idle capacity, cost hotspot…) becomes an observation, once a day per finding. A finding you have looked at and decided can wait: `ack --reason "…"` sets it aside for `finding_ack_hours` (it comes back sooner if its subject changes, and the dashboard lists it with your reason); fix the cause instead when there is one (a held ticket, a lowered cap). Record anything else you notice about the graph, the code or the project as an observation too (`observe --subject … --kind … --detail … --severity 0-4 --valence -2..2 --key …`): estimates that missed, tickets added after a plan landed, a design change planning should have caught. `observe-metrics` also probes the registry once a day (`link-unreachable`; a page that answers 401, 403 or 405 is the low `link-behind-auth`), checks main's lines (`unowned-files`: no new ticket starts until a chore extends a line; an owns glob that matches nothing is the low `line-empty`, which blocks nothing), watches for `line-pressure`, and raises `merged-out-of-order` for a ticket that reached its main before a ticket it depends on (`merge` refuses that, so it was merged by hand or in the forge's UI): check the ticket works without it and merge the dependency next.
**Orient**
3. Fix drift (§4).
-4. `devteam.mjs orient`: every recurring signal (three or more observations or corrections on one key) gets explained: a lesson (`knowledge add --kind lesson --scope … --evidence …`, with `--kit` when it is about the kit rather than this project, §9 Share upstream), a problem (`problem add`), or noise (say so in an observation). A candidate lesson waits in the owner's inbox to keep or retire; accept one yourself (`knowledge set --status accepted`) only when the evidence is plain, and the owner can still retire it. Accepted knowledge goes into the next prompts in that scope. Triage open problems: assign, mitigate, resolve. Read the owner's recent resolutions (`log --type observation`, kind `thread-resolved`): what they keep raising, about which goals and pages, and what they resolved without a change. Three threads on one goal is a signal about the goal: orient on it. For each new problem or incident, and each observation of severity 3 or more, `search "" --limit 5`: it may have happened before under other ids.
+4. `devteam.mjs orient`: every recurring signal (three or more observations or corrections on one key) gets explained: a lesson (`knowledge add --kind lesson --scope … --evidence …`, with `--kit` when it is about the kit rather than this project, §9 Share upstream), a problem (`problem add`), or noise: `decide --class orient-noise --refs -- ""` (it clears the signal; the owner can correct it, and a corrected ruling brings the signal back). A sensor's qualifies and spikes on one fingerprint are one signal, keyed by the fingerprint's id. A ruling that names the signal's key covers all of its observations up to the ruling, so it needn't list them; one after it gets a fresh look (use `sensor ignore` for a fingerprint that is always noise). A fingerprint an open incident holds is in hand, not a signal. A candidate lesson waits in the owner's inbox to keep or retire; accept one yourself (`knowledge set --status accepted`) only when the evidence is plain, and the owner can still retire it. Accepted knowledge goes into the next prompts in that scope. Triage open problems: assign, mitigate, resolve. Read the owner's recent resolutions (`log --type observation`, kind `thread-resolved`): what they keep raising, about which goals and pages, and what they resolved without a change. Three threads on one goal is a signal about the goal: orient on it. For each new problem or incident, and each observation of severity 3 or more, `search "" --limit 5`: it may have happened before under other ids.
- **Revisit what matters.** You rate; the kit orders. A goal with no impact (`goal-unjudged`), or one whose impact was corrected at a retro: `goal impact <1-4> --rationale "…"`, judged against the strategy's "How it wins" order and never against how much work the goal is (`planning.md` §1). Judge again when the strategy changes. An open question whose stake or urgency has changed (work stopped on it, a ticket merged, another question now covers it): `rate --urgency 3`. A new question that an open one would settle: `rate --settles `, which adds it to the list. An unrated question (`question-unrated`): rate it.
**Decide**
@@ -102,7 +105,7 @@ Production is watched by **sensors**, and they are yours: you add, change and st
- A rollback across a `production-migration` act, or a release whose range touches a migration in git (a release phase runs those with no act; a release the repo can't anchor to a commit fails closed), is a one-way door: ask it `--kind one-way-door`, with no default, and the owner answers it (in the Inbox or in this chat, it's the same).
- Asking, deciding and running follow Upkeep's hold; `--force` overrides it, so use it only with the owner's word.
- A new or changed remediation is a chore ticket, and its settings change (the entry and its `sha`) is `governance-edit`.
-- **Findings.** `sensor-down` while it stays down, `cert-expiring`, `slo-breach-week`; `sensor-failing` (3 failed runs; a lapsed login says so), `sensor-stale` (not polled in 3 times its interval while you run), `probe-drift` (it ran clean, then printed lines that don't validate: the provider changed), `probe-costly` (timed out, over the caps, slower than `budget.run_s` or half its timeout, or a `quota…remaining` metric under 10%), `probe-noisy`, `probe-split`, `probe-merged`, and `incident-unowned` (severity 3 or more, open over an hour, no decision).
+- **Findings.** `sensor-down` while it stays down, `cert-expiring`, `slo-breach-week`; `sensor-failing` (3 failed runs; a lapsed login says so), `sensor-stale` (not polled in 3 times its interval while you run), `probe-drift` (it ran clean, then printed lines that don't validate: the provider changed), `probe-costly` (timed out, over the caps, slower than `budget.run_s` or half its timeout, or a `quota…remaining` metric under 10%), `probe-noisy`, `probe-split`, `probe-merged`, `sensor-untriaged` (a fingerprint's escalation of severity 3 or more, `sensor_defaults.triage_overdue_hours` (default 2) old while you run, that no incident holds, no ignore covers and no noise ruling cites: fold it, ignore it, or rule it noise), and `incident-unowned` (severity 3 or more, open over an hour, no decision).
## 4. Fixing drift
diff --git a/docs/devteam/schema/log.schema.json b/docs/devteam/schema/log.schema.json
index 07c61e9..9be2b91 100644
--- a/docs/devteam/schema/log.schema.json
+++ b/docs/devteam/schema/log.schema.json
@@ -783,7 +783,7 @@
"type": "object",
"properties": {
"source": {
- "description": "Who or what noticed it.",
+ "description": "Who or what noticed it, as its writer gave it: a sensor's name on a sensor's line, repo, prod or owner on a goal reading, one of x-ooda.sources from observe --source, or older free text. Free text stays valid; where it came from, in x-ooda.sources words, is computed when it is read (provenance).",
"type": "string"
},
"subject": {
@@ -799,7 +799,7 @@
"type": "string"
},
"detector": {
- "description": "The check that found it.",
+ "description": "The check that found it: sensor and metrics mark the kit's own lines (every kit reads them so), pane the kit's pane check; anything else is free text.",
"type": "string"
},
"severity": {
@@ -2418,5 +2418,58 @@
]
}
}
+ },
+ "x-ooda": {
+ "description": "Where each record sits in the loop (Observe, Orient, Decide, Act, Learn), and the words for where an observation came from. The views compute both when they read a record (stageOf, provenanceOf in scripts/devteam.mjs); nothing stores them, so a line an older kit wrote reads the same. A measurement (a reading, spend, a gate) is outside the loop: it is never a signal Orient groups. inbox: a question, drawn in Decide as the owner's call once answered. A type not listed (the work graph, conversation) has no stage.",
+ "x-tip": "The five stages of the loop: what was noticed, what is known, what was decided, what was done, and what was learned.",
+ "stages": {
+ "observation": {
+ "kind": {
+ "reading": "measurement",
+ "learning": "learn",
+ "outcome": "learn",
+ "fix-deployed": "learn",
+ "fix-regressed": "learn",
+ "recurred": "learn",
+ "finding-ack": "decide",
+ "sensor-ignored": "decide",
+ "sensor-unignored": "decide", "sensor-reconsidered": "decide",
+ "*": "observe"
+ }
+ },
+ "knowledge": "orient",
+ "problem": "orient",
+ "decision": "decide",
+ "act": "act",
+ "question": "inbox",
+ "spend": "measurement",
+ "gate": "measurement"
+ },
+ "sources": [
+ "sensor",
+ "metrics",
+ "spend",
+ "owner",
+ "owner-answer",
+ "owner-thread",
+ "owner-chat",
+ "retro",
+ "worker",
+ "planner",
+ "supervisor"
+ ],
+ "x-source-words": {
+ "sensor": "a production sensor, or a goal's measure",
+ "metrics": "the kit's own checks",
+ "spend": "the spend meter",
+ "owner": "you",
+ "owner-answer": "your answer to a question",
+ "owner-thread": "a thread with you",
+ "owner-chat": "what you said in the supervisor's chat",
+ "retro": "a retro",
+ "worker": "a ticket's worker",
+ "planner": "a planner",
+ "supervisor": "the supervisor"
+ }
}
}
diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs
index a3941f7..3b3984e 100644
--- a/docs/devteam/scripts/devteam.mjs
+++ b/docs/devteam/scripts/devteam.mjs
@@ -2036,7 +2036,8 @@ function cmdLog() {
if (flagStr('id')) objs = objs.filter((o) => o.id === flagStr('id'));
objs.sort(byTime);
objs = objs.slice(-Number(flagStr('tail') ?? 50));
- if (flags.json) return json(objs);
+ // each record's stage in the loop, and an observation's provenance (computed: x-ooda)
+ if (flags.json) return json(objs.map(withOoda));
for (const o of objs) {
const what = o.question ?? o.statement ?? o.title ?? o.what ?? o.detail ?? o.text ?? (o.type === 'phase' ? `${o.from ?? '-'} -> ${o.to}` : o.type === 'gate' ? `${o.gate} ${o.ok ? 'ok' : 'FAIL'} ${o.ms}ms` : '');
out(`${o.created_at} ${o.id} [${o.type}${o.kind ? ` ${o.kind}` : ''}${o.status ? ` ${o.status}` : ''}] ${String(what).slice(0, 160)}`);
@@ -7879,6 +7880,8 @@ function cmdDecide() {
if (confidence !== undefined && !(confidence >= 0 && confidence <= 1)) die('--confidence is a number from 0 to 1');
if (['approve-spec', 'approve-plan'].includes(cls) && !tickets().has(refs[0])) die(`${cls} names the ticket first: --refs [other ids]`);
if (cls === 'goal-readiness') readinessGoal(refs);
+ // a noise ruling explains observations by citing them (orientClusters); one that cites none explains nothing
+ if (cls === 'orient-noise' && !refs.some((r) => logGet(r)?.type === 'observation')) die('a noise ruling names the observations it explains: --refs …');
const a = authorise(cls, scope, approved, refs, 'decide');
if (a.doorCited && a.approval.ticket !== refs[0] && !(features().get(tickets().get(refs[0])?.feature)?.goals ?? []).includes(a.approval.goal)) die(`${approved} answered a one-way door for ${a.approval.ticket ?? a.approval.goal ?? 'something else'}, not for ${refs[0]} or a goal its feature serves`);
const line = appendLog({
@@ -8015,6 +8018,57 @@ function cmdRepair() {
// --- observe and orient ---
+// Where a record sits in the loop and where an observation came from (schema x-ooda), computed
+// when it is read and never stored: `source` and `detector` already mean things every kit reads (a
+// sensor's name, repo or prod on a goal reading, detector sensor on the kit's own lines), so a
+// rewrite would have an older kit on a mixed fleet misread a newer kit's lines.
+const OODA = SCHEMA['x-ooda'];
+// observe, learn, decide, act, orient, measurement or inbox; null for the work graph and conversation.
+// An act is always Act, whether or not a decision stands behind it.
+const stageOf = (o) => { const s = Object.hasOwn(OODA.stages, o?.type ?? '') ? OODA.stages[o.type] : null; return !s ? null : typeof s === 'string' ? s : Object.hasOwn(s.kind, o.kind ?? '') ? s.kind[o.kind] : s.kind['*']; };
+// The sources each writer may name for itself (observe --source); sensor, metrics and spend are the
+// kit's own, read off its own lines, never given. The supervisor records what the owner said (an
+// answer, a thread, the chat), but never speaks as the owner.
+const SOURCE_FOR = { ticket: ['worker'], planner: ['planner'], supervisor: OODA.sources.filter((s) => !['sensor', 'metrics', 'spend', 'owner', 'worker', 'planner'].includes(s)), owner: ['owner', 'owner-answer', 'owner-thread', 'owner-chat'] };
+// An owner-* source on a line someone else wrote is a claim about the owner's words, so it counts
+// only when its ref is the record that holds them: an answered question (the owner's answer) for
+// owner-answer, the owner's turn or thread for owner-thread, either for owner-chat. A ref that
+// doesn't check out reads as its writer's (provenanceOf); observe refuses it.
+function ownerRefHolds(from, ref) {
+ const o = ref ? logGet(ref) : null;
+ if (!o) return false;
+ const answered = o.type === 'question' && questionView(o).status === 'answered';
+ const spoke = (o.type === 'turn' && o.created_by === 'owner') || o.type === 'thread';
+ return from === 'owner-answer' ? answered : from === 'owner-thread' ? spoke : from === 'owner-chat' ? answered || spoke : true;
+}
+// { from, ref }: from in x-ooda.sources, ref which one within it (a sensor's name, a finding's kind,
+// a question, a thread, a retro's file). First match wins.
+function provenanceOf(o) {
+ if (o?.type !== 'observation') return null;
+ const cls = writerClass(o.created_by);
+ const ref = o.source_ref ?? null;
+ // a set-aside (an ack, a triage ignore) is the call of whoever made it, not the check it sets
+ // aside: it falls to its writer below, though an ignore carries detector sensor
+ const setAside = ['finding-ack', 'sensor-ignored', 'sensor-unignored', 'sensor-reconsidered'].includes(o.kind);
+ // the kit's own lines, as the readers already trust them: a sensor's from the supervisor's process
+ // (fromSensor, which also takes a supervisor's hand line, as it always has), the findings pass's
+ // and the pane check's from the supervisor alone
+ if (!setAside && fromSensor(o)) return { from: 'sensor', ref: o.source ?? null };
+ if (!setAside && o.detector === 'metrics' && cls === 'supervisor') return { from: o.kind === 'cost-hotspot' ? 'spend' : 'metrics', ref: o.kind };
+ if (o.detector === 'pane' && cls === 'supervisor') return { from: 'metrics', ref: 'pane' };
+ // a goal reading (goal reading, by the supervisor or the owner): a repo or prod measure is read like a
+ // sensor's; an owner measure is the owner's yes to a goal-met question
+ const reading = o.kind === 'reading' && ['owner', 'supervisor'].includes(cls);
+ if (reading && ['repo', 'prod'].includes(o.source)) return { from: 'sensor', ref: 'goal-measure' };
+ if (reading && o.source === 'owner' && ownerRefHolds('owner-answer', o.approved)) return { from: 'owner-answer', ref: o.approved };
+ if (o.kind === 'thread-resolved' && cls === 'owner') return { from: 'owner-thread', ref: o.refs?.[0] ?? null };
+ if (o.kind === 'retro' && cls === 'supervisor') return { from: 'retro', ref };
+ if (!setAside && (SOURCE_FOR[cls] ?? []).includes(o.source) && (cls === 'owner' || !o.source.startsWith('owner-') || ownerRefHolds(o.source, ref))) return { from: o.source, ref };
+ if (cls === 'owner') return { from: o.channel === 'chat' ? 'owner-chat' : o.channel === 'thread' ? 'owner-thread' : 'owner', ref };
+ return { from: cls === 'ticket' ? 'worker' : cls, ref };
+}
+// A record as the views show it: its stage, and an observation's provenance.
+const withOoda = (o) => (o?.type === 'observation' ? { ...o, stage: stageOf(o), provenance: provenanceOf(o) } : { ...o, stage: stageOf(o) });
function cmdObserve() {
const by = flags.owner ? ownerWriter().by : logWriter();
// the sensors write their own lines; a hand-made one could fake an outage or hide an error
@@ -8022,10 +8076,19 @@ function cmdObserve() {
const subject = flagStr('subject') ?? die('observe needs --subject (a ticket, component, feature, gate, goal, class or act id)');
// the kit's own detectors write their own lines: a hand-written one could pass for a poll's
if (['sensor', 'metrics', 'pane'].includes(flagStr('detector'))) die(`observe --detector ${flagStr('detector')}: those lines come from the kit itself (sensor poll, observe-metrics, the pane check); use another detector name`);
+ // --source in the loop's words is the writer's own; older free text is still written as given,
+ // and reads as its writer's (provenanceOf)
+ const src = flagStr('source');
+ if (['sensor', 'metrics', 'spend'].includes(src)) die(`observe --source ${src}: those lines come from the kit itself (sensor poll, observe-metrics, observe-spend)`);
+ // a goal's reading (source repo, prod or owner) is the kit's, from goal check or an answer
+ if (flagStr('kind') === 'reading' && ['repo', 'prod', 'owner'].includes(src)) die(`observe --kind reading --source ${src}: a goal's readings come from the kit itself (goal check, the owner's answer)`);
+ const mine = SOURCE_FOR[writerClass(by)];
+ if (OODA.sources.includes(src) && !mine.includes(src)) die(`observe --source ${src}: a ${{ ticket: 'worker', planner: 'planner', supervisor: 'supervisor', owner: 'owner' }[writerClass(by)]}'s observation is from ${mine.join(', ')}`);
+ if (writerClass(by) !== 'owner' && String(src).startsWith('owner-') && !ownerRefHolds(src, flagStr('ref'))) die(`observe --source ${src} needs --ref naming where the owner said it: ${src === 'owner-answer' ? 'a question they answered' : src === 'owner-thread' ? 'their turn or the thread' : 'a question they answered, or their turn'}`);
const line = appendLog({
type: 'observation', source: flagStr('source') ?? writerClass(by), subject, kind: flagStr('kind') ?? die('observe needs --kind (a slug)'),
detail: flagStr('detail') ?? die('observe needs --detail'), detector: flagStr('detector'), severity: flagNum('severity'), valence: flagNum('valence'),
- source_key: flagStr('key'), refs: asList(flags.refs), fix_hint: flagStr('fix'),
+ source_key: flagStr('key'), source_ref: flagStr('ref'), refs: asList(flags.refs), fix_hint: flagStr('fix'),
measure: flagStr('value') !== undefined ? { value: Number(flagStr('value')), unit: flagStr('unit') } : undefined,
}, by);
out(line.id);
@@ -8048,6 +8111,12 @@ function cmdObserveLink() {
out(line.id);
}
const SEV = { low: 1, medium: 2, high: 3 };
+// The most observations a set-aside (an ack, a triage ignore) cites: 40 ids keep its line well under
+// LINE_MAX; the finding's key or the fingerprint, in source_ref, joins the rest.
+const SET_ASIDE_REFS = 40;
+// A set-aside's refs, the newest kept, trimmed so its line (with the envelope's ~250 bytes) fits LINE_MAX:
+// a long reason costs refs, never the line.
+const fitRefs = (fields, refs) => { const r = [...refs]; while (r.length && JSON.stringify({ ...fields, refs: r }).length + 250 > LINE_MAX) r.shift(); return r; };
// Each metrics finding becomes an observation, once a day per finding (deduped on source_key).
function observeFindings(m) {
const recent = new Map();
@@ -8516,16 +8585,43 @@ function cmdProblem() {
if (flags.json) return json(ps);
for (const p of ps) out(`${p.id} [sev ${p.severity}, ${p.realized ? 'issue' : 'risk'}, ${p.status}${p.assigned_to ? `, ${p.assigned_to}` : ''}] ${p.title}${(p.threatens ?? []).length ? ` (threatens ${p.threatens.join(', ')})` : ''}`);
}
-// Recurring signals no knowledge or problem explains yet: three or more observations (or
+// The key an observation recurs on: a sensor's escalation its fingerprint's id, so each qualify and
+// spike on one fingerprint is one signal (a spike's source_key carries its hour, and would never
+// recur); anything else its source_key (a finding's metrics::), else kind:subject.
+// A fix-regressed or recurred line keeps its own key: it already cites its incident.
+const signalKeyOf = (o) => (fromSensor(o) && TRIAGE_KINDS.includes(o.kind) ? fpIdOfObservation(o) : null) ?? o.source_key ?? `${o.kind}:${o.subject}`;
+// What explains an observation, one definition for Orient and for sensor-untriaged. ids: what
+// knowledge's evidence, a problem's refs, or a live decision's refs cite (any class: a decision
+// that cites an observation took it into account). keys: signal key → until when it is explained.
+// A live orient-noise ruling naming a key covers that signal's observations up to the ruling, so a
+// chatty one needn't list them all, and an escalation after it gets a fresh look (a fingerprint that
+// is always noise is for sensor ignore). An open incident holds its fingerprints for as long as it is
+// open. A corrected ruling explains nothing, so the owner's correction brings its signal back.
+// covered(keys, key, at): whether an observation on key, made at at, is explained by its key.
+function explained() {
+ const ids = new Set();
+ const keys = new Map();
+ const upTo = (k, at) => { if (!(keys.get(k) >= at)) keys.set(k, at); };
+ for (const o of [...logObjects('knowledge'), ...logObjects('problem')]) for (const r of [...(o.evidence ?? []), ...(o.refs ?? [])]) ids.add(r);
+ for (const d of logObjects('decision')) {
+ if (!(d.refs ?? []).some((r) => String(r).startsWith('o-')) || !liveDecision(d)) continue;
+ for (const r of d.refs) { ids.add(r); if (d.class === 'orient-noise' && !logGet(r)) upTo(r, d.created_at); }
+ }
+ for (const p of incidentList()) if (p.status !== 'resolved') for (const f of p.fingerprints ?? []) upTo(f, '9999');
+ return { ids, keys };
+}
+const covered = (keys, key, at) => keys.has(key) && at <= keys.get(key);
+// Recurring signals nothing explains yet (explained, above): three or more observations (or
// corrections) on one key, from at least two writers or on at least two days.
function orientClusters() {
- const linked = new Set();
- for (const o of [...logObjects('knowledge'), ...logObjects('problem')]) for (const r of [...(o.evidence ?? []), ...(o.refs ?? [])]) linked.add(r);
+ const { ids: linked, keys } = explained();
const groups = new Map();
const add = (key, item) => (groups.get(key) ?? groups.set(key, []).get(key)).push(item);
for (const o of logObjects('observation')) {
if (linked.has(o.id) || ['reading', 'retro', 'outcome', 'link'].includes(o.kind)) continue;
- add(o.source_key ?? `${o.kind}:${o.subject}`, { id: o.id, at: o.created_at, who: o.created_by, valence: o.valence ?? 0, detail: o.detail });
+ const key = signalKeyOf(o);
+ if (covered(keys, key, o.created_at)) continue;
+ add(key, { id: o.id, at: o.created_at, who: o.created_by, valence: o.valence ?? 0, detail: o.detail });
}
for (const a of calls()) {
const c = correctionOf(a);
@@ -10007,6 +10103,27 @@ function collectMetrics(since) {
const sev = incidentSeverity(p);
if (sev >= 3 && !incidentDecisions(p.id).length) add('medium', 'incident-unowned', p.id, `severity ${sev}, open since ${p.created_at}, no incident-fix decision`, 'decide a fix (decide --class incident-fix --refs …), or watch it with a reason');
}
+ // a sensor's escalation of severity 3 or more nobody has triaged, triage_overdue_hours (default 2)
+ // after the first, while the supervisor runs: no incident holds it (once one does, incident-unowned
+ // above takes over), it isn't ignored, and no live noise ruling cites it. Per fingerprint, over
+ // the last 7 days, so an upgrade doesn't raise every escalation the log ever held.
+ if (supervisorRunning) {
+ const overdue = (Number(loadSettings().sensor_defaults?.triage_overdue_hours) || 2) * 3600000;
+ const sensorsNow = new Set(sensorList().map((x) => x.name));
+ const { ids: handled, keys: heldKeys } = explained();
+ const byFp = new Map();
+ for (const o of logObjects('observation')) {
+ if (!fromSensor(o) || !TRIAGE_KINDS.includes(o.kind) || handled.has(o.id) || now - Date.parse(o.created_at) > 7 * 86400000) continue;
+ const id = fpIdOfObservation(o);
+ if (!id || !sensorsNow.has(splitFpId(id).sensor) || ignoredNow.has(id) || covered(heldKeys, id, o.created_at) || openIncidentOf(id)) continue;
+ (byFp.get(id) ?? byFp.set(id, []).get(id)).push(o);
+ }
+ for (const [id, obs] of byFp) {
+ const sev = Math.max(...obs.map((o) => o.severity ?? 0));
+ const first = obs.map((o) => o.created_at).sort()[0];
+ if (sev >= 3 && now - Date.parse(first) > overdue) add('medium', 'sensor-untriaged', id, `severity ${sev}, ${obs.length} observation(s) since ${first} (${obs.slice(-3).map((o) => o.id).join(', ')}), not triaged`, 'triage it: fold it into an incident, ignore it, or rule it noise (sensor incident, sensor ignore, decide --class orient-noise)');
+ }
+ }
if (sensorList().length || logObjects('observation').some((o) => o.kind === 'discovery')) {
const last = logObjects('observation').filter((o) => o.kind === 'discovery' && o.subject === 'sensors').sort(byTime).pop();
const changed = last?.source_ref && last.source_ref !== discoveryKey();
@@ -10316,7 +10433,12 @@ function cmdAck() {
const live = collectMetrics(0).findings.find((x) => x.kind === kind && x.subject === subject);
if (!live) die(`no current finding ${kind} "${subject}" (devteam.mjs metrics lists them)`);
const by = flags.owner ? ownerWriter().by : (supervisorOnly('ack'), 'supervisor');
- const line = appendLog({ type: 'observation', source: 'ack', kind: 'finding-ack', subject: `${kind} ${subject}`, detail: reason, refs: [] }, by);
+ // it cites what it sets aside: the finding's observations (observeFindings'), the newest 40 so the
+ // line stays under LINE_MAX; the finding's key, in source_ref, joins the rest
+ const key = `metrics:${kind}:${subject}`;
+ const refs = logObjects('observation').filter((o) => o.source_key === key && o.detector === 'metrics' && o.created_by === 'supervisor').sort(byTime).slice(-SET_ASIDE_REFS).map((o) => o.id);
+ const fields = { type: 'observation', source: 'ack', kind: 'finding-ack', subject: `${kind} ${subject}`, detail: reason, source_ref: key };
+ const line = appendLog({ ...fields, refs: fitRefs(fields, refs) }, by);
out(`${line.id}: ${kind} ${subject} set aside for ${loadSettings().finding_ack_hours}h`);
}
@@ -10387,7 +10509,7 @@ function collectOoda() {
goals: logObjects('goal').map(goalView).sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99)),
roadmap: collectRoadmap(),
retro: retroDue(),
- observations: recent(logObjects('observation').filter((o) => !['reading', 'retro', 'finding-ack'].includes(o.kind)), 40),
+ observations: recent(logObjects('observation').filter((o) => !['reading', 'retro', 'finding-ack'].includes(o.kind)), 40).map(withOoda),
clusters: orientClusters(),
// every lesson waiting on the owner is in the Inbox, however many: only the rest are the recent 60
knowledge: [...recent(logObjects('knowledge').filter((k) => k.status === 'candidate'), Infinity), ...recent(logObjects('knowledge').filter((k) => !['retired', 'candidate'].includes(k.status)), 60)],
@@ -13661,6 +13783,8 @@ async function pollSensorLocked(m, { rollupDay = null } = {}) {
let st = sensorState(m.name);
if (m.kind === 'http' && fresh) st = { ...st, ...sensorStateFromLog(m.name) };
const r = m.kind === 'http' ? await pollHttp(m, st) : await pollProbe(m, st);
+ // a check that ran is an hour the sensor heard, events or not (a quiet hour counts toward a median)
+ if (r.sample?.ok) markListened(m.name);
// a rollup for each finished UTC day not rolled up yet (up to a week back), or the day asked for
const yesterday = dateOnly(new Date(Date.now() - 86400000).toISOString());
if (rollupDay || st.rollup_day !== yesterday) {
@@ -13687,11 +13811,14 @@ async function pollSensorLocked(m, { rollupDay = null } = {}) {
if (live.length !== Object.keys(fps).length) writeJsonAtomic(fpsPath, Object.fromEntries(live));
}
const counts = jsonOr(path.join(sensorDir(m.name), 'counts.json'), null);
+ const cut = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 13);
if (counts) {
- const cut = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 13);
for (const fp of Object.keys(counts)) { for (const h of Object.keys(counts[fp])) if (h < cut) delete counts[fp][h]; if (!Object.keys(counts[fp]).length) delete counts[fp]; }
writeJsonAtomic(path.join(sensorDir(m.name), 'counts.json'), counts);
}
+ // the hours it heard, with the counts they are a median of
+ const heard = jsonOr(listenedFile(m.name), null);
+ if (heard) writeJsonAtomic(listenedFile(m.name), Object.fromEntries(Object.entries(heard).filter(([h]) => h >= cut)));
if (st.days) for (const d of Object.keys(st.days)) if (d < dateOnly(new Date(Date.now() - 8 * 86400000).toISOString())) delete st.days[d];
st.pruned = dateOnly(nowIso());
}
@@ -14241,12 +14368,23 @@ function repoPath(file) {
return null;
}
const frameWords = (fr) => `${fr.file}${fr.line != null ? `:${fr.line}` : ''}${fr.fn ? ` ${fr.fn}` : ''}`;
+// The hours a sensor heard: a check that ran, or events that arrived (listened.json, { "":
+// true }, beside counts.json, whose keys are fingerprints). A spike's median is over these alone,
+// so hours a box was down (nobody listening) never read as quiet ones.
+const listenedFile = (name) => path.join(sensorDir(name), 'listened.json');
+function markListened(name, hour = nowIso().slice(0, 13)) {
+ const l = jsonOr(listenedFile(name), {});
+ if (!l[hour]) { l[hour] = true; fs.mkdirSync(sensorDir(name), { recursive: true }); writeJsonAtomic(listenedFile(name), l); }
+ return l;
+}
function ingestEvents(m, events, st, at = nowIso()) {
if (!events.length) return [];
const dir = sensorDir(m.name);
const fps = jsonOr(path.join(dir, 'fingerprints.json'), {});
const counts = jsonOr(path.join(dir, 'counts.json'), {});
const hour = at.slice(0, 13);
+ // events arrived: it heard this hour
+ const listened = markListened(m.name, hour);
const day = dayStats(st, at.slice(0, 10));
const tri = { min_level: 'error', min_count: 1, min_users: 0, ...(m.triage ?? {}) };
const spike = { factor: 5, min: 20, ...(m.spike ?? {}) };
@@ -14301,21 +14439,25 @@ function ingestEvents(m, events, st, at = nowIso()) {
}
}
// spikes: a known fingerprint (a day of the kit's own counts, not the provider's first_seen,
- // which can be months back with nothing counted) this hour against its 7-day hourly median
+ // which can be months back with nothing counted) this hour against its 7-day hourly median, over
+ // the hours the sensor heard (a sensor just upgraded has none yet: a day of them before a spike)
for (const fp of touched) {
const f = fps[fp];
if (levelRank(f.level) < levelRank('warning')) continue;
const c = counts[fp][hour];
const start = Math.max(Date.parse(f.seen_at) || Date.now(), Date.now() - 7 * 86400000);
const hours = [];
- for (let t = Math.floor(start / 3600000) * 3600000; t < Date.parse(`${hour}:00:00.000Z`); t += 3600000) hours.push(counts[fp][new Date(t).toISOString().slice(0, 13)] ?? 0);
+ for (let t = Math.floor(start / 3600000) * 3600000; t < Date.parse(`${hour}:00:00.000Z`); t += 3600000) { const h = new Date(t).toISOString().slice(0, 13); if (listened[h]) hours.push(counts[fp][h] ?? 0); }
if (hours.length < 24) continue;
const median = quantile(hours, 0.5);
const prev = new Date(Date.parse(`${hour}:00:00.000Z`) - 3600000).toISOString().slice(0, 13);
if (c >= Math.max(spike.min, spike.factor * median)) {
if (f.spike_hour !== hour && f.spike_hour !== prev) {
const sev = Math.min(4, (LEVEL_SEV[f.level] ?? 1) + 1);
- const o = sensorObserveOnce(m, { kind: 'spike', detail: `${m.name}: ${f.class ?? f.code ?? 'a log line'} is spiking: ${c} this hour against a median of ${median} an hour over the last ${Math.round(hours.length / 24)} day(s)`.slice(0, 900), severity: sev, valence: -1, measure: { value: c, unit: 'events/h' }, source_key: `sensor:${m.name}:spike:${fp}:${hour}`, refs: typeof f.observed === 'string' ? [f.observed] : [] });
+ // which line: its class or code and its id; a log line's template is production text, framed
+ // (the head is cut first, so the cut never reaches the frame's end marker)
+ const tpl = f.kind === 'log' && isStr(f.template) ? `\n${untrusted('the line', f.template.slice(0, 300))}` : '';
+ const o = sensorObserveOnce(m, { kind: 'spike', detail: `${`${m.name}: ${f.class ?? f.code ?? 'a log line'} (${fpIdOf(m.name, fp)}) is spiking: ${c} this hour against a median of ${median} an hour over the last ${Math.floor(hours.length / 24)} day(s)`.slice(0, 400)}${tpl}`, severity: sev, valence: -1, measure: { value: c, unit: 'events/h' }, source_key: `sensor:${m.name}:spike:${fp}:${hour}`, refs: typeof f.observed === 'string' ? [f.observed] : [] });
if (o && sev >= 4) alerts.push(`${m.name}: a ${f.kind} is spiking (${o.id}; sensor show ${fp})`);
}
f.spike_hour = hour;
@@ -14640,7 +14782,10 @@ function cmdSensorIgnore(sub) {
if (sub === 'ignore') {
if (ignored) die(`${fp.id} is already ignored`);
const reason = flagStr('reason') ?? die('sensor ignore needs --reason "…" (why it isn\'t worth fixing)');
- const o = appendLog({ type: 'observation', source: fp.sensor, subject: sensorSubject(fp.sensor), detector: 'sensor', kind: 'sensor-ignored', detail: `ignored ${fp.id}: ${reason}`, severity: 0, valence: 0, source_ref: fp.id, source_key: `sensor:${fp.sensor}:ignore:${fp.key}:${nowIso()}` }, by);
+ // it cites what it sets aside: the fingerprint's observations, the newest 40 (source_ref joins the rest)
+ const refs = [...fpObservations(fp.id)].sort(byTime).slice(-SET_ASIDE_REFS).map((x) => x.id);
+ const fields = { type: 'observation', source: fp.sensor, subject: sensorSubject(fp.sensor), detector: 'sensor', kind: 'sensor-ignored', detail: `ignored ${fp.id}: ${reason}`, severity: 0, valence: 0, source_ref: fp.id, source_key: `sensor:${fp.sensor}:ignore:${fp.key}:${nowIso()}` };
+ const o = appendLog({ ...fields, refs: fitRefs(fields, refs) }, by);
return out(`${o.id}: ignored ${fp.id}; triage skips it until sensor unignore`);
}
if (!ignored) die(`${fp.id} is not ignored`);
@@ -15329,6 +15474,8 @@ const fileHistoryIn = (R, rel, ref) => (!R ? '' : R.git(['log', '-n', '60', '--f
function peekView(kind, id) {
// the stored record this object is, or lives in (a story in its feature, a milestone in its goal)
const v = { kind, id, record: recordView(flagStr('rec') ?? (kind === 'planner' ? '' : id)) };
+ // where a record sits in the loop, and where an observation came from
+ if (v.record) { const o = logGet(v.record.id); v.stage = stageOf(o); if (o.type === 'observation') v.provenance = provenanceOf(o); }
const cited = (refs) => Object.fromEntries([...new Set(refs ?? [])].map((r) => [r, logGet(r)]).filter(([, o]) => o).map(([r, o]) => [r, { type: o.type, created_at: o.created_at, created_by: o.created_by, kind: o.kind ?? null, text: o.detail ?? o.statement ?? o.what ?? o.title ?? o.question ?? o.text ?? null }]));
if (kind === 'ticket') {
gitReadCache = new Map();
@@ -15970,7 +16117,7 @@ Decide and act (supervisor.md, constitution.md)
trust [--json] the ladder: rung, ceiling, agreement, corrections per class
Observe and orient
- observe --subject s --kind k --detail d [--severity 0-4] [--valence -2..2] [--key k] [--refs ids] [--fix f] [--value n --unit u]
+ observe --subject s --kind k --detail d [--source owner-chat|owner-answer|owner-thread|retro|…] [--severity 0-4] [--valence -2..2] [--key k] [--refs ids] [--fix f] [--value n --unit u]
observe-link --url u | --path p --title t [--for scope…] [--notes n] [--owner] a resource the work uses; orient proposes it for the registry
wait [--minutes n] supervisor, between ticks: blocks until a new message to you, a new owner line, a new high finding, or the tick is due (tick_minutes, default 15); an open high finding returns at once until acted on or acked
observe-metrics | observe-spend | orient [--json] | metrics [--since date] [--json] (observe-metrics records spend lines too)
diff --git a/docs/superpowers/specs/2026-11-11-ooda-loop-inspectable-design.md b/docs/superpowers/specs/2026-11-11-ooda-loop-inspectable-design.md
index c654758..b9a838e 100644
--- a/docs/superpowers/specs/2026-11-11-ooda-loop-inspectable-design.md
+++ b/docs/superpowers/specs/2026-11-11-ooda-loop-inspectable-design.md
@@ -64,26 +64,26 @@ One model, for production and for the kit's own checks:
- **Recurring signals** are Orient's: three or more observations on one recurrence key that nothing has explained yet. They are computed, not stored, and get a stable address: `signal:`.
The key Orient groups on, fixed so sensor observations recur:
-- a sensor observation: its fingerprint (`::`, as `fpIdOfObservation` already derives), so every spike, qualify and regression on one fingerprint is one signal
+- a sensor observation: its fingerprint (`::`, as `fpIdOfObservation` already derives), so every qualify and spike on one fingerprint is one signal (a `fix-regressed` or `recurred` line keeps its own key: it already cites its incident)
- a finding: `metrics::` (its `source_key` today)
- anything else: `source_key`, else `kind:subject`
Fixes to the spike check:
-- **Listened hours.** Every sensor records the hours in which it ran or listened (`listened` in its `counts.json`, set on each successful run or drain). The spike check's median is over those hours only, and a spike needs 24 of them. A probe on a box that was down has the same gap a stream has.
+- **Listened hours.** Every sensor records the hours in which it ran or listened (`listened.json` beside its `counts.json`, whose keys are fingerprints: set on each successful check and whenever events arrive). The spike check's median is over those hours only, and a spike needs 24 of them. A probe on a box that was down has the same gap a stream has.
- **Say which line.** A spike's detail names the fingerprint's class or code and its id; a log template goes inside the untrusted-data frame, as `log-error` does. The days count is `floor(hours / 24)`, never "0 day(s)".
-- **Untriaged escalations.** A new finding `sensor-untriaged`, per fingerprint: an observation of severity 3 or more from a sensor, older than `triage_overdue_hours` (default 2) while the supervisor runs, with no incident, ignore or noise ruling. Today `incident-unowned` needs an incident to exist first.
+- **Untriaged escalations.** A new finding `sensor-untriaged`, per fingerprint: an observation of severity 3 or more from a sensor, older than `sensor_defaults.triage_overdue_hours` (default 2) while the supervisor runs, with no incident, ignore or noise ruling, over the last 7 days (so an upgrade doesn't raise every escalation the log ever held). Today `incident-unowned` needs an incident to exist first.
### 2. Provenance
-Observation already carries `source`, `detector`, `source_key`, `source_ref` and `subject`. Reconcile them; add no field.
+Observation already carries `source`, `detector`, `source_key`, `source_ref` and `subject`. Reconcile them; add no field, and rewrite none.
-- `source`: where it came from. It stays a string in the schema (history holds `ticket`, `owner`, `ack`, `repo`, `prod` and sensor names, and `log-check` validates every line ever written), and is not required (an older worktree's `observe` lines must still sync). The kit's writers set one of `x-ooda.sources`: `sensor`, `metrics`, `spend`, `owner-answer`, `owner-thread`, `owner-chat`, `retro`, `worker`, `planner`, `supervisor`; `observe` refuses any other value from the command line, as it refuses `--detector sensor` today. A ticket's writer is `worker`, a plan's `planner`.
-- `source_ref`: which one within that source: the sensor's name, the finding's kind, the question, thread or turn id, the retro's file, the fingerprint.
-- `source_key`: the recurrence key (unchanged meaning).
-- `subject`: what it is about (unchanged).
-- `detector`: retired; no new line writes it. The views read any line through one function, `sourceOf(o)`: `detector: "sensor"` → `sensor` with the old `source` as `source_ref`; `detector: "metrics"` → `metrics`; `ack` → `supervisor`; `repo`, `prod` or `owner` on a goal reading → `sensor` with `source_ref: goal-measure`; any other or missing value → by its writer (`owner`, `supervisor`, a ticket → `worker`, a plan → `planner`).
+*Changed while building PR 2:* an earlier draft had every writer set `source` to one of `x-ooda.sources` and retired `detector`. That would break a mixed fleet: `source` and `detector` already mean things every kit reads (`source` is `repo`, `prod` or `owner` on a goal reading, which `goalReadingOnMain` reads, and a sensor's name on a sensor's line; `detector: "sensor"` is how every kit, an older worktree's included, recognises the kit's own sensor lines, `fromSensor`, and `detector: "metrics"` its findings, `observeFindings`). A newer kit writing new values there would have an older kit misread its lines. So provenance is computed when a line is read, and nothing stored changes:
-Each writer sets it: `sensorObserveOnce` (sensor), `observeFindings` (metrics), the cost-hotspot finding (spend), `answer` and `thread resolve` observations (owner-answer, owner-thread), chat-derived observations (owner-chat), the retro (retro), `observe` by its writer. The sidepeek shows it first: "From the g3d-stacks-logs sensor", "From your answer to …".
+- `source` and `detector`: written as today, by the same writers. `source` stays a free string in the schema (history holds `ticket`, `owner`, `ack`, `repo`, `prod`, `pane` and sensor names, and `log-check` validates every line ever written) and is not required.
+- `provenanceOf(o)` → `{ from, ref }`, `from` one of `x-ooda.sources` (`sensor`, `metrics`, `spend`, `owner`, `owner-answer`, `owner-thread`, `owner-chat`, `retro`, `worker`, `planner`, `supervisor`), `ref` which one within it. First match wins: a set-aside (`finding-ack`, `sensor-ignored`, `sensor-unignored`, `sensor-reconsidered`) is its writer's call, below; a sensor's line (`fromSensor`, as readers trust it today) → `sensor`, ref its sensor's name; a finding (`detector: "metrics"`, the supervisor's) → `metrics`, or `spend` for a cost hotspot, ref its kind; the pane check (`detector: "pane"`) → `metrics`, ref `pane`; a goal reading of a repo or prod measure → `sensor`, ref `goal-measure`, of an owner measure → `owner-answer`, ref the question; `thread-resolved` → `owner-thread`, ref the thread; `retro` → `retro`, ref its file; a `source` already in the vocabulary and the writer's own (`observe --source`) → that, ref `source_ref`; else by writer: the owner → `owner` (or `owner-chat`, `owner-thread` by channel), the supervisor → `supervisor`, a ticket → `worker`, a planner → `planner`.
+- `observe --source` refuses the kit's own sources (`sensor`, `metrics`, `spend`), as it refuses `--detector sensor`, and another writer's: a worker gives only `worker`, a planner only `planner`, the owner `owner` and the `owner-…` ones, the supervisor any other but `owner` (it records what the owner said, as `owner-chat`, `owner-answer` or `owner-thread`, and never speaks as the owner). Older free text is still written as given and reads as its writer's.
+- `source_ref`: which one within that source (unchanged). `source_key`: the recurrence key (unchanged meaning). `subject`: what it is about (unchanged).
+- The views carry it: `log --json`, the dashboard's observations and `peek` add `provenance` and `stage` (`stageOf`, from `x-ooda.stages`). Readers, the operator's included, read `provenance.from`, not `source`. The sidepeek shows it first: "From the g3d-stacks-logs sensor", "From your answer to …".
### 3. The links the loop needs
@@ -168,7 +168,7 @@ This is an additive change to the kit's internals: new fields, new optional valu
## PRs, in order (each leaves every instance working)
1. **Reader tolerance** (§8). Small. Adopted fleet-wide before anything writes a new field.
-2. **Signals the loop can see** (§1, §2, §3's set-aside and ignore refs, noise rulings, orientation debt): listened hours, spike detail, `sensor-untriaged`, the fingerprint key, `source` provenance and the `detector` alias, `orient-noise`, `x-ooda`.
+2. **Signals the loop can see** (§1, §2, §3's set-aside and ignore refs, noise rulings): listened hours, spike detail, `sensor-untriaged`, the fingerprint key, computed provenance (`provenanceOf`) and stages (`stageOf`), `orient-noise`, `x-ooda`. Orientation debt's list on the Orient tab comes with the sidepeeks (PR 3) and resolved-incident debt with PR 4.
3. **Lineage and sidepeeks** (§4, §5): `lineage()`, `/api/peek`'s `lineage`, `--mermaid`, the observation, signal and learning sidepeeks, clickable cards, where-it-sits lines.
4. **Knowledge in decisions and prompts** (§3's `decision.knowledge` and `worker-slots.knowledge`, resolved-incident debt).
5. **The learning record and the chart** (§6), with back-fill. Lesson efficacy starts filling in from PR 4's records.
@@ -177,7 +177,7 @@ This is an additive change to the kit's internals: new fields, new optional valu
- Spike: a stream with a 10-hour listening gap and a normal hour after it raises nothing; a real 5× hour over 24 listened hours raises one spike naming the fingerprint; "1 day(s)" at 24–47 hours.
- Orient: four spikes and a qualify on one fingerprint form one recurring signal; a noise ruling citing them clears it; `decide --on` a retired lesson is refused; a set-aside's refs stop at 40; an ack's and an ignore's refs name the observations.
-- Provenance: every writer sets `source`; `sourceOf` maps old `detector`, `ack`, `repo`, `prod`, `owner` and missing values; `log-check` passes on a historical log with free-text `source`; `observe --source ` is refused.
+- Provenance: `provenanceOf` gives every writer's line its source (a sensor's, a finding, a cost hotspot, the pane check, goal readings, a thread resolved, a retro, a set-aside, the owner's, the supervisor's, a worker's); stored `source` and `detector` are unchanged; `observe --source` refuses the kit's own sources and another writer's.
- Reader tolerance: `log-check` on a log with an unknown field and an unknown type lists both and passes; `log-sync` syncs a file whose lines carry a field it doesn't know; `merge`'s log rule passes them too (only `errors` fail), since the merging kit is the instance's newest; `appendLog` still refuses to write one.
- Lineage: an incident chain (observations → incident → incident-fix decision → add-ticket act → fix-deployed → outcome) walks both ways from every node; collapse above five siblings; the 120-node cap sets `truncated`; labels with Mermaid syntax characters render as text; `--mermaid` output parses.
- Dashboard (the existing `dashboard.test.mjs` harness): every Loop card opens its sidepeek; a Mermaid node found by `data-id` opens the right one; a signal key with `/` routes; with Mermaid blocked the list fallback renders.
diff --git a/test/fixtures/g1/dashboard-data.txt b/test/fixtures/g1/dashboard-data.txt
index 4665359..ef726e7 100644
--- a/test/fixtures/g1/dashboard-data.txt
+++ b/test/fixtures/g1/dashboard-data.txt
@@ -349,7 +349,12 @@ exit 0
"source_key": "note:g1",
"refs": [],
"created_by": "owner",
- "updates": []
+ "updates": [],
+ "stage": "observe",
+ "provenance": {
+ "from": "owner",
+ "ref": null
+ }
},
{
"id": "o---owner",
@@ -363,7 +368,12 @@ exit 0
"source_key": "slow:g1",
"refs": [],
"created_by": "owner",
- "updates": []
+ "updates": [],
+ "stage": "observe",
+ "provenance": {
+ "from": "owner",
+ "ref": null
+ }
},
{
"id": "o---supervisor",
@@ -376,7 +386,12 @@ exit 0
"source_key": "slow:g1",
"refs": [],
"created_by": "supervisor",
- "updates": []
+ "updates": [],
+ "stage": "observe",
+ "provenance": {
+ "from": "supervisor",
+ "ref": null
+ }
},
{
"id": "o---supervisor",
@@ -389,7 +404,12 @@ exit 0
"source_key": "slow:g1",
"refs": [],
"created_by": "supervisor",
- "updates": []
+ "updates": [],
+ "stage": "observe",
+ "provenance": {
+ "from": "supervisor",
+ "ref": null
+ }
}
],
"clusters": [
diff --git a/test/fixtures/g1/peek-ticket.txt b/test/fixtures/g1/peek-ticket.txt
index 16a8bdb..0dd7d71 100644
--- a/test/fixtures/g1/peek-ticket.txt
+++ b/test/fixtures/g1/peek-ticket.txt
@@ -28,6 +28,7 @@ exit 0
},
"set_by": {}
},
+ "stage": null,
"ticket": {
"id": "g1-01-a",
"type": "ticket",
diff --git a/test/lib/chain.mjs b/test/lib/chain.mjs
index b1475a6..86c4a9d 100644
--- a/test/lib/chain.mjs
+++ b/test/lib/chain.mjs
@@ -1016,10 +1016,13 @@ out({ cursor: 'c-' + mode });
const counts = json(fs.readFileSync(path.join(runMon, 'fake/counts.json'), 'utf8'));
counts['log:S1'] = Object.fromEntries([...Array(40)].map((_, i) => [new Date(Date.now() - (i + 1) * 3600000).toISOString().slice(0, 13), 1]));
fs.writeFileSync(path.join(runMon, 'fake/counts.json'), JSON.stringify(counts));
+ // the median is over the hours the sensor heard: it heard those 40
+ const heardFile = path.join(runMon, 'fake/listened.json');
+ fs.writeFileSync(heardFile, JSON.stringify({ ...(fs.existsSync(heardFile) ? json(fs.readFileSync(heardFile, 'utf8')) : {}), ...Object.fromEntries(Object.keys(counts['log:S1']).map((h) => [h, true])) }));
fs.writeFileSync(mode, 'spike');
must(['sensor', 'poll', '--name', 'fake'], sup);
const spike = obs().find((o) => o.kind === 'spike');
- assert.ok(spike && spike.severity === 4 && /a log line is spiking: 50 this hour/.test(spike.detail), JSON.stringify(spike));
+ assert.ok(spike && spike.severity === 4 && /a log line \(fake:log:S1\) is spiking: 50 this hour against a median of 1 an hour over the last 1 day\(s\)\n--- untrusted production data (\w+) \(the line\)[^\n]*\npayment gateway timeout after ms\n--- end of untrusted data \1 ---$/.test(spike.detail), JSON.stringify(spike));
must(['sensor', 'poll', '--name', 'fake'], sup);
assert.equal(obs().filter((o) => o.kind === 'spike').length, 1, 'once per window');
assert.equal(status().state, 'ok', 'a clean run ends the failing');
diff --git a/test/ooda-signals.test.mjs b/test/ooda-signals.test.mjs
new file mode 100644
index 0000000..5fd9488
--- /dev/null
+++ b/test/ooda-signals.test.mjs
@@ -0,0 +1,308 @@
+// Signals the loop can see (the OODA spec, PR 2): where each observation came from and which stage
+// of the loop it sits in, computed when it is read; spikes over the hours a sensor heard; one
+// recurring signal per fingerprint; noise rulings; set-asides that cite what they set aside; and
+// escalations nobody triaged.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fleetFixture, json, line } from './lib/kit.mjs';
+
+const SUP = { DEVTEAM_ROLE: 'supervisor' };
+const stamp = (d) => `${d.toISOString().slice(0, 10).replace(/-/g, '')}-${d.toISOString().slice(11, 23).replace(/[:.]/g, '')}`;
+// A line as the kit would have written it agoMs ago, in its writer's file (owner lines carry
+// their channel). Ids follow the kit's form, so each case picks a distinct agoMs.
+function handFor(F) {
+ const logDir = path.join(F.old, 'docs/devteam/run/log');
+ return (by, o, agoMs = 1000) => {
+ const d = new Date(Date.now() - agoMs);
+ const id = o.id ?? `${{ observation: 'o', problem: 'p', decision: 'd' }[o.type]}-${stamp(d)}-${by}`;
+ const file = ['owner', 'supervisor'].includes(by) ? `${by}-${d.toISOString().slice(0, 7)}.jsonl` : `${by}.jsonl`;
+ fs.mkdirSync(logDir, { recursive: true });
+ fs.appendFileSync(path.join(logDir, file), line({ op: 'new', ...(by === 'owner' ? { channel: 'cli' } : {}), ...o, id, at: d.toISOString(), by }));
+ return id;
+ };
+}
+const one = (F, id) => json(F.ok(F.old, ['log', '--id', id, '--json']))[0];
+
+test('provenance and stage are computed for every writer; observe --source keeps to the vocabulary', () => {
+ const F = fleetFixture('ooda-provenance');
+ const { old, ok, run } = F;
+ const hand = handFor(F);
+ try {
+ const cases = [];
+ const want = (id, from, ref, stage = 'observe') => cases.push({ id, from, ref, stage });
+ want(hand('supervisor', { type: 'observation', source: 'web', detector: 'sensor', subject: 'sensor:web', kind: 'log-error', detail: 'web: a error log line', severity: 3, source_key: 'sensor:web:log:L1' }, 90000), 'sensor', 'web');
+ want(hand('supervisor', { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'w-01-a', kind: 'stale-worker', detail: 'quiet', source_key: 'metrics:stale-worker:w-01-a' }, 89000), 'metrics', 'stale-worker');
+ want(hand('supervisor', { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'build', kind: 'cost-hotspot', detail: '60% of spend', source_key: 'metrics:cost-hotspot:build' }, 88000), 'spend', 'cost-hotspot');
+ want(hand('supervisor', { type: 'observation', source: 'pane', detector: 'pane', subject: 'w-01-a', kind: 'resent-enter', detail: 'pressed Enter once', source_key: 'resent-enter x' }, 87000), 'metrics', 'pane');
+ want(hand('supervisor', { type: 'observation', source: 'repo', subject: 'g-20260101-000000000-supervisor', kind: 'reading', detail: 'reading 5', measure: { value: 5 } }, 86000), 'sensor', 'goal-measure', 'measurement');
+ // an owner reading counts as the owner's answer only when it names a question they answered
+ const q = ok(old, ['ask', '--question', 'Is it met?', '--recommendation', 'yes'], SUP).match(/q-\S+/)[0];
+ ok(old, ['answer', q, 'yes']);
+ want(hand('supervisor', { type: 'observation', source: 'owner', subject: 'g-20260101-000000000-supervisor', kind: 'reading', detail: `the owner said yes to ${q}`, measure: { value: 1 }, approved: q }, 85000), 'owner-answer', q, 'measurement');
+ want(hand('supervisor', { type: 'observation', source: 'owner', subject: 'g-20260101-000000000-supervisor', kind: 'reading', detail: 'the owner said yes to a question that never was', measure: { value: 1 }, approved: 'q-20260101-000000000-supervisor' }, 84500), 'supervisor', null, 'measurement');
+ // a thread's resolution is the owner's alone: the supervisor's line saying so is the supervisor's
+ want(hand('supervisor', { type: 'observation', source: 'owner', subject: 'thread:t-20260101-000000000-owner', kind: 'thread-resolved', detail: 'resolved', refs: ['t-20260101-000000000-owner'] }, 84200), 'supervisor', null);
+ want(hand('owner', { type: 'observation', source: 'owner', subject: 'thread:t-20260101-000000000-owner', kind: 'thread-resolved', detail: 'resolved', refs: ['t-20260101-000000000-owner'] }, 84000), 'owner-thread', 't-20260101-000000000-owner');
+ want(hand('supervisor', { type: 'observation', source: 'supervisor', subject: 'project', kind: 'retro', detail: 'a week', source_ref: 'project/retros/2026-11-09.md' }, 83000), 'retro', 'project/retros/2026-11-09.md');
+ // a set-aside is the call of whoever made it, not the check it sets aside
+ want(hand('supervisor', { type: 'observation', source: 'ack', kind: 'finding-ack', subject: 'stale-worker w-01-a', detail: 'it is fine' }, 82000), 'supervisor', null, 'decide');
+ want(hand('supervisor', { type: 'observation', source: 'web', detector: 'sensor', subject: 'sensor:web', kind: 'sensor-ignored', detail: 'ignored web:log:L1: bots', source_ref: 'web:log:L1' }, 81000), 'supervisor', 'web:log:L1', 'decide');
+ // a worker's line that says it is a sensor's is still the worker's (fromSensor trusts only the supervisor and the owner)
+ want(hand('w-01-a', { type: 'observation', source: 'web', detector: 'sensor', subject: 'sensor:web', kind: 'down', detail: 'web is down' }, 80000), 'worker', null);
+ want(ok(old, ['observe', '--owner', '--subject', 'app', '--kind', 'note', '--detail', 'the owner saw it']).trim(), 'owner', null);
+ want(ok(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'the supervisor saw it'], SUP).trim(), 'supervisor', null);
+ want(ok(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'a worker saw it'], { DEVTEAM_ROLE: 'worker', DEVTEAM_TICKET: 'w-01-a' }).trim(), 'worker', null);
+ want(ok(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'the owner said so in chat', '--source', 'owner-chat', '--ref', q], SUP).trim(), 'owner-chat', q);
+ // the owner's words need the record that holds them; the supervisor writes as no worker or planner
+ assert.match(run(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'the owner approved X', '--source', 'owner-answer'], SUP).err, /needs --ref naming where the owner said it/);
+ assert.match(run(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'x', '--source', 'owner-answer', '--ref', 'q-20260101-000000000-supervisor'], SUP).err, /needs --ref/);
+ for (const s of ['worker', 'planner']) assert.match(run(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'x', '--source', s], SUP).err, /a supervisor's observation is from/);
+ for (const c of cases) {
+ const o = one(F, c.id);
+ assert.deepEqual(o.provenance, { from: c.from, ref: c.ref }, `${o.kind} by ${o.created_by}: ${JSON.stringify(o.provenance)}`);
+ assert.equal(o.stage, c.stage, `${o.kind}'s stage`);
+ }
+ // the kit's own sources, and another writer's, are refused from the command line
+ for (const s of ['sensor', 'metrics', 'spend']) assert.match(run(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'x', '--source', s], SUP).err, /come from the kit itself/);
+ assert.match(run(old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'x', '--source', 'supervisor'], { DEVTEAM_ROLE: 'worker', DEVTEAM_TICKET: 'w-01-a' }).err, /a worker's observation is from worker/);
+ // what the dashboard reads carries them too, and the schema says where each type sits
+ const d = json(ok(old, ['dashboard-data']));
+ const seen = new Map(d.ooda.observations.map((o) => [o.id, o]));
+ assert.deepEqual(seen.get(cases[0].id).provenance, { from: 'sensor', ref: 'web' });
+ assert.equal(seen.get(cases[0].id).stage, 'observe');
+ const S = json(fs.readFileSync(path.join(old, 'docs/devteam/schema/log.schema.json'), 'utf8'));
+ assert.equal(S['x-ooda'].stages.act, 'act');
+ assert.ok(S['x-ooda'].sources.includes('owner-chat'));
+ // a peek of an observation carries them as well
+ const p = json(ok(old, ['peek', 'observation', cases[1].id, '--json']));
+ assert.deepEqual(p.provenance, { from: 'metrics', ref: 'stale-worker' });
+ assert.equal(p.stage, 'observe');
+ } finally { F.done(); }
+});
+
+// A probe sensor on main, "fake", that prints what its mode file says: the chain's probe, cut down.
+function sensorFixture(F) {
+ const mode = path.join(F.base, 'probe-mode');
+ fs.writeFileSync(mode, '');
+ const probe = `import fs from 'node:fs';
+const p = JSON.parse(process.env.DEVTEAM_PARAMS);
+for (const l of fs.readFileSync(p.mode, 'utf8').split('\\n').filter(Boolean)) console.log(l);
+`;
+ fs.mkdirSync(path.join(F.old, 'docs/devteam/project/sensors'), { recursive: true });
+ fs.writeFileSync(path.join(F.old, 'docs/devteam/project/sensors/fake.mjs'), probe);
+ const sf = path.join(F.old, 'docs/devteam/project/settings.md');
+ fs.writeFileSync(sf, fs.readFileSync(sf, 'utf8').replace('"gates": {}', `"sensors": { "fake": { "kind": "probe", "probe": "project/sensors/fake.mjs", "every_minutes": 5, "params": { "mode": ${JSON.stringify(mode)} } } }, "gates": {}`));
+ F.g(['add', '-A'], F.old);
+ F.g(['commit', '-qm', 'a sensor'], F.old);
+ F.g(['push', '-q', 'origin', 'main'], F.old);
+ const dir = path.join(F.old, 'docs/devteam/run/sensors/fake');
+ const emit = (...events) => fs.writeFileSync(mode, events.map((e) => JSON.stringify(e)).join('\n'));
+ const poll = () => F.ok(F.old, ['sensor', 'poll', '--name', 'fake'], SUP);
+ const file = (f) => path.join(dir, f);
+ const read = (f, d = {}) => (fs.existsSync(file(f)) ? json(fs.readFileSync(file(f), 'utf8')) : d);
+ const put = (f, v) => { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file(f), JSON.stringify(v)); };
+ return { mode, dir, emit, poll, read, put };
+}
+const hourAgo = (n) => new Date(Date.now() - n * 3600000).toISOString().slice(0, 13);
+
+test('spikes: a median over the hours the sensor heard, naming the line', () => {
+ const F = fleetFixture('ooda-spike');
+ try {
+ const s = sensorFixture(F);
+ // a first poll makes the sensor's files; nothing in it qualifies
+ s.emit({ kind: 'log', fingerprint: 'W0', level: 'warning', template: 'warm up', count: 1 });
+ s.poll();
+ assert.ok(s.read('listened.json')[hourAgo(0)], 'a poll that ran marks its hour heard');
+ // two lines the kit has known for three days, ten an hour in the 30 hours it heard; the box was
+ // down for the 42 hours between, which nobody heard (the Stacks case)
+ const heard = [...Array(30)].map((_, i) => hourAgo(i < 20 ? i + 1 : i + 33));
+ const fps = s.read('fingerprints.json');
+ const counts = s.read('counts.json');
+ for (const k of ['S1', 'S2']) {
+ fps[`log:${k}`] = { kind: 'log', first_seen: new Date(Date.now() - 3 * 86400000).toISOString(), seen_at: new Date(Date.now() - 3 * 86400000).toISOString(), total: 300, users: 0, level: 'error', observed: true, template: 'payment gateway timeout after ms' };
+ counts[`log:${k}`] = Object.fromEntries(heard.map((h) => [h, 10]));
+ }
+ s.put('fingerprints.json', fps);
+ s.put('counts.json', counts);
+ s.put('listened.json', { ...s.read('listened.json'), ...Object.fromEntries(heard.map((h) => [h, true])) });
+ const spikes = () => json(F.ok(F.old, ['log', '--type', 'observation', '--json'])).filter((o) => o.kind === 'spike');
+ // a normal hour after the gap: 25 against a median of 10 over what it heard (the unheard hours
+ // would have made the median 0)
+ s.emit({ kind: 'log', fingerprint: 'S1', level: 'error', template: 'payment gateway timeout after ms', count: 25 });
+ s.poll();
+ assert.deepEqual(spikes(), [], 'no spike from hours nobody heard');
+ // a real one: five times the median
+ s.emit({ kind: 'log', fingerprint: 'S2', level: 'error', template: 'payment gateway timeout after ms', count: 60 });
+ s.poll();
+ const [sp] = spikes();
+ assert.ok(sp, 'one spike');
+ assert.match(sp.detail, /^fake: a log line \(fake:log:S2\) is spiking: 60 this hour against a median of 10 an hour over the last 1 day\(s\)\n--- untrusted production data (\w+) \(the line\)[^\n]*\npayment gateway timeout after ms\n--- end of untrusted data \1 ---$/);
+ assert.equal(spikes().length, 1, 'once');
+ } finally { F.done(); }
+});
+
+// A sensor's lines on one fingerprint, written as the sensors write them: its first qualify, then
+// spikes in hours on two days (Orient wants a signal on two days or from two writers).
+function fingerprintLines(hand, sensor, fp, spikes, ago = 3 * 86400000, stepMs = 3 * 3600000) {
+ const ids = [hand('supervisor', { type: 'observation', source: sensor, detector: 'sensor', subject: `sensor:${sensor}`, kind: 'log-error', detail: `${sensor}: a error log line`, severity: 3, valence: -1, source_key: `sensor:${sensor}:${fp}` }, ago)];
+ for (let i = 0; i < spikes; i++) {
+ const at = ago - (i + 1) * stepMs;
+ const hour = new Date(Date.now() - at).toISOString().slice(0, 13);
+ ids.push(hand('supervisor', { type: 'observation', source: sensor, detector: 'sensor', subject: `sensor:${sensor}`, kind: 'spike', detail: `${sensor}: a log line is spiking`, severity: 4, valence: -1, source_key: `sensor:${sensor}:spike:${fp}:${hour}` }, at));
+ }
+ return ids;
+}
+
+test('orient: one signal per fingerprint; a noise ruling clears it until it is corrected', () => {
+ const F = fleetFixture('ooda-orient');
+ const { old, ok, run } = F;
+ const hand = handFor(F);
+ try {
+ const ids = fingerprintLines(hand, 'web', 'log:L1', 4, 6 * 86400000, 86400000); // five days, so the signal forms whatever the hour
+ const signals = () => json(ok(old, ['orient', '--json'])).filter((c) => c.count);
+ const [sig, ...more] = signals();
+ assert.deepEqual(more, [], 'one signal');
+ assert.equal(sig.key, 'web:log:L1', 'keyed on the fingerprint, not each spike\'s hour');
+ assert.deepEqual([...sig.ids].sort(), [...ids].sort());
+ assert.match(ok(old, ['orient']), /^web:log:L1 {2}x5/m);
+ // a noise ruling names the observations it explains
+ assert.match(run(old, ['decide', '--class', 'orient-noise', '--refs', 'web:log:L1', '--', 'bots'], SUP).err, /a noise ruling names the observations it explains/);
+ const d = ok(old, ['decide', '--class', 'orient-noise', '--refs', 'web:log:L1', ...ids, '--', 'bots crawling an old route'], SUP).trim();
+ assert.deepEqual(signals(), [], 'the ruling explains it');
+ assert.equal(one(F, d).stage, 'decide');
+ // the owner corrects it: the signal is unexplained again
+ ok(old, ['correct', d, '--reason', 'not bots: real customers']);
+ assert.equal(signals()[0]?.key, 'web:log:L1', 'a corrected ruling explains nothing');
+ } finally { F.done(); }
+});
+
+test('set-asides cite what they set aside: an ack its finding\'s observations, an ignore its fingerprint\'s, newest 40', () => {
+ const F = fleetFixture('ooda-setaside');
+ const { old, ok } = F;
+ const hand = handFor(F);
+ try {
+ const s = sensorFixture(F);
+ // a sensor and no discovery yet: sensor-discovery-due is live; 41 days of it observed
+ const found = [...Array(41)].map((_, i) => hand('supervisor', { type: 'observation', source: 'metrics', detector: 'metrics', subject: 'sensors', kind: 'sensor-discovery-due', detail: 'production has not been looked over yet', severity: 1, valence: -1, source_key: 'metrics:sensor-discovery-due:sensors' }, (41 - i) * 86400000));
+ const a = one(F, ok(old, ['ack', 'sensor-discovery-due', 'sensors', '--reason', 'next week'], SUP).split(':')[0]);
+ assert.equal(a.refs.length, 40, 'at most 40');
+ assert.deepEqual([...a.refs].sort(), found.slice(1).sort(), 'the newest 40');
+ assert.equal(a.source_ref, 'metrics:sensor-discovery-due:sensors', 'the finding\'s key, for the rest');
+ // an ignore: the fingerprint's observations
+ const fps = s.read('fingerprints.json');
+ fps['log:L1'] = { kind: 'log', first_seen: new Date().toISOString(), total: 1, users: 0, level: 'error', template: 'boom' };
+ s.put('fingerprints.json', fps);
+ const obs = fingerprintLines(hand, 'fake', 'log:L1', 40, 30 * 86400000);
+ const ig = one(F, ok(old, ['sensor', 'ignore', 'fake:log:L1', '--reason', 'bots'], SUP).split(':')[0]);
+ assert.equal(ig.refs.length, 40);
+ assert.deepEqual([...ig.refs].sort(), obs.slice(1).sort(), 'the newest 40');
+ assert.equal(ig.source_ref, 'fake:log:L1');
+ assert.equal(ig.stage, 'decide');
+ // a long reason costs refs, never the line
+ ok(old, ['sensor', 'unignore', 'fake:log:L1'], SUP);
+ const long = one(F, ok(old, ['sensor', 'ignore', 'fake:log:L1', '--reason', 'b'.repeat(3000)], SUP).split(':')[0]);
+ assert.ok(long.refs.length > 0 && long.refs.length < 40, `${long.refs.length} refs fit beside a long reason`);
+ } finally { F.done(); }
+});
+
+test('sensor-untriaged: a sensor\'s escalation nobody folded, ignored or ruled noise, while the supervisor runs', () => {
+ const F = fleetFixture('ooda-untriaged');
+ const { old, ok } = F;
+ const hand = handFor(F);
+ try {
+ const s = sensorFixture(F);
+ const H = 3600000;
+ const esc = (fp, severity, ago, sensor = 'fake') => hand('supervisor', { type: 'observation', source: sensor, detector: 'sensor', subject: `sensor:${sensor}`, kind: 'log-error', detail: `${sensor}: a error log line`, severity, valence: -1, source_key: `sensor:${sensor}:log:${fp}` }, ago);
+ const u1 = esc('U1', 3, 3 * H);
+ esc('U2', 2, 3 * H + 1000); // under severity 3
+ esc('U3', 3, H); // not overdue yet
+ esc('U4', 3, 3 * H + 2000, 'gone'); // a sensor no longer in the settings
+ esc('U5', 3, 3 * H + 3000);
+ const u6 = esc('U6', 3, 3 * H + 4000);
+ const fps = s.read('fingerprints.json');
+ fps['log:U5'] = { kind: 'log', first_seen: new Date().toISOString(), total: 1, users: 0, level: 'error', template: 'boom' };
+ s.put('fingerprints.json', fps);
+ ok(old, ['sensor', 'ignore', 'fake:log:U5', '--reason', 'bots'], SUP);
+ ok(old, ['decide', '--class', 'orient-noise', '--refs', 'fake:log:U6', u6, '--', 'a health check, by design'], SUP);
+ const findings = () => json(ok(old, ['metrics', '--json'])).findings.filter((x) => ['sensor-untriaged', 'incident-unowned'].includes(x.kind));
+ assert.deepEqual(findings(), [], 'nothing while the supervisor is not running');
+ // the supervisor's wait is alive
+ fs.writeFileSync(path.join(old, 'docs/devteam/run/sensors/.wait-alive'), '');
+ const [f, ...rest] = findings();
+ assert.deepEqual(rest, [], JSON.stringify(rest));
+ assert.equal(f.kind, 'sensor-untriaged');
+ assert.equal(f.subject, 'fake:log:U1');
+ assert.match(f.detail, /severity 3/);
+ assert.match(f.suggestion, /fold it into an incident, ignore it, or rule it noise/);
+ // an incident takes it: untriaged stops, and incident-unowned takes over after its hour
+ hand('supervisor', { type: 'problem', title: 'payment errors', severity: 3, realized: true, status: 'open', threatens: [], refs: [u1], fingerprints: ['fake:log:U1'], sensor: 'fake' }, 2 * H);
+ assert.deepEqual(findings().map((x) => x.kind), ['incident-unowned']);
+ } finally { F.done(); }
+});
+
+test('a kit lesson keeps its tag through Orient: stage orient, listed by knowledge --kit, untouched by a noise ruling', () => {
+ const F = fleetFixture('ooda-kit-lesson');
+ const { old, ok } = F;
+ try {
+ const o1 = ok(old, ['observe', '--subject', 'app', '--kind', 'friction', '--detail', 'the kit made me retype it'], SUP).trim();
+ const k = ok(old, ['knowledge', 'add', '--kind', 'lesson', '--scope', 'project', '--statement', 'the kit should remember the last value', '--evidence', o1, '--kit'], SUP).match(/k-\S+/)[0];
+ ok(old, ['decide', '--class', 'orient-noise', '--refs', `note:app`, o1, '--', 'one-off'], SUP);
+ const listed = json(ok(old, ['knowledge', '--kit', '--json']));
+ assert.ok(listed.some((x) => x.id === k), 'still a kit lesson');
+ const rec = json(ok(old, ['log', '--id', k, '--json']))[0];
+ assert.equal(rec.stage, 'orient');
+ } finally { F.done(); }
+});
+
+test('an open incident holds its fingerprint; a noise ruling by key covers its signal; a hand reading is refused', () => {
+ const F = fleetFixture('ooda-held');
+ const { old, ok, run } = F;
+ try {
+ const hand = handFor(F);
+ const signals = () => json(ok(old, ['orient', '--json'], SUP)).clusters ?? json(ok(old, ['orient', '--json'], SUP));
+ const ids = fingerprintLines(hand, 'web', 'log:L1', 4, 6 * 86400000, 86400000);
+ const key = 'web:log:L1';
+ // an open incident that cites only the first observation still holds the whole fingerprint
+ hand('supervisor', { type: 'problem', title: 'web errors', severity: 3, realized: true, status: 'open', refs: [ids[0]], fingerprints: [key], sensor: 'web' }, 1000);
+ assert.ok(!JSON.stringify(signals()).includes(key), 'held by its open incident');
+ // a ruling naming the key covers the signal's observations before it, cited or not
+ const F2 = fleetFixture('ooda-ruled');
+ try {
+ const h2 = handFor(F2);
+ const first = fingerprintLines(h2, 'web', 'log:L1', 3, 6 * 86400000, 86400000);
+ F2.ok(F2.old, ['decide', '--class', 'orient-noise', '--refs', key, ...first, '--', 'bots'], SUP);
+ fingerprintLines(h2, 'web', 'log:L1', 3, 2 * 86400000, 12 * 3600000).slice(1);
+ assert.ok(!JSON.stringify(json(F2.ok(F2.old, ['orient', '--json'], SUP))).includes(key), 'the ruling covers every observation on the signal before it, cited or not');
+ } finally { F2.done(); }
+ assert.match(run(old, ['observe', '--subject', 'g-x', '--kind', 'reading', '--source', 'repo', '--detail', '5'], SUP).err, /goal's readings come from the kit/);
+ } finally { F.done(); }
+});
+
+test('a noise ruling covers its signal up to the ruling: an escalation after it is untriaged again', () => {
+ const F = fleetFixture('ooda-ruling-window');
+ const F0 = fleetFixture('ooda-ruling-shape');
+ const { old, ok } = F;
+ const hand = handFor(F);
+ try {
+ sensorFixture(F);
+ const H = 3600000;
+ const esc = (ago) => hand('supervisor', { type: 'observation', source: 'fake', detector: 'sensor', subject: 'sensor:fake', kind: 'log-error', detail: 'fake: a error log line', severity: 3, valence: -1, source_key: 'sensor:fake:log:U7' }, ago);
+ const before = esc(5 * H);
+ // a ruling as decide writes it (its shape from another project), made four hours ago
+ const o0 = F0.ok(F0.old, ['observe', '--subject', 'app', '--kind', 'note', '--detail', 'x'], SUP).trim();
+ const d0 = one(F0, F0.ok(F0.old, ['decide', '--class', 'orient-noise', '--refs', 'fake:log:U7', o0, '--', 'a health check'], SUP).match(/d-\S+/)[0]);
+ const shape = Object.fromEntries(Object.entries(d0).filter(([k]) => !['id', 'type', 'created_at', 'created_by', 'updates', 'stage', 'refs'].includes(k) && !k.startsWith('__')));
+ hand('supervisor', { ...shape, type: 'decision', refs: ['fake:log:U7', before] }, 4 * H);
+ fs.mkdirSync(path.join(old, 'docs/devteam/run/sensors'), { recursive: true });
+ fs.writeFileSync(path.join(old, 'docs/devteam/run/sensors/.wait-alive'), '');
+ const untriaged = () => json(ok(old, ['metrics', '--json'])).findings.filter((x) => x.kind === 'sensor-untriaged' && x.subject === 'fake:log:U7');
+ assert.deepEqual(untriaged(), [], 'the ruling covers what came before it');
+ const after = esc(3 * H);
+ const [f] = untriaged();
+ assert.ok(f, 'an escalation after the ruling gets a fresh look');
+ assert.match(f.detail, new RegExp(after));
+ assert.doesNotMatch(f.detail, new RegExp(before));
+ } finally { F.done(); F0.done(); }
+});