@crouton-kit/crouter 0.3.26 → 0.3.28

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.
Files changed (68) hide show
  1. package/dist/builtin-memory/internal/INDEX.md +1 -1
  2. package/dist/builtin-personas/runtime-base.md +3 -0
  3. package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
  4. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  5. package/dist/clients/attach/attach-cmd.js +187 -19
  6. package/dist/clients/attach/canvas-panels.d.ts +10 -0
  7. package/dist/clients/attach/canvas-panels.js +50 -0
  8. package/dist/clients/attach/graph-overlay.d.ts +34 -0
  9. package/dist/clients/attach/graph-overlay.js +266 -0
  10. package/dist/clients/attach/input-controller.d.ts +6 -0
  11. package/dist/clients/attach/input-controller.js +2 -0
  12. package/dist/clients/attach/slash-commands.d.ts +22 -1
  13. package/dist/clients/attach/slash-commands.js +160 -3
  14. package/dist/clients/attach/view-socket.d.ts +19 -1
  15. package/dist/clients/attach/view-socket.js +61 -6
  16. package/dist/commands/human/prompts.js +3 -3
  17. package/dist/commands/human/queue.d.ts +17 -0
  18. package/dist/commands/human/queue.js +111 -4
  19. package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
  20. package/dist/commands/memory/lint.d.ts +5 -4
  21. package/dist/commands/memory/lint.js +9 -5
  22. package/dist/commands/memory/write.js +19 -2
  23. package/dist/commands/memory.js +1 -1
  24. package/dist/commands/sys/feedback.d.ts +1 -0
  25. package/dist/commands/sys/feedback.js +163 -0
  26. package/dist/commands/sys.js +3 -2
  27. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  28. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
  31. package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
  32. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  33. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  34. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  35. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  36. package/dist/core/canvas/nav-model.d.ts +162 -0
  37. package/dist/core/canvas/nav-model.js +486 -0
  38. package/dist/core/canvas/paths.d.ts +7 -0
  39. package/dist/core/canvas/paths.js +9 -0
  40. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  41. package/dist/core/runtime/broker-sdk.js +77 -6
  42. package/dist/core/runtime/broker.d.ts +2 -1
  43. package/dist/core/runtime/broker.js +26 -1
  44. package/dist/core/runtime/front-door.js +23 -8
  45. package/dist/core/runtime/naming.d.ts +1 -5
  46. package/dist/core/runtime/naming.js +33 -49
  47. package/dist/core/runtime/placement.d.ts +7 -6
  48. package/dist/core/runtime/placement.js +24 -12
  49. package/dist/core/runtime/revive.js +9 -0
  50. package/dist/core/runtime/spawn.d.ts +5 -0
  51. package/dist/core/runtime/spawn.js +69 -11
  52. package/dist/core/runtime/tmux.d.ts +9 -0
  53. package/dist/core/runtime/tmux.js +12 -0
  54. package/dist/core/spawn.d.ts +14 -0
  55. package/dist/core/spawn.js +29 -9
  56. package/dist/core/substrate/index.d.ts +1 -1
  57. package/dist/core/substrate/index.js +6 -6
  58. package/dist/core/substrate/injected-store.d.ts +10 -0
  59. package/dist/core/substrate/injected-store.js +55 -0
  60. package/dist/core/substrate/schema.d.ts +6 -8
  61. package/dist/core/substrate/schema.js +26 -28
  62. package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
  63. package/dist/pi-extensions/canvas-goal-capture.js +38 -22
  64. package/dist/pi-extensions/canvas-nav.js +30 -385
  65. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  66. package/dist/pi-extensions/canvas-stophook.js +32 -2
  67. package/package.json +1 -1
  68. package/dist/builtin-memory/memory-authoring.md +0 -100
@@ -2,7 +2,7 @@ import { defineLeaf } from '../../core/command.js';
2
2
  import { InputError } from '../../core/io.js';
3
3
  import { spawnNode } from '../../core/runtime/nodes.js';
4
4
  import { interactionDir } from '../../core/artifact.js';
5
- import { isInTmux } from '../../core/spawn.js';
5
+ import { tmuxServerReachable } from '../../core/spawn.js';
6
6
  import { mkdirSync, existsSync } from 'node:fs';
7
7
  import { join, resolve } from 'node:path';
8
8
  import { randomBytes } from 'node:crypto';
