@chantier/core 0.5.0 → 0.6.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.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,42 @@
1
1
  import { ApprovalDetail, ApprovalSink, PermissionEngine, PermissionRules } from "@chantier/permissions";
2
+ //#region src/todo.d.ts
3
+ /**
4
+ * Frozen v0.6.0 contract (spec /home/debian/portfolio/chantier/v06-spec.md
5
+ * §Frozen contract): the todo-checklist step shape shared by the core tool,
6
+ * the store, and the TUI. Types only — Worker CoreExt implements the tool;
7
+ * the TUI worker implements the store fields and rendering.
8
+ */
9
+ /** One checklist row. Whole-list replace per call; at most one in_progress. */
10
+ interface TodoStep {
11
+ readonly content: string;
12
+ readonly status: "pending" | "in_progress" | "completed";
13
+ }
14
+ /**
15
+ * The todo tool factory. The tool replaces the whole checklist per call and
16
+ * enforces the exactly-one-in_progress invariant (normalizing extras to
17
+ * pending). `onTodo` receives every accepted list; the CLI loop forwards it
18
+ * to the TUI store.
19
+ */
20
+ type CreateTodoTool = (deps: {
21
+ onTodo: (steps: readonly TodoStep[]) => void;
22
+ }) => ToolDefinition;
23
+ /**
24
+ * The `todo` tool: whole-list checklist replace with the
25
+ * exactly-one-in_progress invariant (extras normalize to pending, first
26
+ * keeps the slot). readOnly: the checklist mutates nothing on disk.
27
+ */
28
+ export declare function createTodoTool(deps: {
29
+ onTodo: (steps: readonly TodoStep[]) => void;
30
+ }): ToolDefinition;
31
+ /**
32
+ * Lenient row normalization: drops non-object/blank rows, coerces unknown
33
+ * statuses to pending, and enforces exactly one in_progress (the first keeps
34
+ * the slot; extras become pending).
35
+ */
36
+ export declare function normalizeTodoSteps(raw: readonly unknown[]): readonly TodoStep[];
37
+ /** One-line summary the model sees: counts per status in fixed order. */
38
+ export declare function summarizeTodoSteps(steps: readonly TodoStep[]): string;
39
+ //#endregion
2
40
  //#region src/types.d.ts
