agentlas 1.0.12 → 1.0.15

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 (54) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +4 -2
  3. package/bin/agentlas.cjs +17 -3
  4. package/engine/agentlas-cloud-runtime.cjs +8 -2
  5. package/engine/agentlas-config.cjs +25 -18
  6. package/engine/agentlas-core-harness.cjs +14 -1
  7. package/engine/agentlas-i18n.cjs +2 -0
  8. package/engine/agentlas-input.cjs +1 -1
  9. package/engine/agentlas-memory-governance.cjs +10 -0
  10. package/engine/agentlas-onboard.cjs +22 -5
  11. package/engine/agentlas-sqlite-policy.cjs +18 -7
  12. package/engine/agentlas-workforce.cjs +136 -19
  13. package/engine/agentlas-workload-routing.cjs +32 -4
  14. package/engine/agentlas.cjs +8 -0
  15. package/engine/automation/daemon.cjs +67 -30
  16. package/engine/automation/schedule.cjs +16 -0
  17. package/engine/automation/store.cjs +70 -12
  18. package/engine/bootstrap-schema.sql +788 -53
  19. package/engine/cloud-assets/cas.cjs +7 -2
  20. package/engine/cloud-assets/commands.cjs +2 -0
  21. package/engine/cloud-assets/package.cjs +49 -1
  22. package/engine/cloud-assets/state.cjs +35 -0
  23. package/engine/commands/automation.cjs +8 -0
  24. package/engine/commands/chats.cjs +6 -1
  25. package/engine/commands/firm.cjs +7 -0
  26. package/engine/commands/help.cjs +23 -2
  27. package/engine/commands/hep-cloud.cjs +31 -0
  28. package/engine/commands/hep-hub.cjs +30 -0
  29. package/engine/commands/hep-local.cjs +32 -0
  30. package/engine/commands/hep-network.cjs +43 -0
  31. package/engine/commands/index.cjs +38 -7
  32. package/engine/commands/open.cjs +5 -1
  33. package/engine/commands/run.cjs +12 -2
  34. package/engine/commands/setup.cjs +12 -11
  35. package/engine/commands/storm.cjs +5 -9
  36. package/engine/commands/swarm.cjs +4 -4
  37. package/engine/commands/uninstall.cjs +36 -2
  38. package/engine/commands/version.cjs +29 -0
  39. package/engine/commands/workforce.cjs +10 -10
  40. package/engine/core/schema-ensure.cjs +75 -0
  41. package/engine/experience/variant.cjs +46 -2
  42. package/engine/hephaestus/runtime.cjs +7 -0
  43. package/engine/memory-cli/curate.cjs +3 -7
  44. package/engine/project/memory-context.cjs +3 -7
  45. package/engine/project/state.cjs +7 -6
  46. package/engine/sessions/orchestrator.cjs +41 -3
  47. package/engine/sessions/prompt.cjs +4 -7
  48. package/engine/sessions/session.cjs +32 -0
  49. package/engine/storm/swarm.cjs +12 -0
  50. package/engine/ui/palette.cjs +14 -2
  51. package/engine/ui/renderer.cjs +37 -0
  52. package/engine/ui/repl.cjs +7 -1
  53. package/engine/workforce/concurrency.cjs +41 -0
  54. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.15 — 2026-07-29
4
+
5
+ - **Agent Cloud saves work from a new machine or fresh clone.** Before writing,
6
+ the terminal resolves the signed-in owner's exact asset revision by
7
+ `slug` and scope, then keeps the conditional-write guard on that revision.
8
+ A missing local receipt no longer turns an owned asset into a false create
9
+ conflict.
10
+ - **A real stale-copy conflict stays fail-closed.** The terminal stops before
11
+ replacing a newer server revision, reports its identity, and offers an
12
+ explicit `--overwrite` only when the owner deliberately chooses the current
13
+ folder over the newer copy.
14
+ - Conflict messages now describe ownership and the next command instead of
15
+ exposing storage precondition terminology.
16
+
17
+ ## 1.0.14 — 2026-07-29
18
+
19
+ - **Write-capable commands now prepare every project automatically.** The first
20
+ `run`, `storm`, `swarm`, or workforce execution in any folder installs the
21
+ same private, merge-only Agentlas project infrastructure through Core. This
22
+ is based on the folder the user opened, not on the Agentlas source checkout.
23
+ Read-only commands remain passive and do not create files.
24
+ - **Per-command help works before database startup.** Commands such as
25
+ `agentlas run --help` and `agentlas workforce --help` no longer require an
26
+ SQLite driver or open the project database just to print usage.
27
+
28
+ ## 1.0.13 — 2026-07-28
29
+
30
+ - **MCP servers reach the chat again.** `runNativeTurn` only injects them at
31
+ `full` permission and that gate was right, but `sessions/session.cjs` never
32
+ put `mcpServers` in the request — zero callers — so no CLI ever received a
33
+ server. `agentlas mcp probe` printed "connected" while the turn that followed
34
+ could not use it. Turns now carry servers the user already consented to;
35
+ nothing new is asked mid-turn, because a turn is not a place a user can answer.
36
+ - **The shared database stops taking a write lock every turn.** Four
37
+ `CREATE TABLE IF NOT EXISTS`, five indexes and an `ALTER TABLE` ran on every
38
+ single turn against the file Desktop also uses. All idempotent, all taking the
39
+ lock; during a Desktop migration that burns the 15s busy timeout and every
40
+ call site swallowed the failure. Schema repair is once per connection now, and
41
+ the three copies of `ensureMemoryContextColumn` are one function.
42
+ - **Rows written here are checked for referential integrity.** `foreign_keys` is
43
+ a connection property, not a file property — Desktop opened with it ON and the
44
+ terminal did not, so terminal writes skipped the check on shared tables.
45
+ - **A zero-byte database file no longer blocks first run forever.** One stray
46
+ `sqlite3 <missing-path>` left an empty file, and every run after that read
47
+ "already exists" and died on `no such table` with nothing visible to explain it.
48
+ - **Bootstrap schema is current again** (v81; it was a v45 snapshot from
49
+ 2026-07-07), and a mismatch with Desktop's migration target now fails a gate.
50
+ - **An engine update landing mid-run cannot mix two releases.** Core roots
51
+ resolve to their real path, so a long run keeps the modules it started with.
52
+ The next call still picks up the new release.
53
+ - `/ontology` says what it manages — this project's knowledge sources, not the
54
+ engine's knowledge runtime. The command itself is unchanged.
55
+
3
56
  ## 1.0.12 — 2026-07-28
