@agent-plan/core 0.2.25 → 0.2.27

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 (66) hide show
  1. package/dist/description-freshness.d.ts +37 -0
  2. package/dist/description-freshness.d.ts.map +1 -0
  3. package/dist/description-freshness.js +84 -0
  4. package/dist/display-status.d.ts +3 -3
  5. package/dist/display-status.d.ts.map +1 -1
  6. package/dist/display-status.js +5 -4
  7. package/dist/handoff-context.d.ts +222 -1
  8. package/dist/handoff-context.d.ts.map +1 -1
  9. package/dist/handoff-context.js +461 -11
  10. package/dist/index.d.ts +8 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +8 -1
  13. package/dist/naming.d.ts +3 -0
  14. package/dist/naming.d.ts.map +1 -1
  15. package/dist/naming.js +7 -0
  16. package/dist/package-version.d.ts +2 -0
  17. package/dist/package-version.d.ts.map +1 -1
  18. package/dist/package-version.js +1 -1
  19. package/dist/payload-fallback.d.ts +38 -0
  20. package/dist/payload-fallback.d.ts.map +1 -0
  21. package/dist/payload-fallback.js +79 -0
  22. package/dist/plan-store.d.ts +192 -36
  23. package/dist/plan-store.d.ts.map +1 -1
  24. package/dist/plan-store.js +1048 -126
  25. package/dist/planner-rules.d.ts.map +1 -1
  26. package/dist/planner-rules.js +9 -3
  27. package/dist/planner-skill.d.ts +24 -0
  28. package/dist/planner-skill.d.ts.map +1 -0
  29. package/dist/planner-skill.js +113 -0
  30. package/dist/project-context-migration.d.ts +47 -0
  31. package/dist/project-context-migration.d.ts.map +1 -0
  32. package/dist/project-context-migration.js +168 -0
  33. package/dist/read-tracking.d.ts +47 -13
  34. package/dist/read-tracking.d.ts.map +1 -1
  35. package/dist/read-tracking.js +88 -33
  36. package/dist/recap.d.ts.map +1 -1
  37. package/dist/recap.js +34 -9
  38. package/dist/refs.d.ts +6 -1
  39. package/dist/refs.d.ts.map +1 -1
  40. package/dist/refs.js +25 -0
  41. package/dist/renderer.d.ts.map +1 -1
  42. package/dist/renderer.js +24 -2
  43. package/dist/requirement-macro-tasks.d.ts +18 -0
  44. package/dist/requirement-macro-tasks.d.ts.map +1 -0
  45. package/dist/requirement-macro-tasks.js +55 -0
  46. package/dist/runtime-diagnostics.d.ts +34 -0
  47. package/dist/runtime-diagnostics.d.ts.map +1 -0
  48. package/dist/runtime-diagnostics.js +39 -0
  49. package/dist/schema.d.ts +1575 -290
  50. package/dist/schema.d.ts.map +1 -1
  51. package/dist/schema.js +89 -4
  52. package/dist/task-context.d.ts +41 -2
  53. package/dist/task-context.d.ts.map +1 -1
  54. package/dist/task-context.js +102 -4
  55. package/dist/task-selection.d.ts +44 -1
  56. package/dist/task-selection.d.ts.map +1 -1
  57. package/dist/task-selection.js +158 -7
  58. package/dist/task-start-outcome.d.ts +1 -1
  59. package/dist/task-start-outcome.d.ts.map +1 -1
  60. package/dist/task-start-outcome.js +1 -0
  61. package/dist/write-coordination.d.ts +27 -0
  62. package/dist/write-coordination.d.ts.map +1 -0
  63. package/dist/write-coordination.js +223 -0
  64. package/package.json +3 -1
  65. package/planner-skill.md +226 -0
  66. package/skills/grill-me/SKILL.md +10 -0
@@ -1,8 +1,108 @@
1
1
  const unavailable = new Set(["blocked", "waiting", "deferred", "canceled", "rejected"]);
2
2
  const hardUnavailable = new Set(["blocked", "deferred", "canceled", "rejected"]);
3
3
  const terminal = new Set(["done", "canceled", "rejected"]);
