@crouton-kit/crouter 0.3.21 → 0.3.23

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 (111) hide show
  1. package/dist/commands/canvas-history/read.d.ts +1 -0
  2. package/dist/commands/canvas-history/read.js +61 -0
  3. package/dist/commands/canvas-history/search.d.ts +1 -0
  4. package/dist/commands/canvas-history/search.js +270 -0
  5. package/dist/commands/canvas-history/show.d.ts +1 -0
  6. package/dist/commands/canvas-history/show.js +79 -0
  7. package/dist/commands/canvas-history.d.ts +2 -0
  8. package/dist/commands/canvas-history.js +33 -0
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/memory/find.js +7 -40
  11. package/dist/commands/memory/lint.js +19 -34
  12. package/dist/commands/memory/list.js +8 -18
  13. package/dist/commands/memory/read.js +4 -37
  14. package/dist/commands/pkg/bridge.d.ts +1 -0
  15. package/dist/commands/pkg/bridge.js +137 -0
  16. package/dist/commands/pkg/plugin-inspect.js +7 -6
  17. package/dist/commands/pkg.js +2 -1
  18. package/dist/commands/skill/author.js +35 -44
  19. package/dist/commands/skill/shared.d.ts +1 -5
  20. package/dist/commands/skill/shared.js +9 -63
  21. package/dist/commands/skill.js +5 -6
  22. package/dist/commands/sys/doctor.js +6 -144
  23. package/dist/core/__tests__/broker-dormant-wake.test.js +71 -34
  24. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +21 -5
  25. package/dist/core/__tests__/canvas-inbox-watcher.test.js +21 -5
  26. package/dist/core/__tests__/child-death-wake.test.js +133 -185
  27. package/dist/core/__tests__/daemon-liveness.test.js +28 -258
  28. package/dist/core/__tests__/flagship-lifecycle.test.js +156 -135
  29. package/dist/core/__tests__/frame-decoder-perf.test.js +51 -25
  30. package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js} +62 -35
  31. package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js} +54 -31
  32. package/dist/core/__tests__/full/broker-crash-teardown.test.js +143 -0
  33. package/dist/core/__tests__/full/broker-dialogs.test.js +153 -0
  34. package/dist/core/__tests__/full/broker-lifecycle.test.js +116 -0
  35. package/dist/core/__tests__/{cascade-close.test.js → full/cascade-close.test.js} +1 -1
  36. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +292 -0
  37. package/dist/core/__tests__/{dead-pane-regression.test.js → full/dead-pane-regression.test.js} +2 -2
  38. package/dist/core/__tests__/{detach-focus.test.js → full/detach-focus.test.js} +5 -5
  39. package/dist/core/__tests__/{human-new-window-regression.test.js → full/human-new-window-regression.test.js} +2 -2
  40. package/dist/core/__tests__/{placement-focus.test.js → full/placement-focus.test.js} +5 -5
  41. package/dist/core/__tests__/{placement-reconcile.test.js → full/placement-reconcile.test.js} +3 -3
  42. package/dist/core/__tests__/{placement-revive.test.js → full/placement-revive.test.js} +5 -5
  43. package/dist/core/__tests__/{placement-teardown.test.js → full/placement-teardown.test.js} +4 -4
  44. package/dist/core/__tests__/{review-render-pane-regression.test.js → full/review-render-pane-regression.test.js} +2 -2
  45. package/dist/core/__tests__/full/spike-harness.test.js +117 -0
  46. package/dist/core/__tests__/grace-clock.test.js +72 -75
  47. package/dist/core/__tests__/helpers/harness.d.ts +35 -1
  48. package/dist/core/__tests__/helpers/harness.js +70 -12
  49. package/dist/core/__tests__/live-mutation-verbs.test.js +99 -104
  50. package/dist/core/__tests__/live-mutation.test.js +111 -134
  51. package/dist/core/__tests__/revive.test.js +96 -86
  52. package/dist/core/__tests__/subscription-delivery.test.js +116 -138
  53. package/dist/core/__tests__/wake-origin.test.js +54 -50
  54. package/dist/core/bridge-map.d.ts +19 -0
  55. package/dist/core/bridge-map.js +73 -0
  56. package/dist/core/canvas/history.d.ts +91 -0
  57. package/dist/core/canvas/history.js +389 -0
  58. package/dist/core/canvas/index.d.ts +1 -0
  59. package/dist/core/canvas/index.js +1 -0
  60. package/dist/core/config.js +3 -22
  61. package/dist/core/memory-resolver.d.ts +9 -2
  62. package/dist/core/memory-resolver.js +47 -7
  63. package/dist/core/resolver.d.ts +1 -30
  64. package/dist/core/resolver.js +6 -546
  65. package/dist/core/runtime/persona.js +5 -7
  66. package/dist/core/scope.d.ts +7 -5
  67. package/dist/core/scope.js +16 -17
  68. package/dist/core/substrate/ceiling.d.ts +28 -0
  69. package/dist/core/substrate/ceiling.js +87 -0
  70. package/dist/core/substrate/index.d.ts +2 -0
  71. package/dist/core/substrate/index.js +1 -0
  72. package/dist/core/substrate/on-read.js +18 -2
  73. package/dist/core/substrate/render.d.ts +2 -3
  74. package/dist/core/substrate/render.js +63 -51
  75. package/dist/pi-extensions/canvas-inbox-watcher.js +19 -2
  76. package/dist/types.d.ts +3 -18
  77. package/dist/types.js +0 -8
  78. package/package.json +4 -3
  79. package/dist/builtin-skills/.crouter-plugin/plugin.json +0 -5
  80. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +0 -157
  81. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +0 -106
  82. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +0 -49
  83. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +0 -49
  84. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +0 -156
  85. package/dist/builtin-skills/skills/design/SKILL.md +0 -51
  86. package/dist/builtin-skills/skills/development/SKILL.md +0 -109
  87. package/dist/builtin-skills/skills/planning/SKILL.md +0 -59
  88. package/dist/builtin-skills/skills/spec/SKILL.md +0 -83
  89. package/dist/commands/skill/state.d.ts +0 -3
  90. package/dist/commands/skill/state.js +0 -71
  91. package/dist/core/__tests__/broker-crash-teardown.test.js +0 -116
  92. package/dist/core/__tests__/broker-dialogs.test.js +0 -126
  93. package/dist/core/__tests__/broker-lifecycle.test.js +0 -87
  94. package/dist/core/__tests__/resolver.test.js +0 -181
  95. package/dist/core/__tests__/spike-harness.test.js +0 -242
  96. /package/dist/core/__tests__/{broker-attach-limits.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
  97. /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
  98. /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
  99. /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{cascade-close.test.d.ts → full/cascade-close.test.d.ts} +0 -0
  102. /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
  103. /package/dist/core/__tests__/{detach-focus.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
  104. /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/detach-focus.test.d.ts} +0 -0
  105. /package/dist/core/__tests__/{placement-focus.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
  106. /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/placement-focus.test.d.ts} +0 -0
  107. /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
  108. /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-revive.test.d.ts} +0 -0
  109. /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
  110. /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
  111. /package/dist/core/__tests__/{spike-harness.test.d.ts → full/spike-harness.test.d.ts} +0 -0
