youmd 0.10.0 → 0.10.2

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 (130) hide show
  1. package/dist/commands/brain.d.ts +25 -0
  2. package/dist/commands/brain.d.ts.map +1 -0
  3. package/dist/commands/brain.js +683 -0
  4. package/dist/commands/brain.js.map +1 -0
  5. package/dist/commands/doctor.d.ts.map +1 -1
  6. package/dist/commands/doctor.js +4 -0
  7. package/dist/commands/doctor.js.map +1 -1
  8. package/dist/commands/machine.d.ts +3 -0
  9. package/dist/commands/machine.d.ts.map +1 -1
  10. package/dist/commands/machine.js +73 -4
  11. package/dist/commands/machine.js.map +1 -1
  12. package/dist/commands/orchestrate.d.ts.map +1 -1
  13. package/dist/commands/orchestrate.js +4 -3
  14. package/dist/commands/orchestrate.js.map +1 -1
  15. package/dist/commands/project.d.ts.map +1 -1
  16. package/dist/commands/project.js +24 -0
  17. package/dist/commands/project.js.map +1 -1
  18. package/dist/commands/remote.d.ts.map +1 -1
  19. package/dist/commands/remote.js +42 -13
  20. package/dist/commands/remote.js.map +1 -1
  21. package/dist/commands/skill.d.ts.map +1 -1
  22. package/dist/commands/skill.js +2 -0
  23. package/dist/commands/skill.js.map +1 -1
  24. package/dist/commands/tasks.d.ts +23 -3
  25. package/dist/commands/tasks.d.ts.map +1 -1
  26. package/dist/commands/tasks.js +179 -49
  27. package/dist/commands/tasks.js.map +1 -1
  28. package/dist/generated/capability-registry.d.ts +1096 -0
  29. package/dist/generated/capability-registry.d.ts.map +1 -0
  30. package/dist/generated/capability-registry.js +1204 -0
  31. package/dist/generated/capability-registry.js.map +1 -0
  32. package/dist/index.js +42 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/lib/api.d.ts +84 -0
  35. package/dist/lib/api.d.ts.map +1 -1
  36. package/dist/lib/api.js +26 -0
  37. package/dist/lib/api.js.map +1 -1
  38. package/dist/lib/brain/adapters.d.ts +34 -0
  39. package/dist/lib/brain/adapters.d.ts.map +1 -0
  40. package/dist/lib/brain/adapters.js +362 -0
  41. package/dist/lib/brain/adapters.js.map +1 -0
  42. package/dist/lib/brain/apply-control-plane.d.ts +17 -0
  43. package/dist/lib/brain/apply-control-plane.d.ts.map +1 -0
  44. package/dist/lib/brain/apply-control-plane.js +117 -0
  45. package/dist/lib/brain/apply-control-plane.js.map +1 -0
  46. package/dist/lib/brain/cache.d.ts +25 -0
  47. package/dist/lib/brain/cache.d.ts.map +1 -0
  48. package/dist/lib/brain/cache.js +99 -0
  49. package/dist/lib/brain/cache.js.map +1 -0
  50. package/dist/lib/brain/canonical.d.ts +5 -0
  51. package/dist/lib/brain/canonical.d.ts.map +1 -0
  52. package/dist/lib/brain/canonical.js +38 -0
  53. package/dist/lib/brain/canonical.js.map +1 -0
  54. package/dist/lib/brain/discovery.d.ts +9 -0
  55. package/dist/lib/brain/discovery.d.ts.map +1 -0
  56. package/dist/lib/brain/discovery.js +560 -0
  57. package/dist/lib/brain/discovery.js.map +1 -0
  58. package/dist/lib/brain/index.d.ts +55 -0
  59. package/dist/lib/brain/index.d.ts.map +1 -0
  60. package/dist/lib/brain/index.js +384 -0
  61. package/dist/lib/brain/index.js.map +1 -0
  62. package/dist/lib/brain/proposal.d.ts +179 -0
  63. package/dist/lib/brain/proposal.d.ts.map +1 -0
  64. package/dist/lib/brain/proposal.js +694 -0
  65. package/dist/lib/brain/proposal.js.map +1 -0
  66. package/dist/lib/brain/render.d.ts +33 -0
  67. package/dist/lib/brain/render.d.ts.map +1 -0
  68. package/dist/lib/brain/render.js +515 -0
  69. package/dist/lib/brain/render.js.map +1 -0
  70. package/dist/lib/brain/safe-file.d.ts +110 -0
  71. package/dist/lib/brain/safe-file.d.ts.map +1 -0
  72. package/dist/lib/brain/safe-file.js +564 -0
  73. package/dist/lib/brain/safe-file.js.map +1 -0
  74. package/dist/lib/brain/safety.d.ts +8 -0
  75. package/dist/lib/brain/safety.d.ts.map +1 -0
  76. package/dist/lib/brain/safety.js +42 -0
  77. package/dist/lib/brain/safety.js.map +1 -0
  78. package/dist/lib/brain/staged-apply.d.ts +57 -0
  79. package/dist/lib/brain/staged-apply.d.ts.map +1 -0
  80. package/dist/lib/brain/staged-apply.js +336 -0
  81. package/dist/lib/brain/staged-apply.js.map +1 -0
  82. package/dist/lib/brain/store.d.ts +26 -0
  83. package/dist/lib/brain/store.d.ts.map +1 -0
  84. package/dist/lib/brain/store.js +504 -0
  85. package/dist/lib/brain/store.js.map +1 -0
  86. package/dist/lib/brain/types.d.ts +160 -0
  87. package/dist/lib/brain/types.d.ts.map +1 -0
  88. package/dist/lib/brain/types.js +24 -0
  89. package/dist/lib/brain/types.js.map +1 -0
  90. package/dist/lib/config.d.ts +22 -1
  91. package/dist/lib/config.d.ts.map +1 -1
  92. package/dist/lib/config.js +70 -14
  93. package/dist/lib/config.js.map +1 -1
  94. package/dist/lib/daemon.d.ts +27 -0
  95. package/dist/lib/daemon.d.ts.map +1 -1
  96. package/dist/lib/daemon.js +57 -0
  97. package/dist/lib/daemon.js.map +1 -1
  98. package/dist/lib/machine-identity.d.ts +16 -0
  99. package/dist/lib/machine-identity.d.ts.map +1 -0
  100. package/dist/lib/machine-identity.js +101 -0
  101. package/dist/lib/machine-identity.js.map +1 -0
  102. package/dist/lib/orchestrator/loop.d.ts +1 -0
  103. package/dist/lib/orchestrator/loop.d.ts.map +1 -1
  104. package/dist/lib/orchestrator/loop.js +16 -0
  105. package/dist/lib/orchestrator/loop.js.map +1 -1
  106. package/dist/lib/orchestrator/tools.d.ts +11 -0
  107. package/dist/lib/orchestrator/tools.d.ts.map +1 -1
  108. package/dist/lib/orchestrator/tools.js +143 -0
  109. package/dist/lib/orchestrator/tools.js.map +1 -1
  110. package/dist/lib/project-strategy.d.ts +38 -0
  111. package/dist/lib/project-strategy.d.ts.map +1 -1
  112. package/dist/lib/project-strategy.js +91 -0
  113. package/dist/lib/project-strategy.js.map +1 -1
  114. package/dist/lib/projectContext.d.ts +6 -4
  115. package/dist/lib/projectContext.d.ts.map +1 -1
  116. package/dist/lib/projectContext.js +19 -17
  117. package/dist/lib/projectContext.js.map +1 -1
  118. package/dist/lib/tasksFile.d.ts +27 -32
  119. package/dist/lib/tasksFile.d.ts.map +1 -1
  120. package/dist/lib/tasksFile.js +359 -163
  121. package/dist/lib/tasksFile.js.map +1 -1
  122. package/dist/mcp/registry.d.ts +8 -2
  123. package/dist/mcp/registry.d.ts.map +1 -1
  124. package/dist/mcp/registry.js +189 -2
  125. package/dist/mcp/registry.js.map +1 -1
  126. package/dist/mcp/server.d.ts +1 -0
  127. package/dist/mcp/server.d.ts.map +1 -1
  128. package/dist/mcp/server.js +3 -2
  129. package/dist/mcp/server.js.map +1 -1
  130. package/package.json +1 -1
