Skip to content

Commit 99fbc9d

Browse files
ucsandmanclaude
andcommitted
feat(livingcode): collect signal types + stabilize state-report ordering
Extends the livingcode shape with a fifth auto-collected vocabulary: the signal `type` strings that flow through fireWebhooksForOrg and deliverNativeNotifications. Detection runs in two modes, applied per file: 1. Files that call the delivery pipeline directly (cost-alerts.js, health-change-alerts.js) — every `type:` literal is kept. 2. Other files contribute only `type:` literals paired with a red/amber `severity:` in close proximity. This catches signal-builder modules (signals.js returning arrays the cron later delivers) while rejecting demoFixtures.js-style files that contain both real signal fixtures AND unrelated `type:` fields — previously leaked `concept` into the skill. Skill now carries a "Signal Types" section listing all 9 live types (autonomy_spike, branch_stale, cost_exceeded, green_insufficient, integration_health_changed, integration_mismatch, mcp_degraded, stale_action, test). Also fixes a pre-existing Windows-NTFS flake in livingcode/state.py::read_latest_state_report. The function sorted by `st_mtime`, which on NTFS has ~15ms resolution — back-to-back writes tied and the "latest" report was non-deterministic. Filenames now carry a monotonic process-local counter and the read sorts lexically, so insertion order wins regardless of filesystem timestamp granularity. Full livingcode test suite now green for the first time (172 passing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0a6488e commit 99fbc9d

11 files changed

Lines changed: 301 additions & 14 deletions

File tree

app/lib/doctor/generated/last-snapshot.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"timestamp": "sha1:0a45543dd6eea2cca1b05176f7075258617c9ed5",
2+
"timestamp": "sha1:a1474e482800bd24d85993b7e4ff9d69865042f4",
33
"routes": [
44
{
55
"path": "/api/_archive/agent-schedules",
@@ -4258,5 +4258,16 @@
42584258
"SLACK_WEBHOOK_URL"
42594259
]
42604260
}
4261+
],
4262+
"signal_types": [
4263+
"autonomy_spike",
4264+
"branch_stale",
4265+
"cost_exceeded",
4266+
"green_insufficient",
4267+
"integration_health_changed",
4268+
"integration_mismatch",
4269+
"mcp_degraded",
4270+
"stale_action",
4271+
"test"
42614272
]
42624273
}

app/lib/doctor/generated/shape.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"timestamp": "sha1:0a45543dd6eea2cca1b05176f7075258617c9ed5",
2+
"timestamp": "sha1:a1474e482800bd24d85993b7e4ff9d69865042f4",
33
"routes": [
44
{
55
"path": "/api/_archive/agent-schedules",
@@ -4258,5 +4258,16 @@
42584258
"SLACK_WEBHOOK_URL"
42594259
]
42604260
}
4261+
],
4262+
"signal_types": [
4263+
"autonomy_spike",
4264+
"branch_stale",
4265+
"cost_exceeded",
4266+
"green_insufficient",
4267+
"integration_health_changed",
4268+
"integration_mismatch",
4269+
"mcp_degraded",
4270+
"stale_action",
4271+
"test"
42614272
]
42624273
}

