@oneie/claude 0.6.0 → 0.7.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.
Files changed (111) hide show
  1. package/agents/w1-recon.md +9 -4
  2. package/agents/w2-decide.md +51 -28
  3. package/agents/w3-edit.md +70 -78
  4. package/agents/w4-verify.md +164 -69
  5. package/commands/browser.md +98 -35
  6. package/commands/cc-connect.md +6 -0
  7. package/commands/chat.md +13 -1
  8. package/commands/close.md +45 -19
  9. package/commands/create.md +20 -10
  10. package/commands/db-sync.md +5 -1
  11. package/commands/deploy.md +174 -87
  12. package/commands/do-autonomous.md +31 -20
  13. package/commands/do-improve.md +4 -4
  14. package/commands/do-show.md +4 -3
  15. package/commands/do.md +53 -13
  16. package/commands/go.md +75 -0
  17. package/commands/improve.md +4 -2
  18. package/commands/kill.md +6 -8
  19. package/commands/notify.md +15 -2
  20. package/commands/oo-push.md +24 -9
  21. package/commands/rag.md +6 -0
  22. package/commands/release.md +46 -25
  23. package/commands/restart.md +5 -4
  24. package/commands/see.md +45 -35
  25. package/commands/skill-create.md +38 -5
  26. package/commands/sync.md +21 -11
  27. package/hooks/scripts/auto-continue.sh +70 -0
  28. package/hooks/scripts/branch-pin.sh +85 -0
  29. package/hooks/scripts/git-add-guard.sh +16 -5
  30. package/hooks/scripts/post-edit-check.sh +16 -0
  31. package/hooks/scripts/session-start.sh +13 -8
  32. package/hooks/scripts/sync-priority-todo.sh +5 -0
  33. package/package.json +1 -1
  34. package/rules/api.md +9 -0
  35. package/rules/design.md +13 -3
  36. package/rules/documentation.md +30 -71
  37. package/rules/engine.md +8 -1
  38. package/rules/ui.md +5 -1
  39. package/scripts/__pycache__/do-rank.cpython-313.pyc +0 -0
  40. package/scripts/browser-check.mjs +44 -103
  41. package/scripts/cc-events-proof.sh +13 -12
  42. package/scripts/chrome.mjs +381 -0
  43. package/scripts/do-analyze.sh +109 -4
  44. package/scripts/do-auto.sh +350 -43
  45. package/scripts/do-consumer-sweep.sh +72 -0
  46. package/scripts/do-derives-check.sh +88 -0
  47. package/scripts/do-fleet.sh +50 -1
  48. package/scripts/do-killswitch-audit.py +33 -14
  49. package/scripts/do-promise-lint.sh +150 -4
  50. package/scripts/do-promise-settle.sh +131 -9
  51. package/scripts/do-prove.sh +197 -17
  52. package/scripts/do-rank.py +606 -20
  53. package/scripts/do-rubric.py +21 -2
  54. package/scripts/do-signal.sh +157 -17
  55. package/scripts/do-smoke.sh +9 -0
  56. package/scripts/do-substrate-check.sh +49 -0
  57. package/scripts/do-tasks-wire-check.sh +81 -0
  58. package/scripts/do-tier.sh +90 -5
  59. package/scripts/do-ui-gate.sh +332 -0
  60. package/scripts/do-untracked-gate.sh +231 -0
  61. package/scripts/do-walk.sh +344 -0
  62. package/scripts/factory-check.sh +747 -0
  63. package/scripts/factory-repo.sh +1361 -0
  64. package/scripts/factory-tasks-check.sh +85 -0
  65. package/scripts/outcome-pull.ts +279 -0
  66. package/scripts/promise-manifest.mjs +167 -0
  67. package/scripts/reconcile-allow.txt +5 -0
  68. package/scripts/rubric-weights.json +64 -0
  69. package/scripts/spine-canary.sh +20 -0
  70. package/scripts/typedb-cluster-status.sh +128 -0
  71. package/scripts/typedb-env.sh +182 -0
  72. package/scripts/typedb-flap-recorder.sh +85 -0
  73. package/scripts/typedb-probes/containment-probe.py +88 -0
  74. package/scripts/typedb-probes/panic-probe-version.py +92 -0
  75. package/scripts/typedb-probes/panic-probe.py +77 -0
  76. package/scripts/typedb-scratch.sh +73 -0
  77. package/scripts/urls-lint.sh +58 -0
  78. package/scripts/w4-rubric.ts +96 -12
  79. package/scripts/wf-check.mjs +75 -0
  80. package/skills/ai-ui/SKILL.md +1 -1
  81. package/skills/astro/SKILL.md +177 -132
  82. package/skills/astro/add-content-collection.md +29 -13
  83. package/skills/astro/check-build.md +19 -13
  84. package/skills/astro/create-component.md +36 -9
  85. package/skills/astro/create-page.md +39 -10
  86. package/skills/astro/optimize-performance.md +36 -8
  87. package/skills/cli/SKILL.md +254 -220
  88. package/skills/dev/SKILL.md +1 -1
  89. package/skills/directory-autofill/SKILL.md +68 -0
  90. package/skills/docs/SKILL.md +4 -4
  91. package/skills/mcp/SKILL.md +186 -156
  92. package/skills/promise-make/SKILL.md +2 -2
  93. package/skills/puck/SKILL.md +465 -250
  94. package/skills/react19/SKILL.md +196 -296
  95. package/skills/reactflow/SKILL.md +387 -412
  96. package/skills/sdk/SKILL.md +167 -106
  97. package/skills/shadcn/SKILL.md +245 -201
  98. package/skills/signal/SKILL.md +5 -0
  99. package/skills/sui/SKILL.md +6 -1
  100. package/skills/tutorial/SKILL.md +15 -0
  101. package/skills/typedb/SKILL.md +427 -217
  102. package/skills/typedb/examples/python-patterns.py +11 -4
  103. package/skills/typedb/examples/query-patterns.tql +8 -3
  104. package/skills/typedb/examples/schema-patterns.tql +4 -2
  105. package/skills/typedb/reference/migration-2x-3x.md +19 -1
  106. package/skills/typedb/reference/python-driver.md +17 -1
  107. package/skills/voice/SKILL.md +117 -0
  108. package/skills/writer/SKILL.md +29 -12
  109. package/templates/template-feature.md +26 -1
  110. package/templates/template-tests.md +4 -0
  111. package/templates/template-todo.md +65 -10
@@ -25,10 +25,13 @@ Usage:
25
25
  do-rank.py --top N the N highest READY slugs, one per line