@@ -1,17 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
- * Canonical per-project task files: project-context/tasks.json + tasks.md.
4
- *
5
- * tasks.json is the machine truth (schema you-md/tasks/v1, fields aligned
6
- * with the Convex portfolioTasks table so rows round-trip losslessly).
7
- * tasks.md is the human/agent view, regenerated from tasks.json on every
8
- * write. Checkbox edits made directly in tasks.md are reconciled back into
9
- * tasks.json on the next read — tick a box in either file and both agree.
10
- * Freeform prose in tasks.md between the notes markers is preserved verbatim
11
- * across regenerations, so watchpoints and context never get clobbered.
12
- *
13
- * Legacy freeform tasks.md files (plain checklists with no id comments) are
14
- * imported automatically on first read: bullets become tasks, nothing is lost.
3
+ * Canonical task ledger: strict project-context/tasks.json plus a deterministic
4
+ * generated tasks.md companion. tasks.json is the only editable truth.
15
5
  */
16
6
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
7
  if (k2 === undefined) k2 = k;
@@ -47,13 +37,16 @@ var __importStar = (this && this.__importStar) || (function () {
47
37
  };
48
38
  })();
49
39
  Object.defineProperty(exports, "__esModule", { value: true });
50
- exports.TASK_PRIORITIES = exports.TASK_STATUSES = exports.TASKS_SCHEMA = void 0;
40
+ exports.TasksFileError = exports.TASK_PRIORITIES = exports.TASK_STATUSES = exports.TASKS_SCHEMA = void 0;
51
41
  exports.generateTaskId = generateTaskId;
52
42
  exports.tasksJsonPath = tasksJsonPath;
53
43
  exports.tasksMdPath = tasksMdPath;
44
+ exports.parseTasksJson = parseTasksJson;
54
45
  exports.parseLegacyTasksMd = parseLegacyTasksMd;
55
46
  exports.renderTasksMd = renderTasksMd;
47
+ exports.assertTasksMdMatches = assertTasksMdMatches;
56
48
  exports.reconcileMdCheckboxes = reconcileMdCheckboxes;
49
+ exports.resolveTaskWriteGrant = resolveTaskWriteGrant;
57
50
  exports.readTasksDoc = readTasksDoc;
58
51
  exports.writeTasksDoc = writeTasksDoc;
59
52
  exports.addTask = addTask;
@@ -65,52 +58,146 @@ const crypto = __importStar(require("crypto"));
65
58
  exports.TASKS_SCHEMA = "you-md/tasks/v1";
66
59
  exports.TASK_STATUSES = ["proposed", "open", "in_progress", "done", "snoozed", "cancelled"];
67
60
  exports.TASK_PRIORITIES = ["low", "normal", "high", "urgent"];