4
- const priority = (entity) => entity.priority && entity.priority > 0 ? entity.priority : Number.MAX_SAFE_INTEGER;
4
+ const priority = (entity) => entity.priority != null ? entity.priority : Number.MAX_SAFE_INTEGER;
5
5
  const compare = (a, b) => priority(a) - priority(b) || a.number - b.number;
6
+ const summarizeFeature = (feature) => ({ id: feature.id, number: feature.number, priority: feature.priority, title: feature.name, status: feature.status });
7
+ const summarizePhase = (phase) => ({ id: phase.id, number: phase.number, priority: phase.priority, title: phase.title, status: phase.status });
8
+ const summarizeTask = (task) => ({ id: task.id, number: task.number, priority: task.priority, title: task.title, status: task.status });
9
+ const RECOMMENDATION_CLAIM_LIMIT = 8;
10
+ const selectionClaimKind = (selectionKind) => {
11
+ if (selectionKind === "priority")
12
+ return "recommendation";
13
+ if (selectionKind === "conflict")
14
+ return "conflict";
15
+ if (selectionKind === "none")
16
+ return "insufficient";
17
+ return "advisory";
18
+ };
19
+ const buildRecommendationClaims = (selection, evidence = {}, dependencyReadyCandidates = []) => {
20
+ const claims = [];
21
+ if (selection.candidate) {
22
+ claims.push({
23
+ kind: selectionClaimKind(selection.kind),
24
+ source: selection.kind === "resume" ? "resume-deviation" : selection.kind === "active" ? "active-task" : "priority",
25
+ ref: selection.candidate.task.id,
26
+ title: selection.candidate.task.title,
27
+ reason: selection.reason,
28
+ taskId: selection.candidate.task.id,
29
+ phaseId: selection.candidate.phase.id,
30
+ ...(selection.candidate.feature?.id ? { featureId: selection.candidate.feature.id } : {}),
31
+ });
32
+ }
33
+ for (const candidate of dependencyReadyCandidates) {
34
+ claims.push({
35
+ kind: "advisory",
36
+ source: "dependency-ready",
37
+ ref: candidate.task.id,
38
+ title: candidate.task.title,
39
+ reason: "All persisted task dependencies are done; this task is ready for explicit priority review.",
40
+ taskId: candidate.task.id,
41
+ phaseId: candidate.phase.id,
42
+ ...(candidate.feature?.id ? { featureId: candidate.feature.id } : {}),
43
+ });
44
+ }
45
+ if (selection.kind === "conflict") {
46
+ for (const activeCandidate of selection.activeCandidates ?? []) {
47
+ claims.push({
48
+ kind: "conflict",
49
+ source: "active-task",
50
+ ref: activeCandidate.task.id,
51
+ title: activeCandidate.task.title,
52
+ reason: "Multiple in-progress tasks are persisted; resolve the active-work conflict before automatic selection.",
53
+ taskId: activeCandidate.task.id,
54
+ phaseId: activeCandidate.phase.id,
55
+ ...(activeCandidate.feature?.id ? { featureId: activeCandidate.feature.id } : {}),
56
+ });
57
+ }
58
+ }
59
+ if (selection.deviation) {
60
+ const deviationTaskId = selection.candidate?.task.id ?? selection.deviation.resumeTaskId;
61
+ claims.push({
62
+ kind: "advisory",
63
+ source: "resume-deviation",
64
+ ref: deviationTaskId,
65
+ title: selection.candidate?.task.title ?? "Resume-required deviation",
66
+ reason: selection.deviation.state === "resume-required"
67
+ ? "Persisted deviation requires resuming the preserved task before new priority work can continue."
68
+ : "Persisted approved deviation competes with priority selection.",
69
+ taskId: deviationTaskId,
70
+ });
71
+ }
72
+ for (const handoff of evidence.handoffs ?? []) {
73
+ if (!handoff.resumeReady) {
74
+ claims.push({
75
+ kind: "insufficient",
76
+ source: "handoff",
77
+ ref: handoff.compositeRef,
78
+ title: handoff.firstLine || handoff.compositeRef,
79
+ reason: "Persisted handoff read-back is incomplete; it is not an actionable resume claim.",
80
+ });
81
+ continue;
82
+ }
83
+ claims.push({
84
+ kind: "advisory",
85
+ source: "handoff",
86
+ ref: handoff.compositeRef,
87
+ title: handoff.firstLine || handoff.compositeRef,
88
+ reason: "Persisted phase handoff is resume-ready and competes with fresh priority work.",
89
+ });
90
+ }
91
+ // Archived Markdown is historical context, not authoritative next-work
92
+ // evidence. A future explicit structured resume target may opt it in; never
93
+ // recover an action by parsing archived prose.
94
+ void evidence.archivedHandoffs;
95
+ if (claims.length === 0) {
96
+ claims.push({
97
+ kind: "insufficient",
98
+ source: "priority",
99
+ ref: "",
100
+ title: "No competing claim evidence",
101
+ reason: selection.reason,
102
+ });
103
+ }
104
+ return claims.slice(0, RECOMMENDATION_CLAIM_LIMIT);
105
+ };
6
106
  /**
7
107
  * Validate an explicitly requested task start. Priority and existing active
8
108
  * work remain advisory for explicit user choices; availability and dependency
@@ -41,14 +141,21 @@ export function checkExplicitTaskStart(features, phases, taskId, deviations = []
41
141
  * may use its recommendation as a default while still allowing an explicitly
42
142
  * approved temporary deviation.
43
143
  */
