@halcyonlabs/app 0.47.0-halcyon.326 → 0.47.0-halcyon.328

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/dist/index.d.ts CHANGED
@@ -83,8 +83,8 @@ declare const appSettingsSchema: z$1.ZodObject<{
83
83
  providerOrder: z$1.ZodArray<z$1.ZodString>;
84
84
  defaultProviderId: z$1.ZodNullable<z$1.ZodString>;
85
85
  providerCompletedTurnDisplay: z$1.ZodRecord<z$1.ZodString, z$1.ZodEnum<{
86
- collapse: "collapse";
87
86
  flat: "flat";
87
+ collapse: "collapse";
88
88
  }>>;
89
89
  streamerMode: z$1.ZodBoolean;
90
90
  allowFastServiceTier: z$1.ZodBoolean;
@@ -105,8 +105,8 @@ declare const appSettingsUpdateSchema: z$1.ZodUnion<readonly [z$1.ZodObject<{
105
105
  providerOrder: z$1.ZodArray<z$1.ZodString>;
106
106
  defaultProviderId: z$1.ZodNullable<z$1.ZodString>;
107
107
  providerCompletedTurnDisplay: z$1.ZodRecord<z$1.ZodString, z$1.ZodEnum<{
108
- collapse: "collapse";
109
108
  flat: "flat";
109
+ collapse: "collapse";
110
110
  }>>;
111
111
  streamerMode: z$1.ZodBoolean;
112
112
  managedBranchPrefix: z$1.ZodString;
@@ -143,8 +143,8 @@ declare const appSettingsUpdateSchema: z$1.ZodUnion<readonly [z$1.ZodObject<{
143
143
  providerOrder: z$1.ZodArray<z$1.ZodString>;
144
144
  defaultProviderId: z$1.ZodNullable<z$1.ZodString>;
145
145
  providerCompletedTurnDisplay: z$1.ZodRecord<z$1.ZodString, z$1.ZodEnum<{
146
- collapse: "collapse";
147
146
  flat: "flat";
147
+ collapse: "collapse";
148
148
  }>>;
149
149
  streamerMode: z$1.ZodBoolean;
150
150
  machineServerUrl: z$1.ZodNullable<z$1.ZodString>;
@@ -3008,8 +3008,8 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3008
3008
  }>;
3009
3009
  name: z$1.ZodString;
3010
3010
  source: z$1.ZodEnum<{
3011
- skill: "skill";
3012
3011
  command: "command";
3012
+ skill: "skill";
3013
3013
  }>;
3014
3014
  origin: z$1.ZodEnum<{
3015
3015
  user: "user";
@@ -3098,8 +3098,8 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3098
3098
  }>;
3099
3099
  name: z$1.ZodString;
3100
3100
  source: z$1.ZodEnum<{
3101
- skill: "skill";
3102
3101
  command: "command";
3102
+ skill: "skill";
3103
3103
  }>;
3104
3104
  origin: z$1.ZodEnum<{
3105
3105
  user: "user";
@@ -4016,8 +4016,8 @@ declare const promptTextMentionSchema: z$1.ZodObject<{
4016
4016
  }>;
4017
4017
  name: z$1.ZodString;
4018
4018
  source: z$1.ZodEnum<{
4019
- skill: "skill";
4020
4019
  command: "command";
4020
+ skill: "skill";
4021
4021
  }>;
4022
4022
  origin: z$1.ZodEnum<{
4023
4023
  user: "user";
@@ -4081,8 +4081,8 @@ declare const promptInputSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{
4081
4081
  }>;
4082
4082
  name: z$1.ZodString;
4083
4083
  source: z$1.ZodEnum<{
4084
- skill: "skill";
4085
4084
  command: "command";
4085
+ skill: "skill";
4086
4086
  }>;
4087
4087
  origin: z$1.ZodEnum<{
4088
4088
  user: "user";
@@ -4240,8 +4240,8 @@ declare const providerInfoSchema: z$1.ZodObject<{
4240
4240
  }, z$1.core.$strip>;
4241
4241
  }, z$1.core.$strip>], "kind">>;
4242
4242
  completedTurnDisplay: z$1.ZodEnum<{
4243
- collapse: "collapse";
4244
4243
  flat: "flat";
4244
+ collapse: "collapse";
4245
4245
  }>;
4246
4246
  available: z$1.ZodBoolean;
4247
4247
  strings: z$1.ZodOptional<z$1.ZodObject<{
@@ -4329,8 +4329,8 @@ declare const threadTimelinePendingTodosSchema: z$1.ZodObject<{
4329
4329
  type ThreadTimelinePendingTodos = z$1.infer<typeof threadTimelinePendingTodosSchema>;
4330
4330
 
4331
4331
  declare const threadParentNotificationsSchema: z$1.ZodEnum<{
4332
- off: "off";
4333
4332
  auto: "auto";
4333
+ off: "off";
4334
4334
  stuck: "stuck";
4335
4335
  }>;
4336
4336
  type ThreadParentNotifications = z$1.infer<typeof threadParentNotificationsSchema>;
@@ -4393,8 +4393,8 @@ declare const threadQueuedMessageSchema: z$1.ZodObject<{
4393
4393
  }>;
4394
4394
  name: z$1.ZodString;
4395
4395
  source: z$1.ZodEnum<{
4396
- skill: "skill";
4397
4396
  command: "command";
4397
+ skill: "skill";
4398
4398
  }>;
4399
4399
  origin: z$1.ZodEnum<{
4400
4400
  user: "user";
@@ -7860,13 +7860,13 @@ declare const hostDaemonCommandRegistry: {
7860
7860
  modifiedAtMs: z$1.ZodOptional<z$1.ZodNumber>;
7861
7861
  sha256: z$1.ZodString;
7862
7862
  }, z$1.core.$strip>, z$1.ZodObject<{
7863
- path: z$1.ZodString;
7864
7863
  contentEncoding: z$1.ZodEnum<{
7865
7864
  base64: "base64";
7866
7865
  utf8: "utf8";
7867
7866
  }>;
7868
7867
  sha256: z$1.ZodString;
7869
7868
  sizeBytes: z$1.ZodNumber;
7869
+ path: z$1.ZodString;
7870
7870
  mimeType: z$1.ZodOptional<z$1.ZodString>;
7871
7871
  modifiedAtMs: z$1.ZodOptional<z$1.ZodNumber>;
7872
7872
  notModified: z$1.ZodLiteral<true>;
@@ -8131,8 +8131,8 @@ declare const hostDaemonCommandRegistry: {
8131
8131
  supported: z$1.ZodLiteral<true>;
8132
8132
  health: z$1.ZodObject<{
8133
8133
  status: z$1.ZodEnum<{
8134
- unknown: "unknown";
8135
8134
  ready: "ready";
8135
+ unknown: "unknown";
8136
8136
  not_installed: "not_installed";
8137
8137
  unauthenticated: "unauthenticated";
8138
8138
  expired: "expired";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halcyonlabs/app",
3
- "version": "0.47.0-halcyon.326",
3
+ "version": "0.47.0-halcyon.328",
4
4
  "description": "bb app launcher for npx",
5
5
  "keywords": [
6
6
  "bb",
@@ -564,6 +564,49 @@ class Workspaces:
564
564
  os.close(self.open_workspace(job_id))
565
565
  return unpushed_work(self.workspace_root / (self.prefix + job_id), account)
566
566
 
567
+ def save_and_clean(self, job_id, account):
568
+ with self.locked():
569
+ manifest = self.read(job_id)
570
+ if not has_workspace(manifest) or manifest['state'] not in ('reserved', 'running', 'parked'):
571
+ raise ValueError('Only an open workspace can save and clean.')
572
+ path = self.workspace_root / (self.prefix + job_id)
573
+ users, unknown = workspace_users(path)
574
+ if users or unknown:
575
+ raise ValueError('Workspace writers must be confirmed stopped before saving and cleaning.')
576
+ manifest = self.declared_outputs(manifest)
577
+ root = self.open_workspace(job_id)
578
+ try:
579
+ checkouts = as_account(account, lambda: dict(paths=[str(p.relative_to(path)) for p in git_checkouts(path, time.monotonic() + UNPUSHED_SECONDS, include_build=True)]))['paths']
580
+ work = dict(checkouts=[dict(path=name, preserved=True) for name in checkouts])
581
+ caches = [tuple([name]) for name in JOB_CACHES] + declared_rebuildable(root, manifest['outputs'])
582
+ caches += [tuple(parts) for parts in as_account(account, lambda: dict(paths=recovery_caches(path, checkouts, account, manifest['outputs'])))['paths']]
583
+ safe_caches = []
584
+ for parts in caches:
585
+ cache = '/'.join(parts)
586
+ if any(checkout == cache or checkout.startswith(cache + '/') for checkout in checkouts):
587
+ if parts == ('tmp',) or parts[-2:] == ('.orbit-build', 'tmp'):
588
+ continue
589
+ raise ValueError('A rebuildable folder contains a checkout; unpublished work is kept.')
590
+ safe_caches.append(parts)
591
+ caches = safe_caches
592
+ name = 'results/storage-recovery-' + secrets.token_hex(16) + '.tar.gz'
593
+ outputs, _ = declared_output_names(manifest['outputs'] + [name], strict=True)
594
+ as_account(account, lambda: save_recovery(root, caches, account, name, outputs))
595
+ write_record(root, '.orbit-outputs.json', outputs)
596
+ declaration = os.open('.orbit-outputs.json', os.O_RDONLY | os.O_NOFOLLOW, dir_fd=root)
597
+ try:
598
+ os.fchown(declaration, account.pw_uid, account.pw_gid)
599
+ finally:
600
+ os.close(declaration)
601
+ manifest['outputs'] = outputs
602
+ write_record(self.state, manifest['id'] + '.json', manifest)
603
+ reclaim_workspace(root, outputs, caches)
604
+ if manifest['state'] != 'parked':
605
+ self.save_transition(manifest, 'parked')
606
+ return dict(output=name, unpublished=work['checkouts'])
607
+ finally:
608
+ os.close(root)
609
+
567
610
  def measure(self, job_id, sized=True):
568
611
  """Free space and file entries for the stop rule; the folder's size only when low.
569
612
 
@@ -1064,13 +1107,50 @@ def declared_rebuildable(root, outputs):
1064
1107
  return paths[:REBUILDABLE_LIMIT]
1065
1108
 
1066
1109
 
1067
- def reclaim_workspace(root, outputs=()):
1110
+ def cache_has_checkout(parent, name, device):
1111
+ stack = []
1112
+ deadline = time.monotonic() + 5
1113
+ try:
1114
+ stack.append((os.open(name, DIR_FLAGS, dir_fd=parent), None))
1115
+ while stack:
1116
+ current, entries = stack[-1]
1117
+ if entries is None:
1118
+ names = os.listdir(current)
1119
+ if '.git' in names or {'HEAD', 'objects', 'config'}.issubset(names):
1120
+ return True
1121
+ entries = iter(names)
1122
+ stack[-1] = (current, entries)
1123
+ entry = next(entries, None)
1124
+ if entry is None:
1125
+ os.close(current)
1126
+ stack.pop()
1127
+ continue
1128
+ if time.monotonic() > deadline:
1129
+ return True
1130
+ info = os.stat(entry, dir_fd=current, follow_symlinks=False)
1131
+ if info.st_dev != device:
1132
+ return True
1133
+ if stat.S_ISDIR(info.st_mode):
1134
+ stack.append((os.open(entry, DIR_FLAGS, dir_fd=current), None))
1135
+ return False
1136
+ except (OSError, ValueError):
1137
+ return True
1138
+ finally:
1139
+ for fd, _ in reversed(stack):
1140
+ os.close(fd)
1141
+
1142
+
1143
+ def reclaim_workspace(root, outputs=(), paths=None):
1068
1144
  """Empty the caches the supervisor gives every job, then the folders the job declared rebuildable."""
1069
1145
  device = os.fstat(root).st_dev
1070
1146
  for name in JOB_CACHES:
1071
- if directory_at(root, name, device):
1147
+ if paths is not None and (name,) not in paths:
1148
+ continue
1149
+ if directory_at(root, name, device) and not cache_has_checkout(root, name, device):
1072
1150
  clear_contents(root, name, device)
1073
- for parts in declared_rebuildable(root, outputs):
1151
+ for parts in declared_rebuildable(root, outputs) if paths is None else paths:
1152
+ if len(parts) == 1 and parts[0] in JOB_CACHES:
1153
+ continue
1074
1154
  holder = os.dup(root)
1075
1155
  try:
1076
1156
  for part in parts[:-1]:
@@ -1080,12 +1160,140 @@ def reclaim_workspace(root, outputs=()):
1080
1160
  os.close(holder)
1081
1161
  holder = nested
1082
1162
  else:
1083
- if directory_at(holder, parts[-1], device):
1163
+ if directory_at(holder, parts[-1], device) and not cache_has_checkout(holder, parts[-1], device):
1084
1164
  remove_tree(holder, parts[-1], device)
1085
1165
  finally:
1086
1166
  os.close(holder)
1087
1167
 
1088
1168
 
1169
+ def recovery_caches(root, checkouts, account, outputs):
1170
+ caches = []
1171
+ for name in checkouts:
1172
+ checkout = root / name
1173
+ if not (checkout / '.git').exists():
1174
+ continue
1175
+ tracked = git_output(account, checkout, name, 'ls-files', '-z', offline=True).stdout.split('\0')
1176
+ directories = {Path(file).parent for file in tracked if Path(file).name in ('package.json', 'Cargo.toml')}
1177
+ candidates = [checkout / '.orbit-build' / folder for folder in (*JOB_CACHES, 'rustup', 'rustup-home', 'home/.rustup')]
1178
+ candidates += [checkout / '.orbit-build/cargo-home' / folder for folder in ('registry', 'git')]
1179
+ candidates += [checkout / directory / folder for directory in directories for folder in ('node_modules', 'target')]
1180
+ for candidate in candidates:
1181
+ relative = str(candidate.relative_to(root))
1182
+ local = str(candidate.relative_to(checkout))
1183
+ if (any(file == local or file.startswith(local + '/') for file in tracked if file)
1184
+ or any(output == relative or output.startswith(relative + '/') or relative.startswith(output + '/') for output in outputs)):
1185
+ continue
1186
+ caches.append(tuple(candidate.relative_to(root).parts))
1187
+ return caches
1188
+
1189
+
1190
+ def workspace_metadata(root, excluded=()):
1191
+ device = os.fstat(root).st_dev
1192
+ def visit(directory, prefix):
1193
+ for entry in os.listdir(directory):
1194
+ parts = (*prefix, entry)
1195
+ if any(parts[:len(path)] == path for path in excluded):
1196
+ continue
1197
+ info = os.stat(entry, dir_fd=directory, follow_symlinks=False)
1198
+ if info.st_dev != device:
1199
+ raise ValueError('Recovery source crosses a filesystem.')
1200
+ yield parts, info
1201
+ if stat.S_ISDIR(info.st_mode):
1202
+ child = os.open(entry, DIR_FLAGS, dir_fd=directory)
1203
+ try:
1204
+ yield from visit(child, parts)
1205
+ finally:
1206
+ os.close(child)
1207
+ yield from visit(root, ())
1208
+
1209
+
1210
+ def source_links(root, excluded):
1211
+ shared = {(info.st_dev, info.st_ino): [file_identity(info), 0]
1212
+ for _, info in workspace_metadata(root, excluded)
1213
+ if stat.S_ISREG(info.st_mode) and info.st_nlink > 1}
1214
+ if shared:
1215
+ for _, info in workspace_metadata(root):
1216
+ entry = shared.get((info.st_dev, info.st_ino))
1217
+ if entry is not None:
1218
+ entry[1] += 1
1219
+ if any(identity[3] != count for identity, count in shared.values()):
1220
+ raise ValueError('Source has a file name outside this workspace; originals are kept.')
1221
+ return {key: identity for key, (identity, _) in shared.items()}
1222
+
1223
+
1224
+ def save_recovery(root, caches, account, name, outputs):
1225
+ import tarfile
1226
+ try:
1227
+ os.mkdir('results', 0o700, dir_fd=root)
1228
+ except FileExistsError:
1229
+ pass
1230
+ results = os.open('results', DIR_FLAGS, dir_fd=root)
1231
+ os.fchown(results, account.pw_uid, account.pw_gid)
1232
+ device = os.fstat(root).st_dev
1233
+ declared = {tuple(output.split('/')) for output in outputs}
1234
+ pending = '.storage-recovery-' + secrets.token_hex(16)
1235
+ shared = source_links(root, [*caches, *declared])
1236
+ descriptor = None
1237
+ try:
1238
+ if os.fstat(results).st_dev != device:
1239
+ raise ValueError('Recovery output crosses a filesystem.')
1240
+ descriptor = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=results)
1241
+ os.fchown(descriptor, account.pw_uid, account.pw_gid)
1242
+ with os.fdopen(os.dup(descriptor), 'wb') as stream:
1243
+ with tarfile.open(fileobj=stream, mode='w|gz') as archive:
1244
+ def visit(directory, prefix):
1245
+ for entry in sorted(os.listdir(directory)):
1246
+ parts = (*prefix, entry)
1247
+ if (any(parts[:len(cache)] == cache for cache in caches)
1248
+ or parts == ('results', pending) or parts in declared):
1249
+ continue
1250
+ info = os.stat(entry, dir_fd=directory, follow_symlinks=False)
1251
+ if info.st_dev != device:
1252
+ raise ValueError('Recovery source crosses a filesystem.')
1253
+ member = tarfile.TarInfo('/'.join(parts))
1254
+ member.mode = info.st_mode & 0o777
1255
+ member.mtime = info.st_mtime
1256
+ if stat.S_ISDIR(info.st_mode):
1257
+ member.type = tarfile.DIRTYPE
1258
+ archive.addfile(member)
1259
+ child = os.open(entry, DIR_FLAGS, dir_fd=directory)
1260
+ try:
1261
+ visit(child, parts)
1262
+ finally:
1263
+ os.close(child)
1264
+ elif stat.S_ISLNK(info.st_mode):
1265
+ member.type = tarfile.SYMTYPE
1266
+ member.linkname = os.readlink(entry, dir_fd=directory)
1267
+ archive.addfile(member)
1268
+ elif stat.S_ISREG(info.st_mode):
1269
+ if info.st_nlink > 1 and shared.get((info.st_dev, info.st_ino)) != file_identity(info):
1270
+ raise ValueError('Source links changed; originals are kept.')
1271
+ fd = os.open(entry, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
1272
+ with os.fdopen(fd, 'rb') as source:
1273
+ if file_identity(os.fstat(source.fileno())) != file_identity(info):
1274
+ raise ValueError('Recovery source changed; originals are kept.')
1275
+ member.size = info.st_size
1276
+ archive.addfile(member, source)
1277
+ if file_identity(os.fstat(source.fileno())) != file_identity(info):
1278
+ raise ValueError('Recovery source changed; originals are kept.')
1279
+ else:
1280
+ raise ValueError('Recovery source is not a private file; originals are kept.')
1281
+ visit(root, ())
1282
+ stream.flush()
1283
+ os.fsync(stream.fileno())
1284
+ os.link(pending, Path(name).name, src_dir_fd=results, dst_dir_fd=results, follow_symlinks=False)
1285
+ os.unlink(pending, dir_fd=results)
1286
+ os.fsync(results)
1287
+ finally:
1288
+ if descriptor is not None:
1289
+ os.close(descriptor)
1290
+ try:
1291
+ os.unlink(pending, dir_fd=results)
1292
+ except FileNotFoundError:
1293
+ pass
1294
+ os.close(results)
1295
+
1296
+
1089
1297
  def available(fd):
1090
1298
  disk = os.fstatvfs(fd)
1091
1299
  return disk.f_bavail * disk.f_frsize
@@ -1377,7 +1585,7 @@ GIT_SAFE = ('-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c'
1377
1585
  '-c', 'diff.external=', '-c', 'submodule.recurse=false')
1378
1586
 
1379
1587
 
1380
- def git_checkouts(root, deadline):
1588
+ def git_checkouts(root, deadline, include_build=False):
1381
1589
  def refuse(error):
1382
1590
  raise error
1383
1591
 
@@ -1386,20 +1594,20 @@ def git_checkouts(root, deadline):
1386
1594
  if time.monotonic() > deadline:
1387
1595
  raise ValueError('Searching the work folder for Git checkouts took too long.')
1388
1596
  here = Path(directory)
1389
- checkout = '.git' in folders or '.git' in files
1597
+ checkout = '.git' in folders or '.git' in files or (include_build and {'HEAD', 'objects', 'config'}.issubset(set(folders) | set(files)))
1390
1598
  if '.git' in folders and not (here / '.git').is_symlink():
1391
1599
  with os.scandir(here / '.git') as entries:
1392
1600
  checkout = next(entries, None) is not None
1393
1601
  if checkout:
1394
1602
  found.append(here)
1395
- if len(here.relative_to(root).parts) >= UNPUSHED_DEPTH:
1603
+ if not include_build and len(here.relative_to(root).parts) >= UNPUSHED_DEPTH:
1396
1604
  folders[:] = []
1397
1605
  else:
1398
- folders[:] = sorted(name for name in folders if name not in UNPUSHED_SKIPPED)
1606
+ folders[:] = sorted(name for name in folders if name not in UNPUSHED_SKIPPED or (include_build and name == '.orbit-build'))
1399
1607
  return found
1400
1608
 
1401
1609
 
1402
- def git_output(account, checkout, name, *args, allowed=(0,), stdin=''):
1610
+ def git_output(account, checkout, name, *args, allowed=(0,), stdin='', offline=False):
1403
1611
  import shutil
1404
1612
  import subprocess
1405
1613
  program = shutil.which('git', path=GIT_PATH)
@@ -1407,6 +1615,8 @@ def git_output(account, checkout, name, *args, allowed=(0,), stdin=''):
1407
1615
  raise ValueError('Git is not installed, so unpushed work cannot be checked.')
1408
1616
  environment = dict(PATH=GIT_PATH, HOME=account.pw_dir, LC_ALL='C', GIT_CONFIG_NOSYSTEM='1',
1409
1617
  GIT_CONFIG_GLOBAL='/dev/null', GIT_OPTIONAL_LOCKS='0', GIT_TERMINAL_PROMPT='0')
1618
+ if offline:
1619
+ environment.update(GIT_NO_LAZY_FETCH='1', GIT_ALLOW_PROTOCOL='', GIT_PAGER='cat')
1410
1620
  try:
1411
1621
  result = subprocess.run([program, '-C', str(checkout), *GIT_SAFE, *args], cwd='/', env=environment, input=stdin,
1412
1622
  capture_output=True, text=True, errors='replace', timeout=GIT_SECONDS)
@@ -1449,10 +1659,10 @@ def unpushed_checkout(account, checkout, name):
1449
1659
  return dict(branches=branches, detached=detached, uncommitted=len(changed), untracked=len(untracked), stashes=stashes)
1450
1660
 
1451
1661
 
1452
- def search_unpushed(root, account, seconds):
1662
+ def search_unpushed(root, account, seconds, include_build=False):
1453
1663
  deadline = time.monotonic() + seconds
1454
1664
  found = []
1455
- for checkout in git_checkouts(root, deadline):
1665
+ for checkout in git_checkouts(root, deadline, include_build=include_build):
1456
1666
  if time.monotonic() > deadline:
1457
1667
  raise ValueError('Checking the work folder for unpushed commits took too long.')
1458
1668
  name = str(checkout.relative_to(root))
@@ -1641,6 +1851,10 @@ def managed_operation(store, account, request):
1641
1851
  os.close(fd)
1642
1852
  elif request['operation'] == 'unpushed':
1643
1853
  print(json.dumps(store.unpushed(request['id'], account)))
1854
+ elif request['operation'] == 'save-clean':
1855
+ if set(request) - {'operation', 'id', 'user', 'data'}:
1856
+ raise ValueError('Saving and cleanup accept no command or build arguments.')
1857
+ print(json.dumps(store.save_and_clean(request['id'], account)))
1644
1858
  elif request['operation'] == 'retire':
1645
1859
  store.retire(request['id'])
1646
1860
  elif request['operation'] == 'park':
@@ -1 +1 @@
1
- 10f5c81216e5bf74f0946d078047db4b55c47f59
1
+ 9b5d54c9c031f64c8ca64213bd2c5907735b0da9
@@ -30,8 +30,8 @@ guess one.
30
30
 
31
31
  ### Plain words before sending
32
32
 
33
- Compose each reply privately before sending it, using the review command described
34
- below. Revise from its reader's critique and send exactly the final text it returns.
33
+ Compose each final reply privately before sending it, using the review command described
34
+ below. Send the first plain line required by rule 5 before compose rounds. Revise from its reader's critique and send exactly the final text it returns.
35
35
  Record corrections and later reactions in the memory store; retrieve examples for
36
36
  each draft. Do not turn corrections into a maintained file or a new wording rule.
37
37
  Use judgment about meaning; do not make word lists or add glossary entries.
@@ -61,8 +61,10 @@ These hold whatever the preferences say; the tone and standing instructions add
61
61
  Ask rare real questions here in chat, in the existing Waiting for you list; push notifications reach {{owner}}.
62
62
  Link an existing live question instead of asking it again. Never use the operator inbox.
63
63
  Never ask for routine status.
64
- 5. **Answer last.** Do the work first, and put the answer in your final message, so it's the
65
- last thing {{owner}} reads.
64
+ 5. **Answer first.** In any turn {{owner}} starts, write one short plain line within seconds:
65
+ the answer if you have it, or what you are doing. Write it before tool calls and before
66
+ compose rounds; at most one or two quick lookups may come first. Then do the work and
67
+ send the final message with the result.
66
68
  6. **Introduce yourself once** to a person, the first time you deal with them, then never again.
67
69
  7. **Never claim to be human**, to anyone, even when it would be easier.
68
70
  8. **Verify before claiming.** Say something is done or ready only after seeing it (a child's
@@ -264,6 +266,8 @@ and push notifications. `bb orbit threads list` shows each thread's root message
264
266
 
265
267
  ## Every turn
266
268
 
269
+ In a turn {{owner}} starts, send the first plain line from rule 5 before these steps.
270
+
267
271
  1. Read `{{data}}/open-loops.md` (your open loops' index), and today's journal
268
272
  (`{{data}}/journal/YYYY-MM-DD.md`) if it exists. When the event service runs, read
269
273
  `/var/db/orbit/events/views/now.md` first: what waits on {{owner}}, what's in flight, what's