4
57
 
5
58
  - **Orchestrator/worker model roles now resolve from ordered candidate
package/README.md CHANGED
@@ -110,9 +110,11 @@ agentlas connect <sub> # Telegram 등 플랫폼 연결 (무인자는 u
110
110
  agentlas import <폴더> # 로컬 에이전트/팀 임포트
111
111
  agentlas native prepare <agent> # 네이티브 CLI 문맥 파일 생성
112
112
  agentlas list # 설치 에이전트/회사 + 활성 런타임
113
- agentlas uninstall <agent> # 설치 에이전트 제거 (빌트인 거부)
113
+ agentlas uninstall <agent> [--yes] # 설치 에이전트 제거 (빌트인 거부).
114
+ # 대화 이력이 있으면 건수를 보여주고 --yes 없이는 거절한다
115
+ # (챗/메시지가 CASCADE로 함께 영구 삭제되기 때문).
114
116
  agentlas experience <sub> # list|inspect|validate|save|publish|status|export|unpublish|withdraw
115
- agentlas variant resolve # 로컬 variant 호환성 프리뷰 (권위 없음)
117
+ agentlas variant resolve --base-release <id> # 로컬 variant 호환성 프리뷰 (권위 없음, `agentlas variant help`)
116
118
  ```
117
119
 
118
120
  ### EXECUTE
package/bin/agentlas.cjs CHANGED
@@ -117,9 +117,23 @@ function bootstrapDbIfMissing() {
117
117
  const dir = path.dirname(p);
118
118
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
119
119
  securePrivateMode(dir, 0o700);
120
+ // 존재 여부만 보면 **0바이트 파일이 부트스트랩을 영구히 막는다.** 실측 2026-07-28:
121
+ // `sqlite3 <없는경로> 'PRAGMA user_version;'` 같은 무해한 명령 하나가 빈 파일을 남기고,
122
+ // 그 뒤 모든 실행이 "이미 있음"으로 판단해 스키마 없이 진행하다 `no such table` 로
123
+ // 죽는다. 사용자에게는 원인이 전혀 안 보이는 상태다.
124
+ //
125
+ // SQLite 파일은 최소 한 페이지(512바이트)를 갖는다. 0바이트는 DB 가 아니라 자리만
126
+ // 잡힌 파일이므로 없는 것으로 취급해 정상 부트스트랩 경로를 태운다. 내용이 있는데
127
+ // 손상된 경우는 여기서 판단하지 않는다 — 그건 복구지 부트스트랩이 아니고, 멀쩡한
128
+ // DB 를 빈 것으로 오판해 덮어쓰는 위험이 훨씬 크다.
120
129
  if (exists(p)) {
121
- securePrivateMode(p, 0o600);
122
- return { created: false, path: p };
130
+ let empty = false;
131
+ try { empty = fs.statSync(p).size === 0; } catch { empty = false; }
132
+ if (!empty) {
133
+ securePrivateMode(p, 0o600);
134
+ return { created: false, path: p };
135
+ }
136
+ try { fs.rmSync(p, { force: true }); } catch { /* 지울 수 없으면 아래 link 가 EEXIST 로 알려준다 */ }
123
137
  }
124
138
  const schemaFile = path.join(PKG_ROOT, "engine", "bootstrap-schema.sql");
125
139
  if (!exists(schemaFile)) {
@@ -172,7 +186,7 @@ function main() {
172
186
  let error = null;
173
187
  if (!engineFound) {
174
188
  error = "Engine not found (engine/agentlas.cjs). Reinstall with: npm i -g agentlas";
175
- } else if (!sqliteDriver) {
189
+ } else if (!sqliteDriver && !metadataOnly) {
176
190
  error = `Node ${process.version} — no SQLite driver. Upgrade to Node 22.5+ or reinstall with 'npm i -g agentlas' to build better-sqlite3.`;
177
191
  }
178
192
 
@@ -74,7 +74,7 @@ function buildManifest(root, options = {}) {
74
74
  memoryPolicy: { writeBack: "ask", publicCopy: "reset" },
75
75
  memory: files.filter((file) => [".agentlas/memory-map.json", ".agentlas/agent-card.json"].includes(file.path)).map((file) => file.path),
76
76
  allowRead: ["README.md", "AGENTS.md", "agent.md", "skills/**", ".agentlas/*.json"],
77
- denyRead: [".env", ".env.*", "**/secrets/**", "**/credentials/**", "**/cookies/**", "**/*token*", "**/*secret*"],
77
+ denyRead: [".env", ".env.*", "secrets/**", "**/secrets/**", "credentials/**", "**/credentials/**", "cookies/**", "**/cookies/**"],
78
78
  publicExportPolicy: "clean-copy",
79
79
  requiredRuntime: ["mcp-client"],
80
80
  license: "call-only-default",
@@ -92,7 +92,13 @@ function scanFiles(files) {
92
92
  findings.push({ verdict, type, path: file.path, ...(line ? { line } : {}), message, redacted: true });
93
93
  }
94
94
  for (const file of files) {
95
- if ([".env", ".env.local"].includes(file.path) || /(?:^|\/)(secrets|credentials|cookies)\//i.test(file.path) || /token|secret/i.test(file.path)) {
95
+ // A credential store is a path SEGMENT, never a filename substring. The old
96
+ // `/token|secret/i` test blocked ordinary vocabulary — measured 2026-07-29,
97
+ // it gave the live `web-master` package a BLOCK verdict for its own design
98
+ // system (`token-architecture.md`, `reference-token-db.json`). Dropping it
99
+ // costs no coverage: the per-line SECRET_PATTERNS scan below reads every
100
+ // packaged file and is the check that actually looks at values.
101
+ if (/^\.env(\.|$)/.test(file.path.split("/").pop() || "") || /(?:^|\/)(secrets|credentials|cookies)\//i.test(file.path)) {
96
102
  add("BLOCK", "credential-path", file, null, "Credential-like file path is excluded from Cloud package and public publish.");
97
103
  }
98
104
  file.content.split(/\r?\n/).forEach((line, index) => {
@@ -100,28 +100,35 @@ function mergePrefs(base, patch) {
100
100
  return next;
101
101
  }
102
102
 
103
+ /*
104
+ * Throws on failure (lock timeout, read-only data dir, ENOSPC …) — it used to
105
+ * swallow every error and return null, so `agentlas setup` announced "All set."
106
+ * and exited 0 while the user's language/runtime/permission were never written.
107
+ * A caller that cannot persist must be able to say so; use savePrefs for the
108
+ * best-effort boolean contract.
109
+ */
103
110
  function updatePrefs(userDataDir, patch) {
104
- try {
105
- fs.mkdirSync(userDataDir, { recursive: true });
106
- return withPrefsLock(userDataDir, () => {
107
- const file = prefsPath(userDataDir);
108
- const currentWasValid = Boolean(readPrefsFile(file));
109
- const current = loadPrefs(userDataDir);
110
- if (fs.existsSync(file) && !currentWasValid) {
111
- try { fs.renameSync(file, `${file}.corrupt-${Date.now()}-${process.pid}`); } catch { /* recover from backup */ }
112
- }
113
- const next = mergePrefs(current, patch);
114
- if (currentWasValid) atomicWrite(backupPath(userDataDir), current);
115
- atomicWrite(file, next);
116
- return next;
117
- });
118
- } catch {
119
- return null;
120
- }
111
+ fs.mkdirSync(userDataDir, { recursive: true });
112
+ return withPrefsLock(userDataDir, () => {
113
+ const file = prefsPath(userDataDir);
114
+ const currentWasValid = Boolean(readPrefsFile(file));
115
+ const current = loadPrefs(userDataDir);
116
+ if (fs.existsSync(file) && !currentWasValid) {
117
+ try { fs.renameSync(file, `${file}.corrupt-${Date.now()}-${process.pid}`); } catch { /* recover from backup */ }
118
+ }
119
+ const next = mergePrefs(current, patch);
120
+ if (currentWasValid) atomicWrite(backupPath(userDataDir), current);
121
+ atomicWrite(file, next);
122
+ return next;
123
+ });
121
124
  }
122
125
 
123
126
  function savePrefs(userDataDir, prefs) {
124
- return Boolean(updatePrefs(userDataDir, prefs));
127
+ try {
128
+ return Boolean(updatePrefs(userDataDir, prefs));
129
+ } catch {
130
+ return false;
131
+ }
125
132
  }
126
133
 
127
134
  module.exports = { prefsPath, loadPrefs, savePrefs, updatePrefs, mergePrefs };
@@ -84,7 +84,20 @@ function resolveCoreRuntimeRootFromCandidates(candidateRoots, requiredMarkers =
84
84
  const version = readCoreRuntimeVersion(root);
85
85
  if (!version || compareSemVer(version, minVersion) < 0) continue;
86
86
  }
87
- return root;
87
+ // `~/.agentlas/runtime/current` 는 업데이터가 원자적으로 갈아 끼우는 심볼릭
88
+ // 링크다. 그 경로를 그대로 넘기면 Python 이 import 를 늦게 해석하는 특성상,
89
+ // 긴 실행 도중 업데이트가 일어나면 **옛 버전 모듈과 새 버전 모듈이 한 프로세스에
90
+ // 섞여 로드된다** — 어떤 버전에서도 시험된 적 없는 조합이고 버전 번호로는
91
+ // 재현조차 못 한다(감사 D3).
92
+ //
93
+ // 실경로로 고정해도 라이브 버전 선택은 그대로다: **다음** 호출이 다시 해석해
94
+ // 새 릴리스를 집는다. 없어지는 것은 실행 중 교체뿐이다.
95
+ try {
96
+ return fs.realpathSync(root);
97
+ } catch {
98
+ // 링크를 읽을 수 없다고 실행을 거부할 이유는 없다 — 이전 동작대로 경로를 쓴다.
99
+ return root;
100
+ }
88
101
  } catch {
89
102
  // Continue to the next installed/bundled root.
90
103
  }
@@ -260,6 +260,7 @@ const STRINGS = {
260
260
  "wiz.default": "(Enter default)",
261
261
  "wiz.invalid": "Choose 1–%s.",
262
262
  "wiz.saved": "All set. You can change any of this later with /runtime, /permission.",
263
+ "wiz.saveFailed": "Could not save your choices (%s) — they apply to this session only, and setup will ask again next launch.",
263
264
  "wiz.changeLang": "Tip: re-run setup anytime with agentlas setup",
264
265
  },
265
266
  ko: {
@@ -505,6 +506,7 @@ const STRINGS = {
505
506
  "wiz.default": "(Enter 기본값)",
506
507
  "wiz.invalid": "1–%s 중 고르세요.",
507
508
  "wiz.saved": "완료. 나중에 /runtime, /permission으로 언제든 바꿀 수 있어요.",
509
+ "wiz.saveFailed": "선택을 저장하지 못했습니다 (%s) — 이번 세션에만 적용되고, 다음 실행 때 다시 물어봅니다.",
508
510
  "wiz.changeLang": "팁: 언제든 agentlas setup 으로 다시 설정",
509
511
  },
510
512
  };
@@ -298,7 +298,7 @@ const SLASH_COMMAND_META = [
298
298
  { command: "/route", description: "Preview which agent/pipeline would take a request", category: "Engine", usage: "/route <request>", detail: "Runs the Hephaestus router without executing — shows the selected agent, candidates, and reasons." },
299
299
  { command: "/research", description: "Run the Hephaestus Research Engine", category: "Engine", usage: "/research search \"query\"", detail: "status|gather|search|read|plan — evidence-grade web research from the terminal." },
300
300
  { command: "/search", description: "Discover agents in the Hub", category: "Hub", usage: "/search <what you need>", detail: "Search the Agentlas Hub + local for an agent that fits the task (hep-search)." },
301
- { command: "/install", description: "Install an agent from the Hub by slug", category: "Hub", usage: "/install <slug>", detail: "Install a marketplace agent into this terminal (hep-cloud)." },
301
+ { command: "/install", description: "Install an agent from the Hub by slug", category: "Hub", usage: "/install <slug>", detail: "Install a marketplace agent into this terminal." },
302
302
  { command: "/network", description: "Staff and execute an ontology-grounded task force", category: "Engine", usage: "/network <request> [--benchmark]", detail: "The active top host LLM creates the work order, searches the Hub workforce ontology, selects exact releases, and executes a receipt-backed task force.", aliases: ["/taskforce", "/workforce"] },
303
303
  { command: "/legacy-network", description: "Run the compatibility Hephaestus network route", category: "Engine", usage: "/legacy-network <request>", detail: "Explicit compatibility escape hatch; never used as a fallback by /network." },
304
304
  { command: "/browser", description: "Real browser execution hardpoint", category: "Engine", usage: "/browser [sub]", detail: "Runs the Agentlas browser hardpoint (hep-browser)." },
@@ -163,7 +163,17 @@ function tableExists(db, name) {
163
163
  }
164
164
  }
165
165
 
166
+ // 커넥션당 1회. 이전에는 `beginTurn`/`listScopedTimeline` 이 부를 때마다, 즉 **매 턴**
167
+ // 공유 DB 에 테이블 4개 + 인덱스 5개 DDL 과 `UPDATE … stale` 을 날렸다. 전부
168
+ // IF NOT EXISTS 라 결과는 멱등이지만 쓰기 락을 잡는 것은 매번이었고, 데스크탑
169
+ // 마이그레이션과 겹치면 15초 busy_timeout 을 소진했다(2026-07-28).
170
+ const { ensureOnce } = require("./core/schema-ensure.cjs");
171
+
166
172
  function ensureGovernanceSchema(db) {
173
+ return ensureOnce(db, "terminal_memory.schema", (conn) => ensureGovernanceSchemaOnce(conn));
174
+ }
175
+
176
+ function ensureGovernanceSchemaOnce(db) {
167
177
  db.exec(`
