instar 0.28.95 → 0.28.97

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 (51) 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 +782 -7
  40. package/dist/server/routes.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/data/builtin-manifest.json +48 -48
  43. package/upgrades/0.28.96.md +347 -0
  44. package/upgrades/0.28.97.md +146 -0
  45. package/upgrades/side-effects/project-scope-phase1-7-skill-surface.md +175 -0
  46. package/upgrades/side-effects/project-scope-phase1b-connect-the-dots.md +184 -0
  47. package/upgrades/side-effects/project-scope-phase1b-pr3.md +228 -0
  48. package/upgrades/side-effects/project-scope-phase1b-pr4.md +198 -0
  49. package/upgrades/side-effects/project-scope-phase1b-pr5.md +150 -0
  50. package/upgrades/side-effects/project-scope-phase1b-pr6.md +136 -0
  51. 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,27 @@ 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
+ * Names are the canonical command set from PROJECT-SCOPE-SPEC § Phase 1.7. */
501
+ function skillCommandForAction(action, projectId, roundIndex) {
502
+ switch (action) {
503
+ case 'await-user-approval':
504
+ return `/project ack ${projectId}`;
505
+ case 'ack-required':
506
+ return `/project ack ${projectId}`;
507
+ case 'resolve-conflict':
508
+ return `/project resolve-conflict ${projectId}`;
509
+ case 'accept-partial':
510
+ return `/project accept-partial ${projectId} ${roundIndex}`;
511
+ case 'run-spec-converge':
512
+ return `/spec-converge`;
513
+ case 'run-drift-check':
514
+ return `/project drift ${projectId} ${roundIndex}`;
515
+ case 'start-round':
516
+ default:
517
+ return `/project run-round ${projectId} ${roundIndex}`;
518
+ }
519
+ }
497
520
  export function createRoutes(ctx) {
498
521
  const router = Router();
499
522
  // ── PR-REVIEW-HARDENING kill-switch (Phase A) ─────────────────────
@@ -4982,9 +5005,21 @@ export function createRoutes(ctx) {
4982
5005
  return;
4983
5006
  }
4984
5007
  const status = typeof req.query.status === 'string' ? req.query.status : undefined;
4985
- const items = status
5008
+ // Phase 1b PR 5 — server-side filters added so the dashboard
5009
+ // Initiatives tab can hide project-kind records (rendered in the
5010
+ // separate Projects tab) and any record that's a child of a
5011
+ // project (rendered inside its parent's card).
5012
+ const excludeKind = typeof req.query.excludeKind === 'string' ? req.query.excludeKind : undefined;
5013
+ const excludeParented = req.query.excludeParented === 'true';
5014
+ let items = status
4986
5015
  ? ctx.initiativeTracker.list({ status: status })
4987
5016
  : ctx.initiativeTracker.list();
5017
+ if (excludeKind) {
5018
+ items = items.filter((i) => (i.kind ?? 'task') !== excludeKind);
5019
+ }
5020
+ if (excludeParented) {
5021
+ items = items.filter((i) => !i.parentProjectId);
5022
+ }
4988
5023
  res.json({ items, count: items.length });
4989
5024
  });