26
26
  """
27
27
  import json
28
+ import os
28
29
  import re
29
30
  import signal
30
31
  import subprocess
31
32
  import sys
33
+ import urllib.parse
34
+ import urllib.request
32
35
  from pathlib import Path
33
36
 
34
37
  # Exit cleanly when piped into head/etc. (closes our stdout early).
@@ -261,11 +264,284 @@ def tasks_view(active):
261
264
  mark = " ✓proven" if c["proven"] else ""
262
265
  out.append(f" {c['weight']:>3} {c['tags'][0]} + {c['tags'][1]:<16} (×{c['seen']}{mark})")
263
266
  out.append("")
264
- out.append(" → signal(receiver:\"tasks:announce\", payload:task, tags:task.tags); agents follow fn:*+topics, mark() moves weight")
267
+ out.append(" → signal(receiver:\"tasks:announce\", payload:task, tags:task.tags) — live; agents follow fn:*+topics, mark() moves weight")
265
268
  out.append(" seed: do-rank.py --tasks-json > text/tasks-seed.json · design: text/tasks-plan.md")
266
269
  return out
267
270
 
268
271
 
272
+ # Which doc-family suffixes an agent should load to work a task, in read order.
273
+ # The promise first (the contract), then how/what, then the cycles, then the spec.
274
+ _CONTEXT_SUFFIXES = ("", "-plan", "-features", "-ui", "-todo", "-docs")
275
+
276
+
277
+ def task_context_for(p):
278
+ """Build the context a picked-up task carries — the keystone for auto-pickup.
279
+
280
+ Returns (notes, context_docs):
281
+ • notes — human prose description → `thing.notes` (shown on the row / detail).
282
+ Fallback chain: title em-dash detail → outcome_asserts → title.
283
+ • context_docs— the doc *stems* that exist for this slug → `thing.task-context`
284
+ (multi-valued, one per stem). An agent reads these files to know
285
+ HOW to do the work — literally "get context" for `/do <slug>`.
286
+ """
287
+ slug, title = p["slug"], (p.get("title") or p["slug"])
288
+ # detail = the part after the em-dash / en-dash in the title ("Tasks — the loop…")
289
+ m = re.match(r"^\s*(.+?)\s+[—–-]\s+(.+)$", title)
290
+ detail = m.group(2).strip() if m else title.strip()
291
+ asserts = (p.get("outcome_asserts") or "").strip()
292
+ notes = detail
293
+ if asserts and asserts.lower() not in detail.lower():
294
+ notes = f"{detail}\n\nDone when: {asserts}" if detail else asserts
295
+ docs = [f"{slug}{sfx}" for sfx in _CONTEXT_SUFFIXES
296
+ if (TEXT / f"{slug}{sfx}.md").exists()]
297
+ return notes, docs
298
+
299
+
300
+ def _gateway_key():
301
+ """Same lookup as do-signal.sh's _gateway_key() — first file that has it wins.
302
+
303
+ GATEWAY_API_KEY, not SERVER_SECRET: /api/ask/<receiver> decides isServiceCaller off
304
+ env.GATEWAY_API_KEY (one.ie/web/src/pages/api/ask/[...receiver].ts). Presenting
305
+ SERVER_SECRET there resolves to no service caller, no ctx.ownerSlug, and every
306
+ tasks:* resolver answers {error:"workspace required"} — which this fetch used to
307
+ swallow as an empty board, so the ranker silently ran file-only forever."""
308
+ # UPDATE 2026-07-26 — prefer the WORLD key. The service-caller path described above is
309
+ # real but fragile: it grants identity only when the receiver's request schema declares
310
+ # `workspace`/`slug`, because /api/ask reads the nomination off validation.payload —
311
+ # i.e. AFTER zod, which strips undeclared keys. tasks:mine and tasks:everywhere did not
312
+ # declare it, so GATEWAY_API_KEY could never address them (fixed in 73dbeca7a, live only
313
+ # after a one-prod deploy).
314
+ #
315
+ # A world key (`one-`/`osk_`) has no such dependency: verifyWorldKey resolves it to an
316
+ # actual actor, so ctx.ownerSlug comes from the key itself and no nomination is needed.
317
+ # It is what the MCP server has always used (ONEIE_API_KEY — packages/mcp/src/env.ts)
318
+ # and it reads the board TODAY. Verified 2026-07-26: POST /api/ask/tasks:everywhere
319
+ # returns ok:true with the world key and "forbidden: authentication required" with
320
+ # GATEWAY_API_KEY. World key first; GATEWAY_API_KEY stays as the fallback.
321
+ candidates = [os.environ.get("DO_ENV_FILE", "")] if os.environ.get("DO_ENV_FILE") else []
322
+ candidates += [str(ROOT / "one.ie/web/.env"), str(ROOT / "one.ie/web/.secrets.generated.local"),
323
+ str(ROOT / "one.ie/web/.dev.vars")]
324
+ # An explicit env var wins over any file — how a fleet/CI run injects its own identity.
325
+ for var in ("ONEIE_API_KEY", "ONE_API_KEY", "GATEWAY_API_KEY"):
326
+ if os.environ.get(var):
327
+ return os.environ[var].strip()
328
+
329
+ # Rank by KEY GENERATION, not by which file we happened to read first. Both `one-` and
330
+ # legacy `osk_` authenticate (ok:true either way), but they resolve to DIFFERENT actors —
331
+ # and on 2026-07-26 the `osk_` key in one.ie/web/.env resolved to an actor that could see
332
+ # nothing, so every board read came back green-and-empty while the operator's own board
333
+ # showed 11 open tasks. A green empty read is the worst failure this script has: it is
334
+ # indistinguishable from "no work queued". So: modern world key > legacy > service secret.
335
+ found = {"one-": "", "osk_": "", "gw": ""}
336
+
337
+ def offer(tok):
338
+ if tok.startswith("one-"):
339
+ found["one-"] = found["one-"] or tok
340
+ elif tok.startswith("osk_"):
341
+ found["osk_"] = found["osk_"] or tok
342
+
343
+ # The working credential lives in the MCP server config, not in any repo .env — that is
344
+ # what the MCP tools authenticate with, and it is the one proven to read the board.
345
+ # Read it in place; never copy key bytes into a repo file.
346
+ mcp_cfg = Path.home() / ".claude.json"
347
+ if mcp_cfg.exists():
348
+ try:
349
+ def walk(o):
350
+ if isinstance(o, dict):
351
+ for k, v in o.items():
352
+ if k in ("ONEIE_API_KEY", "ONE_API_KEY") and isinstance(v, str) and v.strip():
353
+ offer(v.strip())
354
+ else:
355
+ walk(v)
356
+ elif isinstance(o, list):
357
+ for v in o:
358
+ walk(v)
359
+ walk(json.loads(mcp_cfg.read_text(errors="ignore")))
360
+ except Exception:
361
+ pass # a malformed config must never break ranking
362
+
363
+ for f in candidates:
364
+ p = Path(f)
365
+ if not p.exists():
366
+ continue
367
+ for line in p.read_text(errors="ignore").splitlines():
368
+ for var in ("ONEIE_API_KEY=", "ONE_API_KEY="):
369
+ if line.startswith(var):
370
+ offer(line.split("=", 1)[1].strip().strip('"'))
371
+ if line.startswith("GATEWAY_API_KEY=") and not found["gw"]:
372
+ found["gw"] = line.split("=", 1)[1].strip().strip('"')
373
+
374
+ return found["one-"] or found["osk_"] or found["gw"]
375
+
376
+
377
+ def fetch_tasks_everywhere(timeout=None):
378
+ """Board-origin open tasks — the substrate half of the ranked queue (tasks-do C2).
379
+
380
+ Returns (rows, reason): rows is the raw `tasks:everywhere` list, reason is "" on a
381
+ real answer or a SHORT diagnostic on any degrade. The ranker still never crashes and
382
+ still falls back to file-only — but the caller can now tell "no key configured" from
383
+ "auth rejected" from "the board is genuinely empty" (closed-loop rule: no silent
384
+ returns). TASKS_EVERYWHERE marker below is what the demo gate greps for."""
385
+ # tasks:everywhere is a four-phase read (actor groups → task scan → strength reads);
386
+ # measured 10.9s against a warm dev server. The old 5s default meant that even a
387
+ # correctly-authenticated ranker timed out every time — the auth bug was masking a
388
+ # latency bug.
389
+ #
390
+ # Two budgets, because this script has two very different callers. The SessionStart
391
+ # hook and the *-todo.md PostToolUse hook run it on EVERY session open and EVERY plan
392
+ # edit; blocking those for 25s to enrich a ranking that degrades gracefully is a bad
393
+ # trade, so hook-driven runs get a short budget and fall back to file-only. An explicit
394
+ # queue read (/do next, the fleet) waits for the real answer.
395
+ if timeout is None:
396
+ default = "4" if os.environ.get("DO_RANK_HOOK") else "25"
397
+ timeout = float(os.environ.get("DO_RANK_BOARD_TIMEOUT", default))
398
+ secret = _gateway_key()
399
+ if not secret:
400
+ return [], "no GATEWAY_API_KEY in one.ie/web/.env" # TASKS_EVERYWHERE: file-only fallback
401
+ url = os.environ.get("DO_SIGNAL_URL", "https://one.ie") + "/api/ask/tasks:everywhere"
402
+ # `workspace` is load-bearing, not decoration: /api/ask reads the service caller's
403
+ # nominated slug off payload.slug|payload.workspace and that becomes ctx.ownerSlug.
404
+ # Omit it and tasks:everywhere answers {ok:false,error:"forbidden: authentication
405
+ # required"} because a service call carries no session actor of its own.
406
+ ws = os.environ.get("CC_TASKS_WORKSPACE", "one")
407
+ body = json.dumps({"data": {"limit": 200, "workspace": ws}}).encode()
408
+ req = urllib.request.Request(
409
+ url, data=body, method="POST",
410
+ headers={
411
+ "Content-Type": "application/json",
412
+ "Authorization": f"Bearer {secret}",
413
+ # Cloudflare answers the default urllib UA with 403 "error code: 1010"
414
+ # (browser-integrity block) before the Worker ever runs. curl gets through,
415
+ # which is why do-signal.sh never hit this and the ranker always did.
416
+ "User-Agent": "one-do-rank/1.0 (+https://one.ie)",
417
+ },
418
+ )
419
+ try:
420
+ with urllib.request.urlopen(req, timeout=timeout) as r:
421
+ payload = json.loads(r.read().decode())
422
+ except urllib.error.HTTPError as e: # TASKS_EVERYWHERE: fetch failed — file-only fallback
423
+ detail = "CF bot block (UA rejected)" if e.code == 403 else ""
424
+ return [], f"HTTP {e.code}{' — ' + detail if detail else ''}"
425
+ except Exception as e: # TASKS_EVERYWHERE: fetch failed — file-only fallback
426
+ return [], f"unreachable ({type(e).__name__})"
427
+ if not isinstance(payload, dict):
428
+ return [], "malformed response (not an object)"
429
+ # /api/ask wraps every answer: {outcome, result:{ok, tasks}, signalId, receiver}.
430
+ # Reading ok/tasks off the TOP level (as this did) makes even a good answer look
431
+ # like a failure — the merge could never have fired, key or no key.
432
+ inner = payload.get("result")
433
+ inner = inner if isinstance(inner, dict) else payload
434
+ if not inner.get("ok"):
435
+ err = str(inner.get("error") or payload.get("error") or payload.get("outcome") or "unknown")
436
+ hint = ""
437
+ if "authentication" in err or err == "workspace required":
438
+ # This used to read "local key may not match prod", which is WRONG and cost two
439
+ # sessions chasing a secret that was never broken (73dbeca7a). A `forbidden` here
440
+ # is almost never the key: /api/ask resolves a service caller's workspace from
441
+ # validation.payload.slug|workspace — i.e. AFTER zod has run — and z.object()
442
+ # strips any key the request schema doesn't declare. If the receiver's schema
443
+ # omits `workspace`, `nominated` is undefined, ctx.ownerSlug stays empty, and you
444
+ # get this exact error no matter which key you present. Browser calls are immune
445
+ # (locals.slug supplies the workspace), so it fails only for the harness.
446
+ hint = (
447
+ " — check the RECEIVER SCHEMA before the key: /api/ask reads the nominated"
448
+ " workspace off validation.payload (post-zod), so a receiver whose request"
449
+ " schema omits `workspace` can never be addressed by a service caller."
450
+ " Verify the key separately against a receiver that DOES declare it."
451
+ )
452
+ return [], f"receiver refused: {err}{hint}"
453
+ rows = inner.get("tasks", [])
454
+ if not isinstance(rows, list):
455
+ return [], "malformed response (tasks is not a list)"
456
+ # tasks:everywhere answers "workspaces I BELONG TO" — assignment/follow/ownership. A key
457
+ # whose actor belongs to nothing (the MCP bootstrap agent is one) gets ok:true and an
458
+ # empty list while the operator's own board is full. That green-empty read is worse than
459
+ # a refusal: it is indistinguishable from "no work queued", and it is exactly how the
460
+ # ranker ran file-only for months. So an empty answer is not accepted as the final word —
461
+ # fall back to the WORKSPACE BOARD read, which is what /u/<slug>/tasks and the MCP
462
+ # tasks_list tool both make (GET /api/things?type=task&workspace=…). Verified 2026-07-26:
463
+ # everywhere → 0 rows, board → 11.
464
+ if not rows:
465
+ board, why = fetch_workspace_board(timeout=timeout)
466
+ if board:
467
+ return board, ""
468
+ if why:
469
+ return [], f"everywhere empty; board read also failed ({why})"
470
+ return rows, ""
471
+
472
+
473
+ def fetch_workspace_board(timeout=None, workspace=None):
474
+ """The workspace board — every task on it, not just what this actor subscribes to.
475
+
476
+ Same read the /u/<slug>/tasks page and the MCP tasks_list tool make. Returns
477
+ (rows, reason) on the same contract as fetch_tasks_everywhere: no silent returns."""
478
+ if timeout is None:
479
+ timeout = float(os.environ.get("DO_RANK_BOARD_TIMEOUT", "25"))
480
+ secret = _gateway_key()
481
+ if not secret:
482
+ return [], "no world key"
483
+ ws = workspace or os.environ.get("CC_TASKS_WORKSPACE", "one")
484
+ base = os.environ.get("DO_SIGNAL_URL", "https://one.ie")
485
+ q = urllib.parse.urlencode({"type": "task", "workspace": ws, "status": "open", "limit": 200})
486
+ req = urllib.request.Request(
487
+ f"{base}/api/things?{q}", method="GET",
488
+ headers={
489
+ "Authorization": f"Bearer {secret}",
490
+ # Cloudflare 403s the default urllib UA before the Worker runs.
491
+ "User-Agent": "one-do-rank/1.0 (+https://one.ie)",
492
+ },
493
+ )
494
+ try:
495
+ with urllib.request.urlopen(req, timeout=timeout) as r:
496
+ payload = json.loads(r.read().decode())
497
+ except urllib.error.HTTPError as e:
498
+ return [], f"HTTP {e.code}"
499
+ except Exception as e:
500
+ return [], f"unreachable ({type(e).__name__})"
501
+ rows = payload.get("things") if isinstance(payload, dict) else None
502
+ if not isinstance(rows, list):
503
+ return [], "malformed response (things is not a list)"
504
+ return rows, ""
505
+
506
+
507
+ def board_task_entry(row):
508
+ """Shape one tasks:everywhere row as a plan-like dict — the same keys tasks_view()/
509
+ tasks_seed() already read off a local todo's `plans[slug]` entry, so a board-created
510
+ task renders in the SAME ranked list with no second code path."""
511
+ tags = [t for t in (row.get("tags") or []) if not t.startswith(("workspace:", "@"))]
512
+ slug_tag = next((t[len("slug:"):] for t in tags if t.startswith("slug:")), "")
513
+ slug = slug_tag or str(row.get("id", "")).split(":", 1)[-1]
514
+ dept = classify_dept(slug, {"title": row.get("name", ""), "tags": tags})
515
+ weight = row.get("weight")
516
+ weight = max(1, min(100, round(weight))) if isinstance(weight, (int, float)) else 50
517
+ return {
518
+ "slug": slug, "title": row.get("name", slug), "outcome_asserts": "",
519
+ "tier": "?", "tags": tags, "priority": row.get("priority", 0) or 0,
520
+ "depends_on": [], "done_boxes": 0, "total_boxes": 0, "is_done": False,
521
+ "gated": False, "strategic": 0, "proximity": 0.0, "momentum": 0.0,
522
+ "_dirs": [], "cycles": [], "dept": dept, "leverage": 0,
523
+ "weight": weight, "waiting_on": [], "_board_origin": True,
524
+ }
525
+
526
+
527
+ def merge_board_tasks(active, plans):
528
+ """Merge tasks:everywhere rows into the local `active` list. A board row carrying a
529
+ `slug:` tag that matches a local todo file is authoritative — the local file entry is
530
+ dropped (task-paths.json becomes a cache, not a second source of truth); a row with no
531
+ local match is a pure board-created task and is simply added. Empty/failed fetch is a
532
+ no-op — file-only ranking, unchanged."""
533
+ rows, reason = fetch_tasks_everywhere()
534
+ if not rows:
535
+ why = reason or "board is empty (0 open tasks)"
536
+ print(f"[do-rank] tasks:everywhere: 0 rows — {why} — file-only fallback", file=sys.stderr)
537
+ return active
538
+ board_entries = [board_task_entry(r) for r in rows]
539
+ matched_slugs = {e["slug"] for e in board_entries if e["slug"] in plans}
540
+ print(f"[do-rank] tasks:everywhere: merged {len(board_entries)} row(s), "
541
+ f"{len(matched_slugs)} superseding a local todo", file=sys.stderr)
542
+ return [p for p in active if p["slug"] not in matched_slugs] + board_entries
543
+
544
+
269
545
  def tasks_seed(active):
270
546
  """The same data shaped to the substrate, so testnet seeding is a LOAD not a
