@dreki-gg/taskman 0.2.1 → 0.3.1

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/README.md CHANGED
@@ -46,6 +46,15 @@ taskman <command> --help # flags + arguments for one command
46
46
  duplicate it (so it cannot drift). Every command prints human text by default
47
47
  and accepts `--json` for machine consumption.
48
48
 
49
+ Create a plan from any harness (handoff/tasks accept an inline value, a
50
+ `--*-file <path>`, or piped stdin):
51
+
52
+ ```bash
53
+ echo "$MARKDOWN" | taskman create-plan --name my-plan --title "My Plan" \
54
+ --handoff-file - --tasks '[{"description":"do it"}]'
55
+ taskman create-handoff --plan my-plan --file HANDOFF.md # write/replace prose
56
+ ```
57
+
49
58
  A typical execution loop:
50
59
 
51
60
  ```bash
package/dist/cli.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { N as readInitiativesManifest, P as upsertInitiativeEntry, S as initiativeRollup, T as reconcileInitiativeForPlan, V as upsertPlanEntry, X as makePlanRuntime, _ as applyInitiativeReconcile, a as filterPlans, b as collectPlanDrift, d as setTaskStatus, g as resolvePlanByName, i as loadInitiativeListItems, l as sortPlans, m as loadPlanData, n as formatInitiativeList, o as formatPlanList, s as loadPlanListItems, t as filterInitiatives, u as appendDeferredTask, v as applyReconcile, y as collectInitiativeDrift, z as readPlansManifest } from "./initiatives-Ij_teFl_.mjs";
2
+ import { J as writeTasksJsonl, N as readInitiativesManifest, P as upsertInitiativeEntry, S as initiativeRollup, T as reconcileInitiativeForPlan, V as upsertPlanEntry, W as saveHandoff, X as makePlanRuntime, _ as applyInitiativeReconcile, a as filterPlans, b as collectPlanDrift, d as setTaskStatus, f as nextTaskId, g as resolvePlanByName, i as loadInitiativeListItems, l as sortPlans, m as loadPlanData, n as formatInitiativeList, o as formatPlanList, p as toKebabCase, s as loadPlanListItems, t as filterInitiatives, u as appendDeferredTask, v as applyReconcile, y as collectInitiativeDrift, z as readPlansManifest } from "./initiatives-Ij_teFl_.mjs";
3
3
  import { Effect } from "effect";
4
+ import { readFile } from "node:fs/promises";
5
+ import { readFileSync } from "node:fs";
4
6
  import { Command } from "commander";
5
7
  //#region src/cli/runtime.ts
6
8
  /**
@@ -252,6 +254,143 @@ async function closeInitiativeCommand(status, name, opts) {
252
254
  }, `Initiative ${name} → ${s}${opts.reason ? ` (${opts.reason})` : ""}.`);
253
255
  }
254
256
  //#endregion
257
+ //#region src/cli/input.ts
258
+ /**
259
+ * Content resolution for CLI commands that accept markdown / JSON payloads from
260
+ * a foreign harness: an inline string, a file path, or piped stdin.
261
+ */
262
+ /** Read all of stdin as a UTF-8 string (used when neither inline nor file is given). */
263
+ async function readStdin() {
264
+ const chunks = [];
265
+ for await (const chunk of process.stdin) chunks.push(chunk);
266
+ return Buffer.concat(chunks).toString("utf8");
267
+ }
268
+ /**
269
+ * Resolve payload content from, in priority order: an inline value, a `--file`
270
+ * path (or `-` for stdin), else piped stdin. Throws `CliError` when nothing is
271
+ * provided and stdin is a TTY (no piped input).
272
+ */
273
+ async function resolveContent(inline, file, label) {
274
+ if (inline !== void 0) return inline;
275
+ if (file !== void 0 && file !== "-") try {
276
+ return await readFile(file, "utf8");
277
+ } catch {
278
+ throw new CliError(`Could not read ${label} file: ${file}`);
279
+ }
280
+ if (process.stdin.isTTY) throw new CliError(`No ${label} provided. Pass it inline, via --${label}-file, or on stdin.`);
281
+ return readStdin();
282
+ }
283
+ //#endregion
284
+ //#region src/cli/commands/create-plan.ts
285
+ /**
286
+ * `taskman create-plan` — create a plan from a foreign harness.
287
+ *
288
+ * The CLI sibling of plan-mode's `submit_plan` tool: writes tasks.jsonl,
289
+ * HANDOFF.md, and a plans.jsonl registry entry in one transaction, then
290
+ * re-projects the parent initiative (when linked). Handoff/tasks payloads come
291
+ * from inline values, files, or stdin so any harness can drive it.
292
+ */
293
+ /** Parse and validate the `--tasks` JSON payload into TaskInput[]. */
294
+ function parseTasks(raw) {
295
+ let parsed;
296
+ try {
297
+ parsed = JSON.parse(raw);
298
+ } catch {
299
+ throw new CliError("--tasks must be a JSON array of { description, ... } objects.");
300
+ }
301
+ if (!Array.isArray(parsed) || parsed.length === 0) throw new CliError("--tasks must be a non-empty JSON array.");
302
+ return parsed.map((entry, i) => {
303
+ if (typeof entry !== "object" || entry === null) throw new CliError(`Task at index ${i} is not an object.`);
304
+ const { description } = entry;
305
+ if (typeof description !== "string" || description.trim() === "") throw new CliError(`Task at index ${i} is missing a "description".`);
306
+ const t = entry;
307
+ return {
308
+ id: t.id,
309
+ description: t.description,
310
+ details: t.details,
311
+ depends_on: t.depends_on
312
+ };
313
+ });
314
+ }
315
+ /** Assign explicit IDs where given, generate t-NNN for the rest. */
316
+ function assignIds(inputs, now) {
317
+ const ids = inputs.map((t) => t.id).filter((id) => Boolean(id));
318
+ const records = [];
319
+ for (const input of inputs) {
320
+ const id = input.id ?? nextTaskId(ids);
321
+ if (!input.id) ids.push(id);
322
+ records.push({
323
+ _type: "task",
324
+ id,
325
+ description: input.description.slice(0, 60),
326
+ details: input.details ?? "",
327
+ status: "pending",
328
+ depends_on: input.depends_on,
329
+ created_at: now,
330
+ updated_at: now
331
+ });
332
+ }
333
+ return records;
334
+ }
335
+ async function createPlanCommand(opts) {
336
+ if (!opts.name) throw new CliError("--name is required.");
337
+ if (!opts.title) throw new CliError("--title is required.");
338
+ const planName = toKebabCase(opts.name);
339
+ const planDir = `.plans/${planName}`;
340
+ const initiative = opts.initiative ? toKebabCase(opts.initiative) : void 0;
341
+ const dependsOnPlans = opts.dependsOn ? opts.dependsOn.split(",").map((s) => toKebabCase(s.trim())).filter(Boolean) : void 0;
342
+ const now = (/* @__PURE__ */ new Date()).toISOString();
343
+ const handoff = await resolveContent(opts.handoff, opts.handoffFile, "handoff");
344
+ const tasks = assignIds(parseTasks(await resolveContent(opts.tasks, opts.tasksFile, "tasks")), now);
345
+ const meta = {
346
+ _type: "meta",
347
+ title: opts.title,
348
+ plan_name: planName,
349
+ created_at: now
350
+ };
351
+ const unknownInitiative = await runPlanIO(Effect.gen(function* () {
352
+ yield* writeTasksJsonl(planDir, meta, tasks);
353
+ yield* saveHandoff(planDir, handoff);
354
+ yield* upsertPlanEntry(planName, {
355
+ status: "in-progress",
356
+ title: opts.title,
357
+ initiative,
358
+ depends_on: dependsOnPlans
359
+ });
360
+ yield* reconcileInitiativeForPlan(planName);
361
+ if (!initiative) return false;
362
+ return !(yield* readInitiativesManifest()).some((entry) => entry.name === initiative);
363
+ }));
364
+ const linkSuffix = initiative ? ` Linked to initiative "${initiative}"${unknownInitiative ? " (no initiatives.jsonl entry yet — create it with submit_initiative)" : ""}.` : "";
365
+ emit(Boolean(opts.json), {
366
+ plan_name: planName,
367
+ plan_dir: planDir,
368
+ task_count: tasks.length,
369
+ task_ids: tasks.map((t) => t.id),
370
+ initiative: initiative ?? null,
371
+ depends_on: dependsOnPlans ?? null,
372
+ unknown_initiative: unknownInitiative
373
+ }, `Plan "${opts.title}" saved with ${tasks.length} tasks in ${planDir}.${linkSuffix}`);
374
+ }
375
+ //#endregion
376
+ //#region src/cli/commands/create-handoff.ts
377
+ /**
378
+ * `taskman create-handoff [content]` — write/replace HANDOFF.md for a plan.
379
+ *
380
+ * Lets a foreign harness hand off markdown without going through the plan-mode
381
+ * extension: content comes from an inline argument, `--file <path>`, or stdin.
382
+ */
383
+ async function createHandoffCommand(content, opts) {
384
+ const { planName, planDir } = await resolvePlanDir(opts.plan);
385
+ const markdown = await resolveContent(content, opts.file, "handoff");
386
+ await runPlanIO(saveHandoff(planDir, markdown));
387
+ emit(Boolean(opts.json), {
388
+ plan_name: planName,
389
+ path: `${planDir}/HANDOFF.md`,
390
+ bytes: Buffer.byteLength(markdown)
391
+ }, `Wrote HANDOFF.md (${Buffer.byteLength(markdown)} bytes) for ${planName}.`);
392
+ }
393
+ //#endregion
255
394
  //#region src/cli.ts
256
395
  /**
257
396
  * `taskman` CLI — drive the `.plans/` task ledger from any Node harness.
@@ -259,9 +398,18 @@ async function closeInitiativeCommand(status, name, opts) {
259
398
  * Thin Commander wiring over the engine: each subcommand delegates to an action
260
399
  * module under `cli/commands/`. Human text by default; `--json` for machines.
261
400
  */
401
+ /** Read the shipped package version (dist/cli.mjs → ../package.json). */
402
+ function packageVersion() {
403
+ try {
404
+ const pkgUrl = new URL("../package.json", import.meta.url);
405
+ return JSON.parse(readFileSync(pkgUrl, "utf-8")).version ?? "0.0.0";
406
+ } catch {
407
+ return "0.0.0";
408
+ }
409
+ }
262
410
  function buildProgram() {
263
411
  const program = new Command();
264
- program.name("taskman").description("Task-management engine over a .plans/ JSONL ledger").version("0.1.0");
412
+ program.name("taskman").description("Task-management engine over a .plans/ JSONL ledger").version(packageVersion());
265
413
  program.command("status").description("Progress + task ids/statuses for the active plan").option("--plan <name>", "plan name (or .plans/<name>) to inspect").option("--json", "machine-readable JSON output").action((opts) => statusCommand(opts));
266
414
  program.command("list").description("List plans").option("--status <status>", "all|in-progress|done|superseded|abandoned").option("--sort <field>", "name|date-asc|date-desc|tasks").option("--json", "machine-readable JSON output").action((opts) => listPlansCommand(opts));
267
415
  program.command("initiatives").description("List initiatives").option("--status <status>", "all|in-progress|done|superseded|abandoned").option("--json", "machine-readable JSON output").action((opts) => listInitiativesCommand(opts));
@@ -269,6 +417,8 @@ function buildProgram() {
269
417
  program.command("update-task").description("Set a task status (done|skipped|blocked|pending)").argument("<id>", "task id, e.g. t-001").argument("<status>", "done|skipped|blocked|pending").option("--plan <name>", "plan to target").option("--notes <text>", "notes recorded on the task").option("--json", "machine-readable JSON output").action((id, status, opts) => updateTaskCommand(id, status, opts));
270
418
  program.command("add-task").description("Append a deferred follow-up task").argument("<description>", "short task label").requiredOption("--reason <text>", "why this follow-up matters").option("--plan <name>", "plan to target").option("--details <text>", "fuller implementation notes").option("--json", "machine-readable JSON output").action((description, opts) => addTaskCommand(description, opts));
271
419
  program.command("reconcile").description("Detect (and with --apply, repair) status drift").option("--apply", "repair safe in-progress→done drift").option("--json", "machine-readable JSON output").action((opts) => reconcileCommand(opts));
420
+ program.command("create-plan").description("Create a plan (tasks.jsonl + HANDOFF.md + registry entry) from any harness").requiredOption("--name <name>", "short kebab-case plan name").requiredOption("--title <title>", "human-readable plan title").option("--handoff <text>", "HANDOFF.md markdown (inline)").option("--handoff-file <path>", "read HANDOFF.md markdown from a file (\"-\" for stdin)").option("--tasks <json>", "tasks as an inline JSON array of { description, ... }").option("--tasks-file <path>", "read the tasks JSON array from a file (\"-\" for stdin)").option("--initiative <name>", "parent initiative name to link this plan to").option("--depends-on <names>", "comma-separated plan names this plan depends on").option("--json", "machine-readable JSON output").action((opts) => createPlanCommand(opts));
421
+ program.command("create-handoff").description("Write/replace HANDOFF.md for a plan from any harness").argument("[content]", "HANDOFF.md markdown (inline); else use --file or stdin").option("--plan <name>", "plan to target").option("--file <path>", "read markdown from a file (\"-\" for stdin)").option("--json", "machine-readable JSON output").action((content, opts) => createHandoffCommand(content, opts));
272
422
  program.command("close").description("Set a plan lifecycle status").argument("<status>", "done|superseded|abandoned|in-progress").option("--plan <name>", "plan to target").option("--reason <text>", "why (recorded in the registry)").option("--json", "machine-readable JSON output").action((status, opts) => closePlanCommand(status, opts));
273
423
  program.command("close-initiative").description("Set an initiative lifecycle status").argument("<status>", "done|superseded|abandoned|in-progress").argument("<name>", "initiative name").option("--reason <text>", "why (recorded in the registry)").option("--json", "machine-readable JSON output").action((status, name, opts) => closeInitiativeCommand(status, name, opts));
274
424
  return program;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/taskman",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Standalone task-management engine + CLI over a .plans/ JSONL ledger — the plan-mode core, usable from any Node harness",
5
5
  "keywords": [
6
6
  "tasks",
@@ -2,13 +2,14 @@
2
2
  name: taskman/core
3
3
  description: >
4
4
  Drive the taskman CLI and engine over a .plans/ JSONL ledger — plans,
5
- initiatives, and tasks. Load when running `taskman` commands (status, list,
6
- update-task, add-task, reconcile, close), tracking plan/task progress across
7
- sessions or harnesses, or calling @dreki-gg/taskman as a library. Covers the
8
- status-is-a-projection model, stateless plan resolution, and reconcile.
5
+ initiatives, and tasks. Load when running `taskman` commands (create-plan,
6
+ create-handoff, status, list, update-task, add-task, reconcile, close),
7
+ tracking plan/task progress across sessions or harnesses, or calling
8
+ @dreki-gg/taskman as a library. Covers the status-is-a-projection model,
9
+ stateless plan resolution, and reconcile.
9
10
  type: core
10
11
  library: "@dreki-gg/taskman"
11
- library_version: "0.2.1"
12
+ library_version: "0.3.1"
12
13
  sources:
13
14
  - "dreki-gg/pi-extensions:packages/taskman/README.md"
14
15
  - "dreki-gg/pi-extensions:packages/taskman/src/cli.ts"
@@ -65,6 +66,23 @@ taskman add-task "handle empty input" --reason "found gap while implementing"
65
66
  taskman reconcile --apply # repair safe (in-progress→done) drift
66
67
  ```
67
68
 
69
+ ### Creating plans from any harness
70
+
71
+ Plans don't have to originate in plan-mode's `submit_plan` tool. The CLI creates
72
+ the same durable artifacts (tasks.jsonl + HANDOFF.md + registry entry) so a
73
+ foreign harness can seed the ledger directly. Handoff/task payloads accept an
74
+ inline value, a `--*-file <path>`, or piped stdin.
75
+
76
+ ```bash
77
+ echo "$MARKDOWN" | taskman create-plan --name my-plan --title "My Plan" \
78
+ --handoff-file - --tasks '[{"description":"do it"}]' # tasks get t-NNN ids
79
+ taskman create-handoff --plan my-plan --file HANDOFF.md # write/replace prose
80
+ ```
81
+
82
+ Use `--initiative <name>` to link the plan (create the initiative first) and
83
+ `--depends-on a,b` for plan-level ordering. Task creation here is plan setup —
84
+ distinct from `add-task`, which records a *deferred* follow-up (see below).
85
+
68
86
  ### Machine-readable output
69
87
 
70
88
  Every command prints human text by default and accepts `--json`: