@coopcli/specsketch 2026.826.3 → 2026.831.1

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 (106) hide show
  1. package/dist/cli/index.js +967 -1560
  2. package/dist/client/assets/abnfDiagram-VCTEODGH-BRSPbDe2.js +1 -0
  3. package/dist/client/assets/arc-CydpUA4x.js +1 -0
  4. package/dist/client/assets/architecture-7GRP2DOG-DN-gDuKL.js +1 -0
  5. package/dist/client/assets/architectureDiagram-5GKGNRK7-BnAWQ9BF.js +36 -0
  6. package/dist/client/assets/array-BifhSqXX.js +1 -0
  7. package/dist/client/assets/blockDiagram-I7D4REHJ-BLfEm7nQ.js +129 -0
  8. package/dist/client/assets/c4Diagram-7LVT6UL2-BiGdyDb3.js +38 -0
  9. package/dist/client/assets/channel-CViH2Cbt.js +1 -0
  10. package/dist/client/assets/chunk-2E4U76K2-B7jDhluq.js +2 -0
  11. package/dist/client/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  12. package/dist/client/assets/chunk-4HAMMTFA-CeU3f92U.js +62 -0
  13. package/dist/client/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  14. package/dist/client/assets/chunk-75Z2AOVW-4JCVdQWY.js +2 -0
  15. package/dist/client/assets/chunk-CLGD4ZFX-BOVbR6kW.js +1 -0
  16. package/dist/client/assets/chunk-DU6HZSFF-CIXLDUI_.js +127 -0
  17. package/dist/client/assets/chunk-F27PBJKO-B5GmXBLb.js +1 -0
  18. package/dist/client/assets/chunk-FOHPRMQF-CyTuVbu3.js +161 -0
  19. package/dist/client/assets/chunk-GMAD6QVW-D6jp_rLd.js +72 -0
  20. package/dist/client/assets/chunk-GVQU2GXP-BSS01C8s.js +1 -0
  21. package/dist/client/assets/chunk-IMKFNOWR-RYwZN0Xs.js +231 -0
  22. package/dist/client/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  23. package/dist/client/assets/chunk-L3NEJ4N5-D0rNlikd.js +1 -0
  24. package/dist/client/assets/chunk-LNGE3PJU-DNNNwF8Y.js +34 -0
  25. package/dist/client/assets/chunk-OSK3NFVY-CxtLTZDw.js +10 -0
  26. package/dist/client/assets/chunk-P2QGCYS3-CYiUdWzq.js +1 -0
  27. package/dist/client/assets/chunk-POPQ4Y6H-DgkPLssL.js +1 -0
  28. package/dist/client/assets/chunk-PWAF6VOD-Db4-4OsA.js +1 -0
  29. package/dist/client/assets/chunk-SHT3W25Y-Ji-xYakZ.js +168 -0
  30. package/dist/client/assets/chunk-SVP7TREG-cmFzvno3.js +88 -0
  31. package/dist/client/assets/chunk-TICWLB2K-Cx1Qs9wQ.js +206 -0
  32. package/dist/client/assets/chunk-TLUHSLCS-BLXY086M.js +2 -0
  33. package/dist/client/assets/chunk-XXDRQBXY--jdiETSF.js +1 -0
  34. package/dist/client/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  35. package/dist/client/assets/classDiagram-ZZMXUADV-BFPBK_Z1.js +1 -0
  36. package/dist/client/assets/classDiagram-v2-VYDZK3BY-BFPBK_Z1.js +1 -0
  37. package/dist/client/assets/cose-bilkent-JH36ORCC-Ba6yLkVt.js +1 -0
  38. package/dist/client/assets/cynefin-OW5HDTMX-ra_KnFP6.js +1 -0
  39. package/dist/client/assets/cynefinDiagram-5FMLGOSQ-Ct-LNR_1.js +62 -0
  40. package/dist/client/assets/cytoscape.esm-B3I8pqwA.js +321 -0
  41. package/dist/client/assets/dagre-BJJUWK_n.js +1 -0
  42. package/dist/client/assets/dagre-GXQ25YYZ-DJi473sw.js +4 -0
  43. package/dist/client/assets/defaultLocale-C8Fc0cco.js +1 -0
  44. package/dist/client/assets/diagram-S7CK7UJ4-kMRGU2Fv.js +30 -0
  45. package/dist/client/assets/diagram-UQ7AKVKN-YvBZDAJN.js +41 -0
  46. package/dist/client/assets/diagram-VSXAHHWV-DDK4yWa2.js +3 -0
  47. package/dist/client/assets/diagram-VX7I27RA-DGg18jEl.js +24 -0
  48. package/dist/client/assets/diagram-Z3DM3KII-i5D_IxwV.js +24 -0
  49. package/dist/client/assets/dist-fh2zfMMY.js +1 -0
  50. package/dist/client/assets/ebnfDiagram-PWID7BFC-lD2h9EBl.js +1 -0
  51. package/dist/client/assets/erDiagram-RLTQ6QDP-xW2abP7i.js +99 -0
  52. package/dist/client/assets/eventmodeling-NTZA5JFV-DNmj4472.js +1 -0
  53. package/dist/client/assets/flowDiagram-HODETNUW-Df4CErRm.js +1 -0
  54. package/dist/client/assets/ganttDiagram-EL5Y4UJY-BeymYKLD.js +292 -0
  55. package/dist/client/assets/gitGraph-4MIJSDKK-BEWLgvm2.js +1 -0
  56. package/dist/client/assets/gitGraphDiagram-WWUBYQGX-BUDPc-8k.js +106 -0
  57. package/dist/client/assets/graphlib-B8gBHxth.js +1 -0
  58. package/dist/client/assets/index-5LnLmZOs.css +1 -0
  59. package/dist/client/assets/index-BWHy8TXM.js +1549 -0
  60. package/dist/client/assets/info-A6RAGUB7-CKfM1Jkg.js +1 -0
  61. package/dist/client/assets/infoDiagram-27XIBGKW-Ad-Gyaye.js +2 -0
  62. package/dist/client/assets/init-D6jRqBbL.js +1 -0
  63. package/dist/client/assets/ishikawaDiagram-5VMMS53U-KbTiiaHC.js +70 -0
  64. package/dist/client/assets/journeyDiagram-3NMN7TZE-CN2MkzPK.js +139 -0
  65. package/dist/client/assets/kanban-definition-UXKFOSKX-DhEzOLN4.js +89 -0
  66. package/dist/client/assets/katex-CddkPoXu.js +257 -0
  67. package/dist/client/assets/line-B90f4k6X.js +1 -0
  68. package/dist/client/assets/linear-CW7Oi12O.js +1 -0
  69. package/dist/client/assets/mermaid-parser.core-C9nLZzv4.js +7 -0
  70. package/dist/client/assets/mermaid.core-DQoThCaK.js +11 -0
  71. package/dist/client/assets/mindmap-definition-YA3MSWOX-J048QoEO.js +96 -0
  72. package/dist/client/assets/ordinal-hYBb2elL.js +1 -0
  73. package/dist/client/assets/packet-AYTQ26CC-BKj_BZVA.js +1 -0
  74. package/dist/client/assets/path-BWPyau1x.js +1 -0
  75. package/dist/client/assets/pegDiagram-XKGWAZYB-DG0kAfWm.js +1 -0
  76. package/dist/client/assets/pie-WAS4IAKB-DE_LVWU7.js +1 -0
  77. package/dist/client/assets/pieDiagram-E7YTZNPT-Z3FhwRXc.js +39 -0
  78. package/dist/client/assets/quadrantDiagram-AXDQQJYC-Dw3TFUlr.js +7 -0
  79. package/dist/client/assets/radar-RG4KPBEZ-BtwXXEfu.js +1 -0
  80. package/dist/client/assets/railroad-74A4TZTK-CBKff9R-.js +1 -0
  81. package/dist/client/assets/railroad-abnf-HS5TGJTU-Bv7gdzGQ.js +1 -0
  82. package/dist/client/assets/railroad-ebnf-LZEXJU2U-CCPDJ5pu.js +1 -0
  83. package/dist/client/assets/railroad-peg-WCYAUIDC-PBEP962D.js +1 -0
  84. package/dist/client/assets/railroadDiagram-O6MQD6OU-Cf_GxnJ6.js +1 -0
  85. package/dist/client/assets/requirementDiagram-BXWQKSXE-DeSfKgO1.js +84 -0
  86. package/dist/client/assets/rough.esm-CSKSodPl.js +1 -0
  87. package/dist/client/assets/sankeyDiagram-P5KCCOFB-ZJbsUmhM.js +40 -0
  88. package/dist/client/assets/sequenceDiagram-WJ2MYXX4-CZV-uNwY.js +162 -0
  89. package/dist/client/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
  90. package/dist/client/assets/src-CKIr8zwz.js +1 -0
  91. package/dist/client/assets/stateDiagram-D77RDMKH-S2gUXjCW.js +1 -0
  92. package/dist/client/assets/stateDiagram-v2-MP3YSRHH-BEJXWTYJ.js +1 -0
  93. package/dist/client/assets/swimlanes-42K2YHIH-D9K5izOo.js +1 -0
  94. package/dist/client/assets/swimlanesDiagram-VR7AAH4N-ze5UMZmc.js +8 -0
  95. package/dist/client/assets/timeline-definition-24CTP7MA-o1QNGpc1.js +120 -0
  96. package/dist/client/assets/treeView-Q6P3EWNA-shNxWNdU.js +1 -0
  97. package/dist/client/assets/treemap-WGGIJYW6-CTnOzBS1.js +1 -0
  98. package/dist/client/assets/vennDiagram-4TSXK5OY-C74kYmI6.js +34 -0
  99. package/dist/client/assets/wardley-WFR3VGLG-wa25fBU8.js +1 -0
  100. package/dist/client/assets/wardleyDiagram-VM6X3IG4-DMCFZZJI.js +78 -0
  101. package/dist/client/assets/xychartDiagram-S5SC5T6Z-CZzaBxU9.js +7 -0
  102. package/dist/client/index.html +2 -2
  103. package/package.json +79 -87
  104. package/dist/client/assets/MarkdownEditorImpl-B7fANsTl.js +0 -16
  105. package/dist/client/assets/index-CLLS_dcT.css +0 -1
  106. package/dist/client/assets/index-DzRQXC_d.js +0 -197
package/dist/cli/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // server/cli.ts
4
- import { existsSync as existsSync9, readFileSync as readFileSync9, readdirSync as readdirSync8 } from "node:fs";
4
+ import { existsSync as existsSync10, readFileSync as readFileSync8, readdirSync as readdirSync8 } from "node:fs";
5
5
  import { homedir as homedir4 } from "node:os";
6
- import { dirname as dirname6, extname, join as join11, normalize as normalize2, resolve as resolve4 } from "node:path";
6
+ import { dirname as dirname6, extname, join as join12, normalize as normalize2, resolve as resolve4 } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { parseArgs as parseArgs2 } from "node:util";
9
9
  import { serve } from "@hono/node-server";
@@ -16,6 +16,20 @@ import { join } from "node:path";
16
16
  var ModelAuthError = class extends Error {
17
17
  };
18
18
  var API_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
