@coopcli/specsketch 2026.817.2 → 2026.825.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.
package/dist/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // server/cli.ts
4
- import { existsSync as existsSync8, readFileSync as readFileSync9, readdirSync as readdirSync8 } from "node:fs";
4
+ import { existsSync as existsSync9, readFileSync as readFileSync9, readdirSync as readdirSync8 } from "node:fs";
5
5
  import { homedir as homedir4 } from "node:os";
6
6
  import { dirname as dirname6, extname, join as join11, normalize as normalize2, resolve as resolve4 } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -2150,7 +2150,7 @@ var ChangeWorkspace = class {
2150
2150
  };
2151
2151
 
2152
2152
  // server/workbench-host.ts
2153
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
2153
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "node:fs";
2154
2154
  import { homedir as homedir3 } from "node:os";
2155
2155
  import { dirname as dirname5, join as join10, resolve as resolve3 } from "node:path";
2156
2156
 
@@ -2491,6 +2491,7 @@ function changeDocument(change) {
2491
2491
  design: change.design,
2492
2492
  groups: numberTasks(change.tasks),
2493
2493
  testing: testingRows(change.capabilities),
2494
+ proposalExtras: change.proposalExtras,
2494
2495
  deltaSpecs: change.capabilities.filter(emitsDeltaSpec).map(deltaSpecDocument).sort((a, b) => a.capability < b.capability ? -1 : a.capability > b.capability ? 1 : 0)
2495
2496
  };
2496
2497
  }
@@ -2517,10 +2518,14 @@ ${UNSATISFIED_MARKER}
2517
2518
  }
2518
2519
  }
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
+ `);
2520
2524
  const parts = [`# ${doc.id}
2521
- `];
2525
+ `, ...after(null)];
2522
2526
  for (const spec of PROPOSAL_SECTIONS) {
2523
2527
  parts.push(renderProposalSection(spec, doc.proposal[spec.id]));
2528
+ parts.push(...after(spec.id));
2524
2529
  }
2525
2530
  return `${parts.join("\n")}`;
2526
2531
  }
