@yegor256/jinka 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  First, you create `~/.jinka/wishes/Make-Me-Happy.md` file:
8
8
 
9
9
  ```text
10
- I wish my GitHub repositories were in good shape: `foo/bar` and `foo/bar2`.
10
+ I wish my GitHub repository to be in good shape: `foo/bar`.
11
11
  ```
12
12
 
13
13
  Then, you install `jinka`:
@@ -16,7 +16,7 @@ Then, you install `jinka`:
16
16
  npm install -g @yegor256/jinka
17
17
  ```
18
18
 
19
- Finally, you run it, giving it the path to your directory with wishes:
19
+ Finally, you run it:
20
20
 
21
21
  ```bash
22
22
  jinka
package/bin/jinka.js CHANGED
@@ -7,32 +7,37 @@ import { readFile } from 'node:fs/promises';
7
7
  import { resolve, join, dirname, parse } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { Jinka } from '../src/jinka.js';
11
- import { Wishes } from '../src/wishes.js';
12
- import { Agent } from '../src/agent.js';
13
- import { Claude } from '../src/claude.js';
14
- import { Codex } from '../src/codex.js';
15
- import { Genie } from '../src/genie.js';
16
- import { Disk } from '../src/disk.js';
17
- import { Tty } from '../src/tty.js';
18
- import { Log } from '../src/log.js';
19
- import { Status } from '../src/status.js';
20
- import { Quota } from '../src/quota.js';
21
- import { Rollouts } from '../src/rollouts.js';
22
- import { Meters } from '../src/meters.js';
23
- import { Freeze } from '../src/freeze.js';
24
- import { Frozen } from '../src/frozen.js';
25
- import { Limited } from '../src/limited.js';
26
- import { Throttled } from '../src/throttled.js';
27
- import { Keys } from '../src/keys.js';
28
- import { Wheel } from '../src/wheel.js';
29
- import { Legend } from '../src/legend.js';
30
- import { Tokens } from '../src/tokens.js';
31
- import { Crew } from '../src/crew.js';
32
- import { Jobs } from '../src/jobs.js';
33
- import { Summary } from '../src/summary.js';
34
- import { Summarized } from '../src/summarized.js';
35
- import { Semaphore } from '../src/semaphore.js';
10
+ import { jinka } from '../src/jinka.js';
11
+ import { wishes } from '../src/wishes.js';
12
+ import { agent } from '../src/agent.js';
13
+ import { claude } from '../src/claude.js';
14
+ import { codex } from '../src/codex.js';
15
+ import { genie } from '../src/genie.js';
16
+ import { disk } from '../src/disk.js';
17
+ import { tty } from '../src/tty.js';
18
+ import { colorless } from '../src/colorless.js';
19
+ import { log } from '../src/log.js';
20
+ import { status } from '../src/status.js';
21
+ import { quota } from '../src/quota.js';
22
+ import { rollouts } from '../src/rollouts.js';
23
+ import { recorded } from '../src/recorded.js';
24
+ import { model } from '../src/model.js';
25
+ import { meters } from '../src/meters.js';
26
+ import { freeze } from '../src/freeze.js';
27
+ import { frozen } from '../src/frozen.js';
28
+ import { limited } from '../src/limited.js';
29
+ import { throttled } from '../src/throttled.js';
30
+ import { keys } from '../src/keys.js';
31
+ import { wheel } from '../src/wheel.js';
32
+ import { legend } from '../src/legend.js';
33
+ import { release } from '../src/release.js';
34
+ import { tokens } from '../src/tokens.js';
35
+ import { crew } from '../src/crew.js';
36
+ import { jobs } from '../src/jobs.js';
37
+ import { summary } from '../src/summary.js';
38
+ import { summarized } from '../src/summarized.js';
39
+ import { semaphore } from '../src/semaphore.js';
40
+ import { hello } from '../src/hello.js';
36
41
 
37
42
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
38
43
  const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
@@ -62,6 +67,7 @@ const program = new Command()
62
67
  .name('jinka')
63
68
  .description(`${pkg.description} (${pkg.version})`)