livingcode/collectors/signals.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Signal-type collector.
2+
3+
Signals are the vocabulary webhooks subscribe to and native adapters render.
4+
Unlike EVENTS and VALID_SETTING_KEYS (which live in one file each), signal
5+
types are authored inline wherever something decides to alert — `signals.js`,
6+
`cost-alerts.js`, `health-change-alerts.js`, and future alerters.
7+
8+
Rather than ask authors to register into a central list, we detect signal-
9+
emitting files by the call sites they use (`fireWebhooksForOrg(` or
10+
`deliverNativeNotifications(`) and harvest every `type: '<snake_case>'`
11+
literal from those files. That keeps the collector drift-proof: any new
12+
alerter wired through the canonical delivery pipeline is discovered
13+
automatically, while unrelated `type:` strings in other files are ignored.
14+
"""
15+
import os
16+
import re
17+
18+
from livingcode.types import SettingKeyInfo # noqa: F401 (kept for parity)
19+
20+
SCAN_ROOTS = ["app/lib", "app/api"]
21+
SKIP_DIRS = {"node_modules", ".next", "__pycache__", "_archive"}
22+
SCAN_EXTENSIONS = {".js", ".mjs", ".ts", ".tsx"}
23+
24+
_DELIVERY_CALL_RE = re.compile(
25+
r"\b(?:fireWebhooksForOrg|deliverNativeNotifications)\s*\("
26+
)
27+
_TYPE_RE = re.compile(r"type:\s*['\"]([a-z][a-z0-9_]*)['\"]")
28+
# Signal-shape probe: type + severity ('red' or 'amber') within a small window.
29+
# `severity: 'red'|'amber'` is unique to signal objects in this codebase,
30+
# which lets us detect files that BUILD signals without delivering them
31+
# (e.g. signals.js, which returns signals for the cron to later deliver).
32+
_SIGNAL_SHAPE_WINDOW = 200 # characters — roughly 6-8 lines
33+
_SEVERITY_LITERAL_RE = re.compile(r"severity:\s*['\"](?:red|amber)['\"]")
34+
35+
36+
def _signal_types_in_file(content: str, scrape_all: bool) -> set[str]:
37+
"""Extract signal `type:` names from one file.
38+
39+
If `scrape_all` is True (the file calls the delivery pipeline directly),
40+
every `type:` literal is treated as a signal — the delivery call site
41+
is strong evidence that this file authors signals.
42+
43+
Otherwise, only `type:` literals that have a red/amber `severity:`
44+
literal within a short proximity window are kept. This is the
45+
signal-shape heuristic: it catches authored-elsewhere signals
46+
(e.g. signals.js returning arrays the cron later delivers) while
47+
rejecting unrelated `type:` literals that happen to share a file
48+
with fixture/demo signals.
49+
"""
50+
out: set[str] = set()
51+
for m in _TYPE_RE.finditer(content):
52+
if scrape_all:
53+
out.add(m.group(1))
54+
continue
55+
start = max(0, m.start() - _SIGNAL_SHAPE_WINDOW)
56+
end = min(len(content), m.end() + _SIGNAL_SHAPE_WINDOW)
57+
if _SEVERITY_LITERAL_RE.search(content[start:end]):
58+
out.add(m.group(1))
59+
return out
60+
61+
62+
def collect_signal_types(repo_path: str) -> list[str]:
63+
"""Return sorted unique signal `type` strings.
64+
65+
Two detection modes applied per file:
66+
1. Files calling `fireWebhooksForOrg` / `deliverNativeNotifications`
67+
are assumed to be signal call sites — every `type:` is kept.
68+
2. Other files contribute only `type:` literals that pair with a
69+
red/amber `severity:` in close proximity. Catches signal-builder
70+
modules (signals.js) without pulling in demo fixtures or unrelated
71+
`type:` fields elsewhere in the same file.
72+
"""
73+
types: set[str] = set()
74+
75+
for rel_root in SCAN_ROOTS:
76+
root = os.path.join(repo_path, rel_root)
77+
if not os.path.isdir(root):
78+
continue
79+
for dirpath, dirnames, filenames in os.walk(root):
80+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
81+
for fname in filenames:
82+
ext = os.path.splitext(fname)[1]
83+
if ext not in SCAN_EXTENSIONS:
84+
continue
85+
fpath = os.path.join(dirpath, fname)
86+
try:
87+
with open(fpath, encoding="utf-8", errors="ignore") as f:
88+
content = f.read()
89+
except OSError:
90+
continue
91+
delivers = bool(_DELIVERY_CALL_RE.search(content))
92+
types |= _signal_types_in_file(content, scrape_all=delivers)
93+
94+
return sorted(types)

livingcode/emitters/skill.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,20 @@ def emit_skill(shape: ShapeModel) -> str:
155155
lines.append(f"| `{e.constant}` | `{e.event}` |")
156156
lines.append("")
157157

158+
# Signal vocabulary — types emitted by alerters, consumed by webhooks + adapters
159+
if getattr(shape, "signal_types", []):
160+
lines += ["## Signal Types", ""]
161+
lines += [
162+
"These are the `type` strings emitted through `fireWebhooksForOrg` "
163+
"and `deliverNativeNotifications`. Webhooks can subscribe to any "
164+
"subset by putting the type in their `events: [...]` array (or "
165+
"use `['all']` for everything).",
166+
"",
167+
]
168+
for t in shape.signal_types:
169+
lines.append(f"- `{t}`")
170+
lines.append("")
171+
158172
# Native notification adapters
159173
if getattr(shape, "adapters", []):
160174
lines += ["## Native Notification Adapters", ""]

livingcode/shape.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from livingcode.collectors.routes import collect_routes
1515
from livingcode.collectors.schema import collect_schema
1616
from livingcode.collectors.settings import collect_setting_keys
17+
from livingcode.collectors.signals import collect_signal_types
1718
from livingcode.types import ShapeModel
1819

1920

@@ -27,4 +28,5 @@ def build_shape(repo_path: str) -> ShapeModel:
2728
setting_keys=collect_setting_keys(repo_path),
2829
events=collect_events(repo_path),
2930
adapters=collect_adapters(repo_path),
31+
signal_types=collect_signal_types(repo_path),
3032
)

livingcode/state.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Filesystem operations for .organism/ state directory."""
22
import json
3+
import threading
34
from datetime import datetime, timezone
45
from pathlib import Path
56
from typing import Any
@@ -8,6 +9,16 @@
89
ORGANISM_DIR = ".organism"
910
SUBDIRS = ["state-reports", "heartbeats", "backlog", "cycle-history", "shape-snapshots"]
1011