44
- export function recommendNextTask(features, phases, deviations = [], currentPhaseId = "") {
144
+ export function recommendNextTask(features, phases, deviations = [], currentPhaseId = "", sessionId = "") {
45
145
  const featureById = new Map(features.map((feature) => [feature.id, feature]));
46
146
  const candidates = phases.flatMap((phase) => phase.tasks.map((task) => ({ feature: phase.featureId ? featureById.get(phase.featureId) : undefined, phase, task })));
47
147
  const byTaskId = new Map(candidates.map((candidate) => [candidate.task.id, candidate]));
48
148
  const active = candidates.filter(({ task }) => task.status === "in-progress");
49
- if (active.length > 1)
149
+ if (sessionId) {
150
+ const ownedActive = active.filter(({ task }) => task.activeOwnerSession === sessionId);
151
+ if (ownedActive.length > 1)
152
+ return { kind: "conflict", activeCandidates: ownedActive, reason: "This session owns more than one active task; resolve the active-work conflict before autonomous selection." };
153
+ if (ownedActive.length === 1)
154
+ return { kind: "active", candidate: ownedActive[0], reason: "Resume the single active task owned by this session." };
155
+ }
156
+ else if (active.length > 1)
50
157
  return { kind: "conflict", activeCandidates: active, reason: "More than one task is in progress; resolve the active-work conflict before autonomous selection." };
51
- if (active.length === 1)
158
+ else if (active.length === 1)
52
159
  return { kind: "active", candidate: active[0], reason: "Resume the single active task." };
53
160
  const newestFirst = (left, right) => right.createdAt.localeCompare(left.createdAt);
54
161
  const resumable = (candidate) => candidate
@@ -105,11 +212,55 @@ export function recommendNextTask(features, phases, deviations = [], currentPhas
105
212
  reason: "Continue the current phase before selecting new work elsewhere.",
106
213
  };
107
214
  }
108
- ready.sort((a, b) => compare(a.feature ?? { priority: 0, number: 0 }, b.feature ?? { priority: 0, number: 0 }) || compare(a.phase, b.phase) || compare(a.task, b.task));
109
- return ready[0]
110
- ? { kind: "priority", candidate: ready[0], reason: "Highest-priority ready task by feature, phase, then task." }
215
+ const bestTaskInPhase = (phaseId) => ready
216
+ .filter((candidate) => candidate.phase.id === phaseId)
217
+ .sort((a, b) => compare(a.task, b.task))[0];
218
+ const bestPhaseInFeature = (featureId) => {
219
+ const phaseIds = new Set(ready.filter((candidate) => candidate.feature?.id === featureId).map((candidate) => candidate.phase.id));
220
+ return phases
221
+ .filter((phase) => phase.featureId === featureId && phaseIds.has(phase.id))
222
+ .sort((a, b) => compare(a, b))[0];
223
+ };
224
+ const bestFeature = features
225
+ .filter((feature) => ready.some((candidate) => candidate.feature?.id === feature.id))
226
+ .sort((a, b) => compare(a, b))[0];
227
+ if (!bestFeature)
228
+ return { kind: "none", reason: "No ready task is available." };
229
+ const bestPhase = bestPhaseInFeature(bestFeature.id);
230
+ if (!bestPhase)
231
+ return { kind: "none", reason: "No ready task is available." };
232
+ const candidate = bestTaskInPhase(bestPhase.id);
233
+ return candidate
234
+ ? { kind: "priority", candidate, reason: "Select the lowest-priority ready feature, then phase, then task." }
111
235
  : { kind: "none", reason: "No ready task is available." };
112
236
  }
