toolcraft 0.0.15 → 0.0.17

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/cli.js CHANGED
@@ -921,7 +921,7 @@ function renderGroupHelp(group, breadcrumb, scope, casing, globalOptions, rootUs
921
921
  ...formatGlobalOptionRows(globalOptions),
922
922
  ...collectSchemaGlobalFieldRows(group, scope, casing, globalLongOptionFlags)
923
923
  ];
924
- sections.push(`${text.sectionHeader("Global options")}\n${formatHelpOptionList(globalRows)}`);
924
+ sections.push(`${text.sectionHeader("Options")}\n${formatHelpOptionList(globalRows)}`);
925
925
  }
926
926
  return renderHelpDocument({
927
927
  breadcrumb,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-code/design-system",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -140,6 +140,104 @@ await project.move(created.id, { after: "42" });
140
140
 
141
141
  `gh-issues` exposes one list named `${project.owner}/${project.number}`. `create()` ignores `TaskCreate.id` because GitHub assigns issue numbers. `fire(state)` writes the Project v2 `Status` field to the matching single-select option. `move()` reorders project items.
142
142
 
143
+ If the GitHub Project v2 board has not been set up manually, run `poe-code tasks sync <list>` before opening the backend. `openTaskList({ type: "gh-issues" })` no longer requires manual board setup when the board was provisioned with `tasks sync` first.
144
+
145
+ ## Verifying and provisioning the GitHub Project v2 board
146
+
147
+ Use `verifyGhProject` to check whether a GitHub Project v2 board has the required Project and `Status` single-select options:
148
+
149
+ ```ts
150
+ import { verifyGhProject } from "@poe-code/task-list";
151
+
152
+ const report = await verifyGhProject({
153
+ owner: "octo-org",
154
+ number: 7,
155
+ requiredStates: ["queued", "agent-running", "human-review", "done", "failed", "archived"],
156
+ auth: { token: "github-token" }
157
+ });
158
+ ```
159
+
160
+ `VerifyGhProjectOptions` has this shape:
161
+
162
+ ```ts
163
+ interface VerifyGhProjectOptions {
164
+ owner: string;
165
+ number: number;
166
+ requiredStates: readonly string[];
167
+ client?: GhClient;
168
+ fetch?: typeof fetch;
169
+ auth?: { token: string };
170
+ }
171
+ ```
172
+
173
+ `verifyGhProject` returns `Promise<VerifyGhProjectReport>`:
174
+
175
+ ```ts
176
+ interface VerifyGhProjectReport {
177
+ ok: boolean;
178
+ project: { id: string; number: number; owner: string } | null;
179
+ statusField: { id: string; options: readonly string[] } | null;
180
+ missingProject: boolean;
181
+ missingStatusField: boolean;
182
+ missingOptions: readonly string[];
183
+ }
184
+ ```
185
+
186
+ Use `syncGhProject` to provision anything missing:
187
+
188
+ ```ts
189
+ import { syncGhProject } from "@poe-code/task-list";
190
+
191
+ const report = await syncGhProject({
192
+ owner: "octo-org",
193
+ number: 7,
194
+ requiredStates: ["queued", "agent-running", "human-review", "done", "failed", "archived"],
195
+ title: "Delivery Board",
196
+ yes: true,
197
+ auth: { token: "github-token" }
198
+ });
199
+ ```
200
+
201
+ `SyncGhProjectOptions` extends `VerifyGhProjectOptions`:
202
+
203
+ ```ts
204
+ interface SyncGhProjectOptions extends VerifyGhProjectOptions {
205
+ title?: string;
206
+ yes?: boolean;
207
+ }
208
+ ```
209
+
210
+ `syncGhProject` returns `Promise<SyncGhProjectReport>`:
211
+
212
+ ```ts
213
+ interface SyncGhProjectReport extends VerifyGhProjectReport {
214
+ created: readonly string[];
215
+ updated: readonly string[];
216
+ }
217
+ ```
218
+
219
+ The CLI exposes the same verification and provisioning flow:
220
+
221
+ ```sh
222
+ poe-code tasks verify <list> --workflow ./WORKFLOW.md --repo octo-org/octo-repo --project octo-org/7 --states queued,agent-running,human-review,done,failed,archived --json
223
+ poe-code tasks sync <list> --workflow ./WORKFLOW.md --repo octo-org/octo-repo --project octo-org/7 --states queued,agent-running,human-review,done,failed,archived --json --yes
224
+ ```
225
+
226
+ `<list>` and `--project` both use `<owner>/<number>` project syntax. `--workflow` defaults to `./WORKFLOW.md`. `--repo` overrides the task repository from workflow frontmatter. `--states` overrides the required state list from workflow frontmatter. `--json` prints the report object as JSON. `--yes` confirms non-interactive sync; it is only used by `poe-code tasks sync`.
227
+
228
+ | Option | Commands | Behavior |
229
+ | --- | --- | --- |
230
+ | `--workflow <path>` | `verify`, `sync` | Workflow file path. Defaults to `./WORKFLOW.md`. |
231
+ | `--repo <owner/name>` | `verify`, `sync` | GitHub repository owner/name. |
232
+ | `--project <owner/number>` | `verify`, `sync` | GitHub Project v2 owner/number. Overrides `<list>`. |
233
+ | `--states <csv>` | `verify`, `sync` | Required task state names. |
234
+ | `--json` | `verify`, `sync` | Prints the report as JSON. |
235
+ | `--yes` | `sync` | Confirms non-interactive provisioning. |
236
+
237
+ The `Status` field name and option names are matched case-sensitively. The field must be named `Status`, and required option names must match exactly. For example, `status` is treated as a missing field, and `Done` is treated as missing when the required state is `done`.
238
+
239
+ In v1, if sync creates a new GitHub Project v2 board, the CLI prints the new project number and does not rewrite `WORKFLOW.md`. The operator must update `WORKFLOW.md` by hand with the printed `<owner>/<number>`.
240
+
143
241
  ## Notes
144
242
 
145
243
  The package never overwrites existing task files or store files. `defaults.metadata` is applied only when creating new tasks and does not retroactively update existing tasks.
@@ -0,0 +1,46 @@
1
+ import { type GhClient } from "./gh-issues-client.js";
2
+ export interface VerifyGhProjectOptions {
3
+ owner: string;
4
+ number: number;
5
+ requiredStates: readonly string[];
6
+ client?: GhClient;
7
+ fetch?: typeof fetch;
8
+ auth?: {
9
+ token: string;
10
+ };
11
+ }
12
+ export interface VerifyGhProjectReport {
13
+ ok: boolean;
14
+ project: {
15
+ id: string;
16
+ number: number;
17
+ owner: string;
18
+ } | null;
19
+ statusField: {
20
+ id: string;
21
+ options: readonly string[];
22
+ } | null;
23
+ missingProject: boolean;
24
+ missingStatusField: boolean;
25
+ missingOptions: readonly string[];
26
+ }
27
+ export interface SyncGhProjectOptions extends VerifyGhProjectOptions {
28
+ title?: string;
29
+ yes?: boolean;
30
+ }
31
+ export interface SyncGhProjectReport extends VerifyGhProjectReport {
32
+ created: readonly string[];
33
+ updated: readonly string[];
34
+ }
35
+ export declare class GhProjectSyncError extends Error {
36
+ readonly op: "lookup" | "createProject" | "createField" | "createOption";
37
+ readonly target: string;
38
+ constructor(options: {
39
+ op: "lookup" | "createProject" | "createField" | "createOption";
40
+ target: string;
41
+ cause?: unknown;
42
+ message: string;
43
+ });
44
+ }
45
+ export declare function verifyGhProject(opts: VerifyGhProjectOptions): Promise<VerifyGhProjectReport>;
46
+ export declare function syncGhProject(opts: SyncGhProjectOptions): Promise<SyncGhProjectReport>;
@@ -0,0 +1,309 @@
1
+ import { PROJECT_ORGANIZATION_QUERY, PROJECT_USER_QUERY } from "./gh-issues.js";
2
+ import { createGhClient } from "./gh-issues-client.js";
3
+ const OWNER_ORGANIZATION_QUERY = `query ProjectOwner($owner: String!) {
4
+ organization(login: $owner) {
5
+ id
6
+ }
7
+ }`;
8
+ const OWNER_USER_QUERY = `query ProjectOwner($owner: String!) {
9
+ user(login: $owner) {
10
+ id
11
+ }
12
+ }`;
13
+ const CREATE_PROJECT_MUTATION = `mutation CreateProject($input: CreateProjectV2Input!) {
14
+ createProjectV2(input: $input) {
15
+ projectV2 {
16
+ id
17
+ number
18
+ }
19
+ }
20
+ }`;
21
+ const CREATE_STATUS_FIELD_MUTATION = `mutation CreateStatusField($input: CreateProjectV2FieldInput!) {
22
+ createProjectV2Field(input: $input) {
23
+ projectV2Field {
24
+ ... on ProjectV2SingleSelectField {
25
+ id
26
+ name
27
+ options { id name }
28
+ }
29
+ }
30
+ }
31
+ }`;
32
+ const CREATE_STATUS_OPTION_MUTATION = `mutation CreateStatusOption($input: CreateProjectV2SingleSelectFieldOptionInput!) {
33
+ createProjectV2SingleSelectFieldOption(input: $input) {
34
+ singleSelectFieldOption {
35
+ id
36
+ name
37
+ }
38
+ }
39
+ }`;
40
+ export class GhProjectSyncError extends Error {
41
+ op;
42
+ target;
43
+ constructor(options) {
44
+ super(options.message, { cause: options.cause });
45
+ this.name = "GhProjectSyncError";
46
+ this.op = options.op;
47
+ this.target = options.target;
48
+ }
49
+ }
50
+ export async function verifyGhProject(opts) {
51
+ const client = resolveGhClient(opts);
52
+ const target = `project:${opts.owner}/${opts.number}`;
53
+ const variables = {
54
+ owner: opts.owner,
55
+ number: opts.number
56
+ };
57
+ let project;
58
+ try {
59
+ const organizationResult = await client.graphql(PROJECT_ORGANIZATION_QUERY, variables);
60
+ project = organizationResult.organization?.projectV2 ?? null;
61
+ if (project === null) {
62
+ const userResult = await client.graphql(PROJECT_USER_QUERY, variables);
63
+ project = userResult.user?.projectV2 ?? null;
64
+ }
65
+ }
66
+ catch (error) {
67
+ throw new GhProjectSyncError({
68
+ op: "lookup",
69
+ target,
70
+ cause: error,
71
+ message: "lookup_failed"
72
+ });
73
+ }
74
+ if (project === null) {
75
+ return {
76
+ ok: false,
77
+ project: null,
78
+ statusField: null,
79
+ missingProject: true,
80
+ missingStatusField: true,
81
+ missingOptions: opts.requiredStates
82
+ };
83
+ }
84
+ const statusField = selectStatusField(project);
85
+ if (statusField === null) {
86
+ return {
87
+ ok: false,
88
+ project: {
89
+ id: project.id,
90
+ number: opts.number,
91
+ owner: opts.owner
92
+ },
93
+ statusField: null,
94
+ missingProject: false,
95
+ missingStatusField: true,
96
+ missingOptions: opts.requiredStates
97
+ };
98
+ }
99
+ const options = statusField.options.map((option) => option.name);
100
+ const missingOptions = opts.requiredStates.filter((state) => !options.includes(state));
101
+ return {
102
+ ok: missingOptions.length === 0,
103
+ project: {
104
+ id: project.id,
105
+ number: opts.number,
106
+ owner: opts.owner
107
+ },
108
+ statusField: {
109
+ id: statusField.id,
110
+ options
111
+ },
112
+ missingProject: false,
113
+ missingStatusField: false,
114
+ missingOptions
115
+ };
116
+ }
117
+ export async function syncGhProject(opts) {
118
+ const client = resolveGhClient(opts);
119
+ const verified = await verifyGhProject({ ...opts, client });
120
+ const created = [];
121
+ if (verified.ok) {
122
+ return {
123
+ ...verified,
124
+ created,
125
+ updated: []
126
+ };
127
+ }
128
+ let project = verified.project;
129
+ let statusField = verified.statusField;
130
+ let missingOptions = [...verified.missingOptions];
131
+ if (project === null) {
132
+ project = await createProject(client, opts);
133
+ created.push("project");
134
+ statusField = null;
135
+ missingOptions = [...opts.requiredStates];
136
+ }
137
+ if (statusField === null) {
138
+ const createdStatusField = await createStatusField(client, project.id);
139
+ statusField = createdStatusField;
140
+ created.push("field");
141
+ missingOptions = opts.requiredStates.filter((state) => !createdStatusField.options.includes(state));
142
+ }
143
+ if (missingOptions.length > 0) {
144
+ for (const optionName of missingOptions) {
145
+ await createStatusOption(client, statusField.id, optionName);
146
+ created.push(`option:${optionName}`);
147
+ }
148
+ statusField = {
149
+ id: statusField.id,
150
+ options: [...statusField.options, ...missingOptions]
151
+ };
152
+ missingOptions = [];
153
+ }
154
+ return {
155
+ ok: statusField !== null && missingOptions.length === 0,
156
+ project,
157
+ statusField,
158
+ missingProject: false,
159
+ missingStatusField: statusField === null,
160
+ missingOptions,
161
+ created,
162
+ updated: []
163
+ };
164
+ }
165
+ async function createProject(client, opts) {
166
+ const target = `${opts.owner}/${opts.number}`;
167
+ try {
168
+ const ownerId = await lookupOwnerId(client, opts.owner);
169
+ const result = await client.graphql(CREATE_PROJECT_MUTATION, {
170
+ input: {
171
+ ownerId,
172
+ title: opts.title ?? `${opts.owner}/${opts.number}`
173
+ }
174
+ });
175
+ const project = result.createProjectV2?.projectV2;
176
+ if (project === undefined || project === null) {
177
+ throw new Error("createProjectV2 returned no project");
178
+ }
179
+ return {
180
+ id: project.id,
181
+ number: project.number,
182
+ owner: opts.owner
183
+ };
184
+ }
185
+ catch (error) {
186
+ throw new GhProjectSyncError({
187
+ op: "createProject",
188
+ target,
189
+ cause: error,
190
+ message: errorMessage(error)
191
+ });
192
+ }
193
+ }
194
+ async function lookupOwnerId(client, owner) {
195
+ const organizationResult = await client.graphql(OWNER_ORGANIZATION_QUERY, {
196
+ owner
197
+ });
198
+ const organizationId = organizationResult.organization?.id;
199
+ if (organizationId !== undefined) {
200
+ return organizationId;
201
+ }
202
+ const userResult = await client.graphql(OWNER_USER_QUERY, {
203
+ owner
204
+ });
205
+ const userId = userResult.user?.id;
206
+ if (userId !== undefined) {
207
+ return userId;
208
+ }
209
+ throw new Error(`GitHub owner not found: ${owner}`);
210
+ }
211
+ async function createStatusField(client, projectId) {
212
+ try {
213
+ const result = await client.graphql(CREATE_STATUS_FIELD_MUTATION, {
214
+ input: {
215
+ projectId,
216
+ dataType: "SINGLE_SELECT",
217
+ name: "Status",
218
+ singleSelectOptions: []
219
+ }
220
+ });
221
+ const field = result.createProjectV2Field?.projectV2Field;
222
+ if (!isStatusField(field)) {
223
+ throw new Error("createProjectV2Field returned no Status field");
224
+ }
225
+ return {
226
+ id: field.id,
227
+ options: field.options.map((option) => option.name)
228
+ };
229
+ }
230
+ catch (error) {
231
+ throw new GhProjectSyncError({
232
+ op: "createField",
233
+ target: "Status",
234
+ cause: error,
235
+ message: errorMessage(error)
236
+ });
237
+ }
238
+ }
239
+ async function createStatusOption(client, fieldId, name) {
240
+ try {
241
+ await client.graphql(CREATE_STATUS_OPTION_MUTATION, {
242
+ input: {
243
+ fieldId,
244
+ name,
245
+ color: "GRAY"
246
+ }
247
+ });
248
+ }
249
+ catch (error) {
250
+ throw new GhProjectSyncError({
251
+ op: "createOption",
252
+ target: name,
253
+ cause: error,
254
+ message: errorMessage(error)
255
+ });
256
+ }
257
+ }
258
+ function errorMessage(error) {
259
+ return error instanceof Error ? error.message : String(error);
260
+ }
261
+ function resolveGhClient(opts) {
262
+ if (opts.client !== undefined) {
263
+ return opts.client;
264
+ }
265
+ const token = opts.auth?.token;
266
+ if (token === undefined || token.length === 0) {
267
+ throw new GhProjectSyncError({
268
+ op: "lookup",
269
+ target: "auth",
270
+ message: "missing_auth"
271
+ });
272
+ }
273
+ return createGhClient({
274
+ token,
275
+ fetch: opts.fetch
276
+ });
277
+ }
278
+ function selectStatusField(project) {
279
+ const fields = project.fields?.nodes?.filter(isStatusField) ?? [];
280
+ const exactStatusField = fields.find((field) => field.name === "Status");
281
+ if (exactStatusField !== undefined) {
282
+ return exactStatusField;
283
+ }
284
+ if (isStatusField(project.field) && isExactStatusField(project.field)) {
285
+ return project.field;
286
+ }
287
+ return null;
288
+ }
289
+ function isExactStatusField(field) {
290
+ return field.name === undefined || field.name === "Status";
291
+ }
292
+ function isStatusOption(value) {
293
+ return (typeof value === "object" &&
294
+ value !== null &&
295
+ "id" in value &&
296
+ typeof value.id === "string" &&
297
+ "name" in value &&
298
+ typeof value.name === "string");
299
+ }
300
+ function isStatusField(value) {
301
+ return (typeof value === "object" &&
302
+ value !== null &&
303
+ "id" in value &&
304
+ typeof value.id === "string" &&
305
+ (!("name" in value) || typeof value.name === "string") &&
306
+ "options" in value &&
307
+ Array.isArray(value.options) &&
308
+ value.options.every(isStatusOption));
309
+ }
@@ -1,4 +1,6 @@
1
1
  import type { TaskDefaults, TaskList } from "../types.js";
2
+ export declare const PROJECT_ORGANIZATION_QUERY = "query Project($owner: String!, $number: Int!) {\n organization(login: $owner) {\n projectV2(number: $number) {\n id\n title\n field(name: \"Status\") {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n fields(first: 100) {\n nodes {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }\n }\n}";
3
+ export declare const PROJECT_USER_QUERY = "query Project($owner: String!, $number: Int!) {\n user(login: $owner) {\n projectV2(number: $number) {\n id\n title\n field(name: \"Status\") {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n fields(first: 100) {\n nodes {\n ... on ProjectV2SingleSelectField {\n id\n name\n options { id name }\n }\n }\n }\n }\n }\n}";
2
4
  export interface GhIssuesBackendDeps {
3
5
  repo: string;
4
6
  project: {
@@ -2,7 +2,7 @@ import { eventsFromState, findEvent } from "../state-machine.js";
2
2
  import { AnchorNotFoundError, InvalidTransitionError, OrderMismatchError, TaskNotFoundError } from "../types.js";
3
3
  import { createGhClient } from "./gh-issues-client.js";
4
4
  import { applyOrder, sortTasks } from "./utils.js";
5
- const PROJECT_ORGANIZATION_QUERY = `query Project($owner: String!, $number: Int!) {
5
+ export const PROJECT_ORGANIZATION_QUERY = `query Project($owner: String!, $number: Int!) {
6
6
  organization(login: $owner) {
7
7
  projectV2(number: $number) {
8
8
  id
@@ -10,13 +10,23 @@ const PROJECT_ORGANIZATION_QUERY = `query Project($owner: String!, $number: Int!
10
10
  field(name: "Status") {
11
11
  ... on ProjectV2SingleSelectField {
12
12
  id
13
+ name
13
14
  options { id name }
14
15
  }
15
16
  }
17
+ fields(first: 100) {
18
+ nodes {
19
+ ... on ProjectV2SingleSelectField {
20
+ id
21
+ name
22
+ options { id name }
23
+ }
24
+ }
25
+ }
16
26
  }
17
27
  }
18
28
  }`;
19
- const PROJECT_USER_QUERY = `query Project($owner: String!, $number: Int!) {
29
+ export const PROJECT_USER_QUERY = `query Project($owner: String!, $number: Int!) {
20
30
  user(login: $owner) {
21
31
  projectV2(number: $number) {
22
32
  id
@@ -24,9 +34,19 @@ const PROJECT_USER_QUERY = `query Project($owner: String!, $number: Int!) {
24
34
  field(name: "Status") {
25
35
  ... on ProjectV2SingleSelectField {
26
36
  id
37
+ name
27
38
  options { id name }
28
39
  }
29
40
  }
41
+ fields(first: 100) {
42
+ nodes {
43
+ ... on ProjectV2SingleSelectField {
44
+ id
45
+ name
46
+ options { id name }
47
+ }
48
+ }
49
+ }
30
50
  }
31
51
  }
32
52
  }`;
@@ -22,6 +22,10 @@ const RESERVED_FRONTMATTER_KEYS = new Set([
22
22
  "state",
23
23
  "version"
24
24
  ]);
25
+ const PASSTHROUGH_RESERVED_FRONTMATTER_KEYS = new Set(["description", "name", "state"]);
26
+ function resolveListLayout(deps) {
27
+ return deps.singleList ? { kind: "single", name: deps.singleList } : { kind: "multi" };
28
+ }
25
29
  function validateListName(name) {
26
30
  if (name.length === 0 ||
27
31
  name === ARCHIVE_DIRECTORY_NAME ||
@@ -45,17 +49,19 @@ function parseQualifiedId(qualifiedId) {
45
49
  id: validateTaskId(qualifiedId.slice(separatorIndex + 1))
46
50
  };
47
51
  }
48
- function listPath(rootPath, list) {
49
- return path.join(rootPath, list);
52
+ function listPath(rootPath, layout, list) {
53
+ return layout.kind === "single" ? rootPath : path.join(rootPath, list);
50
54
  }
51
- function archiveDirectoryPath(rootPath, list) {
52
- return path.join(listPath(rootPath, list), ARCHIVE_DIRECTORY_NAME);
55
+ function archiveDirectoryPath(rootPath, layout, list) {
56
+ return layout.kind === "single"
57
+ ? path.join(rootPath, ARCHIVE_DIRECTORY_NAME)
58
+ : path.join(rootPath, list, ARCHIVE_DIRECTORY_NAME);
53
59
  }
54
60
  function activeTaskFilename(id, order, width) {
55
61
  return `${String(order).padStart(width, "0")}-${id}${MARKDOWN_EXTENSION}`;
56
62
  }
57
- function archivedTaskPath(rootPath, list, id) {
58
- return path.join(archiveDirectoryPath(rootPath, list), `${id}${MARKDOWN_EXTENSION}`);
63
+ function archivedTaskPath(rootPath, layout, list, id) {
64
+ return path.join(archiveDirectoryPath(rootPath, layout, list), `${id}${MARKDOWN_EXTENSION}`);
59
65
  }
60
66
  function isMarkdownFile(entryName) {
61
67
  return entryName.endsWith(MARKDOWN_EXTENSION);
@@ -67,7 +73,11 @@ function isLockFile(entryName) {
67
73
  return entryName.endsWith(".lock");
68
74
  }
69
75
  function isValidTaskIdShape(id) {
70
- return id.length > 0 && !id.startsWith(".") && !id.includes("/") && !id.includes("\\") && !id.includes("..");
76
+ return (id.length > 0 &&
77
+ !id.startsWith(".") &&
78
+ !id.includes("/") &&
79
+ !id.includes("\\") &&
80
+ !id.includes(".."));
71
81
  }
72
82
  function parseActiveFilename(entryName) {
73
83
  if (!isMarkdownFile(entryName))
@@ -94,9 +104,13 @@ function malformedTask(filePath, field) {
94
104
  function stripTrailingCarriageReturn(line) {
95
105
  return line.endsWith("\r") ? line.slice(0, -1) : line;
96
106
  }
97
- function splitTaskDocument(content, filePath) {
107
+ function splitTaskDocument(content, filePath, mode) {
98
108
  const lines = content.split("\n");
99
- if (lines.length === 0 || stripTrailingCarriageReturn(lines[0]) !== "---") {
109
+ const hasFrontmatterBlock = lines.length > 0 && stripTrailingCarriageReturn(lines[0]) === "---";
110
+ if (!hasFrontmatterBlock) {
111
+ if (mode === "passthrough") {
112
+ return { frontmatter: "", body: content };
113
+ }
100
114
  throw malformedTask(filePath, "frontmatter");
101
115
  }
102
116
  let closingIndex = -1;
@@ -153,16 +167,20 @@ function assertValidTaskRecord(frontmatter, filePath, validStates) {
153
167
  throw malformedTask(filePath, "description");
154
168
  }
155
169
  }
156
- function metadataFromFrontmatter(frontmatter) {
170
+ function reservedFrontmatterKeys(mode) {
171
+ return mode === "passthrough" ? PASSTHROUGH_RESERVED_FRONTMATTER_KEYS : RESERVED_FRONTMATTER_KEYS;
172
+ }
173
+ function metadataFromFrontmatter(frontmatter, mode) {
157
174
  const metadata = {};
175
+ const reservedKeys = reservedFrontmatterKeys(mode);
158
176
  for (const [key, value] of Object.entries(frontmatter)) {
159
- if (!RESERVED_FRONTMATTER_KEYS.has(key)) {
177
+ if (!reservedKeys.has(key)) {
160
178
  metadata[key] = value;
161
179
  }
162
180
  }
163
181
  return metadata;
164
182
  }
165
- function createTask(list, id, frontmatter, body) {
183
+ function createTask(list, id, frontmatter, body, mode) {
166
184
  return {
167
185
  list,
168
186
  id,
@@ -170,7 +188,7 @@ function createTask(list, id, frontmatter, body) {
170
188
  name: frontmatter.name,
171
189
  state: frontmatter.state,
172
190
  description: body,
173
- metadata: metadataFromFrontmatter(frontmatter)
191
+ metadata: metadataFromFrontmatter(frontmatter, mode)
174
192
  };
175
193
  }
176
194
  function serializeTaskDocument(frontmatter, description) {
@@ -194,15 +212,33 @@ async function ensureRootPath(deps) {
194
212
  }
195
213
  await deps.fs.stat(deps.path);
196
214
  }
197
- async function readTaskFile(fs, list, id, filePath, validStates) {
215
+ async function readTaskFile(fs, list, id, filePath, validStates, initialState, mode) {
198
216
  const content = await fs.readFile(filePath, "utf8");
199
- const document = splitTaskDocument(content, filePath);
200
- const frontmatter = readFrontmatter(document.frontmatter, filePath);
201
- assertValidTaskRecord(frontmatter, filePath, validStates);
217
+ const document = splitTaskDocument(content, filePath, mode);
218
+ const frontmatter = mode === "passthrough" && document.frontmatter.trim().length === 0
219
+ ? {}
220
+ : readFrontmatter(document.frontmatter, filePath);
221
+ if (mode !== "passthrough") {
222
+ assertValidTaskRecord(frontmatter, filePath, validStates);
223
+ return {
224
+ path: filePath,
225
+ frontmatter,
226
+ task: createTask(list, id, frontmatter, document.body, mode)
227
+ };
228
+ }
229
+ const parsedFilename = parseActiveFilename(path.basename(filePath));
230
+ const defaultName = parsedFilename?.id ?? id;
231
+ const effectiveFrontmatter = {
232
+ ...frontmatter,
233
+ name: typeof frontmatter.name === "string" ? frontmatter.name : defaultName,
234
+ state: typeof frontmatter.state === "string" && validStates.has(frontmatter.state)
235
+ ? frontmatter.state
236
+ : initialState
237
+ };
202
238
  return {
203
239
  path: filePath,
204
240
  frontmatter,
205
- task: createTask(list, id, frontmatter, document.body)
241
+ task: createTask(list, id, effectiveFrontmatter, document.body, mode)
206
242
  };
207
243
  }
208
244
  async function findActiveTaskFilename(fs, listDirectoryPath, id) {
@@ -217,8 +253,8 @@ async function findActiveTaskFilename(fs, listDirectoryPath, id) {
217
253
  }
218
254
  return undefined;
219
255
  }
220
- async function findTaskLocation(fs, rootPath, list, id) {
221
- const listDirectoryPath = listPath(rootPath, list);
256
+ async function findTaskLocation(fs, rootPath, layout, list, id) {
257
+ const listDirectoryPath = listPath(rootPath, layout, list);
222
258
  const activeName = await findActiveTaskFilename(fs, listDirectoryPath, id);
223
259
  if (activeName) {
224
260
  const activePath = path.join(listDirectoryPath, activeName);
@@ -227,71 +263,91 @@ async function findTaskLocation(fs, rootPath, list, id) {
227
263
  return { archived: false, path: activePath };
228
264
  }
229
265
  }
230
- const archivedPath = archivedTaskPath(rootPath, list, id);
266
+ const archivedPath = archivedTaskPath(rootPath, layout, list, id);
231
267
  const archivedStat = await statIfExists(fs, archivedPath);
232
268
  if (archivedStat?.isFile()) {
233
269
  return { archived: true, path: archivedPath };
234
270
  }
235
271
  return undefined;
236
272
  }
237
- async function readTaskAtLocation(fs, rootPath, list, id, validStates) {
238
- const location = await findTaskLocation(fs, rootPath, list, id);
273
+ async function readTaskAtLocation(fs, rootPath, layout, list, id, validStates, initialState, mode) {
274
+ const location = await findTaskLocation(fs, rootPath, layout, list, id);
239
275
  if (!location) {
240
276
  throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
241
277
  }
242
- return readTaskFile(fs, list, id, location.path, validStates);
278
+ return readTaskFile(fs, list, id, location.path, validStates, initialState, mode);
243
279
  }
244
- function createdFrontmatter(defaults, input, initialState) {
245
- const frontmatter = {
246
- $schema: TASK_SCHEMA_ID,
247
- kind: TASK_KIND,
248
- version: TASK_VERSION,
249
- name: input.name,
250
- state: initialState
251
- };
280
+ function createdFrontmatter(defaults, input, initialState, mode) {
281
+ const frontmatter = mode !== "passthrough"
282
+ ? {
283
+ $schema: TASK_SCHEMA_ID,
284
+ kind: TASK_KIND,
285
+ version: TASK_VERSION,
286
+ name: input.name,
287
+ state: initialState
288
+ }
289
+ : {
290
+ name: input.name,
291
+ state: initialState
292
+ };
293
+ const reservedKeys = reservedFrontmatterKeys(mode);
252
294
  for (const [key, value] of Object.entries(defaults.metadata)) {
253
- if (!RESERVED_FRONTMATTER_KEYS.has(key)) {
295
+ if (!reservedKeys.has(key)) {
254
296
  frontmatter[key] = value;
255
297
  }
256
298
  }
257
299
  for (const [key, value] of Object.entries(input.metadata ?? {})) {
258
- if (!RESERVED_FRONTMATTER_KEYS.has(key)) {
300
+ if (!reservedKeys.has(key)) {
259
301
  frontmatter[key] = value;
260
302
  }
261
303
  }
262
304
  frontmatter.created = new Date().toISOString();
263
305
  return frontmatter;
264
306
  }
265
- function updatedFrontmatter(existingFrontmatter, task, patch) {
266
- const nextFrontmatter = {
267
- ...existingFrontmatter,
268
- $schema: existingFrontmatter.$schema ?? TASK_SCHEMA_ID,
269
- kind: existingFrontmatter.kind ?? TASK_KIND,
270
- version: existingFrontmatter.version ?? TASK_VERSION,
271
- name: patch.name ?? task.name,
272
- state: task.state
273
- };
307
+ function updatedFrontmatter(existingFrontmatter, task, patch, mode) {
308
+ const nextFrontmatter = mode !== "passthrough"
309
+ ? {
310
+ ...existingFrontmatter,
311
+ $schema: existingFrontmatter.$schema ?? TASK_SCHEMA_ID,
312
+ kind: existingFrontmatter.kind ?? TASK_KIND,
313
+ version: existingFrontmatter.version ?? TASK_VERSION,
314
+ name: patch.name ?? task.name,
315
+ state: task.state
316
+ }
317
+ : {
318
+ ...existingFrontmatter,
319
+ name: patch.name ?? task.name,
320
+ state: task.state
321
+ };
322
+ const reservedKeys = reservedFrontmatterKeys(mode);
274
323
  for (const [key, value] of Object.entries(patch.metadata ?? {})) {
275
- if (!RESERVED_FRONTMATTER_KEYS.has(key)) {
324
+ if (!reservedKeys.has(key)) {
276
325
  nextFrontmatter[key] = value;
277
326
  }
278
327
  }
279
328
  return nextFrontmatter;
280
329
  }
281
- function transitionedFrontmatter(existingFrontmatter, task, to) {
282
- return {
283
- ...existingFrontmatter,
284
- $schema: existingFrontmatter.$schema ?? TASK_SCHEMA_ID,
285
- kind: existingFrontmatter.kind ?? TASK_KIND,
286
- version: existingFrontmatter.version ?? TASK_VERSION,
287
- name: task.name,
288
- state: to
289
- };
290
- }
291
- function firedFrontmatter(existingFrontmatter, task, to, metadataPatch) {
292
- const nextFrontmatter = transitionedFrontmatter(existingFrontmatter, task, to);
330
+ function transitionedFrontmatter(existingFrontmatter, task, to, mode) {
331
+ return mode !== "passthrough"
332
+ ? {
333
+ ...existingFrontmatter,
334
+ $schema: existingFrontmatter.$schema ?? TASK_SCHEMA_ID,
335
+ kind: existingFrontmatter.kind ?? TASK_KIND,
336
+ version: existingFrontmatter.version ?? TASK_VERSION,
337
+ name: task.name,
338
+ state: to
339
+ }
340
+ : {
341
+ ...existingFrontmatter,
342
+ name: task.name,
343
+ state: to
344
+ };
345
+ }
346
+ function firedFrontmatter(existingFrontmatter, task, to, mode, metadataPatch) {
347
+ const nextFrontmatter = transitionedFrontmatter(existingFrontmatter, task, to, mode);
348
+ const reservedKeys = reservedFrontmatterKeys(mode);
293
349
  for (const [key, value] of Object.entries(metadataPatch ?? {})) {
294
- if (!RESERVED_FRONTMATTER_KEYS.has(key)) {
350
+ if (!reservedKeys.has(key)) {
295
351
  nextFrontmatter[key] = value;
296
352
  }
297
353
  }
@@ -312,8 +368,8 @@ function assertUpdateDoesNotSetState(patch) {
312
368
  throw new Error('Tasks.update() does not accept "state"; use fire() to change task state.');
313
369
  }
314
370
  }
315
- function createTasksView(deps, list) {
316
- const listDirectoryPath = listPath(deps.path, list);
371
+ function createTasksView(deps, layout, list) {
372
+ const listDirectoryPath = listPath(deps.path, layout, list);
317
373
  const stateMachine = resolveStateMachine(deps.stateMachine);
318
374
  const validStates = new Set(stateMachine.states);
319
375
  async function readActiveEntries() {
@@ -345,13 +401,13 @@ function createTasksView(deps, list) {
345
401
  const tasks = new Map();
346
402
  for (const entry of entries) {
347
403
  const filePath = path.join(listDirectoryPath, entry.filename);
348
- const file = await readTaskFile(deps.fs, list, entry.id, filePath, validStates);
404
+ const file = await readTaskFile(deps.fs, list, entry.id, filePath, validStates, stateMachine.initial, deps.frontmatterMode);
349
405
  tasks.set(entry.id, { task: file.task, raw: file.frontmatter });
350
406
  }
351
407
  return { entries, tasks };
352
408
  }
353
409
  async function readArchivedTasks() {
354
- const archivePath = archiveDirectoryPath(deps.path, list);
410
+ const archivePath = archiveDirectoryPath(deps.path, layout, list);
355
411
  const entries = await readDirectoryNames(deps.fs, archivePath);
356
412
  const result = [];
357
413
  for (const entryName of entries) {
@@ -362,35 +418,109 @@ function createTasksView(deps, list) {
362
418
  if (!entryStat?.isFile())
363
419
  continue;
364
420
  const id = entryName.slice(0, -MARKDOWN_EXTENSION.length);
365
- const file = await readTaskFile(deps.fs, list, id, entryPath, validStates);
421
+ const file = await readTaskFile(deps.fs, list, id, entryPath, validStates, stateMachine.initial, deps.frontmatterMode);
366
422
  result.push({ task: file.task, raw: file.frontmatter });
367
423
  }
368
424
  return result.sort((left, right) => left.task.qualifiedId.localeCompare(right.task.qualifiedId));
369
425
  }
426
+ async function renameActiveEntries(entries, desiredOrdersById) {
427
+ const staged = [];
428
+ const maxOrder = Math.max(...desiredOrdersById.values(), entries.length);
429
+ const width = padWidthForCount(maxOrder);
430
+ for (let index = 0; index < entries.length; index += 1) {
431
+ const entry = entries[index];
432
+ const desiredOrder = desiredOrdersById.get(entry.id);
433
+ if (desiredOrder === undefined)
434
+ continue;
435
+ const desiredFilename = activeTaskFilename(entry.id, desiredOrder, width);
436
+ if (entry.filename !== desiredFilename) {
437
+ const fromPath = path.join(listDirectoryPath, entry.filename);
438
+ const stagingPath = path.join(listDirectoryPath, `${desiredFilename}.staging-${process.pid}-${index}`);
439
+ const targetPath = path.join(listDirectoryPath, desiredFilename);
440
+ await deps.fs.rename(fromPath, stagingPath);
441
+ staged.push({ from: stagingPath, to: targetPath });
442
+ }
443
+ }
444
+ for (const entry of staged) {
445
+ await deps.fs.rename(entry.from, entry.to);
446
+ }
447
+ }
370
448
  async function rewriteListPrefixes(orderedIds) {
371
449
  const entries = await readActiveEntries();
372
450
  const byId = new Map(entries.map((entry) => [entry.id, entry]));
373
- const width = padWidthForCount(orderedIds.length);
451
+ const desiredOrdersById = new Map();
374
452
  for (let index = 0; index < orderedIds.length; index += 1) {
375
453
  const id = orderedIds[index];
376
454
  const entry = byId.get(id);
377
455
  if (!entry)
378
456
  continue;
379
- const desiredFilename = activeTaskFilename(id, index + 1, width);
380
- if (entry.filename !== desiredFilename) {
381
- const fromPath = path.join(listDirectoryPath, entry.filename);
382
- const stagingPath = path.join(listDirectoryPath, `${desiredFilename}.staging-${process.pid}-${index}`);
383
- await deps.fs.rename(fromPath, stagingPath);
384
- entry.filename = path.basename(stagingPath);
457
+ desiredOrdersById.set(id, index + 1);
458
+ }
459
+ await renameActiveEntries(entries, desiredOrdersById);
460
+ }
461
+ function entryOrder(entry, index) {
462
+ return entry.order ?? index + 1;
463
+ }
464
+ async function rewriteMovedPrefix(movedId, orderedIds) {
465
+ const entries = await readActiveEntries();
466
+ const byId = new Map(entries.map((entry, index) => [entry.id, { entry, index }]));
467
+ const movedIndex = orderedIds.indexOf(movedId);
468
+ const moved = byId.get(movedId);
469
+ if (movedIndex < 0 || moved === undefined)
470
+ return;
471
+ const desiredOrdersById = new Map();
472
+ const previousId = movedIndex > 0 ? orderedIds[movedIndex - 1] : undefined;
473
+ const nextId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : undefined;
474
+ const previous = previousId === undefined ? undefined : byId.get(previousId);
475
+ const next = nextId === undefined ? undefined : byId.get(nextId);
476
+ if (previous !== undefined && next === undefined) {
477
+ desiredOrdersById.set(movedId, entryOrder(previous.entry, previous.index) + 1);
478
+ await renameActiveEntries(entries, desiredOrdersById);
479
+ return;
480
+ }
481
+ if (previous === undefined && next !== undefined) {
482
+ const nextOrder = entryOrder(next.entry, next.index);
483
+ if (nextOrder > 1) {
484
+ desiredOrdersById.set(movedId, nextOrder - 1);
485
+ await renameActiveEntries(entries, desiredOrdersById);
486
+ return;
487
+ }
488
+ desiredOrdersById.set(movedId, 1);
489
+ let lastOrder = 1;
490
+ for (let index = movedIndex + 1; index < orderedIds.length; index += 1) {
491
+ const candidate = byId.get(orderedIds[index]);
492
+ if (candidate === undefined)
493
+ continue;
494
+ const currentOrder = entryOrder(candidate.entry, candidate.index);
495
+ if (currentOrder > lastOrder)
496
+ break;
497
+ lastOrder += 1;
498
+ desiredOrdersById.set(candidate.entry.id, lastOrder);
385
499
  }
500
+ await renameActiveEntries(entries, desiredOrdersById);
501
+ return;
386
502
  }
387
- const refreshed = await readDirectoryNames(deps.fs, listDirectoryPath);
388
- for (const entryName of refreshed) {
389
- const stagingMatch = /\.staging-\d+-\d+$/.exec(entryName);
390
- if (!stagingMatch)
391
- continue;
392
- const desiredName = entryName.slice(0, stagingMatch.index);
393
- await deps.fs.rename(path.join(listDirectoryPath, entryName), path.join(listDirectoryPath, desiredName));
503
+ if (previous !== undefined && next !== undefined) {
504
+ const previousOrder = entryOrder(previous.entry, previous.index);
505
+ const nextOrder = entryOrder(next.entry, next.index);
506
+ if (nextOrder - previousOrder > 1) {
507
+ desiredOrdersById.set(movedId, previousOrder + 1);
508
+ await renameActiveEntries(entries, desiredOrdersById);
509
+ return;
510
+ }
511
+ let lastOrder = previousOrder + 1;
512
+ desiredOrdersById.set(movedId, lastOrder);
513
+ for (let index = movedIndex + 1; index < orderedIds.length; index += 1) {
514
+ const candidate = byId.get(orderedIds[index]);
515
+ if (candidate === undefined)
516
+ continue;
517
+ const currentOrder = entryOrder(candidate.entry, candidate.index);
518
+ if (currentOrder > lastOrder)
519
+ break;
520
+ lastOrder += 1;
521
+ desiredOrdersById.set(candidate.entry.id, lastOrder);
522
+ }
523
+ await renameActiveEntries(entries, desiredOrdersById);
394
524
  }
395
525
  }
396
526
  async function withListLock(action) {
@@ -411,9 +541,22 @@ function createTasksView(deps, list) {
411
541
  validateTaskId(id);
412
542
  return withListLock(action);
413
543
  }
544
+ async function withLocatedTaskLock(location, action) {
545
+ const release = await acquireFileLock(location.path, {
546
+ fs: deps.fs,
547
+ staleMs: deps.lockStaleMs,
548
+ retries: deps.lockRetries
549
+ });
550
+ try {
551
+ return await action();
552
+ }
553
+ finally {
554
+ await release();
555
+ }
556
+ }
414
557
  async function getTaskFile(id) {
415
558
  validateTaskId(id);
416
- return readTaskAtLocation(deps.fs, deps.path, list, id, validStates);
559
+ return readTaskAtLocation(deps.fs, deps.path, layout, list, id, validStates, stateMachine.initial, deps.frontmatterMode);
417
560
  }
418
561
  function assertFireableTaskEvent(task, eventName) {
419
562
  const event = findEvent(stateMachine, task.state, eventName);
@@ -457,7 +600,7 @@ function createTasksView(deps, list) {
457
600
  validateTaskId(input.id);
458
601
  await deps.fs.mkdir(listDirectoryPath, { recursive: true });
459
602
  return withListLock(async () => {
460
- const existing = await findTaskLocation(deps.fs, deps.path, list, input.id);
603
+ const existing = await findTaskLocation(deps.fs, deps.path, layout, list, input.id);
461
604
  if (existing) {
462
605
  throw new TaskAlreadyExistsError(`Task "${list}/${input.id}" already exists.`);
463
606
  }
@@ -467,24 +610,24 @@ function createTasksView(deps, list) {
467
610
  const width = padWidthForCount(activeEntries.length + 1);
468
611
  const filename = activeTaskFilename(input.id, nextOrder, width);
469
612
  const targetPath = path.join(listDirectoryPath, filename);
470
- const frontmatter = createdFrontmatter(deps.defaults, input, stateMachine.initial);
613
+ const frontmatter = createdFrontmatter(deps.defaults, input, stateMachine.initial, deps.frontmatterMode);
471
614
  const description = input.description ?? "";
472
615
  await writeAtomically(deps.fs, targetPath, serializeTaskDocument(frontmatter, description));
473
- return createTask(list, input.id, frontmatter, description);
616
+ return createTask(list, input.id, frontmatter, description, deps.frontmatterMode);
474
617
  });
475
618
  },
476
619
  async update(id, patch) {
477
620
  assertUpdateDoesNotSetState(patch);
478
621
  return withTaskLock(id, async () => {
479
622
  const existing = await getTaskFile(id);
480
- const nextFrontmatter = updatedFrontmatter(existing.frontmatter, existing.task, patch);
623
+ const nextFrontmatter = updatedFrontmatter(existing.frontmatter, existing.task, patch, deps.frontmatterMode);
481
624
  const description = patch.description ?? existing.task.description;
482
625
  await writeAtomically(deps.fs, existing.path, serializeTaskDocument(nextFrontmatter, description));
483
- return createTask(list, id, nextFrontmatter, description);
626
+ return createTask(list, id, nextFrontmatter, description, deps.frontmatterMode);
484
627
  });
485
628
  },
486
629
  async fire(id, eventName, opts) {
487
- return withTaskLock(id, async () => {
630
+ const fireTask = async () => {
488
631
  const existing = await getTaskFile(id);
489
632
  const event = assertFireableTaskEvent(existing.task, eventName);
490
633
  const guardResult = event.guard?.(existing.task) ?? true;
@@ -497,17 +640,17 @@ function createTasksView(deps, list) {
497
640
  });
498
641
  }
499
642
  await event.onExit?.(existing.task);
500
- const nextFrontmatter = firedFrontmatter(existing.frontmatter, existing.task, event.to, opts?.metadataPatch);
501
- const nextTask = createTask(list, id, nextFrontmatter, existing.task.description);
643
+ const nextFrontmatter = firedFrontmatter(existing.frontmatter, existing.task, event.to, deps.frontmatterMode, opts?.metadataPatch);
644
+ const nextTask = createTask(list, id, nextFrontmatter, existing.task.description, deps.frontmatterMode);
502
645
  const serializedTask = serializeTaskDocument(nextFrontmatter, existing.task.description);
503
646
  if (event.to === "archived") {
504
- const targetPath = archivedTaskPath(deps.path, list, id);
647
+ const targetPath = archivedTaskPath(deps.path, layout, list, id);
505
648
  const archivedTargetExists = await statIfExists(deps.fs, targetPath);
506
649
  if (archivedTargetExists?.isFile()) {
507
650
  throw new TaskAlreadyExistsError(`Task "${list}/${id}" already exists in archive.`);
508
651
  }
509
652
  await writeAtomically(deps.fs, existing.path, serializedTask);
510
- await deps.fs.mkdir(archiveDirectoryPath(deps.path, list), { recursive: true });
653
+ await deps.fs.mkdir(archiveDirectoryPath(deps.path, layout, list), { recursive: true });
511
654
  await deps.fs.rename(existing.path, targetPath);
512
655
  await event.onEnter?.(nextTask);
513
656
  return nextTask;
@@ -515,7 +658,18 @@ function createTasksView(deps, list) {
515
658
  await writeAtomically(deps.fs, existing.path, serializedTask);
516
659
  await event.onEnter?.(nextTask);
517
660
  return nextTask;
518
- });
661
+ };
662
+ if (stateMachine.events[eventName]?.to === "archived") {
663
+ validateTaskId(id);
664
+ return withListLock(async () => {
665
+ const location = await findTaskLocation(deps.fs, deps.path, layout, list, id);
666
+ if (!location) {
667
+ throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
668
+ }
669
+ return withLocatedTaskLock(location, fireTask);
670
+ });
671
+ }
672
+ return withTaskLock(id, fireTask);
519
673
  },
520
674
  async canFire(id, eventName) {
521
675
  const task = (await getTaskFile(id)).task;
@@ -531,7 +685,7 @@ function createTasksView(deps, list) {
531
685
  },
532
686
  async delete(id) {
533
687
  await withTaskLock(id, async () => {
534
- const location = await findTaskLocation(deps.fs, deps.path, list, id);
688
+ const location = await findTaskLocation(deps.fs, deps.path, layout, list, id);
535
689
  if (!location) {
536
690
  throw new TaskNotFoundError(`Task "${list}/${id}" not found.`);
537
691
  }
@@ -561,7 +715,7 @@ function createTasksView(deps, list) {
561
715
  insertIndex = "before" in anchor ? anchorIndex : anchorIndex + 1;
562
716
  }
563
717
  ordered.splice(insertIndex, 0, id);
564
- await rewriteListPrefixes(ordered);
718
+ await rewriteMovedPrefix(id, ordered);
565
719
  return tasks.get(id).task;
566
720
  });
567
721
  },
@@ -587,13 +741,23 @@ function createTasksView(deps, list) {
587
741
  }
588
742
  export async function markdownDirBackend(deps) {
589
743
  await ensureRootPath(deps);
744
+ const layout = resolveListLayout(deps);
590
745
  const stateMachine = resolveStateMachine(deps.stateMachine);
591
746
  const validStates = new Set(stateMachine.states);
592
747
  const list = (name) => {
748
+ if (layout.kind === "single") {
749
+ if (name !== layout.name) {
750
+ throw new Error(`Task list "${name}" not found.`);
751
+ }
752
+ return createTasksView(deps, layout, name);
753
+ }
593
754
  const listName = validateListName(name);
594
- return createTasksView(deps, listName);
755
+ return createTasksView(deps, layout, listName);
595
756
  };
596
757
  const lists = async () => {
758
+ if (layout.kind === "single") {
759
+ return [layout.name];
760
+ }
597
761
  const entries = await readDirectoryNames(deps.fs, deps.path);
598
762
  const result = [];
599
763
  for (const entryName of entries) {
@@ -623,21 +787,24 @@ export async function markdownDirBackend(deps) {
623
787
  return list(listName).get(id);
624
788
  };
625
789
  const moveBetweenLists = async (qualifiedId, targetList) => {
790
+ if (layout.kind === "single") {
791
+ throw new Error("moveBetweenLists is unsupported in single-list mode.");
792
+ }
626
793
  const { list: sourceListName, id } = parseQualifiedId(qualifiedId);
627
794
  const targetListName = validateListName(targetList);
628
795
  if (sourceListName === targetListName) {
629
- const file = await readTaskAtLocation(deps.fs, deps.path, sourceListName, id, validStates);
796
+ const file = await readTaskAtLocation(deps.fs, deps.path, layout, sourceListName, id, validStates, stateMachine.initial, deps.frontmatterMode);
630
797
  return file.task;
631
798
  }
632
- const targetExisting = await findTaskLocation(deps.fs, deps.path, targetListName, id);
799
+ const targetExisting = await findTaskLocation(deps.fs, deps.path, layout, targetListName, id);
633
800
  if (targetExisting) {
634
801
  throw new TaskAlreadyExistsError(`Task "${targetListName}/${id}" already exists.`);
635
802
  }
636
- const sourceLocation = await findTaskLocation(deps.fs, deps.path, sourceListName, id);
803
+ const sourceLocation = await findTaskLocation(deps.fs, deps.path, layout, sourceListName, id);
637
804
  if (!sourceLocation) {
638
805
  throw new TaskNotFoundError(`Task "${sourceListName}/${id}" not found.`);
639
806
  }
640
- const targetListDir = listPath(deps.path, targetListName);
807
+ const targetListDir = listPath(deps.path, layout, targetListName);
641
808
  await deps.fs.mkdir(targetListDir, { recursive: true });
642
809
  const targetEntries = await (async () => {
643
810
  const out = [];
@@ -653,11 +820,11 @@ export async function markdownDirBackend(deps) {
653
820
  return out;
654
821
  })();
655
822
  if (sourceLocation.archived) {
656
- const archivedTargetDir = archiveDirectoryPath(deps.path, targetListName);
823
+ const archivedTargetDir = archiveDirectoryPath(deps.path, layout, targetListName);
657
824
  await deps.fs.mkdir(archivedTargetDir, { recursive: true });
658
- const archivedTargetPath = archivedTaskPath(deps.path, targetListName, id);
825
+ const archivedTargetPath = archivedTaskPath(deps.path, layout, targetListName, id);
659
826
  await deps.fs.rename(sourceLocation.path, archivedTargetPath);
660
- const file = await readTaskFile(deps.fs, targetListName, id, archivedTargetPath, validStates);
827
+ const file = await readTaskFile(deps.fs, targetListName, id, archivedTargetPath, validStates, stateMachine.initial, deps.frontmatterMode);
661
828
  return file.task;
662
829
  }
663
830
  const maxOrder = targetEntries.reduce((max, entry) => (entry.order !== null && entry.order > max ? entry.order : max), 0);
@@ -665,7 +832,7 @@ export async function markdownDirBackend(deps) {
665
832
  const targetFilename = activeTaskFilename(id, maxOrder + 1, width);
666
833
  const targetPath = path.join(targetListDir, targetFilename);
667
834
  await deps.fs.rename(sourceLocation.path, targetPath);
668
- const file = await readTaskFile(deps.fs, targetListName, id, targetPath, validStates);
835
+ const file = await readTaskFile(deps.fs, targetListName, id, targetPath, validStates, stateMachine.initial, deps.frontmatterMode);
669
836
  return file.task;
670
837
  };
671
838
  return {
@@ -4,3 +4,4 @@ export { eventsFromState, findEvent, validateMachine, type EventDef, type StateM
4
4
  export { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError, type ListFilter, type MoveAnchor, type OpenGhIssuesOptions, type OpenMarkdownDirOptions, type OpenTaskListOptions, type OpenYamlFileOptions, type Task, type TaskCreate, type TaskDefaults, type TaskFireOptions, type TaskList, type TaskListFs, type TaskOrder, type TaskState, type Tasks, type TaskUpdate } from "./types.js";
5
5
  export type { GhClient, GhClientOptions, ResolveAuthOptions, ResolveEndpointOptions } from "./backends/gh-issues-client.js";
6
6
  export type { GhIssuesBackendDeps } from "./backends/gh-issues.js";
7
+ export { GhProjectSyncError, syncGhProject, verifyGhProject, type SyncGhProjectOptions, type SyncGhProjectReport, type VerifyGhProjectOptions, type VerifyGhProjectReport } from "./backends/gh-issues-sync.js";
@@ -2,3 +2,4 @@ export { openTaskList } from "./open.js";
2
2
  export { assertEvent, assertTransition, defaultStateMachine } from "./state.js";
3
3
  export { eventsFromState, findEvent, validateMachine } from "./state-machine.js";
4
4
  export { AnchorNotFoundError, InvalidTransitionError, MalformedTaskError, OrderMismatchError, TaskAlreadyExistsError, TaskNotFoundError } from "./types.js";
5
+ export { GhProjectSyncError, syncGhProject, verifyGhProject } from "./backends/gh-issues-sync.js";
@@ -29,11 +29,14 @@ async function openFileBackend(options) {
29
29
  const factory = backendFactories[options.type];
30
30
  const stateMachine = resolveStateMachine(options.stateMachine);
31
31
  validateMachine(stateMachine);
32
+ const markdownOptions = options.type === "markdown-dir" ? options : undefined;
32
33
  const deps = {
33
34
  path: options.path,
34
35
  defaults: {
35
36
  metadata: { ...(options.defaults?.metadata ?? {}) }
36
37
  },
38
+ singleList: markdownOptions?.singleList,
39
+ frontmatterMode: markdownOptions?.frontmatterMode ?? "strict",
37
40
  lockStaleMs: options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS,
38
41
  lockRetries: options.lockRetries ?? DEFAULT_LOCK_RETRIES,
39
42
  create: options.create ?? false,
@@ -91,6 +91,8 @@ export interface OpenMarkdownDirOptions {
91
91
  path: string;
92
92
  defaults?: TaskDefaults;
93
93
  create?: boolean;
94
+ singleList?: string;
95
+ frontmatterMode?: "strict" | "passthrough";
94
96
  lockStaleMs?: number;
95
97
  lockRetries?: number;
96
98
  fs?: TaskListFs;
@@ -122,6 +124,8 @@ export interface OpenGhIssuesOptions {
122
124
  export interface BackendDeps {
123
125
  path: string;
124
126
  defaults: Required<TaskDefaults>;
127
+ singleList?: string;
128
+ frontmatterMode: "strict" | "passthrough";
125
129
  lockStaleMs: number;
126
130
  lockRetries: number;
127
131
  create: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -49,7 +49,7 @@
49
49
  "mustache": "^4.2.0",
50
50
  "smol-toml": "^1.3.0",
51
51
  "tiny-stdio-mcp-server": "^0.1.0",
52
- "toolcraft-schema": "^0.0.15",
52
+ "toolcraft-schema": "^0.0.17",
53
53
  "yaml": "^2.8.2"
54
54
  },
55
55
  "files": [
@@ -81,7 +81,7 @@
81
81
  "@poe-code/agent-human-in-loop": "*",
82
82
  "@poe-code/agent-mcp-config": "*",
83
83
  "@poe-code/config-mutations": "*",
84
- "@poe-code/design-system": "^0.0.1",
84
+ "@poe-code/design-system": "^0.0.2",
85
85
  "@poe-code/file-lock": "*",
86
86
  "@poe-code/process-runner": "*",
87
87
  "@poe-code/task-list": "*",