agentplane 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/.build-manifest.json +106 -96
  2. package/dist/adapters/task-backend/task-backend-adapter.d.ts +2 -2
  3. package/dist/adapters/task-backend/task-backend-adapter.d.ts.map +1 -1
  4. package/dist/adapters/task-backend/task-backend-adapter.js +2 -2
  5. package/dist/backends/task-backend/local-backend.d.ts +7 -5
  6. package/dist/backends/task-backend/local-backend.d.ts.map +1 -1
  7. package/dist/backends/task-backend/local-backend.js +79 -7
  8. package/dist/backends/task-backend/redmine/env.d.ts +1 -1
  9. package/dist/backends/task-backend/redmine/env.d.ts.map +1 -1
  10. package/dist/backends/task-backend/redmine/env.js +3 -0
  11. package/dist/backends/task-backend/redmine/inspect.d.ts +11 -0
  12. package/dist/backends/task-backend/redmine/inspect.d.ts.map +1 -0
  13. package/dist/backends/task-backend/redmine/inspect.js +75 -0
  14. package/dist/backends/task-backend/redmine/mapping.d.ts.map +1 -1
  15. package/dist/backends/task-backend/redmine/mapping.js +21 -2
  16. package/dist/backends/task-backend/redmine/state.d.ts +17 -0
  17. package/dist/backends/task-backend/redmine/state.d.ts.map +1 -0
  18. package/dist/backends/task-backend/redmine/state.js +95 -0
  19. package/dist/backends/task-backend/redmine-backend.d.ts +10 -16
  20. package/dist/backends/task-backend/redmine-backend.d.ts.map +1 -1
  21. package/dist/backends/task-backend/redmine-backend.js +205 -15
  22. package/dist/backends/task-backend/shared/constants.d.ts +1 -1
  23. package/dist/backends/task-backend/shared/constants.js +1 -1
  24. package/dist/backends/task-backend/shared/record.d.ts.map +1 -1
  25. package/dist/backends/task-backend/shared/record.js +20 -1
  26. package/dist/backends/task-backend/shared/types.d.ts +42 -4
  27. package/dist/backends/task-backend/shared/types.d.ts.map +1 -1
  28. package/dist/backends/task-backend/shared.d.ts +1 -1
  29. package/dist/backends/task-backend/shared.d.ts.map +1 -1
  30. package/dist/backends/task-backend.d.ts +1 -1
  31. package/dist/backends/task-backend.d.ts.map +1 -1
  32. package/dist/backends/task-index.d.ts.map +1 -1
  33. package/dist/backends/task-index.js +1 -0
  34. package/dist/cli/run-cli/command-catalog/project.d.ts +1 -1
  35. package/dist/cli/run-cli/command-catalog/project.d.ts.map +1 -1
  36. package/dist/cli/run-cli/command-catalog/project.js +3 -1
  37. package/dist/cli/run-cli/command-catalog.d.ts +1 -1
  38. package/dist/cli/run-cli/command-catalog.d.ts.map +1 -1
  39. package/dist/cli/run-cli/commands/init/write-env.d.ts.map +1 -1
  40. package/dist/cli/run-cli/commands/init/write-env.js +12 -0
  41. package/dist/cli/run-cli.test-helpers.d.ts.map +1 -1
  42. package/dist/cli/run-cli.test-helpers.js +2 -0
  43. package/dist/commands/backend/sync.command.d.ts +5 -1
  44. package/dist/commands/backend/sync.command.d.ts.map +1 -1
  45. package/dist/commands/backend/sync.command.js +67 -3
  46. package/dist/commands/backend.d.ts +22 -0
  47. package/dist/commands/backend.d.ts.map +1 -1
  48. package/dist/commands/backend.js +110 -1
  49. package/dist/commands/commit.spec.d.ts.map +1 -1
  50. package/dist/commands/commit.spec.js +30 -6
  51. package/dist/commands/doctor/workspace.d.ts +8 -0
  52. package/dist/commands/doctor/workspace.d.ts.map +1 -1
  53. package/dist/commands/doctor/workspace.js +127 -3
  54. package/dist/commands/guard/commit.command.d.ts.map +1 -1
  55. package/dist/commands/guard/commit.command.js +30 -6
  56. package/dist/commands/guard/impl/allow.d.ts +4 -0
  57. package/dist/commands/guard/impl/allow.d.ts.map +1 -1
  58. package/dist/commands/guard/impl/allow.js +14 -3
  59. package/dist/commands/guard/impl/commands.d.ts.map +1 -1
  60. package/dist/commands/guard/impl/commands.js +11 -2
  61. package/dist/commands/shared/task-backend.d.ts +1 -1
  62. package/dist/commands/shared/task-backend.d.ts.map +1 -1
  63. package/dist/commands/shared/task-backend.js +9 -0
  64. package/dist/commands/shared/task-store.d.ts +61 -2
  65. package/dist/commands/shared/task-store.d.ts.map +1 -1
  66. package/dist/commands/shared/task-store.js +298 -60
  67. package/dist/commands/task/block.d.ts.map +1 -1
  68. package/dist/commands/task/block.js +58 -37
  69. package/dist/commands/task/close-shared.d.ts.map +1 -1
  70. package/dist/commands/task/close-shared.js +17 -20
  71. package/dist/commands/task/comment.d.ts.map +1 -1
  72. package/dist/commands/task/comment.js +14 -19
  73. package/dist/commands/task/derive.command.d.ts +1 -0
  74. package/dist/commands/task/derive.command.d.ts.map +1 -1
  75. package/dist/commands/task/derive.command.js +15 -2
  76. package/dist/commands/task/derive.d.ts +1 -0
  77. package/dist/commands/task/derive.d.ts.map +1 -1
  78. package/dist/commands/task/derive.js +27 -4
  79. package/dist/commands/task/doc.d.ts.map +1 -1
  80. package/dist/commands/task/doc.js +16 -5
  81. package/dist/commands/task/finish.d.ts.map +1 -1
  82. package/dist/commands/task/finish.js +41 -41
  83. package/dist/commands/task/migrate-doc.d.ts +15 -0
  84. package/dist/commands/task/migrate-doc.d.ts.map +1 -1
  85. package/dist/commands/task/migrate-doc.js +126 -35
  86. package/dist/commands/task/new.d.ts.map +1 -1
  87. package/dist/commands/task/new.js +3 -1
  88. package/dist/commands/task/plan.js +28 -28
  89. package/dist/commands/task/set-status.d.ts.map +1 -1
  90. package/dist/commands/task/set-status.js +104 -61
  91. package/dist/commands/task/shared/dependencies.d.ts +1 -0
  92. package/dist/commands/task/shared/dependencies.d.ts.map +1 -1
  93. package/dist/commands/task/shared/dependencies.js +10 -0
  94. package/dist/commands/task/shared/docs.js +1 -1
  95. package/dist/commands/task/shared/transitions.d.ts +17 -0
  96. package/dist/commands/task/shared/transitions.d.ts.map +1 -1
  97. package/dist/commands/task/shared/transitions.js +20 -7
  98. package/dist/commands/task/shared.d.ts +2 -2
  99. package/dist/commands/task/shared.d.ts.map +1 -1
  100. package/dist/commands/task/shared.js +2 -2
  101. package/dist/commands/task/start.d.ts.map +1 -1
  102. package/dist/commands/task/start.js +33 -28
  103. package/dist/commands/task/verify-record.d.ts.map +1 -1
  104. package/dist/commands/task/verify-record.js +32 -32
  105. package/dist/commands/upgrade/apply.d.ts +2 -0
  106. package/dist/commands/upgrade/apply.d.ts.map +1 -1
  107. package/dist/commands/upgrade/apply.js +33 -1
  108. package/dist/commands/upgrade.command.d.ts.map +1 -1
  109. package/dist/commands/upgrade.command.js +25 -0
  110. package/dist/commands/upgrade.d.ts +1 -0
  111. package/dist/commands/upgrade.d.ts.map +1 -1
  112. package/dist/commands/upgrade.js +34 -0
  113. package/dist/policy/rules/allowlist.d.ts.map +1 -1
  114. package/dist/policy/rules/allowlist.js +12 -9
  115. package/dist/ports/task-backend-port.d.ts +2 -2
  116. package/dist/ports/task-backend-port.d.ts.map +1 -1
  117. package/dist/shared/protected-paths.d.ts +10 -0
  118. package/dist/shared/protected-paths.d.ts.map +1 -1
  119. package/dist/shared/protected-paths.js +33 -0
  120. package/package.json +2 -2