@@ -2560,7 +2565,8 @@ function renderTasks(doc) {
2560
2565
  `];
2561
2566
  for (const task of group.tasks) {
2562
2567
  const box = task.done ? "x" : " ";
2563
- lines.push(`- [${box}] ${task.number} ${task.title}`);
2568
+ lines.push(`- [${box}] ${task.number} ${task.title.split("\n").join(`
2569
+ ${TASK_CONTINUATION}`)}`);
2564
2570
  }
2565
2571
  parts.push(`${lines.join("\n")}
2566
2572
  `);
@@ -2640,6 +2646,8 @@ function emitChange(change) {
2640
2646
  return renderChangeDocument(changeDocument(change));
2641
2647
  }
2642
2648
  var TASK_LINE = /^- \[([ xX])\] (\d+\.\d+) (.*)$/;
2649
+ var TASK_CONTINUATION = " ";
2650
+ var TASK_CONTINUATION_LINE = /^ {2}(.*)$/;
2643
2651
  var GROUP_CITATION = /^(.*) — implements `([^`]+)`: `([^`]+)`$/;
2644
2652
  var GROUP_HEADING = /^## (\d+)\. (.*)$/;
2645
2653
  var FENCE = /^```(\S*)\s*$/;
@@ -2648,11 +2656,13 @@ function parseChangeDocument(emitted) {
2648
2656
  const proposalSrc = byPath.get("proposal.md") ?? "";
2649
2657
  const designSrc = byPath.get("design.md") ?? "";
2650
2658
  const tasksSrc = byPath.get("tasks.md") ?? "";
2659
+ const parsedProposal = parseProposalFile(proposalSrc);
2651
2660
  const groups = parseTasksFile(tasksSrc);
2652
2661
  const deltaSpecs = emitted.files.filter((f) => f.path.startsWith("specs/") && f.path.endsWith("/spec.md")).map((f) => parseDeltaSpecFile(capabilityOfPath(f.path), f.content));
2653
2662
  return {
2654
2663
  id: emitted.id,
2655
- proposal: parseProposalFile(proposalSrc),
2664
+ proposal: parsedProposal.draft,
2665
+ proposalExtras: parsedProposal.extras,
2656
2666
  design: parseDesignFile(designSrc),
2657
2667
  groups: numberTasks(groups),
2658
2668
  testing: parseTestingTable(tasksSrc),
@@ -2667,25 +2677,44 @@ function parseProposalFile(src) {
2667
2677
  const lines = src.split("\n");
2668
2678
  const bodies = /* @__PURE__ */ new Map();
2669
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;
2688
+ };
2670
2689
  for (const line of lines) {
2671
2690
  const p = probe2(line);
2672
2691
  const spec = PROPOSAL_SECTIONS.find(
2673
2692
  (s) => p === `${"#".repeat(s.level)} ${s.title}`
2674
2693
  );
2675
2694
  if (spec) {
2695
+ flushExtra();
2676
2696
  current = spec.id;
2697
+ lastFixed = spec.id;
2677
2698
  bodies.set(spec.id, []);
2678
2699
  continue;
2679
2700
  }
2680
2701
  if (ANY_HEADING.test(p)) {
2702
+ flushExtra();
2681
2703
  current = null;
2704
+ if (/^#[ \t]/.test(p) && !/^##/.test(p)) continue;
2705
+ extra = { afterSection: lastFixed, lines: [p] };
2682
2706
  continue;
2683
2707
  }
2684
2708
  if (current) bodies.get(current).push(p);
2709
+ else if (extra) extra.lines.push(p);
2685
2710
  }
2711
+ flushExtra();
2686
2712
  const draft = emptyProposal();
2687
2713
  for (const spec of PROPOSAL_SECTIONS) {
2688
- const body = (bodies.get(spec.id) ?? []).join("\n").trim();
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");
2689
2718
  if (body === "" || body === UNSATISFIED_MARKER) {
2690
2719
  draft[spec.id] = { state: "empty" };
2691
2720
  continue;
@@ -2696,7 +2725,7 @@ function parseProposalFile(src) {
2696
2725
  }
2697
2726
  draft[spec.id] = { state: "authored", body };
2698
2727
  }
2699
- return draft;
2728
+ return { draft, extras };
2700
2729
  }
2701
2730
  var DESIGN_HEADING = /^##[ \t]+(.*)$/;
2702
2731
  function parseDesignFile(src) {
@@ -2775,7 +2804,17 @@ function parseTasksFile(src) {
2775
2804
  const task = TASK_LINE.exec(p);
2776
2805
  if (task && open) {
2777
2806
  const done = (task[1] ?? " ").toLowerCase() === "x";
2778
- open.tasks.push({ title: (task[3] ?? "").trim(), done });
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] ?? ""}`
2817
+ };
2779
2818
  }
2780
2819
  }
2781
2820
  if (open) groups.push(open);
@@ -2997,10 +3036,29 @@ function validateDesign(change) {
2997
3036
  });
2998
3037
  return findings;
2999
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, "\\$&");
3048
+ }
3000
3049
  function validateCapabilities(change) {
3001
3050
  const findings = [];
3002
3051
  let derivedDeltas = 0;
3003
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
+ }
3004
3062
  if (entry.derivation.outcome === "refused") {
3005
3063
  findings.push(...refusalFindings(entry));
3006
3064
  continue;
@@ -3433,8 +3491,16 @@ function isDirectory(path) {
3433
3491
  }
3434
3492
 
3435
3493
  // src/workbench/write/write-change.ts
3436
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, readdirSync as readdirSync6, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
3494
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, readdirSync as readdirSync6, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
3437
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
3438
3504
  function changeDirectory(repoRoot, changeId) {
3439
3505
  return join8(repoRoot, "openspec", "changes", changeId);
3440
3506
  }
@@ -3454,6 +3520,7 @@ function writeChange(repoRoot, change, provenance, options = {}) {
3454
3520
  }
3455
3521
  const emitted = emitChangeWithProvenance(change, provenance);
3456
3522
  writeEmitted(directory, emitted);
3523
+ writeSchemaFile(directory);
3457
3524
  return {
3458
3525
  outcome: "written",
3459
3526
  directory,
@@ -3489,6 +3556,12 @@ function readEmittedChange(directory, id) {
3489
3556
  }
3490
3557
  return { id, files };
3491
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
+ }
3492
3565
  function writeEmitted(directory, emitted) {
3493
3566
  mkdirSync4(directory, { recursive: true });
3494
3567
  for (const file of emitted.files) {
@@ -3599,11 +3672,11 @@ ${body}
3599
3672
  }
3600
3673
 
3601
3674
  // src/workbench/agent/credential-probe.ts
3602
- import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync as readdirSync7, statSync as statSync5 } from "node:fs";
3675
+ import { existsSync as existsSync7, readFileSync as readFileSync7, readdirSync as readdirSync7, statSync as statSync5 } from "node:fs";
3603
3676
  import { delimiter, join as join9 } from "node:path";
3604
3677
  function readSubscriptionToken(home) {
3605
3678
  const path = join9(home, ".claude", ".credentials.json");
3606
- if (!existsSync6(path)) return null;
3679
+ if (!existsSync7(path)) return null;
3607
3680
  try {
3608
3681
  const parsed = JSON.parse(readFileSync7(path, "utf8"));
3609
3682
  const token = parsed.claudeAiOauth?.accessToken;
@@ -3841,7 +3914,7 @@ ${file.content}`;
3841
3914
  if (!target.resuming) return { read, changeText };
3842
3915
  for (const name of ["proposal.md", "tasks.md"]) {
3843
3916
  const file = join10(target.changeDir, name);
3844
- if (!existsSync7(file)) continue;
3917
+ if (!existsSync8(file)) continue;
3845
3918
  changeText += `
3846
3919
 
3847
3920
  ===== ${name} =====
@@ -3850,7 +3923,7 @@ ${readFileSync8(file, "utf8")}`;
3850
3923
  }
3851
3924
  for (const capability of source.openCapabilities ?? []) {
3852
3925
  const file = join10(target.changeDir, "specs", capability, "spec.md");
3853
- if (!existsSync7(file)) continue;
3926
+ if (!existsSync8(file)) continue;
3854
3927
  changeText += `
3855
3928
 
3856
3929
  ===== specs/${capability}/spec.md (the change's deltas) =====
@@ -3864,7 +3937,7 @@ var WorkbenchHostError = class extends Error {
3864
3937
  function findRepoRoot(start) {
3865
3938
  let dir = resolve3(start);
3866
3939
  for (; ; ) {
3867
- if (existsSync7(join10(dir, "openspec"))) return dir;
3940
+ if (existsSync8(join10(dir, "openspec"))) return dir;
3868
3941
  const parent = dirname5(dir);
3869
3942
  if (parent === dir) return null;
3870
3943
  dir = parent;
@@ -3886,7 +3959,7 @@ function resolveTarget(cwd, changeId) {
3886
3959
  );
3887
3960
  }
3888
3961
  const changeDir = changeDirectory(repoRoot, changeId);
3889
- return { repoRoot, changeId, changeDir, resuming: existsSync7(changeDir) };
3962
+ return { repoRoot, changeId, changeDir, resuming: existsSync8(changeDir) };
3890
3963
  }
3891
3964
  function mountWorkbenchHost(app, options) {
3892
3965
  const { target } = options;
@@ -3908,7 +3981,13 @@ function mountWorkbenchHost(app, options) {
3908
3981
  folded,
3909
3982
  draft: rebuilt.draft,
3910
3983
  reconstructed: rebuilt.outcome === "reconstructed",
3911
- ...rebuilt.outcome === "unreconstructable" ? { reason: rebuilt.reason } : {}
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 !== "")
3912
3991
  });
3913
3992
  }
3914
3993
  } catch {
@@ -4014,7 +4093,7 @@ function mountWorkbenchHost(app, options) {
4014
4093
  }
4015
4094
 
4016
4095
  // server/cli.ts
4017
- var CLI_VERSION = true ? "2026.08.17.2" : "dev";
4096
+ var CLI_VERSION = true ? "2026.08.25.1" : "dev";
4018
4097
  var USAGE = `Usage: specsketch <change-id> [--port <n>] [--fixture]
4019
4098
  specsketch login [--profile <name>] [--web-url <url>]
4020
4099
  specsketch --version
@@ -4095,7 +4174,7 @@ function resolveClientDir(moduleUrl) {
4095
4174
  // repo: server/ -> dist/client
4096
4175
  ];
4097
4176
  for (const dir of candidates) {
4098
- if (existsSync8(join11(dir, "index.html"))) return dir;
4177
+ if (existsSync9(join11(dir, "index.html"))) return dir;
4099
4178
  }
4100
4179
  return null;
4101
4180
  }
@@ -4103,8 +4182,8 @@ function staticResponse(clientDir, pathname) {
4103
4182
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
4104
4183
  const target = resolve4(clientDir, rel === "" ? "index.html" : rel);
4105
4184
  if (!target.startsWith(resolve4(clientDir))) return null;
4106
- const file = existsSync8(target) && extname(target) ? target : join11(clientDir, "index.html");
4107
- if (!existsSync8(file)) return null;
4185
+ const file = existsSync9(target) && extname(target) ? target : join11(clientDir, "index.html");
4186
+ if (!existsSync9(file)) return null;
4108
4187
  return new Response(readFileSync9(file), {
4109
4188
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
4110
4189
  });