@cleocode/contracts 2026.9.23 → 2026.9.25

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 (85) hide show
  1. package/dist/branch-lock.d.ts +51 -0
  2. package/dist/branch-lock.d.ts.map +1 -1
  3. package/dist/branch-lock.js +25 -3
  4. package/dist/branch-lock.js.map +1 -1
  5. package/dist/cloud/api.d.ts +1 -1
  6. package/dist/data-accessor.d.ts +270 -16
  7. package/dist/data-accessor.d.ts.map +1 -1
  8. package/dist/data-accessor.js.map +1 -1
  9. package/dist/decide.d.ts +110 -1
  10. package/dist/decide.d.ts.map +1 -1
  11. package/dist/decide.js +23 -0
  12. package/dist/decide.js.map +1 -1
  13. package/dist/dispatch/operations-registry.d.ts.map +1 -1
  14. package/dist/dispatch/operations-registry.js +137 -6
  15. package/dist/dispatch/operations-registry.js.map +1 -1
  16. package/dist/doctor.d.ts +9 -0
  17. package/dist/doctor.d.ts.map +1 -1
  18. package/dist/exit-codes.d.ts +7 -0
  19. package/dist/exit-codes.d.ts.map +1 -1
  20. package/dist/exit-codes.js +7 -0
  21. package/dist/exit-codes.js.map +1 -1
  22. package/dist/index.d.ts +10 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/knowledge-health.d.ts +21 -0
  27. package/dist/knowledge-health.d.ts.map +1 -1
  28. package/dist/nexus-account.d.ts +160 -0
  29. package/dist/nexus-account.d.ts.map +1 -0
  30. package/dist/nexus-account.js +68 -0
  31. package/dist/nexus-account.js.map +1 -0
  32. package/dist/operations/focus.d.ts +7 -3
  33. package/dist/operations/focus.d.ts.map +1 -1
  34. package/dist/operations/nexus-scope-map.d.ts +8 -0
  35. package/dist/operations/nexus-scope-map.d.ts.map +1 -1
  36. package/dist/operations/nexus-scope-map.js +8 -0
  37. package/dist/operations/nexus-scope-map.js.map +1 -1
  38. package/dist/operations/nexus.d.ts +265 -0
  39. package/dist/operations/nexus.d.ts.map +1 -1
  40. package/dist/operations/nexus.js +9 -1
  41. package/dist/operations/nexus.js.map +1 -1
  42. package/dist/operations/orchestrate.d.ts +6 -1
  43. package/dist/operations/orchestrate.d.ts.map +1 -1
  44. package/dist/operations/output-contracts-data.d.ts.map +1 -1
  45. package/dist/operations/output-contracts-data.js +115 -1
  46. package/dist/operations/output-contracts-data.js.map +1 -1
  47. package/dist/operations/tasks.d.ts +73 -9
  48. package/dist/operations/tasks.d.ts.map +1 -1
  49. package/dist/operations/tasks.js.map +1 -1
  50. package/dist/project-lifecycle.d.ts +6 -2
  51. package/dist/project-lifecycle.d.ts.map +1 -1
  52. package/dist/row-identity.d.ts +128 -0
  53. package/dist/row-identity.d.ts.map +1 -0
  54. package/dist/row-identity.js +20 -0
  55. package/dist/row-identity.js.map +1 -0
  56. package/dist/session.d.ts +19 -1
  57. package/dist/session.d.ts.map +1 -1
  58. package/dist/session.js +9 -2
  59. package/dist/session.js.map +1 -1
  60. package/dist/task.d.ts +56 -2
  61. package/dist/task.d.ts.map +1 -1
  62. package/dist/tools/score-task-priority.d.ts +1 -1
  63. package/dist/tools/score-task-priority.d.ts.map +1 -1
  64. package/package.json +2 -2
  65. package/src/__tests__/__snapshots__/operations-registry.test.ts.snap +1 -1
  66. package/src/branch-lock.ts +80 -3
  67. package/src/data-accessor.ts +283 -17
  68. package/src/decide.ts +127 -1
  69. package/src/dispatch/operations-registry.ts +144 -6
  70. package/src/doctor.ts +9 -0
  71. package/src/exit-codes.ts +7 -0
  72. package/src/index.ts +56 -0
  73. package/src/knowledge-health.ts +22 -0
  74. package/src/nexus-account.ts +181 -0
  75. package/src/operations/focus.ts +7 -3
  76. package/src/operations/nexus-scope-map.ts +9 -0
  77. package/src/operations/nexus.ts +281 -0
  78. package/src/operations/orchestrate.ts +6 -2
  79. package/src/operations/output-contracts-data.ts +125 -1
  80. package/src/operations/tasks.ts +78 -12
  81. package/src/project-lifecycle.ts +6 -2
  82. package/src/row-identity.ts +132 -0
  83. package/src/session.ts +20 -2
  84. package/src/task.ts +58 -2
  85. package/src/tools/score-task-priority.ts +1 -1
