@jungjaehoon/mama-os 0.48.0 → 0.48.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/agent/agent-loop.d.ts.map +1 -1
  2. package/dist/agent/agent-loop.js +4 -0
  3. package/dist/agent/agent-loop.js.map +1 -1
  4. package/dist/agent/code-act/host-bridge.d.ts.map +1 -1
  5. package/dist/agent/code-act/host-bridge.js +47 -5
  6. package/dist/agent/code-act/host-bridge.js.map +1 -1
  7. package/dist/agent/gateway-tool-executor.d.ts.map +1 -1
  8. package/dist/agent/gateway-tool-executor.js +110 -0
  9. package/dist/agent/gateway-tool-executor.js.map +1 -1
  10. package/dist/agent/gateway-tools.md +4 -3
  11. package/dist/agent/tool-registry.d.ts.map +1 -1
  12. package/dist/agent/tool-registry.js +33 -6
  13. package/dist/agent/tool-registry.js.map +1 -1
  14. package/dist/agent/types.d.ts +10 -1
  15. package/dist/agent/types.d.ts.map +1 -1
  16. package/dist/agent/types.js.map +1 -1
  17. package/dist/cli/commands/start.d.ts.map +1 -1
  18. package/dist/cli/commands/start.js +27 -3
  19. package/dist/cli/commands/start.js.map +1 -1
  20. package/dist/cli/config/types.d.ts.map +1 -1
  21. package/dist/cli/config/types.js +1 -0
  22. package/dist/cli/config/types.js.map +1 -1
  23. package/dist/cli/runtime/api-routes-init.d.ts.map +1 -1
  24. package/dist/cli/runtime/api-routes-init.js +142 -15
  25. package/dist/cli/runtime/api-routes-init.js.map +1 -1
  26. package/dist/db/migrations/operator-task-classification.d.ts +15 -0
  27. package/dist/db/migrations/operator-task-classification.d.ts.map +1 -0
  28. package/dist/db/migrations/operator-task-classification.js +25 -0
  29. package/dist/db/migrations/operator-task-classification.js.map +1 -0
  30. package/dist/evidence/effects.d.ts +2 -1
  31. package/dist/evidence/effects.d.ts.map +1 -1
  32. package/dist/evidence/effects.js.map +1 -1
  33. package/dist/gateways/message-router.d.ts.map +1 -1
  34. package/dist/gateways/message-router.js +2 -1
  35. package/dist/gateways/message-router.js.map +1 -1
  36. package/dist/multi-agent/dashboard-agent-persona.d.ts.map +1 -1
  37. package/dist/multi-agent/dashboard-agent-persona.js +7 -8
  38. package/dist/multi-agent/dashboard-agent-persona.js.map +1 -1
  39. package/dist/multi-agent/wiki-agent-persona.d.ts +7 -6
  40. package/dist/multi-agent/wiki-agent-persona.d.ts.map +1 -1
  41. package/dist/multi-agent/wiki-agent-persona.js +9 -93
  42. package/dist/multi-agent/wiki-agent-persona.js.map +1 -1
  43. package/dist/operator/action-verifier.d.ts +1 -1
  44. package/dist/operator/action-verifier.d.ts.map +1 -1
  45. package/dist/operator/action-verifier.js +4 -0
  46. package/dist/operator/action-verifier.js.map +1 -1
  47. package/dist/operator/board-reconcile.d.ts +1 -1
  48. package/dist/operator/board-reconcile.js +1 -1
  49. package/dist/operator/owner-event-outcome.d.ts +6 -0
  50. package/dist/operator/owner-event-outcome.d.ts.map +1 -1
  51. package/dist/operator/owner-event-outcome.js +6 -3
  52. package/dist/operator/owner-event-outcome.js.map +1 -1
  53. package/dist/operator/owner-event-policy.d.ts.map +1 -1
  54. package/dist/operator/owner-event-policy.js +9 -4
  55. package/dist/operator/owner-event-policy.js.map +1 -1
  56. package/dist/operator/owner-event-prompt.d.ts.map +1 -1
  57. package/dist/operator/owner-event-prompt.js +4 -0
  58. package/dist/operator/owner-event-prompt.js.map +1 -1
  59. package/dist/operator/task-ledger.d.ts +86 -1
  60. package/dist/operator/task-ledger.d.ts.map +1 -1
  61. package/dist/operator/task-ledger.js +285 -8
  62. package/dist/operator/task-ledger.js.map +1 -1
  63. package/dist/operator/task-list-views.d.ts.map +1 -1
  64. package/dist/operator/task-list-views.js +28 -0
  65. package/dist/operator/task-list-views.js.map +1 -1
  66. package/dist/operator/temporal-reconcile.d.ts +1 -0
  67. package/dist/operator/temporal-reconcile.d.ts.map +1 -1
  68. package/dist/operator/temporal-reconcile.js +1 -0
  69. package/dist/operator/temporal-reconcile.js.map +1 -1
  70. package/dist/operator/wiki-continuity.d.ts +151 -0
  71. package/dist/operator/wiki-continuity.d.ts.map +1 -0
  72. package/dist/operator/wiki-continuity.js +215 -0
  73. package/dist/operator/wiki-continuity.js.map +1 -0
  74. package/dist/operator/worker-run.js +1 -1
  75. package/dist/operator/worker-run.js.map +1 -1
  76. package/dist/operator/workorder-consumer.d.ts.map +1 -1
  77. package/dist/operator/workorder-consumer.js +10 -7
  78. package/dist/operator/workorder-consumer.js.map +1 -1
  79. package/dist/operator/workorder-hooks.d.ts +1 -1
  80. package/dist/operator/workorder-publishers.d.ts +26 -4
  81. package/dist/operator/workorder-publishers.d.ts.map +1 -1
  82. package/dist/operator/workorder-publishers.js +136 -1
  83. package/dist/operator/workorder-publishers.js.map +1 -1
  84. package/dist/wiki/obsidian-writer.d.ts.map +1 -1
  85. package/dist/wiki/obsidian-writer.js +14 -2
  86. package/dist/wiki/obsidian-writer.js.map +1 -1
  87. package/dist/wiki/wiki-turn-contract.d.ts +19 -0
  88. package/dist/wiki/wiki-turn-contract.d.ts.map +1 -0
  89. package/dist/wiki/wiki-turn-contract.js +58 -0
  90. package/dist/wiki/wiki-turn-contract.js.map +1 -0
  91. package/package.json +1 -1
