klyro 1.0.0 → 1.0.2

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 (110) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +49 -5
  2. package/dist/agent/anthropic-adapter.js +86 -17
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +53 -6
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +124 -6
  8. package/dist/agent/orchestrator.js +425 -58
  9. package/dist/agent/provider-adapter.d.ts +8 -0
  10. package/dist/agent/provider-adapter.js +12 -3
  11. package/dist/agent/retry.d.ts +1 -1
  12. package/dist/agent/retry.js +54 -12
  13. package/dist/agent/runtime.d.ts +84 -8
  14. package/dist/agent/runtime.js +352 -39
  15. package/dist/agent/stream-budget.d.ts +36 -0
  16. package/dist/agent/stream-budget.js +121 -0
  17. package/dist/agent/worktree-manager.d.ts +74 -0
  18. package/dist/agent/worktree-manager.js +189 -0
  19. package/dist/checkpoints/store.d.ts +9 -0
  20. package/dist/checkpoints/store.js +56 -5
  21. package/dist/cli/auth.js +16 -1
  22. package/dist/cli/commit.d.ts +31 -0
  23. package/dist/cli/commit.js +142 -0
  24. package/dist/cli/config.d.ts +54 -3
  25. package/dist/cli/config.js +146 -3
  26. package/dist/cli/doctor.d.ts +1 -0
  27. package/dist/cli/doctor.js +71 -6
  28. package/dist/cli/eval.d.ts +6 -1
  29. package/dist/cli/eval.js +9 -0
  30. package/dist/cli/hooks.d.ts +47 -0
  31. package/dist/cli/hooks.js +181 -0
  32. package/dist/cli/repl.js +196 -29
  33. package/dist/cli/run.d.ts +13 -11
  34. package/dist/cli/run.js +144 -20
  35. package/dist/cli/update.d.ts +5 -0
  36. package/dist/cli/update.js +62 -10
  37. package/dist/context/import-graph.d.ts +2 -0
  38. package/dist/context/import-graph.js +31 -3
  39. package/dist/context/klyro-md.js +4 -1
  40. package/dist/context/memory.d.ts +8 -0
  41. package/dist/context/memory.js +50 -2
  42. package/dist/context/project-map.d.ts +6 -0
  43. package/dist/context/project-map.js +50 -2
  44. package/dist/context/repo-map.d.ts +2 -0
  45. package/dist/context/repo-map.js +31 -1
  46. package/dist/events/catalog.d.ts +37 -0
  47. package/dist/events/catalog.js +9 -0
  48. package/dist/index.js +177 -8
  49. package/dist/mcp/client.d.ts +6 -4
  50. package/dist/mcp/client.js +83 -14
  51. package/dist/mcp/config.d.ts +10 -0
  52. package/dist/mcp/config.js +18 -1
  53. package/dist/mcp/registry.d.ts +23 -19
  54. package/dist/mcp/registry.js +127 -8
  55. package/dist/mcp/schema.d.ts +11 -4
  56. package/dist/mcp/schema.js +27 -16
  57. package/dist/mcp/trust.d.ts +20 -0
  58. package/dist/mcp/trust.js +74 -0
  59. package/dist/persistence/audit.d.ts +28 -0
  60. package/dist/persistence/audit.js +101 -1
  61. package/dist/persistence/store.d.ts +26 -2
  62. package/dist/persistence/store.js +140 -13
  63. package/dist/policy/approval.d.ts +14 -0
  64. package/dist/policy/approval.js +44 -2
  65. package/dist/policy/engine.d.ts +17 -0
  66. package/dist/policy/engine.js +162 -9
  67. package/dist/policy/path-guard.d.ts +24 -0
  68. package/dist/policy/path-guard.js +46 -0
  69. package/dist/policy/secret-redactor.js +4 -0
  70. package/dist/providers/model-info.d.ts +23 -0
  71. package/dist/providers/model-info.js +43 -2
  72. package/dist/repl.d.ts +6 -0
  73. package/dist/repl.js +12 -7
  74. package/dist/tools/agent/spawn-agent.js +5 -5
  75. package/dist/tools/agent/task-apply.d.ts +4 -0
  76. package/dist/tools/agent/task-apply.js +44 -0
  77. package/dist/tools/agent/task-stop.d.ts +6 -0
  78. package/dist/tools/agent/task-stop.js +39 -0
  79. package/dist/tools/agent/task-wait.d.ts +17 -0
  80. package/dist/tools/agent/task-wait.js +79 -0
  81. package/dist/tools/fs/apply-patch.js +77 -1
  82. package/dist/tools/fs/edit-file.js +69 -1
  83. package/dist/tools/fs/multi-edit.d.ts +4 -0
  84. package/dist/tools/fs/multi-edit.js +70 -1
  85. package/dist/tools/fs/write-file.js +83 -6
  86. package/dist/tools/plan/todo-write.js +1 -1
  87. package/dist/tools/registry.js +6 -0
  88. package/dist/tools/shell/background.js +6 -3
  89. package/dist/tools/shell/sandbox.d.ts +51 -0
  90. package/dist/tools/shell/sandbox.js +143 -0
  91. package/dist/tools/shell/shell-exec.d.ts +29 -0
  92. package/dist/tools/shell/shell-exec.js +170 -12
  93. package/dist/tools/shell/worker-entry.d.ts +12 -0
  94. package/dist/tools/shell/worker-entry.js +43 -0
  95. package/dist/tools/types.d.ts +6 -0
  96. package/dist/tools/verify/run-verify.js +3 -1
  97. package/dist/trace/writer.d.ts +20 -0
  98. package/dist/trace/writer.js +62 -4
  99. package/dist/tui/app.js +1 -1
  100. package/dist/tui/approval.js +20 -21
  101. package/dist/util.d.ts +1 -0
  102. package/dist/util.js +1 -0
  103. package/dist/verification/baseline.js +17 -3
  104. package/dist/verification/classify.js +27 -15
  105. package/dist/verification/engine.d.ts +8 -0
  106. package/dist/verification/engine.js +28 -1
  107. package/dist/verification/registry.d.ts +2 -0
  108. package/dist/verification/registry.js +44 -0
  109. package/dist/verification/scoped.js +64 -11
  110. package/package.json +1 -1
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Rate-limit scheduler for provider streams.
3
+ *
4
+ * A global in-flight counter + FIFO semaphore caps concurrent
5
+ * `ProviderAdapter.stream` calls at `MAX_CONCURRENT_STREAMS` (4). An
6
+ * adaptive cooldown collapses the cap to 1 while a recent 429 was
7
+ * observed (`noteRateLimited`), honouring an optional server-provided
8
+ * `Retry-After` delay, otherwise 60s.
9
+ *
10
+ * Consumed by `retryingAdapter` (see `./retry.js`), which acquires one
11
+ * slot around each `inner.stream` call and releases it in a `finally`.
12
+ * Aborted waiters are dequeued and rejected promptly.
13
+ */
14
+ export declare const MAX_CONCURRENT_STREAMS = 4;
15
+ /** Fallback cooldown when no Retry-After delay was provided. */
16
+ export declare const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60000;
17
+ /**
18
+ * Record a 429 (or equivalent) rate-limit signal. Collapses the stream
19
+ * cap to 1 for `retryAfterMs` (or 60s when absent/invalid).
20
+ */
21
+ export declare function noteRateLimited(retryAfterMs?: number): void;
22
+ /**
23
+ * Acquire a stream slot. Grants immediately when `inFlight` is under the
24
+ * effective cap, otherwise queues FIFO until a release (or the cooldown
25
+ * lifting) frees one. Abort-aware: an already-aborted signal rejects
26
+ * immediately; aborting while queued dequeues and rejects promptly.
27
+ */
28
+ export declare function acquireStreamSlot(signal?: AbortSignal): Promise<() => void>;
29
+ /** Observable budget state (tests/diagnostics). */
30
+ export declare function streamBudgetState(): {
31
+ inFlight: number;
32
+ queued: number;
33
+ cap: number;
34
+ };
35
+ /** Reset the budget (counters, cooldown, queued waiters). Tests only. */
36
+ export declare function setStreamBudgetForTests(): void;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Rate-limit scheduler for provider streams.
3
+ *
4
+ * A global in-flight counter + FIFO semaphore caps concurrent
5
+ * `ProviderAdapter.stream` calls at `MAX_CONCURRENT_STREAMS` (4). An
6
+ * adaptive cooldown collapses the cap to 1 while a recent 429 was
7
+ * observed (`noteRateLimited`), honouring an optional server-provided
8
+ * `Retry-After` delay, otherwise 60s.
9
+ *
10
+ * Consumed by `retryingAdapter` (see `./retry.js`), which acquires one
11
+ * slot around each `inner.stream` call and releases it in a `finally`.
12
+ * Aborted waiters are dequeued and rejected promptly.
13
+ */
14
+ export const MAX_CONCURRENT_STREAMS = 4;
15
+ /** Fallback cooldown when no Retry-After delay was provided. */
16
+ export const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
17
+ let inFlight = 0;
18
+ /** `Date.now()` timestamp until which the cap stays collapsed at 1. */
19
+ let recent429Until = 0;
20
+ const queue = [];
21
+ function abortError() {
22
+ const err = new Error('stream slot acquisition aborted');
23
+ err.name = 'AbortError';
24
+ return err;
25
+ }
26
+ function effectiveCap(now = Date.now()) {
27
+ return now < recent429Until ? 1 : MAX_CONCURRENT_STREAMS;
28
+ }
29
+ function makeRelease() {
30
+ let released = false;
31
+ return () => {
32
+ if (released)
33
+ return;
34
+ released = true;
35
+ inFlight = Math.max(0, inFlight - 1);
36
+ pump();
37
+ };
38
+ }
39
+ /** Grant queued waiters while a slot is free under the current cap. */
40
+ function pump() {
41
+ while (queue.length > 0 && inFlight < effectiveCap()) {
42
+ const waiter = queue.shift();
43
+ if (!waiter)
44
+ break;
45
+ if (waiter.signal?.aborted) {
46
+ waiter.reject(abortError());
47
+ continue;
48
+ }
49
+ if (waiter.signal && waiter.onAbort) {
50
+ waiter.signal.removeEventListener('abort', waiter.onAbort);
51
+ }
52
+ inFlight += 1;
53
+ waiter.resolve(makeRelease());
54
+ }
55
+ }
56
+ /**
57
+ * Record a 429 (or equivalent) rate-limit signal. Collapses the stream
58
+ * cap to 1 for `retryAfterMs` (or 60s when absent/invalid).
59
+ */
60
+ export function noteRateLimited(retryAfterMs) {
61
+ const cooldown = typeof retryAfterMs === 'number' &&
62
+ Number.isFinite(retryAfterMs) &&
63
+ retryAfterMs >= 0
64
+ ? retryAfterMs
65
+ : DEFAULT_RATE_LIMIT_COOLDOWN_MS;
66
+ recent429Until = Date.now() + cooldown;
67
+ // Wake queued waiters once the cooldown lifts even if no release
68
+ // happens in between (holders may outlive the cooldown). Unref'd so
69
+ // tests and short-lived processes never hang on this timer.
70
+ if (cooldown > 0 && cooldown < 3_600_000) {
71
+ const timer = setTimeout(pump, cooldown);
72
+ timer.unref?.();
73
+ }
74
+ }
75
+ /**
76
+ * Acquire a stream slot. Grants immediately when `inFlight` is under the
77
+ * effective cap, otherwise queues FIFO until a release (or the cooldown
78
+ * lifting) frees one. Abort-aware: an already-aborted signal rejects
79
+ * immediately; aborting while queued dequeues and rejects promptly.
80
+ */
81
+ export function acquireStreamSlot(signal) {
82
+ if (signal?.aborted)
83
+ return Promise.reject(abortError());
84
+ if (inFlight < effectiveCap()) {
85
+ inFlight += 1;
86
+ return Promise.resolve(makeRelease());
87
+ }
88
+ return new Promise((resolve, reject) => {
89
+ const waiter = { resolve, reject };
90
+ if (signal) {
91
+ waiter.signal = signal;
92
+ waiter.onAbort = () => {
93
+ const idx = queue.indexOf(waiter);
94
+ if (idx >= 0)
95
+ queue.splice(idx, 1);
96
+ reject(abortError());
97
+ };
98
+ signal.addEventListener('abort', waiter.onAbort, { once: true });
99
+ }
100
+ queue.push(waiter);
101
+ // Re-check: the cap may have widened (cooldown expiry) between the
102
+ // fast-path check and the push.
103
+ pump();
104
+ });
105
+ }
106
+ /** Observable budget state (tests/diagnostics). */
107
+ export function streamBudgetState() {
108
+ return { inFlight, queued: queue.length, cap: effectiveCap() };
109
+ }
110
+ /** Reset the budget (counters, cooldown, queued waiters). Tests only. */
111
+ export function setStreamBudgetForTests() {
112
+ inFlight = 0;
113
+ recent429Until = 0;
114
+ const pending = queue.splice(0, queue.length);
115
+ for (const waiter of pending) {
116
+ if (waiter.signal && waiter.onAbort) {
117
+ waiter.signal.removeEventListener('abort', waiter.onAbort);
118
+ }
119
+ waiter.reject(new Error('stream budget reset'));
120
+ }
121
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Git worktree isolation for parallel write-capable child agents.
3
+ *
4
+ * Each write-capable child gets its own worktree on a branch
5
+ * `klyro/<taskId>` so concurrent children never clobber each other's files.
6
+ * `task_apply` merges the branch back into the parent tree; failures,
7
+ * cancellations, and timeouts remove the worktree best-effort.
8
+ *
9
+ * Semantics worth knowing:
10
+ * - Dirty parent: the merge runs in the parent checkout, so uncommitted
11
+ * parent edits can conflict. On conflict the merge is ABORTED (no
12
+ * commit), the branch is kept, and the caller gets MERGE_CONFLICT with
13
+ * the file list — resolve and re-apply.
14
+ * - Stale branch: a leftover `klyro/<taskId>` branch (e.g. from a crashed
15
+ * session) makes `createWorktree` fail. Delete the stale branch
16
+ * (`deleteBranch`) or sweep orphaned worktrees (`pruneStaleWorktrees`)
17
+ * before retrying.
18
+ * - No-apply: tasks that never reach `succeeded` are NEVER merged — their
19
+ * worktrees are removed best-effort and `task_apply` reports NOT_READY.
20
+ *
21
+ * All git invocations go through `execFile` argv (no shell) with a ~30s
22
+ * timeout, so this is safe on Windows.
23
+ */
24
+ /** Per-command timeout for every git invocation. */
25
+ export declare const GIT_TIMEOUT_MS = 30000;
26
+ export interface WorktreeInfo {
27
+ worktreePath: string;
28
+ branch: string;
29
+ }
30
+ export interface MergeResult {
31
+ merged: boolean;
32
+ conflictFiles: string[];
33
+ }
34
+ /** True when `cwd` is inside a git working tree. Never throws — false on any failure. */
35
+ export declare function ensureGitRepo(cwd: string): Promise<boolean>;
36
+ /** Resolve the repo top-level for any path inside it. Throws when not in a repo. */
37
+ export declare function repoTopLevel(cwd: string): Promise<string>;
38
+ /**
39
+ * Create an isolated worktree for a task on branch `klyro/<taskId>`,
40
+ * rooted at `<repo>/.klyro/worktrees/<taskId>` from HEAD.
41
+ */
42
+ export declare function createWorktree(opts: {
43
+ repoCwd: string;
44
+ taskId: string;
45
+ }): Promise<WorktreeInfo>;
46
+ /**
47
+ * Merge a task branch into the current checkout (`repoCwd`) with
48
+ * `git merge --no-ff --no-edit`. On conflict, parses `git status
49
+ * --porcelain` for unmerged paths (UU/AA/DD), aborts the merge WITHOUT
50
+ * committing, and returns `{ merged: false, conflictFiles }`.
51
+ */
52
+ export declare function mergeWorktree(opts: {
53
+ repoCwd: string;
54
+ branch: string;
55
+ }): Promise<MergeResult>;
56
+ /** Remove a worktree and prune its metadata. Throws with context on failure. */
57
+ export declare function removeWorktree(opts: {
58
+ repoCwd: string;
59
+ worktreePath: string;
60
+ force?: boolean;
61
+ }): Promise<void>;
62
+ /** Delete a task branch (best-effort cleanup after a successful merge). Never throws. */
63
+ export declare function deleteBranch(opts: {
64
+ repoCwd: string;
65
+ branch: string;
66
+ }): Promise<void>;
67
+ /**
68
+ * Remove worktrees under `.klyro/worktrees` whose task id is NOT in
69
+ * `activeTaskIds` (orphans from crashed/timed-out sessions). Lists
70
+ * `git worktree list --porcelain`, force-removes each stale entry, and
71
+ * returns the removed paths. Never throws — per-worktree failures are
72
+ * skipped and a failing `git` itself yields `[]`.
73
+ */
74
+ export declare function pruneStaleWorktrees(repoCwd: string, activeTaskIds: readonly string[]): Promise<string[]>;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Git worktree isolation for parallel write-capable child agents.
3
+ *
4
+ * Each write-capable child gets its own worktree on a branch
5
+ * `klyro/<taskId>` so concurrent children never clobber each other's files.
6
+ * `task_apply` merges the branch back into the parent tree; failures,
7
+ * cancellations, and timeouts remove the worktree best-effort.
8
+ *
9
+ * Semantics worth knowing:
10
+ * - Dirty parent: the merge runs in the parent checkout, so uncommitted
11
+ * parent edits can conflict. On conflict the merge is ABORTED (no
12
+ * commit), the branch is kept, and the caller gets MERGE_CONFLICT with
13
+ * the file list — resolve and re-apply.
14
+ * - Stale branch: a leftover `klyro/<taskId>` branch (e.g. from a crashed
15
+ * session) makes `createWorktree` fail. Delete the stale branch
16
+ * (`deleteBranch`) or sweep orphaned worktrees (`pruneStaleWorktrees`)
17
+ * before retrying.
18
+ * - No-apply: tasks that never reach `succeeded` are NEVER merged — their
19
+ * worktrees are removed best-effort and `task_apply` reports NOT_READY.
20
+ *
21
+ * All git invocations go through `execFile` argv (no shell) with a ~30s
22
+ * timeout, so this is safe on Windows.
23
+ */
24
+ import { execFile } from 'node:child_process';
25
+ import { promisify } from 'node:util';
26
+ import * as path from 'node:path';
27
+ import { filteredEnv } from '../tools/shell/shell-exec.js';
28
+ const execFileAsync = promisify(execFile);
29
+ /** Per-command timeout for every git invocation. */
30
+ export const GIT_TIMEOUT_MS = 30_000;
31
+ async function git(repoCwd, args) {
32
+ // Filtered env (same call shape as shell-exec) so secrets never reach
33
+ // child processes. Then strip repo-pinning vars so discovery is always
34
+ // relative to `repoCwd` (a sandbox may export GIT_DIR for its own
35
+ // harness — that must not make every directory look like a repo).
36
+ const env = { ...filteredEnv() };
37
+ delete env.GIT_DIR;
38
+ delete env.GIT_WORK_TREE;
39
+ delete env.GIT_NAMESPACE;
40
+ const r = await execFileAsync('git', [...args], {
41
+ cwd: repoCwd,
42
+ timeout: GIT_TIMEOUT_MS,
43
+ windowsHide: true,
44
+ maxBuffer: 4 * 1024 * 1024,
45
+ env,
46
+ });
47
+ return { stdout: r.stdout, stderr: r.stderr };
48
+ }
49
+ /** True when `cwd` is inside a git working tree. Never throws — false on any failure. */
50
+ export async function ensureGitRepo(cwd) {
51
+ try {
52
+ const r = await git(cwd, ['rev-parse', '--is-inside-work-tree']);
53
+ return r.stdout.trim() === 'true';
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ /** Resolve the repo top-level for any path inside it. Throws when not in a repo. */
60
+ export async function repoTopLevel(cwd) {
61
+ const r = await git(cwd, ['rev-parse', '--show-toplevel']);
62
+ return r.stdout.trim();
63
+ }
64
+ function branchForTask(taskId) {
65
+ return `klyro/${taskId}`;
66
+ }
67
+ /**
68
+ * Create an isolated worktree for a task on branch `klyro/<taskId>`,
69
+ * rooted at `<repo>/.klyro/worktrees/<taskId>` from HEAD.
70
+ */
71
+ export async function createWorktree(opts) {
72
+ const branch = branchForTask(opts.taskId);
73
+ const top = await repoTopLevel(opts.repoCwd);
74
+ const worktreePath = path.join(top, '.klyro', 'worktrees', opts.taskId);
75
+ await git(opts.repoCwd, ['worktree', 'add', '-b', branch, worktreePath, 'HEAD']);
76
+ return { worktreePath, branch };
77
+ }
78
+ /**
79
+ * Merge a task branch into the current checkout (`repoCwd`) with
80
+ * `git merge --no-ff --no-edit`. On conflict, parses `git status
81
+ * --porcelain` for unmerged paths (UU/AA/DD), aborts the merge WITHOUT
82
+ * committing, and returns `{ merged: false, conflictFiles }`.
83
+ */
84
+ export async function mergeWorktree(opts) {
85
+ try {
86
+ await git(opts.repoCwd, ['merge', '--no-ff', '--no-edit', opts.branch]);
87
+ return { merged: true, conflictFiles: [] };
88
+ }
89
+ catch {
90
+ const conflictFiles = await unmergedFiles(opts.repoCwd);
91
+ try {
92
+ await git(opts.repoCwd, ['merge', '--abort']);
93
+ }
94
+ catch {
95
+ /* best-effort: leave the repo as-is if abort itself fails */
96
+ }
97
+ return { merged: false, conflictFiles };
98
+ }
99
+ }
100
+ async function unmergedFiles(repoCwd) {
101
+ try {
102
+ const r = await git(repoCwd, ['status', '--porcelain']);
103
+ const out = [];
104
+ for (const line of r.stdout.split('\n')) {
105
+ if (line.length < 4)
106
+ continue;
107
+ const x = line[0];
108
+ const y = line[1];
109
+ const unmerged = (x === 'U' || y === 'U' || x === 'A' || y === 'A') && (x === 'U' || y === 'U' || (x === 'A' && y === 'A'));
110
+ // Unmerged statuses: UU, AA, DD, AU, UA, UD, DU.
111
+ const code = `${x}${y}`;
112
+ if (['UU', 'AA', 'DD', 'AU', 'UA', 'UD', 'DU'].includes(code) || unmerged) {
113
+ out.push(line.slice(3).trim());
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ }
122
+ /** Remove a worktree and prune its metadata. Throws with context on failure. */
123
+ export async function removeWorktree(opts) {
124
+ const args = ['worktree', 'remove', ...(opts.force ? ['--force'] : []), opts.worktreePath];
125
+ try {
126
+ await git(opts.repoCwd, args);
127
+ }
128
+ catch (err) {
129
+ throw new Error(`git worktree remove failed for ${opts.worktreePath}: ${err instanceof Error ? err.message : String(err)}`);
130
+ }
131
+ finally {
132
+ try {
133
+ await git(opts.repoCwd, ['worktree', 'prune']);
134
+ }
135
+ catch {
136
+ /* best-effort */
137
+ }
138
+ }
139
+ }
140
+ /** Delete a task branch (best-effort cleanup after a successful merge). Never throws. */
141
+ export async function deleteBranch(opts) {
142
+ try {
143
+ await git(opts.repoCwd, ['branch', '-D', opts.branch]);
144
+ }
145
+ catch {
146
+ /* best-effort */
147
+ }
148
+ }
149
+ /**
150
+ * Remove worktrees under `.klyro/worktrees` whose task id is NOT in
151
+ * `activeTaskIds` (orphans from crashed/timed-out sessions). Lists
152
+ * `git worktree list --porcelain`, force-removes each stale entry, and
153
+ * returns the removed paths. Never throws — per-worktree failures are
154
+ * skipped and a failing `git` itself yields `[]`.
155
+ */
156
+ export async function pruneStaleWorktrees(repoCwd, activeTaskIds) {
157
+ const removed = [];
158
+ try {
159
+ const r = await git(repoCwd, ['worktree', 'list', '--porcelain']);
160
+ const active = new Set(activeTaskIds);
161
+ for (const line of r.stdout.split('\n')) {
162
+ const raw = /^worktree (.+)$/.exec(line.trim())?.[1];
163
+ if (!raw)
164
+ continue;
165
+ // git prints forward slashes even on win32 — normalize so returned
166
+ // paths textually match the path.join-built worktreePath.
167
+ const wp = path.normalize(raw);
168
+ // Only manage our own namespace: <repo>/.klyro/worktrees/<taskId>.
169
+ const parts = wp.split(/[\\/]/);
170
+ const idx = parts.lastIndexOf('worktrees');
171
+ if (idx < 1 || parts[idx - 1] !== '.klyro' || idx + 1 >= parts.length)
172
+ continue;
173
+ const taskId = parts[idx + 1];
174
+ if (!taskId || active.has(taskId))
175
+ continue;
176
+ try {
177
+ await removeWorktree({ repoCwd, worktreePath: wp, force: true });
178
+ removed.push(wp);
179
+ }
180
+ catch {
181
+ /* skip — never throws */
182
+ }
183
+ }
184
+ }
185
+ catch {
186
+ /* git itself failed (not a repo, binary missing) — return what we have */
187
+ }
188
+ return removed;
189
+ }
@@ -1,5 +1,14 @@
1
1
  /**
2
2
  * 4.5 — Checkpoint snapshots: every mutation to checkpoints dir
3
+ *
4
+ * Snapshot bytes stay RAW (no redaction): checkpoint copies must be
5
+ * bit-identical to the working tree so undo() restores exact fidelity —
6
+ * redacting at snapshot time would corrupt restores (a redacted snapshot
7
+ * written back would permanently replace real code with [REDACTED]).
8
+ * Same-trust-domain rationale: snapshots never leave the project dir and
9
+ * are only read back by undo() into the same tree, so secret hygiene is
10
+ * enforced at the trace/persist boundaries (TraceWriter, SessionStore)
11
+ * instead of here.
3
12
  */
4
13
  export declare function snapshot(cwd: string, files: string[]): Promise<string>;
5
14
  export declare function listCheckpoints(cwd: string): Promise<string[]>;
@@ -1,12 +1,47 @@
1
1
  /**
2
2
  * 4.5 — Checkpoint snapshots: every mutation to checkpoints dir
3
+ *
4
+ * Snapshot bytes stay RAW (no redaction): checkpoint copies must be
5
+ * bit-identical to the working tree so undo() restores exact fidelity —
6
+ * redacting at snapshot time would corrupt restores (a redacted snapshot
7
+ * written back would permanently replace real code with [REDACTED]).
8
+ * Same-trust-domain rationale: snapshots never leave the project dir and
9
+ * are only read back by undo() into the same tree, so secret hygiene is
10
+ * enforced at the trace/persist boundaries (TraceWriter, SessionStore)
11
+ * instead of here.
3
12
  */
4
13
  import * as fs from 'node:fs/promises';
14
+ import * as fsSync from 'node:fs';
5
15
  import * as path from 'node:path';
6
16
  import * as crypto from 'node:crypto';
7
17
  function ckptDir(cwd) {
8
18
  return path.join(cwd, '.klyro', 'checkpoints');
9
19
  }
20
+ /**
21
+ * Best-effort permission lockdown (0600 files / 0700 dirs).
22
+ * Windows ACLs ignore POSIX mode bits — no-op by design.
23
+ */
24
+ function lockDown(p, mode) {
25
+ if (process.platform === 'win32')
26
+ return;
27
+ try {
28
+ fsSync.chmodSync(p, mode);
29
+ }
30
+ catch { /* best-effort only */ }
31
+ }
32
+ /** Best-effort fsync of a just-written file (crash safety). */
33
+ async function fsyncFile(p) {
34
+ try {
35
+ const fh = await fs.open(p, 'r+');
36
+ try {
37
+ await fh.sync();
38
+ }
39
+ finally {
40
+ await fh.close();
41
+ }
42
+ }
43
+ catch { /* ignore on Windows */ }
44
+ }
10
45
  /**
11
46
  * Resolve a checkpoint file list entry inside cwd. Returns null for anything
12
47
  * escaping the project (no arbitrary read/write outside cwd, via either the
@@ -22,9 +57,11 @@ function containedPath(cwd, base, rel) {
22
57
  export async function snapshot(cwd, files) {
23
58
  const dir = ckptDir(cwd);
24
59
  await fs.mkdir(dir, { recursive: true });
60
+ lockDown(dir, 0o700);
25
61
  const id = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
26
62
  const dest = path.join(dir, id);
27
63
  await fs.mkdir(dest, { recursive: true });
64
+ lockDown(dest, 0o700);
28
65
  const missing = [];
29
66
  const kept = [];
30
67
  for (const f of files) {
@@ -39,6 +76,8 @@ export async function snapshot(cwd, files) {
39
76
  continue;
40
77
  await fs.mkdir(path.dirname(out), { recursive: true });
41
78
  await fs.writeFile(out, data);
79
+ lockDown(out, 0o600);
80
+ await fsyncFile(out);
42
81
  kept.push(rel);
43
82
  }
44
83
  catch (e) {
@@ -47,8 +86,12 @@ export async function snapshot(cwd, files) {
47
86
  missing.push(f);
48
87
  }
49
88
  }
50
- // Save meta
51
- await fs.writeFile(path.join(dest, '.meta.json'), JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
89
+ // Save meta (fsync before the checkpoint is visible — mirrors the
90
+ // SessionStore.writeIndex atomic pattern).
91
+ const metaPath = path.join(dest, '.meta.json');
92
+ await fs.writeFile(metaPath, JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
93
+ lockDown(metaPath, 0o600);
94
+ await fsyncFile(metaPath);
52
95
  // Best-effort last.diff for the repair guard (guardRepair reads it).
53
96
  try {
54
97
  const { spawn } = await import('node:child_process');
@@ -86,8 +129,15 @@ export async function snapshot(cwd, files) {
86
129
  resolve('');
87
130
  });
88
131
  });
89
- if (diffText)
132
+ if (diffText) {
90
133
  await fs.writeFile(path.join(dir, 'last.diff'), diffText, 'utf-8');
134
+ // Per-checkpoint diff file (best-effort); the repair guard keeps
135
+ // reading last.diff, so its behavior is unchanged.
136
+ try {
137
+ await fs.writeFile(path.join(dir, `${id}.diff`), diffText, 'utf-8');
138
+ }
139
+ catch { /* best-effort only */ }
140
+ }
91
141
  }
92
142
  catch { /* best-effort only */ }
93
143
  return id;
@@ -96,8 +146,9 @@ export async function listCheckpoints(cwd) {
96
146
  const dir = ckptDir(cwd);
97
147
  try {
98
148
  const entries = await fs.readdir(dir);
99
- // last.diff is a guard artifact, not a checkpoint (must never be an undo target).
100
- return entries.filter((e) => !e.startsWith('.') && e !== 'last.diff').sort();
149
+ // last.diff is a guard artifact and <id>.diff files are per-checkpoint
150
+ // diffs — neither is a checkpoint (must never be an undo target).
151
+ return entries.filter((e) => !e.startsWith('.') && e !== 'last.diff' && !e.endsWith('.diff')).sort();
101
152
  }
102
153
  catch {
103
154
  return [];
package/dist/cli/auth.js CHANGED
@@ -111,7 +111,11 @@ export async function runLogout(provider) {
111
111
  const creds = JSON.parse(raw);
112
112
  if (provider) {
113
113
  delete creds[provider];
114
- await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), 'utf-8');
114
+ await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
115
+ try {
116
+ await fs.chmod(credPath(), 0o600);
117
+ }
118
+ catch { /* ignore on Windows */ }
115
119
  process.stdout.write(`Removed ${provider} key\n`);
116
120
  }
117
121
  else {
@@ -128,6 +132,17 @@ export async function runLogout(provider) {
128
132
  }
129
133
  export function getStoredKey(provider) {
130
134
  try {
135
+ // Warn (don't refuse) when the credentials file is group/other-readable.
136
+ // Refusing would lock out existing users with old umasks; warn instead.
137
+ if (process.platform !== 'win32') {
138
+ try {
139
+ const st = fsSync.statSync(credPath());
140
+ if ((st.mode & 0o077) !== 0) {
141
+ process.stderr.write(`warning: credentials file ${credPath()} is group/other-readable (mode ${(st.mode & 0o777).toString(8)}) — run chmod 600 on it\n`);
142
+ }
143
+ }
144
+ catch { /* missing file → no warning */ }
145
+ }
131
146
  const raw = fsSync.readFileSync(credPath(), 'utf-8');
132
147
  const creds = JSON.parse(raw);
133
148
  const v = creds[provider];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `klyro commit` — conventional commit of already-staged changes.
3
+ *
4
+ * Steps:
5
+ * (a) `git status --porcelain` must show staged entries (index column set).
6
+ * Nothing staged → error 'nothing staged (git add first)', exit 2.
7
+ * (`--yes` never bypasses this — there is nothing to commit.)
8
+ * (b) Secret-scan the staged diff via `redact()`: if redaction shrinks or
9
+ * alters the diff, refuse and list the files (exit 2) unless
10
+ * `--force-secret` is passed.
11
+ * (c) Build a conventional message: type heuristic + top-dir scope +
12
+ * `--message` summary (or `update <n> files`).
13
+ * (d) `git commit -m` via execFileSync (no shell). Verification hooks always
14
+ * run — by construction this file contains no flag that skips them.
15
+ * (e) `--dry-run` prints the message + files and exits 0 without committing.
16
+ */
17
+ export interface CommitOptions {
18
+ cwd: string;
19
+ yes?: boolean;
20
+ dryRun?: boolean;
21
+ message?: string;
22
+ forceSecret?: boolean;
23
+ }
24
+ /** Porcelain XY: staged iff the index (first) column is set. Handles renames. */
25
+ export declare function stagedFilesFromPorcelain(porcelain: string): string[];
26
+ /** Type heuristic: test-only→test, docs-only→docs, lockfiles→chore, else feat. */
27
+ export declare function commitTypeFor(files: string[]): string;
28
+ /** Scope = most common top-level dir among staged files with a dir; '' if none. */
29
+ export declare function commitScopeFor(files: string[]): string;
30
+ export declare function buildCommitMessage(files: string[], summaryOpt?: string): string;
31
+ export declare function runCommit(opts: CommitOptions): Promise<number>;