@@ -1,41 +1,52 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { ensureDocSections, docChanged, extractTaskDoc, mergeTaskDoc, parseTaskReadme, renderTaskReadme, setMarkdownSection, } from "@agentplaneorg/core";
3
+ import { docChanged, extractTaskDoc, mergeTaskDoc, parseTaskReadme, renderTaskReadme, renderTaskDocFromSections, taskDocToSectionMap, } from "@agentplaneorg/core";
4
4
  import { LocalBackend, taskRecordToData, } from "../../backends/task-backend.js";
5
5
  import { exitCodeForError } from "../../cli/exit-codes.js";
6
6
  import { CliError } from "../../shared/errors.js";
7
7
  import { writeTextIfChanged } from "../../shared/write-if-changed.js";
8
8
  import { resolveDocUpdatedBy, taskDataToFrontmatter } from "./task-backend.js";
9
+ export function setTaskFieldsIntent(task) {
10
+ return { kind: "set-task-fields", task };
11
+ }
12
+ export function appendTaskCommentsIntent(comments) {
13
+ return { kind: "append-comments", comments };
14
+ }
15
+ export function appendTaskCommentIntent(comment) {
16
+ return appendTaskCommentsIntent([comment]);
17
+ }
18
+ export function appendTaskEventsIntent(events) {
19
+ return { kind: "append-events", events };
20
+ }
21
+ export function appendTaskEventIntent(event) {
22
+ return appendTaskEventsIntent([event]);
23
+ }
24
+ export function replaceTaskDocIntent(opts) {
25
+ return { kind: "replace-doc", ...opts };
26
+ }
27
+ export function setTaskSectionIntent(opts) {
28
+ return { kind: "set-section", ...opts };
29
+ }
30
+ export function touchTaskDocMetaIntent(opts = {}) {
31
+ return { kind: "touch-doc-meta", ...opts };
32
+ }
9
33
  function taskReadmePath(ctx, taskId) {
10
34
  return path.join(ctx.resolvedProject.gitRoot, ctx.config.paths.workflow_dir, taskId, "README.md");
11
35
  }
