@chantier/core 0.3.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 +243 -4
- package/dist/index.mjs +487 -14
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
|
-
import { ApprovalDetail, ApprovalSink, PermissionEngine } from "@chantier/permissions";
|
|
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,9 +393,82 @@ 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
|
-
/**
|
|
277
|
-
|
|
445
|
+
/**
|
|
446
|
+
* Per-model-family prompt profile. Only the identity section is
|
|
447
|
+
* profile-specific; every other section is shared, fixed-order text.
|
|
448
|
+
*/
|
|
449
|
+
interface ModelProfile {
|
|
450
|
+
/** Family name, for diagnostics and tests. */
|
|
451
|
+
name: string;
|
|
452
|
+
/** Replaces the default identity paragraph when present. */
|
|
453
|
+
identity?: string;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Profile registry: family prefix match on the model id — `glm-` and `claude-`
|
|
457
|
+
* resolve their profiles, anything else the default. Prefixes match the leading
|
|
458
|
+
* id so suffixed tags (e.g. `glm-5.3-flash:cloud`) still resolve to the family.
|
|
459
|
+
*/
|
|
460
|
+
export declare function resolveModelProfile(model: string): ModelProfile;
|
|
461
|
+
/**
|
|
462
|
+
* Builds the system prompt from fixed, blank-line-joined sections: environment,
|
|
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.
|
|
467
|
+
*
|
|
468
|
+
* `profile` omitted means the default profile: identity and tool rules keep
|
|
469
|
+
* today's semantics, new sections are purely additive.
|
|
470
|
+
*/
|
|
471
|
+
export declare function buildSystemPrompt(cwd: string, tools: ToolDefinition[], profile?: ModelProfile, skills?: readonly Skill[]): Promise<string>;
|
|
278
472
|
//#endregion
|
|
279
473
|
//#region src/session.d.ts
|
|
280
474
|
export declare const DEFAULT_SESSIONS_ROOT: string;
|
|
@@ -350,4 +544,49 @@ export declare function compactSession(opts: CompactSessionOptions): Promise<Com
|
|
|
350
544
|
/** `--continue`: newest session file (by mtime) in this cwd's session dir. */
|
|
351
545
|
export declare function loadNewestSessionId(cwd: string, sessionsRoot?: string): Promise<string | null>;
|
|
352
546
|
//#endregion
|
|
353
|
-
|
|
547
|
+
//#region src/subagent.d.ts
|
|
548
|
+
interface SubagentDeps {
|
|
549
|
+
/** The parent's model adapter; the child runs on the same provider/model seam. */
|
|
550
|
+
adapter: ModelAdapter;
|
|
551
|
+
/** The parent's settings rules, verbatim: deny rules bind the child, allow rules pre-approve. */
|
|
552
|
+
rules: PermissionRules;
|
|
553
|
+
/** The parent's approval sink: a child ask surfaces in the parent's approval UI. */
|
|
554
|
+
sink: ApprovalSink;
|
|
555
|
+
/** Provider label recorded in the child session header. */
|
|
556
|
+
provider: string;
|
|
557
|
+
model: string;
|
|
558
|
+
/** Child turn cap; defaults to 25. */
|
|
559
|
+
maxTurns?: number;
|
|
560
|
+
/**
|
|
561
|
+
* Declared model context window; opts the child into compaction (same
|
|
562
|
+
* contract as runAgent). Undefined keeps compaction off for the child.
|
|
563
|
+
*/
|
|
564
|
+
contextWindow?: number;
|
|
565
|
+
/**
|
|
566
|
+
* The child's toolset. Phase A depth cap: the child toolset is the builtin
|
|
567
|
+
* set, which does not contain `task`, so a child cannot recurse by
|
|
568
|
+
* construction.
|
|
569
|
+
*/
|
|
570
|
+
tools: ToolDefinition[];
|
|
571
|
+
}
|
|
572
|
+
interface SubagentResult {
|
|
573
|
+
/** The child's final summary text (50 KiB cap, see spawnSubagent). */
|
|
574
|
+
text: string;
|
|
575
|
+
/** The child's own session id, so the parent can reference/inspect the transcript. */
|
|
576
|
+
sessionId: string;
|
|
577
|
+
truncated: boolean;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Runs one child agent loop for a single `task` call and returns its final
|
|
581
|
+
* summary. Isolation is structural: a fresh permission engine per spawn (the
|
|
582
|
+
* parent's remembered grants never inherit; deny rules still win first), a
|
|
583
|
+
* fresh session store (the child transcript is its own JSONL file), and a
|
|
584
|
+
* task-free toolset. Child asks surface through the parent's sink, each one a
|
|
585
|
+
* fresh decision. `ctx.signal` propagates: on abort the child loop stops and
|
|
586
|
+
* the error surfaces as the parent's tool result.
|
|
587
|
+
*/
|
|
588
|
+
export declare function spawnSubagent(input: {
|
|
589
|
+
prompt: string;
|
|
590
|
+
}, deps: SubagentDeps, ctx: ToolContext): Promise<SubagentResult>;
|
|
591
|
+
//#endregion
|
|
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
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { appendFile, mkdir, readFile, readdir, stat } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { createPermissionEngine, createRememberingEngine } from "@chantier/permissions";
|
|
5
6
|
//#region src/compaction.ts
|
|
6
7
|
/**
|
|
7
8
|
* Rough token estimate: ~4 characters per token for English/code text. Used
|
|
@@ -195,8 +196,11 @@ function sessionsDirFor(sessionsRoot, cwd) {
|
|
|
195
196
|
const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
|
|
196
197
|
return path.join(sessionsRoot, hash);
|
|
197
198
|
}
|
|
199
|
+
let sequence = 0;
|
|
198
200
|
function newSessionId() {
|
|
199
|
-
|
|
201
|
+
sequence = (sequence + 1) % 1679616;
|
|
202
|
+
const seq = sequence.toString(36).padStart(4, "0");
|
|
203
|
+
return `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${seq}${randomBytes(2).toString("hex")}`;
|
|
200
204
|
}
|
|
201
205
|
function buildStore(file, dir, id, selfId) {
|
|
202
206
|
const readEntries = async (loadId) => {
|
|
@@ -665,10 +669,44 @@ async function executeCall(call, opts, byName) {
|
|
|
665
669
|
}
|
|
666
670
|
}
|
|
667
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
|
|
668
705
|
//#region src/context.ts
|
|
669
|
-
/**
|
|
670
|
-
|
|
671
|
-
|
|
706
|
+
/**
|
|
707
|
+
* Tool-usage rules the model needs to drive the harness correctly.
|
|
708
|
+
*/
|
|
709
|
+
const TOOL_RULES = `# Tool usage rules
|
|
672
710
|
|
|
673
711
|
- Paths are relative to the project cwd unless you pass an absolute path deliberately.
|
|
674
712
|
- Protected paths (.env, .env.*, *.pem, id_rsa*, ~/.ssh) are refused by the harness; do not retry them.
|
|
@@ -680,17 +718,131 @@ const TOOL_RULES = `
|
|
|
680
718
|
const IDENTITY = `You are chantier, a terminal coding agent. You work inside the user's project directory:
|
|
681
719
|
read before you write, make surgical edits, and explain what you did in one short paragraph at the end
|
|
682
720
|
of a task. When a mutation is denied, state it plainly and continue with what is allowed.`;
|
|
683
|
-
/**
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
721
|
+
/** Doing-tasks discipline shared by every model family. */
|
|
722
|
+
const DOING_TASKS = `# Doing tasks
|
|
723
|
+
|
|
724
|
+
- Prefer editing existing files over creating new ones.
|
|
725
|
+
- Do exactly what was asked: no scope creep — no extra retries, telemetry, or
|
|
726
|
+
abstraction "while you're at it"; the real ask only.
|
|
727
|
+
- Comments explain WHY, not WHAT; skip them where the code already says it.
|
|
728
|
+
- Verify behavioral changes by running the changed path, not by re-reading the edit.
|
|
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
|
|
689
732
|
|
|
690
|
-
|
|
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.`;
|
|
736
|
+
const DENIALS = `# Permission denials
|
|
691
737
|
|
|
692
|
-
|
|
693
|
-
|
|
738
|
+
A denied tool call is final for that exact invocation: never retry the identical
|
|
739
|
+
denied call. Adjust the arguments or switch the approach, or continue with what
|
|
740
|
+
is allowed, and state plainly that the action was not permitted.`;
|
|
741
|
+
/** Present in the prompt only when a tool named "task" is offered. */
|
|
742
|
+
const DELEGATION = `# Delegating subtasks
|
|
743
|
+
|
|
744
|
+
- Delegate self-contained subtasks with the full context the child needs in the
|
|
745
|
+
prompt (paths, constraints, acceptance); the child returns a final summary.
|
|
746
|
+
- Scale the prompt effort to the subtask: brief for mechanical work, detailed
|
|
747
|
+
for design work.
|
|
748
|
+
- Do not delegate single sequential edits you can do directly.`;
|
|
749
|
+
/** Default semantics: today's identity, unchanged. */
|
|
750
|
+
const DEFAULT_PROFILE = { name: "default" };
|
|
751
|
+
const GLM_PROFILE = {
|
|
752
|
+
name: "glm",
|
|
753
|
+
identity: `You are chantier, a terminal coding agent working directly in the user's project directory.
|
|
754
|
+
Style for this model family: keep prose terse; tool arguments are strict JSON
|
|
755
|
+
objects with no trailing commentary inside tool calls; finish each task with one
|
|
756
|
+
short final paragraph and nothing more. When a tool call is denied or fails,
|
|
757
|
+
adjust the arguments or change the approach — never loop identical retries.`
|
|
758
|
+
};
|
|
759
|
+
const CLAUDE_PROFILE = {
|
|
760
|
+
name: "claude",
|
|
761
|
+
identity: `You are chantier, a terminal coding agent working directly in the user's project directory.
|
|
762
|
+
Read before you write; strongly prefer editing existing files over creating new
|
|
763
|
+
ones; do exactly the task asked with no scope creep; when something is unclear,
|
|
764
|
+
say so plainly instead of guessing.`
|
|
765
|
+
};
|
|
766
|
+
/**
|
|
767
|
+
* Profile registry: family prefix match on the model id — `glm-` and `claude-`
|
|
768
|
+
* resolve their profiles, anything else the default. Prefixes match the leading
|
|
769
|
+
* id so suffixed tags (e.g. `glm-5.3-flash:cloud`) still resolve to the family.
|
|
770
|
+
*/
|
|
771
|
+
function resolveModelProfile(model) {
|
|
772
|
+
if (model.startsWith("glm-")) return GLM_PROFILE;
|
|
773
|
+
if (model.startsWith("claude-")) return CLAUDE_PROFILE;
|
|
774
|
+
return DEFAULT_PROFILE;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Builds the system prompt from fixed, blank-line-joined sections: environment,
|
|
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.
|
|
782
|
+
*
|
|
783
|
+
* `profile` omitted means the default profile: identity and tool rules keep
|
|
784
|
+
* today's semantics, new sections are purely additive.
|
|
785
|
+
*/
|
|
786
|
+
async function buildSystemPrompt(cwd, tools, profile = DEFAULT_PROFILE, skills = []) {
|
|
787
|
+
const toolCatalog = tools.map((tool) => `- ${tool.name}${tool.readOnly ? " (read-only)" : ""}: ${tool.description.split(".")[0]}.`).join("\n");
|
|
788
|
+
const sections = [
|
|
789
|
+
await environmentSection(cwd),
|
|
790
|
+
profile.identity ?? IDENTITY,
|
|
791
|
+
DOING_TASKS
|
|
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);
|
|
796
|
+
if (tools.some((tool) => tool.name === "task")) sections.push(DELEGATION);
|
|
797
|
+
sections.push(`# Available tools\n\n${toolCatalog}`, TOOL_RULES);
|
|
798
|
+
const agentsDocs = await collectAgentsMd(cwd);
|
|
799
|
+
if (agentsDocs.length > 0) sections.push(`# Project instructions (AGENTS.md)\n\n${agentsDocs.join("\n\n")}`);
|
|
800
|
+
return sections.join("\n\n");
|
|
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
|
+
}
|
|
824
|
+
/** Environment facts first; the branch line is omitted rather than fabricated. */
|
|
825
|
+
async function environmentSection(cwd) {
|
|
826
|
+
const branch = await resolveBranch(await findGitRoot(path.resolve(cwd)));
|
|
827
|
+
const lines = [
|
|
828
|
+
"# Environment",
|
|
829
|
+
"",
|
|
830
|
+
`- cwd: ${cwd}`,
|
|
831
|
+
`- date: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)} (UTC)`
|
|
832
|
+
];
|
|
833
|
+
if (branch !== null) lines.push(`- branch: ${branch}`);
|
|
834
|
+
return lines.join("\n");
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Branch read from the filesystem, no git process spawned: `.git/HEAD` with
|
|
838
|
+
* `ref: refs/heads/<name>` yields the name; a raw sha means detached. Missing
|
|
839
|
+
* or unreadable HEAD → null → the caller omits the branch line.
|
|
840
|
+
*/
|
|
841
|
+
async function resolveBranch(gitRoot) {
|
|
842
|
+
if (gitRoot === null) return null;
|
|
843
|
+
const head = await readFile(path.join(gitRoot, ".git", "HEAD"), "utf8").catch(() => null);
|
|
844
|
+
if (head === null) return null;
|
|
845
|
+
return /^ref: refs\/heads\/(.+)$/.exec(head.trim())?.[1] ?? "(detached)";
|
|
694
846
|
}
|
|
695
847
|
/**
|
|
696
848
|
* AGENTS.md discovery: walk from cwd to the git root. Files from ancestor
|
|
@@ -726,4 +878,325 @@ async function findGitRoot(dir) {
|
|
|
726
878
|
}
|
|
727
879
|
}
|
|
728
880
|
//#endregion
|
|
729
|
-
|
|
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
|
|
1048
|
+
//#region src/subagent.ts
|
|
1049
|
+
/** The returned summary is capped at 50 KiB; the full transcript stays in the child session. */
|
|
1050
|
+
const SUMMARY_CAP_CHARS = 51200;
|
|
1051
|
+
/** Children get their own (smaller) turn budget; the parent's cap says nothing about subtasks. */
|
|
1052
|
+
const DEFAULT_SUBAGENT_TURNS = 25;
|
|
1053
|
+
/**
|
|
1054
|
+
* Plain-string appendix for the child system prompt. Kept out of the composer
|
|
1055
|
+
* on purpose: the subagent role is a property of delegation, not of the
|
|
1056
|
+
* project's base prompt.
|
|
1057
|
+
*/
|
|
1058
|
+
const SUBAGENT_ROLE_APPENDIX = "\n\n# Subagent role\n\nYou are a subagent spawned by the parent agent to complete one self-contained task. Work within the prompt you were given, use the allowed tools, and end with a single short summary paragraph of what you did and found. Do not ask the user questions; decide and act.";
|
|
1059
|
+
/**
|
|
1060
|
+
* Runs one child agent loop for a single `task` call and returns its final
|
|
1061
|
+
* summary. Isolation is structural: a fresh permission engine per spawn (the
|
|
1062
|
+
* parent's remembered grants never inherit; deny rules still win first), a
|
|
1063
|
+
* fresh session store (the child transcript is its own JSONL file), and a
|
|
1064
|
+
* task-free toolset. Child asks surface through the parent's sink, each one a
|
|
1065
|
+
* fresh decision. `ctx.signal` propagates: on abort the child loop stops and
|
|
1066
|
+
* the error surfaces as the parent's tool result.
|
|
1067
|
+
*/
|
|
1068
|
+
async function spawnSubagent(input, deps, ctx) {
|
|
1069
|
+
const childPermission = createRememberingEngine(createPermissionEngine(deps.rules));
|
|
1070
|
+
const childSession = await createSessionStore({
|
|
1071
|
+
cwd: ctx.cwd,
|
|
1072
|
+
provider: deps.provider,
|
|
1073
|
+
model: deps.model
|
|
1074
|
+
});
|
|
1075
|
+
const system = `${await buildSystemPrompt(ctx.cwd, deps.tools)}${SUBAGENT_ROLE_APPENDIX}`;
|
|
1076
|
+
const userMessage = {
|
|
1077
|
+
role: "user",
|
|
1078
|
+
content: [{
|
|
1079
|
+
type: "text",
|
|
1080
|
+
text: input.prompt
|
|
1081
|
+
}]
|
|
1082
|
+
};
|
|
1083
|
+
await childSession.append({
|
|
1084
|
+
type: "message",
|
|
1085
|
+
message: userMessage
|
|
1086
|
+
});
|
|
1087
|
+
let text = "";
|
|
1088
|
+
for await (const event of runAgent({
|
|
1089
|
+
adapter: deps.adapter,
|
|
1090
|
+
tools: deps.tools,
|
|
1091
|
+
permission: childPermission,
|
|
1092
|
+
sink: deps.sink,
|
|
1093
|
+
session: childSession,
|
|
1094
|
+
cwd: ctx.cwd,
|
|
1095
|
+
system,
|
|
1096
|
+
messages: [userMessage],
|
|
1097
|
+
maxTurns: deps.maxTurns ?? DEFAULT_SUBAGENT_TURNS,
|
|
1098
|
+
contextWindow: deps.contextWindow,
|
|
1099
|
+
compaction: deps.contextWindow === void 0 ? void 0 : { enabled: true },
|
|
1100
|
+
signal: ctx.signal
|
|
1101
|
+
})) if (event.type === "result") text = event.text;
|
|
1102
|
+
if (text.length <= SUMMARY_CAP_CHARS) return {
|
|
1103
|
+
text,
|
|
1104
|
+
sessionId: childSession.id,
|
|
1105
|
+
truncated: false
|
|
1106
|
+
};
|
|
1107
|
+
return {
|
|
1108
|
+
text: `${text.slice(0, SUMMARY_CAP_CHARS)}\n[truncated: subagent summary exceeded 50 KiB cap; full transcript session id: ${childSession.id}]`,
|
|
1109
|
+
sessionId: childSession.id,
|
|
1110
|
+
truncated: true
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
//#endregion
|
|
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.
|
|
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.
|
|
39
|
+
"@chantier/permissions": "^0.6.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"tsdown": "0.23.0"
|