@@ -7,7 +7,8 @@
7
7
  * workorder state. Implements the pre-existing `TaskSource` interface
8
8
  * (operator-interfaces.ts) so the board projects one task model, not two.
9
9
  *
10
- * Reconcile runs create/update rows through the task_create/task_update gateway
10
+ * Owner conversations create rows through task_create. Reconcile runs update or
11
+ * reclassify existing rows through the task_update/task_reclassify gateway
11
12
  * tools; the pipeline board slot is a projection of `list({order:
12
13
  * 'deadline_priority'})`. The agent proposes task changes, while this ledger
13
14
  * enforces revisions, temporal ownership, idempotency, and atomic receipts.
@@ -20,11 +21,12 @@
20
21
  * (start.ts opens and closes it once) - deliberately no close() here.
21
22
  */
22
23
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.TaskLedger = exports.WORKORDER_CHANNEL_PREFIX = exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS = exports.WORKORDER_KINDS = exports.TASK_KINDS = exports.TASK_PRIORITIES = exports.TASK_STATUSES = void 0;
24
+ exports.TaskLedger = exports.TASK_COMPLETION_CRITERIA_MAX_LENGTH = exports.TASK_RECLASSIFY_REASON_MAX_LENGTH = exports.TASK_RECLASSIFY_DISPOSITIONS = exports.TASK_RESOLUTION_KINDS = exports.WORKORDER_CHANNEL_PREFIX = exports.TEMPORAL_WORKORDER_MAX_ATTEMPTS = exports.WORKORDER_KINDS = exports.TASK_KINDS = exports.TASK_PRIORITIES = exports.TASK_STATUSES = void 0;
24
25
  const node_crypto_1 = require("node:crypto");
25
26
  const operator_task_temporal_js_1 = require("../db/migrations/operator-task-temporal.js");
26
27
  const operator_task_external_lifecycle_js_1 = require("../db/migrations/operator-task-external-lifecycle.js");
27
28
  const operator_task_review_lifecycle_js_1 = require("../db/migrations/operator-task-review-lifecycle.js");
29
+ const operator_task_classification_js_1 = require("../db/migrations/operator-task-classification.js");
28
30
  const effects_js_1 = require("../evidence/effects.js");
29
31
  const temporal_effect_js_1 = require("./temporal-effect.js");
30
32
  const task_temporal_js_1 = require("./task-temporal.js");