4990
5025
  router.get('/initiatives/digest', (_req, res) => {
@@ -5109,7 +5144,7 @@ export function createRoutes(ctx) {
5109
5144
  const items = ctx.initiativeTracker.list({ kind: 'project' });
5110
5145
  res.json({ items, count: items.length });
5111
5146
  });
5112
- router.get('/projects/:id', (req, res) => {
5147
+ router.get('/projects/:id', async (req, res) => {
5113
5148
  if (!ctx.initiativeTracker) {
5114
5149
  res.status(503).json({ error: 'Initiative tracker not configured' });
5115
5150
  return;
@@ -5124,9 +5159,105 @@ export function createRoutes(ctx) {
5124
5159
  return;
5125
5160
  }
5126
5161
  // Join children that point at this project via parentProjectId.
5127
- const children = ctx.initiativeTracker
5162
+ let children = ctx.initiativeTracker
5128
5163
  .list()
5129
5164
  .filter((i) => i.parentProjectId === project.id);
5165
+ // Lazy merged-state reconciler — spec § Phase 1.4 lines 256-258.
5166
+ //
5167
+ // GET /projects/:id is documented as "may mutate": when a 'building' child's
5168
+ // mergeCommitOid is no longer ancestor of origin/main, we transition it to
5169
+ // 'regressed' and clear future autoAdvanceAt on its round.
5170
+ //
5171
+ // Selection contract:
5172
+ // - debounce: skip any child with `ciCheckedAt < 6h ago`
5173
+ // - cap: at most 3 child-revalidations per GET
5174
+ // - order: oldest `ciCheckedAt` first (treat undefined as 1970), ties broken
5175
+ // by `roundIndex` ASC, then `itemId` ASC — no child can starve.
5176
+ // - opt-out: `?reconcile=false` (or `?reconcile=0`) skips entirely
5177
+ const reconcileFlag = req.query.reconcile;
5178
+ const wantReconcile = reconcileFlag !== 'false' && reconcileFlag !== '0';
5179
+ if (wantReconcile && project.targetRepoPath) {
5180
+ const nowMs = Date.now();
5181
+ const debounceMs = 6 * 60 * 60 * 1000; // 6 hours
5182
+ const roundIndexOf = (childId) => {
5183
+ const rounds = project.rounds ?? [];
5184
+ for (let i = 0; i < rounds.length; i++) {
5185
+ if ((rounds[i].itemIds ?? []).includes(childId))
5186
+ return i;
5187
+ }
5188
+ return Number.MAX_SAFE_INTEGER;
5189
+ };
5190
+ const candidates = children
5191
+ .filter((c) => c.pipelineStage === 'building' && typeof c.mergeCommitOid === 'string' && c.mergeCommitOid)
5192
+ .filter((c) => {
5193
+ const t = c.ciCheckedAt ? Date.parse(c.ciCheckedAt) : 0;
5194
+ return !(Number.isFinite(t) && t > 0 && nowMs - t < debounceMs);
5195
+ })
5196
+ .sort((a, b) => {
5197
+ const ta = a.ciCheckedAt ? Date.parse(a.ciCheckedAt) : 0;
5198
+ const tb = b.ciCheckedAt ? Date.parse(b.ciCheckedAt) : 0;
5199
+ if (ta !== tb)
5200
+ return ta - tb;
5201
+ const ra = roundIndexOf(a.id);
5202
+ const rb = roundIndexOf(b.id);
5203
+ if (ra !== rb)
5204
+ return ra - rb;
5205
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
5206
+ })
5207
+ .slice(0, 3);
5208
+ const candidateIds = candidates.map((c) => c.id);
5209
+ if (candidateIds.length > 0) {
5210
+ let verified = new Set();
5211
+ try {
5212
+ verified = await verifyMergedItemsViaGit(project.targetRepoPath, candidateIds, ctx.initiativeTracker);
5213
+ }
5214
+ catch {
5215
+ // git not available, no network, etc. — leave verified empty so we
5216
+ // still update ciCheckedAt to back off; better than a hot loop.
5217
+ }
5218
+ let touched = false;
5219
+ for (const cand of candidates) {
5220
+ const child = ctx.initiativeTracker.get(cand.id);
5221
+ if (!child)
5222
+ continue;
5223
+ const isMerged = verified.has(cand.id);
5224
+ try {
5225
+ await ctx.initiativeTracker.update(cand.id, {
5226
+ pipelineStage: isMerged ? 'merged' : 'regressed',
5227
+ ciCheckedAt: new Date(nowMs).toISOString(),
5228
+ ifMatch: child.version,
5229
+ });
5230
+ touched = true;
5231
+ if (!isMerged) {
5232
+ // On regression: clear future autoAdvanceAt on the child's round
5233
+ // so the next round doesn't auto-fire while this one is broken.
5234
+ const projAfter = ctx.initiativeTracker.get(project.id);
5235
+ const rIdx = roundIndexOf(cand.id);
5236
+ if (projAfter && rIdx < (projAfter.rounds ?? []).length) {
5237
+ const newRounds = (projAfter.rounds ?? []).map((r, i) => i > rIdx ? { ...r, autoAdvanceAt: undefined } : r);
5238
+ try {
5239
+ await ctx.initiativeTracker.update(project.id, {
5240
+ rounds: newRounds,
5241
+ ifMatch: projAfter.version,
5242
+ });
5243
+ }
5244
+ catch {
5245
+ // OCC race; next GET will retry.
5246
+ }
5247
+ }
5248
+ }
5249
+ }
5250
+ catch {
5251
+ // OCC race or validator reject; leave as-is for next GET.
5252
+ }
5253
+ }
5254
+ if (touched) {
5255
+ children = ctx.initiativeTracker
5256
+ .list()
5257
+ .filter((i) => i.parentProjectId === project.id);
5258
+ }
5259
+ }
5260
+ }
5130
5261
  // Optional field selector: `?fields=id,title,pipelineStage`. Used by
5131
5262
  // the dashboard projects selector (Phase 1.10). Always preserves `id`.
5132
5263
  const fieldsParam = typeof req.query.fields === 'string' ? req.query.fields : '';
@@ -5143,17 +5274,661 @@ export function createRoutes(ctx) {
5143
5274
  }
5144
5275
  res.json({ project, children });
5145
5276
  });
5277
+ // GET /projects/:id/next — structured next-action payload.
5278
+ //
5279
+ // Spec § Phase 1.5 (line 268): returns `{ action, params, estimatedCost?,
5280
+ // skillCommand? }` for the FIRST round whose status is not 'complete'.
5281
+ // Ordering: roundIndex ASC, then pipelineStage ASC, then itemId ASC.
5282
+ //
5283
+ // Action verbs the spec enumerates (a non-exhaustive contract):
5284
+ // - 'await-user-approval' — first round needs `firstLaunchAckAt`
5285
+ // - 'ack-required' — unacknowledgedAdvanceCount >= cap
5286
+ // - 'resolve-conflict' — `awaitingReconciliation` non-empty
5287
+ // - 'accept-partial' — round is partially-complete
5288
+ // - 'run-spec-converge' — at least one item is `spec-drafted`
5289
+ // - 'run-drift-check' — at least one item is `approved`, no fresh
5290
+ // drift verdict
5291
+ // - 'start-round' — all preconditions met, ready to fire
5292
+ //
5293
+ // The endpoint does NOT run the runner's preflight — that's a heavier
5294
+ // check at fire time. The action is a hint for the dashboard + skill UI.
5146
5295
  router.get('/projects/:id/next', (req, res) => {
5296
+ if (!ctx.initiativeTracker) {
5297
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5298
+ return;
5299
+ }
5147
5300
  if (!initiativeIdRe.test(req.params.id)) {
5148
5301
  res.status(400).json({ error: 'invalid project id' });
5149
5302
  return;
5150
5303
  }
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',
5304
+ const project = ctx.initiativeTracker.get(req.params.id);
5305
+ if (!project || (project.kind ?? 'task') !== 'project') {
5306
+ res.status(404).json({ error: 'project not found' });
5307
+ return;
5308
+ }
5309
+ const rounds = project.rounds ?? [];
5310
+ const idx = rounds.findIndex((r) => (r.status ?? 'pending') !== 'complete');
5311
+ if (idx === -1) {
5312
+ res.status(204).end();
5313
+ return;
5314
+ }
5315
+ const r = rounds[idx];
5316
+ let action = 'start-round';
5317
+ const childrenById = new Map(ctx.initiativeTracker
5318
+ .list()
5319
+ .filter((i) => i.parentProjectId === project.id)
5320
+ .map((c) => [c.id, c]));
5321
+ const itemsForRound = (r.itemIds ?? []).map((id) => childrenById.get(id)).filter(Boolean);
5322
+ if ((project.awaitingReconciliation ?? []).length > 0) {
5323
+ action = 'resolve-conflict';
5324
+ }
5325
+ else if ((r.status ?? 'pending') === 'partially-complete') {
5326
+ action = 'accept-partial';
5327
+ }
5328
+ else if (idx === 0 && !project.firstLaunchAckAt) {
5329
+ action = 'await-user-approval';
5330
+ }
5331
+ else if ((project.unacknowledgedAdvanceCount ?? 0) >= 2) {
5332
+ action = 'ack-required';
5333
+ }
5334
+ else if (itemsForRound.some((c) => c?.pipelineStage === 'spec-drafted')) {
5335
+ action = 'run-spec-converge';
5336
+ }
5337
+ else if (itemsForRound.some((c) => c?.pipelineStage === 'approved' && !r.lastDriftVerdict)) {
5338
+ action = 'run-drift-check';
5339
+ }
5340
+ res.json({
5341
+ action,
5342
+ params: {
5343
+ projectId: project.id,
5344
+ projectVersion: project.version,
5345
+ roundIndex: idx,
5346
+ name: r.name,
5347
+ itemIds: r.itemIds,
5348
+ status: r.status ?? 'pending',
5349
+ autoAdvanceAt: r.autoAdvanceAt,
5350
+ },
5351
+ skillCommand: skillCommandForAction(action, project.id, idx),
5155
5352
  });
5156
5353
  });
5354
+ // ── /projects/:id mutating routes (Phase 1b PR 3) ──────────────
5355
+ //
5356
+ // All mutating endpoints require `If-Match: <version>` (OCC). Missing
5357
+ // header → 428. Stale version → 409. Spec § Phase 1.3.
5358
+ //
5359
+ // /advance is a single-item stage transition driven by StageTransitionValidator.
5360
+ // /halt, /ack, /accept-partial route through ProjectRoundRunner.
5361
+ function parseIfMatch(req, res) {
5362
+ const header = req.headers['if-match'];
5363
+ if (typeof header !== 'string' || !header.trim()) {
5364
+ res.status(428).json({ error: 'If-Match header required' });
5365
+ return null;
5366
+ }
5367
+ const n = parseInt(header.replace(/"/g, ''), 10);
5368
+ if (!Number.isInteger(n) || n <= 0) {
5369
+ res.status(400).json({ error: 'If-Match must be a positive integer version' });
5370
+ return null;
5371
+ }
5372
+ return n;
5373
+ }
5374
+ function lookupProject(req, res) {
5375
+ if (!ctx.initiativeTracker) {
5376
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5377
+ return null;
5378
+ }
5379
+ if (!initiativeIdRe.test(req.params.id)) {
5380
+ res.status(400).json({ error: 'invalid project id' });
5381
+ return null;
5382
+ }
5383
+ const project = ctx.initiativeTracker.get(req.params.id);
5384
+ if (!project || (project.kind ?? 'task') !== 'project') {
5385
+ res.status(404).json({ error: 'project not found' });
5386
+ return null;
5387
+ }
5388
+ return project;
5389
+ }
5390
+ // POST /projects/:id/advance — advance one child item by one stage.
5391
+ //
5392
+ // Body shape:
5393
+ // {
5394
+ // "itemId": "<child id>",
5395
+ // "fromStage": "<optional explicit current stage>",
5396
+ // "targetStage": "spec-drafted|spec-converged|approved|building|merged|skipped|outline",
5397
+ // "artifact": {
5398
+ // "specPath"?: "...", // outline → spec-drafted
5399
+ // "prNumber"?: 123, // building → merged
5400
+ // "taskFlowRecordId"?: "...", // approved → building
5401
+ // "skippedReason"?: "...", // any → skipped
5402
+ // "skippedBy"?: "...", // any → skipped
5403
+ // "unskippedAt"?: "..." // skipped → outline
5404
+ // }
5405
+ // }
5406
+ //
5407
+ // The HTTP layer does NOT enforce gates the ProjectRoundRunner already
5408
+ // enforces — `/advance` is a single-item transition that operates on
5409
+ // child records, not on round status. Round-level invariants (lock,
5410
+ // first-launch ack, owner machine, etc.) apply only to /run-round
5411
+ // and to the autonomous loop, both of which ship in a later PR.
5412
+ router.post('/projects/:id/advance', async (req, res) => {
5413
+ const project = lookupProject(req, res);
5414
+ if (!project)
5415
+ return;
5416
+ if (!ctx.initiativeTracker)
5417
+ return; // narrow for TS
5418
+ const ifMatch = parseIfMatch(req, res);
5419
+ if (ifMatch === null)
5420
+ return;
5421
+ if (ifMatch !== project.version) {
5422
+ res.status(409).json({ error: 'version mismatch', currentVersion: project.version });
5423
+ return;
5424
+ }
5425
+ const body = (req.body ?? {});
5426
+ const itemId = typeof body.itemId === 'string' ? body.itemId : '';
5427
+ const targetStage = typeof body.targetStage === 'string' ? body.targetStage : null;
5428
+ if (!itemId) {
5429
+ res.status(400).json({ error: '"itemId" required' });
5430
+ return;
5431
+ }
5432
+ if (!targetStage) {
5433
+ res.status(400).json({ error: '"targetStage" required' });
5434
+ return;
5435
+ }
5436
+ const child = ctx.initiativeTracker.get(itemId);
5437
+ if (!child || child.parentProjectId !== project.id) {
5438
+ res.status(404).json({ error: `child item "${itemId}" not found under project ${project.id}` });
5439
+ return;
5440
+ }
5441
+ if (!project.targetRepoPath) {
5442
+ res.status(400).json({ error: 'project has no targetRepoPath; cannot validate artifacts' });
5443
+ return;
5444
+ }
5445
+ const artifact = body.artifact ?? {};
5446
+ const validationCtx = {
5447
+ targetRepoPath: project.targetRepoPath,
5448
+ specPath: typeof artifact.specPath === 'string' ? artifact.specPath : undefined,
5449
+ prNumber: typeof artifact.prNumber === 'number' ? artifact.prNumber : undefined,
5450
+ taskFlowRecordId: typeof artifact.taskFlowRecordId === 'string' ? artifact.taskFlowRecordId : undefined,
5451
+ skippedReason: typeof artifact.skippedReason === 'string' ? artifact.skippedReason : undefined,
5452
+ skippedBy: typeof artifact.skippedBy === 'string' ? artifact.skippedBy : undefined,
5453
+ unskippedAt: typeof artifact.unskippedAt === 'string' ? artifact.unskippedAt : undefined,
5454
+ };
5455
+ const fromStage = typeof body.fromStage === 'string'
5456
+ ? body.fromStage
5457
+ : child.pipelineStage;
5458
+ const result = await validateStageTransition(fromStage, targetStage, validationCtx);
5459
+ if (!result.ok) {
5460
+ res.status(409).json({ error: 'stage transition rejected', code: result.code, reason: result.reason });
5461
+ return;
5462
+ }
5463
+ try {
5464
+ const updated = await ctx.initiativeTracker.update(itemId, {
5465
+ pipelineStage: targetStage,
5466
+ ifMatch: child.version,
5467
+ });
5468
+ // Bump the project version too so concurrent advance calls don't
5469
+ // operate on a stale view. We touch description ("last advance
5470
+ // <timestamp>") to force a meaningful update.
5471
+ const refreshed = await ctx.initiativeTracker.update(project.id, {
5472
+ // No-op-but-bump: nudge `lastTouchedAt` indirectly via update.
5473
+ nextCheckAt: project.nextCheckAt,
5474
+ ifMatch: project.version,
5475
+ });
5476
+ res.json({
5477
+ item: { id: updated.id, pipelineStage: updated.pipelineStage, version: updated.version },
5478
+ project: { id: refreshed.id, version: refreshed.version },
5479
+ });
5480
+ }
5481
+ catch (err) {
5482
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5483
+ const cv = err.currentVersion;
5484
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5485
+ return;
5486
+ }
5487
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5488
+ }
5489
+ });
5490
+ // POST /projects/:id/halt — kills the active round. Idempotent.
5491
+ router.post('/projects/:id/halt', async (req, res) => {
5492
+ const project = lookupProject(req, res);
5493
+ if (!project)
5494
+ return;
5495
+ if (!ctx.projectRoundRunner) {
5496
+ res.status(503).json({ error: 'ProjectRoundRunner not configured' });
5497
+ return;
5498
+ }
5499
+ const reason = typeof (req.body ?? {}).reason === 'string' ? req.body.reason : 'no reason given';
5500
+ try {
5501
+ const result = await ctx.projectRoundRunner.halt(project.id, reason);
5502
+ if (!result) {
5503
+ res.status(409).json({ error: 'project has no halt-able round' });
5504
+ return;
5505
+ }
5506
+ res.json({ id: result.project.id, roundIndex: result.roundIndex, version: result.project.version });
5507
+ }
5508
+ catch (err) {
5509
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5510
+ }
5511
+ });
5512
+ // POST /projects/:id/ack — record user acknowledgment.
5513
+ router.post('/projects/:id/ack', async (req, res) => {
5514
+ const project = lookupProject(req, res);
5515
+ if (!project)
5516
+ return;
5517
+ if (!ctx.projectRoundRunner) {
5518
+ res.status(503).json({ error: 'ProjectRoundRunner not configured' });
5519
+ return;
5520
+ }
5521
+ const body = (req.body ?? {});
5522
+ const idx = typeof body.forRoundIndex === 'number' ? body.forRoundIndex : (typeof body.roundIndex === 'number' ? body.roundIndex : 0);
5523
+ if (!Number.isInteger(idx) || idx < 0) {
5524
+ res.status(400).json({ error: '"forRoundIndex" must be a non-negative integer' });
5525
+ return;
5526
+ }
5527
+ try {
5528
+ const updated = await ctx.projectRoundRunner.recordAck(project.id, idx);
5529
+ if (!updated) {
5530
+ res.status(404).json({ error: 'project not found' });
5531
+ return;
5532
+ }
5533
+ res.json({
5534
+ id: updated.id,
5535
+ firstLaunchAckAt: updated.firstLaunchAckAt,
5536
+ lastAckedRoundIndex: updated.lastAckedRoundIndex,
5537
+ unacknowledgedAdvanceCount: updated.unacknowledgedAdvanceCount,
5538
+ version: updated.version,
5539
+ });
5540
+ }
5541
+ catch (err) {
5542
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5543
+ const cv = err.currentVersion;
5544
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5545
+ return;
5546
+ }
5547
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5548
+ }
5549
+ });
5550
+ // POST /projects/:id/accept-partial — close a partially-complete round.
5551
+ router.post('/projects/:id/accept-partial', async (req, res) => {
5552
+ const project = lookupProject(req, res);
5553
+ if (!project)
5554
+ return;
5555
+ if (!ctx.projectRoundRunner) {
5556
+ res.status(503).json({ error: 'ProjectRoundRunner not configured' });
5557
+ return;
5558
+ }
5559
+ const body = (req.body ?? {});
5560
+ const idx = typeof body.roundIndex === 'number' ? body.roundIndex : -1;
5561
+ const reason = typeof body.reason === 'string' ? body.reason : '';
5562
+ const skippedBy = typeof body.skippedBy === 'string' ? body.skippedBy : '';
5563
+ if (!Number.isInteger(idx) || idx < 0) {
5564
+ res.status(400).json({ error: '"roundIndex" must be a non-negative integer' });
5565
+ return;
5566
+ }
5567
+ if (!reason.trim()) {
5568
+ res.status(400).json({ error: '"reason" required' });
5569
+ return;
5570
+ }
5571
+ if (!skippedBy.trim()) {
5572
+ res.status(400).json({ error: '"skippedBy" required' });
5573
+ return;
5574
+ }
5575
+ try {
5576
+ const result = await ctx.projectRoundRunner.acceptPartial(project.id, idx, reason, skippedBy);
5577
+ if (!result) {
5578
+ res.status(404).json({ error: 'project or round not found' });
5579
+ return;
5580
+ }
5581
+ res.json({
5582
+ id: result.project.id,
5583
+ skippedItemIds: result.skippedItemIds,
5584
+ version: result.project.version,
5585
+ });
5586
+ }
5587
+ catch (err) {
5588
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5589
+ const cv = err.currentVersion;
5590
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5591
+ return;
5592
+ }
5593
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5594
+ }
5595
+ });
5596
+ // POST /projects/:id/claim-ownership — multi-machine ownership transfer.
5597
+ //
5598
+ // Body: {} or { force?: boolean }
5599
+ // Header: If-Match: <version> (OCC)
5600
+ //
5601
+ // Spec § P5: the claimer must (a) commit-and-push the claim before
5602
+ // acting on it, (b) wait 60s for git-sync to converge, (c) re-read
5603
+ // before any auto-action. This endpoint ONLY records the ownership
5604
+ // change; the wait-and-converge happens at the caller level (the
5605
+ // round runner, the auto-advance poller, the dashboard). Claim is
5606
+ // refused (409) if the current owner's heartbeat is still fresh and
5607
+ // `force: true` was not set.
5608
+ router.post('/projects/:id/claim-ownership', async (req, res) => {
5609
+ const project = lookupProject(req, res);
5610
+ if (!project)
5611
+ return;
5612
+ if (!ctx.initiativeTracker)
5613
+ return; // narrow for TS
5614
+ if (!ctx.machineHeartbeat || !ctx.machineHeartbeat.config) {
5615
+ res.status(503).json({ error: 'machine heartbeat not configured' });
5616
+ return;
5617
+ }
5618
+ const ifMatch = parseIfMatch(req, res);
5619
+ if (ifMatch === null)
5620
+ return;
5621
+ if (ifMatch !== project.version) {
5622
+ res.status(409).json({ error: 'version mismatch', currentVersion: project.version });
5623
+ return;
5624
+ }
5625
+ const force = (req.body ?? {}).force === true;
5626
+ const heartbeat = ctx.machineHeartbeat.api;
5627
+ const currentOwner = project.ownerMachineId;
5628
+ const claimer = ctx.machineHeartbeat.config.machineId;
5629
+ if (currentOwner === claimer) {
5630
+ // Already owned by claimer — idempotent success.
5631
+ res.json({ id: project.id, ownerMachineId: claimer, version: project.version, alreadyOwned: true });
5632
+ return;
5633
+ }
5634
+ if (currentOwner && !heartbeat.isStale(currentOwner) && !force) {
5635
+ res.status(409).json({
5636
+ error: 'current owner heartbeat is still fresh; pass {force:true} to claim anyway',
5637
+ currentOwner,
5638
+ });
5639
+ return;
5640
+ }
5641
+ try {
5642
+ const updated = await ctx.initiativeTracker.update(project.id, {
5643
+ ownerMachineId: claimer,
5644
+ ifMatch: project.version,
5645
+ });
5646
+ res.json({
5647
+ id: updated.id,
5648
+ ownerMachineId: updated.ownerMachineId,
5649
+ previousOwner: currentOwner ?? null,
5650
+ version: updated.version,
5651
+ });
5652
+ }
5653
+ catch (err) {
5654
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5655
+ const cv = err.currentVersion;
5656
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5657
+ return;
5658
+ }
5659
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5660
+ }
5661
+ });
5662
+ // POST /projects/:id/drift-check — run the drift checker for a given
5663
+ // round and return the verdict. Wraps ProjectDriftChecker.run().
5664
+ //
5665
+ // Body: { roundIndex: number, specPath: string, referencedFiles: string[],
5666
+ // timeoutMs?: number, modelId?: string }
5667
+ //
5668
+ // Mutex-guarded per project (spec § Phase 1.5 line 279). Without it two
5669
+ // concurrent calls would double-spend the drift-spend ledger and
5670
+ // double-bill the LLM. A second concurrent call returns 409 with
5671
+ // `{error: 'drift-check already in flight'}` so the caller can poll.
5672
+ //
5673
+ // 400 on validation; 503 if no checker configured; 200 with verdict envelope
5674
+ // on success. We do NOT require If-Match: drift is a read-only signal.
5675
+ const driftInFlight = new Set();
5676
+ router.post('/projects/:id/drift-check', async (req, res) => {
5677
+ if (!ctx.initiativeTracker) {
5678
+ res.status(503).json({ error: 'Initiative tracker not configured' });
5679
+ return;
5680
+ }
5681
+ if (!ctx.projectDriftChecker) {
5682
+ res.status(503).json({ error: 'ProjectDriftChecker not configured' });
5683
+ return;
5684
+ }
5685
+ if (!initiativeIdRe.test(req.params.id)) {
5686
+ res.status(400).json({ error: 'invalid project id' });
5687
+ return;
5688
+ }
5689
+ const project = ctx.initiativeTracker.get(req.params.id);
5690
+ if (!project || (project.kind ?? 'task') !== 'project') {
5691
+ res.status(404).json({ error: 'project not found' });
5692
+ return;
5693
+ }
5694
+ if (!project.targetRepoPath) {
5695
+ res.status(400).json({ error: 'project has no targetRepoPath' });
5696
+ return;
5697
+ }
5698
+ const body = (req.body ?? {});
5699
+ const roundIndex = typeof body.roundIndex === 'number' ? body.roundIndex : -1;
5700
+ const specPath = typeof body.specPath === 'string' ? body.specPath : '';
5701
+ const referencedFiles = Array.isArray(body.referencedFiles)
5702
+ ? body.referencedFiles.filter((f) => typeof f === 'string')
5703
+ : [];
5704
+ if (!Number.isInteger(roundIndex) || roundIndex < 0) {
5705
+ res.status(400).json({ error: '"roundIndex" (non-negative integer) required' });
5706
+ return;
5707
+ }
5708
+ if (!specPath.trim()) {
5709
+ res.status(400).json({ error: '"specPath" (string) required' });
5710
+ return;
5711
+ }
5712
+ const rounds = project.rounds ?? [];
5713
+ if (roundIndex >= rounds.length) {
5714
+ res.status(400).json({ error: 'roundIndex out of range', rounds: rounds.length });
5715
+ return;
5716
+ }
5717
+ if (driftInFlight.has(project.id)) {
5718
+ res.status(409).json({ error: 'drift-check already in flight for this project' });
5719
+ return;
5720
+ }
5721
+ driftInFlight.add(project.id);
5722
+ try {
5723
+ const verdict = await ctx.projectDriftChecker.run({
5724
+ projectId: project.id,
5725
+ roundIndex,
5726
+ targetRepoPath: project.targetRepoPath,
5727
+ specPath,
5728
+ referencedFiles,
5729
+ timeoutMs: typeof body.timeoutMs === 'number' ? body.timeoutMs : undefined,
5730
+ modelId: typeof body.modelId === 'string' ? body.modelId : undefined,
5731
+ });
5732
+ res.json({ verdict, projectId: project.id, roundIndex });
5733
+ }
5734
+ catch (err) {
5735
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
5736
+ }
5737
+ finally {
5738
+ driftInFlight.delete(project.id);
5739
+ }
5740
+ });
5741
+ // POST /projects/:id/run-round — manual round-start trigger.
5742
+ //
5743
+ // Spec § Phase 1.7: `/project run-round` is the user-invoked entry path.
5744
+ // Per § Phase 1.5, every entry path goes through `ProjectRoundRunner.preflight`.
5745
+ // On a successful preflight the route schedules the round for the auto-advance
5746
+ // poller by setting `autoAdvanceAt = now`; the poller fires the executor on
5747
+ // its next tick (≤60s). This avoids spawning a long-running child process
5748
+ // from a request handler and keeps a single fire path through the poller.
5749
+ //
5750
+ // Body: { roundIndex: number }
5751
+ // Returns 200 with the preflight verdict + scheduling timestamp on accept,
5752
+ // 409 with the preflight reason on reject, 503 if the runner is not wired.
5753
+ router.post('/projects/:id/run-round', async (req, res) => {
5754
+ const project = lookupProject(req, res);
5755
+ if (!project)
5756
+ return;
5757
+ if (!ctx.initiativeTracker)
5758
+ return;
5759
+ if (!ctx.projectRoundRunner) {
5760
+ res.status(503).json({ error: 'ProjectRoundRunner not configured' });
5761
+ return;
5762
+ }
5763
+ const body = (req.body ?? {});
5764
+ const idx = typeof body.roundIndex === 'number' ? body.roundIndex : 0;
5765
+ if (!Number.isInteger(idx) || idx < 0) {
5766
+ res.status(400).json({ error: '"roundIndex" must be a non-negative integer' });
5767
+ return;
5768
+ }
5769
+ const rounds = project.rounds ?? [];
5770
+ if (idx >= rounds.length) {
5771
+ res.status(404).json({ error: `round index ${idx} out of range (0..${rounds.length - 1})` });
5772
+ return;
5773
+ }
5774
+ const verdict = ctx.projectRoundRunner.preflight(project.id, idx);
5775
+ if (!verdict.ok) {
5776
+ res.status(409).json({ error: 'preflight rejected', code: verdict.code, reason: verdict.reason });
5777
+ return;
5778
+ }
5779
+ try {
5780
+ const now = new Date().toISOString();
5781
+ const nextRounds = rounds.map((r, i) => (i === idx ? { ...r, autoAdvanceAt: now } : r));
5782
+ const updated = await ctx.initiativeTracker.update(project.id, {
5783
+ rounds: nextRounds,
5784
+ ifMatch: project.version,
5785
+ });
5786
+ res.json({
5787
+ id: updated.id,
5788
+ roundIndex: idx,
5789
+ scheduledAt: now,
5790
+ version: updated.version,
5791
+ });
5792
+ }
5793
+ catch (err) {
5794
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5795
+ const cv = err.currentVersion;
5796
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5797
+ return;
5798
+ }
5799
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5800
+ }
5801
+ });
5802
+ // POST /projects/:id/resume — resume a halted round.
5803
+ //
5804
+ // Spec § Phase 1.5: clears `haltedAt`/`haltReason` on the round and schedules
5805
+ // it for the auto-advance poller. For rounds with status='failed' that have
5806
+ // hit the 3-attempt resume cap (`resumeAttempts >= 3`), the body must include
5807
+ // `{ force: true }`; otherwise the route returns 409. Resetting a `failed`
5808
+ // round also zeroes `resumeAttempts` so the runner gets a fresh budget.
5809
+ //
5810
+ // Body: { roundIndex?: number (default 0), force?: boolean }
5811
+ router.post('/projects/:id/resume', async (req, res) => {
5812
+ const project = lookupProject(req, res);
5813
+ if (!project)
5814
+ return;
5815
+ if (!ctx.initiativeTracker)
5816
+ return;
5817
+ const body = (req.body ?? {});
5818
+ const idx = typeof body.roundIndex === 'number' ? body.roundIndex : 0;
5819
+ const force = body.force === true;
5820
+ if (!Number.isInteger(idx) || idx < 0) {
5821
+ res.status(400).json({ error: '"roundIndex" must be a non-negative integer' });
5822
+ return;
5823
+ }
5824
+ const rounds = project.rounds ?? [];
5825
+ if (idx >= rounds.length) {
5826
+ res.status(404).json({ error: `round index ${idx} out of range (0..${rounds.length - 1})` });
5827
+ return;
5828
+ }
5829
+ const round = rounds[idx];
5830
+ if (round.status === 'failed' && (round.resumeAttempts ?? 0) >= 3 && !force) {
5831
+ res.status(409).json({
5832
+ error: 'round at resume-attempts cap; pass {force:true} to override',
5833
+ resumeAttempts: round.resumeAttempts,
5834
+ });
5835
+ return;
5836
+ }
5837
+ if (!round.haltedAt && round.status !== 'failed') {
5838
+ res.status(409).json({
5839
+ error: 'round is not halted or failed',
5840
+ status: round.status,
5841
+ });
5842
+ return;
5843
+ }
5844
+ try {
5845
+ const now = new Date().toISOString();
5846
+ const nextRounds = rounds.map((r, i) => {
5847
+ if (i !== idx)
5848
+ return r;
5849
+ const { haltedAt: _h, haltReason: _r, ...rest } = r;
5850
+ const resetAttempts = r.status === 'failed' && force;
5851
+ return {
5852
+ ...rest,
5853
+ status: 'pending',
5854
+ autoAdvanceAt: now,
5855
+ resumeAttempts: resetAttempts ? 0 : r.resumeAttempts,
5856
+ };
5857
+ });
5858
+ const nextStatus = project.status === 'halted' || project.status === 'abandoned' ? 'active' : project.status;
5859
+ const updated = await ctx.initiativeTracker.update(project.id, {
5860
+ rounds: nextRounds,
5861
+ status: nextStatus,
5862
+ ifMatch: project.version,
5863
+ });
5864
+ res.json({
5865
+ id: updated.id,
5866
+ roundIndex: idx,
5867
+ scheduledAt: now,
5868
+ forced: force,
5869
+ version: updated.version,
5870
+ });
5871
+ }
5872
+ catch (err) {
5873
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5874
+ const cv = err.currentVersion;
5875
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5876
+ return;
5877
+ }
5878
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5879
+ }
5880
+ });
5881
+ // POST /projects/:id/abandon — archive a halted/failed project.
5882
+ //
5883
+ // Spec § Phase 1.7: "archive a halted round; children remain at current stage."
5884
+ // Sets project.status='abandoned' and clears any future `autoAdvanceAt` on
5885
+ // remaining rounds so the poller stops considering them. Children's
5886
+ // `pipelineStage` is left untouched (per spec). Idempotent.
5887
+ //
5888
+ // Refuses (409) if any round is currently in-progress — halt first.
5889
+ router.post('/projects/:id/abandon', async (req, res) => {
5890
+ const project = lookupProject(req, res);
5891
+ if (!project)
5892
+ return;
5893
+ if (!ctx.initiativeTracker)
5894
+ return;
5895
+ const rounds = project.rounds ?? [];
5896
+ const active = rounds.find((r) => r.status === 'in-progress');
5897
+ if (active) {
5898
+ res.status(409).json({
5899
+ error: 'cannot abandon while a round is in-progress; halt the round first',
5900
+ activeRound: active.name,
5901
+ });
5902
+ return;
5903
+ }
5904
+ if (project.status === 'abandoned') {
5905
+ res.json({ id: project.id, status: 'abandoned', version: project.version, alreadyAbandoned: true });
5906
+ return;
5907
+ }
5908
+ try {
5909
+ const clearedRounds = rounds.map((r) => {
5910
+ if (r.autoAdvanceAt) {
5911
+ const { autoAdvanceAt: _x, ...rest } = r;
5912
+ return rest;
5913
+ }
5914
+ return r;
5915
+ });
5916
+ const updated = await ctx.initiativeTracker.update(project.id, {
5917
+ rounds: clearedRounds,
5918
+ status: 'abandoned',
5919
+ ifMatch: project.version,
5920
+ });
5921
+ res.json({ id: updated.id, status: updated.status, version: updated.version });
5922
+ }
5923
+ catch (err) {
5924
+ if (err instanceof Error && err.name === 'OccVersionMismatchError') {
5925
+ const cv = err.currentVersion;
5926
+ res.status(409).json({ error: 'version mismatch', currentVersion: cv });
5927
+ return;
5928
+ }
5929
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
5930
+ }
5931
+ });
5157
5932
  router.post('/projects', async (req, res) => {
5158
5933
  if (!ctx.initiativeTracker) {
5159
5934
  res.status(503).json({ error: 'Initiative tracker not configured' });