@crouton-kit/crouter 0.3.3 → 0.3.8

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.
@@ -1,13 +1,10 @@
1
- // Tests for the job subtree argv migration.
2
- // Exercises parseArgv against each leaf's param schema directly — no subprocess,
3
- // no tmux, no filesystem side-effects from the handler.
1
+ // Tests for the job subtree (monitoring) and agent new * (spawning) argv
2
+ // migration. Exercises parseArgv against each leaf's param schema directly —
3
+ // no subprocess, no tmux, no filesystem side-effects from the handler.
4
4
  //
5
5
  // Run with: node --import tsx/esm --test src/core/__tests__/job.test.ts
6
6
  import { test, describe } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
11
8
  import { parseArgv } from '../command.js';
12
9
  // ---------------------------------------------------------------------------
13
10
  // Param schemas extracted from each leaf (mirrors job.ts exactly)
@@ -51,9 +48,16 @@ const readLogsParams = [
51
48
  { kind: 'flag', name: 'level', type: 'enum', choices: ['debug', 'info', 'warn', 'error'], required: false, default: 'info', constraint: '' },
52
49
  { kind: 'flag', name: 'follow', type: 'bool', required: false, constraint: '' },
53
50
  ];
51
+ // NOTE: the real leaf also declares a `stdin` param for `body`. We omit it
52
+ // from the test schema because parseArgv reads stdin to EOF whenever a stdin
53
+ // param is declared — and under `node --test`, stdin is piped with no EOF, so
54
+ // the call hangs forever. The body-required-on-status=done check lives in the
55
+ // leaf's `run` handler, not in parseArgv, so the schema tests below cover
56
+ // everything parseArgv can see without needing stdin.
54
57
  const submitParams = [
55
58
  { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: '' },
56
- { kind: 'context-file', name: 'result', required: true, constraint: '' },
59
+ { kind: 'flag', name: 'status', type: 'enum', choices: ['done', 'failed'], required: false, default: 'done', constraint: '' },
60
+ { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: '' },
57
61
  { kind: 'flag', name: 'kill-pane', type: 'bool', required: false, constraint: '' },
58
62
  ];