19
+ var THIRD_PARTY_ROUTING_KEYS = [
20
+ "CLAUDE_CODE_USE_BEDROCK",
21
+ "CLAUDE_CODE_USE_VERTEX",
22
+ "CLAUDE_CODE_USE_FOUNDRY",
23
+ "CLAUDE_CODE_USE_MANTLE",
24
+ "CLAUDE_CODE_USE_GATEWAY",
25
+ "CLAUDE_CODE_USE_ANTHROPIC_AWS",
26
+ "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD",
27
+ "ANTHROPIC_BASE_URL"
28
+ ];
29
+ var SUBSCRIPTION_STRIPPED_ENV_KEYS = [
30
+ ...API_ENV_KEYS,
31
+ ...THIRD_PARTY_ROUTING_KEYS
32
+ ];
19
33
  function hasAntAuthProfile(home) {
20
34
  try {
21
35
  return readdirSync(join(home, ".config", "anthropic", "credentials")).some(
@@ -40,7 +54,7 @@ function resolveModelCredential(opts = {}) {
40
54
  }
41
55
  if (hasSubscription) {
42
56
  const subprocessEnv = { ...env };
43
- for (const k of API_ENV_KEYS) delete subprocessEnv[k];
57
+ for (const k of SUBSCRIPTION_STRIPPED_ENV_KEYS) delete subprocessEnv[k];
44
58
  return {
45
59
  path: "subscription",
46
60
  billsApiCredits: false,
@@ -117,9 +131,38 @@ function destructiveBashHook(profile) {
117
131
  ]
118
132
  };
119
133
  }
120
- function* mapMessage(message, expectStructuredOutput) {
134
+ var KNOWN_CATEGORIES = {
135
+ authentication_failed: true,
136
+ oauth_org_not_allowed: true,
137
+ billing_error: true,
138
+ rate_limit: true,
139
+ overloaded: true,
140
+ invalid_request: true,
141
+ model_not_found: true,
142
+ server_error: true,
143
+ unknown: true,
144
+ max_output_tokens: true
145
+ };
146
+ var ABSENT_CATEGORY = "(none reported)";
147
+ function classifyFailure(category) {
148
+ if (typeof category !== "string" || category === "") {
149
+ return { category: ABSENT_CATEGORY, recognised: false };
150
+ }
151
+ return { category, recognised: Object.hasOwn(KNOWN_CATEGORIES, category) };
152
+ }
153
+ function* mapMessage(message, ctx) {
121
154
  switch (message.type) {
122
155
  case "system": {
156
+ if (message.subtype === "api_retry") {
157
+ yield {
158
+ type: "retry",
159
+ failure: classifyFailure(message.error),
160
+ attempt: message.attempt,
161
+ maxRetries: message.max_retries,
162
+ retryDelayMs: message.retry_delay_ms
163
+ };
164
+ return;
165
+ }
123
166
  if (message.subtype === "init") {
124
167
  yield {
125
168
  type: "start",
@@ -131,8 +174,11 @@ function* mapMessage(message, expectStructuredOutput) {
131
174
  }
132
175
  case "assistant": {
133
176
  const assistant = message;
177
+ if (assistant.error !== void 0) ctx.lastFailure = classifyFailure(assistant.error);
134
178
  for (const block of assistant.message.content) {
135
179
  if (block.type === "text") {
180
+ ctx.partialText += ctx.partialText ? `
181
+ ${block.text}` : block.text;
136
182
  yield { type: "text", text: block.text };
137
183
  } else if (block.type === "thinking") {
138
184
  yield { type: "thinking", text: block.thinking };
@@ -160,20 +206,37 @@ function* mapMessage(message, expectStructuredOutput) {
160
206
  }
161
207
  case "result": {
162
208
  if (message.subtype === "success") {
163
- const missingStructured = expectStructuredOutput && message.structured_output === void 0;
209
+ const missingStructured = ctx.expectStructuredOutput && message.structured_output === void 0;
210
+ const isError = message.is_error || missingStructured;
164
211
  yield {
165
212
  type: "result",
166
213
  text: message.result,
167
214
  costUsd: message.total_cost_usd,
168
- isError: message.is_error || missingStructured,
215
+ isError,
216
+ outcome: isError ? "failed" : "answered",
217
+ turns: message.num_turns,
218
+ ...isError && ctx.lastFailure ? { failure: ctx.lastFailure } : {},
169
219
  ...message.structured_output !== void 0 ? { structuredOutput: message.structured_output } : {}
170
220
  };
221
+ } else if (message.subtype === "error_max_turns") {
222
+ yield {
223
+ type: "result",
224
+ text: ctx.partialText || message.errors.join("\n"),
225
+ costUsd: message.total_cost_usd,
226
+ isError: true,
227
+ outcome: "cut_short",
228
+ turns: message.num_turns,
229
+ ...ctx.lastFailure ? { failure: ctx.lastFailure } : {}
230
+ };
171
231
  } else {
172
232
  yield {
173
233
  type: "result",
174
234
  text: message.errors.join("\n"),
175
235
  costUsd: message.total_cost_usd,
176
- isError: true
236
+ isError: true,
237
+ outcome: "failed",
238
+ turns: message.num_turns,
239
+ ...ctx.lastFailure ? { failure: ctx.lastFailure } : {}
177
240
  };
178
241
  }
179
242
  return;
@@ -206,6 +269,12 @@ function buildOptions(input, resolved) {
206
269
  // path, so the subprocess cannot prefer an API key over the login.
207
270
  env: resolved.subprocessEnv
208
271
  };
272
+ if (input.systemPrompt !== void 0) {
273
+ options.systemPrompt = input.systemPrompt;
274
+ }
275
+ if (input.maxTurns !== void 0) {
276
+ options.maxTurns = input.maxTurns;
277
+ }
209
278
  if (profile.disallowedTools) {
210
279
  options.disallowedTools = profile.disallowedTools;
211
280
  }
@@ -261,14 +330,17 @@ function createClaudeHarness(deps) {
261
330
  const notify = input.onNotice ?? ((message) => console.error(message));
262
331
  notify(resolved.notice);
263
332
  const queryFn = deps?.queryFn ?? await loadSdkQuery(deps?.sdkLoader);
264
- const expectStructuredOutput = input.outputSchema !== void 0;
333
+ const ctx = {
334
+ expectStructuredOutput: input.outputSchema !== void 0,
335
+ partialText: ""
336
+ };
265
337
  try {
266
338
  const stream = queryFn({ prompt: input.prompt, options: buildOptions(input, resolved) });
267
339
  for await (const message of stream) {
268
340
  if (message.type === "system" && message.subtype === "init") {
269
341
  verifyBilling(resolved, message.apiKeySource, notify);
270
342
  }
271
- yield* mapMessage(message, expectStructuredOutput);
343
+ yield* mapMessage(message, ctx);
272
344
  }
273
345
  } catch (error) {
274
346
  if (isSdkAbsence(error)) throw new Error(SDK_UNAVAILABLE_MESSAGE, { cause: error });
@@ -334,6 +406,17 @@ function lifeOSProfile(identity, mcpServers) {
334
406
  }
335
407
  return profile;
336
408
  }
409
+ function readMostlyProfile(identity) {
410
+ return {
411
+ allowedTools: ["Read", "Grep", "Glob", "WebFetch", "WebSearch"],
412
+ gatedTools: [...SIDE_EFFECT_GATED_TOOLS],
413
+ allowGated: false,
414
+ blockedBashPatterns: blockedBashPatterns(),
415
+ permissionMode: "default",
416
+ settingSources: [],
417
+ identity
418
+ };
419
+ }
337
420
 
338
421
  // server/cli.ts
339
422
  import "ws";
@@ -964,9 +1047,12 @@ ${refContext}` : "");
964
1047
  let resultError = false;
965
1048
  try {
966
1049
  for await (const ev of harness.run({
967
- prompt: `${system}
968
-
969
- ${prompt}`,
1050
+ // System instructions ride their OWN channel — the harness
1051
+ // forwards them to the SDK's system-prompt option. They are
1052
+ // never joined to the prompt (they used to be, because
1053
+ // HarnessRunInput had nowhere else to put them).
1054
+ systemPrompt: system,
1055
+ prompt,
970
1056
  cwd: refRoot ?? process.cwd(),
971
1057
  session: workspace?.changeName ?? "specsketch-generate",
972
1058
  profile: generateProfile,
@@ -1040,9 +1126,7 @@ ${prompt}`,
1040
1126
  const refContext = renderReferenceContext(refs);
1041
1127
  const history = conversation.map((m) => `[${m.role === "user" ? "user" : "assistant"}]
1042
1128
  ${m.text}`).join("\n\n");
1043
- const prompt = `${CHAT_SYSTEM}
1044
-
1045
- ${renderBundleContext(bundle)}` + (history ? `
1129
+ const prompt = `${renderBundleContext(bundle)}` + (history ? `
1046
1130
 
1047
1131
  Conversation so far:
1048
1132
 
@@ -1059,6 +1143,7 @@ ${refContext}` : "");
1059
1143
  let resultError = false;
1060
1144
  try {
1061
1145
  for await (const ev of harness.run({
1146
+ systemPrompt: CHAT_SYSTEM,
1062
1147
  prompt,
1063
1148
  cwd: refRoot ?? process.cwd(),
1064
1149
  session: workspace?.changeName ?? bundle.changeName ?? "specsketch-review",
@@ -2150,9 +2235,8 @@ var ChangeWorkspace = class {
2150
2235
  };
2151
2236
 
2152
2237
  // server/workbench-host.ts
2153
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "node:fs";
2154
- import { homedir as homedir3 } from "node:os";
2155
- import { dirname as dirname5, join as join10, resolve as resolve3 } from "node:path";
2238
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
2239
+ import { dirname as dirname5, join as join11, resolve as resolve3 } from "node:path";
2156
2240
 
2157
2241
  // src/workbench/core/capability-store.ts
2158
2242
  import { readFileSync as readFileSync4, readdirSync as readdirSync5, statSync as statSync2 } from "node:fs";
@@ -2285,7 +2369,7 @@ var SPEC_FILE = "spec.md";
2285
2369
  function validateCapabilityName(name) {
2286
2370
  if (name.length === 0) return "a capability name cannot be empty";
2287
2371
  if (!CAPABILITY_NAME.test(name)) {
2288
- return `\`${name}\` is not a capability name: expected lowercase kebab-case, e.g. \`specsketch-delta-derivation\``;
2372
+ return `\`${name}\` is not a capability name: expected lowercase kebab-case, e.g. \`specsketch-workbench-host\``;
2289
2373
  }
2290
2374
  return null;
2291
2375
  }
@@ -2332,1567 +2416,892 @@ function createFoldedSpecReader(repoRoot) {
2332
2416
  return Object.freeze({ specsDir, listCapabilities, readCapability, openCapability });
2333
2417
  }
2334
2418
 
2335
- // src/workbench/approve/approve.ts
2336
- import { execFileSync } from "node:child_process";
2337
- import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync5, rmSync, statSync as statSync3 } from "node:fs";
2338
- import { tmpdir } from "node:os";
2339
- import { join as join7 } from "node:path";
2419
+ // src/workbench/core/citation.ts
2420
+ var REQUIREMENT_HEADING2 = /^###[ \t]+Requirement:[ \t]*(.+?)[ \t]*$/gm;
2421
+ function citableRequirements(specs) {
2422
+ return specs.flatMap(
2423
+ (spec) => [...spec.content.matchAll(REQUIREMENT_HEADING2)].map((match) => ({
2424
+ capability: spec.capability,
2425
+ key: match[1]
2426
+ }))
2427
+ );
2428
+ }
2340
2429
 
2341
- // src/workbench/core/emit.ts
2342
- var PROPOSAL_SECTIONS = Object.freeze([
2343
- { id: "why", title: "Why", level: 2, required: true, skippable: false },
2344
- { id: "what-changes", title: "What Changes", level: 2, required: true, skippable: false },
2345
- { id: "system-context", title: "System context", level: 3, required: true, skippable: true },
2346
- { id: "containers", title: "Containers", level: 3, required: true, skippable: true },
2347
- { id: "components", title: "Components", level: 3, required: true, skippable: true },
2348
- { id: "code", title: "Code", level: 3, required: false, skippable: true },
2349
- { id: "impact", title: "Impact", level: 2, required: true, skippable: false }
2350
- ]);
2351
- function emptyProposal() {
2430
+ // src/workbench/agent/credentials.ts
2431
+ var CREDENTIAL_SOURCES = [
2432
+ "subscription",
2433
+ "api-key",
2434
+ "ant-auth"
2435
+ ];
2436
+ var CREDENTIAL_LABELS = {
2437
+ subscription: "Claude Code subscription login",
2438
+ "api-key": "ANTHROPIC_API_KEY",
2439
+ "ant-auth": "ant auth token"
2440
+ };
2441
+ var CREDENTIAL_BILLS = {
2442
+ subscription: "bills your Max/Pro subscription",
2443
+ "api-key": "bills API credits",
2444
+ "ant-auth": "bills API credits, NOT your subscription"
2445
+ };
2446
+ function unavailableDetail(id, reason) {
2447
+ switch (reason) {
2448
+ case "not-installed":
2449
+ return id === "subscription" ? "Claude Code is not installed on this machine" : id === "api-key" ? "ANTHROPIC_API_KEY is not set in this environment" : "no ant auth token or credentials profile is present";
2450
+ case "installed-not-authenticated":
2451
+ return id === "subscription" ? "Claude Code is installed but not logged in \u2014 run `claude login`" : id === "api-key" ? "ANTHROPIC_API_KEY is set but empty" : "an ant auth profile exists but holds no usable token \u2014 run `ant auth login`";
2452
+ case "present-but-rejected":
2453
+ return "a credential is present but the last run was rejected \u2014 investigate before reusing it";
2454
+ }
2455
+ }
2456
+ function sourceRow(id, status) {
2352
2457
  return {
2353
- why: { state: "empty" },
2354
- "what-changes": { state: "empty" },
2355
- "system-context": { state: "empty" },
2356
- containers: { state: "empty" },
2357
- components: { state: "empty" },
2358
- code: { state: "empty" },
2359
- impact: { state: "empty" }
2458
+ id,
2459
+ label: CREDENTIAL_LABELS[id],
2460
+ status,
2461
+ detail: status.kind === "available" ? CREDENTIAL_BILLS[id] : unavailableDetail(id, status.reason)
2360
2462
  };
2361
2463
  }
2362
- var DESIGN_FENCES = Object.freeze({
2363
- mermaid: "mermaid",
2364
- sketch: "specsketch-sketch"
2365
- });
2366
- function numberTasks(groups) {
2367
- return groups.map((group, g) => ({
2368
- ...group,
2369
- number: `${g + 1}`,
2370
- title: group.title,
2371
- tasks: group.tasks.map((task, t) => ({ ...task, number: `${g + 1}.${t + 1}` }))
2372
- }));
2373
- }
2374
- var SCENARIO_HEADING = /^####[ \t]+Scenario:[ \t]*(.*)$/;
2375
- var ANY_HEADING = /^#{1,6}[ \t]/;
2376
- function probe2(line) {
2377
- return line.endsWith("\r") ? line.slice(0, -1) : line;
2378
- }
2379
- function parseScenarios(block) {
2380
- const scenarios = [];
2381
- let open = null;
2382
- for (const line of block.bodyLines) {
2383
- const p = probe2(line);
2384
- const heading = SCENARIO_HEADING.exec(p);
2385
- if (heading) {
2386
- if (open) scenarios.push(open);
2387
- open = { name: (heading[1] ?? "").trim(), steps: [] };
2388
- continue;
2464
+ function resolveRailCredential(machine, selection) {
2465
+ if (selection !== null) {
2466
+ const row = machine.rows.find((r) => r.id === selection);
2467
+ if (row && row.status.kind === "available") {
2468
+ return { kind: "in-use", source: selection, reason: { kind: "explicit-selection" } };
2389
2469
  }
2390
- if (ANY_HEADING.test(p)) {
2391
- if (open) scenarios.push(open);
2392
- open = null;
2393
- continue;
2470
+ if (machine.defaultPick !== null) {
2471
+ return {
2472
+ kind: "in-use",
2473
+ source: machine.defaultPick,
2474
+ reason: { kind: "selection-unavailable", selected: selection }
2475
+ };
2394
2476
  }
2395
- if (open && p.trim() !== "") open.steps.push(p);
2477
+ return { kind: "no-model", droppedSelection: selection };
2478
+ }
2479
+ if (machine.defaultPick !== null) {
2480
+ return { kind: "in-use", source: machine.defaultPick, reason: { kind: "default-precedence" } };
2396
2481
  }
2397
- if (open) scenarios.push(open);
2398
- return scenarios;
2482
+ return { kind: "no-model", droppedSelection: null };
2399
2483
  }
2400
- function requirementProse(block) {
2401
- const prose = [];
2402
- for (const line of block.bodyLines) {
2403
- const p = probe2(line);
2404
- if (SCENARIO_HEADING.test(p) || ANY_HEADING.test(p)) break;
2405
- prose.push(p);
2406
- }
2407
- return prose.join("\n").trim();
2484
+ function sourceFromApiKeySource(apiKeySource) {
2485
+ if (apiKeySource === "none") return "subscription";
2486
+ if (apiKeySource === "oauth") return "ant-auth";
2487
+ return "api-key";
2408
2488
  }
2409
- function draftBlockOf(delta) {
2410
- return delta.operation === "REMOVED" ? null : delta.block;
2489
+ function servedSourceFromRun(selected, apiKeySource) {
2490
+ if (apiKeySource === void 0) return { kind: "unobserved" };
2491
+ const serving = sourceFromApiKeySource(apiKeySource);
2492
+ return serving === selected ? { kind: "served", source: serving } : { kind: "diverged", selected, serving };
2411
2493
  }
2412
- function deltasOf(entry) {
2413
- return entry.derivation.outcome === "derived" ? entry.derivation.deltas : [];
2494
+ function servedSentence(served) {
2495
+ switch (served.kind) {
2496
+ case "served":
2497
+ return `served by ${CREDENTIAL_LABELS[served.source]} \u2014 ${CREDENTIAL_BILLS[served.source]}`;
2498
+ case "diverged":
2499
+ return `served by ${CREDENTIAL_LABELS[served.serving]}, NOT the selected ${CREDENTIAL_LABELS[served.selected]} \u2014 ${CREDENTIAL_BILLS[served.serving]}`;
2500
+ case "unobserved":
2501
+ return "the run carried no billing signal \u2014 which credential served this turn is unknown";
2502
+ case "fixture":
2503
+ return "answered by the workbench fixture \u2014 no model served this turn";
2504
+ }
2414
2505
  }
2415
- function emitsDeltaSpec(entry) {
2416
- return deltasOf(entry).length > 0;
2506
+
2507
+ // src/workbench/agent/profile.ts
2508
+ var AGENT_RAIL_IDENTITY = "specsketch-workbench-agent";
2509
+ function agentRailProfile() {
2510
+ return readMostlyProfile(AGENT_RAIL_IDENTITY);
2417
2511
  }
2418
- function testingRows(capabilities) {
2419
- const rows = [];
2420
- for (const entry of capabilities) {
2421
- for (const delta of deltasOf(entry)) {
2422
- const block = draftBlockOf(delta);
2423
- if (!block) continue;
2424
- for (const scenario of parseScenarios(block)) {
2425
- rows.push({
2426
- index: rows.length + 1,
2427
- capability: entry.capability,
2428
- requirement: block.name,
2429
- scenario: scenario.name
2430
- });
2431
- }
2512
+
2513
+ // src/workbench/agent/credential-probe.ts
2514
+ import { existsSync as existsSync5, readdirSync as readdirSync6, statSync as statSync3 } from "node:fs";
2515
+ import { homedir as homedir3 } from "node:os";
2516
+ import { delimiter, join as join7 } from "node:path";
2517
+ function claudeOnPath(env) {
2518
+ const path = env["PATH"];
2519
+ if (path === void 0 || path === "") return false;
2520
+ for (const dir of path.split(delimiter)) {
2521
+ if (dir === "") continue;
2522
+ try {
2523
+ if (statSync3(join7(dir, "claude")).isFile()) return true;
2524
+ } catch {
2432
2525
  }
2433
2526
  }
2434
- return rows;
2527
+ return false;
2435
2528
  }
2436
- function citableRequirements(capabilities) {
2437
- const citable = [];
2438
- for (const entry of capabilities) {
2439
- for (const delta of deltasOf(entry)) {
2440
- citable.push({ capability: entry.capability, key: delta.key });
2441
- }
2529
+ function antAuthProfile(home) {
2530
+ const dir = join7(home, ".config", "anthropic", "credentials");
2531
+ try {
2532
+ return readdirSync6(dir).some((f) => f.endsWith(".json")) ? "token" : "empty-profile";
2533
+ } catch {
2534
+ return "absent";
2442
2535
  }
2443
- return citable;
2444
2536
  }
2445
- function purposeOf(entry) {
2446
- if (entry.derivation.outcome !== "derived") return null;
2447
- if (entry.derivation.foldedComparison.kind !== "no-folded-counterpart") return null;
2448
- return readPurposeBody(entry);
2537
+ function subscriptionStatus(home, installed) {
2538
+ if (existsSync5(join7(home, ".claude", ".credentials.json"))) return { kind: "available" };
2539
+ return {
2540
+ kind: "unavailable",
2541
+ reason: installed ? "installed-not-authenticated" : "not-installed"
2542
+ };
2449
2543
  }
2450
- var PURPOSE_HEADING = /^##[ \t]+Purpose[ \t]*$/;
2451
- function readPurposeBody(entry) {
2452
- const body = [];
2453
- let inPurpose = false;
2454
- for (const line of entry.draft.split("\n")) {
2455
- const p = probe2(line);
2456
- if (PURPOSE_HEADING.test(p)) {
2457
- inPurpose = true;
2458
- continue;
2459
- }
2460
- if (inPurpose && ANY_HEADING.test(p)) break;
2461
- if (inPurpose) body.push(p);
2544
+ function apiKeyStatus(env) {
2545
+ const value = env["ANTHROPIC_API_KEY"];
2546
+ if (value === void 0) return { kind: "unavailable", reason: "not-installed" };
2547
+ if (value === "") return { kind: "unavailable", reason: "installed-not-authenticated" };
2548
+ return { kind: "available" };
2549
+ }
2550
+ function antAuthStatus(env, home) {
2551
+ const token = env["ANTHROPIC_AUTH_TOKEN"];
2552
+ if (token !== void 0 && token !== "") return { kind: "available" };
2553
+ switch (antAuthProfile(home)) {
2554
+ case "token":
2555
+ return { kind: "available" };
2556
+ case "empty-profile":
2557
+ return { kind: "unavailable", reason: "installed-not-authenticated" };
2558
+ case "absent":
2559
+ return {
2560
+ kind: "unavailable",
2561
+ reason: token === "" ? "installed-not-authenticated" : "not-installed"
2562
+ };
2462
2563
  }
2463
- const text = body.join("\n").trim();
2464
- return text === "" ? null : text;
2465
2564
  }
2466
- function deltaSpecDocument(entry) {
2467
- const deltas = deltasOf(entry);
2468
- return {
2469
- capability: entry.capability,
2470
- purpose: purposeOf(entry),
2471
- added: deltas.filter((d) => d.operation === "ADDED").map((d) => blockText(d)),
2472
- // A declared rename whose body also changed emits RENAMED *and* MODIFIED: the
2473
- // rename names the heading change, the modification carries the body change
2474
- // with the folded block as its base. Emitting only the rename would discard a
2475
- // body edit the author made; refusing would make a legitimate edit and a
2476
- // legitimate rename mutually exclusive. See `docs/specsketch-workbench-issues.md` #8.
2477
- modified: deltas.filter((d) => d.operation === "MODIFIED" || d.operation === "RENAMED" && d.bodyChanged).map((d) => blockText(d)),
2478
- removed: deltas.filter((d) => d.operation === "REMOVED").map((d) => d.base.name),
2479
- renamed: deltas.filter((d) => d.operation === "RENAMED").map((d) => ({ from: d.base.name, to: d.block.name }))
2565
+ function probeMachineCredentials(input = {}) {
2566
+ const env = input.env ?? process.env;
2567
+ const home = input.home ?? homedir3();
2568
+ const installed = input.claudeInstalled ?? claudeOnPath(env);
2569
+ const rejected = new Set(input.rejected ?? []);
2570
+ const statuses = {
2571
+ subscription: subscriptionStatus(home, installed),
2572
+ "api-key": apiKeyStatus(env),
2573
+ "ant-auth": antAuthStatus(env, home)
2480
2574
  };
2575
+ for (const id of CREDENTIAL_SOURCES) {
2576
+ if (rejected.has(id) && statuses[id].kind === "available") {
2577
+ statuses[id] = { kind: "unavailable", reason: "present-but-rejected" };
2578
+ }
2579
+ }
2580
+ const rows = CREDENTIAL_SOURCES.map((id) => sourceRow(id, statuses[id]));
2581
+ return { rows, defaultPick: defaultPick(env, home, statuses) };
2582
+ }
2583
+ function defaultPick(env, home, statuses) {
2584
+ try {
2585
+ const pick = resolveModelCredential({ env, home }).path;
2586
+ return statuses[pick].kind === "available" ? pick : null;
2587
+ } catch (error) {
2588
+ if (error instanceof ModelAuthError) return null;
2589
+ throw error;
2590
+ }
2481
2591
  }
2482
- function blockText(delta) {
2483
- const block = draftBlockOf(delta);
2484
- if (!block) throw new RangeError(`no draft block for ${delta.operation} delta \`${delta.key}\``);
2485
- return block.text;
2592
+
2593
+ // src/workbench/agent/live-run.ts
2594
+ function harnessInputsFor(source, env) {
2595
+ if (source === "subscription") return { env, useApi: false };
2596
+ if (source === "api-key") return { env, useApi: true };
2597
+ const withheld = { ...env };
2598
+ delete withheld["ANTHROPIC_API_KEY"];
2599
+ return { env: withheld, useApi: true };
2486
2600
  }
2487
- function changeDocument(change) {
2601
+ async function runRailTurn(input, deps = {}) {
2602
+ const env = deps.env ?? process.env;
2603
+ const machine = deps.machine ?? probeMachineCredentials({
2604
+ env,
2605
+ ...deps.home !== void 0 ? { home: deps.home } : {},
2606
+ ...deps.probe ?? {}
2607
+ });
2608
+ const credential = resolveRailCredential(machine, input.selection ?? null);
2609
+ if (credential.kind === "no-model") return { kind: "no-model", credential };
2610
+ const inputs = harnessInputsFor(credential.source, env);
2611
+ const harness = (deps.createHarness ?? ((d) => createClaudeHarness(d)))({
2612
+ env: inputs.env,
2613
+ home: deps.home
2614
+ });
2615
+ const runInput = {
2616
+ prompt: input.prompt,
2617
+ cwd: input.cwd,
2618
+ session: input.session,
2619
+ // Read-only by construction — the live run carries the SAME profile the
2620
+ // canned rail always declared (issue #53).
2621
+ profile: agentRailProfile(),
2622
+ useApi: inputs.useApi,
2623
+ ...input.systemPrompt !== void 0 ? { systemPrompt: input.systemPrompt } : {},
2624
+ ...input.maxTurns !== void 0 ? { maxTurns: input.maxTurns } : {},
2625
+ ...input.outputSchema !== void 0 ? { outputSchema: input.outputSchema } : {},
2626
+ ...deps.onNotice ? { onNotice: deps.onNotice } : {}
2627
+ };
2628
+ let apiKeySource;
2629
+ let answer = "";
2630
+ let isError = false;
2631
+ let outcome;
2632
+ let turns;
2633
+ let failure;
2634
+ let structuredOutput;
2635
+ let sawStructuredOutput = false;
2636
+ for await (const event of harness.run(runInput)) {
2637
+ if (event.type === "start") {
2638
+ apiKeySource = event.apiKeySource;
2639
+ } else if (event.type === "result") {
2640
+ answer = event.text;
2641
+ isError = event.isError;
2642
+ outcome = event.outcome;
2643
+ turns = event.turns;
2644
+ failure = event.failure;
2645
+ if ("structuredOutput" in event) {
2646
+ structuredOutput = event.structuredOutput;
2647
+ sawStructuredOutput = true;
2648
+ }
2649
+ }
2650
+ }
2488
2651
  return {
2489
- id: change.id,
2490
- proposal: change.proposal,
2491
- design: change.design,
2492
- groups: numberTasks(change.tasks),
2493
- testing: testingRows(change.capabilities),
2494
- proposalExtras: change.proposalExtras,
2495
- deltaSpecs: change.capabilities.filter(emitsDeltaSpec).map(deltaSpecDocument).sort((a, b) => a.capability < b.capability ? -1 : a.capability > b.capability ? 1 : 0)
2652
+ kind: "turn",
2653
+ turn: {
2654
+ answer,
2655
+ served: servedSourceFromRun(credential.source, apiKeySource),
2656
+ selected: credential.source,
2657
+ isError,
2658
+ ...outcome !== void 0 ? { outcome } : {},
2659
+ ...turns !== void 0 ? { turns } : {},
2660
+ ...failure !== void 0 ? { failure } : {},
2661
+ ...sawStructuredOutput ? { structuredOutput } : {}
2662
+ }
2496
2663
  };
2497
2664
  }
2498
- var SKIPPED_MARKER = "_Skipped._";
2499
- var UNSATISFIED_MARKER = "_Unsatisfied \u2014 this section has no body and no recorded skip._";
2500
- function renderProposalSection(spec, section2) {
2501
- const heading = `${"#".repeat(spec.level)} ${spec.title}`;
2502
- switch (section2.state) {
2503
- case "authored":
2504
- return `${heading}
2505
-
2506
- ${section2.body}
2507
- `;
2508
- case "skipped":
2509
- return `${heading}
2510
-
2511
- ${SKIPPED_MARKER} ${section2.reason.trim()}
2512
- `;
2513
- case "empty":
2514
- return `${heading}
2515
2665
 
2516
- ${UNSATISFIED_MARKER}
2517
- `;
2666
+ // src/workbench/agent/tools.ts
2667
+ import { z as z5 } from "zod";
2668
+ var ROOT_ARTIFACTS = ["proposal.md", "design.md", "tasks.md"];
2669
+ var SPEC_ARTIFACT = /^specs\/([a-z0-9]+(?:-[a-z0-9]+)*)\/spec\.md$/;
2670
+ var AGENT_PROPOSAL_TOOLS = [
2671
+ {
2672
+ name: "edit_artifact",
2673
+ description: "Propose a markdown edit to one artifact of this change. `artifact` is one of `proposal.md`, `design.md`, `tasks.md`, or `specs/<capability>/spec.md` \u2014 it must be an artifact the change already has. `text` is the markdown, exactly as it should sit in the file. `mode` is `replace` for the whole file or `append` to add at the end."
2674
+ },
2675
+ {
2676
+ name: "add_task_group",
2677
+ description: "Propose a group of implementation tasks, appended to `tasks.md`. `title` and `tasks` are required; one group per coherent area, each task a single concrete action. `capability` + `requirement` are optional and go together \u2014 name both to cite the requirement the group implements, or neither for a chore group."
2678
+ },
2679
+ {
2680
+ name: "add_requirement",
2681
+ description: "Propose a requirement, appended to a delta spec. `capability` must be one the change already has a delta spec for. `block` must be full markdown: a `### Requirement:` heading, RFC-2119 SHALL text, and at least one `#### Scenario:` with WHEN/THEN steps."
2518
2682
  }
2519
- }
2520
- function renderProposal(doc) {
2521
- const extras = doc.proposalExtras ?? [];
2522
- const after = (id) => extras.filter((e) => e.afterSection === id).map((e) => `${e.text.trimEnd()}
2523
- `);
2524
- const parts = [`# ${doc.id}
2525
- `, ...after(null)];
2526
- for (const spec of PROPOSAL_SECTIONS) {
2527
- parts.push(renderProposalSection(spec, doc.proposal[spec.id]));
2528
- parts.push(...after(spec.id));
2683
+ ];
2684
+ var proposalCall = z5.object({
2685
+ tool: z5.enum(AGENT_PROPOSAL_TOOLS.map((t) => t.name)).describe("Which kind of edit to propose."),
2686
+ artifact: z5.string().optional().describe("edit_artifact: the artifact this edit is against."),
2687
+ mode: z5.string().optional().describe("edit_artifact: `replace` or `append`. Defaults to append."),
2688
+ text: z5.string().optional().describe("edit_artifact: the markdown, exactly as it should read."),
2689
+ title: z5.string().optional().describe("add_task_group: the group heading."),
2690
+ tasks: z5.array(z5.string()).optional().describe("add_task_group: task titles, one per task, each one concrete action."),
2691
+ capability: z5.string().optional().describe("add_requirement: the delta spec\u2019s capability. add_task_group: the citation\u2019s."),
2692
+ requirement: z5.string().optional().describe("add_task_group: the exact requirement name, as it appears after `### Requirement:`."),
2693
+ block: z5.string().optional().describe("add_requirement: the complete requirement block, in markdown.")
2694
+ });
2695
+ var agentTurnResult = z5.object({
2696
+ answer: z5.string().describe("Brief prose for the author: what you proposed and why, or your answer if nothing."),
2697
+ proposals: z5.array(proposalCall).describe("The edits to propose. Empty when the turn only answers.")
2698
+ });
2699
+ var AGENT_TURN_SCHEMA = z5.toJSONSchema(agentTurnResult, {
2700
+ target: "draft-7"
2701
+ });
2702
+ function knownArtifacts(shape) {
2703
+ if (shape.artifacts !== void 0) return shape.artifacts;
2704
+ if (shape.buffers !== void 0) return Object.keys(shape.buffers);
2705
+ if (shape.openCapabilities !== void 0) {
2706
+ return [...ROOT_ARTIFACTS, ...shape.openCapabilities.map((c) => `specs/${c}/spec.md`)];
2529
2707
  }
2530
- return `${parts.join("\n")}`;
2708
+ return null;
2531
2709
  }
2532
- function renderDesign(doc) {
2533
- const parts = [`# Design \u2014 ${doc.id}
2534
- `];
2535
- for (const block of doc.design) {
2536
- const title = (block.title ?? "").trim();
2537
- const heading = title === "" ? "" : `## ${title}
2538
-
2539
- `;
2540
- if (block.kind === "markdown") {
2541
- parts.push(`${heading}${block.text.trim()}
2542
- `);
2543
- continue;
2544
- }
2545
- const info = block.kind === "mermaid" ? DESIGN_FENCES.mermaid : DESIGN_FENCES.sketch;
2546
- parts.push(`${heading}\`\`\`${info}
2547
- ${block.text.trim()}
2548
- \`\`\`
2549
- `);
2550
- }
2551
- return parts.join("\n");
2710
+ function changeHas(shape, artifact) {
2711
+ const known = knownArtifacts(shape);
2712
+ return known === null ? true : known.includes(artifact);
2552
2713
  }
2553
- function cell(text) {
2554
- return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
2714
+ function stripLeadingNumber(text) {
2715
+ return text.replace(/^\s*\d+(?:\.\d+)*[.)]?\s+/, "").trim();
2555
2716
  }
2556
- function uncell(text) {
2557
- return text.replace(/\\\|/g, "|").replace(/\\\\/g, "\\");
2717
+ function renderTaskGroup(title, tasks, cites) {
2718
+ const heading = cites ? `## ${title} \u2014 implements \`${cites.capability}\`: \`${cites.key}\`` : `## ${title}`;
2719
+ return [heading, "", ...tasks.map((task) => `- [ ] ${task}`)].join("\n");
2558
2720
  }
2559
- function renderTasks(doc) {
2560
- const parts = [`# Tasks \u2014 ${doc.id}
2561
- `];
2562
- for (const group of doc.groups) {
2563
- const citation = group.cites ? ` \u2014 implements \`${group.cites.capability}\`: \`${group.cites.key}\`` : "";
2564
- const lines = [`## ${group.number}. ${group.title}${citation}
2565
- `];
2566
- for (const task of group.tasks) {
2567
- const box = task.done ? "x" : " ";
2568
- lines.push(`- [${box}] ${task.number} ${task.title.split("\n").join(`
2569
- ${TASK_CONTINUATION}`)}`);
2721
+ function proposalFromToolCall(name, args, shape) {
2722
+ switch (name) {
2723
+ case "edit_artifact": {
2724
+ const artifact = String(args["artifact"] ?? "").trim();
2725
+ const shaped = ROOT_ARTIFACTS.includes(artifact) || SPEC_ARTIFACT.test(artifact);
2726
+ if (!shaped) {
2727
+ return {
2728
+ kind: "refused",
2729
+ tool: name,
2730
+ missing: `\`${artifact}\` is not an artifact of an OpenSpec change`
2731
+ };
2732
+ }
2733
+ if (!changeHas(shape, artifact)) {
2734
+ return {
2735
+ kind: "refused",
2736
+ tool: name,
2737
+ missing: `this change has no \`${artifact}\``
2738
+ };
2739
+ }
2740
+ const text = String(args["text"] ?? "");
2741
+ if (text.trim() === "") {
2742
+ return { kind: "refused", tool: name, missing: "the markdown to write" };
2743
+ }
2744
+ const mode = String(args["mode"] ?? "").trim() === "replace" ? "replace" : "append";
2745
+ return { kind: "artifact-edit", artifact, mode, text, tool: name };
2570
2746
  }
2571
- parts.push(`${lines.join("\n")}
2572
- `);
2747
+ case "add_task_group": {
2748
+ if (!changeHas(shape, "tasks.md")) {
2749
+ return { kind: "refused", tool: name, missing: "this change has no `tasks.md`" };
2750
+ }
2751
+ const title = stripLeadingNumber(String(args["title"] ?? ""));
2752
+ const tasks = Array.isArray(args["tasks"]) ? args["tasks"].map((t) => stripLeadingNumber(String(t))).filter((t) => t !== "") : [];
2753
+ if (title === "" || tasks.length === 0) {
2754
+ return { kind: "refused", tool: name, missing: "a title and at least one task" };
2755
+ }
2756
+ const capability = String(args["capability"] ?? "").trim();
2757
+ const key = String(args["requirement"] ?? "").trim();
2758
+ if (capability === "" && key === "") {
2759
+ return {
2760
+ kind: "artifact-edit",
2761
+ artifact: "tasks.md",
2762
+ mode: "append",
2763
+ text: renderTaskGroup(title, tasks, null),
2764
+ tool: name
2765
+ };
2766
+ }
2767
+ if (capability === "" || key === "") {
2768
+ return {
2769
+ kind: "refused",
2770
+ tool: name,
2771
+ missing: "both a capability and a requirement name, or neither"
2772
+ };
2773
+ }
2774
+ if (shape.citable !== void 0 && !shape.citable.some((c) => c.capability === capability && c.key === key)) {
2775
+ return {
2776
+ kind: "refused",
2777
+ tool: name,
2778
+ missing: `\`${key}\` in \`${capability}\` is not a requirement this change declares`
2779
+ };
2780
+ }
2781
+ const cites = { capability, key };
2782
+ return {
2783
+ kind: "artifact-edit",
2784
+ artifact: "tasks.md",
2785
+ mode: "append",
2786
+ text: renderTaskGroup(title, tasks, cites),
2787
+ tool: name,
2788
+ cites
2789
+ };
2790
+ }
2791
+ case "add_requirement": {
2792
+ const capability = String(args["capability"] ?? "").trim();
2793
+ const artifact = `specs/${capability}/spec.md`;
2794
+ if (capability === "" || !SPEC_ARTIFACT.test(artifact)) {
2795
+ return { kind: "refused", tool: name, missing: "a capability name" };
2796
+ }
2797
+ if (!changeHas(shape, artifact)) {
2798
+ return {
2799
+ kind: "refused",
2800
+ tool: name,
2801
+ missing: `this change has no delta spec for \`${capability}\``
2802
+ };
2803
+ }
2804
+ const block = String(args["block"] ?? "").trim();
2805
+ if (!/^###[ \t]+Requirement:/m.test(block)) {
2806
+ return { kind: "refused", tool: name, missing: "a `### Requirement:` heading" };
2807
+ }
2808
+ return { kind: "artifact-edit", artifact, mode: "append", text: block, tool: name };
2809
+ }
2810
+ default:
2811
+ return { kind: "refused", tool: name, missing: `no such tool \`${name}\`` };
2573
2812
  }
2574
- parts.push(renderTestingTable(doc.testing));
2575
- return parts.join("\n");
2576
2813
  }
2577
- function renderTestingTable(rows) {
2578
- const lines = [
2579
- "## Testing",
2580
- "",
2581
- "One test per scenario. This table is derived from the change's deltas \u2014 it is not",
2582
- "editable text, and its row count is the change's scenario count.",
2583
- "",
2584
- "| # | Capability | Requirement | Scenario |",
2585
- "|---:|---|---|---|"
2586
- ];
2587
- for (const row of rows) {
2588
- lines.push(
2589
- `| ${row.index} | \`${cell(row.capability)}\` | ${cell(row.requirement)} | ${cell(row.scenario)} |`
2590
- );
2591
- }
2592
- if (rows.length === 0) lines.push("| \u2014 | \u2014 | \u2014 | \u2014 |");
2593
- return `${lines.join("\n")}
2594
- `;
2814
+ function proposalsFromStructuredOutput(value, shape) {
2815
+ const parsed = agentTurnResult.safeParse(value);
2816
+ if (!parsed.success) return null;
2817
+ return {
2818
+ answer: parsed.data.answer,
2819
+ proposals: parsed.data.proposals.map((call) => {
2820
+ const { tool, ...args } = call;
2821
+ return proposalFromToolCall(tool, args, shape);
2822
+ })
2823
+ };
2595
2824
  }
2596
- function renderDeltaSpec(doc, changeId) {
2597
- const parts = [`# ${doc.capability} (delta) \u2014 ${changeId}
2598
- `];
2599
- if (doc.purpose !== null) parts.push(`## Purpose
2600
2825
 
2601
- ${doc.purpose}
2602
- `);
2603
- if (doc.added.length > 0) parts.push(section("## ADDED Requirements", doc.added));
2604
- if (doc.modified.length > 0) parts.push(section("## MODIFIED Requirements", doc.modified));
2605
- if (doc.removed.length > 0) {
2606
- parts.push(
2607
- section(
2608
- "## REMOVED Requirements",
2609
- doc.removed.map((name) => `### Requirement: ${name}`)
2610
- )
2611
- );
2826
+ // src/workbench/agent/agent-turn.ts
2827
+ var AGENT_TURN_MAX_TURNS = 12;
2828
+ function noCredentialReason(credential) {
2829
+ if (credential.kind !== "no-model") return "no model is available";
2830
+ return credential.droppedSelection === null ? "no model is available \u2014 no credential source on this machine is usable" : `no model is available \u2014 the selected ${credential.droppedSelection} source is not usable, and nothing else resolved`;
2831
+ }
2832
+ function servedLabel(served) {
2833
+ return servedSentence(served);
2834
+ }
2835
+ async function runAgentTurn(input, deps = {}) {
2836
+ const run = await runRailTurn(
2837
+ {
2838
+ prompt: input.prompt,
2839
+ systemPrompt: input.systemPrompt,
2840
+ cwd: input.cwd,
2841
+ session: input.session,
2842
+ maxTurns: AGENT_TURN_MAX_TURNS,
2843
+ // The turn needs structure, so it asks for it. The harness validates against
2844
+ // this schema and treats a "successful" run without conforming output as a
2845
+ // failure, which is why nothing below needs a prose fallback.
2846
+ outputSchema: AGENT_TURN_SCHEMA,
2847
+ ...input.selection !== void 0 ? { selection: input.selection } : {}
2848
+ },
2849
+ deps
2850
+ );
2851
+ if (run.kind === "no-model") {
2852
+ return { outcome: { kind: "no-credential", reason: noCredentialReason(run.credential) }, proposals: [] };
2612
2853
  }
2613
- if (doc.renamed.length > 0) {
2614
- const pairs = doc.renamed.map(
2615
- (pair) => `- FROM: \`### Requirement: ${pair.from}\`
2616
- - TO: \`### Requirement: ${pair.to}\``
2617
- );
2618
- parts.push(`## RENAMED Requirements
2619
-
2620
- ${pairs.join("\n\n")}
2621
- `);
2854
+ const turn = run.turn;
2855
+ const served = servedLabel(turn.served);
2856
+ if (turn.outcome === "failed" || turn.outcome === void 0 && turn.isError) {
2857
+ return {
2858
+ outcome: {
2859
+ kind: "failed",
2860
+ reason: turn.failure !== void 0 ? `${turn.failure.category}${turn.failure.recognised ? "" : " (unrecognised category)"}: ${turn.answer || "the model returned an error"}` : turn.answer || "the model returned an error",
2861
+ served
2862
+ },
2863
+ proposals: []
2864
+ };
2865
+ }
2866
+ const decoded = proposalsFromStructuredOutput(turn.structuredOutput, input.shape);
2867
+ if (turn.outcome === "cut_short") {
2868
+ return {
2869
+ outcome: {
2870
+ kind: "cut-short",
2871
+ answer: decoded?.answer ?? turn.answer,
2872
+ served,
2873
+ selected: turn.selected
2874
+ },
2875
+ proposals: decoded?.proposals ?? []
2876
+ };
2877
+ }
2878
+ if (decoded === null) {
2879
+ return {
2880
+ outcome: {
2881
+ kind: "failed",
2882
+ reason: "the turn returned no structured output to read proposals from",
2883
+ served
2884
+ },
2885
+ proposals: []
2886
+ };
2622
2887
  }
2623
- return parts.join("\n");
2888
+ return {
2889
+ outcome: { kind: "answered", answer: decoded.answer, served, selected: turn.selected },
2890
+ proposals: decoded.proposals
2891
+ };
2624
2892
  }
2625
- function section(heading, blocks) {
2626
- return `${heading}
2627
2893
 
2628
- ${blocks.map((b) => b.trimEnd()).join("\n\n")}
2629
- `;
2630
- }
2631
- function renderChangeDocument(doc) {
2632
- const files = [
2633
- { path: "proposal.md", content: renderProposal(doc) },
2634
- { path: "design.md", content: renderDesign(doc) },
2635
- { path: "tasks.md", content: renderTasks(doc) }
2636
- ];
2637
- for (const spec of doc.deltaSpecs) {
2638
- files.push({
2639
- path: `specs/${spec.capability}/spec.md`,
2640
- content: renderDeltaSpec(spec, doc.id)
2641
- });
2894
+ // server/change-files.ts
2895
+ import { createHash as createHash3 } from "node:crypto";
2896
+ import {
2897
+ existsSync as existsSync6,
2898
+ mkdirSync as mkdirSync3,
2899
+ readFileSync as readFileSync5,
2900
+ readdirSync as readdirSync7,
2901
+ statSync as statSync4,
2902
+ writeFileSync as writeFileSync3
2903
+ } from "node:fs";
2904
+ import { dirname as dirname4, join as join8 } from "node:path";
2905
+
2906
+ // src/workbench/shell/lifecycle.ts
2907
+ var SPEC_DRIVEN = Object.freeze({
2908
+ name: "spec-driven",
2909
+ artifacts: Object.freeze([
2910
+ Object.freeze({ id: "proposal", generates: "proposal.md", requires: Object.freeze([]) }),
2911
+ Object.freeze({
2912
+ id: "specs",
2913
+ generates: "specs/**/*.md",
2914
+ requires: Object.freeze(["proposal"])
2915
+ }),
2916
+ Object.freeze({ id: "design", generates: "design.md", requires: Object.freeze(["proposal"]) }),
2917
+ Object.freeze({
2918
+ id: "tasks",
2919
+ generates: "tasks.md",
2920
+ requires: Object.freeze(["specs", "design"])
2921
+ })
2922
+ ])
2923
+ });
2924
+ function parseSchemaName(yamlText) {
2925
+ for (const raw of yamlText.split("\n")) {
2926
+ const line = raw.trim();
2927
+ if (line.startsWith("#")) continue;
2928
+ const match = /^schema:\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))\s*(?:#.*)?$/.exec(line);
2929
+ if (!match) continue;
2930
+ const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
2931
+ return value === "" ? null : value;
2642
2932
  }
2643
- return { id: doc.id, files };
2933
+ return null;
2644
2934
  }
2645
- function emitChange(change) {
2646
- return renderChangeDocument(changeDocument(change));
2935
+ function resolveSchema(yamlText, known = [SPEC_DRIVEN]) {
2936
+ const name = parseSchemaName(yamlText);
2937
+ if (name === null) return { kind: "unknown-schema", name: "" };
2938
+ const schema = known.find((candidate) => candidate.name === name);
2939
+ return schema ? { kind: "resolved", schema } : { kind: "unknown-schema", name };
2647
2940
  }
2648
- var TASK_LINE = /^- \[([ xX])\] (\d+\.\d+) (.*)$/;
2649
- var TASK_CONTINUATION = " ";
2650
- var TASK_CONTINUATION_LINE = /^ {2}(.*)$/;
2651
- var GROUP_CITATION = /^(.*) implements `([^`]+)`: `([^`]+)`$/;
2652
- var GROUP_HEADING = /^## (\d+)\. (.*)$/;
2653
- var FENCE = /^```(\S*)\s*$/;
2654
- function parseChangeDocument(emitted) {
2655
- const byPath = new Map(emitted.files.map((f) => [f.path, f.content]));
2656
- const proposalSrc = byPath.get("proposal.md") ?? "";
2657
- const designSrc = byPath.get("design.md") ?? "";
2658
- const tasksSrc = byPath.get("tasks.md") ?? "";
2659
- const parsedProposal = parseProposalFile(proposalSrc);
2660
- const groups = parseTasksFile(tasksSrc);
2661
- const deltaSpecs = emitted.files.filter((f) => f.path.startsWith("specs/") && f.path.endsWith("/spec.md")).map((f) => parseDeltaSpecFile(capabilityOfPath(f.path), f.content));
2662
- return {
2663
- id: emitted.id,
2664
- proposal: parsedProposal.draft,
2665
- proposalExtras: parsedProposal.extras,
2666
- design: parseDesignFile(designSrc),
2667
- groups: numberTasks(groups),
2668
- testing: parseTestingTable(tasksSrc),
2669
- deltaSpecs
2670
- };
2941
+
2942
+ // server/change-files.ts
2943
+ function changeDirectory(repoRoot, changeId) {
2944
+ return join8(repoRoot, "openspec", "changes", changeId);
2671
2945
  }
2672
- function capabilityOfPath(path) {
2673
- const parts = path.split("/");
2674
- return parts[1] ?? "";
2946
+ var ROOT_ARTIFACTS2 = Object.freeze(["proposal.md", "design.md", "tasks.md"]);
2947
+ var CAPABILITY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2948
+ var SPEC_ARTIFACT2 = /^specs\/([^/]+)\/spec\.md$/;
2949
+ function resolveArtifact(name) {
2950
+ if (name === "" || name !== name.trim()) return null;
2951
+ if (name.includes("\\") || name.includes("\0")) return null;
2952
+ if (name.startsWith("/") || /^[A-Za-z]:/.test(name)) return null;
2953
+ if (name.split("/").some((part) => part === ".." || part === "." || part === "")) return null;
2954
+ if (ROOT_ARTIFACTS2.includes(name)) return name;
2955
+ const spec = SPEC_ARTIFACT2.exec(name);
2956
+ if (spec && CAPABILITY.test(spec[1])) return name;
2957
+ return null;
2675
2958
  }
2676
- function parseProposalFile(src) {
2677
- const lines = src.split("\n");
2678
- const bodies = /* @__PURE__ */ new Map();
2679
- let current = null;
2680
- const extras = [];
2681
- let extra = null;
2682
- let lastFixed = null;
2683
- const flushExtra = () => {
2684
- if (!extra) return;
2685
- const text = extra.lines.join("\n").trimEnd();
2686
- if (text !== "") extras.push({ afterSection: extra.afterSection, text });
2687
- extra = null;
2959
+ var REAL_IO = { readText: (path) => readFileSync5(path, "utf8") };
2960
+ function stateOf(path, content) {
2961
+ const stat = statSync4(path);
2962
+ return {
2963
+ size: stat.size,
2964
+ mtimeMs: stat.mtimeMs,
2965
+ hash: createHash3("sha256").update(Buffer.from(content, "utf8")).digest("hex")
2688
2966
  };
2689
- for (const line of lines) {
2690
- const p = probe2(line);
2691
- const spec = PROPOSAL_SECTIONS.find(
2692
- (s) => p === `${"#".repeat(s.level)} ${s.title}`
2693
- );
2694
- if (spec) {
2695
- flushExtra();
2696
- current = spec.id;
2697
- lastFixed = spec.id;
2698
- bodies.set(spec.id, []);
2699
- continue;
2700
- }
2701
- if (ANY_HEADING.test(p)) {
2702
- flushExtra();
2703
- current = null;
2704
- if (/^#[ \t]/.test(p) && !/^##/.test(p)) continue;
2705
- extra = { afterSection: lastFixed, lines: [p] };
2706
- continue;
2707
- }
2708
- if (current) bodies.get(current).push(p);
2709
- else if (extra) extra.lines.push(p);
2710
- }
2711
- flushExtra();
2712
- const draft = emptyProposal();
2713
- for (const spec of PROPOSAL_SECTIONS) {
2714
- const collected = [...bodies.get(spec.id) ?? []];
2715
- if (collected[0] === "") collected.shift();
2716
- if (collected.length > 0 && collected[collected.length - 1] === "") collected.pop();
2717
- const body = collected.join("\n");
2718
- if (body === "" || body === UNSATISFIED_MARKER) {
2719
- draft[spec.id] = { state: "empty" };
2720
- continue;
2721
- }
2722
- if (body.startsWith(SKIPPED_MARKER)) {
2723
- draft[spec.id] = { state: "skipped", reason: body.slice(SKIPPED_MARKER.length).trim() };
2724
- continue;
2725
- }
2726
- draft[spec.id] = { state: "authored", body };
2727
- }
2728
- return { draft, extras };
2729
2967
  }
2730
- var DESIGN_HEADING = /^##[ \t]+(.*)$/;
2731
- function parseDesignFile(src) {
2732
- const lines = src.split("\n");
2733
- const blocks = [];
2734
- let markdown = [];
2735
- let title = null;
2736
- let fence = null;
2737
- const withTitle = (block) => {
2738
- if (title === null) return block;
2739
- const titled = { ...block, title };
2740
- title = null;
2741
- return titled;
2742
- };
2743
- const flushMarkdown = () => {
2744
- const text = markdown.join("\n").trim();
2745
- if (text !== "" || title !== null) blocks.push(withTitle({ kind: "markdown", text }));
2746
- markdown = [];
2747
- };
2748
- for (const [i, line] of lines.entries()) {
2749
- const p = probe2(line);
2750
- const fenceMatch = FENCE.exec(p);
2751
- if (fence) {
2752
- if (p === "```") {
2753
- const kind = fence.info === DESIGN_FENCES.mermaid ? "mermaid" : "sketch";
2754
- blocks.push(withTitle({ kind, text: fence.lines.join("\n").trim() }));
2755
- fence = null;
2756
- continue;
2757
- }
2758
- fence.lines.push(p);
2759
- continue;
2760
- }
2761
- if (fenceMatch && isDesignFence(fenceMatch[1] ?? "")) {
2762
- if (markdown.join("\n").trim() !== "") flushMarkdown();
2763
- markdown = [];
2764
- fence = { info: fenceMatch[1], lines: [] };
2765
- continue;
2766
- }
2767
- const heading = DESIGN_HEADING.exec(p);
2768
- if (heading) {
2769
- flushMarkdown();
2770
- title = (heading[1] ?? "").trim();
2771
- continue;
2772
- }
2773
- if (i === 0 && p.startsWith("# Design \u2014 ")) continue;
2774
- markdown.push(p);
2968
+ function currentState(path) {
2969
+ try {
2970
+ return stateOf(path, readFileSync5(path, "utf8"));
2971
+ } catch {
2972
+ return null;
2775
2973
  }
2776
- flushMarkdown();
2777
- return blocks;
2778
- }
2779
- function isDesignFence(info) {
2780
- return info === DESIGN_FENCES.mermaid || info === DESIGN_FENCES.sketch;
2781
2974
  }
2782
- function parseTasksFile(src) {
2783
- const groups = [];
2784
- let open = null;
2785
- for (const line of src.split("\n")) {
2786
- const p = probe2(line);
2787
- const heading = GROUP_HEADING.exec(p);
2788
- if (heading) {
2789
- if (open) groups.push(open);
2790
- const text = (heading[2] ?? "").trim();
2791
- const cited = GROUP_CITATION.exec(text);
2792
- open = cited ? {
2793
- title: (cited[1] ?? "").trim(),
2794
- tasks: [],
2795
- cites: { capability: cited[2], key: cited[3] }
2796
- } : { title: text, tasks: [] };
2797
- continue;
2798
- }
2799
- if (/^##[ \t]/.test(p)) {
2800
- if (open) groups.push(open);
2801
- open = null;
2802
- continue;
2803
- }
2804
- const task = TASK_LINE.exec(p);
2805
- if (task && open) {
2806
- const done = (task[1] ?? " ").toLowerCase() === "x";
2807
- open.tasks.push({ title: task[3] ?? "", done });
2808
- continue;
2809
- }
2810
- const continuation = TASK_CONTINUATION_LINE.exec(p);
2811
- if (continuation && open && open.tasks.length > 0) {
2812
- const last = open.tasks[open.tasks.length - 1];
2813
- open.tasks[open.tasks.length - 1] = {
2814
- ...last,
2815
- title: `${last.title}
2816
- ${continuation[1] ?? ""}`
2975
+ function readArtifact(changeDir, name, io = REAL_IO) {
2976
+ const artifact = resolveArtifact(name);
2977
+ if (artifact === null) {
2978
+ return { outcome: "refused", artifact: name, reason: notAnArtifact(name) };
2979
+ }
2980
+ const path = join8(changeDir, artifact);
2981
+ let size;
2982
+ try {
2983
+ const stat = statSync4(path);
2984
+ if (!stat.isFile()) {
2985
+ return {
2986
+ outcome: "unreadable",
2987
+ artifact,
2988
+ reason: `\`${artifact}\` is not a file \u2014 the change directory holds something else under that name`
2817
2989
  };
2818
2990
  }
2991
+ size = stat.size;
2992
+ } catch (err) {
2993
+ if (err.code === "ENOENT") return { outcome: "absent", artifact };
2994
+ return { outcome: "unreadable", artifact, reason: describeErrno(artifact, err) };
2819
2995
  }
2820
- if (open) groups.push(open);
2821
- return groups;
2822
- }
2823
- function parseTestingTable(src) {
2824
- const rows = [];
2825
- let inTable = false;
2826
- for (const line of src.split("\n")) {
2827
- const p = probe2(line);
2828
- if (p.startsWith("| # | Capability |")) {
2829
- inTable = true;
2830
- continue;
2831
- }
2832
- if (!inTable) continue;
2833
- if (!p.startsWith("|")) break;
2834
- if (/^\|[-: |]+\|$/.test(p)) continue;
2835
- const cells = splitRow(p);
2836
- if (cells.length !== 4) continue;
2837
- const index = Number.parseInt(cells[0], 10);
2838
- if (!Number.isFinite(index)) continue;
2839
- rows.push({
2840
- index,
2841
- capability: cells[1].replace(/^`|`$/g, ""),
2842
- requirement: cells[2],
2843
- scenario: cells[3]
2844
- });
2845
- }
2846
- return rows;
2847
- }
2848
- function splitRow(line) {
2849
- const cells = [];
2850
- let buffer = "";
2851
- let escaped = false;
2852
- for (const char of line.slice(1, line.lastIndexOf("|"))) {
2853
- if (escaped) {
2854
- buffer += `\\${char}`;
2855
- escaped = false;
2856
- continue;
2857
- }
2858
- if (char === "\\") {
2859
- escaped = true;
2860
- continue;
2861
- }
2862
- if (char === "|") {
2863
- cells.push(uncell(buffer.trim()));
2864
- buffer = "";
2865
- continue;
2866
- }
2867
- buffer += char;
2868
- }
2869
- cells.push(uncell(buffer.trim()));
2870
- return cells;
2871
- }
2872
- var DELTA_SECTION = /^## (ADDED|MODIFIED|REMOVED|RENAMED) Requirements[ \t]*$/;
2873
- var REQUIREMENT_HEADING2 = /^###[ \t]+Requirement:[ \t]*(.*)$/;
2874
- var RENAME_FROM = /^-[ \t]+FROM:[ \t]*`###[ \t]*Requirement:[ \t]*(.+?)`[ \t]*$/;
2875
- var RENAME_TO = /^-[ \t]+TO:[ \t]*`###[ \t]*Requirement:[ \t]*(.+?)`[ \t]*$/;
2876
- function parseDeltaSpecFile(capability, src) {
2877
- const added = [];
2878
- const modified = [];
2879
- const removed = [];
2880
- const renamed = [];
2881
- const purposeLines = [];
2882
- let operation = null;
2883
- let block = null;
2884
- let pendingFrom = null;
2885
- const flushBlock = () => {
2886
- if (!block) return;
2887
- const text = block.join("\n").trimEnd();
2888
- if (operation === "ADDED") added.push(text);
2889
- if (operation === "MODIFIED") modified.push(text);
2890
- block = null;
2891
- };
2892
- for (const line of src.split("\n")) {
2893
- const p = probe2(line);
2894
- const sectionHeading = DELTA_SECTION.exec(p);
2895
- if (sectionHeading) {
2896
- flushBlock();
2897
- operation = sectionHeading[1];
2898
- continue;
2899
- }
2900
- if (PURPOSE_HEADING.test(p)) {
2901
- flushBlock();
2902
- operation = "PURPOSE";
2903
- continue;
2904
- }
2905
- if (/^##(?!#)[ \t]/.test(p)) {
2906
- flushBlock();
2907
- operation = null;
2908
- continue;
2909
- }
2910
- if (operation === "PURPOSE") {
2911
- purposeLines.push(p);
2912
- continue;
2913
- }
2914
- const requirementHeading = REQUIREMENT_HEADING2.exec(p);
2915
- if (requirementHeading) {
2916
- flushBlock();
2917
- if (operation === "REMOVED") {
2918
- removed.push((requirementHeading[1] ?? "").trim());
2919
- continue;
2920
- }
2921
- if (operation === "ADDED" || operation === "MODIFIED") block = [p];
2922
- continue;
2923
- }
2924
- if (operation === "RENAMED") {
2925
- const from = RENAME_FROM.exec(p);
2926
- if (from) {
2927
- pendingFrom = (from[1] ?? "").trim();
2928
- continue;
2929
- }
2930
- const to = RENAME_TO.exec(p);
2931
- if (to && pendingFrom !== null) {
2932
- renamed.push({ from: pendingFrom, to: (to[1] ?? "").trim() });
2933
- pendingFrom = null;
2934
- }
2935
- continue;
2936
- }
2937
- if (block) block.push(p);
2996
+ let content;
2997
+ try {
2998
+ content = io.readText(path);
2999
+ } catch (err) {
3000
+ return { outcome: "unreadable", artifact, reason: describeErrno(artifact, err) };
2938
3001
  }
2939
- flushBlock();
2940
- const purpose = purposeLines.join("\n").trim();
2941
- return {
2942
- capability,
2943
- purpose: purpose === "" ? null : purpose,
2944
- added,
2945
- modified,
2946
- removed,
2947
- renamed
2948
- };
2949
- }
2950
-
2951
- // src/workbench/core/validate.ts
2952
- var SHALL_OR_MUST = /\b(SHALL|MUST)\b/;
2953
- function validateChange(change) {
2954
- const findings = [
2955
- ...validateProposal(change),
2956
- ...validateDesign(change),
2957
- ...validateCapabilities(change),
2958
- ...validateTasks(change)
2959
- ];
2960
- return report(findings);
2961
- }
2962
- function report(findings) {
2963
- const errors = findings.filter((f) => f.severity === "error");
2964
- const warnings = findings.filter((f) => f.severity === "warning");
2965
- return { findings, errors, warnings, valid: errors.length === 0 };
2966
- }
2967
- function validateProposal(change) {
2968
- const findings = [];
2969
- for (const spec of PROPOSAL_SECTIONS) {
2970
- const section2 = change.proposal[spec.id];
2971
- const subject = {
2972
- kind: "proposal-section",
2973
- section: spec.id,
2974
- title: spec.title
3002
+ if (content === "" && size > 0) {
3003
+ return {
3004
+ outcome: "unreadable",
3005
+ artifact,
3006
+ reason: `\`${artifact}\` holds ${size} bytes on disk but read back empty \u2014 this is a read failure, not an empty artifact. Nothing has been shown to you, so do not save over it.`
2975
3007
  };
2976
- if (section2.state === "skipped") {
2977
- findings.push(...validateSkip(spec, section2.reason, subject));
2978
- continue;
2979
- }
2980
- if (section2.state === "empty" && spec.required) {
2981
- findings.push({
2982
- severity: "error",
2983
- kind: "proposal-section-unsatisfied",
2984
- subject,
2985
- message: `\`${spec.title}\` has no body and no recorded skip, and is required.`
2986
- });
2987
- }
2988
3008
  }
2989
- return findings;
3009
+ return { outcome: "read", artifact, content, state: stateOf(path, content) };
2990
3010
  }
2991
- function validateSkip(spec, reason, subject) {
2992
- if (!spec.skippable) {
2993
- return [
2994
- {
2995
- severity: "error",
2996
- kind: "proposal-section-not-skippable",
2997
- subject,
2998
- message: `\`${spec.title}\` is not skippable \u2014 only the C4 levels are.`
2999
- }
3000
- ];
3011
+ function listArtifacts(changeDir) {
3012
+ const found = [];
3013
+ for (const name of ROOT_ARTIFACTS2) {
3014
+ if (existsSync6(join8(changeDir, name))) found.push(name);
3001
3015
  }
3002
- if (reason.trim() === "") {
3003
- return [
3004
- {
3005
- severity: "error",
3006
- kind: "proposal-skip-without-reason",
3007
- subject,
3008
- message: `\`${spec.title}\` is skipped with no recorded reason; a skip requires one.`
3009
- }
3010
- ];
3016
+ let capabilities = [];
3017
+ try {
3018
+ capabilities = readdirSync7(join8(changeDir, "specs"), { withFileTypes: true }).filter((e) => e.isDirectory() && CAPABILITY.test(e.name)).map((e) => e.name).sort();
3019
+ } catch {
3020
+ capabilities = [];
3011
3021
  }
3012
- return [];
3013
- }
3014
- function validateDesign(change) {
3015
- if (change.design.length === 0) {
3016
- return [
3017
- {
3018
- severity: "warning",
3019
- kind: "design-empty",
3020
- subject: { kind: "change", change: change.id },
3021
- message: "`design.md` carries no blocks; the change will emit a title and nothing else."
3022
- }
3023
- ];
3022
+ for (const capability of capabilities) {
3023
+ const artifact = `specs/${capability}/spec.md`;
3024
+ if (existsSync6(join8(changeDir, artifact))) found.push(artifact);
3024
3025
  }
3025
- const findings = [];
3026
- change.design.forEach((block, index) => {
3027
- if ((block.title ?? "").trim() !== "") return;
3028
- findings.push({
3029
- severity: "error",
3030
- kind: "design-block-untitled",
3031
- // An untitled block has no name to be reported under, so it is named by where
3032
- // it is and what it is — the two things the author can find it by.
3033
- subject: { kind: "design-block", index, blockKind: block.kind },
3034
- message: `Design block ${index + 1} (\`${block.kind}\`) carries no title, and a block's title is emitted as its \`##\` heading.`
3035
- });
3036
- });
3037
- return findings;
3038
- }
3039
- function draftIsWholeAgain(entry) {
3040
- const declared = entry.declaredRequirements ?? [];
3041
- if (declared.length === 0) return false;
3042
- return declared.every(
3043
- (name) => new RegExp(`^###[ \\t]+Requirement:[ \\t]*${escapeForRegExp(name)}\\s*$`, "m").test(entry.draft)
3044
- );
3045
- }
3046
- function escapeForRegExp(text) {
3047
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3026
+ return found;
3048
3027
  }
3049
- function validateCapabilities(change) {
3050
- const findings = [];
3051
- let derivedDeltas = 0;
3052
- for (const entry of change.capabilities) {
3053
- if (entry.reconstructed === false && !draftIsWholeAgain(entry)) {
3054
- findings.push({
3055
- severity: "error",
3056
- kind: "capability-draft-unreconstructed",
3057
- subject: { kind: "capability", capability: entry.capability },
3058
- message: `\`${entry.capability}\`'s draft could not be rebuilt from this change's delta and opened from the folded text instead${entry.reconstructionReason ? ` (${entry.reconstructionReason})` : ""}. Emitting from it would derive a delta without whatever this change had already added. Restore the draft before emitting.`
3059
- });
3060
- continue;
3061
- }
3062
- if (entry.derivation.outcome === "refused") {
3063
- findings.push(...refusalFindings(entry));
3064
- continue;
3065
- }
3066
- const deltas = deltasOf(entry);
3067
- derivedDeltas += deltas.length;
3068
- findings.push(...emptyDerivationFindings(entry));
3069
- findings.push(...conditionFindings(entry));
3070
- findings.push(...requirementFindings(entry));
3071
- findings.push(...duplicateIdentityFindings(entry));
3072
- }
3073
- if (derivedDeltas === 0) {
3074
- findings.push({
3075
- severity: "error",
3076
- kind: "no-delta-derived",
3077
- subject: { kind: "change", change: change.id },
3078
- message: "the change derives no delta at all, so it alters nothing \u2014 there is no operation to fold."
3079
- });
3028
+ function writeArtifact(changeDir, name, content, options = {}) {
3029
+ const artifact = resolveArtifact(name);
3030
+ if (artifact === null) {
3031
+ return { outcome: "refused", artifact: name, reason: notAnArtifact(name) };
3080
3032
  }
3081
- return findings;
3033
+ const path = join8(changeDir, artifact);
3034
+ if (options.expected !== void 0 && options.overwrite !== true) {
3035
+ const conflict = conflictWith(path, artifact, options.expected);
3036
+ if (conflict) return conflict;
3037
+ }
3038
+ const materialised = !existsSync6(changeDir);
3039
+ try {
3040
+ mkdirSync3(dirname4(path), { recursive: true });
3041
+ writeFileSync3(path, Buffer.from(content, "utf8"));
3042
+ writeSchemaFile(changeDir);
3043
+ } catch (err) {
3044
+ return { outcome: "refused", artifact, reason: describeErrno(artifact, err) };
3045
+ }
3046
+ return { outcome: "written", artifact, path, state: stateOf(path, content), materialised };
3082
3047
  }
3083
- function refusalFindings(entry) {
3084
- if (entry.derivation.outcome !== "refused") return [];
3085
- return entry.derivation.refusals.map((refusal) => {
3086
- if (refusal.kind === "duplicate-identity") {
3087
- const keys = refusal.collisions.map((c) => `\`${c.key}\``).join(", ");
3088
- return {
3089
- severity: "error",
3090
- kind: "duplicate-requirement-identity",
3091
- subject: { kind: "capability", capability: entry.capability },
3092
- message: `two requirements in the ${refusal.side} \`${entry.capability}\` claim one identity: ${keys}. One of them would derive nothing.`
3093
- };
3094
- }
3095
- if (refusal.kind === "unterminated-fence") {
3096
- return {
3097
- severity: "error",
3098
- kind: "capability-derivation-refused",
3099
- subject: { kind: "capability", capability: entry.capability },
3100
- message: `the ${refusal.side} \`${entry.capability}\` opens a fence on line ${refusal.openLine + 1} (\`${refusal.openText.trim()}\`) and never closes it, so everything after it sits inside the requirement above.`
3101
- };
3102
- }
3048
+ function conflictWith(path, artifact, expected) {
3049
+ const current = currentState(path);
3050
+ if (expected === null) {
3051
+ if (current === null) return null;
3103
3052
  return {
3104
- severity: "error",
3105
- kind: "capability-derivation-refused",
3106
- subject: { kind: "capability", capability: entry.capability },
3107
- message: `\`${entry.capability}\` derives nothing: ${refusal.kind}.`
3053
+ outcome: "changed-underneath",
3054
+ artifact,
3055
+ reason: `\`${artifact}\` was created underneath the buffer \u2014 the buffer was opened on a change that did not have this file, and something has written one since. Nothing was written.`,
3056
+ current
3108
3057
  };
3109
- });
3110
- }
3111
- function emptyDerivationFindings(entry) {
3112
- if (entry.derivation.outcome !== "derived") return [];
3113
- if (entry.derivation.deltas.length > 0) return [];
3114
- const subject = { kind: "capability", capability: entry.capability };
3115
- switch (entry.derivation.foldedComparison.kind) {
3116
- case "identical":
3117
- return [
3118
- {
3119
- severity: "warning",
3120
- kind: "capability-unchanged",
3121
- subject,
3122
- message: `\`${entry.capability}\` is open but byte-identical to its folded file; no delta spec is emitted for it.`
3123
- }
3124
- ];
3125
- case "differs-outside-requirements":
3126
- return [
3127
- {
3128
- severity: "error",
3129
- kind: "capability-changed-outside-requirements",
3130
- subject,
3131
- message: `\`${entry.capability}\` differs from its folded file only outside its requirements (its \`## Purpose\` or head). OpenSpec has no operation for that edit \u2014 a delta file carrying no operation cannot fold. Edit \`openspec/specs/${entry.capability}/spec.md\` directly, or revert the edit.`
3132
- }
3133
- ];
3134
- case "differs-in-requirement-order":
3135
- return [
3136
- {
3137
- severity: "error",
3138
- kind: "capability-requirements-reordered",
3139
- subject,
3140
- message: `\`${entry.capability}\`'s requirements are reordered and otherwise unchanged. OpenSpec has no move operation, so the reordering cannot be folded and would be lost.`
3141
- }
3142
- ];
3143
- case "no-folded-counterpart":
3144
- return [
3145
- {
3146
- severity: "error",
3147
- kind: "new-capability-declares-no-requirement",
3148
- subject,
3149
- message: `\`${entry.capability}\` is a new capability that declares no requirement; there is nothing to add.`
3150
- }
3151
- ];
3152
- case "differs":
3153
- return [
3154
- {
3155
- severity: "error",
3156
- kind: "capability-differs-without-delta",
3157
- subject,
3158
- message: `\`${entry.capability}\` differs from its folded file but derives no delta; the derivation and the comparison disagree about the same two documents.`
3159
- }
3160
- ];
3161
- }
3162
- }
3163
- function conditionFindings(entry) {
3164
- if (entry.derivation.outcome !== "derived") return [];
3165
- const findings = [];
3166
- const isNew = entry.derivation.foldedComparison.kind === "no-folded-counterpart";
3167
- const subject = { kind: "capability", capability: entry.capability };
3168
- for (const condition of entry.derivation.conditions) {
3169
- if (isNew && (condition.kind === "purpose-empty" || condition.kind === "purpose-missing")) {
3170
- findings.push({
3171
- severity: "error",
3172
- kind: "new-capability-purpose-empty",
3173
- subject,
3174
- message: `new capability \`${entry.capability}\` has ${condition.kind === "purpose-missing" ? "no `## Purpose` heading" : "an empty `## Purpose`"}; it seeds the folded spec's Purpose, which cannot be left blank.`
3175
- });
3176
- }
3177
- if (condition.kind === "identity-scheme-changed") {
3178
- findings.push({
3179
- severity: "warning",
3180
- kind: "identity-scheme-changed",
3181
- subject,
3182
- message: `\`${entry.capability}\`'s folded file resolves on ${condition.foldedScheme} identity and its draft on ${condition.draftScheme}, so no key matches across them: every requirement derives as a removal plus an addition. Declare the renames if that is not what you meant.`
3183
- });
3184
- }
3185
3058
  }
3186
- return findings;
3187
- }
3188
- function requirementFindings(entry) {
3189
- const findings = [];
3190
- for (const delta of deltasOf(entry)) {
3191
- const block = draftBlockOf(delta);
3192
- if (!block) continue;
3193
- const subject = {
3194
- kind: "requirement",
3195
- capability: entry.capability,
3196
- requirement: block.name
3059
+ if (current === null) {
3060
+ return {
3061
+ outcome: "changed-underneath",
3062
+ artifact,
3063
+ reason: `\`${artifact}\` has gone from disk underneath the buffer \u2014 it was deleted or the change directory was moved (\`openspec archive\` moves the whole directory). Nothing was written.`,
3064
+ current: null
3197
3065
  };
3198
- if (!SHALL_OR_MUST.test(requirementProse(block))) {
3199
- findings.push({
3200
- severity: "error",
3201
- kind: "requirement-without-shall",
3202
- subject,
3203
- message: `\`${block.name}\` in \`${entry.capability}\` states no obligation: its text carries neither SHALL nor MUST.`
3204
- });
3205
- }
3206
- const scenarios = parseScenarios(block);
3207
- if (scenarios.length === 0) {
3208
- findings.push({
3209
- severity: "error",
3210
- kind: "requirement-without-scenario",
3211
- subject,
3212
- message: `\`${block.name}\` in \`${entry.capability}\` has no \`#### Scenario:\`; a requirement is not testable without one.`
3213
- });
3214
- }
3215
- for (const scenario of scenarios) {
3216
- if (scenario.steps.length > 0) continue;
3217
- findings.push({
3218
- severity: "error",
3219
- kind: "scenario-without-steps",
3220
- subject: {
3221
- kind: "scenario",
3222
- capability: entry.capability,
3223
- requirement: block.name,
3224
- scenario: scenario.name
3225
- },
3226
- message: `scenario \`${scenario.name}\` of \`${block.name}\` in \`${entry.capability}\` has no steps.`
3227
- });
3228
- }
3229
3066
  }
3230
- return findings;
3067
+ if (current.hash === expected.hash) return null;
3068
+ return {
3069
+ outcome: "changed-underneath",
3070
+ artifact,
3071
+ reason: `\`${artifact}\` changed underneath the buffer \u2014 the file on disk is no longer the one this buffer was read from. Nothing was written, and your edits are still here: choose whether to overwrite the file with the buffer or discard the buffer and reload the file.`,
3072
+ current
3073
+ };
3231
3074
  }
3232
- function duplicateIdentityFindings(entry) {
3233
- const findings = [];
3234
- const seen = /* @__PURE__ */ new Map();
3235
- for (const delta of deltasOf(entry)) {
3236
- const block = draftBlockOf(delta);
3237
- const name = block ? block.name : delta.operation === "REMOVED" ? delta.base.name : delta.key;
3238
- const section2 = delta.operation === "RENAMED" ? "MODIFIED" : delta.operation;
3239
- const slot = `${section2} ${name}`;
3240
- seen.set(slot, { name, count: (seen.get(slot)?.count ?? 0) + 1 });
3241
- }
3242
- for (const { name, count } of seen.values()) {
3243
- if (count < 2) continue;
3244
- findings.push({
3245
- severity: "error",
3246
- kind: "duplicate-requirement-identity",
3247
- subject: { kind: "requirement", capability: entry.capability, requirement: name },
3248
- message: `\`${name}\` appears ${count} times in \`${entry.capability}\`'s deltas; one of them would be invisible after the fold.`
3249
- });
3250
- }
3251
- return findings;
3075
+ function writeSchemaFile(changeDir) {
3076
+ const path = join8(changeDir, ".openspec.yaml");
3077
+ if (existsSync6(path)) return;
3078
+ writeFileSync3(path, `schema: ${SPEC_DRIVEN.name}
3079
+ `, "utf8");
3252
3080
  }
3253
- function validateTasks(change) {
3254
- const findings = [];
3255
- const citable = new Set(
3256
- citableRequirements(change.capabilities).map((c) => `${c.capability} ${c.key}`)
3257
- );
3258
- for (const group of numberTasks(change.tasks)) {
3259
- if (group.tasks.length === 0) {
3260
- findings.push({
3261
- severity: "error",
3262
- kind: "task-group-empty",
3263
- subject: { kind: "task-group", number: group.number, title: group.title },
3264
- message: `Task group ${group.number} (\`${group.title}\`) carries no tasks.`
3265
- });
3266
- }
3267
- const cites = group.cites;
3268
- if (cites) {
3269
- const subject = {
3270
- kind: "task-group",
3271
- number: group.number,
3272
- title: group.title
3273
- };
3274
- if (cites.capability.includes("`") || cites.key.includes("`") || group.title.includes("`")) {
3275
- findings.push({
3276
- severity: "error",
3277
- kind: "task-citation-unrepresentable",
3278
- subject,
3279
- message: `task group ${group.number} carries a backtick in its title or citation; the emitted citation could not be read back unambiguously.`
3280
- });
3281
- } else if (!citable.has(`${cites.capability} ${cites.key}`)) {
3282
- findings.push({
3283
- severity: "error",
3284
- kind: "task-cites-absent-requirement",
3285
- subject,
3286
- message: `task group ${group.number} cites \`${cites.key}\` in \`${cites.capability}\`, which this change no longer declares.`
3287
- });
3288
- }
3289
- }
3290
- }
3291
- return findings;
3081
+ function notAnArtifact(name) {
3082
+ return `\`${name}\` is not an artifact of this change. The workbench writes exactly the OpenSpec layout \u2014 \`proposal.md\`, \`design.md\`, \`tasks.md\` and \`specs/<capability>/spec.md\` \u2014 within the one open change, and reaches nothing else.`;
3083
+ }
3084
+ function describeErrno(artifact, err) {
3085
+ const e = err;
3086
+ const code = e.code ?? "unknown";
3087
+ const why = {
3088
+ EACCES: "permission denied",
3089
+ EPERM: "the operation is not permitted",
3090
+ EROFS: "the filesystem is read-only",
3091
+ ENOSPC: "the filesystem is full",
3092
+ ENOENT: "the change directory has gone",
3093
+ EISDIR: "a directory is in the way",
3094
+ ENOTDIR: "a file is in the way of the directory it needs"
3095
+ };
3096
+ return `\`${artifact}\`: ${why[code] ?? e.message ?? String(err)} (${code})`;
3292
3097
  }
3293
3098
 
3294
- // src/workbench/shell/provenance.ts
3295
- var NO_ISSUE = Object.freeze({ kind: "none" });
3296
- function provenanceValue(provenance) {
3297
- return provenance.kind === "issue" ? String(provenance.issue) : "none";
3298
- }
3299
- function provenanceFrontmatter(provenance) {
3300
- return `---
3301
- issue: ${provenanceValue(provenance)}
3302
- ---
3303
- `;
3304
- }
3305
- function emitChangeWithProvenance(change, provenance) {
3306
- const emitted = emitChange(change);
3099
+ // server/openspec-validate.ts
3100
+ import { spawnSync } from "node:child_process";
3101
+ import { existsSync as existsSync7 } from "node:fs";
3102
+ import { join as join9 } from "node:path";
3103
+
3104
+ // src/workbench/core/validate.ts
3105
+ var QUOTED_SUBJECT = /"([^"]+)"/;
3106
+ var SCENARIO_IN_MESSAGE = /scenario\s+"([^"]+)"/i;
3107
+ function severityOf(level) {
3108
+ return level.trim().toUpperCase() === "WARNING" ? "warning" : "error";
3109
+ }
3110
+ function capabilityOfIssuePath(path) {
3111
+ if (path === void 0) return null;
3112
+ return /(?:^|\/)([^/]+)\/spec\.md$/.exec(path)?.[1] ?? null;
3113
+ }
3114
+ function openSpecFinding(issue, changeId = "") {
3115
+ const capability = capabilityOfIssuePath(issue.path);
3116
+ const named = QUOTED_SUBJECT.exec(issue.message)?.[1];
3117
+ const scenario = SCENARIO_IN_MESSAGE.exec(issue.message)?.[1];
3118
+ let subject = { kind: "change", change: changeId };
3119
+ if (capability !== null && named !== void 0 && scenario !== void 0) {
3120
+ subject = { kind: "scenario", capability, requirement: named, scenario };
3121
+ } else if (capability !== null && named !== void 0) {
3122
+ subject = { kind: "requirement", capability, requirement: named };
3123
+ } else if (capability !== null) {
3124
+ subject = { kind: "capability", capability };
3125
+ }
3307
3126
  return {
3308
- id: emitted.id,
3309
- files: emitted.files.map(
3310
- (file) => file.path === "proposal.md" ? { path: file.path, content: `${provenanceFrontmatter(provenance)}
3311
- ${file.content}` } : file
3312
- )
3127
+ severity: severityOf(issue.level),
3128
+ kind: "openspec-reported",
3129
+ subject,
3130
+ message: issue.message
3313
3131
  };
3314
3132
  }
3315
-
3316
- // src/workbench/approve/approve.ts
3317
- var ARCHIVE_FLAGS = Object.freeze(["--json", "--yes"]);
3318
- var FLAGS_APPROVAL_NEVER_PASSES = Object.freeze([
3319
- "--skip-specs",
3320
- "--no-validate"
3321
- ]);
3322
- function archiveArgv(changeId) {
3323
- return ["archive", changeId, ...ARCHIVE_FLAGS];
3324
- }
3325
- function changeDirectoryOf(repoRoot, changeId) {
3326
- return join7(repoRoot, "openspec", "changes", changeId);
3133
+ function openSpecFindings(issues, changeId = "") {
3134
+ return issues.map((issue) => openSpecFinding(issue, changeId));
3327
3135
  }
3328
- function approveChange(repoRoot, change, provenance, options = {}) {
3329
- const directory = changeDirectoryOf(repoRoot, change.id);
3330
- const report2 = validateChange(change);
3331
- if (!report2.valid) {
3332
- return { outcome: "refused-validation", errors: report2.errors, warnings: report2.warnings };
3333
- }
3334
- const emitted = emitChangeWithProvenance(change, provenance);
3335
- if (!existsSync5(directory)) return { outcome: "refused-not-emitted", directory };
3336
- const differing = emittedDifferences(directory, emitted);
3337
- if (differing.length > 0) return { outcome: "refused-emission-stale", directory, differing };
3338
- const foldedCapabilities = emitted.files.filter((f) => f.path.startsWith("specs/") && f.path.endsWith("/spec.md")).map((f) => f.path.split("/")[1]);
3339
- const snapshot = takeSnapshot(repoRoot);
3340
- try {
3341
- const archive = runArchive(repoRoot, change.id, options);
3342
- if (archive.kind === "failed") {
3343
- return {
3344
- outcome: "refused-fold-failed",
3345
- code: archive.code,
3346
- message: archive.message,
3347
- rollback: restore(repoRoot, snapshot)
3348
- };
3349
- }
3350
- const incomplete = incompleteness(repoRoot, change.id, archive, foldedCapabilities);
3351
- if (incomplete) {
3352
- return {
3353
- outcome: "refused-fold-incomplete",
3354
- reason: incomplete,
3355
- rollback: restore(repoRoot, snapshot)
3356
- };
3357
- }
3136
+
3137
+ // server/openspec-validate.ts
3138
+ var runOpenSpecValidate = (repoRoot, changeId) => {
3139
+ const local = join9(repoRoot, "node_modules", ".bin", "openspec");
3140
+ const command = existsSync7(local) ? local : "openspec";
3141
+ const run = spawnSync(command, ["validate", changeId, "--strict", "--json", "--no-interactive"], {
3142
+ cwd: repoRoot,
3143
+ encoding: "utf8",
3144
+ // A validator is not allowed to hang the surface that recomputes on every edit.
3145
+ timeout: 2e4
3146
+ });
3147
+ if (run.error) {
3358
3148
  return {
3359
- outcome: "approved",
3360
- archivedAs: archive.archivedAs,
3361
- archivePath: archive.path,
3362
- foldedCapabilities,
3363
- totals: archive.totals,
3364
- warnings: report2.warnings
3149
+ stdout: "",
3150
+ stderr: "",
3151
+ failed: run.error.code === "ENOENT" ? `\`openspec\` could not be run from ${repoRoot} \u2014 the tool this workbench validates through is not installed there` : run.error.message
3365
3152
  };
3366
- } finally {
3367
- discardSnapshot(snapshot);
3368
- }
3369
- }
3370
- function emittedDifferences(directory, emitted) {
3371
- const differing = [];
3372
- for (const file of emitted.files) {
3373
- const target = join7(directory, file.path);
3374
- let onDisk;
3375
- try {
3376
- onDisk = readFileSync5(target, "utf8");
3377
- } catch {
3378
- differing.push(file.path);
3379
- continue;
3380
- }
3381
- if (onDisk !== file.content) differing.push(file.path);
3382
- }
3383
- return differing;
3384
- }
3385
- var SNAPSHOT_TREES = Object.freeze(["specs", "changes"]);
3386
- function takeSnapshot(repoRoot) {
3387
- const directory = mkdtempSync(join7(tmpdir(), "specsketch-approve-"));
3388
- const present = [];
3389
- for (const tree of SNAPSHOT_TREES) {
3390
- const source = join7(repoRoot, "openspec", tree);
3391
- if (!existsSync5(source)) continue;
3392
- cpSync(source, join7(directory, tree), { recursive: true });
3393
- present.push(tree);
3394
- }
3395
- return { root: repoRoot, present, directory };
3396
- }
3397
- function restore(repoRoot, snapshot) {
3398
- try {
3399
- for (const tree of SNAPSHOT_TREES) {
3400
- const target = join7(repoRoot, "openspec", tree);
3401
- rmSync(target, { recursive: true, force: true });
3402
- if (!snapshot.present.includes(tree)) continue;
3403
- mkdirSync3(join7(repoRoot, "openspec"), { recursive: true });
3404
- cpSync(join7(snapshot.directory, tree), target, { recursive: true });
3405
- }
3406
- return { kind: "restored" };
3407
- } catch (error) {
3408
- return { kind: "restore-failed", error: error instanceof Error ? error.message : String(error) };
3409
3153
  }
3410
- }
3411
- function discardSnapshot(snapshot) {
3412
- rmSync(snapshot.directory, { recursive: true, force: true });
3413
- }
3414
- function resolveOpenspecBinary(repoRoot) {
3415
- const local = join7(repoRoot, "node_modules", ".bin", "openspec");
3416
- return existsSync5(local) ? local : "openspec";
3417
- }
3418
- var NO_TOTALS = Object.freeze({ added: 0, modified: 0, removed: 0, renamed: 0 });
3419
- function runArchive(repoRoot, changeId, options) {
3420
- const bin = options.openspecBin ?? resolveOpenspecBinary(repoRoot);
3421
- let stdout;
3422
- try {
3423
- stdout = execFileSync(bin, [...archiveArgv(changeId)], {
3424
- cwd: repoRoot,
3425
- encoding: "utf8"
3426
- });
3427
- } catch (error) {
3428
- const captured = error.stdout;
3429
- if (typeof captured === "string" && captured.trim() !== "") stdout = captured;
3430
- else {
3431
- return {
3432
- kind: "failed",
3433
- code: "archive_not_run",
3434
- message: error instanceof Error ? error.message : String(error)
3435
- };
3436
- }
3437
- }
3438
- return readArchiveJson(stdout);
3439
- }
3440
- function readArchiveJson(stdout) {
3154
+ return { stdout: run.stdout ?? "", stderr: run.stderr ?? "" };
3155
+ };
3156
+ function validateChangeOnDisk(repoRoot, changeId, run = runOpenSpecValidate) {
3157
+ const result = run(repoRoot, changeId);
3158
+ if (result.failed !== void 0) return { outcome: "unavailable", reason: result.failed };
3441
3159
  let parsed;
3442
3160
  try {
3443
- parsed = JSON.parse(stdout);
3161
+ parsed = JSON.parse(result.stdout);
3444
3162
  } catch {
3445
- return { kind: "failed", code: "archive_output_unreadable", message: stdout.trim() };
3163
+ const detail = (result.stderr || result.stdout).trim().split("\n")[0] ?? "";
3164
+ return {
3165
+ outcome: "unavailable",
3166
+ reason: `\`openspec validate\` did not answer with JSON${detail === "" ? "" : `: ${detail}`}`
3167
+ };
3446
3168
  }
3447
- if (!parsed.archive) {
3448
- const diagnostic = parsed.status?.[0];
3169
+ const item = parsed.items?.find((i) => i.id === changeId) ?? parsed.items?.[0];
3170
+ if (!item) {
3449
3171
  return {
3450
- kind: "failed",
3451
- code: diagnostic?.code ?? "archive_failed",
3452
- message: diagnostic?.message ?? "openspec archive reported no result and no diagnostic."
3172
+ outcome: "unavailable",
3173
+ reason: `\`openspec validate\` reported nothing for \`${changeId}\``
3453
3174
  };
3454
3175
  }
3455
- const totals = parsed.archive.totals;
3176
+ const findings = openSpecFindings(item.issues ?? []);
3177
+ const errors = findings.filter((f) => f.severity === "error");
3178
+ const warnings = findings.filter((f) => f.severity === "warning");
3456
3179
  return {
3457
- kind: "archived",
3458
- archivedAs: parsed.archive.archivedAs ?? "",
3459
- path: parsed.archive.path ?? "",
3460
- specsUpdated: parsed.archive.specsUpdated === true,
3461
- totals: totals ? {
3462
- added: totals.added ?? 0,
3463
- modified: totals.modified ?? 0,
3464
- removed: totals.removed ?? 0,
3465
- renamed: totals.renamed ?? 0
3466
- } : NO_TOTALS
3180
+ outcome: "validated",
3181
+ // The tool's own verdict, not one recomputed from the issue list — under
3182
+ // `--strict` a warning fails, and re-deriving that rule here would be the second
3183
+ // implementation this module exists to avoid.
3184
+ valid: item.valid === true,
3185
+ errors,
3186
+ warnings
3467
3187
  };
3468
3188
  }
3469
- function incompleteness(repoRoot, changeId, archive, foldedCapabilities) {
3470
- if (!archive.specsUpdated) return { kind: "no-spec-updated" };
3471
- const folded = archive.totals.added + archive.totals.modified + archive.totals.removed + archive.totals.renamed;
3472
- if (folded === 0) return { kind: "nothing-folded" };
3473
- for (const capability of foldedCapabilities) {
3474
- if (!existsSync5(join7(repoRoot, "openspec", "specs", capability, "spec.md"))) {
3475
- return { kind: "capability-not-folded", capability };
3189
+
3190
+ // server/approvals.ts
3191
+ import { existsSync as existsSync8, readFileSync as readFileSync6, statSync as statSync5, writeFileSync as writeFileSync4 } from "node:fs";
3192
+ import { join as join10 } from "node:path";
3193
+ var SIDECAR = ".specsketch.json";
3194
+ var OVERLAY_DECLARATION = "authoring-overlay \u2014 specsketch only, never lifecycle truth";
3195
+ var STAGE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3196
+ var REAL_IO2 = { readText: (path) => readFileSync6(path, "utf8") };
3197
+ function readApprovals(changeDir, io = REAL_IO2) {
3198
+ const path = join10(changeDir, SIDECAR);
3199
+ let size;
3200
+ try {
3201
+ const stat = statSync5(path);
3202
+ if (!stat.isFile()) {
3203
+ return { outcome: "unreadable", reason: `\`${SIDECAR}\` is not a file` };
3476
3204
  }
3205
+ size = stat.size;
3206
+ } catch (err) {
3207
+ if (err.code === "ENOENT") return { outcome: "absent" };
3208
+ return { outcome: "unreadable", reason: describe(err) };
3477
3209
  }
3478
- if (existsSync5(changeDirectoryOf(repoRoot, changeId))) return { kind: "change-still-in-changes" };
3479
- const archived = join7(repoRoot, "openspec", "changes", "archive", archive.archivedAs);
3480
- if (archive.archivedAs === "" || !isDirectory(archived)) {
3481
- return { kind: "archive-directory-missing", expected: archived };
3482
- }
3483
- return null;
3484
- }
3485
- function isDirectory(path) {
3210
+ let text;
3486
3211
  try {
3487
- return statSync3(path).isDirectory();
3488
- } catch {
3489
- return false;
3212
+ text = io.readText(path);
3213
+ } catch (err) {
3214
+ return { outcome: "unreadable", reason: describe(err) };
3490
3215
  }
3491
- }
3492
-
3493
- // src/workbench/write/write-change.ts
3494
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, readdirSync as readdirSync6, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
3495
- import { dirname as dirname4, join as join8 } from "node:path";
3496
-
3497
- // src/workbench/shell/lifecycle.ts
3498
- var SPEC_DRIVEN = Object.freeze({
3499
- name: "spec-driven",
3500
- artifacts: Object.freeze(["proposal", "specs", "design", "tasks"])
3501
- });
3502
-
3503
- // src/workbench/write/write-change.ts
3504
- function changeDirectory(repoRoot, changeId) {
3505
- return join8(repoRoot, "openspec", "changes", changeId);
3506
- }
3507
- function writeChange(repoRoot, change, provenance, options = {}) {
3508
- const directory = changeDirectory(repoRoot, change.id);
3509
- const report2 = validateChange(change);
3510
- if (!report2.valid) {
3216
+ if (text === "" && size > 0) {
3511
3217
  return {
3512
- outcome: "validation-failed",
3513
- directory,
3514
- errors: report2.errors,
3515
- warnings: report2.warnings
3218
+ outcome: "unreadable",
3219
+ reason: `\`${SIDECAR}\` holds ${size} bytes on disk but read back empty \u2014 this is a read failure, not an absence of approvals`
3516
3220
  };
3517
3221
  }
3518
- if (!options.overwrite && isNonEmptyDirectory(directory)) {
3519
- return { outcome: "directory-exists", directory };
3520
- }
3521
- const emitted = emitChangeWithProvenance(change, provenance);
3522
- writeEmitted(directory, emitted);
3523
- writeSchemaFile(directory);
3524
- return {
3525
- outcome: "written",
3526
- directory,
3527
- files: emitted.files.map((f) => f.path),
3528
- warnings: report2.warnings
3529
- };
3530
- }
3531
- function isNonEmptyDirectory(directory) {
3532
- try {
3533
- if (!statSync4(directory).isDirectory()) return true;
3534
- return readdirSync6(directory).length > 0;
3535
- } catch {
3536
- return false;
3537
- }
3538
- }
3539
- function readEmittedChange(directory, id) {
3540
- const files = [];
3541
- for (const name of ["proposal.md", "design.md", "tasks.md"]) {
3542
- files.push({ path: name, content: readFileSync6(join8(directory, name), "utf8") });
3543
- }
3544
- const specsDir = join8(directory, "specs");
3545
- let capabilities = [];
3222
+ let parsed;
3546
3223
  try {
3547
- capabilities = readdirSync6(specsDir).filter((name) => statSync4(join8(specsDir, name)).isDirectory()).sort();
3548
- } catch {
3549
- capabilities = [];
3550
- }
3551
- for (const capability of capabilities) {
3552
- files.push({
3553
- path: `specs/${capability}/spec.md`,
3554
- content: readFileSync6(join8(specsDir, capability, "spec.md"), "utf8")
3555
- });
3556
- }
3557
- return { id, files };
3558
- }
3559
- function writeSchemaFile(directory) {
3560
- const path = join8(directory, ".openspec.yaml");
3561
- if (existsSync6(path)) return;
3562
- writeFileSync3(path, `schema: ${SPEC_DRIVEN.name}
3563
- `, "utf8");
3564
- }
3565
- function writeEmitted(directory, emitted) {
3566
- mkdirSync4(directory, { recursive: true });
3567
- for (const file of emitted.files) {
3568
- const target = join8(directory, file.path);
3569
- mkdirSync4(dirname4(target), { recursive: true });
3570
- writeFileSync3(target, file.content, "utf8");
3571
- }
3572
- }
3573
-
3574
- // src/workbench/core/reconstruct.ts
3575
- function headingName(block) {
3576
- const match = /^###[ \t]+Requirement:[ \t]*(.*)$/m.exec(block);
3577
- return match?.[1]?.trim() ?? null;
3578
- }
3579
- function reconstructDraft(folded, delta) {
3580
- const capability = delta.capability;
3581
- if (folded.trim() === "") {
3582
- const purpose = delta.purpose === null ? "" : `
3583
- ## Purpose
3584
- ${delta.purpose}
3585
- `;
3586
- const head = `# ${capability} Specification
3587
- ${purpose}
3588
- ## Requirements
3589
-
3590
- `;
3224
+ parsed = JSON.parse(text);
3225
+ } catch (err) {
3591
3226
  return {
3592
- outcome: "reconstructed",
3593
- capability,
3594
- draft: `${head}${delta.added.map((b) => b.trimEnd()).join("\n\n")}
3595
- `
3227
+ outcome: "unreadable",
3228
+ reason: `\`${SIDECAR}\` is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 the approvals it holds cannot be read, and this is not an absence of approvals`
3596
3229
  };
3597
3230
  }
3598
- const parsed = parseSpecFile(folded);
3599
- if (parsed.outside.length > 0) {
3231
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3232
+ return { outcome: "unreadable", reason: `\`${SIDECAR}\` is not an approval store` };
3233
+ }
3234
+ const raw = parsed.approvals;
3235
+ if (raw === void 0) {
3600
3236
  return {
3601
- outcome: "unreconstructable",
3602
- capability,
3603
- reason: "the folded spec carries content outside its requirement blocks, which a rebuild would drop",
3604
- draft: folded
3237
+ outcome: "unreadable",
3238
+ reason: `\`${SIDECAR}\` carries no \`approvals\` map \u2014 it is not the file this workbench wrote`
3605
3239
  };
3606
3240
  }
3607
- const blocks = /* @__PURE__ */ new Map();
3608
- const order = [];
3609
- for (const requirement of parsed.requirements) {
3610
- blocks.set(requirement.name, requirement.text);
3611
- order.push(requirement.name);
3241
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3242
+ return { outcome: "unreadable", reason: `\`${SIDECAR}\`'s \`approvals\` is not a map of stages` };
3612
3243
  }
3613
- for (const pair of delta.renamed) {
3614
- const existing = blocks.get(pair.from);
3615
- if (existing === void 0) {
3616
- return {
3617
- outcome: "unreconstructable",
3618
- capability,
3619
- reason: `the delta renames \`${pair.from}\`, which the folded spec does not declare`,
3620
- draft: folded
3621
- };
3244
+ const approvals = {};
3245
+ for (const [stage, value] of Object.entries(raw)) {
3246
+ if (!STAGE.test(stage)) {
3247
+ return { outcome: "unreadable", reason: `\`${SIDECAR}\` records \`${stage}\`, which is not a stage` };
3622
3248
  }
3623
- blocks.delete(pair.from);
3624
- blocks.set(pair.to, existing.replace(pair.from, pair.to));
3625
- order[order.indexOf(pair.from)] = pair.to;
3626
- }
3627
- for (const name of delta.removed) {
3628
- if (!blocks.delete(name)) {
3249
+ const approvedAt = value?.approvedAt;
3250
+ if (typeof approvedAt !== "string" || approvedAt === "") {
3629
3251
  return {
3630
- outcome: "unreconstructable",
3631
- capability,
3632
- reason: `the delta removes \`${name}\`, which the folded spec does not declare`,
3633
- draft: folded
3252
+ outcome: "unreadable",
3253
+ reason: `\`${SIDECAR}\`'s entry for \`${stage}\` carries no \`approvedAt\` \u2014 the store is corrupt, not empty`
3634
3254
  };
3635
3255
  }
3636
- order.splice(order.indexOf(name), 1);
3256
+ approvals[stage] = { approvedAt };
3637
3257
  }
3638
- for (const block of delta.modified) {
3639
- const name = headingName(block);
3640
- if (name === null || !blocks.has(name)) {
3641
- return {
3642
- outcome: "unreconstructable",
3643
- capability,
3644
- reason: name === null ? "the delta modifies a block with no readable heading" : `the delta modifies \`${name}\`, which is not in the folded spec after renames and removals`,
3645
- draft: folded
3646
- };
3647
- }
3648
- blocks.set(name, block.trimEnd());
3258
+ return { outcome: "read", approvals };
3259
+ }
3260
+ function setApproval(changeDir, stage, approved, now = /* @__PURE__ */ new Date()) {
3261
+ if (!STAGE.test(stage)) {
3262
+ return { outcome: "failed", reason: `\`${stage}\` is not a stage of this change` };
3649
3263
  }
3650
- for (const block of delta.added) {
3651
- const name = headingName(block);
3652
- if (name === null) {
3653
- return {
3654
- outcome: "unreconstructable",
3655
- capability,
3656
- reason: "the delta adds a block with no readable heading",
3657
- draft: folded
3658
- };
3659
- }
3660
- if (!blocks.has(name)) order.push(name);
3661
- blocks.set(name, block.trimEnd());
3264
+ if (!existsSync8(changeDir)) {
3265
+ return {
3266
+ outcome: "failed",
3267
+ reason: `the change directory does not exist yet \u2014 save an artifact before approving a stage`
3268
+ };
3662
3269
  }
3663
- const body = order.map((name) => blocks.get(name).trimEnd()).join("\n\n");
3664
- return {
3665
- outcome: "reconstructed",
3666
- capability,
3667
- draft: `${parsed.headText.trimEnd()}
3668
-
3669
- ${body}
3670
- `
3270
+ const current = readApprovals(changeDir);
3271
+ if (current.outcome === "unreadable") {
3272
+ return {
3273
+ outcome: "failed",
3274
+ reason: `the approval store could not be read, so it was not written: ${current.reason}`
3275
+ };
3276
+ }
3277
+ const next = {
3278
+ ...current.outcome === "read" ? current.approvals : {}
3671
3279
  };
3672
- }
3673
-
3674
- // src/workbench/agent/credential-probe.ts
3675
- import { existsSync as existsSync7, readFileSync as readFileSync7, readdirSync as readdirSync7, statSync as statSync5 } from "node:fs";
3676
- import { delimiter, join as join9 } from "node:path";
3677
- function readSubscriptionToken(home) {
3678
- const path = join9(home, ".claude", ".credentials.json");
3679
- if (!existsSync7(path)) return null;
3280
+ if (approved) next[stage] = { approvedAt: now.toISOString() };
3281
+ else delete next[stage];
3680
3282
  try {
3681
- const parsed = JSON.parse(readFileSync7(path, "utf8"));
3682
- const token = parsed.claudeAiOauth?.accessToken;
3683
- if (typeof token !== "string" || token === "") return null;
3684
- const expiresAt = parsed.claudeAiOauth?.expiresAt;
3685
- if (typeof expiresAt === "number" && expiresAt <= Date.now()) return null;
3686
- return token;
3687
- } catch {
3688
- return null;
3689
- }
3690
- }
3691
-
3692
- // src/workbench/agent/model-boundary.ts
3693
- import { builtinModels } from "@earendil-works/pi-ai/providers/all";
3694
- var MAX_TOOL_ROUNDS = 6;
3695
- var DEFAULT_PROVIDER = "anthropic";
3696
- var DEFAULT_MODEL2 = "claude-opus-4-5";
3697
- async function runTurn(input, deps = {}) {
3698
- const provider = deps.provider ?? DEFAULT_PROVIDER;
3699
- const modelId = deps.modelId ?? DEFAULT_MODEL2;
3700
- const maxRounds = deps.maxRounds ?? MAX_TOOL_ROUNDS;
3701
- if (deps.oauthToken) process.env.ANTHROPIC_OAUTH_TOKEN = deps.oauthToken;
3702
- else if (deps.apiKey) process.env.ANTHROPIC_API_KEY = deps.apiKey;
3703
- const models = deps.models ?? builtinModels();
3704
- const model = models.getModel(provider, modelId);
3705
- if (!model) return { kind: "failed", reason: `no such model: ${provider}/${modelId}` };
3706
- const messages = [
3707
- { role: "user", content: input.prompt, timestamp: Date.now() }
3708
- ];
3709
- const context = {
3710
- systemPrompt: input.systemPrompt,
3711
- messages,
3712
- tools: [...input.tools]
3283
+ writeFileSync4(
3284
+ join10(changeDir, SIDECAR),
3285
+ `${JSON.stringify({ overlay: OVERLAY_DECLARATION, approvals: next }, null, " ")}
3286
+ `,
3287
+ "utf8"
3288
+ );
3289
+ } catch (err) {
3290
+ return { outcome: "failed", reason: `the approval could not be stored \u2014 ${describe(err)}` };
3291
+ }
3292
+ return { outcome: "written", approvals: next };
3293
+ }
3294
+ function describe(err) {
3295
+ const e = err;
3296
+ const why = {
3297
+ EACCES: "permission denied",
3298
+ EPERM: "the operation is not permitted",
3299
+ EROFS: "the filesystem is read-only",
3300
+ ENOSPC: "the filesystem is full",
3301
+ ENOENT: "the change directory has gone",
3302
+ EISDIR: "a directory is in the way"
3713
3303
  };
3714
- const calls = [];
3715
- const served = `${provider}/${modelId}`;
3716
- let answer = "";
3717
- for (let round = 0; round < maxRounds; round += 1) {
3718
- const stream = models.stream(model, context);
3719
- const pending = [];
3720
- let message;
3721
- try {
3722
- for await (const event of stream) {
3723
- if (event.type === "toolcall_end") pending.push(event.toolCall);
3724
- }
3725
- message = await stream.result();
3726
- } catch (err) {
3727
- return { kind: "failed", reason: err instanceof Error ? err.message : String(err) };
3728
- }
3729
- if (message.stopReason === "error") {
3730
- return { kind: "failed", reason: message.errorMessage ?? "the model returned an error" };
3731
- }
3732
- answer = textOf2(message);
3733
- context.messages.push(message);
3734
- if (pending.length === 0) return { kind: "answered", answer, calls, served };
3735
- for (const call of pending) {
3736
- const result = await input.execute(call.name, call.arguments);
3737
- calls.push({ name: call.name, arguments: call.arguments, result });
3738
- context.messages.push({
3739
- role: "toolResult",
3740
- toolCallId: call.id,
3741
- toolName: call.name,
3742
- content: [{ type: "text", text: result }],
3743
- // The executor reports a refusal as ordinary text — a refused proposal is a
3744
- // normal outcome the model should reason about, not a transport error.
3745
- isError: false,
3746
- timestamp: Date.now()
3747
- });
3748
- }
3749
- }
3750
- return { kind: "cut-short", answer, calls, served, rounds: maxRounds };
3751
- }
3752
- function textOf2(message) {
3753
- return message.content.filter((block) => block.type === "text").map((block) => block.text).join("").trim();
3754
- }
3755
-
3756
- // src/workbench/agent/tools.ts
3757
- import { Type } from "@earendil-works/pi-ai";
3758
- var AGENT_TOOLS = [
3759
- {
3760
- name: "add_task_group",
3761
- description: "Propose a group of implementation tasks for this change. Use for planning the work; one group per coherent area, each task a single concrete action.",
3762
- parameters: Type.Object({
3763
- title: Type.String({ description: 'Short group title, e.g. "Search" or "Byte-safe writes"' }),
3764
- tasks: Type.Array(Type.String(), {
3765
- description: "Task titles, one per task. Each states one concrete action."
3766
- }),
3767
- capability: Type.Optional(
3768
- Type.String({
3769
- description: "Optional. The capability of the requirement this group implements \u2014 omit for a chore group that implements none."
3770
- })
3771
- ),
3772
- requirement: Type.Optional(
3773
- Type.String({
3774
- description: "Optional. The exact requirement name this group implements, as it appears after `### Requirement:`."
3775
- })
3776
- )
3777
- })
3778
- },
3779
- {
3780
- name: "add_requirement",
3781
- description: "Propose a requirement to draft into an open capability. The block must be full markdown: a `### Requirement:` heading, RFC-2119 SHALL text, and at least one `#### Scenario:` with WHEN/THEN steps.",
3782
- parameters: Type.Object({
3783
- capability: Type.String({ description: "A capability already open in this change" }),
3784
- block: Type.String({ description: "The complete requirement block, in markdown" })
3785
- })
3786
- },
3787
- {
3788
- name: "set_proposal_section",
3789
- description: "Propose the body of one proposal section.",
3790
- parameters: Type.Object({
3791
- section: Type.String({
3792
- description: `One of: ${PROPOSAL_SECTIONS.map((s) => s.id).join(", ")}`
3793
- }),
3794
- body: Type.String({ description: "The section body, in markdown" })
3795
- })
3796
- },
3797
- {
3798
- name: "add_design_block",
3799
- description: "Propose a design block. Use `mermaid` for a diagram (C4Context / C4Container / C4Component / C4Dynamic render as diagrams), `markdown` for prose.",
3800
- parameters: Type.Object({
3801
- kind: Type.String({ description: "markdown | mermaid | sketch" }),
3802
- title: Type.String({ description: "The block's heading" }),
3803
- text: Type.String({ description: "The block body" })
3804
- })
3805
- }
3806
- ];
3807
- var DESIGN_KINDS = /* @__PURE__ */ new Set(["markdown", "mermaid", "sketch"]);
3808
- function stripLeadingNumber(text) {
3809
- return text.replace(/^\s*\d+(?:\.\d+)*[.)]?\s+/, "").trim();
3810
- }
3811
- function proposalFromToolCall(name, args, shape) {
3812
- switch (name) {
3813
- case "add_task_group": {
3814
- const title = stripLeadingNumber(String(args["title"] ?? ""));
3815
- const tasks = Array.isArray(args["tasks"]) ? args["tasks"].map((t) => stripLeadingNumber(String(t))).filter((t) => t !== "") : [];
3816
- if (title === "" || tasks.length === 0) {
3817
- return { kind: "refused", tool: name, missing: "a title and at least one task" };
3818
- }
3819
- const capability = String(args["capability"] ?? "").trim();
3820
- const key = String(args["requirement"] ?? "").trim();
3821
- if (capability === "" && key === "") return { kind: "task-group", title, tasks };
3822
- if (capability === "" || key === "") {
3823
- return {
3824
- kind: "refused",
3825
- tool: name,
3826
- missing: "both a capability and a requirement name, or neither"
3827
- };
3828
- }
3829
- if (shape.citable !== void 0 && !shape.citable.some((c) => c.capability === capability && c.key === key)) {
3830
- return {
3831
- kind: "refused",
3832
- tool: name,
3833
- missing: `\`${key}\` in \`${capability}\` is not a requirement this change declares`
3834
- };
3835
- }
3836
- return { kind: "task-group", title, tasks, cites: { capability, key } };
3837
- }
3838
- case "add_requirement": {
3839
- const capability = String(args["capability"] ?? "").trim();
3840
- const block = String(args["block"] ?? "").trim();
3841
- if (!shape.openCapabilities.includes(capability)) {
3842
- return {
3843
- kind: "refused",
3844
- tool: name,
3845
- missing: `capability \`${capability}\` is not open in this change`
3846
- };
3847
- }
3848
- if (!/^###[ \t]+Requirement:/m.test(block)) {
3849
- return { kind: "refused", tool: name, missing: "a `### Requirement:` heading" };
3850
- }
3851
- return { kind: "requirement", capability, block };
3852
- }
3853
- case "set_proposal_section": {
3854
- const section2 = String(args["section"] ?? "").trim();
3855
- const known = PROPOSAL_SECTIONS.find((s) => s.id === section2);
3856
- if (!known) {
3857
- return { kind: "refused", tool: name, missing: `no proposal section \`${section2}\`` };
3858
- }
3859
- return {
3860
- kind: "proposal-section",
3861
- section: known.id,
3862
- body: String(args["body"] ?? "")
3863
- };
3864
- }
3865
- case "add_design_block": {
3866
- const blockKind = String(args["kind"] ?? "").trim();
3867
- if (!DESIGN_KINDS.has(blockKind)) {
3868
- return { kind: "refused", tool: name, missing: `no design block kind \`${blockKind}\`` };
3869
- }
3870
- const title = String(args["title"] ?? "").trim();
3871
- if (title === "") return { kind: "refused", tool: name, missing: "a block title" };
3872
- return {
3873
- kind: "design-block",
3874
- blockKind,
3875
- title,
3876
- text: String(args["text"] ?? "")
3877
- };
3878
- }
3879
- default:
3880
- return { kind: "refused", tool: name, missing: `no such tool \`${name}\`` };
3881
- }
3882
- }
3883
- function toolResultFor(proposal) {
3884
- switch (proposal.kind) {
3885
- case "task-group":
3886
- return `Proposed task group "${proposal.title}" with ${proposal.tasks.length} task(s)${proposal.cites ? `, implementing \`${proposal.cites.key}\`` : ", implementing no requirement"}. Awaiting the author's apply; nothing is written yet.`;
3887
- case "requirement":
3888
- return `Proposed a requirement for \`${proposal.capability}\`. Awaiting the author's apply; nothing is written yet.`;
3889
- case "proposal-section":
3890
- return `Proposed a body for the \`${proposal.section}\` section. Awaiting the author's apply; nothing is written yet.`;
3891
- case "design-block":
3892
- return `Proposed a ${proposal.blockKind} design block "${proposal.title}". Awaiting the author's apply; nothing is written yet.`;
3893
- case "refused":
3894
- return `Refused: ${proposal.tool} needs ${proposal.missing}. Nothing was proposed.`;
3895
- }
3304
+ return `\`${SIDECAR}\`: ${why[e.code ?? ""] ?? e.message ?? String(err)} (${e.code ?? "unknown"})`;
3896
3305
  }
3897
3306
 
3898
3307
  // server/workbench-host.ts
@@ -3900,44 +3309,45 @@ var CHANGE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3900
3309
  function changeContextFor(target, source) {
3901
3310
  const read = [];
3902
3311
  let changeText = "";
3903
- if (source.change) {
3904
- for (const file of emitChange({ ...source.change, id: target.changeId }).files) {
3905
- if (file.content.replace(/^#[ \t].*$/m, "").trim() === "") continue;
3906
- changeText += `
3907
-
3908
- ===== ${file.path} (unsaved draft \u2014 what the author is looking at) =====
3909
- ${file.content}`;
3910
- read.push(`${file.path} (draft)`);
3911
- }
3912
- return { read, changeText };
3913
- }
3914
3312
  if (!target.resuming) return { read, changeText };
3915
3313
  for (const name of ["proposal.md", "tasks.md"]) {
3916
- const file = join10(target.changeDir, name);
3917
- if (!existsSync8(file)) continue;
3314
+ const file = join11(target.changeDir, name);
3315
+ if (!existsSync9(file)) continue;
3918
3316
  changeText += `
3919
3317
 
3920
3318
  ===== ${name} =====
3921
- ${readFileSync8(file, "utf8")}`;
3319
+ ${readFileSync7(file, "utf8")}`;
3922
3320
  read.push(name);
3923
3321
  }
3924
3322
  for (const capability of source.openCapabilities ?? []) {
3925
- const file = join10(target.changeDir, "specs", capability, "spec.md");
3926
- if (!existsSync8(file)) continue;
3323
+ const file = join11(target.changeDir, "specs", capability, "spec.md");
3324
+ if (!existsSync9(file)) continue;
3927
3325
  changeText += `
3928
3326
 
3929
3327
  ===== specs/${capability}/spec.md (the change's deltas) =====
3930
- ${readFileSync8(file, "utf8")}`;
3328
+ ${readFileSync7(file, "utf8")}`;
3931
3329
  read.push(`specs/${capability}/spec.md`);
3932
3330
  }
3933
3331
  return { read, changeText };
3934
3332
  }
3333
+ function citableOnDisk(target) {
3334
+ if (!target.resuming) return [];
3335
+ const specs = [];
3336
+ for (const artifact of listArtifacts(target.changeDir)) {
3337
+ const capability = /^specs\/([^/]+)\/spec\.md$/.exec(artifact)?.[1];
3338
+ if (capability === void 0) continue;
3339
+ const read = readArtifact(target.changeDir, artifact);
3340
+ if (read.outcome !== "read") continue;
3341
+ specs.push({ capability, content: read.content });
3342
+ }
3343
+ return citableRequirements(specs);
3344
+ }
3935
3345
  var WorkbenchHostError = class extends Error {
3936
3346
  };
3937
3347
  function findRepoRoot(start) {
3938
3348
  let dir = resolve3(start);
3939
3349
  for (; ; ) {
3940
- if (existsSync8(join10(dir, "openspec"))) return dir;
3350
+ if (existsSync9(join11(dir, "openspec"))) return dir;
3941
3351
  const parent = dirname5(dir);
3942
3352
  if (parent === dir) return null;
3943
3353
  dir = parent;
@@ -3959,41 +3369,20 @@ function resolveTarget(cwd, changeId) {
3959
3369
  );
3960
3370
  }
3961
3371
  const changeDir = changeDirectory(repoRoot, changeId);
3962
- return { repoRoot, changeId, changeDir, resuming: existsSync8(changeDir) };
3372
+ return { repoRoot, changeId, changeDir, resuming: existsSync9(changeDir) };
3373
+ }
3374
+ function readSchema(target) {
3375
+ try {
3376
+ return resolveSchema(readFileSync7(join11(target.changeDir, ".openspec.yaml"), "utf8"));
3377
+ } catch {
3378
+ return { kind: "unknown-schema", name: "" };
3379
+ }
3963
3380
  }
3964
3381
  function mountWorkbenchHost(app, options) {
3965
3382
  const { target } = options;
3966
3383
  const reader = createFoldedSpecReader(target.repoRoot);
3967
3384
  app.get("/api/workbench/context", (c) => {
3968
- let document = null;
3969
- let deltaCapabilities = [];
3970
- const capabilities = [];
3971
- if (target.resuming) {
3972
- try {
3973
- const emitted = readEmittedChange(target.changeDir, target.changeId);
3974
- document = parseChangeDocument(emitted);
3975
- deltaCapabilities = emitted.files.map((f) => /^specs\/([^/]+)\/spec\.md$/.exec(f.path)?.[1]).filter((n) => n !== void 0);
3976
- for (const spec of document.deltaSpecs) {
3977
- const folded = reader.readCapability(spec.capability);
3978
- const rebuilt = reconstructDraft(folded ?? "", spec);
3979
- capabilities.push({
3980
- capability: spec.capability,
3981
- folded,
3982
- draft: rebuilt.draft,
3983
- reconstructed: rebuilt.outcome === "reconstructed",
3984
- ...rebuilt.outcome === "unreconstructable" ? { reason: rebuilt.reason } : {},
3985
- // The requirement names the EMITTED delta declares. Carried so the
3986
- // emission gate can ask whether the draft is whole NOW, rather than
3987
- // only whether it loaded whole — otherwise an author who restores a
3988
- // requirement by hand stays blocked with no way forward inside the
3989
- // tool, which is a lock rather than a guard.
3990
- declared: [...spec.added, ...spec.modified].map((b) => /^###[ \t]+Requirement:[ \t]*(.*)$/m.exec(b)?.[1]?.trim()).filter((n) => n !== void 0 && n !== "")
3991
- });
3992
- }
3993
- } catch {
3994
- document = null;
3995
- }
3996
- }
3385
+ const artifacts = target.resuming ? listArtifacts(target.changeDir).map((name) => readArtifact(target.changeDir, name)) : [];
3997
3386
  return c.json({
3998
3387
  repoRoot: target.repoRoot,
3999
3388
  changeId: target.changeId,
@@ -4001,12 +3390,16 @@ function mountWorkbenchHost(app, options) {
4001
3390
  resuming: target.resuming,
4002
3391
  fixture: options.fixture === true,
4003
3392
  specsDir: reader.specsDir,
4004
- document,
4005
- deltaCapabilities,
4006
- capabilities,
4007
- // True when every delta this change carries was rebuilt. A capability that
4008
- // failed is listed with its reason rather than appearing to carry no edits.
4009
- draftsRestorable: capabilities.every((c2) => c2.reconstructed)
3393
+ // The schema the change's own `.openspec.yaml` names — the rail's stages come
3394
+ // from it, never from a default this host picked. A change with no such file
3395
+ // resolves to `unknown-schema`, which is a fact to report, not one to paper over.
3396
+ schema: readSchema(target),
3397
+ artifacts,
3398
+ // The approval overlay, so a reopened change comes back with the stages the
3399
+ // author already gated still gated. Carried as its own outcome: an
3400
+ // unreadable store must reach the surface as unreadable, never as an
3401
+ // absence of approvals that would silently re-lock every stage.
3402
+ approvals: readApprovals(target.changeDir)
4010
3403
  });
4011
3404
  });
4012
3405
  app.get("/api/workbench/capabilities", (c) => {
@@ -4025,52 +3418,69 @@ function mountWorkbenchHost(app, options) {
4025
3418
  throw err;
4026
3419
  }
4027
3420
  });
4028
- app.post("/api/workbench/emit", async (c) => {
3421
+ app.get(
3422
+ "/api/workbench/artifacts",
3423
+ (c) => c.json({
3424
+ changeDir: target.changeDir,
3425
+ exists: existsSync9(target.changeDir),
3426
+ artifacts: listArtifacts(target.changeDir)
3427
+ })
3428
+ );
3429
+ app.get("/api/workbench/artifact", (c) => {
3430
+ const name = c.req.query("artifact") ?? "";
3431
+ const result = readArtifact(target.changeDir, name);
3432
+ return c.json({ result }, result.outcome === "refused" ? 400 : 200);
3433
+ });
3434
+ app.post("/api/workbench/artifact", async (c) => {
4029
3435
  const body = await c.req.json();
4030
- if (!body.change) return c.json({ error: "missing-change" }, 400);
4031
- const result = writeChange(
4032
- target.repoRoot,
4033
- { ...body.change, id: target.changeId },
4034
- options.provenance ?? NO_ISSUE,
4035
- body.overwrite === true ? { overwrite: true } : {}
4036
- );
3436
+ if (typeof body.artifact !== "string" || typeof body.content !== "string") {
3437
+ return c.json({ error: "missing-artifact" }, 400);
3438
+ }
3439
+ const result = writeArtifact(target.changeDir, body.artifact, body.content, {
3440
+ ...body.expected === void 0 ? {} : { expected: body.expected },
3441
+ // Never a default. An overwrite after a changed-underneath refusal is the
3442
+ // author's explicit answer, and taking it unasked is the whole of what that
3443
+ // refusal exists to prevent.
3444
+ ...body.overwrite === true ? { overwrite: true } : {}
3445
+ });
4037
3446
  return c.json({ result }, result.outcome === "written" ? 200 : 409);
4038
3447
  });
4039
- app.post("/api/workbench/archive", async (c) => {
3448
+ app.get(
3449
+ "/api/workbench/validity",
3450
+ (c) => c.json({ result: validateChangeOnDisk(target.repoRoot, target.changeId) })
3451
+ );
3452
+ app.get("/api/workbench/approvals", (c) => c.json({ result: readApprovals(target.changeDir) }));
3453
+ app.post("/api/workbench/approvals", async (c) => {
4040
3454
  const body = await c.req.json();
4041
- if (!body.change) return c.json({ error: "missing-change" }, 400);
4042
- const result = approveChange(
4043
- target.repoRoot,
4044
- { ...body.change, id: target.changeId },
4045
- options.provenance ?? NO_ISSUE
4046
- );
4047
- return c.json({ result }, result.outcome === "approved" ? 200 : 409);
3455
+ if (typeof body.stage !== "string" || typeof body.approved !== "boolean") {
3456
+ return c.json({ error: "missing-stage" }, 400);
3457
+ }
3458
+ const result = setApproval(target.changeDir, body.stage, body.approved);
3459
+ return c.json({ result }, result.outcome === "written" ? 200 : 409);
4048
3460
  });
4049
3461
  app.post("/api/workbench/agent/turn", async (c) => {
4050
3462
  const body = await c.req.json();
4051
3463
  const prompt = (body.prompt ?? "").trim();
4052
3464
  if (prompt === "") return c.json({ error: "missing-prompt" }, 400);
4053
- const token = readSubscriptionToken(options.home ?? homedir3());
4054
- if (!token && !process.env["ANTHROPIC_API_KEY"]) {
4055
- return c.json({ outcome: { kind: "no-credential" } }, 200);
4056
- }
3465
+ const citable = citableOnDisk(target);
4057
3466
  const shape = {
4058
3467
  openCapabilities: body.openCapabilities ?? [],
4059
- ...body.change ? { citable: citableRequirements(body.change.capabilities) } : {}
3468
+ ...citable.length === 0 ? {} : { citable }
4060
3469
  };
4061
3470
  const { read, changeText } = changeContextFor(target, body);
4062
- const proposals = [];
4063
- const outcome = await runTurn(
3471
+ const { outcome, proposals } = await runAgentTurn(
4064
3472
  {
3473
+ // Standing instructions on their OWN channel, forwarded to the harness's
3474
+ // system-prompt option — never joined to the prompt.
4065
3475
  systemPrompt: [
4066
3476
  "You are helping author an OpenSpec change in the SpecSketch workbench.",
4067
3477
  `The change is \`${target.changeId}\`, in the repository at ${target.repoRoot}.`,
4068
3478
  shape.openCapabilities.length > 0 ? `Capabilities open in this change: ${shape.openCapabilities.join(", ")}.` : "No capability is open in this change yet.",
4069
3479
  body.scope ? `The author is looking at ${body.scope}.` : "",
4070
3480
  "",
4071
- "Use the tools to propose concrete items. Everything you propose is reviewed and",
4072
- "applied by the author \u2014 you never write to the change yourself, so propose freely",
4073
- "and say briefly what you proposed and why.",
3481
+ "Return your proposals as the structured result. Everything you propose is",
3482
+ "reviewed and applied by the author \u2014 you never write to the change yourself, so",
3483
+ "propose freely and say briefly in `answer` what you proposed and why.",
4074
3484
  "",
4075
3485
  "Ground every proposal in the change below. Use its own vocabulary: if it says",
4076
3486
  '"changes", do not write "plans". Do not invent states, sections or files it does',
@@ -4079,21 +3489,18 @@ function mountWorkbenchHost(app, options) {
4079
3489
  changeText === "" ? "(This change has nothing written yet.)" : changeText
4080
3490
  ].filter((line) => line !== "").join("\n"),
4081
3491
  prompt,
4082
- tools: AGENT_TOOLS,
4083
- execute: (name, args) => {
4084
- const proposal = proposalFromToolCall(name, args, shape);
4085
- proposals.push(proposal);
4086
- return toolResultFor(proposal);
4087
- }
3492
+ cwd: target.repoRoot,
3493
+ session: `specsketch-workbench-${target.changeId}`,
3494
+ shape
4088
3495
  },
4089
- { oauthToken: token ?? void 0 }
3496
+ options.home !== void 0 ? { home: options.home } : {}
4090
3497
  );
4091
3498
  return c.json({ outcome, proposals, read });
4092
3499
  });
4093
3500
  }
4094
3501
 
4095
3502
  // server/cli.ts
4096
- var CLI_VERSION = true ? "2026.08.26.3" : "dev";
3503
+ var CLI_VERSION = true ? "2026.08.31.1" : "dev";
4097
3504
  var USAGE = `Usage: specsketch <change-id> [--port <n>] [--fixture]
4098
3505
  specsketch login [--profile <name>] [--web-url <url>]
4099
3506
  specsketch --version
@@ -4168,13 +3575,13 @@ var MIME = {
4168
3575
  function resolveClientDir(moduleUrl) {
4169
3576
  const here = dirname6(fileURLToPath(moduleUrl));
4170
3577
  const candidates = [
4171
- join11(here, "..", "client"),
3578
+ join12(here, "..", "client"),
4172
3579
  // packed: dist/cli -> dist/client
4173
- join11(here, "..", "dist", "client")
3580
+ join12(here, "..", "dist", "client")
4174
3581
  // repo: server/ -> dist/client
4175
3582
  ];
4176
3583
  for (const dir of candidates) {
4177
- if (existsSync9(join11(dir, "index.html"))) return dir;
3584
+ if (existsSync10(join12(dir, "index.html"))) return dir;
4178
3585
  }
4179
3586
  return null;
4180
3587
  }
@@ -4182,9 +3589,9 @@ function staticResponse(clientDir, pathname) {
4182
3589
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
4183
3590
  const target = resolve4(clientDir, rel === "" ? "index.html" : rel);
4184
3591
  if (!target.startsWith(resolve4(clientDir))) return null;
4185
- const file = existsSync9(target) && extname(target) ? target : join11(clientDir, "index.html");
4186
- if (!existsSync9(file)) return null;
4187
- return new Response(readFileSync9(file), {
3592
+ const file = existsSync10(target) && extname(target) ? target : join12(clientDir, "index.html");
3593
+ if (!existsSync10(file)) return null;
3594
+ return new Response(readFileSync8(file), {
4188
3595
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
4189
3596
  });
4190
3597
  }
@@ -4200,7 +3607,7 @@ function detectCredentialSource(env = process.env, home = homedir4()) {
4200
3607
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
4201
3608
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
4202
3609
  try {
4203
- const dir = join11(home, ".config", "anthropic", "credentials");
3610
+ const dir = join12(home, ".config", "anthropic", "credentials");
4204
3611
  if (readdirSync8(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
4205
3612
  } catch {
4206
3613
  }