@north-light/crouter 0.3.216 → 0.3.218
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/api/client.d.ts +5 -2
- package/dist/api/client.js +7 -1
- package/dist/api/dto/canvas.d.ts +9 -0
- package/dist/api/dto/nodes.d.ts +27 -1
- package/dist/builtin-memory/04-base-worker.md +7 -1
- package/dist/builtin-memory/internal/plugins.md +67 -0
- package/dist/builtin-memory/memory-read-orientation.md +13 -0
- package/dist/clients/attach/chrome/bash-jobs.js +1 -1
- package/dist/clients/attach/photon_rs_bg.wasm +0 -0
- package/dist/clients/attach/viewer.js +516 -516
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +2 -2
- package/dist/clients/inbox/tui/input.js +12 -8
- package/dist/clients/inbox/tui/slots.d.ts +3 -1
- package/dist/clients/inbox/tui/slots.js +9 -3
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/memory/edit.js +6 -1
- package/dist/commands/memory/lint.d.ts +1 -6
- package/dist/commands/memory/lint.js +18 -126
- package/dist/commands/memory/list.d.ts +1 -0
- package/dist/commands/memory/list.js +17 -2
- package/dist/commands/memory/read.js +15 -6
- package/dist/commands/memory/shared.d.ts +33 -3
- package/dist/commands/memory/shared.js +180 -1
- package/dist/commands/memory/write.js +6 -1
- package/dist/commands/pkg/browse/catalog.js +2 -0
- package/dist/commands/pkg/browse/model.d.ts +4 -1
- package/dist/commands/pkg/plugin-manage.js +209 -148
- package/dist/core/__tests__/human-cancel-guard.test.js +30 -12
- package/dist/core/bash-jobs.d.ts +2 -5
- package/dist/core/bash-jobs.js +4 -8
- package/dist/core/command-plugins/bundle.d.ts +4 -1
- package/dist/core/command-plugins/bundle.js +16 -2
- package/dist/core/human/__tests__/page.test.js +12 -0
- package/dist/core/human/component-docs.js +6 -1
- package/dist/core/human/page-schema.d.ts +14 -0
- package/dist/core/human/page-schema.js +24 -7
- package/dist/core/human/page.d.ts +2 -0
- package/dist/core/human/page.js +17 -6
- package/dist/core/human/scan.d.ts +5 -4
- package/dist/core/human/scan.js +7 -4
- package/dist/core/human/tickets.js +9 -2
- package/dist/core/io.js +3 -2
- package/dist/core/manifest.d.ts +2 -0
- package/dist/core/manifest.js +5 -0
- package/dist/core/memory/extensions.d.ts +30 -0
- package/dist/core/memory/extensions.js +219 -0
- package/dist/core/preview-result-path.d.ts +4 -0
- package/dist/core/preview-result-path.js +25 -0
- package/dist/core/substrate/frontmatter-validation.d.ts +13 -0
- package/dist/core/substrate/frontmatter-validation.js +101 -0
- package/dist/core/substrate/index.d.ts +1 -0
- package/dist/core/substrate/index.js +1 -0
- package/dist/daemon/api/__tests__/nodes-activity-query.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/nodes-activity-query.test.js +101 -0
- package/dist/daemon/api/handlers/canvas.js +3 -0
- package/dist/daemon/api/handlers/inbox.js +4 -3
- package/dist/daemon/api/handlers/nodes.d.ts +1 -0
- package/dist/daemon/api/handlers/nodes.js +101 -19
- package/dist/daemon/api/handlers/reports.d.ts +4 -0
- package/dist/daemon/api/handlers/reports.js +14 -8
- package/dist/daemon/crtrd.js +7 -5
- package/dist/daemon/manage.d.ts +3 -0
- package/dist/daemon/manage.js +14 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -3
- package/dist/pi-extensions/canvas-bash-valve.js +19 -6
- package/dist/pi-extensions/canvas-preview-result.d.ts +0 -8
- package/dist/pi-extensions/canvas-preview-result.js +9 -23
- package/dist/types.d.ts +31 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { sep } from 'node:path';
|
|
2
|
+
import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
|
|
3
|
+
const LOCAL_FIELD_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
4
|
+
const DECLARATION_KEYS = new Set(['type', 'values', 'default', 'write_help', 'edit_help']);
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
export function isMemoryExtensionScalar(value) {
|
|
9
|
+
return typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value));
|
|
10
|
+
}
|
|
11
|
+
function declarationError(path, message) {
|
|
12
|
+
return new Error(`invalid memory_extensions declaration at ${path}: ${message}`);
|
|
13
|
+
}
|
|
14
|
+
function validHelp(value) {
|
|
15
|
+
return typeof value === 'string' && value.trim() !== '' && !/[\r\n]/.test(value);
|
|
16
|
+
}
|
|
17
|
+
function validDefault(declaration, value) {
|
|
18
|
+
if (!isMemoryExtensionScalar(value))
|
|
19
|
+
return false;
|
|
20
|
+
if (declaration.type === 'enum')
|
|
21
|
+
return typeof value === 'string' && declaration.values.includes(value);
|
|
22
|
+
return typeof value === declaration.type;
|
|
23
|
+
}
|
|
24
|
+
/** Parse the closed declaration addendum. Callers that accept a plugin manifest
|
|
25
|
+
* invoke this at their install/preflight boundary; normal runtime catalogs only
|
|
26
|
+
* admit declarations which have already cleared that boundary. */
|
|
27
|
+
export function validateMemoryExtensionDeclarations(raw) {
|
|
28
|
+
if (!isRecord(raw))
|
|
29
|
+
throw declarationError('memory_extensions', 'expected a non-empty object');
|
|
30
|
+
const declarations = {};
|
|
31
|
+
const fields = Object.entries(raw);
|
|
32
|
+
if (fields.length === 0)
|
|
33
|
+
throw declarationError('memory_extensions', 'must not be empty');
|
|
34
|
+
for (const [field, value] of fields) {
|
|
35
|
+
const path = `memory_extensions.${field}`;
|
|
36
|
+
if (!LOCAL_FIELD_NAME.test(field)) {
|
|
37
|
+
throw declarationError(path, 'field names must be lowercase kebab-case');
|
|
38
|
+
}
|
|
39
|
+
if (!isRecord(value))
|
|
40
|
+
throw declarationError(path, 'expected a declaration object');
|
|
41
|
+
for (const key of Object.keys(value)) {
|
|
42
|
+
if (!DECLARATION_KEYS.has(key))
|
|
43
|
+
throw declarationError(path, `unknown key \`${key}\``);
|
|
44
|
+
}
|
|
45
|
+
const type = value['type'];
|
|
46
|
+
if (type !== 'boolean' && type !== 'string' && type !== 'number' && type !== 'enum') {
|
|
47
|
+
throw declarationError(`${path}.type`, 'expected boolean, string, number, or enum');
|
|
48
|
+
}
|
|
49
|
+
if (!validHelp(value['write_help']))
|
|
50
|
+
throw declarationError(`${path}.write_help`, 'expected a nonblank single-line string');
|
|
51
|
+
if (!validHelp(value['edit_help']))
|
|
52
|
+
throw declarationError(`${path}.edit_help`, 'expected a nonblank single-line string');
|
|
53
|
+
if (type === 'enum') {
|
|
54
|
+
const values = value['values'];
|
|
55
|
+
if (!Array.isArray(values) || values.length === 0 || !values.every((entry) => typeof entry === 'string' && entry !== '')) {
|
|
56
|
+
throw declarationError(`${path}.values`, 'expected a non-empty string list');
|
|
57
|
+
}
|
|
58
|
+
if (new Set(values).size !== values.length)
|
|
59
|
+
throw declarationError(`${path}.values`, 'values must be unique');
|
|
60
|
+
const declaration = {
|
|
61
|
+
type,
|
|
62
|
+
values: [...values],
|
|
63
|
+
write_help: value['write_help'],
|
|
64
|
+
edit_help: value['edit_help'],
|
|
65
|
+
};
|
|
66
|
+
if ('default' in value) {
|
|
67
|
+
if (!validDefault(declaration, value['default'])) {
|
|
68
|
+
throw declarationError(`${path}.default`, 'must be one of the declared enum values');
|
|
69
|
+
}
|
|
70
|
+
declaration.default = value['default'];
|
|
71
|
+
}
|
|
72
|
+
declarations[field] = declaration;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if ('values' in value)
|
|
76
|
+
throw declarationError(`${path}.values`, 'is allowed only for enum declarations');
|
|
77
|
+
const declaration = {
|
|
78
|
+
type,
|
|
79
|
+
write_help: value['write_help'],
|
|
80
|
+
edit_help: value['edit_help'],
|
|
81
|
+
};
|
|
82
|
+
if ('default' in value) {
|
|
83
|
+
if (!validDefault(declaration, value['default'])) {
|
|
84
|
+
throw declarationError(`${path}.default`, `must be a ${type}`);
|
|
85
|
+
}
|
|
86
|
+
declaration.default = value['default'];
|
|
87
|
+
}
|
|
88
|
+
declarations[field] = declaration;
|
|
89
|
+
}
|
|
90
|
+
return declarations;
|
|
91
|
+
}
|
|
92
|
+
/** Throw when a manifest carries an invalid addendum; absent is valid. */
|
|
93
|
+
export function validatePluginMemoryExtensions(manifest) {
|
|
94
|
+
if (manifest.memory_extensions !== undefined)
|
|
95
|
+
validateMemoryExtensionDeclarations(manifest.memory_extensions);
|
|
96
|
+
}
|
|
97
|
+
function declarationsFromManifest(manifest, strict) {
|
|
98
|
+
if (manifest.memory_extensions === undefined)
|
|
99
|
+
return undefined;
|
|
100
|
+
try {
|
|
101
|
+
return validateMemoryExtensionDeclarations(manifest.memory_extensions);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (strict)
|
|
105
|
+
throw error;
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function catalogForPlugins(groups, enabledOnly, candidate) {
|
|
110
|
+
const catalog = {};
|
|
111
|
+
const shadowed = new Set();
|
|
112
|
+
if (candidate !== undefined) {
|
|
113
|
+
// A candidate that removes its addendum must still hide an installed copy
|
|
114
|
+
// while that package's own documents are being validated.
|
|
115
|
+
shadowed.add(candidate.name);
|
|
116
|
+
const declarations = declarationsFromManifest(candidate, true);
|
|
117
|
+
if (declarations !== undefined)
|
|
118
|
+
catalog[candidate.name] = declarations;
|
|
119
|
+
}
|
|
120
|
+
for (const plugins of groups) {
|
|
121
|
+
for (const plugin of plugins) {
|
|
122
|
+
if (enabledOnly && !plugin.enabled)
|
|
123
|
+
continue;
|
|
124
|
+
const namespace = plugin.manifest.name;
|
|
125
|
+
if (shadowed.has(namespace) || catalog[namespace] !== undefined)
|
|
126
|
+
continue;
|
|
127
|
+
const declarations = declarationsFromManifest(plugin.manifest, false);
|
|
128
|
+
if (declarations !== undefined)
|
|
129
|
+
catalog[namespace] = declarations;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return catalog;
|
|
133
|
+
}
|
|
134
|
+
/** Declarations for a native document. Project documents use their owning
|
|
135
|
+
* project scope only, then user scope; profile/node/user docs use user scope. */
|
|
136
|
+
function pluginGroupsForDocument(doc) {
|
|
137
|
+
if (doc.scope === 'project') {
|
|
138
|
+
const marker = `${sep}.crouter${sep}`;
|
|
139
|
+
const markerIndex = doc.path.lastIndexOf(marker);
|
|
140
|
+
if (markerIndex >= 0) {
|
|
141
|
+
const root = doc.path.slice(0, markerIndex + `${sep}.crouter`.length);
|
|
142
|
+
return [listInstalledPluginsInRoot('project', root), listInstalledPlugins('user')];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [listInstalledPlugins('user')];
|
|
146
|
+
}
|
|
147
|
+
/** Installed declarations used for validation and authoring. Disabled plugins
|
|
148
|
+
* remain present so existing values can still be checked and edited. */
|
|
149
|
+
export function memoryExtensionValidationCatalog(doc, options = {}) {
|
|
150
|
+
return catalogForPlugins(pluginGroupsForDocument(doc), false, options.candidate);
|
|
151
|
+
}
|
|
152
|
+
/** Winning enabled declarations used only to interpret structured output. */
|
|
153
|
+
export function memoryExtensionEffectiveCatalog(doc) {
|
|
154
|
+
return catalogForPlugins(pluginGroupsForDocument(doc), true);
|
|
155
|
+
}
|
|
156
|
+
/** Validate the raw `extensions` frontmatter value. Unresolved namespaces are
|
|
157
|
+
* intentionally reported rather than discarded: removal leaves raw metadata
|
|
158
|
+
* intact but lint-invalid until its plugin returns. */
|
|
159
|
+
export function validateMemoryExtensionValues(raw, catalog) {
|
|
160
|
+
if (raw === undefined)
|
|
161
|
+
return [];
|
|
162
|
+
if (!isRecord(raw))
|
|
163
|
+
return [{ path: 'extensions', message: 'expected a mapping' }];
|
|
164
|
+
const issues = [];
|
|
165
|
+
for (const [namespace, fields] of Object.entries(raw)) {
|
|
166
|
+
const namespacePath = `extensions.${namespace}`;
|
|
167
|
+
if (!isRecord(fields)) {
|
|
168
|
+
issues.push({ path: namespacePath, message: 'expected a mapping' });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const declarations = catalog[namespace];
|
|
172
|
+
if (declarations === undefined) {
|
|
173
|
+
issues.push({ path: namespacePath, message: 'unresolved extension namespace' });
|
|
174
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
175
|
+
if (!isMemoryExtensionScalar(value)) {
|
|
176
|
+
issues.push({ path: `${namespacePath}.${field}`, message: 'expected a string, boolean, or finite number' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
182
|
+
const path = `${namespacePath}.${field}`;
|
|
183
|
+
if (!isMemoryExtensionScalar(value)) {
|
|
184
|
+
issues.push({ path, message: 'expected a string, boolean, or finite number' });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const declaration = declarations[field];
|
|
188
|
+
if (declaration === undefined) {
|
|
189
|
+
issues.push({ path, message: 'undeclared extension field' });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!validDefault(declaration, value)) {
|
|
193
|
+
const expected = declaration.type === 'enum' ? `one of ${declaration.values.join('|')}` : declaration.type;
|
|
194
|
+
issues.push({ path, message: `expected ${expected}` });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return issues;
|
|
199
|
+
}
|
|
200
|
+
/** Effective plugin metadata for structured consumers. It overlays only valid
|
|
201
|
+
* explicit scalars onto defaults and never mutates or serializes frontmatter. */
|
|
202
|
+
export function projectEffectiveMemoryExtensions(raw, catalog) {
|
|
203
|
+
const source = isRecord(raw) ? raw : {};
|
|
204
|
+
const effective = {};
|
|
205
|
+
for (const [namespace, declarations] of Object.entries(catalog)) {
|
|
206
|
+
const values = {};
|
|
207
|
+
const explicit = isRecord(source[namespace]) ? source[namespace] : {};
|
|
208
|
+
for (const [field, declaration] of Object.entries(declarations)) {
|
|
209
|
+
if (declaration.default !== undefined)
|
|
210
|
+
values[field] = declaration.default;
|
|
211
|
+
const value = explicit[field];
|
|
212
|
+
if (validDefault(declaration, value))
|
|
213
|
+
values[field] = value;
|
|
214
|
+
}
|
|
215
|
+
if (Object.keys(values).length > 0)
|
|
216
|
+
effective[namespace] = values;
|
|
217
|
+
}
|
|
218
|
+
return effective;
|
|
219
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const PREVIEW_RESULT_PATH_ENV = "CRTR_PREVIEW_RESULT_PATH";
|
|
2
|
+
/** Deterministic from the tool call id, so the spawning side and the result
|
|
3
|
+
* side derive the same path without sharing state. */
|
|
4
|
+
export declare function previewResultPath(toolCallId: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The private structured-result side channel between a crtr CLI invocation and
|
|
2
|
+
// the canvas broker that spawned it: the broker names one file per bash tool
|
|
3
|
+
// call, the leaf mirrors its record there, and the broker attaches the record
|
|
4
|
+
// to Pi's ToolResult.details.
|
|
5
|
+
//
|
|
6
|
+
// The path travels in the child process environment. It must never be spliced
|
|
7
|
+
// into the command text — the executed script has to stay exactly what the
|
|
8
|
+
// agent wrote, because `cmd.sh`, job inspection, and the doc substrate's
|
|
9
|
+
// `command` surfaces all read that text as the agent's own words.
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
export const PREVIEW_RESULT_PATH_ENV = 'CRTR_PREVIEW_RESULT_PATH';
|
|
13
|
+
/** A tool call id is unique only within one broker: some provider adapters
|
|
14
|
+
* synthesize a missing id from a process-local counter and a millisecond
|
|
15
|
+
* clock, so two brokers can mint the same one and collide on a shared file.
|
|
16
|
+
* Node id and pid are equal across both sides of the transport (they run in
|
|
17
|
+
* the same broker process) without either side sharing state. */
|
|
18
|
+
function brokerToken() {
|
|
19
|
+
return `${encodeURIComponent(process.env['CRTR_NODE_ID'] ?? 'no-node')}-${process.pid}`;
|
|
20
|
+
}
|
|
21
|
+
/** Deterministic from the tool call id, so the spawning side and the result
|
|
22
|
+
* side derive the same path without sharing state. */
|
|
23
|
+
export function previewResultPath(toolCallId) {
|
|
24
|
+
return join(tmpdir(), 'crouter-preview-results', `${brokerToken()}-${encodeURIComponent(toolCallId)}.json`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Strict validation of one raw routing entry. Runtime parsing deliberately
|
|
2
|
+
* tolerates malformed docs; this authoring/preflight contract does not. */
|
|
3
|
+
export declare function lintSubstrateSurfaces(value: unknown): string | null;
|
|
4
|
+
/** Strict substrate frontmatter checker shared by lint and package preflight. */
|
|
5
|
+
export declare function lintSubstrateFrontmatter(fm: Record<string, unknown> | null): string | null;
|
|
6
|
+
/** Compatibility name for consumers that adopted lint's former export. */
|
|
7
|
+
export declare const lintSubstrateSchema: typeof lintSubstrateFrontmatter;
|
|
8
|
+
/** Parsed routing entries for checks that run after strict validation. */
|
|
9
|
+
export declare function parsedSubstrateSurfaces(fm: Record<string, unknown>): {
|
|
10
|
+
on: string;
|
|
11
|
+
at: string;
|
|
12
|
+
match?: string[];
|
|
13
|
+
}[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { isDocKind, parseSubstrateFrontmatter, SURFACE_EVENTS, SURFACE_RUNGS } from './schema.js';
|
|
2
|
+
const RETIRED_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
|
|
3
|
+
const SURFACE_ENTRY_KEYS = ['on', 'match', 'match-frontmatter', 'at'];
|
|
4
|
+
const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
|
|
5
|
+
/** Strict validation of one raw routing entry. Runtime parsing deliberately
|
|
6
|
+
* tolerates malformed docs; this authoring/preflight contract does not. */
|
|
7
|
+
export function lintSubstrateSurfaces(value) {
|
|
8
|
+
if (value === undefined)
|
|
9
|
+
return null;
|
|
10
|
+
if (!Array.isArray(value))
|
|
11
|
+
return `invalid surfaces: ${JSON.stringify(value)} (expected a list of {on, at, match?, match-frontmatter?} entries)`;
|
|
12
|
+
for (const raw of value) {
|
|
13
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
14
|
+
return `invalid surfaces entry: ${JSON.stringify(raw)} (expected an {on, at, match?, match-frontmatter?} object)`;
|
|
15
|
+
}
|
|
16
|
+
const entry = raw;
|
|
17
|
+
for (const key of Object.keys(entry)) {
|
|
18
|
+
if (!SURFACE_ENTRY_KEYS.includes(key)) {
|
|
19
|
+
return `invalid surfaces entry: unknown key \`${key}\` (an entry carries only on, match, match-frontmatter, at)`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const on = entry['on'];
|
|
23
|
+
if (typeof on !== 'string' || !SURFACE_EVENTS.includes(on)) {
|
|
24
|
+
return `invalid surfaces entry \`on\`: ${JSON.stringify(on)} (expected ${SURFACE_EVENTS.join('|')})`;
|
|
25
|
+
}
|
|
26
|
+
const at = entry['at'];
|
|
27
|
+
if (typeof at !== 'string' || !SURFACE_RUNGS.includes(at)) {
|
|
28
|
+
return `invalid surfaces entry \`at\`: ${JSON.stringify(at)} (expected ${SURFACE_RUNGS.join('|')})`;
|
|
29
|
+
}
|
|
30
|
+
let hasMatch = false;
|
|
31
|
+
if (entry['match'] !== undefined) {
|
|
32
|
+
if (on === 'boot' || on === 'workspace-open') {
|
|
33
|
+
return `invalid surfaces entry: \`match\` is meaningless on \`${on}\` — the entry's presence is the match; drop it`;
|
|
34
|
+
}
|
|
35
|
+
const match = entry['match'];
|
|
36
|
+
const globs = typeof match === 'string' ? [match] : Array.isArray(match) && match.every((glob) => typeof glob === 'string') ? match : null;
|
|
37
|
+
if (globs === null || globs.length === 0 || globs.some((glob) => glob.trim() === '')) {
|
|
38
|
+
return `invalid surfaces entry \`match\`: ${JSON.stringify(match)} (expected a non-empty glob or non-empty glob list)`;
|
|
39
|
+
}
|
|
40
|
+
hasMatch = true;
|
|
41
|
+
}
|
|
42
|
+
const matchFrontmatter = entry['match-frontmatter'];
|
|
43
|
+
if (matchFrontmatter !== undefined) {
|
|
44
|
+
if (on !== 'read')
|
|
45
|
+
return 'invalid surfaces entry: `match-frontmatter` is a `read`-event predicate only';
|
|
46
|
+
if (matchFrontmatter === null || typeof matchFrontmatter !== 'object' || Array.isArray(matchFrontmatter)) {
|
|
47
|
+
return `invalid surfaces entry \`match-frontmatter\`: ${JSON.stringify(matchFrontmatter)} (expected a field→matcher object)`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if ((on === 'read' || on === 'memory-read' || on === 'command') && !hasMatch && matchFrontmatter === undefined) {
|
|
51
|
+
return `invalid surfaces entry: a \`${on}\` entry requires \`match\`${on === 'read' ? ' or `match-frontmatter`' : ''}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** Strict substrate frontmatter checker shared by lint and package preflight. */
|
|
57
|
+
export function lintSubstrateFrontmatter(fm) {
|
|
58
|
+
if (fm === null)
|
|
59
|
+
return 'missing frontmatter: a memory store doc requires `kind: knowledge|preference`';
|
|
60
|
+
if (!isDocKind(fm.kind))
|
|
61
|
+
return `invalid kind: ${JSON.stringify(fm.kind)} (expected knowledge|preference)`;
|
|
62
|
+
if ('when' in fm || 'why' in fm) {
|
|
63
|
+
return 'retired `when`/`why` keys: merge them into one `when-and-why-to-read` line — "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the benefit unlocked by reading, not the document summary, rule, or obedience rationale.';
|
|
64
|
+
}
|
|
65
|
+
if (typeof fm['when-and-why-to-read'] !== 'string' || fm['when-and-why-to-read'].trim() === '') {
|
|
66
|
+
return 'missing `when-and-why-to-read`: one routing line — "When <circumstance>, this <kind> should be read because <broader downstream payoff>." WHY is the benefit unlocked by reading, not the document summary, rule, or obedience rationale.';
|
|
67
|
+
}
|
|
68
|
+
for (const field of RETIRED_FIELDS) {
|
|
69
|
+
if (field in fm) {
|
|
70
|
+
return `retired \`${field}\` key: the visibility axes were replaced by \`surfaces\` event routing — run \`crtr sys migrate\` to convert old-format docs, or author \`surfaces\` entries by hand (\`crtr memory write -h\`)`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (fm['unlisted'] !== undefined && typeof fm['unlisted'] !== 'boolean') {
|
|
74
|
+
return `invalid unlisted: ${JSON.stringify(fm['unlisted'])} (expected a boolean)`;
|
|
75
|
+
}
|
|
76
|
+
const surfacesError = lintSubstrateSurfaces(fm['surfaces']);
|
|
77
|
+
if (surfacesError !== null)
|
|
78
|
+
return surfacesError;
|
|
79
|
+
if (fm.gate !== undefined && (fm.gate === null || typeof fm.gate !== 'object' || Array.isArray(fm.gate))) {
|
|
80
|
+
return `invalid gate: ${JSON.stringify(fm.gate)} (expected a field→matcher object)`;
|
|
81
|
+
}
|
|
82
|
+
if (fm.slash !== undefined && typeof fm.slash !== 'boolean') {
|
|
83
|
+
return `invalid slash: ${JSON.stringify(fm.slash)} (expected a boolean)`;
|
|
84
|
+
}
|
|
85
|
+
if (fm.rationale !== undefined && typeof fm.rationale !== 'string') {
|
|
86
|
+
return `invalid rationale: ${JSON.stringify(fm.rationale)} (expected a string)`;
|
|
87
|
+
}
|
|
88
|
+
if (fm['lint-ignore'] !== undefined) {
|
|
89
|
+
const rules = Array.isArray(fm['lint-ignore']) ? fm['lint-ignore'] : [fm['lint-ignore']];
|
|
90
|
+
if (rules.length === 0 || !rules.every((rule) => typeof rule === 'string' && SUPPRESSIBLE_RULES.includes(rule))) {
|
|
91
|
+
return `invalid lint-ignore: ${JSON.stringify(fm['lint-ignore'])} (suppressible rules: ${SUPPRESSIBLE_RULES.map((rule) => `\`${rule}\``).join(', ')})`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/** Compatibility name for consumers that adopted lint's former export. */
|
|
97
|
+
export const lintSubstrateSchema = lintSubstrateFrontmatter;
|
|
98
|
+
/** Parsed routing entries for checks that run after strict validation. */
|
|
99
|
+
export function parsedSubstrateSurfaces(fm) {
|
|
100
|
+
return parseSubstrateFrontmatter(fm)?.surfaces ?? [];
|
|
101
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, SURFACE_EVENTS, SURFACE_RUNGS, bootRung, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, normalizeNameSegment, normalizeDocName, resolveDocName, } from './schema.js';
|
|
2
2
|
export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc, SurfaceEntry, SurfaceEvent, SurfaceRung } from './schema.js';
|
|
3
|
+
export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
|
|
3
4
|
export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
|
|
4
5
|
export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
|
|
5
6
|
export { deliveredAtOrAbove, recordDelivery, mergeInjectedDocs } from './injected-store.js';
|
|
@@ -16,6 +16,7 @@ parseSubstrateFrontmatter, parseSubstrateDoc, previewLine,
|
|
|
16
16
|
normalizeNameSegment, normalizeDocName,
|
|
17
17
|
// substrate identity (explicit-name-then-path-fallback)
|
|
18
18
|
resolveDocName, } from './schema.js';
|
|
19
|
+
export { lintSubstrateFrontmatter, lintSubstrateSchema, lintSubstrateSurfaces, parsedSubstrateSurfaces } from './frontmatter-validation.js';
|
|
19
20
|
export { matchesReadEntry, matchesMemoryReadEntry, matchesCommandEntry, readDeliveryRung, workspaceOpenRung, memoryReadDeliveryRung, commandDeliveryRung, owningRootOf, underOwningRoot } from './surface-match.js';
|
|
20
21
|
export { dirDedupKey, docsByName, isDirName, ancestorDirsOf, renderDirListing } from './listings.js';
|
|
21
22
|
export { deliveredAtOrAbove, recordDelivery, mergeInjectedDocs } from './injected-store.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { after, before, beforeEach, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createNode } from '../../../core/canvas/canvas.js';
|
|
7
|
+
import { closeDb } from '../../../core/canvas/db.js';
|
|
8
|
+
import { reportsDir } from '../../../core/canvas/paths.js';
|
|
9
|
+
import { invalidateTicketCounts } from '../../../core/canvas/attention.js';
|
|
10
|
+
import { bindTicketReplyRoute } from '../../../core/human/convention.js';
|
|
11
|
+
import { ticketDir } from '../../../core/human/root.js';
|
|
12
|
+
import { submitPage } from '../../../core/human/tickets.js';
|
|
13
|
+
import { handleList } from '../handlers/nodes.js';
|
|
14
|
+
const previousCrtrHome = process.env['CRTR_HOME'];
|
|
15
|
+
let home = '';
|
|
16
|
+
before(() => {
|
|
17
|
+
closeDb();
|
|
18
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-nodes-activity-query-'));
|
|
19
|
+
process.env['CRTR_HOME'] = home;
|
|
20
|
+
});
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
closeDb();
|
|
23
|
+
invalidateTicketCounts();
|
|
24
|
+
rmSync(home, { recursive: true, force: true });
|
|
25
|
+
mkdirSync(home, { recursive: true });
|
|
26
|
+
});
|
|
27
|
+
after(() => {
|
|
28
|
+
closeDb();
|
|
29
|
+
rmSync(home, { recursive: true, force: true });
|
|
30
|
+
if (previousCrtrHome === undefined)
|
|
31
|
+
delete process.env['CRTR_HOME'];
|
|
32
|
+
else
|
|
33
|
+
process.env['CRTR_HOME'] = previousCrtrHome;
|
|
34
|
+
});
|
|
35
|
+
function node(id, overrides = {}) {
|
|
36
|
+
return {
|
|
37
|
+
node_id: id,
|
|
38
|
+
name: id,
|
|
39
|
+
created: '2026-08-17T00:00:00.000Z',
|
|
40
|
+
cwd: home,
|
|
41
|
+
kind: 'developer',
|
|
42
|
+
mode: 'base',
|
|
43
|
+
lifecycle: 'terminal',
|
|
44
|
+
status: 'active',
|
|
45
|
+
parent: null,
|
|
46
|
+
profile_id: 'activity-profile-7f3a9c01',
|
|
47
|
+
launch: { model: 'anthropic/original', extensions: [], env: {} },
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function list(query) {
|
|
52
|
+
const result = handleList({
|
|
53
|
+
method: 'GET',
|
|
54
|
+
path: '/v1/nodes',
|
|
55
|
+
params: {},
|
|
56
|
+
query: new URLSearchParams(query),
|
|
57
|
+
body: null,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(result.status, 200);
|
|
60
|
+
return result.body;
|
|
61
|
+
}
|
|
62
|
+
function report(nodeId, stamp, tier, body) {
|
|
63
|
+
const filename = `${stamp}-${tier}.md`;
|
|
64
|
+
writeFileSync(join(reportsDir(nodeId), filename), `---\nkind: ${tier}\nts: ${stamp}\n---\n${body}\n`);
|
|
65
|
+
return filename;
|
|
66
|
+
}
|
|
67
|
+
test('list activity query composes profile, lifecycle, topology filters and report enrichment', () => {
|
|
68
|
+
const finalName = '20260817T100000-final.md';
|
|
69
|
+
createNode(node('root', {
|
|
70
|
+
status: 'done',
|
|
71
|
+
final_report: finalName,
|
|
72
|
+
finalized_at: '2026-08-17T10:00:00.000Z',
|
|
73
|
+
}));
|
|
74
|
+
report('root', '20260817T090000', 'update', 'Started the task');
|
|
75
|
+
report('root', '20260817T100000', 'final', 'Finished the task');
|
|
76
|
+
report('root', '20260817T110000', 'update', 'Still monitoring the task');
|
|
77
|
+
createNode(node('bridge', { kind: 'human', parent: 'root' }));
|
|
78
|
+
const page = join(home, 'pending-page.tsx');
|
|
79
|
+
writeFileSync(page, `export default function PendingPage() {
|
|
80
|
+
return <Page title="Need a decision"><UserText id="answer" initialText="" /></Page>;
|
|
81
|
+
}
|
|
82
|
+
`);
|
|
83
|
+
const ticket = submitPage({
|
|
84
|
+
dir: ticketDir('bridge'),
|
|
85
|
+
sourceFile: page,
|
|
86
|
+
delivery: { placement: 'inline', inbox: true, reply: true },
|
|
87
|
+
});
|
|
88
|
+
bindTicketReplyRoute(ticket.dir, 'bridge');
|
|
89
|
+
invalidateTicketCounts();
|
|
90
|
+
createNode(node('child', { parent: 'root' }));
|
|
91
|
+
createNode(node('resident', { lifecycle: 'resident' }));
|
|
92
|
+
createNode(node('other-profile', { profile_id: 'other-profile-7f3a9c01' }));
|
|
93
|
+
const roots = list('profile_prefix=activity-profile-&lifecycle=terminal&top_level=true&include=activity');
|
|
94
|
+
assert.deepEqual(roots.map((row) => row.node_id), ['root']);
|
|
95
|
+
assert.equal(roots[0].activity?.final_report?.body, 'Finished the task');
|
|
96
|
+
assert.equal(roots[0].activity?.latest_report?.body, 'Still monitoring the task');
|
|
97
|
+
assert.equal(roots[0].activity?.pending_human_count, 1);
|
|
98
|
+
const children = list('profile_id=activity-profile-7f3a9c01&parent=root');
|
|
99
|
+
assert.deepEqual(children.map((row) => row.node_id), ['bridge', 'child']);
|
|
100
|
+
assert.equal(children[1].activity, undefined);
|
|
101
|
+
});
|
|
@@ -278,6 +278,7 @@ function handleSnapshot() {
|
|
|
278
278
|
lifecycle: row.lifecycle ?? meta?.lifecycle,
|
|
279
279
|
status: row.status,
|
|
280
280
|
cwd: row.cwd,
|
|
281
|
+
profile_id: meta?.profile_id ?? null,
|
|
281
282
|
parent: meta?.parent ?? null,
|
|
282
283
|
created: row.created,
|
|
283
284
|
host_kind: hostKind,
|
|
@@ -289,6 +290,8 @@ function handleSnapshot() {
|
|
|
289
290
|
streaming: row.streaming ?? false,
|
|
290
291
|
hanging: (row.hanging ?? null),
|
|
291
292
|
viewed: row.viewed ?? false,
|
|
293
|
+
final_report: meta?.final_report ?? null,
|
|
294
|
+
finalized_at: meta?.finalized_at ?? null,
|
|
292
295
|
};
|
|
293
296
|
});
|
|
294
297
|
const subscriptions = Object.fromEntries(rows.map((row) => [row.node_id, subscriptionsOf(row.node_id).map((ref) => ref.node_id)]));
|
|
@@ -226,11 +226,12 @@ function handleList() {
|
|
|
226
226
|
// GET /v1/human/inbox/history?node_id=<id> — page ticket history
|
|
227
227
|
// ===========================================================================
|
|
228
228
|
function handleHistory(ctx) {
|
|
229
|
+
// Home-wide when `node_id` is absent; an explicitly empty param is a caller bug.
|
|
229
230
|
const nodeId = ctx.query.get('node_id');
|
|
230
|
-
if (
|
|
231
|
-
throw new ApiError(400, 'invalid_request', '
|
|
231
|
+
if (nodeId === '') {
|
|
232
|
+
throw new ApiError(400, 'invalid_request', 'node_id must be nonempty when present');
|
|
232
233
|
}
|
|
233
|
-
const history = scanPageHistory(nodeId);
|
|
234
|
+
const history = scanPageHistory(nodeId ?? undefined);
|
|
234
235
|
const canonicalRoot = realpathSync(ticketsRoot());
|
|
235
236
|
const tickets = history.map((item) => pageSummaryToDTO(item, canonicalRoot));
|
|
236
237
|
return { status: 200, body: { tickets } };
|
|
@@ -6,6 +6,7 @@ interface NodeHandlerDeps {
|
|
|
6
6
|
}
|
|
7
7
|
export declare function resolveCreateKind(kind: string | undefined, profile: ReturnType<typeof assertProfileActive>): string;
|
|
8
8
|
export declare function handleCreate(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
9
|
+
export declare function handleList(ctx: RequestContext): HandlerResult;
|
|
9
10
|
export declare function handlePromote(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
10
11
|
export declare function handleYield(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|
|
11
12
|
export declare function handleConfig(ctx: RequestContext, deps?: NodeHandlerDeps): Promise<HandlerResult>;
|