271
547
  reshape: task→thing+tag, combo-weight→path-context.context-strength, payload→signal."""
@@ -282,6 +558,11 @@ def tasks_seed(active):
282
558
  "tid": f"task:{p['slug']}",
283
559
  "thingType": "task",
284
560
  "name": p["title"] or p["slug"],
561
+ # context = what the task IS + which docs load it, so an agent (or a
562
+ # human) that picks it up knows the goal and where to read. notes →
563
+ # thing.notes (prose); contextDocs → thing.task-context (doc stems).
564
+ "notes": task_context_for(p)[0],
565
+ "contextDocs": task_context_for(p)[1],
285
566
  "tag": _task_tags(p),
286
567
  "taskStatus": "blocked" if p.get("waiting_on") else "open",
287
568
  "taskPriority": round(p["priority"] / 3, 2),
@@ -305,7 +586,7 @@ def tasks_seed(active):
305
586
  "shape": "signal(sender, receiver, payload, tags)",
306
587
  "receiver": "tasks:announce",
307
588
  "routing": "tag-intersection fan-out (one.ie/web/src/lib/world-receivers.ts) + subscriptions:register (declared, not yet persisted)",
308
- "note": "no tasks:* receiver wired yetC3 in tasks-plan.md. weights here are what a stake would later move.",
589
+ "note": "tasks:* receivers are LIVE (one.ie/web/src/lib/resolvers/tasks.ts) create/claim/status/link/launch and the announce fan-out. This seed is the bulk-load shape; weights here are what a stake moves.",
309
590
  },
310
591
  }
311
592
 
@@ -343,7 +624,11 @@ def parse_frontmatter(text):
343
624
  if not m:
344
625
  return fm
345
626
  body = m.group(1)
346
- for key in ("title", "tier", "mode", "priority"):
627
+ # outcome_asserts is a single-line quoted string that describes what "done" looks
628
+ # like in the reader's language — the richest one-line description a task can carry.
629
+ # Pulled here (not just title/tier/mode/priority) so tasks_seed can hand an agent
630
+ # real context: what the task IS, not just its name. See § "context on task".
631
+ for key in ("title", "tier", "mode", "priority", "outcome_asserts"):
347
632
  km = re.search(rf"^{key}:\s*(.+)$", body, re.MULTILINE)
348
633
  if km:
349
634
  val = km.group(1)
@@ -599,15 +884,22 @@ def load_task_paths():
599
884
 
600
885
  def path_momentum(slug, paths):
601
886
  """Momentum from path strength (the closed-loop source), on the same 0–3 scale as