61
+ class TasksFileError extends Error {
62
+ constructor(code, message) {
63
+ super(`${code}: ${message}`);
64
+ this.code = code;
65
+ this.name = "TasksFileError";
66
+ }
67
+ }
68
+ exports.TasksFileError = TasksFileError;
68
69
  const NOTES_START = "<!-- notes:start -->";
69
70
  const NOTES_END = "<!-- notes:end -->";
71
+ const GENERATED_MARKER = "<!-- generated from tasks.json (you-md/tasks/v1). Do not edit;";
70
72
  const ID_COMMENT = /<!--\s*id:([A-Za-z0-9_-]+)\s*-->/;
73
+ const TASK_ID = /^t-[A-Za-z0-9][A-Za-z0-9_-]{0,94}$/;
74
+ const PROJECT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
75
+ const PROJECT_REF = /^project:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
76
+ const ARTIFACT_REF = /^artifact:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\/[a-z0-9][a-z0-9._-]{0,95}$/;
77
+ const SAFE_RELATIVE_PATH = /^(?!\/)(?![A-Za-z]:)(?!.*\\)(?!.*\/\/)(?!.*(?:^|\/)(?:\.{1,2})(?:\/|$))(?!.*[\u0000-\u001f\u007f])(?!.*\/$)[^/]+(?:\/[^/]+)*$/;
71
78
  const OPEN_STATUSES = ["proposed", "open", "in_progress", "snoozed"];
72
- function generateTaskId(now = new Date()) {
73
- const stamp = now.toISOString().slice(0, 10).replace(/-/g, "");
74
- return `t-${stamp}-${crypto.randomBytes(3).toString("hex")}`;
79
+ const DOC_KEYS = new Set(["$schema", "project", "updatedAt", "tasks", "notes"]);
80
+ const TASK_KEYS = new Set([
81
+ "id", "title", "description", "status", "priority", "owner", "ownerLabel", "tags",
82
+ "source", "verify", "createdAt", "updatedAt", "completedAt",
83
+ ]);
84
+ function nowIso(now = new Date()) { return now.toISOString(); }
85
+ function isRecord(value) {
86
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
75
87
  }
76
- function tasksJsonPath(contextDir) {
77
- return path.join(contextDir, "tasks.json");
88
+ function exactIso(value) {
89
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
90
+ && Number.isFinite(Date.parse(value));
78
91
  }
79
- function tasksMdPath(contextDir) {
80
- return path.join(contextDir, "tasks.md");
92
+ function boundedText(value, max, optional = false) {
93
+ if (value === undefined && optional)
94
+ return undefined;
95
+ if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value))
96
+ return undefined;
97
+ return value;
81
98
  }
82
- function nowIso() {
83
- return new Date().toISOString();
99
+ function assertNoUnknown(record, allowed, code, where) {
100
+ const unknown = Object.keys(record).filter((key) => !allowed.has(key));
101
+ if (unknown.includes("remoteId"))
102
+ throw new TasksFileError("TASKS_REMOTE_ID_FORBIDDEN", `${where} contains remoteId`);
103
+ if (unknown.length)
104
+ throw new TasksFileError(code, `${where} contains unknown fields: ${unknown.join(", ")}`);
84
105
  }
85
- function normalizeStatus(raw) {
86
- return exports.TASK_STATUSES.includes(raw) ? raw : "open";
106
+ function generateTaskId(now = new Date()) {
107
+ return `t-${now.toISOString().slice(0, 10).replace(/-/g, "")}-${crypto.randomBytes(3).toString("hex")}`;
87
108
  }
88
- function normalizePriority(raw) {
89
- return exports.TASK_PRIORITIES.includes(raw) ? raw : "normal";
109
+ function tasksJsonPath(contextDir) { return path.join(contextDir, "tasks.json"); }
110
+ function tasksMdPath(contextDir) { return path.join(contextDir, "tasks.md"); }
111
+ function parseTask(value, index) {
112
+ if (!isRecord(value))
113
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}] must be an object`);
114
+ assertNoUnknown(value, TASK_KEYS, "TASKS_INVALID_TASK", `tasks[${index}]`);
115
+ if (typeof value.id !== "string" || !TASK_ID.test(value.id))
116
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].id is invalid`);
117
+ const title = boundedText(value.title, 240);
118
+ if (!title)
119
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].title is invalid`);
120
+ if (!exports.TASK_STATUSES.includes(value.status))
121
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].status is invalid`);
122
+ if (!exports.TASK_PRIORITIES.includes(value.priority))
123
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].priority is invalid`);
124
+ if (value.owner !== "human" && value.owner !== "agent")
125
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].owner is invalid`);
126
+ if (!Array.isArray(value.tags) || value.tags.length > 32 || value.tags.some((tag) => typeof tag !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(tag))) {
127
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].tags are invalid`);
128
+ }
129
+ if (new Set(value.tags).size !== value.tags.length)
130
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].tags must be unique`);
131
+ if (!exactIso(value.createdAt) || !exactIso(value.updatedAt))
132
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}] timestamps are invalid`);
133
+ if (value.completedAt !== undefined && !exactIso(value.completedAt))
134
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].completedAt is invalid`);
135
+ if (value.status === "done" && value.completedAt === undefined)
136
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}] done status requires completedAt`);
137
+ if (value.status !== "done" && value.completedAt !== undefined)
138
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}] non-done status forbids completedAt`);
139
+ for (const [field, max] of [["description", 10000], ["ownerLabel", 120], ["source", 120], ["verify", 2000]]) {
140
+ if (value[field] !== undefined && !boundedText(value[field], max, true))
141
+ throw new TasksFileError("TASKS_INVALID_TASK", `tasks[${index}].${field} is invalid`);
142
+ }
143
+ return {
144
+ id: value.id,
145
+ title,
146
+ ...(value.description !== undefined ? { description: value.description } : {}),
147
+ status: value.status,
148
+ priority: value.priority,
149
+ owner: value.owner,
150
+ ...(value.ownerLabel !== undefined ? { ownerLabel: value.ownerLabel } : {}),
151
+ tags: [...value.tags],
152
+ ...(value.source !== undefined ? { source: value.source } : {}),
153
+ ...(value.verify !== undefined ? { verify: value.verify } : {}),
154
+ createdAt: value.createdAt,
155
+ updatedAt: value.updatedAt,
156
+ ...(value.completedAt !== undefined ? { completedAt: value.completedAt } : {}),
157
+ };
90
158
  }