@@ -211,7 +211,7 @@ export const humanNotify = defineLeaf({
211
211
  const rc = { mode: 'notify' };
212
212
  atomicWriteJson(join(idir, 'run.json'), rc);
213
213
  let shown = false;
214
- if (isInTmux()) {
214
+ if (tmuxServerReachable()) {
215
215
  shown = detachHumanTui(idir, cwd).status === 'spawned';
216
216
  }
217
217
  return { shown, dir: idir };
@@ -256,7 +256,7 @@ export const humanShow = defineLeaf({
256
256
  if (paneId !== undefined) {
257
257
  return { pane_id: paneId, reason: null };
258
258
  }
259
- const reason = isInTmux()
259
+ const reason = tmuxServerReachable()
260
260
  ? 'renderer unavailable (termrender/uv missing)'
261
261
  : 'not in tmux';
262
262
  return { pane_id: null, reason };
@@ -1,3 +1,20 @@
1
+ /** Deliver-back + reap an interaction whose answer is on disk but was never
2
+ * delivered. The detached `_run` worker's `pushFinal` is normally the SOLE
3
+ * deliver-back + reap step; when it never ran (e.g. a broker asker pre-fix, or
4
+ * a human draining via `crtr human inbox`, which writes response.json directly
5
+ * and never calls pushFinal), the answer strands and the bridge node leaks.
6
+ *
7
+ * Idempotent and self-gating: a no-op unless the bridge node is still LIVE
8
+ * (active|idle) AND its interaction is resolved on disk — so it never
9
+ * double-delivers an interaction the `_run` worker already finalized (pushFinal
10
+ * flips status=done). Reconstructs the same pushFinal body `_run` would have
11
+ * emitted, per mode; a canceled-on-disk response reaps the node without
12
+ * delivering a result (mirrors `human cancel`). Returns true iff it acted. */
13
+ export declare function finalizeResolvedInteraction(jobId: string): Promise<boolean>;
14
+ /** Sweep every interaction under the cwd's interactions root and deliver-back +
15
+ * reap any that are answered-but-undelivered (see finalizeResolvedInteraction).
16
+ * Interaction dir names ARE the bridge node ids. Returns how many it healed. */
17
+ export declare function healStrandedInteractions(cwd: string): Promise<number>;
1
18
  export declare const humanInbox: import("../../core/command.js").LeafDef;
2
19
  export declare const humanList: import("../../core/command.js").LeafDef;
3
20
  export declare const humanCancel: import("../../core/command.js").LeafDef;
@@ -6,12 +6,109 @@ import { paginate } from '../../core/pagination.js';
6
6
  import { getNode, subscribersOf } from '../../core/canvas/index.js';
7
7
  import { transition } from '../../core/runtime/lifecycle.js';
8
8
  import { appendInbox } from '../../core/feed/inbox.js';
9
- import { existsSync } from 'node:fs';
9
+ import { existsSync, readdirSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { inbox, scanInbox, parseDeck, deckPath, responsePath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
13
13
  import { killPane } from './shared.js';
14
14
  // ---------------------------------------------------------------------------
15
+ // stranded-answer healing
16
+ // ---------------------------------------------------------------------------
17
+ /** A deterministic one-line-per-interaction summary, mirroring humanloop's
18
+ * `<id>: <option>[ — <freetext>]` shape. Used when reconstructing a
19
+ * ResolutionEnvelope from a bare on-disk response.json (which stores only
20
+ * `{responses, completedAt}`), so a healed deliver-back reads like the live one. */
21
+ function summarizeResponses(responses) {
22
+ return responses
23
+ .map((r) => {
24
+ const opt = r.selectedOptionId ?? '';
25
+ const ft = typeof r.freetext === 'string' && r.freetext.trim() !== '' ? ` — ${r.freetext.trim()}` : '';
26
+ return `${r.id}: ${opt}${ft}`;
27
+ })
28
+ .join('\n');
29
+ }
30
+ /** Deliver-back + reap an interaction whose answer is on disk but was never
31
+ * delivered. The detached `_run` worker's `pushFinal` is normally the SOLE
32
+ * deliver-back + reap step; when it never ran (e.g. a broker asker pre-fix, or
33
+ * a human draining via `crtr human inbox`, which writes response.json directly
34
+ * and never calls pushFinal), the answer strands and the bridge node leaks.
35
+ *
36
+ * Idempotent and self-gating: a no-op unless the bridge node is still LIVE
37
+ * (active|idle) AND its interaction is resolved on disk — so it never
38
+ * double-delivers an interaction the `_run` worker already finalized (pushFinal
39
+ * flips status=done). Reconstructs the same pushFinal body `_run` would have
40
+ * emitted, per mode; a canceled-on-disk response reaps the node without
41
+ * delivering a result (mirrors `human cancel`). Returns true iff it acted. */
42
+ export async function finalizeResolvedInteraction(jobId) {
43
+ const node = getNode(jobId);
44
+ if (node === null)
45
+ return false;
46
+ if (node.status !== 'active' && node.status !== 'idle')
47
+ return false;
48
+ const idir = interactionDir(jobId, node.cwd);
49
+ if (!isResolved(idir))
50
+ return false;
51
+ const rc = readJson(join(idir, 'run.json'));
52
+ if (rc === null)
53
+ return false;
54
+ const resp = readJson(responsePath(idir));
55
+ if (resp === null)
56
+ return false;
57
+ // Canceled out-of-band (a raw canceled response.json, not via `human cancel`):
58
+ // there is no answer to deliver — just reap the node and tell waiting
59
+ // subscribers no answer is coming, the same quiet deferred note `human cancel`
60
+ // emits. (`finalize` is legal from active|idle; the status guard above holds.)
61
+ if (resp['canceled'] === true) {
62
+ transition(jobId, 'finalize');
63
+ const note = typeof resp['reason'] === 'string' && resp['reason'] !== '' ? ` — ${resp['reason']}` : '';
64
+ for (const sub of subscribersOf(jobId)) {
65
+ appendInbox(sub.node_id, {
66
+ from: jobId,
67
+ tier: 'deferred',
68
+ kind: 'message',
69
+ label: `human interaction ${jobId} canceled — no answer is coming${note}`,
70
+ data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
71
+ });
72
+ }
73
+ return true;
74
+ }
75
+ const responses = resp['responses'] ?? [];
76
+ const completedAt = resp['completedAt'] ?? new Date().toISOString();
77
+ const summary = summarizeResponses(responses);
78
+ if (rc.mode === 'approve') {
79
+ const sel = responses.find((r) => r.id === rc.approve_iid)?.selectedOptionId;
80
+ await pushFinal(jobId, JSON.stringify({ approved: sel === 'yes', summary, responses, responsePath: responsePath(idir), completedAt }));
81
+ }
82
+ else {
83
+ // ask (and any other answered deck): the full ResolutionEnvelope shape.
84
+ const env = {
85
+ summary,
86
+ responsePath: responsePath(idir),
87
+ schema: 'humanloop.response/v2',
88
+ responses,
89
+ completedAt,
90
+ };
91
+ await pushFinal(jobId, JSON.stringify(env));
92
+ }
93
+ return true;
94
+ }
95
+ /** Sweep every interaction under the cwd's interactions root and deliver-back +
96
+ * reap any that are answered-but-undelivered (see finalizeResolvedInteraction).
97
+ * Interaction dir names ARE the bridge node ids. Returns how many it healed. */
98
+ export async function healStrandedInteractions(cwd) {
99
+ const root = interactionsRoot(cwd);
100
+ if (!existsSync(root))
101
+ return 0;
102
+ let healed = 0;
103
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
104
+ if (!ent.isDirectory())
105
+ continue;
106
+ if (await finalizeResolvedInteraction(ent.name))
107
+ healed++;
108
+ }
109
+ return healed;
110
+ }
111
+ // ---------------------------------------------------------------------------
15
112
  // inbox (human-invoked, blocking)
16
113
  // ---------------------------------------------------------------------------
17
114
  export const humanInbox = defineLeaf({
@@ -23,13 +120,23 @@ export const humanInbox = defineLeaf({
23
120
  summary: 'interactively drain pending interactions at your own terminal',
24
121
  params: [],
25
122
  inputNote: 'No input. Run this at a human terminal — it blocks until the backlog is drained or you quit.',
26
- output: [{ name: 'drained', type: 'boolean', required: true, constraint: 'True once the loop returns.' }],
123
+ output: [
124
+ { name: 'drained', type: 'boolean', required: true, constraint: 'True once the loop returns.' },
125
+ { name: 'delivered', type: 'integer', required: true, constraint: 'How many answered-but-undelivered interactions were delivered back to their askers and reaped.' },
126
+ ],
27
127
  outputKind: 'object',
28
- effects: ['Resolves pending interactions in the per-project interactions root via the TUI.'],
128
+ effects: [
129
+ 'Resolves pending interactions in the per-project interactions root via the TUI.',
130
+ 'Delivers any answered-but-undelivered interaction back to its asking node and reaps the bridge node (the deliver-back the detached _run worker would have done).',
131
+ ],
29
132
  },
30
133
  run: async () => {
31
134
  await inbox([interactionsRoot(process.cwd())]);
32
- return { drained: true };
135
+ // humanloop's inbox() writes response.json but never calls pushFinal — so a
136
+ // deck drained here would strand (no answer-back, leaked bridge node). Heal
137
+ // every resolved-but-live interaction: deliver its answer to the asker + reap.
138
+ const delivered = await healStrandedInteractions(process.cwd());
139
+ return { drained: true, delivered };
33
140
  },
34
141
  });
35
142
  // ---------------------------------------------------------------------------
@@ -9,6 +9,7 @@ import { test } from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import { lintSubstrateSchema } from '../lint.js';
11
11
  const ROUTING = 'When you are X, this reference should be read because Y';
12
+ const RUNGS = { 'system-prompt-visibility': 'name', 'file-read-visibility': 'none' };
12
13
  test('lint rejects a doc still carrying the retired `when` key', () => {
13
14
  const err = lintSubstrateSchema({ kind: 'reference', when: 'when X' });
14
15
  assert.ok(err !== null, 'old-shape `when` must produce a finding');
@@ -25,5 +26,27 @@ test('lint rejects a substrate doc missing the merged routing field', () => {
25
26
  assert.match(err, /when-and-why-to-read/);
26
27
  });
27
28
  test('lint accepts the merged new-shape frontmatter', () => {
28
- assert.equal(lintSubstrateSchema({ kind: 'reference', 'when-and-why-to-read': ROUTING }), null);
29
+ assert.equal(lintSubstrateSchema({ kind: 'reference', 'when-and-why-to-read': ROUTING, ...RUNGS }), null);
30
+ });
31
+ // Visibility is a required, case-by-case authoring call — there is no kind
32
+ // default. lint must reject a doc that omits either rung (the runtime parser
33
+ // silently floors a missing rung to `none`, which is exactly the tolerance this
34
+ // gate exists to catch at authoring time).
35
+ test('lint rejects a substrate doc missing system-prompt-visibility', () => {
36
+ const err = lintSubstrateSchema({
37
+ kind: 'reference',
38
+ 'when-and-why-to-read': ROUTING,
39
+ 'file-read-visibility': 'none',
40
+ });
41
+ assert.ok(err !== null, 'a missing rung must produce a finding');
42
+ assert.match(err, /missing system-prompt-visibility/);
43
+ });
44
+ test('lint rejects a substrate doc missing file-read-visibility', () => {
45
+ const err = lintSubstrateSchema({
46
+ kind: 'reference',
47
+ 'when-and-why-to-read': ROUTING,
48
+ 'system-prompt-visibility': 'name',
49
+ });
50
+ assert.ok(err !== null, 'a missing rung must produce a finding');
51
+ assert.match(err, /missing file-read-visibility/);
29
52
  });
@@ -1,8 +1,9 @@
1
1
  /** Schema checks for a doc living in a substrate memory dir: a memory store
2
2
  * holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
3
- * error here (elsewhere it just means "not a substrate doc"). Rung and gate
4
- * values are checked RAW the runtime parser silently falls back to kind
5
- * defaults / inert gates, which is exactly the silent tolerance this lint
6
- * exists to catch at authoring time. */
3
+ * error here (elsewhere it just means "not a substrate doc"). Both rungs are
4
+ * REQUIRED there is no kind default to lean on, so a missing rung is an
5
+ * authoring error. Rung and gate values are checked RAW the runtime parser
6
+ * silently falls back to the neutral floor / inert gates, which is exactly the
7
+ * silent tolerance this lint exists to catch at authoring time. */
7
8
  export declare function lintSubstrateSchema(fm: Record<string, unknown> | null): string | null;
8
9
  export declare const lintLeaf: import("../../core/command.js").LeafDef;
@@ -17,10 +17,11 @@ const VALID_RUNGS = [...RUNGS, 'always'];
17
17
  const RUNG_FIELDS = ['system-prompt-visibility', 'file-read-visibility'];
18
18
  /** Schema checks for a doc living in a substrate memory dir: a memory store
19
19
  * holds ONLY substrate docs, so a missing/invalid `kind` is an authoring
20
- * error here (elsewhere it just means "not a substrate doc"). Rung and gate
21
- * values are checked RAW the runtime parser silently falls back to kind
22
- * defaults / inert gates, which is exactly the silent tolerance this lint
23
- * exists to catch at authoring time. */
20
+ * error here (elsewhere it just means "not a substrate doc"). Both rungs are
21
+ * REQUIRED there is no kind default to lean on, so a missing rung is an
22
+ * authoring error. Rung and gate values are checked RAW the runtime parser
23
+ * silently falls back to the neutral floor / inert gates, which is exactly the
24
+ * silent tolerance this lint exists to catch at authoring time. */
24
25
  export function lintSubstrateSchema(fm) {
25
26
  if (fm === null)
26
27
  return 'missing frontmatter: a memory store doc requires `kind: skill|reference|preference`';
@@ -38,7 +39,10 @@ export function lintSubstrateSchema(fm) {
38
39
  }
39
40
  for (const field of RUNG_FIELDS) {
40
41
  const v = fm[field];
41
- if (v !== undefined && (typeof v !== 'string' || !VALID_RUNGS.includes(v))) {
42
+ if (v === undefined) {
43
+ return `missing ${field}: choose a rung explicitly (${RUNGS.join('|')}) — there is no kind default`;
44
+ }
45
+ if (typeof v !== 'string' || !VALID_RUNGS.includes(v)) {
42
46
  return `invalid ${field}: ${JSON.stringify(v)} (expected ${RUNGS.join('|')})`;
43
47
  }
44
48
  }
@@ -10,13 +10,21 @@ export const writeLeaf = defineLeaf({
10
10
  help: {
11
11
  name: 'memory write',
12
12
  summary: 'create or update memory/<name>.md at the resolved scope from frontmatter flags + a stdin body',
13
+ guide: 'The body is the easy part; the craft is ROUTING — every frontmatter flag decides who sees this doc, when, and at what context cost. Each rung up is paid by every future agent at every boot or read, forever, so default each rung DOWN.\n\n' +
14
+ 'Pick the kind. skill = how to DO something (a repeatable procedure/playbook); reference = what is TRUE or how something WORKS (a fact about the user, a system\u2019s behavior, code docs); preference = how to BEHAVE (a directive, a standing correction). The test that splits the close pair: does it DIRECT behavior ("always lint after authoring" \u2192 preference) or INFORM the world-model ("Silas likes chicken", "the daemon never reloads dist/" \u2192 reference)? A correction yields a preference, a learned fact a reference, a repeatable procedure a skill.\n\n' +
15
+ 'Set the rungs \u2014 BOTH --system-prompt-visibility (boot) and --file-read-visibility (on-read) are REQUIRED on create. There is no default: the right rung is a case-by-case call, never a function of kind. The ladder, lowest to highest: `none` for niche docs almost nothing should pull into context; `name` for the common case \u2014 references and skills that are relatively uncommon but an agent (or the user, by name) may reach for; `preview` for docs important enough that their routing line is worth its token cost every session (preferences usually land here); `content` (full body injected) for a doc that would be `preview` except its body is already so short you may as well inline it whole. `content` is rare \u2014 when a `content` doc grows past a bullet or two, downgrade it to `preview`. Each axis is independent: usually one carries a real rung and the other is `none` (see the hook paragraph next).\n\n' +
16
+ 'Choose the hook \u2014 boot vs file-read. There are exactly two moments a doc can surface. Behavior and procedure (preferences, skills) are relevant whatever file is open \u2192 surface at boot. Knowledge about code (references) belongs NEXT TO the code: put the file in that directory\u2019s .crouter/memory/ and it fires positionally when files there are read, costing nothing at boot. The exception that matters: a reference about a PERSON or PROCESS has no code directory to anchor to, so on-read triggering is meaningless \u2014 set --system-prompt-visibility preview so its routing line surfaces at boot instead.\n\n' +
17
+ 'Write the routing line (--when-and-why-to-read) FIRST, before storing anything: "When <circumstance the agent is in>, this <kind> should be read because <what the read buys>." The test: can a stranger mid-task 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 \u2014 ask the user ONE sharp question instead of improvising. ("Remember I like chicken" routes cleanly \u2192 food/meal decisions; "be careful with the API" does not \u2192 which API, careful how, against what failure?) And NEVER paraphrase the advice in the routing line \u2014 a preview that gives away the gist makes every future agent skip the real read. BAD: "\u2026because it carries the placement policy \u2014 -h first, memory only for dev-mode material." GOOD: "\u2026because it carries the standing placement policy."\n\n' +
18
+ 'Find before write. `crtr memory find <topic>` first; grow ONE doc per recurring circumstance rather than minting near-duplicates \u2014 extend `food-preferences`, do not create `likes-chicken`. Group related docs with path names (area/topic). Do not store what is already recorded (code structure, git history, CLAUDE.md) or what only matters to this conversation.\n\n' +
19
+ 'Body: write for a STRANGER \u2014 a future session that shares none of this conversation. State current truth, not the history of getting there (no "as discussed"). Keep the reasoning behind a rule and cut everything else; dense beats complete, since every line costs a mid-task reader.\n\n' +
20
+ 'Gate (--gate, optional): a predicate making the doc eligible only for nodes whose own config matches (e.g. `{ mode: orchestrator }` or `{ orchestration.depth: { gte: 2 } }`); a failing gate hides it from both hooks but it stays findable by search. Default is no gate \u2014 most docs want exactly that. After authoring, validate with `crtr memory lint`.',
13
21
  params: [
14
22
  { kind: 'positional', name: 'name', required: true, constraint: 'Path-derived identity (e.g. `topic` or `area/topic`) → memory/<name>.md at the resolved scope. Updated in place if it already exists, otherwise created.' },
15
23
  { kind: 'flag', name: 'kind', type: 'enum', choices: [...MEMORY_KINDS], required: true, constraint: 'Document kind.' },
16
24
  { kind: 'flag', name: 'when-and-why-to-read', type: 'string', required: false, constraint: 'The read-routing line, authored as ONE sentence: "When <circumstance>, this <kind> should be read <because <payoff>>." It states WHEN to read this doc and WHY the read is worth it — read-routing, NEVER a justification of why the content should be obeyed. Rendered verbatim as the preview. Required when creating.' },
17
25
  { kind: 'flag', name: 'short-form', type: 'string', required: false, constraint: 'Frontmatter short-form — a very abbreviated version of the content, the hook shown in `crtr memory list`.' },
18
- { kind: 'flag', name: 'system-prompt-visibility', type: 'enum', choices: [...VISIBILITY_RUNGS], required: false, constraint: 'Rung controlling how much of this document auto-loads into the system prompt / CLI help.' },
19
- { kind: 'flag', name: 'file-read-visibility', type: 'enum', choices: [...VISIBILITY_RUNGS], required: false, constraint: 'Rung controlling how much of this document surfaces when it is read off disk.' },
26
+ { kind: 'flag', name: 'system-prompt-visibility', type: 'enum', choices: [...VISIBILITY_RUNGS], required: false, constraint: 'Rung controlling how much of this document auto-loads into the system prompt / CLI help. Required when creating — there is no kind default; pick a rung explicitly.' },
27
+ { kind: 'flag', name: 'file-read-visibility', type: 'enum', choices: [...VISIBILITY_RUNGS], required: false, constraint: 'Rung controlling how much of this document surfaces when it is read off disk. Required when creating — there is no kind default; pick a rung explicitly.' },
20
28
  { kind: 'flag', name: 'gate', type: 'string', required: false, constraint: 'Frontmatter gate — expression/condition that determines when this document applies.' },
21
29
  { kind: 'flag', name: 'applies-to', type: 'string', required: false, constraint: 'Frontmatter applies-to — glob/path scope the document applies to.' },
22
30
  { kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user.' },
@@ -48,6 +56,15 @@ export const writeLeaf = defineLeaf({
48
56
  if (created && input['whenAndWhyToRead'] === undefined) {
49
57
  throw usage(`creating ${name} requires --when-and-why-to-read: one read-routing sentence "When <circumstance>, this ${kind} should be read <because <payoff>>." (rendered verbatim as the preview).`);
50
58
  }
59
+ // CREATE requires BOTH visibility rungs as an explicit, case-by-case call —
60
+ // there is no kind default to lean on. UPDATE inherits whatever the existing
61
+ // doc already carries.
62
+ if (created && input['systemPromptVisibility'] === undefined) {
63
+ throw usage(`creating ${name} requires --system-prompt-visibility: pick a rung explicitly (none|name|preview|content) — there is no kind default. See \`crtr memory write -h\` for what each rung means.`);
64
+ }
65
+ if (created && input['fileReadVisibility'] === undefined) {
66
+ throw usage(`creating ${name} requires --file-read-visibility: pick a rung explicitly (none|name|preview|content) — there is no kind default. See \`crtr memory write -h\` for what each rung means.`);
67
+ }
51
68
  // In-place update: start from the existing frontmatter (preserving fields
52
69
  // not passed this time), then overlay the provided ones. Create: start clean.
53
70
  const frontmatter = created
@@ -20,7 +20,7 @@ export function registerMemory() {
20
20
  help: {
21
21
  name: 'memory',
22
22
  summary: 'list, read, search, and write memory documents — skills, references, preferences',
23
- 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. `lint` strict-parses the whole bounded corpus and fails on any invalid frontmatter — run it after authoring. Append `-h` at any leaf for its full schema.',
23
+ 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. `lint` strict-parses the whole bounded corpus and fails on any invalid frontmatter — run it after authoring. 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.',
24
24
  },
25
25
  children: [listLeaf, readLeaf, findLeaf, writeLeaf, lintLeaf],
26
26
  });
@@ -0,0 +1 @@
1
+ export declare const sysFeedbackLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,163 @@
1
+ // `crtr sys feedback` — file a GitHub issue against the crtr repo when the
2
+ // harness itself misbehaves (a command errors unexpectedly, hangs, churns, or
3
+ // acts contrary to its documented contract). The agent supplies one prose
4
+ // description; everything else (version, node identity, recent activity) is
5
+ // auto-collected and templated into the issue body. gh-less / unauthed hosts
6
+ // fall back to a durable local file so the report is never lost.
7
+ import { spawnSync } from 'node:child_process';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import os from 'node:os';
11
+ import { defineLeaf } from '../../core/command.js';
12
+ import { usage } from '../../core/errors.js';
13
+ import { userScopeRoot } from '../../core/scope.js';
14
+ import { getNode, jobDir, readTelemetry } from '../../core/canvas/index.js';
15
+ import { readPackageVersion } from './shared.js';
16
+ /** Canonical repo for crtr harness feedback — feedback is always about the
17
+ * tool, regardless of which project the reporting agent is working in. */
18
+ const FEEDBACK_REPO = 'crouton-labs/crouter';
19
+ function collectContext() {
20
+ const env = process.env;
21
+ const nodeId = env['CRTR_NODE_ID'] ?? null;
22
+ let cycle = null;
23
+ let logTail = [];
24
+ if (nodeId !== null) {
25
+ const meta = getNode(nodeId);
26
+ if (meta !== null && typeof meta.cycles === 'number')
27
+ cycle = meta.cycles;
28
+ logTail = readLogTail(nodeId);
29
+ }
30
+ const contextTokens = nodeId !== null ? readTelemetry(nodeId).context_tokens ?? null : null;
31
+ return {
32
+ version: readPackageVersion(),
33
+ platform: `${os.platform()} ${os.release()} (${os.arch()})`,
34
+ nodeId,
35
+ kind: env['CRTR_KIND'] ?? null,
36
+ mode: env['CRTR_MODE'] ?? null,
37
+ lifecycle: env['CRTR_LIFECYCLE'] ?? null,
38
+ cwd: env['CRTR_NODE_CWD'] ?? process.cwd(),
39
+ cycle,
40
+ contextTokens,
41
+ logTail,
42
+ };
43
+ }
44
+ /** Last few lines of a node's job/log.jsonl — best-effort, never throws. Each
45
+ * line is truncated so a single fat event can't blow up the issue body. */
46
+ function readLogTail(nodeId, count = 15) {
47
+ try {
48
+ const path = join(jobDir(nodeId), 'log.jsonl');
49
+ if (!existsSync(path))
50
+ return [];
51
+ return readFileSync(path, 'utf8')
52
+ .split('\n')
53
+ .filter((l) => l.trim() !== '')
54
+ .slice(-count)
55
+ .map((l) => (l.length > 600 ? l.slice(0, 600) + '…' : l));
56
+ }
57
+ catch {
58
+ return [];
59
+ }
60
+ }
61
+ function deriveTitle(raw, message) {
62
+ const explicit = raw !== undefined && raw.trim() !== '' ? raw.trim() : undefined;
63
+ const base = explicit ?? message.split('\n')[0].trim();
64
+ const trimmed = base.length > 80 ? base.slice(0, 79) + '…' : base;
65
+ return `[feedback] ${trimmed}`;
66
+ }
67
+ function buildBody(message, ctx) {
68
+ const rows = [
69
+ `| crtr version | \`${ctx.version}\` |`,
70
+ `| platform | ${ctx.platform} |`,
71
+ ];
72
+ if (ctx.nodeId !== null) {
73
+ rows.push(`| node id | \`${ctx.nodeId}\` |`);
74
+ rows.push(`| kind / mode / lifecycle | ${ctx.kind ?? '?'} / ${ctx.mode ?? '?'} / ${ctx.lifecycle ?? '?'} |`);
75
+ if (ctx.cycle !== null)
76
+ rows.push(`| cycle | ${ctx.cycle} |`);
77
+ if (ctx.contextTokens !== null)
78
+ rows.push(`| context tokens | ${ctx.contextTokens.toLocaleString('en-US')} |`);
79
+ }
80
+ if (ctx.cwd !== null)
81
+ rows.push(`| working dir | \`${ctx.cwd}\` |`);
82
+ const parts = [
83
+ message.trim(),
84
+ '',
85
+ '---',
86
+ '_Filed via `crtr sys feedback` — context below auto-attached by the harness._',
87
+ '',
88
+ '### Environment',
89
+ '| | |',
90
+ '|---|---|',
91
+ ...rows,
92
+ ];
93
+ if (ctx.logTail.length > 0) {
94
+ parts.push('', `### Recent node activity (last ${ctx.logTail.length} log events)`, '```jsonl', ...ctx.logTail, '```');
95
+ }
96
+ return parts.join('\n') + '\n';
97
+ }
98
+ /** Persist a report locally when gh can't file it, so nothing is lost. Returns
99
+ * the absolute path written. */
100
+ function saveLocally(title, body) {
101
+ const dir = join(userScopeRoot(), 'feedback');
102
+ mkdirSync(dir, { recursive: true });
103
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
104
+ const path = join(dir, `${ts}.md`);
105
+ writeFileSync(path, `# ${title}\n\n${body}`, 'utf8');
106
+ return path;
107
+ }
108
+ export const sysFeedbackLeaf = defineLeaf({
109
+ name: 'feedback',
110
+ description: 'report a crtr harness bug as a GitHub issue',
111
+ whenToUse: 'a crtr command itself misbehaves — it errors unexpectedly, hangs, churns, double-spawns, or acts contrary to its documented contract. NOT for your task work or decisions a human should make (that is `crtr human ask`); this reports the TOOL, filing an issue against the crtr repo with your node context auto-attached.',
112
+ help: {
113
+ name: 'sys feedback',
114
+ summary: 'file a GitHub issue describing a crtr harness bug, with node context auto-attached',
115
+ params: [
116
+ {
117
+ kind: 'positional',
118
+ name: 'message',
119
+ required: true,
120
+ constraint: 'What broke, in your own words: the command you ran, what you expected, what happened instead. Be concrete — this is the only part a human writes; everything else is auto-collected.',
121
+ },
122
+ {
123
+ kind: 'flag',
124
+ name: 'title',
125
+ type: 'string',
126
+ required: false,
127
+ constraint: 'Issue title. Default: first line of the message, truncated.',
128
+ },
129
+ ],
130
+ output: [
131
+ { name: 'status', type: 'string', required: true, constraint: 'filed | saved-locally.' },
132
+ { name: 'url', type: 'string', required: false, constraint: 'Issue URL when status is filed.' },
133
+ { name: 'path', type: 'string', required: false, constraint: 'Local report path when status is saved-locally (gh unavailable).' },
134
+ { name: 'error', type: 'string', required: false, constraint: 'Why filing fell back to local save.' },
135
+ ],
136
+ outputKind: 'object',
137
+ effects: [
138
+ `Opens a public GitHub issue on ${FEEDBACK_REPO} via gh.`,
139
+ 'On gh failure (missing / unauthenticated), writes the report to ~/.crouter/feedback/ instead — never lost.',
140
+ ],
141
+ },
142
+ run: async (input) => {
143
+ const message = input['message']?.trim();
144
+ if (message === undefined || message === '') {
145
+ throw usage('feedback message is required: describe what broke. Run `crtr sys feedback -h`.');
146
+ }
147
+ const ctx = collectContext();
148
+ const title = deriveTitle(input['title'], message);
149
+ const body = buildBody(message, ctx);
150
+ const res = spawnSync('gh', ['issue', 'create', '--repo', FEEDBACK_REPO, '--title', title, '--body-file', '-'], { input: body, encoding: 'utf8' });
151
+ if (res.error === undefined && res.status === 0) {
152
+ const url = res.stdout.trim().split('\n').filter((l) => l.startsWith('http')).pop() ?? res.stdout.trim();
153
+ return { status: 'filed', url };
154
+ }
155
+ // gh missing, unauthenticated, or the API call failed — persist locally so
156
+ // the report survives, and tell the agent how to escalate.
157
+ const reason = res.error !== undefined
158
+ ? `gh not available: ${res.error.message}`
159
+ : (res.stderr?.trim() || `gh exited ${res.status}`);
160
+ const path = saveLocally(title, body);
161
+ return { status: 'saved-locally', path, error: reason };
162
+ },
163
+ });
@@ -3,19 +3,20 @@
3
3
  import { defineBranch } from '../core/command.js';
4
4
  import { configBranch } from './sys/config.js';
5
5
  import { sysDoctorLeaf } from './sys/doctor.js';
6
+ import { sysFeedbackLeaf } from './sys/feedback.js';
6
7
  import { sysUpdateLeaf, sysVersionLeaf } from './sys/update.js';
7
8
  export function registerSys() {
8
9
  return defineBranch({
9
10
  name: 'sys',
10
11
  rootEntry: {
11
12
  concept: 'crtr configuration, diagnostics, and self-management',
12
- desc: 'config, doctor, update, version',
13
+ desc: 'config, doctor, update, version, feedback',
13
14
  useWhen: 'managing the crtr installation',
14
15
  },
15
16
  help: {
16
17
  name: 'sys',
17
18
  summary: 'crtr system configuration, diagnostics, and self-management',
18
19
  },
19
- children: [configBranch, sysDoctorLeaf, sysUpdateLeaf, sysVersionLeaf],
20
+ children: [configBranch, sysDoctorLeaf, sysFeedbackLeaf, sysUpdateLeaf, sysVersionLeaf],
20
21
  });
21
22
  }
@@ -0,0 +1,105 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/broker-snapshot-history.test.ts
2
+ //
3
+ // BUG-REGRESSION (real, observed; surfaced during the crouter-web build, proven
4
+ // broker-side). After a revive resumes a previously-CLOSED pi session, the
5
+ // broker's `welcome` snapshot must present the SAME ordered, complete turn
6
+ // history the persisted session `.jsonl` holds — no omitted or reordered turn.
7
+ //
8
+ // ROOT CAUSE: the broker's `buildSnapshot` served `session.messages`
9
+ // (= `agent.state.messages`, the agent's LIVE in-memory array). pi's recovery
10
+ // paths — auto-retry, context-overflow recovery, and compaction — SLICE the
11
+ // errored/superseded assistant message out of `state.messages` while
12
+ // DELIBERATELY keeping it on disk ("keep in session for history",
13
+ // agent-session.js). So `session.messages` can OMIT a turn the `.jsonl` still
14
+ // holds. crouter-web's static normalizer reads the full ordered history correctly
15
+ // (pi `SessionManager.buildSessionContext` over the file), so a dormant viewer
16
+ // saw the complete turn — then, after a revive, the broker's live welcome
17
+ // snapshot served the diverged (omitted) array. The fix makes the snapshot
18
+ // reconstruct from the persisted session tree (the SAME source the normalizer
19
+ // uses), so live == persisted == disk (single source of truth).
20
+ //
21
+ // This drives the REAL pi SDK offline (no tmux, no broker process, no network) —
22
+ // exactly the seam where the bug lives: it builds a real resumed broker session,
23
+ // SIMULATES pi's retry slice by popping the last assistant off the live
24
+ // `agent.state.messages` (leaving the persisted tree intact), and asserts the
25
+ // snapshot follows the persisted history, not the sliced live array.
26
+ import { test } from 'node:test';
27
+ import assert from 'node:assert/strict';
28
+ import { mkdtempSync, rmSync } from 'node:fs';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
32
+ import { buildBrokerSession, snapshotMessages } from '../runtime/broker.js';
33
+ const realEngine = {
34
+ createAgentSessionServices,
35
+ createAgentSessionFromServices,
36
+ SessionManager,
37
+ VERSION,
38
+ };
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ function userMsg(text) {
41
+ return { role: 'user', content: [{ type: 'text', text }] };
42
+ }
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ function assistantMsg(text, stopReason = 'stop') {
45
+ return {
46
+ role: 'assistant',
47
+ content: [{ type: 'text', text }],
48
+ api: 'anthropic-messages',
49
+ provider: 'anthropic',
50
+ model: 'sonnet',
51
+ usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
52
+ stopReason,
53
+ timestamp: Date.now(),
54
+ };
55
+ }
56
+ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced live array (omit-on-revive regression)', async () => {
57
+ const cwd = mkdtempSync(join(tmpdir(), 'crtr-snaphist-'));
58
+ try {
59
+ // 1. Build a real persisted session whose tail mimics an auto-retried turn:
60
+ // user \u2192 errored assistant (kept on disk) \u2192 retried assistant. This is what
61
+ // pi's `.jsonl` holds after a retryable error (529 / overflow / network).
62
+ const seed = SessionManager.create(cwd);
63
+ seed.appendMessage(userMsg('q1'));
64
+ seed.appendMessage(assistantMsg('a1'));
65
+ seed.appendMessage(userMsg('q2 — triggers a retryable error'));
66
+ seed.appendMessage(assistantMsg('errored attempt', 'error')); // kept in session for history
67
+ seed.appendMessage(assistantMsg('retried answer')); // the successful retry
68
+ const sessionFile = seed.getSessionFile();
69
+ assert.ok(sessionFile, 'seed session persisted a .jsonl');
70
+ // The persisted, ordered history a dormant reader / crouter-web normalizer sees.
71
+ const persisted = SessionManager.open(sessionFile).buildSessionContext().messages;
72
+ assert.equal(persisted.length, 5, 'disk holds all five messages incl. the errored attempt');
73
+ // 2. Revive: the broker resumes the closed session via the SERVICES path.
74
+ const { session } = await buildBrokerSession(realEngine, {
75
+ cwd,
76
+ extensionPaths: [],
77
+ resumeSessionPath: sessionFile,
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ });
80
+ try {
81
+ assert.equal(session.messages.length, 5, 'at boot the live array == persisted history');
82
+ // 3. Simulate pi's auto-retry/overflow slice: it removes the errored
83
+ // assistant from agent.state.messages but KEEPS it on disk. We reproduce
84
+ // that exact mutation on the live array (the persisted tree is untouched).
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ const live = session.agent.state.messages;
87
+ const popped = live.pop(); // drop the last assistant from the LIVE array only
88
+ assert.equal(popped.role, 'assistant', 'sliced an assistant off the live array');
89
+ assert.equal(session.messages.length, 4, 'the live array now OMITS the persisted turn');
90
+ // 4. The bug: pre-fix `buildSnapshot` served `session.messages` (4) \u2014 omitting
91
+ // a turn the .jsonl still holds. The fix serves the persisted history (5),
92
+ // byte-identical to the normalizer.
93
+ const snap = snapshotMessages(session);
94
+ assert.equal(snap.length, persisted.length, 'snapshot presents the COMPLETE persisted history (omit-on-revive regression)');
95
+ assert.notEqual(snap.length, session.messages.length, 'snapshot does NOT echo the sliced live array');
96
+ assert.deepEqual(snap, persisted, 'snapshot is byte-identical to the static normalizer\u2019s buildSessionContext (single source of truth)');
97
+ }
98
+ finally {
99
+ session.dispose();
100
+ }
101
+ }
102
+ finally {
103
+ rmSync(cwd, { recursive: true, force: true });
104
+ }
105
+ });