Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 214 additions & 23 deletions docs/devteam/dashboard/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/devteam/process/supervisor.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ A reading (a sensor's daily rollup, a goal reading, a `spend` line, a `gate` run

**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: `decide --class orient-noise --refs <signal key> <observation ids> -- "<why>"` (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 <id> --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 "<its words>" --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 <signal key> <observation ids> -- "<why>"` (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 <id> --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 "<its words>" --limit 5`: it may have happened before under other ids. A recurring signal nothing has explained for more than a day is orientation debt: the dashboard's Orient tab lists it first (`dashboard-data`'s `ooda.debt`), and you pay it down before newer signals. Each record in the loop has a lineage, what it came from and what came of it (observation → signal → lesson or incident → decision → act → outcome): `devteam.mjs peek <kind> <id> --json` carries it as `lineage`, and `peek <kind> <id> --mermaid` prints it as a Mermaid flowchart to paste into a retro or a PR body (a recurring signal's kind is `signal`, its id its key).
- **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 <g-id> <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 <qid> --urgency 3`. A new question that an open one would settle: `rate <gate-qid> --settles <qid>`, which adds it to the list. An unrated question (`question-unrated`): rate it.

**Decide**
Expand Down
250 changes: 240 additions & 10 deletions docs/devteam/scripts/devteam.mjs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ Additive fields and refs, so a lineage walk finds every step:

The kit computes it, the dashboard draws it.

- `lineage(id)` in `devteam.mjs`, over one reverse index of the folded log (replacing the ad-hoc ones in `orientClusters`, `orientLinks` and `historyOf`). An explicit edge list per type, never a generic string walk:
- observation ← cited by: knowledge `evidence`, problem `refs`, decision `refs` (noise), a set-aside or ignore `refs`, question `evidence`; joined to a recurring signal by its key
- `lineage(id)` in `devteam.mjs`, over one reverse index of the folded log (the ad-hoc indexes in `orientClusters`, `orientLinks` and `historyOf` stay as they are for now). An explicit edge list per type, never a generic string walk:
- observation ← cited by: knowledge `evidence`, problem `refs`, decision `refs` (noise), a set-aside or ignore `refs`, question `evidence`; held by an incident on its fingerprint from its opening to its resolution; joined to a recurring signal by its key
- knowledge and problem → decision `knowledge` and `refs`, question `evidence` and `blocks`, `worker-slots` act `knowledge`
- decision → its acts (`act.decision`), the question it rests on (`approved_by`, that one question only), its correction
- act → the outcome observations that cite it, the ticket it made (a work-graph node)
Expand Down
77 changes: 77 additions & 0 deletions test/dashboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2742,3 +2742,80 @@ test("a goal at its target: the card's Mark met is an owner action, same-origin
assert.match(html, /g\.status === 'ready' && g\.at_target \? `<div class="holdbar"><button type="button" data-met-id=/, 'the card draws Mark met for a running goal at its target');
assert.match(html, /build: \(\) => \(\{ action: 'goal-met', body: \{ id \} \}\)/);
});