12
- function normalizeTaskDocVersion(value, fallback = 2) {
36
+ function normalizeTaskDocVersion(value, fallback = 3) {
13
37
  return value === 3 ? 3 : value === 2 ? 2 : fallback;
14
38
  }
39
+ function normalizeTaskRevision(value, fallback = 1) {
40
+ return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
41
+ }
42
+ function readStoredTaskRevision(value) {
43
+ return Number.isInteger(value) && Number(value) > 0 ? Number(value) : null;
44
+ }
15
45
  function normalizeDocComparison(text) {
16
46
  return String(text ?? "")
17
47
  .replaceAll("\r\n", "\n")
18
48
  .trim();
19
49
  }
20
- function extractDocSectionText(doc, sectionName) {
21
- const lines = doc.replaceAll("\r\n", "\n").split("\n");
22
- let capturing = false;
23
- const out = [];
24
- for (const line of lines) {
25
- const match = /^##\s+(.*)$/.exec(line.trim());
26
- if (match) {
27
- if (capturing)
28
- break;
29
- capturing = (match[1] ?? "").trim() === sectionName;
30
- continue;
31
- }
32
- if (capturing)
33
- out.push(line);
34
- }
35
- if (!capturing)
36
- return null;
37
- return out.join("\n").trimEnd();
38
- }
39
50
  function normalizeComments(task) {
40
51
  return Array.isArray(task.comments)
41
52
  ? task.comments.filter((item) => !!item && typeof item.author === "string" && typeof item.body === "string")
@@ -79,6 +90,20 @@ function throwTaskDocConflict(opts) {
79
90
  },
80
91
  });
81
92
  }