3
41
  interface TextBlock {
4
42
  type: "text";
@@ -78,6 +116,12 @@ interface ToolContext {
78
116
  session: SessionStore;
79
117
  permission: PermissionEngine;
80
118
  signal: AbortSignal;
119
+ /**
120
+ * v0.6: live todo-trail hook. The todo tool forwards each accepted
121
+ * checklist to it; the interactive loop binds it to the TUI store.
122
+ * Undefined = the checklist is accepted without a live consumer.
123
+ */
124
+ onTodo?: (steps: readonly TodoStep[]) => void;
81
125
  }
82
126
  interface ToolDefinition {
83
127
  name: string;
@@ -203,6 +247,83 @@ interface RunAgentOptions {
203
247
  */
204
248
  export declare function runAgent(opts: RunAgentOptions): AsyncGenerator<AgentEvent>;
205
249
  //#endregion
250
+ //#region src/commands.d.ts
251
+ /**
252
+ * Frozen v0.6.0 contract (spec /home/debian/portfolio/chantier/v06-spec.md
253
+ * §Theme 3): the slash-command registry. Types only — Worker CoreExt
254
+ * implements the registry and dispatch; the TUI consumes `list()` for the
255
+ * palette. `/compact` semantics are preserved exactly (trimmed exact match
256
+ * dispatches; a slash-word that is not a registered command goes to the
257
+ * agent as a normal task).
258
+ */
259
+ /** A registered slash command. `name` excludes the leading slash. */
260
+ interface CommandSpec {
261
+ /** `[a-z0-9-]+` — the palette matches this string after `/`. */
262
+ readonly name: string;
263
+ /** One-line description shown in the palette and /help. */
264
+ readonly description: string;
265
+ /**
266
+ * "action" performs work itself (e.g. /compact); "expand" rewrites the
267
+ * task text (e.g. a skill fills the editor / injects its body).
268
+ */
269
+ readonly kind: "action" | "expand";
270
+ }
271
+ /** Registry surface the TUI palette relies on (implementation in core). */
272
+ interface CommandRegistry {
273
+ register(spec: CommandSpec): void;
274
+ /** Stable order: registration order, built-ins first. */
275
+ list(): readonly CommandSpec[];
276
+ }
277
+ /**
278
+ * Core-agnostic IO surface actions render through. The TUI store satisfies
279
+ * it structurally (its pushItem accepts every TuiItem, including these).
280
+ */
281
+ interface CommandIo {
282
+ /** Abort signal for the current prompt interaction (e.g. manual compaction). */
283
+ readonly signal: AbortSignal;
284
+ /** Appends one transcript line. */
285
+ pushItem(item: {
286
+ readonly kind: "info" | "divider" | "error";
287
+ readonly text: string;
288
+ }): void;
289
+ }
290
+ /** A registered action command: `run` performs the work itself. */
291
+ interface ActionSpec extends CommandSpec {
292
+ readonly kind: "action";
293
+ /** Only exact `/name` dispatches (the /compact rule); args go to the agent verbatim. */
294
+ readonly run?: (io: CommandIo) => void | Promise<void>;
295
+ }
296
+ /** A registered expand command (e.g. a skill): rewrites the task text. */
297
+ interface ExpandSpec extends CommandSpec {
298
+ readonly kind: "expand";
299
+ /**
300
+ * Receives the text after the command name ("" when none) and returns the
301
+ * replacement task. Async bodies (skill files) are awaited by dispatch.
302
+ */
303
+ readonly expand: (args: string) => string | Promise<string>;
304
+ }
305
+ type RegistrableCommand = ActionSpec | ExpandSpec;
306
+ type DispatchResult = {
307
+ readonly kind: "handled";
308
+ } | {
309
+ readonly kind: "expanded";
310
+ readonly task: string;
311
+ } | {
312
+ readonly kind: "not-command";
313
+ };
314
+ interface CommandRegistryV6 extends CommandRegistry {
315
+ register(spec: RegistrableCommand): void;
316
+ /**
317
+ * Slash-command seam for the interactive loop. A trimmed task starting
318
+ * with `/` whose first word exactly matches a registered command
319
+ * dispatches; anything else is `not-command` (the task goes to the agent
320
+ * verbatim). Actions dispatch only on the exact `/name` form; expand
321
+ * commands receive the remaining text as args.
322
+ */
323
+ dispatch(task: string, io: CommandIo): Promise<DispatchResult>;
324
+ }
325
+ export declare function createCommandRegistry(): CommandRegistryV6;
326
+ //#endregion
206
327
  //#region src/compaction.d.ts
207
328
  /**
208
329
  * Rough token estimate: ~4 characters per token for English/code text. Used
@@ -272,6 +393,54 @@ export declare function compactConversation(opts: CompactConversationOptions): P
272
393
  /** Builds the regular user message that carries the summary after compaction. */
273
394
  export declare function compactedSummaryMessage(summary: string): UserMessage;
274
395
  //#endregion
396
+ //#region src/skills.d.ts
397
+ /**
398
+ * Frozen v0.6.0 contract (spec /home/debian/portfolio/chantier/v06-spec.md
399
+ * §Theme 1): agentskills.io SKILL.md types. Types only — Worker CoreExt
400
+ * implements the loader (frontmatter parse, discovery, precedence, trust).
401
+ */
402
+ /** Validated SKILL.md frontmatter (agentskills.io/specification subset). */
403
+ interface SkillFrontmatter {
404
+ /** 1–64 chars, [a-z0-9-], no lead/trail/consecutive `-`. */
405
+ readonly name: string;
406
+ /** 1–1024 chars, what + when. */
407
+ readonly description: string;
408
+ readonly license?: string;
409
+ /** ≤500 chars. */
410
+ readonly compatibility?: string;
411
+ readonly metadata?: Readonly<Record<string, string>>;
412
+ }
413
+ /** A discovered skill; the body is NOT loaded (progressive disclosure). */
414
+ interface Skill {
415
+ readonly name: string;
416
+ readonly description: string;
417
+ /** Absolute directory holding SKILL.md (body + bundled files resolve here). */
418
+ readonly dir: string;
419
+ readonly frontmatter: SkillFrontmatter;
420
+ }
421
+ /**
422
+ * Scan the given roots (each root = a directory of `<name>/SKILL.md`
423
+ * subdirectories). First occurrence per name wins (caller passes roots in
424
+ * precedence order); invalid skills are skipped with a one-line notice.
425
+ */
426
+ type LoadSkills = (roots: readonly string[]) => Promise<readonly Skill[]>;
427
+ /** Tier-2 load: the full markdown body below the frontmatter. */
428
+ type LoadSkillBody = (skill: Skill) => Promise<string>;
429
+ interface LoadSkillsOptions {
430
+ /** Called once per skipped-but-diagnosable skill with a one-line reason. */
431
+ onNotice?: (notice: string) => void;
432
+ }
433
+ /**
434
+ * Scans each root one level deep for `<name>/SKILL.md`. Roots are in
435
+ * precedence order (project before user; the caller decides) and the first
436
+ * occurrence of a name wins. Nonexistent or unreadable roots contribute
437
+ * nothing; unreadable directories are skipped silently; a skill whose
438
+ * frontmatter parses but fails validation is skipped with a one-line notice.
439
+ */
440
+ export declare function loadSkills(roots: readonly string[], options?: LoadSkillsOptions): Promise<readonly Skill[]>;
441
+ /** Tier-2 load: the full markdown body below the frontmatter delimiter. */
442
+ export declare function loadSkillBody(skill: Skill): Promise<string>;
443
+ //#endregion
275
444
  //#region src/context.d.ts
276
445
  /**
277
446
  * Per-model-family prompt profile. Only the identity section is
@@ -291,14 +460,15 @@ interface ModelProfile {
291
460
  export declare function resolveModelProfile(model: string): ModelProfile;
292
461
  /**
293
462
  * Builds the system prompt from fixed, blank-line-joined sections: environment,
294
- * identity (profile-adjustable), doing-tasks rules, denial rule, delegation
295
- * (only when a `task` tool is offered), tool catalog, tool rules, and every
296
- * AGENTS.md from cwd up to the git root last.
463
+ * identity (profile-adjustable), doing-tasks rules, todo usage (when the tool
464
+ * is offered), the tier-1 skill catalog (when skills are passed), denial rule,
465
+ * delegation (only when a `task` tool is offered), tool catalog, tool rules,
466
+ * and every AGENTS.md from cwd up to the git root last.
297
467
  *
298
468
  * `profile` omitted means the default profile: identity and tool rules keep
299
469
  * today's semantics, new sections are purely additive.
300
470
  */
301
- export declare function buildSystemPrompt(cwd: string, tools: ToolDefinition[], profile?: ModelProfile): Promise<string>;
471
+ export declare function buildSystemPrompt(cwd: string, tools: ToolDefinition[], profile?: ModelProfile, skills?: readonly Skill[]): Promise<string>;
302
472
  //#endregion
303
473
  //#region src/session.d.ts
304
474
  export declare const DEFAULT_SESSIONS_ROOT: string;
@@ -419,4 +589,4 @@ export declare function spawnSubagent(input: {
419
589
  prompt: string;
420
590
  }, deps: SubagentDeps, ctx: ToolContext): Promise<SubagentResult>;
421
591
  //#endregion
422
- export type { AgentEvent, AssistantMessage, CompactConversationOptions, CompactSessionOptions, CompactionEntry, CompactionOptions, CompactionOutcome, CompactionResult, CreateSessionOptions, Message, ModelAdapter, ModelEvent, ModelProfile, ResumeSessionOptions, RunAgentOptions, SessionEntry, SessionHeader, SessionStore, StopReason, SubagentDeps, SubagentResult, SystemMessage, TextBlock, ToolCallBlock, ToolContext, ToolDefinition, ToolResultMessage, Usage, UserMessage };
592
+ export type { ActionSpec, AgentEvent, AssistantMessage, CommandIo, CommandRegistryV6, CompactConversationOptions, CompactSessionOptions, CompactionEntry, CompactionOptions, CompactionOutcome, CompactionResult, CreateSessionOptions, CreateTodoTool, DispatchResult, ExpandSpec, LoadSkillBody, LoadSkills, LoadSkillsOptions, Message, ModelAdapter, ModelEvent, ModelProfile, RegistrableCommand, ResumeSessionOptions, RunAgentOptions, SessionEntry, SessionHeader, SessionStore, Skill, SkillFrontmatter, StopReason, SubagentDeps, SubagentResult, SystemMessage, TextBlock, TodoStep, ToolCallBlock, ToolContext, ToolDefinition, ToolResultMessage, Usage, UserMessage };
package/dist/index.mjs CHANGED
@@ -669,6 +669,39 @@ async function executeCall(call, opts, byName) {
669
669
  }
670
670
  }
671
671
  //#endregion
672
+ //#region src/commands.ts
673
+ const COMMAND_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
674
+ const COMMAND_NAME_MAX = 64;
675
+ function createCommandRegistry() {
676
+ const commands = /* @__PURE__ */ new Map();
677
+ return {
678
+ register(spec) {
679
+ if (spec.name.length === 0 || spec.name.length > COMMAND_NAME_MAX || !COMMAND_NAME_PATTERN.test(spec.name)) throw new Error(`Invalid command name "${spec.name}" (1-${COMMAND_NAME_MAX} chars, [a-z0-9-], no lead/trail/double -).`);
680
+ if (commands.has(spec.name)) throw new Error(`Command "${spec.name}" is already registered.`);
681
+ if (spec.kind === "expand" && typeof spec.expand !== "function") throw new Error(`Expand command "${spec.name}" requires an expand function.`);
682
+ commands.set(spec.name, spec);
683
+ },
684
+ list: () => [...commands.values()],
685
+ async dispatch(task, io) {
686
+ const trimmed = task.trim();
687
+ if (!trimmed.startsWith("/")) return { kind: "not-command" };
688
+ const word = trimmed.slice(1).split(/\s+/, 1)[0] ?? "";
689
+ const spec = commands.get(word);
690
+ if (spec === void 0) return { kind: "not-command" };
691
+ if (spec.kind === "action") {
692
+ if (trimmed !== `/${spec.name}`) return { kind: "not-command" };
693
+ await spec.run?.(io);
694
+ return { kind: "handled" };
695
+ }
696
+ const args = trimmed.slice(1 + spec.name.length).trim();
697
+ return {
698
+ kind: "expanded",
699
+ task: await spec.expand(args)
700
+ };
701
+ }
702
+ };
703
+ }
704
+ //#endregion
672
705
  //#region src/context.ts
673
706
  /**
674
707
  * Tool-usage rules the model needs to drive the harness correctly.
@@ -694,6 +727,12 @@ const DOING_TASKS = `# Doing tasks
694
727
  - Comments explain WHY, not WHAT; skip them where the code already says it.
695
728
  - Verify behavioral changes by running the changed path, not by re-reading the edit.
696
729
  - State uncertainty plainly rather than guessing.`;
730
+ /** Present in the prompt only when a tool named "todo" is offered. */
731
+ const TODO_USAGE = `# Todo checklist
732
+
733
+ For any task with more than a couple of steps, lay out the plan with the todo tool FIRST
734
+ (one step in_progress, the rest pending), then keep the checklist current as you work:
735
+ each update replaces the whole list, so re-send every row with its new status.`;
697
736
  const DENIALS = `# Permission denials
698
737
 
699
738
  A denied tool call is final for that exact invocation: never retry the identical
@@ -736,27 +775,52 @@ function resolveModelProfile(model) {
736
775
  }
737
776
  /**
738
777
  * Builds the system prompt from fixed, blank-line-joined sections: environment,
739
- * identity (profile-adjustable), doing-tasks rules, denial rule, delegation
740
- * (only when a `task` tool is offered), tool catalog, tool rules, and every
741
- * AGENTS.md from cwd up to the git root last.
778
+ * identity (profile-adjustable), doing-tasks rules, todo usage (when the tool
779
+ * is offered), the tier-1 skill catalog (when skills are passed), denial rule,
780
+ * delegation (only when a `task` tool is offered), tool catalog, tool rules,
781
+ * and every AGENTS.md from cwd up to the git root last.
742
782
  *
743
783
  * `profile` omitted means the default profile: identity and tool rules keep
744
784
  * today's semantics, new sections are purely additive.
745
785
  */
746
- async function buildSystemPrompt(cwd, tools, profile = DEFAULT_PROFILE) {
786
+ async function buildSystemPrompt(cwd, tools, profile = DEFAULT_PROFILE, skills = []) {
747
787
  const toolCatalog = tools.map((tool) => `- ${tool.name}${tool.readOnly ? " (read-only)" : ""}: ${tool.description.split(".")[0]}.`).join("\n");
748
788
  const sections = [
749
789
  await environmentSection(cwd),
750
790
  profile.identity ?? IDENTITY,
751
- DOING_TASKS,
752
- DENIALS
791
+ DOING_TASKS
753
792
  ];
793
+ if (tools.some((tool) => tool.name === "todo")) sections.push(TODO_USAGE);
794
+ if (skills.length > 0) sections.push(skillsSection(skills));
795
+ sections.push(DENIALS);
754
796
  if (tools.some((tool) => tool.name === "task")) sections.push(DELEGATION);
755
797
  sections.push(`# Available tools\n\n${toolCatalog}`, TOOL_RULES);
756
798
  const agentsDocs = await collectAgentsMd(cwd);
757
799
  if (agentsDocs.length > 0) sections.push(`# Project instructions (AGENTS.md)\n\n${agentsDocs.join("\n\n")}`);
758
800
  return sections.join("\n\n");
759
801
  }
802
+ /** Tier-1 skill catalog: `name — description` rows, 200-char descriptions, ~2000-char cap. */
803
+ const SKILL_DESCRIPTION_SHOWN = 200;
804
+ const SKILL_CATALOG_CAP = 2e3;
805
+ const SKILLS_INTRO = "# Skills\n\nSlash commands available (/name [args]) — invoking one injects the skill's full body into the task:";
806
+ function skillsSection(skills) {
807
+ const kept = [];
808
+ let dropped = 0;
809
+ const render = () => {
810
+ const parts = [...kept];
811
+ if (dropped > 0) parts.push(`(+${dropped} more)`);
812
+ return [SKILLS_INTRO, ...parts].join("\n");
813
+ };
814
+ for (const skill of skills) {
815
+ const description = skill.description;
816
+ kept.push(`- ${skill.name} — ${description.length > SKILL_DESCRIPTION_SHOWN ? `${description.slice(0, SKILL_DESCRIPTION_SHOWN)}...` : description}`);
817
+ while (kept.length > 1 && render().length > SKILL_CATALOG_CAP) {
818
+ kept.shift();
819
+ dropped += 1;
820
+ }
821
+ }
822
+ return render();
823
+ }
760
824
  /** Environment facts first; the branch line is omitted rather than fabricated. */
761
825
  async function environmentSection(cwd) {
762
826
  const branch = await resolveBranch(await findGitRoot(path.resolve(cwd)));
@@ -814,6 +878,173 @@ async function findGitRoot(dir) {
814
878
  }
815
879
  }
816
880
  //#endregion
881
+ //#region src/skills.ts
882
+ /** agentskills.io name rules: 1–64 chars, [a-z0-9-], no lead/trail/consecutive `-`. */
883
+ const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
884
+ const NAME_MAX = 64;
885
+ const DESCRIPTION_MAX = 1024;
886
+ const COMPATIBILITY_MAX = 500;
887
+ /**
888
+ * Scans each root one level deep for `<name>/SKILL.md`. Roots are in
889
+ * precedence order (project before user; the caller decides) and the first
890
+ * occurrence of a name wins. Nonexistent or unreadable roots contribute
891
+ * nothing; unreadable directories are skipped silently; a skill whose
892
+ * frontmatter parses but fails validation is skipped with a one-line notice.
893
+ */
894
+ async function loadSkills(roots, options = {}) {
895
+ const onNotice = options.onNotice;
896
+ const byName = /* @__PURE__ */ new Map();
897
+ for (const root of roots) {
898
+ let entries;
899
+ try {
900
+ entries = await readdir(root, { withFileTypes: true });
901
+ } catch {
902
+ continue;
903
+ }
904
+ for (const entry of entries) {
905
+ if (!entry.isDirectory()) continue;
906
+ const dir = path.join(root, entry.name);
907
+ let read;
908
+ try {
909
+ read = await readSkill(dir, entry.name, onNotice);
910
+ } catch {
911
+ continue;
912
+ }
913
+ if ("reason" in read) {
914
+ onNotice?.(`skill '${entry.name}' skipped: ${read.reason}`);
915
+ continue;
916
+ }
917
+ if (byName.has(read.skill.name)) {
918
+ onNotice?.(`skill '${read.skill.name}' from ${dir} skipped: duplicate name (first in precedence wins)`);
919
+ continue;
920
+ }
921
+ byName.set(read.skill.name, read.skill);
922
+ }
923
+ }
924
+ return [...byName.values()];
925
+ }
926
+ /** Tier-2 load: the full markdown body below the frontmatter delimiter. */
927
+ async function loadSkillBody(skill) {
928
+ const doc = parseFrontmatter((await readFile(path.join(skill.dir, "SKILL.md"), "utf8")).replaceAll("\r\n", "\n"));
929
+ if (doc === void 0) return "";
930
+ return doc.body.trim();
931
+ }
932
+ /** Reads and validates one `<dir>/SKILL.md`; a validation failure skips it. */
933
+ async function readSkill(dir, dirName, onNotice) {
934
+ let text;
935
+ try {
936
+ text = (await readFile(path.join(dir, "SKILL.md"), "utf8")).replaceAll("\r\n", "\n");
937
+ } catch {
938
+ return { reason: "no readable SKILL.md" };
939
+ }
940
+ const doc = parseFrontmatter(text);
941
+ if (doc === void 0) return { reason: "missing or malformed frontmatter (expects --- delimited YAML)" };
942
+ const name = doc.fields.name;
943
+ if (name === void 0 || name.length === 0) return { reason: "missing name" };
944
+ if (name.length > NAME_MAX || !NAME_PATTERN.test(name)) return { reason: `invalid name "${name}" (1-${NAME_MAX} chars, [a-z0-9-], no lead/trail/double -)` };
945
+ const description = doc.fields.description;
946
+ if (description === void 0 || description.length === 0) return { reason: "missing description" };
947
+ if (description.length > DESCRIPTION_MAX) return { reason: `description exceeds ${DESCRIPTION_MAX} chars (${description.length})` };
948
+ const compatibility = doc.fields.compatibility;
949
+ if (compatibility !== void 0 && compatibility.length > COMPATIBILITY_MAX) return { reason: `compatibility exceeds ${COMPATIBILITY_MAX} chars (${compatibility.length})` };
950
+ if (name !== dirName) onNotice?.(`skill '${dirName}': frontmatter name '${name}' ignored (name ≠ dir)`);
951
+ return { skill: {
952
+ name: dirName,
953
+ description,
954
+ dir,
955
+ frontmatter: {
956
+ name,
957
+ description,
958
+ ...doc.fields.license === void 0 ? {} : { license: doc.fields.license },
959
+ ...compatibility === void 0 ? {} : { compatibility },
960
+ ...Object.keys(doc.metadata).length === 0 ? {} : { metadata: doc.metadata }
961
+ }
962
+ } };
963
+ }
964
+ /**
965
+ * Line-based YAML frontmatter for the SKILL.md subset: `key: value` pairs
966
+ * split on the FIRST colon per line, so unquoted colons inside values parse
967
+ * (the gemini-cli bug); surrounding single/double quotes are stripped. Only
968
+ * one nesting level is supported, under `metadata:`. Returns undefined when
969
+ * the delimiters are missing or a line is not a plain `key: value` pair.
970
+ */
971
+ function parseFrontmatter(text) {
972
+ const lines = text.split("\n");
973
+ if ((lines[0] ?? "").trimEnd() !== "---") return void 0;
974
+ const fields = {};
975
+ const metadata = {};
976
+ let inMetadata = false;
977
+ let bodyStart = -1;
978
+ let current;
979
+ const flush = () => {
980
+ if (current === void 0) return;
981
+ const folded = current.mode === void 0 ? current.value : current.mode === "fold" ? current.value.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).join(" ") : current.value.replace(/^\n+/, "");
982
+ current.map[current.key] = folded.trim();
983
+ current = void 0;
984
+ };
985
+ for (let i = 1; i < lines.length; i += 1) {
986
+ const line = lines[i] ?? "";
987
+ if (line.trimEnd() === "---") {
988
+ flush();
989
+ bodyStart = i + 1;
990
+ break;
991
+ }
992
+ if (line.trim().length === 0) {
993
+ if (current !== void 0 && current.mode !== void 0) current.value += "\n";
994
+ continue;
995
+ }
996
+ if (/^\s/.test(line)) {
997
+ if (current !== void 0 && current.mode !== void 0) {
998
+ current.value += `\n${line.trim()}`;
999
+ continue;
1000
+ }
1001
+ if (inMetadata) {
1002
+ const entry = splitKeyValue(line.trim());
1003
+ if (entry === void 0) return void 0;
1004
+ metadata[entry[0]] = entry[1];
1005
+ continue;
1006
+ }
1007
+ return;
1008
+ }
1009
+ flush();
1010
+ const pair = splitKeyValue(line);
1011
+ if (pair === void 0) return void 0;
1012
+ const indicator = /^[|>][+-]?\d*$/.exec(pair[1]);
1013
+ if (indicator === null) {
1014
+ inMetadata = pair[0] === "metadata" && pair[1].length === 0;
1015
+ if (!inMetadata) fields[pair[0]] = pair[1];
1016
+ continue;
1017
+ }
1018
+ inMetadata = false;
1019
+ current = {
1020
+ map: fields,
1021
+ key: pair[0],
1022
+ mode: indicator[0].startsWith("|") ? "keep" : "fold",
1023
+ value: ""
1024
+ };
1025
+ }
1026
+ if (bodyStart === -1) return void 0;
1027
+ return {
1028
+ fields,
1029
+ metadata,
1030
+ body: lines.slice(bodyStart).join("\n")
1031
+ };
1032
+ }
1033
+ /**
1034
+ * Splits on the FIRST colon (values may contain colons), trims both sides,
1035
+ * and strips one pair of surrounding single/double quotes. Undefined for a
1036
+ * missing colon or an empty key.
1037
+ */
1038
+ function splitKeyValue(line) {
1039
+ const colon = line.indexOf(":");
1040
+ if (colon <= 0) return void 0;
1041
+ const key = line.slice(0, colon).trim();
1042
+ if (key.length === 0) return void 0;
1043
+ let value = line.slice(colon + 1).trim();
1044
+ if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
1045
+ return [key, value];
1046
+ }
1047
+ //#endregion
817
1048
  //#region src/subagent.ts