64
69
  .version(pkg.version)
70
+ .configureHelp({ sortOptions: true })
65
71
  .showHelpAfterError('(add --help for additional information)')
66
72
  .addOption(new Option('--wishes <dir>', 'directory with wishes, one per Markdown file').default(join(homedir(), '.jinka', 'wishes'), '~/.jinka/wishes'))
67
73
  .option('--threads <n>', `how many wishes run at once, up to ${limit}, which the + and - keys change while running`, bounded, 6)
@@ -77,37 +83,48 @@ const program = new Command()
77
83
  .addOption(new Option('--effort <level>', 'reasoning effort passed to the agent, such as low, medium, high, xhigh, or max').default('', 'agent default'))
78
84
  .option('--throttle <percent>', 'how much of the agent quota, in percent, must be left in every window before a new job starts', percent, 15)
79
85
  .addOption(new Option('--pid <file>', 'file that exists while jinka runs, so that a second jinka refuses to start').default(join(homedir(), '.jinka', 'pid.txt'), '~/.jinka/pid.txt'))
86
+ .option('--no-color', 'paint nothing in the terminal, which a non-empty NO_COLOR environment variable also does')
87
+ .option('--no-tui', 'print only the protocol, without the status bars and the list of threads')
80
88
  .addOption(new Option('--limit <n>', 'how many jobs start before jinka waits for them to finish and quits').argParser(positive).default(Infinity, 'unlimited'))
81
89
  .parse();
82
90
  const values = program.opts();
