portable-agent-layer 0.88.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gemini-researcher.md +13 -0
  3. package/assets/agents/grok-researcher.md +13 -0
  4. package/assets/agents/multi-perspective-researcher.md +12 -0
  5. package/assets/agents/perplexity-researcher.md +13 -0
  6. package/assets/agents/skill-author.md +17 -0
  7. package/assets/agents/subagent-author.md +18 -1
  8. package/assets/skills/create-subagent/SKILL.md +3 -2
  9. package/assets/skills/create-subagent/authoring-guide.md +16 -0
  10. package/assets/templates/PAL/MEMORY_SYSTEM.md +1 -1
  11. package/assets/templates/PAL/README.md +6 -5
  12. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +4 -1
  13. package/assets/templates/hooks.antigravity.json +42 -0
  14. package/assets/templates/settings.antigravity.json +44 -0
  15. package/package.json +1 -1
  16. package/src/cli/builtin-tools.ts +28 -23
  17. package/src/cli/doctor/agents.ts +64 -64
  18. package/src/cli/identity.ts +26 -20
  19. package/src/cli/index.ts +109 -312
  20. package/src/cli/knowledge.ts +160 -201
  21. package/src/cli/ledger-command.ts +69 -0
  22. package/src/cli/ledger.ts +20 -101
  23. package/src/cli/personal-context.ts +15 -12
  24. package/src/cli/rule.ts +29 -29
  25. package/src/cli/server.ts +33 -52
  26. package/src/cli/session-agent.ts +7 -9
  27. package/src/cli/skill.ts +73 -64
  28. package/src/cli/subagent.ts +51 -58
  29. package/src/cli/tree.ts +177 -0
  30. package/src/hooks/InvocationContext.ts +26 -0
  31. package/src/hooks/LedgerCommit.ts +4 -2
  32. package/src/hooks/LedgerSnapshot.ts +2 -0
  33. package/src/hooks/SecurityValidator.ts +2 -0
  34. package/src/hooks/StopOrchestrator.ts +12 -2
  35. package/src/hooks/UserPromptOrchestrator.ts +3 -15
  36. package/src/hooks/handlers/context-digests.ts +21 -21
  37. package/src/hooks/handlers/inject-retrieval.ts +7 -5
  38. package/src/hooks/handlers/readme-sync.ts +6 -2
  39. package/src/hooks/handlers/session-name.ts +3 -12
  40. package/src/hooks/lib/agent-definition.ts +6 -16
  41. package/src/hooks/lib/agent-registry.ts +88 -0
  42. package/src/hooks/lib/agent.ts +47 -23
  43. package/src/hooks/lib/antigravity-inference.ts +84 -0
  44. package/src/hooks/lib/antigravity-transcript.ts +129 -0
  45. package/src/hooks/lib/claim-check.ts +13 -2
  46. package/src/hooks/lib/claim-log.ts +5 -1
  47. package/src/hooks/lib/claude-md.ts +21 -1
  48. package/src/hooks/lib/context.ts +5 -1
  49. package/src/hooks/lib/hook-turn.ts +25 -2
  50. package/src/hooks/lib/inference.ts +77 -1
  51. package/src/hooks/lib/invocation-context.ts +99 -0
  52. package/src/hooks/lib/ledger-hook.ts +14 -6
  53. package/src/hooks/lib/models.ts +34 -17
  54. package/src/hooks/lib/paths.ts +39 -6
  55. package/src/hooks/lib/prompt-capture.ts +28 -0
  56. package/src/hooks/lib/prompt-text.ts +1 -0
  57. package/src/hooks/lib/readme-sync.ts +3 -31
  58. package/src/hooks/lib/security-gate.ts +10 -3
  59. package/src/hooks/lib/semi-static.ts +13 -3
  60. package/src/hooks/lib/session-context.ts +3 -1
  61. package/src/hooks/lib/session-names.ts +20 -4
  62. package/src/hooks/lib/stop.ts +1 -0
  63. package/src/hooks/lib/transcript.ts +6 -3
  64. package/src/targets/agent-render.ts +3 -3
  65. package/src/targets/antigravity/install.ts +68 -0
  66. package/src/targets/antigravity/uninstall.ts +66 -0
  67. package/src/targets/lib.ts +81 -64
  68. package/src/targets/opencode/install.ts +2 -0
  69. package/src/targets/opencode/permission.ts +39 -0
  70. package/src/targets/opencode/uninstall.ts +9 -8
  71. package/src/tools/agent/algorithm-reflect.ts +70 -62
  72. package/src/tools/agent/algorithm-synthesize.ts +39 -14
  73. package/src/tools/agent/analyze.ts +31 -36
  74. package/src/tools/agent/handoff-note.ts +51 -54
  75. package/src/tools/agent/interaction.ts +32 -28
  76. package/src/tools/agent/project.ts +300 -287
  77. package/src/tools/agent/relationship-note.ts +40 -36
  78. package/src/tools/agent/relationship-reflect.ts +23 -32
  79. package/src/tools/agent/synthesize.ts +26 -27
  80. package/src/tools/agent/thread.ts +42 -51
  81. package/src/tools/agent/wisdom-frame.ts +45 -38
  82. package/src/tools/control-room/server.ts +0 -2
  83. package/src/tools/control-room/ui/dist/assets/{index-BZMDzWFa.js → index-mFpXpiBj.js} +1 -1
  84. package/src/tools/control-room/ui/dist/index.html +1 -1
  85. package/src/tools/control-room/ui/screens/log.tsx +10 -1
  86. package/src/tools/lib/command.ts +275 -0
  87. package/src/tools/subagent-doctor.ts +74 -9
  88. package/src/tools/token-cost.ts +18 -17