818
1049
  /** The returned summary is capped at 50 KiB; the full transcript stays in the child session. */
819
1050
  const SUMMARY_CAP_CHARS = 51200;
@@ -880,4 +1111,92 @@ async function spawnSubagent(input, deps, ctx) {
880
1111
  };
881
1112
  }
882
1113
  //#endregion
883
- export { COMPACTED_MARKER, COMPACT_PROMPT, DEFAULT_COMPACTION_KEEP_RECENT, DEFAULT_COMPACTION_RESERVE, DEFAULT_SESSIONS_ROOT, alignedMessageOrdinals, buildSystemPrompt, compactConversation, compactSession, compactedSummaryMessage, createSessionStore, estimateMessageTokens, estimateTokens, loadNewestSessionId, looksLikeContextOverflow, resolveModelProfile, resumeSessionStore, runAgent, serializeConversation, sessionView, sessionsDirFor, shouldCompact, spawnSubagent };
1114
+ //#region src/todo.ts
1115
+ /** Valid TodoStep status values, in checklist order. */
1116
+ const STATUSES = [
1117
+ "pending",
1118
+ "in_progress",
1119
+ "completed"
1120
+ ];
1121
+ /**
1122
+ * The `todo` tool: whole-list checklist replace with the
1123
+ * exactly-one-in_progress invariant (extras normalize to pending, first
1124
+ * keeps the slot). readOnly: the checklist mutates nothing on disk.
1125
+ */
1126
+ function createTodoTool(deps) {
1127
+ return {
1128
+ name: "todo",
1129
+ description: "Track a multi-step plan: replaces the whole checklist with `items` ({content, status: pending|in_progress|completed}). Exactly one step stays in_progress (extras normalize to pending); lay out the plan first and keep it current as you work. An empty list clears the checklist.",
1130
+ inputSchema: {
1131
+ type: "object",
1132
+ properties: { items: {
1133
+ type: "array",
1134
+ description: "The full checklist; replaces the previous list wholesale.",
1135
+ items: {
1136
+ type: "object",
1137
+ properties: {
1138
+ content: {
1139
+ type: "string",
1140
+ description: "One imperative checklist row."
1141
+ },
1142
+ status: {
1143
+ type: "string",
1144
+ enum: [...STATUSES]
1145
+ }
1146
+ },
1147
+ required: ["content", "status"]
1148
+ }
1149
+ } },
1150
+ required: ["items"]
1151
+ },
1152
+ readOnly: true,
1153
+ handler: async (input) => {
1154
+ const raw = input.items;
1155
+ if (!Array.isArray(raw)) return "Error: todo requires an `items` array of {content, status} rows.";
1156
+ const steps = normalizeTodoSteps(raw);
1157
+ if (steps.length === 0) {
1158
+ deps.onTodo([]);
1159
+ return "todo: list cleared";
1160
+ }
1161
+ deps.onTodo(steps);
1162
+ return summarizeTodoSteps(steps);
1163
+ }
1164
+ };
1165
+ }
1166
+ /**
1167
+ * Lenient row normalization: drops non-object/blank rows, coerces unknown
1168
+ * statuses to pending, and enforces exactly one in_progress (the first keeps
1169
+ * the slot; extras become pending).
1170
+ */
1171
+ function normalizeTodoSteps(raw) {
1172
+ const steps = [];
1173
+ let inProgressSeen = false;
1174
+ for (const row of raw) {
1175
+ if (typeof row !== "object" || row === null) continue;
1176
+ const record = row;
1177
+ const content = typeof record.content === "string" ? record.content.trim() : "";
1178
+ if (content.length === 0) continue;
1179
+ const status = STATUSES.find((candidate) => candidate === record.status) ?? "pending";
1180
+ if (status === "in_progress" && inProgressSeen) {
1181
+ steps.push({
1182
+ content,
1183
+ status: "pending"
1184
+ });
1185
+ continue;
1186
+ }
1187
+ if (status === "in_progress") inProgressSeen = true;
1188
+ steps.push({
1189
+ content,
1190
+ status
1191
+ });
1192
+ }
1193
+ return steps;
1194
+ }
1195
+ /** One-line summary the model sees: counts per status in fixed order. */
1196
+ function summarizeTodoSteps(steps) {
1197
+ const done = steps.filter((step) => step.status === "completed").length;
1198
+ const active = steps.filter((step) => step.status === "in_progress").length;
1199
+ return `todo: ${done} done, ${active} in progress, ${steps.length - done - active} pending`;
1200
+ }
1201
+ //#endregion
1202
+ export { COMPACTED_MARKER, COMPACT_PROMPT, DEFAULT_COMPACTION_KEEP_RECENT, DEFAULT_COMPACTION_RESERVE, DEFAULT_SESSIONS_ROOT, alignedMessageOrdinals, buildSystemPrompt, compactConversation, compactSession, compactedSummaryMessage, createCommandRegistry, createSessionStore, createTodoTool, estimateMessageTokens, estimateTokens, loadNewestSessionId, loadSkillBody, loadSkills, looksLikeContextOverflow, normalizeTodoSteps, resolveModelProfile, resumeSessionStore, runAgent, serializeConversation, sessionView, sessionsDirFor, shouldCompact, spawnSubagent, summarizeTodoSteps };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chantier/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Agent loop, session store, and model-adapter seam for the chantier coding agent",
@@ -36,7 +36,7 @@
36
36
  "prepublishOnly": "npm run build"
37
37
  },
38
38
  "dependencies": {
39
- "@chantier/permissions": "^0.5.0"
39
+ "@chantier/permissions": "^0.6.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "tsdown": "0.23.0"