@@ -111,8 +111,50 @@ export interface WorktreeMergeResult {
111
111
  *
112
112
  * Callers should treat `nothingToIntegrate: true` as success-with-no-work,
113
113
  * and reserve failure handling for `merged: false` WITHOUT this flag.
114
+ *
115
+ * T12773: also set when {@link WorktreeMergeResult.landedUpstream} is true —
116
+ * the task branch already landed on `origin/<targetBranch>`, so there is no
117
+ * local integration left to do.
114
118
  */
115
119
  nothingToIntegrate?: boolean;
120
+ /**
121
+ * True when the `task/<taskId>` branch had **already landed upstream** — its
122
+ * tip is an ancestor of `origin/<targetBranch>`, or merging it into
123
+ * `origin/<targetBranch>` would change nothing (squash-merged PR).
124
+ *
125
+ * @remarks
126
+ * T12773: `cleo done --pr <n>` on a task whose PR had already merged on
127
+ * origin ran a local `git merge --no-ff task/<id>` into a stale local
128
+ * default branch, forking it from origin. When this flag is set no local
129
+ * merge was attempted and the operator's checkout was NOT moved; when the
130
+ * local default branch is behind, {@link WorktreeMergeResult.syncCommand}
131
+ * names the command to sync it (hint-only).
132
+ *
133
+ * Not set for a task branch with zero commits beyond the local default
134
+ * branch — that is plain `nothingToIntegrate`.
135
+ */
136
+ landedUpstream?: boolean;
137
+ /**
138
+ * True when the local merge was REFUSED because the local default branch is
139
+ * behind `origin/<targetBranch>` (or diverged from it: behind and ahead). A
140
+ * merge there would fork it from origin (T12773). The worktree and task
141
+ * branch are preserved; sync the default branch
142
+ * ({@link WorktreeMergeResult.syncCommand}) and re-run the integration.
143
+ */
144
+ staleTarget?: boolean;
145
+ /**
146
+ * Exact shell command that syncs the local default branch with
147
+ * `origin/<targetBranch>` — `git merge --ff-only` when it is only behind,
148
+ * a plain `git merge origin/<targetBranch>` when it is diverged (never a
149
+ * rebase, which would drop the local ADR-062 `--no-ff` task merge commits
150
+ * and rewrite agent SHAs). CLEO never runs it (T12773).
151
+ */
152
+ syncCommand?: string;
153
+ /**
154
+ * Operator-facing next step when integration was skipped or refused without
155
+ * a merge (T12773) — e.g. how to sync a stale local default branch.
156
+ */
157
+ hint?: string;
116
158
  }
117
159
 
118
160
  /**
@@ -366,6 +408,19 @@ export const ISOLATION_ENV_KEYS = [
366
408
  /** Union of all isolation env key strings. */
367
409
  export type IsolationEnvKey = (typeof ISOLATION_ENV_KEYS)[number];
368
410
 
411
+ /**
412
+ * The identity keys re-exported on every shell call of a spawned agent
413
+ * (T12502) — see {@link IsolationResult.perCallLine}. `CLEO_AGENT_ROLE` is
414
+ * among them because the git shim reads it on every `git` call to enforce
415
+ * the worker's restricted-role policy; a shell that lost it would run git
416
+ * unrestricted.
417
+ */
418
+ const IDENTITY_ENV_KEYS = [
419
+ 'CLEO_SESSION_ID',
420
+ 'CLEO_AGENT_ID',
421
+ 'CLEO_AGENT_ROLE',
422
+ ] as const satisfies readonly IsolationEnvKey[];
423
+
369
424
  /**
370
425
  * Absolute-path validation rules enforced for Edit/Write SDK tool operations.
371
426
  *
@@ -515,6 +570,15 @@ export interface IsolationResult {
515
570
  * The trailing newline is included so callers can concatenate directly.
516
571
  */
517
572
  preamble: string;