// The Loop's sidepeeks (the OODA spec, PR 3): every card opens one when clicked anywhere on it; an
// observation's says where it came from and where it sits; a signal opens by its key, / and all.
test('the Loop: a card opens its sidepeek; an observation says where it came from and where it sits; a signal opens by its key', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 180000 }, async (t) => {
const F = fleetFixture('loop-peeks');
t.after(() => F.done());
const SUP = { DEVTEAM_ROLE: 'supervisor' };
const O1 = F.ok(F.old, ['observe', '--subject', 'app', '--kind', 'slow-test', '--detail', 'the checkout test is slow'], SUP);
const O2 = F.ok(F.old, ['observe', '--subject', 'app', '--kind', 'slow-test', '--detail', `slower than ${O1}`], SUP);
// two more that became a lesson: each has a lineage to draw
const O3 = F.ok(F.old, ['observe', '--subject', 'pay', '--kind', 'timeout', '--detail', 'payments timed out'], SUP);
const O4 = F.ok(F.old, ['observe', '--subject', 'pay', '--kind', 'timeout', '--detail', 'payments timed out again'], SUP);
const K = F.ok(F.old, ['knowledge', 'add', '--kind', 'lesson', '--scope', 'project', '--statement', 'payments time out under load', '--evidence', O3, O4], SUP).match(/k-\S+/)[0];
// a finding whose subject holds a /, three times on two days: a recurring signal
const logDir = path.join(F.old, 'docs/devteam/run/log');
const stamp = (d) => `${d.toISOString().slice(0, 10).replace(/-/g, '')}-${d.toISOString().slice(11, 23).replace(/[:.]/g, '')}`;
for (const h of [30, 28, 2]) {
const d = new Date(Date.now() - h * 3600000);
fs.appendFileSync(path.join(logDir, `supervisor-${d.toISOString().slice(0, 7)}.jsonl`), `${JSON.stringify({ v: 1, op: 'new', type: 'observation', id: `o-${stamp(d)}-supervisor`, at: d.toISOString(), by: 'supervisor', source: 'metrics', detector: 'metrics', subject: 'web/e2e', kind: 'flaky-gate', detail: 'web/e2e failed, then passed', source_key: 'metrics:flaky-gate:web/e2e' })}\n`);
}
const port = await freePort();
const dash = startDashboard(F.old, F.env({}), ['--port', String(port)]);
t.after(() => dash.stop());
const chrome = await openChrome();
t.after(() => chrome.close());
const p = await chrome.newPage();
t.after(() => p.close());
await until(() => /dashboard on http/.test(dash.out()), 'the dashboard');
await p.send('Page.enable');
await p.send('Runtime.enable');
await p.send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 900, deviceScaleFactor: 1, mobile: false });
await p.send('Page.navigate', { url: `http://127.0.0.1:${port}/#loop` });
const soon = async (expr, what) => { for (let i = 0; i < 400; i++) { const v = await p.ev(expr); if (v) return v; await new Promise((r) => setTimeout(r, 50)); } assert.fail(`timed out: ${what}`); };
const card = (sel) => `#observations [data-open="observation/${sel}"]`;
await soon(`!!document.querySelector('${card(O1)}')`, 'the observation card');
assert.equal(await p.ev(`document.querySelector('${card(O1)} .place').textContent`), 'Part of a recurring signal (2 of 3)');
// an o- id in text links to its sidepeek
assert.equal(await p.ev(`document.querySelector('${card(O2)}').querySelector('a[href="#observation/${O1}"]') !== null`), true, 'an o- id in text is a link');
// a click anywhere on the card (here its detail, no link in it) opens it
await p.click(`${card(O1)} > div`);
await soon(`location.hash === '#observation/${O1}' && !document.getElementById('drawer').hidden`, 'the observation sidepeek');
await soon(`/From the supervisor/.test(document.querySelector('#drawer-body [data-from]')?.textContent ?? '')`, 'where it came from, first');
// an attribute's value, by its label (the label's tooltip is in its text)
const attr = (label) => p.ev(`[...document.querySelectorAll('#drawer-body .attrs dt')].find((x) => x.textContent.startsWith(${JSON.stringify(label)}))?.nextElementSibling?.textContent ?? null`);
assert.equal(await attr('Where it sits'), 'Part of a recurring signal (2 of 3)', 'two slow-test notes on app, of the three a signal takes');
assert.match(await p.ev(`document.getElementById('drawer-bar-title').textContent`), /Observation/);
// a signal whose key holds a /: its card opens #signal/<key>
await p.ev(`document.getElementById('drawer-close').click()`);
await p.click('[data-tab="orient"]');
const sig = `#clusters [data-open="signal/${encodeURIComponent('metrics:flaky-gate:web/e2e')}"]`;
await soon(`!!document.querySelector(${JSON.stringify(sig)})`, 'the signal card');
// thirty hours unexplained: orientation debt, whose card opens the same signal
assert.match(await p.ev(`document.getElementById('debt').textContent`), /Metrics flaky-gate:web\/e2e · seen 3 times · unexplained for 30 hours/);
await p.click(`${sig} > div`);
await soon(`decodeURIComponent(location.hash) === '#signal/metrics:flaky-gate:web/e2e' && !document.getElementById('drawer').hidden`, 'the signal sidepeek');
await soon(`document.querySelectorAll('#drawer-body h3 + ul a[href^="#observation/"]').length === 3`, 'its three observations');
assert.match(await p.ev(`document.getElementById('drawer-bar-title').textContent`), /Recurring signal/);
assert.equal(await attr('Unexplained for'), '30 hours');
// the lineage: Mermaid blocked, the list stands in, every record in it a link
await p.ev(`document.getElementById('drawer-close').click(); void (MERMAID = Promise.reject(new Error('blocked')))`);
await p.ev(`location.hash = '#observation/${O3}'`);
await soon(`document.querySelectorAll('#drawer-body .lineage-list a[href]').length === 2`, 'the lineage as a list');
assert.deepEqual(await p.ev(`[...document.querySelectorAll('#drawer-body .lineage-list a[href]')].map((a) => a.getAttribute('href'))`), [`#observation/${O3}`, `#lesson/${K}`]);
assert.equal(await p.ev(`document.querySelector('#drawer-body .lineage-list [aria-current]').getAttribute('href')`), `#observation/${O3}`);
assert.equal(await p.ev(`/flowchart/.test(document.getElementById('drawer-body').textContent)`), false, 'never the Mermaid source');
// Mermaid available (it loads from its CDN, so a machine offline keeps the list): each node opens its sidepeek
await p.ev(`document.getElementById('drawer-close').click(); MERMAID = null`);
await p.ev(`location.hash = '#observation/${O4}'`);
await soon(`!!document.querySelector('#drawer-body .lineage-list, #drawer-body .lineage svg')`, 'the lineage');
let drawn = false;
for (let i = 0; i < 300 && !drawn; i++) { drawn = await p.ev(`!!document.querySelector('#drawer-body .lineage svg a.lineage-node')`); if (!drawn) await new Promise((r) => setTimeout(r, 100)); }
if (!drawn) return t.diagnostic('Mermaid did not load (offline?): the graph and its links were not checked');
assert.equal(await p.ev(`document.querySelector('#drawer-body .lineage svg a[aria-current]').getAttribute('href')`), `#observation/${O4}`);
assert.equal(await p.ev(`document.querySelectorAll('#drawer-body .lineage svg a.lineage-node title').length`), 2, 'each node titled');
await p.click('#drawer-body .lineage svg a.lineage-node:not([aria-current])');
await soon(`location.hash === '#lesson/${K}'`, 'a node opens its sidepeek');
});
25 changes: 25 additions & 0 deletions test/fixtures/g1/dashboard-data.txt
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ exit 0
"provenance": {
"from": "owner",
"ref": null
},
"place": {
"state": "new",
"ref": null
}
},
{
Expand All @@ -373,6 +377,12 @@ exit 0
"provenance": {
"from": "owner",
"ref": null
},
"place": {
"state": "signal",
"ref": "slow:g1",
"n": 3,
"recurring": true
}
},
{
Expand All @@ -391,6 +401,12 @@ exit 0
"provenance": {
"from": "supervisor",
"ref": null
},
"place": {
"state": "signal",
"ref": "slow:g1",
"n": 3,
"recurring": true
}
},
{
Expand All @@ -409,6 +425,12 @@ exit 0
"provenance": {
"from": "supervisor",
"ref": null
},
"place": {
"state": "signal",
"ref": "slow:g1",
"n": 3,
"recurring": true
}
}
],
Expand All @@ -425,6 +447,9 @@ exit 0
"latest": "slow 3"
}
],
"debt": {
"signals": []
},
"knowledge": [
{
"id": "k-<sha>-<sha>-owner",
Expand Down
8 changes: 5 additions & 3 deletions test/machine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -375,16 +375,18 @@ test('each read the operator may run writes nothing', () => {
assert.equal(dtIn(r, ['goal', 'add', '--title', 'a goal', '--outcome', 'x'], sup).status, 0);
const goal = dtIn(r, ['goal'], sup).stdout.match(/g-\S+/)?.[0];
assert.ok(goal);
assert.equal(dtIn(r, ['knowledge', 'add', '--kind', 'fact', '--statement', 's'], sup).status, 0);
const k = dtIn(r, ['knowledge', 'add', '--kind', 'fact', '--statement', 's'], sup).stdout.match(/k-\S+/)?.[0];
assert.ok(k);
assert.equal(dtIn(r, ['ask', '--question', 'q?', '--kind', 'owner-decision', '--class', 'design', '--reasons', 'x'], sup).status, 0);
assert.equal(dtIn(r, ['send', 'supervisor', 'hello'], sup).status, 0);
assert.equal(dtIn(r, ['problem', 'add', '--title', 'a problem', '--severity', '2'], sup).status, 0);
const p = dtIn(r, ['problem', 'add', '--title', 'a problem', '--severity', '2'], sup).stdout.trim();
assert.match(p, /^p-/);
conversation(r);
const op = { DEVTEAM_ROLE: 'operator', TMUX: 'x', TMUX_PANE: '%7' };
const walk = (d, out = []) => { for (const e of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, e.name); if (e.name === '.git') continue; if (e.isDirectory()) walk(p, out); else out.push(p); } return out; };
const snap = () => ({ files: Object.fromEntries(walk(r).map((f) => [f, fs.readFileSync(f).toString('base64')])), status: execFileSync('git', ['status', '--porcelain'], { cwd: r, encoding: 'utf8' }) });
const before = snap();
const reads = [['search', 'goal'], ['status'], ['status', '--json'], ['ready'], ['ready', '--json'], ['metrics'], ['metrics', '--json'], ['hello', '--json'], ['settings'], ['supervisor-session'], ['prompt', 'supervisor'], ['validate'], ['questions'], ['questions', '--json', '--all'], ['log', '--json', '--tail', '5'], ['messages', '--to', 'supervisor', '--open'], ['knowledge', '--json'], ['knowledge', '--for', 'x'], ['goal', '--json'], ['goal', 'list'], ['goal', 'show', goal], ['fleet', 'status', '--json', '--no-fetch'], ['trust'], ['trust', '--json'], ['problem'], ['problem', '--json', '--all'], ['sensor', 'status'], ['sensor', 'status', '--json'], ['sensor', 'series', 'web', '--range', '7d'], ['knowledge', '--kit'], ['machine', 'pane-state', '%7']];
const reads = [['search', 'goal'], ['status'], ['status', '--json'], ['ready'], ['ready', '--json'], ['metrics'], ['metrics', '--json'], ['hello', '--json'], ['settings'], ['supervisor-session'], ['prompt', 'supervisor'], ['validate'], ['questions'], ['questions', '--json', '--all'], ['log', '--json', '--tail', '5'], ['messages', '--to', 'supervisor', '--open'], ['knowledge', '--json'], ['knowledge', '--for', 'x'], ['goal', '--json'], ['goal', 'list'], ['goal', 'show', goal], ['fleet', 'status', '--json', '--no-fetch'], ['trust'], ['trust', '--json'], ['problem'], ['problem', '--json', '--all'], ['sensor', 'status'], ['sensor', 'status', '--json'], ['sensor', 'series', 'web', '--range', '7d'], ['knowledge', '--kit'], ['machine', 'pane-state', '%7'], ['peek', 'lesson', k, '--json'], ['peek', 'lesson', k, '--mermaid'], ['peek', 'problem', p, '--json'], ['peek', 'goal', goal, '--json'], ['peek', 'sensor', 'web', '--json']];
for (const c of reads) { const x = dtIn(r, c, op); assert.equal(x.status, 0, `${c.join(' ')}: ${x.stderr}`); assert.doesNotMatch(x.stderr, /only reads/, c.join(' ')); }
// and the machine's digest, run from inside the project, reads it the same way
assert.equal(dtIn(r, ['machine', 'status', '--in', code], op).status, 0);
Expand Down
Loading
Loading