@north-light/crouter 0.3.162 → 0.3.163
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/00-runtime-base.md +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
- package/dist/builtin-memory/init.md +38 -0
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +34 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +14 -3
- package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
- package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
- package/dist/clients/attach/render/chat-view.js +8 -3
- package/dist/clients/attach/render/crtr-output.js +31 -2
- package/dist/clients/attach/render/edit-diff.js +26 -2
- package/dist/clients/attach/render/tool-calls.d.ts +7 -2
- package/dist/clients/attach/render/tool-calls.js +25 -4
- package/dist/clients/attach/session/connection.d.ts +1 -1
- package/dist/clients/attach/session/editor-frame.d.ts +5 -1
- package/dist/clients/attach/session/editor-frame.js +21 -15
- package/dist/clients/attach/session/identity.d.ts +1 -1
- package/dist/clients/attach/viewer.js +621 -619
- package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
- package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
- package/dist/commands/__tests__/human.test.js +2 -0
- package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
- package/dist/commands/__tests__/search-contents.test.js +52 -0
- package/dist/commands/api-client.js +1 -0
- package/dist/commands/human/prompts.js +2 -2
- package/dist/commands/human/shared.d.ts +1 -0
- package/dist/commands/human/shared.js +7 -4
- package/dist/commands/memory/lint.d.ts +6 -0
- package/dist/commands/memory/lint.js +177 -35
- package/dist/commands/memory/read.js +22 -1
- package/dist/commands/memory/write.js +18 -4
- package/dist/commands/memory.js +1 -1
- package/dist/commands/search/contents.js +2 -2
- package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
- package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
- package/dist/commands/sys/setup-wizard.d.ts +8 -1
- package/dist/commands/sys/setup-wizard.js +209 -58
- package/dist/commands/sys/sync-deps.js +14 -18
- package/dist/commands/sys/sync-project-guidance.js +16 -19
- package/dist/core/__tests__/migration.test.js +8 -3
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
- package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
- package/dist/core/__tests__/on-read-identity.test.js +8 -15
- package/dist/core/__tests__/revive.test.js +2 -2
- package/dist/core/__tests__/tmux-surface.test.js +10 -4
- package/dist/core/__tests__/worktree.test.js +3 -3
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +18 -11
- package/dist/core/canvas/labels.d.ts +4 -5
- package/dist/core/canvas/labels.js +9 -9
- package/dist/core/command-manifests/schema.js +18 -5
- package/dist/core/command.js +17 -7
- package/dist/core/configured-clis/invoker.js +2 -0
- package/dist/core/help.d.ts +3 -1
- package/dist/core/help.js +5 -2
- package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
- package/dist/core/keybindings/catalog.d.ts +3 -2
- package/dist/core/keybindings/catalog.js +34 -25
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/resolve.js +8 -0
- package/dist/core/keybindings/types.d.ts +6 -1
- package/dist/core/memory/doc-link-grammar.d.ts +20 -0
- package/dist/core/memory/doc-link-grammar.js +110 -0
- package/dist/core/memory-resolver.d.ts +5 -0
- package/dist/core/memory-resolver.js +12 -1
- package/dist/core/runtime/bearings.d.ts +8 -8
- package/dist/core/runtime/bearings.js +20 -16
- package/dist/core/runtime/broker.js +3 -13
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/launch.d.ts +1 -1
- package/dist/core/runtime/launch.js +1 -1
- package/dist/core/runtime/tmux.js +136 -100
- package/dist/core/scope.js +4 -5
- package/dist/core/substrate/ceiling.d.ts +3 -6
- package/dist/core/substrate/ceiling.js +13 -15
- package/dist/core/substrate/index.d.ts +2 -3
- package/dist/core/substrate/index.js +2 -2
- package/dist/core/substrate/on-read.d.ts +4 -13
- package/dist/core/substrate/on-read.js +142 -262
- package/dist/core/substrate/render.js +4 -4
- package/dist/core/substrate/schema.d.ts +3 -2
- package/dist/core/substrate/schema.js +5 -5
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
- package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
- package/dist/pi-extensions/canvas-tool-guide.js +74 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { ApiCanvasSource } from '../api-client.js';
|
|
4
|
+
const detail = {
|
|
5
|
+
node_id: 'node-1',
|
|
6
|
+
name: 'test',
|
|
7
|
+
kind: 'general',
|
|
8
|
+
mode: 'base',
|
|
9
|
+
lifecycle: 'resident',
|
|
10
|
+
status: 'active',
|
|
11
|
+
cwd: '/tmp/project',
|
|
12
|
+
host_kind: 'broker',
|
|
13
|
+
profile_id: null,
|
|
14
|
+
parent: null,
|
|
15
|
+
created: '2026-07-31T00:00:00.000Z',
|
|
16
|
+
intent: null,
|
|
17
|
+
waiting_for: null,
|
|
18
|
+
pi_pid: null,
|
|
19
|
+
final_report: null,
|
|
20
|
+
finalized_at: null,
|
|
21
|
+
description: 'generated-description',
|
|
22
|
+
icon: '\uf1fc',
|
|
23
|
+
edges: { parent: null, spawned_by: null, subscribes_to: [], subscribers: [], children: [] },
|
|
24
|
+
paths: {
|
|
25
|
+
node_dir: '/tmp/node',
|
|
26
|
+
context_dir: '/tmp/node/context',
|
|
27
|
+
reports_dir: '/tmp/node/reports',
|
|
28
|
+
meta_path: '/tmp/node/meta.json',
|
|
29
|
+
inbox_path: '/tmp/node/inbox.jsonl',
|
|
30
|
+
transcript_path: '/tmp/node/transcript.jsonl',
|
|
31
|
+
view_socket: '/tmp/node/view.sock',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
test('the API canvas source retains the node icon needed by a reloaded viewer', async () => {
|
|
35
|
+
const source = new ApiCanvasSource({ getNode: async () => detail });
|
|
36
|
+
assert.equal((await source.getNode(detail.node_id))?.icon, '\uf1fc');
|
|
37
|
+
});
|
|
@@ -167,6 +167,8 @@ describe('human ask: stdin question + strict deck', () => {
|
|
|
167
167
|
assert.match(out, /^ stdin\s+optional\. Simple open-text ask body\./m);
|
|
168
168
|
assert.match(out, /single-quoted heredoc/);
|
|
169
169
|
assert.match(out, /--context-file PATH/);
|
|
170
|
+
assert.match(out, /bodyPath is a relative path to the body file, resolved from the directory containing the deck JSON/);
|
|
171
|
+
assert.match(out, /paths escaping with \.\. or symlinks are rejected; absolute paths are unsupported/);
|
|
170
172
|
assert.doesNotMatch(out, /crtr human ask "<question>"/);
|
|
171
173
|
});
|
|
172
174
|
test('reads an explicit title and literal question bytes', async () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { parseArgv } from '../../core/command.js';
|
|
4
|
+
import { contentsLeaf } from '../search/contents.js';
|
|
5
|
+
async function requestBody(argv) {
|
|
6
|
+
const previousKey = process.env['EXA_API_KEY'];
|
|
7
|
+
const previousFetch = globalThis.fetch;
|
|
8
|
+
let body;
|
|
9
|
+
let requestCount = 0;
|
|
10
|
+
process.env['EXA_API_KEY'] = 'test-key';
|
|
11
|
+
globalThis.fetch = (async (_input, init) => {
|
|
12
|
+
requestCount++;
|
|
13
|
+
body = JSON.parse(String(init?.body));
|
|
14
|
+
return new Response(JSON.stringify({ results: [], statuses: [] }), {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: { 'content-type': 'application/json' },
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
const input = await parseArgv(contentsLeaf.help.params ?? [], argv);
|
|
21
|
+
await contentsLeaf.run(input);
|
|
22
|
+
assert.equal(requestCount, 1);
|
|
23
|
+
assert.ok(body !== undefined);
|
|
24
|
+
return body;
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
globalThis.fetch = previousFetch;
|
|
28
|
+
if (previousKey === undefined)
|
|
29
|
+
delete process.env['EXA_API_KEY'];
|
|
30
|
+
else
|
|
31
|
+
process.env['EXA_API_KEY'] = previousKey;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
test('search contents accepts separate URL positional argv tokens in one request', async () => {
|
|
35
|
+
const body = await requestBody(['https://example.com/one', 'https://example.com/two']);
|
|
36
|
+
assert.deepEqual(body['urls'], ['https://example.com/one', 'https://example.com/two']);
|
|
37
|
+
});
|
|
38
|
+
test('search contents flattens comma and whitespace-separated URLs across positional tokens', async () => {
|
|
39
|
+
const body = await requestBody([
|
|
40
|
+
'https://example.com/one,https://example.com/two',
|
|
41
|
+
'https://example.com/three https://example.com/four',
|
|
42
|
+
]);
|
|
43
|
+
assert.deepEqual(body['urls'], [
|
|
44
|
+
'https://example.com/one',
|
|
45
|
+
'https://example.com/two',
|
|
46
|
+
'https://example.com/three',
|
|
47
|
+
'https://example.com/four',
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
test('search contents still rejects zero URL positional tokens', async () => {
|
|
51
|
+
await assert.rejects(() => parseArgv(contentsLeaf.help.params ?? [], []), (error) => error instanceof Error && error.message.includes('required parameter is missing'));
|
|
52
|
+
});
|
|
@@ -14,7 +14,7 @@ import { tmuxServerReachable } from '../../core/spawn.js';
|
|
|
14
14
|
import { BrokerClient, BrokerUnavailableError } from '../../core/broker-client/index.js';
|
|
15
15
|
import { markStopSignal } from '../../core/runtime/stop-signals.js';
|
|
16
16
|
import { validateDeck, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
|
|
17
|
-
import { DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
|
|
17
|
+
import { BODY_PATH_CONTRACT, DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
|
|
18
18
|
import { inboxOpenInstruction, inboxPopupHint } from '../../core/keybindings/index.js';
|
|
19
19
|
/** The asking node's id, or null when run from a bare shell (no parent to route to). */
|
|
20
20
|
function askingNode() {
|
|
@@ -159,7 +159,7 @@ export const humanAsk = defineLeaf({
|
|
|
159
159
|
{ kind: 'flag', name: 'title', type: 'string', required: false, constraint: 'Required with stdin: a short ≤~4-word inbox topic. Mutually exclusive with --context-file, whose JSON carries its own required title.' },
|
|
160
160
|
{ kind: 'flag', name: 'subtitle', type: 'string', required: false, constraint: 'Required with stdin: one plain-English sentence stating the decision, recommendation, and stakes. Mutually exclusive with --context-file, whose JSON requires every interaction subtitle.' },
|
|
161
161
|
{ kind: 'stdin', name: 'question', required: false, constraint: "Simple open-text ask body. Pipe markdown from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes. Requires --title and --subtitle; mutually exclusive with --context-file." },
|
|
162
|
-
{ kind: 'context-file', name: 'deck', required: false, constraint:
|
|
162
|
+
{ kind: 'context-file', name: 'deck', required: false, constraint: `Strict structured deck JSON with a required deck title and non-empty subtitle on every interaction. ${BODY_PATH_CONTRACT}. Use this for multiple interactions, options, or other deck structure. Mutually exclusive with --title, --subtitle, and the question shorthand.`, shape: DECK_SCHEMA_HINT },
|
|
163
163
|
],
|
|
164
164
|
output: [
|
|
165
165
|
{ name: 'job_id', type: 'string', required: true, constraint: 'Node id of this human interaction. Its answer is pushed to your inbox when the human responds.' },
|
|
@@ -5,14 +5,17 @@ import { existsSync, mkdirSync, readdirSync, renameSync, rmdirSync, statSync } f
|
|
|
5
5
|
import { readConfig } from '../../core/config.js';
|
|
6
6
|
import { nodesRoot } from '../../core/canvas/paths.js';
|
|
7
7
|
import { interactionState, listInboxRoots, registerInboxRoot, unregisterInboxRoot } from '@crouton-kit/humanloop';
|
|
8
|
+
export const BODY_PATH_CONTRACT = 'bodyPath is a relative path to the body file, resolved from the directory containing the deck JSON; ' +
|
|
9
|
+
'it must stay inside that directory, and paths escaping with .. or symlinks are rejected; absolute paths are unsupported';
|
|
8
10
|
export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title (required short inbox topic), ' +
|
|
9
11
|
'source?:{sessionName?,askedBy?,blockedSince?}, interactions:[{id, ' +
|
|
10
12
|
'title (short ≤~4-word inbox topic that identifies the decision for a cold reader), ' +
|
|
11
13
|
'subtitle (required ONE plain-English sentence stating the decision, your recommendation, and its stakes), ' +
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
`(body?|bodyPath?) (a self-contained decision brief: give the minimum plain-language ` +
|
|
15
|
+
`context and tradeoffs needed to decide, then place supporting evidence, chronology, and ` +
|
|
16
|
+
`technical detail under ## Details; the ONLY place for long or rich prose — directive-flavored ` +
|
|
17
|
+
`markdown rendered by termrender). ${BODY_PATH_CONTRACT}. ` +
|
|
18
|
+
'body and bodyPath are mutually exclusive, options:[{id,label,description?}], multiSelect?, ' +
|
|
16
19
|
'allowFreetext?, freetextLabel?, ' +
|
|
17
20
|
"kind?:'notify'|'decision'|'context'|'error'}]}.";
|
|
18
21
|
export function resolveMaxPanes() {
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** The length rule. Caps are computed from the RAW rung strings — an invalid
|
|
2
|
+
* rung already fails the schema check, so it simply matches no cap here.
|
|
3
|
+
* `docName` is the doc's canonical resolver identity (explicit frontmatter
|
|
4
|
+
* `name`, else the normalized path-derived name) — persona layers under
|
|
5
|
+
* `kinds/` are recognized by it and never capped. */
|
|
6
|
+
export declare function lintBodyLength(fm: Record<string, unknown>, body: string, docName: string): string | null;
|
|
1
7
|
/** Schema checks for a doc living in a substrate memory dir: a memory store
|
|
2
8
|
* holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
|
|
3
9
|
* error here (elsewhere it just means "not a substrate doc"). Both rungs are
|
|
@@ -13,10 +13,75 @@ import { listInstalledPlugins, listInstalledPluginsInRoot } from '../../core/res
|
|
|
13
13
|
import { pluginMemoryDir, projectScopeRoots, scopeMemoryDir } from '../../core/scope.js';
|
|
14
14
|
import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
|
|
15
15
|
import { getDefaultProfileId } from '../../core/profiles/default-binding.js';
|
|
16
|
-
import { isDocKind, RUNGS } from '../../core/substrate/schema.js';
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
import { isDocKind, normalizeDocName, resolveDocName, RUNGS } from '../../core/substrate/schema.js';
|
|
17
|
+
import { displayName } from '../../core/substrate/ceiling.js';
|
|
18
|
+
import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
|
|
19
|
+
import { listAllMemoryDocs } from '../../core/memory-resolver.js';
|
|
20
|
+
const VALID_RUNGS = [...RUNGS];
|
|
19
21
|
const RUNG_FIELDS = ['system-prompt-visibility', 'file-read-visibility'];
|
|
22
|
+
/** Rung-scaled body-length caps, measured in WORDS (frontmatter excluded).
|
|
23
|
+
* Words, not lines: house style writes each paragraph as ONE logical line
|
|
24
|
+
* and lets the editor soft-wrap, so a line count measures wrapping style
|
|
25
|
+
* rather than context cost — words track what the reader actually pays.
|
|
26
|
+
*
|
|
27
|
+
* `content` on the system axis inlines the whole body into every agent's
|
|
28
|
+
* system prompt at boot; `preview`/`content` on the file-read axis surface
|
|
29
|
+
* the whole body through a workspace mount or a matching file read — both
|
|
30
|
+
* cap at 1000 words. `preview` on the system axis routes a deliberate
|
|
31
|
+
* reader into the whole body, so its cap is the longest doc worth reading
|
|
32
|
+
* end-to-end (calibrated to the humanizer doc, 2936 words). `name`/`none`
|
|
33
|
+
* rungs never cap: such a doc is only reached deliberately (browse or a
|
|
34
|
+
* [[link]]), so its length is the reader's choice. The strictest
|
|
35
|
+
* applicable cap wins.
|
|
36
|
+
*
|
|
37
|
+
* Persona layers — canonical names under `kinds/`, the docs the prompt
|
|
38
|
+
* render composes into an agent's persona — are structurally exempt: an
|
|
39
|
+
* agent reads its whole persona by construction, so persona length is a
|
|
40
|
+
* persona-design choice, not an authoring smell. Suppression elsewhere is
|
|
41
|
+
* deliberate and per-doc: `lint-ignore: length` in the frontmatter,
|
|
42
|
+
* surfaced ONLY by the finding itself — never advertised in authoring
|
|
43
|
+
* help. */
|
|
44
|
+
const SYSTEM_CONTENT_MAX_WORDS = 1000;
|
|
45
|
+
const FILE_READ_MAX_WORDS = 1000;
|
|
46
|
+
const SYSTEM_PREVIEW_MAX_WORDS = 3000;
|
|
47
|
+
/** Rules a doc may suppress via frontmatter `lint-ignore`. */
|
|
48
|
+
const SUPPRESSIBLE_RULES = ['length'];
|
|
49
|
+
function ignoresRule(fm, rule) {
|
|
50
|
+
const v = fm['lint-ignore'];
|
|
51
|
+
return v === rule || (Array.isArray(v) && v.includes(rule));
|
|
52
|
+
}
|
|
53
|
+
function countBodyWords(body) {
|
|
54
|
+
const trimmed = body.trim();
|
|
55
|
+
return trimmed === '' ? 0 : trimmed.split(/\s+/).length;
|
|
56
|
+
}
|
|
57
|
+
/** The length rule. Caps are computed from the RAW rung strings — an invalid
|
|
58
|
+
* rung already fails the schema check, so it simply matches no cap here.
|
|
59
|
+
* `docName` is the doc's canonical resolver identity (explicit frontmatter
|
|
60
|
+
* `name`, else the normalized path-derived name) — persona layers under
|
|
61
|
+
* `kinds/` are recognized by it and never capped. */
|
|
62
|
+
export function lintBodyLength(fm, body, docName) {
|
|
63
|
+
if (ignoresRule(fm, 'length'))
|
|
64
|
+
return null;
|
|
65
|
+
// Persona exemption: render composes an agent's persona from the docs
|
|
66
|
+
// resolving under `kinds/...`, and an agent reads its whole persona by
|
|
67
|
+
// construction — persona length is persona design, never an authoring smell.
|
|
68
|
+
if (docName === 'kinds' || docName.startsWith('kinds/'))
|
|
69
|
+
return null;
|
|
70
|
+
const sys = fm['system-prompt-visibility'];
|
|
71
|
+
const file = fm['file-read-visibility'];
|
|
72
|
+
const words = countBodyWords(body);
|
|
73
|
+
const remedy = 'Keep the load-bearing core here and split the depth into [[linked]] reference docs saved at `none` visibility on both axes (the link is how they are found, so they cost nothing until followed). Keep it whole — `lint-ignore: length` in the frontmatter — only when every reader who surfaces this doc genuinely benefits from reading 100% of it, or it is one indivisible body of knowledge; then splitting just adds hops.';
|
|
74
|
+
if (sys === 'content' && words > SYSTEM_CONTENT_MAX_WORDS) {
|
|
75
|
+
return `body is ${words} words but system-prompt-visibility: content inlines every word into every agent's system prompt at boot — capped at ${SYSTEM_CONTENT_MAX_WORDS} words (system-prompt preview gets ${SYSTEM_PREVIEW_MAX_WORDS}; name/none are never capped on that axis). ${remedy}`;
|
|
76
|
+
}
|
|
77
|
+
if ((file === 'content' || file === 'preview') && words > FILE_READ_MAX_WORDS) {
|
|
78
|
+
return `body is ${words} words, over the ${FILE_READ_MAX_WORDS}-word cap for file-read routed rungs (file-read-visibility: preview|content surfaces the whole body through a workspace mount or a matching file read; name/none are never capped on that axis). ${remedy}`;
|
|
79
|
+
}
|
|
80
|
+
if (sys === 'preview' && words > SYSTEM_PREVIEW_MAX_WORDS) {
|
|
81
|
+
return `body is ${words} words, over the ${SYSTEM_PREVIEW_MAX_WORDS}-word cap for system-prompt preview (the routing line invites every reader into the whole body, so the cap is the longest doc worth reading end-to-end; system-prompt content is capped at ${SYSTEM_CONTENT_MAX_WORDS} words; name/none are never capped on that axis). ${remedy}`;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
20
85
|
/** Schema checks for a doc living in a substrate memory dir: a memory store
|
|
21
86
|
* holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
|
|
22
87
|
* error here (elsewhere it just means "not a substrate doc"). Both rungs are
|
|
@@ -53,10 +118,20 @@ export function lintSubstrateSchema(fm) {
|
|
|
53
118
|
return `invalid gate: ${JSON.stringify(gate)} (expected a field→matcher object)`;
|
|
54
119
|
}
|
|
55
120
|
const appliesTo = fm['applies-to'];
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
121
|
+
let appliesToGlobs;
|
|
122
|
+
if (appliesTo !== undefined) {
|
|
123
|
+
if (typeof appliesTo === 'string') {
|
|
124
|
+
appliesToGlobs = [appliesTo];
|
|
125
|
+
}
|
|
126
|
+
else if (Array.isArray(appliesTo) && appliesTo.every((g) => typeof g === 'string')) {
|
|
127
|
+
appliesToGlobs = appliesTo;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
return `invalid applies-to: ${JSON.stringify(appliesTo)} (expected one non-empty file glob or a non-empty list of file globs)`;
|
|
131
|
+
}
|
|
132
|
+
if (appliesToGlobs.length === 0 || appliesToGlobs.some((glob) => glob.trim() === '')) {
|
|
133
|
+
return `invalid applies-to: ${JSON.stringify(appliesTo)} (expected one non-empty file glob or a non-empty list of file globs)`;
|
|
134
|
+
}
|
|
60
135
|
}
|
|
61
136
|
// read-when (Stream A on-read frontmatter trigger): same well-formed-object
|
|
62
137
|
// contract as gate — a non-object is inert (never fires), so catch it here.
|
|
@@ -64,6 +139,11 @@ export function lintSubstrateSchema(fm) {
|
|
|
64
139
|
if (readWhen !== undefined && (readWhen === null || typeof readWhen !== 'object' || Array.isArray(readWhen))) {
|
|
65
140
|
return `invalid read-when: ${JSON.stringify(readWhen)} (expected a field→matcher object)`;
|
|
66
141
|
}
|
|
142
|
+
// Every authored file-read surface needs an explicit event boundary. Runtime
|
|
143
|
+
// parsing stays tolerant so one bad doc cannot break an agent's context.
|
|
144
|
+
if (fm['file-read-visibility'] !== 'none' && appliesToGlobs === undefined) {
|
|
145
|
+
return `missing applies-to: file-read-visibility is \`${fm['file-read-visibility']}\`, so this memory must declare when it surfaces. Use \`applies-to: "."\` for context loaded when cwd or a selected profile mounts this project store, use a non-empty file glob (or YAML list) for context loaded only after a matching file is read, or set \`file-read-visibility: none\` when this memory has no file-context route. File globs match the read file's absolute path, basename, or path relative to the memory's owning project root. \`read-when\` matches file metadata but does not replace the required workspace/file boundary. Run \`crtr memory write -h\` before changing routing frontmatter.`;
|
|
146
|
+
}
|
|
67
147
|
// A dead on-read trigger: an explicit applies-to/read-when with nothing to
|
|
68
148
|
// surface (file-read-visibility none) can never fire — flag it loudly rather
|
|
69
149
|
// than store a silent no-op.
|
|
@@ -80,14 +160,26 @@ export function lintSubstrateSchema(fm) {
|
|
|
80
160
|
if (rationale !== undefined && typeof rationale !== 'string') {
|
|
81
161
|
return `invalid rationale: ${JSON.stringify(rationale)} (expected a string)`;
|
|
82
162
|
}
|
|
163
|
+
const lintIgnore = fm['lint-ignore'];
|
|
164
|
+
if (lintIgnore !== undefined) {
|
|
165
|
+
const rules = Array.isArray(lintIgnore) ? lintIgnore : [lintIgnore];
|
|
166
|
+
if (rules.length === 0 || !rules.every((r) => typeof r === 'string' && SUPPRESSIBLE_RULES.includes(r))) {
|
|
167
|
+
return `invalid lint-ignore: ${JSON.stringify(lintIgnore)} (the only suppressible rule is \`length\`)`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
83
170
|
return null;
|
|
84
171
|
}
|
|
85
172
|
/** Strict-parse one file; push a finding on a YAML error, then run the
|
|
86
|
-
* schema check when the file lives in a substrate store
|
|
87
|
-
|
|
173
|
+
* schema check when the file lives in a substrate store, then validate every
|
|
174
|
+
* `[[canonical/name]]` doc link in the body against the exact resolvable
|
|
175
|
+
* corpus. A dangling link is an authoring error caught HERE, never silently
|
|
176
|
+
* carried; leaf-name fallback is deliberately excluded so links stay stable
|
|
177
|
+
* as the graph grows and another document acquires the same leaf name. */
|
|
178
|
+
function lintFile(file, substrateStore, findings, corpusNames, fallbackName) {
|
|
88
179
|
let fm;
|
|
180
|
+
let body;
|
|
89
181
|
try {
|
|
90
|
-
fm = parseFrontmatterGeneric(readText(file))
|
|
182
|
+
({ data: fm, body } = parseFrontmatterGeneric(readText(file)));
|
|
91
183
|
}
|
|
92
184
|
catch (e) {
|
|
93
185
|
const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
@@ -99,18 +191,31 @@ function lintFile(file, substrateStore, findings) {
|
|
|
99
191
|
const schemaError = lintSubstrateSchema(fm);
|
|
100
192
|
if (schemaError !== null)
|
|
101
193
|
findings.push({ path: file, error: schemaError });
|
|
194
|
+
if (fm !== null) {
|
|
195
|
+
const lengthError = lintBodyLength(fm, body, resolveDocName(fm, fallbackName));
|
|
196
|
+
if (lengthError !== null)
|
|
197
|
+
findings.push({ path: file, error: lengthError });
|
|
198
|
+
}
|
|
199
|
+
for (const name of docLinkNames(body)) {
|
|
200
|
+
if (!corpusNames.has(name)) {
|
|
201
|
+
findings.push({
|
|
202
|
+
path: file,
|
|
203
|
+
error: `dangling doc link [[${name}]]: no memory document has that exact canonical name — retarget the link (\`crtr memory find ${name.split('/').pop()}\`) or drop it`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
102
207
|
}
|
|
103
208
|
export const lintLeaf = defineLeaf({
|
|
104
209
|
name: 'lint',
|
|
105
|
-
description: 'validate frontmatter across the whole bounded document corpus',
|
|
106
|
-
whenToUse: 'you authored or migrated documents and want the authoring-time gate: strict-parse every doc in the bounded corpus (the substrate memory stores) and fail loudly on any invalid YAML
|
|
210
|
+
description: 'validate frontmatter and body length across the whole bounded document corpus',
|
|
211
|
+
whenToUse: 'you authored or migrated documents and want the authoring-time gate: strict-parse every doc in the bounded corpus (the substrate memory stores) and fail loudly on any invalid YAML, substrate schema violation, body longer than its visibility rung earns, or dangling `[[canonical/name]]` doc link; also validates the root INDEX.md front door of every project managed by the selected profile and warns when an unprofiled working directory has no front door. Run it before shipping doc changes; CI-friendly (non-zero exit on findings, warnings remain non-fatal).',
|
|
107
212
|
help: {
|
|
108
213
|
name: 'memory lint',
|
|
109
|
-
summary: 'strict-parse
|
|
214
|
+
summary: 'strict-parse the bounded memory corpus and validate project root INDEX front doors',
|
|
110
215
|
params: [],
|
|
111
216
|
output: [
|
|
112
217
|
{ name: 'checked', type: 'number', required: true, constraint: 'Files linted across all corpora.' },
|
|
113
|
-
{ name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus counts: {memory_stores (files), profile_projects (managed dirs checked for
|
|
218
|
+
{ name: 'corpora', type: 'object', required: true, constraint: 'Per-corpus counts: {memory_stores (files), profile_projects (managed dirs checked for a root INDEX.md front door)}.' },
|
|
114
219
|
{ name: 'findings', type: 'object[]', required: true, constraint: 'One row per failure: {path, error}. Empty when green.' },
|
|
115
220
|
{ name: 'warnings', type: 'string[]', required: true, constraint: 'Non-fatal authoring gaps detected for the directory where lint ran.' },
|
|
116
221
|
],
|
|
@@ -121,14 +226,7 @@ export const lintLeaf = defineLeaf({
|
|
|
121
226
|
const findings = [];
|
|
122
227
|
const warnings = [];
|
|
123
228
|
let memoryCount = 0;
|
|
124
|
-
// An INDEX gives the directory a discoverable, routed front door. It is
|
|
125
|
-
// intentionally advisory: lint still validates every available store when
|
|
126
|
-
// invoked from a directory that has not adopted project memory yet.
|
|
127
229
|
const workingDirectory = process.cwd();
|
|
128
|
-
const workingDirectoryIndex = join(workingDirectory, '.crouter', 'memory', 'INDEX.md');
|
|
129
|
-
if (!pathExists(workingDirectoryIndex)) {
|
|
130
|
-
warnings.push(`${workingDirectory}: no .crouter/memory/INDEX.md — create the directory's routed front door with \`crtr memory write INDEX -h\``);
|
|
131
|
-
}
|
|
132
230
|
// Substrate memory stores (ancestor projects/user/builtin), schema-aware.
|
|
133
231
|
// A source's corpus is its NATIVE memory dir plus each enabled plugin's memory dir —
|
|
134
232
|
// plugin docs are substrate docs and lint through the same schema gate.
|
|
@@ -137,14 +235,24 @@ export const lintLeaf = defineLeaf({
|
|
|
137
235
|
// path never yields a doc name, so e.g. the maintainer store shipped
|
|
138
236
|
// at builtin-memory/.crouter can never register) — lint must not
|
|
139
237
|
// flag files the substrate can never load.
|
|
238
|
+
// Exact canonical names in the current resolvable corpus. INDEX docs also
|
|
239
|
+
// expose their folded bare-directory name (`taste`, not `taste/INDEX`).
|
|
240
|
+
const corpusNames = new Set();
|
|
241
|
+
for (const doc of listAllMemoryDocs(undefined, true)) {
|
|
242
|
+
const canonical = displayName(doc.name);
|
|
243
|
+
if (canonical !== '')
|
|
244
|
+
corpusNames.add(canonical);
|
|
245
|
+
}
|
|
140
246
|
const lintDir = (dir) => {
|
|
141
247
|
if (!dir || !pathExists(dir))
|
|
142
248
|
return;
|
|
143
249
|
for (const file of walkFiles(dir, (n) => n.endsWith('.md'), (d) => d.startsWith('.'))) {
|
|
144
|
-
|
|
250
|
+
const relPath = relative(dir, file).split(sep).join('/');
|
|
251
|
+
if (!relPath)
|
|
145
252
|
continue;
|
|
146
253
|
memoryCount += 1;
|
|
147
|
-
|
|
254
|
+
const fallbackName = normalizeDocName(relPath.replace(/\.md$/, ''));
|
|
255
|
+
lintFile(file, basename(file) !== 'MEMORY.md', findings, corpusNames, fallbackName);
|
|
148
256
|
}
|
|
149
257
|
};
|
|
150
258
|
for (const root of projectScopeRoots()) {
|
|
@@ -161,32 +269,66 @@ export const lintLeaf = defineLeaf({
|
|
|
161
269
|
lintDir(pluginMemoryDir(plugin));
|
|
162
270
|
}
|
|
163
271
|
}
|
|
164
|
-
// Profile coverage:
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// agent there blind, so its absence is an authoring finding like any
|
|
169
|
-
// schema violation. (The managed dirs' stores themselves already lint
|
|
170
|
-
// above: projectScopeRoots folds the profile's project pointers in.)
|
|
272
|
+
// Profile coverage: each managed project has one direct root INDEX front
|
|
273
|
+
// door. It enters first-message context through the workspace-open `.`
|
|
274
|
+
// route, so its exact contract is knowledge + system none/file content +
|
|
275
|
+
// applies-to containing `.`.
|
|
171
276
|
let profileProjects = 0;
|
|
277
|
+
let profileResolved = false;
|
|
172
278
|
const profileIdOrName = process.env['CRTR_PROFILE_ID'] || getDefaultProfileId(process.cwd());
|
|
173
279
|
if (profileIdOrName) {
|
|
174
280
|
try {
|
|
175
281
|
const { profileId, manifest } = loadProfileManifest(profileIdOrName);
|
|
282
|
+
profileResolved = true;
|
|
176
283
|
lintDir(profileMemoryDir(profileId));
|
|
177
284
|
for (const dir of manifest.projects) {
|
|
178
285
|
profileProjects += 1;
|
|
179
|
-
|
|
286
|
+
const indexPath = join(dir, '.crouter', 'memory', 'INDEX.md');
|
|
287
|
+
if (!pathExists(indexPath)) {
|
|
180
288
|
findings.push({
|
|
181
289
|
path: dir,
|
|
182
|
-
error: `profile "${manifest.name}" manages this dir but it has no .crouter/memory/
|
|
290
|
+
error: `profile "${manifest.name}" manages this dir but it has no .crouter/memory/INDEX.md root front door — author INDEX in that exact project with kind knowledge, system-prompt-visibility none, file-read-visibility content, and applies-to "."; run \`crtr memory write -h\` first`,
|
|
183
291
|
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const fm = parseFrontmatterGeneric(readText(indexPath)).data;
|
|
296
|
+
const problems = [];
|
|
297
|
+
if (fm?.kind !== 'knowledge')
|
|
298
|
+
problems.push('kind must be `knowledge`');
|
|
299
|
+
if (fm?.['system-prompt-visibility'] !== 'none')
|
|
300
|
+
problems.push('system-prompt-visibility must be `none`');
|
|
301
|
+
if (fm?.['file-read-visibility'] !== 'content')
|
|
302
|
+
problems.push('file-read-visibility must be `content`');
|
|
303
|
+
const applies = fm?.['applies-to'];
|
|
304
|
+
const globs = typeof applies === 'string'
|
|
305
|
+
? [applies]
|
|
306
|
+
: Array.isArray(applies) && applies.every((value) => typeof value === 'string')
|
|
307
|
+
? applies
|
|
308
|
+
: [];
|
|
309
|
+
if (!globs.some((glob) => glob.trim() === '.'))
|
|
310
|
+
problems.push('applies-to must include `.`');
|
|
311
|
+
if (problems.length > 0) {
|
|
312
|
+
findings.push({ path: indexPath, error: `invalid project root front door: ${problems.join('; ')}` });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
|
|
317
|
+
findings.push({ path: indexPath, error: `invalid project root front door YAML: ${msg}` });
|
|
184
318
|
}
|
|
185
319
|
}
|
|
186
320
|
}
|
|
187
321
|
catch {
|
|
188
|
-
// Unresolvable profile
|
|
189
|
-
//
|
|
322
|
+
// Unresolvable profile: profile coverage does not apply. Treat cwd as
|
|
323
|
+
// unprofiled for the advisory front-door warning below.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Without a selected, resolvable profile there is no managed-project
|
|
327
|
+
// finding to carry this signal, so keep a non-fatal cwd adoption warning.
|
|
328
|
+
if (!profileResolved) {
|
|
329
|
+
const workingDirectoryIndex = join(workingDirectory, '.crouter', 'memory', 'INDEX.md');
|
|
330
|
+
if (!pathExists(workingDirectoryIndex)) {
|
|
331
|
+
warnings.push(`${workingDirectory}: no .crouter/memory/INDEX.md root front door — author INDEX for this project with kind knowledge, system-prompt-visibility none, file-read-visibility content, and applies-to "."; run \`crtr memory write -h\` first`);
|
|
190
332
|
}
|
|
191
333
|
}
|
|
192
334
|
const checked = memoryCount;
|
|
@@ -203,7 +345,7 @@ export const lintLeaf = defineLeaf({
|
|
|
203
345
|
checked,
|
|
204
346
|
findings: findings.map((f) => ({ path: f.path, error: f.error })),
|
|
205
347
|
warnings,
|
|
206
|
-
next: 'Fix each doc (quote YAML values containing `: `; use a valid kind/rung/gate
|
|
348
|
+
next: 'Fix each doc (quote YAML values containing `: `; use a valid kind/rung/gate; route every non-`none` file-read rung with `applies-to: "."` for workspace-open context or a non-empty file glob for matching reads; otherwise use file-read-visibility `none`; retarget or drop dangling [[links]]; split an over-length body into [[linked]] `none`-visibility reference docs); make every profile-managed project root INDEX match the front-door contract; then re-run `crtr memory lint`.',
|
|
207
349
|
});
|
|
208
350
|
}
|
|
209
351
|
return {
|
|
@@ -2,8 +2,10 @@ import { defineLeaf } from '../../core/command.js';
|
|
|
2
2
|
import { CrtrError, notFound } from '../../core/errors.js';
|
|
3
3
|
import { resolveMemoryDoc } from '../../core/memory-resolver.js';
|
|
4
4
|
import { effectiveDocKind } from '../../core/substrate/schema.js';
|
|
5
|
+
import { displayName } from '../../core/substrate/ceiling.js';
|
|
5
6
|
import { interpolateNodePaths } from '../../core/canvas/paths.js';
|
|
6
7
|
import { readText } from '../../core/fs-utils.js';
|
|
8
|
+
import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
|
|
7
9
|
import { MEMORY_KINDS } from './shared.js';
|
|
8
10
|
export const readLeaf = defineLeaf({
|
|
9
11
|
name: 'read',
|
|
@@ -23,6 +25,7 @@ export const readLeaf = defineLeaf({
|
|
|
23
25
|
{ name: 'scope', type: 'string', required: true, constraint: 'Scope the document was resolved from: node, project, profile, user, or builtin.' },
|
|
24
26
|
{ name: 'path', type: 'string', required: true, constraint: 'Absolute path to the document on disk — direct edits here are for a small body-only tweak; any routing/frontmatter/visibility change goes through `crtr memory write`.' },
|
|
25
27
|
{ name: 'content', type: 'string', required: true, constraint: 'Document body. Frontmatter stripped unless --frontmatter is set.' },
|
|
28
|
+
{ name: 'links', type: 'string[]', required: false, constraint: 'Canonical names this document links to via `[[name]]` that resolve in the current corpus — further reading, loaded only on demand with `crtr memory read <name>`. Omitted when the body carries no resolvable links.' },
|
|
26
29
|
{ name: 'follow_up', type: 'string', required: true, constraint: 'Hints at variant flags or next commands.' },
|
|
27
30
|
],
|
|
28
31
|
outputKind: 'object',
|
|
@@ -55,13 +58,31 @@ export const readLeaf = defineLeaf({
|
|
|
55
58
|
// reading generically (env unset) keeps the token literal.
|
|
56
59
|
const nodeId = process.env['CRTR_NODE_ID'];
|
|
57
60
|
const content = nodeId ? interpolateNodePaths(raw, nodeId) : raw;
|
|
61
|
+
// `[[name]]` doc links are pointers, never transclusion: surface which
|
|
62
|
+
// linked names actually resolve so the reader can follow one when the
|
|
63
|
+
// task needs that depth, without ever auto-loading a linked body.
|
|
64
|
+
const links = docLinkNames(doc.body).filter((linkName) => {
|
|
65
|
+
try {
|
|
66
|
+
// `resolveMemoryDoc` permits leaf-name fallback for interactive reads;
|
|
67
|
+
// a stored graph edge does not. Compare the resolved canonical name
|
|
68
|
+
// so an old shorthand never masquerades as a first-class doc link.
|
|
69
|
+
return displayName(resolveMemoryDoc(linkName).name) === linkName;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
58
75
|
return {
|
|
59
76
|
name: doc.name,
|
|
60
77
|
kind,
|
|
61
78
|
scope: doc.scope,
|
|
62
79
|
path: doc.path,
|
|
63
80
|
content,
|
|
64
|
-
|
|
81
|
+
...(links.length > 0 ? { links } : {}),
|
|
82
|
+
follow_up: (links.length > 0
|
|
83
|
+
? 'The `[[name]]` links in the body are further reading — follow one with `crtr memory read <name>` only when the task needs that depth. '
|
|
84
|
+
: '') +
|
|
85
|
+
'Use --frontmatter on this same command to inspect the YAML frontmatter, or edit `path` directly for a body-only tweak. Browse the inventory with `crtr memory list`.',
|
|
65
86
|
};
|
|
66
87
|
}
|
|
67
88
|
throw notFound(`memory document not found: ${nameRaw}`, {
|