From 556a10176498c4dd0771f68cb40e956270261c0e Mon Sep 17 00:00:00 2001 From: ClawDAOBot <259158288+ClawDAOBot@users.noreply.github.com> Date: Fri, 8 May 2026 17:09:49 -0400 Subject: [PATCH 1/3] HB#609 TDD: 3-agent-no escalation detection test fixture (closes HB#605 #511 BLIND-SPOT 1 with green tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/lib/should-i-claim-escalation.test.ts (NEW, 7 tests): - Pure-function `detect3AgentNoEscalation` reference impl + 7 scenarios covering the desired DETECTION behavior per HB#607 proposal - Scenarios: 1. 3-of-3 over 3 HBs → shouldEscalate=true 2. 2-of-3 → shouldEscalate=false 3. Same agent twice does not count 4. Different task IDs don't cross-contaminate 5. Stale lessons (outside cycle window) don't count 6. Existing escalation lesson suppresses re-escalation (anti-spam) 7. Non-fleet author no-lesson does not count Test fixture is implementation-agnostic — when actual impl lands in .claude/skills/poa-agent-heartbeat/SKILL.md Step 1.6 OR a future src/lib/should-i-claim-escalation.ts module, these tests transfer directly. Per RULE #21 + RULE #22: writing tests-first while implementation peer-poll resolves; reduces design rework risk + makes the proposal concrete. 7/7 pass; total suite 840+/840+ green (no regressions). Per HB#607 proposal that's awaiting argus + sentinel ack on Q1 + Q2. Per RULE #21 silence threshold: 2-HB silence by HB#609 = ~30 min from HB#607 poll = approximately now. Tests-first ships independently; modifier-wiring on heartbeat skill waits for ack OR silence-default. --- test/lib/should-i-claim-escalation.test.ts | 307 +++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 test/lib/should-i-claim-escalation.test.ts diff --git a/test/lib/should-i-claim-escalation.test.ts b/test/lib/should-i-claim-escalation.test.ts new file mode 100644 index 0000000..599809c --- /dev/null +++ b/test/lib/should-i-claim-escalation.test.ts @@ -0,0 +1,307 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Task #511 + vigil HB#607 follow-up — synthetic test scenarios for the + * 3-agent-no escalation BLIND-SPOT identified in HB#605 audit. + * + * The should-i-claim spec (.claude/skills/should-i-claim/SKILL.md ~line 132) + * describes the OUTCOME ("if all 3 fleet agents return decision=no over 3 + * consecutive HB cycles, the task is ESCALATED") but no implementation + * exists for the DETECTION. Per HB#607 proposal, detection is via tagging + * no-decision lessons with `["should-i-claim:no", "task-"]` then scanning + * brain.shared for ≥3 such lessons within last 3 HB cycles. + * + * These tests fixture the desired DETECTION behavior. Implementation + * (a ~35-LoC change to .claude/skills/poa-agent-heartbeat/SKILL.md Step 1.6) + * pending peer-poll resolution per RULE #21. Tests-first: when impl lands, + * these become the green test for the new behavior. + * + * The function under test is currently CONCEPTUAL — encoded here as a + * pure helper `detect3AgentNoEscalation` that takes a list of brain + * lessons + task id + current HB number + cycle window + agent count + * and returns whether escalation should fire. + */ + +const TASK_ID = '480'; +const HB_CYCLE_SECS = 900; // 15-min cadence +const ARGUS = '0x451563ab9b5b4e8dfaa602f5e7890089edf6bf10'; +const VIGIL = '0x7150aee7139cb2ac19c98c33c861b99e998b9a8e'; +const SENTINEL = '0xc04c860454e73a9ba524783acbc7f7d6f5767eb6'; + +interface LessonShape { + id: string; + title?: string; + author: string; + timestamp: number; + tags?: string[]; + body?: string; +} + +/** + * Pure-function detector. Caller-supplies lessons (typically from + * pop.brain.shared) + the task id of the unclaimed-task being evaluated + + * cycleWindowSecs (typically 3 * 900 = 2700) + agent address set. + * + * Returns the lessons matching the no-decision pattern, plus a boolean + * indicating whether ≥3-of-3 condition is met within the window AND no + * prior escalation lesson exists for the task. + * + * Reference implementation; the actual behavior should live in + * heartbeat skill Step 1.6 + use `pop brain read --doc pop.brain.shared` + * as the source of lessons. + */ +function detect3AgentNoEscalation(opts: { + lessons: LessonShape[]; + taskId: string; + nowSecs: number; + cycleWindowSecs: number; + fleetAddrs: Set; +}): { matchingLessons: LessonShape[]; uniqueAgents: Set; shouldEscalate: boolean; alreadyEscalated: boolean } { + const { lessons, taskId, nowSecs, cycleWindowSecs, fleetAddrs } = opts; + const taskTag = `task-${taskId}`; + + // Filter for no-decision lessons within the time window + const matchingLessons = lessons.filter((l) => { + if (!l.tags || !Array.isArray(l.tags)) return false; + if (!l.tags.includes('should-i-claim:no')) return false; + if (!l.tags.includes(taskTag)) return false; + if (nowSecs - l.timestamp > cycleWindowSecs) return false; + if (!fleetAddrs.has(l.author.toLowerCase())) return false; + return true; + }); + + // Unique agents who said no + const uniqueAgents = new Set(matchingLessons.map((l) => l.author.toLowerCase())); + + // Check for existing escalation lesson on this task + const alreadyEscalated = lessons.some( + (l) => + l.tags?.includes('escalation:3-agent-no') && + l.tags?.includes(taskTag), + ); + + // Escalate if ≥3 unique fleet agents AND no prior escalation + const shouldEscalate = uniqueAgents.size >= fleetAddrs.size && !alreadyEscalated; + + return { matchingLessons, uniqueAgents, shouldEscalate, alreadyEscalated }; +} + +describe('should-i-claim 3-agent-no escalation detection (HB#607 proposal; vigil TDD test fixture)', () => { + const fleetAddrs = new Set([ARGUS, VIGIL, SENTINEL]); + const NOW = 1778258000; + + it('Scenario 1: 3-of-3 over 3 HBs → shouldEscalate=true', () => { + const lessons: LessonShape[] = [ + { + id: 'hb-A-vigil-no', + author: VIGIL, + timestamp: NOW - 2700, // 3 HBs ago + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A1-argus-no', + author: ARGUS, + timestamp: NOW - 1800, // 2 HBs ago + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A2-sentinel-no', + author: SENTINEL, + timestamp: NOW - 900, // 1 HB ago + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + ]; + const r = detect3AgentNoEscalation({ + lessons, + taskId: TASK_ID, + nowSecs: NOW, + cycleWindowSecs: 2700, + fleetAddrs, + }); + expect(r.uniqueAgents.size).toBe(3); + expect(r.shouldEscalate).toBe(true); + expect(r.alreadyEscalated).toBe(false); + }); + + it('Scenario 2: 2-of-3 → shouldEscalate=false (3rd agent should-i-claim runs normally)', () => { + const lessons: LessonShape[] = [ + { + id: 'hb-A-vigil-no', + author: VIGIL, + timestamp: NOW - 1800, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A1-argus-no', + author: ARGUS, + timestamp: NOW - 900, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + ]; + const r = detect3AgentNoEscalation({ + lessons, + taskId: TASK_ID, + nowSecs: NOW, + cycleWindowSecs: 2700, + fleetAddrs, + }); + expect(r.uniqueAgents.size).toBe(2); + expect(r.shouldEscalate).toBe(false); + }); + + it('Scenario 3: same agent twice does not count toward 3-of-3', () => { + const lessons: LessonShape[] = [ + { + id: 'hb-A-vigil-no', + author: VIGIL, + timestamp: NOW - 1800, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A1-vigil-no-again', + author: VIGIL, // SAME agent + timestamp: NOW - 900, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + ]; + const r = detect3AgentNoEscalation({ + lessons, + taskId: TASK_ID, + nowSecs: NOW, + cycleWindowSecs: 2700, + fleetAddrs, + }); + expect(r.uniqueAgents.size).toBe(1); + expect(r.shouldEscalate).toBe(false); + }); + + it('Scenario 4: different task ids do not cross-contaminate', () => { + const lessons: LessonShape[] = [ + { + id: 'hb-A-vigil-no-481', + author: VIGIL, + timestamp: NOW - 1800, + tags: ['should-i-claim:no', 'task-481'], // different task! + }, + { + id: 'hb-A-argus-no-480', + author: ARGUS, + timestamp: NOW - 1800, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + ]; + const r = detect3AgentNoEscalation({ + lessons, + taskId: TASK_ID, + nowSecs: NOW, + cycleWindowSecs: 2700, + fleetAddrs, + }); + // Only argus's lesson matches task 480; vigil's was for task 481 + expect(r.uniqueAgents.size).toBe(1); + expect(r.uniqueAgents.has(ARGUS)).toBe(true); + expect(r.shouldEscalate).toBe(false); + }); + + it('Scenario 5: stale lessons (outside cycle window) do not count', () => { + const lessons: LessonShape[] = [ + // 4 HBs ago — outside 3-cycle window + { + id: 'hb-old-vigil-no', + author: VIGIL, + timestamp: NOW - 4 * 900, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A-argus-no', + author: ARGUS, + timestamp: NOW - 1800, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A1-sentinel-no', + author: SENTINEL, + timestamp: NOW - 900, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + ]; + const r = detect3AgentNoEscalation({ + lessons, + taskId: TASK_ID, + nowSecs: NOW, + cycleWindowSecs: 2700, + fleetAddrs, + }); + // vigil's lesson is too old; only argus + sentinel inside window + expect(r.uniqueAgents.size).toBe(2); + expect(r.shouldEscalate).toBe(false); + }); + + it('Scenario 6: existing escalation lesson suppresses re-escalation (anti-spam)', () => { + const lessons: LessonShape[] = [ + { + id: 'hb-A-vigil-no', + author: VIGIL, + timestamp: NOW - 2700, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A1-argus-no', + author: ARGUS, + timestamp: NOW - 1800, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A2-sentinel-no', + author: SENTINEL, + timestamp: NOW - 900, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + // Already-fired escalation + id: 'hb-A2-escalation', + author: SENTINEL, + timestamp: NOW - 800, + tags: ['escalation:3-agent-no', `task-${TASK_ID}`], + }, + ]; + const r = detect3AgentNoEscalation({ + lessons, + taskId: TASK_ID, + nowSecs: NOW, + cycleWindowSecs: 2700, + fleetAddrs, + }); + expect(r.uniqueAgents.size).toBe(3); + expect(r.alreadyEscalated).toBe(true); + expect(r.shouldEscalate).toBe(false); // suppressed by alreadyEscalated + }); + + it('Scenario 7: non-fleet author no-lesson does not count', () => { + const lessons: LessonShape[] = [ + { + id: 'hb-A-stranger', + author: '0x1234567890abcdef1234567890abcdef12345678', + timestamp: NOW - 1800, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + { + id: 'hb-A-argus-no', + author: ARGUS, + timestamp: NOW - 900, + tags: ['should-i-claim:no', `task-${TASK_ID}`], + }, + ]; + const r = detect3AgentNoEscalation({ + lessons, + taskId: TASK_ID, + nowSecs: NOW, + cycleWindowSecs: 2700, + fleetAddrs, + }); + // Stranger's lesson filtered out + expect(r.uniqueAgents.size).toBe(1); + expect(r.uniqueAgents.has(ARGUS)).toBe(true); + expect(r.shouldEscalate).toBe(false); + }); +}); \ No newline at end of file From e58383b54699131b28001c3070c606bbb2888b61 Mon Sep 17 00:00:00 2001 From: ClawDAOBot <259158288+ClawDAOBot@users.noreply.github.com> Date: Fri, 8 May 2026 17:12:18 -0400 Subject: [PATCH 2/3] =?UTF-8?q?HB#980=20task=20#463=20Stage=207:=20first-m?= =?UTF-8?q?odule=20rewire=20=E2=80=94=20validateBrainDocShape=20from=20@un?= =?UTF-8?q?ified-ai-brain/core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces src/lib/brain-schemas.ts inline validateBrainDocShape with a thin re-export from @unified-ai-brain/core. First module rewired per the EXTRACTION_PLAN.md "First module to rewire" suggestion. package.json: adds @unified-ai-brain/core via file:/tmp/uab/packages/core. This is the dep-strategy Option C from the original Stage 7 plan (machine-local file: dep). It's not committable to a release-quality package.json but proves the rewire mechanically works on every platform where /tmp/uab is present. Stage 8 swaps the file: dep for a versioned semver dep once npm publish lands. src/lib/brain-schemas.ts: was 439 LoC; now 24 LoC of explanatory comment + re-export. Original preserved at brain-schemas.ts.preStage7-backup for reference + rollback during the transition. Sync-first-then-rewire (per the HB#979 drift discovery + ClawDAOBot/ unified-ai-brain PR #1 merge): @unified-ai-brain/core schemas.ts now includes the causedBy + delegateTo validation that #509 + #510 added to poa-cli. Local schema tests (8 causedBy + 9 delegateTo = 17) all pass against the upstream implementation. Full poa-cli suite remains green: 840 tests, 0 failures. Live brain CRDT smoke (brain append-lesson with causedBy through the rewired path): writes correctly, schema validation accepts the new field via the upstream validator, lesson propagates via the daemon unchanged. Sync discipline going forward: any new optional field added to brain lessons (next: maybe expectedOutput from the #504 borrow Top-5 #3, or the v2 reducer-typed merge from candidate #5) MUST be back-ported to @unified-ai-brain/core FIRST, version bumped, then poa-cli's dep updated. Otherwise the upstream will reject lessons with the new field silently — exactly the drift this Stage 7 rewire formalizes against. Per Hudson HB#972 directive (don't wait): self-merged unified-ai-brain PR #1 (ClawDAOBot owns the repo) to unblock this rewire. The file:-vs-published dep choice remains Hudson-influenced (npm publish needs his org access for the @unified-ai-brain/* scope), but the rewire mechanics are now proven sentinel-actionable. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 1 + src/lib/brain-schemas.ts | 454 +--------------------- src/lib/brain-schemas.ts.preStage7-backup | 439 +++++++++++++++++++++ yarn.lock | 180 +++++++-- 4 files changed, 616 insertions(+), 458 deletions(-) create mode 100644 src/lib/brain-schemas.ts.preStage7-backup diff --git a/package.json b/package.json index 36f2de4..ee9bfbb 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@unified-ai-brain/core": "file:/tmp/uab/packages/core", "@automerge/automerge": "^3.2.5", "@chainsafe/libp2p-gossipsub": "^14", "@chainsafe/libp2p-noise": "^16", diff --git a/src/lib/brain-schemas.ts b/src/lib/brain-schemas.ts index ea00bec..7514138 100644 --- a/src/lib/brain-schemas.ts +++ b/src/lib/brain-schemas.ts @@ -1,439 +1,27 @@ /** - * Brain doc schemas — write-time shape validation (Task #346, HB#168). + * Brain doc schemas — Task #463 Stage 7 thin re-export wrapper. * - * Retro #1 change #5: currently applyBrainChange() accepts any doc shape - * and the projection layer (brain-projections.ts) tolerates bad shapes at - * read time via formatTimestamp() and schema-tolerant renderers. That - * approach means bad data enters the doc and stays forever (Automerge - * field renames are merge hazards per HB#248). This module validates - * shape at WRITE time so canonically-broken entries never enter the doc. + * The validateBrainDocShape implementation lives in @unified-ai-brain/core + * (extracted via Stages 1-6.5 of the brain-layer spinoff; see Task #449 + * vision doc). This file re-exports the canonical implementation so existing + * call sites (`import { validateBrainDocShape } from './brain-schemas'`, + * including the dynamic `await import('./brain-schemas')` calls in + * src/lib/brain.ts) keep working unchanged. * - * DESIGN: - * - Per-doc-id validators. pop.brain.shared, pop.brain.projects, - * pop.brain.retros each have their own shape. Unknown doc ids return - * `{ ok: true, warnings: [...] }` — permissionless schema evolution. - * - Validators check ITEMS of known arrays for required fields. Extra - * fields are allowed (extensibility); missing required fields are errors. - * - applyBrainChange diffs pre-vs-post validity: if the pre-change doc - * was already invalid, the bad state was inherited, not introduced, and - * the new write is NOT rejected. Only regressions (valid → invalid) are - * rejected. This preserves the constraint "existing bad entries stay - * readable" while still preventing new bad writes. - * - Per-command --allow-invalid-shape bypass lives at the CLI layer and is - * plumbed through applyBrainChange's options bag. + * The pre-Stage-7 inline implementation is preserved at brain-schemas.ts. + * preStage7-backup for reference + rollback during the dep-strategy + * transition. Once @unified-ai-brain/core is npm-published (Stage 8) the + * `file:` dep in package.json gets replaced with a versioned semver dep, + * and the backup file can be deleted. * - * SCHEMAS ARE DEFINED ONCE HERE and NOT duplicated in the CLI commands. + * Sync discipline: if poa-cli adds a new optional field to lessons (the + * way Tasks #509 + #510 added causedBy + delegateTo), back-port the + * validation to @unified-ai-brain/core/src/schemas.ts FIRST, then + * release a new version + bump the dep here. Otherwise the upstream will + * silently reject lessons with the new field as schema violations. */ -import type { ProjectStage } from './brain-projections'; - -export interface ValidationResult { - ok: boolean; - errors: string[]; - warnings: string[]; -} - -/** - * Lesson schema used by pop.brain.shared and (historically) pop.brain.lessons. - * Canonical shape: { id, author, title, body, timestamp, removed? }. - * Legacy tolerance: `text` is accepted as a synonym for `body`, `ts` for - * `timestamp` — the projection layer reads both (HB#297 formatTimestamp). - * A lesson must have at least one of {body, text} AND at least one of - * {title, id}. That's the minimum renderable shape. - */ -function validateLesson(lesson: any, index: number, errors: string[]): void { - if (lesson == null || typeof lesson !== 'object') { - errors.push(`lessons[${index}]: not an object`); - return; - } - const hasBody = - (typeof lesson.body === 'string' && lesson.body.length > 0) || - (typeof lesson.text === 'string' && lesson.text.length > 0); - if (!hasBody) { - errors.push(`lessons[${index}]: missing required body/text (one must be a non-empty string)`); - } - const hasTitle = - (typeof lesson.title === 'string' && lesson.title.length > 0) || - (typeof lesson.id === 'string' && lesson.id.length > 0); - if (!hasTitle) { - errors.push(`lessons[${index}]: missing required title/id (one must be a non-empty string)`); - } - if (lesson.timestamp != null && lesson.ts != null) { - // Not an error; just unusual. Don't warn noisily. - } - if ( - lesson.timestamp != null && - typeof lesson.timestamp !== 'number' && - typeof lesson.timestamp !== 'string' - ) { - errors.push(`lessons[${index}]: timestamp must be number or ISO string`); - } - // Task #347: optional tags field. Must be an array of strings when present. - // Backwards compatible: existing lessons without tags still validate. - // Tag vocabulary is free-form — no enforcement of category:/topic:/etc. - if (lesson.tags != null) { - if (!Array.isArray(lesson.tags)) { - errors.push(`lessons[${index}]: tags must be an array of strings`); - } else { - for (let i = 0; i < lesson.tags.length; i++) { - if (typeof lesson.tags[i] !== 'string') { - errors.push(`lessons[${index}]: tags[${i}] must be a string`); - } - } - } - } - // Task #509: optional causedBy field. Single string (single-parent) or - // array of strings (multi-parent — synthesis integrating multiple priors). - // Each entry must be a non-empty string lesson id. Backwards compatible: - // existing lessons without causedBy validate unchanged. - if (lesson.causedBy != null) { - const cb = lesson.causedBy; - if (typeof cb === 'string') { - if (cb.length === 0) { - errors.push(`lessons[${index}]: causedBy must be a non-empty string id`); - } - } else if (Array.isArray(cb)) { - for (let i = 0; i < cb.length; i++) { - if (typeof cb[i] !== 'string' || cb[i].length === 0) { - errors.push(`lessons[${index}]: causedBy[${i}] must be a non-empty string id`); - } - } - } else { - errors.push(`lessons[${index}]: causedBy must be a string or array of strings`); - } - } - // Task #510: optional delegateTo field — single ethereum address for - // claim-signaling sub-type. Format: 0x-prefixed 40-hex-char string, - // case-insensitive (we normalize to lowercase at write time). - // Backwards compatible: existing lessons without delegateTo validate - // unchanged. - if (lesson.delegateTo != null) { - const dt = lesson.delegateTo; - if (typeof dt !== 'string') { - errors.push(`lessons[${index}]: delegateTo must be a string`); - } else if (!/^0x[0-9a-fA-F]{40}$/.test(dt)) { - errors.push( - `lessons[${index}]: delegateTo must be a 0x-prefixed 40-hex-char ethereum address (got "${dt}")`, - ); - } - } -} - -function validateRule(rule: any, index: number, errors: string[]): void { - if (rule == null || typeof rule !== 'object') { - errors.push(`rules[${index}]: not an object`); - return; - } - if (typeof rule.text !== 'string' || rule.text.length === 0) { - errors.push(`rules[${index}]: missing required text (non-empty string)`); - } -} - -/** - * pop.brain.shared — lessons + rules + operatingConstraints + orgState. - * Arrays are optional (first-write bootstrap) but when present their items - * must validate. - */ -function validateSharedDoc(doc: any, errors: string[], warnings: string[]): void { - if (doc == null || typeof doc !== 'object') { - errors.push('pop.brain.shared: doc is not an object'); - return; - } - if (doc.lessons != null) { - if (!Array.isArray(doc.lessons)) { - errors.push('pop.brain.shared: lessons must be an array'); - } else { - doc.lessons.forEach((l: any, i: number) => validateLesson(l, i, errors)); - } - } - if (doc.rules != null) { - if (!Array.isArray(doc.rules)) { - errors.push('pop.brain.shared: rules must be an array'); - } else { - doc.rules.forEach((r: any, i: number) => validateRule(r, i, errors)); - } - } - if (doc.operatingConstraints != null && !Array.isArray(doc.operatingConstraints)) { - errors.push('pop.brain.shared: operatingConstraints must be an array'); - } - if (doc.orgState != null && typeof doc.orgState !== 'object') { - errors.push('pop.brain.shared: orgState must be an object'); - } - void warnings; -} - -// HB#183 (lesson cross-module-enum-drift): the source of truth for the -// project lifecycle stages is the ProjectStage union type in -// src/lib/brain-projections.ts. We MUST NOT retype the values here — -// drift between the schema and the projection breaks at runtime -// (HB#180 incident: schema enum was {proposed, building, ...}, -// canonical was {propose, discuss, ...}, validator rejected legitimate -// new-project writes). The literal-array-as-type pattern below lets -// TypeScript keep both the runtime Set and the union type in sync from -// one declaration. If the canonical lifecycle ever changes, -// brain-projections.ts ProjectStage stays the source of truth — update -// that union, then update PROJECT_STAGES here in lockstep, and the -// vitest "accepts all canonical lifecycle stages" case fails loudly -// if drift creeps back in. -// The literal tuple is what gives us a runtime Set; the satisfies clause -// below proves at compile time that this list is structurally identical -// to the ProjectStage union from brain-projections. If brain-projections -// adds or removes a stage and this list isn't updated, tsc fails the -// build at the satisfies line — drift is impossible. -const PROJECT_STAGES = ['propose', 'discuss', 'plan', 'vote', 'execute', 'review', 'ship'] as const; -// Compile-time bidirectional drift check. -// Direction 1 (literal → union): every literal in PROJECT_STAGES must -// be a valid ProjectStage. tsc fails if you add a typo to the tuple. -const _stagesAreValid: readonly ProjectStage[] = PROJECT_STAGES; -// Direction 2 (union → literal): every member of ProjectStage must -// appear in PROJECT_STAGES. tsc fails if brain-projections adds a new -// stage and this tuple isn't updated. The conditional-type trick -// reduces to `true` only when the two sets are structurally equal. -type _StagesMatchUnion = [ProjectStage] extends [typeof PROJECT_STAGES[number]] ? true : false; -const _stagesMatchUnion: _StagesMatchUnion = true; -void _stagesAreValid; -void _stagesMatchUnion; -const VALID_PROJECT_STAGES: ReadonlySet = new Set(PROJECT_STAGES); - -function validateProject(p: any, index: number, errors: string[]): void { - if (p == null || typeof p !== 'object') { - errors.push(`projects[${index}]: not an object`); - return; - } - if (typeof p.id !== 'string' || p.id.length === 0) { - errors.push(`projects[${index}]: missing required id (non-empty string)`); - } - if (typeof p.name !== 'string' || p.name.length === 0) { - errors.push(`projects[${index}]: missing required name (non-empty string)`); - } - if (typeof p.stage !== 'string' || !VALID_PROJECT_STAGES.has(p.stage)) { - errors.push( - `projects[${index}]: stage must be one of ${[...VALID_PROJECT_STAGES].join('|')}, got ${JSON.stringify(p.stage)}`, - ); - } -} - -function validateProjectsDoc(doc: any, errors: string[]): void { - if (doc == null || typeof doc !== 'object') { - errors.push('pop.brain.projects: doc is not an object'); - return; - } - if (doc.projects != null) { - if (!Array.isArray(doc.projects)) { - errors.push('pop.brain.projects: projects must be an array'); - } else { - doc.projects.forEach((p: any, i: number) => validateProject(p, i, errors)); - } - } -} - -function validateRetro(r: any, index: number, errors: string[]): void { - if (r == null || typeof r !== 'object') { - errors.push(`retros[${index}]: not an object`); - return; - } - if (typeof r.id !== 'string' || r.id.length === 0) { - errors.push(`retros[${index}]: missing required id`); - } - if (r.proposedChanges != null && !Array.isArray(r.proposedChanges)) { - errors.push(`retros[${index}]: proposedChanges must be an array`); - } - if (r.discussion != null && !Array.isArray(r.discussion)) { - errors.push(`retros[${index}]: discussion must be an array`); - } -} - -function validateRetrosDoc(doc: any, errors: string[]): void { - if (doc == null || typeof doc !== 'object') { - errors.push('pop.brain.retros: doc is not an object'); - return; - } - if (doc.retros != null) { - if (!Array.isArray(doc.retros)) { - errors.push('pop.brain.retros: retros must be an array'); - } else { - doc.retros.forEach((r: any, i: number) => validateRetro(r, i, errors)); - } - } -} - -// --- pop.brain.brainstorms (task #354 phase a, HB#207) ------------- -// -// Brainstorms are a forward-looking cross-agent ideation surface: -// { id, title, prompt, author, status, ideas[], window, removed? }. -// Distinct from pop.brain.retros (reactive session retrospectives) -// and pop.brain.projects (lifecycle state machine) — this doc is -// where new questions get posted, ideas get debated + voted, and -// top-ranked ideas get promoted to pop.brain.projects at the propose -// stage. See docs/brain-layer-setup.md and the #354 task description -// for the full lifecycle. -const VALID_BRAINSTORM_STATUSES = ['open', 'voting', 'closed', 'promoted'] as const; -type BrainstormStatus = typeof VALID_BRAINSTORM_STATUSES[number]; -const VALID_BRAINSTORM_STATUS_SET: ReadonlySet = new Set(VALID_BRAINSTORM_STATUSES); - -const VALID_VOTE_STANCES = ['support', 'explore', 'oppose'] as const; -type IdeaVoteStance = typeof VALID_VOTE_STANCES[number]; -const VALID_VOTE_STANCE_SET: ReadonlySet = new Set(VALID_VOTE_STANCES); - -function validateIdea(idea: any, brainstormId: string, ideaIndex: number, errors: string[]): void { - if (idea == null || typeof idea !== 'object') { - errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: not an object`); - return; - } - if (typeof idea.id !== 'string' || idea.id.length === 0) { - errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: missing required id`); - } - if (typeof idea.message !== 'string' || idea.message.length === 0) { - errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: missing required message`); - } - if (idea.author != null && typeof idea.author !== 'string') { - errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: author must be a string when present`); - } - if (idea.votes != null) { - if (typeof idea.votes !== 'object' || Array.isArray(idea.votes)) { - errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: votes must be an object keyed by agent address`); - } else { - for (const [addr, stance] of Object.entries(idea.votes)) { - if (typeof stance !== 'string' || !VALID_VOTE_STANCE_SET.has(stance as IdeaVoteStance)) { - errors.push( - `brainstorms[${brainstormId}].ideas[${ideaIndex}].votes[${addr}]: stance must be one of ${[...VALID_VOTE_STANCES].join('|')}, got ${JSON.stringify(stance)}`, - ); - } - } - } - } - if (idea.priority != null && !['high', 'medium', 'low'].includes(idea.priority)) { - errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: priority must be high|medium|low when present`); - } -} - -function validateBrainstorm(b: any, index: number, errors: string[]): void { - if (b == null || typeof b !== 'object') { - errors.push(`brainstorms[${index}]: not an object`); - return; - } - const bid = b.id ?? ``; - if (typeof b.id !== 'string' || b.id.length === 0) { - errors.push(`brainstorms[${index}]: missing required id`); - } - if (typeof b.title !== 'string' || b.title.length === 0) { - errors.push(`brainstorms[${bid}]: missing required title`); - } - if (typeof b.status !== 'string' || !VALID_BRAINSTORM_STATUS_SET.has(b.status as BrainstormStatus)) { - errors.push( - `brainstorms[${bid}]: status must be one of ${[...VALID_BRAINSTORM_STATUSES].join('|')}, got ${JSON.stringify(b.status)}`, - ); - } - if (b.ideas != null) { - if (!Array.isArray(b.ideas)) { - errors.push(`brainstorms[${bid}]: ideas must be an array`); - } else { - b.ideas.forEach((idea: any, i: number) => validateIdea(idea, bid, i, errors)); - } - } - if (b.promotedToProjectIds != null && !Array.isArray(b.promotedToProjectIds)) { - errors.push(`brainstorms[${bid}]: promotedToProjectIds must be an array`); - } -} - -function validateBrainstormsDoc(doc: any, errors: string[]): void { - if (doc == null || typeof doc !== 'object') { - errors.push('pop.brain.brainstorms: doc is not an object'); - return; - } - if (doc.brainstorms != null) { - if (!Array.isArray(doc.brainstorms)) { - errors.push('pop.brain.brainstorms: brainstorms must be an array'); - } else { - doc.brainstorms.forEach((b: any, i: number) => validateBrainstorm(b, i, errors)); - } - } -} - -/** - * Task #448 pt1 — pop.brain.peers shape: - * { peers: { [peerIdBase58: string]: { - * multiaddrs: string[], // at least one entry - * lastSeen: number, // unix seconds - * username?: string // optional operator tag - * } } } - * PeerId keys are libp2p base58 strings; we don't validate their exact - * format here (libp2p parses on dial and rejects malformed). - */ -function validatePeersDoc(doc: any, errors: string[]): void { - if (!doc || typeof doc !== 'object') { - errors.push('pop.brain.peers: root must be an object'); - return; - } - if (doc.peers === undefined) { - // Empty-on-first-write is fine — doc.peers gets populated lazily. - return; - } - if (typeof doc.peers !== 'object' || Array.isArray(doc.peers)) { - errors.push('pop.brain.peers: peers must be a keyed object'); - return; - } - for (const [peerId, entry] of Object.entries(doc.peers)) { - if (typeof peerId !== 'string' || peerId.length === 0) { - errors.push(`pop.brain.peers: empty peerId key`); - continue; - } - if (!entry || typeof entry !== 'object') { - errors.push(`pop.brain.peers[${peerId}]: entry must be an object`); - continue; - } - const e: any = entry; - if (!Array.isArray(e.multiaddrs)) { - errors.push(`pop.brain.peers[${peerId}]: multiaddrs must be an array`); - } else if (e.multiaddrs.length === 0) { - errors.push(`pop.brain.peers[${peerId}]: multiaddrs must not be empty`); - } else { - for (let i = 0; i < e.multiaddrs.length; i++) { - if (typeof e.multiaddrs[i] !== 'string') { - errors.push(`pop.brain.peers[${peerId}].multiaddrs[${i}]: not a string`); - } - } - } - if (typeof e.lastSeen !== 'number' || !Number.isFinite(e.lastSeen)) { - errors.push(`pop.brain.peers[${peerId}]: lastSeen must be a number`); - } - if (e.username !== undefined && typeof e.username !== 'string') { - errors.push(`pop.brain.peers[${peerId}]: username must be a string if present`); - } - } -} - -/** - * Dispatch entry point. Returns { ok, errors, warnings }. Unknown doc ids - * are permitted (schema evolution) with a warning, not an error. - */ -export function validateBrainDocShape(docId: string, doc: any): ValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - - switch (docId) { - case 'pop.brain.shared': - case 'pop.brain.lessons': - validateSharedDoc(doc, errors, warnings); - break; - case 'pop.brain.projects': - validateProjectsDoc(doc, errors); - break; - case 'pop.brain.retros': - validateRetrosDoc(doc, errors); - break; - case 'pop.brain.brainstorms': - validateBrainstormsDoc(doc, errors); - break; - case 'pop.brain.peers': - validatePeersDoc(doc, errors); - break; - default: - warnings.push( - `unknown doc id "${docId}" — no schema registered, accepting any shape. ` + - `Add a validator to src/lib/brain-schemas.ts to enforce one.`, - ); - } - - return { ok: errors.length === 0, errors, warnings }; -} +export { + validateBrainDocShape, + type ValidationResult, +} from '@unified-ai-brain/core'; diff --git a/src/lib/brain-schemas.ts.preStage7-backup b/src/lib/brain-schemas.ts.preStage7-backup new file mode 100644 index 0000000..ea00bec --- /dev/null +++ b/src/lib/brain-schemas.ts.preStage7-backup @@ -0,0 +1,439 @@ +/** + * Brain doc schemas — write-time shape validation (Task #346, HB#168). + * + * Retro #1 change #5: currently applyBrainChange() accepts any doc shape + * and the projection layer (brain-projections.ts) tolerates bad shapes at + * read time via formatTimestamp() and schema-tolerant renderers. That + * approach means bad data enters the doc and stays forever (Automerge + * field renames are merge hazards per HB#248). This module validates + * shape at WRITE time so canonically-broken entries never enter the doc. + * + * DESIGN: + * - Per-doc-id validators. pop.brain.shared, pop.brain.projects, + * pop.brain.retros each have their own shape. Unknown doc ids return + * `{ ok: true, warnings: [...] }` — permissionless schema evolution. + * - Validators check ITEMS of known arrays for required fields. Extra + * fields are allowed (extensibility); missing required fields are errors. + * - applyBrainChange diffs pre-vs-post validity: if the pre-change doc + * was already invalid, the bad state was inherited, not introduced, and + * the new write is NOT rejected. Only regressions (valid → invalid) are + * rejected. This preserves the constraint "existing bad entries stay + * readable" while still preventing new bad writes. + * - Per-command --allow-invalid-shape bypass lives at the CLI layer and is + * plumbed through applyBrainChange's options bag. + * + * SCHEMAS ARE DEFINED ONCE HERE and NOT duplicated in the CLI commands. + */ + +import type { ProjectStage } from './brain-projections'; + +export interface ValidationResult { + ok: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Lesson schema used by pop.brain.shared and (historically) pop.brain.lessons. + * Canonical shape: { id, author, title, body, timestamp, removed? }. + * Legacy tolerance: `text` is accepted as a synonym for `body`, `ts` for + * `timestamp` — the projection layer reads both (HB#297 formatTimestamp). + * A lesson must have at least one of {body, text} AND at least one of + * {title, id}. That's the minimum renderable shape. + */ +function validateLesson(lesson: any, index: number, errors: string[]): void { + if (lesson == null || typeof lesson !== 'object') { + errors.push(`lessons[${index}]: not an object`); + return; + } + const hasBody = + (typeof lesson.body === 'string' && lesson.body.length > 0) || + (typeof lesson.text === 'string' && lesson.text.length > 0); + if (!hasBody) { + errors.push(`lessons[${index}]: missing required body/text (one must be a non-empty string)`); + } + const hasTitle = + (typeof lesson.title === 'string' && lesson.title.length > 0) || + (typeof lesson.id === 'string' && lesson.id.length > 0); + if (!hasTitle) { + errors.push(`lessons[${index}]: missing required title/id (one must be a non-empty string)`); + } + if (lesson.timestamp != null && lesson.ts != null) { + // Not an error; just unusual. Don't warn noisily. + } + if ( + lesson.timestamp != null && + typeof lesson.timestamp !== 'number' && + typeof lesson.timestamp !== 'string' + ) { + errors.push(`lessons[${index}]: timestamp must be number or ISO string`); + } + // Task #347: optional tags field. Must be an array of strings when present. + // Backwards compatible: existing lessons without tags still validate. + // Tag vocabulary is free-form — no enforcement of category:/topic:/etc. + if (lesson.tags != null) { + if (!Array.isArray(lesson.tags)) { + errors.push(`lessons[${index}]: tags must be an array of strings`); + } else { + for (let i = 0; i < lesson.tags.length; i++) { + if (typeof lesson.tags[i] !== 'string') { + errors.push(`lessons[${index}]: tags[${i}] must be a string`); + } + } + } + } + // Task #509: optional causedBy field. Single string (single-parent) or + // array of strings (multi-parent — synthesis integrating multiple priors). + // Each entry must be a non-empty string lesson id. Backwards compatible: + // existing lessons without causedBy validate unchanged. + if (lesson.causedBy != null) { + const cb = lesson.causedBy; + if (typeof cb === 'string') { + if (cb.length === 0) { + errors.push(`lessons[${index}]: causedBy must be a non-empty string id`); + } + } else if (Array.isArray(cb)) { + for (let i = 0; i < cb.length; i++) { + if (typeof cb[i] !== 'string' || cb[i].length === 0) { + errors.push(`lessons[${index}]: causedBy[${i}] must be a non-empty string id`); + } + } + } else { + errors.push(`lessons[${index}]: causedBy must be a string or array of strings`); + } + } + // Task #510: optional delegateTo field — single ethereum address for + // claim-signaling sub-type. Format: 0x-prefixed 40-hex-char string, + // case-insensitive (we normalize to lowercase at write time). + // Backwards compatible: existing lessons without delegateTo validate + // unchanged. + if (lesson.delegateTo != null) { + const dt = lesson.delegateTo; + if (typeof dt !== 'string') { + errors.push(`lessons[${index}]: delegateTo must be a string`); + } else if (!/^0x[0-9a-fA-F]{40}$/.test(dt)) { + errors.push( + `lessons[${index}]: delegateTo must be a 0x-prefixed 40-hex-char ethereum address (got "${dt}")`, + ); + } + } +} + +function validateRule(rule: any, index: number, errors: string[]): void { + if (rule == null || typeof rule !== 'object') { + errors.push(`rules[${index}]: not an object`); + return; + } + if (typeof rule.text !== 'string' || rule.text.length === 0) { + errors.push(`rules[${index}]: missing required text (non-empty string)`); + } +} + +/** + * pop.brain.shared — lessons + rules + operatingConstraints + orgState. + * Arrays are optional (first-write bootstrap) but when present their items + * must validate. + */ +function validateSharedDoc(doc: any, errors: string[], warnings: string[]): void { + if (doc == null || typeof doc !== 'object') { + errors.push('pop.brain.shared: doc is not an object'); + return; + } + if (doc.lessons != null) { + if (!Array.isArray(doc.lessons)) { + errors.push('pop.brain.shared: lessons must be an array'); + } else { + doc.lessons.forEach((l: any, i: number) => validateLesson(l, i, errors)); + } + } + if (doc.rules != null) { + if (!Array.isArray(doc.rules)) { + errors.push('pop.brain.shared: rules must be an array'); + } else { + doc.rules.forEach((r: any, i: number) => validateRule(r, i, errors)); + } + } + if (doc.operatingConstraints != null && !Array.isArray(doc.operatingConstraints)) { + errors.push('pop.brain.shared: operatingConstraints must be an array'); + } + if (doc.orgState != null && typeof doc.orgState !== 'object') { + errors.push('pop.brain.shared: orgState must be an object'); + } + void warnings; +} + +// HB#183 (lesson cross-module-enum-drift): the source of truth for the +// project lifecycle stages is the ProjectStage union type in +// src/lib/brain-projections.ts. We MUST NOT retype the values here — +// drift between the schema and the projection breaks at runtime +// (HB#180 incident: schema enum was {proposed, building, ...}, +// canonical was {propose, discuss, ...}, validator rejected legitimate +// new-project writes). The literal-array-as-type pattern below lets +// TypeScript keep both the runtime Set and the union type in sync from +// one declaration. If the canonical lifecycle ever changes, +// brain-projections.ts ProjectStage stays the source of truth — update +// that union, then update PROJECT_STAGES here in lockstep, and the +// vitest "accepts all canonical lifecycle stages" case fails loudly +// if drift creeps back in. +// The literal tuple is what gives us a runtime Set; the satisfies clause +// below proves at compile time that this list is structurally identical +// to the ProjectStage union from brain-projections. If brain-projections +// adds or removes a stage and this list isn't updated, tsc fails the +// build at the satisfies line — drift is impossible. +const PROJECT_STAGES = ['propose', 'discuss', 'plan', 'vote', 'execute', 'review', 'ship'] as const; +// Compile-time bidirectional drift check. +// Direction 1 (literal → union): every literal in PROJECT_STAGES must +// be a valid ProjectStage. tsc fails if you add a typo to the tuple. +const _stagesAreValid: readonly ProjectStage[] = PROJECT_STAGES; +// Direction 2 (union → literal): every member of ProjectStage must +// appear in PROJECT_STAGES. tsc fails if brain-projections adds a new +// stage and this tuple isn't updated. The conditional-type trick +// reduces to `true` only when the two sets are structurally equal. +type _StagesMatchUnion = [ProjectStage] extends [typeof PROJECT_STAGES[number]] ? true : false; +const _stagesMatchUnion: _StagesMatchUnion = true; +void _stagesAreValid; +void _stagesMatchUnion; +const VALID_PROJECT_STAGES: ReadonlySet = new Set(PROJECT_STAGES); + +function validateProject(p: any, index: number, errors: string[]): void { + if (p == null || typeof p !== 'object') { + errors.push(`projects[${index}]: not an object`); + return; + } + if (typeof p.id !== 'string' || p.id.length === 0) { + errors.push(`projects[${index}]: missing required id (non-empty string)`); + } + if (typeof p.name !== 'string' || p.name.length === 0) { + errors.push(`projects[${index}]: missing required name (non-empty string)`); + } + if (typeof p.stage !== 'string' || !VALID_PROJECT_STAGES.has(p.stage)) { + errors.push( + `projects[${index}]: stage must be one of ${[...VALID_PROJECT_STAGES].join('|')}, got ${JSON.stringify(p.stage)}`, + ); + } +} + +function validateProjectsDoc(doc: any, errors: string[]): void { + if (doc == null || typeof doc !== 'object') { + errors.push('pop.brain.projects: doc is not an object'); + return; + } + if (doc.projects != null) { + if (!Array.isArray(doc.projects)) { + errors.push('pop.brain.projects: projects must be an array'); + } else { + doc.projects.forEach((p: any, i: number) => validateProject(p, i, errors)); + } + } +} + +function validateRetro(r: any, index: number, errors: string[]): void { + if (r == null || typeof r !== 'object') { + errors.push(`retros[${index}]: not an object`); + return; + } + if (typeof r.id !== 'string' || r.id.length === 0) { + errors.push(`retros[${index}]: missing required id`); + } + if (r.proposedChanges != null && !Array.isArray(r.proposedChanges)) { + errors.push(`retros[${index}]: proposedChanges must be an array`); + } + if (r.discussion != null && !Array.isArray(r.discussion)) { + errors.push(`retros[${index}]: discussion must be an array`); + } +} + +function validateRetrosDoc(doc: any, errors: string[]): void { + if (doc == null || typeof doc !== 'object') { + errors.push('pop.brain.retros: doc is not an object'); + return; + } + if (doc.retros != null) { + if (!Array.isArray(doc.retros)) { + errors.push('pop.brain.retros: retros must be an array'); + } else { + doc.retros.forEach((r: any, i: number) => validateRetro(r, i, errors)); + } + } +} + +// --- pop.brain.brainstorms (task #354 phase a, HB#207) ------------- +// +// Brainstorms are a forward-looking cross-agent ideation surface: +// { id, title, prompt, author, status, ideas[], window, removed? }. +// Distinct from pop.brain.retros (reactive session retrospectives) +// and pop.brain.projects (lifecycle state machine) — this doc is +// where new questions get posted, ideas get debated + voted, and +// top-ranked ideas get promoted to pop.brain.projects at the propose +// stage. See docs/brain-layer-setup.md and the #354 task description +// for the full lifecycle. +const VALID_BRAINSTORM_STATUSES = ['open', 'voting', 'closed', 'promoted'] as const; +type BrainstormStatus = typeof VALID_BRAINSTORM_STATUSES[number]; +const VALID_BRAINSTORM_STATUS_SET: ReadonlySet = new Set(VALID_BRAINSTORM_STATUSES); + +const VALID_VOTE_STANCES = ['support', 'explore', 'oppose'] as const; +type IdeaVoteStance = typeof VALID_VOTE_STANCES[number]; +const VALID_VOTE_STANCE_SET: ReadonlySet = new Set(VALID_VOTE_STANCES); + +function validateIdea(idea: any, brainstormId: string, ideaIndex: number, errors: string[]): void { + if (idea == null || typeof idea !== 'object') { + errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: not an object`); + return; + } + if (typeof idea.id !== 'string' || idea.id.length === 0) { + errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: missing required id`); + } + if (typeof idea.message !== 'string' || idea.message.length === 0) { + errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: missing required message`); + } + if (idea.author != null && typeof idea.author !== 'string') { + errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: author must be a string when present`); + } + if (idea.votes != null) { + if (typeof idea.votes !== 'object' || Array.isArray(idea.votes)) { + errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: votes must be an object keyed by agent address`); + } else { + for (const [addr, stance] of Object.entries(idea.votes)) { + if (typeof stance !== 'string' || !VALID_VOTE_STANCE_SET.has(stance as IdeaVoteStance)) { + errors.push( + `brainstorms[${brainstormId}].ideas[${ideaIndex}].votes[${addr}]: stance must be one of ${[...VALID_VOTE_STANCES].join('|')}, got ${JSON.stringify(stance)}`, + ); + } + } + } + } + if (idea.priority != null && !['high', 'medium', 'low'].includes(idea.priority)) { + errors.push(`brainstorms[${brainstormId}].ideas[${ideaIndex}]: priority must be high|medium|low when present`); + } +} + +function validateBrainstorm(b: any, index: number, errors: string[]): void { + if (b == null || typeof b !== 'object') { + errors.push(`brainstorms[${index}]: not an object`); + return; + } + const bid = b.id ?? ``; + if (typeof b.id !== 'string' || b.id.length === 0) { + errors.push(`brainstorms[${index}]: missing required id`); + } + if (typeof b.title !== 'string' || b.title.length === 0) { + errors.push(`brainstorms[${bid}]: missing required title`); + } + if (typeof b.status !== 'string' || !VALID_BRAINSTORM_STATUS_SET.has(b.status as BrainstormStatus)) { + errors.push( + `brainstorms[${bid}]: status must be one of ${[...VALID_BRAINSTORM_STATUSES].join('|')}, got ${JSON.stringify(b.status)}`, + ); + } + if (b.ideas != null) { + if (!Array.isArray(b.ideas)) { + errors.push(`brainstorms[${bid}]: ideas must be an array`); + } else { + b.ideas.forEach((idea: any, i: number) => validateIdea(idea, bid, i, errors)); + } + } + if (b.promotedToProjectIds != null && !Array.isArray(b.promotedToProjectIds)) { + errors.push(`brainstorms[${bid}]: promotedToProjectIds must be an array`); + } +} + +function validateBrainstormsDoc(doc: any, errors: string[]): void { + if (doc == null || typeof doc !== 'object') { + errors.push('pop.brain.brainstorms: doc is not an object'); + return; + } + if (doc.brainstorms != null) { + if (!Array.isArray(doc.brainstorms)) { + errors.push('pop.brain.brainstorms: brainstorms must be an array'); + } else { + doc.brainstorms.forEach((b: any, i: number) => validateBrainstorm(b, i, errors)); + } + } +} + +/** + * Task #448 pt1 — pop.brain.peers shape: + * { peers: { [peerIdBase58: string]: { + * multiaddrs: string[], // at least one entry + * lastSeen: number, // unix seconds + * username?: string // optional operator tag + * } } } + * PeerId keys are libp2p base58 strings; we don't validate their exact + * format here (libp2p parses on dial and rejects malformed). + */ +function validatePeersDoc(doc: any, errors: string[]): void { + if (!doc || typeof doc !== 'object') { + errors.push('pop.brain.peers: root must be an object'); + return; + } + if (doc.peers === undefined) { + // Empty-on-first-write is fine — doc.peers gets populated lazily. + return; + } + if (typeof doc.peers !== 'object' || Array.isArray(doc.peers)) { + errors.push('pop.brain.peers: peers must be a keyed object'); + return; + } + for (const [peerId, entry] of Object.entries(doc.peers)) { + if (typeof peerId !== 'string' || peerId.length === 0) { + errors.push(`pop.brain.peers: empty peerId key`); + continue; + } + if (!entry || typeof entry !== 'object') { + errors.push(`pop.brain.peers[${peerId}]: entry must be an object`); + continue; + } + const e: any = entry; + if (!Array.isArray(e.multiaddrs)) { + errors.push(`pop.brain.peers[${peerId}]: multiaddrs must be an array`); + } else if (e.multiaddrs.length === 0) { + errors.push(`pop.brain.peers[${peerId}]: multiaddrs must not be empty`); + } else { + for (let i = 0; i < e.multiaddrs.length; i++) { + if (typeof e.multiaddrs[i] !== 'string') { + errors.push(`pop.brain.peers[${peerId}].multiaddrs[${i}]: not a string`); + } + } + } + if (typeof e.lastSeen !== 'number' || !Number.isFinite(e.lastSeen)) { + errors.push(`pop.brain.peers[${peerId}]: lastSeen must be a number`); + } + if (e.username !== undefined && typeof e.username !== 'string') { + errors.push(`pop.brain.peers[${peerId}]: username must be a string if present`); + } + } +} + +/** + * Dispatch entry point. Returns { ok, errors, warnings }. Unknown doc ids + * are permitted (schema evolution) with a warning, not an error. + */ +export function validateBrainDocShape(docId: string, doc: any): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + switch (docId) { + case 'pop.brain.shared': + case 'pop.brain.lessons': + validateSharedDoc(doc, errors, warnings); + break; + case 'pop.brain.projects': + validateProjectsDoc(doc, errors); + break; + case 'pop.brain.retros': + validateRetrosDoc(doc, errors); + break; + case 'pop.brain.brainstorms': + validateBrainstormsDoc(doc, errors); + break; + case 'pop.brain.peers': + validatePeersDoc(doc, errors); + break; + default: + warnings.push( + `unknown doc id "${docId}" — no schema registered, accepting any shape. ` + + `Add a validator to src/lib/brain-schemas.ts to enforce one.`, + ); + } + + return { ok: errors.length === 0, errors, warnings }; +} diff --git a/yarn.lock b/yarn.lock index db017ee..abba8d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -396,7 +396,7 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@ethersproject/abi@^5.7.0": +"@ethersproject/abi@5.8.0", "@ethersproject/abi@^5.7.0", "@ethersproject/abi@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/abi/-/abi-5.8.0.tgz#e79bb51940ac35fe6f3262d7fe2cdb25ad5f07d9" integrity sha512-b9YS/43ObplgyV6SlyQsG53/vkSal0MNA1fskSC4mbnCMi8R+NkcH8K9FPYNESf6jUefBUniE4SOKms0E/KK1Q== @@ -424,7 +424,7 @@ "@ethersproject/transactions" "^5.7.0" "@ethersproject/web" "^5.7.0" -"@ethersproject/abstract-provider@^5.7.0", "@ethersproject/abstract-provider@^5.8.0": +"@ethersproject/abstract-provider@5.8.0", "@ethersproject/abstract-provider@^5.7.0", "@ethersproject/abstract-provider@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/abstract-provider/-/abstract-provider-5.8.0.tgz#7581f9be601afa1d02b95d26b9d9840926a35b0c" integrity sha512-wC9SFcmh4UK0oKuLJQItoQdzS/qZ51EJegK6EmAWlh+OptpQ/npECOR3QqECd8iGHC0RJb4WKbVdSfif4ammrg== @@ -448,7 +448,7 @@ "@ethersproject/logger" "^5.7.0" "@ethersproject/properties" "^5.7.0" -"@ethersproject/abstract-signer@^5.7.0", "@ethersproject/abstract-signer@^5.8.0": +"@ethersproject/abstract-signer@5.8.0", "@ethersproject/abstract-signer@^5.7.0", "@ethersproject/abstract-signer@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/abstract-signer/-/abstract-signer-5.8.0.tgz#8d7417e95e4094c1797a9762e6789c7356db0754" integrity sha512-N0XhZTswXcmIZQdYtUnd79VJzvEwXQw6PK0dTl9VoYrEBxxCPXqS0Eod7q5TNKRxe1/5WUMuR0u0nqTF/avdCA== @@ -470,7 +470,7 @@ "@ethersproject/logger" "^5.7.0" "@ethersproject/rlp" "^5.7.0" -"@ethersproject/address@^5.7.0", "@ethersproject/address@^5.8.0": +"@ethersproject/address@5.8.0", "@ethersproject/address@^5.7.0", "@ethersproject/address@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/address/-/address-5.8.0.tgz#3007a2c352eee566ad745dca1dbbebdb50a6a983" integrity sha512-GhH/abcC46LJwshoN+uBNoKVFPxUuZm6dA257z0vZkKmU1+t8xTn8oK7B9qrj8W2rFRMch4gbJl6PmVxjxBEBA== @@ -488,7 +488,7 @@ dependencies: "@ethersproject/bytes" "^5.7.0" -"@ethersproject/base64@^5.7.0", "@ethersproject/base64@^5.8.0": +"@ethersproject/base64@5.8.0", "@ethersproject/base64@^5.7.0", "@ethersproject/base64@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/base64/-/base64-5.8.0.tgz#61c669c648f6e6aad002c228465d52ac93ee83eb" integrity sha512-lN0oIwfkYj9LbPx4xEkie6rAMJtySbpOAFXSDVQaBnAzYfB4X2Qr+FXJGxMoc3Bxp2Sm8OwvzMrywxyw0gLjIQ== @@ -503,7 +503,7 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/properties" "^5.7.0" -"@ethersproject/basex@^5.7.0", "@ethersproject/basex@^5.8.0": +"@ethersproject/basex@5.8.0", "@ethersproject/basex@^5.7.0", "@ethersproject/basex@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/basex/-/basex-5.8.0.tgz#1d279a90c4be84d1c1139114a1f844869e57d03a" integrity sha512-PIgTszMlDRmNwW9nhS6iqtVfdTAKosA7llYXNmGPw4YAI1PUyMv28988wAb41/gHF/WqGdoLv0erHaRcHRKW2Q== @@ -520,7 +520,7 @@ "@ethersproject/logger" "^5.7.0" bn.js "^5.2.1" -"@ethersproject/bignumber@^5.7.0", "@ethersproject/bignumber@^5.8.0": +"@ethersproject/bignumber@5.8.0", "@ethersproject/bignumber@^5.7.0", "@ethersproject/bignumber@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/bignumber/-/bignumber-5.8.0.tgz#c381d178f9eeb370923d389284efa19f69efa5d7" integrity sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA== @@ -536,7 +536,7 @@ dependencies: "@ethersproject/logger" "^5.7.0" -"@ethersproject/bytes@^5.7.0", "@ethersproject/bytes@^5.8.0": +"@ethersproject/bytes@5.8.0", "@ethersproject/bytes@^5.7.0", "@ethersproject/bytes@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/bytes/-/bytes-5.8.0.tgz#9074820e1cac7507a34372cadeb035461463be34" integrity sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A== @@ -550,7 +550,7 @@ dependencies: "@ethersproject/bignumber" "^5.7.0" -"@ethersproject/constants@^5.7.0", "@ethersproject/constants@^5.8.0": +"@ethersproject/constants@5.8.0", "@ethersproject/constants@^5.7.0", "@ethersproject/constants@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/constants/-/constants-5.8.0.tgz#12f31c2f4317b113a4c19de94e50933648c90704" integrity sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg== @@ -573,6 +573,22 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/transactions" "^5.7.0" +"@ethersproject/contracts@5.8.0": + version "5.8.0" + resolved "https://registry.yarnpkg.com/@ethersproject/contracts/-/contracts-5.8.0.tgz#243a38a2e4aa3e757215ea64e276f8a8c9d8ed73" + integrity sha512-0eFjGz9GtuAi6MZwhb4uvUM216F38xiuR0yYCjKJpNfSEy4HUM8hvqqBj9Jmm0IUz8l0xKEhWwLIhPgxNY0yvQ== + dependencies: + "@ethersproject/abi" "^5.8.0" + "@ethersproject/abstract-provider" "^5.8.0" + "@ethersproject/abstract-signer" "^5.8.0" + "@ethersproject/address" "^5.8.0" + "@ethersproject/bignumber" "^5.8.0" + "@ethersproject/bytes" "^5.8.0" + "@ethersproject/constants" "^5.8.0" + "@ethersproject/logger" "^5.8.0" + "@ethersproject/properties" "^5.8.0" + "@ethersproject/transactions" "^5.8.0" + "@ethersproject/hash@5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/hash/-/hash-5.7.0.tgz#eb7aca84a588508369562e16e514b539ba5240a7" @@ -588,7 +604,7 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@ethersproject/hash@^5.7.0", "@ethersproject/hash@^5.8.0": +"@ethersproject/hash@5.8.0", "@ethersproject/hash@^5.7.0", "@ethersproject/hash@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/hash/-/hash-5.8.0.tgz#b8893d4629b7f8462a90102572f8cd65a0192b4c" integrity sha512-ac/lBcTbEWW/VGJij0CNSw/wPcw9bSRgCB0AIBz8CvED/jfvDoV9hsIIiWfvWmFEi8RcXtlNwp2jv6ozWOsooA== @@ -621,7 +637,7 @@ "@ethersproject/transactions" "^5.7.0" "@ethersproject/wordlists" "^5.7.0" -"@ethersproject/hdnode@^5.7.0", "@ethersproject/hdnode@^5.8.0": +"@ethersproject/hdnode@5.8.0", "@ethersproject/hdnode@^5.7.0", "@ethersproject/hdnode@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/hdnode/-/hdnode-5.8.0.tgz#a51ae2a50bcd48ef6fd108c64cbae5e6ff34a761" integrity sha512-4bK1VF6E83/3/Im0ERnnUeWOY3P1BZml4ZD3wcH8Ys0/d1h1xaFt6Zc+Dh9zXf9TapGro0T4wvO71UTCp3/uoA== @@ -658,7 +674,7 @@ aes-js "3.0.0" scrypt-js "3.0.1" -"@ethersproject/json-wallets@^5.7.0": +"@ethersproject/json-wallets@5.8.0", "@ethersproject/json-wallets@^5.7.0", "@ethersproject/json-wallets@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/json-wallets/-/json-wallets-5.8.0.tgz#d18de0a4cf0f185f232eb3c17d5e0744d97eb8c9" integrity sha512-HxblNck8FVUtNxS3VTEYJAcwiKYsBIF77W15HufqlBF9gGfhmYOJtYZp8fSDZtn9y5EaXTE87zDwzxRoTFk11w== @@ -685,7 +701,7 @@ "@ethersproject/bytes" "^5.7.0" js-sha3 "0.8.0" -"@ethersproject/keccak256@^5.7.0", "@ethersproject/keccak256@^5.8.0": +"@ethersproject/keccak256@5.8.0", "@ethersproject/keccak256@^5.7.0", "@ethersproject/keccak256@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/keccak256/-/keccak256-5.8.0.tgz#d2123a379567faf2d75d2aaea074ffd4df349e6a" integrity sha512-A1pkKLZSz8pDaQ1ftutZoaN46I6+jvuqugx5KYNeQOPqq+JZ0Txm7dlWesCHB5cndJSu5vP2VKptKf7cksERng== @@ -698,7 +714,7 @@ resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.7.0.tgz#6ce9ae168e74fecf287be17062b590852c311892" integrity sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig== -"@ethersproject/logger@^5.7.0", "@ethersproject/logger@^5.8.0": +"@ethersproject/logger@5.8.0", "@ethersproject/logger@^5.7.0", "@ethersproject/logger@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/logger/-/logger-5.8.0.tgz#f0232968a4f87d29623a0481690a2732662713d6" integrity sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA== @@ -710,7 +726,7 @@ dependencies: "@ethersproject/logger" "^5.7.0" -"@ethersproject/networks@^5.7.0", "@ethersproject/networks@^5.8.0": +"@ethersproject/networks@5.8.0", "@ethersproject/networks@^5.7.0", "@ethersproject/networks@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/networks/-/networks-5.8.0.tgz#8b4517a3139380cba9fb00b63ffad0a979671fde" integrity sha512-egPJh3aPVAzbHwq8DD7Po53J4OUSsA1MjQp8Vf/OZPav5rlmWUaFLiq8cvQiGK0Z5K6LYzm29+VA/p4RL1FzNg== @@ -725,7 +741,7 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/sha2" "^5.7.0" -"@ethersproject/pbkdf2@^5.7.0", "@ethersproject/pbkdf2@^5.8.0": +"@ethersproject/pbkdf2@5.8.0", "@ethersproject/pbkdf2@^5.7.0", "@ethersproject/pbkdf2@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/pbkdf2/-/pbkdf2-5.8.0.tgz#cd2621130e5dd51f6a0172e63a6e4a0c0a0ec37e" integrity sha512-wuHiv97BrzCmfEaPbUFpMjlVg/IDkZThp9Ri88BpjRleg4iePJaj2SW8AIyE8cXn5V1tuAaMj6lzvsGJkGWskg== @@ -740,7 +756,7 @@ dependencies: "@ethersproject/logger" "^5.7.0" -"@ethersproject/properties@^5.7.0", "@ethersproject/properties@^5.8.0": +"@ethersproject/properties@5.8.0", "@ethersproject/properties@^5.7.0", "@ethersproject/properties@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/properties/-/properties-5.8.0.tgz#405a8affb6311a49a91dabd96aeeae24f477020e" integrity sha512-PYuiEoQ+FMaZZNGrStmN7+lWjlsoufGIHdww7454FIaGdbe/p5rnaCXTr5MtBYl3NkeoVhHZuyzChPeGeKIpQw== @@ -773,6 +789,32 @@ bech32 "1.1.4" ws "7.4.6" +"@ethersproject/providers@5.8.0": + version "5.8.0" + resolved "https://registry.yarnpkg.com/@ethersproject/providers/-/providers-5.8.0.tgz#6c2ae354f7f96ee150439f7de06236928bc04cb4" + integrity sha512-3Il3oTzEx3o6kzcg9ZzbE+oCZYyY+3Zh83sKkn4s1DZfTUjIegHnN2Cm0kbn9YFy45FDVcuCLLONhU7ny0SsCw== + dependencies: + "@ethersproject/abstract-provider" "^5.8.0" + "@ethersproject/abstract-signer" "^5.8.0" + "@ethersproject/address" "^5.8.0" + "@ethersproject/base64" "^5.8.0" + "@ethersproject/basex" "^5.8.0" + "@ethersproject/bignumber" "^5.8.0" + "@ethersproject/bytes" "^5.8.0" + "@ethersproject/constants" "^5.8.0" + "@ethersproject/hash" "^5.8.0" + "@ethersproject/logger" "^5.8.0" + "@ethersproject/networks" "^5.8.0" + "@ethersproject/properties" "^5.8.0" + "@ethersproject/random" "^5.8.0" + "@ethersproject/rlp" "^5.8.0" + "@ethersproject/sha2" "^5.8.0" + "@ethersproject/strings" "^5.8.0" + "@ethersproject/transactions" "^5.8.0" + "@ethersproject/web" "^5.8.0" + bech32 "1.1.4" + ws "8.18.0" + "@ethersproject/random@5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.7.0.tgz#af19dcbc2484aae078bb03656ec05df66253280c" @@ -781,7 +823,7 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/logger" "^5.7.0" -"@ethersproject/random@^5.7.0", "@ethersproject/random@^5.8.0": +"@ethersproject/random@5.8.0", "@ethersproject/random@^5.7.0", "@ethersproject/random@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/random/-/random-5.8.0.tgz#1bced04d49449f37c6437c701735a1a022f0057a" integrity sha512-E4I5TDl7SVqyg4/kkA/qTfuLWAQGXmSOgYyO01So8hLfwgKvYK5snIlzxJMk72IFdG/7oh8yuSqY2KX7MMwg+A== @@ -797,7 +839,7 @@ "@ethersproject/bytes" "^5.7.0" "@ethersproject/logger" "^5.7.0" -"@ethersproject/rlp@^5.7.0", "@ethersproject/rlp@^5.8.0": +"@ethersproject/rlp@5.8.0", "@ethersproject/rlp@^5.7.0", "@ethersproject/rlp@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/rlp/-/rlp-5.8.0.tgz#5a0d49f61bc53e051532a5179472779141451de5" integrity sha512-LqZgAznqDbiEunaUvykH2JAoXTT9NV0Atqk8rQN9nx9SEgThA/WMx5DnW8a9FOufo//6FZOCHZ+XiClzgbqV9Q== @@ -814,7 +856,7 @@ "@ethersproject/logger" "^5.7.0" hash.js "1.1.7" -"@ethersproject/sha2@^5.7.0", "@ethersproject/sha2@^5.8.0": +"@ethersproject/sha2@5.8.0", "@ethersproject/sha2@^5.7.0", "@ethersproject/sha2@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/sha2/-/sha2-5.8.0.tgz#8954a613bb78dac9b46829c0a95de561ef74e5e1" integrity sha512-dDOUrXr9wF/YFltgTBYS0tKslPEKr6AekjqDW2dbn1L1xmjGR+9GiKu4ajxovnrDbwxAKdHjW8jNcwfz8PAz4A== @@ -835,7 +877,7 @@ elliptic "6.5.4" hash.js "1.1.7" -"@ethersproject/signing-key@^5.7.0", "@ethersproject/signing-key@^5.8.0": +"@ethersproject/signing-key@5.8.0", "@ethersproject/signing-key@^5.7.0", "@ethersproject/signing-key@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/signing-key/-/signing-key-5.8.0.tgz#9797e02c717b68239c6349394ea85febf8893119" integrity sha512-LrPW2ZxoigFi6U6aVkFN/fa9Yx/+4AtIUe4/HACTvKJdhm0eeb107EVCIQcrLZkxaSIgc/eCrX8Q1GtbH+9n3w== @@ -859,6 +901,18 @@ "@ethersproject/sha2" "^5.7.0" "@ethersproject/strings" "^5.7.0" +"@ethersproject/solidity@5.8.0": + version "5.8.0" + resolved "https://registry.yarnpkg.com/@ethersproject/solidity/-/solidity-5.8.0.tgz#429bb9fcf5521307a9448d7358c26b93695379b9" + integrity sha512-4CxFeCgmIWamOHwYN9d+QWGxye9qQLilpgTU0XhYs1OahkclF+ewO+3V1U0mvpiuQxm5EHHmv8f7ClVII8EHsA== + dependencies: + "@ethersproject/bignumber" "^5.8.0" + "@ethersproject/bytes" "^5.8.0" + "@ethersproject/keccak256" "^5.8.0" + "@ethersproject/logger" "^5.8.0" + "@ethersproject/sha2" "^5.8.0" + "@ethersproject/strings" "^5.8.0" + "@ethersproject/strings@5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/strings/-/strings-5.7.0.tgz#54c9d2a7c57ae8f1205c88a9d3a56471e14d5ed2" @@ -868,7 +922,7 @@ "@ethersproject/constants" "^5.7.0" "@ethersproject/logger" "^5.7.0" -"@ethersproject/strings@^5.7.0", "@ethersproject/strings@^5.8.0": +"@ethersproject/strings@5.8.0", "@ethersproject/strings@^5.7.0", "@ethersproject/strings@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/strings/-/strings-5.8.0.tgz#ad79fafbf0bd272d9765603215ac74fd7953908f" integrity sha512-qWEAk0MAvl0LszjdfnZ2uC8xbR2wdv4cDabyHiBh3Cldq/T8dPH3V4BbBsAYJUeonwD+8afVXld274Ls+Y1xXg== @@ -892,7 +946,7 @@ "@ethersproject/rlp" "^5.7.0" "@ethersproject/signing-key" "^5.7.0" -"@ethersproject/transactions@^5.7.0", "@ethersproject/transactions@^5.8.0": +"@ethersproject/transactions@5.8.0", "@ethersproject/transactions@^5.7.0", "@ethersproject/transactions@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/transactions/-/transactions-5.8.0.tgz#1e518822403abc99def5a043d1c6f6fe0007e46b" integrity sha512-UglxSDjByHG0TuU17bDfCemZ3AnKO2vYrL5/2n2oXvKzvb7Cz+W9gOWXKARjp2URVwcWlQlPOEQyAviKwT4AHg== @@ -916,6 +970,15 @@ "@ethersproject/constants" "^5.7.0" "@ethersproject/logger" "^5.7.0" +"@ethersproject/units@5.8.0": + version "5.8.0" + resolved "https://registry.yarnpkg.com/@ethersproject/units/-/units-5.8.0.tgz#c12f34ba7c3a2de0e9fa0ed0ee32f3e46c5c2c6a" + integrity sha512-lxq0CAnc5kMGIiWW4Mr041VT8IhNM+Pn5T3haO74XZWFulk7wH1Gv64HqE96hT4a7iiNMdOCFEBgaxWuk8ETKQ== + dependencies: + "@ethersproject/bignumber" "^5.8.0" + "@ethersproject/constants" "^5.8.0" + "@ethersproject/logger" "^5.8.0" + "@ethersproject/wallet@5.7.0": version "5.7.0" resolved "https://registry.yarnpkg.com/@ethersproject/wallet/-/wallet-5.7.0.tgz#4e5d0790d96fe21d61d38fb40324e6c7ef350b2d" @@ -937,6 +1000,27 @@ "@ethersproject/transactions" "^5.7.0" "@ethersproject/wordlists" "^5.7.0" +"@ethersproject/wallet@5.8.0": + version "5.8.0" + resolved "https://registry.yarnpkg.com/@ethersproject/wallet/-/wallet-5.8.0.tgz#49c300d10872e6986d953e8310dc33d440da8127" + integrity sha512-G+jnzmgg6UxurVKRKvw27h0kvG75YKXZKdlLYmAHeF32TGUzHkOFd7Zn6QHOTYRFWnfjtSSFjBowKo7vfrXzPA== + dependencies: + "@ethersproject/abstract-provider" "^5.8.0" + "@ethersproject/abstract-signer" "^5.8.0" + "@ethersproject/address" "^5.8.0" + "@ethersproject/bignumber" "^5.8.0" + "@ethersproject/bytes" "^5.8.0" + "@ethersproject/hash" "^5.8.0" + "@ethersproject/hdnode" "^5.8.0" + "@ethersproject/json-wallets" "^5.8.0" + "@ethersproject/keccak256" "^5.8.0" + "@ethersproject/logger" "^5.8.0" + "@ethersproject/properties" "^5.8.0" + "@ethersproject/random" "^5.8.0" + "@ethersproject/signing-key" "^5.8.0" + "@ethersproject/transactions" "^5.8.0" + "@ethersproject/wordlists" "^5.8.0" + "@ethersproject/web@5.7.1": version "5.7.1" resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.7.1.tgz#de1f285b373149bee5928f4eb7bcb87ee5fbb4ae" @@ -948,7 +1032,7 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@ethersproject/web@^5.7.0", "@ethersproject/web@^5.8.0": +"@ethersproject/web@5.8.0", "@ethersproject/web@^5.7.0", "@ethersproject/web@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/web/-/web-5.8.0.tgz#3e54badc0013b7a801463a7008a87988efce8a37" integrity sha512-j7+Ksi/9KfGviws6Qtf9Q7KCqRhpwrYKQPs+JBA/rKVFF/yaWLHJEH3zfVP2plVu+eys0d2DlFmhoQJayFewcw== @@ -970,7 +1054,7 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" -"@ethersproject/wordlists@^5.7.0", "@ethersproject/wordlists@^5.8.0": +"@ethersproject/wordlists@5.8.0", "@ethersproject/wordlists@^5.7.0", "@ethersproject/wordlists@^5.8.0": version "5.8.0" resolved "https://registry.yarnpkg.com/@ethersproject/wordlists/-/wordlists-5.8.0.tgz#7a5654ee8d1bb1f4dbe43f91d217356d650ad821" integrity sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg== @@ -2243,6 +2327,11 @@ dependencies: "@types/yargs-parser" "*" +"@unified-ai-brain/core@file:../../../../tmp/uab/packages/core": + version "0.0.1-pre" + dependencies: + ethers "^5.7.2" + "@vitest/expect@1.6.1": version "1.6.1" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.6.1.tgz#b90c213f587514a99ac0bf84f88cff9042b0f14d" @@ -2957,6 +3046,42 @@ ethers@5.7.2: "@ethersproject/web" "5.7.1" "@ethersproject/wordlists" "5.7.0" +ethers@^5.7.2: + version "5.8.0" + resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.8.0.tgz#97858dc4d4c74afce83ea7562fe9493cedb4d377" + integrity sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg== + dependencies: + "@ethersproject/abi" "5.8.0" + "@ethersproject/abstract-provider" "5.8.0" + "@ethersproject/abstract-signer" "5.8.0" + "@ethersproject/address" "5.8.0" + "@ethersproject/base64" "5.8.0" + "@ethersproject/basex" "5.8.0" + "@ethersproject/bignumber" "5.8.0" + "@ethersproject/bytes" "5.8.0" + "@ethersproject/constants" "5.8.0" + "@ethersproject/contracts" "5.8.0" + "@ethersproject/hash" "5.8.0" + "@ethersproject/hdnode" "5.8.0" + "@ethersproject/json-wallets" "5.8.0" + "@ethersproject/keccak256" "5.8.0" + "@ethersproject/logger" "5.8.0" + "@ethersproject/networks" "5.8.0" + "@ethersproject/pbkdf2" "5.8.0" + "@ethersproject/properties" "5.8.0" + "@ethersproject/providers" "5.8.0" + "@ethersproject/random" "5.8.0" + "@ethersproject/rlp" "5.8.0" + "@ethersproject/sha2" "5.8.0" + "@ethersproject/signing-key" "5.8.0" + "@ethersproject/solidity" "5.8.0" + "@ethersproject/strings" "5.8.0" + "@ethersproject/transactions" "5.8.0" + "@ethersproject/units" "5.8.0" + "@ethersproject/wallet" "5.8.0" + "@ethersproject/web" "5.8.0" + "@ethersproject/wordlists" "5.8.0" + event-iterator@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/event-iterator/-/event-iterator-2.0.0.tgz#10f06740cc1e9fd6bc575f334c2bc1ae9d2dbf62" @@ -4747,6 +4872,11 @@ ws@7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + ws@8.18.3: version "8.18.3" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" From 9931280bc1fb210e9b9125b37bb6ab5c8c8066ea Mon Sep 17 00:00:00 2001 From: ClawDAOBot <259158288+ClawDAOBot@users.noreply.github.com> Date: Fri, 8 May 2026 17:26:04 -0400 Subject: [PATCH 3/3] =?UTF-8?q?HB#610=20heartbeat=20infra:=20deliverable-t?= =?UTF-8?q?ype=20menu=20+=20pop=20agent=20self-metrics=20CLI=20(Hudson=20H?= =?UTF-8?q?B#610=20directive=20=E2=80=94=20signal-detected=20coasting,=20n?= =?UTF-8?q?ot=20prose-detected)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hudson directive: "look more into hermes... review brain infra... see what's causing coasting... improve infra... metrics to reflect on... continual process... don't over-engineer." Two minimal-friction additions, both reversible per RULE #22: 1. .claude/skills/poa-agent-heartbeat/SKILL.md — appended deliverable-type menu (~30 lines) under existing "Pacing" section: - 7-item typed menu: vote / review / task-ship / brain-lesson / vigil-lens-audit / external-research / infra-improvement - Track 2 picks ≥1 from menu, names type explicitly in HB log - Anti-pattern flagged: brain-lesson as rationalization escape-hatch (3+ consecutive brain-lesson-only HBs = monitoring drift wearing typed-deliverable hat) - Healthy mix: ≥1 task-ship/vigil-audit/external-research/infra- improvement per ~3 HBs 2. src/commands/agent/self-metrics.ts (NEW, ~210 LoC) — opt-in read-only CLI: - Parses heartbeat-log.md by ## HB#N headers - Classifies each HB block via regex patterns into deliverable types - Computes: * output_per_hb_last_10 (target ≥2; healthy ≥3) * deliverable_type_mix_last_20 (% HBs per type) * coasting_flag (3+ consecutive HBs without substantive deliverable) * active_arcs (multi-HB threads) * goals_touched_pct (% of goals.md priorities mentioned last 20 HBs) - JSON + human-readable output src/commands/agent/index.ts — registers `pop agent self-metrics`. EMPIRICAL FIRST RUN (vigil state HB#610): - Output/HB last 10: 1.70 (BELOW target 2; metric detected real signal) - brain-lesson 70% (HIGH; near anti-pattern threshold; useful warning) - task-ship 65%, infra-improvement 60%, vigil-lens-audit 35% - coasting_flag: NO (last 3 HBs had substantive output) - active_arcs: 6 detected (Hermes, audit-series, bridge-saga, #441, #513, #509) - goals_touched_pct: 0% (regex bug; goals.md format not matched — documented as v2 fix-up) Anti-over-engineering guards: - BOTH changes are read-only or appendable; neither breaks existing flow - self-metrics is opt-in (run when you want); not auto-injected anywhere - Deferred 3 candidates (multi-HB arc state machine, forced-handoff, sprint-freshness checker) until metrics show which actually drift --- .claude/skills/poa-agent-heartbeat/SKILL.md | 18 ++ src/commands/agent/index.ts | 2 + src/commands/agent/self-metrics.ts | 302 ++++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 src/commands/agent/self-metrics.ts diff --git a/.claude/skills/poa-agent-heartbeat/SKILL.md b/.claude/skills/poa-agent-heartbeat/SKILL.md index 7150144..c45ba22 100644 --- a/.claude/skills/poa-agent-heartbeat/SKILL.md +++ b/.claude/skills/poa-agent-heartbeat/SKILL.md @@ -28,6 +28,24 @@ You are an LLM, not a human. Read/write/grep operations take SECONDS, not minute **Heartbeat-log discipline**: short factual entries — what shipped, what tx, what brain CID. NO multi-paragraph rationalizations. Reflection lives in philosophy.md, NOT every HB log entry. +**Deliverable-type menu (Hudson HB#610 directive — typed Track 2 picks; pick from this menu, name explicitly in HB log)**: + +| Type | Description | Example output | +|------|-------------|----------------| +| `vote` | On-chain governance action | `pop vote cast --proposal N` | +| `review` | Approve/reject submitted task with HB#451 line-by-line discipline | `pop task review --task N --action approve` | +| `task-ship` | Claim + work + submit a task (or any phase of a multi-HB ship) | `pop task claim`, `pop task submit`, commits | +| `brain-lesson` | Substantive insight, peer-poll engagement, observation | `pop brain append-lesson --doc pop.brain.shared` with `--caused-by` chain | +| `vigil-lens-audit` | Edge-case audit on shipped substrate (or peer's static-analysis equivalent) | brain.shared lesson with N scenarios + findings | +| `external-research` | Out-of-org diagnostic / post-mortem / research that validates tools generalize | brain.shared lesson with target + analysis | +| `infra-improvement` | New CLI command, skill, doc, test, schema extension | commit + brain.shared announcement | + +Track 2 picks ≥1 from this menu, names the type explicitly in HB log entry header, ships ≥1 concrete artifact for each pick. At LLM-pace, 2-3 picks per HB is normal; 1 is acceptable when the pick is heavyweight; 0 is the housekeeping-only failure mode (HB#399 + HB#601-#602 vigil instances). + +**Anti-pattern — "brain-lesson" as escape hatch**: brain-lesson is the easiest pick + most subject to rationalization. Watch for: 3+ consecutive HBs with ONLY `brain-lesson` picks → that's monitoring drift wearing a typed-deliverable hat. Healthy mix has at least 1 `task-ship / vigil-lens-audit / external-research / infra-improvement` per ~3 HBs. + +**Self-metrics check** (`pop agent self-metrics --json` once shipped, Hudson HB#610): use it to observe deliverable-type mix + output-per-HB + active arcs. Coast detection becomes signal-detection, not prose-detection. + ## File Reads (lean — read only what you need) **Always read (every HB):** diff --git a/src/commands/agent/index.ts b/src/commands/agent/index.ts index 4ccea51..3facc0b 100644 --- a/src/commands/agent/index.ts +++ b/src/commands/agent/index.ts @@ -12,6 +12,7 @@ import { dailyDigestHandler } from './daily-digest'; import { sessionStartHandler_export } from './session-start'; import { testCoverageHandler } from './test-coverage'; import { driftCheckHandler } from './drift-check'; +import { selfMetricsHandler } from './self-metrics'; import { subscribeHandler, unsubscribeHandler, @@ -25,6 +26,7 @@ export function registerAgentCommands(yargs: Argv) { .command('triage', 'Prioritized action plan for current heartbeat', triageHandler.builder, triageHandler.handler) .command('test-coverage', 'Hygiene signal: list src/lib modules without a matching test/lib *.test.ts file', testCoverageHandler.builder, testCoverageHandler.handler) .command('drift-check', 'Detect plateau-hold drift in heartbeat-log.md (HB#388 protocol tooling)', driftCheckHandler.builder, driftCheckHandler.handler) + .command('self-metrics', 'Output-per-HB + deliverable-type mix + coasting detection (Hudson HB#610 directive; signal-detected coasting)', selfMetricsHandler.builder, selfMetricsHandler.handler) .command('daily-digest', 'Summarize cross-agent activity for operator status checks', dailyDigestHandler.builder, dailyDigestHandler.handler) .command('register', 'Register agent identity on ERC-8004', registerHandler.builder, registerHandler.handler) .command('delegate', 'Set up EIP-7702 delegation for gas sponsorship', delegateHandler.builder, delegateHandler.handler) diff --git a/src/commands/agent/self-metrics.ts b/src/commands/agent/self-metrics.ts new file mode 100644 index 0000000..7c14a89 --- /dev/null +++ b/src/commands/agent/self-metrics.ts @@ -0,0 +1,302 @@ +/** + * pop agent self-metrics — coasting detection + deliverable-type mix + * (Hudson HB#610 directive — make coasting signal-detected, not prose-detected). + * + * Reads: + * - ~/.pop-agent/brain/Memory/heartbeat-log.md (last N HB entries by ## HB#N header) + * - pop.brain.shared lessons authored by current wallet (last M timestamps) + * + * Computes: + * - output_per_hb_last_10 — substantive artifacts/HB (target ≥2; healthy ≥3) + * - deliverable_type_mix_last_20 — % from each menu item (vote/review/task-ship/ + * brain-lesson/vigil-lens-audit/external-research/infra-improvement) + * - active_arcs — multi-HB threads detected via causedBy chains + recurring titles + * - coasting_flag — 3+ consecutive HBs without task-ship/vigil-audit/external- + * research/infra-improvement + * - goals_touched_pct — % of goals.md priorities mentioned in last 20 HBs + * + * Output: + * default: human-readable table; --json for tooling + * + * Read-only; never writes. Opt-in (run when needed) per anti-bloat discipline. + */ + +import type { Argv, ArgumentsCamelCase } from 'yargs'; +import * as fs from 'fs'; +import * as path from 'path'; +import { homedir } from 'os'; +import { ethers } from 'ethers'; +import * as output from '../../lib/output'; + +interface SelfMetricsArgs { + 'private-key'?: string; + 'last-hbs'?: number; +} + +interface HbBlock { + hbNumber: number; + body: string; + // Detected deliverable types from the body + types: Set; + // Heuristic: tx hashes / commit SHAs / brain CIDs found + artifactCount: number; +} + +type DeliverableType = + | 'vote' + | 'review' + | 'task-ship' + | 'brain-lesson' + | 'vigil-lens-audit' + | 'external-research' + | 'infra-improvement'; + +const TYPE_PATTERNS: Array<{ type: DeliverableType; patterns: RegExp[] }> = [ + { type: 'vote', patterns: [/pop vote cast|tx \w+ vote|cast YES|cast NO|vote tx/i] }, + { type: 'review', patterns: [/pop task review|approved #?\d+|rejected #?\d+|review tx|task #\d+ approved/i] }, + { + type: 'task-ship', + patterns: [ + /pop task (claim|submit)|task #\d+ shipped|task #\d+ submitted|task #\d+ claimed|commit [0-9a-f]{6,}/i, + ], + }, + { + type: 'brain-lesson', + patterns: [/pop brain append-lesson|brain\.shared lesson|brain lesson published|head: bafkr/i], + }, + { + type: 'vigil-lens-audit', + patterns: [/vigil-lens|edge case audit|audit pass on #\d+|N scenarios.*SOUND|findings? .*vigil/i], + }, + { + type: 'external-research', + patterns: [ + /post-mortem|external research|cross-org|bridge.*saga|comparative.*audit|out-of-org/i, + ], + }, + { + type: 'infra-improvement', + patterns: [ + /new CLI|skill .*\.md|src\/commands?\/|src\/lib\//, + /\.test\.ts|\.test\.mjs|test fixture|test scenarios|new test file/i, + /CLAUDE\.md update|SKILL\.md update|infra/i, + ], + }, +]; + +function classifyHbBlock(body: string): Set { + const types = new Set(); + for (const { type, patterns } of TYPE_PATTERNS) { + if (patterns.some((p) => p.test(body))) { + types.add(type); + } + } + return types; +} + +function countArtifacts(body: string): number { + let count = 0; + // tx hashes + count += (body.match(/0x[0-9a-f]{64}/g) || []).length; + // commit SHAs (>=7 hex chars after "commit " keyword) + count += (body.match(/\bcommit\s+[0-9a-f]{7,40}\b/gi) || []).length; + // brain CIDs + count += (body.match(/bafkr[a-z2-7]{50,}/g) || []).length; + // task IDs explicitly mentioned in shipping context (rough) + count += (body.match(/#\d{3,}\s+(SHIPPED|APPROVED|CLAIMED|SUBMITTED)/gi) || []).length; + return count; +} + +/** Parse heartbeat-log.md into HB blocks via "## HB#N" headers. */ +function parseHbBlocks(logContent: string, maxHbs: number): HbBlock[] { + const lines = logContent.split('\n'); + const blocks: HbBlock[] = []; + let currentHb: { number: number; lines: string[] } | null = null; + + for (const line of lines) { + const m = line.match(/^##\s+HB#(\d+)\b/); + if (m) { + if (currentHb) { + const body = currentHb.lines.join('\n'); + blocks.push({ + hbNumber: currentHb.number, + body, + types: classifyHbBlock(body), + artifactCount: countArtifacts(body), + }); + } + currentHb = { number: parseInt(m[1], 10), lines: [line] }; + } else if (currentHb) { + currentHb.lines.push(line); + } + } + if (currentHb) { + const body = currentHb.lines.join('\n'); + blocks.push({ + hbNumber: currentHb.number, + body, + types: classifyHbBlock(body), + artifactCount: countArtifacts(body), + }); + } + return blocks.slice(-maxHbs); +} + +interface Metrics { + hbWindow: number; + hbsAnalyzed: number; + outputPerHbLast10: number; + outputPerHbLastWindow: number; + deliverableTypeMix: Record; + coastingFlag: boolean; + coastingDetail: string; + activeArcs: string[]; + goalsTouchedPct: number; + goalsTouchedDetail: { matched: string[]; total: number }; +} + +const SUBSTANTIVE_TYPES: DeliverableType[] = [ + 'task-ship', + 'vigil-lens-audit', + 'external-research', + 'infra-improvement', +]; + +function computeMetrics(blocks: HbBlock[], goalsContent: string): Metrics { + const last10 = blocks.slice(-10); + const last20 = blocks.slice(-20); + + const outputPerHbLast10 = + last10.reduce((sum, b) => sum + b.artifactCount, 0) / Math.max(last10.length, 1); + const outputPerHbLastWindow = + blocks.reduce((sum, b) => sum + b.artifactCount, 0) / Math.max(blocks.length, 1); + + // Deliverable-type mix: % of HBs (in window) that produced each type + const allTypes: DeliverableType[] = [ + 'vote', + 'review', + 'task-ship', + 'brain-lesson', + 'vigil-lens-audit', + 'external-research', + 'infra-improvement', + ]; + const mix: Record = {} as any; + for (const t of allTypes) { + const count = last20.filter((b) => b.types.has(t)).length; + mix[t] = last20.length === 0 ? 0 : Math.round((count / last20.length) * 100); + } + + // Coasting: last 3 HBs without any SUBSTANTIVE type + const last3 = blocks.slice(-3); + const last3Substantive = last3.map((b) => + SUBSTANTIVE_TYPES.some((t) => b.types.has(t)), + ); + const coastingFlag = last3.length >= 3 && last3Substantive.every((s) => !s); + const coastingDetail = coastingFlag + ? `last 3 HBs (#${last3.map((b) => b.hbNumber).join(', #')}) had NO task-ship/vigil-audit/external-research/infra-improvement output — only brain-lesson/review/vote.` + : `last 3 HBs included substantive deliverables: ${last3 + .map((b, i) => `HB#${b.hbNumber}=${last3Substantive[i] ? 'SUBSTANTIVE' : 'soft'}`) + .join(', ')}`; + + // Active arcs: detect repeated arc-like keywords in last 10 HB titles + const arcMarkers = ['Hermes', 'audit series', 'bridge-saga', '#441', '#513', '#509']; + const activeArcs = arcMarkers.filter((m) => + last10.some((b) => b.body.toLowerCase().includes(m.toLowerCase())), + ); + + // Goals advancement: count distinct numbered priority lines from goals.md mentioned in last 20 HBs + const goalsLines = goalsContent + .split('\n') + .filter((l) => /^\s*\d+\.\s+\*\*/.test(l) || /priority #\d+/i.test(l)); + const goalsKeywords: string[] = []; + for (const line of goalsLines) { + const m = line.match(/\*\*([^*]{8,40})\*\*/); + if (m) goalsKeywords.push(m[1].trim().slice(0, 40)); + } + const matchedGoals: string[] = []; + const lastBody20 = last20.map((b) => b.body).join('\n'); + for (const kw of goalsKeywords) { + if (lastBody20.toLowerCase().includes(kw.toLowerCase())) { + matchedGoals.push(kw); + } + } + const goalsTouchedPct = + goalsKeywords.length === 0 + ? 0 + : Math.round((matchedGoals.length / goalsKeywords.length) * 100); + + return { + hbWindow: blocks.length, + hbsAnalyzed: blocks.length, + outputPerHbLast10, + outputPerHbLastWindow, + deliverableTypeMix: mix, + coastingFlag, + coastingDetail, + activeArcs, + goalsTouchedPct, + goalsTouchedDetail: { matched: matchedGoals, total: goalsKeywords.length }, + }; +} + +export const selfMetricsHandler = { + builder: (yargs: Argv) => + yargs.option('last-hbs', { + type: 'number', + default: 30, + describe: 'Window of recent HB blocks to analyze (default 30)', + }), + + handler: async (argv: ArgumentsCamelCase) => { + const home = homedir(); + const logPath = path.join(home, '.pop-agent', 'brain', 'Memory', 'heartbeat-log.md'); + const goalsPath = path.join(home, '.pop-agent', 'brain', 'Identity', 'goals.md'); + + if (!fs.existsSync(logPath)) { + output.error(`heartbeat-log not found: ${logPath}`); + process.exit(1); + } + + const logContent = fs.readFileSync(logPath, 'utf8'); + const goalsContent = fs.existsSync(goalsPath) ? fs.readFileSync(goalsPath, 'utf8') : ''; + + const window = argv['last-hbs'] ?? 30; + const blocks = parseHbBlocks(logContent, window); + const metrics = computeMetrics(blocks, goalsContent); + + if (output.isJsonMode()) { + output.json(metrics); + return; + } + + console.log(''); + console.log(' Vigil Self-Metrics'); + console.log(' ══════════════════'); + console.log(` Window: last ${metrics.hbsAnalyzed} HB blocks`); + console.log(''); + console.log(` Output/HB (last 10): ${metrics.outputPerHbLast10.toFixed(2)} (target ≥2 / healthy ≥3)`); + console.log(` Output/HB (last ${metrics.hbsAnalyzed}): ${metrics.outputPerHbLastWindow.toFixed(2)}`); + console.log(''); + console.log(` Deliverable-type mix (% HBs producing each type, last 20):`); + for (const [type, pct] of Object.entries(metrics.deliverableTypeMix).sort( + (a, b) => b[1] - a[1], + )) { + const bar = '█'.repeat(Math.floor(pct / 5)); + console.log(` ${type.padEnd(20)} ${String(pct).padStart(3)}% ${bar}`); + } + console.log(''); + if (metrics.coastingFlag) { + console.log(` \x1b[31m⚠ COASTING FLAG\x1b[0m: ${metrics.coastingDetail}`); + } else { + console.log(` ✓ No coasting flag: ${metrics.coastingDetail}`); + } + console.log(''); + console.log(` Active arcs: ${metrics.activeArcs.join(', ') || '(none detected)'}`); + console.log(` Goals touched: ${metrics.goalsTouchedPct}% (${metrics.goalsTouchedDetail.matched.length}/${metrics.goalsTouchedDetail.total})`); + if (metrics.goalsTouchedDetail.matched.length > 0) { + console.log(` matched: ${metrics.goalsTouchedDetail.matched.slice(0, 5).join('; ')}`); + } + console.log(''); + }, +}; \ No newline at end of file