instar 1.3.712 → 1.3.714

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 (68) hide show
  1. package/.claude/skills/autonomous/SKILL.md +49 -0
  2. package/.claude/skills/autonomous/hooks/autonomous-stop-hook.sh +103 -9
  3. package/.claude/skills/autonomous/scripts/setup-autonomous.sh +47 -0
  4. package/dist/commands/init.js +9 -0
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  7. package/dist/config/ConfigDefaults.js +19 -0
  8. package/dist/config/ConfigDefaults.js.map +1 -1
  9. package/dist/core/AutonomousRunStore.d.ts +200 -0
  10. package/dist/core/AutonomousRunStore.d.ts.map +1 -0
  11. package/dist/core/AutonomousRunStore.js +402 -0
  12. package/dist/core/AutonomousRunStore.js.map +1 -0
  13. package/dist/core/CompletionEvaluator.d.ts +24 -0
  14. package/dist/core/CompletionEvaluator.d.ts.map +1 -1
  15. package/dist/core/CompletionEvaluator.js +29 -1
  16. package/dist/core/CompletionEvaluator.js.map +1 -1
  17. package/dist/core/ExternalOperationGate.d.ts.map +1 -1
  18. package/dist/core/ExternalOperationGate.js +6 -0
  19. package/dist/core/ExternalOperationGate.js.map +1 -1
  20. package/dist/core/PostUpdateMigrator.d.ts +1 -0
  21. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  22. package/dist/core/PostUpdateMigrator.js +56 -3
  23. package/dist/core/PostUpdateMigrator.js.map +1 -1
  24. package/dist/core/ScopeAccretionCorroboration.d.ts +79 -0
  25. package/dist/core/ScopeAccretionCorroboration.d.ts.map +1 -0
  26. package/dist/core/ScopeAccretionCorroboration.js +159 -0
  27. package/dist/core/ScopeAccretionCorroboration.js.map +1 -0
  28. package/dist/core/ScopeAccretionRatifier.d.ts +93 -0
  29. package/dist/core/ScopeAccretionRatifier.d.ts.map +1 -0
  30. package/dist/core/ScopeAccretionRatifier.js +228 -0
  31. package/dist/core/ScopeAccretionRatifier.js.map +1 -0
  32. package/dist/core/ScopeAccretionSweep.d.ts +83 -0
  33. package/dist/core/ScopeAccretionSweep.d.ts.map +1 -0
  34. package/dist/core/ScopeAccretionSweep.js +353 -0
  35. package/dist/core/ScopeAccretionSweep.js.map +1 -0
  36. package/dist/core/WorkingSetManifest.d.ts.map +1 -1
  37. package/dist/core/WorkingSetManifest.js +26 -1
  38. package/dist/core/WorkingSetManifest.js.map +1 -1
  39. package/dist/core/installCodexHooks.d.ts.map +1 -1
  40. package/dist/core/installCodexHooks.js +9 -0
  41. package/dist/core/installCodexHooks.js.map +1 -1
  42. package/dist/core/types.d.ts +29 -0
  43. package/dist/core/types.d.ts.map +1 -1
  44. package/dist/core/types.js.map +1 -1
  45. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  46. package/dist/lifeline/TelegramLifeline.js +8 -0
  47. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  48. package/dist/messaging/TelegramAdapter.d.ts +18 -0
  49. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  50. package/dist/messaging/TelegramAdapter.js +28 -0
  51. package/dist/messaging/TelegramAdapter.js.map +1 -1
  52. package/dist/scaffold/templates.js +2 -2
  53. package/dist/scaffold/templates.js.map +1 -1
  54. package/dist/server/routes.d.ts.map +1 -1
  55. package/dist/server/routes.js +638 -1
  56. package/dist/server/routes.js.map +1 -1
  57. package/dist/server/specReviewRoutes.d.ts +6 -0
  58. package/dist/server/specReviewRoutes.d.ts.map +1 -1
  59. package/dist/server/specReviewRoutes.js +30 -0
  60. package/dist/server/specReviewRoutes.js.map +1 -1
  61. package/package.json +1 -1
  62. package/scripts/lint-scrape-fixture-realness.js +40 -0
  63. package/src/data/builtin-manifest.json +65 -65
  64. package/src/scaffold/templates.ts +2 -2
  65. package/upgrades/1.3.713.md +65 -0
  66. package/upgrades/1.3.714.md +40 -0
  67. package/upgrades/side-effects/autonomous-scope-accretion-completion.md +77 -0
  68. package/upgrades/side-effects/bench-prompt-fixes-2.md +72 -0
@@ -137,6 +137,11 @@ import { HomeostasisMonitor } from '../monitoring/HomeostasisMonitor.js';
137
137
  import { readReaperAudit } from '../monitoring/SessionReaper.js';
138
138
  import { verifyMergedItemsViaGit } from '../core/ProjectRoundExecution.js';
139
139
  import { listAutonomousJobs, canStartAutonomousJob, stopAutonomousTopic, stopAllAutonomousJobs, suspendAutonomousTopicForMove, DEFAULT_MAX_CONCURRENT_AUTONOMOUS, } from '../core/AutonomousSessions.js';
140
+ import { AutonomousRunStore, hashPathSet } from '../core/AutonomousRunStore.js';
141
+ import { withSyncOp } from '../core/InFlightSyncOpMarker.js';
142
+ import { runAccretionSweep, computeUnbuiltSet, parseDeclaredDeliverables, } from '../core/ScopeAccretionSweep.js';
143
+ import { resolveCorroboration } from '../core/ScopeAccretionCorroboration.js';
144
+ import { ScopeAccretionRatifier } from '../core/ScopeAccretionRatifier.js';
140
145
  import { ProcessIntegrity } from '../core/ProcessIntegrity.js';
141
146
  import { decideIngress, commitInboundReply, dedupeKeyFor } from '../messaging/ingressDedup.js';
142
147
  import { OutboundContentDedup } from '../messaging/OutboundContentDedup.js';