12+
# Monotonic counter baked into every state-report filename. Previously,
13+
# filenames used microsecond-precision timestamps and resolved collisions
14+
# with a `-N` suffix — but `read_latest_state_report` sorted by `st_mtime`,
15+
# which on Windows NTFS has ~15ms resolution. Two back-to-back writes
16+
# therefore tied on mtime and the "most recent" file was non-deterministic.
17+
# An always-present zero-padded counter gives a lexical sort order that
18+
# matches insertion order regardless of filesystem timestamp granularity.
19+
_counter_lock = threading.Lock()
20+
_counter = 0
21+
1122

1223
def ensure_organism_dir(repo_path: str) -> Path:
1324
"""Create .organism/ directory structure if it doesn't exist."""
@@ -19,32 +30,42 @@ def ensure_organism_dir(repo_path: str) -> Path:
1930

2031

2132
def _safe_timestamp() -> str:
22-
"""Generate a Windows-safe timestamp string (no colons, microsecond precision)."""
33+
"""Generate a Windows-safe, monotonic filename stem.
34+
35+
Format: `YYYY-MM-DDTHH-MM-SS-ffffff-NNNNNN` where NNNNNN is a process-local
36+
counter. Lexical sort of two stems always matches the order they were
37+
generated — no mtime dependency.
38+
"""
39+
global _counter
2340
now = datetime.now(timezone.utc)
24-
return now.strftime("%Y-%m-%dT%H-%M-%S-%f")
41+
with _counter_lock:
42+
_counter += 1
43+
seq = _counter
44+
return now.strftime("%Y-%m-%dT%H-%M-%S-%f") + f"-{seq:06d}"
2545

2646

2747
def write_state_report(repo_path: str, data: dict[str, Any]) -> str:
2848
"""Write a state report JSON file. Returns the file path."""
2949
reports_dir = Path(repo_path) / ORGANISM_DIR / "state-reports"
3050
reports_dir.mkdir(parents=True, exist_ok=True)
31-
base = _safe_timestamp()
32-
filepath = reports_dir / f"{base}.json"
33-
counter = 0
34-
while filepath.exists():
35-
counter += 1
36-
filepath = reports_dir / f"{base}-{counter}.json"
51+
filepath = reports_dir / f"{_safe_timestamp()}.json"
3752
with open(filepath, "w") as f:
3853
json.dump(data, f, indent=2, default=str)
3954
return str(filepath)
4055