93
+ function throwTaskRevisionConflict(opts) {
94
+ throw new CliError({
95
+ exitCode: exitCodeForError("E_VALIDATION"),
96
+ code: "E_VALIDATION",
97
+ message: `Task revision changed concurrently: ${opts.taskId} ` +
98
+ `(expected revision ${opts.expectedRevision}, current revision ${opts.currentRevision})`,
99
+ context: {
100
+ task_id: opts.taskId,
101
+ expected_revision: opts.expectedRevision,
102
+ current_revision: opts.currentRevision,
103
+ reason_code: "task_revision_conflict",
104
+ },
105
+ });
106
+ }
82
107
  function applyTaskDocPatch(opts) {
83
108
  if (opts.patch.kind === "replace-doc") {
84
109
  if (opts.patch.expectedCurrentDoc !== undefined) {
@@ -88,17 +113,209 @@ function applyTaskDocPatch(opts) {
88
113
  throwTaskDocConflict({ taskId: opts.taskId });
89
114
  }
90
115
  }
91
- return opts.patch.doc;
116
+ return renderTaskDocFromSections(taskDocToSectionMap(opts.patch.doc));
117
+ }
118
+ const sections = taskDocToSectionMap(opts.currentDocRaw);
119
+ for (const requiredSection of opts.patch.requiredSections) {
120
+ if (!(requiredSection in sections))
121
+ sections[requiredSection] = "";
92
122
  }
93
- const baseDoc = ensureDocSections(opts.currentDocRaw, opts.patch.requiredSections);
94
123
  if (opts.patch.expectedCurrentText !== undefined) {
95
- const currentSection = normalizeDocComparison(extractDocSectionText(baseDoc, opts.patch.section));
124
+ const currentSection = normalizeDocComparison(sections[opts.patch.section] ?? null);
96
125
  const expectedSection = normalizeDocComparison(opts.patch.expectedCurrentText);
97
126
  if (currentSection !== expectedSection) {
98
127
  throwTaskSectionConflict({ taskId: opts.taskId, section: opts.patch.section });
99
128
  }
100
129
  }
101
- return ensureDocSections(setMarkdownSection(baseDoc, opts.patch.section, opts.patch.text), opts.patch.requiredSections);
130
+ sections[opts.patch.section] = opts.patch.text.replaceAll("\r\n", "\n").trimEnd();
131
+ return renderTaskDocFromSections(sections);
132
+ }
133
+ function normalizeTaskStoreIntents(intents) {
134
+ if (!intents)
135
+ return [];
136
+ if (Array.isArray(intents)) {
137
+ return intents.filter((intent) => intent != null);
138
+ }
139
+ return [intents];
140
+ }
141
+ function patchToIntents(patch) {
142
+ if (!patch)
143
+ return [];
144
+ const intents = [];
145
+ if (patch.task) {
146
+ intents.push(setTaskFieldsIntent(patch.task));
147
+ }
148
+ if (patch.appendComments && patch.appendComments.length > 0) {
149
+ intents.push(appendTaskCommentsIntent(patch.appendComments));
150
+ }
151
+ if (patch.appendEvents && patch.appendEvents.length > 0) {
152
+ intents.push(appendTaskEventsIntent(patch.appendEvents));
153
+ }
154
+ if (patch.doc) {
155
+ intents.push(patch.doc.kind === "replace-doc"
156
+ ? replaceTaskDocIntent({
157
+ doc: patch.doc.doc,
158
+ expectedCurrentDoc: patch.doc.expectedCurrentDoc,
159
+ })
160
+ : setTaskSectionIntent({
161
+ section: patch.doc.section,
162
+ text: patch.doc.text,
163
+ requiredSections: patch.doc.requiredSections,
164
+ expectedCurrentText: patch.doc.expectedCurrentText,
165
+ }));
166
+ }
167
+ if (patch.docMeta && (patch.doc !== undefined || patch.docMeta.touch === true)) {
168
+ intents.push(touchTaskDocMetaIntent({
169
+ updatedBy: patch.docMeta.updatedBy,
170
+ version: patch.docMeta.version,
171
+ }));
172
+ }
173
+ return intents;
174
+ }
175
+ export function taskStorePatchFromIntents(intents) {
176
+ const normalized = normalizeTaskStoreIntents(intents);
177
+ if (normalized.length === 0)
178
+ return null;
179
+ const patch = {};
180
+ for (const intent of normalized) {
181
+ switch (intent.kind) {
182
+ case "set-task-fields": {
183
+ patch.task = patch.task ? { ...patch.task, ...intent.task } : { ...intent.task };
184
+ break;
185
+ }
186
+ case "append-comments": {
187
+ if (intent.comments.length > 0) {
188
+ patch.appendComments = [...(patch.appendComments ?? []), ...intent.comments];
189
+ }
190
+ break;
191
+ }
192
+ case "append-events": {
193
+ if (intent.events.length > 0) {
194
+ patch.appendEvents = [...(patch.appendEvents ?? []), ...intent.events];
195
+ }
196
+ break;
197
+ }
198
+ case "replace-doc": {
199
+ const docPatch = {
200
+ kind: "replace-doc",
201
+ doc: intent.doc,
202
+ };
203
+ if (intent.expectedCurrentDoc === undefined) {
204
+ patch.doc = docPatch;
205
+ break;
206
+ }
207
+ docPatch.expectedCurrentDoc = intent.expectedCurrentDoc;
208
+ patch.doc = docPatch;
209
+ break;
210
+ }
211
+ case "set-section": {
212
+ const sectionPatch = {
213
+ kind: "set-section",
214
+ section: intent.section,
215
+ text: intent.text,
216
+ requiredSections: [...intent.requiredSections],
217
+ };
218
+ if (intent.expectedCurrentText === undefined) {
219
+ patch.doc = sectionPatch;
220
+ break;
221
+ }
222
+ sectionPatch.expectedCurrentText = intent.expectedCurrentText;
223
+ patch.doc = sectionPatch;
224
+ break;
225
+ }
226
+ case "touch-doc-meta": {
227
+ patch.docMeta = {
228
+ touch: true,
229
+ updatedBy: intent.updatedBy ?? patch.docMeta?.updatedBy,
230
+ version: intent.version ?? patch.docMeta?.version,
231
+ };
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ return patch;
237
+ }
238
+ export async function mutateTaskStore(store, taskId, builder, opts = {}) {
239
+ if (typeof store.mutate === "function") {
240
+ return await store.mutate(taskId, builder, opts);
241
+ }
242
+ return await store.patch(taskId, async (current) => taskStorePatchFromIntents(await builder(current)), opts);
243
+ }
244
+ function applyTaskStoreIntents(entry, intents) {
245
+ if (intents.length === 0)
246
+ return { ...entry.task };
247
+ const current = entry.task;
248
+ const next = { ...current };
249
+ let touchDoc = false;
250
+ let docMetaUpdatedBy;
251
+ let docMetaVersion;
252
+ for (const intent of intents) {
253
+ switch (intent.kind) {
254
+ case "set-task-fields": {
255
+ Object.assign(next, intent.task);
256
+ break;
257
+ }
258
+ case "append-comments": {
259
+ if (intent.comments.length > 0) {
260
+ next.comments = [...normalizeComments(next), ...intent.comments];
261
+ }
262
+ break;
263
+ }
264
+ case "append-events": {
265
+ if (intent.events.length > 0) {
266
+ next.events = [...normalizeEvents(next), ...intent.events];
267
+ }
268
+ break;
269
+ }
270
+ case "replace-doc": {
271
+ next.doc = applyTaskDocPatch({
272
+ taskId: current.id,
273
+ currentDocRaw: String(next.doc ?? ""),
274
+ patch: {
275
+ kind: "replace-doc",
276
+ doc: intent.doc,
277
+ expectedCurrentDoc: intent.expectedCurrentDoc,
278
+ },
279
+ });
280
+ next.sections = taskDocToSectionMap(String(next.doc ?? ""));
281
+ touchDoc = true;
282
+ break;
283
+ }
284
+ case "set-section": {
285
+ next.doc = applyTaskDocPatch({
286
+ taskId: current.id,
287
+ currentDocRaw: String(next.doc ?? ""),
288
+ patch: {
289
+ kind: "set-section",
290
+ section: intent.section,
291
+ text: intent.text,
292
+ requiredSections: intent.requiredSections,
293
+ expectedCurrentText: intent.expectedCurrentText,
294
+ },
295
+ });
296
+ next.sections = taskDocToSectionMap(String(next.doc ?? ""));
297
+ touchDoc = true;
298
+ break;
299
+ }
300
+ case "touch-doc-meta": {
301
+ touchDoc = true;
302
+ if (intent.updatedBy !== undefined) {
303
+ docMetaUpdatedBy = intent.updatedBy;
304
+ }
305
+ if (intent.version !== undefined) {
306
+ docMetaVersion = intent.version;
307
+ }
308
+ break;
309
+ }
310
+ }
311
+ }
312
+ if (touchDoc) {
313
+ const currentDocVersion = normalizeTaskDocVersion(entry.parsed.frontmatter.doc_version);
314
+ next.doc_version = normalizeTaskDocVersion(docMetaVersion ?? next.doc_version, currentDocVersion);
315
+ next.doc_updated_at = new Date().toISOString();
316
+ next.doc_updated_by = docMetaUpdatedBy ?? resolveDocUpdatedBy(next);
317
+ }
318
+ return next;
102
319
  }
103
320
  async function readTaskReadmeCached(opts) {
104
321
  const readmePath = taskReadmePath(opts.ctx, opts.taskId);
@@ -126,7 +343,7 @@ async function readTaskReadmeCached(opts) {
126
343
  body: parsed.body,
127
344
  readmePath,
128
345
  });
129
- return { task, readmePath, mtimeMs: st.mtimeMs, parsed };
346
+ return { task, readmePath, mtimeMs: st.mtimeMs, parsed, rawText: text };
130
347
  }
131
348
  async function ensureUnchangedOnDisk(opts) {
132
349
  const st = await stat(opts.readmePath);
@@ -138,6 +355,18 @@ async function ensureUnchangedOnDisk(opts) {
138
355
  });
139
356
  }
140
357
  }