602
- learnings momentum so scoring is unchanged. Strength has two writers, both summed:
887
+ learnings momentum so scoring is unchanged. Strength has three writers, all summed:
603
888
  • `closed` — reconciled from learnings.md closes by --sync-closes (idempotent)
604
889
  • `strength`/`resistance` — LIVE marks (agent picks, manual --mark)
605
- net = closed + strengthresistance. Returns None when this slug has no path yet →
606
- caller falls back to learnings.md (bootstrap for never-closed, never-marked work)."""
890
+ `prod.strength`/`prod.resistance` production outcomes pulled in by
891
+ outcome-pull.ts (text/self-improving-outcome.md): a settled promise, a /do
892
+ cycle that really closed in a live workspace, or a graded delivery. Full
893
+ overwrite per pull, never accumulated — see outcome-pull.ts's merge law.
894
+ net = closed + strength − resistance + prod.strength − prod.resistance. Returns
895
+ None when this slug has no path yet → caller falls back to learnings.md
896
+ (bootstrap for never-closed, never-marked work)."""
607
897
  e = paths.get(f"task:{slug}")
608
898
  if not e:
609
899
  return None
610
- net = max(0.0, float(e.get("closed", 0)) + float(e.get("strength", 0)) - float(e.get("resistance", 0)))
900
+ prod = e.get("prod") or {}
901
+ net = max(0.0, float(e.get("closed", 0)) + float(e.get("strength", 0)) - float(e.get("resistance", 0))
902
+ + float(prod.get("strength", 0)) - float(prod.get("resistance", 0)))
611
903
  return round(min(3.0, net / 2.0), 2)
