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.
- package/.claude/skills/autonomous/SKILL.md +49 -0
- package/.claude/skills/autonomous/hooks/autonomous-stop-hook.sh +103 -9
- package/.claude/skills/autonomous/scripts/setup-autonomous.sh +47 -0
- package/dist/commands/init.js +9 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +19 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/AutonomousRunStore.d.ts +200 -0
- package/dist/core/AutonomousRunStore.d.ts.map +1 -0
- package/dist/core/AutonomousRunStore.js +402 -0
- package/dist/core/AutonomousRunStore.js.map +1 -0
- package/dist/core/CompletionEvaluator.d.ts +24 -0
- package/dist/core/CompletionEvaluator.d.ts.map +1 -1
- package/dist/core/CompletionEvaluator.js +29 -1
- package/dist/core/CompletionEvaluator.js.map +1 -1
- package/dist/core/ExternalOperationGate.d.ts.map +1 -1
- package/dist/core/ExternalOperationGate.js +6 -0
- package/dist/core/ExternalOperationGate.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +56 -3
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/ScopeAccretionCorroboration.d.ts +79 -0
- package/dist/core/ScopeAccretionCorroboration.d.ts.map +1 -0
- package/dist/core/ScopeAccretionCorroboration.js +159 -0
- package/dist/core/ScopeAccretionCorroboration.js.map +1 -0
- package/dist/core/ScopeAccretionRatifier.d.ts +93 -0
- package/dist/core/ScopeAccretionRatifier.d.ts.map +1 -0
- package/dist/core/ScopeAccretionRatifier.js +228 -0
- package/dist/core/ScopeAccretionRatifier.js.map +1 -0
- package/dist/core/ScopeAccretionSweep.d.ts +83 -0
- package/dist/core/ScopeAccretionSweep.d.ts.map +1 -0
- package/dist/core/ScopeAccretionSweep.js +353 -0
- package/dist/core/ScopeAccretionSweep.js.map +1 -0
- package/dist/core/WorkingSetManifest.d.ts.map +1 -1
- package/dist/core/WorkingSetManifest.js +26 -1
- package/dist/core/WorkingSetManifest.js.map +1 -1
- package/dist/core/installCodexHooks.d.ts.map +1 -1
- package/dist/core/installCodexHooks.js +9 -0
- package/dist/core/installCodexHooks.js.map +1 -1
- package/dist/core/types.d.ts +29 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
- package/dist/lifeline/TelegramLifeline.js +8 -0
- package/dist/lifeline/TelegramLifeline.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +18 -0
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +28 -0
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/scaffold/templates.js +2 -2
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +638 -1
- package/dist/server/routes.js.map +1 -1
- package/dist/server/specReviewRoutes.d.ts +6 -0
- package/dist/server/specReviewRoutes.d.ts.map +1 -1
- package/dist/server/specReviewRoutes.js +30 -0
- package/dist/server/specReviewRoutes.js.map +1 -1
- package/package.json +1 -1
- package/scripts/lint-scrape-fixture-realness.js +40 -0
- package/src/data/builtin-manifest.json +65 -65
- package/src/scaffold/templates.ts +2 -2
- package/upgrades/1.3.713.md +65 -0
- package/upgrades/1.3.714.md +40 -0
- package/upgrades/side-effects/autonomous-scope-accretion-completion.md +77 -0
- package/upgrades/side-effects/bench-prompt-fixes-2.md +72 -0
package/dist/server/routes.js
CHANGED
|
@@ -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
|
|
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
|