@jungjaehoon/mama-os 0.48.0 → 0.48.1

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 (88) hide show
  1. package/dist/agent/code-act/host-bridge.d.ts.map +1 -1
  2. package/dist/agent/code-act/host-bridge.js +40 -4
  3. package/dist/agent/code-act/host-bridge.js.map +1 -1
  4. package/dist/agent/gateway-tool-executor.d.ts.map +1 -1
  5. package/dist/agent/gateway-tool-executor.js +94 -0
  6. package/dist/agent/gateway-tool-executor.js.map +1 -1
  7. package/dist/agent/gateway-tools.md +4 -3
  8. package/dist/agent/tool-registry.d.ts.map +1 -1
  9. package/dist/agent/tool-registry.js +33 -6
  10. package/dist/agent/tool-registry.js.map +1 -1
  11. package/dist/agent/types.d.ts +1 -1
  12. package/dist/agent/types.d.ts.map +1 -1
  13. package/dist/agent/types.js.map +1 -1
  14. package/dist/cli/commands/start.d.ts.map +1 -1
  15. package/dist/cli/commands/start.js +17 -3
  16. package/dist/cli/commands/start.js.map +1 -1
  17. package/dist/cli/config/types.d.ts.map +1 -1
  18. package/dist/cli/config/types.js +1 -0
  19. package/dist/cli/config/types.js.map +1 -1
  20. package/dist/cli/runtime/api-routes-init.d.ts.map +1 -1
  21. package/dist/cli/runtime/api-routes-init.js +142 -15
  22. package/dist/cli/runtime/api-routes-init.js.map +1 -1
  23. package/dist/db/migrations/operator-task-classification.d.ts +15 -0
  24. package/dist/db/migrations/operator-task-classification.d.ts.map +1 -0
  25. package/dist/db/migrations/operator-task-classification.js +25 -0
  26. package/dist/db/migrations/operator-task-classification.js.map +1 -0
  27. package/dist/evidence/effects.d.ts +2 -1
  28. package/dist/evidence/effects.d.ts.map +1 -1
  29. package/dist/evidence/effects.js.map +1 -1
  30. package/dist/gateways/message-router.d.ts.map +1 -1
  31. package/dist/gateways/message-router.js +2 -1
  32. package/dist/gateways/message-router.js.map +1 -1
  33. package/dist/multi-agent/dashboard-agent-persona.d.ts.map +1 -1
  34. package/dist/multi-agent/dashboard-agent-persona.js +7 -8
  35. package/dist/multi-agent/dashboard-agent-persona.js.map +1 -1
  36. package/dist/multi-agent/wiki-agent-persona.d.ts +7 -6
  37. package/dist/multi-agent/wiki-agent-persona.d.ts.map +1 -1
  38. package/dist/multi-agent/wiki-agent-persona.js +9 -93
  39. package/dist/multi-agent/wiki-agent-persona.js.map +1 -1
  40. package/dist/operator/action-verifier.d.ts +1 -1
  41. package/dist/operator/action-verifier.d.ts.map +1 -1
  42. package/dist/operator/action-verifier.js +4 -0
  43. package/dist/operator/action-verifier.js.map +1 -1
  44. package/dist/operator/board-reconcile.d.ts +1 -1
  45. package/dist/operator/board-reconcile.js +1 -1
  46. package/dist/operator/owner-event-outcome.d.ts +6 -0
  47. package/dist/operator/owner-event-outcome.d.ts.map +1 -1
  48. package/dist/operator/owner-event-outcome.js +6 -3
  49. package/dist/operator/owner-event-outcome.js.map +1 -1
  50. package/dist/operator/owner-event-policy.d.ts.map +1 -1
  51. package/dist/operator/owner-event-policy.js +9 -4
  52. package/dist/operator/owner-event-policy.js.map +1 -1
  53. package/dist/operator/owner-event-prompt.d.ts.map +1 -1
  54. package/dist/operator/owner-event-prompt.js +4 -0
  55. package/dist/operator/owner-event-prompt.js.map +1 -1
  56. package/dist/operator/task-ledger.d.ts +84 -1
  57. package/dist/operator/task-ledger.d.ts.map +1 -1
  58. package/dist/operator/task-ledger.js +281 -8
  59. package/dist/operator/task-ledger.js.map +1 -1
  60. package/dist/operator/task-list-views.d.ts.map +1 -1
  61. package/dist/operator/task-list-views.js +22 -0
  62. package/dist/operator/task-list-views.js.map +1 -1
  63. package/dist/operator/temporal-reconcile.d.ts +1 -0
  64. package/dist/operator/temporal-reconcile.d.ts.map +1 -1
  65. package/dist/operator/temporal-reconcile.js +1 -0
  66. package/dist/operator/temporal-reconcile.js.map +1 -1
  67. package/dist/operator/wiki-continuity.d.ts +149 -0
  68. package/dist/operator/wiki-continuity.d.ts.map +1 -0
  69. package/dist/operator/wiki-continuity.js +214 -0
  70. package/dist/operator/wiki-continuity.js.map +1 -0
  71. package/dist/operator/worker-run.js +1 -1
  72. package/dist/operator/worker-run.js.map +1 -1
  73. package/dist/operator/workorder-consumer.d.ts.map +1 -1
  74. package/dist/operator/workorder-consumer.js +10 -7
  75. package/dist/operator/workorder-consumer.js.map +1 -1
  76. package/dist/operator/workorder-hooks.d.ts +1 -1
  77. package/dist/operator/workorder-publishers.d.ts +26 -4
  78. package/dist/operator/workorder-publishers.d.ts.map +1 -1
  79. package/dist/operator/workorder-publishers.js +122 -1
  80. package/dist/operator/workorder-publishers.js.map +1 -1
  81. package/dist/wiki/obsidian-writer.d.ts.map +1 -1
  82. package/dist/wiki/obsidian-writer.js +14 -2
  83. package/dist/wiki/obsidian-writer.js.map +1 -1
  84. package/dist/wiki/wiki-turn-contract.d.ts +19 -0
  85. package/dist/wiki/wiki-turn-contract.d.ts.map +1 -0
  86. package/dist/wiki/wiki-turn-contract.js +58 -0
  87. package/dist/wiki/wiki-turn-contract.js.map +1 -0
  88. 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,12 @@ class TaskLedger {
377
418
  where.push('updated_at >= ?');
378
419
  params.push(filter.updatedSinceMs);
379
420
  }
