instar 0.28.95 → 0.28.96

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 (49) hide show
  1. package/dashboard/index.html +249 -2
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +90 -1
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/core/MachineHeartbeat.d.ts +94 -0
  6. package/dist/core/MachineHeartbeat.d.ts.map +1 -0
  7. package/dist/core/MachineHeartbeat.js +155 -0
  8. package/dist/core/MachineHeartbeat.js.map +1 -0
  9. package/dist/core/ProjectAutoAdvancePoller.d.ts +107 -0
  10. package/dist/core/ProjectAutoAdvancePoller.d.ts.map +1 -0
  11. package/dist/core/ProjectAutoAdvancePoller.js +176 -0
  12. package/dist/core/ProjectAutoAdvancePoller.js.map +1 -0
  13. package/dist/core/ProjectRoundCompleteMessage.d.ts +158 -0
  14. package/dist/core/ProjectRoundCompleteMessage.d.ts.map +1 -0
  15. package/dist/core/ProjectRoundCompleteMessage.js +253 -0
  16. package/dist/core/ProjectRoundCompleteMessage.js.map +1 -0
  17. package/dist/core/ProjectRoundExecution.d.ts +111 -0
  18. package/dist/core/ProjectRoundExecution.d.ts.map +1 -0
  19. package/dist/core/ProjectRoundExecution.js +359 -0
  20. package/dist/core/ProjectRoundExecution.js.map +1 -0
  21. package/dist/core/ProjectRoundLock.d.ts +67 -0
  22. package/dist/core/ProjectRoundLock.d.ts.map +1 -0
  23. package/dist/core/ProjectRoundLock.js +161 -0
  24. package/dist/core/ProjectRoundLock.js.map +1 -0
  25. package/dist/core/ProjectRoundRunner.d.ts +146 -0
  26. package/dist/core/ProjectRoundRunner.d.ts.map +1 -0
  27. package/dist/core/ProjectRoundRunner.js +371 -0
  28. package/dist/core/ProjectRoundRunner.js.map +1 -0
  29. package/dist/core/ProjectRoundWorktrees.d.ts +57 -0
  30. package/dist/core/ProjectRoundWorktrees.d.ts.map +1 -0
  31. package/dist/core/ProjectRoundWorktrees.js +111 -0
  32. package/dist/core/ProjectRoundWorktrees.js.map +1 -0
  33. package/dist/server/AgentServer.d.ts +12 -0
  34. package/dist/server/AgentServer.d.ts.map +1 -1
  35. package/dist/server/AgentServer.js +3 -0
  36. package/dist/server/AgentServer.js.map +1 -1
  37. package/dist/server/routes.d.ts +19 -0
  38. package/dist/server/routes.d.ts.map +1 -1
  39. package/dist/server/routes.js +590 -7
  40. package/dist/server/routes.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/data/builtin-manifest.json +47 -47
  43. package/upgrades/0.28.96.md +347 -0
  44. package/upgrades/side-effects/project-scope-phase1b-connect-the-dots.md +184 -0
  45. package/upgrades/side-effects/project-scope-phase1b-pr3.md +228 -0
  46. package/upgrades/side-effects/project-scope-phase1b-pr4.md +198 -0
  47. package/upgrades/side-effects/project-scope-phase1b-pr5.md +150 -0
  48. package/upgrades/side-effects/project-scope-phase1b-pr6.md +136 -0
  49. package/upgrades/side-effects/project-scope-phase1b-pr7.md +175 -0
@@ -22,6 +22,7 @@ import { execFile as execFileCb } from 'node:child_process';
22
22
  import { promisify } from 'node:util';
23
23
  import { SafeGitExecutor } from '../core/SafeGitExecutor.js';
24
24
  import { SafeFsExecutor } from '../core/SafeFsExecutor.js';
25
+ import { validateStageTransition } from '../core/StageTransitionValidator.js';
25
26
  const execFile = promisify(execFileCb);
