taskforce-loop-engineering 0.13.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/lib/core.mjs CHANGED
@@ -4,6 +4,8 @@ import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { createHash } from 'node:crypto';
7
+ import { listSteps } from './execution-ledger.mjs';
8
+ import { readAndVerifyEvidence } from './production-evidence.mjs';
7
9
 
8
10
  export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
11
 
@@ -649,6 +651,7 @@ export async function recentRuns(root, id, options = {}) {
649
651
  }
650
652
 
651
653
  export async function summarizeLoopRuns(root, options = {}) {
654
+ await reconcileProjectGates(root, options.queue ? { queue: options.queue } : {});
652
655
  const ids = await targetRuntimeIds(root, options);
653
656
  const summaries = [];
654
657
  for (const id of ids) {
@@ -747,6 +750,38 @@ export async function doctorReport(root, options = {}) {
747
750
  add('configs-dir', 'warn', await exists(configsDir), path.relative(root, configsDir));
748
751
  const runtimeDir = path.join(root, 'runtime', 'loops');
749
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
+ }
771
+ try {
772
+ const steps = await listSteps(root);
773
+ const invalid = steps.filter((step) => step.version !== 2 || !step.step_id || !step.input_fingerprint || !['llm', 'tool', 'effect'].includes(step.kind));
774
+ const reconciliation = steps.filter((step) => step.status === 'unknown' || step.reconciliation?.required);
775
+ add('execution-ledger', 'fail', invalid.length === 0, { schema_version: 2, steps: steps.length, invalid: invalid.map((step) => step.step_id), reconciliation_required: reconciliation.map((step) => step.step_id) });
776
+ if (reconciliation.length) add('execution-ledger:reconciliation', 'warn', false, `${reconciliation.length} step(s) require evidence-backed reconciliation`);
777
+ } catch (error) {
778
+ add('execution-ledger', 'fail', false, error instanceof Error ? error.message : String(error));
779
+ }
780
+ const evidenceFile = path.join(root, '.production-evidence', 'evidence.json');
781
+ if (await exists(evidenceFile)) {
782
+ try { const evidence = await readAndVerifyEvidence(evidenceFile); add('production-evidence', 'fail', evidence.verification.valid && evidence.report.passed, { schema_version: evidence.report.schema_version, integrity: evidence.verification.valid, passed: evidence.report.passed, digest: evidence.report.integrity?.digest }); }
783
+ catch (error) { add('production-evidence', 'fail', false, error instanceof Error ? error.message : String(error)); }
784
+ } else add('production-evidence', 'warn', false, '.production-evidence/evidence.json not generated');
750
785
 
751
786
  const loopConfigs = await configFilesFromArgs(root, []);
752
787
  add('loop-configs-found', 'warn', loopConfigs.length > 0, `${loopConfigs.length} loop config(s)`);
@@ -1009,6 +1044,7 @@ function parkedDisplayState(parked, now = Date.now()) {
1009
1044
 
1010
1045
  export async function tickParkedTasks(root, options = {}) {
1011
1046
  const queue = normalizeLoopId(options.queue);
1047
+ await reconcileProjectGates(root, { queue });
1012
1048
  const now = options.now ? new Date(options.now) : new Date();
1013
1049
  if (!Number.isFinite(now.getTime())) throw new Error(`Invalid tick time: ${options.now}`);
1014
1050
  if (!options.notifyCommand && !options.dryRun) throw new Error('queue-wait-tick requires --notify-command unless --dry-run is used.');
@@ -1117,6 +1153,138 @@ function projectConfigPathFor(root, project) {
1117
1153
  return path.join(root, 'configs', 'loops', 'projects', `${normalizeProjectId(project)}.json`);
1118
1154
  }
1119
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
+
1120
1288
  function projectLatestIntakePath(root, project) {
1121
1289
  return path.join(projectRuntimeDirFor(root, project), 'intake', 'latest.json');
1122
1290
  }
@@ -1578,13 +1746,19 @@ export async function projectStatus(root, options = {}) {
1578
1746
  } catch {
1579
1747
  latestIntake = null;
1580
1748
  }
1581
- 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);
1582
1754
  let backlog = null;
1583
1755
  try {
1584
1756
  const loaded = await readJson(backlogFile);
1585
1757
  backlog = {
1586
1758
  file: path.relative(root, backlogFile),
1587
- 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
1588
1762
  };
1589
1763
  } catch {
1590
1764
  backlog = null;
@@ -1596,11 +1770,45 @@ export async function projectStatus(root, options = {}) {
1596
1770
  if (queue.status.locked) acc.locked += 1;
1597
1771
  return acc;
1598
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';
1599
1805
  const needsAttention = [];
1600
1806
  if (totals.failed > 0) needsAttention.push('failed_tasks_present');
1601
1807
  if (totals.waiting > 0) needsAttention.push('human_input_waiting');
1602
1808
  if (totals.active > 0) needsAttention.push('active_tasks_present');
1603
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');
1604
1812
  return {
1605
1813
  version: 1,
1606
1814
  project,
@@ -1611,11 +1819,22 @@ export async function projectStatus(root, options = {}) {
1611
1819
  queues,
1612
1820
  totals,
1613
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,
1614
1831
  latestIntake,
1615
1832
  needsAttention,
1616
1833
  nextActions: needsAttention.length
1617
1834
  ? ['Inspect queue-status or code-task-status for the queue needing attention.']
1618
- : ['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.']
1619
1838
  };
1620
1839
  }
1621
1840
 
@@ -1643,6 +1862,7 @@ export async function enqueueTask(root, options) {
1643
1862
  status: 'queued',
1644
1863
  enqueuedAt: new Date().toISOString(),
1645
1864
  ...(options.riskAssessment ? { riskAssessment: options.riskAssessment } : {}),
1865
+ ...(options.projectId ? { projectId: normalizeProjectId(options.projectId) } : {}),
1646
1866
  ...(options.supersedesTaskId ? { supersedesTaskId: options.supersedesTaskId } : {}),
1647
1867
  ...(options.supersedeReason ? { supersedeReason: options.supersedeReason } : {}),
1648
1868
  ...(taskSourceFromOptions(options) ? { source: taskSourceFromOptions(options) } : {})
@@ -1741,6 +1961,7 @@ async function appendActiveTaskAmendment(root, queue, activeTask, options = {})
1741
1961
  }
1742
1962
 
1743
1963
  export async function routeLoopMessage(root, options = {}) {
1964
+ if (options.queue) await reconcileProjectGates(root, { queue: options.queue });
1744
1965
  const classification = classifyLoopMessage(options.message);
1745
1966
  if (!options.route) return classification;
1746
1967
  if (classification.intent === 'status') {
@@ -1769,10 +1990,20 @@ export async function routeLoopMessage(root, options = {}) {
1769
1990
  if (options.supersedeActive && options.amendActive) {
1770
1991
  throw new Error('--supersede-active and --amend-active are mutually exclusive.');
1771
1992
  }
1772
- const activeFiles = (options.supersedeActive || options.amendActive) ? await listJson(queueSubdirFor(root, queue, 'active')) : [];
1773
- const activeTask = activeFiles.length > 0
1774
- ? await readJson(path.join(queueSubdirFor(root, queue, 'active'), activeFiles[0]))
1775
- : 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;
1776
2007
  if (options.amendActive) {
1777
2008
  if (!activeTask) throw new Error('No active loop task exists to amend.');
1778
2009
  const amended = await appendActiveTaskAmendment(root, queue, activeTask, options);
@@ -1788,14 +2019,33 @@ export async function routeLoopMessage(root, options = {}) {
1788
2019
  };
1789
2020
  }
1790
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
+ }
1791
2048
  const requestFile = path.join(taskRuntimeDirFor(root, queue, activeTask.id), 'supersede_request.json');
1792
- await writeJson(requestFile, {
1793
- version: 1,
1794
- requestedAt: new Date().toISOString(),
1795
- activeTaskId: activeTask.id,
1796
- reason: String(options.message).trim(),
1797
- status: 'requested'
1798
- });
1799
2049
  for (const file of await listJson(queueSubdirFor(root, queue, 'inbox'))) {
1800
2050
  const full = path.join(queueSubdirFor(root, queue, 'inbox'), file);
1801
2051
  const queued = await readJson(full);
@@ -1822,7 +2072,8 @@ export async function routeLoopMessage(root, options = {}) {
1822
2072
  task,
1823
2073
  riskAssessment: 'model_assessed',
1824
2074
  supersedesTaskId: activeTask?.id,
1825
- supersedeReason: activeTask ? String(options.message).trim() : null
2075
+ supersedeReason: activeTask ? String(options.message).trim() : null,
2076
+ projectId: routedProject
1826
2077
  });
1827
2078
  if (activeTask) {
1828
2079
  const requestFile = path.join(taskRuntimeDirFor(root, queue, activeTask.id), 'supersede_request.json');
@@ -1889,6 +2140,16 @@ export async function notifyTerminalTasks(root, options = {}) {
1889
2140
  throw new Error('queue-terminal-notify requires --notify-command unless --dry-run is used.');
1890
2141
  }
1891
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
+ }
1892
2153
  const notificationDir = path.join(queueDirFor(root, queue), 'notifications');
1893
2154
  await mkdir(notificationDir, { recursive: true });
1894
2155
  const results = [];
@@ -1897,6 +2158,17 @@ export async function notifyTerminalTasks(root, options = {}) {
1897
2158
  for (const file of await listJson(dir)) {
1898
2159
  const task = await readJson(path.join(dir, file));
1899
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
+ }
1900
2172
  if (!task?.source?.channel || !task?.source?.target) {
1901
2173
  results.push({
1902
2174
  taskId: task.id,
@@ -2008,8 +2280,10 @@ export async function refreshTaskAcceptance(root, options = {}) {
2008
2280
 
2009
2281
  function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
2010
2282
  const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
2011
- const blockerText = blockers.length
2012
- ? 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))
2013
2287
  : [checkpoint.next_action ?? 'Human input is required before the task can continue.'];
2014
2288
  if (language === 'zh') return [
2015
2289
  'Loop 任务正在等待你的输入',
@@ -2040,8 +2314,70 @@ async function tasksById(root, queue) {
2040
2314
  return tasks;
2041
2315
  }
2042
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
+
2043
2378
  export async function notifyHumanInputRequests(root, options = {}) {
2044
2379
  const queue = normalizeLoopId(options.queue);
2380
+ await reconcileProjectGates(root, { queue });
2045
2381
  const language = await installedQueueLanguage(root, queue, options.language);
2046
2382
  if (!options.notifyCommand && !options.dryRun) {
2047
2383
  throw new Error('queue-human-input-notify requires --notify-command unless --dry-run is used.');
@@ -2051,29 +2387,15 @@ export async function notifyHumanInputRequests(root, options = {}) {
2051
2387
  const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
2052
2388
  await mkdir(gatesDir, { recursive: true });
2053
2389
  const results = [];
2054
- for (const [taskId, entry] of tasks) {
2055
- if (!entry.task.source) continue;
2056
- const checkpointsDir = path.join(taskRuntimeDirFor(root, queue, taskId), 'checkpoints');
2057
- let checkpointFiles = await listJson(checkpointsDir);
2058
- const judgementFile = path.join(taskRuntimeDirFor(root, queue, taskId), 'final_judgement.json');
2059
- if (await exists(judgementFile)) {
2060
- const judgement = await readJson(judgementFile);
2061
- const effectiveIds = Array.isArray(judgement?.coverage?.effective_review_ids)
2062
- ? new Set(judgement.coverage.effective_review_ids)
2063
- : null;
2064
- if (effectiveIds?.size > 0) {
2065
- checkpointFiles = checkpointFiles.filter((file) => effectiveIds.has(path.basename(file, '.json')));
2066
- }
2067
- }
2068
- for (const file of checkpointFiles) {
2069
- const checkpoint = await readJson(path.join(checkpointsDir, file));
2070
- 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 : [];
2071
2393
  const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
2072
2394
  const gateId = `${taskId}:${checkpointId}`;
2073
2395
  const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
2074
2396
  if (await exists(ledgerFile)) {
2075
2397
  const gate = await readJson(ledgerFile);
2076
- if (gate.status === 'waiting_for_human' && ['inbox', 'failed'].includes(entry.subdir)) {
2398
+ if (gate.status === 'waiting_for_human' && ['inbox', 'done', 'failed'].includes(entry.subdir)) {
2077
2399
  const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
2078
2400
  if (await exists(sourceFile)) {
2079
2401
  const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
@@ -2081,13 +2403,46 @@ export async function notifyHumanInputRequests(root, options = {}) {
2081
2403
  ...entry.task,
2082
2404
  status: 'waiting_for_human',
2083
2405
  waitingGateId: gateId,
2406
+ waitKind: gate.gate_kind ?? 'human_input',
2084
2407
  waitingSince: gate.requested_at ?? new Date().toISOString()
2085
2408
  });
2086
2409
  await rm(sourceFile, { force: true });
2087
2410
  }
2088
2411
  }
2089
- results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
2090
- 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
+ }
2091
2446
  }
2092
2447
  const message = humanInputMessage(queue, entry.task, checkpoint, gateId, language);
2093
2448
  if (options.dryRun) {
@@ -2108,36 +2463,20 @@ export async function notifyHumanInputRequests(root, options = {}) {
2108
2463
  }
2109
2464
  });
2110
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
+ } });
2111
2471
  results.push({ taskId, checkpointId, gateId, outcome: 'failed', result: compactCommandResult(result) });
2112
2472
  continue;
2113
2473
  }
2114
- await writeJson(ledgerFile, {
2115
- version: 1,
2116
- gate_id: gateId,
2117
- queue,
2118
- task_id: taskId,
2119
- checkpoint_id: checkpointId,
2120
- status: 'waiting_for_human',
2121
- source: entry.task.source,
2122
- request: message,
2123
- requested_at: new Date().toISOString(),
2124
- notification: compactCommandResult(result)
2125
- });
2126
- if (['inbox', 'failed'].includes(entry.subdir)) {
2127
- const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
2128
- if (await exists(sourceFile)) {
2129
- const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
2130
- await writeJson(waitingFile, {
2131
- ...entry.task,
2132
- status: 'waiting_for_human',
2133
- waitingGateId: gateId,
2134
- waitingSince: new Date().toISOString()
2135
- });
2136
- await rm(sourceFile, { force: true });
2137
- }
2138
- }
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
+ } });
2139
2479
  results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
2140
- }
2141
2480
  }
2142
2481
  return {
2143
2482
  queue,
@@ -2450,14 +2789,25 @@ function defaultBlockedActions(riskLevel) {
2450
2789
  return blocked;
2451
2790
  }
2452
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
+
2453
2806
  function buildTaskContract(queue, task, options = {}) {
2454
2807
  const inferredRisk = inferTaskRisk(task);
2455
2808
  const modelAssessed = task.riskAssessment === 'model_assessed';
2456
2809
  const riskLevel = options.riskLevel ?? (modelAssessed ? 'model_assessed' : inferredRisk.level);
2457
- const requestText = `${task.title ?? ''}\n${task.body ?? ''}`.toLowerCase();
2458
- const taskScope = /project[-_ ]level|项目级|完整项目|整体项目|single milestone|单(?:一)?里程碑/.test(requestText)
2459
- ? 'project'
2460
- : 'scoped_task';
2810
+ const taskScope = inferTaskScope(task);
2461
2811
  return {
2462
2812
  version: 1,
2463
2813
  task_id: task.id,
@@ -2568,11 +2918,12 @@ export async function writeTaskContract(root, queue, task, options = {}) {
2568
2918
  const dir = taskRuntimeDirFor(root, queue, task.id);
2569
2919
  const file = path.join(dir, 'task_contract.json');
2570
2920
  const historicalPatterns = await retrieveHistoricalPatterns(root, queue, task, options.history ?? {});
2571
- const contract = buildTaskContract(queue, task, {
2921
+ let contract = buildTaskContract(queue, task, {
2572
2922
  ...options,
2573
2923
  workspace: options.workspace ?? root,
2574
2924
  historicalPatterns
2575
2925
  });
2926
+ contract = await mergeLiveAmendments(root, queue, task.id, contract, 'supplemental_requirements');
2576
2927
  await writeJson(file, contract);
2577
2928
  return {
2578
2929
  contract,
@@ -2580,6 +2931,36 @@ export async function writeTaskContract(root, queue, task, options = {}) {
2580
2931
  };
2581
2932
  }
2582
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
+
2583
2964
  function inferAcceptanceSignals(contract) {
2584
2965
  const text = `${contract.title ?? ''}\n${contract.original_request ?? ''}`.toLowerCase();
2585
2966
  return {
@@ -2760,7 +3141,8 @@ function buildAcceptancePlan(contract, options = {}) {
2760
3141
  export async function writeAcceptancePlan(root, queue, task, taskContract, options = {}) {
2761
3142
  const dir = taskRuntimeDirFor(root, queue, task.id);
2762
3143
  const file = path.join(dir, 'acceptance_plan.json');
2763
- const plan = buildAcceptancePlan(taskContract.contract, options);
3144
+ let plan = buildAcceptancePlan(taskContract.contract, options);
3145
+ plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_checks');
2764
3146
  await writeJson(file, plan);
2765
3147
  return {
2766
3148
  plan,
@@ -2827,7 +3209,8 @@ export async function writeDevPlan(root, queue, task, taskContract, acceptancePl
2827
3209
  await mkdir(checkpointsDir, { recursive: true });
2828
3210
  await mkdir(reviewsDir, { recursive: true });
2829
3211
  const file = path.join(dir, 'dev_plan.json');
2830
- 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');
2831
3214
  await writeJson(file, plan);
2832
3215
  return {
2833
3216
  plan,
@@ -3107,6 +3490,7 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
3107
3490
  checkpointCreatedAt: checkpoint.created_at ?? null,
3108
3491
  createdAt: checkpoint.created_at ?? review.created_at,
3109
3492
  projectCompletion: checkpoint.project_completion ?? null,
3493
+ deferredGates: Array.isArray(checkpoint.deferred_gates) ? checkpoint.deferred_gates : [],
3110
3494
  status: review.status,
3111
3495
  file: path.relative(root, reviewFile)
3112
3496
  });
@@ -3179,6 +3563,8 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3179
3563
  const dispatchStatus = context.dispatchStatus ?? 'unknown';
3180
3564
  const dispatchFailure = context.dispatchFailureClassification ?? null;
3181
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);
3182
3568
  let outcome = 'needs_revision';
3183
3569
 
3184
3570
  if (dispatchStatus === 'runtime_interrupted') {
@@ -3229,10 +3615,11 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3229
3615
  outcome = 'needs_revision';
3230
3616
  reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
3231
3617
  nextActions.push('Complete and review the remaining planned checkpoints.');
3232
- } else if (contract.task_scope === 'project' && !projectCompletionAccepted) {
3618
+ } else if ((contract.task_scope === 'project' || projectCompletionInProgress || deferredGateCount > 0) && !projectCompletionAccepted) {
3233
3619
  outcome = 'project_in_progress';
3234
3620
  reasons.push('The latest milestone is accepted, but the project terminal contract is not accepted.');
3235
- 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.');
3236
3623
  } else if (contract.requires_human_gate) {
3237
3624
  outcome = 'ready_for_human_review';
3238
3625
  reasons.push('All reviewed checkpoints are accepted, and the task contract requires a human gate.');
@@ -3269,6 +3656,7 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
3269
3656
  accepted: acceptedCount,
3270
3657
  revise: reviseCount,
3271
3658
  blocked: blockedCount,
3659
+ deferred_gates: deferredGateCount,
3272
3660
  rubric_items: Array.isArray(acceptancePlan?.rubric) ? acceptancePlan.rubric.length : 0,
3273
3661
  automation_suggestions: Array.isArray(acceptancePlan?.automation) ? acceptancePlan.automation.length : 0
3274
3662
  },
@@ -3437,6 +3825,7 @@ async function listJson(dir) {
3437
3825
  export async function queueStatus(root, queue) {
3438
3826
  normalizeLoopId(queue);
3439
3827
  await ensureQueueDirs(root, queue);
3828
+ await reconcileProjectGates(root, { queue });
3440
3829
  const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
3441
3830
  const lock = await readQueueLock(root, queue);
3442
3831
  const lockOwnerAlive = queueLockOwnerAlive(lock);
@@ -3447,7 +3836,7 @@ export async function queueStatus(root, queue) {
3447
3836
  taskId: task.id,
3448
3837
  status: task.status,
3449
3838
  waitId: task.parked?.wait_id ?? task.waitingGateId ?? null,
3450
- 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),
3451
3840
  displayState: task.parked ? parkedDisplayState(task.parked) : task.status,
3452
3841
  parkedAt: task.parked?.parked_at ?? task.waitingSince ?? null,
3453
3842
  reminderCount: Number(task.parked?.reminder_count ?? 0),
@@ -8137,13 +8526,22 @@ function shellQuote(value) {
8137
8526
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
8138
8527
  }
8139
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
+
8140
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;
8141
8538
  return {
8142
8539
  ...process.env,
8143
8540
  LOOP_QUEUE_ID: queue,
8144
8541
  LOOP_TASK_ID: task.id,
8145
8542
  LOOP_TASK_TITLE: task.title,
8146
- LOOP_TASK_BODY: task.body,
8543
+ ...(inlineTaskBody ? { LOOP_TASK_BODY: taskBody } : {}),
8544
+ LOOP_TASK_BODY_MODE: inlineTaskBody ? 'inline' : 'task_file',
8147
8545
  LOOP_TASK_FILE: taskFile,
8148
8546
  LOOP_TASK_FILE_REL: path.relative(root, taskFile),
8149
8547
  LOOP_RUN_ID: runId,
@@ -8465,6 +8863,7 @@ async function runPreflight(root, config, timeoutMs) {
8465
8863
  export async function runQueueOnce(root, options) {
8466
8864
  const queue = normalizeLoopId(options.queue);
8467
8865
  await ensureQueueDirs(root, queue);
8866
+ await reconcileProjectGates(root, { queue });
8468
8867
  let progressTask = null;
8469
8868
  const liveProgressNotifications = [];
8470
8869
  let liveProgressChain = Promise.resolve();