attesso 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,10 +22,10 @@ are appended, and writes mode and path changes to the same files the hook reads:
22
22
 
23
23
  ```sh
24
24
  npx attesso ui
25
- npx attesso ui --port 0 --no-open
25
+ npx attesso ui --no-open
26
26
  ```
27
27
 
28
- The server binds `127.0.0.1`, mints a per-process token, and serves the page from
28
+ The server binds `127.0.0.1:8197`, mints a per-process token, and serves the page from
29
29
  the installed package. It has no account, no hosted data, and no dependency on a
30
30
  snapshot file. `Ctrl-C` stops it.
31
31
 
@@ -45,6 +45,21 @@ npx attesso sync
45
45
  npx attesso sync --watch
46
46
  ```
47
47
 
48
+ ## Hosted policy
49
+
50
+ A hosted workspace can publish its portable rule set from the dashboard. The
51
+ local engine fetches the signed bundle, verifies it against Attesso's published
52
+ JWKS and only then writes `.attesso/rules.json`; path scope stays local.
53
+
54
+ ```sh
55
+ export ATTESSO_SYNC_URL=https://api.attesso.com
56
+ export ATTESSO_API_KEY=att_live_...
57
+ npx attesso policy pull --apply
58
+ ```
59
+
60
+ Without `--apply` the command verifies and previews the version and digest. A
61
+ tampered signature, an unknown key or an invalid rule set writes nothing.
62
+
48
63
  The API key must belong to an entitled live workspace. A retry after a failed
49
64
  request is safe: the server deduplicates by device and sequence, and the local
50
65
  cursor does not move until the batch is accepted. `--watch` repeats every five
package/bin/attesso.mjs CHANGED
@@ -30,6 +30,7 @@ import { CAPABILITIES, TRUST_LEVELS, validateRules } from './../src/rules.mjs';
30
30
  import { validatePathsConfig } from './../src/fs-paths.mjs';
31
31
  import { startUi } from './../src/ui/server.mjs';
32
32
  import { sync, watchSync } from './../src/sync.mjs';
33
+ import { pullPolicy } from './../src/remote-policy.mjs';
33
34
 
34
35
  const argv = process.argv.slice(2);
35
36
  const dirAt = argv.indexOf('--dir');
@@ -71,12 +72,16 @@ const USAGE = `attesso: a local, signed ledger of what your agents attempted
71
72
  attesso hook [--observe] harness hook: one tool call on stdin, native refusal on stdout
72
73
  attesso setup [--write] [--enforce] [--dry-run]
73
74
  detect a harness, wire the hook, write the agent block
74
- attesso connect [--write] [--mode observe|enforce|off] [--json]
75
+ attesso connect [--write] [--mode observe|enforce|off] [--harness KEY] [--json]
75
76
  self-service: init, mode, hook and AGENTS.md
76
77
  attesso status [--json] connection, mode and wired harnesses
77
78
  attesso mode [observe|enforce|off] read or set the active hook mode
78
79
  attesso paths [--json|--set FILE] read or replace the filesystem scope
79
80
  attesso ui [--port N] [--no-open] local control surface for the signed ledger
81
+ (default 127.0.0.1:8197)
82
+ attesso policy pull [--url URL] [--apply] [--json]
83
+ verify the hosted rule bundle and optionally
84
+ write rules.json (key from ATTESSO_API_KEY)
80
85
  attesso sync [--url URL] [--limit N] [--json] [--dry-run] [--watch]
81
86
  send the next decisions to the hosted mirror
82
87
  --watch keeps it current every 5 seconds
@@ -625,6 +630,34 @@ switch (command) {
625
630
  }
626
631
  break;
627
632
  }
