@ngockhoale/ukit 3.4.67 → 3.4.69

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 (63) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/manifests/documentation.yaml +33 -0
  3. package/manifests/instructionRules.yaml +10 -0
  4. package/manifests/platform.full.yaml +67 -0
  5. package/package.json +1 -1
  6. package/src/core/applyPlan.js +111 -2
  7. package/src/core/buildPlan.js +3 -1
  8. package/src/core/diffPlan.js +49 -1
  9. package/src/core/executionContracts.js +49 -8
  10. package/src/core/metadata.js +45 -0
  11. package/src/core/runInstallPipeline.js +30 -2
  12. package/src/core/uninstall.js +106 -2
  13. package/src/manifest/validateManifest.js +25 -1
  14. package/template_project/.claude/agents/code-reviewer.md +22 -9
  15. package/template_project/.claude/agents/handoff-planner.md +97 -8
  16. package/template_project/.claude/commands/ukit/handoff-create.md +114 -19
  17. package/template_project/.claude/commands/ukit/handoff-fullstack.md +247 -79
  18. package/template_project/.claude/commands/ukit/handoff-implement.md +75 -40
  19. package/template_project/.claude/commands/ukit/handoff-review.md +14 -10
  20. package/template_project/.claude/hooks/auto-allow-bash.sh +73 -12
  21. package/template_project/.claude/hooks/auto-prune-bash.sh +10 -0
  22. package/template_project/.claude/hooks/delegation-gate.mjs +21 -0
  23. package/template_project/.claude/hooks/handoff-model-guard.sh +22 -6
  24. package/template_project/.claude/hooks/handoff-resume.sh +141 -1
  25. package/template_project/.claude/hooks/record-execution.mjs +140 -9
  26. package/template_project/.claude/hooks/skill-router.sh +173 -8
  27. package/template_project/.claude/settings.json +2 -2
  28. package/template_project/.claude/skills/advisor-plan/REFERENCE.md +10 -0
  29. package/template_project/.claude/skills/advisor-plan/SKILL.md +2 -2
  30. package/template_project/.claude/ukit/index/allocate-task-worktree.mjs +15 -0
  31. package/template_project/.claude/ukit/index/amend-guard.mjs +116 -0
  32. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  33. package/template_project/.claude/ukit/index/land-worktree.mjs +243 -21
  34. package/template_project/.claude/ukit/index/plan-count.mjs +115 -0
  35. package/template_project/.claude/ukit/index/ready-gate.mjs +170 -0
  36. package/template_project/.claude/ukit/index/reset-auto-permissions.mjs +293 -4
  37. package/template_project/.claude/ukit/index/route-resolver.mjs +48 -8
  38. package/template_project/.claude/ukit/index/route-task.mjs +180 -11
  39. package/template_project/.claude/ukit/index/source-snapshot.mjs +172 -0
  40. package/template_project/.claude/ukit/index/task-budget-validator.mjs +109 -3
  41. package/template_project/.claude/ukit/runtime/async-lock.mjs +3 -3
  42. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +20 -3
  43. package/template_project/.claude/ukit/runtime/handoff-task-scope.mjs +205 -0
  44. package/template_project/.claude/ukit/runtime/loop-state.mjs +3 -1
  45. package/template_project/.claude/ukit/runtime/plan-dispatch.mjs +32 -5
  46. package/template_project/.claude/ukit/runtime/resumable-run.mjs +310 -21
  47. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +405 -105
  48. package/template_project/.claude/ukit/runtime/task-record-reconciliation.mjs +46 -0
  49. package/template_project/.claude/ukit/runtime/task-watchdog.mjs +28 -2
  50. package/template_project/.claude/ukit/runtime/worktree-allocation.mjs +75 -0
  51. package/template_project/.codex/README.md +25 -0
  52. package/template_project/.omp/RULES.md +22 -22
  53. package/template_project/.omp/agents/code-reviewer.md +22 -9
  54. package/template_project/.omp/agents/handoff-planner.md +102 -12
  55. package/template_project/.omp/hooks/pre/ukit-bridge.js +1 -1
  56. package/template_project/AGENTS.md +5 -1
  57. package/template_project/CLAUDE.md +1 -1
  58. package/template_project/docs/AI_HANDOFF/RULES.md +8 -0
  59. package/template_project/docs/AI_HANDOFF/tasks/_TEMPLATE.md +8 -1
  60. package/template_project/instructions/core.md +5 -1
  61. package/template_project/instructions/layout.yaml +2 -0
  62. package/template_project/instructions/overlays/omp-rules.md +22 -22
  63. package/template_project/ukit/storage/config.json +1 -1
