@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.
- package/README.md +1 -1
- package/dist/chronicle/index.js +117 -30
- package/dist/chronicle/project-server.js +12 -5
- package/dist/coordination/agents/index.js +4313 -3516
- package/dist/coordination/agents/project-agent-auto-optimize.d.ts +116 -0
- package/dist/coordination/agents/project-agent-capture-window.d.ts +29 -0
- package/dist/coordination/agents/project-agent-config-io.d.ts +11 -0
- package/dist/coordination/agents/project-agent-consolidation.d.ts +29 -2
- package/dist/coordination/agents/project-agent-files.d.ts +12 -3
- package/dist/coordination/agents/project-agent-identity-types.d.ts +4 -0
- package/dist/coordination/agents/project-agent-identity.d.ts +22 -9
- package/dist/coordination/agents/project-agent-learning-entries.d.ts +8 -2
- package/dist/coordination/agents/project-agent-learning-structured.d.ts +27 -1
- package/dist/coordination/agents/project-agent-optimizer.d.ts +49 -0
- package/dist/coordination/agents/project-agent-skill-layer.d.ts +101 -0
- package/dist/coordination/agents/role-skills.d.ts +11 -1
- package/dist/coordination/index.d.ts +1 -1
- package/dist/coordination/index.js +2322 -1526
- package/dist/coordination/mail-tools.d.ts +1 -1
- package/dist/core/context.d.ts +4 -0
- package/dist/core/index.js +9 -0
- package/dist/defaults/index.js +895 -597
- package/dist/execution/index.js +2915 -2621
- package/dist/goal/index.js +7 -0
- package/dist/index.js +2406 -1331
- package/dist/kernel/events/agent-events.d.ts +28 -0
- package/dist/plugin/index.js +10 -4
- package/dist/security/index.js +69 -3
- package/dist/security/kanban-boundary.d.ts +5 -1
- package/dist/session-catalog/index.js +24 -2
- package/dist/session-catalog/project-server.js +27 -4
- package/dist/session-catalog/protocol.d.ts +9 -0
- package/dist/session-catalog/store.d.ts +17 -1
- package/dist/storage/index.js +81 -2
- package/dist/storage/plan-store.d.ts +1 -1
- package/dist/tasking/index.js +5 -0
- package/dist/tools/index.js +2824 -2604
- package/dist/types/config/root.d.ts +11 -1
- package/dist/types/config/skills-fleet-brain.d.ts +34 -0
- package/dist/types/config/ui.d.ts +14 -0
- package/dist/types/config.d.ts +1 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +20 -0
- package/dist/types/multi-agent.d.ts +7 -0
- package/dist/types/task-graph.d.ts +2 -0
- package/dist/types/tool-executor.d.ts +2 -0
- package/dist/utils/index.js +3 -0
- package/instructions/system-lite.md +14 -8
- package/instructions/system-pro.md +17 -11
- package/instructions/system.md +17 -11
- package/package.json +3 -3
- 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;
|
package/dist/plugin/index.js
CHANGED
|
@@ -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(
|
|
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")
|
|
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(
|
|
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")
|
|
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) {
|
package/dist/security/index.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/storage/index.js
CHANGED
|
@@ -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
|
package/dist/tasking/index.js
CHANGED
|
@@ -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);
|