@wrongstack/core 0.302.2 → 0.303.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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/dist/chronicle/index.js +117 -30
  3. package/dist/chronicle/project-server.js +12 -5
  4. package/dist/coordination/agents/index.js +4313 -3516
  5. package/dist/coordination/agents/project-agent-auto-optimize.d.ts +116 -0
  6. package/dist/coordination/agents/project-agent-capture-window.d.ts +29 -0
  7. package/dist/coordination/agents/project-agent-config-io.d.ts +11 -0
  8. package/dist/coordination/agents/project-agent-consolidation.d.ts +29 -2
  9. package/dist/coordination/agents/project-agent-files.d.ts +12 -3
  10. package/dist/coordination/agents/project-agent-identity-types.d.ts +4 -0
  11. package/dist/coordination/agents/project-agent-identity.d.ts +22 -9
  12. package/dist/coordination/agents/project-agent-learning-entries.d.ts +8 -2
  13. package/dist/coordination/agents/project-agent-learning-structured.d.ts +27 -1
  14. package/dist/coordination/agents/project-agent-optimizer.d.ts +49 -0
  15. package/dist/coordination/agents/project-agent-skill-layer.d.ts +101 -0
  16. package/dist/coordination/agents/role-skills.d.ts +11 -1
  17. package/dist/coordination/index.d.ts +1 -1
  18. package/dist/coordination/index.js +2322 -1526
  19. package/dist/coordination/mail-tools.d.ts +1 -1
  20. package/dist/core/context.d.ts +4 -0
  21. package/dist/core/index.js +9 -0
  22. package/dist/defaults/index.js +895 -597
  23. package/dist/execution/index.js +2915 -2621
  24. package/dist/goal/index.js +7 -0
  25. package/dist/index.js +2406 -1331
  26. package/dist/kernel/events/agent-events.d.ts +28 -0
  27. package/dist/plugin/index.js +10 -4
  28. package/dist/security/index.js +69 -3
  29. package/dist/security/kanban-boundary.d.ts +5 -1
  30. package/dist/session-catalog/index.js +24 -2
  31. package/dist/session-catalog/project-server.js +27 -4
  32. package/dist/session-catalog/protocol.d.ts +9 -0
  33. package/dist/session-catalog/store.d.ts +17 -1
  34. package/dist/storage/index.js +81 -2
  35. package/dist/storage/plan-store.d.ts +1 -1
  36. package/dist/tasking/index.js +5 -0
  37. package/dist/tools/index.js +2824 -2604
  38. package/dist/types/config/root.d.ts +11 -1
  39. package/dist/types/config/skills-fleet-brain.d.ts +34 -0
  40. package/dist/types/config/ui.d.ts +14 -0
  41. package/dist/types/config.d.ts +1 -0
  42. package/dist/types/index.d.ts +2 -2
  43. package/dist/types/index.js +20 -0
  44. package/dist/types/multi-agent.d.ts +7 -0
  45. package/dist/types/task-graph.d.ts +2 -0
  46. package/dist/types/tool-executor.d.ts +2 -0
  47. package/dist/utils/index.js +3 -0
  48. package/instructions/system-lite.md +14 -8
  49. package/instructions/system-pro.md +17 -11
  50. package/instructions/system.md +17 -11
  51. package/package.json +3 -3
  52. package/skills/wrongstack-kanban/SKILL.md +39 -8
@@ -139,6 +139,34 @@ export interface AgentEventMap {
139
139
  */
140
140
  transcriptPath?: string | undefined;
141
141
  };
