specrails-core 6.0.0 → 6.1.0

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 (173) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-runtime/capabilities.js +3 -1
  3. package/dist/agent-runtime/change-scope.d.ts +63 -0
  4. package/dist/agent-runtime/change-scope.js +297 -0
  5. package/dist/agent-runtime/cli-executor.d.ts +3 -0
  6. package/dist/agent-runtime/cli-executor.js +50 -13
  7. package/dist/agent-runtime/cli-process.js +6 -2
  8. package/dist/agent-runtime/cli.d.ts +15 -0
  9. package/dist/agent-runtime/cli.js +43 -9
  10. package/dist/agent-runtime/compact/chat-client.js +2 -1
  11. package/dist/agent-runtime/compact/environment.d.ts +12 -0
  12. package/dist/agent-runtime/compact/environment.js +31 -1
  13. package/dist/agent-runtime/compact/test-reachability.js +6 -2
  14. package/dist/agent-runtime/config.d.ts +4 -1
  15. package/dist/agent-runtime/config.js +41 -3
  16. package/dist/agent-runtime/core-host.d.ts +10 -4
  17. package/dist/agent-runtime/core-host.js +22 -19
  18. package/dist/agent-runtime/efficiency-summary.d.ts +4 -1
  19. package/dist/agent-runtime/efficiency-summary.js +30 -27
  20. package/dist/agent-runtime/efficiency-types.d.ts +4 -4
  21. package/dist/agent-runtime/efficiency.d.ts +1 -1
  22. package/dist/agent-runtime/efficiency.js +7 -6
  23. package/dist/agent-runtime/engine/__fixtures__/implementation-component.json +90 -0
  24. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.d.ts +11 -0
  25. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +60 -0
  26. package/dist/agent-runtime/engine/__fixtures__/implementation.json +43 -0
  27. package/dist/agent-runtime/engine/budget.d.ts +9 -0
  28. package/dist/agent-runtime/engine/budget.js +33 -0
  29. package/dist/agent-runtime/engine/canonical-json.d.ts +9 -0
  30. package/dist/agent-runtime/engine/canonical-json.js +162 -0
  31. package/dist/agent-runtime/engine/checkpoint/database.d.ts +67 -0
  32. package/dist/agent-runtime/engine/checkpoint/database.js +304 -0
  33. package/dist/agent-runtime/engine/checkpoint/lease.d.ts +17 -0
  34. package/dist/agent-runtime/engine/checkpoint/lease.js +82 -0
  35. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +133 -0
  36. package/dist/agent-runtime/engine/checkpoint/ledger.js +510 -0
  37. package/dist/agent-runtime/engine/checkpoint/saver.d.ts +25 -0
  38. package/dist/agent-runtime/engine/checkpoint/saver.js +146 -0
  39. package/dist/agent-runtime/engine/cli.d.ts +10 -0
  40. package/dist/agent-runtime/engine/cli.js +172 -0
  41. package/dist/agent-runtime/engine/compiler.d.ts +23 -0
  42. package/dist/agent-runtime/engine/compiler.js +261 -0
  43. package/dist/agent-runtime/engine/composition.d.ts +16 -0
  44. package/dist/agent-runtime/engine/composition.js +28 -0
  45. package/dist/agent-runtime/engine/concurrency.d.ts +14 -0
  46. package/dist/agent-runtime/engine/concurrency.js +55 -0
  47. package/dist/agent-runtime/engine/contracts.d.ts +273 -0
  48. package/dist/agent-runtime/engine/contracts.js +11 -0
  49. package/dist/agent-runtime/engine/definition-schema.d.ts +250 -0
  50. package/dist/agent-runtime/engine/definition-schema.js +313 -0
  51. package/dist/agent-runtime/engine/definition-types.d.ts +84 -0
  52. package/dist/agent-runtime/engine/definition-types.js +3 -0
  53. package/dist/agent-runtime/engine/definition-validator.d.ts +7 -0
  54. package/dist/agent-runtime/engine/definition-validator.js +187 -0
  55. package/dist/agent-runtime/engine/efficiency-summary.d.ts +62 -0
  56. package/dist/agent-runtime/engine/efficiency-summary.js +31 -0
  57. package/dist/agent-runtime/engine/events.d.ts +19 -0
  58. package/dist/agent-runtime/engine/events.js +88 -0
  59. package/dist/agent-runtime/engine/execution.d.ts +29 -0
  60. package/dist/agent-runtime/engine/execution.js +121 -0
  61. package/dist/agent-runtime/engine/expressions.d.ts +11 -0
  62. package/dist/agent-runtime/engine/expressions.js +172 -0
  63. package/dist/agent-runtime/engine/fork.d.ts +28 -0
  64. package/dist/agent-runtime/engine/fork.js +211 -0
  65. package/dist/agent-runtime/engine/graph-description.d.ts +48 -0
  66. package/dist/agent-runtime/engine/graph-description.js +16 -0
  67. package/dist/agent-runtime/engine/implementation-compiler.d.ts +214 -0
  68. package/dist/agent-runtime/engine/implementation-compiler.js +91 -0
  69. package/dist/agent-runtime/engine/index.d.ts +14 -0
  70. package/dist/agent-runtime/engine/index.js +14 -0
  71. package/dist/agent-runtime/engine/invocation-context.d.ts +11 -0
  72. package/dist/agent-runtime/engine/invocation-context.js +128 -0
  73. package/dist/agent-runtime/engine/otel.d.ts +15 -0
  74. package/dist/agent-runtime/engine/otel.js +132 -0
  75. package/dist/agent-runtime/engine/output.d.ts +4 -0
  76. package/dist/agent-runtime/engine/output.js +73 -0
  77. package/dist/agent-runtime/engine/piece-dependencies.d.ts +50 -0
  78. package/dist/agent-runtime/engine/piece-dependencies.js +193 -0
  79. package/dist/agent-runtime/engine/piece-registry.d.ts +19 -0
  80. package/dist/agent-runtime/engine/piece-registry.js +62 -0
  81. package/dist/agent-runtime/engine/pieces/assign.d.ts +4 -0
  82. package/dist/agent-runtime/engine/pieces/assign.js +35 -0
  83. package/dist/agent-runtime/engine/pieces/control.d.ts +5 -0
  84. package/dist/agent-runtime/engine/pieces/control.js +49 -0
  85. package/dist/agent-runtime/engine/pieces/decider.d.ts +5 -0
  86. package/dist/agent-runtime/engine/pieces/decider.js +70 -0
  87. package/dist/agent-runtime/engine/pieces/implementation-binding.d.ts +12 -0
  88. package/dist/agent-runtime/engine/pieces/implementation-binding.js +40 -0
  89. package/dist/agent-runtime/engine/pieces/implementation-journal.d.ts +25 -0
  90. package/dist/agent-runtime/engine/pieces/implementation-journal.js +192 -0
  91. package/dist/agent-runtime/engine/pieces/implementation.d.ts +23 -0
  92. package/dist/agent-runtime/engine/pieces/implementation.js +176 -0
  93. package/dist/agent-runtime/engine/pieces/index.d.ts +7 -0
  94. package/dist/agent-runtime/engine/pieces/index.js +22 -0
  95. package/dist/agent-runtime/engine/pieces/openspec.d.ts +3 -0
  96. package/dist/agent-runtime/engine/pieces/openspec.js +69 -0
  97. package/dist/agent-runtime/engine/pieces/ports.d.ts +48 -0
  98. package/dist/agent-runtime/engine/pieces/ports.js +1 -0
  99. package/dist/agent-runtime/engine/pieces/project-memory.d.ts +3 -0
  100. package/dist/agent-runtime/engine/pieces/project-memory.js +13 -0
  101. package/dist/agent-runtime/engine/pieces/prompt.d.ts +5 -0
  102. package/dist/agent-runtime/engine/pieces/prompt.js +130 -0
  103. package/dist/agent-runtime/engine/pieces/role-turn.d.ts +7 -0
  104. package/dist/agent-runtime/engine/pieces/role-turn.js +73 -0
  105. package/dist/agent-runtime/engine/pieces/shared.d.ts +67 -0
  106. package/dist/agent-runtime/engine/pieces/shared.js +40 -0
  107. package/dist/agent-runtime/engine/pieces/shell.d.ts +6 -0
  108. package/dist/agent-runtime/engine/pieces/shell.js +47 -0
  109. package/dist/agent-runtime/engine/pieces/verify.d.ts +7 -0
  110. package/dist/agent-runtime/engine/pieces/verify.js +52 -0
  111. package/dist/agent-runtime/engine/preflight.d.ts +27 -0
  112. package/dist/agent-runtime/engine/preflight.js +54 -0
  113. package/dist/agent-runtime/engine/regex.d.ts +3 -0
  114. package/dist/agent-runtime/engine/regex.js +19 -0
  115. package/dist/agent-runtime/engine/run-status.d.ts +563 -0
  116. package/dist/agent-runtime/engine/run-status.js +61 -0
  117. package/dist/agent-runtime/engine/runs.d.ts +605 -0
  118. package/dist/agent-runtime/engine/runs.js +247 -0
  119. package/dist/agent-runtime/engine/state.d.ts +64 -0
  120. package/dist/agent-runtime/engine/state.js +98 -0
  121. package/dist/agent-runtime/engine/steering/inbox.d.ts +54 -0
  122. package/dist/agent-runtime/engine/steering/inbox.js +113 -0
  123. package/dist/agent-runtime/engine/storage/fork-allocation.d.ts +21 -0
  124. package/dist/agent-runtime/engine/storage/fork-allocation.js +143 -0
  125. package/dist/agent-runtime/engine/storage/private-path.d.ts +11 -0
  126. package/dist/agent-runtime/engine/storage/private-path.js +74 -0
  127. package/dist/agent-runtime/engine/store/sqlite-store.d.ts +26 -0
  128. package/dist/agent-runtime/engine/store/sqlite-store.js +227 -0
  129. package/dist/agent-runtime/evaluation-corpus.d.ts +125 -0
  130. package/dist/agent-runtime/evaluation-corpus.js +4 -0
  131. package/dist/agent-runtime/evaluation.d.ts +9 -3
  132. package/dist/agent-runtime/evaluation.js +50 -8
  133. package/dist/agent-runtime/executor-types.d.ts +53 -3
  134. package/dist/agent-runtime/executor-types.js +35 -2
  135. package/dist/agent-runtime/executors.js +2 -2
  136. package/dist/agent-runtime/graph/artifacts.d.ts +7 -0
  137. package/dist/agent-runtime/graph/artifacts.js +86 -10
  138. package/dist/agent-runtime/graph/convergence.d.ts +26 -0
  139. package/dist/agent-runtime/graph/convergence.js +62 -0
  140. package/dist/agent-runtime/graph/nodes.d.ts +2 -0
  141. package/dist/agent-runtime/graph/nodes.js +171 -24
  142. package/dist/agent-runtime/graph/roles.d.ts +6 -0
  143. package/dist/agent-runtime/graph/roles.js +23 -17
  144. package/dist/agent-runtime/graph/state.d.ts +5 -0
  145. package/dist/agent-runtime/graph/state.js +2 -0
  146. package/dist/agent-runtime/kimi-acp.js +6 -6
  147. package/dist/agent-runtime/openai-executor.js +17 -12
  148. package/dist/agent-runtime/openspec.d.ts +17 -2
  149. package/dist/agent-runtime/openspec.js +54 -20
  150. package/dist/agent-runtime/prompts.d.ts +11 -5
  151. package/dist/agent-runtime/prompts.js +71 -20
  152. package/dist/agent-runtime/recovery.d.ts +1 -1
  153. package/dist/agent-runtime/repository-context.js +20 -1
  154. package/dist/agent-runtime/role-routing.js +4 -2
  155. package/dist/agent-runtime/role-state.d.ts +5 -0
  156. package/dist/agent-runtime/verification-plan.d.ts +2 -0
  157. package/dist/agent-runtime/verification-plan.js +18 -3
  158. package/dist/agent-runtime/workflow-types.d.ts +2 -0
  159. package/dist/agent-runtime/workflow.d.ts +3 -1
  160. package/dist/agent-runtime/workflow.js +7 -3
  161. package/dist/agent-runtime/workspace-tools.d.ts +2 -2
  162. package/dist/agent-runtime/workspace-tools.js +6 -6
  163. package/dist/installer/cli.js +5 -0
  164. package/dist/installer/phases/scaffold.d.ts +2 -2
  165. package/dist/installer/phases/scaffold.js +3 -7
  166. package/dist/pipeline/pipeline-state.d.ts +49 -4
  167. package/dist/pipeline/pipeline-state.js +150 -23
  168. package/integration-contract.json +84 -12
  169. package/package.json +12 -5
  170. package/schemas/agent-runtime.schema.json +93 -1
  171. package/schemas/workflow-definition.schema.json +312 -0
  172. package/templates/kimi/specrails/run-skill.mjs +15 -1
  173. package/templates/commands/specrails/batch-implement.md +0 -32
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  The agent workflow engine behind [Specrails Desktop](https://github.com/fjpulidop/specrails-desktop). Core does two things:
8
8
 
9
- 1. **Installs** the specrails workflow into a project for Claude Code, Codex CLI, Gemini CLI or Kimi Code: three roles (architect, developer, reviewer), the `implement` / `batch-implement` / `retry` entry points and the official OpenSpec skills.
9
+ 1. **Installs** the specrails workflow into a project for Claude Code, Codex CLI, Gemini CLI or Kimi Code: three roles (architect, developer, reviewer), the `implement` and `retry` entry points (`implement` runs one ticket or several tickets as one aggregate run) and the official OpenSpec skills.
10
10
  2. **Runs** implementations through the [programmatic agent runtime](docs/agent-runtime.md): a LangGraph workflow (architect → developer → verify → reviewer → archive) with per-role providers, deterministic verification, durable recovery and OpenAI-compatible local models.
11
11
 
12
12
  Desktop bundles a pinned Core package and owns everything around it: worktrees, commits, pull requests, backlog and the user interface. Core never ships code on its own; it prepares a reviewed candidate for the host.
@@ -1,3 +1,4 @@
1
+ import { resolveRoleDescriptor, roleIds } from './config.js';
1
2
  import { readFileSync, statSync } from 'node:fs';
2
3
  import { homedir, tmpdir } from 'node:os';
3
4
  import path from 'node:path';
@@ -71,7 +72,8 @@ export async function configuredCapabilities(config, registry) {
71
72
  return probes.get(key);
72
73
  };
73
74
  const roles = [];
74
- for (const [role, selected] of Object.entries(config.agents)) {
75
+ for (const role of roleIds(config)) {
76
+ const selected = resolveRoleDescriptor(config, role);
75
77
  for (const [tier, choice] of [['base', selected], ...(selected.escalation ? [['escalation', selected.escalation]] : [])]) {
76
78
  roles.push({ role, tier, provider: selected.provider, model: choice.model ?? null, requestedEffort: choice.effort ?? null, ...await probe(selected.provider, choice.model) });
77
79
  }
@@ -0,0 +1,63 @@
1
+ import { type PipelineContext, type PipelineRepository } from '../pipeline/pipeline-state.js';
2
+ export interface ChangedFile {
3
+ path: string;
4
+ status: 'added' | 'modified' | 'deleted';
5
+ }
6
+ export interface RepositoryChangeSet {
7
+ repositoryId: string;
8
+ scope?: string[];
9
+ files: ChangedFile[];
10
+ truncated: boolean;
11
+ }
12
+ /** An out-of-scope edit Core undid after a role turn. */
13
+ export interface DiscardedEdit {
14
+ repositoryId: string;
15
+ path: string;
16
+ restored: 'base' | 'previous' | 'removed';
17
+ }
18
+ /** A command without `cwd` runs in its repository's scope directory, never at the root of a larger checkout. */
19
+ export declare function withScopeDefault<T extends {
20
+ repositoryId: string;
21
+ cwd?: string;
22
+ }>(context: PipelineContext, command: T): T;
23
+ /** Model-proposed checks stay inside the repository scope: they prove the change, not the whole checkout. */
24
+ export declare function assertProposalInScope(context: PipelineContext, command: {
25
+ repositoryId: string;
26
+ cwd?: string;
27
+ }, what?: string): void;
28
+ /** Base commit per repository id; the first call records it. */
29
+ export declare function changeBases(context: PipelineContext): Record<string, string>;
30
+ /** Every path that differs from the base: tracked edits (staged or not), deletions and new untracked files. */
31
+ export declare function repositoryChanges(context: PipelineContext, repository: PipelineRepository, base: string, change?: string): ChangedFile[];
32
+ /** What this run changed, per repository and bounded: untracked files that predate the run are not part of it. */
33
+ export declare function changeSet(context: PipelineContext, change?: string, limit?: number): RepositoryChangeSet[];
34
+ /** Changes outside the repository scope (none when the whole checkout is the repository). */
35
+ export declare function outOfScope(repository: PipelineRepository, files: readonly ChangedFile[]): ChangedFile[];
36
+ interface SavedState {
37
+ fingerprint: string;
38
+ content?: Buffer;
39
+ mode?: number;
40
+ link?: string;
41
+ }
42
+ /** The out-of-scope state before a role turn: the host undoes only what that turn did. */
43
+ export interface ScopeGuard {
44
+ change?: string;
45
+ repositories: Array<{
46
+ id: string;
47
+ base: string;
48
+ before: Map<string, SavedState>;
49
+ }>;
50
+ }
51
+ export declare function captureOutOfScope(context: PipelineContext, change?: string): ScopeGuard;
52
+ /**
53
+ * Undoes every out-of-scope edit made since `guard` was captured. A path the
54
+ * turn changed outside the scope returns to its state before the turn: its
55
+ * base content, its earlier out-of-scope content, or absence. In-scope work,
56
+ * ignored files and out-of-scope state that predates the turn stay untouched.
57
+ */
58
+ export declare function discardOutOfScopeEdits(context: PipelineContext, guard: ScopeGuard): DiscardedEdit[];
59
+ /** Bounded prompt lines for a change set; repository ids prefix the paths only when several repositories are in scope. */
60
+ export declare function renderChangeSet(sets: readonly RepositoryChangeSet[], limit?: number): string[];
61
+ /** One human line naming discarded edits, bounded. */
62
+ export declare function describeDiscarded(edits: readonly DiscardedEdit[], multiple: boolean, limit?: number): string;
63
+ export {};
@@ -0,0 +1,297 @@
1
+ // The change a run makes, measured by the host from git rather than taken from
2
+ // a role's own summary, and the boundary it must stay inside.
3
+ //
4
+ // Observed (a monorepo app registered as the repository, busuu-courses inside
5
+ // busuu-web): the configured `yarn test` ran at the checkout root and fanned
6
+ // out to every workspace; the fixer then "repaired" .yarnrc.yml, the root
7
+ // package.json and two other apps, the reviewer rejected those unrelated
8
+ // edits, the fixer reverted them, verification failed again — a loop that
9
+ // never converged. A repository scope makes the boundary explicit: commands
10
+ // default to the package, reviews see the exact change set, and edits outside
11
+ // the scope are undone by the host before anyone verifies or reviews them.
12
+ import { spawnSync } from 'node:child_process';
13
+ import { createHash, randomUUID } from 'node:crypto';
14
+ import { lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { pipelineStateDirectory, withinRepositoryScope } from '../pipeline/pipeline-state.js';
17
+ /** Git's empty tree: the base of a repository without commits. */
18
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
19
+ /** Files whose change inside the scope legitimately refreshes a lockfile of an enclosing workspace. */
20
+ const MANIFESTS = new Set(['package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'Pipfile', 'Gemfile', 'composer.json', 'requirements.txt']);
21
+ const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'Cargo.lock', 'go.sum', 'go.work.sum', 'poetry.lock', 'uv.lock', 'Pipfile.lock', 'Gemfile.lock', 'composer.lock', '.pnp.cjs', '.pnp.loader.mjs', '.pnp.data.json']);
22
+ const AGENT_MEMORY = ['.claude', '.codex', '.gemini', '.kimi-code'].map(root => root + '/agent-memory');
23
+ const GENERATED = new Set(['node_modules', 'coverage', '.nyc_output', '.jest-cache', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.venv', 'venv', '.tox', '.gradle', '.cache', '.turbo']);
24
+ /** Bounded memory for restoring a previous out-of-scope state byte for byte; beyond it the base is restored. */
25
+ const RESTORE_FILE_LIMIT = 8 * 1024 * 1024;
26
+ const RESTORE_TOTAL_LIMIT = 64 * 1024 * 1024;
27
+ /** Untracked files present when the run started are host/user state (overlays, local files), not the change. */
28
+ const PREEXISTING_LIMIT = 20_000;
29
+ function gitEnv() {
30
+ // An inherited GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE must not redirect the inspection.
31
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
32
+ return { ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1', GIT_TERMINAL_PROMPT: '0' };
33
+ }
34
+ function git(root, args, allowFailure = false) {
35
+ const result = spawnSync('git', ['-c', 'core.quotePath=false', '-c', 'core.fsmonitor=false', '-C', root, ...args], { env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, windowsHide: true });
36
+ const overflow = result.error?.code === 'ENOBUFS';
37
+ const ok = !result.error && result.status === 0;
38
+ if (!ok && !allowFailure)
39
+ throw new Error(`git ${args[0]} failed in ${root}: ${String(result.error?.message ?? result.stderr ?? '').trim().slice(0, 400)}`);
40
+ return { ok, stdout: result.stdout ?? '', overflow };
41
+ }
42
+ const posix = (file) => file.split(path.sep).join('/');
43
+ /** A command without `cwd` runs in its repository's scope directory, never at the root of a larger checkout. */
44
+ export function withScopeDefault(context, command) {
45
+ if (command.cwd !== undefined)
46
+ return command;
47
+ const scope = context.repositories.find(repo => repo.id === command.repositoryId)?.scope;
48
+ return scope?.length ? { ...command, cwd: scope[0] } : command;
49
+ }
50
+ /** Model-proposed checks stay inside the repository scope: they prove the change, not the whole checkout. */
51
+ export function assertProposalInScope(context, command, what = 'verification proposal') {
52
+ const repository = context.repositories.find(repo => repo.id === command.repositoryId);
53
+ if (!repository?.scope?.length || command.cwd === undefined)
54
+ return;
55
+ const relative = posix(path.relative(repository.path, path.resolve(repository.path, command.cwd)));
56
+ if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative) || !withinRepositoryScope(repository, relative)) {
57
+ throw new Error(`Invalid ${what} cwd ${JSON.stringify(command.cwd)}: it runs outside the repository scope (${repository.scope.join(', ')}). Omit cwd to run in ${repository.scope[0]}, or name a directory inside the scope.`);
58
+ }
59
+ }
60
+ /** Untracked, non-ignored paths; a checkout with an enormous untracked tree reports directories instead of files. */
61
+ function untracked(root) {
62
+ const files = git(root, ['ls-files', '--others', '--exclude-standard', '-z'], true);
63
+ if (files.ok)
64
+ return files.stdout.split('\0').filter(Boolean);
65
+ if (!files.overflow)
66
+ git(root, ['ls-files', '--others', '--exclude-standard', '-z']);
67
+ return git(root, ['ls-files', '--others', '--exclude-standard', '--directory', '-z']).stdout.split('\0').filter(Boolean);
68
+ }
69
+ function baseFile(context) { return path.join(pipelineStateDirectory(context), 'change-base.json'); }
70
+ /**
71
+ * The commit each repository's change is measured against — the host's frozen
72
+ * base when it declared one, otherwise HEAD the first time Core asks (before
73
+ * any role edits) — plus the untracked files that already existed then.
74
+ * Recorded once, so every later turn measures the same change.
75
+ */
76
+ function baseRecords(context) {
77
+ const file = baseFile(context);
78
+ let recorded = {};
79
+ try {
80
+ const value = JSON.parse(readFileSync(file, 'utf8'));
81
+ if (value.schemaVersion === 1 && value.repositories && typeof value.repositories === 'object')
82
+ recorded = value.repositories;
83
+ }
84
+ catch { /* first use */ }
85
+ let changed = false;
86
+ for (const repository of context.repositories) {
87
+ const known = recorded[repository.id];
88
+ if (known && typeof known.base === 'string' && /^[a-f0-9]{40,64}$/.test(known.base))
89
+ continue;
90
+ let base = repository.baseSha;
91
+ if (!base) {
92
+ const head = git(repository.path, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], true);
93
+ base = head.ok && /^[a-f0-9]{40,64}$/.test(head.stdout.trim()) ? head.stdout.trim() : EMPTY_TREE;
94
+ }
95
+ let present;
96
+ try {
97
+ present = untracked(repository.path);
98
+ }
99
+ catch { /* not a git checkout: the change cannot be measured */ }
100
+ recorded[repository.id] = { base, ...(present && present.length <= PREEXISTING_LIMIT ? { preexisting: present } : {}) };
101
+ changed = true;
102
+ }
103
+ if (changed) {
104
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
105
+ const temp = file + '.' + randomUUID() + '.tmp';
106
+ try {
107
+ writeFileSync(temp, JSON.stringify({ schemaVersion: 1, repositories: recorded }) + '\n', { flag: 'wx', mode: 0o600 });
108
+ renameSync(temp, file);
109
+ }
110
+ finally {
111
+ rmSync(temp, { force: true });
112
+ }
113
+ }
114
+ return recorded;
115
+ }
116
+ /** Base commit per repository id; the first call records it. */
117
+ export function changeBases(context) {
118
+ return Object.fromEntries(Object.entries(baseRecords(context)).map(([id, record]) => [id, record.base]));
119
+ }
120
+ /** Host and runtime state, the change's own OpenSpec artifacts and generated trees are never part of the code change. */
121
+ function ignored(context, repository, change, file, fresh) {
122
+ if (file === '.specrails' || file.startsWith('.specrails/'))
123
+ return true;
124
+ if (repository.id === context.artifactRepositoryId && change && (file === 'openspec/changes/' + change || file.startsWith('openspec/changes/' + change + '/')))
125
+ return true;
126
+ if (!fresh)
127
+ return false;
128
+ return AGENT_MEMORY.some(root => file === root || file.startsWith(root + '/')) || file.split('/').some(segment => GENERATED.has(segment));
129
+ }
130
+ /** Every path that differs from the base: tracked edits (staged or not), deletions and new untracked files. */
131
+ export function repositoryChanges(context, repository, base, change) {
132
+ const files = new Map();
133
+ const diff = git(repository.path, ['diff', '--name-status', '-z', '--no-renames', '--ignore-submodules=all', base, '--']).stdout.split('\0');
134
+ for (let i = 0; i + 1 < diff.length; i += 2) {
135
+ const status = diff[i], file = diff[i + 1];
136
+ if (!status || !file || ignored(context, repository, change, file, false))
137
+ continue;
138
+ files.set(file, status.startsWith('A') ? 'added' : status.startsWith('D') ? 'deleted' : 'modified');
139
+ }
140
+ for (const file of untracked(repository.path)) {
141
+ const relative = file.replace(/\/$/, '');
142
+ if (!files.has(relative) && !ignored(context, repository, change, relative, true))
143
+ files.set(file, 'added');
144
+ }
145
+ return [...files].map(([file, status]) => ({ path: file, status })).sort((a, b) => a.path.localeCompare(b.path));
146
+ }
147
+ /** What this run changed, per repository and bounded: untracked files that predate the run are not part of it. */
148
+ export function changeSet(context, change, limit = 5000) {
149
+ const records = baseRecords(context);
150
+ return context.repositories.map(repository => {
151
+ const record = records[repository.id];
152
+ const preexisting = new Set(record.preexisting ?? []);
153
+ const files = repositoryChanges(context, repository, record.base, change).filter(file => !(file.status === 'added' && preexisting.has(file.path)));
154
+ return { repositoryId: repository.id, ...(repository.scope ? { scope: repository.scope } : {}), files: files.slice(0, limit), truncated: files.length > limit };
155
+ });
156
+ }
157
+ function dependencyFileForScope(repository, file) {
158
+ // A dependency change in the package legitimately refreshes the lockfile (or
159
+ // Yarn's install artifacts) of a workspace that encloses the scope.
160
+ const enclosing = (directory) => (repository.scope ?? []).some(scope => directory === '.' || directory === '' || scope === directory || scope.startsWith(directory + '/'));
161
+ if (LOCKFILES.has(path.posix.basename(file)))
162
+ return enclosing(path.posix.dirname(file));
163
+ const yarn = /^(?:(.*)\/)?\.yarn\/(?:cache\/|install-state\.gz$)/.exec(file);
164
+ return yarn !== null && enclosing(yarn[1] ?? '.');
165
+ }
166
+ /** Changes outside the repository scope (none when the whole checkout is the repository). */
167
+ export function outOfScope(repository, files) {
168
+ if (!repository.scope?.length)
169
+ return [];
170
+ const manifestChanged = files.some(file => withinRepositoryScope(repository, file.path) && MANIFESTS.has(path.posix.basename(file.path)));
171
+ return files.filter(file => !withinRepositoryScope(repository, file.path.replace(/\/$/, '')) && !(manifestChanged && dependencyFileForScope(repository, file.path)));
172
+ }
173
+ function fingerprintOf(file) {
174
+ const stat = lstatSync(file, { throwIfNoEntry: false });
175
+ if (!stat)
176
+ return { fingerprint: 'missing' };
177
+ if (stat.isSymbolicLink()) {
178
+ const link = readlinkSync(file);
179
+ return { fingerprint: 'link:' + link, link };
180
+ }
181
+ if (!stat.isFile())
182
+ return { fingerprint: 'other' };
183
+ const content = readFileSync(file);
184
+ return { fingerprint: `file:${stat.mode & 0o777}:${createHash('sha256').update(content).digest('hex')}`, content, mode: stat.mode & 0o777 };
185
+ }
186
+ export function captureOutOfScope(context, change) {
187
+ const scoped = context.repositories.filter(repository => repository.scope?.length);
188
+ if (!scoped.length)
189
+ return { change, repositories: [] };
190
+ const bases = changeBases(context);
191
+ let budget = 0;
192
+ return {
193
+ change,
194
+ repositories: scoped.map(repository => {
195
+ const base = bases[repository.id];
196
+ const before = new Map();
197
+ for (const file of outOfScope(repository, repositoryChanges(context, repository, base, change))) {
198
+ const { content, ...saved } = fingerprintOf(path.join(repository.path, file.path));
199
+ const keep = content && content.length <= RESTORE_FILE_LIMIT && budget + content.length <= RESTORE_TOTAL_LIMIT;
200
+ if (keep)
201
+ budget += content.length;
202
+ before.set(file.path, keep ? { ...saved, content } : saved);
203
+ }
204
+ return { id: repository.id, base, before };
205
+ }),
206
+ };
207
+ }
208
+ function restoreFromBase(repository, base, files) {
209
+ const restored = [];
210
+ for (let i = 0; i < files.length; i += 100) {
211
+ const batch = files.slice(i, i + 100);
212
+ const tracked = base === EMPTY_TREE ? new Set() : new Set(git(repository.path, ['ls-tree', '-r', '-z', '--name-only', '--full-tree', base, '--', ...batch.map(file => file.replace(/\/$/, ''))]).stdout.split('\0').filter(Boolean));
213
+ const existing = batch.filter(file => tracked.has(file));
214
+ if (existing.length)
215
+ git(repository.path, ['restore', '--source=' + base, '--staged', '--worktree', '--', ...existing]);
216
+ const added = batch.filter(file => !tracked.has(file));
217
+ if (added.length)
218
+ git(repository.path, ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...added.map(file => file.replace(/\/$/, ''))], true);
219
+ for (const file of added)
220
+ rmSync(path.join(repository.path, file), { recursive: true, force: true });
221
+ restored.push(...existing.map(file => ({ repositoryId: repository.id, path: file, restored: 'base' })), ...added.map(file => ({ repositoryId: repository.id, path: file.replace(/\/$/, ''), restored: 'removed' })));
222
+ }
223
+ return restored;
224
+ }
225
+ function restorePrevious(repository, file, saved) {
226
+ const target = path.join(repository.path, file);
227
+ rmSync(target, { recursive: true, force: true });
228
+ if (saved.fingerprint === 'missing')
229
+ return;
230
+ mkdirSync(path.dirname(target), { recursive: true });
231
+ if (saved.link !== undefined)
232
+ symlinkSync(saved.link, target);
233
+ else
234
+ writeFileSync(target, saved.content, { mode: saved.mode ?? 0o644 });
235
+ }
236
+ /**
237
+ * Undoes every out-of-scope edit made since `guard` was captured. A path the
238
+ * turn changed outside the scope returns to its state before the turn: its
239
+ * base content, its earlier out-of-scope content, or absence. In-scope work,
240
+ * ignored files and out-of-scope state that predates the turn stay untouched.
241
+ */
242
+ export function discardOutOfScopeEdits(context, guard) {
243
+ const discarded = [];
244
+ for (const entry of guard.repositories) {
245
+ const repository = context.repositories.find(repo => repo.id === entry.id);
246
+ const now = outOfScope(repository, repositoryChanges(context, repository, entry.base, guard.change)).map(file => file.path);
247
+ const changedNow = new Set(now);
248
+ // A tracked file the turn returned to its base content leaves the change smaller: that clean-up stays.
249
+ const settled = [...entry.before.keys()].filter(file => !changedNow.has(file));
250
+ const tracked = settled.length && entry.base !== EMPTY_TREE ? new Set(git(repository.path, ['ls-tree', '-r', '-z', '--name-only', '--full-tree', entry.base, '--', ...settled]).stdout.split('\0').filter(Boolean)) : new Set();
251
+ const toBase = [];
252
+ for (const file of new Set([...now, ...entry.before.keys()])) {
253
+ const saved = entry.before.get(file);
254
+ if (!saved) {
255
+ toBase.push(file);
256
+ continue;
257
+ }
258
+ if (saved.fingerprint === fingerprintOf(path.join(repository.path, file)).fingerprint)
259
+ continue;
260
+ if (!changedNow.has(file) && tracked.has(file))
261
+ continue;
262
+ if (saved.fingerprint === 'missing' || saved.link !== undefined || saved.content) {
263
+ restorePrevious(repository, file, saved);
264
+ discarded.push({ repositoryId: repository.id, path: file, restored: 'previous' });
265
+ }
266
+ else
267
+ toBase.push(file);
268
+ }
269
+ discarded.push(...restoreFromBase(repository, entry.base, toBase));
270
+ }
271
+ return discarded.sort((a, b) => a.repositoryId.localeCompare(b.repositoryId) || a.path.localeCompare(b.path));
272
+ }
273
+ /** Bounded prompt lines for a change set; repository ids prefix the paths only when several repositories are in scope. */
274
+ export function renderChangeSet(sets, limit = 150) {
275
+ const multiple = sets.length > 1;
276
+ const lines = [];
277
+ let hidden = 0;
278
+ for (const set of sets) {
279
+ for (const file of set.files) {
280
+ if (lines.length >= limit) {
281
+ hidden++;
282
+ continue;
283
+ }
284
+ lines.push(`- ${multiple ? '`' + set.repositoryId + '` ' : ''}\`${file.path}\` (${file.status})`);
285
+ }
286
+ if (set.truncated)
287
+ hidden++;
288
+ }
289
+ if (hidden)
290
+ lines.push(`- … ${hidden}${sets.some(set => set.truncated) ? '+' : ''} more not listed; compare against the base with git to see them.`);
291
+ return lines;
292
+ }
293
+ /** One human line naming discarded edits, bounded. */
294
+ export function describeDiscarded(edits, multiple, limit = 12) {
295
+ const names = edits.map(edit => (multiple ? edit.repositoryId + ':' : '') + edit.path);
296
+ return names.slice(0, limit).join(', ') + (names.length > limit ? ` and ${names.length - limit} more` : '');
297
+ }
@@ -21,6 +21,9 @@ interface CliInvocationOptions {
21
21
  * developer that cannot run tests iterates blind and fails verification.
22
22
  */
23
23
  export declare function buildCliInvocation(provider: CliProvider, request: AgentRequest, options?: CliInvocationOptions): CliInvocation;
24
+ /** Native commands are data rendered for the selected provider; never shell text. */
25
+ export declare function renderNativeCommand(provider: Exclude<CliProvider, 'kimi'>, command: NonNullable<AgentRequest['nativeCommand']>): string;
26
+ export declare function kimiNativeSkill(id: string): string;
24
27
  interface ParsedCliResult extends AgentResult {
25
28
  terminal: boolean;
26
29
  failed: boolean;
@@ -2,13 +2,13 @@ import { codexOutputSchema, restoreOptionalFields } from './codex-schema.js';
2
2
  import { assertEffortSupported, cliCapabilities } from './capabilities.js';
3
3
  import { providerDiagnostic } from './provider-diagnostic.js';
4
4
  import { toolEvent } from './tool-event.js';
5
- import { openSpecPrompt, writeOpenSpecBridge } from './openspec.js';
5
+ import { openSpecPrompt, openSpecPolicy, writeOpenSpecBridge } from './openspec.js';
6
6
  import { existsSync, readFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { sumCacheUsage } from './efficiency-types.js';
10
10
  import { normalizeKimiCliModel } from './kimi-model.js';
11
- import { AgentExecutionError, unknownUsage, validateAgentRequest } from './executor-types.js';
11
+ import { AgentExecutionError, unknownUsage, normalizeAgentRequest, requestPolicy, isBuiltinRole } from './executor-types.js';
12
12
  import { runCliProcess } from './cli-process.js';
13
13
  import { canonicalWorkspace } from './workspace-tools.js';
14
14
  import { parseStructuredText } from './openai-executor.js';
@@ -23,22 +23,29 @@ const CLAUDE_DEVELOPER_DISALLOWED = 'Agent,Task,Skill';
23
23
  * developer that cannot run tests iterates blind and fails verification.
24
24
  */
25
25
  export function buildCliInvocation(provider, request, options = {}) {
26
- const readOnly = request.role !== 'developer';
26
+ if (!isBuiltinRole(request.role) || request.access !== undefined || request.nativeCommand)
27
+ request = normalizeAgentRequest(request);
28
+ if (request.nativeCommand) {
29
+ if (provider === 'kimi')
30
+ throw new AgentExecutionError('Kimi commands require skill rendering before invocation', 'native_command_unsupported');
31
+ request = { ...request, prompt: renderNativeCommand(provider, request.nativeCommand) };
32
+ }
33
+ const readOnly = requestPolicy(request).access === 'read';
27
34
  // Pin the product alias; explicit IDs remain reproducible across releases.
28
35
  const modelId = provider === 'claude' && request.model === 'opus' ? 'claude-opus-5-5' : request.model;
29
36
  const model = modelId ? ['--model', modelId] : [];
30
37
  const extraRoots = request.allowedRoots.filter(root => root !== request.cwd);
31
38
  const resume = request.resumeSessionId;
32
39
  switch (provider) {
33
- case 'claude': return { command: 'claude', stdin: request.prompt, args: [
34
- '-p', '--output-format', 'stream-json', '--verbose', '--max-turns', String(request.maxTurns ?? 100), ...model,
40
+ case 'claude': return { command: 'claude', ...(request.nativeCommand ? {} : { stdin: request.prompt }), args: [
41
+ '-p', ...(request.nativeCommand ? [request.prompt] : []), '--output-format', 'stream-json', '--verbose', '--max-turns', String(request.maxTurns ?? 100), ...model,
35
42
  ...(request.effort === undefined ? [] : ['--effort', request.effort]),
36
43
  // Project instructions and rules stay visible; the user's global config,
37
44
  // memory and plugins never leak into an autonomous role.
38
45
  '--setting-sources', 'project,local',
39
46
  ...(readOnly
40
- ? ['--tools', options.mcpConfigFile ? 'Read,Grep,Glob,ToolSearch' : 'Read,Grep,Glob', '--permission-mode', options.mcpConfigFile ? 'dontAsk' : 'plan', '--strict-mcp-config', ...(options.mcpConfigFile ? ['--allowedTools', 'Read,Grep,Glob,ToolSearch,mcp__specrails_openspec__workflow,mcp__specrails_openspec__read_verification_evidence'] : [])]
41
- : ['--tools', 'default', '--disallowedTools', CLAUDE_DEVELOPER_DISALLOWED, '--dangerously-skip-permissions']),
47
+ ? ['--tools', request.nativeCommand ? 'Read,Grep,Glob,Skill' : options.mcpConfigFile ? 'Read,Grep,Glob,ToolSearch' : 'Read,Grep,Glob', '--permission-mode', options.mcpConfigFile ? 'dontAsk' : 'plan', '--strict-mcp-config', ...(options.mcpConfigFile ? ['--allowedTools', 'Read,Grep,Glob,ToolSearch,mcp__specrails_openspec__workflow,mcp__specrails_openspec__read_verification_evidence'] : [])]
48
+ : ['--tools', 'default', '--disallowedTools', request.nativeCommand ? 'Agent,Task' : CLAUDE_DEVELOPER_DISALLOWED, '--dangerously-skip-permissions']),
42
49
  ...(options.mcpConfigFile ? ['--mcp-config', options.mcpConfigFile] : []),
43
50
  ...(request.outputSchema ? ['--json-schema', JSON.stringify(request.outputSchema)] : []),
44
51
  ...(request.maxCostUsd === undefined ? [] : ['--max-budget-usd', String(request.maxCostUsd)]),
@@ -63,8 +70,8 @@ export function buildCliInvocation(provider, request, options = {}) {
63
70
  case 'gemini': {
64
71
  if (readOnly && !options.geminiPolicyFile)
65
72
  throw new AgentExecutionError('Gemini architect/reviewer roles require --admin-policy support for an enforced read-only tool allowlist. Upgrade Gemini CLI or select another provider for this role.', 'provider_capability_unsupported');
66
- return { command: 'gemini', stdin: request.prompt, args: [
67
- '-p', 'Execute the task supplied on stdin.', '--output-format', 'stream-json', ...model,
73
+ return { command: 'gemini', ...(request.nativeCommand ? {} : { stdin: request.prompt }), args: [
74
+ '-p', request.nativeCommand ? request.prompt : 'Execute the task supplied on stdin.', '--output-format', 'stream-json', ...model,
68
75
  // Headless Gemini cannot answer shell approvals; the developer runs with
69
76
  // auto-approval exactly as the legacy Implement step did.
70
77
  ...(readOnly ? ['--approval-mode', 'plan'] : ['--yolo']),
@@ -86,6 +93,34 @@ export function buildCliInvocation(provider, request, options = {}) {
86
93
  }
87
94
  }
88
95
  }
96
+ /** Native commands are data rendered for the selected provider; never shell text. */
97
+ export function renderNativeCommand(provider, command) {
98
+ return (provider === 'codex' ? '$' : '/') + command.id + (command.args ? ' ' + command.args : '');
99
+ }
100
+ export function kimiNativeSkill(id) {
101
+ const openspec = { 'opsx:ff': 'openspec-ff-change', 'opsx:apply': 'openspec-apply-change', 'opsx:verify': 'openspec-verify-change' };
102
+ const skill = openspec[id] ?? (id.startsWith('specrails:') ? id.replace('specrails:', 'specrails-') : id);
103
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(skill))
104
+ throw new AgentExecutionError('Kimi native command is unsupported', 'native_command_unsupported');
105
+ return skill;
106
+ }
107
+ async function renderKimiRequest(request, options) {
108
+ const skill = kimiNativeSkill(request.nativeCommand.id);
109
+ const runnerPath = path.join(request.cwd, '.kimi-code', 'specrails', 'run-skill.mjs');
110
+ if (!existsSync(runnerPath))
111
+ throw new AgentExecutionError('Kimi managed skill runner is not installed', 'native_command_unsupported');
112
+ const result = await (options.runProcess ?? runCliProcess)({ command: process.execPath, args: [runnerPath, '--skill', skill, '--model', request.model ?? 'kimi-for-coding', '--args', request.nativeCommand.args ?? '', ...(request.resumeSessionId ? ['--session', request.resumeSessionId] : []), '--render-only'] }, { cwd: request.cwd, signal: request.signal, timeoutMs: 10000, env: options.env });
113
+ if (result.exitCode !== 0)
114
+ throw new AgentExecutionError('Kimi native skill cannot be rendered', 'native_command_unsupported');
115
+ let prompt;
116
+ try {
117
+ prompt = JSON.parse(result.stdout).prompt;
118
+ }
119
+ catch { /* Invalid runner output cannot become a provider prompt. */ }
120
+ if (typeof prompt !== 'string' || !prompt.trim() || prompt.includes('\0') || Buffer.byteLength(prompt) > 1_048_576)
121
+ throw new AgentExecutionError('Invalid Kimi native skill prompt', 'native_command_unsupported');
122
+ return { ...request, prompt, nativeCommand: undefined };
123
+ }
89
124
  function object(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
90
125
  function number(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; }
91
126
  function textBlocks(value) {
@@ -244,7 +279,7 @@ export class CliExecutor {
244
279
  capabilities(model) { return cliCapabilities(this.provider, model, this.options); }
245
280
  capabilityChecks = new Map();
246
281
  async validateOpenSpec(context) {
247
- const key = context.role === 'developer' ? 'write' : 'read';
282
+ const key = openSpecPolicy(context).access;
248
283
  let checked = this.capabilityChecks.get(key);
249
284
  if (!checked) {
250
285
  checked = (async () => {
@@ -266,11 +301,13 @@ export class CliExecutor {
266
301
  throw new AgentExecutionError('Kimi does not report authoritative token usage. Remove the token cap or select another provider for this role.', 'usage_unavailable');
267
302
  }
268
303
  async execute(request) {
269
- validateAgentRequest(request);
304
+ request = normalizeAgentRequest(request);
270
305
  if (request.effort !== undefined)
271
306
  assertEffortSupported(request, await this.capabilities(request.model));
272
307
  this.validateLimits(request);
273
308
  const scope = canonicalWorkspace(request.cwd, request.allowedRoots);
309
+ if (this.provider === 'kimi' && request.nativeCommand)
310
+ request = await renderKimiRequest(request, this.options);
274
311
  const normalized = { ...request, prompt: (request.openspec ? openSpecPrompt(request.openspec) : '') + request.prompt, cwd: scope.cwd, allowedRoots: scope.roots };
275
312
  const runner = this.options.runProcess ?? runCliProcess;
276
313
  let temporary, kimiAgentFile, geminiPolicyFile, codexSchemaFile, stream = '', turns = 0, observedSession;
@@ -295,7 +332,7 @@ export class CliExecutor {
295
332
  if (this.provider === 'kimi')
296
333
  return await executeKimiReadonlyAcp(normalized, { ...this.options, openspecBridge });
297
334
  }
298
- if (this.provider === 'gemini' && request.role !== 'developer') {
335
+ if (this.provider === 'gemini' && requestPolicy(request).access === 'read') {
299
336
  assertGeminiAdminPolicyAvailable();
300
337
  const help = await runner({ command: 'gemini', args: ['--help'] }, { cwd: scope.cwd, signal: request.signal, timeoutMs: 10_000, env: this.options.env });
301
338
  if (help.exitCode !== 0 || !help.stdout.includes('--admin-policy'))
@@ -303,7 +340,7 @@ export class CliExecutor {
303
340
  geminiPolicyFile = path.join(scratch(), 'readonly.toml');
304
341
  writeFileSync(geminiPolicyFile, (openspecBridge ? '[[rule]]\nmcpName = "specrails_openspec"\ntoolName = "workflow"\ndecision = "allow"\npriority = 1000\n\n[[rule]]\nmcpName = "specrails_openspec"\ntoolName = "read_verification_evidence"\ndecision = "allow"\npriority = 1000\n\n' : '') + GEMINI_READONLY_POLICY, { mode: 0o600 });
305
342
  }
306
- if (this.provider === 'kimi' && request.role !== 'developer') {
343
+ if (this.provider === 'kimi' && requestPolicy(request).access === 'read') {
307
344
  const help = await runner({ command: 'kimi', args: ['--help'] }, { cwd: scope.cwd, signal: request.signal, timeoutMs: 10_000, env: this.options.env });
308
345
  if (help.exitCode !== 0)
309
346
  throw new AgentExecutionError('Cannot detect Kimi CLI capabilities', 'provider_capability_unsupported');
@@ -62,6 +62,10 @@ export function cliProcessEnvironment(base, platform = process.platform) {
62
62
  export const runCliProcess = async (raw, options) => {
63
63
  if (options.signal?.aborted)
64
64
  throw new AgentExecutionError('Agent cancelled', 'aborted');
65
+ for (const value of [options.timeoutMs, options.idleTimeoutMs]) {
66
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0 || value > 2_147_483_647))
67
+ throw new AgentExecutionError('Invalid process timeout', 'invalid_limit');
68
+ }
65
69
  const env = cliProcessEnvironment(options.env ?? process.env);
66
70
  const invocation = process.platform === 'win32' && raw.command === 'kimi' ? windowsKimiInvocation(raw, { env }) : raw;
67
71
  return new Promise((resolve, reject) => {
@@ -93,12 +97,12 @@ export const runCliProcess = async (raw, options) => {
93
97
  };
94
98
  const fail = (error) => { failure ??= error; terminate(); };
95
99
  const abort = () => fail(new AgentExecutionError('Agent cancelled', 'aborted'));
96
- const timer = setTimeout(() => fail(new AgentExecutionError(`Agent reached the total execution limit (${options.timeoutMs} ms)`, 'timeout')), options.timeoutMs);
100
+ const timer = options.timeoutMs > 0 ? setTimeout(() => fail(new AgentExecutionError(`Agent reached the total execution limit (${options.timeoutMs} ms)`, 'timeout')), options.timeoutMs) : undefined;
97
101
  let idleTimer;
98
102
  const activity = () => {
99
103
  if (idleTimer)
100
104
  clearTimeout(idleTimer);
101
- if (options.idleTimeoutMs !== undefined && !failure)
105
+ if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0 && !failure)
102
106
  idleTimer = setTimeout(() => fail(new AgentExecutionError(`Agent produced no output for ${options.idleTimeoutMs} ms (idle timeout)`, 'idle_timeout')), options.idleTimeoutMs);
103
107
  };
104
108
  activity();
@@ -1,4 +1,16 @@
1
1
  import type { WorkflowState } from './workflow-types.js';
2
+ /** Both runtime entry points emit the same machine-readable fatal result. */
3
+ export declare function runtimeFailure(error: unknown): {
4
+ type: string;
5
+ status: string;
6
+ error: string | {
7
+ details?: import("./workflow-types.js").JsonValue | undefined;
8
+ code: string;
9
+ message: string;
10
+ };
11
+ };
12
+ /** Machine operations are mirrored in integration-contract.json; help is presentation-only. */
13
+ export declare const RUNTIME_CLI_OPERATIONS: readonly ["api", "validate", "run", "status", "resume", "prompts", "capabilities", "evidence", "recovery", "evaluate", "workflows", "fork", "signal", "cancel", "help"];
2
14
  /** The host-facing summary: phase status, pending interrupts and usage without accumulated outputs. */
3
15
  export declare function compactState(state: WorkflowState | null): {
4
16
  runId: string;
@@ -44,4 +56,7 @@ export declare function compactState(state: WorkflowState | null): {
44
56
  }[];
45
57
  metrics: import("./efficiency-types.js").RuntimeEfficiency;
46
58
  } | null;
59
+ /** Engines this package can launch: the legacy built-in pipeline (1) and workflow definitions (2).
60
+ * Core 7 advertises [2] so hosts refuse unconverted legacy loops before admission. */
61
+ export declare const RUNTIME_ENGINES: readonly number[];
47
62
  export declare function runRuntimeCommand(flags: Record<string, string | boolean>, positionals: string[], emit?: (value: unknown) => void): Promise<number>;