@crouton-kit/crouter 0.3.43 → 0.3.45
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/builtin-memory/05-kinds/advisor/00-base.md +6 -0
- package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +26 -0
- package/dist/builtin-memory/05-kinds/design/00-base.md +8 -1
- package/dist/builtin-memory/05-kinds/developer/00-base.md +7 -0
- package/dist/builtin-memory/05-kinds/explore/00-base.md +7 -0
- package/dist/builtin-memory/05-kinds/general/00-base.md +5 -1
- package/dist/builtin-memory/05-kinds/plan/00-base.md +8 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +8 -4
- package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +5 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +6 -0
- package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +5 -1
- package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +6 -1
- package/dist/builtin-memory/05-kinds/product/00-base.md +4 -0
- package/dist/builtin-memory/05-kinds/review/00-base.md +6 -0
- package/dist/builtin-memory/05-kinds/spec/00-base.md +3 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/memory-slash-commands.test.ts +184 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +166 -0
- package/dist/clients/attach/attach-cmd.js +154 -154
- package/dist/commands/memory/delete.d.ts +1 -0
- package/dist/commands/memory/delete.js +77 -0
- package/dist/commands/memory/lint.js +10 -0
- package/dist/commands/memory/shared.js +1 -0
- package/dist/commands/memory/write.js +7 -1
- package/dist/commands/memory.js +3 -2
- package/dist/commands/node.js +46 -11
- package/dist/core/__tests__/review-model-floor.test.js +16 -0
- package/dist/core/memory-resolver.d.ts +6 -0
- package/dist/core/memory-resolver.js +1 -1
- package/dist/core/runtime/broker.js +13 -5
- package/dist/core/runtime/launch.d.ts +1 -0
- package/dist/core/runtime/launch.js +4 -3
- package/dist/core/runtime/spawn.d.ts +6 -3
- package/dist/core/runtime/stop-guard.js +19 -0
- package/dist/core/runtime/structured-output.d.ts +12 -0
- package/dist/core/runtime/structured-output.js +90 -0
- package/dist/core/substrate/schema.d.ts +15 -0
- package/dist/core/substrate/schema.js +9 -0
- package/dist/pi-extensions/canvas-structured-output.d.ts +9 -0
- package/dist/pi-extensions/canvas-structured-output.js +150 -0
- package/dist/prompts/review.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const deleteLeaf: import("../../core/command.js").LeafDef;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmSync, rmdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
3
|
+
import { defineLeaf } from '../../core/command.js';
|
|
4
|
+
import { CrtrError, notFound, usage } from '../../core/errors.js';
|
|
5
|
+
import { resolveMemoryDoc } from '../../core/memory-resolver.js';
|
|
6
|
+
import { MEMORY_SCOPES } from './shared.js';
|
|
7
|
+
/** Remove the file's now-empty parent directories up to (never including) the
|
|
8
|
+
* scope's `memory/` root, so deleting the last doc under an `area/` prefix
|
|
9
|
+
* does not orphan an empty directory. Stops at the first non-empty ancestor.
|
|
10
|
+
* `memoryRoot` is derived by walking up one dirname per name segment: a doc
|
|
11
|
+
* named `a/b` sits at `<root>/a/b.md`, so its root is two dirnames above. */
|
|
12
|
+
function pruneEmptyParents(filePath, nameSegments) {
|
|
13
|
+
let memoryRoot = filePath;
|
|
14
|
+
for (let i = 0; i < nameSegments; i += 1)
|
|
15
|
+
memoryRoot = dirname(memoryRoot);
|
|
16
|
+
let dir = dirname(filePath);
|
|
17
|
+
while (dir !== memoryRoot && dir.startsWith(memoryRoot) && existsSync(dir) && readdirSync(dir).length === 0) {
|
|
18
|
+
rmdirSync(dir);
|
|
19
|
+
dir = dirname(dir);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export const deleteLeaf = defineLeaf({
|
|
23
|
+
name: 'delete',
|
|
24
|
+
description: 'delete a memory document by name',
|
|
25
|
+
whenToUse: 'a stored document is wrong, obsolete, or superseded and should be removed — the sanctioned way to delete one, so you never rm the markdown off disk. Resolves the name exactly as `read` does (project stack > profile > user precedence, leaf-name fallback) and removes that file: if you can read it, you can delete it. Refuses docs you do not own — builtin docs (shipped with the package) and installed-plugin docs (managed by `crtr pkg`) — and refuses an ambiguous leaf name rather than guessing which doc to destroy; pass the full `area/topic` name or `--scope` to pin one.',
|
|
26
|
+
help: {
|
|
27
|
+
name: 'memory delete',
|
|
28
|
+
summary: 'resolve a path-derived name and remove that document from its scope store',
|
|
29
|
+
params: [
|
|
30
|
+
{ kind: 'positional', name: 'name', required: true, constraint: 'Path-derived memory identifier (e.g. `topic` or `area/topic`), resolved as `read` resolves it: precedence project stack > profile > user, with leaf-name fallback. A leaf that maps to genuinely different docs across scopes is rejected as ambiguous — pass the full name or --scope to disambiguate. May be scope-prefixed (`user/topic`) to pin the store directly.' },
|
|
31
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Restrict resolution to this scope before deleting. Use it to disambiguate a leaf name present at multiple scopes, or to assert which store you mean. builtin is not a choice — builtin docs ship with the package and cannot be deleted.' },
|
|
32
|
+
],
|
|
33
|
+
output: [
|
|
34
|
+
{ name: 'name', type: 'string', required: true, constraint: 'Path-derived name of the deleted document.' },
|
|
35
|
+
{ name: 'scope', type: 'string', required: true, constraint: 'Scope the document was deleted from: user, project, or profile.' },
|
|
36
|
+
{ name: 'path', type: 'string', required: true, constraint: 'Absolute path of the file that was removed.' },
|
|
37
|
+
{ name: 'deleted', type: 'boolean', required: true, constraint: 'Always true on success — the file was removed. A missing document fails with not_found instead.' },
|
|
38
|
+
{ name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next command — browse what remains.' },
|
|
39
|
+
],
|
|
40
|
+
outputKind: 'object',
|
|
41
|
+
effects: [
|
|
42
|
+
'Permanently removes memory/<name>.md from the resolved scope store, and prunes any parent directories it leaves empty. Irreversible.',
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
run: async (input) => {
|
|
46
|
+
const nameRaw = input['name'];
|
|
47
|
+
const scopeArg = input['scope'];
|
|
48
|
+
let doc;
|
|
49
|
+
try {
|
|
50
|
+
doc = resolveMemoryDoc(nameRaw, scopeArg !== undefined ? { scope: scopeArg } : {});
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e instanceof CrtrError && e.code === 'not_found') {
|
|
54
|
+
throw notFound(`memory document not found: ${nameRaw}`, {
|
|
55
|
+
memory: nameRaw,
|
|
56
|
+
next: 'Run `crtr memory find <query>` to locate it, or `crtr memory list` to browse the inventory. If it exists at another scope, pass --scope.',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
throw e; // ambiguous / usage propagate with their own recovery guidance
|
|
60
|
+
}
|
|
61
|
+
if (doc.plugin !== undefined) {
|
|
62
|
+
throw usage(`${doc.name} belongs to the installed plugin "${doc.plugin}" — it is not a scope doc and cannot be deleted here. Manage plugin docs with \`crtr pkg\`. To override just this doc, write a same-named doc at a writable scope (\`crtr memory write ${doc.name} ...\`).`, { memory: doc.name, plugin: doc.plugin });
|
|
63
|
+
}
|
|
64
|
+
if (doc.scope === 'builtin') {
|
|
65
|
+
throw usage(`${doc.name} is a builtin document shipped with the package (read-only) — it cannot be deleted. Override it with a same-named doc at a writable scope instead (\`crtr memory write ${doc.name} ...\`).`, { memory: doc.name, scope: 'builtin' });
|
|
66
|
+
}
|
|
67
|
+
rmSync(doc.path);
|
|
68
|
+
pruneEmptyParents(resolvePath(doc.path), doc.name.split('/').length);
|
|
69
|
+
return {
|
|
70
|
+
name: doc.name,
|
|
71
|
+
scope: doc.scope,
|
|
72
|
+
path: doc.path,
|
|
73
|
+
deleted: true,
|
|
74
|
+
follow_up: `Removed. Browse what remains with \`crtr memory list\`.`,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -68,6 +68,16 @@ export function lintSubstrateSchema(fm) {
|
|
|
68
68
|
if (fm['file-read-visibility'] === 'none' && (appliesTo !== undefined || readWhen !== undefined)) {
|
|
69
69
|
return 'dead on-read trigger: applies-to/read-when is set but file-read-visibility is `none` — raise the rung or drop the trigger';
|
|
70
70
|
}
|
|
71
|
+
const slash = fm.slash;
|
|
72
|
+
if (slash !== undefined && typeof slash !== 'boolean') {
|
|
73
|
+
return `invalid slash: ${JSON.stringify(slash)} (expected a boolean)`;
|
|
74
|
+
}
|
|
75
|
+
// rationale (maintainer-facing gap this doc closes) is optional everywhere —
|
|
76
|
+
// no nagging when absent, just a type check when present.
|
|
77
|
+
const rationale = fm.rationale;
|
|
78
|
+
if (rationale !== undefined && typeof rationale !== 'string') {
|
|
79
|
+
return `invalid rationale: ${JSON.stringify(rationale)} (expected a string)`;
|
|
80
|
+
}
|
|
71
81
|
return null;
|
|
72
82
|
}
|
|
73
83
|
/** Strict-parse one file; push a finding on a YAML error, then run the
|
|
@@ -16,7 +16,8 @@ export const writeLeaf = defineLeaf({
|
|
|
16
16
|
'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
|
|
17
17
|
'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
|
|
18
18
|
'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
|
|
19
|
-
'Find before write. Group related docs with path names (area/topic). Do not store what is already recorded or what only matters to this conversation. Body is for current truth, not history. Provenance is automatic on create and preserved on update. Validate after authoring
|
|
19
|
+
'Find before write. Group related docs with path names (area/topic). Do not store what is already recorded or what only matters to this conversation. Body is for current truth, not history. Provenance is automatic on create and preserved on update. Validate after authoring.\n\n' +
|
|
20
|
+
'--rationale is the gap this doc exists to close — the observed agent failure that prompted it, captured from user signal (a correction, a mistake you watched happen) rather than inferred from the doc’s own content. The bar: if the rationale is guessable from reading the doc, it is not the real one — a guessable gap is one agents do not actually fall into, so a merely-plausible-sounding reason is not worth recording. It is maintainer-facing only and NEVER ships in a delivered surface (boot render, on-read injection, `memory read` content) — it lives in frontmatter, visible only in the raw file or `memory read --frontmatter`. Omit it when you have no such gap to record; omitting the flag on an update always preserves whatever rationale already exists.',
|
|
20
21
|
params: [
|
|
21
22
|
{ kind: 'positional', name: 'name', required: true, constraint: 'Path-derived identity: one segment, or several joined with `/` to nest the document under a directory and group it with related docs → memory/<name>.md at the resolved scope. Updated in place if it already exists, otherwise created.' },
|
|
22
23
|
{ kind: 'flag', name: 'kind', type: 'enum', choices: [...MEMORY_KINDS], required: true, constraint: 'Document kind.' },
|
|
@@ -27,6 +28,8 @@ export const writeLeaf = defineLeaf({
|
|
|
27
28
|
{ kind: 'flag', name: 'gate', type: 'string', required: false, constraint: 'Frontmatter gate — YAML/JSON object predicate over node config using the same field/matcher vocabulary described in the guide.' },
|
|
28
29
|
{ kind: 'flag', name: 'applies-to', type: 'string', required: false, constraint: 'Frontmatter applies-to — glob/path scope the document applies to.' },
|
|
29
30
|
{ kind: 'flag', name: 'read-when', type: 'string', required: false, constraint: 'Frontmatter read-when — YAML/JSON object predicate over a read file’s own frontmatter using the same field/matcher vocabulary described in the guide.' },
|
|
31
|
+
{ kind: 'flag', name: 'slash', type: 'bool', required: false, default: false, constraint: 'Presence flags this doc invocable as a pi slash command (`/<name>`, `/` in a nested name rendered as `:`) — the doc body becomes the command’s injected prompt. Default false: most docs are consulted, not invoked.' },
|
|
32
|
+
{ kind: 'flag', name: 'rationale', type: 'string', required: false, constraint: 'Frontmatter rationale — the observed agent failure that made this doc necessary. Maintainer-facing only: never ships in any delivered surface (boot render, on-read injection, `memory read` content), visible only in the raw file or `memory read --frontmatter`. Omitting this flag on an update PRESERVES an existing rationale unchanged.' },
|
|
30
33
|
{ kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `project` resolves to the NEAREST ancestor `.crouter/` walking up from cwd — in a nested workspace that can be a parent’s store, not the dir you are standing in; pass --dir to pin the exact project directory. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
|
|
31
34
|
{ kind: 'flag', name: 'dir', type: 'string', required: false, constraint: 'Exact project directory to write under — targets `<dir>/.crouter/memory/` regardless of cwd or ancestor stores, scaffolding `.crouter/` there if absent. THE way to place a doc in a specific project’s store (e.g. another project in your profile’s purview) without cd’ing there, and the only way to target a dir shadowed by an ancestor store. Implies --scope project; rejects --scope user/profile.' },
|
|
32
35
|
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Profile id or name to write under, for --scope profile. Default: the process CRTR_PROFILE_ID (the node\u2019s selected profile). Resolved through the same profile lookup as `crtr profile show`; ignored for any other --scope.' },
|
|
@@ -99,6 +102,9 @@ export const writeLeaf = defineLeaf({
|
|
|
99
102
|
if (input['readWhen'] !== undefined) {
|
|
100
103
|
frontmatter['read-when'] = coerceReadWhen(input['readWhen']);
|
|
101
104
|
}
|
|
105
|
+
if (input['slash'] === true)
|
|
106
|
+
frontmatter['slash'] = true;
|
|
107
|
+
setIf('rationale', input['rationale']);
|
|
102
108
|
writeText(path, serializeMemoryDoc(frontmatter, body));
|
|
103
109
|
return {
|
|
104
110
|
name,
|
package/dist/commands/memory.js
CHANGED
|
@@ -5,6 +5,7 @@ import { listLeaf } from './memory/list.js';
|
|
|
5
5
|
import { readLeaf } from './memory/read.js';
|
|
6
6
|
import { findLeaf } from './memory/find.js';
|
|
7
7
|
import { writeLeaf } from './memory/write.js';
|
|
8
|
+
import { deleteLeaf } from './memory/delete.js';
|
|
8
9
|
import { originLeaf } from './memory/origin.js';
|
|
9
10
|
import { lintLeaf } from './memory/lint.js';
|
|
10
11
|
export function registerMemory() {
|
|
@@ -18,8 +19,8 @@ export function registerMemory() {
|
|
|
18
19
|
help: {
|
|
19
20
|
name: 'memory',
|
|
20
21
|
summary: 'list, read, search, and write memory documents — knowledge and preferences',
|
|
21
|
-
model: '`list` for a human inventory of what is stored — one line per document, the only surface that shows short-form. `read` (leaf) loads one document body by name, resolved project > user > builtin with leaf-name fallback; --frontmatter keeps the YAML header. `find` when you do not yet know which document applies — it ranks by relevance over name/when/why/short-form, --body to also weigh bodies, --grep for an exact regex over bodies. `write` creates or updates memory/<name>.md at a scope from frontmatter flags + a body piped on stdin — use it for new docs and frontmatter changes; for a quick body tweak, edit the `path` every leaf emits directly. `lint` strict-parses the whole bounded corpus and fails on any invalid frontmatter — run it after authoring. `origin` derefs a doc back to its provenance — the node, conversation, and project that created it (the runtime stamps an `origin` block at write time; this reads it and resolves the node pointer to its live session + context dir). A directory may carry an `INDEX.md` with the same frontmatter schema as any doc; the dir then renders as one entry at the INDEX\'s rung, and that rung is a ceiling for its whole subtree (`none` hides the dir) — when a doc mysteriously is not surfacing, check its ancestors\' INDEX rungs and its gate. Append `-h` at any leaf for its full schema, and `crtr memory write -h` for the authoring guide.',
|
|
22
|
+
model: '`list` for a human inventory of what is stored — one line per document, the only surface that shows short-form. `read` (leaf) loads one document body by name, resolved project > user > builtin with leaf-name fallback; --frontmatter keeps the YAML header. `find` when you do not yet know which document applies — it ranks by relevance over name/when/why/short-form, --body to also weigh bodies, --grep for an exact regex over bodies. `write` creates or updates memory/<name>.md at a scope from frontmatter flags + a body piped on stdin — use it for new docs and frontmatter changes; for a quick body tweak, edit the `path` every leaf emits directly. `delete` removes a document by name (resolved as `read` resolves it) — the sanctioned way to retire one instead of rm-ing the markdown off disk. `lint` strict-parses the whole bounded corpus and fails on any invalid frontmatter — run it after authoring. `origin` derefs a doc back to its provenance — the node, conversation, and project that created it (the runtime stamps an `origin` block at write time; this reads it and resolves the node pointer to its live session + context dir). A directory may carry an `INDEX.md` with the same frontmatter schema as any doc; the dir then renders as one entry at the INDEX\'s rung, and that rung is a ceiling for its whole subtree (`none` hides the dir) — when a doc mysteriously is not surfacing, check its ancestors\' INDEX rungs and its gate. Append `-h` at any leaf for its full schema, and `crtr memory write -h` for the authoring guide.',
|
|
22
23
|
},
|
|
23
|
-
children: [listLeaf, readLeaf, findLeaf, writeLeaf, originLeaf, lintLeaf],
|
|
24
|
+
children: [listLeaf, readLeaf, findLeaf, writeLeaf, deleteLeaf, originLeaf, lintLeaf],
|
|
24
25
|
});
|
|
25
26
|
}
|
package/dist/commands/node.js
CHANGED
|
@@ -13,6 +13,7 @@ import { spawnChild } from '../core/runtime/spawn.js';
|
|
|
13
13
|
import { promote, requestYield } from '../core/runtime/promote.js';
|
|
14
14
|
import { writeYieldMessage, readGoal } from '../core/runtime/kickoff.js';
|
|
15
15
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
16
|
+
import { parseOutputSchemaValue, writeOutputSchema } from '../core/runtime/structured-output.js';
|
|
16
17
|
import { newNodeId } from '../core/runtime/nodes.js';
|
|
17
18
|
import { readRoadmap, hasRoadmap, seedRoadmap } from '../core/runtime/roadmap.js';
|
|
18
19
|
import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
|
|
@@ -129,8 +130,9 @@ const nodeNew = defineLeaf({
|
|
|
129
130
|
{ kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with). Mutually exclusive with --worktree.' },
|
|
130
131
|
{ kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on `origin/main`; the child\'s cwd is pinned there, and it lands the work serially onto origin/main later with `crtr worktree close`. Mutually exclusive with --root.' },
|
|
131
132
|
{ kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
|
|
132
|
-
{ kind: 'flag', name: 'model', type: '
|
|
133
|
+
{ kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Override the model the node runs on: exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (anthropic|openai + ultra|strong|medium|light, e.g. openai/ultra — pin a specific model family, useful for spawning cross-family children), a bare capability TIER (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku). Must resolve to a concrete model OFFLINE — unlike `node config --model`, no live broker exists yet at spawn to free-text substring-search a registry. Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
|
|
133
134
|
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (root has none, so an uninherited child has none either). --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
|
|
135
|
+
{ kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. The spawned node gets a `submit` tool shaped by this schema, must call it to finish, and the submitted JSON is written to the final report and context/result.json. Rejected with trigger flags in this version.' },
|
|
134
136
|
{ kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'DEFER the birth to a one-shot clock trigger at this future time instead of spawning now — a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", host-local or in --tz). Required unless --every is given (the cadence then sets the first fire). Mutually exclusive with --when.' },
|
|
135
137
|
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'SPAWN-CRON: re-birth a fresh node on this cadence — a duration ("6h","30m") or a 5-field cron / @alias ("0 9 * * *","@daily"). Combine with --at to anchor the first fire, then recur normally. Min cadence 60s. With --when, this instead sets the PREDICATE EVALUATION cadence (required), not a repeating birth.' },
|
|
136
138
|
{ kind: 'flag', name: 'when', type: 'string', required: false, constraint: 'Arm a daemon-evaluated bash predicate instead of a clock trigger: exit 0 births the node once, non-zero leaves it armed for the next evaluation. Requires --every (eval cadence) and --timeout (wall-clock bound). Mutually exclusive with --at.' },
|
|
@@ -155,7 +157,7 @@ const nodeNew = defineLeaf({
|
|
|
155
157
|
dynamicState: () => [kindsStateBlock(), profilesStateBlock()].join('\n'),
|
|
156
158
|
outputKind: 'object',
|
|
157
159
|
effects: [
|
|
158
|
-
'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on origin/main; the child closes it later with `crtr worktree close` before finishing. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
|
|
160
|
+
'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on origin/main; the child closes it later with `crtr worktree close` before finishing. With --output-schema: writes nodes/<id>/output-schema.json in terminal mode; the node must call submit, which writes context/result.json and pushes final. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
|
|
159
161
|
'Armed (--at/--every/--when): inserts one detached `new`/`node_birth` triggers row (node_id NULL, owner=you, parent/cwd resolved now); NO node and NO window exist until fire time. The daemon spawns from the stored recipe at fire time, re-deriving the launch spec live.',
|
|
160
162
|
],
|
|
161
163
|
},
|
|
@@ -172,8 +174,18 @@ const nodeNew = defineLeaf({
|
|
|
172
174
|
const root = input['root'] === true;
|
|
173
175
|
const worktree = input['worktree'] === true;
|
|
174
176
|
const forkFrom = input['forkFrom'];
|
|
175
|
-
const
|
|
177
|
+
const modelSpec = input['model']?.trim();
|
|
178
|
+
if (modelSpec !== undefined) {
|
|
179
|
+
if (modelSpec === '') {
|
|
180
|
+
throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Pass a `provider/id`, a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku).' });
|
|
181
|
+
}
|
|
182
|
+
if (!normalizeModel(modelSpec).includes('/')) {
|
|
183
|
+
throw new InputError({ error: 'unresolvable_model', message: `'${modelSpec}' does not resolve to a concrete model`, field: 'model', next: 'Pass an exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (e.g. openai/ultra), a tier (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku) — a live-broker substring search (like `node config --model`) is not available at spawn.' });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const model = modelSpec;
|
|
176
187
|
const profile = input['profile'];
|
|
188
|
+
const outputSchema = parseOutputSchemaValue(input['outputSchema']);
|
|
177
189
|
const at = input['at']?.trim();
|
|
178
190
|
const every = input['every']?.trim();
|
|
179
191
|
const whenRaw = input['when'];
|
|
@@ -194,9 +206,14 @@ const nodeNew = defineLeaf({
|
|
|
194
206
|
if (root && worktree) {
|
|
195
207
|
throw new InputError({ error: 'worktree_root_conflict', message: '--worktree is mutually exclusive with --root', field: 'worktree', next: 'Drop either --root or --worktree.' });
|
|
196
208
|
}
|
|
209
|
+
if (outputSchema !== null && triggered) {
|
|
210
|
+
throw new InputError({ error: 'output_schema_trigger_unsupported', message: '--output-schema is not supported with --at, --every, or --when in this version', field: 'output-schema', next: 'Spawn immediately with --output-schema, or drop --output-schema for the triggered spawn.' });
|
|
211
|
+
}
|
|
197
212
|
if (!triggered) {
|
|
198
213
|
try {
|
|
199
214
|
const res = await spawnChild({ kind, mode, cwd, name, prompt, parent, root, worktree, forkFrom, model, profile });
|
|
215
|
+
if (outputSchema !== null)
|
|
216
|
+
writeOutputSchema(res.node.node_id, 'terminal', outputSchema);
|
|
200
217
|
return {
|
|
201
218
|
node_id: res.node.node_id,
|
|
202
219
|
name: res.node.name,
|
|
@@ -712,7 +729,12 @@ const nodeConfig = defineLeaf({
|
|
|
712
729
|
throw new InputError({ error: 'not_found', message: `no node: ${nodeId}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
713
730
|
if (isBrokerLive(meta)) {
|
|
714
731
|
try {
|
|
715
|
-
|
|
732
|
+
// Normalize tiers/aliases (opus, strong, anthropic/strong, …) through the
|
|
733
|
+
// config ladder before the broker sees them — a bare alias sent raw falls
|
|
734
|
+
// into the broker's registry substring search, where `opus` resolves to the
|
|
735
|
+
// deprecated claude-3-opus-20240229 on catalog order. Concrete `provider/id`
|
|
736
|
+
// specs and free-text substrings pass through unchanged.
|
|
737
|
+
resolved = await setModelLive(nodeId, normalizeModel(modelSpec));
|
|
716
738
|
}
|
|
717
739
|
catch (err) {
|
|
718
740
|
throw new InputError({ error: 'switch_failed', message: `live model switch failed: ${err instanceof Error ? err.message : String(err)}`, next: 'Check the spec resolves (`provider/id`, tier, alias, or a substring of a registered model).' });
|
|
@@ -1016,11 +1038,12 @@ const nodeMsg = defineLeaf({
|
|
|
1016
1038
|
name: 'node msg',
|
|
1017
1039
|
summary: 'message a node now, or arm a trigger (clock or predicate) that messages/revives it later. No trigger flag → immediate inbox delivery (or immediate fresh revive with --fresh). --at/--every → clock trigger. --self --until → self-only deadline: fires whichever comes first, an inbox message or this time, and that outcome is what should change your next action. --when → daemon-evaluated predicate, fires once on exit 0',
|
|
1018
1040
|
params: [
|
|
1019
|
-
{ kind: 'stdin', name: 'body', required: false, constraint: 'Message body. Positional or stdin. Required for a plain or clock-triggered message and for --until. Forbidden with --fresh. Optional with --when — if omitted, the predicate\'s captured stdout becomes the body when it fires.' },
|
|
1041
|
+
{ kind: 'stdin', name: 'body', required: false, constraint: 'Message body. Positional or stdin. Required for a plain or clock-triggered message and for --until, unless --output-schema is present (then a default one-off structured-output request body is generated). Forbidden with --fresh. Optional with --when — if omitted, the predicate\'s captured stdout becomes the body when it fires.' },
|
|
1020
1042
|
{ kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to/--self is required.' },
|
|
1021
1043
|
{ kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node (CRTR_NODE_ID). Exactly one of --to/--self is required. Required for --until.' },
|
|
1022
|
-
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, constraint: 'Inbox delivery urgency: critical = interrupt + new turn; urgent = steer mid-turn; normal = follow-up; deferred = read on next cycle. Defaults to normal when omitted. Valid for a plain or --when inbox message (immediate or clock-triggered); forbidden with --fresh or --until (--until is always urgent).' },
|
|
1023
|
-
{ kind: 'flag', name: 'fresh', type: 'bool', required: false, constraint: 'Fresh-revive the target instead of delivering a message: no inbox entry, just a clean revive (resume:false). Valid alone (revives now) or with --at/--every (arms a clock-triggered fresh revive). Forbidden with a body, --tier, --until, or --when. Rejected if the target has no goal/roadmap on disk (would resume amnesiac).' },
|
|
1044
|
+
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, constraint: 'Inbox delivery urgency: critical = interrupt + new turn; urgent = steer mid-turn; normal = follow-up; deferred = read on next cycle. Defaults to normal when omitted. Valid for a plain or --when inbox message (immediate or clock-triggered); forbidden with --fresh or --until (--until is always urgent). With --output-schema, deferred is rejected because the target must wake to answer the one-off request.' },
|
|
1045
|
+
{ kind: 'flag', name: 'fresh', type: 'bool', required: false, constraint: 'Fresh-revive the target instead of delivering a message: no inbox entry, just a clean revive (resume:false). Valid alone (revives now) or with --at/--every (arms a clock-triggered fresh revive). Forbidden with a body, --tier, --until, --output-schema, or --when. Rejected if the target has no goal/roadmap on disk (would resume amnesiac).' },
|
|
1046
|
+
{ kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. Grants or replaces a one-off `submit` tool on the target; the target must submit before it can stop, then the result is written to context/result.json, pushed as an update report, and the tool is retired. Immediate messages only; rejected with trigger flags, --fresh, and --until.' },
|
|
1024
1047
|
{ kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'Arm a one-shot clock trigger at this future time instead of acting now — a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", host-local or in --tz). Required unless --every is given (the cadence then sets the first fire). Mutually exclusive with --when.' },
|
|
1025
1048
|
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Arm a recurring clock trigger on this cadence — a duration ("6h","30m") or a 5-field cron / @alias ("0 9 * * *","@daily"). Combine with --at to anchor the first fire, then recur normally. Min cadence 60s. With --when, this instead sets the PREDICATE EVALUATION cadence (required), not a repeating action.' },
|
|
1026
1049
|
{ kind: 'flag', name: 'when', type: 'string', required: false, constraint: 'Arm a daemon-evaluated bash predicate instead of a clock trigger: exit 0 fires the message once, non-zero leaves it armed for the next evaluation. Requires --every (eval cadence) and --timeout (wall-clock bound). Mutually exclusive with --at, --fresh, and --until.' },
|
|
@@ -1044,7 +1067,7 @@ const nodeMsg = defineLeaf({
|
|
|
1044
1067
|
],
|
|
1045
1068
|
outputKind: 'object',
|
|
1046
1069
|
effects: [
|
|
1047
|
-
'Immediate inbox_message: appends a message entry to the target inbox.jsonl and best-effort revives the target if dormant.',
|
|
1070
|
+
'Immediate inbox_message: appends a message entry to the target inbox.jsonl and best-effort revives the target if dormant. With --output-schema: also writes nodes/<target>/output-schema.json in oneoff mode before delivery.',
|
|
1048
1071
|
'Immediate fresh_revive: revives the target now with no inbox entry (resume:false).',
|
|
1049
1072
|
'Armed (--at/--every/--when/--until): inserts one triggers row; nothing fires/evaluates until the daemon\'s next pass. No pi spawn, no transition — end your turn separately to go dormant.',
|
|
1050
1073
|
'--until upserts the node\'s single deadline trigger, replacing any prior.',
|
|
@@ -1069,6 +1092,7 @@ const nodeMsg = defineLeaf({
|
|
|
1069
1092
|
const tz = input['tz'];
|
|
1070
1093
|
const hasAt = at !== undefined && at !== '';
|
|
1071
1094
|
const hasEvery = every !== undefined && every !== '';
|
|
1095
|
+
const outputSchema = parseOutputSchemaValue(input['outputSchema']);
|
|
1072
1096
|
// --- Validation per the approved flag matrix ---
|
|
1073
1097
|
if (hasUntil && !isSelf) {
|
|
1074
1098
|
throw new InputError({ error: 'until_self_only', message: '--until is self-only', field: 'until', next: 'Use --self, or drop --until.' });
|
|
@@ -1098,6 +1122,12 @@ const nodeMsg = defineLeaf({
|
|
|
1098
1122
|
if (hasTz && !hasAt && !hasEvery && !hasUntil) {
|
|
1099
1123
|
throw new InputError({ error: 'tz_needs_trigger', message: '--tz has no effect without --at, --every, or --until to parse it against', field: 'tz', next: 'Drop --tz, or pass --at/--every/--until.' });
|
|
1100
1124
|
}
|
|
1125
|
+
if (outputSchema !== null && (hasAt || hasEvery || hasWhen || hasUntil || fresh)) {
|
|
1126
|
+
throw new InputError({ error: 'output_schema_msg_immediate_only', message: '--output-schema on node msg grants an immediate one-off request and cannot combine with --at, --every, --when, --until, or --fresh', field: 'output-schema', next: 'Deliver the one-off request immediately, or drop --output-schema for the trigger/fresh revive.' });
|
|
1127
|
+
}
|
|
1128
|
+
if (outputSchema !== null && tierRaw === 'deferred') {
|
|
1129
|
+
throw new InputError({ error: 'output_schema_deferred_tier', message: '--output-schema cannot combine with --tier deferred because the target must wake and answer the one-off request', field: 'tier', next: 'Use normal, urgent, or critical tier, or drop --output-schema.' });
|
|
1130
|
+
}
|
|
1101
1131
|
const target = isSelf ? 'self' : targetId;
|
|
1102
1132
|
// --- Deadline (--until): self-only, one-shot, urgent ---
|
|
1103
1133
|
if (hasUntil) {
|
|
@@ -1265,19 +1295,24 @@ const nodeMsg = defineLeaf({
|
|
|
1265
1295
|
guidance: `Fresh-revived ${isSelf ? 'yourself' : targetId} now — it resumes a clean window re-reading its roadmap/disk.`,
|
|
1266
1296
|
};
|
|
1267
1297
|
}
|
|
1268
|
-
if (!hasBody) {
|
|
1269
|
-
throw new InputError({ error: 'empty_body', message: 'a message body is required (or --fresh / --when)', field: 'body', next: 'Pass the message after the target flags or on stdin.' });
|
|
1298
|
+
if (!hasBody && outputSchema === null) {
|
|
1299
|
+
throw new InputError({ error: 'empty_body', message: 'a message body is required (or --fresh / --when / --output-schema)', field: 'body', next: 'Pass the message after the target flags or on stdin, or pass --output-schema to grant a one-off structured-output request.' });
|
|
1270
1300
|
}
|
|
1271
1301
|
const targetMeta = getNode(targetId);
|
|
1272
1302
|
if (targetMeta === null) {
|
|
1273
1303
|
throw new InputError({ error: 'not_found', message: `no node: ${targetId}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
1274
1304
|
}
|
|
1305
|
+
if (outputSchema !== null)
|
|
1306
|
+
writeOutputSchema(targetId, 'oneoff', outputSchema);
|
|
1275
1307
|
const tier = (hasTier ? tierRaw : 'normal');
|
|
1276
1308
|
const from = process.env['CRTR_NODE_ID'] ?? 'human';
|
|
1309
|
+
const messageBody = hasBody
|
|
1310
|
+
? body
|
|
1311
|
+
: 'A one-off structured-output request has been granted. Call the `submit` tool with a result matching the requested schema before you stop; after submit succeeds, continue normally.';
|
|
1277
1312
|
// A body over the preview bound spills to a ref file; appendInbox clips the
|
|
1278
1313
|
// inline preview and the receiver dereferences the ref for the full text,
|
|
1279
1314
|
// same as any other report.
|
|
1280
|
-
appendInbox(targetId, { from, tier, kind: 'message', label:
|
|
1315
|
+
appendInbox(targetId, { from, tier, kind: 'message', label: messageBody.split('\n')[0].slice(0, 120), data: { body: messageBody } });
|
|
1281
1316
|
// A direct message wakes any node: if the target has no live window
|
|
1282
1317
|
// (done/dead/idle-released), revive it so its inbox-watcher delivers this.
|
|
1283
1318
|
let woke = false;
|
|
@@ -30,3 +30,19 @@ test('explore remains cheap by default and may explicitly run light', () => {
|
|
|
30
30
|
assert.equal(spec('explore'), normalizeModel('anthropic/light'));
|
|
31
31
|
assert.equal(spec('explore', 'light'), normalizeModel('light'));
|
|
32
32
|
});
|
|
33
|
+
// `node new --model` widened to the full spec grammar (provider/tier, exact
|
|
34
|
+
// provider/id, family alias) alongside the bare tier — the review floor must
|
|
35
|
+
// still hold no matter which shape the requested model arrives in.
|
|
36
|
+
test('review honors an explicit provider/tier override at or above its floor', () => {
|
|
37
|
+
assert.equal(spec('review', 'openai/ultra'), normalizeModel('openai/ultra'));
|
|
38
|
+
});
|
|
39
|
+
test('review still floors a provider/tier override below its floor', () => {
|
|
40
|
+
assert.equal(spec('review', 'openai/light'), normalizeModel('openai/strong'));
|
|
41
|
+
});
|
|
42
|
+
test('review passes an unrecognized concrete provider/id straight through, unfloored', () => {
|
|
43
|
+
// A fully concrete id that matches no ladder cell resolves to no known
|
|
44
|
+
// strength, so floorReviewModel trusts the explicit pin as-given rather than
|
|
45
|
+
// guessing — same trust boundary as `node config --model` on a dormant node.
|
|
46
|
+
const concrete = 'anthropic/claude-does-not-exist-9000';
|
|
47
|
+
assert.equal(spec('review', concrete), concrete);
|
|
48
|
+
});
|
|
@@ -22,6 +22,12 @@ export interface MemoryDoc {
|
|
|
22
22
|
frontmatter: Record<string, unknown> | null;
|
|
23
23
|
/** Document body, with the frontmatter block stripped. */
|
|
24
24
|
body: string;
|
|
25
|
+
/** Set to the owning plugin's name when this doc is mounted from an installed
|
|
26
|
+
* plugin (under `<pluginName>/`), undefined for a native scope doc. Plugin
|
|
27
|
+
* docs surface within a source scope but live under the plugins dir and are
|
|
28
|
+
* managed by `crtr pkg`, not writable/deletable as scope docs — consumers
|
|
29
|
+
* that mutate the store gate on this. */
|
|
30
|
+
plugin?: string;
|
|
25
31
|
}
|
|
26
32
|
/** The memory-only scope union: the global `Scope` (`user|project|builtin`)
|
|
27
33
|
* plus `profile`. Confined to the memory resolver/list/read/write/render
|
|
@@ -164,7 +164,7 @@ export function listPluginMemoryDocs(plugin, scope, quiet = false) {
|
|
|
164
164
|
continue;
|
|
165
165
|
const name = `${plugin.name}/${derived}`;
|
|
166
166
|
try {
|
|
167
|
-
docs.push(loadMemoryDoc(name, scope, file));
|
|
167
|
+
docs.push({ ...loadMemoryDoc(name, scope, file), plugin: plugin.name });
|
|
168
168
|
}
|
|
169
169
|
catch (e) {
|
|
170
170
|
const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
@@ -1212,10 +1212,16 @@ export async function runBroker(nodeId) {
|
|
|
1212
1212
|
break;
|
|
1213
1213
|
const requested = parseModelSpec(frame.model);
|
|
1214
1214
|
let model;
|
|
1215
|
+
let normalized;
|
|
1215
1216
|
try {
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1217
|
+
// Tier/alias specs (`opus`, `strong`, `anthropic/strong`, …) MUST resolve
|
|
1218
|
+
// through the config ladder before touching the registry: the fuzzy
|
|
1219
|
+
// registry search rank-ties `opus` between claude-3-opus-20240229 and
|
|
1220
|
+
// claude-opus-4-8, and the deprecated 2024 model wins on catalog order.
|
|
1221
|
+
// Concrete `provider/id` specs and genuine substrings (`fable`) pass
|
|
1222
|
+
// through normalizeModel unchanged and take the exact/search path below.
|
|
1223
|
+
normalized = parseModelSpec(normalizeModel(requested.modelSpec));
|
|
1224
|
+
model = resolveModelQuery(normalized.modelSpec);
|
|
1219
1225
|
}
|
|
1220
1226
|
catch (err) {
|
|
1221
1227
|
// N2: registry.find should never throw on the real SDK, but a degenerate
|
|
@@ -1234,8 +1240,10 @@ export async function runBroker(nodeId) {
|
|
|
1234
1240
|
void session
|
|
1235
1241
|
.setModel(model)
|
|
1236
1242
|
.then(() => {
|
|
1237
|
-
|
|
1238
|
-
|
|
1243
|
+
// The caller's explicit `:thinking` wins; else the ladder cell's.
|
|
1244
|
+
const thinking = requested.thinkingLevel ?? normalized.thinkingLevel;
|
|
1245
|
+
if (thinking !== undefined)
|
|
1246
|
+
session.setThinkingLevel(thinking);
|
|
1239
1247
|
ackTo(client, 'set_model');
|
|
1240
1248
|
broadcastModelChanged();
|
|
1241
1249
|
})
|
|
@@ -8,6 +8,7 @@ export declare const CANVAS_GOAL_CAPTURE_PATH: string;
|
|
|
8
8
|
export declare const CANVAS_PASSIVE_CONTEXT_PATH: string;
|
|
9
9
|
export declare const CANVAS_CONTEXT_INTRO_PATH: string;
|
|
10
10
|
export declare const CANVAS_DOC_SUBSTRATE_PATH: string;
|
|
11
|
+
export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
|
|
11
12
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
12
13
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
13
14
|
* graph chrome), recap (the per-node inactivity recap card — Haiku over the
|
|
@@ -16,9 +16,8 @@ import { nodeEnv } from './nodes.js';
|
|
|
16
16
|
import { editorLabel } from '../canvas/index.js';
|
|
17
17
|
import { nodeDir } from '../canvas/paths.js';
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// with no respawn.
|
|
19
|
+
// Canvas pi-extensions every node loads. They self-gate on node/runtime state,
|
|
20
|
+
// so persona changes and per-node feature grants take effect without respawn.
|
|
22
21
|
// ---------------------------------------------------------------------------
|
|
23
22
|
function resolveExtension(name) {
|
|
24
23
|
const here = dirname(fileURLToPath(import.meta.url)); // dist/core/runtime or src/core/runtime
|
|
@@ -36,6 +35,7 @@ export const CANVAS_GOAL_CAPTURE_PATH = resolveExtension('canvas-goal-capture');
|
|
|
36
35
|
export const CANVAS_PASSIVE_CONTEXT_PATH = resolveExtension('canvas-passive-context');
|
|
37
36
|
export const CANVAS_CONTEXT_INTRO_PATH = resolveExtension('canvas-context-intro');
|
|
38
37
|
export const CANVAS_DOC_SUBSTRATE_PATH = resolveExtension('canvas-doc-substrate');
|
|
38
|
+
export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured-output');
|
|
39
39
|
/** The canvas extensions every node loads, in order: stophook (routing +
|
|
40
40
|
* telemetry + session-id capture), inbox-watcher (wake), nav (in-editor
|
|
41
41
|
* graph chrome), recap (the per-node inactivity recap card — Haiku over the
|
|
@@ -57,6 +57,7 @@ export const CANVAS_EXTENSIONS = [
|
|
|
57
57
|
CANVAS_PASSIVE_CONTEXT_PATH,
|
|
58
58
|
CANVAS_CONTEXT_INTRO_PATH,
|
|
59
59
|
CANVAS_DOC_SUBSTRATE_PATH,
|
|
60
|
+
CANVAS_STRUCTURED_OUTPUT_PATH,
|
|
60
61
|
];
|
|
61
62
|
// ---------------------------------------------------------------------------
|
|
62
63
|
// Two-axis model resolution: (provider × strength) → concrete `model:thinking`.
|
|
@@ -42,9 +42,12 @@ export interface SpawnChildOpts {
|
|
|
42
42
|
* a <crtr-wake> block is prepended to the kickoff so the newborn knows a clock
|
|
43
43
|
* birthed it. */
|
|
44
44
|
wakeOrigin?: WakeOrigin;
|
|
45
|
-
/** Pin the node to a model
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
/** Pin the node to a model spec — exact `provider/id`, `provider/tier`
|
|
46
|
+
* (e.g. `openai/ultra`), bare tier (ultra/strong/medium/light), or family
|
|
47
|
+
* alias (opus/sonnet/haiku) — overriding the persona's declared default.
|
|
48
|
+
* The RAW spec is persisted to `meta.model_override` (buildLaunchSpec
|
|
49
|
+
* re-normalizes through the current ladders on every revive/polymorph, so
|
|
50
|
+
* ladder config edits keep propagating). Omit to use the persona default. */
|
|
48
51
|
model?: string;
|
|
49
52
|
/** Select the profile this node runs under — an exact profile id or a unique
|
|
50
53
|
* manifest name, validated through `loadProfileManifest`. Omit to INHERIT the
|
|
@@ -17,8 +17,21 @@
|
|
|
17
17
|
// • otherwise → a TERMINAL node with nothing live to wait for and no
|
|
18
18
|
// final pushed. Re-prompt it to finish or escalate.
|
|
19
19
|
import { hasActiveLiveSubscription, hasPendingSelfTrigger, getNode } from '../canvas/index.js';
|
|
20
|
+
import { hasPendingStructuredOutput, readOutputRequest } from './structured-output.js';
|
|
20
21
|
export const STALL_REPROMPT = "You've stopped but you're not waiting on anyone and haven't finished. " +
|
|
21
22
|
'Run `crtr push final "<result>"` if the work is done, or `crtr human ask` if you are blocked or need the user.';
|
|
23
|
+
function structuredOutputReprompt(nodeId) {
|
|
24
|
+
let schema = '{}';
|
|
25
|
+
try {
|
|
26
|
+
const request = readOutputRequest(nodeId);
|
|
27
|
+
if (request !== null)
|
|
28
|
+
schema = JSON.stringify(request.schema, null, 2);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* keep a deterministic reprompt even if the schema file was corrupted */
|
|
32
|
+
}
|
|
33
|
+
return `You must call the \`submit\` tool with a result matching the required schema before you can stop. You cannot finish or go dormant any other way while this request is pending.\n\nRequired schema:\n\n\`\`\`json\n${schema}\n\`\`\``;
|
|
34
|
+
}
|
|
22
35
|
/** Decide what to do when a node stops. Pure given the canvas + this turn's
|
|
23
36
|
* signals — the stophook supplies the signals and enacts the action. */
|
|
24
37
|
export function evaluateStop(nodeId, signals) {
|
|
@@ -32,6 +45,12 @@ export function evaluateStop(nodeId, signals) {
|
|
|
32
45
|
if (node !== null && (node.status === 'done' || node.status === 'dead' || node.status === 'canceled')) {
|
|
33
46
|
return { action: 'allow', reason: 'finished' };
|
|
34
47
|
}
|
|
48
|
+
// A pending structured-output request overrides every normal dormancy path:
|
|
49
|
+
// terminal mode must finish through submit, and one-off mode must answer the
|
|
50
|
+
// outstanding request before the node is freed to stop normally again.
|
|
51
|
+
if (hasPendingStructuredOutput(nodeId)) {
|
|
52
|
+
return { action: 'reprompt', reason: 'stalled', message: structuredOutputReprompt(nodeId) };
|
|
53
|
+
}
|
|
35
54
|
// A RESIDENT node is interactable / human-driven and is never forced to submit
|
|
36
55
|
// a final: stopping to go dormant is always legitimate (the inbox or the human
|
|
37
56
|
// wakes it). Keyed on lifecycle, not parent — whether it has a parent doesn't
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type StructuredOutputMode = 'terminal' | 'oneoff';
|
|
2
|
+
export interface StructuredOutputRequest {
|
|
3
|
+
mode: StructuredOutputMode;
|
|
4
|
+
schema: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export declare function outputSchemaPath(nodeId: string): string;
|
|
7
|
+
export declare function outputResultPath(nodeId: string): string;
|
|
8
|
+
export declare function parseOutputSchemaValue(value: string | undefined): Record<string, unknown> | null;
|
|
9
|
+
export declare function writeOutputSchema(nodeId: string, mode: StructuredOutputMode, schema: Record<string, unknown>): string;
|
|
10
|
+
export declare function readOutputRequest(nodeId: string): StructuredOutputRequest | null;
|
|
11
|
+
export declare function removeOutputSchema(nodeId: string): void;
|
|
12
|
+
export declare function hasPendingStructuredOutput(nodeId: string): boolean;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { nodeDir, contextDir } from '../canvas/paths.js';
|
|
4
|
+
import { InputError } from '../io.js';
|
|
5
|
+
export function outputSchemaPath(nodeId) {
|
|
6
|
+
return `${nodeDir(nodeId)}/output-schema.json`;
|
|
7
|
+
}
|
|
8
|
+
export function outputResultPath(nodeId) {
|
|
9
|
+
return `${contextDir(nodeId)}/result.json`;
|
|
10
|
+
}
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
export function parseOutputSchemaValue(value) {
|
|
15
|
+
const raw = value?.trim();
|
|
16
|
+
if (raw === undefined || raw === '')
|
|
17
|
+
return null;
|
|
18
|
+
let text;
|
|
19
|
+
let source;
|
|
20
|
+
if (raw.startsWith('{')) {
|
|
21
|
+
text = raw;
|
|
22
|
+
source = 'inline JSON';
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
const path = resolve(raw);
|
|
26
|
+
source = path;
|
|
27
|
+
try {
|
|
28
|
+
text = readFileSync(path, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw new InputError({
|
|
32
|
+
error: 'bad_output_schema',
|
|
33
|
+
message: `could not read output schema from ${path}: ${err.message}`,
|
|
34
|
+
field: 'output-schema',
|
|
35
|
+
next: 'Pass a readable JSON Schema file path, or inline JSON beginning with `{`.',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
throw new InputError({
|
|
45
|
+
error: 'bad_output_schema',
|
|
46
|
+
message: `output schema from ${source} is not valid JSON: ${err.message}`,
|
|
47
|
+
field: 'output-schema',
|
|
48
|
+
next: 'Pass valid JSON whose top-level value is an object.',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (!isObject(parsed)) {
|
|
52
|
+
throw new InputError({
|
|
53
|
+
error: 'bad_output_schema',
|
|
54
|
+
message: `output schema from ${source} must be a JSON object`,
|
|
55
|
+
field: 'output-schema',
|
|
56
|
+
next: 'Pass a JSON Schema object, not an array, string, number, boolean, or null.',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
export function writeOutputSchema(nodeId, mode, schema) {
|
|
62
|
+
const path = outputSchemaPath(nodeId);
|
|
63
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
64
|
+
const request = { mode, schema };
|
|
65
|
+
writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`, 'utf8');
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
export function readOutputRequest(nodeId) {
|
|
69
|
+
const path = outputSchemaPath(nodeId);
|
|
70
|
+
if (!existsSync(path))
|
|
71
|
+
return null;
|
|
72
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
73
|
+
if (!isObject(parsed))
|
|
74
|
+
return null;
|
|
75
|
+
const mode = parsed['mode'];
|
|
76
|
+
const schema = parsed['schema'];
|
|
77
|
+
if ((mode !== 'terminal' && mode !== 'oneoff') || !isObject(schema))
|
|
78
|
+
return null;
|
|
79
|
+
return { mode, schema };
|
|
80
|
+
}
|
|
81
|
+
export function removeOutputSchema(nodeId) {
|
|
82
|
+
const path = outputSchemaPath(nodeId);
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(path);
|
|
85
|
+
}
|
|
86
|
+
catch { /* already gone */ }
|
|
87
|
+
}
|
|
88
|
+
export function hasPendingStructuredOutput(nodeId) {
|
|
89
|
+
return existsSync(outputSchemaPath(nodeId));
|
|
90
|
+
}
|