83
- const semaphore = new Semaphore(resolve(values.pid));
84
- await semaphore.acquire().catch((e) => {
91
+ const s = semaphore(resolve(values.pid));
92
+ await s.acquire().catch((e) => {
85
93
  console.error(e.message);
86
94
  process.exit(1);
87
95
  });
88
- process.on('exit', () => semaphore.release());
96
+ process.on('exit', () => s.release());
89
97
  const dir = resolve(values.wishes);
90
98
  const engine = values.engine ?? (parse(values.agent).name === 'codex' ? 'codex' : 'claude');
91
- const agent = values.agent === '' ? engine : values.agent;
92
- const tokens = new Tokens();
93
- const quota = new Quota();
94
- const crew = new Crew(values.threads, limit, { warn: (text) => screen.notice().warn(text) });
95
- const jobs = new Jobs(new Genie(new (engine === 'codex' ? Codex : Claude)(new Agent(agent), values.model, values.effort, resolve(values.skill)), new Disk(dir, resolve(values.logs), resolve(values.memory), crew), new Meters([tokens, engine === 'codex' ? new Rollouts(quota, join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions')) : quota])));
96
- const freeze = new Freeze(crew, { warn: (text) => screen.notice().warn(text) }, () => quit(0));
99
+ const a = values.agent === '' ? engine : values.agent;
100
+ const t = tokens();
101
+ const q = quota();
102
+ const c = crew(values.threads, limit, { warn: (text) => screen.notice().warn(text) });
103
+ const sessions = join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'sessions');
104
+ const e = (engine === 'codex' ? codex : claude)(agent(a), values.model, values.effort, resolve(values.skill));
105
+ const d = disk(dir, resolve(values.logs), resolve(values.memory), c);
106
+ const j = jobs(genie(e, d, meters([t, engine === 'codex' ? rollouts(q, sessions) : q]), engine === 'codex' ? () => recorded(model(), sessions) : model));
107
+ const f = freeze(c, { warn: (text) => screen.notice().warn(text) }, () => quit(0));
108
+ const r = release(pkg.version, 'https://registry.npmjs.org/@yegor256/jinka/latest');
97
109
  const start = Date.now();
98
- const screen = new Summarized(
99
- process.stdout.isTTY
100
- ? new Tty(
101
- process.stdout,
102
- crew,
103
- new Status(start, jobs, agent, values.model, tokens, quota),
104
- new Legend(freeze, pkg.version)
110
+ const tui = process.stdout.isTTY && values.tui;
111
+ const screen = summarized(
112
+ tui
113
+ ? tty(
114
+ values.color && !process.env.NO_COLOR ? process.stdout : colorless(process.stdout),
115
+ c,
116
+ status(start, j, a, values.model, t, q),
117
+ legend(f, r)
105
118
  )
106
- : new Log(process.stdout),
107
- new Summary(start, pkg.version, jobs, { agent, model: values.model, effort: values.effort, wishes: dir }),
119
+ : log(process.stdout),
120
+ summary(start, pkg.version, j, { agent: a, model: values.model, effort: values.effort, wishes: dir }),
108
121
  process.stdout
109
122
  );
110
123
  process.on('exit', () => screen.close());
124
+ if (tui) {
125
+ r.check();
126
+ }
127
+ screen.notice().warn(`every agent starts as: ${e.command(d.args())}`);
111
128
  const quit = (code) => {
112
129
  screen.close();
113
130
  process.stdout.write('', () => process.stderr.write('', () => process.exit(code)));
@@ -120,18 +137,19 @@ const interrupt = () => {
120
137
  controller.abort();
121
138
  };
122
139
  process.on('SIGINT', interrupt);
123
- new Keys(process.stdin, freeze, crew, interrupt).listen();
124
- new Wheel(process.stdin, screen).listen();
140
+ keys(process.stdin, f, c, interrupt).listen();
141
+ wheel(process.stdin, screen).listen();
125
142
  try {
126
- await new Jinka(
127
- new Limited(new Frozen(new Throttled(new Wishes(dir, values.interval, values.archive === '' ? '' : resolve(values.archive), values.pause, { warn: (text) => screen.notice().warn(text) }), quota, values.throttle, 15 * 60 * 1000, { warn: (text) => screen.notice().warn(text) }), freeze), values.limit, freeze),
128
- jobs,
143
+ await hello(e, screen.notice()).run(controller.signal);
144
+ await jinka(
145
+ limited(frozen(throttled(wishes(dir, values.interval, values.archive === '' ? '' : resolve(values.archive), values.pause, { warn: (text) => screen.notice().warn(text) }), q, values.throttle, 15 * 60 * 1000, { warn: (text) => screen.notice().warn(text) }), f), values.limit, f),
146
+ j,
129
147
  screen,
130
- crew
148
+ c
131
149
  ).run(controller.signal);
132
150
  quit(130);
133
151
  } catch (e) {
134
152
  screen.close();
135
- console.error(e.message);
153
+ console.error(e);
136
154
  quit(1);
137
155
  }
package/package.json CHANGED
@@ -35,5 +35,5 @@
35
35
  "test": "c8 --check-coverage --lines 80 mocha"
36
36
  },
37
37
  "type": "module",
38
- "version": "0.0.8"
38
+ "version": "0.0.10"
39
39
  }
package/src/agent.js CHANGED
@@ -3,13 +3,10 @@
3
3
 
4
4
  import spawn from 'cross-spawn';
5
5
 
6
- export class Agent {
7
- constructor(command) {
8
- this.command = command;
9
- }
10
- start(args, signal) {
6
+ export const agent = (command) => Object.freeze({
7
+ start: (args, signal) => {
11
8
  const windows = process.platform === 'win32';
12
- const child = spawn(this.command, args, windows ? { stdio: 'pipe' } : { signal, stdio: 'pipe' });
9
+ const child = spawn(command, args, windows ? { stdio: 'pipe' } : { signal, stdio: 'pipe' });
13
10
  if (windows) {
14
11
  signal.addEventListener(
15
12
  'abort',
@@ -18,5 +15,6 @@ export class Agent {
18
15
  );
19
16
  }
20
17
  return child;
21
- }
22
- }
18
+ },
19
+ command: (args) => [command, ...args].map((a) => (/^[\w./\\:=@%+,~-]+$/.test(a) ? a : `'${a.replaceAll("'", "'\\''")}'`)).join(' ')
20
+ });
package/src/archived.js CHANGED
@@ -4,34 +4,26 @@
4
4
  import { copyFile, mkdir, access } from 'node:fs/promises';
5
5
  import { join, dirname, extname } from 'node:path';
6
6
 
7
- export class Archived {
8
- constructor(origin, dir, archive) {
9
- this.origin = origin;
10
- this.dir = dir;
11
- this.archive = archive;
12
- }
13
- name() {
14
- return this.origin.name();
15
- }
16
- async text() {
17
- return this.origin.text();
18
- }
19
- fate() {
20
- return this.origin.fate();
21
- }
22
- async close() {
23
- await copyFile(join(this.dir, this.origin.name()), await this.#target());
24
- await this.origin.close();
25
- }
26
- free() {
27
- this.origin.free();
28
- }
29
- async #target() {
30
- const target = join(this.archive, this.origin.name());
31
- await mkdir(dirname(target), { recursive: true });
32
- return access(target).then(
33
- () => `${target.slice(0, target.length - extname(target).length)}-${Date.now()}${extname(target)}`,
34
- () => target
7
+ export const archived = (origin, dir, archive) => {
8
+ const target = async () => {
9
+ const path = join(archive, origin.name());
10
+ await mkdir(dirname(path), { recursive: true });
11
+ return access(path).then(
12
+ () => `${path.slice(0, path.length - extname(path).length)}-${Date.now()}${extname(path)}`,
13
+ () => path
35
14
  );
36
- }
37
- }
15
+ };
16
+ return Object.freeze({
17
+ name: () => origin.name(),
18
+ text: async () => origin.text(),
19
+ fate: () => origin.fate(),
20
+ close: async () => {
21
+ await access(join(dir, origin.name())).then(
22
+ async () => copyFile(join(dir, origin.name()), await target()),
23
+ () => {}
24
+ );
25
+ await origin.close();
26
+ },
27
+ free: () => origin.free()
28
+ });
29
+ };
package/src/claude.js CHANGED
@@ -1,27 +1,18 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- export class Claude {
5
- constructor(agent, model, effort, plugin) {
6
- this.agent = agent;
7
- this.model = model;
8
- this.effort = effort;
9
- this.plugin = plugin;
10
- }
11
- start(args, signal) {
12
- return this.agent.start(
13
- [
14
- '-p', '--output-format', 'stream-json', '--verbose',
15
- '--plugin-dir', this.plugin,
16
- ...args,
17
- '--dangerously-skip-permissions',
18
- ...(this.model === '' ? [] : ['--model', this.model]),
19
- ...(this.effort === '' ? [] : ['--effort', this.effort])
20
- ],
21
- signal
22
- );
23
- }
24
- lines() {
25
- return [];
26
- }
27
- }
4
+ export const claude = (agent, model, effort, plugin) => {
5
+ const full = (args) => [
6
+ '-p', '--output-format', 'stream-json', '--verbose',
7
+ '--plugin-dir', plugin,
8
+ ...args,
9
+ '--dangerously-skip-permissions',
10
+ ...(model === '' ? [] : ['--model', model]),
11
+ ...(effort === '' ? [] : ['--effort', effort])
12
+ ];
13
+ return Object.freeze({
14
+ start: (args, signal) => agent.start(full(args), signal),
15
+ command: (args) => agent.command(full(args)),
16
+ lines: () => []
17
+ });
18
+ };
package/src/codex.js CHANGED
@@ -3,30 +3,21 @@
3
3
 
4
4
  import { join } from 'node:path';
5
5
 
6
- export class Codex {
7
- constructor(agent, model, effort, plugin) {
8
- this.agent = agent;
9
- this.model = model;
10
- this.effort = effort;
11
- this.plugin = plugin;
12
- }
13
- start(args, signal) {
14
- return this.agent.start(
15
- [
16
- 'exec', '--json',
17
- '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check',
18
- ...args,
19
- ...(this.model === '' ? [] : ['--model', this.model]),
20
- ...(this.effort === '' ? [] : ['--config', `model_reasoning_effort=${this.effort}`]),
21
- '-'
22
- ],
23
- signal
24
- );
25
- }
26
- lines() {
27
- return [
28
- `Your skills live in the directory ${join(this.plugin, 'skills')}, one skill per subdirectory, each described in its SKILL.md file.`,
6
+ export const codex = (agent, model, effort, plugin) => {
7
+ const full = (args) => [
8
+ 'exec', '--json',
9
+ '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check',
10
+ ...args,
11
+ ...(model === '' ? [] : ['--model', model]),
12
+ ...(effort === '' ? [] : ['--config', `model_reasoning_effort=${effort}`]),
13
+ '-'
14
+ ];
15
+ return Object.freeze({
16
+ start: (args, signal) => agent.start(full(args), signal),
17
+ command: (args) => agent.command(full(args)),
18
+ lines: () => [
19
+ `Your skills live in the directory ${join(plugin, 'skills')}, one skill per subdirectory, each described in its SKILL.md file.`,
29
20
  'Before you start, read the descriptions of these skills, and whenever a skill fits your work, read its SKILL.md file and follow it.'
30
- ];
31
- }
32
- }
21
+ ]
22
+ });
23
+ };
@@ -0,0 +1,13 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export const colorless = (stream) => Object.freeze({
5
+ get columns() {
6
+ return stream.columns;
7
+ },
8
+ get rows() {
9
+ return stream.rows;
10
+ },
11
+ on: (event, listener) => stream.on(event, listener),
12
+ write: (text, done) => stream.write(text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), ''), done)
13
+ });
package/src/crew.js CHANGED
@@ -1,67 +1,62 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- export class Crew {
5
- constructor(size, limit, notice) {
6
- this.limit = limit;
7
- this.notice = notice;
8
- this.seats = new Map(Array.from({ length: size }, (_, i) => [i, { idle: false, controller: new AbortController() }]));
9
- this.state = { launch: () => {}, runs: [] };
10
- }
11
- size() {
12
- return this.seats.size;
13
- }
14
- indexes() {
15
- return [...this.seats.keys()].sort((a, b) => a - b);
16
- }
17
- more() {
18
- if (this.seats.size >= this.limit) {
19
- this.notice.warn(`more than ${this.limit} threads are not allowed`);
20
- return;
21
- }
22
- const index = Array.from({ length: this.limit }, (_, i) => i).find((i) => !this.seats.has(i));
23
- this.seats.set(index, { idle: false, controller: new AbortController() });
24
- this.state.launch(index);
25
- }
26
- less() {
27
- const index = this.indexes().findLast((i) => this.seats.get(i).idle);
28
- if (this.seats.size === 1) {
29
- this.notice.warn('the last thread cannot be removed');
30
- } else if (index === undefined) {
31
- this.notice.warn('no idle thread can be removed right now');
32
- } else {
33
- this.seats.get(index).controller.abort();
34
- this.seats.delete(index);
4
+ export const crew = (size, limit, notice) => {
5
+ const seats = new Map(Array.from({ length: size }, (_, i) => [i, { idle: false, controller: new AbortController() }]));
6
+ const state = { launch: () => {}, runs: [] };
7
+ const indexes = () => [...seats.keys()].sort((a, b) => a - b);
8
+ const join = async (from) => {
9
+ const to = state.runs.length;
10
+ if (from < to) {
11
+ await Promise.all(state.runs.slice(from, to));
12
+ await join(to);
35
13
  }
36
- }
37
- async run(wishes, wand, signal) {
38
- this.state.launch = (index) => {
39
- const seat = this.seats.get(index);
40
- this.state.runs.push(
41
- wand(
42
- index,
43
- {
44
- next: async (s) => {
45
- seat.idle = true;
46
- try {
47
- return await wishes.next(s);
48
- } finally {
49
- seat.idle = false;
14
+ };
15
+ return Object.freeze({
16
+ size: () => seats.size,
17
+ indexes,
18
+ more: () => {
19
+ if (seats.size >= limit) {
20
+ notice.warn(`more than ${limit} threads are not allowed`);
21
+ return;
22
+ }
23
+ const index = Array.from({ length: limit }, (_, i) => i).find((i) => !seats.has(i));
24
+ seats.set(index, { idle: false, controller: new AbortController() });
25
+ state.launch(index);
26
+ },
27
+ less: () => {
28
+ const index = indexes().findLast((i) => seats.get(i).idle);
29
+ if (seats.size === 1) {
30
+ notice.warn('the last thread cannot be removed');
31
+ } else if (index === undefined) {
32
+ notice.warn('no idle thread can be removed right now');
33
+ } else {
34
+ seats.get(index).controller.abort();
35
+ seats.delete(index);
36
+ }
37
+ },
38
+ run: async (wishes, wand, signal) => {
39
+ state.launch = (index) => {
40
+ const seat = seats.get(index);
41
+ state.runs.push(
42
+ wand(
43
+ index,
44
+ {
45
+ next: async (s) => {
46
+ seat.idle = true;
47
+ try {
48
+ return await wishes.next(s);
49
+ } finally {
50
+ seat.idle = false;
51
+ }
50
52
  }
51
- }
52
- },
53
- AbortSignal.any([signal, seat.controller.signal])
54
- )
55
- );
56
- };
57
- this.indexes().forEach((i) => this.state.launch(i));
58
- await this.#join(0);
59
- }
60
- async #join(from) {
61
- const to = this.state.runs.length;
62
- if (from < to) {
63
- await Promise.all(this.state.runs.slice(from, to));
64
- await this.#join(to);
53
+ },
54
+ AbortSignal.any([signal, seat.controller.signal])
55
+ )
56
+ );
57
+ };
58
+ indexes().forEach((i) => state.launch(i));
59
+ await join(0);
65
60
  }
66
- }
67
- }
61
+ });
62
+ };
package/src/disk.js CHANGED
@@ -5,37 +5,29 @@ import { createWriteStream } from 'node:fs';
5
5
  import { mkdir } from 'node:fs/promises';
6
6
  import { join, dirname, extname } from 'node:path';
7
7
 
8
- export class Disk {
9
- constructor(wishes, logs, memory, crew) {
10
- this.wishes = wishes;
11
- this.logs = logs;
12
- this.memory = memory;
13
- this.crew = crew;
14
- }
15
- lines(wish) {
16
- return [
17
- `The wish is written in the file ${join(this.wishes, wish.name())}, ${wish.fate()}.`,
18
- `All wishes live in the directory ${this.wishes}, and you may add new wishes there as Markdown files.`,
19
- `If this wish is an atomic job that involves no other jobs, make it come true yourself;`,
20
- `otherwise, do not do it yourself, but split it into smaller wishes`,
21
- `and write each of them as a separate Markdown file in the directory ${join(this.wishes, dirname(wish.name()))}.`,
22
- `Always start the name of every new wish file with a small English letter, never with a capital one.`,
23
- `In every Git repository you touch, do all your work inside a separate Git worktree, never in the main directory of the repository, so that other wishes running at the same time do not see your changes.`,
24
- `Never spend time waiting while doing a wish, neither sleeping nor polling for a build, a review, a merge, or anything else;`,
25
- `if something must happen first, write a new wish that waits for it and finishes the job, and then complete the current wish right away.`, `Your memory lives in the directory ${this.memory}, keep there what the skills tell you to keep.`,
26
- `Jinka runs ${this.crew.size()} threads right now, so up to ${this.crew.size()} wishes run at once.`
27
- ];
28
- }
29
- args() {
30
- return ['--add-dir', this.wishes, '--add-dir', this.memory];
31
- }
32
- async log(wish) {
8
+ export const disk = (wishes, logs, memory, crew) => Object.freeze({
9
+ lines: (wish) => [
10
+ `The wish is written in the file ${join(wishes, wish.name())}, ${wish.fate()}.`,
11
+ 'Never delete, move, or rename the file of this wish yourself, even after you split it into smaller wishes, since Jinka takes care of it.',
12
+ `All wishes live in the directory ${wishes}, and you may add new wishes there as Markdown files.`,
13
+ `If this wish is an atomic job that involves no other jobs, make it come true yourself;`,
14
+ `otherwise, do not do it yourself, but split it into smaller wishes`,
15
+ `and write each of them as a separate Markdown file in the directory ${join(wishes, dirname(wish.name()))}.`,
16
+ `Always start the name of every new wish file with a small English letter, never with a capital one.`,
17
+ `In every Git repository you touch, do all your work inside a separate Git worktree, never in the main directory of the repository, so that other wishes running at the same time do not see your changes.`,
18
+ `Never spend time waiting while doing a wish, neither sleeping nor polling for a build, a review, a merge, or anything else;`,
19
+ `if something must happen first, write a new wish that waits for it and finishes the job, and then complete the current wish right away.`,
20
+ `Your memory lives in the directory ${memory}, keep there what the skills tell you to keep.`,
21
+ `Jinka runs ${crew.size()} threads right now, so up to ${crew.size()} wishes run at once.`
22
+ ],
23
+ args: () => ['--add-dir', wishes, '--add-dir', memory],
24
+ log: async (wish) => {
33
25
  const file = join(
34
- this.logs,
26
+ logs,
35
27
  `${wish.name().slice(0, wish.name().length - extname(wish.name()).length)}-${new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)}.jsonl`
36
28
  );
37
29
  await mkdir(dirname(file), { recursive: true });
38
- await mkdir(this.memory, { recursive: true });
30
+ await mkdir(memory, { recursive: true });
39
31
  return createWriteStream(file);
40
32
  }
41
- }
33
+ });
package/src/duration.js CHANGED
@@ -1,31 +1,31 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- export class Duration {
5
- constructor(millis) {
6
- this.millis = millis;
7
- }
8
- minutes() {
9
- const seconds = Math.floor(this.millis / 1000);
4
+ export const duration = (millis) => {
5
+ const minutes = () => {
6
+ const seconds = Math.floor(millis / 1000);
10
7
  return [Math.floor(seconds / 60), seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
11
- }
12
- clock() {
13
- const minutes = Math.floor(this.millis / 60_000);
14
- if (minutes < 1000) {
15
- return this.minutes().padStart(6);
8
+ };
9
+ return Object.freeze({
10
+ minutes,
11
+ clock: () => {
12
+ const total = Math.floor(millis / 60_000);
13
+ if (total < 1000) {
14
+ return minutes().padStart(6);
15
+ }
16
+ if (total < 6000) {
17
+ return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}m`;
18
+ }
19
+ return `${Math.floor(total / 60)}h`.padStart(6);
20
+ },
21
+ phrase: () => {
22
+ const seconds = Math.floor(millis / 1000);
23
+ const [count, unit] = seconds < 60 ? [seconds, 'second'] : [Math.floor(seconds / 60), 'minute'];
24
+ return `${count} ${unit}${count === 1 ? '' : 's'}`;
25
+ },
26
+ hours: () => {
27
+ const seconds = Math.floor(millis / 1000);
28
+ return [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
16
29
  }
17
- if (minutes < 6000) {
18
- return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}m`;
19
- }
20
- return `${Math.floor(minutes / 60)}h`.padStart(6);
21
- }
22
- phrase() {
23
- const seconds = Math.floor(this.millis / 1000);
24
- const [count, unit] = seconds < 60 ? [seconds, 'second'] : [Math.floor(seconds / 60), 'minute'];
25
- return `${count} ${unit}${count === 1 ? '' : 's'}`;
26
- }
27
- hours() {
28
- const seconds = Math.floor(this.millis / 1000);
29
- return [Math.floor(seconds / 3600), Math.floor(seconds / 60) % 60, seconds % 60].map((p) => String(p).padStart(2, '0')).join(':');
30
- }
31
- }
30
+ });
31
+ };
@@ -0,0 +1,10 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ export const ellipsized = (origin, limit) => Object.freeze({
5
+ text: () => (
6
+ origin.length < limit
7
+ ? origin
8
+ : `${origin.slice(0, Math.floor((limit - 2) / 2))}…${origin.slice(-Math.ceil((limit - 2) / 2))}`
9
+ )
10
+ });