@@ -73,6 +75,28 @@ exports.WORKORDER_CHANNEL_PREFIX = 'workorder:';
73
75
  const EFFECT_CAUSE_CHUNK = 500;
74
76
  const KNOWN_WORKORDER_CHANNELS = exports.WORKORDER_KINDS.map((workKind) => `${exports.WORKORDER_CHANNEL_PREFIX}${workKind}`);
75
77
  const KNOWN_WORKORDER_CHANNELS_SQL = KNOWN_WORKORDER_CHANNELS.map(() => '?').join(',');
78
+ /**
79
+ * Why a row reached its terminal state. Without this the agent cannot tell
80
+ * completed work from an item that was never a task, which is how connector
81
+ * observations, principles and open questions accumulated as owner rows.
82
+ */
83
+ exports.TASK_RESOLUTION_KINDS = [
84
+ 'completed_evidence',
85
+ 'completed_no_issue',
86
+ 'non_task_record',
87
+ 'non_task_memory',
88
+ ];
89
+ /** The recorrection an agent may apply to an existing owner row. */
90
+ exports.TASK_RECLASSIFY_DISPOSITIONS = [
91
+ 'completed_evidence',
92
+ 'completed_no_issue',
93
+ 'non_task_record',
94
+ 'non_task_memory',
95
+ 'reopen',
96
+ ];
97
+ /** Upper bound on the preserved reason, matching latest_event's practical size. */
98
+ exports.TASK_RECLASSIFY_REASON_MAX_LENGTH = 2000;
99
+ exports.TASK_COMPLETION_CRITERIA_MAX_LENGTH = 500;
76
100
  const PRIORITY_RANK = { high: 0, normal: 1, low: 2 };
77
101
  function encodeListCursor(payload) {
78
102
  return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
@@ -122,6 +146,18 @@ function assertIsoDate(value, field) {
122
146
  throw new Error(`${field} must be an ISO date (YYYY-MM-DD), got: ${value}`);
123
147
  }
124
148
  }
149
+ function normalizeCompletionCriteria(value, operation) {
150
+ if (value === undefined || value === null)
151
+ return null;
152
+ if (typeof value !== 'string' || value.trim().length === 0) {
153
+ throw new Error(`${operation}: completion_criteria must be a non-empty string`);
154
+ }
155
+ const normalized = value.trim();
156
+ if (normalized.length > exports.TASK_COMPLETION_CRITERIA_MAX_LENGTH) {
157
+ throw new Error(`${operation}: completion_criteria must be at most ${exports.TASK_COMPLETION_CRITERIA_MAX_LENGTH} characters`);
158
+ }
159
+ return normalized;
160
+ }
125
161
  function rowToRecord(row, now, timeZone) {
126
162
  return {
127
163
  id: row.id,
@@ -153,6 +189,8 @@ function rowToRecord(row, now, timeZone) {
153
189
  }, now, timeZone),
154
190
  reviewStartedAt: row.review_started_at,
155
191
  reviewAnchorEventId: row.review_anchor_event_id,
192
+ completionCriteria: row.completion_criteria ?? null,
193
+ resolutionKind: (row.resolution_kind ?? null),
156
194
  createdAt: row.created_at,
157
195
  updatedAt: row.updated_at,
158
196
  };
@@ -226,6 +264,8 @@ class TaskLedger {
226
264
  last_temporal_attempt_id INTEGER,
227
265
  review_started_at INTEGER,
228
266
  review_anchor_event_id TEXT,
267
+ completion_criteria TEXT,
268
+ resolution_kind TEXT,
229
269
  created_at INTEGER NOT NULL,
230
270
  updated_at INTEGER NOT NULL`;
231
271
  // Unique-index predicate excludes terminal rows so a terminal keyed workorder
@@ -292,6 +332,7 @@ class TaskLedger {
292
332
  (0, operator_task_temporal_js_1.applyOperatorTaskTemporalMigration)(this.db);
293
333
  (0, operator_task_external_lifecycle_js_1.applyOperatorTaskExternalLifecycleMigration)(this.db);
294
334
  (0, operator_task_review_lifecycle_js_1.applyOperatorTaskReviewLifecycleMigration)(this.db);
335
+ (0, operator_task_classification_js_1.applyOperatorTaskClassificationMigration)(this.db);
295
336
  // Old-predicate unique index (no terminal exclusion) -> swap in place.
296
337
  const idxRow = this.db
297
338
  .prepare(`SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_operator_tasks_source'`)
@@ -377,6 +418,16 @@ class TaskLedger {
377
418
  where.push('updated_at >= ?');
378
419
  params.push(filter.updatedSinceMs);
379
420
  }
421
+ if (filter.updatedBeforeMs !== undefined) {
422
+ where.push('updated_at < ?');
423
+ params.push(filter.updatedBeforeMs);
424
+ }
425
+ if (filter.qualification === 'qualified') {
426
+ where.push('completion_criteria IS NOT NULL');
427
+ }
428
+ else if (filter.qualification === 'legacy_unqualified') {
429
+ where.push('completion_criteria IS NULL');
430
+ }
380
431
  return { where, params };
381
432
  }