633
+ case 'policy': {
634
+ try {
635
+ const options = flags(rest);
636
+ const subcommand = rest.find((value) => !value.startsWith('--'));
637
+ if (subcommand !== 'pull') throw new Error('policy takes pull');
638
+ requireLedger();
639
+ const url = typeof options.url === 'string' ? options.url : process.env.ATTESSO_SYNC_URL;
640
+ const key = typeof options.key === 'string' ? options.key : process.env.ATTESSO_API_KEY;
641
+ const result = await pullPolicy({
642
+ dir: DIR,
643
+ url,
644
+ key,
645
+ apply: rest.includes('--apply'),
646
+ });
647
+ if (options.json) {
648
+ console.log(JSON.stringify(result, null, 2));
649
+ } else {
650
+ console.log(`policy: v${result.version} verified with ${result.kid}`);
651
+ console.log(`digest: ${result.digest}`);
652
+ if (result.applied) console.log(`rules: ${result.changed ? 'written' : 'already current'} (${paths(DIR).rules})`);
653
+ else console.log('preview only; re-run with --apply to write rules.json');
654
+ }
655
+ } catch (error) {
656
+ console.error(`policy: ${error.message}`);
657
+ process.exit(2);
658
+ }
659
+ break;
660
+ }
628
661
  case 'sync': {
629
662
  try {
630
663
  const options = flags(rest);
@@ -665,11 +698,12 @@ switch (command) {
665
698
  case 'ui': {
666
699
  try {
667
700
  const options = flags(rest);
668
- const requested = options.port === undefined ? 0 : Number(options.port);
701
+ const requested = options.port === undefined ? 8197 : Number(options.port);
669
702
  if (!Number.isInteger(requested) || requested < 0 || requested > 65535) {
670
703
  console.error('ui: --port must be an integer from 0 to 65535');
671
704
  process.exit(2);
672
705
  }
706
+ requireLedger();
673
707
  const ui = await startUi({
674
708
  dir: DIR,
675
709
  cwd: process.cwd(),
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "attesso",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Bounds: decide an AI agent's next tool call before it runs, with a signed record. Free and unlimited locally, $0.00085 per synced decision.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
8
- "attesso": "./bin/attesso.mjs",
9
- "attesso-mcp": "./bin/attesso-mcp.mjs",
10
- "attesso-control-mcp": "./bin/attesso-control-mcp.mjs"
8
+ "attesso": "bin/attesso.mjs",
9
+ "attesso-mcp": "bin/attesso-mcp.mjs",
10
+ "attesso-control-mcp": "bin/attesso-control-mcp.mjs"
11
11
  },
12
12
  "files": [
13
13
  "bin",
package/src/connect.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // harness hook, and report a machine-readable result on stdout.
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
- import { dirname, join } from 'node:path';
6
+ import { dirname, join, resolve } from 'node:path';
7
7
  import { detectHarnesses, writeAgentContext } from './setup.mjs';
8
8
  import { wireHarness } from './install.mjs';
9
9
  import { init, isInitialised, paths } from './store.mjs';
@@ -57,36 +57,43 @@ function legacyModeDirs(home) {
57
57
  }
58
58
  }
59
59
 
60
- function hasAttessoHook(path) {
61
- if (!existsSync(path)) return false;
62
- try {
63
- const text = readFileSync(path, 'utf8');
64
- return /attesso[^\n"]*hook/.test(text);
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
- function wiredHarnesses(harnesses) {
60
+ function wiredHarnesses(harnesses, { dir, cwd }) {
61
+ const ledger = resolve(dir);
71
62
  return harnesses.map((harness) => {
72
63
  const candidate = harness.key === 'cline' ? join(harness.path, 'PreToolUse') : harness.path;
73
- return { ...harness, wired: hasAttessoHook(candidate) };
64
+ let wired = false;
65
+ if (existsSync(candidate)) {
66
+ try {
67
+ const text = readFileSync(candidate, 'utf8');
68
+ // A hook for a different ledger is not this project's connection. The
69
+ // legacy wrapper has no --dir, so it keeps counting as wired.
70
+ const match = text.match(/--dir\s+(?:'([^']+)'|"([^"]+)"|(\S+))/);
71
+ const target = match ? match[1] || match[2] || match[3] : null;
72
+ wired = target ? resolve(cwd, target) === ledger : /attesso[^\n"]*hook/.test(text);
73
+ } catch {
74
+ wired = false;
75
+ }
76
+ }
77
+ return { ...harness, wired };
74
78
  });
75
79
  }
76
80
 
77
- function instructions({ mode, wired, manual }) {
81
+ function instructions({ initialized, mode, wired, manual }) {
82
+ if (!initialized) return ['run `attesso init` first'];
78
83
  const next = [];
79
84
  if (!mode) next.push('run `attesso connect --write --mode observe` from the agent');
80
85
  else if (mode === 'off') next.push('run `attesso mode observe` to start recording');
81
86
  if (manual.length) next.push('wire the listed harnesses by hand, or use the generic hook contract');
82
- if (mode && !wired.length) next.push('run `attesso connect --write --mode observe` from the agent');
87
+ if (mode && !wired.length && !manual.length) {
88
+ next.push('no agent harness detected; wire the generic hook contract or run `attesso connect` from an agent project');
89
+ }
83
90
  return next;
84
91
  }
85
92
 
86
93
  export function status({ dir, cwd = process.cwd(), home = homedir() } = {}) {
87
94
  const initialized = isInitialised(dir);
88
95
  const mode = readMode(dir);
89
- const detected = wiredHarnesses(detectHarnesses({ home, cwd }));
96
+ const detected = wiredHarnesses(detectHarnesses({ home, cwd }), { dir, cwd });
90
97
  const wired = detected.filter((harness) => harness.wired);
91
98
  const manual = detected.filter((harness) => !['hermes', 'claude-code', 'codex', 'gemini', 'cline'].includes(harness.key));
92
99
  return {
@@ -97,7 +104,7 @@ export function status({ dir, cwd = process.cwd(), home = homedir() } = {}) {
97
104
  wired: wired.map((harness) => harness.key),
98
105
  manual: manual.map((harness) => harness.key),
99
106
  connected: initialized && mode !== null && mode !== 'off' && wired.length > 0,
100
- next: instructions({ mode, wired, manual }),
107
+ next: instructions({ initialized, mode, wired, manual }),
101
108
  };
102
109
  }
103
110
 
@@ -113,8 +120,12 @@ export function connect({
113
120
  if (!CONNECT_MODES.includes(mode)) {
114
121
  throw new Error(`invalid mode '${mode}'; expected ${CONNECT_MODES.join(', ')}`);
115
122
  }
116
- const initialized = isInitialised(dir);
117
- if (write && !initialized) init(dir);
123
+ // Harness configs are global. A relative --dir becomes a hook that points at
124
+ // whatever .attesso happens to be in the agent's current directory, so the
125
+ // ledger path must be absolute before it is written anywhere.
126
+ const ledgerDir = resolve(dir);
127
+ const initialized = isInitialised(ledgerDir);
128
+ if (write && !initialized) init(ledgerDir);
118
129
  const detected = detectHarnesses({ home, cwd }).filter((harness) => !only || harness.key === only);
119
130
  const agentContext = writeAgentContext(cwd, { dryRun: !write });
120
131
  const results = [];
@@ -130,7 +141,7 @@ export function connect({
130
141
  continue;
131
142
  }
132
143
  try {
133
- const result = wireHarness(harness.key, cli, dir, {
144
+ const result = wireHarness(harness.key, cli, ledgerDir, {
134
145
  dryRun: !write,
135
146
  enforce: mode === 'enforce',
136
147
  modeFile: true,
@@ -143,18 +154,18 @@ export function connect({
143
154
  results.push({ key: harness.key, path: harness.path, wired: false, error: error.message });
144
155
  }
145
156
  }
146
- if (write) setModeAll(dir, mode, { home });
157
+ if (write) setModeAll(ledgerDir, mode, { home });
147
158
  const wired = results.filter((result) => result.applied || result.reason === 'already wired');
148
- const effectiveMode = write ? mode : readMode(dir);
159
+ const effectiveMode = write ? mode : readMode(ledgerDir);
149
160
  return {
150
- initialized: isInitialised(dir),
161
+ initialized: isInitialised(ledgerDir),
151
162
  mode: effectiveMode,
152
- connected: isInitialised(dir) && effectiveMode !== null && effectiveMode !== 'off' && wired.length > 0,
153
- ledger: paths(dir).dir,
163
+ connected: isInitialised(ledgerDir) && effectiveMode !== null && effectiveMode !== 'off' && wired.length > 0,
164
+ ledger: paths(ledgerDir).dir,
154
165
  harnesses: results,
155
166
  wired: wired.map((result) => result.key),
156
167
  manual: results.filter((result) => result.manual || result.error || result.reason?.includes('merge by hand')).map((result) => result.key),
157
168
  agent_context: agentContext,
158
- next: instructions({ mode: effectiveMode, wired, manual: results.filter((result) => result.manual || result.error) }),
169
+ next: instructions({ initialized: isInitialised(ledgerDir), mode: effectiveMode, wired, manual: results.filter((result) => result.manual || result.error) }),
159
170
  };
160
171
  }
@@ -0,0 +1,142 @@
1
+ // Hosted policy distribution: fetch a signed rule bundle, verify it against the
2
+ // published Attesso JWKS, and only then let the local engine write rules.json.
3
+ import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
4
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { paths } from './store.mjs';
6
+ import { validateRules } from './rules.mjs';
7
+
8
+ export const POLICY_FORMAT = 'attesso.bounds.policy.v1';
9
+
10
+ const fromB64url = (value) => {
11
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
12
+ return Buffer.from(normalized + '='.repeat((4 - (normalized.length % 4)) % 4), 'base64');
13
+ };
14
+
15
+ export function policyEndpoint(base) {
16
+ const parsed = new URL(base);
17
+ if (parsed.protocol !== 'https:' && parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
18
+ throw new Error('policy url must use HTTPS, except on localhost');
19
+ }
20
+ const path = parsed.pathname.replace(/\/+$/, '').replace(/\/v1\/bounds\/(sync|policy)$/, '');
21
+ parsed.pathname = `${path}/v1/bounds/policy`;
22
+ parsed.search = '';
23
+ parsed.hash = '';
24
+ return parsed.toString();
25
+ }
26
+
27
+ export function jwksEndpoint(base) {
28
+ return new URL('/.well-known/jwks.json', new URL(base)).toString();
29
+ }
30
+
31
+ export async function pullPolicy({
32
+ dir,
33
+ url,
34
+ key,
35
+ apply = false,
36
+ fetchImpl = fetch,
37
+ } = {}) {
38
+ if (!url) throw new Error('policy url is required');
39
+ if (!key) throw new Error('policy API key is required');
40
+ const response = await fetchImpl(policyEndpoint(url), {
41
+ headers: { Authorization: `Bearer ${key}` },
42
+ signal: AbortSignal.timeout(20_000),
43
+ });
44
+ if (response.status === 404) throw new Error('no hosted policy is published');
45
+ if (!response.ok) throw new Error(`policy fetch failed with status ${response.status}`);
46
+ let bundle;
47
+ try {
48
+ bundle = await response.json();
49
+ } catch {
50
+ throw new Error('policy response is invalid');
51
+ }
52
+ if (!bundle || typeof bundle !== 'object' || !bundle.envelope || typeof bundle.envelope !== 'object') {
53
+ throw new Error('policy response is missing its signature');
54
+ }
55
+ if (bundle.envelope.format !== POLICY_FORMAT) {
56
+ throw new Error('policy response has an unexpected format');
57
+ }
58
+
59
+ const jwksResponse = await fetchImpl(jwksEndpoint(url), { signal: AbortSignal.timeout(20_000) });
60
+ if (!jwksResponse.ok) throw new Error(`policy key fetch failed with status ${jwksResponse.status}`);
61
+ let jwks;
62
+ try {
63
+ jwks = await jwksResponse.json();
64
+ } catch {
65
+ throw new Error('policy key response is invalid');
66
+ }
67
+
68
+ const verified = verifyPolicyEnvelope(bundle.envelope, jwks);
69
+ const changed = !existsSync(paths(dir).rules) ||
70
+ JSON.stringify(JSON.parse(readFileSync(paths(dir).rules, 'utf8'))) !== JSON.stringify(verified.rules);
71
+ if (apply) writeRules(dir, verified.rules);
72
+ return {
73
+ version: verified.version,
74
+ kid: verified.kid,
75
+ digest: verified.digest,
76
+ applied: apply,
77
+ changed,
78
+ rules: verified.rules,
79
+ };
80
+ }
81
+
82
+ export function verifyPolicyEnvelope(envelope, jwks) {
83
+ if (!envelope || typeof envelope !== 'object') throw new Error('policy envelope is invalid');
84
+ if (envelope.format !== POLICY_FORMAT) throw new Error('policy envelope has an unexpected format');
85
+ for (const field of ['protected', 'payload', 'signature']) {
86
+ if (typeof envelope[field] !== 'string' || envelope[field] === '') {
87
+ throw new Error('policy envelope is incomplete');
88
+ }
89
+ }
90
+ let header;
91
+ try {
92
+ header = JSON.parse(fromB64url(envelope.protected).toString('utf8'));
93
+ } catch {
94
+ throw new Error('policy envelope header is invalid');
95
+ }
96
+ if (header.alg !== 'ES256' || typeof header.kid !== 'string') {
97
+ throw new Error('policy envelope must use an ES256 key');
98
+ }
99
+ const key = Array.isArray(jwks?.keys) ? jwks.keys.find((item) => item.kid === header.kid) : undefined;
100
+ if (!key || key.kty !== 'EC' || key.crv !== 'P-256' || key.alg !== 'ES256') {
101
+ throw new Error(`no ES256 verification key for ${header.kid}`);
102
+ }
103
+ let publicKey;
104
+ try {
105
+ publicKey = createPublicKey({ key: { kty: key.kty, crv: key.crv, x: key.x, y: key.y }, format: 'jwk' });
106
+ } catch {
107
+ throw new Error(`verification key ${header.kid} is invalid`);
108
+ }
109
+ const valid = verifySignature(
110
+ 'sha256',
111
+ Buffer.from(`${envelope.protected}.${envelope.payload}`, 'utf8'),
112
+ { key: publicKey, dsaEncoding: 'ieee-p1363' },
113
+ fromB64url(envelope.signature),
114
+ );
115
+ if (!valid) throw new Error('policy signature did not verify');
116
+
117
+ let payload;
118
+ try {
119
+ payload = JSON.parse(fromB64url(envelope.payload).toString('utf8'));
120
+ } catch {
121
+ throw new Error('policy payload is invalid');
122
+ }
123
+ if (payload.schema !== POLICY_FORMAT ||
124
+ !Number.isSafeInteger(payload.version) || payload.version < 1 ||
125
+ typeof payload.rules !== 'object' || payload.rules === null) {
126
+ throw new Error('policy payload is invalid');
127
+ }
128
+ const rules = validateRules(payload.rules);
129
+ return {
130
+ version: payload.version,
131
+ rules,
132
+ kid: header.kid,
133
+ digest: `sha256:${createHash('sha256').update(envelope.payload).digest('hex')}`,
134
+ };
135
+ }
136
+
137
+ function writeRules(dir, rules) {
138
+ const file = paths(dir).rules;
139
+ const temporary = `${file}.${process.pid}.tmp`;
140
+ writeFileSync(temporary, `${JSON.stringify(rules, null, 2)}\n`, { mode: 0o600 });
141
+ renameSync(temporary, file);
142
+ }
package/src/setup.mjs CHANGED
@@ -10,10 +10,12 @@ import { join, resolve } from 'node:path';
10
10
  import { argsHashOf } from './rules.mjs';
11
11
 
12
12
  const AGENT_BLOCK = `<!-- attesso:start -->
13
- A veto is active on your tool calls. When a call is refused you get a tool error naming
14
- the rule and the step that tainted the task. Do not retry a refused call unchanged:
15
- read \`attesso explain <seq>\`, take another path, or ask the user whether the rule is
16
- wrong. To see rules the ledger supports, run \`attesso suggest\`.
13
+ Bounds evaluates your tool calls locally and records every decision. In observe mode
14
+ a refusal is recorded but not blocked; in enforce mode it blocks. When a call is
15
+ refused you get a tool error naming the rule and the step that tainted the task.
16
+ Do not retry a refused call unchanged: read \`attesso explain <seq>\`, take another
17
+ path, or ask the user whether the rule is wrong. To see rules the ledger supports,
18
+ run \`attesso suggest\`.
17
19
  <!-- attesso:end -->`;
18
20
 
19
21
  function stripAgentBlock(text) {
@@ -69,6 +69,7 @@
69
69
  .badge{border:1px solid #ead6b3;background:#fbf5e8;color:#7a4b12;padding:2px 6px;font-size:10px}
70
70
  .actions{display:flex;gap:7px;align-items:center}
71
71
  .button{border:1px solid #d0d5dd;background:#fff;color:var(--body);padding:7px 11px;font-size:12px;cursor:pointer}
72
+ .button:disabled{opacity:.4;cursor:default}
72
73
  .button.primary{border-color:var(--ink);background:var(--ink);color:#fff}
73
74
  .button:hover{background:#f2f4f7}
74
75
  .page{max-width:1440px;padding:24px 30px 48px}
@@ -303,7 +304,7 @@
303
304
  <div class="topbar">
304
305
  <div class="crumbs"><span>Bounds</span><span>/</span><span class="current">local</span><span class="badge">ledger</span></div>
305
306
  <div class="actions">
306
- <button class="button" type="button" id="export-draft">Export draft</button>
307
+ <button class="button" type="button" id="export-draft" title="Set a rule on a block first" disabled>Export draft</button>
307
308
  <button class="button" type="button" id="all-calls">All calls</button>
308
309
  </div>
309
310
  </div>
@@ -686,7 +687,13 @@
686
687
  el('agent-sub').textContent = 'Hook active' + seq;
687
688
  el('agent-action').textContent = 'Manage agent';
688
689
  } else {
689
- el('agent-sub').textContent = 'Hook is ' + (agentData.mode || 'not configured') + ' - connect any agent to start observing';
690
+ const wired = Array.isArray(agentData.wired) ? agentData.wired.length : 0;
691
+ const recorded = days.reduce((total, day) => total + summarize(day).calls, 0);
692
+ el('agent-sub').textContent = wired
693
+ ? 'Hook is ' + (agentData.mode || 'not configured') + ' - connect any agent to start observing'
694
+ : recorded > 0
695
+ ? fmt(recorded) + ' recorded calls; no harness wired'
696
+ : 'No harness wired - connect an agent to start observing';
690
697
  el('agent-action').textContent = 'Connect agent';
691
698
  }
692
699
  renderAgentPanel();
@@ -903,7 +910,11 @@
903
910
  renderAgentStatus();
904
911
  if (body.status?.paths) pathState = body.status.paths;
905
912
  renderPaths();
906
- status.textContent = agentData.connected ? 'Agent connected.' : 'Hook is ' + mode + '.';
913
+ status.textContent = agentData.connected
914
+ ? 'Agent connected.'
915
+ : mode === 'off'
916
+ ? 'Bounds is off; choose Observe and connect again.'
917
+ : 'No agent harness detected; copy the setup command below.';
907
918
  } catch (error) {
908
919
  status.textContent = 'Connect failed: ' + error.message;
909
920
  }
@@ -943,6 +954,7 @@
943
954
  function renderFlow(day, preserveCenter = null) {
944
955
  if (v1) return renderV1Flow(day, preserveCenter);
945
956
  const summary = summarize(day);
957
+ const empty = summary.calls === 0;
946
958
  const width = flowWidth();
947
959
  const inner = width - GUTTER;
948
960
  el('flow-track').style.width = width + 'px';
@@ -953,13 +965,15 @@
953
965
  el('flow-count').textContent = fmt(summary.calls) + ' calls';
954
966
  const hours = el('flow-hours');
955
967
  hours.replaceChildren();
956
- for (let hour = 0; hour <= 24; hour += 1) {
957
- const line = document.createElement('div');
958
- line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
959
- line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
960
- const labelEvery = zoom >= 2 ? 1 : 3;
961
- if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
962
- hours.append(line);
968
+ if (!empty) {
969
+ for (let hour = 0; hour <= 24; hour += 1) {
970
+ const line = document.createElement('div');
971
+ line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
972
+ line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
973
+ const labelEvery = zoom >= 2 ? 1 : 3;
974
+ if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
975
+ hours.append(line);
976
+ }
963
977
  }
964
978
  const lanes = el('flow-lanes');
965
979
  lanes.replaceChildren();
@@ -1008,18 +1022,20 @@
1008
1022
  for (const row of rows) markerBySeq.set(String(row[0]), marker);
1009
1023
  fragments.get(cap).append(marker);
1010
1024
  }
1011
- for (const cap of CAPS) {
1012
- const lane = document.createElement('div');
1013
- lane.className = 'f-lane';
1014
- lane.dataset.capability = cap;
1015
- const label = document.createElement('div');
1016
- label.className = 'f-label';
1017
- label.textContent = cap.toUpperCase();
1018
- const track = document.createElement('div');
1019
- track.className = 'f-track';
1020
- track.append(fragments.get(cap));
1021
- lane.append(label, track);
1022
- lanes.append(lane);
1025
+ if (!empty) {
1026
+ for (const cap of CAPS) {
1027
+ const lane = document.createElement('div');
1028
+ lane.className = 'f-lane';
1029
+ lane.dataset.capability = cap;
1030
+ const label = document.createElement('div');
1031
+ label.className = 'f-label';
1032
+ label.textContent = cap.toUpperCase();
1033
+ const track = document.createElement('div');
1034
+ track.className = 'f-track';
1035
+ track.append(fragments.get(cap));
1036
+ lane.append(label, track);
1037
+ lanes.append(lane);
1038
+ }
1023
1039
  }
1024
1040
  const scroller = el('flow-scroller');
1025
1041
  if (preserveCenter == null) {
@@ -1032,10 +1048,12 @@
1032
1048
  closePopover();
1033
1049
  applyFlowFilter();
1034
1050
  renderTokens(day);
1051
+ updateExportDraft();
1035
1052
  }
1036
1053
 
1037
1054
  function renderV1Flow(day, preserveCenter = null) {
1038
1055
  const summary = summarize(day);
1056
+ const empty = summary.calls === 0;
1039
1057
  const buckets = flowCache.get(day)?.buckets || [];
1040
1058
  const width = flowWidth();
1041
1059
  const inner = width - GUTTER;
@@ -1050,13 +1068,15 @@
1050
1068
  el('flow-count').textContent = fmt(summary.calls) + ' calls';
1051
1069
  const hours = el('flow-hours');
1052
1070
  hours.replaceChildren();
1053
- for (let hour = 0; hour <= 24; hour += 1) {
1054
- const line = document.createElement('div');
1055
- line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
1056
- line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
1057
- const labelEvery = zoom >= 2 ? 1 : 3;
1058
- if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
1059
- hours.append(line);
1071
+ if (!empty) {
1072
+ for (let hour = 0; hour <= 24; hour += 1) {
1073
+ const line = document.createElement('div');
1074
+ line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
1075
+ line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
1076
+ const labelEvery = zoom >= 2 ? 1 : 3;
1077
+ if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
1078
+ hours.append(line);
1079
+ }
1060
1080
  }
1061
1081
  const lanes = el('flow-lanes');
1062
1082
  lanes.replaceChildren();
@@ -1092,18 +1112,20 @@
1092
1112
  }
1093
1113
  fragments.get(cap).append(marker);
1094
1114
  }
1095
- for (const cap of CAPS) {
1096
- const lane = document.createElement('div');
1097
- lane.className = 'f-lane';
1098
- lane.dataset.capability = cap;
1099
- const label = document.createElement('div');
1100
- label.className = 'f-label';
1101
- label.textContent = cap.toUpperCase();
1102
- const track = document.createElement('div');
1103
- track.className = 'f-track';
1104
- track.append(fragments.get(cap));
1105
- lane.append(label, track);
1106
- lanes.append(lane);
1115
+ if (!empty) {
1116
+ for (const cap of CAPS) {
1117
+ const lane = document.createElement('div');
1118
+ lane.className = 'f-lane';
1119
+ lane.dataset.capability = cap;
1120
+ const label = document.createElement('div');
1121
+ label.className = 'f-label';
1122
+ label.textContent = cap.toUpperCase();
1123
+ const track = document.createElement('div');
1124
+ track.className = 'f-track';
1125
+ track.append(fragments.get(cap));
1126
+ lane.append(label, track);
1127
+ lanes.append(lane);
1128
+ }
1107
1129
  }
1108
1130
  const scroller = el('flow-scroller');
1109
1131
  if (preserveCenter == null) {
@@ -1115,6 +1137,7 @@
1115
1137
  closePopover();
1116
1138
  applyFlowFilter();
1117
1139
  renderTokens(day);
1140
+ updateExportDraft();
1118
1141
  }
1119
1142
 
1120
1143
  function applyFlowFilter() {
@@ -1311,6 +1334,7 @@
1311
1334
  if (allOn) drafts.delete(key); else drafts.set(key, state);
1312
1335
  }
1313
1336
  updateMarkerDraft(marker);
1337
+ updateExportDraft();
1314
1338
  const popover = el('flow-popover');
1315
1339
  if (popover && !popover.hidden) {
1316
1340
  const draft = popover.querySelector('#fp-block-draft');
@@ -1330,6 +1354,7 @@
1330
1354
  if (!row) continue;
1331
1355
  if (next === 'unset') drafts.delete(String(row[0])); else drafts.set(String(row[0]), next);
1332
1356
  }
1357
+ updateExportDraft();
1333
1358
  const marker = selectedMarker || markerBySeq.get(key);
1334
1359
  if (marker) {
1335
1360
  marker.classList.remove('draft-allow', 'draft-deny', 'draft-ask');
@@ -1365,6 +1390,10 @@
1365
1390
  async function renderDay(day) {
1366
1391
  if (!calls[day] && !v1) return;
1367
1392
  const request = ++dayRequest;
1393
+ if (selectedDay !== day && drafts.size > 0) {
1394
+ drafts.clear();
1395
+ updateExportDraft();
1396
+ }
1368
1397
  selectedDay = day;
1369
1398
  callsDay = day;
1370
1399
  callsPage = 0;
@@ -1397,7 +1426,15 @@
1397
1426
  renderFlow(selectedDay, centerMinute);
1398
1427
  }
1399
1428
 
1429
+ function updateExportDraft() {
1430
+ const button = el('export-draft');
1431
+ if (!button) return;
1432
+ button.disabled = drafts.size === 0;
1433
+ button.title = drafts.size === 0 ? 'Set a rule on a block first' : '';
1434
+ }
1435
+
1400
1436
  function exportDraft() {
1437
+ if (drafts.size === 0) return;
1401
1438
  const output = {
1402
1439
  generated_at: new Date().toISOString(),
1403
1440
  day: selectedDay,