4156

4257
def read_latest_state_report(repo_path: str) -> dict[str, Any] | None:
43-
"""Read the most recent state report. Returns None if none exist."""
58+
"""Read the most recent state report. Returns None if none exist.
59+
60+
Sorts by filename (lexical) — `_safe_timestamp()` guarantees monotonicity,
61+
so this ordering is stable on every filesystem. Do NOT switch back to
62+
mtime: NTFS coarse-granularity mtimes produced false ties that left the
63+
'latest' report ambiguous.
64+
"""
4465
reports_dir = Path(repo_path) / ORGANISM_DIR / "state-reports"
4566
if not reports_dir.exists():
4667
return None
47-
files = sorted(reports_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
68+
files = sorted(reports_dir.glob("*.json"))
4869
if not files:
4970
return None
5071
with open(files[-1]) as f:

livingcode/tests/test_new_collectors.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,120 @@ def test_missing_dir_returns_empty(self):
149149
self.assertEqual(collect_adapters("/does/not/exist"), [])
150150

151151

152+
class TestSignalsCollector(unittest.TestCase):
153+
"""The signals collector scans files that CALL the delivery pipeline
154+
(fireWebhooksForOrg / deliverNativeNotifications) and harvests
155+
`type: '<name>'` literals. Files that don't import either function must
156+
be ignored — otherwise status enums, tool_type strings, etc. would
157+
leak in as false positives."""
158+
159+
def setUp(self):
160+
self.tmpdir = tempfile.mkdtemp()
161+
self.lib = os.path.join(self.tmpdir, "app", "lib")
162+
os.makedirs(self.lib, exist_ok=True)
163+
164+
def _write(self, rel_path, body):
165+
full = os.path.join(self.tmpdir, rel_path)
166+
os.makedirs(os.path.dirname(full), exist_ok=True)
167+
with open(full, "w", encoding="utf-8") as f:
168+
f.write(body)
169+
170+
def test_collects_types_from_delivery_call_sites(self):
171+
self._write("app/lib/cost-alerts.js", """
172+
import { fireWebhooksForOrg } from './webhooks.js';
173+
export function buildSignal() {
174+
return { type: 'cost_exceeded', severity: 'red' };
175+
}
176+
export async function fire(sql, org) {
177+
await fireWebhooksForOrg(org, [{ type: 'cost_exceeded' }], sql);
178+
}
179+
""")
180+
self._write("app/lib/signals.js", """
181+
import { deliverNativeNotifications } from './notification-adapters/index.js';
182+
export function agentMismatch() {
183+
return { type: 'integration_mismatch', severity: 'amber' };
184+
}
185+
export async function flushSignals(sql, org, signals, settings) {
186+
await deliverNativeNotifications(org, signals, settings, sql);
187+
}
188+
""")
189+
from livingcode.collectors.signals import collect_signal_types
190+
types = collect_signal_types(self.tmpdir)
191+
self.assertEqual(types, ['cost_exceeded', 'integration_mismatch'])
192+
193+
def test_ignores_files_that_dont_call_delivery(self):
194+
# Looks like a signal but nothing in this file calls the pipeline,
195+
# so the collector must leave its `type:` alone.
196+
self._write("app/lib/unrelated.js", """
197+
export const TOOL_TYPES = [
198+
{ type: 'bash' },
199+
{ type: 'edit' },
200+
];
201+
""")
202+
from livingcode.collectors.signals import collect_signal_types
203+
self.assertEqual(collect_signal_types(self.tmpdir), [])
204+
205+
def test_catches_signal_builders_that_dont_deliver(self):
206+
"""signals.js returns signal objects for a caller to deliver later —
207+
the collector must catch those via the signal-shape heuristic
208+
(type + red/amber severity in close proximity)."""
209+
self._write("app/lib/signals.js", """
210+
export function collectSignals() {
211+
return [
212+
{ type: 'integration_mismatch', severity: 'red', label: 'Bad cred' },
213+
{ type: 'stale_running_action', severity: 'amber', label: 'Stuck' },
214+
];
215+
}
216+
""")
217+
from livingcode.collectors.signals import collect_signal_types
218+
types = collect_signal_types(self.tmpdir)
219+
self.assertEqual(types, ['integration_mismatch', 'stale_running_action'])
220+
221+
def test_rejects_unrelated_types_in_signal_builder_files(self):
222+
"""A file like demoFixtures.js can contain REAL signal fixtures
223+
(type+severity) alongside unrelated `type:` fields (memory entities,
224+
tool types). Only the signal-shaped ones should survive — if we
225+
qualify once and then scrape every `type:` in the file, demo entity
226+
`type:` values leak into the skill."""
227+
self._write("app/lib/demo/demoFixtures.js", """
228+
// Unrelated memory entity — has `type:` but no severity nearby.
229+
export const MEMORY = {
230+
entities: [
231+
{ name: 'Concept A', type: 'concept', mentions: 32 },
232+
{ name: 'Concept B', type: 'concept', mentions: 28 },
233+
],
234+
};
235+
// Lots of padding so the proximity window doesn't reach up to the memory block.
236+
// .........................................................................
237+
// .........................................................................
238+
// .........................................................................
239+
// .........................................................................
240+
// Actual signal fixture lives way down here.
241+
export const SIGNAL_FIXTURES = [
242+
{ severity: 'red', type: 'autonomy_spike', agent_id: 'x' },
243+
];
244+
""")
245+
from livingcode.collectors.signals import collect_signal_types
246+
types = collect_signal_types(self.tmpdir)
247+
# The real signal survives; the unrelated `concept` entities don't.
248+
self.assertIn('autonomy_spike', types)
249+
self.assertNotIn('concept', types)
250+
251+
def test_dedupes_across_files(self):
252+
self._write("app/lib/a.js", """
253+
import { fireWebhooksForOrg } from './webhooks.js';
254+
const s = { type: 'shared_type', severity: 'red' };
255+
fireWebhooksForOrg('org', [s], {});
256+
""")
257+
self._write("app/lib/b.js", """
258+
import { deliverNativeNotifications } from './notification-adapters/index.js';
259+
const s = { type: 'shared_type', severity: 'amber' };
260+
deliverNativeNotifications('org', [s], [], {});
261+
""")
262+
from livingcode.collectors.signals import collect_signal_types
263+
self.assertEqual(collect_signal_types(self.tmpdir), ['shared_type'])
264+
265+
152266
class TestSkillEmitterNewSections(unittest.TestCase):
153267
"""Verify the emitter renders the new sections when the shape carries them."""
154268

@@ -175,9 +289,14 @@ def test_renders_all_three_new_sections(self):
175289
adapters=[
176290
AdapterInfo(name='slack', required_keys=['SLACK_WEBHOOK_URL']),
177291
],
292+
signal_types=['cost_exceeded', 'integration_health_changed'],
178293
)
179294
out = emit_skill(shape)
180295

296+
self.assertIn('## Signal Types', out)
297+
self.assertIn('`cost_exceeded`', out)
298+
self.assertIn('`integration_health_changed`', out)
299+
181300
self.assertIn('## Configuration Knobs', out)
182301
self.assertIn('### AI Providers', out)
183302
self.assertIn('### Cost alerts', out)

livingcode/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ class ShapeModel:
238238
setting_keys: list[SettingKeyInfo] = field(default_factory=list)
239239
events: list[EventInfo] = field(default_factory=list)
240240
adapters: list[AdapterInfo] = field(default_factory=list)
241+
signal_types: list[str] = field(default_factory=list)
241242

242243

243244
@dataclass
180 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)