26
27
  /**
27
28
  * Per-session continue-ceiling (spec § (b) Outcomes).
@@ -74,6 +75,7 @@ async function postVerifyEvidence(projectDir, evidence, pointer) {
74
75
  }
75
76
  import { ReflectionMetrics } from '../monitoring/ReflectionMetrics.js';
76
77
  import { HomeostasisMonitor } from '../monitoring/HomeostasisMonitor.js';
78
+ import { verifyMergedItemsViaGit } from '../core/ProjectRoundExecution.js';
77
79
  import { ProcessIntegrity } from '../core/ProcessIntegrity.js';
78
80
  import { getOutboundQueueStatus, cleanupDeliveredOutbound, buildAgentList } from '../messaging/GitSyncTransport.js';
79
81
  import { verifyAgentToken, getAgentToken } from '../messaging/AgentTokenManager.js';
@@ -494,6 +496,26 @@ function pickFields(obj, fields) {
494
496
  }
495
497
  return out;
496
498
  }
499
+ /** Maps a /projects/:id/next action verb to the suggested skill invocation. */
500
+ function skillCommandForAction(action, projectId, roundIndex) {
501
+ switch (action) {
502
+ case 'await-user-approval':
503
+ return `/project approve ${projectId}`;
504
+ case 'ack-required':
505
+ return `/project ack ${projectId}`;
506
+ case 'resolve-conflict':
507
+ return `/project resolve-conflict ${projectId}`;
508
+ case 'accept-partial':
509
+ return `/project accept-partial ${projectId} ${roundIndex}`;
510
+ case 'run-spec-converge':
511
+ return `/spec-converge`;
512
+ case 'run-drift-check':
513
+ return `/project drift-check ${projectId} ${roundIndex}`;
514
+ case 'start-round':
515
+ default:
516
+ return `/project run-round ${projectId} ${roundIndex}`;
517
+ }
518
+ }
497
519
  export function createRoutes(ctx) {
498
520
  const router = Router();
499
521
  // ── PR-REVIEW-HARDENING kill-switch (Phase A) ─────────────────────
@@ -4982,9 +5004,21 @@ export function createRoutes(ctx) {
4982
5004
  return;
4983
5005
  }
4984
5006
  const status = typeof req.query.status === 'string' ? req.query.status : undefined;
4985
- const items = status
5007
+ // Phase 1b PR 5 — server-side filters added so the dashboard
5008
+ // Initiatives tab can hide project-kind records (rendered in the
5009
+ // separate Projects tab) and any record that's a child of a
5010
+ // project (rendered inside its parent's card).
5011
+ const excludeKind = typeof req.query.excludeKind === 'string' ? req.query.excludeKind : undefined;
5012
+ const excludeParented = req.query.excludeParented === 'true';
5013
+ let items = status
4986
5014
  ? ctx.initiativeTracker.list({ status: status })
4987
5015
  : ctx.initiativeTracker.list();
5016
+ if (excludeKind) {
5017
+ items = items.filter((i) => (i.kind ?? 'task') !== excludeKind);
5018
+ }
5019
+ if (excludeParented) {
5020
+ items = items.filter((i) => !i.parentProjectId);
5021
+ }
4988
5022
  res.json({ items, count: items.length });
4989
5023
  });