91
- function normalizeTask(raw) {
92
- const created = typeof raw.createdAt === "string" ? raw.createdAt : nowIso();
159
+ function parseTasksJson(content, expectedProject) {
160
+ let value;
161
+ try {
162
+ value = JSON.parse(content);
163
+ }
164
+ catch {
165
+ throw new TasksFileError("TASKS_INVALID_JSON", "tasks.json is not valid JSON");
166
+ }
167
+ if (!isRecord(value))
168
+ throw new TasksFileError("TASKS_INVALID_DOCUMENT", "tasks.json must be an object");
169
+ assertNoUnknown(value, DOC_KEYS, "TASKS_INVALID_DOCUMENT", "tasks.json");
170
+ if (value.$schema !== exports.TASKS_SCHEMA)
171
+ throw new TasksFileError("TASKS_INVALID_SCHEMA", `expected ${exports.TASKS_SCHEMA}`);
172
+ if (typeof value.project !== "string" || !PROJECT_NAME.test(value.project))
173
+ throw new TasksFileError("TASKS_INVALID_DOCUMENT", "project is invalid");
174
+ if (expectedProject && value.project !== expectedProject)
175
+ throw new TasksFileError("TASKS_INVALID_DOCUMENT", `project must equal ${expectedProject}`);
176
+ if (!exactIso(value.updatedAt))
177
+ throw new TasksFileError("TASKS_INVALID_DOCUMENT", "updatedAt is invalid");
178
+ if (!Array.isArray(value.tasks) || value.tasks.length > 10000)
179
+ throw new TasksFileError("TASKS_INVALID_DOCUMENT", "tasks must be an array with at most 10000 rows");
180
+ if (value.notes !== undefined && !boundedText(value.notes, 100000, true))
181
+ throw new TasksFileError("TASKS_INVALID_DOCUMENT", "notes is invalid");
182
+ const tasks = value.tasks.map(parseTask);
183
+ const ids = tasks.map((task) => task.id);
184
+ if (new Set(ids).size !== ids.length)
185
+ throw new TasksFileError("TASKS_DUPLICATE_ID", "task ids must be unique");
93
186
  return {
94
- id: typeof raw.id === "string" && raw.id ? raw.id : generateTaskId(),
95
- title: String(raw.title || "").trim() || "(untitled)",
96
- description: typeof raw.description === "string" ? raw.description : undefined,
97
- status: normalizeStatus(raw.status),
98
- priority: normalizePriority(raw.priority),
99
- owner: raw.owner === "agent" ? "agent" : "human",
100
- ownerLabel: typeof raw.ownerLabel === "string" ? raw.ownerLabel : undefined,
101
- tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
102
- source: typeof raw.source === "string" ? raw.source : undefined,
103
- verify: typeof raw.verify === "string" ? raw.verify : undefined,
104
- createdAt: created,
105
- updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : created,
106
- completedAt: typeof raw.completedAt === "string" ? raw.completedAt : undefined,
107
- remoteId: typeof raw.remoteId === "string" ? raw.remoteId : undefined,
187
+ $schema: exports.TASKS_SCHEMA,
188
+ project: value.project,
189
+ updatedAt: value.updatedAt,
190
+ tasks,
191
+ ...(value.notes !== undefined ? { notes: value.notes } : {}),
108
192
  };
109
193
  }
110
- /** Parse a legacy freeform tasks.md checklist into task entries. */
194
+ /** Legacy import is reviewable in memory; persistence still requires task_write. */
111
195
  function parseLegacyTasksMd(content, project) {
196
+ if (content.includes(GENERATED_MARKER) || ID_COMMENT.test(content)) {
197
+ throw new TasksFileError("TASKS_JSON_MISSING", "generated tasks.md exists without canonical tasks.json");
198
+ }
112
199
  const tasks = [];
113
- const now = nowIso();
200
+ const timestamp = nowIso();
114
201
  const notes = [];
115
202
  let inNotes = false;
116
203
  for (const line of content.split(/\r?\n/)) {
@@ -126,32 +213,23 @@ function parseLegacyTasksMd(content, project) {
126
213
  notes.push(line);
127
214
  continue;
128
215
  }
129
- const bullet = line.match(/^\s*-\s*\[([ xX])\]\s+(.*)$/);
216
+ const bullet = line.match(/^\s*-\s*\[([ xX])\]\s+(.+)$/);
130
217
  if (!bullet)
131
218
  continue;
132
219
  const done = bullet[1].toLowerCase() === "x";
133
- // Legacy entries are often paragraph-length; keep the first sentence as
134
- // the title and the rest as description so nothing is lost.
135
220
  const text = bullet[2].trim();
136
221
  const splitAt = text.length > 160 ? text.indexOf(". ") : -1;
137
- const title = splitAt > 0 ? text.slice(0, splitAt + 1) : text;
138
- const description = splitAt > 0 ? text.slice(splitAt + 2) : undefined;
139
- tasks.push(normalizeTask({
140
- title,
141
- description,
142
- status: done ? "done" : "open",
143
- source: "legacy-md",
144
- createdAt: now,
145
- completedAt: done ? now : undefined,
146
- }));
222
+ tasks.push({
223
+ id: generateTaskId(), title: splitAt > 0 ? text.slice(0, splitAt + 1) : text,
224
+ ...(splitAt > 0 ? { description: text.slice(splitAt + 2) } : {}),
225
+ status: done ? "done" : "open", priority: "normal", owner: "human", tags: [], source: "legacy-md",
226
+ createdAt: timestamp, updatedAt: timestamp, ...(done ? { completedAt: timestamp } : {}),
227
+ });
147
228
  }
148
- return {
149
- $schema: exports.TASKS_SCHEMA,
150
- project,
151
- updatedAt: now,
152
- tasks,
153
- notes: notes.length ? notes.join("\n") : undefined,
154
- };
229
+ return { $schema: exports.TASKS_SCHEMA, project, updatedAt: timestamp, tasks, ...(notes.length ? { notes: notes.join("\n") } : {}) };
230
+ }
231
+ function compareTask(a, b) {
232
+ return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
155
233
  }
156
234
  function statusLabel(task) {
157
235
  const bits = [task.status];
@@ -160,147 +238,265 @@ function statusLabel(task) {
160
238
  if (task.owner === "agent")
161
239
  bits.push(task.ownerLabel ? `agent:${task.ownerLabel}` : "agent");
162
240
  if (task.tags.length)
163
- bits.push(task.tags.map((t) => `#${t}`).join(" "));
241
+ bits.push(task.tags.map((tag) => `#${tag}`).join(" "));
164
242
  return bits.join(" · ");
165
243
  }
166
- /** Render tasks.md from the doc. Deterministic; safe to regenerate. */
167
244
  function renderTasksMd(doc) {
168
- const active = doc.tasks.filter((t) => OPEN_STATUSES.includes(t.status));
169
- const closed = doc.tasks.filter((t) => !OPEN_STATUSES.includes(t.status));
170
- const lines = [
171
- `# Tasks ${doc.project}`,
172
- "",
173
- "<!-- generated from tasks.json (you-md/tasks/v1). Checkbox edits here sync back on next read;",
174
- " everything else regenerates. Freeform notes live between the notes markers below. -->",
175
- "",
176
- `## Active (${active.length})`,
177
- "",
178
- ];
179
- for (const t of active) {
180
- lines.push(`- [ ] ${t.title} <!-- id:${t.id} -->`);
181
- lines.push(` ${statusLabel(t)}`);
182
- if (t.verify)
183
- lines.push(` verify: ${t.verify}`);
184
- if (t.description)
185
- lines.push(` ${t.description}`);
245
+ const active = doc.tasks.filter((task) => OPEN_STATUSES.includes(task.status)).sort(compareTask);
246
+ const closed = doc.tasks.filter((task) => !OPEN_STATUSES.includes(task.status)).sort(compareTask);
247
+ const lines = [`# Tasks — ${doc.project}`, "", GENERATED_MARKER, " regenerate with `you tasks`; manual changes are rejected. -->", "", `## Active (${active.length})`, ""];
248
+ for (const task of active) {
249
+ lines.push(`- [ ] ${task.title} <!-- id:${task.id} -->`, ` ${statusLabel(task)}`);
250
+ if (task.verify)
251
+ lines.push(` verify: ${task.verify}`);
252
+ if (task.description)
253
+ lines.push(` ${task.description}`);
186
254
  }
187
255
  lines.push("", `## Done (${closed.length})`, "");
188
- for (const t of closed) {
189
- const box = t.status === "done" ? "x" : " ";
190
- const suffix = t.status === "done" ? "" : ` (${t.status})`;
191
- lines.push(`- [${box}] ${t.title}${suffix} <!-- id:${t.id} -->`);
192
- }
256
+ for (const task of closed)
257
+ lines.push(`- [${task.status === "done" ? "x" : " "}] ${task.title}${task.status === "done" ? "" : ` (${task.status})`} <!-- id:${task.id} -->`);
193
258
  lines.push("", NOTES_START, doc.notes || "", NOTES_END, "");
194
259
  return lines.join("\n");
195
260
  }
261
+ function assertTasksMdMatches(doc, content) {
262
+ if (content !== renderTasksMd(doc))
263
+ throw new TasksFileError("TASKS_MD_DRIFT", "tasks.md differs from deterministic tasks.json rendering");
264
+ }
196
265
  /**
197
- * Reconcile checkbox states in tasks.md back into the doc. Only the checkbox
198
- * is authoritative in md a ticked box marks done, an unticked box on a
199
- * done/cancelled task reopens it. Returns the number of changed tasks.
266
+ * Pure migration utility retained for callers that explicitly review a legacy
267
+ * checkbox diff. Canonical reads never call this automatically: generated
268
+ * tasks.md drift is rejected until an approved tasks.json change is written.
200
269
  */
201
- function reconcileMdCheckboxes(doc, mdContent) {
202
- const byId = new Map(doc.tasks.map((t) => [t.id, t]));
270
+ function reconcileMdCheckboxes(doc, mdContent, now = new Date()) {
271
+ const byId = new Map(doc.tasks.map((task) => [task.id, task]));
203
272
  let changed = 0;
204
273
  for (const line of mdContent.split(/\r?\n/)) {
205
- const bullet = line.match(/^\s*-\s*\[([ xX])\]\s+(.*)$/);
206
- if (!bullet)
207
- continue;
208
- const idMatch = bullet[2].match(ID_COMMENT);
209
- if (!idMatch)
210
- continue;
211
- const task = byId.get(idMatch[1]);
212
- if (!task)
274
+ const bullet = line.match(/^\s*-\s*\[([ xX])\]\s+(.+)$/);
275
+ const id = bullet?.[2].match(ID_COMMENT)?.[1];
276
+ const task = id ? byId.get(id) : undefined;
277
+ if (!bullet || !task)
213
278
  continue;
214
279
  const checked = bullet[1].toLowerCase() === "x";
215
- const isOpen = OPEN_STATUSES.includes(task.status);
216
- if (checked && isOpen) {
280
+ if (checked && OPEN_STATUSES.includes(task.status)) {
217
281
  task.status = "done";
218
- task.completedAt = nowIso();
219
- task.updatedAt = nowIso();
282
+ task.completedAt = nowIso(now);
283
+ task.updatedAt = nowIso(now);
220
284
  changed += 1;
221
285
  }
222
286
  else if (!checked && task.status === "done") {
223
287
  task.status = "open";
224
288
  task.completedAt = undefined;
225
- task.updatedAt = nowIso();
289
+ task.updatedAt = nowIso(now);
226
290
  changed += 1;
227
291
  }
228
292
  }
229
293
  return changed;
230
294
  }
231
- function extractNotes(mdContent) {
232
- const start = mdContent.indexOf(NOTES_START);
233
- const end = mdContent.indexOf(NOTES_END);
234
- if (start === -1 || end === -1 || end <= start)
235
- return undefined;
236
- const notes = mdContent.slice(start + NOTES_START.length, end).trim();
237
- return notes || undefined;
295
+ function findGitRoot(start) {
296
+ let current = path.resolve(start);
297
+ while (true) {
298
+ const marker = path.join(current, ".git");
299
+ if (fs.existsSync(marker))
300
+ return current;
301
+ const parent = path.dirname(current);
302
+ if (parent === current)
303
+ return null;
304
+ current = parent;
305
+ }
306
+ }
307
+ function assertNoSymlink(root, candidate) {
308
+ const relative = path.relative(root, candidate);
309
+ if (relative.startsWith("..") || path.isAbsolute(relative))
310
+ throw new TasksFileError("TASKS_SYMLINK_REFUSED", "path escapes repository root");
311
+ let current = root;
312
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
313
+ current = path.join(current, segment);
314
+ let stat;
315
+ try {
316
+ stat = fs.lstatSync(current);
317
+ }
318
+ catch (error) {
319
+ if (error.code === "ENOENT")
320
+ continue;
321
+ throw error;
322
+ }
323
+ if (stat.isSymbolicLink())
324
+ throw new TasksFileError("TASKS_SYMLINK_REFUSED", `${path.relative(root, current)} is a symlink`);
325
+ }
326
+ }
327
+ function resolveTaskWriteGrant(contextDir) {
328
+ const repoRoot = findGitRoot(contextDir);
329
+ if (!repoRoot)
330
+ throw new TasksFileError("TASKS_WRITE_NOT_GRANTED", "task writes require a Git repository");
331
+ assertNoSymlink(repoRoot, contextDir);
332
+ const descriptorPath = path.join(repoRoot, ".you-project");
333
+ assertNoSymlink(repoRoot, descriptorPath);
334
+ let descriptor;
335
+ try {
336
+ descriptor = JSON.parse(fs.readFileSync(descriptorPath, "utf8"));
337
+ }
338
+ catch {
339
+ throw new TasksFileError("TASKS_WRITE_NOT_GRANTED", "a valid .you-project descriptor is required");
340
+ }
341
+ const writePolicy = isRecord(descriptor) && isRecord(descriptor.writePolicy) ? descriptor.writePolicy : null;
342
+ const taskWrite = writePolicy && isRecord(writePolicy.taskWrite) ? writePolicy.taskWrite : null;
343
+ if (!isRecord(descriptor) || descriptor.schemaVersion !== "you-md/project-brain/v1" || descriptor.kind !== "project"
344
+ || typeof descriptor.projectRef !== "string" || !PROJECT_REF.test(descriptor.projectRef)
345
+ || !taskWrite || taskWrite.enabled !== true || typeof taskWrite.artifactRef !== "string"
346
+ || !ARTIFACT_REF.test(taskWrite.artifactRef) || !Array.isArray(descriptor.artifacts)) {
347
+ throw new TasksFileError("TASKS_WRITE_NOT_GRANTED", ".you-project does not grant task_write");
348
+ }
349
+ const artifact = descriptor.artifacts.find((item) => isRecord(item) && item.artifactRef === taskWrite.artifactRef);
350
+ const artifactPath = path.relative(repoRoot, tasksJsonPath(contextDir)).split(path.sep).join("/");
351
+ if (!isRecord(artifact) || artifact.kind !== "tasks" || artifact.path !== artifactPath || artifact.writePolicy !== "task_write"
352
+ || artifact.owner !== "repository" || artifact.authority !== "canonical" || !SAFE_RELATIVE_PATH.test(artifactPath)) {
353
+ throw new TasksFileError("TASKS_WRITE_NOT_GRANTED", "task_write is not bound to this canonical tasks.json artifact");
354
+ }
355
+ return { projectRef: descriptor.projectRef, artifactRef: artifact.artifactRef, artifactPath, repoRoot };
356
+ }
357
+ function assertRegularOrMissing(root, filePath) {
358
+ assertNoSymlink(root, filePath);
359
+ try {
360
+ if (!fs.lstatSync(filePath).isFile())
361
+ throw new TasksFileError("TASKS_SYMLINK_REFUSED", `${path.basename(filePath)} is not a regular file`);
362
+ }
363
+ catch (error) {
364
+ if (error.code !== "ENOENT")
365
+ throw error;
366
+ }
367
+ }
368
+ function writeSynced(filePath, content) {
369
+ const fd = fs.openSync(filePath, "wx", 0o600);
370
+ try {
371
+ fs.writeFileSync(fd, content, "utf8");
372
+ fs.fsyncSync(fd);
373
+ }
374
+ finally {
375
+ fs.closeSync(fd);
376
+ }
238
377
  }
239
- /**
240
- * Load the canonical task doc for a project-context directory.
241
- *
242
- * - tasks.json exists → parse it, then reconcile tasks.md checkbox edits.
243
- * - only a legacy tasks.md exists → import it (one-time migration).
244
- * - neither exists → fresh empty doc.
245
- */
246
378
  function readTasksDoc(contextDir, project) {
247
379
  const jsonPath = tasksJsonPath(contextDir);
248
380
  const mdPath = tasksMdPath(contextDir);
381
+ const repoRoot = findGitRoot(contextDir) ?? path.resolve(contextDir);
382
+ assertNoSymlink(repoRoot, contextDir);
383
+ assertRegularOrMissing(repoRoot, jsonPath);
384
+ assertRegularOrMissing(repoRoot, mdPath);
385
+ const tmpRoot = path.join(contextDir, ".tmp");
386
+ if (fs.existsSync(tmpRoot) && fs.readdirSync(tmpRoot).some((name) => name.startsWith("youmd-tasks-"))) {
387
+ throw new TasksFileError("TASKS_WRITE_INCOMPLETE", "an interrupted task-ledger transaction requires recovery by the next granted write");
388
+ }
249
389
  if (fs.existsSync(jsonPath)) {
250
- let parsed;
390
+ const doc = parseTasksJson(fs.readFileSync(jsonPath, "utf8"), project);
391
+ if (!fs.existsSync(mdPath))
392
+ throw new TasksFileError("TASKS_MD_MISSING", "tasks.md is missing");
393
+ assertTasksMdMatches(doc, fs.readFileSync(mdPath, "utf8"));
394
+ return { doc, migrated: false, reconciled: 0 };
395
+ }
396
+ if (fs.existsSync(mdPath))
397
+ return { doc: parseLegacyTasksMd(fs.readFileSync(mdPath, "utf8"), project), migrated: true, reconciled: 0 };
398
+ return { doc: { $schema: exports.TASKS_SCHEMA, project, updatedAt: nowIso(), tasks: [] }, migrated: false, reconciled: 0 };
399
+ }
400
+ function writeTasksDoc(contextDir, input, options = {}) {
401
+ const grant = resolveTaskWriteGrant(contextDir);
402
+ fs.mkdirSync(contextDir, { recursive: true, mode: 0o755 });
403
+ assertNoSymlink(grant.repoRoot, contextDir);
404
+ const jsonPath = tasksJsonPath(contextDir);
405
+ const mdPath = tasksMdPath(contextDir);
406
+ assertRegularOrMissing(grant.repoRoot, jsonPath);
407
+ assertRegularOrMissing(grant.repoRoot, mdPath);
408
+ const doc = { ...input, updatedAt: nowIso(options.now), tasks: input.tasks.map((task) => ({ ...task, tags: [...task.tags] })) };
409
+ const json = JSON.stringify(doc, null, 2) + "\n";
410
+ const validated = parseTasksJson(json, input.project);
411
+ const markdown = renderTasksMd(validated);
412
+ const tmpRoot = path.join(contextDir, ".tmp");
413
+ assertNoSymlink(grant.repoRoot, tmpRoot);
414
+ fs.mkdirSync(tmpRoot, { recursive: true, mode: 0o700 });
415
+ const lockPath = path.join(tmpRoot, "youmd-tasks-write.lock");
416
+ let lockFd;
417
+ try {
418
+ lockFd = fs.openSync(lockPath, "wx", 0o600);
419
+ }
420
+ catch {
421
+ throw new TasksFileError("TASKS_WRITE_BUSY", "another task-ledger write is active");
422
+ }
423
+ const txn = path.join(tmpRoot, `youmd-tasks-${crypto.randomBytes(8).toString("hex")}`);
424
+ fs.mkdirSync(txn, { mode: 0o700 });
425
+ const newJson = path.join(txn, "tasks.json.new");
426
+ const newMd = path.join(txn, "tasks.md.new");
427
+ const oldJson = path.join(txn, "tasks.json.old");
428
+ const oldMd = path.join(txn, "tasks.md.old");
429
+ const hadJson = fs.existsSync(jsonPath);
430
+ const hadMd = fs.existsSync(mdPath);
431
+ try {
432
+ writeSynced(newJson, json);
433
+ writeSynced(newMd, markdown);
434
+ if (hadJson)
435
+ fs.copyFileSync(jsonPath, oldJson, fs.constants.COPYFILE_EXCL);
436
+ if (hadMd)
437
+ fs.copyFileSync(mdPath, oldMd, fs.constants.COPYFILE_EXCL);
438
+ assertRegularOrMissing(grant.repoRoot, jsonPath);
439
+ assertRegularOrMissing(grant.repoRoot, mdPath);
440
+ fs.renameSync(newJson, jsonPath);
441
+ if (options.faultAfterJson)
442
+ throw new Error("faultAfterJson");
443
+ fs.renameSync(newMd, mdPath);
444
+ fs.chmodSync(jsonPath, 0o644);
445
+ fs.chmodSync(mdPath, 0o644);
446
+ input.updatedAt = validated.updatedAt;
447
+ }
448
+ catch (error) {
251
449
  try {
252
- parsed = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
450
+ if (hadJson && fs.existsSync(oldJson))
451
+ fs.copyFileSync(oldJson, jsonPath);
452
+ else if (!hadJson && fs.existsSync(jsonPath))
453
+ fs.unlinkSync(jsonPath);
454
+ if (hadMd && fs.existsSync(oldMd))
455
+ fs.copyFileSync(oldMd, mdPath);
456
+ else if (!hadMd && fs.existsSync(mdPath))
457
+ fs.unlinkSync(mdPath);
253
458
  }
254
459
  catch {
255
- parsed = {};
256
- }
257
- const rawTasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
258
- const doc = {
259
- $schema: exports.TASKS_SCHEMA,
260
- project: typeof parsed.project === "string" && parsed.project ? parsed.project : project,
261
- updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : nowIso(),
262
- tasks: rawTasks.map(normalizeTask),
263
- notes: typeof parsed.notes === "string" ? parsed.notes : undefined,
264
- };
265
- let reconciled = 0;
266
- if (fs.existsSync(mdPath)) {
267
- const md = fs.readFileSync(mdPath, "utf-8");
268
- reconciled = reconcileMdCheckboxes(doc, md);
269
- const notes = extractNotes(md);
270
- if (notes !== undefined)
271
- doc.notes = notes;
460
+ throw new TasksFileError("TASKS_WRITE_INCOMPLETE", "task-ledger rollback failed; manual recovery required");
272
461
  }
273
- return { doc, migrated: false, reconciled };
462
+ throw error;
274
463
  }
275
- if (fs.existsSync(mdPath)) {
276
- const doc = parseLegacyTasksMd(fs.readFileSync(mdPath, "utf-8"), project);
277
- return { doc, migrated: true, reconciled: 0 };
464
+ finally {
465
+ try {
466
+ fs.rmSync(txn, { recursive: true, force: true });
467
+ }
468
+ catch { /* surfaced on next read */ }
469
+ try {
470
+ fs.closeSync(lockFd);
471
+ }
472
+ catch { /* no-op */ }
473
+ try {
474
+ fs.unlinkSync(lockPath);
475
+ }
476
+ catch { /* no-op */ }
278
477
  }
279
- return {
280
- doc: { $schema: exports.TASKS_SCHEMA, project, updatedAt: nowIso(), tasks: [] },
281
- migrated: false,
282
- reconciled: 0,
283
- };
284
- }
285
- /** Write both files atomically-ish: json first (truth), then the md render. */
286
- function writeTasksDoc(contextDir, doc) {
287
- fs.mkdirSync(contextDir, { recursive: true });
288
- doc.updatedAt = nowIso();
289
- fs.writeFileSync(tasksJsonPath(contextDir), JSON.stringify(doc, null, 2) + "\n", "utf-8");
290
- fs.writeFileSync(tasksMdPath(contextDir), renderTasksMd(doc), "utf-8");
291
478
  }
292
479
  function addTask(doc, input) {
293
- const task = normalizeTask({ ...input, createdAt: nowIso() });
480
+ const timestamp = nowIso();
481
+ const status = input.status ?? "open";
482
+ const task = {
483
+ id: input.id ?? generateTaskId(), title: input.title.trim(), ...(input.description ? { description: input.description } : {}),
484
+ status, priority: input.priority ?? "normal", owner: input.owner ?? "human", ...(input.ownerLabel ? { ownerLabel: input.ownerLabel } : {}),
485
+ tags: [...(input.tags ?? [])], ...(input.source ? { source: input.source } : {}), ...(input.verify ? { verify: input.verify } : {}),
486
+ createdAt: input.createdAt ?? timestamp, updatedAt: timestamp,
487
+ ...(status === "done" ? { completedAt: input.completedAt ?? timestamp } : {}),
488
+ };
489
+ parseTask(task, doc.tasks.length);
490
+ if (doc.tasks.some((existing) => existing.id === task.id))
491
+ throw new TasksFileError("TASKS_DUPLICATE_ID", `duplicate task id ${task.id}`);
294
492
  doc.tasks.push(task);
295
493
  return task;
296
494
  }
297
495
  function findTask(doc, idOrTitle) {
298
496
  const needle = idOrTitle.trim().toLowerCase();
299
- return (doc.tasks.find((t) => t.id.toLowerCase() === needle) ||
300
- doc.tasks.find((t) => t.title.toLowerCase() === needle) ||
301
- doc.tasks.find((t) => t.title.toLowerCase().includes(needle)));
302
- }
303
- function openTasks(doc) {
304
- return doc.tasks.filter((t) => OPEN_STATUSES.includes(t.status));
497
+ return doc.tasks.find((task) => task.id.toLowerCase() === needle)
498
+ || doc.tasks.find((task) => task.title.toLowerCase() === needle)
499
+ || doc.tasks.find((task) => task.title.toLowerCase().includes(needle));
305
500
  }
501
+ function openTasks(doc) { return doc.tasks.filter((task) => OPEN_STATUSES.includes(task.status)); }
306
502
  //# sourceMappingURL=tasksFile.js.map