@ricsam/r5d-worker 0.0.107 → 0.0.108

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 CHANGED
@@ -52,7 +52,7 @@ Worker labels are mandatory and unique per user. Choose labels that describe hos
52
52
 
53
53
  `r5d-worker start` keeps a lightweight supervisor process attached to the launching terminal or service. When both r5d CLIs are updated from User Settings, the connected runtime verifies the installed versions, exits with a reload signal, and the supervisor reconnects using the new worker package. Updates only run while the worker has no active commands or shells.
54
54
 
55
- The supervisor also reconnects automatically after server rollouts and transient network failures. A brief bounded delay prevents a tight retry loop while the server is unavailable. Pressing Ctrl+C still stops the worker, and agent-managed processes retain their existing in-process recovery path while disconnected.
55
+ The supervisor also reconnects automatically after server rollouts and transient network failures. A brief bounded delay prevents a tight retry loop while the server is unavailable. Pressing Ctrl+C still stops the worker, and agent-managed processes retain their existing in-process recovery path while disconnected. In-process reconnects retain initialized project configurations alongside those processes, so an unchanged checkout can reconnect while a dev server is running. Changed layouts and checkouts that need initialization still wait for active commands to finish.
56
56
 
57
57
  The runtime opens its control connection before recovering interrupted project snapshots. During recovery and initial project configuration it remains connected but non-dispatchable, reports its current phase plus branch/byte progress to the server, and prints the same progress locally. Recovery runs in a credential-sanitized helper process so the control loop can continue answering heartbeats. A durable `restoring` transaction state permits same-filesystem snapshots to be restored with one atomic directory move; cross-filesystem layouts retain the copy-and-fsync fallback.
58
58
 
package/dist/cjs/main.cjs CHANGED
@@ -56,6 +56,9 @@ __export(main_exports, {
56
56
  writeWorkerTextFile: () => writeWorkerTextFile
57
57
  });
58
58
  module.exports = __toCommonJS(main_exports);
59
+ var import_plan_paths = require("./plan-paths.cjs");
60
+ var import_plan_parser = require("./plan-parser.cjs");
61
+ var import_plan_lint = require("./plan-lint.cjs");
59
62
  var import_node_fs = __toESM(require("node:fs"), 1);
60
63
  var import_node_path = __toESM(require("node:path"), 1);
61
64
  var import_node_crypto = require("node:crypto");
