taskforce-loop-engineering 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.0 - 2026-08-20
6
+
7
+ - Reconcile project-scoped human gates against the authoritative project contract and acceptance ledger, superseding stale or optional deferred gates without replay.
8
+ - Strengthen doctor and project status checks for structured gate context, acceptance-ledger drift, execution-ledger reconciliation, and project completion.
9
+ - Make active-task supersession project-aware across active and waiting tasks, and add end-to-end regression coverage for durable gate materialization.
10
+
5
11
  ## 0.14.0 - 2026-08-14
6
12
 
7
13
  - Add the platform-neutral runtime adapter SDK v1 with OpenClaw, Hermes, Codex CLI, and Claude Code factories, shared conformance tests, fail-closed effects, redacted telemetry, migration guidance, and a credential-free demo.
package/lib/core.mjs CHANGED
@@ -651,6 +651,7 @@ export async function recentRuns(root, id, options = {}) {
651
651
  }
652
652
 
653
653
  export async function summarizeLoopRuns(root, options = {}) {
654
+ await reconcileProjectGates(root, options.queue ? { queue: options.queue } : {});
654
655
  const ids = await targetRuntimeIds(root, options);
655
656
  const summaries = [];
656
657
  for (const id of ids) {
@@ -749,6 +750,24 @@ export async function doctorReport(root, options = {}) {
749
750
  add('configs-dir', 'warn', await exists(configsDir), path.relative(root, configsDir));
750
751
  const runtimeDir = path.join(root, 'runtime', 'loops');
751
752
  add('runtime-dir', 'warn', await exists(runtimeDir), path.relative(root, runtimeDir));
753
+ const gateSpecs = new Map((await projectSpecs(root)).map((spec) => [spec.project, spec]));
754
+ const gateQueues = [...new Set([...gateSpecs.values()].flatMap((spec) => (spec.queues ?? []).map((item) => item.queue)))];
755
+ for (const queue of gateQueues) {
756
+ const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
757
+ for (const file of await listJson(gatesDir)) {
758
+ const gate = await readJson(path.join(gatesDir, file));
759
+ if (gate.status !== 'waiting_for_human') continue;
760
+ const invalid = !gate.project_id || !gate.contract_hash || !Array.isArray(gate.requirement_ids)
761
+ ? { code: 'missing_structured_project_context' }
762
+ : gateInvalidity(gate, gateSpecs.get(gate.project_id));
763
+ add(`human-gate:${gate.gate_id}`, 'fail', !invalid, invalid ?? {
764
+ project_id: gate.project_id,
765
+ contract_version: gate.contract_version,
766
+ milestone_id: gate.milestone_id,
767
+ requirement_ids: gate.requirement_ids
768
+ });
769
+ }
770
+ }
752
771
  try {
753
772
  const steps = await listSteps(root);
754
773
  const invalid = steps.filter((step) => step.version !== 2 || !step.step_id || !step.input_fingerprint || !['llm', 'tool', 'effect'].includes(step.kind));
@@ -1025,6 +1044,7 @@ function parkedDisplayState(parked, now = Date.now()) {
1025
1044
 
1026
1045
  export async function tickParkedTasks(root, options = {}) {
1027
1046
  const queue = normalizeLoopId(options.queue);
1047
+ await reconcileProjectGates(root, { queue });
1028
1048
  const now = options.now ? new Date(options.now) : new Date();
1029
1049
  if (!Number.isFinite(now.getTime())) throw new Error(`Invalid tick time: ${options.now}`);
1030
1050
  if (!options.notifyCommand && !options.dryRun) throw new Error('queue-wait-tick requires --notify-command unless --dry-run is used.');
@@ -1133,6 +1153,138 @@ function projectConfigPathFor(root, project) {
1133
1153
  return path.join(root, 'configs', 'loops', 'projects', `${normalizeProjectId(project)}.json`);
1134
1154
  }
1135
1155
 
1156
+ function projectContractVersion(spec) {
1157
+ return spec.contractVersion ?? spec.contract_version ?? spec.schemaVersion ?? null;
1158
+ }
1159
+
1160
+ function projectContractHash(spec) {
1161
+ return createHash('sha256').update(JSON.stringify(spec)).digest('hex');
1162
+ }
1163
+
1164
+ async function projectSpecs(root) {
1165
+ const dir = path.join(root, 'configs', 'loops', 'projects');
1166
+ const specs = [];
1167
+ for (const file of await listJson(dir)) {
1168
+ try {
1169
+ const spec = await readJson(path.join(dir, file));
1170
+ if (spec?.project) specs.push(spec);
1171
+ } catch { /* doctor reports malformed project files through its normal config checks */ }
1172
+ }
1173
+ return specs;
1174
+ }
1175
+
1176
+ async function projectTerminalAccepted(root, spec) {
1177
+ const configuredAcceptanceLedger = spec?.acceptanceLedger ?? spec?.ledger;
1178
+ if (!configuredAcceptanceLedger) return false;
1179
+ try {
1180
+ const ledgerFile = path.resolve(root, safeRelativePath(configuredAcceptanceLedger, 'project acceptance ledger'));
1181
+ const ledger = await readJson(ledgerFile);
1182
+ return ledger.status === 'accepted'
1183
+ && Array.isArray(ledger.unmet) && ledger.unmet.length === 0
1184
+ && Array.isArray(ledger.blockers) && ledger.blockers.length === 0;
1185
+ } catch {
1186
+ return false;
1187
+ }
1188
+ }
1189
+
1190
+ function requirementIdsFromCheckpoint(checkpoint, specs) {
1191
+ const explicit = checkpoint?.requirement_ids ?? checkpoint?.requirementIds;
1192
+ if (Array.isArray(explicit)) return [...new Set(explicit.map(String))];
1193
+ const text = JSON.stringify({
1194
+ milestone_id: checkpoint?.milestone_id,
1195
+ blockers: checkpoint?.blockers,
1196
+ next_action: checkpoint?.next_action
1197
+ });
1198
+ const known = new Set(specs.flatMap((spec) => (spec.backlog ?? []).map((item) => String(item.id))));
1199
+ return [...known].filter((id) => new RegExp(`(^|[^A-Za-z0-9_-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9_-]|$)`).test(text));
1200
+ }
1201
+
1202
+ function inferProjectSpec(task, checkpoint, specs, requirementIds = []) {
1203
+ const explicit = checkpoint?.project_id ?? checkpoint?.projectId ?? task?.project_id ?? task?.projectId;
1204
+ if (explicit) return specs.find((spec) => spec.project === explicit) ?? null;
1205
+ const text = `${task?.title ?? ''}\n${task?.body ?? ''}`.toLowerCase();
1206
+ const candidates = specs.map((spec) => ({
1207
+ spec,
1208
+ requirementMatches: requirementIds.filter((id) => (spec.backlog ?? []).some((item) => item.id === id)).length,
1209
+ nameMatch: text.includes(String(spec.project).toLowerCase()) ? 1 : 0
1210
+ })).filter((item) => item.requirementMatches > 0 || item.nameMatch > 0)
1211
+ .sort((a, b) => b.requirementMatches - a.requirementMatches || b.nameMatch - a.nameMatch || b.spec.project.length - a.spec.project.length);
1212
+ return candidates[0]?.spec ?? null;
1213
+ }
1214
+
1215
+ async function gateProjectMetadata(root, task, checkpoint) {
1216
+ const specs = await projectSpecs(root);
1217
+ const requirementIds = requirementIdsFromCheckpoint(checkpoint, specs);
1218
+ const spec = inferProjectSpec(task, checkpoint, specs, requirementIds);
1219
+ return {
1220
+ project_id: spec?.project ?? null,
1221
+ contract_version: spec ? projectContractVersion(spec) : null,
1222
+ contract_hash: spec ? projectContractHash(spec) : null,
1223
+ milestone_id: checkpoint?.milestone_id ?? checkpoint?.checkpoint_id ?? null,
1224
+ requirement_ids: requirementIds
1225
+ };
1226
+ }
1227
+
1228
+ function gateInvalidity(gate, spec) {
1229
+ if (!spec) return { code: 'project_not_found', status: 'superseded' };
1230
+ const backlog = new Map((spec.backlog ?? []).map((item) => [String(item.id), item]));
1231
+ for (const id of gate.requirement_ids ?? []) {
1232
+ const requirement = backlog.get(String(id));
1233
+ if (!requirement) return { code: 'requirement_not_found', requirement_id: id, status: 'superseded' };
1234
+ if (requirement.required === false || ['out_of_scope', 'canceled_by_owner'].includes(requirement.status) || requirement.disposition === 'canceled_by_owner') {
1235
+ return {
1236
+ code: requirement.disposition === 'canceled_by_owner' ? 'canceled_by_owner' : 'requirement_not_required',
1237
+ requirement_id: id,
1238
+ status: requirement.disposition === 'canceled_by_owner' ? 'canceled' : 'superseded'
1239
+ };
1240
+ }
1241
+ }
1242
+ if (gate.contract_version !== projectContractVersion(spec)) {
1243
+ return { code: 'stale_contract', status: 'superseded' };
1244
+ }
1245
+ return null;
1246
+ }
1247
+
1248
+ export async function reconcileProjectGates(root, options = {}) {
1249
+ const queues = options.queue
1250
+ ? [normalizeLoopId(options.queue)]
1251
+ : [...new Set((await projectSpecs(root)).flatMap((spec) => (spec.queues ?? []).map((item) => item.queue)))];
1252
+ const specs = new Map((await projectSpecs(root)).map((spec) => [spec.project, spec]));
1253
+ const results = [];
1254
+ for (const queue of queues) {
1255
+ await ensureQueueDirs(root, queue);
1256
+ const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
1257
+ for (const file of await listJson(gatesDir)) {
1258
+ const full = path.join(gatesDir, file);
1259
+ let gate = await readJson(full);
1260
+ if (gate.status !== 'waiting_for_human') continue;
1261
+ const found = await findTaskFile(root, queue, gate.task_id);
1262
+ const task = found ? await readJson(found.file) : null;
1263
+ const checkpointFile = path.join(taskRuntimeDirFor(root, queue, gate.task_id), 'checkpoints', `${gate.checkpoint_id}.json`);
1264
+ const checkpoint = await exists(checkpointFile) ? await readJson(checkpointFile) : null;
1265
+ if (!gate.project_id || !Array.isArray(gate.requirement_ids) || !gate.contract_hash) {
1266
+ gate = { ...gate, ...await gateProjectMetadata(root, task, checkpoint) };
1267
+ await writeJson(full, gate);
1268
+ }
1269
+ const spec = specs.get(gate.project_id);
1270
+ const invalid = gate.gate_kind === 'deferred_authorization' && await projectTerminalAccepted(root, spec)
1271
+ ? { code: 'project_accepted_optional_deferred', status: 'superseded' }
1272
+ : gateInvalidity(gate, spec);
1273
+ if (!invalid) continue;
1274
+ const now = new Date().toISOString();
1275
+ const reconciled = { ...gate, status: invalid.status, reconciliation: { ...invalid, reconciled_at: now } };
1276
+ await writeJson(full, reconciled);
1277
+ if (found?.subdir === 'waiting' && task?.waitingGateId === gate.gate_id) {
1278
+ const canceledFile = path.join(queueSubdirFor(root, queue, 'canceled'), path.basename(found.file));
1279
+ await writeJson(canceledFile, { ...task, status: invalid.status, canceledAt: now, gateReconciliation: reconciled.reconciliation });
1280
+ await rm(found.file, { force: true });
1281
+ }
1282
+ results.push({ queue, gateId: gate.gate_id, projectId: gate.project_id, outcome: invalid.status, reason: invalid.code });
1283
+ }
1284
+ }
1285
+ return { inspectedQueues: queues.length, reconciled: results.length, results };
1286
+ }
1287
+
1136
1288
  function projectLatestIntakePath(root, project) {
1137
1289
  return path.join(projectRuntimeDirFor(root, project), 'intake', 'latest.json');
1138
1290
  }
@@ -1594,13 +1746,19 @@ export async function projectStatus(root, options = {}) {
1594
1746
  } catch {
1595
1747
  latestIntake = null;
1596
1748
  }
1597
- const backlogFile = projectInitialBacklogPath(root, project);
1749
+ const configuredBacklogSource = spec.backlogSource ?? spec.authoritativeBacklog;
1750
+ const configuredAcceptanceLedger = spec.acceptanceLedger ?? spec.ledger;
1751
+ const backlogFile = configuredBacklogSource
1752
+ ? path.resolve(root, safeRelativePath(configuredBacklogSource, 'project backlog source'))
1753
+ : projectInitialBacklogPath(root, project);
1598
1754
  let backlog = null;
1599
1755
  try {
1600
1756
  const loaded = await readJson(backlogFile);
1601
1757
  backlog = {
1602
1758
  file: path.relative(root, backlogFile),
1603
- count: Array.isArray(loaded.tasks) ? loaded.tasks.length : 0
1759
+ count: Array.isArray(loaded.tasks) ? loaded.tasks.length : Array.isArray(loaded.items) ? loaded.items.length : 0,
1760
+ status: loaded.status ?? null,
1761
+ updatedAt: loaded.updatedAt ?? loaded.generatedAt ?? null
1604
1762
  };
1605
1763
  } catch {
1606
1764
  backlog = null;
@@ -1612,11 +1770,45 @@ export async function projectStatus(root, options = {}) {
1612
1770
  if (queue.status.locked) acc.locked += 1;
1613
1771
  return acc;
1614
1772
  }, { queued: 0, active: 0, waiting: 0, done: 0, failed: 0, canceled: 0, runs: 0, locked: 0 });
1773
+ let acceptanceLedger = null;
1774
+ if (configuredAcceptanceLedger) {
1775
+ const ledgerFile = path.resolve(root, safeRelativePath(configuredAcceptanceLedger, 'project acceptance ledger'));
1776
+ try {
1777
+ const ledger = await readJson(ledgerFile);
1778
+ acceptanceLedger = {
1779
+ file: path.relative(root, ledgerFile),
1780
+ status: ledger.status ?? null,
1781
+ updatedAt: ledger.updatedAt ?? null,
1782
+ unmet: Array.isArray(ledger.unmet) ? ledger.unmet : [],
1783
+ blockers: Array.isArray(ledger.blockers) ? ledger.blockers : []
1784
+ };
1785
+ } catch (error) {
1786
+ acceptanceLedger = { file: path.relative(root, ledgerFile), readable: false, error: error.message };
1787
+ }
1788
+ }
1789
+ const registryItems = Array.isArray(spec.backlog) ? spec.backlog : [];
1790
+ const sourceItems = backlog ? await readJson(backlogFile).then((loaded) => loaded.items ?? loaded.tasks ?? []) : [];
1791
+ const registryById = new Map(registryItems.map((item) => [item.id, item]));
1792
+ const sourceById = new Map(sourceItems.map((item) => [item.id, item]));
1793
+ const drift = [];
1794
+ for (const id of new Set([...registryById.keys(), ...sourceById.keys()])) {
1795
+ const registry = registryById.get(id);
1796
+ const authoritative = sourceById.get(id);
1797
+ if (!registry || !authoritative) drift.push({ id, kind: registry ? 'missing_from_authoritative_backlog' : 'missing_from_registry' });
1798
+ else if (registry.status !== authoritative.status || Boolean(registry.required) !== Boolean(authoritative.required)) {
1799
+ drift.push({ id, kind: 'status_or_scope_mismatch', registry: { status: registry.status, required: registry.required }, authoritative: { status: authoritative.status, required: authoritative.required } });
1800
+ }
1801
+ }
1802
+ const projectCompletion = acceptanceLedger?.status === 'accepted' && acceptanceLedger.unmet.length === 0 && acceptanceLedger.blockers.length === 0
1803
+ ? 'accepted'
1804
+ : 'in_progress';
1615
1805
  const needsAttention = [];
1616
1806
  if (totals.failed > 0) needsAttention.push('failed_tasks_present');
1617
1807
  if (totals.waiting > 0) needsAttention.push('human_input_waiting');
1618
1808
  if (totals.active > 0) needsAttention.push('active_tasks_present');
1619
1809
  if (queues.some((queue) => queue.status.locked)) needsAttention.push('queue_locked');
1810
+ if (drift.length > 0) needsAttention.push('authoritative_source_drift');
1811
+ if (acceptanceLedger?.readable === false) needsAttention.push('acceptance_ledger_unreadable');
1620
1812
  return {
1621
1813
  version: 1,
1622
1814
  project,
@@ -1627,11 +1819,22 @@ export async function projectStatus(root, options = {}) {
1627
1819
  queues,
1628
1820
  totals,
1629
1821
  backlog,
1822
+ acceptanceLedger,
1823
+ authority: {
1824
+ terminalContract: spec.terminalContract ?? null,
1825
+ backlog: backlog?.file ?? null,
1826
+ acceptanceLedger: acceptanceLedger?.file ?? null,
1827
+ rule: 'acceptance ledger determines project completion; backlog source determines remaining work; registry is a projection validated for drift'
1828
+ },
1829
+ consistency: { ok: drift.length === 0 && acceptanceLedger?.readable !== false, drift },
1830
+ projectCompletion,
1630
1831
  latestIntake,
1631
1832
  needsAttention,
1632
1833
  nextActions: needsAttention.length
1633
1834
  ? ['Inspect queue-status or code-task-status for the queue needing attention.']
1634
- : ['Enqueue or run the next planned backlog task when ready.']
1835
+ : projectCompletion === 'accepted'
1836
+ ? ['Project terminal contract is accepted.']
1837
+ : ['Enqueue or run the next safe authorized item from the authoritative backlog.']
1635
1838
  };
1636
1839
  }
1637
1840
 
@@ -1659,6 +1862,7 @@ export async function enqueueTask(root, options) {
1659
1862
  status: 'queued',
1660
1863
  enqueuedAt: new Date().toISOString(),
1661
1864
  ...(options.riskAssessment ? { riskAssessment: options.riskAssessment } : {}),
1865
+ ...(options.projectId ? { projectId: normalizeProjectId(options.projectId) } : {}),
1662
1866
  ...(options.supersedesTaskId ? { supersedesTaskId: options.supersedesTaskId } : {}),
1663
1867
  ...(options.supersedeReason ? { supersedeReason: options.supersedeReason } : {}),
1664
1868
  ...(taskSourceFromOptions(options) ? { source: taskSourceFromOptions(options) } : {})
@@ -1757,6 +1961,7 @@ async function appendActiveTaskAmendment(root, queue, activeTask, options = {})
1757
1961
  }
1758
1962
 
1759
1963
  export async function routeLoopMessage(root, options = {}) {
1964
+ if (options.queue) await reconcileProjectGates(root, { queue: options.queue });
1760
1965
  const classification = classifyLoopMessage(options.message);
1761
1966
  if (!options.route) return classification;
1762
1967
  if (classification.intent === 'status') {
@@ -1785,10 +1990,20 @@ export async function routeLoopMessage(root, options = {}) {
1785
1990
  if (options.supersedeActive && options.amendActive) {
1786
1991
  throw new Error('--supersede-active and --amend-active are mutually exclusive.');
1787
1992
  }
1788
- const activeFiles = (options.supersedeActive || options.amendActive) ? await listJson(queueSubdirFor(root, queue, 'active')) : [];
1789
- const activeTask = activeFiles.length > 0
1790
- ? await readJson(path.join(queueSubdirFor(root, queue, 'active'), activeFiles[0]))
1791
- : null;
1993
+ const specs = await projectSpecs(root);
1994
+ const routedProject = inferProjectSpec({ title: options.title, body: options.message }, null, specs)?.project ?? null;
1995
+ const activeEntries = [];
1996
+ if (options.supersedeActive || options.amendActive) {
1997
+ for (const subdir of options.supersedeActive ? ['active', 'waiting'] : ['active']) {
1998
+ for (const file of await listJson(queueSubdirFor(root, queue, subdir))) {
1999
+ const task = await readJson(path.join(queueSubdirFor(root, queue, subdir), file));
2000
+ const metadata = await gateProjectMetadata(root, task, null);
2001
+ if (routedProject && metadata.project_id !== routedProject) continue;
2002
+ activeEntries.push({ task, subdir, file });
2003
+ }
2004
+ }
2005
+ }
2006
+ const activeTask = activeEntries[0]?.task ?? null;
1792
2007
  if (options.amendActive) {
1793
2008
  if (!activeTask) throw new Error('No active loop task exists to amend.');
1794
2009
  const amended = await appendActiveTaskAmendment(root, queue, activeTask, options);
@@ -1804,14 +2019,33 @@ export async function routeLoopMessage(root, options = {}) {
1804
2019
  };
1805
2020
  }
1806
2021
  if (activeTask) {
2022
+ const now = new Date().toISOString();
2023
+ for (const entry of activeEntries) {
2024
+ const requestFile = path.join(taskRuntimeDirFor(root, queue, entry.task.id), 'supersede_request.json');
2025
+ await writeJson(requestFile, {
2026
+ version: 1,
2027
+ requestedAt: now,
2028
+ activeTaskId: entry.task.id,
2029
+ projectId: routedProject,
2030
+ reason: String(options.message).trim(),
2031
+ status: 'requested'
2032
+ });
2033
+ if (entry.subdir === 'waiting') {
2034
+ const sourceFile = path.join(queueSubdirFor(root, queue, 'waiting'), entry.file);
2035
+ const canceledFile = path.join(queueSubdirFor(root, queue, 'canceled'), entry.file);
2036
+ await writeJson(canceledFile, { ...entry.task, status: 'superseded', canceledAt: now, supersededByNewerRequest: true });
2037
+ await rm(sourceFile, { force: true });
2038
+ const separator = entry.task.waitingGateId?.lastIndexOf(':') ?? -1;
2039
+ if (separator > 0) {
2040
+ const gateFile = path.join(queueDirFor(root, queue), 'human-input', 'gates', `${safeTaskId(entry.task.id)}.${normalizeLoopId(entry.task.waitingGateId.slice(separator + 1))}.json`);
2041
+ if (await exists(gateFile)) {
2042
+ const gate = await readJson(gateFile);
2043
+ await writeJson(gateFile, { ...gate, status: 'superseded', reconciliation: { code: 'superseded_by_owner', reconciled_at: now } });
2044
+ }
2045
+ }
2046
+ }
2047
+ }
1807
2048
  const requestFile = path.join(taskRuntimeDirFor(root, queue, activeTask.id), 'supersede_request.json');
1808
- await writeJson(requestFile, {
1809
- version: 1,
1810
- requestedAt: new Date().toISOString(),
1811
- activeTaskId: activeTask.id,
1812
- reason: String(options.message).trim(),
1813
- status: 'requested'
1814
- });
1815
2049
  for (const file of await listJson(queueSubdirFor(root, queue, 'inbox'))) {
1816
2050
  const full = path.join(queueSubdirFor(root, queue, 'inbox'), file);
1817
2051
  const queued = await readJson(full);
@@ -1838,7 +2072,8 @@ export async function routeLoopMessage(root, options = {}) {
1838
2072
  task,
1839
2073
  riskAssessment: 'model_assessed',
1840
2074
  supersedesTaskId: activeTask?.id,
1841
- supersedeReason: activeTask ? String(options.message).trim() : null
2075
+ supersedeReason: activeTask ? String(options.message).trim() : null,
2076
+ projectId: routedProject
1842
2077
  });
1843
2078
  if (activeTask) {
1844
2079
  const requestFile = path.join(taskRuntimeDirFor(root, queue, activeTask.id), 'supersede_request.json');
@@ -1905,6 +2140,16 @@ export async function notifyTerminalTasks(root, options = {}) {
1905
2140
  throw new Error('queue-terminal-notify requires --notify-command unless --dry-run is used.');
1906
2141
  }
1907
2142
  await ensureQueueDirs(root, queue);
2143
+ await reconcileProjectGates(root, { queue });
2144
+ const queueConfigFile = path.join(root, 'configs', 'loops', 'queues', `${queue}.json`);
2145
+ let terminalNotificationArchive = { tasks: {} };
2146
+ if (await exists(queueConfigFile)) {
2147
+ const queueConfig = await readJson(queueConfigFile);
2148
+ if (queueConfig.terminalNotificationArchive) {
2149
+ const archiveFile = path.resolve(root, queueConfig.terminalNotificationArchive);
2150
+ if (await exists(archiveFile)) terminalNotificationArchive = await readJson(archiveFile);
2151
+ }
2152
+ }
1908
2153
  const notificationDir = path.join(queueDirFor(root, queue), 'notifications');
1909
2154
  await mkdir(notificationDir, { recursive: true });
1910
2155
  const results = [];
@@ -1913,6 +2158,17 @@ export async function notifyTerminalTasks(root, options = {}) {
1913
2158
  for (const file of await listJson(dir)) {
1914
2159
  const task = await readJson(path.join(dir, file));
1915
2160
  if (!task?.id || !task?.status) continue;
2161
+ const archived = terminalNotificationArchive?.tasks?.[task.id];
2162
+ if (archived) {
2163
+ results.push({
2164
+ taskId: task.id,
2165
+ status: task.status,
2166
+ outcome: 'archived_unroutable',
2167
+ reason: archived.reason,
2168
+ archive: path.relative(root, path.resolve(root, terminalNotificationArchive.archiveFile ?? queueConfigFile))
2169
+ });
2170
+ continue;
2171
+ }
1916
2172
  if (!task?.source?.channel || !task?.source?.target) {
1917
2173
  results.push({
1918
2174
  taskId: task.id,
@@ -2024,8 +2280,10 @@ export async function refreshTaskAcceptance(root, options = {}) {
2024
2280
 
2025
2281
  function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
2026
2282
  const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
2027
- const blockerText = blockers.length
2028
- ? blockers.map((item) => typeof item === 'string' ? item : item?.human_action_required ?? item?.user_action ?? item?.description ?? item?.message ?? JSON.stringify(item))
2283
+ const deferredGates = Array.isArray(checkpoint.deferred_gates) ? checkpoint.deferred_gates : [];
2284
+ const requirements = blockers.length > 0 ? blockers : deferredGates;
2285
+ const blockerText = requirements.length
2286
+ ? requirements.map((item) => typeof item === 'string' ? item : item?.required_authority ?? item?.human_action_required ?? item?.user_action ?? item?.reason ?? item?.description ?? item?.message ?? JSON.stringify(item))
2029
2287
  : [checkpoint.next_action ?? 'Human input is required before the task can continue.'];
2030
2288
  if (language === 'zh') return [
2031
2289
  'Loop 任务正在等待你的输入',
@@ -2056,8 +2314,70 @@ async function tasksById(root, queue) {
2056
2314
  return tasks;
2057
2315
  }
2058
2316
 
2317
+ function taskRecency(task) {
2318
+ // Queue-carrier authority follows creation/enqueue order. Later bookkeeping
2319
+ // updates on an old task must never make it newer than its successor.
2320
+ const value = task?.enqueuedAt ?? task?.createdAt ?? task?.updatedAt ?? task?.completedAt ?? '';
2321
+ const parsed = Date.parse(value);
2322
+ return Number.isFinite(parsed) ? parsed : 0;
2323
+ }
2324
+
2325
+ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2326
+ const specs = await projectSpecs(root);
2327
+ const candidates = [];
2328
+ for (const [taskId, entry] of tasks) {
2329
+ if (!entry.task.source || entry.subdir === 'canceled') continue;
2330
+ const runtimeDir = taskRuntimeDirFor(root, queue, taskId);
2331
+ let files = await listJson(path.join(runtimeDir, 'checkpoints'));
2332
+ const judgementFile = path.join(runtimeDir, 'final_judgement.json');
2333
+ if (await exists(judgementFile)) {
2334
+ const judgement = await readJson(judgementFile);
2335
+ const effectiveIds = new Set(Array.isArray(judgement?.coverage?.effective_review_ids)
2336
+ ? judgement.coverage.effective_review_ids
2337
+ : []);
2338
+ if (effectiveIds.size > 0) files = files.filter((file) => effectiveIds.has(path.basename(file, '.json')));
2339
+ }
2340
+ const checkpoints = [];
2341
+ for (const file of files) {
2342
+ const checkpointFile = path.join(runtimeDir, 'checkpoints', file);
2343
+ const checkpoint = await readJson(checkpointFile);
2344
+ const deferred = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
2345
+ if (['needs_human_input', 'blocked'].includes(checkpoint?.status) || deferred.length > 0) {
2346
+ checkpoints.push({ file, checkpoint, mtimeMs: (await stat(checkpointFile)).mtimeMs });
2347
+ }
2348
+ }
2349
+ if (checkpoints.length === 0) continue;
2350
+ checkpoints.sort((a, b) => Number(b.checkpoint.sequence ?? 0) - Number(a.checkpoint.sequence ?? 0)
2351
+ || b.mtimeMs - a.mtimeMs
2352
+ || String(b.checkpoint.checkpoint_id ?? b.file).localeCompare(String(a.checkpoint.checkpoint_id ?? a.file)));
2353
+ const metadata = await gateProjectMetadata(root, entry.task, checkpoints[0].checkpoint);
2354
+ const latestCheckpoint = checkpoints[0].checkpoint;
2355
+ const deferredOnly = Array.isArray(latestCheckpoint.deferred_gates)
2356
+ && latestCheckpoint.deferred_gates.length > 0
2357
+ && (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
2358
+ && !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
2359
+ const spec = specs.find((item) => item.project === metadata.project_id);
2360
+ if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
2361
+ candidates.push({ taskId, entry, projectId: metadata.project_id, ...checkpoints[0] });
2362
+ }
2363
+
2364
+ // A project deferred gate is materialized only from its newest authoritative
2365
+ // carrier. Historical terminal tasks are audit evidence, not a replay queue.
2366
+ const newestByProject = new Map();
2367
+ for (const candidate of candidates.filter((item) => item.projectId)) {
2368
+ const current = newestByProject.get(candidate.projectId);
2369
+ if (!current || taskRecency(candidate.entry.task) > taskRecency(current.entry.task)) newestByProject.set(candidate.projectId, candidate);
2370
+ }
2371
+ return candidates.filter((candidate) => {
2372
+ if (candidate.projectId) return newestByProject.get(candidate.projectId) === candidate && candidate.entry.subdir !== 'failed';
2373
+ return ['inbox', 'active', 'waiting'].includes(candidate.entry.subdir)
2374
+ || (candidate.entry.subdir === 'failed' && ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status));
2375
+ });
2376
+ }
2377
+
2059
2378
  export async function notifyHumanInputRequests(root, options = {}) {
2060
2379
  const queue = normalizeLoopId(options.queue);
2380
+ await reconcileProjectGates(root, { queue });
2061
2381
  const language = await installedQueueLanguage(root, queue, options.language);
2062
2382
  if (!options.notifyCommand && !options.dryRun) {
2063
2383
  throw new Error('queue-human-input-notify requires --notify-command unless --dry-run is used.');
@@ -2067,29 +2387,15 @@ export async function notifyHumanInputRequests(root, options = {}) {
2067
2387
  const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
2068
2388
  await mkdir(gatesDir, { recursive: true });
2069
2389
  const results = [];
2070
- for (const [taskId, entry] of tasks) {
2071
- if (!entry.task.source) continue;
2072
- const checkpointsDir = path.join(taskRuntimeDirFor(root, queue, taskId), 'checkpoints');
2073
- let checkpointFiles = await listJson(checkpointsDir);
2074
- const judgementFile = path.join(taskRuntimeDirFor(root, queue, taskId), 'final_judgement.json');
2075
- if (await exists(judgementFile)) {
2076
- const judgement = await readJson(judgementFile);
2077
- const effectiveIds = Array.isArray(judgement?.coverage?.effective_review_ids)
2078
- ? new Set(judgement.coverage.effective_review_ids)
2079
- : null;
2080
- if (effectiveIds?.size > 0) {
2081
- checkpointFiles = checkpointFiles.filter((file) => effectiveIds.has(path.basename(file, '.json')));
2082
- }
2083
- }
2084
- for (const file of checkpointFiles) {
2085
- const checkpoint = await readJson(path.join(checkpointsDir, file));
2086
- if (!['needs_human_input', 'blocked'].includes(checkpoint?.status)) continue;
2390
+ const candidates = await authoritativeHumanGateCandidates(root, queue, tasks);
2391
+ for (const { taskId, entry, file, checkpoint } of candidates) {
2392
+ const deferredGates = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
2087
2393
  const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
2088
2394
  const gateId = `${taskId}:${checkpointId}`;
2089
2395
  const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
2090
2396
  if (await exists(ledgerFile)) {
2091
2397
  const gate = await readJson(ledgerFile);
2092
- if (gate.status === 'waiting_for_human' && ['inbox', 'failed'].includes(entry.subdir)) {
2398
+ if (gate.status === 'waiting_for_human' && ['inbox', 'done', 'failed'].includes(entry.subdir)) {
2093
2399
  const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
2094
2400
  if (await exists(sourceFile)) {
2095
2401
  const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
@@ -2097,13 +2403,46 @@ export async function notifyHumanInputRequests(root, options = {}) {
2097
2403
  ...entry.task,
2098
2404
  status: 'waiting_for_human',
2099
2405
  waitingGateId: gateId,
2406
+ waitKind: gate.gate_kind ?? 'human_input',
2100
2407
  waitingSince: gate.requested_at ?? new Date().toISOString()
2101
2408
  });
2102
2409
  await rm(sourceFile, { force: true });
2103
2410
  }
2104
2411
  }
2105
- results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
2106
- continue;
2412
+ const notified = gate.notification_record?.status === 'sent' || gate.notification?.exitCode === 0;
2413
+ if (gate.status !== 'waiting_for_human' || notified || options.dryRun) {
2414
+ results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
2415
+ continue;
2416
+ }
2417
+ // A prior notification failure is safely retryable because the durable
2418
+ // gate/task transition already exists and notification_record is the cursor.
2419
+ } else if (!options.dryRun) {
2420
+ const now = new Date().toISOString();
2421
+ await writeJson(ledgerFile, {
2422
+ version: 1, gate_id: gateId, queue, task_id: taskId, checkpoint_id: checkpointId,
2423
+ ...await gateProjectMetadata(root, entry.task, checkpoint), status: 'waiting_for_human',
2424
+ gate_kind: deferredGates.length > 0 ? 'deferred_authorization' : 'human_input',
2425
+ authorization_requirements: deferredGates.map((gate, index) => ({
2426
+ id: gate?.id ?? `deferred-${index + 1}`, action: gate?.action ?? gate?.kind ?? null,
2427
+ required_authority: gate?.required_authority ?? gate?.human_action_required ?? gate?.reason ?? gate?.description ?? String(gate),
2428
+ scope: gate?.scope ?? null
2429
+ })), source: entry.task.source, requested_at: now,
2430
+ notification_record: { status: 'pending', attempts: 0, idempotency_key: gateId }
2431
+ });
2432
+ if (['inbox', 'done', 'failed'].includes(entry.subdir)) {
2433
+ const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
2434
+ if (await exists(sourceFile)) {
2435
+ const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
2436
+ await writeJson(waitingFile, {
2437
+ ...entry.task,
2438
+ status: 'waiting_for_human',
2439
+ waitingGateId: gateId,
2440
+ waitKind: deferredGates.length > 0 ? 'deferred_authorization' : 'human_input',
2441
+ waitingSince: now
2442
+ });
2443
+ await rm(sourceFile, { force: true });
2444
+ }
2445
+ }
2107
2446
  }
2108
2447
  const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language);
2109
2448
  if (options.dryRun) {
@@ -2124,36 +2463,20 @@ export async function notifyHumanInputRequests(root, options = {}) {
2124
2463
  }
2125
2464
  });
2126
2465
  if (result.exitCode !== 0) {
2466
+ const gate = await readJson(ledgerFile);
2467
+ await writeJson(ledgerFile, { ...gate, request: message, notification_record: {
2468
+ status: 'failed', attempts: Number(gate.notification_record?.attempts ?? 0) + 1,
2469
+ idempotency_key: gateId, last_attempt_at: new Date().toISOString(), result: compactCommandResult(result)
2470
+ } });
2127
2471
  results.push({ taskId, checkpointId, gateId, outcome: 'failed', result: compactCommandResult(result) });
2128
2472
  continue;
2129
2473
  }
2130
- await writeJson(ledgerFile, {
2131
- version: 1,
2132
- gate_id: gateId,
2133
- queue,
2134
- task_id: taskId,
2135
- checkpoint_id: checkpointId,
2136
- status: 'waiting_for_human',
2137
- source: entry.task.source,
2138
- request: message,
2139
- requested_at: new Date().toISOString(),
2140
- notification: compactCommandResult(result)
2141
- });
2142
- if (['inbox', 'failed'].includes(entry.subdir)) {
2143
- const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
2144
- if (await exists(sourceFile)) {
2145
- const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
2146
- await writeJson(waitingFile, {
2147
- ...entry.task,
2148
- status: 'waiting_for_human',
2149
- waitingGateId: gateId,
2150
- waitingSince: new Date().toISOString()
2151
- });
2152
- await rm(sourceFile, { force: true });
2153
- }
2154
- }
2474
+ const gate = await readJson(ledgerFile);
2475
+ await writeJson(ledgerFile, { ...gate, request: message, notification: compactCommandResult(result), notification_record: {
2476
+ status: 'sent', attempts: Number(gate.notification_record?.attempts ?? 0) + 1,
2477
+ idempotency_key: gateId, sent_at: new Date().toISOString()
2478
+ } });
2155
2479
  results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
2156
- }
2157
2480
  }
2158
2481
  return {
2159
2482
  queue,
@@ -2466,14 +2789,25 @@ function defaultBlockedActions(riskLevel) {
2466
2789
  return blocked;
2467
2790
  }
2468
2791
 
2792
+ export function inferTaskScope(task) {
2793
+ const requestText = `${task?.title ?? ''}\n${task?.body ?? ''}`.toLowerCase();
2794
+ // A project can be referenced as context while the routed task is explicitly
2795
+ // bounded to one milestone or backlog item. Those bounded instructions must
2796
+ // win; otherwise phrases such as "do not call the overall project complete"
2797
+ // incorrectly turn an accepted milestone into an endlessly requeued project
2798
+ // task.
2799
+ const boundedMilestone = /(?:only|仅|只)(?:推进|处理|执行|完成|验证)|阶段完成|任务完成|第\s*\d+\s*(?:项|步)|选项\s*\d+|boundary item|backlog item|(?:取消|撤销|移除).{0,40}(?:功能|范围|计划|必做|门槛)|(?:cancel|remove|drop|withdraw).{0,40}(?:feature|scope|requirement|backlog item|completion gate)|(?:product[-_ ]scope|产品范围).{0,20}(?:decision|amendment|决策|修订)/.test(requestText);
2800
+ if (boundedMilestone) return 'scoped_task';
2801
+ return /project[-_ ]level|项目级|完整项目|整体项目|single milestone|单(?:一)?里程碑/.test(requestText)
2802
+ ? 'project'
2803
+ : 'scoped_task';
2804
+ }
2805
+
2469
2806
  function buildTaskContract(queue, task, options = {}) {
2470
2807
  const inferredRisk = inferTaskRisk(task);
2471
2808
  const modelAssessed = task.riskAssessment === 'model_assessed';
2472
2809
  const riskLevel = options.riskLevel ?? (modelAssessed ? 'model_assessed' : inferredRisk.level);
2473
- const requestText = `${task.title ?? ''}\n${task.body ?? ''}`.toLowerCase();
2474
- const taskScope = /project[-_ ]level|项目级|完整项目|整体项目|single milestone|单(?:一)?里程碑/.test(requestText)
2475
- ? 'project'
2476
- : 'scoped_task';
2810
+ const taskScope = inferTaskScope(task);
2477
2811
  return {
2478
2812
  version: 1,
2479
2813
  task_id: task.id,
@@ -2584,11 +2918,12 @@ export async function writeTaskContract(root, queue, task, options = {}) {
2584
2918
  const dir = taskRuntimeDirFor(root, queue, task.id);
2585
2919
  const file = path.join(dir, 'task_contract.json');
2586
2920
  const historicalPatterns = await retrieveHistoricalPatterns(root, queue, task, options.history ?? {});
2587
- const contract = buildTaskContract(queue, task, {
2921
+ let contract = buildTaskContract(queue, task, {
2588
2922
  ...options,
2589
2923
  workspace: options.workspace ?? root,
2590
2924
  historicalPatterns
2591
2925
  });
2926
+ contract = await mergeLiveAmendments(root, queue, task.id, contract, 'supplemental_requirements');
2592
2927
  await writeJson(file, contract);
2593
2928
  return {
2594
2929
  contract,
@@ -2596,6 +2931,36 @@ export async function writeTaskContract(root, queue, task, options = {}) {
2596
2931
  };
2597
2932
  }
2598
2933
 
2934
+ async function mergeLiveAmendments(root, queue, taskId, artifact, supplementalField) {
2935
+ const amendmentsDir = path.join(taskRuntimeDirFor(root, queue, taskId), 'amendments');
2936
+ if (!(await exists(amendmentsDir))) return artifact;
2937
+ const files = (await listJson(amendmentsDir)).filter((file) => /^\d{4}\.json$/.test(file)).sort();
2938
+ if (files.length === 0) return artifact;
2939
+ const refs = [];
2940
+ const instructions = [];
2941
+ for (const name of files) {
2942
+ const amendment = await readJson(path.join(amendmentsDir, name));
2943
+ const instruction = String(amendment.instruction ?? amendment.requirement ?? '').trim();
2944
+ if (!instruction) continue;
2945
+ const sequence = Number(amendment.sequence ?? refs.length + 1);
2946
+ refs.push({
2947
+ sequence,
2948
+ instruction,
2949
+ requested_at: amendment.requestedAt ?? amendment.created_at ?? null,
2950
+ artifact: path.relative(root, path.join(amendmentsDir, name))
2951
+ });
2952
+ instructions.push(instruction);
2953
+ }
2954
+ if (refs.length === 0) return artifact;
2955
+ return {
2956
+ ...artifact,
2957
+ amendment_version: Math.max(...refs.map((item) => item.sequence)),
2958
+ amendments: refs,
2959
+ [supplementalField]: instructions,
2960
+ updated_at: new Date().toISOString()
2961
+ };
2962
+ }
2963
+
2599
2964
  function inferAcceptanceSignals(contract) {
2600
2965
  const text = `${contract.title ?? ''}\n${contract.original_request ?? ''}`.toLowerCase();
2601
2966
  return {
@@ -2776,7 +3141,8 @@ function buildAcceptancePlan(contract, options = {}) {
2776
3141
  export async function writeAcceptancePlan(root, queue, task, taskContract, options = {}) {
2777
3142
  const dir = taskRuntimeDirFor(root, queue, task.id);
2778
3143
  const file = path.join(dir, 'acceptance_plan.json');
2779
- const plan = buildAcceptancePlan(taskContract.contract, options);
3144
+ let plan = buildAcceptancePlan(taskContract.contract, options);
3145
+ plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_checks');
2780
3146
  await writeJson(file, plan);
2781
3147
  return {
2782
3148
  plan,
@@ -2843,7 +3209,8 @@ export async function writeDevPlan(root, queue, task, taskContract, acceptancePl
2843
3209
  await mkdir(checkpointsDir, { recursive: true });
2844
3210
  await mkdir(reviewsDir, { recursive: true });
2845
3211
  const file = path.join(dir, 'dev_plan.json');
2846
- const plan = buildDevPlan(taskContract.contract, acceptancePlan.plan);
3212
+ let plan = buildDevPlan(taskContract.contract, acceptancePlan.plan);
3213
+ plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_instructions');
2847
3214
  await writeJson(file, plan);
2848
3215
  return {
2849
3216
  plan,
@@ -3123,6 +3490,7 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
3123
3490
  checkpointCreatedAt: checkpoint.created_at ?? null,
3124
3491
  createdAt: checkpoint.created_at ?? review.created_at,
3125
3492
  projectCompletion: checkpoint.project_completion ?? null,
3493
+ deferredGates: Array.isArray(checkpoint.deferred_gates) ? checkpoint.deferred_gates : [],
3126
3494
  status: review.status,
3127
3495
  file: path.relative(root, reviewFile)
3128
3496
  });
@@ -3195,6 +3563,8 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3195
3563
  const dispatchStatus = context.dispatchStatus ?? 'unknown';
3196
3564
  const dispatchFailure = context.dispatchFailureClassification ?? null;
3197
3565
  const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
3566
+ const projectCompletionInProgress = effectiveReviews.some((review) => review.projectCompletion?.status === 'in_progress');
3567
+ const deferredGateCount = effectiveReviews.reduce((count, review) => count + (review.deferredGates?.length ?? 0), 0);
3198
3568
  let outcome = 'needs_revision';
3199
3569
 
3200
3570
  if (dispatchStatus === 'runtime_interrupted') {
@@ -3245,10 +3615,11 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3245
3615
  outcome = 'needs_revision';
3246
3616
  reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
3247
3617
  nextActions.push('Complete and review the remaining planned checkpoints.');
3248
- } else if (contract.task_scope === 'project' && !projectCompletionAccepted) {
3618
+ } else if ((contract.task_scope === 'project' || projectCompletionInProgress || deferredGateCount > 0) && !projectCompletionAccepted) {
3249
3619
  outcome = 'project_in_progress';
3250
3620
  reasons.push('The latest milestone is accepted, but the project terminal contract is not accepted.');
3251
- nextActions.push('Continue with the next safe actionable project backlog item.');
3621
+ if (deferredGateCount > 0) reasons.push(`${deferredGateCount} deferred authorization gate(s) remain and must be materialized as waiting gates when no unrelated safe work is actionable.`);
3622
+ nextActions.push('Reread the authoritative project ledger and continue with the next safe actionable backlog item, or wait on a structured authorization gate.');
3252
3623
  } else if (contract.requires_human_gate) {
3253
3624
  outcome = 'ready_for_human_review';
3254
3625
  reasons.push('All reviewed checkpoints are accepted, and the task contract requires a human gate.');
@@ -3285,6 +3656,7 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3285
3656
  accepted: acceptedCount,
3286
3657
  revise: reviseCount,
3287
3658
  blocked: blockedCount,
3659
+ deferred_gates: deferredGateCount,
3288
3660
  rubric_items: Array.isArray(acceptancePlan?.rubric) ? acceptancePlan.rubric.length : 0,
3289
3661
  automation_suggestions: Array.isArray(acceptancePlan?.automation) ? acceptancePlan.automation.length : 0
3290
3662
  },
@@ -3453,6 +3825,7 @@ async function listJson(dir) {
3453
3825
  export async function queueStatus(root, queue) {
3454
3826
  normalizeLoopId(queue);
3455
3827
  await ensureQueueDirs(root, queue);
3828
+ await reconcileProjectGates(root, { queue });
3456
3829
  const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
3457
3830
  const lock = await readQueueLock(root, queue);
3458
3831
  const lockOwnerAlive = queueLockOwnerAlive(lock);
@@ -3463,7 +3836,7 @@ export async function queueStatus(root, queue) {
3463
3836
  taskId: task.id,
3464
3837
  status: task.status,
3465
3838
  waitId: task.parked?.wait_id ?? task.waitingGateId ?? null,
3466
- waitKind: task.parked?.kind ?? (task.status === 'waiting_for_human' ? 'human_input' : null),
3839
+ waitKind: task.parked?.kind ?? task.waitKind ?? (task.status === 'waiting_for_human' ? 'human_input' : null),
3467
3840
  displayState: task.parked ? parkedDisplayState(task.parked) : task.status,
3468
3841
  parkedAt: task.parked?.parked_at ?? task.waitingSince ?? null,
3469
3842
  reminderCount: Number(task.parked?.reminder_count ?? 0),
@@ -8153,13 +8526,22 @@ function shellQuote(value) {
8153
8526
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
8154
8527
  }
8155
8528
 
8529
+ // Linux limits the size of a single argv/env string (commonly 128 KiB). Revision
8530
+ // tasks can legitimately exceed that after many checkpoints, so keep the
8531
+ // legacy inline body only for bounded tasks. The authoritative task body is
8532
+ // always available through LOOP_TASK_FILE.
8533
+ const MAX_INLINE_TASK_BODY_BYTES = 32 * 1024;
8534
+
8156
8535
  function buildQueueEnv(root, queue, task, taskFile, runId, extra = {}) {
8536
+ const taskBody = String(task.body ?? '');
8537
+ const inlineTaskBody = Buffer.byteLength(taskBody, 'utf8') <= MAX_INLINE_TASK_BODY_BYTES;
8157
8538
  return {
8158
8539
  ...process.env,
8159
8540
  LOOP_QUEUE_ID: queue,
8160
8541
  LOOP_TASK_ID: task.id,
8161
8542
  LOOP_TASK_TITLE: task.title,
8162
- LOOP_TASK_BODY: task.body,
8543
+ ...(inlineTaskBody ? { LOOP_TASK_BODY: taskBody } : {}),
8544
+ LOOP_TASK_BODY_MODE: inlineTaskBody ? 'inline' : 'task_file',
8163
8545
  LOOP_TASK_FILE: taskFile,
8164
8546
  LOOP_TASK_FILE_REL: path.relative(root, taskFile),
8165
8547
  LOOP_RUN_ID: runId,
@@ -8481,6 +8863,7 @@ async function runPreflight(root, config, timeoutMs) {
8481
8863
  export async function runQueueOnce(root, options) {
8482
8864
  const queue = normalizeLoopId(options.queue);
8483
8865
  await ensureQueueDirs(root, queue);
8866
+ await reconcileProjectGates(root, { queue });
8484
8867
  let progressTask = null;
8485
8868
  const liveProgressNotifications = [];
8486
8869
  let liveProgressChain = Promise.resolve();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  "check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
24
24
  "check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
25
25
  "check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
26
+ "check:project-gates": "node --check lib/core.mjs && node scripts/project-gate-reconciliation-self-test.mjs",
26
27
  "check": "npm run check:config-drift && npm run check:openclaw-install && npm run check:hermes-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node --check lib/action-reservations.mjs && node --check lib/todo-control-plane.mjs && node --check lib/operator-dashboard.mjs && node scripts/action-reservation-self-test.mjs && node scripts/todo-control-plane-self-test.mjs && node scripts/operator-dashboard-self-test.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node scripts/human-gate-lifecycle-v2-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs dashboard-health --root . --max-age-seconds 999999999 --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
27
28
  "pack:dry": "npm pack --dry-run"
28
29
  },
@@ -1,9 +1,15 @@
1
1
  import assert from 'node:assert/strict';
2
- import { buildFinalJudgement, dispatchFailureClassification, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
2
+ import { buildFinalJudgement, dispatchFailureClassification, inferTaskScope, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
3
3
 
4
4
  const basePlan = { rubric: [], automation: [] };
5
5
  const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: false, task_scope: 'scoped_task' };
6
6
 
7
+ assert.equal(inferTaskScope({ body: '开发完整项目并完成整体目标' }), 'project');
8
+ assert.equal(inferTaskScope({ body: '继续同一整体项目,仅推进 T-01 第 2 项;阶段完成后不能称整体项目完成' }), 'scoped_task');
9
+ assert.equal(inferTaskScope({ body: 'Continue the project, only complete boundary item 2' }), 'scoped_task');
10
+ assert.equal(inferTaskScope({ body: '继续同一整体项目,取消团队功能并从项目完成门槛中移除,其他 backlog 不变' }), 'scoped_task');
11
+ assert.equal(inferTaskScope({ body: 'Continue the overall project with a product-scope amendment: remove the team feature from the completion gate.' }), 'scoped_task');
12
+
7
13
  {
8
14
  const devPlan = { checkpoints: [{ id: 'cp1' }] };
9
15
  const reviews = {
@@ -96,6 +102,19 @@ const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: fal
96
102
  assert.equal(judgement.outcome, 'project_in_progress');
97
103
  }
98
104
 
105
+ {
106
+ const devPlan = { checkpoints: [{ id: 'cp1' }] };
107
+ const reviews = { reviews: [{
108
+ checkpointId: 'cp1', sequence: 1, status: 'accepted',
109
+ projectCompletion: { status: 'in_progress' },
110
+ deferredGates: [{ id: 'deploy', required_authority: 'owner deployment approval' }]
111
+ }] };
112
+ const judgement = buildFinalJudgement(baseContract, basePlan, devPlan, { count: 1 }, reviews, { dispatchStatus: 'completed' });
113
+ assert.equal(judgement.outcome, 'project_in_progress');
114
+ assert.equal(judgement.coverage.deferred_gates, 1);
115
+ assert.match(judgement.reasons.join(' '), /structured|waiting gates/);
116
+ }
117
+
99
118
  {
100
119
  const devPlan = { checkpoints: [{ id: 'cp1' }] };
101
120
  const reviews = { reviews: [{ checkpointId: 'cp6', sequence: 6, status: 'accepted', projectCompletion: { status: 'accepted' } }] };
@@ -0,0 +1,107 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, routeLoopMessage, taskRuntimeDirFor } from '../lib/core.mjs';
6
+
7
+ const root = await mkdtemp(path.join(os.tmpdir(), 'loop-project-gates-'));
8
+ const queue = 'shared';
9
+ const projectFile = path.join(root, 'configs', 'loops', 'projects', 'openreel.json');
10
+ const spec = { schemaVersion: 1, project: 'openreel', type: 'code_project', queues: [{ queue, kind: 'standard' }], backlog: [
11
+ { id: 'B-01', required: true, status: 'human_gated', dependsOn: [] },
12
+ { id: 'S-01', required: true, status: 'human_gated', dependsOn: [] }
13
+ ] };
14
+ await mkdir(path.dirname(projectFile), { recursive: true });
15
+ await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
16
+
17
+ const b = await enqueueTask(root, { queue, title: 'OpenReel B-01', task: 'Project openreel requirement B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
18
+ const checkpointDir = path.join(taskRuntimeDirFor(root, queue, b.task.id), 'checkpoints');
19
+ await mkdir(checkpointDir, { recursive: true });
20
+ await writeFile(path.join(checkpointDir, 'cp1.json'), `${JSON.stringify({ version: 1, task_id: b.task.id, checkpoint_id: 'cp1', milestone_id: 'B-01', requirement_ids: ['B-01'], status: 'needs_human_input', blockers: ['Authorize B-01'] }, null, 2)}\n`);
21
+ const notified = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
22
+ assert.equal(notified.sent, 1);
23
+ assert.equal((await queueStatus(root, queue)).waiting, 1);
24
+ const gateFile = path.join(root, notified.results[0].ledger);
25
+ const gate = JSON.parse(await readFile(gateFile, 'utf8'));
26
+ assert.equal(gate.project_id, 'openreel');
27
+ assert.equal(gate.milestone_id, 'B-01');
28
+ assert.deepEqual(gate.requirement_ids, ['B-01']);
29
+ assert.equal(typeof gate.contract_hash, 'string');
30
+
31
+ spec.backlog[0] = { ...spec.backlog[0], required: false, status: 'out_of_scope', disposition: 'canceled_by_owner' };
32
+ await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
33
+ const status = await queueStatus(root, queue);
34
+ assert.equal(status.waiting, 0, 'B-01 waiting gate must disappear after B-01 leaves project scope');
35
+ assert.equal(status.canceled, 1);
36
+ assert.equal(JSON.parse(await readFile(gateFile, 'utf8')).status, 'canceled');
37
+
38
+ const other = await enqueueTask(root, { queue, title: 'Other project task', task: 'Project other', projectId: 'other' });
39
+ const openreel = await enqueueTask(root, { queue, title: 'OpenReel target', task: 'Project openreel', projectId: 'openreel' });
40
+ await rename(path.join(root, other.file), path.join(root, 'runtime', 'loops', queue, 'active', path.basename(other.file)));
41
+ await rename(path.join(root, openreel.file), path.join(root, 'runtime', 'loops', queue, 'active', path.basename(openreel.file)));
42
+ const replacement = await routeLoopMessage(root, { route: true, confirmExecute: true, supersedeActive: true, queue, message: '走 loop:继续 openreel 项目' });
43
+ assert.equal(replacement.supersededTaskId, openreel.task.id);
44
+ assert.equal(await readFile(path.join(taskRuntimeDirFor(root, queue, other.task.id), 'supersede_request.json'), 'utf8').then(() => true, () => false), false);
45
+
46
+ const invalidGate = { ...gate, gate_id: `${openreel.task.id}:bad`, task_id: openreel.task.id, checkpoint_id: 'bad', status: 'waiting_for_human', requirement_ids: ['MISSING'] };
47
+ const invalidFile = path.join(root, 'runtime', 'loops', queue, 'human-input', 'gates', `${openreel.task.id}.bad.json`);
48
+ await writeFile(invalidFile, `${JSON.stringify(invalidGate, null, 2)}\n`);
49
+ const doctor = await doctorReport(root);
50
+ assert.equal(doctor.ok, false);
51
+ assert(doctor.checks.some((check) => check.id === `human-gate:${invalidGate.gate_id}` && !check.ok));
52
+ await reconcileProjectGates(root, { queue });
53
+
54
+ // project-status treats the configured ledger/backlog as authoritative and
55
+ // exposes registry drift instead of silently reporting stale completion data.
56
+ const authoritativeBacklog = path.join(root, 'project', 'backlog.json');
57
+ const authoritativeLedger = path.join(root, 'project', 'acceptance-ledger.json');
58
+ await mkdir(path.dirname(authoritativeBacklog), { recursive: true });
59
+ await writeFile(authoritativeBacklog, `${JSON.stringify({ status: 'ongoing', items: [{ id: 'S-01', required: true, status: 'accepted' }] }, null, 2)}\n`);
60
+ await writeFile(authoritativeLedger, `${JSON.stringify({ status: 'ongoing', unmet: ['S-01'], blockers: [{ id: 'S-01' }] }, null, 2)}\n`);
61
+ await writeFile(projectFile, `${JSON.stringify({ ...spec, backlogSource: 'project/backlog.json', acceptanceLedger: 'project/acceptance-ledger.json', terminalContract: 'project/terminal.md' }, null, 2)}\n`);
62
+ const drifted = await projectStatus(root, { project: 'openreel' });
63
+ assert.equal(drifted.projectCompletion, 'in_progress');
64
+ assert.equal(drifted.authority.acceptanceLedger, 'project/acceptance-ledger.json');
65
+ assert.equal(drifted.consistency.ok, false);
66
+ assert(drifted.needsAttention.includes('authoritative_source_drift'));
67
+
68
+ // A ready milestone with project in progress and a deferred authorization is
69
+ // converted into a structured waiting gate, not left as prose on a done task.
70
+ const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
71
+ const deferredDir = path.join(taskRuntimeDirFor(root, queue, deferred.task.id), 'checkpoints');
72
+ await mkdir(deferredDir, { recursive: true });
73
+ await writeFile(path.join(deferredDir, 'cp-ready.json'), `${JSON.stringify({
74
+ version: 1, task_id: deferred.task.id, checkpoint_id: 'cp-ready', milestone_id: 'S-01', requirement_ids: ['S-01'],
75
+ status: 'ready_for_acceptance', blockers: [], verification: ['local phase passed'], risks: [],
76
+ project_completion: { status: 'in_progress' },
77
+ deferred_gates: [{ id: 'S-01-production', action: 'production_rollback_drill', required_authority: 'Owner authorization for the exact production rollback drill scope.' }]
78
+ }, null, 2)}\n`);
79
+ const deferredNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
80
+ const deferredResult = deferredNotice.results.find((item) => item.taskId === deferred.task.id);
81
+ assert.equal(deferredResult.outcome, 'sent');
82
+ const deferredGate = JSON.parse(await readFile(path.join(root, deferredResult.ledger), 'utf8'));
83
+ assert.equal(deferredGate.gate_kind, 'deferred_authorization');
84
+ assert.equal(deferredGate.authorization_requirements[0].action, 'production_rollback_drill');
85
+ assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
86
+ assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
87
+
88
+ // Once the authoritative project ledger accepts the terminal contract, an
89
+ // optional post-completion deferred action stays in operations backlog and
90
+ // neither creates nor retains a project-queue waiting gate.
91
+ await writeFile(authoritativeLedger, `${JSON.stringify({ status: 'accepted', unmet: [], blockers: [] }, null, 2)}\n`);
92
+ await reconcileProjectGates(root, { queue });
93
+ assert.equal((await queueStatus(root, queue)).waiting, 0);
94
+ assert.equal(JSON.parse(await readFile(path.join(root, deferredResult.ledger), 'utf8')).status, 'superseded');
95
+ const acceptedOptional = await enqueueTask(root, { queue, title: 'OpenReel optional operations transfer', task: 'Project openreel optional post-completion transfer', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
96
+ const acceptedOptionalDir = path.join(taskRuntimeDirFor(root, queue, acceptedOptional.task.id), 'checkpoints');
97
+ await mkdir(acceptedOptionalDir, { recursive: true });
98
+ await writeFile(path.join(acceptedOptionalDir, 'cp1.json'), `${JSON.stringify({
99
+ version: 1, task_id: acceptedOptional.task.id, checkpoint_id: 'cp1', milestone_id: 'cp1',
100
+ status: 'ready_for_acceptance', blockers: [], project_completion: { status: 'accepted' },
101
+ deferred_gates: [{ id: 'optional-transfer', required_authority: 'Separate authorization for optional production transfer.' }]
102
+ }, null, 2)}\n`);
103
+ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
104
+ assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
105
+ assert.equal((await queueStatus(root, queue)).waiting, 0);
106
+
107
+ console.log(JSON.stringify({ status: 'ok', assertions: ['structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'accepted project optional deferred gate stays out of queue'] }));
@@ -89,6 +89,27 @@ const contract = await readJson(path.join(taskRuntimeDirFor(root, queue, routed.
89
89
  assert.equal(contract.risk_level, 'model_assessed');
90
90
  assert.equal(contract.requires_human_gate, false);
91
91
 
92
+ const largeBodyQueue = 'large-body-smoke';
93
+ const largeBody = `走 loop 验证超长任务正文\n${'checkpoint evidence '.repeat(10_000)}`;
94
+ const largeBodyRouted = await routeLoopMessage(root, {
95
+ route: true,
96
+ confirmExecute: true,
97
+ queue: largeBodyQueue,
98
+ message: largeBody,
99
+ sourceChannel: 'feishu',
100
+ sourceTarget: 'user-1'
101
+ });
102
+ const largeBodyRun = await runQueueOnce(root, {
103
+ queue: largeBodyQueue,
104
+ dispatcher: `node -e "const fs=require('fs');const t=JSON.parse(fs.readFileSync(process.env.LOOP_TASK_FILE,'utf8'));if(process.env.LOOP_TASK_BODY!==undefined||process.env.LOOP_TASK_BODY_MODE!=='task_file'||t.body.length<100000)process.exit(7)"`,
105
+ progressNotifyCommand: '/bin/true',
106
+ timeoutMs: 10_000,
107
+ leaseMs: 20_000,
108
+ staleActiveMs: 60_000
109
+ });
110
+ assert.equal(largeBodyRun.processed, true);
111
+ assert.equal(largeBodyRun.run.dispatch.exitCode, 0);
112
+
92
113
  const orphanQueue = 'orphan-recovery-smoke';
93
114
  const orphanRouted = await routeLoopMessage(root, {
94
115
  route: true,
@@ -483,5 +504,35 @@ assert.equal(sent.sent, 1);
483
504
  const repeated = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
484
505
  assert.equal(repeated.results[0].outcome, 'already_notified');
485
506
 
507
+ // Real directory-level regression: terminal history plus the newest project
508
+ // carrier yields exactly one durable gate, one waiting task, and zero replay.
509
+ const regressionRoot = await mkdtemp(path.join(tmpdir(), 'loop-gate-regression-'));
510
+ const regressionQueue = 'project-regression';
511
+ await writeJson(path.join(regressionRoot, 'configs', 'loops', 'projects', 'demo.json'), {
512
+ schemaVersion: 1, project: 'demo', queues: [{ queue: regressionQueue }],
513
+ backlog: [{ id: 'R-1', status: 'human_gated', required: true }]
514
+ });
515
+ for (const [id, enqueuedAt] of [['history-done', '2026-01-01T00:00:00Z'], ['latest-done', '2026-01-02T00:00:00Z']]) {
516
+ await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'done'), `${id}.json`), {
517
+ id, title: `demo ${id}`, body: 'demo R-1', projectId: 'demo', status: 'completed', enqueuedAt,
518
+ source: { channel: 'test', target: 'owner' }
519
+ });
520
+ await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'checkpoints', 'cp1.json'), {
521
+ version: 1, task_id: id, checkpoint_id: 'cp1', milestone_id: 'R-1', sequence: 1,
522
+ status: 'ready_for_acceptance', blockers: [], deferred_gates: [{ id: 'R-1', required_authority: 'Approve R-1.' }],
523
+ verification: [], risks: [], project_completion: 'in_progress', next_action: 'wait'
524
+ });
525
+ await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'final_judgement.json'), {
526
+ version: 1, task_id: id, outcome: 'project_in_progress', coverage: { effective_review_ids: ['cp1'] }
527
+ });
528
+ }
529
+ const regressionFirst = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
530
+ assert.equal(regressionFirst.sent, 1);
531
+ assert.equal((await queueStatus(regressionRoot, regressionQueue)).waiting, 1);
532
+ assert.equal((await readdir(path.join(queueDirFor(regressionRoot, regressionQueue), 'human-input', 'gates'))).length, 1);
533
+ assert.equal((await readdir(queueSubdirFor(regressionRoot, regressionQueue, 'done'))).length, 1);
534
+ const regressionRepeated = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
535
+ assert.equal(regressionRepeated.sent, 0);
536
+
486
537
  console.log('route/notify self-test passed');
487
538
  await import('./config-drift-self-test.mjs');