612
904
 
613
905
 
@@ -684,6 +976,100 @@ def momentum_score(slug, log, ref):
684
976
  return round(min(3.0, score), 2)
685
977
 
686
978
 
979
+ # The eight-field close-line vector (text/rubrics.md § Code Rubric — eight dimensions).
980
+ # Fixed field order, self-labeling short codes → the full dimension name do-rank prints.
981
+ DIM_FIELDS = [
982
+ ("sec", "security"),
983
+ ("simp", "simplicity"),
984
+ ("struct", "structure"),
985
+ ("speed", "speed"),
986
+ ("integ", "integration"),
987
+ ("reuse", "code-reuse"),
988
+ ("test", "test-coverage"),
989
+ ("ux", "ux"),
990
+ ]
991
+ _VECTOR_RE = re.compile(
992
+ r"\[sec(\d*\.\d+)\s+simp(\d*\.\d+)\s+struct(\d*\.\d+)\s+speed(\d*\.\d+)\s+"
993
+ r"integ(\d*\.\d+)\s+reuse(\d*\.\d+)\s+test(\d*\.\d+)\s+ux(\d*\.\d+)\]"
994
+ )
995
+
996
+
997
+ def parse_vector(line):
998
+ """The eight-field close-line vector, if present: {dim_name: float}. None for a
999
+ close-line with no vector (every close before the self-improving-ratchet plan —
1000
+ back-compat: those still count toward the composite trend only)."""
1001
+ m = _VECTOR_RE.search(line)
1002
+ if not m:
1003
+ return None
1004
+ return {name: float(v) for (_short, name), v in zip(DIM_FIELDS, m.groups())}
1005
+
1006
+
1007
+ def trailing_median(values):
1008
+ """Median of a list of floats; 0.0 for an empty list (no prior closes yet)."""
1009
+ if not values:
1010
+ return 0.0
1011
+ s = sorted(values)
1012
+ n = len(s)
1013
+ mid = n // 2
1014
+ return s[mid] if n % 2 else round((s[mid - 1] + s[mid]) / 2.0, 4)
1015
+
1016
+
1017
+ def _slope(points):
1018
+ """Least-squares slope of value-over-day-offset. 0.0 with fewer than 2 points
1019
+ (insufficient data to trend — printed as flat, not a false rising/slipping)."""
1020
+ n = len(points)
1021
+ if n < 2:
1022
+ return 0.0
1023
+ xs = [p[0] for p in points]
1024
+ ys = [p[1] for p in points]
1025
+ mx = sum(xs) / n
1026
+ my = sum(ys) / n
1027
+ num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
1028
+ den = sum((x - mx) ** 2 for x in xs)
1029
+ return round(num / den, 4) if den else 0.0
1030
+
1031
+
1032
+ def ratchet_trend(log, ref, window_days=30):
1033
+ """Per-dimension trend over the momentum module's own recency window — reuses
1034
+ load_learnings_log's ledger, no second parser. Returns (dims, composite):
1035
+ dims — [(short, name, last_value|None, slope, 'rising'|'flat'|'slipping'), ...]
1036
+ in DIM_FIELDS order; last_value is None until the first vectored close.
1037
+ composite — (last_composite|None, trailing_median, 'rising'|'flat'|'slipping') —
1038
+ the gate C3 reads: composite-ratchet = composite ≥ max(0.65, median).
1039
+ """
1040
+ if ref is None:
1041
+ return [], (None, None, "flat")
1042
+
1043
+ window = [(d, rub, parse_vector(ln)) for d, rub, ln in log if (ref - d).days < window_days]
1044
+ window.sort(key=lambda t: t[0]) # oldest → newest, so slope reads left-to-right in time
1045
+ composites = [rub for _d, rub, _v in window if rub is not None]
1046
+ vectored = [(d, rub, v) for d, rub, v in window if v is not None]
1047
+
1048
+ dims = []
1049
+ for short, name in DIM_FIELDS:
1050
+ if not vectored:
1051
+ dims.append((short, name, None, 0.0, "flat"))
1052
+ continue
1053
+ origin = vectored[0][0]
1054
+ pts = [((d - origin).days, v[name]) for d, _rub, v in vectored]
1055
+ slope = _slope(pts)
1056
+ last_val = pts[-1][1]
1057
+ label = "rising" if slope > 0.01 else "slipping" if slope < -0.01 else "flat"
1058
+ dims.append((short, name, last_val, slope, label))
1059
+
1060
+ last_composite = vectored[-1][1] if vectored else (composites[-1] if composites else None)
1061
+ median = trailing_median(composites)
1062
+ if last_composite is None or not composites:
1063
+ direction = "flat"
1064
+ elif last_composite > median:
1065
+ direction = "rising"
1066
+ elif last_composite < median:
1067
+ direction = "slipping"
1068
+ else:
1069
+ direction = "flat"
1070
+ return dims, (last_composite, median, direction)
1071
+
1072
+
687
1073
  def selftest():
688
1074
  """Lock the three bugs found building the momentum gate. Pure, no mocks,
689
1075
  real learnings-shaped strings. Run: do-rank.py --selftest"""
@@ -791,15 +1177,65 @@ def selftest():
791
1177
  check("gated: empty build_gate value ⇒ not gated (needs a reason)",
792
1178
  is_gated('---\ntitle: x\nbuild_gate:\n---\nbody') is False)
793
1179
 