@@ -5,26 +5,11 @@
5
5
  * Each project is stored at `~/.pal/memory/projects/{slug}/ISA.md`.
6
6
  * Frontmatter holds operational state; body holds ISA spec sections.
7
7
  *
8
- * Usage:
9
- * pal cli project list
10
- * pal cli project create [name] [--path PATH] [--objectives "..."] [--serves goal|revenue|fun]
11
- * pal cli project serves <name> <goal|revenue|fun> [note]
12
- * pal cli project resume <name>
13
- * pal cli project complete | archive | pause | unpause <name>
14
- * pal cli project add-next <name> "text"
15
- * pal cli project add-blocker <name> "text"
16
- * pal cli project add-decision <name> "decision" "rationale"
17
- * pal cli project add-handoff <name> "text"
18
- * pal cli project rm-next | rm-blocker <name> <index>
19
- * pal cli project update-section <name> <section> "content"
20
- * pal cli project criteria <name>
21
- * pal cli project isa-init <name>
22
- * pal cli project migrate
8
+ * Usage: pal cli project <command> --help
23
9
  */
24
10
 
25
11
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
26
12
  import { resolve } from "node:path";
27
- import { parseArgs } from "node:util";
28
13
  import { writeBinding } from "../../hooks/lib/bindings";
29
14
  import { paths, toPath } from "../../hooks/lib/paths";