421
+ if (filter.qualification === 'qualified') {
422
+ where.push('completion_criteria IS NOT NULL');
423
+ }
424
+ else if (filter.qualification === 'legacy_unqualified') {
425
+ where.push('completion_criteria IS NULL');
426
+ }
380
427
  return { where, params };
381
428
  }
382
429
  /** ORDER BY for both list() and listPage(); the trailing id makes it a TOTAL order,
@@ -873,7 +920,15 @@ class TaskLedger {
873
920
  outcome = 'superseded';
874
921
  }
875
922
  else if (input.decision === 'apply') {
876
- const transitioned = this.transitionTaskInTransaction(candidate.taskId, { status: candidate.proposedStatus, latest_event: candidate.evidenceSummary }, origin, true);
923
+ const terminalEvidence = candidate.proposedStatus === 'done' || candidate.proposedStatus === 'cancelled';
924
+ const transitioned = this.transitionTaskInTransaction(candidate.taskId, {
925
+ status: candidate.proposedStatus,
926
+ latest_event: candidate.evidenceSummary,
927
+ // This candidate is a host-issued current authoritative lifecycle
928
+ // observation. Preserve its semantic completion, or clear an earlier
929
+ // terminal classification when later feedback reopens the same row.
930
+ resolution_kind: terminalEvidence ? 'completed_evidence' : null,
931
+ }, origin, true);
877
932
  taskRevisionAfter = transitioned.revision;
878
933
  outcome = 'applied';
879
934
  }
@@ -1053,6 +1108,9 @@ class TaskLedger {
1053
1108
  }
1054
1109
  const normalizedDeadline = exactDue?.deadline ?? input.deadline ?? null;
1055
1110
  const initialTemporalEpoch = normalizedDeadline !== null ? 1 : 0;
1111
+ // Optional HERE on purpose: the PUBLIC task_create boundary requires it, while
1112
+ // internal/system seeding and legacy callers stay backward compatible.
1113
+ const normalizedCompletionCriteria = normalizeCompletionCriteria(input.completion_criteria, 'task_create');
1056
1114
  const now = this.now();
1057
1115
  if (input.source_channel && input.source_event_id) {
1058
1116
  // kind='owner' probe: an agent-supplied (channel, event) pair can never
@@ -1073,6 +1131,9 @@ class TaskLedger {
1073
1131
  ...(input.due_at !== undefined ? { due_at: input.due_at } : {}),
1074
1132
  ...(input.confirmed !== undefined ? { confirmed: input.confirmed } : {}),
1075
1133
  ...(input.latest_event !== undefined ? { latest_event: input.latest_event } : {}),
1134
+ ...(normalizedCompletionCriteria !== null
1135
+ ? { completion_criteria: normalizedCompletionCriteria }
1136
+ : {}),
1076
1137
  ...(input.expected_revision !== undefined
1077
1138
  ? { expected_revision: input.expected_revision }
1078
1139
  : {}),
@@ -1084,6 +1145,7 @@ class TaskLedger {
1084
1145
  // carried nothing.
1085
1146
  {
1086
1147
  ...origin,
1148
+ taskCreateUpsert: true,
1087
1149
  causeEventIds: origin.causeEventIds?.length
1088
1150
  ? origin.causeEventIds
1089
1151
  : input.source_event_id
@@ -1099,9 +1161,9 @@ class TaskLedger {
1099
1161
  .prepare(`INSERT INTO operator_tasks
1100
1162
  (title, status, priority, assignee, deadline, due_at, deadline_offset_minutes,
1101
1163
  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);
1164
+ auto_created, confirmed, completion_criteria, created_at, updated_at)
1165
+ VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1166
+ .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
1167
  createdId = Number(result.lastInsertRowid);
1106
1168
  this.recordTaskChange({
1107
1169
  kind: 'task_create',
@@ -1142,6 +1204,7 @@ class TaskLedger {
1142
1204
  source_event_id: input.source_event_id ?? null,
1143
1205
  latest_event: input.latest_event ?? null,
1144
1206
  confirmed: input.confirmed ? 1 : 0,
1207
+ completion_criteria: normalizedCompletionCriteria,
1145
1208
  },
1146
1209
  atMs: now,
1147
1210
  });
@@ -1203,6 +1266,148 @@ class TaskLedger {
1203
1266
  throw error;
1204
1267
  }
1205
1268
  }
1269
+ /**
1270
+ * Recorrect an existing owner row with a NAMED disposition.
1271
+ *
1272
+ * Records and tasks are separate. Without a semantic terminal reason the agent
1273
+ * could not distinguish completed work from an item that was never a task, so
1274
+ * connector observations, principles, lessons and open questions accumulated as
1275
+ * owner rows and could only be "closed" as if they had been done.
1276
+ *
1277
+ * Every path requires the exact current revision, a non-empty bounded reason
1278
+ * preserved as `latest_event`, an OWNER row, and the same candidate/workorder
1279
+ * authority checks as task_update (TG-03/TG-04/TG-06) - a terminal or foreign
1280
+ * board attempt cannot mutate an unrelated row. The whole transition, its
1281
+ * `resolution_kind` and its effect receipt commit as ONE transaction.
1282
+ */
1283
+ reclassify(id, input, origin = {}) {
1284
+ const disposition = input?.disposition;
1285
+ if (!exports.TASK_RECLASSIFY_DISPOSITIONS.includes(disposition)) {
1286
+ throw new Error(`task_reclassify: disposition must be one of ${exports.TASK_RECLASSIFY_DISPOSITIONS.join('|')}, got: ${String(disposition)}`);
1287
+ }
1288
+ const reason = typeof input.reason === 'string' ? input.reason.trim() : '';
1289
+ if (reason.length === 0) {
1290
+ throw new Error('task_reclassify: reason must be a non-empty string');
1291
+ }
1292
+ if (reason.length > exports.TASK_RECLASSIFY_REASON_MAX_LENGTH) {
1293
+ throw new Error(`task_reclassify: reason must be at most ${exports.TASK_RECLASSIFY_REASON_MAX_LENGTH} characters`);
1294
+ }
1295
+ if (!Number.isSafeInteger(input.expected_revision) || input.expected_revision < 0) {
1296
+ throw new Error('task_reclassify: expected_revision must be a non-negative integer');
1297
+ }
1298
+ this.db.exec('BEGIN IMMEDIATE');
1299
+ try {
1300
+ const existing = this.db.prepare('SELECT * FROM operator_tasks WHERE id = ?').get(id);
1301
+ if (!existing)
1302
+ throw new Error(`task_reclassify: no task with id ${id}`);
1303
+ if (existing.kind === 'system') {
1304
+ throw new Error(`task_reclassify: task ${id} is a system workorder row (owner rows only)`);
1305
+ }
1306
+ if (input.expected_revision !== existing.revision) {
1307
+ throw new Error(`task_reclassify: expected revision ${input.expected_revision}, current ${existing.revision}`);
1308
+ }
1309
+ this.assertTaskReclassificationAuthorized(existing, origin);
1310
+ const patch = this.buildReclassifyPatch(existing, disposition, reason);
1311
+ // Same primitive as task_update: candidate guard, board-attempt liveness,
1312
+ // revision-checked UPDATE, effect receipt, temporal generation supersession.
1313
+ const record = this.transitionTaskInTransaction(id, patch, origin);
1314
+ this.db.exec('COMMIT');
1315
+ return record;
1316
+ }
1317
+ catch (error) {
1318
+ this.db.exec('ROLLBACK');
1319
+ throw error;
1320
+ }
1321
+ }
1322
+ assertTaskReclassificationAuthorized(existing, origin) {
1323
+ if (origin.workOrderAttemptId !== undefined) {
1324
+ const attempt = this.getWorkOrderById(origin.workOrderAttemptId);
1325
+ if (!attempt || attempt.workKind !== 'board' || attempt.status !== 'in_progress') {
1326
+ throw new Error(`task_reclassify: Board workorder ${String(origin.workOrderAttemptId)} is no longer active`);
1327
+ }
1328
+ const { attempts: _attempts, ...payload } = attempt.payload;
1329
+ (0, workorder_publishers_js_1.validateWorkOrderPayload)('board', payload);
1330
+ const candidates = Array.isArray(payload.reclassificationCandidates)
1331
+ ? payload.reclassificationCandidates
1332
+ : [];
1333
+ const candidate = candidates.find((item) => item.taskId === existing.id);
1334
+ if (!candidate || candidate.taskRevision !== existing.revision) {
1335
+ throw new Error(`task_reclassify: task ${existing.id} is outside this host-issued Board candidate set`);
1336
+ }
1337
+ }
1338
+ if (origin.reclassificationCauseBound) {
1339
+ const eventIds = (origin.causeEventIds ?? []).filter(effects_js_1.isUsableCause);
1340
+ if (eventIds.length === 0) {
1341
+ throw new Error('task_reclassify: owner-event reclassification requires causal events');
1342
+ }
1343
+ if (existing.source_event_id && eventIds.includes(existing.source_event_id))
1344
+ return;
1345
+ const placeholders = eventIds.map(() => '?').join(',');
1346
+ const channels = this.db
1347
+ .prepare(`SELECT DISTINCT inbox.channel_key
1348
+ FROM owner_event_inbox AS inbox, json_each(inbox.event_ids_json) AS event
1349
+ WHERE event.value IN (${placeholders})`)
1350
+ .all(...eventIds);
1351
+ if (existing.source_channel === null ||
1352
+ !channels.some((row) => row.channel_key === existing.source_channel)) {
1353
+ throw new Error(`task_reclassify: task ${existing.id} is outside this owner-event source channel`);
1354
+ }
1355
+ }
1356
+ }
1357
+ buildReclassifyPatch(existing, disposition, reason) {
1358
+ const base = {
1359
+ latest_event: reason,
1360
+ expected_revision: existing.revision,
1361
+ };
1362
+ if (disposition === 'reopen') {
1363
+ if (existing.status !== 'done' && existing.status !== 'cancelled') {
1364
+ throw new Error(`task_reclassify: reopen requires a terminal owner row, task is '${existing.status}'`);
1365
+ }
1366
+ // A reopened row must not inherit a deadline that already passed: it would
1367
+ // read as overdue the moment it reopens. A still-future deadline is kept.
1368
+ const stale = this.isDeadlineInThePast(existing);
1369
+ return {
1370
+ ...base,
1371
+ status: 'pending',
1372
+ resolution_kind: null,
1373
+ ...(stale ? { deadline: null, due_at: null } : {}),
1374
+ };
1375
+ }
1376
+ if (existing.status === 'done' || existing.status === 'cancelled') {
1377
+ throw new Error(`task_reclassify: ${disposition} requires an active owner row, task is '${existing.status}'`);
1378
+ }
1379
+ if (disposition === 'completed_evidence') {
1380
+ return { ...base, status: 'done', resolution_kind: 'completed_evidence' };
1381
+ }
1382
+ if (disposition === 'completed_no_issue') {
1383
+ // Owner policy: a PAST deadline plus a complete relevant-source check with no
1384
+ // issue is enough. Without a passed deadline there is nothing to conclude from.
1385
+ if (!this.isDeadlineInThePast(existing)) {
1386
+ throw new Error('task_reclassify: completed_no_issue requires a deadline or due_at already in the past');
1387
+ }
1388
+ return { ...base, status: 'done', resolution_kind: 'completed_no_issue' };
1389
+ }
1390
+ // non_task_record / non_task_memory: this was never a task. Cancelled, not done,
1391
+ // so accounting never reads it as finished work.
1392
+ return {
1393
+ ...base,
1394
+ status: 'cancelled',
1395
+ completion_criteria: null,
1396
+ resolution_kind: disposition,
1397
+ };
1398
+ }
1399
+ /** True when the row carries a deadline/due_at that has already passed. */
1400
+ isDeadlineInThePast(existing) {
1401
+ const state = (0, task_temporal_js_1.deriveTemporalState)({
1402
+ // Terminal rows derive 'closed', which would hide a genuinely past deadline;
1403
+ // ask the temporal question about the CLOCK fields only.
1404
+ status: 'pending',
1405
+ dueAt: existing.due_at,
1406
+ deadlineIso: existing.deadline,
1407
+ deadlineOffsetMinutes: existing.deadline_offset_minutes,
1408
+ }, this.now(), this.timeZone);
1409
+ return state === 'exact_overdue' || state === 'date_overdue';
1410
+ }
1206
1411
  /**
1207
1412
  * The one mutation primitive for owner-task state. Callers own the outer
1208
1413
  * transaction so lifecycle receipts, effects, generation ownership, and the
@@ -1224,7 +1429,8 @@ class TaskLedger {
1224
1429
  : null;
1225
1430
  const hasLifecyclePatch = Object.prototype.hasOwnProperty.call(patch, 'status') ||
1226
1431
  Object.prototype.hasOwnProperty.call(patch, 'due_at') ||
1227
- Object.prototype.hasOwnProperty.call(patch, 'latest_event');
1432
+ Object.prototype.hasOwnProperty.call(patch, 'latest_event') ||
1433
+ Object.prototype.hasOwnProperty.call(patch, 'completion_criteria');
1228
1434
  const isBoardWorkorderLifecycleJudgment = revisionAttempt?.workKind === 'board' && hasLifecyclePatch;
1229
1435
  if (isBoardWorkorderLifecycleJudgment && revisionAttempt.status !== 'in_progress') {
1230
1436
  throw new Error(`task_update: board workorder ${revisionAttempt.id} is no longer active (${revisionAttempt.status})`);
@@ -1276,6 +1482,8 @@ class TaskLedger {
1276
1482
  last_temporal_attempt_id: existing.last_temporal_attempt_id,
1277
1483
  review_started_at: existing.review_started_at,
1278
1484
  review_anchor_event_id: existing.review_anchor_event_id,
1485
+ completion_criteria: existing.completion_criteria,
1486
+ resolution_kind: existing.resolution_kind,
1279
1487
  };
1280
1488
  if (patch.title !== undefined)
1281
1489
  next.title = patch.title.trim();
@@ -1289,6 +1497,12 @@ class TaskLedger {
1289
1497
  next.latest_event = patch.latest_event;
1290
1498
  if (patch.confirmed !== undefined)
1291
1499
  next.confirmed = patch.confirmed ? 1 : 0;
1500
+ if (patch.completion_criteria !== undefined) {
1501
+ next.completion_criteria = normalizeCompletionCriteria(patch.completion_criteria, 'task_update');
1502
+ }
1503
+ // Host-internal (reclassify only); the public task_update surface rejects it.
1504
+ if (patch.resolution_kind !== undefined)
1505
+ next.resolution_kind = patch.resolution_kind;
1292
1506
  const leavingVerifiedReview = existing.status === 'review' &&
1293
1507
  patch.status !== undefined &&
1294
1508
  patch.status !== 'review' &&
@@ -1353,6 +1567,16 @@ class TaskLedger {
1353
1567
  const terminalToOpen = (existing.status === 'done' || existing.status === 'cancelled') &&
1354
1568
  next.status !== 'done' &&
1355
1569
  next.status !== 'cancelled';
1570
+ if (terminalToOpen && existing.resolution_kind !== null && patch.resolution_kind !== null) {
1571
+ throw new Error('task_update: a classified terminal row must reopen through task_reclassify');
1572
+ }
1573
+ if (patch.status !== undefined &&
1574
+ next.status !== existing.status &&
1575
+ patch.resolution_kind === undefined) {
1576
+ // A generic status change has no semantic reclassification authority. Clear an
1577
+ // old terminal meaning rather than rendering contradictory status/reason pairs.
1578
+ next.resolution_kind = null;
1579
+ }
1356
1580
  const openToTerminal = existing.status !== 'done' &&
1357
1581
  existing.status !== 'cancelled' &&
1358
1582
  (next.status === 'done' || next.status === 'cancelled');
@@ -1380,6 +1604,8 @@ class TaskLedger {
1380
1604
  'last_temporal_attempt_id',
1381
1605
  'review_started_at',
1382
1606
  'review_anchor_event_id',
1607
+ 'completion_criteria',
1608
+ 'resolution_kind',
1383
1609
  ];
1384
1610
  const changedColumns = persistedColumns.filter((column) => next[column] !== existing[column]);
1385
1611
  if (changedColumns.length === 0) {
@@ -1424,7 +1650,8 @@ class TaskLedger {
1424
1650
  assertCandidateTaskMutationAllowed(taskId, patch, origin) {
1425
1651
  if (origin.workOrderAttemptId === undefined ||
1426
1652
  (!Object.prototype.hasOwnProperty.call(patch, 'status') &&
1427
- !Object.prototype.hasOwnProperty.call(patch, 'latest_event'))) {
1653
+ !Object.prototype.hasOwnProperty.call(patch, 'latest_event') &&
1654
+ !Object.prototype.hasOwnProperty.call(patch, 'completion_criteria'))) {
1428
1655
  return;
1429
1656
  }
1430
1657
  const attempt = this.getWorkOrderById(origin.workOrderAttemptId);
@@ -1439,6 +1666,18 @@ class TaskLedger {
1439
1666
  }
1440
1667
  const { attempts: _attempts, ...payload } = attempt.payload;
1441
1668
  (0, workorder_publishers_js_1.validateWorkOrderPayload)('board', payload);
1669
+ if (Object.prototype.hasOwnProperty.call(patch, 'completion_criteria') &&
1670
+ !origin.taskCreateUpsert) {
1671
+ const reclassificationCandidates = Array.isArray(payload.reclassificationCandidates)
1672
+ ? payload.reclassificationCandidates
1673
+ : [];
1674
+ const qualificationCandidate = reclassificationCandidates.find((candidate) => candidate.taskId === taskId);
1675
+ if (!qualificationCandidate ||
1676
+ (patch.expected_revision !== undefined &&
1677
+ qualificationCandidate.taskRevision !== patch.expected_revision)) {
1678
+ throw new Error(`task_update: task ${taskId} is outside this host-issued qualification candidate set`);
1679
+ }
1680
+ }
1442
1681
  if (payload.mode !== 'reconcile' || !payload.candidates) {
1443
1682
  return;
1444
1683
  }
@@ -1546,6 +1785,40 @@ class TaskLedger {
1546
1785
  completedAt: row.updated_at,
1547
1786
  };
1548
1787
  }
1788
+ /**
1789
+ * The newest COMPLETED wiki run - the baseline the wiki daily-continuity
1790
+ * planner gates against (wiki-continuity.ts). Only `status='done'` rows count.
1791
+ *
1792
+ * `ownerDate`/`sourceWatermark` are read straight from the completed run's
1793
+ * payload and are null for a LEGACY row (one enqueued before the continuity
1794
+ * fields existed): the planner treats a null-field baseline as "no baseline"
1795
+ * and re-runs from the owner day's start rather than fabricating a boundary.
1796
+ */
1797
+ lastCompletedWikiRun() {
1798
+ const row = this.db
1799
+ .prepare(`SELECT json_extract(payload, '$.ownerDate') AS owner_date,
1800
+ json_extract(payload, '$.sourceWatermark') AS source_watermark,
1801
+ json_extract(payload, '$.range.end_ms') AS covered_through_ms,
1802
+ updated_at
1803
+ FROM operator_tasks
1804
+ WHERE kind = 'system' AND source_channel = ? AND status = 'done'
1805
+ ORDER BY id DESC LIMIT 1`)
1806
+ .get(`${exports.WORKORDER_CHANNEL_PREFIX}wiki`);
1807
+ if (!row)
1808
+ return null;
1809
+ return {
1810
+ ownerDate: typeof row.owner_date === 'string' && row.owner_date.length > 0 ? row.owner_date : null,
1811
+ sourceWatermark: typeof row.source_watermark === 'string' && row.source_watermark.length > 0
1812
+ ? row.source_watermark
1813
+ : null,
1814
+ // The prior run's range.end_ms - where the next same-day run resumes.
1815
+ // Null for a legacy row with no range; never fabricated.
1816
+ coveredThroughMs: typeof row.covered_through_ms === 'number' && Number.isFinite(row.covered_through_ms)
1817
+ ? row.covered_through_ms
1818
+ : null,
1819
+ completedAt: row.updated_at,
1820
+ };
1821
+ }
1549
1822
  /**
1550
1823
  * Board-input term for the NATIVE owner task ledger - the pipeline slot's
1551
1824
  * projection source. Reuses payloadHash(): it already digests exactly the