573
+ /**
574
+ * The one line an agent must run at the start of EVERY shell call (T12502):
575
+ * `cd <worktree> && export CLEO_SESSION_ID=… CLEO_AGENT_ID=… CLEO_AGENT_ROLE=… || exit 1`
576
+ * (identity keys only when set; every value shell-quoted). Harness shells
577
+ * keep neither cwd nor env between calls, so this line re-enters the
578
+ * worktree AND re-binds the agent's own session and the role the git shim
579
+ * enforces. It is also Step 1 of {@link IsolationResult.preamble}.
580
+ */
581
+ perCallLine: string;
518
582
  /**
519
583
  * Boundary contract for downstream enforcement layers (git-shim, tests).
520
584
  *
@@ -596,11 +660,24 @@ export function provisionIsolatedShell(options: IsolationOptions): IsolationResu
596
660
  // T12520 — every path is emitted through `shellQuote`, never interpolated
597
661
  // bare or inside double quotes, so the snippet parses for any worktree path.
598
662
  const quotedPath = shellQuote(worktreePath);
663
+ // T12502 — env does not persist between an agent harness's Bash calls, so
664
+ // the per-call line re-binds the agent's own identity (and the role the git
665
+ // shim enforces) along with the cwd.
666
+ // Without it a worker's `cleo start` resolves to the ORCHESTRATOR's session
667
+ // and is refused on its own (spawn-claimed) task with E_TASK_CLAIMED.
668
+ const identityExports = IDENTITY_ENV_KEYS.filter((k) => env[k] !== '').map(
669
+ (k) => `${k}=${shellQuote(env[k])}`,
670
+ );
671
+ const perCallLine =
672
+ identityExports.length > 0
673
+ ? `cd ${quotedPath} && export ${identityExports.join(' ')} || exit 1`
674
+ : `cd ${quotedPath} || exit 1`;
599
675
  const preamble = [
600
676
  '## Worktree Isolation (REQUIRED — do not skip)',
601
677
  '',
602
- '# Step 1: Enter the worktree (exits immediately if path is missing)',
603
- `cd ${quotedPath} || exit 1`,
678
+ '# Step 1: Enter the worktree and bind your session (exits if the path is missing).',
679
+ '# Repeat this line at the start of EVERY Bash call: cwd and env do not persist.',
680
+ perCallLine,
604
681
  '',
605
682
  '# Step 2: Verify working directory (guards against shell/symlink quirks)',
606
683
  'case "$PWD" in',
@@ -630,7 +707,7 @@ export function provisionIsolatedShell(options: IsolationOptions): IsolationResu
630
707
  absolutePathRules,
631
708
  };
632
709
 
633
- return { cwd, env, preamble, boundaryContract };
710
+ return { cwd, env, preamble, perCallLine, boundaryContract };
634
711
  }
635
712
 
636
713
  // ---------------------------------------------------------------------------
@@ -17,6 +17,7 @@ import type { ArchivedTask } from './archive.js';
17
17
  import type { Session } from './session.js';
18
18
  import type {
19
19
  Task,
20
+ TaskClaim,
20
21
  TaskKind,
21
22
  TaskPriority,
22
23
  TaskSeverity,
@@ -150,6 +151,18 @@ export interface TaskFieldUpdates {
150
151
  updatedAt?: string | null;
151
152
  assignee?: string | null;
152
153
  pipelineStage?: string | null;
154
+ /**
155
+ * Agent claim lease columns (T12502). Change them through
156
+ * `DataAccessor.claimTask` / `unclaimTask`, which pair them with a
157
+ * {@link TaskClaimGuard} so the write is a compare-and-set on the holder.
158
+ */
159
+ claimedBySession?: string | null;
160
+ /** Agent identity of the lease holder (T12502). */
161
+ claimedByAgent?: string | null;
162
+ /** ISO-8601 UTC instant the lease was taken (T12502). */
163
+ claimedAt?: string | null;
164
+ /** ISO-8601 UTC instant the lease lapses unless renewed (T12502). */
165
+ leaseExpiresAt?: string | null;
153
166
  }
154
167
 
155
168
  /**
@@ -180,6 +193,14 @@ export interface AcRow {
180
193
  updatedAt: string | null;
181
194
  /** Optional sha256(text) snapshot; writers MAY populate, readers MUST treat null as "unknown". */
182
195
  contentHash: string | null;
196
+ /**
197
+ * Row uid (T12341): the criterion's identity across devices and across edits
198
+ * (`id` changes with the text; `uid` does not). `null` until the store fills
199
+ * it; absent from accessors that predate it.
200
+ */
201
+ uid?: string | null;
202
+ /** Birth fingerprint (T12341): write-once, carried with the uid across edits. */
203
+ birthFp?: string | null;
183
204
  }
184
205
 
185
206
  /** Machine-readable AC child-projection drift codes for doctor/audit output. */
@@ -251,6 +272,12 @@ export interface AcBindingRow {
251
272
  bindingType: 'direct' | 'satisfies' | 'coverage';
252
273
  /** ISO-8601 timestamp of binding creation. */
253
274
  createdAt: string;
275
+ /**
276
+ * The evidence was recorded against a different text of this criterion
277
+ * (T12341): the criterion kept its identity through an edit, but no gate may
278
+ * count this binding until the evidence is re-verified. Absent means valid.
279
+ */
280
+ stale?: boolean;
254
281
  }
255
282
 
256
283
  /** Query options for bounded reads from the append-only task audit log. @task T10594 */