4990
5024
  router.get('/initiatives/digest', (_req, res) => {
@@ -5109,7 +5143,7 @@ export function createRoutes(ctx) {
5109
5143
  const items = ctx.initiativeTracker.list({ kind: 'project' });
5110
5144
  res.json({ items, count: items.length });
5111
5145
  });
5112
- router.get('/projects/:id', (req, res) => {
5146
+ router.get('/projects/:id', async (req, res) => {
5113
5147
  if (!ctx.initiativeTracker) {
5114
5148
  res.status(503).json({ error: 'Initiative tracker not configured' });
5115
5149
  return;
@@ -5124,9 +5158,105 @@ export function createRoutes(ctx) {
5124
5158
  return;
5125
5159
  }
5126
5160
  // Join children that point at this project via parentProjectId.
5127
- const children = ctx.initiativeTracker
5161
+ let children = ctx.initiativeTracker
5128
5162
  .list()
5129
5163
  .filter((i) => i.parentProjectId === project.id);
5164
+ // Lazy merged-state reconciler — spec § Phase 1.4 lines 256-258.
5165
+ //
5166
+ // GET /projects/:id is documented as "may mutate": when a 'building' child's
5167
+ // mergeCommitOid is no longer ancestor of origin/main, we transition it to
5168
+ // 'regressed' and clear future autoAdvanceAt on its round.
5169
+ //
5170
+ // Selection contract:
5171
+ // - debounce: skip any child with `ciCheckedAt < 6h ago`
5172
+ // - cap: at most 3 child-revalidations per GET
5173
+ // - order: oldest `ciCheckedAt` first (treat undefined as 1970), ties broken
5174
+ // by `roundIndex` ASC, then `itemId` ASC — no child can starve.
5175
+ // - opt-out: `?reconcile=false` (or `?reconcile=0`) skips entirely
5176
+ const reconcileFlag = req.query.reconcile;
5177
+ const wantReconcile = reconcileFlag !== 'false' && reconcileFlag !== '0';
5178
+ if (wantReconcile && project.targetRepoPath) {
5179
+ const nowMs = Date.now();
5180
+ const debounceMs = 6 * 60 * 60 * 1000; // 6 hours
5181
+ const roundIndexOf = (childId) => {
5182
+ const rounds = project.rounds ?? [];
5183
+ for (let i = 0; i < rounds.length; i++) {
5184
+ if ((rounds[i].itemIds ?? []).includes(childId))
5185
+ return i;
5186
+ }
5187
+ return Number.MAX_SAFE_INTEGER;
5188
+ };
5189
+ const candidates = children
5190
+ .filter((c) => c.pipelineStage === 'building' && typeof c.mergeCommitOid === 'string' && c.mergeCommitOid)
5191
+ .filter((c) => {
5192
+ const t = c.ciCheckedAt ? Date.parse(c.ciCheckedAt) : 0;
5193
+ return !(Number.isFinite(t) && t > 0 && nowMs - t < debounceMs);
5194
+ })
5195
+ .sort((a, b) => {
5196
+ const ta = a.ciCheckedAt ? Date.parse(a.ciCheckedAt) : 0;
5197
+ const tb = b.ciCheckedAt ? Date.parse(b.ciCheckedAt) : 0;
5198
+ if (ta !== tb)
5199
+ return ta - tb;
5200
+ const ra = roundIndexOf(a.id);
5201
+ const rb = roundIndexOf(b.id);
5202
+ if (ra !== rb)
5203
+ return ra - rb;
5204
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
5205
+ })
5206
+ .slice(0, 3);
5207
+ const candidateIds = candidates.map((c) => c.id);
5208
+ if (candidateIds.length > 0) {
5209
+ let verified = new Set();
5210
+ try {
5211
+ verified = await verifyMergedItemsViaGit(project.targetRepoPath, candidateIds, ctx.initiativeTracker);
5212
+ }
5213
+ catch {
5214
+ // git not available, no network, etc. — leave verified empty so we
5215
+ // still update ciCheckedAt to back off; better than a hot loop.
5216
+ }
5217
+ let touched = false;
5218
+ for (const cand of candidates) {
5219
+ const child = ctx.initiativeTracker.get(cand.id);
5220
+ if (!child)
5221
+ continue;
5222
+ const isMerged = verified.has(cand.id);
5223
+ try {
5224
+ await ctx.initiativeTracker.update(cand.id, {
5225
+ pipelineStage: isMerged ? 'merged' : 'regressed',
5226
+ ciCheckedAt: new Date(nowMs).toISOString(),
5227
+ ifMatch: child.version,
5228
+ });
5229
+ touched = true;
5230
+ if (!isMerged) {
5231
+ // On regression: clear future autoAdvanceAt on the child's round
5232
+ // so the next round doesn't auto-fire while this one is broken.
5233
+ const projAfter = ctx.initiativeTracker.get(project.id);
5234
+ const rIdx = roundIndexOf(cand.id);
5235
+ if (projAfter && rIdx < (projAfter.rounds ?? []).length) {
5236
+ const newRounds = (projAfter.rounds ?? []).map((r, i) => i > rIdx ? { ...r, autoAdvanceAt: undefined } : r);
5237
+ try {
5238
+ await ctx.initiativeTracker.update(project.id, {
5239
+ rounds: newRounds,
5240
+ ifMatch: projAfter.version,
5241
+ });
5242
+ }
5243
+ catch {
5244
+ // OCC race; next GET will retry.
5245
+ }
5246
+ }
5247
+ }
5248
+ }
5249
+ catch {
5250
+ // OCC race or validator reject; leave as-is for next GET.
5251
+ }
5252
+ }
5253
+ if (touched) {
5254
+ children = ctx.initiativeTracker
5255
+ .list()
5256
+ .filter((i) => i.parentProjectId === project.id);
5257
+ }
5258
+ }
5259
+ }
5130
5260
  // Optional field selector: `?fields=id,title,pipelineStage`. Used by
5131
5261
  // the dashboard projects selector (Phase 1.10). Always preserves `id`.
5132
5262
  const fieldsParam = typeof req.query.fields === 'string' ? req.query.fields : '';
@@ -5143,17 +5273,470 @@ export function createRoutes(ctx) {
5143
5273
  }
5144
5274
  res.json({ project, children });
5145
5275
  });
5276
+ // GET /projects/:id/next — structured next-action payload.
5277
+ //
5278
+ // Spec § Phase 1.5 (line 268): returns `{ action, params, estimatedCost?,
5279
+ // skillCommand? }` for the FIRST round whose status is not 'complete'.
5280
+ // Ordering: roundIndex ASC, then pipelineStage ASC, then itemId ASC.
5281
+ //
5282
+ // Action verbs the spec enumerates (a non-exhaustive contract):
5283
+ // - 'await-user-approval' — first round needs `firstLaunchAckAt`
5284
+ // - 'ack-required' — unacknowledgedAdvanceCount >= cap
5285
+ // - 'resolve-conflict' — `awaitingReconciliation` non-empty
5286
+ // - 'accept-partial' — round is partially-complete
5287
+ // - 'run-spec-converge' — at least one item is `spec-drafted`
5288
+ // - 'run-drift-check' — at least one item is `approved`, no fresh
5289
+ // drift verdict
5290
+ // - 'start-round' — all preconditions met, ready to fire
5291
+ //
5292
+ // The endpoint does NOT run the runner's preflight — that's a heavier
5293
+ // check at fire time. The action is a hint for the dashboard + skill UI.
5146
5294
  router.get('/projects/:id/next', (req, res) => {
5295
+ if (!ctx.initiativeTracker) {
5296
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5297
+ return;
5298
+ }
5147
5299
  if (!initiativeIdRe.test(req.params.id)) {
5148
5300
  res.status(400).json({ error: 'invalid project id' });
5149
5301
  return;
5150
5302
  }
5151
- // Next-action computation lands in Phase 1b's ProjectRoundRunner.
5152
- res.status(501).json({
5153
- action: 'not-implemented',
5154
- message: 'next-action computation lands in Phase 1b',
5303
+ const project = ctx.initiativeTracker.get(req.params.id);
5304
+ if (!project || (project.kind ?? 'task') !== 'project') {
5305
+ res.status(404).json({ error: 'project not found' });
5306
+ return;
5307
+ }
5308
+ const rounds = project.rounds ?? [];
5309
+ const idx = rounds.findIndex((r) => (r.status ?? 'pending') !== 'complete');
5310
+ if (idx === -1) {
5311
+ res.status(204).end();
5312
+ return;
5313
+ }
5314
+ const r = rounds[idx];
5315
+ let action = 'start-round';
5316
+ const childrenById = new Map(ctx.initiativeTracker
5317
+ .list()
5318
+ .filter((i) => i.parentProjectId === project.id)
5319
+ .map((c) => [c.id, c]));
5320
+ const itemsForRound = (r.itemIds ?? []).map((id) => childrenById.get(id)).filter(Boolean);
5321
+ if ((project.awaitingReconciliation ?? []).length > 0) {
5322
+ action = 'resolve-conflict';
5323
+ }
5324
+ else if ((r.status ?? 'pending') === 'partially-complete') {
5325
+ action = 'accept-partial';
5326
+ }
5327
+ else if (idx === 0 && !project.firstLaunchAckAt) {
5328
+ action = 'await-user-approval';
5329
+ }
5330
+ else if ((project.unacknowledgedAdvanceCount ?? 0) >= 2) {
5331
+ action = 'ack-required';
5332
+ }
5333
+ else if (itemsForRound.some((c) => c?.pipelineStage === 'spec-drafted')) {
5334
+ action = 'run-spec-converge';
5335
+ }
5336
+ else if (itemsForRound.some((c) => c?.pipelineStage === 'approved' && !r.lastDriftVerdict)) {
5337
+ action = 'run-drift-check';
5338
+ }
5339
+ res.json({
5340
+ action,
5341
+ params: {
5342
+ projectId: project.id,
5343
+ projectVersion: project.version,
5344
+ roundIndex: idx,
5345
+ name: r.name,
5346
+ itemIds: r.itemIds,
5347
+ status: r.status ?? 'pending',
5348
+ autoAdvanceAt: r.autoAdvanceAt,
5349
+ },
5350
+ skillCommand: skillCommandForAction(action, project.id, idx),
5155
5351
  });
5156
5352
  });
5353
+ // ── /projects/:id mutating routes (Phase 1b PR 3) ──────────────
5354
+ //
5355
+ // All mutating endpoints require `If-Match: <version>` (OCC). Missing
5356
+ // header → 428. Stale version → 409. Spec § Phase 1.3.
5357
+ //
5358
+ // /advance is a single-item stage transition driven by StageTransitionValidator.
5359
+ // /halt, /ack, /accept-partial route through ProjectRoundRunner.
5360
+ function parseIfMatch(req, res) {
5361
+ const header = req.headers['if-match'];
5362
+ if (typeof header !== 'string' || !header.trim()) {
5363
+ res.status(428).json({ error: 'If-Match header required' });
5364
+ return null;
5365
+ }
5366
+ const n = parseInt(header.replace(/"/g, ''), 10);
5367
+ if (!Number.isInteger(n) || n <= 0) {
5368
+ res.status(400).json({ error: 'If-Match must be a positive integer version' });
5369
+ return null;
5370
+ }
5371
+ return n;
5372
+ }
5373
+ function lookupProject(req, res) {
5374
+ if (!ctx.initiativeTracker) {
5375
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5376
+ return null;
5377
+ }
5378
+ if (!initiativeIdRe.test(req.params.id)) {
5379
+ res.status(400).json({ error: 'invalid project id' });
5380
+ return null;
5381
+ }
5382
+ const project = ctx.initiativeTracker.get(req.params.id);
5383
+ if (!project || (project.kind ?? 'task') !== 'project') {
5384
+ res.status(404).json({ error: 'project not found' });
5385
+ return null;
5386
+ }
5387
+ return project;
5388
+ }
5389
+ // POST /projects/:id/advance — advance one child item by one stage.
5390
+ //
5391
+ // Body shape:
5392
+ // {
5393
+ // "itemId": "<child id>",
5394
+ // "fromStage": "<optional explicit current stage>",
5395
+ // "targetStage": "spec-drafted|spec-converged|approved|building|merged|skipped|outline",
5396
+ // "artifact": {
5397
+ // "specPath"?: "...", // outline → spec-drafted
5398
+ // "prNumber"?: 123, // building → merged
5399
+ // "taskFlowRecordId"?: "...", // approved → building
5400
+ // "skippedReason"?: "...", // any → skipped
5401
+ // "skippedBy"?: "...", // any → skipped
5402
+ // "unskippedAt"?: "..." // skipped → outline
5403
+ // }
5404
+ // }
5405
+ //
5406
+ // The HTTP layer does NOT enforce gates the ProjectRoundRunner already
5407
+ // enforces — `/advance` is a single-item transition that operates on
5408
+ // child records, not on round status. Round-level invariants (lock,
5409
+ // first-launch ack, owner machine, etc.) apply only to /run-round
5410
+ // and to the autonomous loop, both of which ship in a later PR.
5411
+ router.post('/projects/:id/advance', async (req, res) => {
5412
+ const project = lookupProject(req, res);
5413
+ if (!project)
5414
+ return;
5415
+ if (!ctx.initiativeTracker)
5416
+ return; // narrow for TS
5417
+ const ifMatch = parseIfMatch(req, res);
5418
+ if (ifMatch === null)
5419
+ return;
5420
+ if (ifMatch !== project.version) {
5421
+ res.status(409).json({ error: 'version mismatch', currentVersion: project.version });
5422
+ return;
5423
+ }
5424
+ const body = (req.body ?? {});
5425
+ const itemId = typeof body.itemId === 'string' ? body.itemId : '';
5426
+ const targetStage = typeof body.targetStage === 'string' ? body.targetStage : null;
5427
+ if (!itemId) {
5428
+ res.status(400).json({ error: '"itemId" required' });
5429
+ return;
5430
+ }
5431
+ if (!targetStage) {
5432
+ res.status(400).json({ error: '"targetStage" required' });
5433
+ return;
5434
+ }
5435
+ const child = ctx.initiativeTracker.get(itemId);
5436
+ if (!child || child.parentProjectId !== project.id) {
5437
+ res.status(404).json({ error: `child item "${itemId}" not found under project ${project.id}` });
5438
+ return;
5439
+ }
5440
+ if (!project.targetRepoPath) {
5441
+ res.status(400).json({ error: 'project has no targetRepoPath; cannot validate artifacts' });
5442
+ return;
5443
+ }
5444
+ const artifact = body.artifact ?? {};
5445
+ const validationCtx = {
5446
+ targetRepoPath: project.targetRepoPath,
5447
+ specPath: typeof artifact.specPath === 'string' ? artifact.specPath : undefined,
5448
+ prNumber: typeof artifact.prNumber === 'number' ? artifact.prNumber : undefined,
5449
+ taskFlowRecordId: typeof artifact.taskFlowRecordId === 'string' ? artifact.taskFlowRecordId : undefined,
5450
+ skippedReason: typeof artifact.skippedReason === 'string' ? artifact.skippedReason : undefined,
5451
+ skippedBy: typeof artifact.skippedBy === 'string' ? artifact.skippedBy : undefined,
5452
+ unskippedAt: typeof artifact.unskippedAt === 'string' ? artifact.unskippedAt : undefined,
5453
+ };
5454
+ const fromStage = typeof body.fromStage === 'string'
5455
+ ? body.fromStage
5456
+ : child.pipelineStage;
5457
+ const result = await validateStageTransition(fromStage, targetStage, validationCtx);
5458
+ if (!result.ok) {
5459
+ res.status(409).json({ error: 'stage transition rejected', code: result.code, reason: result.reason });
5460
+ return;
5461
+ }
5462
+ try {
5463
+ const updated = await ctx.initiativeTracker.update(itemId, {
5464
+ pipelineStage: targetStage,
5465
+ ifMatch: child.version,
5466
+ });
5467
+ // Bump the project version too so concurrent advance calls don't
5468
+ // operate on a stale view. We touch description ("last advance
5469
+ // <timestamp>") to force a meaningful update.
5470
+ const refreshed = await ctx.initiativeTracker.update(project.id, {
5471
+ // No-op-but-bump: nudge `lastTouchedAt` indirectly via update.
5472
+ nextCheckAt: project.nextCheckAt,
5473
+ ifMatch: project.version,
5474
+ });
5475
+ res.json({
5476
+ item: { id: updated.id, pipelineStage: updated.pipelineStage, version: updated.version },
5477
+ project: { id: refreshed.id, version: refreshed.version },
5478
+ });
5479
+ }
5480
+ catch (err) {
5481
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5482
+ const cv = err.currentVersion;
5483
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5484
+ return;
5485
+ }
5486
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5487
+ }
5488
+ });
5489
+ // POST /projects/:id/halt — kills the active round. Idempotent.
5490
+ router.post('/projects/:id/halt', async (req, res) => {
5491
+ const project = lookupProject(req, res);
5492
+ if (!project)
5493
+ return;
5494
+ if (!ctx.projectRoundRunner) {
5495
+ res.status(503).json({ error: 'ProjectRoundRunner not configured' });
5496
+ return;
5497
+ }
5498
+ const reason = typeof (req.body ?? {}).reason === 'string' ? req.body.reason : 'no reason given';
5499
+ try {
5500
+ const result = await ctx.projectRoundRunner.halt(project.id, reason);
5501
+ if (!result) {
5502
+ res.status(409).json({ error: 'project has no halt-able round' });
5503
+ return;
5504
+ }
5505
+ res.json({ id: result.project.id, roundIndex: result.roundIndex, version: result.project.version });
5506
+ }
5507
+ catch (err) {
5508
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5509
+ }
5510
+ });
5511
+ // POST /projects/:id/ack — record user acknowledgment.
5512
+ router.post('/projects/:id/ack', async (req, res) => {
5513
+ const project = lookupProject(req, res);
5514
+ if (!project)
5515
+ return;
5516
+ if (!ctx.projectRoundRunner) {
5517
+ res.status(503).json({ error: 'ProjectRoundRunner not configured' });
5518
+ return;
5519
+ }
5520
+ const body = (req.body ?? {});
5521
+ const idx = typeof body.forRoundIndex === 'number' ? body.forRoundIndex : (typeof body.roundIndex === 'number' ? body.roundIndex : 0);
5522
+ if (!Number.isInteger(idx) || idx < 0) {
5523
+ res.status(400).json({ error: '"forRoundIndex" must be a non-negative integer' });
5524
+ return;
5525
+ }
5526
+ try {
5527
+ const updated = await ctx.projectRoundRunner.recordAck(project.id, idx);
5528
+ if (!updated) {
5529
+ res.status(404).json({ error: 'project not found' });
5530
+ return;
5531
+ }
5532
+ res.json({
5533
+ id: updated.id,
5534
+ firstLaunchAckAt: updated.firstLaunchAckAt,
5535
+ lastAckedRoundIndex: updated.lastAckedRoundIndex,
5536
+ unacknowledgedAdvanceCount: updated.unacknowledgedAdvanceCount,
5537
+ version: updated.version,
5538
+ });
5539
+ }
5540
+ catch (err) {
5541
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5542
+ const cv = err.currentVersion;
5543
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5544
+ return;
5545
+ }
5546
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5547
+ }
5548
+ });
5549
+ // POST /projects/:id/accept-partial — close a partially-complete round.
5550
+ router.post('/projects/:id/accept-partial', async (req, res) => {
5551
+ const project = lookupProject(req, res);
5552
+ if (!project)
5553
+ return;
5554
+ if (!ctx.projectRoundRunner) {
5555
+ res.status(503).json({ error: 'ProjectRoundRunner not configured' });
5556
+ return;
5557
+ }
5558
+ const body = (req.body ?? {});
5559
+ const idx = typeof body.roundIndex === 'number' ? body.roundIndex : -1;
5560
+ const reason = typeof body.reason === 'string' ? body.reason : '';
5561
+ const skippedBy = typeof body.skippedBy === 'string' ? body.skippedBy : '';
5562
+ if (!Number.isInteger(idx) || idx < 0) {
5563
+ res.status(400).json({ error: '"roundIndex" must be a non-negative integer' });
5564
+ return;
5565
+ }
5566
+ if (!reason.trim()) {
5567
+ res.status(400).json({ error: '"reason" required' });
5568
+ return;
5569
+ }
5570
+ if (!skippedBy.trim()) {
5571
+ res.status(400).json({ error: '"skippedBy" required' });
5572
+ return;
5573
+ }
5574
+ try {
5575
+ const result = await ctx.projectRoundRunner.acceptPartial(project.id, idx, reason, skippedBy);
5576
+ if (!result) {
5577
+ res.status(404).json({ error: 'project or round not found' });
5578
+ return;
5579
+ }
5580
+ res.json({
5581
+ id: result.project.id,
5582
+ skippedItemIds: result.skippedItemIds,
5583
+ version: result.project.version,
5584
+ });
5585
+ }
5586
+ catch (err) {
5587
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5588
+ const cv = err.currentVersion;
5589
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5590
+ return;
5591
+ }
5592
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5593
+ }
5594
+ });
5595
+ // POST /projects/:id/claim-ownership — multi-machine ownership transfer.
5596
+ //
5597
+ // Body: {} or { force?: boolean }
5598
+ // Header: If-Match: <version> (OCC)
5599
+ //
5600
+ // Spec § P5: the claimer must (a) commit-and-push the claim before
5601
+ // acting on it, (b) wait 60s for git-sync to converge, (c) re-read
5602
+ // before any auto-action. This endpoint ONLY records the ownership
5603
+ // change; the wait-and-converge happens at the caller level (the
5604
+ // round runner, the auto-advance poller, the dashboard). Claim is
5605
+ // refused (409) if the current owner's heartbeat is still fresh and
5606
+ // `force: true` was not set.
5607
+ router.post('/projects/:id/claim-ownership', async (req, res) => {
5608
+ const project = lookupProject(req, res);
5609
+ if (!project)
5610
+ return;
5611
+ if (!ctx.initiativeTracker)
5612
+ return; // narrow for TS
5613
+ if (!ctx.machineHeartbeat || !ctx.machineHeartbeat.config) {
5614
+ res.status(503).json({ error: 'machine heartbeat not configured' });
5615
+ return;
5616
+ }
5617
+ const ifMatch = parseIfMatch(req, res);
5618
+ if (ifMatch === null)
5619
+ return;
5620
+ if (ifMatch !== project.version) {
5621
+ res.status(409).json({ error: 'version mismatch', currentVersion: project.version });
5622
+ return;
5623
+ }
5624
+ const force = (req.body ?? {}).force === true;
5625
+ const heartbeat = ctx.machineHeartbeat.api;
5626
+ const currentOwner = project.ownerMachineId;
5627
+ const claimer = ctx.machineHeartbeat.config.machineId;
5628
+ if (currentOwner === claimer) {
5629
+ // Already owned by claimer — idempotent success.
5630
+ res.json({ id: project.id, ownerMachineId: claimer, version: project.version, alreadyOwned: true });
5631
+ return;
5632
+ }
5633
+ if (currentOwner && !heartbeat.isStale(currentOwner) && !force) {
5634
+ res.status(409).json({
5635
+ error: 'current owner heartbeat is still fresh; pass {force:true} to claim anyway',
5636
+ currentOwner,
5637
+ });
5638
+ return;
5639
+ }
5640
+ try {
5641
+ const updated = await ctx.initiativeTracker.update(project.id, {
5642
+ ownerMachineId: claimer,
5643
+ ifMatch: project.version,
5644
+ });
5645
+ res.json({
5646
+ id: updated.id,
5647
+ ownerMachineId: updated.ownerMachineId,
5648
+ previousOwner: currentOwner ?? null,
5649
+ version: updated.version,
5650
+ });
5651
+ }
5652
+ catch (err) {
5653
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5654
+ const cv = err.currentVersion;
5655
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5656
+ return;
5657
+ }
5658
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5659
+ }
5660
+ });
5661
+ // POST /projects/:id/drift-check — run the drift checker for a given
5662
+ // round and return the verdict. Wraps ProjectDriftChecker.run().
5663
+ //
5664
+ // Body: { roundIndex: number, specPath: string, referencedFiles: string[],
5665
+ // timeoutMs?: number, modelId?: string }
5666
+ //
5667
+ // Mutex-guarded per project (spec § Phase 1.5 line 279). Without it two
5668
+ // concurrent calls would double-spend the drift-spend ledger and
5669
+ // double-bill the LLM. A second concurrent call returns 409 with
5670
+ // `{error: 'drift-check already in flight'}` so the caller can poll.
5671
+ //
5672
+ // 400 on validation; 503 if no checker configured; 200 with verdict envelope
5673
+ // on success. We do NOT require If-Match: drift is a read-only signal.
5674
+ const driftInFlight = new Set();
5675
+ router.post('/projects/:id/drift-check', async (req, res) => {
5676
+ if (!ctx.initiativeTracker) {
5677
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5678
+ return;
5679
+ }
5680
+ if (!ctx.projectDriftChecker) {
5681
+ res.status(503).json({ error: 'ProjectDriftChecker not configured' });
5682
+ return;
5683
+ }
5684
+ if (!initiativeIdRe.test(req.params.id)) {
5685
+ res.status(400).json({ error: 'invalid project id' });
5686
+ return;
5687
+ }
5688
+ const project = ctx.initiativeTracker.get(req.params.id);
5689
+ if (!project || (project.kind ?? 'task') !== 'project') {
5690
+ res.status(404).json({ error: 'project not found' });
5691
+ return;
5692
+ }
5693
+ if (!project.targetRepoPath) {
5694
+ res.status(400).json({ error: 'project has no targetRepoPath' });
5695
+ return;
5696
+ }
5697
+ const body = (req.body ?? {});
5698
+ const roundIndex = typeof body.roundIndex === 'number' ? body.roundIndex : -1;
5699
+ const specPath = typeof body.specPath === 'string' ? body.specPath : '';
5700
+ const referencedFiles = Array.isArray(body.referencedFiles)
5701
+ ? body.referencedFiles.filter((f) => typeof f === 'string')
5702
+ : [];
5703
+ if (!Number.isInteger(roundIndex) || roundIndex < 0) {
5704
+ res.status(400).json({ error: '"roundIndex" (non-negative integer) required' });
5705
+ return;
5706
+ }
5707
+ if (!specPath.trim()) {
5708
+ res.status(400).json({ error: '"specPath" (string) required' });
5709
+ return;
5710
+ }
5711
+ const rounds = project.rounds ?? [];
5712
+ if (roundIndex >= rounds.length) {
5713
+ res.status(400).json({ error: 'roundIndex out of range', rounds: rounds.length });
5714
+ return;
5715
+ }
5716
+ if (driftInFlight.has(project.id)) {
5717
+ res.status(409).json({ error: 'drift-check already in flight for this project' });
5718
+ return;
5719
+ }
5720
+ driftInFlight.add(project.id);
5721
+ try {
5722
+ const verdict = await ctx.projectDriftChecker.run({
5723
+ projectId: project.id,
5724
+ roundIndex,
5725
+ targetRepoPath: project.targetRepoPath,
5726
+ specPath,
5727
+ referencedFiles,
5728
+ timeoutMs: typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined,
5729
+ modelId: typeof body.modelId === 'string' ? body.modelId : undefined,
5730
+ });
5731
+ res.json({ verdict, projectId: project.id, roundIndex });
5732
+ }
5733
+ catch (err) {
5734
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
5735
+ }
5736
+ finally {
5737
+ driftInFlight.delete(project.id);
5738
+ }
5739
+ });
5157
5740
  router.post('/projects', async (req, res) => {
5158
5741
  if (!ctx.initiativeTracker) {
5159
5742
  res.status(503).json({ error: 'Initiative tracker not configured' });