@@ -742,6 +742,128 @@ function claimActive(claim, now) {
742
742
  : false;
743
743
  }
744
744
 
745
+ // Cycle identity (stale-claim across cycles hardening): task ids restart per
746
+ // cycle, so a bare task id names BOTH the archived cycle's task and the live
747
+ // cycle's task. A run pins its cycle with the additive `Cycle:` field in
748
+ // RUN.md (the same identity stop-coordinator's parseRunCycle defines); claims
749
+ // stamped with it let acquire/renew tell a PREVIOUS cycle's ghost from the
750
+ // CURRENT cycle's live worker. Normalized `cycle-NN`; anything unparseable →
751
+ // null (no stamp → the legacy, cycle-blind behavior).
752
+ export function normalizeClaimCycle(value) {
753
+ const raw = String(value ?? '').trim();
754
+ const m = raw.match(/^(?:cycle-)?(\d+)$/i);
755
+ return m ? `cycle-${m[1].padStart(2, '0')}` : null;
756
+ }
757
+
758
+ // True only when BOTH sides carry a cycle stamp and they differ — the only
759
+ // provably-stale shape. Unstamped (legacy) claims always take the legacy path,
760
+ // so an upgraded install never preempts a worker it cannot reason about.
761
+ function claimCycleDiffers(existing, incoming) {
762
+ return Boolean(existing?.cycle) && Boolean(incoming) && existing.cycle !== incoming;
763
+ }
764
+
765
+ // True when the live claim's cycle is strictly newer than the incoming (stale) one.
766
+ function claimCycleOlder(existing, incoming) {
767
+ if (!existing?.cycle || !incoming) return false;
768
+ const a = Number(String(existing.cycle).replace(/\D/g, ''));
769
+ const b = Number(String(incoming).replace(/\D/g, ''));
770
+ return Number.isFinite(a) && Number.isFinite(b) && b < a;
771
+ }
772
+
773
+ // A claim stamped with an OLDER cycle is a ghost (may be superseded) only when that cycle is PROVEN
774
+ // archived for THIS task. Proof is not a path: every component of docs/AI_HANDOFF/<ARCHIVE|archive>/
775
+ // cycle-NN must be a real non-symlink directory, and the archive's INDEX.md snapshot (written at
776
+ // archive time, before INDEX is reset) must be a readable, well-formed, non-symlink file with a
777
+ // terminal row for the claimed task — `done`, or `cancelled_superseded` resolved by a terminal
778
+ // `<root>-R<n>` successor in the same snapshot. Empty dir, regular file, symlink, malformed,
779
+ // unreadable, missing row or nonterminal row all deny (false). Unknown never supersedes.
780
+ const SAFE_DIR_ERRNO = new Set(['ENOENT']);
781
+
782
+ async function realDirectoryOrAbsent(target) {
783
+ try {
784
+ const st = await fs.lstat(target);
785
+ return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'unsafe';
786
+ } catch (error) {
787
+ return SAFE_DIR_ERRNO.has(error?.code) ? 'absent' : 'unsafe';
788
+ }
789
+ }
790
+
791
+ function parseArchiveSnapshotStatuses(text) {
792
+ const map = new Map();
793
+ for (const line of String(text).split('\n')) {
794
+ const row = line.trim();
795
+ if (!/^\|\s*TASK-/i.test(row)) continue;
796
+ const cells = row.split('|').slice(1, -1).map((c) => c.trim());
797
+ if (!row.startsWith('|') || !row.endsWith('|') || cells.length !== 8) return null;
798
+ const key = cells[0].toLowerCase();
799
+ if (map.has(key)) return null;
800
+ map.set(key, cells[4].toLowerCase());
801
+ }
802
+ return map;
803
+ }
804
+
805
+ // Terminal-success under the snapshot: done, or grounded cancellation resolved by a terminal successor.
806
+ function snapshotTaskTerminal(key, statuses, seen = new Set()) {
807
+ if (seen.has(key)) return false;
808
+ seen.add(key);
809
+ const st = statuses.get(key);
810
+ if (st === 'done') return true;
811
+ if (st !== 'cancelled_superseded') return false;
812
+ const root = key.replace(/-r\d+$/, '');
813
+ const currentRepair = BigInt(key.match(/-r(\d+)$/)?.[1] ?? '0');
814
+ for (const [other, ost] of statuses) {
815
+ if (!other.startsWith(`${root}-r`) || !/^\d+$/.test(other.slice(root.length + 2))) continue;
816
+ if (BigInt(other.slice(root.length + 2)) <= currentRepair) continue;
817
+ if (ost === 'done' || (ost === 'cancelled_superseded' && snapshotTaskTerminal(other, statuses, seen))) return true;
818
+ }
819
+ return false;
820
+ }
821
+
822
+ async function claimCycleFinalized(projectRoot, cycle, taskId) {
823
+ if (!cycle || !/^cycle-\d+$/.test(cycle) || !taskId) return false;
824
+ const ai = path.join(projectRoot, 'docs', 'AI_HANDOFF');
825
+ for (const parent of [path.join(projectRoot, 'docs'), ai]) {
826
+ if ((await realDirectoryOrAbsent(parent)) !== 'dir') return false;
827
+ }
828
+ let proven = false;
829
+ for (const name of ['ARCHIVE', 'archive']) {
830
+ const base = path.join(ai, name);
831
+ const baseState = await realDirectoryOrAbsent(base);
832
+ if (baseState === 'unsafe') return false;
833
+ if (baseState === 'absent') continue;
834
+ const cycleDir = path.join(base, cycle);
835
+ const cycleState = await realDirectoryOrAbsent(cycleDir);
836
+ if (cycleState === 'unsafe') return false;
837
+ if (cycleState === 'absent') continue;
838
+ // Snapshot must be a regular, non-symlink file that is readable and well-formed.
839
+ let raw;
840
+ try {
841
+ const st = await fs.lstat(path.join(cycleDir, 'INDEX.md'));
842
+ if (!st.isFile() || st.isSymbolicLink()) return false;
843
+ raw = await fs.readFile(path.join(cycleDir, 'INDEX.md'), 'utf8');
844
+ } catch {
845
+ return false;
846
+ }
847
+ const statuses = parseArchiveSnapshotStatuses(raw);
848
+ if (statuses === null) return false;
849
+ if (snapshotTaskTerminal(goalKey(taskId), statuses)) proven = true;
850
+ else return false;
851
+ }
852
+ return proven;
853
+ }
854
+
855
+ function goalKey(taskId) {
856
+ return String(taskId ?? '').trim().toLowerCase();
857
+ }
858
+
859
+ // True when the incoming cycle is strictly newer than the live claim's stamped cycle.
860
+ function claimCycleNewer(incoming, existing) {
861
+ if (!existing?.cycle || !incoming) return false;
862
+ const a = Number(String(existing.cycle).replace(/\D/g, ''));
863
+ const b = Number(String(incoming).replace(/\D/g, ''));
864
+ return Number.isFinite(a) && Number.isFinite(b) && b > a;
865
+ }
866
+
745
867
  // Read the claims map. Missing file → empty map; corrupt file → `ok:false`