@@ -281,16 +308,165 @@ export interface TaskAuditLogRow {
281
308
  * Optimistic-concurrency guard for a single-task write (T12503).
282
309
  *
283
310
  * A task's version is its `updatedAt` timestamp (falling back to `createdAt`
284
- * for a row that was never updated). Writers keep it strictly increasing, so a
285
- * caller that read version `v` and passes `expectedUpdatedAt: v` either writes
286
- * against exactly the row it read or fails with `E_CONFLICT`
287
- * (`ExitCode.VERSION_CONFLICT`) carrying the current version. The comparison
311
+ * for a row that was never updated). The update, complete and field-update
312
+ * paths advance it strictly, so a caller that read version `v` and passes
313
+ * `expectedUpdatedAt: v` either writes against exactly the row it read or
314
+ * fails with `E_CONFLICT` (`ExitCode.VERSION_CONFLICT`) carrying the current
315
+ * version. Some other writers still stamp it from the clock, leaving a narrow
316
+ * same-millisecond window (T12720). The comparison
288
317
  * runs inside the write transaction, after `BEGIN IMMEDIATE` holds the lock.
289
318
  * Omitting the guard keeps last-writer-wins semantics.
290
319
  */
291
320
  export interface TaskWriteGuard {
292
321
  /** Version the caller read; the write fails with `E_CONFLICT` if the row moved on. */
293
322
  expectedUpdatedAt?: string;
323
+ /**
324
+ * The task as the caller read it. Used only to build the conflict summary:
325
+ * on `E_CONFLICT`, the fields whose stored value differs from this snapshot
326
+ * are reported in {@link TaskConflictDetails.changedFields}. @task T12503
327
+ */
328
+ baseline?: Task;
329
+ /**
330
+ * Compare-and-set on the claim lease columns (T12502). The write matches
331
+ * only while the stored holder satisfies {@link TaskClaimGuard.mode}; when
332
+ * it does not, the write fails with `E_TASK_CLAIMED`
333
+ * ({@link TaskClaimedDetails}) instead of `E_CONFLICT`.
334
+ */
335
+ claim?: TaskClaimGuard;
336
+ /**
337
+ * Leave the task version (`updatedAt`) unchanged. For lease bookkeeping
338
+ * (renew, release) that must not invalidate another writer's `--if-match`.
339
+ * @task T12502
340
+ */
341
+ keepVersion?: boolean;
342
+ }
343
+
344
+ /**
345
+ * How a claim write treats the lease already stored on the task (T12502).
346
+ *
347
+ * - `acquire`: succeeds when the task is unclaimed, already held by the
348
+ * caller's session, or held by `handoffFrom` (an orchestrator handing the
349
+ * task to the agent it spawns). An expired lease still blocks: taking it is
350
+ * explicit.
351
+ * - `take-over`: `acquire`, or the stored lease has expired. Audited.
352
+ * - `force`: unconditional, even over a live lease. Audited.
353
+ * - `renew`: only the holder session may extend its lease.
354
+ * - `release`: only the holder session may clear its lease.
355
+ */
356
+ export type TaskClaimMode = 'acquire' | 'take-over' | 'force' | 'renew' | 'release';
357
+
358
+ /**
359
+ * Claim predicate for a guarded task write (T12502). Evaluated in the
360
+ * UPDATE's WHERE clause, inside the write transaction.
361
+ */
362
+ export interface TaskClaimGuard {
363
+ /** The caller's session, or `null` for an unbound caller (holds no lease). */
364
+ sessionId: string | null;
365
+ /** Which stored holders the write may replace. */
366
+ mode: TaskClaimMode;
367
+ /** ISO-8601 UTC "now" used for the lease-expiry comparison. */
368
+ now: string;
369
+ /** A session whose live lease `acquire` may take over (spawn hand-off). */
370
+ handoffFrom?: string | null;
371
+ }
372
+
373
+ /**
374
+ * A request to take, renew or override the claim lease on a task (T12502).
375
+ */
376
+ export interface TaskClaimRequest {
377
+ /** The claiming session; `null` checks the claim without taking a lease. */
378
+ sessionId: string | null;
379
+ /** Agent identity recorded with the lease, or `null`. */
380
+ agentId: string | null;
381
+ /** How to treat a stored lease (see {@link TaskClaimMode}); never `release`. */
382
+ mode: Exclude<TaskClaimMode, 'release'>;
383
+ /** Lease length in milliseconds; defaults to the core lease TTL. */
384
+ leaseMs?: number;
385
+ /** Clock override (ISO-8601 UTC) for tests. */
386
+ now?: string;
387
+ /** A session whose live lease `acquire` may take over (spawn hand-off). */
388
+ handoffFrom?: string | null;
389
+ }
390
+
391
+ /**
392
+ * `details` of an `E_TASK_CLAIMED` (`ExitCode.TASK_CLAIMED`) error: who holds
393
+ * the task, until when, and which explicit, audited override applies.
394
+ *
395
+ * @task T12502
396
+ */
397
+ export interface TaskClaimedDetails {
398
+ /** Always `'claimedBySession'`: the lease-holder column the claim compared. */
399
+ field: 'claimedBySession';
400
+ /** The claimed task. */
401
+ taskId: string;
402
+ /** The lease currently stored on the task. */
403
+ holder: TaskClaim;
404
+ /** `true` when the holder's lease has lapsed (take it with `--take-over`). */
405
+ expired: boolean;
406
+ /** The refused caller. */
407
+ requester: { sessionId: string | null; agentId: string | null };
408
+ /** The flag that would override: `--take-over` (expired) or `--force-claim` (live). */
409
+ override: '--take-over' | '--force-claim';
410
+ }
411
+
412
+ /**
413
+ * One field that differs between the caller's read and the stored row, as
414
+ * reported on an `E_CONFLICT` error. Values are JSON-serialised and truncated
415
+ * so the envelope stays small; `null` means the field was absent.
416
+ *
417
+ * @task T12503
418
+ */
419
+ export interface TaskConflictChange {
420
+ /** Task field name (camelCase, as on {@link Task}). */
421
+ field: string;
422
+ /** JSON of the value in the caller's read, or `null` when absent. */
423
+ was: string | null;
424
+ /** JSON of the value currently stored, or `null` when absent. */
425
+ now: string | null;
426
+ }
427
+
428
+ /**
429
+ * `details` of an `E_CONFLICT` (`ExitCode.VERSION_CONFLICT`) task error: the
430
+ * version the caller expected, the version now stored, and which fields moved
431
+ * so an agent can re-read, merge and retry with `--if-match <currentVersion>`.
432
+ *
433
+ * `changedFields` is diffed against the caller's read when the write path
434
+ * holds it (`cleo update`, `cleo complete`, or a guard with `baseline`). When
435
+ * the caller's read was already older than the one this command made, the
436
+ * summary covers only changes after that read; `current` always carries the
437
+ * stored values to merge against.
438
+ *
439
+ * @task T12503
440
+ */
441
+ export interface TaskConflictDetails {
442
+ /** Always `'updatedAt'`: the version field. */
443
+ field: 'updatedAt';
444
+ /** The version the caller expected. */
445
+ expected: string;
446
+ /** The version currently stored (same as {@link currentVersion}). */
447
+ actual: string;
448
+ /** The version to pass as `--if-match` after merging. */
449
+ currentVersion: string;
450
+ /** Names of the fields that differ between the caller's read and the stored row. */
451
+ changedFields: string[];
452
+ /** Per-field before/after summary for {@link changedFields}. */
453
+ changes: TaskConflictChange[];
454
+ /**
455
+ * Merge-relevant stored values of the task right now, bounded so the
456
+ * envelope stays small: `title` is cut at 200 characters and `labels` /
457
+ * `depends` hold at most the first 50 entries, with the full counts in
458
+ * `labelsTotal` / `dependsTotal`.
459
+ */
460
+ current: {
461
+ title: string;
462
+ status: TaskStatus;
463
+ priority: TaskPriority;
464
+ labels: string[];
465
+ labelsTotal: number;
466
+ depends: string[];
467
+ dependsTotal: number;
468
+ parentId: string | null;
469
+ } | null;
294
470
  }