237
+ export function recommendNextWork(features, phases, deviations = [], currentPhaseId = "", sessionId = "", evidence = {}) {
238
+ const selection = recommendNextTask(features, phases, deviations, currentPhaseId, sessionId);
239
+ const featureById = new Map(features.map((feature) => [feature.id, feature]));
240
+ const taskById = new Map(phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task])));
241
+ const dependencyReadyCandidates = phases.flatMap((phase) => phase.tasks.map((task) => ({
242
+ feature: phase.featureId ? featureById.get(phase.featureId) : undefined,
243
+ phase,
244
+ task,
245
+ })))
246
+ .filter(({ feature, phase, task }) => task.status === "planned"
247
+ && task.dependsOn.length > 0
248
+ && !hardUnavailable.has(phase.status)
249
+ && !(feature && hardUnavailable.has(feature.status))
250
+ && task.dependsOn.every((id) => taskById.get(id)?.status === "done"))
251
+ .sort((left, right) => compare(left.feature ?? { priority: 0, number: 0 }, right.feature ?? { priority: 0, number: 0 }) || compare(left.phase, right.phase) || compare(left.task, right.task));
252
+ const activeTask = selection.kind === "active" && selection.candidate
253
+ ? summarizeTask(selection.candidate.task)
254
+ : null;
255
+ return {
256
+ selection,
257
+ activeTask,
258
+ nextFeature: selection.candidate?.feature ? summarizeFeature(selection.candidate.feature) : null,
259
+ nextPhase: selection.candidate ? summarizePhase(selection.candidate.phase) : null,
260
+ nextTask: selection.candidate ? summarizeTask(selection.candidate.task) : null,
261
+ claims: buildRecommendationClaims(selection, evidence, dependencyReadyCandidates),
262
+ };
263
+ }
113
264
  /**
114
265
  * Build an explicit resume-required proposal. `ref` is the already-formatted
115
266
  * composite reference (F00x/P00x/T00x) supplied by the caller; `snapshot` is
@@ -1,4 +1,4 @@
1
- export declare const TASK_START_ERROR_CODES: readonly ["PLAN_NOT_FOUND", "TASK_NOT_FOUND", "TASK_DONE", "CONTEXT_READ_REQUIRED", "REQUIREMENTS_READ_REQUIRED", "START_NOT_ALLOWED", "ACTIVE_TASK_CONFLICT", "PERSISTENCE_VERIFICATION_FAILED"];
1
+ export declare const TASK_START_ERROR_CODES: readonly ["PLAN_NOT_FOUND", "TASK_NOT_FOUND", "TASK_DONE", "PROJECT_GUIDELINES_READ_REQUIRED", "CONTEXT_READ_REQUIRED", "REQUIREMENTS_READ_REQUIRED", "START_NOT_ALLOWED", "ACTIVE_TASK_CONFLICT", "PERSISTENCE_VERIFICATION_FAILED"];
2
2
  export type TaskStartErrorCode = (typeof TASK_START_ERROR_CODES)[number];
3
3
  export interface TaskStartDeniedOutcome {
4
4
  started: false;
@@ -1 +1 @@
1
- {"version":3,"file":"task-start-outcome.d.ts","sourceRoot":"","sources":["../src/task-start-outcome.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,mMASzB,CAAC;AAEX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzE,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,KAAK,CAAC;IACf,SAAS,EAAE,kBAAkB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE,IAAI,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,aAAa,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,MAAM,gBAAgB,GAAG,sBAAsB,GAAG,yBAAyB,CAAC;AAElF,wBAAgB,eAAe,CAC7B,SAAS,EAAE,kBAAkB,EAC7B,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;CAAO,GAC3D,sBAAsB,CASxB;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,UAAQ,GAAG,yBAAyB,CAEpG"}
1
+ {"version":3,"file":"task-start-outcome.d.ts","sourceRoot":"","sources":["../src/task-start-outcome.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,sBAAsB,uOAUzB,CAAC;AAEX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzE,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,KAAK,CAAC;IACf,SAAS,EAAE,kBAAkB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE,IAAI,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,aAAa,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,MAAM,gBAAgB,GAAG,sBAAsB,GAAG,yBAAyB,CAAC;AAElF,wBAAgB,eAAe,CAC7B,SAAS,EAAE,kBAAkB,EAC7B,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;CAAO,GAC3D,sBAAsB,CASxB;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,UAAQ,GAAG,yBAAyB,CAEpG"}
@@ -2,6 +2,7 @@ export const TASK_START_ERROR_CODES = [
2
2
  "PLAN_NOT_FOUND",
3
3
  "TASK_NOT_FOUND",
4
4
  "TASK_DONE",
5
+ "PROJECT_GUIDELINES_READ_REQUIRED",
5
6
  "CONTEXT_READ_REQUIRED",
6
7
  "REQUIREMENTS_READ_REQUIRED",
7
8
  "START_NOT_ALLOWED",
@@ -0,0 +1,27 @@
1
+ interface WriterOwner {
2
+ token: string;
3
+ pid: number;
4
+ hostname: string;
5
+ cwd: string;
6
+ acquiredAt: string;
7
+ }
8
+ export interface PlanWriterBusyDetails {
9
+ errorCode: "PLAN_WRITER_BUSY";
10
+ planRoot: string;
11
+ lockPath: string;
12
+ waitedMs: number;
13
+ owner?: WriterOwner | undefined;
14
+ }
15
+ export declare class PlanWriterBusyError extends Error {
16
+ readonly details: PlanWriterBusyDetails;
17
+ readonly code = "PLAN_WRITER_BUSY";
18
+ constructor(details: PlanWriterBusyDetails);
19
+ }
20
+ /**
21
+ * Serialize one logical planner mutation across processes for a planner root.
22
+ * Nested writes in the same async transaction are re-entrant. Reads never take
23
+ * this lock, so secondary processes remain available for inspection.
24
+ */
25
+ export declare function withPlanRootWriteLock<T>(root: string, fn: () => Promise<T>): Promise<T>;
26
+ export {};
27
+ //# sourceMappingURL=write-coordination.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"write-coordination.d.ts","sourceRoot":"","sources":["../src/write-coordination.ts"],"names":[],"mappings":"AAUA,UAAU,WAAW;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,kBAAkB,CAAC;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;CACjC;AAED,qBAAa,mBAAoB,SAAQ,KAAK;aAGhB,OAAO,EAAE,qBAAqB;IAF1D,QAAQ,CAAC,IAAI,sBAAsB;gBAEP,OAAO,EAAE,qBAAqB;CAO3D;AAwLD;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAa7F"}
@@ -0,0 +1,223 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
3
+ import { hostname } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+ const DEFAULT_STALE_MS = 30_000;
7
+ const DEFAULT_TIMEOUT_MS = 5_000;
8
+ const DEFAULT_RETRY_MS = 20;
9
+ export class PlanWriterBusyError extends Error {
10
+ details;
11
+ code = "PLAN_WRITER_BUSY";
12
+ constructor(details) {
13
+ const owner = details.owner
14
+ ? ` Active writer: pid ${details.owner.pid} on ${details.owner.hostname}, acquired ${details.owner.acquiredAt}.`
15
+ : "";
16
+ super(`PLAN_WRITER_BUSY: another process is mutating ${details.planRoot}; waited ${details.waitedMs}ms.${owner} Read-only operations remain available; retry the write after the active mutation finishes.`);
17
+ this.details = details;
18
+ this.name = "PlanWriterBusyError";
19
+ }
20
+ }
21
+ const heldRoots = new AsyncLocalStorage();
22
+ function positiveEnvMs(name, fallback) {
23
+ const raw = Number.parseInt(process.env[name] ?? "", 10);
24
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
25
+ }
26
+ async function readOwner(lockPath) {
27
+ try {
28
+ const parsed = JSON.parse(await readFile(join(lockPath, "owner.json"), "utf8"));
29
+ if (typeof parsed.token !== "string"
30
+ || typeof parsed.pid !== "number"
31
+ || typeof parsed.hostname !== "string"
32
+ || typeof parsed.cwd !== "string"
33
+ || typeof parsed.acquiredAt !== "string")
34
+ return undefined;
35
+ return parsed;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ function ownerIsActive(owner, heartbeatStale) {
42
+ if (owner.hostname !== hostname())
43
+ return !heartbeatStale;
44
+ try {
45
+ process.kill(owner.pid, 0);
46
+ return true;
47
+ }
48
+ catch (error) {
49
+ return error.code === "EPERM";
50
+ }
51
+ }
52
+ async function pathExists(path) {
53
+ try {
54
+ await stat(path);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ async function acquirePlanRootLock(planRoot) {
62
+ const locksRoot = join(planRoot, ".local", "locks");
63
+ const lockPath = join(locksRoot, "writer.lock");
64
+ const recoveryPath = join(locksRoot, "writer-recovery.lock");
65
+ const startedAt = Date.now();
66
+ const timeoutMs = positiveEnvMs("AGENT_PLAN_WRITE_LOCK_TIMEOUT_MS", DEFAULT_TIMEOUT_MS);
67
+ const staleMs = positiveEnvMs("AGENT_PLAN_WRITE_LOCK_STALE_MS", DEFAULT_STALE_MS);
68
+ const retryMs = positiveEnvMs("AGENT_PLAN_WRITE_LOCK_RETRY_MS", DEFAULT_RETRY_MS);
69
+ const owner = {
70
+ token: randomUUID(),
71
+ pid: process.pid,
72
+ hostname: hostname(),
73
+ cwd: process.cwd(),
74
+ acquiredAt: new Date().toISOString(),
75
+ };
76
+ for (;;) {
77
+ try {
78
+ await mkdir(locksRoot, { recursive: true });
79
+ }
80
+ catch (error) {
81
+ if (error.code !== "ENOENT")
82
+ throw error;
83
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
84
+ continue;
85
+ }
86
+ if (await pathExists(recoveryPath)) {
87
+ try {
88
+ const recovery = await stat(recoveryPath);
89
+ if (Date.now() - recovery.mtimeMs > staleMs) {
90
+ await rm(recoveryPath, { recursive: true, force: true });
91
+ continue;
92
+ }
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ const waitedMs = Date.now() - startedAt;
98
+ if (waitedMs >= timeoutMs) {
99
+ const currentOwner = await readOwner(lockPath);
100
+ throw new PlanWriterBusyError({
101
+ errorCode: "PLAN_WRITER_BUSY",
102
+ planRoot,
103
+ lockPath,
104
+ waitedMs,
105
+ ...(currentOwner ? { owner: currentOwner } : {}),
106
+ });
107
+ }
108
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
109
+ continue;
110
+ }
111
+ try {
112
+ await mkdir(lockPath);
113
+ try {
114
+ await writeFile(join(lockPath, "owner.json"), JSON.stringify(owner, null, 2), "utf8");
115
+ // A stale-owner recovery may have started after our initial check. Its
116
+ // sentinel wins: withdraw this new lock and retry after recovery ends.
117
+ if (await pathExists(recoveryPath)) {
118
+ const persistedOwner = await readOwner(lockPath);
119
+ if (persistedOwner?.token === owner.token) {
120
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
121
+ }
122
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
123
+ continue;
124
+ }
125
+ }
126
+ catch (error) {
127
+ const persistedOwner = await readOwner(lockPath);
128
+ if (!persistedOwner || persistedOwner.token === owner.token) {
129
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
130
+ }
131
+ throw error;
132
+ }
133
+ const heartbeat = setInterval(() => {
134
+ const now = new Date();
135
+ void utimes(lockPath, now, now).catch(() => { });
136
+ }, Math.max(250, Math.floor(staleMs / 3)));
137
+ heartbeat.unref();
138
+ return async () => {
139
+ clearInterval(heartbeat);
140
+ const persistedOwner = await readOwner(lockPath);
141
+ if (persistedOwner?.token === owner.token) {
142
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
143
+ }
144
+ };
145
+ }
146
+ catch (error) {
147
+ const fsError = error;
148
+ if (fsError.code === "ENOENT") {
149
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
150
+ continue;
151
+ }
152
+ if (fsError.code !== "EEXIST")
153
+ throw error;
154
+ const currentOwner = await readOwner(lockPath);
155
+ let stale = false;
156
+ try {
157
+ const info = await stat(lockPath);
158
+ stale = Date.now() - info.mtimeMs > staleMs;
159
+ }
160
+ catch {
161
+ continue;
162
+ }
163
+ if (currentOwner ? !ownerIsActive(currentOwner, stale) : stale) {
164
+ try {
165
+ await mkdir(recoveryPath);
166
+ try {
167
+ const confirmedOwner = await readOwner(lockPath);
168
+ let confirmedStale = false;
169
+ try {
170
+ const info = await stat(lockPath);
171
+ confirmedStale = Date.now() - info.mtimeMs > staleMs;
172
+ }
173
+ catch {
174
+ continue;
175
+ }
176
+ if (confirmedOwner ? !ownerIsActive(confirmedOwner, confirmedStale) : confirmedStale) {
177
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
178
+ }
179
+ }
180
+ finally {
181
+ await rm(recoveryPath, { recursive: true, force: true }).catch(() => { });
182
+ }
183
+ }
184
+ catch (recoveryError) {
185
+ if (recoveryError.code !== "EEXIST")
186
+ throw recoveryError;
187
+ }
188
+ continue;
189
+ }
190
+ const waitedMs = Date.now() - startedAt;
191
+ if (waitedMs >= timeoutMs) {
192
+ throw new PlanWriterBusyError({
193
+ errorCode: "PLAN_WRITER_BUSY",
194
+ planRoot,
195
+ lockPath,
196
+ waitedMs,
197
+ ...(currentOwner ? { owner: currentOwner } : {}),
198
+ });
199
+ }
200
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, retryMs));
201
+ }
202
+ }
203
+ }
204
+ /**
205
+ * Serialize one logical planner mutation across processes for a planner root.
206
+ * Nested writes in the same async transaction are re-entrant. Reads never take
207
+ * this lock, so secondary processes remain available for inspection.
208
+ */
209
+ export async function withPlanRootWriteLock(root, fn) {
210
+ const planRoot = resolve(root);
211
+ const active = heldRoots.getStore();
212
+ if (active?.has(planRoot))
213
+ return fn();
214
+ const release = await acquirePlanRootLock(planRoot);
215
+ const next = new Set(active ?? []);
216
+ next.add(planRoot);
217
+ try {
218
+ return await heldRoots.run(next, fn);
219
+ }
220
+ finally {
221
+ await release();
222
+ }
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-plan/core",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "private": false,
5
5
  "description": "Harness-agnostic core for Agent Plan: schemas, persistence, ordering, status rollups, and markdown rendering.",
6
6
  "license": "MIT",
@@ -16,6 +16,8 @@
16
16
  "dist/**/*.js",
17
17
  "dist/**/*.d.ts",
18
18
  "dist/**/*.d.ts.map",
19
+ "planner-skill.md",
20
+ "skills/**/*.md",
19
21
  "README.md",
20
22
  "LICENSE"
21
23
  ],