142
+ /**
143
+ * A spawn resolved fewer skills than it selected. Emitted so a skill that
144
+ * silently failed to load — missing from the loader, gated by a capability
145
+ * the subagent lacks, or cut by the prompt budget — is observable instead of
146
+ * leaving the agent believing it received guidance it never got.
147
+ */
148
+ 'subagent.skills.dropped': {
149
+ sessionId?: string | undefined;
150
+ role?: string | undefined;
151
+ /** Skills whose body (and project addendum) reached the prompt. */
152
+ selected: string[];
153
+ /** skill → reason it was dropped. */
154
+ dropped: Record<string, string>;
155
+ };
156
+ /**
157
+ * A background learning-distillation pass finished for a roster role.
158
+ * Emitted by the fleet host's auto-optimize scheduler so surfaces can show
159
+ * that an agent's skills were refined without a user action.
160
+ */
161
+ 'agent.learning.optimized': {
162
+ sessionId?: string | undefined;
163
+ role: string;
164
+ trigger: string;
165
+ status: string;
166
+ /** Skill addenda refreshed by the pass. */
167
+ skills: string[];
168
+ error?: string | undefined;
169
+ };
142
170
  'subagent.task_started': {
143
171
  /** Parent/host session id. */
144
172
  sessionId?: string | undefined;
@@ -6371,7 +6371,9 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
6371
6371
  const agentRange = dayFilter("day");
6372
6372
  const uniqueAgents = this.db.prepare(`SELECT COUNT(DISTINCT agent_id) n FROM agent_daily${agentRange.where}`).get(...agentRange.params).n;
6373
6373
  const requestRange = dayFilter("day");
6374
- const logicalRequests = this.db.prepare(`SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`).get(...requestRange.params).n;
6374
+ const logicalRequests = this.db.prepare(
6375
+ `SELECT COUNT(DISTINCT logical_request_id) n FROM logical_request_daily${requestRange.where}`
6376
+ ).get(...requestRange.params).n;
6375
6377
  const fileRange = dayFilter("day");
6376
6378
  const uniqueFiles = this.db.prepare(`SELECT COUNT(DISTINCT path_key) n FROM file_seen_daily${fileRange.where}`).get(...fileRange.params).n;
6377
6379
  const costRange = dayFilter("day");
@@ -6680,10 +6682,13 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
6680
6682
  ON CONFLICT(day, family) DO UPDATE SET count = count + 1, failure_count = failure_count + excluded.failure_count`
6681
6683
  ).run(day, family, failed);
6682
6684
  if (failed) bump("failures = failures + 1");
6683
- if (event.outcome === "cancelled" || event.outcome === "abandoned") bump("cancellations = cancellations + 1");
6685
+ if (event.outcome === "cancelled" || event.outcome === "abandoned")
6686
+ bump("cancellations = cancellations + 1");
6684
6687
  if (family === "agent") bump("agent_events = agent_events + 1");
6685
6688
  if (event.correlation.logicalRequestId) {
6686
- this.db.prepare("INSERT OR IGNORE INTO logical_request_daily (day, logical_request_id) VALUES (?, ?)").run(day, event.correlation.logicalRequestId);
6689
+ this.db.prepare(
6690
+ "INSERT OR IGNORE INTO logical_request_daily (day, logical_request_id) VALUES (?, ?)"
6691
+ ).run(day, event.correlation.logicalRequestId);
6687
6692
  }
6688
6693
  if (event.scope.agentId) {
6689
6694
  this.db.prepare("INSERT OR IGNORE INTO agent_daily (day, agent_id) VALUES (?, ?)").run(day, event.scope.agentId);
@@ -6703,7 +6708,8 @@ var ChronicleMetricsStore = class _ChronicleMetricsStore {
6703
6708
  durationCount
6704
6709
  );
6705
6710
  } else if (type === "process.started") bump("processes = processes + 1");
6706
- else if (type === "process.completed" && event.outcome === "failure") bump("failed_processes = failed_processes + 1");
6711
+ else if (type === "process.completed" && event.outcome === "failure")
6712
+ bump("failed_processes = failed_processes + 1");
6707
6713
  if (event.resource?.kind === "file" || type.startsWith("file.")) {
6708
6714
  bump("file_events_all = file_events_all + 1");
6709
6715
  if (event.resource?.path) {
@@ -1298,11 +1298,13 @@ function windowsAccountName() {
1298
1298
  import { realpath } from "node:fs/promises";
1299
1299
  import * as path2 from "node:path";
1300
1300
  import {
1301
+ evaluateContractGraphReadiness,
1301
1302
  evaluateKanbanBoundaryOpaque,
1302
1303
  evaluateKanbanBoundaryPath,
1303
1304
  readBoard,
1304
1305
  resolveKanbanBoundaryLayers
1305
1306
  } from "@wrongstack/kanban";
1307
+ var GOVERNANCE_CONTROL_TOOLS = /* @__PURE__ */ new Set(["kanban", "plan", "task", "todo"]);
1306
1308
  var PATH_KEYS2 = /* @__PURE__ */ new Set([
1307
1309
  "path",
1308
1310
  "paths",
@@ -1327,12 +1329,60 @@ var TOOL_PATH_KEYS = {
1327
1329
  // filesystem target in language_info. Keep ambiguous names tool-specific.
1328
1330
  language_info: ["target"]
1329
1331
  };
1330
- async function evaluateToolKanbanBoundary(tool, input, ctx) {
1332
+ async function evaluateToolKanbanBoundary(tool, input, ctx, options = {}) {
1333
+ if (tool.name === "kanban") return { decision: "allow" };
1331
1334
  const identity = resolveKanbanIdentity(ctx);
1332
- if (!identity.boardId) return { decision: "allow" };
1335
+ const governanceRequired = options.requireGovernance && isGovernedMutation(tool, input);
1336
+ if (!identity.boardId) {
1337
+ return governanceRequired ? {
1338
+ decision: "block",
1339
+ reason: "Kanban governance is mandatory before product mutation. Create a managed card, add its required details and executable acceptance criteria, then call kanban start_task to bind it to this run."
1340
+ } : { decision: "allow" };
1341
+ }
1333
1342
  const board = await readBoard(identity.policyRoot ?? ctx.projectRoot, identity.boardId);
1334
- if (!board) return { decision: "allow" };
1343
+ if (!board) {
1344
+ return governanceRequired ? {
1345
+ decision: "block",
1346
+ reason: `Active Kanban board not found: ${identity.boardId}. Recreate or select a valid managed card before mutation.`,
1347
+ boardId: identity.boardId,
1348
+ ...identity.taskId ? { taskId: identity.taskId } : {}
1349
+ } : { decision: "allow" };
1350
+ }
1335
1351
  const task = identity.taskId ? board.tasks.find((candidate) => candidate.id === identity.taskId) : void 0;
1352
+ if (governanceRequired) {
1353
+ if (!identity.taskId) {
1354
+ return {
1355
+ decision: "block",
1356
+ reason: "Kanban governance requires an active task, not only a board. Complete the card details and call kanban start_task before mutation.",
1357
+ boardId: board.id
1358
+ };
1359
+ }
1360
+ if (!task) {
1361
+ return {
1362
+ decision: "block",
1363
+ reason: `Active Kanban task not found: ${identity.taskId}. Call kanban start_task with a valid card.`,
1364
+ boardId: board.id,
1365
+ taskId: identity.taskId
1366
+ };
1367
+ }
1368
+ const readiness = evaluateContractGraphReadiness(board, task.id);
1369
+ if (!readiness.ready) {
1370
+ return {
1371
+ decision: "block",
1372
+ reason: "Active card is not implementation-ready: " + readiness.issues.map((issue) => issue.message).join(" | "),
1373
+ boardId: board.id,
1374
+ taskId: task.id
1375
+ };
1376
+ }
1377
+ if (task.lifecycle?.currentStage !== "running" || task.assignment?.status !== "running") {
1378
+ return {
1379
+ decision: "block",
1380
+ reason: "Active card must be in Running with a live assignment before product mutation. Call kanban start_task after completing the required card details.",
1381
+ boardId: board.id,
1382
+ taskId: task.id
1383
+ };
1384
+ }
1385
+ }
1336
1386
  if (identity.leaseId && task?.assignment) {
1337
1387
  const caps2 = tool.capabilities ?? [];
1338
1388
  const isWrite = caps2.includes("fs.write") || caps2.includes("fs.write.outside-project") || caps2.some((c) => c.startsWith("shell."));
@@ -1368,6 +1418,19 @@ async function evaluateToolKanbanBoundary(tool, input, ctx) {
1368
1418
  ...task ? { taskId: task.id } : {}
1369
1419
  };
1370
1420
  }
1421
+ function isGovernedMutation(tool, input) {
1422
+ if (!tool.mutating || GOVERNANCE_CONTROL_TOOLS.has(tool.name)) return false;
1423
+ if (tool.capabilities?.includes("tool.meta")) return false;
1424
+ if (tool.name === "git") {
1425
+ const command = input["command"];
1426
+ if (command === "status" || command === "log" || command === "diff") return false;
1427
+ if (command === "worktree" && input["worktreeAction"] === "list") return false;
1428
+ }
1429
+ const capabilities = tool.capabilities ?? [];
1430
+ return capabilities.some(
1431
+ (capability) => capability === "fs.write" || capability === "fs.write.outside-project" || capability === "package.install" || capability === "tool.mutate.any" || capability.startsWith("shell.") || capability === "net.outbound"
1432
+ );
1433
+ }
1371
1434
  function resolveKanbanIdentity(ctx) {
1372
1435
  const metaKanban = ctx.meta["kanban"];
1373
1436
  const record = metaKanban && typeof metaKanban === "object" ? metaKanban : void 0;
@@ -1685,6 +1748,9 @@ function subjectForToolInput(toolName, input, subjectKey) {
1685
1748
  const obj = input;
1686
1749
  if (subjectKey) {
1687
1750
  const value = obj[subjectKey];
1751
+ if (Array.isArray(value)) {
1752
+ return escapeGlobSubject(JSON.stringify(value));
1753
+ }
1688
1754
  if (typeof value === "string") {
1689
1755
  if (isPathSubjectKey(subjectKey)) {
1690
1756
  const normalized = normalizePathSubject(value);
@@ -5,6 +5,10 @@ export interface ToolKanbanBoundaryEvaluation extends KanbanBoundaryEvaluation {
5
5
  boardId?: string | undefined;
6
6
  taskId?: string | undefined;
7
7
  }
8
+ export interface ToolKanbanGovernanceOptions {
9
+ /** Require every product mutation to run inside a ready, running Kanban card. */
10
+ requireGovernance?: boolean | undefined;
11
+ }
8
12
  /** Resolve the live board/task policy and gate one tool invocation. */
9
- export declare function evaluateToolKanbanBoundary(tool: Tool, input: Record<string, unknown>, ctx: Context): Promise<ToolKanbanBoundaryEvaluation>;
13
+ export declare function evaluateToolKanbanBoundary(tool: Tool, input: Record<string, unknown>, ctx: Context, options?: ToolKanbanGovernanceOptions): Promise<ToolKanbanBoundaryEvaluation>;
10
14
  //# sourceMappingURL=kanban-boundary.d.ts.map
@@ -1392,6 +1392,25 @@ var SessionCatalogStore = class {
1392
1392
  leaseRow(sessionId) {
1393
1393
  return this.db.prepare("SELECT * FROM session_leases WHERE session_id=?").get(sessionId);
1394
1394
  }
1395
+ /**
1396
+ * True when `sessionId` has a live `session_leases` row owned by a
1397
+ * different OS process than the *caller*. Used by
1398
+ * `acquireMaintenance` to distinguish "another running wstack" (which
1399
+ * must be fenced off) from "this very TUI owning its own session"
1400
+ * (which is allowed to claim a non-destructive maintenance lease on
1401
+ * its own transcript).
1402
+ *
1403
+ * `callerPid` must be the pid of the process that asked for the lease,
1404
+ * NOT `process.pid`. In the built runtime this store lives inside the
1405
+ * detached project-catalog daemon, so `process.pid` is the daemon's and
1406
+ * would mark every lease — including the caller's own session — foreign,
1407
+ * making `/clear` and `/rewind` fail with "Session … is live". Callers
1408
+ * that run the store in-process may omit it.
1409
+ */
1410
+ foreignLiveLease(sessionId, callerPid = process.pid) {
1411
+ const live = this.leaseRow(sessionId);
1412
+ return Boolean(live) && live.owner_pid !== callerPid;
1413
+ }
1395
1414
  verifyCredential(credential) {
1396
1415
  assertId(credential.sessionId);
1397
1416
  const row = this.leaseRow(credential.sessionId);
@@ -1725,11 +1744,14 @@ var SessionCatalogStore = class {
1725
1744
  throw error;
1726
1745
  }
1727
1746
  }
1728
- acquireMaintenance(sessionId, operation, holderId, leaseMs) {
1747
+ acquireMaintenance(sessionId, operation, holderId, leaseMs, holderPid) {
1729
1748
  assertId(sessionId);
1730
1749
  return this.transaction(() => {
1731
1750
  this.reapExpired();
1732
- if (this.leaseRow(sessionId)) throw conflict(`Session ${sessionId} is live`);
1751
+ const live = this.leaseRow(sessionId);
1752
+ if (live && (operation === "delete" || this.foreignLiveLease(sessionId, holderPid))) {
1753
+ throw conflict(`Session ${sessionId} is live`);
1754
+ }
1733
1755
  const reservation = this.db.prepare(
1734
1756
  "SELECT 1 AS yes FROM resume_reservations WHERE target_session_id=? AND expires_at>?"
1735
1757
  ).get(sessionId, Date.now());
@@ -761,6 +761,25 @@ var SessionCatalogStore = class {
761
761
  leaseRow(sessionId) {
762
762
  return this.db.prepare("SELECT * FROM session_leases WHERE session_id=?").get(sessionId);
763
763
  }
764
+ /**
765
+ * True when `sessionId` has a live `session_leases` row owned by a
766
+ * different OS process than the *caller*. Used by
767
+ * `acquireMaintenance` to distinguish "another running wstack" (which
768
+ * must be fenced off) from "this very TUI owning its own session"
769
+ * (which is allowed to claim a non-destructive maintenance lease on
770
+ * its own transcript).
771
+ *
772
+ * `callerPid` must be the pid of the process that asked for the lease,
773
+ * NOT `process.pid`. In the built runtime this store lives inside the
774
+ * detached project-catalog daemon, so `process.pid` is the daemon's and
775
+ * would mark every lease — including the caller's own session — foreign,
776
+ * making `/clear` and `/rewind` fail with "Session … is live". Callers
777
+ * that run the store in-process may omit it.
778
+ */
779
+ foreignLiveLease(sessionId, callerPid = process.pid) {
780
+ const live = this.leaseRow(sessionId);
781
+ return Boolean(live) && live.owner_pid !== callerPid;
782
+ }
764
783
  verifyCredential(credential) {
765
784
  assertId(credential.sessionId);
766
785
  const row = this.leaseRow(credential.sessionId);
@@ -1094,11 +1113,14 @@ var SessionCatalogStore = class {
1094
1113
  throw error;
1095
1114
  }
1096
1115
  }
1097
- acquireMaintenance(sessionId, operation, holderId, leaseMs) {
1116
+ acquireMaintenance(sessionId, operation, holderId, leaseMs, holderPid) {
1098
1117
  assertId(sessionId);
1099
1118
  return this.transaction(() => {
1100
1119
  this.reapExpired();
1101
- if (this.leaseRow(sessionId)) throw conflict(`Session ${sessionId} is live`);
1120
+ const live = this.leaseRow(sessionId);
1121
+ if (live && (operation === "delete" || this.foreignLiveLease(sessionId, holderPid))) {
1122
+ throw conflict(`Session ${sessionId} is live`);
1123
+ }
1102
1124
  const reservation = this.db.prepare(
1103
1125
  "SELECT 1 AS yes FROM resume_reservations WHERE target_session_id=? AND expires_at>?"
1104
1126
  ).get(sessionId, Date.now());
@@ -1404,7 +1426,7 @@ var OPERATION_KEYS = {
1404
1426
  resolve_id: ["query"],
1405
1427
  get_summary: ["sessionId"],
1406
1428
  rename: ["sessionId", "name"],
1407
- acquire_maintenance: ["sessionId", "operation", "holderId", "leaseMs"],
1429
+ acquire_maintenance: ["sessionId", "operation", "holderId", "leaseMs", "holderPid"],
1408
1430
  release_maintenance: ["lease"],
1409
1431
  delete: ["sessionId", "lease"],
1410
1432
  prune: ["maxAgeDays", "holderId"],
@@ -1584,7 +1606,8 @@ async function dispatch(op, args) {
1584
1606
  value.sessionId,
1585
1607
  value.operation,
1586
1608
  value.holderId,
1587
- value.leaseMs
1609
+ value.leaseMs,
1610
+ value.holderPid
1588
1611
  );
1589
1612
  }
1590
1613
  case "release_maintenance": {
@@ -76,6 +76,12 @@ export interface SessionCatalogEvent {
76
76
  generation: number;
77
77
  at: string;
78
78
  }
79
+ /**
80
+ * NOTE: adding a field to any `args` below also requires adding it to
81
+ * `OPERATION_KEYS` in `project-server.ts` — the daemon validates every
82
+ * request against that allowlist and rejects unknown fields at runtime,
83
+ * which TypeScript cannot catch.
84
+ */
79
85
  export interface SessionCatalogOperations {
80
86
  ping: {
81
87
  args: Record<string, never>;
@@ -210,6 +216,9 @@ export interface SessionCatalogOperations {
210
216
  operation: MaintenanceLease['operation'];
211
217
  holderId: string;
212
218
  leaseMs?: number;
219
+ /** Caller's OS pid. The server runs in the detached catalog daemon, so
220
+ * it cannot use its own `process.pid` to recognise the session owner. */
221
+ holderPid?: number;
213
222
  };
214
223
  result: MaintenanceLease;
215
224
  };
@@ -17,6 +17,22 @@ export declare class SessionCatalogStore {
17
17
  private reapExpired;
18
18
  private maintenanceExists;
19
19
  private leaseRow;
20
+ /**
21
+ * True when `sessionId` has a live `session_leases` row owned by a
22
+ * different OS process than the *caller*. Used by
23
+ * `acquireMaintenance` to distinguish "another running wstack" (which
24
+ * must be fenced off) from "this very TUI owning its own session"
25
+ * (which is allowed to claim a non-destructive maintenance lease on
26
+ * its own transcript).
27
+ *
28
+ * `callerPid` must be the pid of the process that asked for the lease,
29
+ * NOT `process.pid`. In the built runtime this store lives inside the
30
+ * detached project-catalog daemon, so `process.pid` is the daemon's and
31
+ * would mark every lease — including the caller's own session — foreign,
32
+ * making `/clear` and `/rewind` fail with "Session … is live". Callers
33
+ * that run the store in-process may omit it.
34
+ */
35
+ private foreignLiveLease;
20
36
  private verifyCredential;
21
37
  private createLease;
22
38
  claimNew(entry: SessionRegistryEntry, ownerInstanceId: string, leaseMs?: number): SessionLeaseCredential;
@@ -40,7 +56,7 @@ export declare class SessionCatalogStore {
40
56
  getSummary(sessionId: string): CatalogSessionRecord | null;
41
57
  resolveId(query: string): string;
42
58
  rename(sessionId: string, name: string): Promise<CatalogSessionRecord>;
43
- acquireMaintenance(sessionId: string, operation: MaintenanceLease['operation'], holderId: string, leaseMs?: number): MaintenanceLease;
59
+ acquireMaintenance(sessionId: string, operation: MaintenanceLease['operation'], holderId: string, leaseMs?: number, holderPid?: number): MaintenanceLease;
44
60
  releaseMaintenance(lease: MaintenanceLease): void;
45
61
  delete(sessionId: string, lease: MaintenanceLease): void;
46
62
  prune(maxAgeDays: number, holderId: string): number;
@@ -8039,8 +8039,31 @@ function simpleHash(s) {
8039
8039
  }
8040
8040
 
8041
8041
  // src/storage/plan-store.ts
8042
- import * as fsp5 from "node:fs/promises";
8043
8042
  import { randomUUID as randomUUID5 } from "node:crypto";
8043
+ import * as fsp5 from "node:fs/promises";
8044
+ function assertPlanMutationInvariants(previous, updated) {
8045
+ const ids = updated.items.map((item) => item.id);
8046
+ const uniqueIds = new Set(ids);
8047
+ if (uniqueIds.size !== ids.length) {
8048
+ throw new SessionError({
8049
+ message: "Plan mutation rejected: plan item IDs must be unique.",
8050
+ code: "SESSION_WRITE_FAILED",
8051
+ sessionId: updated.sessionId,
8052
+ context: { operation: "mutatePlan", invariant: "unique_plan_item_ids" }
8053
+ });
8054
+ }
8055
+ const omittedUnfinished = previous.items.filter(
8056
+ (item) => item.status !== "done" && !uniqueIds.has(item.id)
8057
+ );
8058
+ if (omittedUnfinished.length > 0) {
8059
+ throw new SessionError({
8060
+ message: `Plan mutation rejected: unfinished items cannot be omitted: ${omittedUnfinished.map((item) => item.id).join(", ")}. Complete them first.`,
8061
+ code: "SESSION_WRITE_FAILED",
8062
+ sessionId: updated.sessionId,
8063
+ context: { operation: "mutatePlan", invariant: "unfinished_plan_coverage" }
8064
+ });
8065
+ }
8066
+ }
8044
8067
  async function loadPlan(filePath, events) {
8045
8068
  const t0 = Date.now();
8046
8069
  let raw;
@@ -8222,7 +8245,9 @@ function deriveTodosFromPlanItem(plan, idOrIndex, subtasks) {
8222
8245
  async function mutatePlan(filePath, sessionId, fn) {
8223
8246
  return withFileLock(filePath, async () => {
8224
8247
  const plan = await loadPlan(filePath) ?? emptyPlan(sessionId);
8248
+ const previous = structuredClone(plan);
8225
8249
  const updated = await fn(plan);
8250
+ assertPlanMutationInvariants(previous, updated);
8226
8251
  const persisted = await savePlan(filePath, updated);
8227
8252
  if (!persisted) {
8228
8253
  throw new SessionError({
@@ -14441,7 +14466,11 @@ var DefaultSessionStore = class _DefaultSessionStore {
14441
14466
  const maintenance = this.catalogClient ? await this.catalogClient.call("acquire_maintenance", {
14442
14467
  sessionId: canonical,
14443
14468
  operation: "clear",
14444
- holderId: this.maintenanceHolderId
14469
+ holderId: this.maintenanceHolderId,
14470
+ // The catalog store runs inside the detached project daemon; without
14471
+ // our pid it cannot tell "this TUI clearing its own session" from
14472
+ // "another running wstack" and refuses every /clear as `is live`.
14473
+ holderPid: process.pid
14445
14474
  }) : void 0;
14446
14475
  await this.ensureShardDir(canonical);
14447
14476
  const file = this.sessionPath(canonical, ".jsonl");
@@ -14698,6 +14727,54 @@ async function revertSnapshots(snapshots, projectRoot) {
14698
14727
 
14699
14728
  // src/storage/task-store.ts
14700
14729
  import * as fsp21 from "node:fs/promises";
14730
+ function assertTaskMutationInvariants(previous, updated) {
14731
+ const ids = updated.tasks.map((task) => task.id);
14732
+ const uniqueIds = new Set(ids);
14733
+ if (uniqueIds.size !== ids.length) {
14734
+ throw new SessionError({
14735
+ message: "Task mutation rejected: task IDs must be unique.",
14736
+ code: "SESSION_WRITE_FAILED",
14737
+ sessionId: updated.sessionId,
14738
+ context: { operation: "mutateTasks", invariant: "unique_task_ids" }
14739
+ });
14740
+ }
14741
+ const omittedUnfinished = previous.tasks.filter(
14742
+ (task) => task.status !== "completed" && !uniqueIds.has(task.id)
14743
+ );
14744
+ if (omittedUnfinished.length > 0) {
14745
+ throw new SessionError({
14746
+ message: `Task mutation rejected: unfinished tasks cannot be omitted: ${omittedUnfinished.map((task) => task.id).join(", ")}. Complete them first.`,
14747
+ code: "SESSION_WRITE_FAILED",
14748
+ sessionId: updated.sessionId,
14749
+ context: { operation: "mutateTasks", invariant: "unfinished_task_coverage" }
14750
+ });
14751
+ }
14752
+ const byId = new Map(updated.tasks.map((task) => [task.id, task]));
14753
+ for (const task of updated.tasks) {
14754
+ const missing = (task.dependsOn ?? []).filter((dependencyId) => !byId.has(dependencyId));
14755
+ if (missing.length > 0) {
14756
+ throw new SessionError({
14757
+ message: `Task mutation rejected: task "${task.id}" references missing dependencies: ${missing.join(", ")}.`,
14758
+ code: "SESSION_WRITE_FAILED",
14759
+ sessionId: updated.sessionId,
14760
+ context: { operation: "mutateTasks", invariant: "dependency_identity" }
14761
+ });
14762
+ }
14763
+ if (task.status === "in_progress" || task.status === "review" || task.status === "completed") {
14764
+ const unmet = (task.dependsOn ?? []).filter(
14765
+ (dependencyId) => byId.get(dependencyId)?.status !== "completed"
14766
+ );
14767
+ if (unmet.length > 0) {
14768
+ throw new SessionError({
14769
+ message: `Task mutation rejected: task "${task.id}" cannot be ${task.status} before dependencies complete: ${unmet.join(", ")}.`,
14770
+ code: "SESSION_WRITE_FAILED",
14771
+ sessionId: updated.sessionId,
14772
+ context: { operation: "mutateTasks", invariant: "dependency_completion" }
14773
+ });
14774
+ }
14775
+ }
14776
+ }
14777
+ }
14701
14778
  function emptyTaskFile(sessionId) {
14702
14779
  return {
14703
14780
  version: 1,
@@ -14802,7 +14879,9 @@ async function saveTasks(filePath, tasks, events, traceId, warn) {
14802
14879
  async function mutateTasks(filePath, sessionId, fn, events, traceId) {
14803
14880
  return withFileLock(filePath, async () => {
14804
14881
  const file = await loadTasks(filePath, events, traceId) ?? emptyTaskFile(sessionId);
14882
+ const previous = structuredClone(file);
14805
14883
  const updated = await fn(file);
14884
+ assertTaskMutationInvariants(previous, updated);
14806
14885
  const persisted = await saveTasks(filePath, updated, events, traceId);
14807
14886
  if (!persisted) {
14808
14887
  throw new SessionError({
@@ -1,5 +1,5 @@
1
- import type { EventBus } from '../kernel/events.js';
2
1
  import type { ConversationState } from '../core/conversation-state.js';
2
+ import type { EventBus } from '../kernel/events.js';
3
3
  /**
4
4
  * Plan items are the strategic counterpart to todos. Where `ctx.todos`
5
5
  * is the moment-to-moment task board the LLM mutates per-turn, a plan
@@ -367,6 +367,11 @@ var TaskTracker = class _TaskTracker {
367
367
  if (!this.graph) return false;
368
368
  const node = this.graph.nodes.get(id);
369
369
  if (!node) return false;
370
+ if (node.specRequirementId && this.graph.requiredRequirementIds?.includes(node.specRequirementId) && !Array.from(this.graph.nodes.values()).some(
371
+ (candidate) => candidate.id !== id && candidate.specRequirementId === node.specRequirementId
372
+ )) {
373
+ return false;
374
+ }
370
375
  this.graph.nodes.delete(id);
371
376
  this.graph.edges = this.graph.edges.filter((e) => e.from !== id && e.to !== id);
372
377
  this.graph.rootNodes = this.graph.rootNodes.filter((r) => r !== id);