295
471
 
296
472
  /**
@@ -299,6 +475,11 @@ export interface TaskWriteGuard {
299
475
  */
300
476
  export interface TransactionAccessor {
301
477
  upsertSingleTask(task: Task): Promise<void>;
478
+ /**
479
+ * Insert a NEW task; never overwrites. Throws `ID_COLLISION` when the id is
480
+ * already stored (T12724).
481
+ */
482
+ insertNewTask(task: Task): Promise<void>;
302
483
  archiveSingleTask(taskId: string, fields: ArchiveFields): Promise<void>;
303
484
  removeSingleTask(taskId: string): Promise<void>;
304
485
  setMetaValue(key: string, value: unknown): Promise<void>;
@@ -334,6 +515,10 @@ export interface TransactionAccessor {
334
515
  targetTaskId?: string | null;
335
516
  projection?: string;
336
517
  contentHash?: string | null;
518
+ /** Row uid to keep (T12341); omitted → a new uid is minted. */
519
+ uid?: string | null;
520
+ /** Birth fingerprint to keep with the uid (T12341). */
521
+ birthFp?: string | null;
337
522
  }>,
338
523
  ): Promise<void>;
339
524
  /**
@@ -349,13 +534,48 @@ export interface TransactionAccessor {
349
534
  * @task T10508
350
535
  */
351
536
  deleteAcRowsForTask(taskId: string): Promise<void>;