746
868
  // (callers fail closed on mutations, report honestly on reads).
747
869
  async function readClaimsMap(projectRoot) {
@@ -758,13 +880,44 @@ async function readClaimsMap(projectRoot) {
758
880
  async function writeClaimsMap(projectRoot, claims) {
759
881
  const target = taskClaimsPath(projectRoot);
760
882
  await fs.mkdir(path.dirname(target), { recursive: true });
761
- await writeJsonAtomic(target, { schemaVersion: TASK_CLAIMS_SCHEMA_VERSION, claims });
883
+ let delegations = {};
884
+ try {
885
+ const stored = JSON.parse(await fs.readFile(target, 'utf8'));
886
+ if (stored.delegations !== undefined && !isPlainObject(stored.delegations)) throw new Error('invalid delegations');
887
+ delegations = stored.delegations || {};
888
+ } catch (error) {
889
+ if (error?.code !== 'ENOENT') throw error;
890
+ }
891
+ let delegationCount = Object.keys(delegations).length;
892
+ for (const [taskId, claim] of Object.entries(claims)) {
893
+ if (!claim.grantId || !String(claim.owner).startsWith('deleg:')) continue;
894
+ const key = `${claim.owner}/${taskId}`;
895
+ // Immutable launch tuple: a reused owner must never retarget an old completion.
896
+ // Never evict an original identity: a delayed completion could otherwise retarget a
897
+ // reused tool id. Real dispatch refuses saturation; low-level callers retain lease-only metadata.
898
+ if (!delegations[key] && delegationCount < 4096) {
899
+ delegations[key] = { taskId, owner: claim.owner, cycle: claim.cycle ?? null,
900
+ generation: claim.generation, grantId: claim.grantId, sessionId: claim.sessionId ?? null };
901
+ delegationCount++;
902
+ }
903
+ }
904
+ await writeJsonAtomic(target, { schemaVersion: TASK_CLAIMS_SCHEMA_VERSION, claims, delegations });
905
+ return delegations;
762
906
  }
763
907
 
764
908
  /**
765
909
  * Claim exclusive execution of `taskId`. Idempotent for the SAME owner (the
766
910
  * heartbeat is refreshed); a second owner inside the TTL gets
767
911
  * `{ ok: false, reason: 'active-claim' }`; an expired claim can be taken over.
912
+ * `cycle` (the run's `Cycle:` from RUN.md, normalized `cycle-NN`) stamps the
913
+ * claim. A different owner can retire an ACTIVE older-cycle claim only with
914
+ * valid archived delivery proof for that task; a cycle change alone is not proof.
915
+ * Same-owner newer-cycle grants bump generation; stale older-cycle acquires deny.
916
+ * Same-cycle and unstamped different-owner claims remain protected until expiry.
917
+ * `requireTermination` is mandatory for real dispatch: a delegated grant is never
918
+ * replaced using expiry or archived delivery status. Its immutable launch identity
919
+ * must have registry capacity and must not have been used by a previous grant.
920
+ * Low-level callers leaving this false must enforce their own write/landing fence.
768
921
  *
769
922
  * @returns {Promise<{ok:true, claim:{owner,heartbeat,ttlMs}, refreshed?:boolean, tookOver?:boolean}
770
923
  * | {ok:false, reason:string, claim?:object}>}
@@ -772,12 +925,15 @@ async function writeClaimsMap(projectRoot, claims) {
772
925
  export async function acquireTaskClaim(projectRoot, taskId, {
773
926
  owner = null,
774
927
  sessionId = null,
928
+ cycle = null,
775
929
  ttlMs = DEFAULT_TASK_CLAIM_TTL_MS,
776
930
  now = Date.now(),
777
931
  deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
932
+ requireTermination = false,
778
933
  } = {}) {
779
934
  const key = safeTaskId(taskId);
780
935
  const ownerToken = normalizeClaimOwner(owner);
936
+ const claimCycle = normalizeClaimCycle(cycle);
781
937
  const ttl = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : DEFAULT_TASK_CLAIM_TTL_MS;
782
938
  try {
783
939
  const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
@@ -785,13 +941,64 @@ export async function acquireTaskClaim(projectRoot, taskId, {
785
941
  if (!read.ok) return { ok: false, reason: read.reason };
786
942
  const claims = read.claims;
787
943
  const existing = claims[key];
944
+ // Expiry revokes lease authority, not the worker's filesystem access. Real dispatch
945
+ // must not start a competing writer until a terminal handback removed this grant.
946
+ // Keep the low-level lease API compatible for callers that enforce their own fence.
947
+ if (requireTermination && isPlainObject(existing)
948
+ && String(existing.owner).startsWith('deleg:')
949
+ && (!claimActive(existing, now)
950
+ || (existing.owner === ownerToken && claimCycleNewer(claimCycle, existing)))) {
951
+ return { ok: false, reason: 'termination-unconfirmed', claim: { ...existing } };
952
+ }
788
953
  if (isPlainObject(existing) && claimActive(existing, now)) {
789
- if (existing.owner === ownerToken) {
790
- claims[key] = { ...existing, heartbeat: now, ttlMs: existing.ttlMs ?? ttl };
954
+ if (existing.owner !== ownerToken) {
955
+ // A different owner's ACTIVE claim is never displaced by a different cycle alone: a
956
+ // freshly acquired live worker has not renewed yet, so "not renewed" does not prove it
957
+ // dead. Its lease expires by TTL. Sole exception: the claim is stamped with an OLDER
958
+ // cycle that has since been archived (completed-only), so that worker is finished and a
959
+ // reused task id must not wait out the TTL. Unknown/unreadable → keep the deny.
960
+ const ghost = !(requireTermination && String(existing.owner).startsWith('deleg:'))
961
+ && claimCycleNewer(claimCycle, existing)
962
+ && await claimCycleFinalized(projectRoot, existing.cycle, taskId);
963
+ if (!ghost) return { ok: false, reason: 'active-claim', claim: { ...existing } };
964
+ // ghost: fall through to the takeover below (generation bump fences the old owner)
965
+ } else if (claimCycleOlder(existing, claimCycle)) {
966
+ // Same owner, but the incoming acquire is from an OLDER cycle than the live grant:
967
+ // a late/stale acquire must not regress the newer cycle's claim.
968
+ return { ok: false, reason: 'stale-cycle', claim: { ...existing } };
969
+ } else if (!claimCycleDiffers(existing, claimCycle)) {
970
+ // Same owner, same cycle (or unstamped legacy): refresh the heartbeat in place.
971
+ claims[key] = {
972
+ ...existing,
973
+ heartbeat: now,
974
+ ttlMs: existing.ttlMs ?? ttl,
975
+ ...(claimCycle ? { cycle: claimCycle } : {}),
976
+ };
791
977
  await writeClaimsMap(projectRoot, claims);
792
978
  return { ok: true, claim: { ...claims[key] }, refreshed: true };
793
979
  }
794
- return { ok: false, reason: 'active-claim', claim: { ...existing } };
980
+ // Same owner, NEWER cycle: a new grant for the new cycle. Falls through to the takeover
981
+ // below, so the generation bumps and the old cycle's token is fenced.
982
+ }
983
+ if (requireTermination && ownerToken.startsWith('deleg:')) {
984
+ // Never launch a worker whose immutable handback tuple cannot be persisted.
985
+ // Do not evict old identities: a delayed notification must not target a new grant.
986
+ let delegations = {};
987
+ try {
988
+ const stored = JSON.parse(await fs.readFile(taskClaimsPath(projectRoot), 'utf8'));
989
+ if (stored.delegations !== undefined && !isPlainObject(stored.delegations)) {
990
+ return { ok: false, reason: 'claims-unreadable' };
991
+ }
992
+ delegations = stored.delegations || {};
993
+ } catch (error) {
994
+ if (error?.code !== 'ENOENT') return { ok: false, reason: 'claims-unreadable' };
995
+ }
996
+ if (delegations[`${ownerToken}/${key}`]) {
997
+ return { ok: false, reason: 'delegation-identity-reused' };
998
+ }
999
+ if (Object.keys(delegations).length >= 4096) {
1000
+ return { ok: false, reason: 'delegation-registry-full' };
1001
+ }
795
1002
  }
796
1003
  // `generation` is the fencing token: every fresh grant or takeover bumps it, so a result
797
1004
  // committed by an owner whose claim lapsed can be told apart from the current holder's.
@@ -799,15 +1006,20 @@ export async function acquireTaskClaim(projectRoot, taskId, {
799
1006
  const claim = {
800
1007
  owner: ownerToken,
801
1008
  heartbeat: now,
1009
+ claimedAt: now,
1010
+ grantId: crypto.randomUUID(),
802
1011
  ttlMs: ttl,
803
1012
  generation,
1013
+ ...(claimCycle ? { cycle: claimCycle } : {}),
804
1014
  ...(sessionId ? { sessionId: String(sessionId) } : {}),
805
1015
  };
806
1016
  claims[key] = claim;
807
- await writeClaimsMap(projectRoot, claims);
1017
+ const delegations = await writeClaimsMap(projectRoot, claims);
808
1018
  return {
809
1019
  ok: true,
810
1020
  claim: { ...claim },
1021
+ ...(ownerToken.startsWith('deleg:') && !delegations[`${ownerToken}/${key}`]
1022
+ ? { completionRecovery: 'lease-only' } : {}),
811
1023
  ...(isPlainObject(existing) ? { tookOver: true } : {}),
812
1024
  };
813
1025
  });
@@ -823,11 +1035,14 @@ export async function acquireTaskClaim(projectRoot, taskId, {
823
1035
  /** Refresh the heartbeat of a claim this owner still holds. Never creates one. */
824
1036
  export async function heartbeatTaskClaim(projectRoot, taskId, {
825
1037
  owner = null,
1038
+ cycle = null,
1039
+ generation = null,
826
1040
  now = Date.now(),
827
1041
  deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
828
1042
  } = {}) {
829
1043
  const key = safeTaskId(taskId);
830
1044
  const ownerToken = normalizeClaimOwner(owner);
1045
+ const beatCycle = normalizeClaimCycle(cycle);
831
1046
  try {
832
1047
  const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
833
1048
  const read = await readClaimsMap(projectRoot);
@@ -835,6 +1050,12 @@ export async function heartbeatTaskClaim(projectRoot, taskId, {
835
1050
  const existing = read.claims[key];
836
1051
  if (!isPlainObject(existing)) return { ok: false, reason: 'no-claim' };
837
1052
  if (existing.owner !== ownerToken) return { ok: false, reason: 'owner-mismatch' };
1053
+ // Same fences as release: an old-cycle / superseded-generation holder must not keep a
1054
+ // newer grant alive.
1055
+ if (claimCycleDiffers(existing, beatCycle)) return { ok: false, reason: 'cycle-mismatch' };
1056
+ if (generation !== null && Number(existing.generation) !== Number(generation)) {
1057
+ return { ok: false, reason: 'generation-mismatch' };
1058
+ }
838
1059
  read.claims[key] = { ...existing, heartbeat: now };
839
1060
  await writeClaimsMap(projectRoot, read.claims);
840
1061
  return { ok: true, claim: { ...read.claims[key] } };
@@ -846,13 +1067,47 @@ export async function heartbeatTaskClaim(projectRoot, taskId, {
846
1067
  }
847
1068
  }
848
1069
 
1070
+ // Notifications name the original tool-use-id, not the worker's task-id.
1071
+ export async function releaseCompletedDelegation(projectRoot, toolUseId, sessionId) {
1072
+ if (!toolUseId || !sessionId) return;
1073
+ let grants;
1074
+ let legacy = {};
1075
+ try {
1076
+ const store = JSON.parse(await fs.readFile(taskClaimsPath(projectRoot), 'utf8'));
1077
+ grants = store.delegations || {};
1078
+ if (isPlainObject(store.claims)) legacy = store.claims;
1079
+ } catch { return; }
1080
+ if (!isPlainObject(grants)) return;
1081
+ // Pre-grant claims (written before grant ids existed) have no registry entry. The same
1082
+ // authenticated handback (original tool-use id + session) still ends them; a claim that
1083
+ // carries a grantId is only ever released through its registered grant above.
1084
+ for (const [taskId, claim] of Object.entries(legacy)) {
1085
+ if (!isPlainObject(claim) || (claim.grantId !== undefined && claim.grantId !== null) || claim.owner !== `deleg:${toolUseId}`
1086
+ || claim.sessionId !== sessionId || !Number.isInteger(claim.generation)) continue;
1087
+ await releaseTaskClaim(projectRoot, taskId, { owner: claim.owner, cycle: claim.cycle, generation: claim.generation });
1088
+ }
1089
+ for (const grant of Object.values(grants)) {
1090
+ if (!isPlainObject(grant) || typeof grant.owner !== 'string' || !grant.owner.startsWith('deleg:')
1091
+ || typeof grant.taskId !== 'string' || !grant.taskId.trim()
1092
+ || !Number.isInteger(grant.generation) || grant.generation < 1
1093
+ || typeof grant.grantId !== 'string' || !grant.grantId
1094
+ || grant.owner !== `deleg:${toolUseId}` || grant.sessionId !== sessionId) continue;
1095
+ await releaseTaskClaim(projectRoot, grant.taskId, { owner: grant.owner, cycle: grant.cycle,
1096
+ generation: grant.generation, grantId: grant.grantId });
1097
+ }
1098
+ }
1099
+
849
1100
  /** Release a claim. Only the owner can remove it; absent claims release clean. */
850
1101
  export async function releaseTaskClaim(projectRoot, taskId, {
851
1102
  owner = null,
1103
+ cycle = null,
1104
+ generation = null,
1105
+ grantId = null,
852
1106
  deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
853
1107
  } = {}) {
854
1108
  const key = safeTaskId(taskId);
855
1109
  const ownerToken = normalizeClaimOwner(owner);
1110
+ const releaseCycle = normalizeClaimCycle(cycle);
856
1111
  try {
857
1112
  const outcome = await withAsyncLock(taskClaimsPath(projectRoot), { deadlineMs }, async () => {
858
1113
  const read = await readClaimsMap(projectRoot);
@@ -860,6 +1115,13 @@ export async function releaseTaskClaim(projectRoot, taskId, {
860
1115
  const existing = read.claims[key];
861
1116
  if (!isPlainObject(existing)) return { ok: true, released: false };
862
1117
  if (existing.owner !== ownerToken) return { ok: false, reason: 'owner-mismatch' };
1118
+ // Cycle + generation fences: a late release from an older cycle, or by a holder whose
1119
+ // generation was superseded, must not delete the current claim.
1120
+ if (claimCycleDiffers(existing, releaseCycle)) return { ok: false, reason: 'cycle-mismatch' };
1121
+ if (generation !== null && Number(existing.generation) !== Number(generation)) {
1122
+ return { ok: false, reason: 'generation-mismatch' };
1123
+ }
1124
+ if (grantId !== null && existing.grantId !== grantId) return { ok: false, reason: 'grant-mismatch' };
863
1125
  delete read.claims[key];
864
1126
  await writeClaimsMap(projectRoot, read.claims);
865
1127
  return { ok: true, released: true };
@@ -891,6 +1153,8 @@ export async function readTaskClaim(projectRoot, taskId, { now = Date.now() } =
891
1153
  // FIRST `TASK-…` in the delegation text is the primary task (later ids are dependencies).
892
1154
  export const CLAIMING_AGENT_TYPES = new Set(['feature-implementer', 'bug-debugger']);
893
1155
  export const DELEGATED_CLAIM_TTL_MS = 30 * 60 * 1000;
1156
+ // Parent activity is not proof that a delegated worker is still alive.
1157
+ export const DELEGATED_CLAIM_MAX_RENEWAL_MS = 8 * DELEGATED_CLAIM_TTL_MS;
894
1158
  const DELEGATED_TASK_ID_RE = /\bTASK-(?:C\d+-)?\d+\b/gi;
895
1159
  // A TASK id directly after an execution verb is the delegation's primary task; ids elsewhere in
896
1160
  // the text are dependencies or context ("after TASK-001", "see TASK-004").
@@ -945,9 +1209,9 @@ export function delegatedClaimTargets(toolInput, { isKnownTask = null } = {}) {
945
1209
  return targets;
946
1210
  }
947
1211
 
948
- /** Stable owner token for one delegation: the tool-call id, else the session id. */
1212
+ /** Stable owner token for one delegation; a session id cannot identify siblings. */
949
1213
  export function delegatedClaimOwner(payload) {
950
- const id = payload?.tool_use_id || payload?.session_id;
1214
+ const id = payload?.tool_use_id;
951
1215
  return typeof id === 'string' && id ? `deleg:${id}` : null;
952
1216
  }
953
1217
 
@@ -956,16 +1220,26 @@ export function delegatedClaimOwner(payload) {
956
1220
  * through its own session activity. Any tool event of the owning session (the parent's hooks
957
1221
  * fire for subagent tool calls too) refreshes that session's active claims, so a LIVE task is
958
1222
  * never handed to a second agent by TTL alone; only a session that went silent for a full TTL
959
- * loses its claim. Lock-free pre-check keeps the common call (nothing due) free of I/O writes.
1223
+ * loses its claim. `cycle` (the run's current `Cycle:`) scopes the renewal to this run: a
1224
+ * claim stamped with a DIFFERENT cycle is a previous cycle's ghost on a reused task id, and
1225
+ * the session's continued activity must not keep it alive — it expires by its TTL instead.
1226
+ * Unstamped claims renew as before. Lock-free pre-check keeps the common call (nothing due)
1227
+ * free of I/O writes.
960
1228
  */
961
1229
  export async function renewSessionClaims(projectRoot, sessionId, {
1230
+ cycle = null,
962
1231
  now = Date.now(),
963
1232
  deadlineMs = RUN_JOURNAL_LOCK_BUDGET_MS,
964
1233
  } = {}) {
965
1234
  const sid = typeof sessionId === 'string' ? sessionId : '';
966
1235
  if (!sid) return { renewed: 0 };
1236
+ const currentCycle = normalizeClaimCycle(cycle);
967
1237
  const due = (claim) => isPlainObject(claim) && claim.sessionId === sid && claimActive(claim, now)
968
- && now - Number(claim.heartbeat) > Number(claim.ttlMs) / 10;
1238
+ && (!String(claim.owner || '').startsWith('deleg:')
1239
+ || now - Number(claim.claimedAt ?? claim.heartbeat) < DELEGATED_CLAIM_MAX_RENEWAL_MS)
1240
+ && now - Number(claim.heartbeat) > Number(claim.ttlMs) / 10
1241
+ // A provably foreign-cycle claim is never renewed: the ghost ages out.
1242
+ && !(currentCycle && claim.cycle && claim.cycle !== currentCycle);
969
1243
  const peek = await readClaimsMap(projectRoot);
970
1244
  if (!peek.ok || !Object.values(peek.claims).some(due)) return { renewed: 0 };
971
1245
  try {
@@ -975,7 +1249,7 @@ export async function renewSessionClaims(projectRoot, sessionId, {
975
1249
  let renewed = 0;
976
1250
  for (const [key, claim] of Object.entries(read.claims)) {
977
1251
  if (due(claim)) {
978
- read.claims[key] = { ...claim, heartbeat: now };
1252
+ read.claims[key] = { ...claim, heartbeat: now, claimedAt: claim.claimedAt ?? claim.heartbeat };
979
1253
  renewed += 1;
980
1254
  }
981
1255
  }
@@ -993,10 +1267,11 @@ export async function renewSessionClaims(projectRoot, sessionId, {
993
1267
  * task (and, when given, at the same generation). An owner whose lease lapsed and was taken over
994
1268
  * must not commit — the new holder's generation differs.
995
1269
  */
996
- export async function verifyTaskClaimFence(projectRoot, taskId, { owner, generation = null, now = Date.now() } = {}) {
1270
+ export async function verifyTaskClaimFence(projectRoot, taskId, { owner, generation = null, grantId = null, now = Date.now() } = {}) {
997
1271
  const read = await readTaskClaim(projectRoot, taskId, { now });
998
1272
  if (!read.claimed || !read.active) return false;
999
1273
  if (read.claim.owner !== owner) return false;
1274
+ if (grantId !== null && read.claim.grantId !== grantId) return false;
1000
1275
  return generation === null || read.claim.generation === generation;
1001
1276
  }
1002
1277
 
@@ -1081,11 +1356,15 @@ export async function archiveCycleSnapshot(projectRoot, cycle) {
1081
1356
  // cycle must never be attributed here: an existing snapshot row is immutable (prior wins);
1082
1357
  // a live row only fills an id that has no snapshot row yet.
1083
1358
  const ids = new Set();
1359
+ const taskFiles = new Map();
1084
1360
  try {
1085
1361
  for (const f of await fs.readdir(dir)) {
1086
1362
  if (!/^TASK-[\w.-]+\.md$/i.test(f)) continue;
1087
1363
  if ((await fs.lstat(path.join(dir, f))).isSymbolicLink()) return { ok: false, reason: 'archive-symlink' };
1088
- ids.add(f.replace(/\.md$/i, '').toUpperCase());
1364
+ const id = f.replace(/\.md$/i, '').toUpperCase();
1365
+ ids.add(id);
1366
+ if (!taskFiles.has(id)) taskFiles.set(id, []);
1367
+ taskFiles.get(id).push(f);
1089
1368
  }
1090
1369
  } catch (error) {
1091
1370
  return { ok: false, reason: error?.code === 'ENOENT' ? 'no-task-files' : 'archive-unreadable' };
@@ -1114,13 +1393,15 @@ export async function archiveCycleSnapshot(projectRoot, cycle) {
1114
1393
  let stamped = 0;
1115
1394
  for (const [id, row] of merged) {
1116
1395
  if (!TERMINAL_TASK_STATUSES.has(row.status)) continue;
1117
- const file = path.join(dir, `${id}.md`);
1118
- let text;
1119
- try { text = await fs.readFile(file, 'utf8'); } catch { continue; }
1120
- const m = text.match(/^(-\s*Status:\s*`?)([\w-]+)(`?)/im);
1121
- if (!m || m[2].toLowerCase() === row.status) continue;
1122
- await fs.writeFile(file, text.replace(m[0], `${m[1]}${row.status}${m[3]}`), 'utf8');
1123
- stamped += 1;
1396
+ for (const filename of taskFiles.get(id)) {
1397
+ const file = path.join(dir, filename);
1398
+ let text;
1399
+ try { text = await fs.readFile(file, 'utf8'); } catch { continue; }
1400
+ const m = text.match(/^(-\s*Status:\s*`?)([\w-]+)(`?)/im);
1401
+ if (!m || m[2].toLowerCase() === row.status) continue;
1402
+ await fs.writeFile(file, text.replace(m[0], `${m[1]}${row.status}${m[3]}`), 'utf8');
1403
+ stamped += 1;
1404
+ }
1124
1405
  }
1125
1406
  return { ok: true, cycle: nn, snapshotted: merged.size, stamped, unchanged: unchanged && stamped === 0 };
1126
1407
  }
@@ -1212,8 +1493,16 @@ async function main() {
1212
1493
  owner,
1213
1494
  sessionId: owner.includes(':') ? owner.slice(owner.indexOf(':') + 1) : null,
1214
1495
  ttlMs: DELEGATED_CLAIM_TTL_MS,
1496
+ requireTermination: true,
1497
+ // Optional cycle stamp (`--cycle 2`) so a main-agent claim participates
1498
+ // in the same cross-cycle reconcile the delegation path uses.
1499
+ cycle: typeof flags.cycle === 'string' ? flags.cycle : null,
1215
1500
  })
1216
- : await releaseTaskClaim(projectRoot, taskId.toUpperCase(), { owner });
1501
+ : await releaseTaskClaim(projectRoot, taskId.toUpperCase(), {
1502
+ owner,
1503
+ cycle: typeof flags.cycle === 'string' ? flags.cycle : null,
1504
+ generation: typeof flags.generation === 'string' ? flags.generation : null,
1505
+ });
1217
1506
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
1218
1507
  if (result.ok === false) process.exitCode = 3;
1219
1508
  return;
@@ -1221,7 +1510,7 @@ async function main() {
1221
1510
  process.stderr.write(
1222
1511
  'usage: resumable-run.mjs read <projectRoot> <taskId> [--boundary <id>]\n'
1223
1512
  + ' resumable-run.mjs archive-cycle <projectRoot> <NN>\n'
1224
- + ' resumable-run.mjs claim|release <projectRoot> <taskId> --owner <main:session>\n'
1513
+ + ' resumable-run.mjs claim|release <projectRoot> <taskId> --owner <main:session> [--cycle <NN>] [--generation <N>]\n'
1225
1514
  + ' resumable-run.mjs suspend <projectRoot> <taskId> [--boundary <id>] '
1226
1515
  + '[--phase <phase>] [--next <action>] [--completed <a,b,...>]\n',
1227
1516
  );