382
433
  /** ORDER BY for both list() and listPage(); the trailing id makes it a TOTAL order,
@@ -873,7 +924,15 @@ class TaskLedger {
873
924
  outcome = 'superseded';
874
925
  }
875
926
  else if (input.decision === 'apply') {
876
- const transitioned = this.transitionTaskInTransaction(candidate.taskId, { status: candidate.proposedStatus, latest_event: candidate.evidenceSummary }, origin, true);
927
+ const terminalEvidence = candidate.proposedStatus === 'done' || candidate.proposedStatus === 'cancelled';
928
+ const transitioned = this.transitionTaskInTransaction(candidate.taskId, {
929
+ status: candidate.proposedStatus,
930
+ latest_event: candidate.evidenceSummary,
931
+ // This candidate is a host-issued current authoritative lifecycle
932
+ // observation. Preserve its semantic completion, or clear an earlier
933
+ // terminal classification when later feedback reopens the same row.
934
+ resolution_kind: terminalEvidence ? 'completed_evidence' : null,
935
+ }, origin, true);
877
936
  taskRevisionAfter = transitioned.revision;
878
937
  outcome = 'applied';
879
938
  }
@@ -1053,6 +1112,9 @@ class TaskLedger {
1053
1112
  }
1054
1113
  const normalizedDeadline = exactDue?.deadline ?? input.deadline ?? null;
1055
1114
  const initialTemporalEpoch = normalizedDeadline !== null ? 1 : 0;
1115
+ // Optional HERE on purpose: the PUBLIC task_create boundary requires it, while
1116
+ // internal/system seeding and legacy callers stay backward compatible.
1117
+ const normalizedCompletionCriteria = normalizeCompletionCriteria(input.completion_criteria, 'task_create');
1056
1118
  const now = this.now();
1057
1119
  if (input.source_channel && input.source_event_id) {
1058
1120
  // kind='owner' probe: an agent-supplied (channel, event) pair can never
@@ -1073,6 +1135,9 @@ class TaskLedger {
1073
1135
  ...(input.due_at !== undefined ? { due_at: input.due_at } : {}),
1074
1136
  ...(input.confirmed !== undefined ? { confirmed: input.confirmed } : {}),
1075
1137
  ...(input.latest_event !== undefined ? { latest_event: input.latest_event } : {}),
1138
+ ...(normalizedCompletionCriteria !== null
1139
+ ? { completion_criteria: normalizedCompletionCriteria }
1140
+ : {}),
1076
1141
  ...(input.expected_revision !== undefined
1077
1142
  ? { expected_revision: input.expected_revision }
1078
1143
  : {}),
@@ -1084,6 +1149,7 @@ class TaskLedger {
1084
1149
  // carried nothing.
1085
1150
  {
1086
1151
  ...origin,
1152
+ taskCreateUpsert: true,
1087
1153
  causeEventIds: origin.causeEventIds?.length
1088
1154
  ? origin.causeEventIds
1089
1155
  : input.source_event_id
@@ -1099,9 +1165,9 @@ class TaskLedger {
1099
1165
  .prepare(`INSERT INTO operator_tasks
1100
1166
  (title, status, priority, assignee, deadline, due_at, deadline_offset_minutes,
1101
1167
  revision, temporal_epoch, source_channel, source_event_id, latest_event,
1102
- auto_created, confirmed, created_at, updated_at)
1103
- VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?)`)
1104
- .run(input.title.trim(), input.status ?? 'pending', input.priority ?? 'normal', input.assignee ?? null, normalizedDeadline, exactDue?.dueAt ?? null, exactDue?.offsetMinutes ?? null, initialTemporalEpoch, input.source_channel ?? null, input.source_event_id ?? null, input.latest_event ?? null, 1, input.confirmed ? 1 : 0, now, now);
1168
+ auto_created, confirmed, completion_criteria, created_at, updated_at)
1169
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1170
+ .run(input.title.trim(), input.status ?? 'pending', input.priority ?? 'normal', input.assignee ?? null, normalizedDeadline, exactDue?.dueAt ?? null, exactDue?.offsetMinutes ?? null, initialTemporalEpoch, input.source_channel ?? null, input.source_event_id ?? null, input.latest_event ?? null, 1, input.confirmed ? 1 : 0, normalizedCompletionCriteria, now, now);
1105
1171
  createdId = Number(result.lastInsertRowid);
1106
1172
  this.recordTaskChange({
1107
1173
  kind: 'task_create',
@@ -1142,6 +1208,7 @@ class TaskLedger {
1142
1208
  source_event_id: input.source_event_id ?? null,
1143
1209
  latest_event: input.latest_event ?? null,
1144
1210
  confirmed: input.confirmed ? 1 : 0,
1211
+ completion_criteria: normalizedCompletionCriteria,
1145
1212
  },
1146
1213
  atMs: now,
1147
1214
  });
@@ -1203,6 +1270,148 @@ class TaskLedger {
1203
1270
  throw error;
1204
1271
  }
1205
1272
  }
1273
+ /**
1274
+ * Recorrect an existing owner row with a NAMED disposition.
1275
+ *
1276
+ * Records and tasks are separate. Without a semantic terminal reason the agent
1277
+ * could not distinguish completed work from an item that was never a task, so
1278
+ * connector observations, principles, lessons and open questions accumulated as
1279
+ * owner rows and could only be "closed" as if they had been done.
1280
+ *
1281
+ * Every path requires the exact current revision, a non-empty bounded reason
1282
+ * preserved as `latest_event`, an OWNER row, and the same candidate/workorder
1283
+ * authority checks as task_update (TG-03/TG-04/TG-06) - a terminal or foreign
1284
+ * board attempt cannot mutate an unrelated row. The whole transition, its
1285
+ * `resolution_kind` and its effect receipt commit as ONE transaction.
1286
+ */
1287
+ reclassify(id, input, origin = {}) {
1288
+ const disposition = input?.disposition;
1289
+ if (!exports.TASK_RECLASSIFY_DISPOSITIONS.includes(disposition)) {
1290
+ throw new Error(`task_reclassify: disposition must be one of ${exports.TASK_RECLASSIFY_DISPOSITIONS.join('|')}, got: ${String(disposition)}`);
1291
+ }
1292
+ const reason = typeof input.reason === 'string' ? input.reason.trim() : '';
1293
+ if (reason.length === 0) {
1294
+ throw new Error('task_reclassify: reason must be a non-empty string');
1295
+ }
1296
+ if (reason.length > exports.TASK_RECLASSIFY_REASON_MAX_LENGTH) {
1297
+ throw new Error(`task_reclassify: reason must be at most ${exports.TASK_RECLASSIFY_REASON_MAX_LENGTH} characters`);
1298
+ }
1299
+ if (!Number.isSafeInteger(input.expected_revision) || input.expected_revision < 0) {
1300
+ throw new Error('task_reclassify: expected_revision must be a non-negative integer');
1301
+ }
1302
+ this.db.exec('BEGIN IMMEDIATE');
1303
+ try {
1304
+ const existing = this.db.prepare('SELECT * FROM operator_tasks WHERE id = ?').get(id);
1305
+ if (!existing)
1306
+ throw new Error(`task_reclassify: no task with id ${id}`);
1307
+ if (existing.kind === 'system') {
1308
+ throw new Error(`task_reclassify: task ${id} is a system workorder row (owner rows only)`);
1309
+ }
1310
+ if (input.expected_revision !== existing.revision) {
1311
+ throw new Error(`task_reclassify: expected revision ${input.expected_revision}, current ${existing.revision}`);
1312
+ }
1313
+ this.assertTaskReclassificationAuthorized(existing, origin);
1314
+ const patch = this.buildReclassifyPatch(existing, disposition, reason);
1315
+ // Same primitive as task_update: candidate guard, board-attempt liveness,
1316
+ // revision-checked UPDATE, effect receipt, temporal generation supersession.
1317
+ const record = this.transitionTaskInTransaction(id, patch, origin);
1318
+ this.db.exec('COMMIT');
1319
+ return record;
1320
+ }
1321
+ catch (error) {
1322
+ this.db.exec('ROLLBACK');
1323
+ throw error;
1324
+ }
1325
+ }
1326
+ assertTaskReclassificationAuthorized(existing, origin) {
1327
+ if (origin.workOrderAttemptId !== undefined) {
1328
+ const attempt = this.getWorkOrderById(origin.workOrderAttemptId);
1329
+ if (!attempt || attempt.workKind !== 'board' || attempt.status !== 'in_progress') {
1330
+ throw new Error(`task_reclassify: Board workorder ${String(origin.workOrderAttemptId)} is no longer active`);
1331
+ }
1332
+ const { attempts: _attempts, ...payload } = attempt.payload;
1333
+ (0, workorder_publishers_js_1.validateWorkOrderPayload)('board', payload);
1334
+ const candidates = Array.isArray(payload.reclassificationCandidates)
1335
+ ? payload.reclassificationCandidates
1336
+ : [];
1337
+ const candidate = candidates.find((item) => item.taskId === existing.id);
1338
+ if (!candidate || candidate.taskRevision !== existing.revision) {
1339
+ throw new Error(`task_reclassify: task ${existing.id} is outside this host-issued Board candidate set`);
1340
+ }
1341
+ }
1342
+ if (origin.reclassificationCauseBound) {
1343
+ const eventIds = (origin.causeEventIds ?? []).filter(effects_js_1.isUsableCause);
1344
+ if (eventIds.length === 0) {
1345
+ throw new Error('task_reclassify: owner-event reclassification requires causal events');
1346
+ }
1347
+ if (existing.source_event_id && eventIds.includes(existing.source_event_id))
1348
+ return;
1349
+ const placeholders = eventIds.map(() => '?').join(',');
1350
+ const channels = this.db
1351
+ .prepare(`SELECT DISTINCT inbox.channel_key
1352
+ FROM owner_event_inbox AS inbox, json_each(inbox.event_ids_json) AS event
1353
+ WHERE event.value IN (${placeholders})`)
1354
+ .all(...eventIds);
1355
+ if (existing.source_channel === null ||
1356
+ !channels.some((row) => row.channel_key === existing.source_channel)) {
1357
+ throw new Error(`task_reclassify: task ${existing.id} is outside this owner-event source channel`);
1358
+ }
1359
+ }
1360
+ }
1361
+ buildReclassifyPatch(existing, disposition, reason) {
1362
+ const base = {
1363
+ latest_event: reason,
1364
+ expected_revision: existing.revision,
1365
+ };
1366
+ if (disposition === 'reopen') {
1367
+ if (existing.status !== 'done' && existing.status !== 'cancelled') {
1368
+ throw new Error(`task_reclassify: reopen requires a terminal owner row, task is '${existing.status}'`);
1369
+ }
1370
+ // A reopened row must not inherit a deadline that already passed: it would
1371
+ // read as overdue the moment it reopens. A still-future deadline is kept.
1372
+ const stale = this.isDeadlineInThePast(existing);
1373
+ return {
1374
+ ...base,
1375
+ status: 'pending',
1376
+ resolution_kind: null,
1377
+ ...(stale ? { deadline: null, due_at: null } : {}),
1378
+ };
1379
+ }
1380
+ if (existing.status === 'done' || existing.status === 'cancelled') {
1381
+ throw new Error(`task_reclassify: ${disposition} requires an active owner row, task is '${existing.status}'`);
1382
+ }
1383
+ if (disposition === 'completed_evidence') {
1384
+ return { ...base, status: 'done', resolution_kind: 'completed_evidence' };
1385
+ }
1386
+ if (disposition === 'completed_no_issue') {
1387
+ // Owner policy: a PAST deadline plus a complete relevant-source check with no
1388
+ // issue is enough. Without a passed deadline there is nothing to conclude from.
1389
+ if (!this.isDeadlineInThePast(existing)) {
1390
+ throw new Error('task_reclassify: completed_no_issue requires a deadline or due_at already in the past');
1391
+ }
1392
+ return { ...base, status: 'done', resolution_kind: 'completed_no_issue' };
1393
+ }
1394
+ // non_task_record / non_task_memory: this was never a task. Cancelled, not done,
1395
+ // so accounting never reads it as finished work.
1396
+ return {
1397
+ ...base,
1398
+ status: 'cancelled',
1399
+ completion_criteria: null,
1400
+ resolution_kind: disposition,
1401
+ };
1402
+ }
1403
+ /** True when the row carries a deadline/due_at that has already passed. */
1404
+ isDeadlineInThePast(existing) {
1405
+ const state = (0, task_temporal_js_1.deriveTemporalState)({
1406
+ // Terminal rows derive 'closed', which would hide a genuinely past deadline;
1407
+ // ask the temporal question about the CLOCK fields only.
1408
+ status: 'pending',
1409
+ dueAt: existing.due_at,
1410
+ deadlineIso: existing.deadline,
1411
+ deadlineOffsetMinutes: existing.deadline_offset_minutes,
1412
+ }, this.now(), this.timeZone);
1413
+ return state === 'exact_overdue' || state === 'date_overdue';
1414
+ }
1206
1415
  /**
1207
1416
  * The one mutation primitive for owner-task state. Callers own the outer
1208
1417
  * transaction so lifecycle receipts, effects, generation ownership, and the
@@ -1224,7 +1433,8 @@ class TaskLedger {
1224
1433
  : null;
1225
1434
  const hasLifecyclePatch = Object.prototype.hasOwnProperty.call(patch, 'status') ||
1226
1435
  Object.prototype.hasOwnProperty.call(patch, 'due_at') ||
1227
- Object.prototype.hasOwnProperty.call(patch, 'latest_event');
1436
+ Object.prototype.hasOwnProperty.call(patch, 'latest_event') ||
1437
+ Object.prototype.hasOwnProperty.call(patch, 'completion_criteria');
1228
1438
  const isBoardWorkorderLifecycleJudgment = revisionAttempt?.workKind === 'board' && hasLifecyclePatch;
1229
1439
  if (isBoardWorkorderLifecycleJudgment && revisionAttempt.status !== 'in_progress') {
1230
1440
  throw new Error(`task_update: board workorder ${revisionAttempt.id} is no longer active (${revisionAttempt.status})`);
@@ -1276,6 +1486,8 @@ class TaskLedger {
1276
1486
  last_temporal_attempt_id: existing.last_temporal_attempt_id,
1277
1487
  review_started_at: existing.review_started_at,
1278
1488
  review_anchor_event_id: existing.review_anchor_event_id,
1489
+ completion_criteria: existing.completion_criteria,
1490
+ resolution_kind: existing.resolution_kind,
1279
1491
  };
1280
1492
  if (patch.title !== undefined)
1281
1493
  next.title = patch.title.trim();
@@ -1289,6 +1501,12 @@ class TaskLedger {
1289
1501
  next.latest_event = patch.latest_event;
1290
1502
  if (patch.confirmed !== undefined)
1291
1503
  next.confirmed = patch.confirmed ? 1 : 0;
1504
+ if (patch.completion_criteria !== undefined) {
1505
+ next.completion_criteria = normalizeCompletionCriteria(patch.completion_criteria, 'task_update');
1506
+ }
1507
+ // Host-internal (reclassify only); the public task_update surface rejects it.
1508
+ if (patch.resolution_kind !== undefined)
1509
+ next.resolution_kind = patch.resolution_kind;
1292
1510
  const leavingVerifiedReview = existing.status === 'review' &&
1293
1511
  patch.status !== undefined &&
1294
1512
  patch.status !== 'review' &&
@@ -1353,6 +1571,16 @@ class TaskLedger {
1353
1571
  const terminalToOpen = (existing.status === 'done' || existing.status === 'cancelled') &&
1354
1572
  next.status !== 'done' &&
1355
1573
  next.status !== 'cancelled';
1574
+ if (terminalToOpen && existing.resolution_kind !== null && patch.resolution_kind !== null) {
1575
+ throw new Error('task_update: a classified terminal row must reopen through task_reclassify');
1576
+ }
1577
+ if (patch.status !== undefined &&
1578
+ next.status !== existing.status &&
1579
+ patch.resolution_kind === undefined) {
1580
+ // A generic status change has no semantic reclassification authority. Clear an
1581
+ // old terminal meaning rather than rendering contradictory status/reason pairs.
1582
+ next.resolution_kind = null;
1583
+ }
1356
1584
  const openToTerminal = existing.status !== 'done' &&
1357
1585
  existing.status !== 'cancelled' &&
1358
1586
  (next.status === 'done' || next.status === 'cancelled');
@@ -1380,6 +1608,8 @@ class TaskLedger {
1380
1608
  'last_temporal_attempt_id',
1381
1609
  'review_started_at',
1382
1610
  'review_anchor_event_id',
1611
+ 'completion_criteria',
1612
+ 'resolution_kind',
1383
1613
  ];
1384
1614
  const changedColumns = persistedColumns.filter((column) => next[column] !== existing[column]);
1385
1615
  if (changedColumns.length === 0) {
@@ -1424,7 +1654,8 @@ class TaskLedger {
1424
1654
  assertCandidateTaskMutationAllowed(taskId, patch, origin) {
1425
1655
  if (origin.workOrderAttemptId === undefined ||
1426
1656
  (!Object.prototype.hasOwnProperty.call(patch, 'status') &&
1427
- !Object.prototype.hasOwnProperty.call(patch, 'latest_event'))) {
1657
+ !Object.prototype.hasOwnProperty.call(patch, 'latest_event') &&
1658
+ !Object.prototype.hasOwnProperty.call(patch, 'completion_criteria'))) {
1428
1659
  return;
1429
1660
  }
1430
1661
  const attempt = this.getWorkOrderById(origin.workOrderAttemptId);
@@ -1439,6 +1670,18 @@ class TaskLedger {
1439
1670
  }
1440
1671
  const { attempts: _attempts, ...payload } = attempt.payload;
1441
1672
  (0, workorder_publishers_js_1.validateWorkOrderPayload)('board', payload);
1673
+ if (Object.prototype.hasOwnProperty.call(patch, 'completion_criteria') &&
1674
+ !origin.taskCreateUpsert) {
1675
+ const reclassificationCandidates = Array.isArray(payload.reclassificationCandidates)
1676
+ ? payload.reclassificationCandidates
1677
+ : [];
1678
+ const qualificationCandidate = reclassificationCandidates.find((candidate) => candidate.taskId === taskId);
1679
+ if (!qualificationCandidate ||
1680
+ (patch.expected_revision !== undefined &&
1681
+ qualificationCandidate.taskRevision !== patch.expected_revision)) {
1682
+ throw new Error(`task_update: task ${taskId} is outside this host-issued qualification candidate set`);
1683
+ }
1684
+ }
1442
1685
  if (payload.mode !== 'reconcile' || !payload.candidates) {
1443
1686
  return;
1444
1687
  }
@@ -1546,6 +1789,40 @@ class TaskLedger {
1546
1789
  completedAt: row.updated_at,
1547
1790
  };
1548
1791
  }
1792
+ /**
1793
+ * The newest COMPLETED wiki run - the baseline the wiki daily-continuity
1794
+ * planner gates against (wiki-continuity.ts). Only `status='done'` rows count.
1795
+ *
1796
+ * `ownerDate`/`sourceWatermark` are read straight from the completed run's
1797
+ * payload and are null for a LEGACY row (one enqueued before the continuity
1798
+ * fields existed): the planner treats a null-field baseline as "no baseline"
1799
+ * and re-runs from the owner day's start rather than fabricating a boundary.
1800
+ */
1801
+ lastCompletedWikiRun() {
1802
+ const row = this.db
1803
+ .prepare(`SELECT json_extract(payload, '$.ownerDate') AS owner_date,
1804
+ json_extract(payload, '$.sourceWatermark') AS source_watermark,
1805
+ json_extract(payload, '$.range.end_ms') AS covered_through_ms,
1806
+ updated_at
1807
+ FROM operator_tasks
1808
+ WHERE kind = 'system' AND source_channel = ? AND status = 'done'
1809
+ ORDER BY id DESC LIMIT 1`)
1810
+ .get(`${exports.WORKORDER_CHANNEL_PREFIX}wiki`);
1811
+ if (!row)
1812
+ return null;
1813
+ return {
1814
+ ownerDate: typeof row.owner_date === 'string' && row.owner_date.length > 0 ? row.owner_date : null,
1815
+ sourceWatermark: typeof row.source_watermark === 'string' && row.source_watermark.length > 0
1816
+ ? row.source_watermark
1817
+ : null,
1818
+ // The prior run's range.end_ms - where the next same-day run resumes.
1819
+ // Null for a legacy row with no range; never fabricated.
1820
+ coveredThroughMs: typeof row.covered_through_ms === 'number' && Number.isFinite(row.covered_through_ms)
1821
+ ? row.covered_through_ms
1822
+ : null,
1823
+ completedAt: row.updated_at,
1824
+ };
1825
+ }
1549
1826
  /**
1550
1827
  * Board-input term for the NATIVE owner task ledger - the pipeline slot's
1551
1828
  * projection source. Reuses payloadHash(): it already digests exactly the