358
+ async function didReadmeChangeOnDisk(opts) {
359
+ try {
360
+ const st = await stat(opts.readmePath);
361
+ return st.mtimeMs !== opts.expectedMtimeMs;
362
+ }
363
+ catch (err) {
364
+ const code = err?.code;
365
+ if (code === "ENOENT")
366
+ return true;
367
+ throw err;
368
+ }
369
+ }
141
370
  export class TaskStore {
142
371
  ctx;
143
372
  cache = new Map();
@@ -169,45 +398,47 @@ export class TaskStore {
169
398
  this.cache.set(key, load);
170
399
  return await load;
171
400
  }
172
- async update(taskId, updater) {
173
- return await this.runWithRetry(taskId, async (entry) => {
401
+ async update(taskId, updater, opts = {}) {
402
+ return await this.runWithRetry(taskId, opts, async (entry) => {
174
403
  return await updater({ ...entry.task });
175
404
  });
176
405
  }
177
- async patch(taskId, builder) {
178
- return await this.runWithRetry(taskId, async (entry) => {
179
- const patch = await builder({ ...entry.task });
180
- if (!patch)
181
- return { ...entry.task };
182
- const current = entry.task;
183
- const next = patch.task ? { ...current, ...patch.task } : { ...current };
184
- if (patch.appendComments && patch.appendComments.length > 0) {
185
- next.comments = [...normalizeComments(current), ...patch.appendComments];
186
- }
187
- if (patch.appendEvents && patch.appendEvents.length > 0) {
188
- next.events = [...normalizeEvents(current), ...patch.appendEvents];
189
- }
190
- if (patch.doc) {
191
- next.doc = applyTaskDocPatch({
192
- taskId: current.id,
193
- currentDocRaw: String(current.doc ?? ""),
194
- patch: patch.doc,
195
- });
196
- }
197
- const touchDoc = patch.doc !== undefined || patch.docMeta?.touch === true;
198
- if (touchDoc) {
199
- const currentDocVersion = normalizeTaskDocVersion(entry.parsed.frontmatter.doc_version);
200
- next.doc_version = normalizeTaskDocVersion(patch.docMeta?.version ?? current.doc_version, currentDocVersion);
201
- next.doc_updated_at = new Date().toISOString();
202
- next.doc_updated_by = patch.docMeta?.updatedBy ?? resolveDocUpdatedBy(next);
203
- }
204
- return next;
406
+ async patch(taskId, builder, opts = {}) {
407
+ return await this.mutate(taskId, async (current) => patchToIntents(await builder(current)), opts);
408
+ }
409
+ async mutate(taskId, builder, opts = {}) {
410
+ return await this.runWithRetry(taskId, opts, async (entry) => {
411
+ const intents = normalizeTaskStoreIntents(await builder({ ...entry.task }));
412
+ return applyTaskStoreIntents(entry, intents);
205
413
  });
206
414
  }
207
- async runWithRetry(taskId, computeNext) {
415
+ async runWithRetry(taskId, opts, computeNext) {
208
416
  for (let attempt = 0; attempt < 2; attempt++) {
209
417
  const entry = await this.getCached(taskId);
210
- const next = await computeNext(entry);
418
+ if (opts.expectedRevision !== undefined) {
419
+ const expectedRevision = normalizeTaskRevision(opts.expectedRevision);
420
+ const currentRevision = normalizeTaskRevision(entry.task.revision);
421
+ if (currentRevision !== expectedRevision) {
422
+ throwTaskRevisionConflict({ taskId, expectedRevision, currentRevision });
423
+ }
424
+ }
425
+ let next;
426
+ try {
427
+ next = await computeNext(entry);
428
+ }
429
+ catch (err) {
430
+ if (attempt === 0 &&
431
+ err instanceof CliError &&
432
+ err.code === "E_VALIDATION" &&
433
+ (await didReadmeChangeOnDisk({
434
+ readmePath: entry.readmePath,
435
+ expectedMtimeMs: entry.mtimeMs,
436
+ }))) {
437
+ this.cache.delete(taskId);
438
+ continue;
439
+ }
440
+ throw err;
441
+ }
211
442
  try {
212
443
  return await this.writeNextTask(taskId, entry, next);
213
444
  }
@@ -250,12 +481,19 @@ export class TaskStore {
250
481
  frontmatter.doc_updated_by.trim() === "") {
251
482
  frontmatter.doc_updated_by = resolveDocUpdatedBy(next);
252
483
  }
253
- const rendered = renderTaskReadme(frontmatter, body);
484
+ const storedRevision = readStoredTaskRevision(entry.parsed.frontmatter.revision);
485
+ frontmatter.revision = storedRevision ?? 1;
486
+ let nextText = renderTaskReadme(frontmatter, body);
487
+ nextText = nextText.endsWith("\n") ? nextText : `${nextText}\n`;
488
+ if (storedRevision !== null && nextText !== entry.rawText) {
489
+ frontmatter.revision = storedRevision + 1;
490
+ nextText = renderTaskReadme(frontmatter, body);
491
+ nextText = nextText.endsWith("\n") ? nextText : `${nextText}\n`;
492
+ }
254
493
  await ensureUnchangedOnDisk({
255
494
  readmePath: entry.readmePath,
256
495
  expectedMtimeMs: entry.mtimeMs,
257
496
  });
258
- const nextText = rendered.endsWith("\n") ? rendered : `${rendered}\n`;
259
497
  const changed = await writeTextIfChanged(entry.readmePath, nextText);
260
498
  // Refresh cache with latest content on disk.
261
499
  this.cache.set(taskId, (async () => {
@@ -1 +1 @@
1
- {"version":3,"file":"block.d.ts","sourceRoot":"","sources":["../../../src/commands/task/block.ts"],"names":[],"mappings":"AAOA,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,2BAA2B,CAAC;AAiBnC,wBAAsB,QAAQ,CAAC,IAAI,EAAE;IACnC,GAAG,CAAC,EAAE,cAAc,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,iBAAiB,EAAE,OAAO,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,eAAe,EAAE,OAAO,CAAC;IACzB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,kBAAkB,EAAE,OAAO,CAAC;IAC5B,mBAAmB,EAAE,OAAO,CAAC;IAC7B,KAAK,EAAE,OAAO,CAAC;IACf,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CA2IlB"}
1
+ {"version":3,"file":"block.d.ts","sourceRoot":"","sources":["../../../src/commands/task/block.ts"],"names":[],"mappings":"AAOA,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,2BAA2B,CAAC;AA0BnC,wBAAsB,QAAQ,CAAC,IAAI,EAAE;IACnC,GAAG,CAAC,EAAE,cAAc,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,iBAAiB,EAAE,OAAO,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,eAAe,EAAE,OAAO,CAAC;IACzB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,kBAAkB,EAAE,OAAO,CAAC;IAC5B,mBAAmB,EAAE,OAAO,CAAC;IAC7B,KAAK,EAAE,OAAO,CAAC;IACf,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgKlB"}
@@ -1,13 +1,13 @@
1
1
  import { mapBackendError } from "../../cli/error-map.js";
2
- import { successMessage } from "../../cli/output.js";
2
+ import { successMessage, warnMessage } from "../../cli/output.js";
3
3
  import { formatCommentBodyForCommit } from "../../shared/comment-format.js";
4
4
  import { CliError } from "../../shared/errors.js";
5
5
  import { commitFromComment } from "../guard/index.js";
6
6
  import { ensureActionApproved } from "../shared/approval-requirements.js";
7
7
  import { loadCommandContext, loadTaskFromContext, } from "../shared/task-backend.js";
8
- import { backendIsLocalFileBackend, getTaskStore } from "../shared/task-store.js";
8
+ import { appendTaskCommentIntent, appendTaskEventIntent, backendIsLocalFileBackend, getTaskStore, mutateTaskStore, setTaskFieldsIntent, touchTaskDocMetaIntent, } from "../shared/task-store.js";
9
9
  import { readDirectWorkLock } from "../../shared/direct-work-lock.js";
10
- import { appendTaskEvent, defaultCommitEmojiForStatus, ensureCommentCommitAllowed, ensureStatusTransitionAllowed, normalizeTaskDocVersion, nowIso, requireStructuredComment, resolvePrimaryTag, toStringArray, } from "./shared.js";
10
+ import { appendTaskEvent, defaultCommitEmojiForStatus, ensureCommentCommitAllowed, resolveCommentCommitWarning, ensureStatusTransitionAllowed, normalizeTaskDocVersion, nowIso, requireStructuredComment, resolvePrimaryTag, toStringArray, } from "./shared.js";
11
11
  export async function cmdBlock(opts) {
12
12
  try {
13
13
  const ctx = opts.ctx ??
@@ -28,20 +28,22 @@ export async function cmdBlock(opts) {
28
28
  ? await store.get(opts.taskId)
29
29
  : await loadTaskFromContext({ ctx, taskId: opts.taskId });
30
30
  const currentStatus = String(task.status || "TODO").toUpperCase();
31
- ensureStatusTransitionAllowed({
32
- currentStatus,
33
- nextStatus: "BLOCKED",
34
- force: opts.force,
35
- });
36
- ensureCommentCommitAllowed({
37
- enabled: opts.commitFromComment,
38
- config: ctx.config,
39
- action: "block",
40
- confirmed: opts.confirmStatusCommit,
41
- quiet: opts.quiet,
42
- statusFrom: currentStatus,
43
- statusTo: "BLOCKED",
44
- });
31
+ if (!useStore) {
32
+ ensureStatusTransitionAllowed({
33
+ currentStatus,
34
+ nextStatus: "BLOCKED",
35
+ force: opts.force,
36
+ });
37
+ ensureCommentCommitAllowed({
38
+ enabled: opts.commitFromComment,
39
+ config: ctx.config,
40
+ action: "block",
41
+ confirmed: opts.confirmStatusCommit,
42
+ quiet: opts.quiet,
43
+ statusFrom: currentStatus,
44
+ statusTo: "BLOCKED",
45
+ });
46
+ }
45
47
  const formattedComment = opts.commitFromComment
46
48
  ? formatCommentBodyForCommit(opts.body, ctx.config)
47
49
  : null;
@@ -51,33 +53,47 @@ export async function cmdBlock(opts) {
51
53
  : [];
52
54
  const commentsValue = [...existingComments, { author: opts.author, body: commentBody }];
53
55
  const at = nowIso();
56
+ let currentStatusForCommit = currentStatus;
57
+ let primaryTagForCommit = resolvePrimaryTag(toStringArray(task.tags), ctx).primary;
58
+ let deferredWarnings = [];
54
59
  await (useStore
55
- ? store.patch(opts.taskId, (current) => {
60
+ ? mutateTaskStore(store, opts.taskId, (current) => {
61
+ deferredWarnings = [];
56
62
  const currentStatus = String(current.status || "TODO").toUpperCase();
63
+ currentStatusForCommit = currentStatus;
64
+ primaryTagForCommit = resolvePrimaryTag(toStringArray(current.tags), ctx).primary;
57
65
  ensureStatusTransitionAllowed({
58
66
  currentStatus,
59
67
  nextStatus: "BLOCKED",
60
68
  force: opts.force,
61
69
  });
62
- return {
63
- task: { status: "BLOCKED" },
64
- appendComments: [{ author: opts.author, body: commentBody }],
65
- appendEvents: [
66
- {
67
- type: "status",
68
- at,
69
- author: opts.author,
70
- from: currentStatus,
71
- to: "BLOCKED",
72
- note: commentBody,
73
- },
74
- ],
75
- docMeta: {
76
- touch: true,
70
+ const commitWarning = resolveCommentCommitWarning({
71
+ enabled: opts.commitFromComment,
72
+ config: ctx.config,
73
+ action: "block",
74
+ confirmed: opts.confirmStatusCommit,
75
+ quiet: opts.quiet,
76
+ statusFrom: currentStatus,
77
+ statusTo: "BLOCKED",
78
+ });
79
+ if (commitWarning)
80
+ deferredWarnings.push(commitWarning);
81
+ return [
82
+ setTaskFieldsIntent({ status: "BLOCKED" }),
83
+ appendTaskCommentIntent({ author: opts.author, body: commentBody }),
84
+ appendTaskEventIntent({
85
+ type: "status",
86
+ at,
87
+ author: opts.author,
88
+ from: currentStatus,
89
+ to: "BLOCKED",
90
+ note: commentBody,
91
+ }),
92
+ touchTaskDocMetaIntent({
77
93
  updatedBy: opts.author,
78
94
  version: normalizeTaskDocVersion(current.doc_version),
79
- },
80
- };
95
+ }),
96
+ ];
81
97
  })
82
98
  : ctx.taskBackend.writeTask({
83
99
  ...task,
@@ -95,6 +111,11 @@ export async function cmdBlock(opts) {
95
111
  doc_updated_at: at,
96
112
  doc_updated_by: opts.author,
97
113
  }));
114
+ if (!opts.quiet) {
115
+ for (const warning of new Set(deferredWarnings)) {
116
+ process.stderr.write(`${warnMessage(warning)}\n`);
117
+ }
118
+ }
98
119
  let commitInfo = null;
99
120
  if (opts.commitFromComment) {
100
121
  const mode = ctx.config.workflow_mode;
@@ -110,10 +131,10 @@ export async function cmdBlock(opts) {
110
131
  cwd: opts.cwd,
111
132
  rootOverride: opts.rootOverride,
112
133
  taskId: opts.taskId,
113
- primaryTag: resolvePrimaryTag(toStringArray(task.tags), ctx).primary,
134
+ primaryTag: primaryTagForCommit,
114
135
  executorAgent,
115
136
  author: opts.author,
116
- statusFrom: currentStatus,
137
+ statusFrom: currentStatusForCommit,
117
138
  statusTo: "BLOCKED",
118
139
  commentBody: opts.body,
119
140
  formattedComment,
@@ -1 +1 @@
1
- {"version":3,"file":"close-shared.d.ts","sourceRoot":"","sources":["../../../src/commands/task/close-shared.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAE/D,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAShE,wBAAsB,yBAAyB,CAAC,IAAI,EAAE;IACpD,GAAG,EAAE,cAAc,CAAC;IACpB,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,OAAO,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,IAAI,CAAC,CA2EhB"}
1
+ {"version":3,"file":"close-shared.d.ts","sourceRoot":"","sources":["../../../src/commands/task/close-shared.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAE/D,OAAO,EAAE,KAAK,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAgBhE,wBAAsB,yBAAyB,CAAC,IAAI,EAAE;IACpD,GAAG,EAAE,cAAc,CAAC;IACpB,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,OAAO,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,OAAO,CAAC;CAChB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwEhB"}
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "../../shared/errors.js";
2
- import { backendIsLocalFileBackend, getTaskStore } from "../shared/task-store.js";
2
+ import { appendTaskCommentIntent, appendTaskEventIntent, backendIsLocalFileBackend, getTaskStore, setTaskFieldsIntent, touchTaskDocMetaIntent, } from "../shared/task-store.js";
3
3
  import { appendTaskEvent, normalizeTaskDocVersion, nowIso, requireStructuredComment, } from "./shared.js";
4
4
  export async function recordVerifiedNoopClosure(opts) {
5
5
  if (!opts.force && String(opts.task.status || "TODO").toUpperCase() === "DONE") {
@@ -15,7 +15,7 @@ export async function recordVerifiedNoopClosure(opts) {
15
15
  const useStore = backendIsLocalFileBackend(opts.ctx);
16
16
  const store = useStore ? getTaskStore(opts.ctx) : null;
17
17
  await (useStore
18
- ? store.patch(opts.taskId, (current) => {
18
+ ? store.mutate(opts.taskId, (current) => {
19
19
  if (!opts.force && String(current.status || "TODO").toUpperCase() === "DONE") {
20
20
  throw new CliError({
21
21
  exitCode: 2,
@@ -23,30 +23,27 @@ export async function recordVerifiedNoopClosure(opts) {
23
23
  message: `Task is already DONE: ${opts.taskId} (use --force to override)`,
24
24
  });
25
25
  }
26
- return {
27
- task: {
26
+ return [
27
+ setTaskFieldsIntent({
28
28
  status: "DONE",
29
29
  result_summary: opts.resultSummary,
30
30
  risk_level: "low",
31
31
  breaking: false,
32
- },
33
- appendComments: [{ author: opts.author, body: opts.body }],
34
- appendEvents: [
35
- {
36
- type: "status",
37
- at,
38
- author: opts.author,
39
- from: String(current.status || "TODO").toUpperCase(),
40
- to: "DONE",
41
- note: opts.body,
42
- },
43
- ],
44
- docMeta: {
45
- touch: true,
32
+ }),
33
+ appendTaskCommentIntent({ author: opts.author, body: opts.body }),
34
+ appendTaskEventIntent({
35
+ type: "status",
36
+ at,
37
+ author: opts.author,
38
+ from: String(current.status || "TODO").toUpperCase(),
39
+ to: "DONE",
40
+ note: opts.body,
41
+ }),
42
+ touchTaskDocMetaIntent({
46
43
  updatedBy: opts.author,
47
44
  version: normalizeTaskDocVersion(current.doc_version),
48
- },
49
- };
45
+ }),
46
+ ];
50
47
  })
51
48
  : opts.ctx.taskBackend.writeTask({
52
49
  ...opts.task,
@@ -1 +1 @@
1
- {"version":3,"file":"comment.d.ts","sourceRoot":"","sources":["../../../src/commands/task/comment.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,2BAA2B,CAAC;AAInC,wBAAsB,cAAc,CAAC,IAAI,EAAE;IACzC,GAAG,CAAC,EAAE,cAAc,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,MAAM,CAAC,CAsDlB"}
1
+ {"version":3,"file":"comment.d.ts","sourceRoot":"","sources":["../../../src/commands/task/comment.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,2BAA2B,CAAC;AAUnC,wBAAsB,cAAc,CAAC,IAAI,EAAE;IACzC,GAAG,CAAC,EAAE,cAAc,CAAC;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,MAAM,CAAC,CAiDlB"}