30
15
  import {
@@ -37,9 +22,11 @@ import {
37
22
  proposeBinding,
38
23
  readAllProjects,
39
24
  readProject,
25
+ type ServesKind,
40
26
  writeProject,
41
27
  } from "../../hooks/lib/projects";
42
28
  import { isServesKind, SERVES_KINDS, setServes } from "../../hooks/lib/serves";
29
+ import { group, type Leaf, leaf, runCommand, UsageError } from "../lib/command";
43
30
  import {
44
31
  archiveLine,
45
32
  completeIsc,
@@ -76,9 +63,40 @@ function requireProject(name: string): ProjectProgress {
76
63
  return p as ProjectProgress;
77
64
  }
78
65
 
66
+ // ── argument values ───────────────────────────────────────────────
67
+
68
+ function positiveId(value: string, label: string): number {
69
+ const id = Number(value);
70
+ if (!Number.isInteger(id) || id < 1) {
71
+ throw new UsageError(`${label} must be a positive integer, got "${value}"`);
72
+ }
73
+ return id;
74
+ }
75
+
76
+ function listIndex(value: string): number {
77
+ const index = Number(value);
78
+ if (!Number.isInteger(index) || index < 0) {
79
+ throw new UsageError(`<index> must be a non-negative integer, got "${value}"`);
80
+ }
81
+ return index;
82
+ }
83
+
84
+ function nonBlank(text: string, slot: string): string {
85
+ const trimmed = text.trim();
86
+ if (!trimmed) throw new UsageError(`${slot} is empty`);
87
+ return trimmed;
88
+ }
89
+
90
+ function servesKind(value: string, label: string): ServesKind {
91
+ if (!isServesKind(value)) {
92
+ throw new UsageError(`${label} must be one of: ${SERVES_KINDS.join(", ")}`);
93
+ }
94
+ return value;
95
+ }
96
+
79
97
  // ── list ──────────────────────────────────────────────────────────
80
98
 
81
- function cmdList(): void {
99
+ function cmdList(): undefined {
82
100
  const all = readAllProjects().sort((a, b) => b.updated.localeCompare(a.updated));
83
101
  const rows = all.map((p) => ({
84
102
  name: p.name,
@@ -94,27 +112,35 @@ function cmdList(): void {
94
112
 
95
113
  // ── create ────────────────────────────────────────────────────────
96
114
 
97
- function cmdCreate(args: string[]): void {
98
- const { values, positionals } = parseArgs({
99
- args,
100
- options: {
101
- path: { type: "string" },
102
- name: { type: "string" },
103
- objectives: { type: "string" },
104
- serves: { type: "string" },
105
- "serves-note": { type: "string" },
106
- },
107
- allowPositionals: true,
108
- });
115
+ interface CreateValues {
116
+ path?: string;
117
+ name?: string;
118
+ objectives?: string;
119
+ serves?: string;
120
+ "serves-note"?: string;
121
+ }
109
122
 
123
+ function projectName(
124
+ positional: string | undefined,
125
+ flag: string | undefined
126
+ ): string | undefined {
127
+ if (positional !== undefined && flag !== undefined) {
128
+ throw new UsageError("give the name once: as [name] or as --name");
129
+ }
130
+ return positional ?? flag;
131
+ }
132
+
133
+ function cmdCreate(given: string | undefined, values: CreateValues): undefined {
110
134
  const path = toPath(values.path ?? process.cwd());
111
- const name = (values.name ?? positionals[0] ?? defaultSlug(path)).trim();
135
+ const name = (given ?? defaultSlug(path)).trim();
112
136
 
113
137
  if (!/^[a-z0-9_-]+$/.test(name)) {
114
- fail(
138
+ throw new UsageError(
115
139
  `Invalid project name "${name}". Use lowercase letters, digits, hyphens, underscores.`
116
140
  );
117
141
  }
142
+ const serves =
143
+ values.serves === undefined ? undefined : servesKind(values.serves, "--serves");
118
144
 
119
145
  if (readProject(name)) {
120
146
  fail(
@@ -131,10 +157,6 @@ function cmdCreate(args: string[]): void {
131
157
  .join("\n")
132
158
  : undefined;
133
159
 
134
- if (values.serves !== undefined && !isServesKind(values.serves)) {
135
- fail(`--serves must be one of: ${SERVES_KINDS.join(", ")}`);
136
- }
137
-
138
160
  const project: ProjectProgress = {
139
161
  name,
140
162
  path,
@@ -142,9 +164,7 @@ function cmdCreate(args: string[]): void {
142
164
  created: now(),
143
165
  updated: now(),
144
166
  ...(goalLines ? { goal: goalLines } : {}),
145
- ...(isServesKind(values.serves)
146
- ? { serves: values.serves, serves_by: "user" as const }
147
- : {}),
167
+ ...(serves ? { serves, serves_by: "user" as const } : {}),
148
168
  ...(values["serves-note"] ? { serves_note: values["serves-note"] } : {}),
149
169
  };
150
170
  writeProject(project);
@@ -152,15 +172,11 @@ function cmdCreate(args: string[]): void {
152
172
  }
153
173
 
154
174
  /** The answer the scaffolder asks for, and the one place importance can be corrected. */
155
- function cmdServes(args: string[]): void {
156
- const [name, kind, ...note] = args;
157
- if (!name || !kind) fail("Usage: serves <name> <goal|revenue|fun> [note]");
158
- if (!isServesKind(kind)) fail(`serves must be one of: ${SERVES_KINDS.join(", ")}`);
159
-
175
+ function cmdServes(name: string, kind: ServesKind, note: string): undefined {
160
176
  const outcome = setServes({
161
177
  name,
162
178
  kind,
163
- note: note.join(" ").trim() || undefined,
179
+ note: note.trim() || undefined,
164
180
  by: "user",
165
181
  });
166
182
  if (outcome === "missing") fail(`No project named "${name}".`);
@@ -173,9 +189,7 @@ function cmdServes(args: string[]): void {
173
189
  // Criteria/Changelog blobs collapse to open-ISC titles + counts. Full ISC text
174
190
  // is fetched on demand via show-isc / list-isc, so resume stays cheap on
175
191
  // projects carrying a large backlog.
176
- function cmdResume(args: string[]): void {
177
- const name = args[0];
178
- if (!name) fail("Usage: resume <name>");
192
+ function cmdResume(name: string): undefined {
179
193
  const { criteria, changelog, ...project } = requireProject(name);
180
194
  const iscs = parseIscs(criteria ?? "");
181
195
  const archived = parseIscs(changelog ?? "");
@@ -205,7 +219,7 @@ function cmdResume(args: string[]): void {
205
219
 
206
220
  // ── status transitions ────────────────────────────────────────────
207
221
 
208
- function setStatus(name: string, status: ProjectStatus): void {
222
+ function setStatus(name: string, status: ProjectStatus): undefined {
209
223
  const p = requireProject(name);
210
224
  p.status = status;
211
225
  p.updated = now();
@@ -215,20 +229,18 @@ function setStatus(name: string, status: ProjectStatus): void {
215
229
 
216
230
  // ── append/remove for array fields ───────────────────────────────
217
231
 
218
- function appendItem(name: string, field: "next" | "blockers", text: string): void {
219
- if (!text?.trim()) fail(`Empty ${field} text.`);
232
+ function appendItem(name: string, field: "next" | "blockers", text: string): undefined {
233
+ const item = nonBlank(text, `${field} text`);
220
234
  const p = requireProject(name);
221
235
  const list = p[field] ?? [];
222
- list.push(text.trim());
236
+ list.push(item);
223
237
  p[field] = list;
224
238
  p.updated = now();
225
239
  writeProject(p);
226
240
  ok({ updated: true, name, field, count: list.length });
227
241
  }
228
242
 
229
- function removeItem(name: string, field: "next" | "blockers", indexArg: string): void {
230
- const idx = parseInt(indexArg, 10);
231
- if (!Number.isInteger(idx) || idx < 0) fail(`Invalid index "${indexArg}".`);
243
+ function removeItem(name: string, field: "next" | "blockers", idx: number): undefined {
232
244
  const p = requireProject(name);
233
245
  const list = p[field] ?? [];
234
246
  if (idx >= list.length) fail(`Index ${idx} out of range (length ${list.length}).`);
@@ -241,12 +253,12 @@ function removeItem(name: string, field: "next" | "blockers", indexArg: string):
241
253
 
242
254
  // ── decisions (body section append) ──────────────────────────────
243
255
 
244
- function addDecision(name: string, decision: string, rationale: string): void {
245
- if (!decision?.trim() || !rationale?.trim())
246
- fail("Usage: add-decision <name> <decision> <rationale>");
256
+ function addDecision(name: string, decision: string, rationale: string): undefined {
257
+ const what = nonBlank(decision, "<decision>");
258
+ const why = nonBlank(rationale, "<rationale...>");
247
259
  const p = requireProject(name);
248
260
  const date = new Date().toISOString().slice(0, 10);
249
- const line = `- ${date}: ${decision.trim()} (${rationale.trim()})`;
261
+ const line = `- ${date}: ${what} (${why})`;
250
262
  p.decisions = p.decisions ? `${p.decisions}\n${line}` : line;
251
263
  p.updated = now();
252
264
  writeProject(p);
@@ -255,10 +267,10 @@ function addDecision(name: string, decision: string, rationale: string): void {
255
267
 
256
268
  // ── handoff ───────────────────────────────────────────────────────
257
269
 
258
- function addHandoff(name: string, text: string): void {
259
- if (!text?.trim()) fail("Empty handoff text.");
270
+ function addHandoff(name: string, text: string): undefined {
271
+ const handoff = nonBlank(text, "handoff text");
260
272
  const p = requireProject(name);
261
- p.handoff = text.trim();
273
+ p.handoff = handoff;
262
274
  p.updated = now();
263
275
  writeProject(p);
264
276
  ok({ updated: true, name });
@@ -269,10 +281,8 @@ function addHandoff(name: string, text: string): void {
269
281
  // Where a project lives is machine-local, so this writes a binding rather than a
270
282
  // field on the record. Unlike the save path it does not require the directory to
271
283
  // exist yet: naming where a repo is about to be cloned is a legitimate use.
272
- function cmdSetPath(args: string[]): void {
273
- const [name, ...rest] = args;
274
- if (!name || rest.length === 0) fail("Usage: set-path <name> <new-path>");
275
- const newPath = toPath(rest.join(" ").trim());
284
+ function cmdSetPath(name: string, rawPath: string): undefined {
285
+ const newPath = toPath(nonBlank(rawPath, "<path...>"));
276
286
  const p = requireProject(name);
277
287
  writeBinding(p.name, newPath);
278
288
  p.updated = now();
@@ -295,15 +305,19 @@ const VALID_SECTIONS = [
295
305
  ] as const;
296
306
  type Section = (typeof VALID_SECTIONS)[number];
297
307
 
298
- function cmdUpdateSection(args: string[]): void {
299
- const [name, section, ...rest] = args;
300
- if (!name || !section) fail("Usage: update-section <name> <section> <content>");
301
- const key = section.toLowerCase().replace(/\s+/g, "_") as Section;
308
+ function sectionKey(section: string): Section {
309
+ const key = section.toLowerCase().replace(/\s+/g, "_");
302
310
  if (!(VALID_SECTIONS as readonly string[]).includes(key)) {
303
- fail(`Unknown section "${section}". Valid: ${VALID_SECTIONS.join(", ")}`);
311
+ throw new UsageError(
312
+ `Unknown section "${section}". Valid: ${VALID_SECTIONS.join(", ")}`
313
+ );
304
314
  }
305
- const content = rest.join(" ").trim();
306
- if (!content) fail("Empty content.");
315
+ return key as Section;
316
+ }
317
+
318
+ function cmdUpdateSection(name: string, section: string, text: string): undefined {
319
+ const key = sectionKey(section);
320
+ const content = nonBlank(text, "<content...>");
307
321
  const p = requireProject(name);
308
322
  (p as unknown as Record<string, unknown>)[key] = content;
309
323
  p.updated = now();
@@ -313,18 +327,14 @@ function cmdUpdateSection(args: string[]): void {
313
327
 
314
328
  // ── criteria ──────────────────────────────────────────────────────
315
329
 
316
- function cmdCriteria(args: string[]): void {
317
- const name = args[0];
318
- if (!name) fail("Usage: criteria <name>");
330
+ function cmdCriteria(name: string): undefined {
319
331
  const p = requireProject(name);
320
332
  ok({ name, criteria: p.criteria ?? "" });
321
333
  }
322
334
 
323
335
  // ── isa-init ──────────────────────────────────────────────────────
324
336
 
325
- function cmdIsaInit(args: string[]): void {
326
- const name = args[0];
327
- if (!name) fail("Usage: isa-init <name>");
337
+ function cmdIsaInit(name: string): undefined {
328
338
  const p = requireProject(name);
329
339
  const sections: Array<keyof ProjectProgress> = [
330
340
  "problem",
@@ -354,7 +364,7 @@ function cmdIsaInit(args: string[]): void {
354
364
 
355
365
  // ── migrate (from old JSON format) ───────────────────────────────
356
366
 
357
- function cmdMigrate(): void {
367
+ function cmdMigrate(): undefined {
358
368
  const progressDir = paths.progress();
359
369
  if (!existsSync(progressDir)) {
360
370
  ok({ migrated: 0, skipped: 0, results: [] });
@@ -403,9 +413,7 @@ function cmdMigrate(): void {
403
413
 
404
414
  // ── rm (project) ──────────────────────────────────────────────────
405
415
 
406
- function cmdRm(args: string[]): void {
407
- const name = args[0];
408
- if (!name) fail("Usage: rm <name> (deletes the entire project directory)");
416
+ function cmdRm(name: string): undefined {
409
417
  const removed = deleteProject(name);
410
418
  if (!removed) fail(`No project named "${name}".`);
411
419
  ok({ deleted: true, name });
@@ -416,10 +424,8 @@ function cmdRm(args: string[]): void {
416
424
  // Three states, not two: a retired ISC is one that stopped being valid, which the
417
425
  // record must not report as completed work. The box character is the storage form
418
426
  // and the id stays in it, so a retired line keeps reserving its id in nextIscId.
419
- function cmdAddIsc(args: string[]): void {
420
- const name = args[0] ?? fail("Usage: add-isc <name> <title>");
421
- const title = args.slice(1).join(" ").trim();
422
- if (!title) fail("Usage: add-isc <name> <title>");
427
+ function cmdAddIsc(name: string, text: string): undefined {
428
+ const title = nonBlank(text, "<title...>");
423
429
  const p = requireProject(name);
424
430
  const current = p.criteria ?? "";
425
431
  const id = nextIscId(current, p.changelog ?? "");
@@ -439,10 +445,7 @@ function cmdAddIsc(args: string[]): void {
439
445
 
440
446
  // Completing an ISC moves its line out of Criteria and into the dated Changelog
441
447
  // archive, so Criteria stays exactly the open set and never re-bloats context.
442
- function cmdCompleteIsc(args: string[]): void {
443
- const name = args[0] ?? fail("Usage: complete-isc <name> <id>");
444
- const id = Number(args[1] ?? fail("Usage: complete-isc <name> <id>"));
445
- if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
448
+ function cmdCompleteIsc(name: string, id: number): undefined {
446
449
  const p = requireProject(name);
447
450
  const move = completeIsc(sectionsOf(p), id);
448
451
  if (!move.ok) fail(`${move.reason} in project "${name}"`);
@@ -467,10 +470,7 @@ function applyIscMove(p: ProjectProgress, move: IscMove & { ok: true }): void {
467
470
 
468
471
  // Reopening pulls the line back out of the Changelog (or legacy Criteria) into
469
472
  // the open set.
470
- function cmdReopenIsc(args: string[]): void {
471
- const name = args[0] ?? fail("Usage: reopen-isc <name> <id>");
472
- const id = Number(args[1] ?? fail("Usage: reopen-isc <name> <id>"));
473
- if (!Number.isInteger(id) || id < 1) fail("ISC id must be a positive integer");
473
+ function cmdReopenIsc(name: string, id: number): undefined {
474
474
  const p = requireProject(name);
475
475
  const move = reopenIsc(sectionsOf(p), id);
476
476
  if (!move.ok) fail(`${move.reason} in project "${name}"`);
@@ -482,11 +482,23 @@ function cmdReopenIsc(args: string[]): void {
482
482
  ok({ checked: false, id });
483
483
  }
484
484
 
485
- function cmdListIsc(args: string[]): void {
486
- const flags = new Set(args.filter((a) => a.startsWith("--")));
487
- const name =
488
- args.find((a) => !a.startsWith("--")) ??
489
- fail("Usage: list-isc <name> [--all | --closed | --retired]");
485
+ interface IscListFlags {
486
+ all?: boolean;
487
+ closed?: boolean;
488
+ retired?: boolean;
489
+ }
490
+
491
+ function iscListFlag(flags: IscListFlags): Set<string> {
492
+ const chosen = Object.entries(flags)
493
+ .filter(([, on]) => on)
494
+ .map(([flag]) => `--${flag}`);
495
+ if (chosen.length > 1) {
496
+ throw new UsageError(`${chosen.join(" and ")} cannot be combined; pick one`);
497
+ }
498
+ return new Set(chosen);
499
+ }
500
+
501
+ function cmdListIsc(name: string, flags: Set<string>): undefined {
490
502
  const p = requireProject(name);
491
503
  const criteria = parseIscs(p.criteria ?? "");
492
504
  const all = [...criteria, ...parseIscs(p.changelog ?? "")];
@@ -505,10 +517,7 @@ function cmdListIsc(args: string[]): void {
505
517
 
506
518
  // show-isc prints one ISC's full text on demand — the "detail" counterpart to
507
519
  // resume's titles. Scans Criteria (open + not-yet-archived) and Changelog.
508
- function cmdShowIsc(args: string[]): void {
509
- const name = args[0];
510
- const id = Number(args[1]);
511
- if (!name || !Number.isInteger(id) || id < 1) fail("Usage: show-isc <name> <id>");
520
+ function cmdShowIsc(name: string, id: number): undefined {
512
521
  const p = requireProject(name);
513
522
  const isc = [...parseIscs(p.criteria ?? ""), ...parseIscs(p.changelog ?? "")].find(
514
523
  (i) => i.id === id
@@ -520,18 +529,7 @@ function cmdShowIsc(args: string[]): void {
520
529
  // retire-isc closes an ISC that stopped being valid, which complete-isc cannot say:
521
530
  // completing files it as done work. The line moves to the Changelog under its own
522
531
  // heading as [~], so it still reserves its id and never reads as finished.
523
- function cmdRetireIsc(args: string[]): void {
524
- const positional = args.filter((a) => !a.startsWith("--"));
525
- const name = positional[0];
526
- const id = Number(positional[1]);
527
- if (!name || !Number.isInteger(id) || id < 1) {
528
- fail("Usage: retire-isc <name> <id> [--by <supersedingId>]");
529
- }
530
- const byIndex = args.indexOf("--by");
531
- const by = byIndex === -1 ? null : Number(args[byIndex + 1]);
532
- if (byIndex !== -1 && (!Number.isInteger(by) || (by ?? 0) < 1)) {
533
- fail("--by expects a positive ISC id");
534
- }
532
+ function cmdRetireIsc(name: string, id: number, by: number | null): undefined {
535
533
  const p = requireProject(name);
536
534
  if (parseIscs(p.changelog ?? "").some((i) => i.id === id && i.status === "retired")) {
537
535
  ok({ retired: true, id, alreadyRetired: true });
@@ -554,13 +552,8 @@ function cmdRetireIsc(args: string[]): void {
554
552
  // edit-isc rewrites one ISC's text in place, keeping its id and open/done state.
555
553
  // The id never leaves the record, so nextIscId still reserves it. Returns the
556
554
  // previous text because the ISA files carry no version history of their own.
557
- function cmdEditIsc(args: string[]): void {
558
- const name = args[0];
559
- const id = Number(args[1]);
560
- const text = args.slice(2).join(" ").trim();
561
- if (!name || !Number.isInteger(id) || id < 1 || !text) {
562
- fail('Usage: edit-isc <name> <id> "new text"');
563
- }
555
+ function cmdEditIsc(name: string, id: number, newText: string): undefined {
556
+ const text = nonBlank(newText, "<text...>");
564
557
  const p = requireProject(name);
565
558
  const inCriteria = parseIscs(p.criteria ?? "").find((i) => i.id === id);
566
559
  const isc = inCriteria ?? parseIscs(p.changelog ?? "").find((i) => i.id === id);
@@ -592,8 +585,7 @@ function cmdEditIsc(args: string[]): void {
592
585
 
593
586
  // Backfill: sweep any done ISCs still sitting in Criteria (legacy projects, or
594
587
  // completions from before archive-on-complete) into the Changelog in one pass.
595
- function cmdPruneIsc(args: string[]): void {
596
- const name = args[0] ?? fail("Usage: prune-isc <name>");
588
+ function cmdPruneIsc(name: string): undefined {
597
589
  const p = requireProject(name);
598
590
  const done = parseIscs(p.criteria ?? "").filter((i) => i.status !== "open");
599
591
  for (const isc of done) {
@@ -618,9 +610,8 @@ function taskIsaPath(slug: string): string {
618
610
  return resolve(dir, "ISA.md");
619
611
  }
620
612
 
621
- function cmdScaffoldTaskIsa(args: string[]): void {
622
- const title = args.join(" ").trim();
623
- if (!title) fail("Usage: scaffold-task-isa <title>");
613
+ function cmdScaffoldTaskIsa(text: string): undefined {
614
+ const title = nonBlank(text, "<title...>");
624
615
  const slug = taskSlug(title);
625
616
  const ts = new Date().toISOString();
626
617
  const content = [
@@ -644,8 +635,7 @@ function cmdScaffoldTaskIsa(args: string[]): void {
644
635
  ok({ created: true, slug, path: filePath });
645
636
  }
646
637
 
647
- function cmdCompleteTaskIsa(args: string[]): void {
648
- const slug = args[0] ?? fail("Usage: complete-task-isa <slug>");
638
+ function cmdCompleteTaskIsa(slug: string): undefined {
649
639
  const filePath = resolve(paths.work(), slug, "ISA.md");
650
640
  if (!existsSync(filePath)) fail(`Task ISA not found: ${slug}`);
651
641
  const content = readFileSync(filePath, "utf-8");
@@ -656,163 +646,186 @@ function cmdCompleteTaskIsa(args: string[]): void {
656
646
  ok({ completed: true, slug });
657
647
  }
658
648
 
659
- // ── dispatch ──────────────────────────────────────────────────────
660
-
661
- function help(): void {
662
- console.log(`Project — manage PAL project state (ISA.md backed).
663
-
664
- Commands:
665
- list show all registered projects
666
- create [name] [--path PATH] [--objectives X] [--serves KIND] register a project
667
- serves <name> <goal|revenue|fun> [note] say what it is for — outranks PAL's guess
668
- resume <name> print lean project view (open-ISC titles; full text via show-isc)
669
- complete <name> mark complete
670
- archive <name> mark archived
671
- pause <name> | unpause <name> toggle paused/active
672
- set-path <name> <new-path> update the registered path
673
- add-next <name> "text" append next step
674
- add-blocker <name> "text" append blocker
675
- add-decision <name> "decision" "rationale" log a dated decision entry
676
- add-handoff <name> "text" overwrite handoff field
677
- rm-next <name> <index> remove next step by index
678
- rm-blocker <name> <index> remove blocker by index
679
- update-section <name> <section> "content" set an ISA body section
680
- criteria <name> print the Criteria section
681
- add-isc <name> "title" append a new open ISC to Criteria
682
- complete-isc <name> <id> mark ISC-N as done
683
- reopen-isc <name> <id> reopen ISC-N (mark not done)
684
- list-isc <name> [--all | --closed | --retired] list open ISCs (default); --all, --closed, or --retired
685
- show-isc <name> <id> print one ISC's full text
686
- edit-isc <name> <id> "new text" rewrite ISC-N's text, keeping its id and state
687
- retire-isc <name> <id> [--by <id>] close ISC-N as no longer valid, not as done
688
- prune-isc <name> archive done ISCs from Criteria into the Changelog
689
- isa-init <name> mark project as ISA-initialized
690
- scaffold-task-isa <title> create a one-shot task ISA in memory/work/
691
- complete-task-isa <slug> mark a task ISA as complete
692
- migrate migrate old JSON progress files → ISA.md
693
- rm <name> delete the entire project
694
- `);
695
- }
696
-
697
- export function run(argv: string[] = scriptArgs()): void {
698
- const [cmd, ...rest] = argv;
699
- if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
700
- help();
701
- return;
702
- }
703
- switch (cmd) {
704
- case "list":
705
- cmdList();
706
- return;
707
- case "create":
708
- cmdCreate(rest);
709
- return;
710
- case "serves":
711
- cmdServes(rest);
712
- return;
713
- case "resume":
714
- cmdResume(rest);
715
- return;
716
- case "complete":
717
- setStatus(rest[0] ?? fail("Usage: complete <name>"), "complete");
718
- return;
719
- case "archive":
720
- setStatus(rest[0] ?? fail("Usage: archive <name>"), "archived");
721
- return;
722
- case "pause":
723
- setStatus(rest[0] ?? fail("Usage: pause <name>"), "paused");
724
- return;
725
- case "unpause":
726
- setStatus(rest[0] ?? fail("Usage: unpause <name>"), "active");
727
- return;
728
- case "add-next":
729
- appendItem(
730
- rest[0] ?? fail("Usage: add-next <name> <text>"),
731
- "next",
732
- rest.slice(1).join(" ")
733
- );
734
- return;
735
- case "add-blocker":
736
- appendItem(
737
- rest[0] ?? fail("Usage: add-blocker <name> <text>"),
738
- "blockers",
739
- rest.slice(1).join(" ")
740
- );
741
- return;
742
- case "add-decision":
743
- addDecision(
744
- rest[0] ?? fail("Usage: add-decision <name> <decision> <rationale>"),
745
- rest[1] ?? "",
746
- rest.slice(2).join(" ")
747
- );
748
- return;
749
- case "add-handoff":
750
- addHandoff(
751
- rest[0] ?? fail("Usage: add-handoff <name> <text>"),
752
- rest.slice(1).join(" ")
753
- );
754
- return;
755
- case "rm-next":
756
- removeItem(rest[0] ?? fail("Usage: rm-next <name> <index>"), "next", rest[1] ?? "");
757
- return;
758
- case "rm-blocker":
759
- removeItem(
760
- rest[0] ?? fail("Usage: rm-blocker <name> <index>"),
761
- "blockers",
762
- rest[1] ?? ""
763
- );
764
- return;
765
- case "update-section":
766
- cmdUpdateSection(rest);
767
- return;
768
- case "criteria":
769
- cmdCriteria(rest);
770
- return;
771
- case "add-isc":
772
- cmdAddIsc(rest);
773
- return;
774
- case "complete-isc":
775
- cmdCompleteIsc(rest);
776
- return;
777
- case "reopen-isc":
778
- cmdReopenIsc(rest);
779
- return;
780
- case "list-isc":
781
- cmdListIsc(rest);
782
- return;
783
- case "show-isc":
784
- cmdShowIsc(rest);
785
- return;
786
- case "retire-isc":
787
- cmdRetireIsc(rest);
788
- return;
789
- case "edit-isc":
790
- cmdEditIsc(rest);
791
- return;
792
- case "prune-isc":
793
- cmdPruneIsc(rest);
794
- return;
795
- case "isa-init":
796
- cmdIsaInit(rest);
797
- return;
798
- case "scaffold-task-isa":
799
- cmdScaffoldTaskIsa(rest);
800
- return;
801
- case "complete-task-isa":
802
- cmdCompleteTaskIsa(rest);
803
- return;
804
- case "migrate":
805
- cmdMigrate();
806
- return;
807
- case "set-path":
808
- cmdSetPath(rest);
809
- return;
810
- case "rm":
811
- cmdRm(rest);
812
- return;
813
- default:
814
- fail(`Unknown command "${cmd}". Run 'pal cli project help' for usage.`);
815
- }
649
+ // ── command tree ──────────────────────────────────────────────────
650
+
651
+ function joined(words: string[]): string {
652
+ return words.join(" ");
653
+ }
654
+
655
+ function nameLeaf(summary: string, act: (name: string) => undefined): Leaf {
656
+ return leaf({ summary, args: "<name>", run: ({ positionals }) => act(positionals[0]) });
657
+ }
658
+
659
+ function statusLeaf(summary: string, status: ProjectStatus): Leaf {
660
+ return nameLeaf(summary, (name) => setStatus(name, status));
661
+ }
662
+
663
+ function iscLeaf(summary: string, act: (name: string, id: number) => undefined): Leaf {
664
+ return leaf({
665
+ summary,
666
+ args: "<name> <id>",
667
+ run: ({ positionals }) => act(positionals[0], positiveId(positionals[1], "<id>")),
668
+ });
669
+ }
670
+
671
+ function textLeaf(
672
+ summary: string,
673
+ slot: string,
674
+ act: (name: string, text: string) => undefined
675
+ ): Leaf {
676
+ return leaf({
677
+ summary,
678
+ args: `<name> ${slot}`,
679
+ run: ({ positionals }) => act(positionals[0], joined(positionals.slice(1))),
680
+ });
681
+ }
682
+
683
+ function removeLeaf(summary: string, field: "next" | "blockers"): Leaf {
684
+ return leaf({
685
+ summary,
686
+ args: "<name> <index>",
687
+ run: ({ positionals }) =>
688
+ removeItem(positionals[0], field, listIndex(positionals[1])),
689
+ });
816
690
  }
817
691
 
818
- if (import.meta.main) run();
692
+ export const command = group({
693
+ summary: "Manage PAL project state (ISA.md backed)",
694
+ commands: {
695
+ list: leaf({ summary: "Show all registered projects", run: cmdList }),
696
+ create: leaf({
697
+ summary: "Register a project",
698
+ args: "[name]",
699
+ options: {
700
+ path: {
701
+ type: "string",
702
+ value: "<path>",
703
+ description: "Where the project lives (default: cwd)",
704
+ },
705
+ name: {
706
+ type: "string",
707
+ value: "<name>",
708
+ description: "Slug, instead of [name] (default: the path's basename)",
709
+ },
710
+ objectives: {
711
+ type: "string",
712
+ value: "<a;b>",
713
+ description: "Goal lines, split on ';', '|' or newlines",
714
+ },
715
+ serves: {
716
+ type: "string",
717
+ value: `<${SERVES_KINDS.join("|")}>`,
718
+ description: "What the project is for",
719
+ },
720
+ "serves-note": {
721
+ type: "string",
722
+ value: "<text>",
723
+ description: "Why, in a few words",
724
+ },
725
+ },
726
+ run: ({ positionals, values }) =>
727
+ cmdCreate(projectName(positionals[0], values.name), values),
728
+ }),
729
+ serves: leaf({
730
+ summary: "Say what it is for — outranks PAL's guess",
731
+ args: `<name> <${SERVES_KINDS.join("|")}> [note...]`,
732
+ run: ({ positionals: [name, kind, ...note] }) =>
733
+ cmdServes(name, servesKind(kind, "serves"), joined(note)),
734
+ }),
735
+ resume: nameLeaf(
736
+ "Print lean project view (open-ISC titles; full text via show-isc)",
737
+ cmdResume
738
+ ),
739
+ complete: statusLeaf("Mark complete", "complete"),
740
+ archive: statusLeaf("Mark archived", "archived"),
741
+ pause: statusLeaf("Mark paused", "paused"),
742
+ unpause: statusLeaf("Mark active again", "active"),
743
+ "set-path": leaf({
744
+ summary: "Update the registered path",
745
+ args: "<name> <path...>",
746
+ run: ({ positionals: [name, ...path] }) => cmdSetPath(name, joined(path)),
747
+ }),
748
+ "add-next": textLeaf("Append next step", "<text...>", (name, text) =>
749
+ appendItem(name, "next", text)
750
+ ),
751
+ "add-blocker": textLeaf("Append blocker", "<text...>", (name, text) =>
752
+ appendItem(name, "blockers", text)
753
+ ),
754
+ "add-decision": leaf({
755
+ summary: "Log a dated decision entry",
756
+ args: "<name> <decision> <rationale...>",
757
+ run: ({ positionals: [name, decision, ...rationale] }) =>
758
+ addDecision(name, decision, joined(rationale)),
759
+ }),
760
+ "add-handoff": textLeaf("Overwrite handoff field", "<text...>", addHandoff),
761
+ "rm-next": removeLeaf("Remove next step by index", "next"),
762
+ "rm-blocker": removeLeaf("Remove blocker by index", "blockers"),
763
+ "update-section": leaf({
764
+ summary: "Set an ISA body section",
765
+ args: "<name> <section> <content...>",
766
+ details: `Sections: ${VALID_SECTIONS.join(", ")}`,
767
+ run: ({ positionals: [name, section, ...content] }) =>
768
+ cmdUpdateSection(name, section, joined(content)),
769
+ }),
770
+ criteria: nameLeaf("Print the Criteria section", cmdCriteria),
771
+ "add-isc": textLeaf("Append a new open ISC to Criteria", "<title...>", cmdAddIsc),
772
+ "complete-isc": iscLeaf("Mark ISC-N as done", cmdCompleteIsc),
773
+ "reopen-isc": iscLeaf("Reopen ISC-N (mark not done)", cmdReopenIsc),
774
+ "list-isc": leaf({
775
+ summary: "List open ISCs (default), or the closed or retired ones",
776
+ args: "<name>",
777
+ options: {
778
+ all: { type: "boolean", description: "Open, done and retired" },
779
+ closed: { type: "boolean", description: "Done only" },
780
+ retired: { type: "boolean", description: "Retired only" },
781
+ },
782
+ run: ({ positionals, values }) => cmdListIsc(positionals[0], iscListFlag(values)),
783
+ }),
784
+ "show-isc": iscLeaf("Print one ISC's full text", cmdShowIsc),
785
+ "edit-isc": leaf({
786
+ summary: "Rewrite ISC-N's text, keeping its id and state",
787
+ args: "<name> <id> <text...>",
788
+ run: ({ positionals: [name, id, ...text] }) =>
789
+ cmdEditIsc(name, positiveId(id, "<id>"), joined(text)),
790
+ }),
791
+ "retire-isc": leaf({
792
+ summary: "Close ISC-N as no longer valid, not as done",
793
+ args: "<name> <id>",
794
+ options: {
795
+ by: { type: "string", value: "<id>", description: "The ISC that supersedes it" },
796
+ },
797
+ run: ({ positionals: [name, id], values }) =>
798
+ cmdRetireIsc(
799
+ name,
800
+ positiveId(id, "<id>"),
801
+ values.by === undefined ? null : positiveId(values.by, "--by")
802
+ ),
803
+ }),
804
+ "prune-isc": nameLeaf(
805
+ "Archive done ISCs from Criteria into the Changelog",
806
+ cmdPruneIsc
807
+ ),
808
+ "isa-init": nameLeaf("Mark project as ISA-initialized", cmdIsaInit),
809
+ "scaffold-task-isa": leaf({
810
+ summary: "Create a one-shot task ISA in memory/work/",
811
+ args: "<title...>",
812
+ run: ({ positionals }) => cmdScaffoldTaskIsa(joined(positionals)),
813
+ }),
814
+ "complete-task-isa": leaf({
815
+ summary: "Mark a task ISA as complete",
816
+ args: "<slug>",
817
+ run: ({ positionals }) => cmdCompleteTaskIsa(positionals[0]),
818
+ }),
819
+ migrate: leaf({
820
+ summary: "Migrate old JSON progress files → ISA.md",
821
+ run: cmdMigrate,
822
+ }),
823
+ rm: nameLeaf("Delete the entire project", cmdRm),
824
+ },
825
+ });
826
+
827
+ export function run(argv: string[] = scriptArgs()): Promise<number> {
828
+ return runCommand(command, argv, ["pal", "cli", "project"]);
829
+ }
830
+
831
+ if (import.meta.main) process.exit(await run());