@@ -1,56 +1,7 @@
1
1
  import { usage } from '../../core/errors.js';
2
- import { SCOPE_SKILL_PLUGIN } from '../../types.js';
3
- import { listSkillSiblings, listSkillChildren, } from '../../core/resolver.js';
4
2
  import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
5
3
  // ---------------------------------------------------------------------------
6
- // Neighbors section (ported from old impl)
7
- // ---------------------------------------------------------------------------
8
- export function formatNeighborQualifier(s) {
9
- return s.plugin === SCOPE_SKILL_PLUGIN
10
- ? `${s.scope}/${s.name}`
11
- : `${s.plugin}/${s.name}`;
12
- }
13
- export function formatNeighborKeywords(s) {
14
- const kw = s.frontmatter.keywords;
15
- if (!kw || kw.length === 0)
16
- return '';
17
- return ` — [${kw.join(', ')}]`;
18
- }
19
- export function buildNeighborsSection(skill) {
20
- const siblings = listSkillSiblings(skill);
21
- const children = listSkillChildren(skill);
22
- if (siblings.length === 0 && children.length === 0)
23
- return null;
24
- const lines = [
25
- '## Neighbors',
26
- '*Auto-discovered from filesystem. Run `crtr memory read <name>` for full description + body.*',
27
- '',
28
- ];
29
- if (siblings.length > 0) {
30
- lines.push('**Siblings:**');
31
- for (const s of siblings) {
32
- lines.push(`- \`${formatNeighborQualifier(s)}\`${formatNeighborKeywords(s)}`);
33
- }
34
- if (children.length > 0)
35
- lines.push('');
36
- }
37
- if (children.length > 0) {
38
- lines.push('**Nested:**');
39
- for (const s of children) {
40
- lines.push(`- \`${formatNeighborQualifier(s)}\`${formatNeighborKeywords(s)}`);
41
- }
42
- }
43
- return lines.join('\n');
44
- }
45
- export function appendNeighbors(skill, body) {
46
- const section = buildNeighborsSection(skill);
47
- if (section === null)
48
- return body;
49
- const sep = body.endsWith('\n') ? '\n' : '\n\n';
50
- return body + sep + `<neighbors>\n${section}\n</neighbors>\n`;
51
- }
52
- // ---------------------------------------------------------------------------
53
- // Resolve scope for enable/disable/scaffold
4
+ // Resolve scope for scaffold
54
5
  // ---------------------------------------------------------------------------