59
63
  const cancelParams = [
@@ -63,27 +67,9 @@ const failParams = [
63
67
  { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: '' },
64
68
  ];
65
69
  // ---------------------------------------------------------------------------
66
- // Helpers
70
+ // agent new prompt (formerly job start prompt)
67
71
  // ---------------------------------------------------------------------------
68
- let tmpDir;
69
- function setup() {
70
- tmpDir = join(tmpdir(), `crtr-job-test-${Date.now()}`);
71
- mkdirSync(tmpDir, { recursive: true });
72
- }
73
- function teardown() {
74
- if (tmpDir !== undefined) {
75
- rmSync(tmpDir, { recursive: true, force: true });
76
- }
77
- }
78
- function writeTmpJson(name, obj) {
79
- const p = join(tmpDir, name);
80
- writeFileSync(p, JSON.stringify(obj), 'utf8');
81
- return p;
82
- }
83
- // ---------------------------------------------------------------------------
84
- // job start prompt
85
- // ---------------------------------------------------------------------------
86
- describe('job start prompt', () => {
72
+ describe('agent new prompt', () => {
87
73
  // stdin is handled by readStdinRaw() which requires actual stdin — we only
88
74
  // test the non-stdin flag parsing here.
89
75
  test('--cwd flag parsed as string', async () => {
@@ -105,9 +91,9 @@ describe('job start prompt', () => {
105
91
  });
106
92
  });
107
93
  // ---------------------------------------------------------------------------
108
- // job start fork
94
+ // agent new fork (formerly job start fork)
109
95
  // ---------------------------------------------------------------------------
110
- describe('job start fork', () => {
96
+ describe('agent new fork', () => {
111
97
  test('no args parses cleanly', async () => {
112
98
  const result = await parseArgv(startForkParams, []);
113
99
  assert.equal(result['cwd'], undefined);
@@ -121,9 +107,9 @@ describe('job start fork', () => {
121
107
  });
122
108
  });
123
109
  // ---------------------------------------------------------------------------
124
- // job start planner
110
+ // agent new planner (formerly job start planner)
125
111
  // ---------------------------------------------------------------------------
126
- describe('job start planner', () => {
112
+ describe('agent new planner', () => {
127
113
  test('positional spec_path required', async () => {
128
114
  await assert.rejects(() => parseArgv(startPlannerParams, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
129
115
  });
@@ -139,9 +125,9 @@ describe('job start planner', () => {
139
125
  });
140
126
  });
141
127
  // ---------------------------------------------------------------------------
142
- // job start implementer
128
+ // agent new implementer (formerly job start implementer)
143
129
  // ---------------------------------------------------------------------------
144
- describe('job start implementer', () => {
130
+ describe('agent new implementer', () => {
145
131
  test('positional plan_path required', async () => {
146
132
  await assert.rejects(() => parseArgv(startImplementerParams, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
147
133
  });
@@ -151,9 +137,9 @@ describe('job start implementer', () => {
151
137
  });
152
138
  });
153
139
  // ---------------------------------------------------------------------------
154
- // job start reviewer
140
+ // agent new reviewer (formerly job start reviewer)
155
141
  // ---------------------------------------------------------------------------
156
- describe('job start reviewer', () => {
142
+ describe('agent new reviewer', () => {
157
143
  test('positional + --kind required', async () => {
158
144
  await assert.rejects(() => parseArgv(startReviewerParams, ['/tmp/artifact.md']), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
159
145
  });
@@ -273,51 +259,29 @@ describe('job read logs', () => {
273
259
  // job submit
274
260
  // ---------------------------------------------------------------------------
275
261
  describe('job submit', () => {
276
- // setup/teardown wraps tests via explicit calls since node:test lacks
277
- // per-describe lifecycle hooks in older versions.
278
- test('--context-file with valid JSON object', async () => {
279
- setup();
280
- try {
281
- const p = writeTmpJson('result.json', { status: 'done', summary: 'all good' });
282
- const result = await parseArgv(submitParams, ['job-abc', '--context-file', p]);
283
- assert.equal(result['job_id'], 'job-abc');
284
- assert.deepEqual(result['result'], { status: 'done', summary: 'all good' });
285
- assert.equal(result['killPane'], false);
286
- }
287
- finally {
288
- teardown();
289
- }
262
+ test('positional job_id + defaults: status=done, killPane=false', async () => {
263
+ const result = await parseArgv(submitParams, ['job-abc']);
264
+ assert.equal(result['job_id'], 'job-abc');
265
+ assert.equal(result['status'], 'done');
266
+ assert.equal(result['killPane'], false);
267
+ });
268
+ test('--status failed parsed', async () => {
269
+ const result = await parseArgv(submitParams, ['job-abc', '--status', 'failed', '--reason', 'broken']);
270
+ assert.equal(result['status'], 'failed');
271
+ assert.equal(result['reason'], 'broken');
272
+ });
273
+ test('--status invalid enum throws', async () => {
274
+ await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--status', 'bogus']), (err) => { assert.match(err.message, /must be one of/); return true; });
290
275
  });
291
276
  test('--kill-pane presence = true, killPane key', async () => {
292
- setup();
293
- try {
294
- const p = writeTmpJson('result.json', { status: 'done' });
295
- const result = await parseArgv(submitParams, ['job-abc', '--context-file', p, '--kill-pane']);
296
- assert.equal(result['killPane'], true);
297
- }
298
- finally {
299
- teardown();
300
- }
301
- });
302
- test('missing --context-file throws missing_parameter', async () => {
303
- await assert.rejects(() => parseArgv(submitParams, ['job-abc']), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
277
+ const result = await parseArgv(submitParams, ['job-abc', '--kill-pane']);
278
+ assert.equal(result['killPane'], true);
304
279
  });
305
280
  test('missing positional job_id throws missing_parameter', async () => {
306
281
  await assert.rejects(() => parseArgv(submitParams, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
307
282
  });
308
- test('--context-file with non-existent file throws invalid_type', async () => {
309
- await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--context-file', '/no/such/file.json']), (err) => { assert.match(err.message, /cannot read file/); return true; });
310
- });
311
- test('--context-file with invalid JSON throws invalid_type', async () => {
312
- setup();
313
- try {
314
- const p = join(tmpDir, 'bad.json');
315
- writeFileSync(p, 'not json', 'utf8');
316
- await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--context-file', p]), (err) => { assert.match(err.message, /not valid JSON/); return true; });
317
- }
318
- finally {
319
- teardown();
320
- }
283
+ test('--context-file no longer accepted', async () => {
284
+ await assert.rejects(() => parseArgv(submitParams, ['job-abc', '--context-file', '/tmp/anything']), (err) => { assert.match(err.message, /unknown flag: --context-file/); return true; });
321
285
  });
322
286
  });
323
287
  // ---------------------------------------------------------------------------
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ // Tests for jobs.ts result-file storage (markdown vs json paths).
2
+ //
3
+ // Run with: node --import tsx/esm --test src/core/__tests__/jobs.test.ts
4
+ import { test, describe, before, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { createJob, writeResult, writeMarkdownResult, readResult, } from '../jobs.js';
10
+ let stateDir;
11
+ let origXdg;
12
+ before(() => {
13
+ stateDir = join(tmpdir(), `crtr-jobs-test-${Date.now()}`);
14
+ mkdirSync(stateDir, { recursive: true });
15
+ origXdg = process.env['XDG_STATE_HOME'];
16
+ process.env['XDG_STATE_HOME'] = stateDir;
17
+ });
18
+ after(() => {
19
+ if (origXdg === undefined) {
20
+ delete process.env['XDG_STATE_HOME'];
21
+ }
22
+ else {
23
+ process.env['XDG_STATE_HOME'] = origXdg;
24
+ }
25
+ rmSync(stateDir, { recursive: true, force: true });
26
+ });
27
+ describe('writeMarkdownResult + readResult round-trip', () => {
28
+ test('done with body writes result.md, parses frontmatter back', async () => {
29
+ const { jobId, dir } = createJob('prompt', { cwd: '/tmp' });
30
+ const body = '**Summary:** all good\n\nMore details on the next line.\n';
31
+ writeMarkdownResult(jobId, body, 'done');
32
+ assert.ok(existsSync(join(dir, 'result.md')), 'result.md should exist');
33
+ assert.ok(!existsSync(join(dir, 'result.json')), 'result.json should NOT exist on md path');
34
+ const raw = readFileSync(join(dir, 'result.md'), 'utf8');
35
+ assert.match(raw, /^---\nstatus: done\nwritten_at: \d{4}-\d{2}-\d{2}T/);
36
+ assert.ok(raw.endsWith(body), 'body preserved at end of file');
37
+ const r = await readResult(jobId, { waitMs: 0 });
38
+ assert.equal(r.status, 'done');
39
+ assert.equal(r.result_md, body);
40
+ assert.equal(r.reason, undefined);
41
+ assert.equal(r.result, undefined);
42
+ });
43
+ test('failed with reason writes reason into frontmatter and reads it back', async () => {
44
+ const { jobId } = createJob('prompt', { cwd: '/tmp' });
45
+ writeMarkdownResult(jobId, '', 'failed', 'broke: had "quoted" parts and a\nnewline');
46
+ const r = await readResult(jobId, { waitMs: 0 });
47
+ assert.equal(r.status, 'failed');
48
+ assert.equal(r.result_md, '');
49
+ assert.equal(r.reason, 'broke: had "quoted" parts and a\nnewline');
50
+ });
51
+ test('writeResult (JSON) writes result.json and read still works', async () => {
52
+ const { jobId, dir } = createJob('prompt', { cwd: '/tmp' });
53
+ writeResult(jobId, { feedback: 'approved', n: 3 }, 'done');
54
+ assert.ok(existsSync(join(dir, 'result.json')));
55
+ assert.ok(!existsSync(join(dir, 'result.md')));
56
+ const r = await readResult(jobId, { waitMs: 0 });
57
+ assert.equal(r.status, 'done');
58
+ assert.deepEqual(r.result, { feedback: 'approved', n: 3 });
59
+ assert.equal(r.result_md, undefined);
60
+ });
61
+ test('readResult with no result file and waitMs=0 returns timeout', async () => {
62
+ const { jobId } = createJob('prompt', { cwd: '/tmp' });
63
+ const r = await readResult(jobId, { waitMs: 0 });
64
+ assert.equal(r.status, 'timeout');
65
+ });
66
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,113 @@
1
+ // Tests for parseSkillQualifier (slash-only form) and config migration.
2
+ //
3
+ // Run with: node --import tsx/esm --test 'src/core/__tests__/resolver.test.ts'
4
+ import { test, describe, before, after } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { parseSkillQualifier } from '../resolver.js';
10
+ import { InputError } from '../io.js';
11
+ import { readConfig } from '../config.js';
12
+ import { SCHEMA_VERSION } from '../../types.js';
13
+ // ---------------------------------------------------------------------------
14
+ // parseSkillQualifier — slash-only form
15
+ // ---------------------------------------------------------------------------
16
+ describe('parseSkillQualifier', () => {
17
+ test('bare name returns single segment', () => {
18
+ const r = parseSkillQualifier('foo');
19
+ assert.deepEqual(r, { segments: ['foo'] });
20
+ });
21
+ test('plugin/name returns two segments', () => {
22
+ const r = parseSkillQualifier('ai/interface');
23
+ assert.deepEqual(r, { segments: ['ai', 'interface'] });
24
+ });
25
+ test('plugin/nested/name returns three segments', () => {
26
+ const r = parseSkillQualifier('ai/interface/cli-design');
27
+ assert.deepEqual(r, { segments: ['ai', 'interface', 'cli-design'] });
28
+ });
29
+ test('scope-qualified user/name sets scope, single segment', () => {
30
+ const r = parseSkillQualifier('user/my-skill');
31
+ assert.deepEqual(r, { scope: 'user', segments: ['my-skill'] });
32
+ });
33
+ test('scope-qualified project/name sets scope', () => {
34
+ const r = parseSkillQualifier('project/my-skill');
35
+ assert.deepEqual(r, { scope: 'project', segments: ['my-skill'] });
36
+ });
37
+ test('scope-qualified user/ai/x/y sets scope and leaves rest as segments', () => {
38
+ const r = parseSkillQualifier('user/ai/x/y');
39
+ assert.deepEqual(r, { scope: 'user', segments: ['ai', 'x', 'y'] });
40
+ });
41
+ test('colon in input throws InputError with invalid_qualifier', () => {
42
+ assert.throws(() => parseSkillQualifier('ai:interface/cli-design'), (e) => {
43
+ assert.ok(e instanceof InputError, 'should be InputError');
44
+ assert.equal(e.payload.error, 'invalid_qualifier');
45
+ assert.equal(e.payload.received, 'ai:interface/cli-design');
46
+ assert.ok(e.payload.next.includes('ai/interface/cli-design'), `next should contain slash form, got: ${e.payload.next}`);
47
+ return true;
48
+ });
49
+ });
50
+ test('legacy scope:name form throws invalid_qualifier', () => {
51
+ assert.throws(() => parseSkillQualifier('user:my-skill'), (e) => {
52
+ assert.ok(e instanceof InputError);
53
+ assert.equal(e.payload.error, 'invalid_qualifier');
54
+ assert.ok(e.payload.next.includes('user/my-skill'));
55
+ return true;
56
+ });
57
+ });
58
+ test('legacy scope:plugin/name form throws invalid_qualifier', () => {
59
+ assert.throws(() => parseSkillQualifier('user:ai/interface/cli-design'), (e) => {
60
+ assert.ok(e instanceof InputError);
61
+ assert.equal(e.payload.error, 'invalid_qualifier');
62
+ assert.ok(e.payload.next.includes('user/ai/interface/cli-design'));
63
+ return true;
64
+ });
65
+ });
66
+ });
67
+ // ---------------------------------------------------------------------------
68
+ // Config migration: colon keys → slash keys on readConfig with old schema_version
69
+ // ---------------------------------------------------------------------------
70
+ describe('config migration: skill keys colon → slash', () => {
71
+ let testHomeDir;
72
+ let origHome;
73
+ let crouterDir;
74
+ before(() => {
75
+ testHomeDir = join(tmpdir(), `crtr-resolver-test-${Date.now()}`);
76
+ mkdirSync(testHomeDir, { recursive: true });
77
+ origHome = process.env['HOME'];
78
+ process.env['HOME'] = testHomeDir;
79
+ crouterDir = join(testHomeDir, '.crouter');
80
+ mkdirSync(crouterDir, { recursive: true });
81
+ });
82
+ after(() => {
83
+ if (origHome === undefined) {
84
+ delete process.env['HOME'];
85
+ }
86
+ else {
87
+ process.env['HOME'] = origHome;
88
+ }
89
+ rmSync(testHomeDir, { recursive: true, force: true });
90
+ });
91
+ test('colon keys are migrated to slash keys and written with new schema_version', () => {
92
+ const oldConfig = {
93
+ schema_version: 1,
94
+ marketplaces: {},
95
+ plugins: {},
96
+ skills: {
97
+ 'ai:foo': { enabled: true },
98
+ 'ai:interface/cli-design': { enabled: false },
99
+ },
100
+ auto_update: { crtr: 'notify', content: 'notify', interval_hours: 24 },
101
+ max_panes_per_window: 3,
102
+ };
103
+ writeFileSync(join(crouterDir, 'config.json'), JSON.stringify(oldConfig), 'utf8');
104
+ const cfg = readConfig('user');
105
+ assert.ok(!('ai:foo' in cfg.skills), 'old colon key should be gone');
106
+ assert.ok('ai/foo' in cfg.skills, 'slash key should exist');
107
+ assert.equal(cfg.skills['ai/foo']?.enabled, true, 'enabled state preserved');
108
+ assert.ok(!('ai:interface/cli-design' in cfg.skills), 'old colon key should be gone');
109
+ assert.ok('ai/interface/cli-design' in cfg.skills, 'slash key should exist for nested');
110
+ assert.equal(cfg.skills['ai/interface/cli-design']?.enabled, false, 'disabled state preserved');
111
+ assert.equal(cfg.schema_version, SCHEMA_VERSION, 'schema_version bumped to current');
112
+ });
113
+ });
@@ -1,7 +1,8 @@
1
1
  import { join } from 'node:path';
2
- import { CONFIG_FILE, STATE_FILE, defaultScopeConfig, defaultScopeState } from '../types.js';
2
+ import { CONFIG_FILE, STATE_FILE, SCHEMA_VERSION, defaultScopeConfig, defaultScopeState } from '../types.js';
3
3
  import { readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
4
4
  import { scopeRoot, requireScopeRoot } from './scope.js';
5
+ import { diag } from './io.js';
5
6
  function configPathFor(root) {
6
7
  return join(root, CONFIG_FILE);
7
8
  }
@@ -23,7 +24,24 @@ export function readConfig(scope) {
23
24
  const existing = readJsonIfExists(configPathFor(root));
24
25
  if (!existing)
25
26
  return defaultScopeConfig();
26
- return mergeConfig(existing);
27
+ const cfg = mergeConfig(existing);
28
+ if ((existing.schema_version ?? 0) < SCHEMA_VERSION) {
29
+ migrateSkillConfigKeys(cfg, scope, root);
30
+ }
31
+ return cfg;
32
+ }
33
+ function migrateSkillConfigKeys(cfg, scope, root) {
34
+ const colonKeys = Object.keys(cfg.skills).filter((k) => k.includes(':'));
35
+ if (colonKeys.length > 0) {
36
+ for (const key of colonKeys) {
37
+ const newKey = key.replace(/:/g, '/');
38
+ cfg.skills[newKey] = cfg.skills[key];
39
+ delete cfg.skills[key];
40
+ }
41
+ diag(`crtr: migrated ${colonKeys.length} skill config keys to slash form in ${scope}`);
42
+ }
43
+ cfg.schema_version = SCHEMA_VERSION;
44
+ writeJson(configPathFor(root), cfg);
27
45
  }
28
46
  export function readState(scope) {
29
47
  const root = scopeRoot(scope);
@@ -27,28 +27,42 @@ export declare function appendEvent(jobId: string, event: {
27
27
  data?: object;
28
28
  }): void;
29
29
  /**
30
- * Atomically write result.json and update meta.json status.
31
- * result.json's appearance is the ONLY completion signal never inferred from
32
- * log content.
30
+ * Atomically write result.json (structured object) and update meta.json status.
31
+ * Used by programmatic callers (human, sys) that produce object results.
32
+ * The result file's appearance is the completion signal — never inferred from log content.
33
33
  */
34
34
  export declare function writeResult(jobId: string, result: object, terminalStatus: TerminalStatus): void;
35
35
  /**
36
- * Read result.json. If it doesn't exist and waitMs is given, block via fs.watch
37
- * until result.json appears or the timeout elapses.
36
+ * Atomically write result.md (YAML frontmatter + markdown body) and update meta.json status.
37
+ * Used by `crtr job submit` for agent-driven markdown results.
38
+ */
39
+ export declare function writeMarkdownResult(jobId: string, body: string, terminalStatus: TerminalStatus, reason?: string): void;
40
+ /**
41
+ * Read whichever result file exists (result.md or result.json). If neither
42
+ * exists and waitMs is given, block via fs.watch until one appears or the
43
+ * timeout elapses.
38
44
  *
39
- * Race safety: registers the watcher THEN re-stats. If result.json appeared
45
+ * Race safety: registers the watcher THEN re-stats. If a result file appeared
40
46
  * between the first stat and the watch registration, the re-stat catches it
41
47
  * before the watcher has a chance to miss it.
48
+ *
49
+ * Returns shape:
50
+ * - JSON path: { status, result: object }
51
+ * - Markdown path: { status, result_md: string, reason?: string }
52
+ * - Timeout: { status: 'timeout' }
42
53
  */
43
- export declare function readResult(jobId: string, opts?: {
44
- waitMs?: number;
45
- }): Promise<{
54
+ export interface ReadResultResponse {
46
55
  status: 'done' | 'failed' | 'canceled' | 'timeout';
47
56
  result?: object;
48
- }>;
57
+ result_md?: string;
58
+ reason?: string;
59
+ }
60
+ export declare function readResult(jobId: string, opts?: {
61
+ waitMs?: number;
62
+ }): Promise<ReadResultResponse>;
49
63
  /**
50
- * Derive job state from meta.json, result.json, and the tail of log.jsonl.
51
- * If a pid is recorded, is not alive, and no result.json exists → 'failed'.
64
+ * Derive job state from meta.json, the result file, and the tail of log.jsonl.
65
+ * If a pid is recorded, is not alive, and no result file exists → 'failed'.
52
66
  */
53
67
  export declare function jobStatus(jobId: string): {
54
68
  state: JobState;