168
178
  CREATE TABLE IF NOT EXISTS terminal_memory_turn_intents (
169
179
  turn_id TEXT PRIMARY KEY,
@@ -7,8 +7,13 @@ const i18n = require("./agentlas-i18n.cjs");
7
7
  const banner = require("./agentlas-banner.cjs");
8
8
  const { visWidth, wrapWidth } = require("./agentlas-composer.cjs");
9
9
 
10
- // req = { ui, rl, helpers } → Promise<{ onboarded, lang, runtime, permission }>
11
- async function runOnboard({ ui, rl, helpers }) {
10
+ /*
11
+ * req = { ui, rl, helpers, persist } → Promise<{ onboarded, saved, saveError, lang, runtime, permission }>
12
+ * `persist` writes the answers and must throw when it cannot. The wizard calls it
13
+ * itself, before announcing "All set." — the caller used to save afterwards, so a
14
+ * failed write (locked/read-only data dir) was announced as success and dropped.
15
+ */
16
+ async function runOnboard({ ui, rl, helpers, persist }) {
12
17
  const H = helpers;
13
18
  const c = ui.c;
14
19
  const contentWidth = () => Math.max(20, (ui.out.columns || 80) - 5);
@@ -149,10 +154,22 @@ async function runOnboard({ ui, rl, helpers }) {
149
154
  const pi = await pickNum(permOpts.length);
150
155
  const permission = permOpts[pi - 1].v;
151
156
 
157
+ // Save first, then report what actually happened — never the other way round.
158
+ let saveError = null;
159
+ try {
160
+ if (typeof persist !== "function") throw new Error("no persistence handler");
161
+ await persist({ lang, runtime, permission });
162
+ } catch (error) {
163
+ saveError = error;
164
+ }
152
165
  ui.line("");
153
- printSaved(ui.t("wiz.saved"));
154
- printIndented(ui.t("wiz.changeLang"), c.faint);
155
- return { onboarded: true, lang, runtime, permission };
166
+ if (saveError) {
167
+ ui.error(ui.t("wiz.saveFailed", String((saveError && saveError.message) || saveError)));
168
+ } else {
169
+ printSaved(ui.t("wiz.saved"));
170
+ printIndented(ui.t("wiz.changeLang"), c.faint);
171
+ }
172
+ return { onboarded: true, saved: !saveError, saveError, lang, runtime, permission };
156
173
  } finally {
157
174
  rl.removeListener("line", onLine);
158
175
  if (ownsSigint) rl.removeListener("SIGINT", onSigint);
@@ -6,15 +6,26 @@
6
6
  // fail immediately with SQLITE_BUSY even when busy_timeout is configured.
7
7
  const SQLITE_BUSY_TIMEOUT_MS = 15_000;
8
8
 
9
+ // `foreign_keys` 는 파일이 아니라 **커넥션** 속성이다. 데스크탑은
10
+ // `electron/store/db.ts` 에서 `foreign_keys = ON` 으로 열고, 터미널은 켜지 않아
11
+ // **같은 테이블인데 터미널이 쓰는 행만 참조 무결성 검사를 건너뛰고 있었다**
12
+ // (2026-07-28 확인). journal_mode 처럼 파일에 박히는 값이 아니라서 한쪽이 켠 것이
13
+ // 다른 쪽에 전파되지 않는다. 스키마 소유는 데스크탑이므로 그 규칙을 그대로 따른다.
14
+ const SQLITE_CONNECTION_PRAGMAS = [
15
+ `busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`,
16
+ "foreign_keys = ON",
17
+ ];
18
+
9
19
  function configureSqliteConnection(db) {
10
20
  if (!db) throw new TypeError("SQLite connection is required");
11
- if (typeof db.pragma === "function") {
12
- db.pragma(`busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
13
- } else if (typeof db.exec === "function") {
14
- db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
15
- } else {
16
- throw new TypeError("SQLite connection must expose pragma() or exec()");
17
- }
21
+ const applyPragma =
22
+ typeof db.pragma === "function"
23
+ ? (statement) => db.pragma(statement)
24
+ : typeof db.exec === "function"
25
+ ? (statement) => db.exec(`PRAGMA ${statement}`)
26
+ : null;
27
+ if (!applyPragma) throw new TypeError("SQLite connection must expose pragma() or exec()");
28
+ for (const statement of SQLITE_CONNECTION_PRAGMAS) applyPragma(statement);
18
29
  return db;
19
30
  }
20
31
 
@@ -21,6 +21,7 @@ const fs = require("node:fs");
21
21
  const net = require("node:net");
22
22
  const path = require("node:path");
23
23
  const { Ui } = require("./agentlas-ui.cjs");
24
+ const { recommendedConcurrency } = require("./workforce/concurrency.cjs");
24
25
 
25
26
  const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{1,255}$/;
26
27
  const HASH_RE = /^sha256:[0-9a-f]{64}$/;
@@ -1516,7 +1517,9 @@ function validatePreparedExecution(value, workOrder, selection, candidateSet, va
1516
1517
  function validateDelegationPlan(value, selection) {
1517
1518
  const plan = assertObject(value, "delegationPlan");
1518
1519
  assertExactKeys(plan, ["schemaVersion", "planId", "packets", "synthesis", "verifier"], "delegationPlan", "planner_invalid");
1519
- if (plan.schemaVersion !== "agentlas.workforce-delegation-plan.v1") fail("planner_invalid", "unsupported workforce delegation plan schema");
1520
+ // v2: 패킷에 doneWhen(검증 가능한 완료조건 체크리스트)이 필수가 됐다. 생산자(같은
1521
+ // 파일의 플래너 프롬프트)와 검증자가 항상 함께 배포되므로 호환 창구는 없다.
1522
+ if (plan.schemaVersion !== "agentlas.workforce-delegation-plan.v2") fail("planner_invalid", "unsupported workforce delegation plan schema");
1520
1523
  assertId(plan.planId, "executionPlan.planId");
1521
1524
  const assignments = new Map(selection.assignments.map((row) => [`${row.slotId}\0${row.agentReleaseId}`, row]));
1522
1525
  const packets = assertArray(plan.packets, "executionPlan.packets", MAX_ASSIGNMENTS, { min: 1 });
@@ -1534,6 +1537,9 @@ function validateDelegationPlan(value, selection) {
1534
1537
  assertString(packet.objective, "packet.objective", UNBOUNDED_EXPLANATION_FIELD);
1535
1538
  assertArray(packet.inputs, "packet.inputs", 64).forEach((item, index) => assertString(item, `packet.inputs[${index}]`, UNBOUNDED_EXPLANATION_FIELD));
1536
1539
  assertString(packet.expectedOutput, "packet.expectedOutput", UNBOUNDED_EXPLANATION_FIELD);
1540
+ // 완료조건은 위임 계약의 필수 요소다(v2) — 각 항목이 워커 반환물만 보고 참/거짓
1541
+ // 판정 가능한 문장이어야 하며, 검증자 criteria와 같은 개수·길이 상한을 쓴다.
1542
+ assertArray(packet.doneWhen, "packet.doneWhen", 16, { min: 1 }).forEach((item, index) => assertString(item, `packet.doneWhen[${index}]`, 500));
1537
1543
  }
1538
1544
  if (pairs.size !== assignments.size || [...assignments.keys()].some((pair) => !pairs.has(pair))) fail("planner_missing_child", "planner must create one separate child packet for every accepted assignment");
1539
1545
  for (const key of ["synthesis", "verifier"]) {
@@ -1818,11 +1824,12 @@ function buildPrompts(task, identity) {
1818
1824
  requestExpansionForSlots: [],
1819
1825
  };
1820
1826
  const delegationPlanShape = {
1821
- schemaVersion: "agentlas.workforce-delegation-plan.v1",
1827
+ schemaVersion: "agentlas.workforce-delegation-plan.v2",
1822
1828
  planId: "workforce-plan:<id>",
1823
1829
  packets: [{
1824
1830
  packetId: "packet:<id>", slotId: "<selected slot>", agentReleaseId: "<selected release>",
1825
1831
  objective: "bounded objective", inputs: [], expectedOutput: "concrete handoff",
1832
+ doneWhen: ["checkable completion condition"],
1826
1833
  }],
1827
1834
  synthesis: { slotId: "<selected slot>", agentReleaseId: "<selected release>", brief: "integration brief" },
1828
1835
  verifier: { slotId: "<selected slot>", agentReleaseId: "<selected release>", brief: "verification brief", criteria: ["criterion"] },
@@ -1863,13 +1870,14 @@ function buildPrompts(task, identity) {
1863
1870
  const plannerSchemaRequirements = [
1864
1871
  `Return exactly one object: ${stableJson(plannerShape)}`,
1865
1872
  "Return agentlas.workforce-orchestration-plan.v2 with exactly delegationPlan and capabilityBindingPlan. Copy plannerInvocationId, executionContextDigest, and toolInventoryDigest exactly from PLANNER_LINEAGE_DATA. The host computes bindingPlanDigest after validating your choices; do not emit bindingPlanDigest.",
1866
- "Create exactly one delegationPlan packet for every accepted slot/release pair. Every packet must explicitly author packetId, slotId, agentReleaseId, objective, inputs, and expectedOutput.",
1873
+ "Create exactly one delegationPlan packet for every accepted slot/release pair. Every packet must explicitly author packetId, slotId, agentReleaseId, objective, inputs, expectedOutput, and doneWhen.",
1874
+ "doneWhen is that packet's acceptance checklist: 1..16 conditions, each independently checkable as true or false from the worker's returned handoff alone (name concrete artifacts, fields, counts, or observable facts — never vibes like 'high quality'). State the goal and required results, but do not over-specify the worker's method or ordering. The verifier receives every packet's doneWhen alongside its handoff.",
1867
1875
  "Choose capabilityBindingPlan.inventory only from POLICY_FILTERED_LOCAL_TOOL_MENU_DATA. Cover every requiredToolCapabilities id exactly once for each slot/release pair. One selected tool row may cover multiple capabilities. If a required capability has no exact ready tool, do not invent a binding; return the best schema-valid plan and allow deterministic validation to reject it.",
1868
1876
  "Each bound inventory row must explicitly contain slotId, agentReleaseId, permissionPolicyDigest, provider, toolId, capabilityIds, status=bound. An empty inventory is required when every slot has no required tool capability.",
1869
1877
  "synthesis must explicitly author slotId, agentReleaseId, and brief. verifier must explicitly author slotId, agentReleaseId, brief, and a non-empty criteria array. The host will not add, remove, normalize, or substitute a release or field.",
1870
1878
  // 호스트가 강제하는 상한을 미리 알려준다 — 알려주지 않은 상한은 첫 시도를 반드시
1871
1879
  // 깨고 교정 1회로도 회복되지 않는다(2026-07-27 라이브 실측, 중첩 매니저 동일 계열).
1872
- "Objectives, inputs, expectedOutput, and briefs have no character limit — write them as long as the work honestly needs. Only counts are bounded: at most 64 inputs per packet and at most 32 verifier criteria of at most 450 characters each.",
1880
+ "Objectives, inputs, expectedOutput, and briefs have no character limit — write them as long as the work honestly needs. Only counts are bounded: at most 64 inputs per packet, 1..16 doneWhen conditions of at most 500 characters each, and at most 32 verifier criteria of at most 450 characters each.",
1873
1881
  ].join("\n");
1874
1882
  return {
1875
1883
  searchSystem: [
@@ -2012,6 +2020,71 @@ function create(deps = {}) {
2012
2020
  };
2013
2021
  }
2014
2022
 
2023
+ // ── 격리 고지 + 토큰 계측 ───────────────────────────────────────────────
2024
+ // 실행마다 어느 단계가 어느 런타임에서 얼마의 토큰을 썼는지 모은다. 새는 곳을
2025
+ // 추측하지 않고 보기 위한 장부다 — 2026-07-28 실측에서 codex 리더가 사소한
2026
+ // 프롬프트 하나에 입력 18,235 토큰을 실었고, 그 원인(스킬 라이브러리 전량 적재)은
2027
+ // 합계를 보기 전까지 아무도 몰랐다.
2028
+ const isolationNotices = new Map();
2029
+ const tokenLedger = [];
2030
+ // ui 는 실행 컨텍스트에만 있으므로 여기서는 버퍼에 모으고, 영수증 시점에 낸다.
2031
+ function noteIsolationWeakness(kind, role) {
2032
+ const key = `${kind}:${role || "stage"}`;
2033
+ if (isolationNotices.has(key)) return;
2034
+ isolationNotices.set(key, `${kind}(${role || "stage"} 단계)`);
2035
+ }
2036
+ /**
2037
+ * 단계별 토큰 장부를 사람이 읽는 표로 낸다.
2038
+ *
2039
+ * 총합만 보면 "많이 썼다"밖에 모른다. 어느 단계가, 어느 런타임에서, 호출 하나당
2040
+ * 얼마를 실었는지를 나란히 놓아야 새는 곳이 보인다 — 입력이 출력보다 자릿수로
2041
+ * 크면 그건 작업이 아니라 적재다.
2042
+ */
2043
+ function reportTokenLedger(ui) {
2044
+ if (!tokenLedger.length && !isolationNotices.size) return;
2045
+ const byStage = new Map();
2046
+ for (const row of tokenLedger) {
2047
+ const key = `${row.role}·${row.runtime}${row.model ? `/${row.model}` : ""}`;
2048
+ const acc = byStage.get(key) || { calls: 0, input: 0, output: 0, cached: 0 };
2049
+ acc.calls += 1; acc.input += row.input; acc.output += row.output; acc.cached += row.cached;
2050
+ byStage.set(key, acc);
2051
+ }
2052
+ const totalIn = tokenLedger.reduce((sum, row) => sum + row.input, 0);
2053
+ const totalOut = tokenLedger.reduce((sum, row) => sum + row.output, 0);
2054
+ for (const label of isolationNotices.values()) {
2055
+ ui.warn(
2056
+ `격리 고지: ${label}는 도구 인벤토리가 비었음을 증명하지 못합니다. 도구 호출은 차단되지만 `
2057
+ + "이 런타임은 호스트의 스킬/플러그인 이름을 컨텍스트에 싣습니다(그래서 입력 토큰도 큽니다). "
2058
+ + "강한 격리가 필요하면 그 단계를 claude-code로 배정하세요.",
2059
+ );
2060
+ }
2061
+ ui.line("");
2062
+ ui.info(`token ledger — 입력 ${totalIn.toLocaleString()} / 출력 ${totalOut.toLocaleString()} · 호출 ${tokenLedger.length}건`);
2063
+ const rows = [...byStage.entries()].sort((a, b) => b[1].input - a[1].input);
2064
+ for (const [key, acc] of rows) {
2065
+ const perCall = Math.round(acc.input / Math.max(1, acc.calls));
2066
+ const share = totalIn ? Math.round((acc.input / totalIn) * 100) : 0;
2067
+ ui.line(
2068
+ ` ${key.padEnd(34)} 호출 ${String(acc.calls).padStart(2)} · 입력 ${String(acc.input).padStart(8)}`
2069
+ + ` (${String(share).padStart(2)}%, 호출당 ${perCall.toLocaleString()}) · 출력 ${acc.output}`,
2070
+ );
2071
+ }
2072
+ // 입력이 출력의 100배를 넘는 단계는 일이 아니라 적재를 하고 있다.
2073
+ for (const [key, acc] of rows) {
2074
+ if (acc.output > 0 && acc.input / acc.output > 100) {
2075
+ ui.warn(`토큰 누수 의심: ${key} — 입력이 출력의 ${Math.round(acc.input / acc.output)}배. 컨텍스트 적재를 확인하세요.`);
2076
+ }
2077
+ }
2078
+ }
2079
+
2080
+ function recordStageTokens(role, runtimeKind, modelPin, usage) {
2081
+ if (!usage) return;
2082
+ const input = Number(usage.inputTokens ?? usage.input_tokens ?? 0) || 0;
2083
+ const output = Number(usage.outputTokens ?? usage.output_tokens ?? 0) || 0;
2084
+ const cached = Number(usage.cachedInputTokens ?? usage.cached_input_tokens ?? 0) || 0;
2085
+ tokenLedger.push({ role: role || "stage", runtime: runtimeKind || "?", model: modelPin || null, input, output, cached });
2086
+ }
2087
+
2015
2088
  async function runModel(runtime, system, prompt, context) {
2016
2089
  const invocation = stageInvocation(runtime, context);
2017
2090
  const executionRuntime = invocation.executionRuntime;
@@ -2041,19 +2114,32 @@ function create(deps = {}) {
2041
2114
  }
2042
2115
  if (executionRuntime.mode === "cli") {
2043
2116
  const authorityMode = context.authorityMode || "no-authority";
2044
- if (executionRuntime.kind === "codex" && authorityMode === "no-authority") {
2045
- fail(
2046
- "workforce_runtime_isolation_unverified",
2047
- "Codex CLI workforce execution is blocked until this host proves an empty built-in, collaboration, and MCP tool inventory; feature-disable flags and an isolated CODEX_HOME are not sufficient proof",
2048
- );
2049
- }
2050
- if (executionRuntime.kind === "gemini" && authorityMode === "no-authority") {
2051
- fail(
2052
- "workforce_runtime_isolation_unverified",
2053
- "Gemini CLI workforce execution is blocked until this host proves an empty built-in and MCP tool inventory",
2054
- );
2117
+ // 격리 강도는 런타임마다 다르다. claude-code는 `--tools ""`로 도구 인벤토리가
2118
+ // 비었음을 증명할 수 있고, codex/gemini는 못 한다 — 2026-07-28 실측: 모든
2119
+ // 격리 플래그(--ephemeral --ignore-user-config --ignore-rules --disable
2120
+ // plugins/tool_suggest/...)를 codex가 사용자의 개인 스킬 이름을 전부
2121
+ // 열거했고 사소한 프롬프트에 입력 18,235 토큰을 실었다.
2122
+ //
2123
+ // 그런데 그걸 이유로 실행을 통째로 거부하면 런타임을 오케스트레이터로
2124
+ // 고른 사용자는 네트워크 전체를 잃는다. 실제로 새는 것은 "스킬 이름 목록"이고,
2125
+ // 그것도 사용자 본인 기계에서 본인이 시작한 실행이다 — 도구 호출은 여전히
2126
+ // 막혀 있다. 비례가 맞지 않는 차단이었고, 우회 수단조차 없었다.
2127
+ //
2128
+ // 그래서 거부 대신 고지한다: 무엇이 격리되지 않는지 이름을 대고, 실행은 한다.
2129
+ // 강한 격리가 필요한 호스트는 AGENTLAS_WORKFORCE_REQUIRE_PROVEN_ISOLATION=1로
2130
+ // 예전 동작(거부)을 되찾을 수 있다.
2131
+ const provenIsolation = executionRuntime.kind === "claude-code";
2132
+ if (!provenIsolation && authorityMode === "no-authority") {
2133
+ if (String(process.env.AGENTLAS_WORKFORCE_REQUIRE_PROVEN_ISOLATION || "") === "1") {
2134
+ fail(
2135
+ "workforce_runtime_isolation_unverified",
2136
+ `${executionRuntime.kind} cannot prove an empty tool inventory, and this host requires proven isolation. `
2137
+ + "Assign claude-code for this stage, or unset AGENTLAS_WORKFORCE_REQUIRE_PROVEN_ISOLATION.",
2138
+ );
2139
+ }
2140
+ noteIsolationWeakness(executionRuntime.kind, invocation.role);
2055
2141
  }
2056
- return normalizeModelResult(await D.captureRuntime(executionRuntime.kind, effectiveSystem, prompt, {
2142
+ const captured = normalizeModelResult(await D.captureRuntime(executionRuntime.kind, effectiveSystem, prompt, {
2057
2143
  cwd: context.cwd,
2058
2144
  env: context.env,
2059
2145
  permission: context.permission,
@@ -2066,14 +2152,18 @@ function create(deps = {}) {
2066
2152
  outputLimitBytes: authorityMode === "read-only" ? 24 * 1024 * 1024 : undefined,
2067
2153
  envelope: true,
2068
2154
  }));
2155
+ recordStageTokens(invocation.role, executionRuntime.kind, invocation.modelPin, captured.usage);
2156
+ return captured;
2069
2157
  }
2070
- return normalizeModelResult(await D.runApi(
2158
+ const viaApi = normalizeModelResult(await D.runApi(
2071
2159
  executionRuntime.backend,
2072
2160
  invocation.modelPin,
2073
2161
  effectiveSystem,
2074
2162
  prompt,
2075
2163
  { effort: invocation.effort, envelope: true },
2076
2164
  ));
2165
+ recordStageTokens(invocation.role, executionRuntime.backend, invocation.modelPin, viaApi.usage);
2166
+ return viaApi;
2077
2167
  }
2078
2168
 
2079
2169
  async function callHubTool(name, args) {
@@ -3331,7 +3421,10 @@ function create(deps = {}) {
3331
3421
  }
3332
3422
 
3333
3423
  const slotById = new Map(workOrder.roleSlots.map((slot) => [slot.slotId, slot]));
3334
- const concurrency = Math.max(1, Math.min(8, Number(ctx.concurrency) || 3));
3424
+ // 사용자가 --parallel/-n을 명시하면 그 값(상한만 적용), 아니면 사양 기반 추천값.
3425
+ const concurrency = Number.isFinite(Number(ctx.concurrency)) && Number(ctx.concurrency) > 0
3426
+ ? Math.max(1, Math.min(8, Number(ctx.concurrency)))
3427
+ : recommendedConcurrency();
3335
3428
  let cursor = 0;
3336
3429
  const outputs = new Array(delegationPlan.packets.length);
3337
3430
  const publicWorkers = new Array(delegationPlan.packets.length);
@@ -3680,6 +3773,9 @@ function create(deps = {}) {
3680
3773
  `PINNED_PACKAGE_HASH=${pinned.packageHash}`,
3681
3774
  `PINNED_CONTENT_DIGEST=${pinned.contentDigest}`,
3682
3775
  "Do only your packet. Do not select or summon another agent. Return a concrete handoff artifact for the manager.",
3776
+ // 산출물과 한계·상태를 분리해야 검증자가 판정할 근거가 생긴다(위임
3777
+ // 계약 7요소 중 상태·증거). COMPLETED는 워커의 주장일 뿐이다.
3778
+ "End your handoff with two labeled sections: LIMITATIONS (what you could not verify or complete — write 'none' only if truly none) and STATUS (COMPLETED, PARTIAL, or FAILED; for PARTIAL/FAILED name each unmet doneWhen condition from your packet). Claiming COMPLETED does not finish the run — a pinned verifier accepts or rejects your claim.",
3683
3779
  ].join("\n\n"),
3684
3780
  prompt: stableJson({
3685
3781
  sharedTask: workOrder.taskBrief,
@@ -3749,6 +3845,11 @@ function create(deps = {}) {
3749
3845
  system: [
3750
3846
  pinned.executionGraph.manager.content,
3751
3847
  "You are the pinned manager synthesizing every declared worker handoff. Do not omit a worker or claim an undeclared worker ran.",
3848
+ // 팀 합성물은 최상위 패킷의 핸드오프가 된다 — 직접 워커와 동일한 반환
3849
+ // 계약을 적용해야 검증자가 판정 근거를 얻는다. (2026-07-28 라이브 A/B
3850
+ // 실측: 최상위 패킷 2개가 모두 중첩 팀이라 이 요구가 어디에도 적용되지
3851
+ // 않았다 — 직접 워커 경로에만 넣은 커버리지 갭.)
3852
+ "End your synthesis with two labeled sections: LIMITATIONS (what the team could not verify or complete — write 'none' only if truly none) and STATUS (COMPLETED, PARTIAL, or FAILED; for PARTIAL/FAILED name each unmet doneWhen condition from the parent packet). Claiming COMPLETED does not finish the run — a pinned verifier accepts or rejects the claim.",
3752
3853
  ].join("\n\n"),
3753
3854
  prompt: stableJson({ parentPacket: packet, synthesisBrief: manager.plan.synthesisBrief, handoffs: graphWorkerOutputs.map((row) => ({ id: row.graphWorker.id, text: row.text })) }),
3754
3855
  });
@@ -4108,14 +4209,25 @@ function create(deps = {}) {
4108
4209
  system: [
4109
4210
  pinned.instructions,
4110
4211
  "VERIFIER ESCALATION MODE: this exact worker packet was identified as failed by two independent verifier rounds. You are the single allowed orchestrator retry for this packet. Produce one replacement handoff, preserve the packet scope and pinned release, and do not delegate or retry again.",
4212
+ // 수정 지시는 전체 재작성 지시가 아니다 — 통과분(preserve)을 명시하지
4213
+ // 않으면 정확했던 부분이 재작성 과정에서 손상된다(위임 계약 수정 규칙).
4214
+ "Repair, do not rewrite from scratch: preservedChecks lists verifier checks that already PASSED — keep the prior handoff's content that satisfied them and do not regress it. defects lists what failed — change only what those defects require. End with the same LIMITATIONS and STATUS sections required of every worker handoff.",
4111
4215
  ].join("\n\n"),
4112
4216
  prompt: stableJson({
4113
4217
  sharedTask: workOrder.taskBrief,
4114
4218
  roleSlot: slotById.get(packet.slotId),
4115
4219
  packet,
4116
4220
  priorHandoff: output.text,
4117
- verifierFailures: receipt.correctiveHistory.map((row) => ({
4221
+ preservedChecks: receipt.correctiveHistory.flatMap((row) =>
4222
+ (row.verification.checks || [])
4223
+ .filter((check) => check.status === "passed")
4224
+ .map((check) => ({ checkId: check.checkId, evidence: check.evidence })),
4225
+ ),
4226
+ defects: receipt.correctiveHistory.map((row) => ({
4118
4227
  issues: row.verification.issues,
4228
+ failedChecks: (row.verification.checks || [])
4229
+ .filter((check) => check.status === "failed")
4230
+ .map((check) => ({ checkId: check.checkId, evidence: check.evidence })),
4119
4231
  failedPacketIds: row.verification.failedPacketIds,
4120
4232
  })),
4121
4233
  }),
@@ -4316,6 +4428,7 @@ function create(deps = {}) {
4316
4428
  ui.line("");
4317
4429
  ui.markdown(finalText);
4318
4430
  ui.info(`workforce receipt: ${runId} · roster ${receipt.workers.length}/${delegationPlan.packets.length} · verifier passed`);
4431
+ reportTokenLedger(ui);
4319
4432
  if (benchmarkArtifactPath) ui.info(`workforce benchmark artifacts: ${benchmarkArtifactPath}`);
4320
4433
  }
4321
4434
  return {
@@ -4368,6 +4481,10 @@ function create(deps = {}) {
4368
4481
  for (const issue of issues.slice(0, 16)) ui.error(` - ${String(issue).slice(0, 400)}`);
4369
4482
  if (issues.length > 16) ui.error(` … ${issues.length - 16} more issues in the persisted receipt`);
4370
4483
  }
4484
+ // 실패한 실행이야말로 토큰이 어디로 갔는지 알아야 하는 순간이다. issues 유무와
4485
+ // 무관하게 낸다 — 첫 배선이 이 블록 안에 들어가는 바람에 issues 없는 실패에서는
4486
+ // 장부가 통째로 사라졌다.
4487
+ reportTokenLedger(ui);
4371
4488
  }
4372
4489
  return { ok: false, error: receipt.failure, receipt, benchmarkArtifactPath };
4373
4490
  }