55
6
  export function resolveWriteScope(scopeStr) {
56
7
  if (scopeStr !== undefined) {
@@ -73,19 +24,7 @@ export function renderCatalogSection(label, sources, descriptions, out) {
73
24
  const count = sources.reduce((n, s) => n + s.roots.length, 0);
74
25
  out.push('');
75
26
  out.push(`${label} (${count})`);
76
- const sentinel = sources.filter((s) => s.plugin === SCOPE_SKILL_PLUGIN);
77
- const named = sources
78
- .filter((s) => s.plugin !== SCOPE_SKILL_PLUGIN)
79
- .sort((a, b) => a.plugin.localeCompare(b.plugin));
80
- for (const s of sentinel) {
81
- if (s.roots.length > CATALOG_T) {
82
- out.push(` (scope skills) ${s.roots.length} skills`);
83
- }
84
- else {
85
- for (const n of s.roots)
86
- out.push(` ${n}`);
87
- }
88
- }
27
+ const named = [...sources].sort((a, b) => a.plugin.localeCompare(b.plugin));
89
28
  if (named.length === 0)
90
29
  return;
91
30
  const classified = named.map((s) => {
@@ -123,6 +62,13 @@ export function renderCatalogSection(label, sources, descriptions, out) {
123
62
  })
124
63
  .reduce((m, p) => Math.max(m, p.plugin.length + 1), 0);
125
64
  for (const p of classified) {
65
+ // Bare native/builtin skills (no plugin namespace) carry sourceKey ''.
66
+ // Render them as flat lines, never under a stray '/' header.
67
+ if (p.plugin === '') {
68
+ for (const r of [...p.roots].sort())
69
+ out.push(` ${r}`);
70
+ continue;
71
+ }
126
72
  const direct = p.subcats.size + p.bare.length;
127
73
  if (direct > CATALOG_T) {
128
74
  out.push(` ${(p.plugin + '/').padEnd(inlineW)} ${p.roots.length} skills${descSuffix(p.plugin)}`);
@@ -1,22 +1,21 @@
1
1
  // `crtr skill` subtree handlers.
2
- // Sub-branches: author {guide, scaffold}, state {enable, disable}.
2
+ // Sub-branch: author {guide, scaffold}.
3
3
  // Discovery and reading are now `crtr memory` verbs.
4
4
  import { defineBranch } from '../core/command.js';
5
5
  import { authorBranch } from './skill/author.js';
6
- import { stateBranch } from './skill/state.js';
7
6
  export function registerSkill() {
8
7
  return defineBranch({
9
8
  name: 'skill',
10
9
  rootEntry: {
11
10
  concept: 'a SKILL.md you author and manage',
12
11
  desc: 'author and manage skills',
13
- useWhen: 'authoring a new skill (`crtr skill author`) or toggling a skill on/off (`crtr skill state`). To discover and read skills use `crtr memory` — `crtr memory read <name>`, `crtr memory find`, `crtr memory list`.',
12
+ useWhen: 'authoring a new skill (`crtr skill author`). To discover and read skills use `crtr memory` — `crtr memory read <name>`, `crtr memory find`, `crtr memory list`.',
14
13
  },
15
14
  help: {
16
15
  name: 'skill',
17
- summary: 'author and manage skill state — use `crtr memory` to discover and read skills',
18
- model: '`author` when you are writing a new skill — it carries the template workflow and the scaffolder. `state` when a skill should be hidden from discovery without being removed. Discovery and reading are `crtr memory` verbs: `crtr memory list`, `crtr memory find`, `crtr memory read`. Append `-h` at any branch or leaf for its full schema.',
16
+ summary: 'author skills — use `crtr memory` to discover and read them',
17
+ model: '`author` when you are writing a new skill — it carries the template workflow and the scaffolder. Discovery and reading are `crtr memory` verbs: `crtr memory list`, `crtr memory find`, `crtr memory read`. Visibility is governed by the substrate (INDEX/rung/gate), not a per-skill on/off. Append `-h` at any branch or leaf for its full schema.',
19
18
  },
20
- children: [authorBranch, stateBranch],
19
+ children: [authorBranch],
21
20
  });
22
21
  }
@@ -1,54 +1,16 @@
1
1
  import { join } from 'node:path';
2
2
  import { defineLeaf } from '../../core/command.js';
3
3
  import { readConfig, updateConfig } from '../../core/config.js';
4
- import { scopeRoot, listScopes, builtinSkillsRoot, marketplacesDir, pluginsDir } from '../../core/scope.js';
5
- import { listInstalledPlugins, listSkillsInPlugin } from '../../core/resolver.js';
4
+ import { scopeRoot, listScopes, marketplacesDir, pluginsDir } from '../../core/scope.js';
6
5
  import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
7
- import { parseFrontmatter } from '../../core/frontmatter.js';
8
- import { pathExists, listDirs, removePath, readText, writeText } from '../../core/fs-utils.js';
6
+ import { pathExists, listDirs, removePath } from '../../core/fs-utils.js';
9
7
  import { lsRemote } from '../../core/git.js';
10
- import { SKILL_TYPES, isSkillType } from '../../types.js';
11
8
  function pass(scope, name, message) {
12
9
  return { scope, name, status: 'pass', message };
13
10
  }
14
11
  function failCheck(scope, name, message, remediation) {
15
12
  return { scope, name, status: 'fail', message, ...(remediation ? { remediation } : {}) };
16
13
  }
17
- function warnCheck(scope, name, message, remediation) {
18
- return { scope, name, status: 'warn', message, ...(remediation ? { remediation } : {}) };
19
- }
20
- /**
21
- * Surgically replace a single frontmatter scalar field's value in a SKILL.md
22
- * file. Preserves the rest of the file (key order, comments, extra fields,
23
- * body) exactly. Returns true on success, false if no frontmatter or no such
24
- * field was found.
25
- */
26
- function editFrontmatterField(filePath, field, newValue) {
27
- let src;
28
- try {
29
- src = readText(filePath);
30
- }
31
- catch {
32
- return false;
33
- }
34
- const fmMatch = src.match(/^(---\s*\r?\n)([\s\S]*?)(\r?\n---\s*\r?\n?)/);
35
- if (!fmMatch)
36
- return false;
37
- const [, head, body, tail] = fmMatch;
38
- const fieldRe = new RegExp(`^(\\s*${field}\\s*:\\s*)(.*)$`, 'm');
39
- if (!fieldRe.test(body))
40
- return false;
41
- const quoted = /[:#\-\[\]{},&*?|<>=!%@`]/.test(newValue) || /^\s/.test(newValue) || /\s$/.test(newValue);
42
- const formatted = quoted ? `"${newValue.replace(/"/g, '\\"')}"` : newValue;
43
- const newBody = body.replace(fieldRe, `$1${formatted}`);
44
- try {
45
- writeText(filePath, head + newBody + tail + src.slice(fmMatch[0].length));
46
- return true;
47
- }
48
- catch {
49
- return false;
50
- }
51
- }
52
14
  /**
53
15
  * Apply a remediation. Returns true if applied successfully. Idempotent for
54
16
  * the supported kinds (re-applying a config-key removal that's already gone
@@ -79,11 +41,6 @@ function applyRemediation(rem) {
79
41
  removePath(rem.path);
80
42
  return true;
81
43
  }
82
- case 'edit_frontmatter': {
83
- if (!rem.filePath || !rem.field || rem.value === undefined)
84
- return false;
85
- return editFrontmatterField(rem.filePath, rem.field, rem.value);
86
- }
87
44
  default:
88
45
  return false;
89
46
  }
@@ -92,46 +49,7 @@ function applyRemediation(rem) {
92
49
  return false;
93
50
  }
94
51
  }
95
- function readRawTypeField(skillPath) {
96
- const content = readText(skillPath);
97
- const { raw } = parseFrontmatter(content);
98
- if (!raw)
99
- return undefined;
100
- const m = raw.match(/^type:\s*(.+?)\s*$/m);
101
- if (!m)
102
- return undefined;
103
- let v = m[1].trim();
104
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
105
- v = v.slice(1, -1);
106
- }
107
- return v;
108
- }
109
- function runChecksForBuiltin() {
110
- const root = builtinSkillsRoot();
111
- const plugins = listInstalledPlugins('builtin');
112
- if (plugins.length === 0) {
113
- return [failCheck('builtin', 'builtin:crtr:root', `builtin-skills root missing or has no valid plugin.json: ${root}`)];
114
- }
115
- const results = [
116
- pass('builtin', 'builtin:crtr:root', `builtin-skills root present: ${root}`),
117
- ];
118
- for (const plugin of plugins) {
119
- results.push(pass('builtin', `builtin:${plugin.name}:manifest`, `manifest valid`));
120
- const skills = listSkillsInPlugin(plugin);
121
- for (const skill of skills) {
122
- if (!skill.frontmatter.name) {
123
- results.push(failCheck('builtin', `builtin:${plugin.name}:skill:${skill.name}:frontmatter`, `frontmatter missing or name field empty`));
124
- }
125
- else {
126
- results.push(pass('builtin', `builtin:${plugin.name}:skill:${skill.name}:frontmatter`, `frontmatter valid`));
127
- }
128
- }
129
- }
130
- return results;
131
- }
132
52
  function runChecksForScope(scope, opts) {
133
- if (scope === 'builtin')
134
- return runChecksForBuiltin();
135
53
  const results = [];
136
54
  const root = scopeRoot(scope);
137
55
  if (!root)
@@ -253,62 +171,6 @@ function runChecksForScope(scope, opts) {
253
171
  else {
254
172
  seenPluginNames.set(name, dir);
255
173
  }
256
- // Check: skills frontmatter name. Convention: frontmatter `name:` holds
257
- // the leaf segment only (e.g. "cli-design"); the full discovered name
258
- // ("interface/cli-design") is derived from the path automatically.
259
- const plugin = listInstalledPlugins(scope).find((p) => p.name === name);
260
- if (plugin) {
261
- const skills = listSkillsInPlugin(plugin);
262
- for (const skill of skills) {
263
- const checkName = `plugin:${name}:skill:${skill.name}:frontmatter`;
264
- const segments = skill.name.split('/');
265
- const baseName = segments[segments.length - 1];
266
- if (skill.frontmatter.name === baseName) {
267
- results.push(pass(scope, checkName, `frontmatter valid`));
268
- }
269
- else if (skill.frontmatter.name === '') {
270
- const remediation = {
271
- kind: 'edit_frontmatter',
272
- description: `Set frontmatter "name: ${baseName}" (discovered name "${skill.name}" is auto-derived from the directory path; frontmatter holds the base segment only)`,
273
- filePath: skill.path,
274
- field: 'name',
275
- value: baseName,
276
- };
277
- if (opts.fix && applyRemediation(remediation)) {
278
- results.push({ scope, name: checkName, status: 'fail', message: `frontmatter name was missing — set to "${baseName}"`, fixed: true, remediation });
279
- }
280
- else {
281
- results.push(failCheck(scope, checkName, `frontmatter missing or name field empty`, remediation));
282
- }
283
- }
284
- else {
285
- const remediation = {
286
- kind: 'edit_frontmatter',
287
- description: `Replace frontmatter "name: ${skill.frontmatter.name}" with "name: ${baseName}" (discovered name "${skill.name}" is auto-derived from the directory path; frontmatter holds the base segment only)`,
288
- filePath: skill.path,
289
- field: 'name',
290
- value: baseName,
291
- };
292
- if (opts.fix && applyRemediation(remediation)) {
293
- results.push({ scope, name: checkName, status: 'warn', message: `frontmatter name updated from "${skill.frontmatter.name}" to "${baseName}"`, fixed: true, remediation });
294
- }
295
- else {
296
- results.push(warnCheck(scope, checkName, `name mismatch: frontmatter says "${skill.frontmatter.name}", expected base name "${baseName}" (discovered as "${skill.name}")`, remediation));
297
- }
298
- }
299
- const typeCheckName = `plugin:${name}:skill:${skill.name}:type`;
300
- const rawType = readRawTypeField(skill.path);
301
- if (rawType === undefined) {
302
- results.push(warnCheck(scope, typeCheckName, `missing type field — add one of: ${SKILL_TYPES.join(' | ')}`));
303
- }
304
- else if (!isSkillType(rawType)) {
305
- results.push(failCheck(scope, typeCheckName, `invalid type "${rawType}" — valid: ${SKILL_TYPES.join(' | ')}`));
306
- }
307
- else {
308
- results.push(pass(scope, typeCheckName, `type: ${rawType}`));
309
- }
310
- }
311
- }
312
174
  // Git remote check (slow, opt-in)
313
175
  if (opts.remote && manifest.source) {
314
176
  const res = lsRemote(manifest.source);
@@ -326,23 +188,23 @@ function runChecksForScope(scope, opts) {
326
188
  export const sysDoctorLeaf = defineLeaf({
327
189
  name: 'doctor',
328
190
  description: 'diagnose installation health',
329
- whenToUse: 'something in your crtr install looks off and you want it diagnosed — a plugin or marketplace manifest is missing, a config entry points at a directory that no longer exists, or skill frontmatter has drifted from its filename. Reports each problem with a structured remediation, and can apply the repairs for you.',
191
+ whenToUse: 'something in your crtr install looks off and you want it diagnosed — a plugin or marketplace manifest is missing, or a config entry points at a directory that no longer exists. Reports each problem with a structured remediation, and can apply the repairs for you.',
330
192
  help: {
331
193
  name: 'sys doctor',
332
- summary: 'diagnose missing manifests, broken config entries, and skill frontmatter drift',
194
+ summary: 'diagnose missing manifests and broken config entries',
333
195
  params: [
334
196
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to check. Default: all scopes.' },
335
197
  { kind: 'flag', name: 'fix', type: 'bool', required: false, constraint: 'Apply each non-pass check\'s remediation and report what was fixed.' },
336
198
  { kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
337
199
  ],
338
200
  output: [
339
- { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail | warn. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | edit_frontmatter. Sorted by scope then name.' },
201
+ { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path. Sorted by scope then name.' },
340
202
  { name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
341
203
  ],
342
204
  outputKind: 'object',
343
205
  effects: [
344
206
  'Read-only unless --fix is passed.',
345
- 'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries, deletes dangling plugin/marketplace directories, edits frontmatter name fields to the base-name convention.',
207
+ 'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries and deletes dangling plugin/marketplace directories.',
346
208
  'Each non-pass result carries a structured `remediation` describing the fix action (absolute paths, exact config keys) so callers can apply it directly without --fix.',
347
209
  ],
348
210
  },
@@ -1,51 +1,88 @@
1
1
  // Run with: node --import tsx/esm --test src/core/__tests__/broker-dormant-wake.test.ts
2
2
  //
3
- // Acceptance item 3 (DORMANT) of the headless-broker migration. Split out of
4
- // broker-lifecycle.test.ts (see its header for the full file map); the test is
5
- // the original acceptance gate, unchanged, on its own isolated harness.
3
+ // HEADLESS RETARGET (foundation-spec §C.5 + §E). Acceptance item 3 (DORMANT) of
4
+ // the headless-broker migration, fabricated DIRECTLY in the canvas no real
5
+ // tmux session, no pane chrome, and NO real broker boot. The wake decision is
6
+ // pure daemon logic, so the test never pays the ~5s SDK-boot cost.
7
+ //
8
+ // (1) BUG LOCKED — Invariant D (idle-release dormancy). A node that naturally
9
+ // stops while awaiting a LIVE subscription idle-RELEASES its broker (the
10
+ // broker process exits); the daemon's SECOND PASS must revive it (RESUME)
11
+ // the moment an UNSEEN inbox entry appears. Without the second pass a dormant
12
+ // orchestrator never wakes on a worker's message and the graph deadlocks.
13
+ //
14
+ // (2) WHY MODEL-LEVEL, NOT TMUX CHROME — every step is canvas/daemon state: the
15
+ // dormant state is status=idle / intent=idle-release / pi_pid dead (a
16
+ // released broker is a dead pid, the headless analog of a closed pane), and
17
+ // superviseTick's second pass gates on status===idle && intent===idle-release
18
+ // && !isPidAlive(pi_pid) && readInboxSince>cursor → reviveNode(resume). No
19
+ // pane is read anywhere; a broker is supervised purely by pid, so a
20
+ // fabricated row reproduces the exact dormant state with no process at all.
21
+ //
22
+ // (3) HOW THE FABRICATED DRIVE STILL FAILS IF THE BUG REGRESSES — pass 1: a tick
23
+ // with NO unseen inbox must NOT revive (cycles unchanged) — the idle-release
24
+ // early-return. Then we appendInbox an unseen entry and tick again; pass 2's
25
+ // reviveNode bumps cycles + transition('revive') BEFORE the (detached,
26
+ // unawaited) broker spawn, so the wake is observable instantly via cycles +
27
+ // status. If the second-pass reviveNode is removed, the post-inbox tick never
28
+ // bumps cycles → the "revived" asserts go RED. Verified by commenting the
29
+ // second-pass reviveNode(resume) call (see bug-injection report).
6
30
  import { test, before, after } from 'node:test';
7
31
  import assert from 'node:assert/strict';
8
- import { createHarness, hasTmux } from './helpers/harness.js';
9
- import { subscribe } from '../canvas/canvas.js';
32
+ import { spawnSync } from 'node:child_process';
33
+ import { createHarness } from './helpers/harness.js';
10
34
  import { appendInbox } from '../feed/inbox.js';
11
- import { isPidAlive } from '../canvas/pid.js';
12
35
  let h;
13
- let root;
14
36
  before(async () => {
15
- if (!hasTmux())
16
- return;
17
- h = await createHarness({ sessionPrefix: 'crtr-brkdorm' });
18
- // An active resident root: the spawn parent AND the live node a child can hold
19
- // an active subscription to (so its natural stop classifies 'awaiting').
20
- root = h.spawnRoot('broker-dormant suite root');
37
+ h = await createHarness({ headless: true, sessionPrefix: 'crtr-brkdorm' });
21
38
  });
22
39
  after(async () => {
23
40
  if (h !== undefined)
24
41
  await h.dispose();
25
42
  });
43
+ // A pid that is reaped (dead) by the time spawnSync returns — a released
44
+ // broker's exited process.
45
+ function deadPid() {
46
+ const r = spawnSync('true', [], { stdio: 'ignore' });
47
+ return r.pid ?? 0x7ffffffe;
48
+ }
26
49
  // ===========================================================================
27
- // Item 3 (DORMANT) — a natural stop while awaiting a live subscription idle-
28
- // RELEASES the broker (it exits); the daemon's second pass revives it (RESUME)
29
- // on the next unseen inbox entry.
50
+ // Item 3 (DORMANT) — a node idle-RELEASED (its broker exited) is NOT revived by
51
+ // a bare tick, but the daemon's second pass revives it (RESUME) on the next
52
+ // unseen inbox entry.
30
53
  // ===========================================================================
31
- test('DORMANT wake — idle-release broker exits → daemon pass-2 reviveNode(resume) on inbox', { skip: !hasTmux() }, async () => {
32
- const id = await h.spawnHeadlessChild(root, 'headless worker — dormant wake');
33
- const pid = h.node(id).pi_pid;
34
- // Give the (terminal) child an ACTIVE subscription to the LIVE root, so the
35
- // stop-guard classifies its natural stop as 'awaiting' (a dormant orchestrator
36
- // awaiting a worker) → the stophook idle-releases it (paneless → no focus).
37
- subscribe(id, root, true);
38
- await h.stop(id, 'stop');
39
- await h.waitForStatus(id, 'idle');
40
- assert.equal(h.node(id).intent, 'idle-release', 'awaiting + unfocused → idle-release');
41
- await h.waitFor(() => !isPidAlive(pid), { label: 'released broker process exited' });
42
- // Daemon owns wake-on-message for a dormant (pi-dead) broker: an unseen inbox
43
- // entry + a tick → pass 2 → reviveNode(resume:true) → a fresh broker boots.
54
+ test('DORMANT wake — idle-release broker (pi dead) → daemon pass-2 reviveNode(resume) on unseen inbox', async () => {
55
+ // Fabricate the post-release dormant state directly: status=idle,
56
+ // intent=idle-release, a DEAD supervised pid, pi_session_id set (so the revive
57
+ // RESUMES the saved session). No boot, no stop, no pane.
58
+ const id = h.fabricateBrokerNode({
59
+ status: 'idle',
60
+ intent: 'idle-release',
61
+ pi_pid: deadPid(),
62
+ pi_session_id: 'sess-dorm',
63
+ });
64
+ {
65
+ const n = h.node(id);
66
+ assert.equal(n.host_kind, 'broker', 'broker-hosted (paneless)');
67
+ assert.equal(n.status, 'idle', 'idle (released)');
68
+ assert.equal(n.intent, 'idle-release', 'intent=idle-release');
69
+ assert.equal(n.cycles ?? 0, 0, 'no revive yet');
70
+ }
71
+ // PASS 1 — a tick with NO unseen inbox must NOT revive: idle-release is
72
+ // dormancy by choice; the daemon leaves it until an inbox entry arrives.
73
+ await h.tick();
74
+ assert.equal(h.node(id).cycles ?? 0, 0, 'no unseen inbox → NOT revived (idle-release left dormant)');
75
+ assert.equal(h.node(id).status, 'idle', 'still idle after the empty tick');
76
+ // PASS 2 — an unseen inbox entry + a tick → the daemon owns wake-on-message for
77
+ // a dormant (pi-dead) broker: second pass → reviveNode(resume:true).
44
78
  appendInbox(id, { from: 'tester', tier: 'normal', kind: 'message', label: 'resume-me', data: { body: 'work after release' } });
45
79
  await h.tick();
46
- const boot2 = await h.awaitBoot(id, { minCount: 2 });
47
- assert.equal(boot2.resuming, true, 'the dormant revive RESUMES the saved session');
48
- const newPid = h.node(id).pi_pid;
49
- assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
50
- assert.notEqual(newPid, pid, 'the revived broker is a fresh process');
80
+ // reviveNode bumps cycles + transition('revive') BEFORE the (detached,
81
+ // unawaited) broker spawn, so the wake is observable instantly.
82
+ {
83
+ const n = h.node(id);
84
+ assert.equal(n.cycles ?? 0, 1, 'unseen inbox + tick daemon pass-2 REVIVED (cycles bumped to 1)');
85
+ assert.equal(n.status, 'active', 'revive → active (transition(revive))');
86
+ assert.equal(n.intent ?? null, null, 'intent cleared by the revive');
87
+ }
51
88
  });
@@ -13,11 +13,13 @@ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher
13
13
  import { appendInbox } from '../feed/inbox.js';
14
14
  import { createNode, setIntent } from '../canvas/canvas.js';
15
15
  import { closeDb } from '../canvas/db.js';
16
- // Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
17
- // resolve+seed tick, a read tick, and the debounce window before asserting.
18
- const TICK_MS = 800;
19
- const DEBOUNCE_MS = 1000;
20
- const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 500;
16
+ // Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
17
+ // CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the test sleeps milliseconds, not
18
+ // seconds. SETTLE_MS still allows a resolve+seed tick, a read tick, and the
19
+ // debounce window before asserting, exactly as against the real 800/1000 cadence.
20
+ const TICK_MS = 20;
21
+ const DEBOUNCE_MS = 25;
22
+ const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 30;
21
23
  let origHome;
22
24
  let origNode;
23
25
  const homes = [];
@@ -40,9 +42,15 @@ function makeFakePi() {
40
42
  };
41
43
  }
42
44
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
45
+ let origTick;
46
+ let origDebounce;
43
47
  before(() => {
44
48
  origHome = process.env['CRTR_HOME'];
45
49
  origNode = process.env['CRTR_NODE_ID'];
50
+ origTick = process.env['CRTR_WATCHER_TICK_MS'];
51
+ origDebounce = process.env['CRTR_WATCHER_DEBOUNCE_MS'];
52
+ process.env['CRTR_WATCHER_TICK_MS'] = String(TICK_MS);
53
+ process.env['CRTR_WATCHER_DEBOUNCE_MS'] = String(DEBOUNCE_MS);
46
54
  });
47
55
  afterEach(() => {
48
56
  while (disposers.length > 0)
@@ -57,6 +65,14 @@ after(() => {
57
65
  delete process.env['CRTR_NODE_ID'];
58
66
  else
59
67
  process.env['CRTR_NODE_ID'] = origNode;
68
+ if (origTick === undefined)
69
+ delete process.env['CRTR_WATCHER_TICK_MS'];
70
+ else
71
+ process.env['CRTR_WATCHER_TICK_MS'] = origTick;
72
+ if (origDebounce === undefined)
73
+ delete process.env['CRTR_WATCHER_DEBOUNCE_MS'];
74
+ else
75
+ process.env['CRTR_WATCHER_DEBOUNCE_MS'] = origDebounce;
60
76
  for (const h of homes) {
61
77
  try {
62
78
  rmSync(h, { recursive: true, force: true });
@@ -13,11 +13,13 @@ import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
  import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
15
15
  import { appendInbox } from '../feed/inbox.js';
16
- // Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
17
- // resolve+seed tick, a read tick, and the debounce window before asserting.
18
- const TICK_MS = 800;
19
- const DEBOUNCE_MS = 1000;
20
- const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 500;
16
+ // Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
17
+ // CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the test sleeps milliseconds, not
18
+ // seconds. SETTLE_MS still allows a resolve+seed tick, a read tick, and the
19
+ // debounce window before asserting, exactly as against the real 800/1000 cadence.
20
+ const TICK_MS = 20;
21
+ const DEBOUNCE_MS = 25;
22
+ const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 30;
21
23
  let origHome;
22
24
  let origNode;
23
25
  const homes = [];
@@ -40,9 +42,15 @@ function makeFakePi() {
40
42
  };
41
43
  }
42
44
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
45
+ let origTick;
46
+ let origDebounce;
43
47
  before(() => {
44
48
  origHome = process.env['CRTR_HOME'];
45
49
  origNode = process.env['CRTR_NODE_ID'];
50
+ origTick = process.env['CRTR_WATCHER_TICK_MS'];
51
+ origDebounce = process.env['CRTR_WATCHER_DEBOUNCE_MS'];
52
+ process.env['CRTR_WATCHER_TICK_MS'] = String(TICK_MS);
53
+ process.env['CRTR_WATCHER_DEBOUNCE_MS'] = String(DEBOUNCE_MS);
46
54
  });
47
55
  afterEach(() => {
48
56
  while (disposers.length > 0)
@@ -57,6 +65,14 @@ after(() => {
57
65
  delete process.env['CRTR_NODE_ID'];
58
66
  else
59
67
  process.env['CRTR_NODE_ID'] = origNode;
68
+ if (origTick === undefined)
69
+ delete process.env['CRTR_WATCHER_TICK_MS'];
70
+ else
71
+ process.env['CRTR_WATCHER_TICK_MS'] = origTick;
72
+ if (origDebounce === undefined)
73
+ delete process.env['CRTR_WATCHER_DEBOUNCE_MS'];
74
+ else
75
+ process.env['CRTR_WATCHER_DEBOUNCE_MS'] = origDebounce;
60
76
  for (const h of homes) {
61
77
  try {
62
78
  rmSync(h, { recursive: true, force: true });