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.
- package/dist/commands/brain.d.ts +25 -0
- package/dist/commands/brain.d.ts.map +1 -0
- package/dist/commands/brain.js +683 -0
- package/dist/commands/brain.js.map +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +4 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/machine.d.ts +3 -0
- package/dist/commands/machine.d.ts.map +1 -1
- package/dist/commands/machine.js +73 -4
- package/dist/commands/machine.js.map +1 -1
- package/dist/commands/orchestrate.d.ts.map +1 -1
- package/dist/commands/orchestrate.js +4 -3
- package/dist/commands/orchestrate.js.map +1 -1
- package/dist/commands/project.d.ts.map +1 -1
- package/dist/commands/project.js +24 -0
- package/dist/commands/project.js.map +1 -1
- package/dist/commands/remote.d.ts.map +1 -1
- package/dist/commands/remote.js +42 -13
- package/dist/commands/remote.js.map +1 -1
- package/dist/commands/skill.d.ts.map +1 -1
- package/dist/commands/skill.js +2 -0
- package/dist/commands/skill.js.map +1 -1
- package/dist/commands/tasks.d.ts +23 -3
- package/dist/commands/tasks.d.ts.map +1 -1
- package/dist/commands/tasks.js +179 -49
- package/dist/commands/tasks.js.map +1 -1
- package/dist/generated/capability-registry.d.ts +1096 -0
- package/dist/generated/capability-registry.d.ts.map +1 -0
- package/dist/generated/capability-registry.js +1204 -0
- package/dist/generated/capability-registry.js.map +1 -0
- package/dist/index.js +42 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/api.d.ts +84 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +26 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/brain/adapters.d.ts +34 -0
- package/dist/lib/brain/adapters.d.ts.map +1 -0
- package/dist/lib/brain/adapters.js +362 -0
- package/dist/lib/brain/adapters.js.map +1 -0
- package/dist/lib/brain/apply-control-plane.d.ts +17 -0
- package/dist/lib/brain/apply-control-plane.d.ts.map +1 -0
- package/dist/lib/brain/apply-control-plane.js +117 -0
- package/dist/lib/brain/apply-control-plane.js.map +1 -0
- package/dist/lib/brain/cache.d.ts +25 -0
- package/dist/lib/brain/cache.d.ts.map +1 -0
- package/dist/lib/brain/cache.js +99 -0
- package/dist/lib/brain/cache.js.map +1 -0
- package/dist/lib/brain/canonical.d.ts +5 -0
- package/dist/lib/brain/canonical.d.ts.map +1 -0
- package/dist/lib/brain/canonical.js +38 -0
- package/dist/lib/brain/canonical.js.map +1 -0
- package/dist/lib/brain/discovery.d.ts +9 -0
- package/dist/lib/brain/discovery.d.ts.map +1 -0
- package/dist/lib/brain/discovery.js +560 -0
- package/dist/lib/brain/discovery.js.map +1 -0
- package/dist/lib/brain/index.d.ts +55 -0
- package/dist/lib/brain/index.d.ts.map +1 -0
- package/dist/lib/brain/index.js +384 -0
- package/dist/lib/brain/index.js.map +1 -0
- package/dist/lib/brain/proposal.d.ts +179 -0
- package/dist/lib/brain/proposal.d.ts.map +1 -0
- package/dist/lib/brain/proposal.js +694 -0
- package/dist/lib/brain/proposal.js.map +1 -0
- package/dist/lib/brain/render.d.ts +33 -0
- package/dist/lib/brain/render.d.ts.map +1 -0
- package/dist/lib/brain/render.js +515 -0
- package/dist/lib/brain/render.js.map +1 -0
- package/dist/lib/brain/safe-file.d.ts +110 -0
- package/dist/lib/brain/safe-file.d.ts.map +1 -0
- package/dist/lib/brain/safe-file.js +564 -0
- package/dist/lib/brain/safe-file.js.map +1 -0
- package/dist/lib/brain/safety.d.ts +8 -0
- package/dist/lib/brain/safety.d.ts.map +1 -0
- package/dist/lib/brain/safety.js +42 -0
- package/dist/lib/brain/safety.js.map +1 -0
- package/dist/lib/brain/staged-apply.d.ts +57 -0
- package/dist/lib/brain/staged-apply.d.ts.map +1 -0
- package/dist/lib/brain/staged-apply.js +336 -0
- package/dist/lib/brain/staged-apply.js.map +1 -0
- package/dist/lib/brain/store.d.ts +26 -0
- package/dist/lib/brain/store.d.ts.map +1 -0
- package/dist/lib/brain/store.js +504 -0
- package/dist/lib/brain/store.js.map +1 -0
- package/dist/lib/brain/types.d.ts +160 -0
- package/dist/lib/brain/types.d.ts.map +1 -0
- package/dist/lib/brain/types.js +24 -0
- package/dist/lib/brain/types.js.map +1 -0
- package/dist/lib/config.d.ts +22 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +70 -14
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/daemon.d.ts +27 -0
- package/dist/lib/daemon.d.ts.map +1 -1
- package/dist/lib/daemon.js +57 -0
- package/dist/lib/daemon.js.map +1 -1
- package/dist/lib/machine-identity.d.ts +16 -0
- package/dist/lib/machine-identity.d.ts.map +1 -0
- package/dist/lib/machine-identity.js +101 -0
- package/dist/lib/machine-identity.js.map +1 -0
- package/dist/lib/orchestrator/loop.d.ts +1 -0
- package/dist/lib/orchestrator/loop.d.ts.map +1 -1
- package/dist/lib/orchestrator/loop.js +16 -0
- package/dist/lib/orchestrator/loop.js.map +1 -1
- package/dist/lib/orchestrator/tools.d.ts +11 -0
- package/dist/lib/orchestrator/tools.d.ts.map +1 -1
- package/dist/lib/orchestrator/tools.js +143 -0
- package/dist/lib/orchestrator/tools.js.map +1 -1
- package/dist/lib/project-strategy.d.ts +38 -0
- package/dist/lib/project-strategy.d.ts.map +1 -1
- package/dist/lib/project-strategy.js +91 -0
- package/dist/lib/project-strategy.js.map +1 -1
- package/dist/lib/projectContext.d.ts +6 -4
- package/dist/lib/projectContext.d.ts.map +1 -1
- package/dist/lib/projectContext.js +19 -17
- package/dist/lib/projectContext.js.map +1 -1
- package/dist/lib/tasksFile.d.ts +27 -32
- package/dist/lib/tasksFile.d.ts.map +1 -1
- package/dist/lib/tasksFile.js +359 -163
- package/dist/lib/tasksFile.js.map +1 -1
- package/dist/mcp/registry.d.ts +8 -2
- package/dist/mcp/registry.d.ts.map +1 -1
- package/dist/mcp/registry.js +189 -2
- package/dist/mcp/registry.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +3 -2
- package/dist/mcp/server.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/tasksFile.js
CHANGED
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Canonical
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
77
|
-
return
|
|
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
|
|
80
|
-
|
|
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
|
|
83
|
-
|
|
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
|
|
86
|
-
return
|
|
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
|
|
89
|
-
|
|
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
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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((
|
|
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((
|
|
169
|
-
const closed = doc.tasks.filter((
|
|
170
|
-
const lines = [
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
189
|
-
|
|
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
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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((
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
+
throw error;
|
|
274
463
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
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
|
|
300
|
-
doc.tasks.find((
|
|
301
|
-
doc.tasks.find((
|
|
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
|