@@ -565,7 +568,7 @@ function targetPlanDir(planRoot, target) {
565
568
  function planEnv(activePlanId) {
566
569
  const env = {};
567
570
  if (activePlanId) {
568
- validatePlanId(activePlanId);
571
+ (0, import_plan_paths.validatePlanId)(activePlanId);
569
572
  env.R5D_ACTIVE_PLAN_ID = activePlanId;
570
573
  }
571
574
  return env;
@@ -834,11 +837,6 @@ function validateProjectId(projectId) {
834
837
  function validateBranchName(branchName) {
835
838
  (0, import_managed_paths.validateManagedBranchName)(branchName);
836
839
  }
837
- function validatePlanId(planId) {
838
- if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
839
- throw new Error(`Invalid plan id from server: ${planId}`);
840
- }
841
- }
842
840
  const NON_RECURSIVE_GIT_CONFIG_ARGS = [
843
841
  "-c",
844
842
  "submodule.recurse=false",
@@ -1745,6 +1743,7 @@ const workerGitSecurityTestHarness = {
1745
1743
  workspaceSyncFailureHydrationIsSafe,
1746
1744
  assertWorkerChildAdmission,
1747
1745
  executeWriteFileOperation,
1746
+ executeOperation,
1748
1747
  executeEditFileOperation,
1749
1748
  terminateCredentialBearingChildren,
1750
1749
  terminateCredentialBearingChildrenWithRetention,
@@ -2183,6 +2182,55 @@ function editWorkerTextFile(branchPath, filePath, edits, builtInPaths) {
2183
2182
  edits: edits.length
2184
2183
  };
2185
2184
  }
2185
+ async function executePlanOperation(input) {
2186
+ const { message, resolvedTarget } = input;
2187
+ const filePath = message.type === "lint_plans" ? (0, import_plan_paths.getWorkerPlansPath)(resolvedTarget.target) : (0, import_plan_paths.getWorkerPlanPath)(resolvedTarget.target, message.planId);
2188
+ const builtInPaths = await prepareBuiltInToolPathsForTarget({
2189
+ target: resolvedTarget.target,
2190
+ filePath,
2191
+ baseUrl: input.baseUrl,
2192
+ token: input.token,
2193
+ rootDir: input.rootDir,
2194
+ sessionId: message.sessionId,
2195
+ access: message.type === "update_task_status" || message.type === "read_plan" && message.createIfMissing ? "write" : "read"
2196
+ });
2197
+ const resolved = resolveWorkerFilePath(resolvedTarget.rootPath, filePath, builtInPaths);
2198
+ if (message.type === "lint_plans") {
2199
+ const result = await (0, import_plan_lint.lintPlans)(resolved.absolutePath, message.glob);
2200
+ return {
2201
+ type: "lint_plans",
2202
+ content: result.lintedPlanCount === 0 ? message.glob ? `No plan files matched glob "${message.glob}".` : "No plan files found." : (0, import_plan_lint.formatPlanLintErrors)(result.errors) || "No plan lint issues found."
2203
+ };
2204
+ }
2205
+ return withFileMutationQueue(mutationQueueKey(resolvedTarget.target, resolved), async () => {
2206
+ input.assertAdmission();
2207
+ if (message.type === "read_plan") {
2208
+ let created = false;
2209
+ if (message.createIfMissing) {
2210
+ try {
2211
+ import_node_fs.default.writeFileSync(resolved.absolutePath, (0, import_plan_parser.createPlanMarkdown)(message.planId), { encoding: "utf8", flag: "wx" });
2212
+ created = true;
2213
+ } catch (error) {
2214
+ if (error.code !== "EEXIST") throw error;
2215
+ }
2216
+ }
2217
+ return {
2218
+ type: "read_plan",
2219
+ created,
2220
+ read: readWorkerTextFile(resolvedTarget.rootPath, filePath, void 0, void 0, builtInPaths)
2221
+ };
2222
+ }
2223
+ const content = import_node_fs.default.readFileSync(resolved.absolutePath, "utf8");
2224
+ writeWorkerTextFile(resolvedTarget.rootPath, filePath, (0, import_plan_parser.updateTaskCheckbox)(content, message.taskId, message.checked), builtInPaths);
2225
+ return {
2226
+ type: "update_task_status",
2227
+ planId: message.planId,
2228
+ taskId: message.taskId,
2229
+ checked: message.checked,
2230
+ message: `Task ${message.taskId} marked ${message.checked ? "complete" : "incomplete"}.`
2231
+ };
2232
+ });
2233
+ }
2186
2234
  async function executeReadFileOperation(input) {
2187
2235
  const builtInPaths = await prepareBuiltInToolPathsForTarget({
2188
2236
  target: input.resolvedTarget.target,
@@ -2561,6 +2609,10 @@ async function executeViewFileBytesOperation(input) {
2561
2609
  }
2562
2610
  async function executeOperation(input) {
2563
2611
  switch (input.message.type) {
2612
+ case "read_plan":
2613
+ case "lint_plans":
2614
+ case "update_task_status":
2615
+ return executePlanOperation({ ...input, message: input.message });
2564
2616
  case "read":
2565
2617
  return executeReadFileOperation({ ...input, message: input.message });
2566
2618
  case "write":
@@ -3420,7 +3472,12 @@ function websocketUrl(baseUrl, label) {
3420
3472
  url.searchParams.set("version", getWorkerVersion());
3421
3473
  return url.toString();
3422
3474
  }
3423
- async function startWorker(options) {
3475
+ async function startWorker(options, projectRuntime = {
3476
+ projectConfigById: /* @__PURE__ */ new Map(),
3477
+ readyProjectIds: /* @__PURE__ */ new Set(),
3478
+ reconciledProjectConfigFingerprints: /* @__PURE__ */ new Map(),
3479
+ gitIdentity: null
3480
+ }) {
3424
3481
  advanceWorkerAdmissionGeneration();
3425
3482
  const config = readConfig(options.configPath);
3426
3483
  const { baseUrl, token } = resolveCredentials(options, config);
@@ -3461,10 +3518,8 @@ async function startWorker(options) {
3461
3518
  });
3462
3519
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3463
3520
  let projectWorkspaceState = initializedWorkspaceState.state;
3464
- const projectConfigById = /* @__PURE__ */ new Map();
3521
+ const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3465
3522
  const pendingCheckouts = /* @__PURE__ */ new Map();
3466
- const readyProjectIds = /* @__PURE__ */ new Set();
3467
- const reconciledProjectConfigFingerprints = /* @__PURE__ */ new Map();
3468
3523
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
3469
3524
  const advertisedProjectMirrorRefsTokens = /* @__PURE__ */ new Map();
3470
3525
  const advertisedProjectMirrorMountTokens = /* @__PURE__ */ new Map();
@@ -3494,7 +3549,7 @@ async function startWorker(options) {
3494
3549
  let workspaceRemoteUrl = null;
3495
3550
  let workspaceCredentialHelper = null;
3496
3551
  let workspaceCredentialUsername = null;
3497
- let workspaceGitIdentity = null;
3552
+ let workspaceGitIdentity = projectRuntime.gitIdentity;
3498
3553
  let workspaceConfigured = false;
3499
3554
  let activeWorkspaceIncidentId = null;
3500
3555
  let deferredWorkspaceConfiguration = null;
@@ -3855,7 +3910,9 @@ async function startWorker(options) {
3855
3910
  const reportedMissingCheckouts = (inventory) => inventory.missingCheckouts.map(({ projectId, branchName, reason }) => ({ projectId, branchName, reason }));
3856
3911
  const logMissingCheckouts = (inventory) => {
3857
3912
  if (inventory.missingCheckouts.length === 0) return;
3858
- const described = inventory.missingCheckouts.map(({ checkoutPath, reason }) => `${checkoutPath} (${reason === "missing" ? "directory missing" : "not a linked worktree of the project"})`).join(", ");
3913
+ const described = inventory.missingCheckouts.map(
3914
+ ({ checkoutPath, reason }) => `${checkoutPath} (${reason === "missing" ? "directory missing" : "not a linked worktree of the project"})`
3915
+ ).join(", ");
3859
3916
  process.stderr.write(
3860
3917
  `[r5d-worker] skipping ${inventory.missingCheckouts.length} configured project checkout(s) this cycle: ${described}; delete the branch through the platform or reconnect this worker to recreate the checkout
3861
3918
  `
@@ -4426,7 +4483,9 @@ async function startWorker(options) {
4426
4483
  error: error instanceof Error ? error.message : String(error)
4427
4484
  };
4428
4485
  };
4429
- const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks((message) => sendWorkerMessageFromCurrentSource(ws, message));
4486
+ const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks(
4487
+ (message) => sendWorkerMessageFromCurrentSource(ws, message)
4488
+ );
4430
4489
  const performWorkspaceSync = async (input) => {
4431
4490
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
4432
4491
  throw new Error("Worker workspace configuration has not been received");
@@ -4483,7 +4542,11 @@ async function startWorker(options) {
4483
4542
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
4484
4543
  const mounts = outerRemediation ? (0, import_workspace_git_sync.workspaceGitMountsForRemediation)(configuredMounts) : configuredMounts;
4485
4544
  if (outerRemediation) workspaceProjectionLedger.reset();
4486
- const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false, projects: checkoutInventory.projects });
4545
+ const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({
4546
+ allowNonFastForward: false,
4547
+ failClosed: false,
4548
+ projects: checkoutInventory.projects
4549
+ });
4487
4550
  const expectedMirrorHeads = /* @__PURE__ */ new Map();
4488
4551
  for (const bundle of observedProjectHeads) {
4489
4552
  for (const observation of bundle.observations) {
@@ -4672,7 +4735,11 @@ async function startWorker(options) {
4672
4735
  };
4673
4736
  }
4674
4737
  };
4675
- const REMEDIATION_TRIGGER_TYPES = /* @__PURE__ */ new Set(["remediation", "remediation_confirm", "remediation_reset"]);
4738
+ const REMEDIATION_TRIGGER_TYPES = /* @__PURE__ */ new Set([
4739
+ "remediation",
4740
+ "remediation_confirm",
4741
+ "remediation_reset"
4742
+ ]);
4676
4743
  const isOrdinaryWorkspaceSyncRequest = (input) => input.confirmedLargeDiff !== true && input.resetToCanonical !== true && !(input.requiredAncestorHeads && input.requiredAncestorHeads.length > 0) && !REMEDIATION_TRIGGER_TYPES.has(input.trigger.type);
4677
4744
  const LOCAL_ACTIVITY_TRIGGER_TYPES = /* @__PURE__ */ new Set([
4678
4745
  "write",
@@ -4713,7 +4780,14 @@ async function startWorker(options) {
4713
4780
  trigger: leader.trigger,
4714
4781
  assertStillAdmitted: () => assertWorkspaceSyncStillAdmitted(admission, leader.trigger)
4715
4782
  });
4716
- return { result, ranCycle: true, startedAtMs, finishedAtMs: Date.now(), prepareMs: lastWorkspaceSyncMirrorObservationMs, staleAttemptIds };
4783
+ return {
4784
+ result,
4785
+ ranCycle: true,
4786
+ startedAtMs,
4787
+ finishedAtMs: Date.now(),
4788
+ prepareMs: lastWorkspaceSyncMirrorObservationMs,
4789
+ staleAttemptIds
4790
+ };
4717
4791
  }
4718
4792
  });
4719
4793
  const runWorkspaceSync = async (input) => {
@@ -5145,6 +5219,7 @@ async function startWorker(options) {
5145
5219
  bootstrapCredentialGenerationFingerprintAtProcessStart = null;
5146
5220
  visibleGitIdentity = message.gitIdentity;
5147
5221
  workspaceGitIdentity = message.gitIdentity;
5222
+ projectRuntime.gitIdentity = message.gitIdentity;
5148
5223
  workspaceRemoteUrl = message.workspaceRemoteUrl;
5149
5224
  workspaceCredentialHelper = nextWorkspaceCredentialHelper;
5150
5225
  workspaceCredentialUsername = workerCredentialUsername;
@@ -6285,7 +6360,7 @@ async function startWorker(options) {
6285
6360
  });
6286
6361
  return;
6287
6362
  }
6288
- if (message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
6363
+ if (message.type === "read_plan" || message.type === "lint_plans" || message.type === "update_task_status" || message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
6289
6364
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
6290
6365
  sendWorkerMessage(ws, {
6291
6366
  type: "operation_result",
@@ -6295,7 +6370,7 @@ async function startWorker(options) {
6295
6370
  return;
6296
6371
  }
6297
6372
  const reservesVisibleWorkspace = targetMayMutateVisibleWorkspace(message.target);
6298
- const mutatesVisibleWorkspace = (message.type === "write" || message.type === "edit") && targetMayMutateVisibleWorkspace(message.target);
6373
+ const mutatesVisibleWorkspace = (message.type === "write" || message.type === "edit" || message.type === "update_task_status" || message.type === "read_plan" && message.createIfMissing) && targetMayMutateVisibleWorkspace(message.target);
6299
6374
  if (reservesVisibleWorkspace) {
6300
6375
  await (0, import_workspace_command_sync_policy.reserveWorkspaceCommandAfterCurrentSync)(message.target, workspaceSyncSingleFlight, () => {
6301
6376
  workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
@@ -6322,9 +6397,9 @@ async function startWorker(options) {
6322
6397
  result
6323
6398
  })
6324
6399
  );
6325
- if (mutatesVisibleWorkspace) {
6400
+ if (mutatesVisibleWorkspace && (result.type !== "read_plan" || result.created)) {
6326
6401
  dirtyTrigger = {
6327
- type: message.type,
6402
+ type: message.type === "edit" || message.type === "update_task_status" ? "edit" : "write",
6328
6403
  sessionId: message.sessionId,
6329
6404
  toolCallId: message.requestId,
6330
6405
  ...message.target.type === "project" ? { projectId: message.target.projectId, branchName: message.target.branchName } : {}
@@ -6410,7 +6485,7 @@ async function startWorker(options) {
6410
6485
  process.stderr.write(`[r5d-worker] workspace recovery complete; reconnecting in ${reconnectDelayMs}ms
6411
6486
  `);
6412
6487
  setTimeout(() => {
6413
- void startWorker(options).catch((error) => {
6488
+ void startWorker(options, projectRuntime).catch((error) => {
6414
6489
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6415
6490
  `);
6416
6491
  process.exit(error instanceof WorkerServerUnavailableError ? import_supervisor.WORKER_RECONNECT_EXIT_CODE : 1);
@@ -6429,7 +6504,7 @@ async function startWorker(options) {
6429
6504
  setTimeout(reconnect2, reconnectDelayMs);
6430
6505
  };
6431
6506
  const reconnect = () => {
6432
- void startWorker(options).catch((error) => {
6507
+ void startWorker(options, projectRuntime).catch((error) => {
6433
6508
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6434
6509
  `);
6435
6510
  if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.107",
3
+ "version": "0.0.108",
4
4
  "type": "commonjs"
5
5
  }
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var plan_lint_exports = {};
30
+ __export(plan_lint_exports, {
31
+ formatPlanLintErrors: () => formatPlanLintErrors,
32
+ lintPlanContent: () => lintPlanContent,
33
+ lintPlans: () => lintPlans
34
+ });
35
+ module.exports = __toCommonJS(plan_lint_exports);
36
+ var import_promises = __toESM(require("node:fs/promises"), 1);
37
+ var import_node_path = __toESM(require("node:path"), 1);
38
+ var import_picomatch = __toESM(require("picomatch"), 1);
39
+ var import_plan_parser = require("./plan-parser.cjs");
40
+ var import_plan_paths = require("./plan-paths.cjs");
41
+ var import_plan_parser2 = require("./plan-parser.cjs");
42
+ const LEGACY_SCHEMA_FIELD_REGEX = /^\s*(type|status|parent|phase|discovered_problem)\s*:/m;
43
+ function lintPlanContent(planId, content) {
44
+ const errors = [];
45
+ const lines = content.split(/\r?\n/);
46
+ if (/^---(?:\r?\n|$)/.test(content)) {
47
+ errors.push({
48
+ planId,
49
+ severity: "error",
50
+ message: "YAML frontmatter is not supported. Use plain Markdown with a ## Tasks checklist.",
51
+ line: 1
52
+ });
53
+ }
54
+ if (LEGACY_SCHEMA_FIELD_REGEX.test(content)) {
55
+ errors.push({
56
+ planId,
57
+ severity: "error",
58
+ message: "Legacy plan schema fields are not supported. Remove type/status/parent/phase/discovered_problem metadata."
59
+ });
60
+ }
61
+ const titleLineIndex = lines.findIndex((line) => /^# .+\S\s*$/.test(line));
62
+ if (titleLineIndex === -1) {
63
+ errors.push({
64
+ planId,
65
+ severity: "error",
66
+ message: "Plan must start with or contain an H1 title, for example '# Implementation Plan'."
67
+ });
68
+ }
69
+ const range = (0, import_plan_parser.getTasksSectionRange)(content);
70
+ if (!range) {
71
+ errors.push({
72
+ planId,
73
+ severity: "error",
74
+ message: "Plan is missing a ## Tasks section."
75
+ });
76
+ return errors;
77
+ }
78
+ const taskIds = /* @__PURE__ */ new Set();
79
+ let taskCount = 0;
80
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
81
+ const line = lines[index] ?? "";
82
+ if (line.trim() === "") continue;
83
+ const match = line.match(import_plan_parser.PLAN_TASK_LINE_REGEX);
84
+ if (!match || !match[3].trim()) {
85
+ errors.push({
86
+ planId,
87
+ severity: "error",
88
+ line: index + 1,
89
+ message: 'Malformed task line. Use "- [ ] task-id: Description" or "- [x] task-id: Description".'
90
+ });
91
+ continue;
92
+ }
93
+ taskCount += 1;
94
+ const taskId = match[2];
95
+ if (taskIds.has(taskId)) {
96
+ errors.push({
97
+ planId,
98
+ severity: "error",
99
+ line: index + 1,
100
+ message: `Duplicate task id "${taskId}".`
101
+ });
102
+ }
103
+ taskIds.add(taskId);
104
+ }
105
+ if (taskCount === 0) {
106
+ errors.push({
107
+ planId,
108
+ severity: "error",
109
+ message: "Plan must include at least one task in the ## Tasks section.",
110
+ line: range.startIndex + 1
111
+ });
112
+ }
113
+ return errors;
114
+ }
115
+ async function lintPlans(plansDir, glob) {
116
+ const errors = [];
117
+ let entries;
118
+ try {
119
+ entries = await import_promises.default.readdir(plansDir, { withFileTypes: true });
120
+ } catch (error) {
121
+ if (error.code !== "ENOENT") throw error;
122
+ entries = [];
123
+ }
124
+ const matches = glob ? (0, import_picomatch.default)(glob) : null;
125
+ let lintedPlanCount = 0;
126
+ for (const entry of entries) {
127
+ if (!entry.isFile() || !entry.name.endsWith(import_plan_paths.PLAN_EXTENSION)) continue;
128
+ const planId = entry.name.slice(0, -import_plan_paths.PLAN_EXTENSION.length);
129
+ if (!import_plan_parser2.PLAN_TASK_ID_REGEX.test(planId) || matches && !matches(entry.name) && !matches(planId)) continue;
130
+ const content = await import_promises.default.readFile(import_node_path.default.join(plansDir, entry.name), "utf8");
131
+ lintedPlanCount += 1;
132
+ errors.push(...lintPlanContent(planId, content));
133
+ }
134
+ return {
135
+ type: "plan_lint",
136
+ errors,
137
+ errorCount: errors.filter((error) => error.severity === "error").length,
138
+ warningCount: errors.filter((error) => error.severity === "warning").length,
139
+ lintedPlanCount
140
+ };
141
+ }
142
+ function formatPlanLintErrors(errors) {
143
+ if (errors.length === 0) return "";
144
+ const lines = ["## Plan Lint Errors", ""];
145
+ const byPlan = /* @__PURE__ */ new Map();
146
+ for (const error of errors) {
147
+ byPlan.set(error.planId, [...byPlan.get(error.planId) ?? [], error]);
148
+ }
149
+ for (const [planId, planErrors] of byPlan) {
150
+ lines.push(`### ${planId}.plan.md`);
151
+ for (const error of planErrors) {
152
+ const location = error.line ? ` line ${error.line}` : "";
153
+ lines.push(`- ${error.severity.toUpperCase()}${location}: ${error.message}`);
154
+ }
155
+ lines.push("");
156
+ }
157
+ lines.push("Fix these issues by editing the Markdown plan file.");
158
+ return lines.join("\n");
159
+ }
160
+ // Annotate the CommonJS export names for ESM import in node:
161
+ 0 && (module.exports = {
162
+ formatPlanLintErrors,
163
+ lintPlanContent,
164
+ lintPlans
165
+ });
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var plan_parser_exports = {};
20
+ __export(plan_parser_exports, {
21
+ PLAN_TASKS_HEADING_REGEX: () => PLAN_TASKS_HEADING_REGEX,
22
+ PLAN_TASK_ID_REGEX: () => PLAN_TASK_ID_REGEX,
23
+ PLAN_TASK_LINE_REGEX: () => PLAN_TASK_LINE_REGEX,
24
+ createPlanMarkdown: () => createPlanMarkdown,
25
+ formatPlanTitle: () => formatPlanTitle,
26
+ getPlanMetadata: () => getPlanMetadata,
27
+ getTasksSectionRange: () => getTasksSectionRange,
28
+ parseMarkdownPlan: () => parseMarkdownPlan,
29
+ parsePlan: () => parsePlan,
30
+ updateTaskCheckbox: () => updateTaskCheckbox
31
+ });
32
+ module.exports = __toCommonJS(plan_parser_exports);
33
+ const PLAN_TASK_ID_REGEX = /^[a-z0-9][a-z0-9-]*$/;
34
+ const PLAN_TASK_LINE_REGEX = /^- \[( |x)\] ([a-z0-9][a-z0-9-]*): (.+)$/;
35
+ const PLAN_TASKS_HEADING_REGEX = /^## Tasks\s*$/;
36
+ function getPlanMetadata(content) {
37
+ const title = content.split(/\r?\n/).map((line) => line.match(/^# (.+)$/)?.[1]?.trim()).find((value) => Boolean(value));
38
+ return { title: title || "Untitled Plan" };
39
+ }
40
+ function getTasksSectionRange(content) {
41
+ const lines = content.split(/\r?\n/);
42
+ const startIndex = lines.findIndex((line) => PLAN_TASKS_HEADING_REGEX.test(line));
43
+ if (startIndex === -1) return null;
44
+ let endIndex = lines.length;
45
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
46
+ if (/^##\s+/.test(lines[index] ?? "")) {
47
+ endIndex = index;
48
+ break;
49
+ }
50
+ }
51
+ return { startIndex, endIndex };
52
+ }
53
+ function parseMarkdownPlan(content, planId) {
54
+ const lines = content.split(/\r?\n/);
55
+ const { title } = getPlanMetadata(content);
56
+ const range = getTasksSectionRange(content);
57
+ const tasks = [];
58
+ if (range) {
59
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
60
+ const line = lines[index] ?? "";
61
+ const match = line.match(PLAN_TASK_LINE_REGEX);
62
+ if (!match) continue;
63
+ const description = match[3].trim();
64
+ if (!description) continue;
65
+ tasks.push({
66
+ id: match[2],
67
+ description,
68
+ checked: match[1] === "x",
69
+ lineNumber: index + 1
70
+ });
71
+ }
72
+ }
73
+ const currentTask = tasks.find((task) => !task.checked) ?? null;
74
+ return {
75
+ id: planId,
76
+ title,
77
+ content,
78
+ tasks,
79
+ currentTask,
80
+ complete: tasks.length > 0 && tasks.every((task) => task.checked),
81
+ tasksStartLine: range ? range.startIndex + 1 : null,
82
+ tasksEndLine: range ? range.endIndex : null
83
+ };
84
+ }
85
+ const parsePlan = parseMarkdownPlan;
86
+ function createPlanMarkdown(planId, title = formatPlanTitle(planId)) {
87
+ return `# ${title}
88
+
89
+ Brief implementation notes.
90
+
91
+ ## Tasks
92
+ `;
93
+ }
94
+ function updateTaskCheckbox(content, taskId, checked) {
95
+ if (!PLAN_TASK_ID_REGEX.test(taskId)) {
96
+ throw new Error(`Invalid task id: ${taskId}`);
97
+ }
98
+ const lines = content.split(/\r?\n/);
99
+ const range = getTasksSectionRange(content);
100
+ if (!range) {
101
+ throw new Error("Plan is missing a ## Tasks section.");
102
+ }
103
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
104
+ const line = lines[index] ?? "";
105
+ const match = line.match(PLAN_TASK_LINE_REGEX);
106
+ if (!match || match[2] !== taskId) continue;
107
+ lines[index] = `- [${checked ? "x" : " "}] ${match[2]}: ${match[3].trim()}`;
108
+ return lines.join("\n");
109
+ }
110
+ throw new Error(`Task not found in plan: ${taskId}`);
111
+ }
112
+ function formatPlanTitle(planId) {
113
+ return planId.split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
114
+ }
115
+ // Annotate the CommonJS export names for ESM import in node:
116
+ 0 && (module.exports = {
117
+ PLAN_TASKS_HEADING_REGEX,
118
+ PLAN_TASK_ID_REGEX,
119
+ PLAN_TASK_LINE_REGEX,
120
+ createPlanMarkdown,
121
+ formatPlanTitle,
122
+ getPlanMetadata,
123
+ getTasksSectionRange,
124
+ parseMarkdownPlan,
125
+ parsePlan,
126
+ updateTaskCheckbox
127
+ });
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var plan_paths_exports = {};
20
+ __export(plan_paths_exports, {
21
+ PLAN_EXTENSION: () => PLAN_EXTENSION,
22
+ assertPlanTarget: () => assertPlanTarget,
23
+ getWorkerPlanPath: () => getWorkerPlanPath,
24
+ getWorkerPlansPath: () => getWorkerPlansPath,
25
+ validatePlanId: () => validatePlanId
26
+ });
27
+ module.exports = __toCommonJS(plan_paths_exports);
28
+ var import_plan_parser = require("./plan-parser.cjs");
29
+ const PLAN_EXTENSION = ".plan.md";
30
+ function validatePlanId(planId) {
31
+ if (!import_plan_parser.PLAN_TASK_ID_REGEX.test(planId)) throw new Error(`Invalid plan id: ${planId}`);
32
+ }
33
+ function assertPlanTarget(target) {
34
+ if (target.type === "workspace" && target.rootProfile !== "visible_projects") {
35
+ throw new Error("Ordinary plans are unavailable in canonical synchronization sessions");
36
+ }
37
+ }
38
+ function getWorkerPlansPath(target) {
39
+ assertPlanTarget(target);
40
+ return target.type === "project" ? "$R5D_ROOT/plans/$R5D_PROJECT_ID/$R5D_BRANCH_NAME" : "$R5D_ROOT/plans/workspace";
41
+ }
42
+ function getWorkerPlanPath(target, planId) {
43
+ validatePlanId(planId);
44
+ return `${getWorkerPlansPath(target)}/${planId}${PLAN_EXTENSION}`;
45
+ }
46
+ // Annotate the CommonJS export names for ESM import in node:
47
+ 0 && (module.exports = {
48
+ PLAN_EXTENSION,
49
+ assertPlanTarget,
50
+ getWorkerPlanPath,
51
+ getWorkerPlansPath,
52
+ validatePlanId
53
+ });
package/dist/mjs/main.mjs CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env bun
2
+ import { getWorkerPlanPath, getWorkerPlansPath, validatePlanId } from "./plan-paths.mjs";
3
+ import { createPlanMarkdown, updateTaskCheckbox } from "./plan-parser.mjs";
4
+ import { lintPlans, formatPlanLintErrors } from "./plan-lint.mjs";
2
5
  import fs from "node:fs";
3
6
  import path from "node:path";
4
7
  import { createHash } from "node:crypto";
@@ -845,11 +848,6 @@ function validateProjectId(projectId) {
845
848
  function validateBranchName(branchName) {
846
849
  validateManagedBranchName(branchName);
847
850
  }
848
- function validatePlanId(planId) {
849
- if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
850
- throw new Error(`Invalid plan id from server: ${planId}`);
851
- }
852
- }
853
851
  const NON_RECURSIVE_GIT_CONFIG_ARGS = [
854
852
  "-c",
855
853
  "submodule.recurse=false",
@@ -1756,6 +1754,7 @@ const workerGitSecurityTestHarness = {
1756
1754
  workspaceSyncFailureHydrationIsSafe,
1757
1755
  assertWorkerChildAdmission,
1758
1756
  executeWriteFileOperation,
1757
+ executeOperation,
1759
1758
  executeEditFileOperation,
1760
1759
  terminateCredentialBearingChildren,
1761
1760
  terminateCredentialBearingChildrenWithRetention,
@@ -2194,6 +2193,55 @@ function editWorkerTextFile(branchPath, filePath, edits, builtInPaths) {
2194
2193
  edits: edits.length
2195
2194
  };
2196
2195
  }
2196
+ async function executePlanOperation(input) {
2197
+ const { message, resolvedTarget } = input;
2198
+ const filePath = message.type === "lint_plans" ? getWorkerPlansPath(resolvedTarget.target) : getWorkerPlanPath(resolvedTarget.target, message.planId);
2199
+ const builtInPaths = await prepareBuiltInToolPathsForTarget({
2200
+ target: resolvedTarget.target,
2201
+ filePath,
2202
+ baseUrl: input.baseUrl,
2203
+ token: input.token,
2204
+ rootDir: input.rootDir,
2205
+ sessionId: message.sessionId,
2206
+ access: message.type === "update_task_status" || message.type === "read_plan" && message.createIfMissing ? "write" : "read"
2207
+ });
2208
+ const resolved = resolveWorkerFilePath(resolvedTarget.rootPath, filePath, builtInPaths);
2209
+ if (message.type === "lint_plans") {
2210
+ const result = await lintPlans(resolved.absolutePath, message.glob);
2211
+ return {
2212
+ type: "lint_plans",
2213
+ content: result.lintedPlanCount === 0 ? message.glob ? `No plan files matched glob "${message.glob}".` : "No plan files found." : formatPlanLintErrors(result.errors) || "No plan lint issues found."
2214
+ };
2215
+ }
2216
+ return withFileMutationQueue(mutationQueueKey(resolvedTarget.target, resolved), async () => {
2217
+ input.assertAdmission();
2218
+ if (message.type === "read_plan") {
2219
+ let created = false;
2220
+ if (message.createIfMissing) {
2221
+ try {
2222
+ fs.writeFileSync(resolved.absolutePath, createPlanMarkdown(message.planId), { encoding: "utf8", flag: "wx" });
2223
+ created = true;
2224
+ } catch (error) {
2225
+ if (error.code !== "EEXIST") throw error;
2226
+ }
2227
+ }
2228
+ return {
2229
+ type: "read_plan",
2230
+ created,
2231
+ read: readWorkerTextFile(resolvedTarget.rootPath, filePath, void 0, void 0, builtInPaths)
2232
+ };
2233
+ }
2234
+ const content = fs.readFileSync(resolved.absolutePath, "utf8");
2235
+ writeWorkerTextFile(resolvedTarget.rootPath, filePath, updateTaskCheckbox(content, message.taskId, message.checked), builtInPaths);
2236
+ return {
2237
+ type: "update_task_status",
2238
+ planId: message.planId,
2239
+ taskId: message.taskId,
2240
+ checked: message.checked,
2241
+ message: `Task ${message.taskId} marked ${message.checked ? "complete" : "incomplete"}.`
2242
+ };
2243
+ });
2244
+ }
2197
2245
  async function executeReadFileOperation(input) {
2198
2246
  const builtInPaths = await prepareBuiltInToolPathsForTarget({
2199
2247
  target: input.resolvedTarget.target,
@@ -2572,6 +2620,10 @@ async function executeViewFileBytesOperation(input) {
2572
2620
  }
2573
2621
  async function executeOperation(input) {
2574
2622
  switch (input.message.type) {
2623
+ case "read_plan":
2624
+ case "lint_plans":
2625
+ case "update_task_status":
2626
+ return executePlanOperation({ ...input, message: input.message });
2575
2627
  case "read":
2576
2628
  return executeReadFileOperation({ ...input, message: input.message });
2577
2629
  case "write":
@@ -3431,7 +3483,12 @@ function websocketUrl(baseUrl, label) {
3431
3483
  url.searchParams.set("version", getWorkerVersion());
3432
3484
  return url.toString();
3433
3485
  }
3434
- async function startWorker(options) {
3486
+ async function startWorker(options, projectRuntime = {
3487
+ projectConfigById: /* @__PURE__ */ new Map(),
3488
+ readyProjectIds: /* @__PURE__ */ new Set(),
3489
+ reconciledProjectConfigFingerprints: /* @__PURE__ */ new Map(),
3490
+ gitIdentity: null
3491
+ }) {
3435
3492
  advanceWorkerAdmissionGeneration();
3436
3493
  const config = readConfig(options.configPath);
3437
3494
  const { baseUrl, token } = resolveCredentials(options, config);
@@ -3472,10 +3529,8 @@ async function startWorker(options) {
3472
3529
  });
3473
3530
  const projectWorkspaceStateStore = initializedWorkspaceState.store;
3474
3531
  let projectWorkspaceState = initializedWorkspaceState.state;
3475
- const projectConfigById = /* @__PURE__ */ new Map();
3532
+ const { projectConfigById, readyProjectIds, reconciledProjectConfigFingerprints } = projectRuntime;
3476
3533
  const pendingCheckouts = /* @__PURE__ */ new Map();
3477
- const readyProjectIds = /* @__PURE__ */ new Set();
3478
- const reconciledProjectConfigFingerprints = /* @__PURE__ */ new Map();
3479
3534
  const lastObservedProjectHeads = /* @__PURE__ */ new Map();
3480
3535
  const advertisedProjectMirrorRefsTokens = /* @__PURE__ */ new Map();
3481
3536
  const advertisedProjectMirrorMountTokens = /* @__PURE__ */ new Map();
@@ -3505,7 +3560,7 @@ async function startWorker(options) {
3505
3560
  let workspaceRemoteUrl = null;
3506
3561
  let workspaceCredentialHelper = null;
3507
3562
  let workspaceCredentialUsername = null;
3508
- let workspaceGitIdentity = null;
3563
+ let workspaceGitIdentity = projectRuntime.gitIdentity;
3509
3564
  let workspaceConfigured = false;
3510
3565
  let activeWorkspaceIncidentId = null;
3511
3566
  let deferredWorkspaceConfiguration = null;
@@ -3866,7 +3921,9 @@ async function startWorker(options) {
3866
3921
  const reportedMissingCheckouts = (inventory) => inventory.missingCheckouts.map(({ projectId, branchName, reason }) => ({ projectId, branchName, reason }));
3867
3922
  const logMissingCheckouts = (inventory) => {
3868
3923
  if (inventory.missingCheckouts.length === 0) return;
3869
- const described = inventory.missingCheckouts.map(({ checkoutPath, reason }) => `${checkoutPath} (${reason === "missing" ? "directory missing" : "not a linked worktree of the project"})`).join(", ");
3924
+ const described = inventory.missingCheckouts.map(
3925
+ ({ checkoutPath, reason }) => `${checkoutPath} (${reason === "missing" ? "directory missing" : "not a linked worktree of the project"})`
3926
+ ).join(", ");
3870
3927
  process.stderr.write(
3871
3928
  `[r5d-worker] skipping ${inventory.missingCheckouts.length} configured project checkout(s) this cycle: ${described}; delete the branch through the platform or reconnect this worker to recreate the checkout
3872
3929
  `
@@ -4437,7 +4494,9 @@ async function startWorker(options) {
4437
4494
  error: error instanceof Error ? error.message : String(error)
4438
4495
  };
4439
4496
  };
4440
- const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks((message) => sendWorkerMessageFromCurrentSource(ws, message));
4497
+ const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks(
4498
+ (message) => sendWorkerMessageFromCurrentSource(ws, message)
4499
+ );
4441
4500
  const performWorkspaceSync = async (input) => {
4442
4501
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
4443
4502
  throw new Error("Worker workspace configuration has not been received");
@@ -4494,7 +4553,11 @@ async function startWorker(options) {
4494
4553
  const outerRemediation = input.trigger.type === "remediation" || input.trigger.type === "remediation_confirm";
4495
4554
  const mounts = outerRemediation ? workspaceGitMountsForRemediation(configuredMounts) : configuredMounts;
4496
4555
  if (outerRemediation) workspaceProjectionLedger.reset();
4497
- const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({ allowNonFastForward: false, failClosed: false, projects: checkoutInventory.projects });
4556
+ const observedProjectHeads = outerRemediation ? [] : await observeProjectHeadsBeforeOuterWorkspace({
4557
+ allowNonFastForward: false,
4558
+ failClosed: false,
4559
+ projects: checkoutInventory.projects
4560
+ });
4498
4561
  const expectedMirrorHeads = /* @__PURE__ */ new Map();
4499
4562
  for (const bundle of observedProjectHeads) {
4500
4563
  for (const observation of bundle.observations) {
@@ -4683,7 +4746,11 @@ async function startWorker(options) {
4683
4746
  };
4684
4747
  }
4685
4748
  };
4686
- const REMEDIATION_TRIGGER_TYPES = /* @__PURE__ */ new Set(["remediation", "remediation_confirm", "remediation_reset"]);
4749
+ const REMEDIATION_TRIGGER_TYPES = /* @__PURE__ */ new Set([
4750
+ "remediation",
4751
+ "remediation_confirm",
4752
+ "remediation_reset"
4753
+ ]);
4687
4754
  const isOrdinaryWorkspaceSyncRequest = (input) => input.confirmedLargeDiff !== true && input.resetToCanonical !== true && !(input.requiredAncestorHeads && input.requiredAncestorHeads.length > 0) && !REMEDIATION_TRIGGER_TYPES.has(input.trigger.type);
4688
4755
  const LOCAL_ACTIVITY_TRIGGER_TYPES = /* @__PURE__ */ new Set([
4689
4756
  "write",
@@ -4724,7 +4791,14 @@ async function startWorker(options) {
4724
4791
  trigger: leader.trigger,
4725
4792
  assertStillAdmitted: () => assertWorkspaceSyncStillAdmitted(admission, leader.trigger)
4726
4793
  });
4727
- return { result, ranCycle: true, startedAtMs, finishedAtMs: Date.now(), prepareMs: lastWorkspaceSyncMirrorObservationMs, staleAttemptIds };
4794
+ return {
4795
+ result,
4796
+ ranCycle: true,
4797
+ startedAtMs,
4798
+ finishedAtMs: Date.now(),
4799
+ prepareMs: lastWorkspaceSyncMirrorObservationMs,
4800
+ staleAttemptIds
4801
+ };
4728
4802
  }
4729
4803
  });
4730
4804
  const runWorkspaceSync = async (input) => {
@@ -5156,6 +5230,7 @@ async function startWorker(options) {
5156
5230
  bootstrapCredentialGenerationFingerprintAtProcessStart = null;
5157
5231
  visibleGitIdentity = message.gitIdentity;
5158
5232
  workspaceGitIdentity = message.gitIdentity;
5233
+ projectRuntime.gitIdentity = message.gitIdentity;
5159
5234
  workspaceRemoteUrl = message.workspaceRemoteUrl;
5160
5235
  workspaceCredentialHelper = nextWorkspaceCredentialHelper;
5161
5236
  workspaceCredentialUsername = workerCredentialUsername;
@@ -6296,7 +6371,7 @@ async function startWorker(options) {
6296
6371
  });
6297
6372
  return;
6298
6373
  }
6299
- if (message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
6374
+ if (message.type === "read_plan" || message.type === "lint_plans" || message.type === "update_task_status" || message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
6300
6375
  if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
6301
6376
  sendWorkerMessage(ws, {
6302
6377
  type: "operation_result",
@@ -6306,7 +6381,7 @@ async function startWorker(options) {
6306
6381
  return;
6307
6382
  }
6308
6383
  const reservesVisibleWorkspace = targetMayMutateVisibleWorkspace(message.target);
6309
- const mutatesVisibleWorkspace = (message.type === "write" || message.type === "edit") && targetMayMutateVisibleWorkspace(message.target);
6384
+ const mutatesVisibleWorkspace = (message.type === "write" || message.type === "edit" || message.type === "update_task_status" || message.type === "read_plan" && message.createIfMissing) && targetMayMutateVisibleWorkspace(message.target);
6310
6385
  if (reservesVisibleWorkspace) {
6311
6386
  await reserveWorkspaceCommandAfterCurrentSync(message.target, workspaceSyncSingleFlight, () => {
6312
6387
  workspaceSyncPriorityOperationTargets.set(message.requestId, message.target);
@@ -6333,9 +6408,9 @@ async function startWorker(options) {
6333
6408
  result
6334
6409
  })
6335
6410
  );
6336
- if (mutatesVisibleWorkspace) {
6411
+ if (mutatesVisibleWorkspace && (result.type !== "read_plan" || result.created)) {
6337
6412
  dirtyTrigger = {
6338
- type: message.type,
6413
+ type: message.type === "edit" || message.type === "update_task_status" ? "edit" : "write",
6339
6414
  sessionId: message.sessionId,
6340
6415
  toolCallId: message.requestId,
6341
6416
  ...message.target.type === "project" ? { projectId: message.target.projectId, branchName: message.target.branchName } : {}
@@ -6421,7 +6496,7 @@ async function startWorker(options) {
6421
6496
  process.stderr.write(`[r5d-worker] workspace recovery complete; reconnecting in ${reconnectDelayMs}ms
6422
6497
  `);
6423
6498
  setTimeout(() => {
6424
- void startWorker(options).catch((error) => {
6499
+ void startWorker(options, projectRuntime).catch((error) => {
6425
6500
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6426
6501
  `);
6427
6502
  process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
@@ -6440,7 +6515,7 @@ async function startWorker(options) {
6440
6515
  setTimeout(reconnect2, reconnectDelayMs);
6441
6516
  };
6442
6517
  const reconnect = () => {
6443
- void startWorker(options).catch((error) => {
6518
+ void startWorker(options, projectRuntime).catch((error) => {
6444
6519
  process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
6445
6520
  `);
6446
6521
  if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.107",
3
+ "version": "0.0.108",
4
4
  "type": "module"
5
5
  }
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import picomatch from "picomatch";
4
+ import { getTasksSectionRange, PLAN_TASK_LINE_REGEX } from "./plan-parser.mjs";
5
+ import { PLAN_EXTENSION } from "./plan-paths.mjs";
6
+ import { PLAN_TASK_ID_REGEX } from "./plan-parser.mjs";
7
+ const LEGACY_SCHEMA_FIELD_REGEX = /^\s*(type|status|parent|phase|discovered_problem)\s*:/m;
8
+ function lintPlanContent(planId, content) {
9
+ const errors = [];
10
+ const lines = content.split(/\r?\n/);
11
+ if (/^---(?:\r?\n|$)/.test(content)) {
12
+ errors.push({
13
+ planId,
14
+ severity: "error",
15
+ message: "YAML frontmatter is not supported. Use plain Markdown with a ## Tasks checklist.",
16
+ line: 1
17
+ });
18
+ }
19
+ if (LEGACY_SCHEMA_FIELD_REGEX.test(content)) {
20
+ errors.push({
21
+ planId,
22
+ severity: "error",
23
+ message: "Legacy plan schema fields are not supported. Remove type/status/parent/phase/discovered_problem metadata."
24
+ });
25
+ }
26
+ const titleLineIndex = lines.findIndex((line) => /^# .+\S\s*$/.test(line));
27
+ if (titleLineIndex === -1) {
28
+ errors.push({
29
+ planId,
30
+ severity: "error",
31
+ message: "Plan must start with or contain an H1 title, for example '# Implementation Plan'."
32
+ });
33
+ }
34
+ const range = getTasksSectionRange(content);
35
+ if (!range) {
36
+ errors.push({
37
+ planId,
38
+ severity: "error",
39
+ message: "Plan is missing a ## Tasks section."
40
+ });
41
+ return errors;
42
+ }
43
+ const taskIds = /* @__PURE__ */ new Set();
44
+ let taskCount = 0;
45
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
46
+ const line = lines[index] ?? "";
47
+ if (line.trim() === "") continue;
48
+ const match = line.match(PLAN_TASK_LINE_REGEX);
49
+ if (!match || !match[3].trim()) {
50
+ errors.push({
51
+ planId,
52
+ severity: "error",
53
+ line: index + 1,
54
+ message: 'Malformed task line. Use "- [ ] task-id: Description" or "- [x] task-id: Description".'
55
+ });
56
+ continue;
57
+ }
58
+ taskCount += 1;
59
+ const taskId = match[2];
60
+ if (taskIds.has(taskId)) {
61
+ errors.push({
62
+ planId,
63
+ severity: "error",
64
+ line: index + 1,
65
+ message: `Duplicate task id "${taskId}".`
66
+ });
67
+ }
68
+ taskIds.add(taskId);
69
+ }
70
+ if (taskCount === 0) {
71
+ errors.push({
72
+ planId,
73
+ severity: "error",
74
+ message: "Plan must include at least one task in the ## Tasks section.",
75
+ line: range.startIndex + 1
76
+ });
77
+ }
78
+ return errors;
79
+ }
80
+ async function lintPlans(plansDir, glob) {
81
+ const errors = [];
82
+ let entries;
83
+ try {
84
+ entries = await fs.readdir(plansDir, { withFileTypes: true });
85
+ } catch (error) {
86
+ if (error.code !== "ENOENT") throw error;
87
+ entries = [];
88
+ }
89
+ const matches = glob ? picomatch(glob) : null;
90
+ let lintedPlanCount = 0;
91
+ for (const entry of entries) {
92
+ if (!entry.isFile() || !entry.name.endsWith(PLAN_EXTENSION)) continue;
93
+ const planId = entry.name.slice(0, -PLAN_EXTENSION.length);
94
+ if (!PLAN_TASK_ID_REGEX.test(planId) || matches && !matches(entry.name) && !matches(planId)) continue;
95
+ const content = await fs.readFile(path.join(plansDir, entry.name), "utf8");
96
+ lintedPlanCount += 1;
97
+ errors.push(...lintPlanContent(planId, content));
98
+ }
99
+ return {
100
+ type: "plan_lint",
101
+ errors,
102
+ errorCount: errors.filter((error) => error.severity === "error").length,
103
+ warningCount: errors.filter((error) => error.severity === "warning").length,
104
+ lintedPlanCount
105
+ };
106
+ }
107
+ function formatPlanLintErrors(errors) {
108
+ if (errors.length === 0) return "";
109
+ const lines = ["## Plan Lint Errors", ""];
110
+ const byPlan = /* @__PURE__ */ new Map();
111
+ for (const error of errors) {
112
+ byPlan.set(error.planId, [...byPlan.get(error.planId) ?? [], error]);
113
+ }
114
+ for (const [planId, planErrors] of byPlan) {
115
+ lines.push(`### ${planId}.plan.md`);
116
+ for (const error of planErrors) {
117
+ const location = error.line ? ` line ${error.line}` : "";
118
+ lines.push(`- ${error.severity.toUpperCase()}${location}: ${error.message}`);
119
+ }
120
+ lines.push("");
121
+ }
122
+ lines.push("Fix these issues by editing the Markdown plan file.");
123
+ return lines.join("\n");
124
+ }
125
+ export {
126
+ formatPlanLintErrors,
127
+ lintPlanContent,
128
+ lintPlans
129
+ };
@@ -0,0 +1,94 @@
1
+ const PLAN_TASK_ID_REGEX = /^[a-z0-9][a-z0-9-]*$/;
2
+ const PLAN_TASK_LINE_REGEX = /^- \[( |x)\] ([a-z0-9][a-z0-9-]*): (.+)$/;
3
+ const PLAN_TASKS_HEADING_REGEX = /^## Tasks\s*$/;
4
+ function getPlanMetadata(content) {
5
+ const title = content.split(/\r?\n/).map((line) => line.match(/^# (.+)$/)?.[1]?.trim()).find((value) => Boolean(value));
6
+ return { title: title || "Untitled Plan" };
7
+ }
8
+ function getTasksSectionRange(content) {
9
+ const lines = content.split(/\r?\n/);
10
+ const startIndex = lines.findIndex((line) => PLAN_TASKS_HEADING_REGEX.test(line));
11
+ if (startIndex === -1) return null;
12
+ let endIndex = lines.length;
13
+ for (let index = startIndex + 1; index < lines.length; index += 1) {
14
+ if (/^##\s+/.test(lines[index] ?? "")) {
15
+ endIndex = index;
16
+ break;
17
+ }
18
+ }
19
+ return { startIndex, endIndex };
20
+ }
21
+ function parseMarkdownPlan(content, planId) {
22
+ const lines = content.split(/\r?\n/);
23
+ const { title } = getPlanMetadata(content);
24
+ const range = getTasksSectionRange(content);
25
+ const tasks = [];
26
+ if (range) {
27
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
28
+ const line = lines[index] ?? "";
29
+ const match = line.match(PLAN_TASK_LINE_REGEX);
30
+ if (!match) continue;
31
+ const description = match[3].trim();
32
+ if (!description) continue;
33
+ tasks.push({
34
+ id: match[2],
35
+ description,
36
+ checked: match[1] === "x",
37
+ lineNumber: index + 1
38
+ });
39
+ }
40
+ }
41
+ const currentTask = tasks.find((task) => !task.checked) ?? null;
42
+ return {
43
+ id: planId,
44
+ title,
45
+ content,
46
+ tasks,
47
+ currentTask,
48
+ complete: tasks.length > 0 && tasks.every((task) => task.checked),
49
+ tasksStartLine: range ? range.startIndex + 1 : null,
50
+ tasksEndLine: range ? range.endIndex : null
51
+ };
52
+ }
53
+ const parsePlan = parseMarkdownPlan;
54
+ function createPlanMarkdown(planId, title = formatPlanTitle(planId)) {
55
+ return `# ${title}
56
+
57
+ Brief implementation notes.
58
+
59
+ ## Tasks
60
+ `;
61
+ }
62
+ function updateTaskCheckbox(content, taskId, checked) {
63
+ if (!PLAN_TASK_ID_REGEX.test(taskId)) {
64
+ throw new Error(`Invalid task id: ${taskId}`);
65
+ }
66
+ const lines = content.split(/\r?\n/);
67
+ const range = getTasksSectionRange(content);
68
+ if (!range) {
69
+ throw new Error("Plan is missing a ## Tasks section.");
70
+ }
71
+ for (let index = range.startIndex + 1; index < range.endIndex; index += 1) {
72
+ const line = lines[index] ?? "";
73
+ const match = line.match(PLAN_TASK_LINE_REGEX);
74
+ if (!match || match[2] !== taskId) continue;
75
+ lines[index] = `- [${checked ? "x" : " "}] ${match[2]}: ${match[3].trim()}`;
76
+ return lines.join("\n");
77
+ }
78
+ throw new Error(`Task not found in plan: ${taskId}`);
79
+ }
80
+ function formatPlanTitle(planId) {
81
+ return planId.split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
82
+ }
83
+ export {
84
+ PLAN_TASKS_HEADING_REGEX,
85
+ PLAN_TASK_ID_REGEX,
86
+ PLAN_TASK_LINE_REGEX,
87
+ createPlanMarkdown,
88
+ formatPlanTitle,
89
+ getPlanMetadata,
90
+ getTasksSectionRange,
91
+ parseMarkdownPlan,
92
+ parsePlan,
93
+ updateTaskCheckbox
94
+ };
@@ -0,0 +1,25 @@
1
+ import { PLAN_TASK_ID_REGEX } from "./plan-parser.mjs";
2
+ const PLAN_EXTENSION = ".plan.md";
3
+ function validatePlanId(planId) {
4
+ if (!PLAN_TASK_ID_REGEX.test(planId)) throw new Error(`Invalid plan id: ${planId}`);
5
+ }
6
+ function assertPlanTarget(target) {
7
+ if (target.type === "workspace" && target.rootProfile !== "visible_projects") {
8
+ throw new Error("Ordinary plans are unavailable in canonical synchronization sessions");
9
+ }
10
+ }
11
+ function getWorkerPlansPath(target) {
12
+ assertPlanTarget(target);
13
+ return target.type === "project" ? "$R5D_ROOT/plans/$R5D_PROJECT_ID/$R5D_BRANCH_NAME" : "$R5D_ROOT/plans/workspace";
14
+ }
15
+ function getWorkerPlanPath(target, planId) {
16
+ validatePlanId(planId);
17
+ return `${getWorkerPlansPath(target)}/${planId}${PLAN_EXTENSION}`;
18
+ }
19
+ export {
20
+ PLAN_EXTENSION,
21
+ assertPlanTarget,
22
+ getWorkerPlanPath,
23
+ getWorkerPlansPath,
24
+ validatePlanId
25
+ };
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import type { WorkerLintPlansResult, WorkerUpdatePlanTaskResult } from "./plan-paths";
2
3
  import { Database } from "bun:sqlite";
3
4
  import type { WorkerGitIdentity } from "./git-identity";
4
5
  import { configureGitHubRegistryAuthFiles, type PreparedPrivateAuthFileGeneration } from "./registry-auth";
@@ -424,6 +425,27 @@ type WorkerServerMessage = {
424
425
  } | {
425
426
  type: "pty_close";
426
427
  ptyId: string;
428
+ } | {
429
+ type: "read_plan";
430
+ requestId: string;
431
+ target: WorkerSessionTarget;
432
+ sessionId?: string;
433
+ planId: string;
434
+ createIfMissing: boolean;
435
+ } | {
436
+ type: "lint_plans";
437
+ requestId: string;
438
+ target: WorkerSessionTarget;
439
+ sessionId?: string;
440
+ glob?: string;
441
+ } | {
442
+ type: "update_task_status";
443
+ requestId: string;
444
+ target: WorkerSessionTarget;
445
+ sessionId?: string;
446
+ planId: string;
447
+ taskId: string;
448
+ checked: boolean;
427
449
  } | {
428
450
  type: "read";
429
451
  requestId: string;
@@ -514,6 +536,11 @@ type WorkerGitHubCredential = {
514
536
  token: string;
515
537
  missingScopes: Array<"repo" | "read:org" | "gist" | "workflow" | "read:packages" | "write:packages">;
516
538
  };
539
+ type WorkerReadPlanResult = {
540
+ type: "read_plan";
541
+ read: WorkerReadFileResult;
542
+ created: boolean;
543
+ };
517
544
  type WorkerWriteFileResult = {
518
545
  type: "write";
519
546
  file: string;
@@ -587,7 +614,10 @@ type WorkerCodeReadResult = {
587
614
  mtime: string;
588
615
  base64: string;
589
616
  };
590
- type WorkerOperationResult = WorkerReadFileResult | WorkerWriteFileResult | WorkerEditFileResult | WorkerGrepResult | WorkerFindResult | WorkerLsResult | WorkerViewFileBytesResult | WorkerExecStdinAck | WorkerCreateProjectBranchResult | WorkerDeleteProjectBranchResult | WorkerCodeListResult | WorkerCodeReadResult;
617
+ type WorkerOperationResult = WorkerReadFileResult | WorkerReadPlanResult | WorkerLintPlansResult | WorkerUpdatePlanTaskResult | WorkerWriteFileResult | WorkerEditFileResult | WorkerGrepResult | WorkerFindResult | WorkerLsResult | WorkerViewFileBytesResult | WorkerExecStdinAck | WorkerCreateProjectBranchResult | WorkerDeleteProjectBranchResult | WorkerCodeListResult | WorkerCodeReadResult;
618
+ type WorkerOperationServerMessage = Extract<WorkerServerMessage, {
619
+ type: "read_plan" | "lint_plans" | "update_task_status" | "read" | "write" | "edit" | "grep" | "find" | "ls" | "view_file_bytes" | "code_list" | "code_read";
620
+ }>;
591
621
  declare class WorkspaceSyncAdmissionChangedError extends Error {
592
622
  readonly name = "WorkspaceSyncAdmissionChangedError";
593
623
  }
@@ -916,6 +946,7 @@ export declare const workerGitSecurityTestHarness: {
916
946
  workspaceSyncFailureHydrationIsSafe: typeof workspaceSyncFailureHydrationIsSafe;
917
947
  assertWorkerChildAdmission: typeof assertWorkerChildAdmission;
918
948
  executeWriteFileOperation: typeof executeWriteFileOperation;
949
+ executeOperation: typeof executeOperation;
919
950
  executeEditFileOperation: typeof executeEditFileOperation;
920
951
  terminateCredentialBearingChildren: typeof terminateCredentialBearingChildren;
921
952
  terminateCredentialBearingChildrenWithRetention: typeof terminateCredentialBearingChildrenWithRetention;
@@ -1010,6 +1041,14 @@ export declare function listWorkerDirectory(branchPath: string, inputPath?: stri
1010
1041
  export declare function listWorkerCodeDirectory(branchPath: string, inputPath: string): WorkerCodeListResult;
1011
1042
  export declare function readWorkerCodeFile(branchPath: string, inputPath: string): WorkerCodeReadResult;
1012
1043
  export declare function readWorkerImageFile(branchPath: string, filePath: string, builtInPaths?: WorkerBuiltInToolPaths): WorkerViewFileBytesResult;
1044
+ declare function executeOperation(input: {
1045
+ message: WorkerOperationServerMessage;
1046
+ resolvedTarget: ResolvedWorkerSessionTarget;
1047
+ baseUrl: string;
1048
+ token: string;
1049
+ rootDir: string;
1050
+ assertAdmission: () => void;
1051
+ }): Promise<WorkerOperationResult>;
1013
1052
  declare function targetProcessEnv(rootDir: string, target: WorkerSessionTarget): Record<string, string>;
1014
1053
  export declare function prepareShellEnvForTarget(input: {
1015
1054
  target: WorkerSessionTarget;
@@ -0,0 +1,16 @@
1
+ export interface PlanLintError {
2
+ planId: string;
3
+ message: string;
4
+ severity: "warning" | "error";
5
+ line?: number;
6
+ }
7
+ export interface PlanLintResult {
8
+ type: "plan_lint";
9
+ errors: PlanLintError[];
10
+ errorCount: number;
11
+ warningCount: number;
12
+ lintedPlanCount: number;
13
+ }
14
+ export declare function lintPlanContent(planId: string, content: string): PlanLintError[];
15
+ export declare function lintPlans(plansDir: string, glob?: string): Promise<PlanLintResult>;
16
+ export declare function formatPlanLintErrors(errors: PlanLintError[]): string;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Markdown plan parser.
3
+ *
4
+ * Plans are plain Markdown files. The only structured region is the first
5
+ * "## Tasks" section, which contains checkbox task lines.
6
+ */
7
+ export declare const PLAN_TASK_ID_REGEX: RegExp;
8
+ export declare const PLAN_TASK_LINE_REGEX: RegExp;
9
+ export declare const PLAN_TASKS_HEADING_REGEX: RegExp;
10
+ export interface PlanTask {
11
+ id: string;
12
+ description: string;
13
+ checked: boolean;
14
+ lineNumber: number;
15
+ }
16
+ export interface ParsedPlan {
17
+ id: string;
18
+ title: string;
19
+ content: string;
20
+ tasks: PlanTask[];
21
+ currentTask: PlanTask | null;
22
+ complete: boolean;
23
+ tasksStartLine: number | null;
24
+ tasksEndLine: number | null;
25
+ }
26
+ export type ParsedMarkdownPlan = ParsedPlan;
27
+ export declare function getPlanMetadata(content: string): {
28
+ title: string;
29
+ };
30
+ export declare function getTasksSectionRange(content: string): {
31
+ startIndex: number;
32
+ endIndex: number;
33
+ } | null;
34
+ export declare function parseMarkdownPlan(content: string, planId: string): ParsedPlan;
35
+ export declare const parsePlan: typeof parseMarkdownPlan;
36
+ export declare function createPlanMarkdown(planId: string, title?: string): string;
37
+ export declare function updateTaskCheckbox(content: string, taskId: string, checked: boolean): string;
38
+ export declare function formatPlanTitle(planId: string): string;
@@ -0,0 +1,36 @@
1
+ type PlanTarget = {
2
+ type: "project";
3
+ } | {
4
+ type: "workspace";
5
+ rootProfile: "visible_projects" | "canonical_sync";
6
+ };
7
+ export declare const PLAN_EXTENSION = ".plan.md";
8
+ export declare function validatePlanId(planId: string): void;
9
+ export declare function assertPlanTarget(target: PlanTarget): void;
10
+ export declare function getWorkerPlansPath(target: PlanTarget): string;
11
+ /** The same worker path is used in prompts, injected reads, and file tools. */
12
+ export declare function getWorkerPlanPath(target: PlanTarget, planId: string): string;
13
+ export type WorkerReadPlanInput = {
14
+ planId: string;
15
+ createIfMissing: boolean;
16
+ };
17
+ export type WorkerLintPlansInput = {
18
+ glob?: string;
19
+ };
20
+ export type WorkerUpdatePlanTaskInput = {
21
+ planId: string;
22
+ taskId: string;
23
+ checked: boolean;
24
+ };
25
+ export type WorkerLintPlansResult = {
26
+ type: "lint_plans";
27
+ content: string;
28
+ };
29
+ export type WorkerUpdatePlanTaskResult = {
30
+ type: "update_task_status";
31
+ planId: string;
32
+ taskId: string;
33
+ checked: boolean;
34
+ message: string;
35
+ };
36
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.107",
3
+ "version": "0.0.108",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",
@@ -21,7 +21,8 @@
21
21
  "r5d-worker": "dist/cjs/main.cjs"
22
22
  },
23
23
  "dependencies": {
24
- "node-pty": "^1.1.0"
24
+ "node-pty": "^1.1.0",
25
+ "picomatch": "^4.0.3"
25
26
  },
26
27
  "files": [
27
28
  "dist",