1180
+ # 12. ratchet-trend (C2): per-dimension rising/flat/slipping from vectored closes,
1181
+ # plus composite-vs-trailing-median direction. Pure synthetic vectors, deterministic —
1182
+ # security climbs .70→.80→.90 (rising), speed falls .70→.60→.50 (slipping), simplicity
1183
+ # holds flat at .70 throughout.
1184
+ rlog = [
1185
+ (date(2026, 6, 1), 0.70,
1186
+ "- 2026-06-01 · x · c · a [sec.70 simp.70 struct.70 speed.70 integ.70 reuse.70 test.70 ux.70]"),
1187
+ (date(2026, 6, 10), 0.75,
1188
+ "- 2026-06-10 · x · c · b [sec.80 simp.70 struct.70 speed.60 integ.70 reuse.70 test.70 ux.70]"),
1189
+ (date(2026, 6, 19), 0.85,
1190
+ "- 2026-06-19 · x · c · c [sec.90 simp.70 struct.70 speed.50 integ.70 reuse.70 test.70 ux.70]"),
1191
+ ]
1192
+ rdims, (rcomp, rmed, rdir) = ratchet_trend(rlog, date(2026, 6, 19))
1193
+ rd = {name: (label, slope) for _short, name, _val, slope, label in rdims}
1194
+ check("ratchet: security rising", rd["security"][0] == "rising" and rd["security"][1] > 0)
1195
+ check("ratchet: speed slipping", rd["speed"][0] == "slipping" and rd["speed"][1] < 0)
1196
+ check("ratchet: simplicity flat (no change)", rd["simplicity"][0] == "flat")
1197
+ check("ratchet: composite above trailing median ⇒ rising", rcomp == 0.85 and rdir == "rising")
1198
+ check("ratchet: no closes ⇒ empty dims, no crash", ratchet_trend([], None)[0] == [])
1199
+ check("ratchet: parse_vector back-compat (no vector ⇒ None)",
1200
+ parse_vector("- 2026-06-01 · x · c · plain rubric=0.80") is None)
1201
+
1202
+ # 13. outcome-weight (self-improving-outcome): path_momentum() folds prod.strength/
1203
+ # prod.resistance in alongside closed/live — a slug the world MARKED (prod.strength)
1204
+ # ranks strictly above an identical slug the world WARNED (prod.resistance), same
1205
+ # closed/local strength/resistance otherwise. Load-bearing assertion, not a label
1206
+ # grep — this is the exact trap this family already hit once (a green string with no
1207
+ # real check behind it). See text/self-improving-outcome-plan.md § Pre-mortem.
1208
+ marked_paths = {"task:x": {"closed": 1.0, "strength": 0.0, "resistance": 0.0,
1209
+ "prod": {"strength": 2.0, "resistance": 0.0}}}
1210
+ warned_paths = {"task:x": {"closed": 1.0, "strength": 0.0, "resistance": 0.0,
1211
+ "prod": {"strength": 0.0, "resistance": 2.0}}}
1212
+ check("outcome-weight: prod.strength raises momentum above an identical warned slug",
1213
+ path_momentum("x", marked_paths) > path_momentum("x", warned_paths))
1214
+ check("outcome-weight: no prod key ⇒ unchanged behaviour (back-compat)",
1215
+ path_momentum("x", {"task:x": {"closed": 1.0, "strength": 0.0, "resistance": 0.0}}) == 0.5)
1216
+
1217
+ # 14. provenance (self-improving-outcome): --trails' NET calc surfaces prod+/prod-
1218
+ # separately from closed/live/resist, so the operator can see WHY a rank moved (world
1219
+ # vs. local). Exercise the same arithmetic --trails prints, not just path_momentum.
1220
+ prov_entry = {"closed": 0.0, "strength": 0.0, "resistance": 0.0,
1221
+ "prod": {"strength": 1.5, "resistance": 0.4}}
1222
+ prov_prod = prov_entry.get("prod") or {}
1223
+ prov_net = round(float(prov_entry.get("closed", 0)) + float(prov_entry.get("strength", 0))
1224
+ - float(prov_entry.get("resistance", 0))
1225
+ + float(prov_prod.get("strength", 0)) - float(prov_prod.get("resistance", 0)), 2)
1226
+ check("provenance: --trails NET includes prod+/prod- terms", prov_net == 1.1)
1227
+ check("provenance: prod+/prod- readable independent of closed/live/resist",
1228
+ float(prov_prod.get("strength", 0)) == 1.5 and float(prov_prod.get("resistance", 0)) == 0.4)
1229
+
794
1230
  if fails:
795
1231
  print("SELFTEST FAIL:")
796
1232
  for f in fails:
797
1233
  print(" ✗", f)
798
1234
  sys.exit(1)
799
- print("SELFTEST: 35/35 pass (hyphen-boundary · plan-close · momentum subject-zone · "
1235
+ print("SELFTEST: 45/45 pass (hyphen-boundary · plan-close · momentum subject-zone · "
800
1236
  "decay · deliverables-lint · subtree-dirs · transitive-leverage · toxic-classifier · "
801
1237
  "deliverables-guard · fade-asymmetry · frontier-setdiff · frontier-closed-filter · "
802
- "gated-exclusion)")
1238
+ "gated-exclusion · ratchet-trend · outcome-weight · provenance)")
803
1239
 
804
1240
 
805
1241
  def main():
@@ -808,6 +1244,7 @@ def main():
808
1244
  toxic_report = "--toxic" in args
809
1245
  tasks_human = "--tasks" in args
810
1246
  tasks_seed_out = "--tasks-json" in args
1247
+ task_context_out = "--task-context-json" in args # backfill source: context for ALL plans
811
1248
  top_n = None
812
1249
  if "--top" in args:
813
1250
  i = args.index("--top")
@@ -815,6 +1252,24 @@ def main():
815
1252
  if "--selftest" in args:
816
1253
  return selftest()
817
1254
 
1255
+ # `--worktags <slug>` — the plan's task-vocabulary tags (dept + topics), comma-joined,
1256
+ # one line. do-auto.sh passes these to the cycle-close do-event so the reputation harvest
1257
+ # credits the SAME tag-nodes real tasks carry (text/reputation.md § Slice A). Self-contained
1258
+ # (one frontmatter read) so the emit stays cheap.
1259
+ if "--worktags" in args:
1260
+ i = args.index("--worktags")
1261
+ slug = args[i + 1] if i + 1 < len(args) else ""
1262
+ f = TEXT / f"{slug}-todo.md"
1263
+ if not f.exists():
1264
+ f = TEXT / f"{slug}-plan.md"
1265
+ tags = []
1266
+ if f.exists():
1267
+ fm = parse_frontmatter(f.read_text(errors="ignore"))
1268
+ dept = classify_dept(slug, {"title": fm.get("title", ""), "tags": fm.get("tags", [])})
1269
+ tags = _task_tags({"dept": dept, "tags": fm.get("tags", [])})
1270
+ print(",".join(t for t in tags if t))
1271
+ return
1272
+
818
1273
  # `--dirs <slug>` — the subtree dirs a plan ships into, one per line. The SINGLE
819
1274
  # disjointness extractor: do-fleet.sh calls this instead of re-parsing markdown in
820
1275
  # bash, so the displayed waves and the fleet's live conflict check can never drift.
@@ -827,6 +1282,21 @@ def main():
827
1282
  print(d)
828
1283
  return
829
1284
 
1285
+ # `--board-check` — the one failure mode source-greps cannot catch. do-tasks-wire-check.sh
1286
+ # asserts the SHAPE of the board read; this actually performs it. A key that doesn't match
1287
+ # the deployed worker's GATEWAY_API_KEY passes every static check and still leaves the
1288
+ # ranker file-only forever — which is exactly how the original break hid for months.
1289
+ # Deliberately NOT part of any promise `proof:` (a contract must not depend on the network);
1290
+ # run it explicitly when the board looks empty.
1291
+ if "--board-check" in args:
1292
+ target = os.environ.get("DO_SIGNAL_URL", "https://one.ie")
1293
+ rows, reason = fetch_tasks_everywhere()
1294
+ if reason:
1295
+ print(f"[board-check] RED — {target}: {reason}", file=sys.stderr)
1296
+ return 1
1297
+ print(f"[board-check] OK — {target}: {len(rows)} open task(s) readable")
1298
+ return 0
1299
+
830
1300
  # `--lint` — mechanical deliverables hygiene across all OPEN plans. Flags phantom