@@ -742,6 +747,12 @@ export function createRoutes(ctx) {
742
747
  const reflectionMetrics = new ReflectionMetrics(ctx.config.stateDir);
743
748
  // Homeostasis monitor — work-velocity awareness (ported from Dawn)
744
749
  const homeostasisMonitor = new HomeostasisMonitor(ctx.config.stateDir);
750
+ // Scope-Accretion Completion Discipline — the SERVER-OWNED run-record store
751
+ // (spec: autonomous-scope-accretion-completion.md R30). Declared up here so
752
+ // the /hooks/events advisory-ledger hook (defined before the /autonomous
753
+ // routes) can reference it lexically; the gate/route logic lives with the
754
+ // other /autonomous routes below.
755
+ const autonomousRunStore = new AutonomousRunStore(ctx.config.stateDir);
745
756
  // Truncation detector for Telegram messages (Drop Zone integration)
746
757
  const truncationDetector = new TruncationDetector();
747
758
  // ── /telegram/reply X-Instar-DeliveryId dedup LRU (Layer 3 §3d step 4) ──
@@ -2376,6 +2387,21 @@ export function createRoutes(ctx) {
2376
2387
  if (toolName === 'Bash' && typeof toolInput === 'string' && /git\s+commit\b/.test(toolInput)) {
2377
2388
  homeostasisMonitor.recordCommit();
2378
2389
  }
2390
+ // Scope-accretion ADVISORY tool-event ledger (R18): attribution detail
2391
+ // (which turn, which tool) — never the blocking set (the git sweep is the
2392
+ // load-bearing layer). Resolution miss degrades the advisory layer only (R19).
2393
+ const filePath = typeof payload.file_path === 'string' ? payload.file_path : '';
2394
+ const sid = typeof payload.session_id === 'string' ? payload.session_id : '';
2395
+ if (filePath && sid && (toolName === 'Write' || toolName === 'Edit')) {
2396
+ const mapped = autonomousRunStore.resolveSession(sid);
2397
+ if (mapped) {
2398
+ autonomousRunStore.appendAdvisoryArtifact(mapped.topicId, mapped.runId, {
2399
+ filePath,
2400
+ toolName,
2401
+ sessionId: sid,
2402
+ });
2403
+ }
2404
+ }
2379
2405
  }
2380
2406
  // Bridge instar session ID ↔ Claude Code session ID.
2381
2407
  // Hook URLs include ?instar_sid=<INSTAR_SESSION_ID> set via tmux env var.
@@ -3408,6 +3434,349 @@ export function createRoutes(ctx) {
3408
3434
  // Returns a structured self-portrait of what this agent has available.
3409
3435
  // Agents should query this at session start rather than guessing
3410
3436
  // about what infrastructure exists.
3437
+ // ══ Scope-Accretion Completion Discipline ═══════════════════════════════
3438
+ // Spec: docs/specs/autonomous-scope-accretion-completion.md. The load-bearing
3439
+ // facts (what did this session create? did the verified operator ratify
3440
+ // deferral?) are computed SERVER-SIDE at the evaluate-completion chokepoint
3441
+ // from git truth + server-owned state — never transported from the session's
3442
+ // environment, never read from files the session routinely edits (R11/R12).
3443
+ // (The AutonomousRunStore instance is declared near the top of createRoutes
3444
+ // so the /hooks/events advisory-ledger hook can reference it.)
3445
+ const scopeAccretionMetric = (outcome, verdictId, latencyMs) => {
3446
+ try {
3447
+ ctx.featureMetricsLedger?.record({ feature: 'scope-accretion', kind: 'event', outcome, verdictId, latencyMs });
3448
+ }
3449
+ catch { /* @silent-fallback-ok — observability must never break the gate path */ }
3450
+ };
3451
+ // Read-only git funnel for the sweep (R15: SafeGitExecutor.readSync verbs only).
3452
+ const scopeAccretionReadGit = (args, cwd, timeoutMs) => SafeGitExecutor.readSync(args, {
3453
+ cwd,
3454
+ operation: 'src/server/routes.ts:scope-accretion-sweep',
3455
+ timeout: timeoutMs ?? 4000,
3456
+ });
3457
+ const scopeAccretionEffective = (rec) => rec.scopeAccretionOverride ? rec.scopeAccretionOverride.enabled : rec.scopeAccretion.enabled;
3458
+ /** Registration-time base roots (R13): the work_dir repo + the agent-home repo. */
3459
+ const deriveBaseRoots = (workDir) => {
3460
+ const resolveRepo = (dir) => {
3461
+ try {
3462
+ const root = scopeAccretionReadGit(['rev-parse', '--show-toplevel'], dir).trim();
3463
+ const sha = scopeAccretionReadGit(['rev-parse', 'HEAD'], root).trim();
3464
+ if (!root || !/^[0-9a-f]{7,40}$/.test(sha))
3465
+ return null;
3466
+ return { root, sha };
3467
+ }
3468
+ catch {
3469
+ /* @silent-fallback-ok — a non-git workDir/agent-home is a legitimate shape:
3470
+ the sweep degrades to the porcelain-only best-effort root (spec §2.2),
3471
+ never a false hold and never a false done. */
3472
+ return null;
3473
+ }
3474
+ };
3475
+ const roots = [];
3476
+ const wd = resolveRepo(workDir);
3477
+ const home = resolveRepo(ctx.config.projectDir);
3478
+ const sameRoot = wd && home && path.resolve(wd.root) === path.resolve(home.root);
3479
+ if (wd) {
3480
+ // R48: if the run's work_dir IS the shared agent-home clone, HEAD-only
3481
+ // attribution wins (concurrent sessions must not hold this run).
3482
+ roots.push({ root: wd.root, startSha: wd.sha, shared: !!sameRoot });
3483
+ }
3484
+ else {
3485
+ roots.push({ root: workDir, startSha: null, shared: false });
3486
+ }
3487
+ if (home && !sameRoot)
3488
+ roots.push({ root: home.root, startSha: home.sha, shared: true });
3489
+ return roots;
3490
+ };
3491
+ /** §2.8 steps 1: sweep (R31) + corroboration (R21) + persistence (monotone). */
3492
+ const computeAccretion = (rec) => {
3493
+ const sweep = runAccretionSweep(rec, {
3494
+ readGit: scopeAccretionReadGit,
3495
+ worktreesDir: path.join(ctx.config.projectDir, '.worktrees'),
3496
+ });
3497
+ const declared = parseDeclaredDeliverables(rec.condition, rec.declaredDeliverables);
3498
+ const preliminary = computeUnbuiltSet(sweep, declared, rec.ratifiedArtifacts, rec.corroborated);
3499
+ const negativeCached = new Set(preliminary.filter((a) => autonomousRunStore.isNegativeCached(rec, a.path)).map((a) => a.path));
3500
+ const artifactStartShas = {};
3501
+ for (const b of rec.baseRoots)
3502
+ artifactStartShas[path.resolve(b.root)] = b.startSha;
3503
+ for (const [wt, sha] of Object.entries({ ...rec.worktreeFirstSeen, ...sweep.newWorktrees })) {
3504
+ artifactStartShas[path.resolve(wt)] = sha;
3505
+ }
3506
+ const corr = resolveCorroboration({
3507
+ artifacts: preliminary,
3508
+ alreadyCorroborated: rec.corroborated,
3509
+ negativeCached,
3510
+ startedAt: rec.startedAt,
3511
+ workDir: rec.workDir,
3512
+ artifactStartShas,
3513
+ }, {
3514
+ // gh resolves the repo from its cwd — pin it to the run's registered
3515
+ // work_dir so merged-PR evidence is queried against the run's OWN repo.
3516
+ // withSyncOp: judge-path-only, `-m`-bounded (R22) — funneled so the
3517
+ // in-flight marker sees the blocking call (tmux-event-loop-resilience).
3518
+ runGh: (args, timeoutMs) => withSyncOp(() => execFileSync('gh', args, { cwd: rec.workDir, timeout: timeoutMs, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })),
3519
+ readGit: scopeAccretionReadGit,
3520
+ fsExists: (p) => fs.existsSync(p),
3521
+ conformanceInvocationsInWindow: (slug, a, b) => autonomousRunStore.conformanceInvocationsInWindow(slug, a, b),
3522
+ });
3523
+ const nowIso = new Date().toISOString();
3524
+ const updated = autonomousRunStore.update(rec.topicId, rec.runId, (r) => {
3525
+ for (const [p, ev] of Object.entries(corr.cleared)) {
3526
+ if (!r.corroborated[p])
3527
+ r.corroborated[p] = { by: ev.by, at: nowIso, detail: ev.detail };
3528
+ delete r.negativeCache[p];
3529
+ }
3530
+ for (const p of corr.newNegatives)
3531
+ r.negativeCache[p] = nowIso;
3532
+ for (const [wt, sha] of Object.entries(sweep.newWorktrees)) {
3533
+ if (!r.worktreeFirstSeen[wt])
3534
+ r.worktreeFirstSeen[wt] = sha;
3535
+ }
3536
+ }) ?? rec;
3537
+ const unbuilt = computeUnbuiltSet(sweep, declared, updated.ratifiedArtifacts, updated.corroborated);
3538
+ const final = autonomousRunStore.update(rec.topicId, rec.runId, (r) => {
3539
+ const prior = new Map(r.lastUnbuilt.map((u) => [u.path, u.firstSeenAt]));
3540
+ r.lastUnbuilt = unbuilt.map((a) => ({
3541
+ path: a.path,
3542
+ cls: a.cls,
3543
+ deleted: a.deleted,
3544
+ firstSeenAt: prior.get(a.path) ?? nowIso,
3545
+ }));
3546
+ r.lastSweepAt = nowIso;
3547
+ }) ?? updated;
3548
+ const clearedCount = Object.keys(final.corroborated).length + final.ratifiedArtifacts.length;
3549
+ return { rec: final, sweep, unbuilt, clearedCount, degraded: sweep.degraded || corr.degraded };
3550
+ };
3551
+ const clampPathList = (paths, limit = 50) => {
3552
+ const shown = paths.slice(0, limit);
3553
+ const more = paths.length - shown.length;
3554
+ return shown.join(', ') + (more > 0 ? ` — and ${more} more` : '');
3555
+ };
3556
+ /**
3557
+ * EVERY exit is loud (R40): enumerate a non-empty unbuilt accreted set in the
3558
+ * end-of-run notice + ONE deduped attention item. Best-effort, never blocks.
3559
+ */
3560
+ const enumerateUnbuiltLoudly = async (rec, unbuilt, surface, extra) => {
3561
+ if (unbuilt.length === 0)
3562
+ return;
3563
+ const paths = unbuilt.map((u) => u.path);
3564
+ const deleted = unbuilt.filter((u) => u.deleted).map((u) => u.path);
3565
+ const deletedNote = deleted.length
3566
+ ? ` The session DELETED ${deleted.length} accreted deliverable(s): ${clampPathList(deleted, 20)}.`
3567
+ : '';
3568
+ const text = `⚠️ Autonomous run on topic ${rec.topicId} ended (${surface}) with ${paths.length} unbuilt accreted ` +
3569
+ `artifact(s) that were neither built, corroborated, nor ratified: ${clampPathList(paths)}.${deletedNote}` +
3570
+ (extra ? ` ${extra}` : '');
3571
+ const topicNum = Number(rec.topicId);
3572
+ if (ctx.telegram && Number.isFinite(topicNum)) {
3573
+ await ctx.telegram.sendToTopic(topicNum, text).catch(() => { });
3574
+ await ctx.telegram
3575
+ .createAttentionItem({
3576
+ id: `scope-accretion-exit-${rec.topicId}-${rec.runId}`,
3577
+ title: `Autonomous run left ${paths.length} accreted artifact(s) unbuilt`,
3578
+ summary: text.slice(0, 1000),
3579
+ priority: 'NORMAL',
3580
+ sourceContext: 'scope-accretion',
3581
+ category: 'autonomous',
3582
+ })
3583
+ .catch(() => { });
3584
+ }
3585
+ scopeAccretionMetric('fired', `exit-enumeration:${surface}`);
3586
+ };
3587
+ /** R28b daily-sweep backstop — reaped crashed records get their loud enumeration. */
3588
+ const runDailySweepBackstop = () => {
3589
+ try {
3590
+ const reaped = autonomousRunStore.dailySweep();
3591
+ for (const rec of reaped) {
3592
+ void enumerateUnbuiltLoudly(rec, rec.lastUnbuilt ?? [], 'expired >24h ago (daily-sweep backstop)', 'Late-but-loud: the run ended without a run-end call.');
3593
+ }
3594
+ }
3595
+ catch { /* @silent-fallback-ok — the backstop is best-effort; the next call retries */ }
3596
+ };
3597
+ // Conversational ratification (R37/R38/R45) — trigger + confirmation matched
3598
+ // ONLY at the live receive path; server-authored enumeration; server-owned records.
3599
+ const scopeAccretionRatifier = new ScopeAccretionRatifier({
3600
+ store: autonomousRunStore,
3601
+ getOperatorUid: (topicId) => ctx.topicOperatorStore?.getOperator(topicId)?.uid ?? null,
3602
+ sendToTopic: async (topicId, text) => {
3603
+ if (!ctx.telegram)
3604
+ throw new Error('telegram adapter unavailable');
3605
+ const r = await ctx.telegram.sendToTopic(topicId, text);
3606
+ return { messageId: r.messageId };
3607
+ },
3608
+ dashboardLink: (topicId) => `http://localhost:${ctx.config.port}/dashboard?ratify-deferral=${encodeURIComponent(topicId)}`,
3609
+ recordMetric: (o) => scopeAccretionMetric(o, 'ratification'),
3610
+ log: (m) => console.warn(m),
3611
+ });
3612
+ if (ctx.telegram) {
3613
+ // Direct-poll ingress (the server long-polls Telegram itself, R45). The
3614
+ // lifeline-forward ingress fires the same observer inside
3615
+ // POST /internal/telegram-forward below.
3616
+ ctx.telegram.onScopeAccretionInbound = (evt) => {
3617
+ void scopeAccretionRatifier.observeInbound(evt);
3618
+ };
3619
+ }
3620
+ // ── POST /autonomous/register (R30/R43/R49) — the server-side start snapshot ──
3621
+ // Bearer-auth like every /autonomous route (localhost trust plane — the setup
3622
+ // path is the intended caller; there is no finer ambient caller identity, R49).
3623
+ router.post('/autonomous/register', (req, res) => {
3624
+ runDailySweepBackstop();
3625
+ const body = (req.body ?? {});
3626
+ const topicId = typeof body.topicId === 'string' || typeof body.topicId === 'number' ? String(body.topicId) : '';
3627
+ const condition = typeof body.condition === 'string' ? body.condition : '';
3628
+ const workDir = typeof body.workDir === 'string' && body.workDir ? body.workDir : ctx.config.projectDir;
3629
+ if (!topicId || !condition) {
3630
+ res.status(400).json({ error: '"topicId" and "condition" (string) required' });
3631
+ return;
3632
+ }
3633
+ const declaredDeliverables = Array.isArray(body.declaredDeliverables)
3634
+ ? body.declaredDeliverables.filter((d) => typeof d === 'string' && !!d.trim()).slice(0, 100)
3635
+ : [];
3636
+ const startedAt = typeof body.startedAt === 'string' && Number.isFinite(Date.parse(body.startedAt))
3637
+ ? body.startedAt
3638
+ : new Date().toISOString();
3639
+ const endAt = typeof body.endAt === 'string' ? body.endAt : undefined;
3640
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId ? body.sessionId : undefined;
3641
+ // Snapshot the WHOLE scopeAccretion sub-object at registration (R13): the
3642
+ // session editing .instar/config.json mid-run changes nothing the
3643
+ // chokepoint reads. The operator's live lever is the PIN override (R14).
3644
+ const cfg = ctx.config.autonomousSessions?.completionDiscipline?.scopeAccretion ?? {};
3645
+ const snapshot = {
3646
+ enabled: cfg.enabled ?? true,
3647
+ breakerK: Math.max(2, typeof cfg.breakerK === 'number' && Number.isFinite(cfg.breakerK) ? Math.floor(cfg.breakerK) : 3),
3648
+ };
3649
+ const result = autonomousRunStore.register({
3650
+ topicId,
3651
+ condition,
3652
+ workDir,
3653
+ declaredDeliverables,
3654
+ startedAt,
3655
+ endAt,
3656
+ sessionId,
3657
+ scopeAccretion: snapshot,
3658
+ baseRoots: deriveBaseRoots(workDir),
3659
+ maxDurationMs: ctx.config.autonomousSessions?.maxDurationMs ?? 172_800_000,
3660
+ });
3661
+ if (!result.ok) {
3662
+ // One registration per active run (R43): refused + flagged.
3663
+ scopeAccretionMetric('noop', 'register-conflict');
3664
+ void ctx.telegram
3665
+ ?.createAttentionItem({
3666
+ id: `scope-accretion-reregister-${topicId}`,
3667
+ title: `Autonomous re-registration refused for topic ${topicId}`,
3668
+ summary: `A re-register arrived while run ${result.existingRunId} is still active and unexpired.`,
3669
+ priority: 'LOW',
3670
+ sourceContext: 'scope-accretion',
3671
+ category: 'autonomous',
3672
+ })
3673
+ .catch(() => { });
3674
+ res.status(409).json({ error: 'a run is already registered and active for this topic', existingRunId: result.existingRunId });
3675
+ return;
3676
+ }
3677
+ scopeAccretionMetric('fired', 'register');
3678
+ res.json({ runId: result.runId, endAt: result.endAt, clamped: result.clamped });
3679
+ });
3680
+ // ── POST /autonomous/:topic/run-end (R44) — every exit surface reports here ──
3681
+ // Best-effort, `-m`-bounded on the hook side, NEVER blocking or delaying the
3682
+ // exit: the handler runs the advisory sweep, composes the enumerated notice
3683
+ // (R40), and marks the record ended.
3684
+ router.post('/autonomous/:topic/run-end', async (req, res) => {
3685
+ runDailySweepBackstop();
3686
+ const topic = String(req.params.topic);
3687
+ const body = (req.body ?? {});
3688
+ const reason = typeof body.reason === 'string' ? body.reason.slice(0, 200) : 'run-end';
3689
+ const runId = typeof body.runId === 'string' ? body.runId : undefined;
3690
+ const rec = autonomousRunStore.getRecord(topic);
3691
+ if (!rec) {
3692
+ res.status(404).json({ ok: false, error: 'no registered run for topic' });
3693
+ return;
3694
+ }
3695
+ if (runId && rec.runId !== runId) {
3696
+ res.status(409).json({ ok: false, error: 'runId mismatch', currentRunId: rec.runId });
3697
+ return;
3698
+ }
3699
+ let unbuiltEnumerated = 0;
3700
+ if (scopeAccretionEffective(rec)) {
3701
+ try {
3702
+ const comp = computeAccretion(rec);
3703
+ if (comp.unbuilt.length > 0) {
3704
+ await enumerateUnbuiltLoudly(comp.rec, comp.unbuilt, `run-end: ${reason}`);
3705
+ unbuiltEnumerated = comp.unbuilt.length;
3706
+ }
3707
+ }
3708
+ catch (err) {
3709
+ // The advisory sweep must never block the exit (R44) — record + proceed.
3710
+ console.warn(`[scope-accretion] run-end sweep failed (exit proceeds): ${err instanceof Error ? err.message : String(err)}`);
3711
+ scopeAccretionMetric('error', 'run-end-sweep');
3712
+ }
3713
+ }
3714
+ autonomousRunStore.markTerminal(rec.topicId, rec.runId, 'ended', reason);
3715
+ res.json({ ok: true, runId: rec.runId, unbuiltEnumerated });
3716
+ });
3717
+ // ── POST /autonomous/:topic/ratify-deferral (R23 path 1 — PIN, phone-first) ──
3718
+ router.post('/autonomous/:topic/ratify-deferral', async (req, res) => {
3719
+ if (!checkMandatePin(req, res))
3720
+ return;
3721
+ const topic = String(req.params.topic);
3722
+ const rec = autonomousRunStore.getRecord(topic);
3723
+ if (!rec || !autonomousRunStore.isActive(rec)) {
3724
+ res.status(404).json({ error: 'no ACTIVE registered run for topic' });
3725
+ return;
3726
+ }
3727
+ const body = (req.body ?? {});
3728
+ const all = body.all === true;
3729
+ const listed = Array.isArray(body.artifacts)
3730
+ ? body.artifacts.filter((a) => typeof a === 'string' && !!a.trim())
3731
+ : [];
3732
+ if (!all && listed.length === 0) {
3733
+ res.status(400).json({ error: '"artifacts" (string[]) or "all": true required' });
3734
+ return;
3735
+ }
3736
+ // {all:true} ratifies the server's CURRENT unbuilt list at call time — the
3737
+ // response echoes exactly what was ratified (display authority = executed
3738
+ // authority). Re-sweep for freshness; fall back to the last persisted set.
3739
+ let current = rec.lastUnbuilt.map((u) => u.path);
3740
+ try {
3741
+ const comp = computeAccretion(rec);
3742
+ current = comp.unbuilt.map((a) => a.path);
3743
+ }
3744
+ catch { /* @silent-fallback-ok — the persisted lastUnbuilt set is the documented fallback */ }
3745
+ const ratified = all ? current : listed;
3746
+ autonomousRunStore.update(rec.topicId, rec.runId, (r) => {
3747
+ for (const p of ratified) {
3748
+ if (!r.ratifiedArtifacts.includes(p))
3749
+ r.ratifiedArtifacts.push(p);
3750
+ }
3751
+ r.ratifications.push({ via: 'pin', at: new Date().toISOString(), artifacts: [...ratified] });
3752
+ });
3753
+ scopeAccretionMetric('fired', 'ratify-pin');
3754
+ res.json({ ok: true, ratified, runId: rec.runId });
3755
+ });
3756
+ // ── POST /autonomous/:topic/scope-accretion-override (R14 — the operator's
3757
+ // instant mid-run lever; dashboard-PIN-gated, audited, principal-verified) ──
3758
+ router.post('/autonomous/:topic/scope-accretion-override', (req, res) => {
3759
+ if (!checkMandatePin(req, res))
3760
+ return;
3761
+ const topic = String(req.params.topic);
3762
+ const body = (req.body ?? {});
3763
+ if (typeof body.enabled !== 'boolean' || typeof body.reason !== 'string' || !body.reason.trim()) {
3764
+ res.status(400).json({ error: '"enabled" (boolean) and "reason" (string) required' });
3765
+ return;
3766
+ }
3767
+ const rec = autonomousRunStore.getRecord(topic);
3768
+ if (!rec) {
3769
+ res.status(404).json({ error: 'no registered run for topic' });
3770
+ return;
3771
+ }
3772
+ const enabled = body.enabled;
3773
+ autonomousRunStore.update(rec.topicId, rec.runId, (r) => {
3774
+ r.scopeAccretionOverride = { enabled, reason: String(body.reason).slice(0, 300), at: new Date().toISOString() };
3775
+ });
3776
+ console.log(`[scope-accretion] operator PIN override for topic ${topic}: enabled=${enabled} (${String(body.reason).slice(0, 120)})`);
3777
+ scopeAccretionMetric('fired', `override-${enabled ? 'enable' : 'disable'}`);
3778
+ res.json({ ok: true, topic, enabled, runId: rec.runId });
3779
+ });
3411
3780
  // ── Multi-session autonomy: list / start-gate / stop ──────────────────
3412
3781
  // Per-topic autonomous jobs live at .instar/autonomous/<topicId>.local.md.
3413
3782
  // These routes are the management surface; the stop hook is the per-session enforcer.
@@ -3491,6 +3860,13 @@ export function createRoutes(ctx) {
3491
3860
  out.milestoneRationalizationDetected = s.milestoneRationalizationDetected;
3492
3861
  if (typeof s.injectionSuspected === 'boolean')
3493
3862
  out.injectionSuspected = s.injectionSuspected;
3863
+ // Scope-accretion: the advisory Layer-B boolean is the ONE new whitelisted
3864
+ // client field (R23). The BLOCKING inputs (`unbuiltAccretedArtifacts`,
3865
+ // `operatorRatifiedDeferral`, `scopeAccretion` facts) are computed inside
3866
+ // the route and NEVER accepted from the client — anything else on the
3867
+ // signals object is dropped here by construction.
3868
+ if (typeof s.scopeAccretionSuspected === 'boolean')
3869
+ out.scopeAccretionSuspected = s.scopeAccretionSuspected;
3494
3870
  // stopKind may arrive either inside signals or as a sibling field of the body.
3495
3871
  const stopKind = s.stopKind ?? body.stopKind;
3496
3872
  if (stopKind === 'hard-blocker')
@@ -3513,7 +3889,249 @@ export function createRoutes(ctx) {
3513
3889
  return;
3514
3890
  }
3515
3891
  try {
3516
- const verdict = await ctx.completionEvaluator.evaluate(condition, typeof transcriptTail === 'string' ? transcriptTail : '', parseStopSignals(req.body));
3892
+ const tail = typeof transcriptTail === 'string' ? transcriptTail : '';
3893
+ // ══ Scope-accretion deterministic pre-judge gate (R25/R35/R36) ══════════
3894
+ // Whitelist-validated identity fields — everything else on the body is ignored.
3895
+ const saBody = (req.body ?? {});
3896
+ const bodyTopicId = typeof saBody.topicId === 'string' || typeof saBody.topicId === 'number' ? String(saBody.topicId) : undefined;
3897
+ const bodyRunId = typeof saBody.runId === 'string' ? saBody.runId : undefined;
3898
+ const bodySessionId = typeof saBody.sessionId === 'string' ? saBody.sessionId : undefined;
3899
+ runDailySweepBackstop();
3900
+ // Arming is SERVER-resolved, never client-selected (R35): the sessionId↔
3901
+ // topicId map from registration, with the body topicId (verified against
3902
+ // the registration record below) as the direct carrier.
3903
+ let resolvedTopic = bodyTopicId;
3904
+ if (!resolvedTopic && bodySessionId) {
3905
+ resolvedTopic = autonomousRunStore.resolveSession(bodySessionId)?.topicId;
3906
+ }
3907
+ let armedRecord = null;
3908
+ if (!resolvedTopic) {
3909
+ const active = autonomousRunStore.listActive();
3910
+ if (active.length > 0) {
3911
+ // A done-claim with NO resolvable topic while registered runs are
3912
+ // active is REFUSED (R35). Version-skew vs tamper is distinguished by
3913
+ // the migration marker state of the DEPLOYED hook: a pre-migration
3914
+ // hook (no SCOPE_ACCRETION marker) is skew; a post-migration omission
3915
+ // is tamper-shaped and says so.
3916
+ let postMigration = false;
3917
+ try {
3918
+ const deployedHook = path.join(ctx.config.projectDir, '.claude', 'skills', 'autonomous', 'hooks', 'autonomous-stop-hook.sh');
3919
+ postMigration = fs.readFileSync(deployedHook, 'utf8').includes('SCOPE_ACCRETION');
3920
+ }
3921
+ catch { /* @silent-fallback-ok — unreadable hook ⇒ treat as skew (the softer message) */ }
3922
+ const shape = postMigration
3923
+ ? 'the installed stop hook DOES carry the scope-accretion capability, so an omitted topicId is tamper-shaped'
3924
+ : 'the installed stop hook looks pre-migration (version skew) — update instar / re-run the post-update migration';
3925
+ scopeAccretionMetric('fired', 'unattributed-done-claim');
3926
+ void ctx.telegram
3927
+ ?.createAttentionItem({
3928
+ id: 'scope-accretion-unattributed-done-claim',
3929
+ title: 'Unattributed autonomous done-claim refused',
3930
+ summary: `evaluate-completion was called with no topicId/runId while ${active.length} registered run(s) are active. ${shape}.`,
3931
+ priority: 'NORMAL',
3932
+ sourceContext: 'scope-accretion',
3933
+ category: 'autonomous',
3934
+ })
3935
+ .catch(() => { });
3936
+ res.json({
3937
+ met: false,
3938
+ reason: `unattributed-done-claim: ${active.length} registered autonomous run(s) are active on this server but the request carried no topicId/runId (${shape})`,
3939
+ });
3940
+ return;
3941
+ }
3942
+ // Zero active registered runs → a true legacy caller: gate inert, logged.
3943
+ }
3944
+ else {
3945
+ const byTopic = autonomousRunStore.getRecord(resolvedTopic);
3946
+ if (bodyRunId && byTopic && byTopic.runId !== bodyRunId && autonomousRunStore.isActive(byTopic)) {
3947
+ // runId cross-check (§6): a session presenting ANOTHER run's identifiers
3948
+ // is refused by the pair check.
3949
+ scopeAccretionMetric('fired', 'run-identity-mismatch');
3950
+ res.json({
3951
+ met: false,
3952
+ reason: `run-identity-mismatch: topic ${resolvedTopic} is registered to a different runId — the presented pair does not match the server's registration record`,
3953
+ });
3954
+ return;
3955
+ }
3956
+ if (byTopic && autonomousRunStore.isActive(byTopic)) {
3957
+ armedRecord = byTopic;
3958
+ if (bodySessionId)
3959
+ autonomousRunStore.mapSession(bodySessionId, byTopic.topicId, byTopic.runId);
3960
+ }
3961
+ else if (!byTopic) {
3962
+ console.log(`[scope-accretion] evaluate-completion for UNREGISTERED topic ${resolvedTopic} — accretion gate inert (honest degradation)`);
3963
+ }
3964
+ }
3965
+ // R36: when a run is registered, the judge evaluates the SERVER-REGISTERED
3966
+ // condition — the body condition is used only for legacy callers. A
3967
+ // divergence is logged + flagged once per run (closing the weakened-
3968
+ // condition bypass; mid-run state-file edits are ignored for accretion).
3969
+ let effectiveCondition = condition;
3970
+ if (armedRecord) {
3971
+ if (armedRecord.condition.trim() !== condition.trim()) {
3972
+ effectiveCondition = armedRecord.condition;
3973
+ if (!armedRecord.conditionDivergenceFlagged) {
3974
+ autonomousRunStore.update(armedRecord.topicId, armedRecord.runId, (r) => {
3975
+ r.conditionDivergenceFlagged = true;
3976
+ });
3977
+ console.warn(`[scope-accretion] condition divergence for topic ${armedRecord.topicId}: body condition differs from the registered text — judging the REGISTERED condition (R36)`);
3978
+ void ctx.telegram
3979
+ ?.createAttentionItem({
3980
+ id: `scope-accretion-condition-divergence-${armedRecord.topicId}-${armedRecord.runId}`,
3981
+ title: `Autonomous run condition diverged mid-run (topic ${armedRecord.topicId})`,
3982
+ summary: 'The done-claim carried a condition text different from the one registered at setup. The server judges the REGISTERED condition; mid-run edits are ignored for accretion purposes.',
3983
+ priority: 'NORMAL',
3984
+ sourceContext: 'scope-accretion',
3985
+ category: 'autonomous',
3986
+ })
3987
+ .catch(() => { });
3988
+ }
3989
+ else {
3990
+ effectiveCondition = armedRecord.condition;
3991
+ }
3992
+ }
3993
+ }
3994
+ // §2.8 step 1-2: the deterministic gate — sweep + corroboration +
3995
+ // ratification state, then HOLD without spending the judge LLM call.
3996
+ let signals = parseStopSignals(req.body);
3997
+ if (armedRecord && scopeAccretionEffective(armedRecord)) {
3998
+ let comp = null;
3999
+ try {
4000
+ comp = computeAccretion(armedRecord);
4001
+ }
4002
+ catch (err) {
4003
+ // A sweep failure degrades to NO HOLD (the judge still runs) — but is
4004
+ // recorded; it can never manufacture a false hold or a false done.
4005
+ console.warn(`[scope-accretion] sweep failed (gate degrades to judge-only): ${err instanceof Error ? err.message : String(err)}`);
4006
+ scopeAccretionMetric('error', 'sweep-failure');
4007
+ }
4008
+ if (comp) {
4009
+ scopeAccretionMetric(comp.unbuilt.length > 0 ? 'fired' : 'noop', 'sweep', comp.sweep.latencyMs);
4010
+ const unbuiltPaths = comp.unbuilt.map((a) => a.path);
4011
+ const deletedPaths = comp.unbuilt.filter((a) => a.deleted).map((a) => a.path);
4012
+ if (comp.unbuilt.length > 0) {
4013
+ // Breaker (R26): persisted server-side state; K consecutive holds
4014
+ // with an unchanged set hash and no new corroboration/ratification.
4015
+ const setHash = hashPathSet(unbuiltPaths);
4016
+ const k = Math.max(2, armedRecord.scopeAccretion.breakerK || 3);
4017
+ let rec2 = autonomousRunStore.getByPair(armedRecord.topicId, armedRecord.runId) ?? comp.rec;
4018
+ let tripped = rec2.breaker.tripped;
4019
+ if (!tripped) {
4020
+ rec2 =
4021
+ autonomousRunStore.update(armedRecord.topicId, armedRecord.runId, (r) => {
4022
+ const nowIso = new Date().toISOString();
4023
+ if (r.breaker.accretedSetHash === setHash && r.breaker.clearedCount === comp.clearedCount) {
4024
+ r.breaker.consecutiveHolds += 1;
4025
+ }
4026
+ else {
4027
+ r.breaker = {
4028
+ accretedSetHash: setHash,
4029
+ firstSeenAt: nowIso,
4030
+ consecutiveHolds: 1,
4031
+ lastProgressAt: nowIso,
4032
+ clearedCount: comp.clearedCount,
4033
+ tripped: false,
4034
+ };
4035
+ }
4036
+ if (r.breaker.consecutiveHolds >= k) {
4037
+ r.breaker.tripped = true;
4038
+ r.breaker.trippedAt = nowIso;
4039
+ }
4040
+ }) ?? rec2;
4041
+ if (rec2.breaker.tripped) {
4042
+ tripped = true;
4043
+ // The loud, distinctly-labeled trip — carrying the P13 stop-
4044
+ // rationale classification verdict (recorded, never bypassed:
4045
+ // a `buildable` verdict is DISPLAYED so the operator sees the
4046
+ // evasion shape; the exit is still permitted — no wedge, R39).
4047
+ let p13Verdict = 'unavailable';
4048
+ try {
4049
+ const v = await ctx.completionEvaluator.evaluateStopRationale(tail, {
4050
+ ...(signals ?? {}),
4051
+ stopKind: 'hard-blocker',
4052
+ });
4053
+ p13Verdict = v.classifiedBlocker ?? (v.stopAllowed ? 'allowed' : 'blocked');
4054
+ }
4055
+ catch { /* @silent-fallback-ok — classification is display-only on the trip; the trip itself is already decided */ }
4056
+ const tripText = `exiting via scope-accretion breaker with ${unbuiltPaths.length} unbuilt accreted artifact(s): ` +
4057
+ `${clampPathList(unbuiltPaths)}${deletedPaths.length ? ` (deleted: ${clampPathList(deletedPaths, 20)})` : ''} ` +
4058
+ `— P13 classification: ${p13Verdict}`;
4059
+ scopeAccretionMetric('fired', 'breaker-trip');
4060
+ const topicNum = Number(armedRecord.topicId);
4061
+ if (ctx.telegram && Number.isFinite(topicNum)) {
4062
+ await ctx.telegram.sendToTopic(topicNum, `⚠️ ${tripText}`).catch(() => { });
4063
+ await ctx.telegram
4064
+ .createAttentionItem({
4065
+ id: `scope-accretion-breaker-${armedRecord.topicId}-${armedRecord.runId}`,
4066
+ title: `Scope-accretion breaker tripped (topic ${armedRecord.topicId})`,
4067
+ summary: tripText,
4068
+ priority: 'HIGH',
4069
+ sourceContext: 'scope-accretion',
4070
+ category: 'autonomous',
4071
+ })
4072
+ .catch(() => { });
4073
+ }
4074
+ }
4075
+ }
4076
+ if (!tripped) {
4077
+ // The HOLD (R25): met:false with the machine-readable reason +
4078
+ // clamped path list, WITHOUT spending the judge LLM call. The
4079
+ // hook surfaces this verbatim — the session knows what to build.
4080
+ scopeAccretionMetric('fired', 'hold');
4081
+ res.json({
4082
+ met: false,
4083
+ reason: `scope-accretion-hold: ${unbuiltPaths.length} accreted deliverable(s) this run created must be ` +
4084
+ `built+corroborated (merged PR / converged report), declared, or operator-ratified before completion: ` +
4085
+ `${clampPathList(unbuiltPaths)}` +
4086
+ (deletedPaths.length ? ` — DELETED accreted deliverables (deletion is not an exit): ${clampPathList(deletedPaths, 20)}` : '') +
4087
+ (comp.degraded ? ' [corroborationDegraded: merged-PR evidence could not be fully fetched this pass]' : ''),
4088
+ scopeAccretion: {
4089
+ hold: true,
4090
+ unbuilt: unbuiltPaths.slice(0, 50),
4091
+ deleted: deletedPaths.slice(0, 20),
4092
+ consecutiveHolds: rec2.breaker.consecutiveHolds,
4093
+ breakerK: k,
4094
+ },
4095
+ });
4096
+ return;
4097
+ }
4098
+ // Post-trip (R39): the gate DISENGAGES for this run — fall through
4099
+ // to the judge exactly as today; the label was the deterrent.
4100
+ }
4101
+ else {
4102
+ // Progress (empty unbuilt set) resets the breaker counters.
4103
+ autonomousRunStore.update(armedRecord.topicId, armedRecord.runId, (r) => {
4104
+ if (!r.breaker.tripped && r.breaker.consecutiveHolds > 0) {
4105
+ r.breaker.consecutiveHolds = 0;
4106
+ r.breaker.accretedSetHash = '';
4107
+ r.breaker.lastProgressAt = new Date().toISOString();
4108
+ }
4109
+ });
4110
+ }
4111
+ // §2.8 step 3: the accretion facts ride along as CONTEXT lines in the
4112
+ // signals block (advisory corroboration for the judge's narrative
4113
+ // verdict) — gated on field presence so disabled mode is byte-identical.
4114
+ signals = {
4115
+ ...(signals ?? {}),
4116
+ scopeAccretion: {
4117
+ unbuilt: unbuiltPaths.slice(0, 50),
4118
+ deleted: deletedPaths.slice(0, 20),
4119
+ ratifiedCount: comp.rec.ratifiedArtifacts.length,
4120
+ corroborationDegraded: comp.degraded,
4121
+ },
4122
+ };
4123
+ }
4124
+ }
4125
+ const verdict = await ctx.completionEvaluator.evaluate(effectiveCondition, tail, signals);
4126
+ // R43: a met:true final verdict at the chokepoint marks the server-owned
4127
+ // run record TERMINAL (subject to the live-test veto below, which can
4128
+ // still flip met → false — terminality is recorded only on the verdict
4129
+ // that actually leaves this handler as met:true).
4130
+ const markMetTerminal = () => {
4131
+ if (armedRecord && autonomousRunStore.isActive(armedRecord)) {
4132
+ autonomousRunStore.markTerminal(armedRecord.topicId, armedRecord.runId, 'met', 'met:true final verdict');
4133
+ }
4134
+ };
3517
4135
  // ── Live-user-channel-proof veto (spec §4) ──────────────────────────────
3518
4136
  // A deterministic post-check on a met:true verdict: a USER-FACING feature
3519
4137
  // cannot resolve "done" without a verified live-test artifact. Same shape as
@@ -3534,6 +4152,8 @@ export function createRoutes(ctx) {
3534
4152
  return;
3535
4153
  }
3536
4154
  // dry-run / warn: surface the would-block but honor the original verdict.
4155
+ if (verdict.met)
4156
+ markMetTerminal();
3537
4157
  res.json({ ...verdict, liveTestGate: { outcome: gate.outcome, mode: gate.mode, wouldBlock: gate.wouldBlock, reason: gate.reason, overrode: false } });
3538
4158
  return;
3539
4159
  }
@@ -3543,6 +4163,8 @@ export function createRoutes(ctx) {
3543
4163
  // (the completion judge is the primary authority); fall through to the verdict.
3544
4164
  }
3545
4165
  }
4166
+ if (verdict.met)
4167
+ markMetTerminal();
3546
4168
  res.json(verdict);
3547
4169
  }
3548
4170
  catch (err) {
@@ -14677,6 +15299,21 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
14677
15299
  forwarded: req.body.forwarded === true,
14678
15300
  });
14679
15301
  }
15302
+ // ── Scope-accretion ratification observer (R45 — lifeline-forward ingress) ──
15303
+ // The server's OTHER in-process receive path: lifeline-owned-polling agents
15304
+ // never run TelegramAdapter.processUpdate, so the defer-trigger/confirmation
15305
+ // matcher fires here too. Signal-only: fire-and-forget, never blocks routing.
15306
+ if (messageId && text) {
15307
+ const fwdReplyTo = typeof req.body.replyToMessageId === 'number' ? req.body.replyToMessageId : undefined;
15308
+ void scopeAccretionRatifier.observeInbound({
15309
+ topicId: Number(topicId),
15310
+ text: String(text),
15311
+ senderUid: String(fromUserId ?? ''),
15312
+ messageId: Number(messageId) || 0,
15313
+ replyToMessageId: fwdReplyTo,
15314
+ at: Date.now(),
15315
+ });
15316
+ }
14680
15317
  // ── Exactly-once ingress gate (spec §8 G3a) ──────────────────────
14681
15318
  // Default-DARK: only active when multiMachine.exactlyOnceIngress wired the
14682
15319
  // ledger. Placed AFTER the sentinel intercept (emergency-stop/pause must