537
+ /**
538
+ * Delete only the named AC rows of `taskId` (rows of other tasks are never
539
+ * touched). Used by the diff apply path so ACs that survive an edit keep
540
+ * their row — and therefore their evidence bindings.
541
+ *
542
+ * `keepBindingsForUids`: uids the same write carries onto new rows (an
543
+ * edited criterion keeps its uid, T12341); their bindings are kept, as
544
+ * stale evidence, instead of pruned.
545
+ * @task T12789
546
+ */
547
+ deleteAcRowsByIds(
548
+ taskId: string,
549
+ ids: readonly string[],
550
+ keepBindingsForUids?: readonly string[],
551
+ ): Promise<void>;
552
+ /**
553
+ * Update existing AC rows of their own task in place, keyed by `(id, taskId)`.
554
+ * Every mutable column is rewritten from the supplied row; `id` and
555
+ * `taskId` are never changed. A row that does not exist for that task is
556
+ * an error, never an insert.
557
+ * @task T12789
558
+ */
559
+ updateAcRows(
560
+ rows: Array<{
561
+ id: string;
562
+ taskId: string;
563
+ ordinal: number;
564
+ text: string;
565
+ kind?: 'text' | 'child_task' | 'evidence_bound';
566
+ sourceKey?: string;
567
+ targetTaskId?: string | null;
568
+ projection?: string;
569
+ contentHash?: string | null;
570
+ }>,
571
+ ): Promise<void>;
352
572
  /**
353
573
  * Append a history row to `task_acceptance_criteria_history` capturing the
354
574
  * AC text that is about to be superseded.
355
575
  * @task T10508
356
576
  */
357
577
  appendAcHistory(
358
- rows: Array<{ acId: string; previousText: string; reason: string }>,
578
+ rows: Array<{ acId: string; previousText: string; reason: string; acUid?: string | null }>,
359
579
  ): Promise<void>;
360
580
  /**
361
581
  * Read all `evidence_ac_bindings` rows whose `ac_id` ∈ the given set.
@@ -387,6 +607,16 @@ export interface TransactionAccessor {
387
607
  bindingType: 'direct' | 'satisfies' | 'coverage';
388
608
  }>,
389
609
  ): Promise<void>;
610
+ /**
611
+ * Delete every `evidence_ac_bindings` row whose `ac_id` names no AC row
612
+ * (bindings left behind before AC removal pruned them), recording each
613
+ * removed row in the task audit log (`ac.bindings.pruned`) first. Backs
614
+ * `cleo doctor ac-bindings --fix`.
615
+ *
616
+ * @returns the removed bindings
617
+ * @task T12790
618
+ */
619
+ pruneOrphanAcBindings(): Promise<AcBindingRow[]>;
390
620
  }
391
621
 
392
622
  // Re-export AcRow at the module level for both transaction + outer accessor use.