831
1301
  # paths (under-qualified/wrong-rooted) that make a plan a fleet false-parallel, and
832
1302
  # plans with NO parseable deliverables (a fleet hazard). Exit 1 if any plan is dirty.
@@ -868,8 +1338,11 @@ def main():
868
1338
  return
869
1339
 
870
1340
  # `--trails` — track the loop: show the trails outcomes have worn into the path
871
- # store. A trail = accumulated path strength (closes + live marks) minus resistance.
872
- # Strong trails are highways the next pick follows; resistance-heavy ones are fading.
1341
+ # store. A trail = accumulated path strength (closes + live marks + prod outcomes)
1342
+ # minus resistance. Strong trails are highways the next pick follows; resistance-
1343
+ # heavy ones are fading. `prod+`/`prod-` (self-improving-outcome) show provenance —
1344
+ # what production confirmed, separate from local closes/marks — so the operator can
1345
+ # tell WHY a rank moved, not just that it did.
873
1346
  if "--trails" in args:
874
1347
  paths = load_task_paths()
875
1348
  rows = []
@@ -877,21 +1350,25 @@ def main():
877
1350
  closed = float(e.get("closed", 0))
878
1351
  strength = float(e.get("strength", 0))
879
1352
  resistance = float(e.get("resistance", 0))
880
- net = round(closed + strength - resistance, 2)
881
- rows.append((net, key, closed, strength, resistance, int(e.get("traversals", 0))))
1353
+ prod = e.get("prod") or {}
1354
+ prod_strength = float(prod.get("strength", 0))
1355
+ prod_resistance = float(prod.get("resistance", 0))
1356
+ net = round(closed + strength - resistance + prod_strength - prod_resistance, 2)
1357
+ rows.append((net, key, closed, strength, resistance, int(e.get("traversals", 0)), prod_strength, prod_resistance))
882
1358
  rows.sort(reverse=True)
883
- print("\n TRAILS — paths the loop has worn (weighed by outcome · close + live mark − resistance)\n")
1359
+ print("\n TRAILS — paths the loop has worn (weighed by outcome · close + live mark + prod − resistance)\n")
884
1360
  if not rows:
885
1361
  print(" (no trails yet — a cycle close or a mark wears the first one)\n")
886
1362
  return
887
- print(f" {'NET':>5} {'closed':>6} {'live':>5} {'resist':>6} {'×':>4} TRAIL")
888
- print(f" {'-'*5} {'-'*6} {'-'*5} {'-'*6} {'-'*4} {'-'*30}")
889
- for net, key, c, s, r, tv in rows[:25]:
1363
+ print(f" {'NET':>5} {'closed':>6} {'live':>5} {'resist':>6} {'×':>4} {'prod+':>5} {'prod-':>5} TRAIL")
1364
+ print(f" {'-'*5} {'-'*6} {'-'*5} {'-'*6} {'-'*4} {'-'*5} {'-'*5} {'-'*30}")
1365
+ for net, key, c, s, r, tv, ps, pr in rows[:25]:
890
1366
  band = "🛣 highway" if net >= 4 else "· trail" if net >= 2 else " forming"
891
- if r > c + s:
1367
+ if r + pr > c + s + ps:
892
1368
  band = "✗ fading"
893
- print(f" {net:>5.1f} {c:>6.1f} {s:>5.1f} {r:>6.1f} {tv:>4} {key:<26} {band}")
1369
+ print(f" {net:>5.1f} {c:>6.1f} {s:>5.1f} {r:>6.1f} {tv:>4} {ps:>5.1f} {pr:>5.1f} {key:<26} {band}")
894
1370
  print(f"\n {len(rows)} trail(s). A close → --sync-closes wears it deeper; warn/--fail lets it fade.")
1371
+ print(f" prod+/prod- come from outcome-pull.ts (production D1, session-cadence) — provenance, never local.")
895
1372
  print(f" These are the task-layer trails (text/task-paths.json); the substrate trail is claw_paths/path.strength.\n")
896
1373
  return
897
1374
 
@@ -943,6 +1420,80 @@ def main():
943
1420
  f"the migration target (not wired this cycle).\n")
944
1421
  return
945
1422
 
