nexo-brain 7.1.8 → 7.2.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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +2 -2
- package/src/auto_update.py +239 -8
- package/src/autonomy_mandate.py +62 -0
- package/src/checkpoint_policy.py +302 -0
- package/src/cli.py +229 -0
- package/src/core_schedule_controls.py +66 -0
- package/src/doctor/providers/boot.py +190 -0
- package/src/evolution_cycle.py +4 -0
- package/src/guardian_runtime_config.py +98 -0
- package/src/hook_guardrails.py +148 -2
- package/src/hooks/g1_enforcer.py +305 -0
- package/src/hooks/post-compact.sh +34 -0
- package/src/hooks/post_tool_use.py +32 -3
- package/src/hooks/pre-compact.sh +14 -0
- package/src/paths.py +10 -0
- package/src/plugins/adaptive_mode.py +26 -2
- package/src/plugins/protocol.py +24 -0
- package/src/plugins/recover.py +42 -10
- package/src/plugins/update.py +47 -17
- package/src/plugins/workflow.py +65 -0
- package/src/public_contribution.py +51 -5
- package/src/r34_identity_coherence.py +31 -8
- package/src/script_registry.py +14 -6
- package/src/scripts/nexo-watchdog.sh +7 -1
- package/src/scripts/prune_runtime_backups.py +376 -0
- package/src/skills/run-release-final-audit/guide.md +3 -1
- package/src/skills/run-release-final-audit/script.py +2 -0
- package/src/tools_sessions.py +64 -3
- package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -1
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# nexo: name=prune-runtime-backups
|
|
3
|
+
# nexo: description=Rotate technical rollback snapshots under runtime/backups by family. Never touches business (shopify-backups) or hourly_db (nexo-backup.sh) artifacts.
|
|
4
|
+
# nexo: category=maintenance
|
|
5
|
+
# nexo: runtime=python
|
|
6
|
+
# nexo: timeout=300
|
|
7
|
+
# nexo: idempotent=true
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
prune_runtime_backups.py — NEXO backup retention by class.
|
|
11
|
+
|
|
12
|
+
Separates *technical* rollback snapshots (throwaway, produced by the installer,
|
|
13
|
+
updater and backfills) from *operational* snapshots (shopify-backups, hourly
|
|
14
|
+
DB dumps, weekly archives) so the former can be rotated without risk to the
|
|
15
|
+
latter.
|
|
16
|
+
|
|
17
|
+
Target: $NEXO_HOME/runtime/backups/ (default ~/.nexo/runtime/backups)
|
|
18
|
+
|
|
19
|
+
Class taxonomy (prefix-based) and retention policy:
|
|
20
|
+
|
|
21
|
+
TECHNICAL (rollback snapshots, produced by installer/updater/backfills):
|
|
22
|
+
Prefixes:
|
|
23
|
+
pre-update-*, pre-autoupdate-*, pre-backfill-owner-*,
|
|
24
|
+
pre-runtime-sync-*, pre-sleep-wrapper-*, pre-obs-clean-*,
|
|
25
|
+
pre-import-user-data-*, pre-backfill-*,
|
|
26
|
+
code-tree-*, runtime-tree-*,
|
|
27
|
+
app-install-*, app-reinstall-*, desktop-local-install-*,
|
|
28
|
+
packaged-code-f06-conflicts-*, legacy-shim-conflicts-*,
|
|
29
|
+
legacy-personal-brain-db-stubs-*, legacy-root-db-stubs-*,
|
|
30
|
+
codex-live-sync-*, layout-loop-cleanup-*,
|
|
31
|
+
aux-launchagents-restore-*, live-sync-*, manual-*,
|
|
32
|
+
personal-script-legacy-prefix-*, plist-f06fix-*,
|
|
33
|
+
retired-personal-scripts-*, retired-personal-skills-*,
|
|
34
|
+
runtime-core-sync-*, pre-freshinstall-*
|
|
35
|
+
Retention (per prefix family): keep last N_RECENT + 1 per month for
|
|
36
|
+
MONTHLY_WINDOW_DAYS. Older than that and outside the 10 most recent
|
|
37
|
+
are eligible for deletion.
|
|
38
|
+
|
|
39
|
+
HOURLY_DB (sqlite dumps, managed by nexo-backup.sh):
|
|
40
|
+
Prefix: nexo-YYYY-MM-DD-HHMM.db in runtime/backups/ root
|
|
41
|
+
These are already rotated by nexo-backup.sh (48h retention). We skip
|
|
42
|
+
them here to avoid double-rotation logic.
|
|
43
|
+
|
|
44
|
+
WEEKLY_DB (weekly/ directory):
|
|
45
|
+
Already rotated by nexo-backup.sh (90d retention). Skip.
|
|
46
|
+
|
|
47
|
+
BUSINESS (shopify-backups/ and similar protected directories):
|
|
48
|
+
Prefix/name: shopify-backups (directory). Never touched.
|
|
49
|
+
|
|
50
|
+
Usage:
|
|
51
|
+
prune_runtime_backups.py # dry-run summary
|
|
52
|
+
prune_runtime_backups.py --apply # actually delete
|
|
53
|
+
prune_runtime_backups.py --json # machine-readable report
|
|
54
|
+
prune_runtime_backups.py --recent 10 # override N_RECENT
|
|
55
|
+
prune_runtime_backups.py --window-days 90
|
|
56
|
+
prune_runtime_backups.py --only pre-backfill-owner # restrict family
|
|
57
|
+
|
|
58
|
+
Exit codes:
|
|
59
|
+
0 success (or nothing to prune)
|
|
60
|
+
1 bad arguments or fatal I/O error
|
|
61
|
+
"""
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import argparse
|
|
65
|
+
import json
|
|
66
|
+
import os
|
|
67
|
+
import re
|
|
68
|
+
import shutil
|
|
69
|
+
import sys
|
|
70
|
+
import time
|
|
71
|
+
from datetime import datetime, timezone
|
|
72
|
+
from pathlib import Path
|
|
73
|
+
from typing import Iterable
|
|
74
|
+
|
|
75
|
+
# Technical prefixes. Order defines precedence when a name matches several.
|
|
76
|
+
TECHNICAL_PREFIXES = (
|
|
77
|
+
"pre-update-",
|
|
78
|
+
"pre-autoupdate-",
|
|
79
|
+
"pre-backfill-owner-",
|
|
80
|
+
"pre-backfill-",
|
|
81
|
+
"pre-runtime-sync-",
|
|
82
|
+
"pre-sleep-wrapper-",
|
|
83
|
+
"pre-obs-clean-",
|
|
84
|
+
"pre-import-user-data-",
|
|
85
|
+
"pre-freshinstall-",
|
|
86
|
+
"code-tree-",
|
|
87
|
+
"runtime-tree-",
|
|
88
|
+
"app-install-",
|
|
89
|
+
"app-reinstall-",
|
|
90
|
+
"desktop-local-install-",
|
|
91
|
+
"packaged-code-f06-conflicts-",
|
|
92
|
+
"legacy-shim-conflicts-",
|
|
93
|
+
"legacy-personal-brain-db-stubs-",
|
|
94
|
+
"legacy-root-db-stubs-",
|
|
95
|
+
"codex-live-sync-",
|
|
96
|
+
"layout-loop-cleanup-",
|
|
97
|
+
"aux-launchagents-restore-",
|
|
98
|
+
"live-sync-",
|
|
99
|
+
"manual-",
|
|
100
|
+
"personal-script-legacy-prefix-",
|
|
101
|
+
"plist-f06fix-",
|
|
102
|
+
"retired-personal-scripts-",
|
|
103
|
+
"retired-personal-skills-",
|
|
104
|
+
"runtime-core-sync-",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Entries that must never be considered for pruning.
|
|
108
|
+
PROTECTED_NAMES = {"shopify-backups", "weekly"}
|
|
109
|
+
# Hourly DB dumps at the root of runtime/backups — managed by nexo-backup.sh.
|
|
110
|
+
HOURLY_DB_RE = re.compile(r"^nexo-\d{4}-\d{2}-\d{2}-\d{4}\.db$")
|
|
111
|
+
# Big ad-hoc DB files at the root — rare, include for reporting but never auto-prune.
|
|
112
|
+
ROOT_DB_RE = re.compile(r"^(pre-obs-clean|pre-sleep-wrapper-apply|pre-.*)-\d{4}-\d{2}-\d{2}-\d{4}\.db$")
|
|
113
|
+
|
|
114
|
+
# Timestamp patterns embedded in directory names.
|
|
115
|
+
TS_PATTERNS = (
|
|
116
|
+
# e.g. 2026-04-20-0427 or 2026-04-20-042733
|
|
117
|
+
re.compile(r"(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})?$"),
|
|
118
|
+
# e.g. 20260420-083106
|
|
119
|
+
re.compile(r"(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})?$"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def default_nexo_home() -> Path:
|
|
124
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_timestamp(name: str) -> datetime | None:
|
|
128
|
+
for pat in TS_PATTERNS:
|
|
129
|
+
m = pat.search(name)
|
|
130
|
+
if not m:
|
|
131
|
+
continue
|
|
132
|
+
parts = [int(x) for x in m.groups() if x is not None]
|
|
133
|
+
# year, month, day, hour, minute, [second]
|
|
134
|
+
try:
|
|
135
|
+
if len(parts) == 5:
|
|
136
|
+
y, mo, d, h, mi = parts
|
|
137
|
+
s = 0
|
|
138
|
+
else:
|
|
139
|
+
y, mo, d, h, mi, s = parts
|
|
140
|
+
return datetime(y, mo, d, h, mi, s, tzinfo=timezone.utc)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return None
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def classify(name: str) -> tuple[str, str] | None:
|
|
147
|
+
"""Return (class, family) or None if the entry should be ignored."""
|
|
148
|
+
if name in PROTECTED_NAMES:
|
|
149
|
+
return ("BUSINESS", name)
|
|
150
|
+
if HOURLY_DB_RE.match(name):
|
|
151
|
+
return ("HOURLY_DB", "nexo-db")
|
|
152
|
+
if ROOT_DB_RE.match(name):
|
|
153
|
+
return ("ROOT_DB", "root-db")
|
|
154
|
+
for pref in TECHNICAL_PREFIXES:
|
|
155
|
+
if name.startswith(pref):
|
|
156
|
+
return ("TECHNICAL", pref.rstrip("-"))
|
|
157
|
+
# Unknown: report but never touch.
|
|
158
|
+
return ("UNKNOWN", "unknown")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def dir_size_bytes(path: Path) -> int:
|
|
162
|
+
total = 0
|
|
163
|
+
try:
|
|
164
|
+
for root, _dirs, files in os.walk(path, onerror=lambda _e: None):
|
|
165
|
+
for fn in files:
|
|
166
|
+
fp = Path(root) / fn
|
|
167
|
+
try:
|
|
168
|
+
total += fp.stat().st_size
|
|
169
|
+
except OSError:
|
|
170
|
+
pass
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
return total
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def human_size(n: int) -> str:
|
|
177
|
+
for unit in ("B", "K", "M", "G", "T"):
|
|
178
|
+
if n < 1024:
|
|
179
|
+
return f"{n:.1f}{unit}"
|
|
180
|
+
n /= 1024
|
|
181
|
+
return f"{n:.1f}P"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def gather_entries(backups_root: Path) -> list[dict]:
|
|
185
|
+
items: list[dict] = []
|
|
186
|
+
for entry in backups_root.iterdir():
|
|
187
|
+
name = entry.name
|
|
188
|
+
cls = classify(name)
|
|
189
|
+
if cls is None:
|
|
190
|
+
continue
|
|
191
|
+
klass, family = cls
|
|
192
|
+
ts = parse_timestamp(name)
|
|
193
|
+
if ts is None:
|
|
194
|
+
try:
|
|
195
|
+
ts = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
|
|
196
|
+
except OSError:
|
|
197
|
+
ts = None
|
|
198
|
+
size = dir_size_bytes(entry) if entry.is_dir() else entry.stat().st_size
|
|
199
|
+
items.append({
|
|
200
|
+
"name": name,
|
|
201
|
+
"path": str(entry),
|
|
202
|
+
"class": klass,
|
|
203
|
+
"family": family,
|
|
204
|
+
"ts": ts,
|
|
205
|
+
"size": size,
|
|
206
|
+
"is_dir": entry.is_dir(),
|
|
207
|
+
})
|
|
208
|
+
return items
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def plan_prunes(
|
|
212
|
+
items: list[dict],
|
|
213
|
+
*,
|
|
214
|
+
n_recent: int,
|
|
215
|
+
window_days: int,
|
|
216
|
+
only: str | None,
|
|
217
|
+
) -> tuple[list[dict], list[dict]]:
|
|
218
|
+
"""Return (to_delete, to_keep) among TECHNICAL items only."""
|
|
219
|
+
now = datetime.now(tz=timezone.utc)
|
|
220
|
+
to_delete: list[dict] = []
|
|
221
|
+
to_keep: list[dict] = []
|
|
222
|
+
by_family: dict[str, list[dict]] = {}
|
|
223
|
+
for it in items:
|
|
224
|
+
if it["class"] != "TECHNICAL":
|
|
225
|
+
continue
|
|
226
|
+
if only and it["family"] != only:
|
|
227
|
+
continue
|
|
228
|
+
by_family.setdefault(it["family"], []).append(it)
|
|
229
|
+
|
|
230
|
+
for family, group in by_family.items():
|
|
231
|
+
group.sort(key=lambda x: (x["ts"] or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)
|
|
232
|
+
# Keep the N_RECENT most recent unconditionally.
|
|
233
|
+
keep_recent = group[:n_recent]
|
|
234
|
+
older = group[n_recent:]
|
|
235
|
+
recent_ts = {id(x) for x in keep_recent}
|
|
236
|
+
# From older, keep one per (year, month) if within window_days. The
|
|
237
|
+
# rest are pruned.
|
|
238
|
+
seen_months: set[tuple[int, int]] = set()
|
|
239
|
+
for it in older:
|
|
240
|
+
ts = it["ts"]
|
|
241
|
+
age_days = (now - ts).days if ts else 10_000
|
|
242
|
+
if age_days <= window_days and ts is not None:
|
|
243
|
+
ym = (ts.year, ts.month)
|
|
244
|
+
if ym not in seen_months:
|
|
245
|
+
seen_months.add(ym)
|
|
246
|
+
to_keep.append(it)
|
|
247
|
+
continue
|
|
248
|
+
to_delete.append(it)
|
|
249
|
+
to_keep.extend(keep_recent)
|
|
250
|
+
return to_delete, to_keep
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def run(args: argparse.Namespace) -> int:
|
|
254
|
+
backups_root = Path(args.root or (default_nexo_home() / "runtime" / "backups"))
|
|
255
|
+
if not backups_root.is_dir():
|
|
256
|
+
print(f"ERROR: backups root not found: {backups_root}", file=sys.stderr)
|
|
257
|
+
return 1
|
|
258
|
+
items = gather_entries(backups_root)
|
|
259
|
+
tech_items = [i for i in items if i["class"] == "TECHNICAL"]
|
|
260
|
+
biz_items = [i for i in items if i["class"] == "BUSINESS"]
|
|
261
|
+
hourly_items = [i for i in items if i["class"] == "HOURLY_DB"]
|
|
262
|
+
root_db_items = [i for i in items if i["class"] == "ROOT_DB"]
|
|
263
|
+
unknown_items = [i for i in items if i["class"] == "UNKNOWN"]
|
|
264
|
+
|
|
265
|
+
to_delete, to_keep = plan_prunes(
|
|
266
|
+
items,
|
|
267
|
+
n_recent=args.recent,
|
|
268
|
+
window_days=args.window_days,
|
|
269
|
+
only=args.only,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
total_all = sum(i["size"] for i in items)
|
|
273
|
+
total_del = sum(i["size"] for i in to_delete)
|
|
274
|
+
|
|
275
|
+
report = {
|
|
276
|
+
"root": str(backups_root),
|
|
277
|
+
"now_utc": datetime.now(tz=timezone.utc).isoformat(),
|
|
278
|
+
"policy": {
|
|
279
|
+
"n_recent": args.recent,
|
|
280
|
+
"window_days": args.window_days,
|
|
281
|
+
"only": args.only,
|
|
282
|
+
},
|
|
283
|
+
"totals": {
|
|
284
|
+
"all_bytes": total_all,
|
|
285
|
+
"all_human": human_size(total_all),
|
|
286
|
+
"delete_bytes": total_del,
|
|
287
|
+
"delete_human": human_size(total_del),
|
|
288
|
+
"delete_count": len(to_delete),
|
|
289
|
+
},
|
|
290
|
+
"counts_by_class": {
|
|
291
|
+
"technical": len(tech_items),
|
|
292
|
+
"business": len(biz_items),
|
|
293
|
+
"hourly_db": len(hourly_items),
|
|
294
|
+
"root_db": len(root_db_items),
|
|
295
|
+
"unknown": len(unknown_items),
|
|
296
|
+
},
|
|
297
|
+
"delete": [
|
|
298
|
+
{"name": i["name"], "family": i["family"], "size": i["size"],
|
|
299
|
+
"ts": i["ts"].isoformat() if i["ts"] else None}
|
|
300
|
+
for i in sorted(to_delete, key=lambda x: x["size"], reverse=True)
|
|
301
|
+
],
|
|
302
|
+
"keep_sample": [
|
|
303
|
+
{"name": i["name"], "family": i["family"], "size": i["size"],
|
|
304
|
+
"ts": i["ts"].isoformat() if i["ts"] else None}
|
|
305
|
+
for i in sorted(to_keep, key=lambda x: (x["family"], x["ts"] or datetime.min.replace(tzinfo=timezone.utc)), reverse=True)[:30]
|
|
306
|
+
],
|
|
307
|
+
"unknown": [i["name"] for i in unknown_items],
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if args.json:
|
|
311
|
+
print(json.dumps(report, indent=2))
|
|
312
|
+
else:
|
|
313
|
+
print(f"NEXO backup prune — root: {backups_root}")
|
|
314
|
+
print(f" total on disk: {human_size(total_all)} ({len(items)} entries)")
|
|
315
|
+
print(f" technical: {len(tech_items)}")
|
|
316
|
+
print(f" business: {len(biz_items)} (protected)")
|
|
317
|
+
print(f" hourly_db: {len(hourly_items)} (managed by nexo-backup.sh)")
|
|
318
|
+
print(f" root_db: {len(root_db_items)} (never auto-pruned)")
|
|
319
|
+
print(f" unknown: {len(unknown_items)}")
|
|
320
|
+
print(f" policy: keep {args.recent} most-recent + 1 per month within {args.window_days}d")
|
|
321
|
+
if args.only:
|
|
322
|
+
print(f" restricted to family: {args.only}")
|
|
323
|
+
print()
|
|
324
|
+
print(f" would free: {human_size(total_del)} ({len(to_delete)} entries)")
|
|
325
|
+
if to_delete:
|
|
326
|
+
print("\nTOP 20 candidates:")
|
|
327
|
+
for it in sorted(to_delete, key=lambda x: x["size"], reverse=True)[:20]:
|
|
328
|
+
ts = it["ts"].strftime("%Y-%m-%d %H:%M") if it["ts"] else "?"
|
|
329
|
+
print(f" - {human_size(it['size']):>8} {ts} {it['name']}")
|
|
330
|
+
if unknown_items:
|
|
331
|
+
print("\nUNKNOWN entries (never pruned — review manually):")
|
|
332
|
+
for it in unknown_items[:20]:
|
|
333
|
+
print(f" ? {it['name']}")
|
|
334
|
+
|
|
335
|
+
if not args.apply:
|
|
336
|
+
if not args.json:
|
|
337
|
+
print("\n(dry-run: pass --apply to delete)")
|
|
338
|
+
return 0
|
|
339
|
+
|
|
340
|
+
deleted = 0
|
|
341
|
+
failed = 0
|
|
342
|
+
freed = 0
|
|
343
|
+
for it in to_delete:
|
|
344
|
+
p = Path(it["path"])
|
|
345
|
+
try:
|
|
346
|
+
if p.is_dir():
|
|
347
|
+
shutil.rmtree(p)
|
|
348
|
+
else:
|
|
349
|
+
p.unlink()
|
|
350
|
+
deleted += 1
|
|
351
|
+
freed += it["size"]
|
|
352
|
+
except OSError as e:
|
|
353
|
+
failed += 1
|
|
354
|
+
print(f"WARN: failed to delete {p}: {e}", file=sys.stderr)
|
|
355
|
+
print(f"\nDELETED {deleted} entries, freed {human_size(freed)}, failures: {failed}")
|
|
356
|
+
return 0 if failed == 0 else 1
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def main() -> int:
|
|
360
|
+
ap = argparse.ArgumentParser(description="NEXO runtime backups prune (technical rollback tiers).")
|
|
361
|
+
ap.add_argument("--root", help="override runtime/backups path")
|
|
362
|
+
ap.add_argument("--apply", action="store_true", help="actually delete (default is dry-run)")
|
|
363
|
+
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
|
364
|
+
ap.add_argument("--recent", type=int, default=10, help="N most recent per family to always keep (default: 10)")
|
|
365
|
+
ap.add_argument("--window-days", type=int, default=90, help="month-spaced retention window (default: 90)")
|
|
366
|
+
ap.add_argument("--only", help="restrict to one technical family (e.g. 'pre-backfill-owner')")
|
|
367
|
+
args = ap.parse_args()
|
|
368
|
+
try:
|
|
369
|
+
return run(args)
|
|
370
|
+
except KeyboardInterrupt:
|
|
371
|
+
print("interrupted", file=sys.stderr)
|
|
372
|
+
return 1
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
sys.exit(main())
|
|
@@ -10,7 +10,9 @@ Use this before claiming a release/publication is closed when you need a live ch
|
|
|
10
10
|
|
|
11
11
|
## Gotchas
|
|
12
12
|
- A missing auto-resolved contract is a real blocker for the final release audit.
|
|
13
|
-
- Smoke is version-line scoped.
|
|
13
|
+
- Smoke is version-line scoped. The audit now requires a passing smoke artifact for the current version during final closeout, and the contract can tighten it further with `smoke.required_groups` and `smoke.max_age_hours`.
|
|
14
|
+
- Contracts may declare `critical_surfaces` to open installed/user-facing files or directories and verify markers before publication is considered closed.
|
|
15
|
+
- Contracts may declare `publication.status`, `publication.checklist_complete`, and `publication.blockers`; any open `high`/`critical` blocker now keeps publication blocked.
|
|
14
16
|
- The script is read-only except for the optional official `nexo update` step during `final_closeout`; it still does not bump versions, tag, publish, or edit website worktrees.
|
|
15
17
|
- `final_closeout` is intentionally stricter than the repo-only readiness pass: it fails if the release task was not closed with evidence or if its `change_log` row is missing.
|
|
16
18
|
- If the touched area includes bootstrap, startup, or public claims, finish with the manual watchpoints in `docs/client-parity-checklist.md`.
|
|
@@ -245,6 +245,8 @@ def main() -> int:
|
|
|
245
245
|
readiness_cmd.append("--require-contract-complete")
|
|
246
246
|
elif require_contract_complete:
|
|
247
247
|
print("[release-final-audit] require_contract_complete ignored because contract=none")
|
|
248
|
+
if include_smoke or final_closeout:
|
|
249
|
+
readiness_cmd.append("--require-smoke")
|
|
248
250
|
if final_closeout:
|
|
249
251
|
readiness_cmd.append("--final-closeout")
|
|
250
252
|
if protocol_task_id.strip():
|
package/src/tools_sessions.py
CHANGED
|
@@ -32,8 +32,38 @@ except Exception: # pragma: no cover - optional runtime dependency
|
|
|
32
32
|
# Threads are daemon=True so they die when the MCP server process exits.
|
|
33
33
|
|
|
34
34
|
KEEPALIVE_INTERVAL = 600 # 10 min — well inside the 15-min TTL
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Path resolution moved to lazy functions (AUDITOR-V700-PASS2 §11, B10 item
|
|
38
|
+
# 3). The prior module-level NEXO_HOME / SESSION_PORTABILITY_DIR constants
|
|
39
|
+
# were evaluated at import time, so tests that monkeypatched NEXO_HOME or
|
|
40
|
+
# paths.operations_dir() after import saw stale values. The ``__getattr__``
|
|
41
|
+
# hook below keeps ``tools_sessions.SESSION_PORTABILITY_DIR`` / ``.NEXO_HOME``
|
|
42
|
+
# working for attribute-style access (re-evaluated on every read). The
|
|
43
|
+
# existing ``monkeypatch.setattr(tools_sessions, "SESSION_PORTABILITY_DIR",
|
|
44
|
+
# ...)`` pattern in tests keeps working because setattr inserts into the
|
|
45
|
+
# module __dict__ and shadows __getattr__.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _nexo_home() -> Path:
|
|
49
|
+
return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _session_portability_dir() -> Path:
|
|
53
|
+
return paths.operations_dir() / "session-portability"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_LAZY_PATHS = {
|
|
57
|
+
"NEXO_HOME": _nexo_home,
|
|
58
|
+
"SESSION_PORTABILITY_DIR": _session_portability_dir,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def __getattr__(name: str):
|
|
63
|
+
resolver = _LAZY_PATHS.get(name)
|
|
64
|
+
if resolver is None:
|
|
65
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
66
|
+
return resolver()
|
|
37
67
|
|
|
38
68
|
_keepalive_threads: dict[str, threading.Event] = {} # sid → stop_event
|
|
39
69
|
|
|
@@ -264,7 +294,7 @@ def handle_session_export_bundle(sid: str = "", path: str = "") -> str:
|
|
|
264
294
|
return json.dumps(bundle, ensure_ascii=False)
|
|
265
295
|
|
|
266
296
|
session_id = bundle["session"]["sid"]
|
|
267
|
-
export_path = Path(path).expanduser() if path else (
|
|
297
|
+
export_path = Path(path).expanduser() if path else (_session_portability_dir() / f"{session_id}.json")
|
|
268
298
|
export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
269
299
|
export_path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + "\n")
|
|
270
300
|
return json.dumps(
|
|
@@ -556,6 +586,27 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
|
|
|
556
586
|
def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
|
|
557
587
|
"""Inner body of handle_heartbeat — wrapped by tool_span above."""
|
|
558
588
|
from db import get_db, update_last_heartbeat_ts
|
|
589
|
+
|
|
590
|
+
mandate_state = None
|
|
591
|
+
if context_hint:
|
|
592
|
+
try:
|
|
593
|
+
from autonomy_mandate import maybe_ingest_from_text
|
|
594
|
+
|
|
595
|
+
mandate_state = maybe_ingest_from_text(
|
|
596
|
+
context_hint,
|
|
597
|
+
session_id=sid,
|
|
598
|
+
source="heartbeat",
|
|
599
|
+
)
|
|
600
|
+
except Exception:
|
|
601
|
+
mandate_state = None
|
|
602
|
+
if mandate_state is None:
|
|
603
|
+
try:
|
|
604
|
+
from autonomy_mandate import load_state
|
|
605
|
+
|
|
606
|
+
mandate_state = load_state()
|
|
607
|
+
except Exception:
|
|
608
|
+
mandate_state = None
|
|
609
|
+
|
|
559
610
|
update_session(sid, task)
|
|
560
611
|
# v6.0.1 — stamp last_heartbeat_ts so the PostToolUse hook can
|
|
561
612
|
# decide whether to surface a pending-inbox reminder on autopilot
|
|
@@ -602,6 +653,16 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
|
|
|
602
653
|
except Exception:
|
|
603
654
|
pass
|
|
604
655
|
|
|
656
|
+
try:
|
|
657
|
+
from autonomy_mandate import format_execution_latch_notice
|
|
658
|
+
|
|
659
|
+
latch_notice = format_execution_latch_notice(sid, state=mandate_state)
|
|
660
|
+
if latch_notice:
|
|
661
|
+
parts.append("")
|
|
662
|
+
parts.append(latch_notice)
|
|
663
|
+
except Exception:
|
|
664
|
+
pass
|
|
665
|
+
|
|
605
666
|
# Incremental diary draft — accumulate every heartbeat, full UPSERT every 5
|
|
606
667
|
_hb_count = 0 # Hoisted for Layer 3 DIARY_OVERDUE signal
|
|
607
668
|
try:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Protocol reminder for [[task_id]]: keep `nexo_heartbeat(...)` current and close
|
|
1
|
+
Protocol reminder for [[task_id]]: keep `nexo_heartbeat(...)` current and do not close the conversation optimistically. Finish with `nexo_task_close(...)` plus concrete evidence before saying it is resolved.[[change_note]][[closeout_note]]
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R14 post-user-correction: the last user message was classified as a correction (or carried strongly negative valence) and three tool calls have elapsed without a nexo_learning_add. Capture the rule you just learned NOW via nexo_learning_add(category=..., title=..., content=..., reasoning=..., prevention=...). The auto_capture hook fires in parallel, but this reminder stays active until you either call learning_add or acknowledge the correction via nexo_cognitive_trust(event='correction'). Do not produce visible text for this reminder — act on it. After handling the silent action, continue with your previous task; this reminder is silent context, not a new instruction.
|
|
1
|
+
R14 post-user-correction: the last user message was classified as a correction (or carried strongly negative valence) and three tool calls have elapsed without a nexo_learning_add. Capture the rule you just learned NOW via nexo_learning_add(category=..., title=..., content=..., reasoning=..., prevention=...). If the correction led to real edits, close the task with `nexo_task_close(...)` plus concrete evidence and let that closeout capture the `change_log`; if you cannot finish cleanly in this turn, use `followup_needed=true` on the closeout instead of ending the conversation loosely. The auto_capture hook fires in parallel, but this reminder stays active until you either call learning_add or acknowledge the correction via nexo_cognitive_trust(event='correction'). Do not produce visible text for this reminder — act on it. After handling the silent action, continue with your previous task; this reminder is silent context, not a new instruction.
|