@@ -436,6 +666,13 @@ export interface DataAccessor {
436
666
  /** Upsert a single task (targeted write, no full-file reload). */
437
667
  upsertSingleTask(task: Task): Promise<void>;
438
668
 
669
+ /**
670
+ * Insert a NEW task under a freshly allocated or computed id. Never
671
+ * overwrites: throws `ID_COLLISION` when the id is already stored, and
672
+ * writes nothing (T12724).
673
+ */
674
+ insertNewTask(task: Task): Promise<void>;
675
+
439
676
  /** Archive a single task by ID (sets status='archived' + archive metadata). */
440
677
  archiveSingleTask(taskId: string, fields: ArchiveFields): Promise<void>;
441
678
 
@@ -471,6 +708,14 @@ export interface DataAccessor {
471
708
  */
472
709
  getAcBindings(acIds: readonly string[]): Promise<AcBindingRow[]>;
473
710
 
711
+ /**
712
+ * Read every `evidence_ac_bindings` row whose `ac_id` names no row of
713
+ * `task_acceptance_criteria` — a binding for an AC that no longer exists.
714
+ * Read-only; backs `cleo doctor ac-bindings`.
715
+ * @task T12790
716
+ */
717
+ findOrphanAcBindings(): Promise<AcBindingRow[]>;
718
+
474
719
  // ---- Metadata (schema_meta KV store) ----
475
720
 
476
721
  /** Read a typed value from the metadata store. Returns null if not found. */
@@ -579,29 +824,50 @@ export interface DataAccessor {
579
824
  /** Get a single agent instance by ID. Returns null if not found. */
580
825
  getAgentInstance(agentId: string): Promise<DataAccessorAgentInstance | null>;
581
826
 
582
- // ---- Agent task claiming ----
827
+ // ---- Agent task claiming (leased, T12502) ----
583
828
 
584
829
  /**
585
- * Atomically claim a task for an agent.
830
+ * Take, renew or override the claim lease on a task in one compare-and-set
831
+ * write (`updateTaskFields` with a {@link TaskClaimGuard}). The human
832
+ * `assignee` is never touched.
586
833
  *
587
- * Uses `UPDATE ... WHERE assignee IS NULL OR assignee = agentId` to prevent
588
- * race conditions. Throws if the task is already claimed by a different agent.
834
+ * With `sessionId: null` no lease is written; the call only verifies, in
835
+ * the same way, that no other session holds the task.
589
836
  *
590
837
  * @param taskId - ID of the task to claim.
591
- * @param agentId - Agent identifier claiming the task.
592
- * @throws {Error} When the task is not found or is already claimed by another agent.
838
+ * @param request - Claimant, mode and lease length.
839
+ * @returns The lease now held, or `null` when `sessionId` is `null`.
840
+ * @throws {Error} When the task is not found.
841
+ * @throws CleoError `E_TASK_CLAIMED` with {@link TaskClaimedDetails} when
842
+ * another session holds the task and `mode` does not allow replacing it.
593
843
  */
594
- claimTask(taskId: string, agentId: string): Promise<void>;
844
+ claimTask(taskId: string, request: TaskClaimRequest): Promise<TaskClaim | null>;
595
845
 
596
846
  /**
597
- * Release a claimed task, clearing its assignee.
598
- *
599
- * No-op if the task is not currently claimed.
847
+ * Release the claim lease on a task. Without `force`, only the holder
848
+ * session's lease is cleared; with `force` any lease is cleared.
600
849
  *
601
- * @param taskId - ID of the task to unclaim.
850
+ * @param taskId - ID of the task to release.
851
+ * @param release - The releasing session and whether to force.
852
+ * @returns `true` when a lease was cleared, `false` when there was none to clear.
602
853
  * @throws {Error} When the task is not found.
854
+ * @throws CleoError `E_TASK_CLAIMED` when another session holds the lease and `force` is not set.
855
+ */
856
+ unclaimTask(
857
+ taskId: string,
858
+ release: { sessionId: string | null; force?: boolean },
859
+ ): Promise<boolean>;
860
+
861
+ /**
862
+ * Heartbeat: extend every lease held by `sessionId` to `leaseExpiresAt`,
863
+ * without changing any task version (T12502). A session holding no lease
864
+ * costs one indexed read and takes no write lock.
865
+ *
866
+ * @param sessionId - The holder session.
867
+ * @param leaseExpiresAt - New lease expiry (ISO-8601 UTC).
868
+ * @returns Number of leases renewed.
603
869
  */
604
- unclaimTask(taskId: string): Promise<void>;
870
+ renewSessionClaims(sessionId: string, leaseExpiresAt: string): Promise<number>;
605
871
  }
606
872
 
607
873
  // Factory functions (createDataAccessor, getTaskAccessor) live in @cleocode/core,
package/src/decide.ts CHANGED
@@ -209,7 +209,11 @@ export interface DecisionOutcome {
209
209
  readonly costUsd?: number;
210
210
  /** Provider-reported cost in integer micro-dollars, when known (T12664). */
211
211
  readonly costMicros?: number;
212
- /** Account balance in integer micro-dollars reported with this answer, when known. */
212
+ /**
213
+ * Account balance in integer micro-dollars reported with this answer, when
214
+ * known. The Jev wire adapter never sets it (the layahost OpenAPI reports
215
+ * balance only via `GET /v1/usage`, T12715); other providers may.
216
+ */
213
217
  readonly balanceMicros?: number;
214
218
  /** Provider model checkpoint that answered, when reported. */
215
219
  readonly checkpoint?: string;
@@ -234,9 +238,131 @@ export type DecisionProviderKind = (typeof DECISION_PROVIDER_KINDS)[number];
234
238
  /** Base URL of the layahost API (its OpenAPI `servers[0].url`). */
235
239
  export const LAYAHOST_BASE_URL = 'https://layahost.com';
236
240
 
241
+ /**
242
+ * Base URL of Jev's own API (TypeSafe AI). Source: the canonical spec
243
+ * `system-one-integration`, "Provider research: capability map" ("Jev itself
244
+ * (TypeSafe AI, `https://api.typesafe.ai`, OpenAPI v0.2.0)").
245
+ */
246
+ export const JEV_DEFAULT_BASE_URL = 'https://api.typesafe.ai';
247
+
237
248
  /** layahost's routing model, the default for a layahost connection. */
238
249
  export const LAYAHOST_DEFAULT_MODEL = 'laya-auto';
239
250
 
251
+ // ─── Named provider profiles (T12733) ────────────────────────────────────────
252
+
253
+ /**
254
+ * A valid profile name: 1-32 characters of lowercase letters, digits and
255
+ * `-`, starting and ending with a letter or digit (e.g. `default`, `work`,
256
+ * `team-b`). A profile is addressed as `<provider>/<name>`, e.g.
257
+ * `layahost/default` or `jev/team`; one provider may hold several profiles
258
+ * (accounts), each with its own key.
259
+ */
260
+ export const DECIDE_PROFILE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
261
+
262
+ /** The profile name used when none is given: `<provider>/default`. */
263
+ export const DECIDE_DEFAULT_PROFILE_NAME = 'default';
264
+
265
+ /**
266
+ * The stored `baseUrl` of a profile that uses its provider's preset URL. It
267
+ * is resolved when the profile is used, so a changed preset URL flows
268
+ * through to every profile that kept the default.
269
+ */
270
+ export const DECIDE_PROFILE_DEFAULT_URL = 'default';
271
+
272
+ /** How a profile's base URL is set: the provider's preset URL, or an override. */
273
+ export type DecideProfileUrlSource = 'default' | 'override';
274
+
275
+ /**
276
+ * Reachability of a decision provider, as `GET /v1/models` reports it.
277
+ *
278
+ * - `reachable` — the listing answered 2xx.
279
+ * - `unauthorized` — the provider rejected the key.
280
+ * - `key_limit_reached` — the key's monthly decision limit is reached.
281
+ * - `unconfigured` — no valid base URL + key stored.
282
+ * - `unreachable` — network failure, timeout, or any other HTTP status.
283
+ */
284
+ export type DecideProviderState =
285
+ | 'reachable'
286
+ | 'unauthorized'
287
+ | 'key_limit_reached'
288
+ | 'unconfigured'
289
+ | 'unreachable';
290
+
291
+ /** Result of probing one profile (`cleo decide profiles --probe`). Secret-free. */
292
+ export interface DecideProfileProbe {
293
+ /** Reachability verdict. */
294
+ readonly state: DecideProviderState;
295
+ /** HTTP status of the probe, when one was received. */
296
+ readonly httpStatus?: number;
297
+ /** Probe latency, ms. */
298
+ readonly latencyMs?: number;
299
+ /** Secret-free explanation, when there is one. */
300
+ readonly detail?: string;
301
+ }
302
+
303
+ /** One named System One provider profile, as listed. Carries no key, only its masked preview. */
304
+ export interface DecideProfileSummary {
305
+ /** Profile id, `<provider>/<name>`, e.g. `layahost/work`. */
306
+ readonly id: string;
307
+ /** Profile name within its provider, e.g. `work`. */
308
+ readonly name: string;
309
+ /** Whether everyday decisions use this profile. Exactly one profile is active when any is. */
310
+ readonly active: boolean;
311
+ /** Whether the profile holds an acceptable base URL and a non-blank key. */
312
+ readonly configured: boolean;
313
+ /** Provider kind. */
314
+ readonly provider: DecisionProviderKind;
315
+ /** Resolved base URL (userinfo removed). */
316
+ readonly baseUrl: string;
317
+ /** Whether the URL is the provider's preset (`default`) or an override. */
318
+ readonly urlSource: DecideProfileUrlSource;
319
+ /** Default model, when stored. */
320
+ readonly model?: string;
321
+ /** Masked key preview (`…abcd`). */
322
+ readonly keyPreview: string;
323
+ /** ISO timestamp of the profile's last write, when known. */
324
+ readonly updatedAt?: string;
325
+ /** Reachability, when probed. */
326
+ readonly probe?: DecideProfileProbe;
327
+ }
328
+
329
+ /**
330
+ * A profile resolved for use (`resolveDecideProfile`): its id, provider, base
331
+ * URL (a stored `default` already resolved to the preset), plaintext API key
332
+ * and default model. Contains the key: pass it to the wire and never log or
333
+ * emit it. `profile` repeats `name` so the value can be used directly as a
334
+ * decision connection whose provider state is cached per profile.
335
+ */
336
+ export interface DecideProfileConnection {
337
+ /** Profile id, `<provider>/<name>`. */
338
+ readonly name: string;
339
+ /** Same as {@link DecideProfileConnection.name}. */
340
+ readonly profile: string;
341
+ /** Provider kind. */
342
+ readonly provider: DecisionProviderKind;
343
+ /** Resolved base URL. */
344
+ readonly baseUrl: string;
345
+ /** Plaintext API key. SECRET. */
346
+ readonly apiKey: string;
347
+ /** Default model, when stored. */
348
+ readonly model?: string;
349
+ }
350
+
351
+ /** Result of listing the System One profiles. Secret-free. */
352
+ export interface DecideProfileListResult {
353
+ /** Absolute path of the credential store. */
354
+ readonly path: string;
355
+ /** Id (`<provider>/<name>`) of the active profile, or `null` when none is configured. */
356
+ readonly active: string | null;
357
+ /** Every stored profile, sorted by id. */
358
+ readonly profiles: readonly DecideProfileSummary[];
359
+ /**
360
+ * True when the top-level settings disagreed with the active profile's
361
+ * entry (an older CLEO rewrote the file) and were taken as authoritative.
362
+ */
363
+ readonly reconciled: boolean;
364
+ }
365
+
240
366
  /** Connection settings for a decision provider. */
241
367
  export interface DecisionProviderConfig {
242
368
  /** Absolute base URL of the provider's API (no trailing path to the endpoint). */