1423
+ # `--ratchet` (C2, self-improving-ratchet) — read-only projection: each of the eight
1424
+ # rubric dimensions' 30-day trend (rising/flat/slipping), reusing load_learnings_log +
1425
+ # the momentum recency window. Composite vs its trailing median is the number C3's W4
1426
+ # gate reads (composite-ratchet: composite ≥ max(0.65, median)) — this prints it, the
1427
+ # gate (C3) enforces it. Answers "are we getting better?" as a command, not a vibe.
1428
+ if "--ratchet" in args:
1429
+ rlog, rref = load_learnings_log()
1430
+ dims, (last_composite, median, direction) = ratchet_trend(rlog, rref)
1431
+ n_vectored = sum(1 for _d, _r, ln in rlog if parse_vector(ln) is not None)
1432
+ print(f"\n RATCHET — eight dimensions (30-day trend, n={n_vectored} vectored close(s))\n")
1433
+ if n_vectored == 0:
1434
+ print(" no vectored closes yet — the eight-field vector starts accumulating from")
1435
+ print(" the first close-line carrying `[sec.. simp.. …]` (text/rubrics.md § the")
1436
+ print(" close-line vector). Composite-only trend is not shown here — use the")
1437
+ print(" existing momentum/toxic reports for that.\n")
1438
+ return
1439
+ arrow = {"rising": "▲", "flat": "▬", "slipping": "▼"}
1440
+ for short, name, last_val, slope, label in dims:
1441
+ val_s = f"{last_val:.2f}" if last_val is not None else " n/a"
1442
+ warn_s = " ⚠ warn" if label == "slipping" else ""
1443
+ print(f" {name:<13} {arrow[label]} {label:<9} {val_s} ({slope:+.2f}){warn_s}")
1444
+ comp_s = f"{last_composite:.2f}" if last_composite is not None else "n/a"
1445
+ med_s = f"{median:.2f}" if median else "n/a"
1446
+ print(f"\n composite {comp_s} {arrow.get(direction, '▬')} "
1447
+ f"(trailing median {med_s} — gate floor; no regression)\n")
1448
+ return
1449
+
1450
+ # `--ratchet-json` (C3 rescope, self-improving-ratchet) — machine-readable sibling of
1451
+ # `--ratchet`: the one source of truth for the trailing numbers so w4-rubric.ts shells
1452
+ # out instead of re-implementing a vector-regex parser in TypeScript. Read-only, no
1453
+ # exit-code contract — safe under any C3 rescope option.
1454
+ if "--ratchet-json" in args:
1455
+ import json as _json
1456
+ rlog, rref = load_learnings_log()
1457
+ dims, (last_composite, median, direction) = ratchet_trend(rlog, rref)
1458
+ n_vectored = sum(1 for _d, _r, ln in rlog if parse_vector(ln) is not None)
1459
+ print(_json.dumps({
1460
+ "last_composite": last_composite,
1461
+ "trailing_median": median,
1462
+ "direction": direction,
1463
+ "n": len(rlog),
1464
+ "n_vectored": n_vectored,
1465
+ "dims": [
1466
+ {"short": short, "name": name, "last_value": last_val, "slope": slope, "label": label}
1467
+ for short, name, last_val, slope, label in dims
1468
+ ],
1469
+ }))
1470
+ return
1471
+
1472
+ # `--ratchet-dry-run` (C3 rescope calibration receipt) — replays every close against
1473
+ # the trailing median of ITS OWN priors (not the current median), and counts how many
1474
+ # would fail a strict `composite ≥ median-of-priors` gate. This is the number the W2
1475
+ # escape check argued from prose; this prints it from the real ledger so the author's
1476
+ # rescope decision (warn vs block vs rewrite-the-escape) is made on a receipt, not an
1477
+ # estimate. Never touches an exit code.
1478
+ if "--ratchet-dry-run" in args:
1479
+ rlog, _rref = load_learnings_log()
1480
+ chron = sorted(((d, rub) for d, rub, _ln in rlog if rub is not None), key=lambda t: t[0])
1481
+ reblocked = []
1482
+ for i, (d, rub) in enumerate(chron):
1483
+ priors = [r for _d, r in chron[:i]]
1484
+ if len(priors) < 2:
1485
+ continue
1486
+ med = trailing_median(priors)
1487
+ if rub < med:
1488
+ reblocked.append((d, rub, med))
1489
+ print(f"\n RATCHET DRY-RUN — {len(chron)} composite-bearing close(s), "
1490
+ f"{len(reblocked)} would fail composite ≥ median-of-priors\n")
1491
+ for d, rub, med in reblocked[-15:]:
1492
+ print(f" {d} composite={rub:.2f} < median-of-priors={med:.2f}")
1493
+ print(f"\n {len(reblocked)}/{len(chron) - 2 if len(chron) > 2 else 0} eligible closes "
1494
+ f"would have been re-blocked by a hard median gate.\n")
1495
+ return
1496
+
946
1497
  log, ref = load_learnings_log()
947
1498
  task_paths = load_task_paths()
948
1499
  plans = {}
@@ -968,6 +1519,7 @@ def main():
968
1519
  plans[slug] = {
969
1520
  "slug": slug,
970
1521
  "title": fm.get("title", ""),
1522
+ "outcome_asserts": fm.get("outcome_asserts", ""),
971
1523
  "tier": fm.get("tier", "?"),
972
1524
  "tags": fm.get("tags", []),
973
1525
  "priority": priority,
@@ -1053,6 +1605,10 @@ def main():
1053
1605
 
1054
1606
  # active = open work (ready + blocked); done AND faded plans are not live tasks.
1055
1607
  active = ready + blocked
1608
+ # C2 — rank FROM tasks:everywhere too: board-created tasks (never a todo file) join
1609
+ # the same list; a substrate row whose slug: tag matches a local plan wins (see
1610
+ # merge_board_tasks docstring). No-op on fetch failure — file-only, unchanged.
1611
+ active = merge_board_tasks(active, plans)
1056
1612
 
1057
1613
  if toxic_report:
1058
1614
  # Read-only report — the `--toxic` deliverable. Nothing is written or deleted;
@@ -1071,6 +1627,16 @@ def main():
1071
1627
  if tasks_seed_out:
1072
1628
  print(json.dumps(tasks_seed(active), indent=2))
1073
1629
  return
1630
+ if task_context_out:
1631
+ # Context for EVERY plan (done/blocked/ready), keyed by tid — the backfill reads
1632
+ # this to add notes + task-context to tasks already seeded into TypeDB (which the
1633
+ # forward `seed-tasks-typedb.ts` skips because they exist). Idempotent by design.
1634
+ contexts = []
1635
+ for p in sorted(plans.values(), key=lambda x: x["slug"]):
1636
+ notes, docs = task_context_for(p)
1637
+ contexts.append({"tid": f"task:{p['slug']}", "notes": notes, "contextDocs": docs})
1638
+ print(json.dumps({"version": "context-backfill-1", "contexts": contexts}, indent=2))
1639
+ return
1074
1640
  if tasks_human:
1075
1641
  print("\n" + "\n".join(tasks_view(active)) + "\n")
1076
1642
  return
@@ -1096,6 +1662,24 @@ def main():
1096
1662
  # Human table
1097
1663
  print(f"\n PRIORITY QUEUE — {len(ready)} ready · {len(blocked)} blocked · "
1098
1664
  f"{len(faded)} faded · {len(done)} done\n")
1665
+
1666
+ # RATCHET — C4: one compact line surfacing C2's eight-dimension trend so the
1667
+ # operator sees "are we getting better?" at session start without running
1668
+ # --ratchet. Reuses `log`/`ref` (already computed above) + ratchet_trend();
1669
+ # the full 8-row table stays behind --ratchet. Degrades to a "no data" line
1670
+ # (still carrying the word "ratchet") when no vectored closes exist yet.
1671
+ r_dims, (r_comp, r_med, r_dir) = ratchet_trend(log, ref)
1672
+ if r_comp is None:
1673
+ print(" RATCHET no vectored closes yet — trend starts at the first "
1674
+ "[sec.. simp.. …] close\n")
1675
+ else:
1676
+ r_arrow = {"rising": "▲", "flat": "▬", "slipping": "▼"}.get(r_dir, "▬")
1677
+ n_slip = sum(1 for _sh, _nm, _lv, _sl, label in r_dims if label == "slipping")
1678
+ slip_s = f" · {n_slip} dim(s) slipping ⚠" if n_slip else ""
1679
+ r_med_s = f"{r_med:.2f}" if r_med else "n/a"
1680
+ print(f" RATCHET composite {r_comp:.2f} {r_arrow} "
1681
+ f"(median {r_med_s}){slip_s}\n")
1682
+
1099
1683
  print(f" {'SCORE':>5} {'S':>2} {'L':>2} {'P':>4} {'Pr':>2} {'M':>4} {'TIER':<8} SLUG")
1100
1684
  print(f" {'-'*5} {'-'*2} {'-'*2} {'-'*4} {'-'*2} {'-'*4} {'-'*8} {'-'*30}")
1101
1685
  for p in ready[:40]:
@@ -1157,4 +1741,6 @@ def main():
1157
1741
 
1158
1742
 
1159
1743
  if __name__ == "__main__":
1160
- main()
1744
+ # `or 0` so every path that returns None (the vast majority) still exits 0 — only a
1745
+ # mode that deliberately returns a non-zero int (e.g. --board-check RED) sets a code.
1746
+ sys.exit(main() or 0)