@pathmode/mcp-server 1.16.0 → 1.16.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 (47) hide show
  1. package/dist/index.js +1371 -107
  2. package/dist/{api-client.d.ts → packages/mcp-server/src/api-client.d.ts} +1 -0
  3. package/dist/packages/mcp-server/src/api-client.d.ts.map +1 -0
  4. package/dist/{cli-info.d.ts → packages/mcp-server/src/cli-info.d.ts} +1 -0
  5. package/dist/packages/mcp-server/src/cli-info.d.ts.map +1 -0
  6. package/dist/{index.d.ts → packages/mcp-server/src/index.d.ts} +1 -0
  7. package/dist/packages/mcp-server/src/index.d.ts.map +1 -0
  8. package/dist/{install-skills.d.ts → packages/mcp-server/src/install-skills.d.ts} +1 -0
  9. package/dist/packages/mcp-server/src/install-skills.d.ts.map +1 -0
  10. package/dist/{intent-compiler.d.ts → packages/mcp-server/src/intent-compiler.d.ts} +3 -0
  11. package/dist/packages/mcp-server/src/intent-compiler.d.ts.map +1 -0
  12. package/dist/{local-reader.d.ts → packages/mcp-server/src/local-reader.d.ts} +1 -0
  13. package/dist/packages/mcp-server/src/local-reader.d.ts.map +1 -0
  14. package/dist/{measurement-schema.d.ts → packages/mcp-server/src/measurement-schema.d.ts} +1 -0
  15. package/dist/packages/mcp-server/src/measurement-schema.d.ts.map +1 -0
  16. package/dist/packages/mcp-server/src/openspec-import.d.ts +56 -0
  17. package/dist/packages/mcp-server/src/openspec-import.d.ts.map +1 -0
  18. package/dist/packages/mcp-server/src/openspec-product-reader.d.ts +85 -0
  19. package/dist/packages/mcp-server/src/openspec-product-reader.d.ts.map +1 -0
  20. package/dist/packages/mcp-server/src/openspec-writer.d.ts +66 -0
  21. package/dist/packages/mcp-server/src/openspec-writer.d.ts.map +1 -0
  22. package/dist/{pathmode-section.d.ts → packages/mcp-server/src/pathmode-section.d.ts} +1 -0
  23. package/dist/packages/mcp-server/src/pathmode-section.d.ts.map +1 -0
  24. package/dist/{push-spec.d.ts → packages/mcp-server/src/push-spec.d.ts} +1 -0
  25. package/dist/packages/mcp-server/src/push-spec.d.ts.map +1 -0
  26. package/dist/{readiness.d.ts → packages/mcp-server/src/readiness.d.ts} +1 -0
  27. package/dist/packages/mcp-server/src/readiness.d.ts.map +1 -0
  28. package/dist/{save-policy.d.ts → packages/mcp-server/src/save-policy.d.ts} +1 -0
  29. package/dist/packages/mcp-server/src/save-policy.d.ts.map +1 -0
  30. package/dist/{setup.d.ts → packages/mcp-server/src/setup.d.ts} +1 -0
  31. package/dist/packages/mcp-server/src/setup.d.ts.map +1 -0
  32. package/dist/scripts/openspec-corpus/openspec-reader.d.ts +142 -0
  33. package/dist/scripts/openspec-corpus/openspec-reader.d.ts.map +1 -0
  34. package/manifest.json +1 -1
  35. package/package.json +1 -1
  36. package/dist/api-client.d.ts.map +0 -1
  37. package/dist/cli-info.d.ts.map +0 -1
  38. package/dist/index.d.ts.map +0 -1
  39. package/dist/install-skills.d.ts.map +0 -1
  40. package/dist/intent-compiler.d.ts.map +0 -1
  41. package/dist/local-reader.d.ts.map +0 -1
  42. package/dist/measurement-schema.d.ts.map +0 -1
  43. package/dist/pathmode-section.d.ts.map +0 -1
  44. package/dist/push-spec.d.ts.map +0 -1
  45. package/dist/readiness.d.ts.map +0 -1
  46. package/dist/save-policy.d.ts.map +0 -1
  47. package/dist/setup.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -34135,6 +34135,7 @@ async function runInstallSkills() {
34135
34135
  * the personality prompt and file format generators. No AI API calls happen here.
34136
34136
  */
34137
34137
  Object.defineProperty(exports, "__esModule", ({ value: true }));
34138
+ exports.toVerificationChecks = toVerificationChecks;
34138
34139
  exports.getCompileIntentPrompt = getCompileIntentPrompt;
34139
34140
  exports.formatIntentMd = formatIntentMd;
34140
34141
  exports.formatCursorRules = formatCursorRules;
@@ -35568,6 +35569,511 @@ function outcomeSelectorError(input) {
35568
35569
  }
35569
35570
 
35570
35571
 
35572
+ /***/ }),
35573
+
35574
+ /***/ 8388:
35575
+ /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
35576
+
35577
+ "use strict";
35578
+
35579
+ /**
35580
+ * OpenSpec change -> IntentFields: the import half of the bridge whose emit half is
35581
+ * openspec-writer.ts.
35582
+ *
35583
+ * This exists for the OpenSpec-first user, who arrives with changes already written. Without it,
35584
+ * signing up means re-typing judgment that already exists in their repo — the exact activation
35585
+ * wall for the persona the acquisition story is aimed at. With it, adopting a change is one tool
35586
+ * call, and the text is DETERMINISTICALLY EXTRACTED — never paraphrased by a model. That is a
35587
+ * weaker claim than byte-verbatim and deliberately so: the reader strips markdown structure and
35588
+ * HTML comments, and joins multi-line prose. What it never does is reword. The distinction that
35589
+ * matters is the digest: normalizeAnchor collapses whitespace, so the extracted text hashes to
35590
+ * the same digest as the author's, and a confirmation made after import survives exactly as long
35591
+ * as the imported claim does.
35592
+ *
35593
+ * The projection is openSpecChangeToSpec — the SAME reader the preflight grades with, so an
35594
+ * imported intent's verdict matches the preflight verdict of the change it came from on FIVE of
35595
+ * six gates. `goal` is the exception, by design: see the title note below. Pinned in
35596
+ * openspec-import.test.ts rather than asserted here.
35597
+ *
35598
+ * The one place import deliberately reads MORE than the grading projection: the title. The
35599
+ * reader derives title from the directory name (hyphens to spaces), because as an instrument it
35600
+ * must not reward pretty H1s — the corpus numbers depend on it staying frozen. An import is not a
35601
+ * measurement: if proposal.md opens with an H1, that is the author's own name for the change and
35602
+ * the intent should carry it. The grading projection is NOT changed for this; recalibrating the
35603
+ * corpus over a title heuristic would contaminate recorded numbers for zero judgment value.
35604
+ *
35605
+ * That is where the `goal` gate can diverge, and the divergence is reachable rather than
35606
+ * theoretical: a change directory named `wip`, `fix`, `update`, or `v2` fails the title gate as a
35607
+ * title while its H1 passes. The import is the more useful reading of the same change; the
35608
+ * preflight is the frozen instrument. Both are right for their job.
35609
+ *
35610
+ * What does not import, said out loud rather than silently dropped: design.md prose (their
35611
+ * technical context and decisions stay in the change folder, where their workflow owns them) and
35612
+ * tasks.md task lists (implementation state belongs to their flow; only the verification contract
35613
+ * is judgment and travels). The tool receipt names both.
35614
+ */
35615
+ var __importDefault = (this && this.__importDefault) || function (mod) {
35616
+ return (mod && mod.__esModule) ? mod : { "default": mod };
35617
+ };
35618
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
35619
+ exports.importOpenSpecChange = importOpenSpecChange;
35620
+ const fs_1 = __importDefault(__nccwpck_require__(9896));
35621
+ const path_1 = __importDefault(__nccwpck_require__(6928));
35622
+ const openspec_product_reader_1 = __nccwpck_require__(80);
35623
+ const intent_compiler_1 = __nccwpck_require__(6488);
35624
+ /** First `# ` heading in proposal.md, if the author gave the change a name of their own. */
35625
+ function proposalTitle(dir) {
35626
+ let raw;
35627
+ try {
35628
+ raw = fs_1.default.readFileSync(path_1.default.join(dir, 'proposal.md'), 'utf-8');
35629
+ }
35630
+ catch {
35631
+ return null;
35632
+ }
35633
+ let inFence = false;
35634
+ for (const line of raw.split('\n')) {
35635
+ if (/^\s*```/.test(line)) {
35636
+ inFence = !inFence;
35637
+ continue;
35638
+ }
35639
+ if (inFence)
35640
+ continue;
35641
+ const h1 = line.match(/^#\s+(.+?)\s*$/);
35642
+ if (h1)
35643
+ return h1[1].trim();
35644
+ // Stop at the first section heading: an H1 below `## Why` is body content, not a name.
35645
+ if (/^##\s/.test(line))
35646
+ break;
35647
+ }
35648
+ return null;
35649
+ }
35650
+ function importOpenSpecChange(ref) {
35651
+ // Routed through the product adapter so import and preflight agree about which document
35652
+ // carries the judgment. A change written with the pathmode-intent schema has a change-local
35653
+ // intent.md, and that file wins; a standard OpenSpec change is read exactly as before.
35654
+ const read = (0, openspec_product_reader_1.readOpenSpecChangeForPathmode)(ref);
35655
+ if ((0, openspec_product_reader_1.isProductReadError)(read)) {
35656
+ return { fields: {}, notes: [], hasContent: false, error: read.error };
35657
+ }
35658
+ const projection = read.spec;
35659
+ const fromIntentMd = read.source === 'intent.md';
35660
+ const notes = [...read.notes];
35661
+ if (fromIntentMd) {
35662
+ // Named rather than silently dropped, per the evidence-reference decision: we do not
35663
+ // manufacture Pathmode EvidenceItems out of a text reference yet, because an anchor to
35664
+ // evidence that does not exist in the workspace is a broken promise, not provenance.
35665
+ notes.push('Evidence References in intent.md were preserved as text in the source file, not converted into linked evidence. Anchoring them to workspace evidence is a later step.');
35666
+ }
35667
+ if (!fromIntentMd && fs_1.default.existsSync(path_1.default.join(ref.dir, 'design.md'))) {
35668
+ notes.push('design.md prose (context, decisions) stays in the change folder; only its Non-Goals and verification headings were read.');
35669
+ }
35670
+ if (!fromIntentMd && fs_1.default.existsSync(path_1.default.join(ref.dir, 'tasks.md'))) {
35671
+ notes.push('tasks.md task lists stay with the OpenSpec workflow; only the verification contract was imported.');
35672
+ }
35673
+ const fields = {
35674
+ // intent.md carries its own H1, which the adapter already preferred; the proposal's H1 is
35675
+ // the fallback only when the artifacts are the source.
35676
+ title: fromIntentMd ? projection.title : (proposalTitle(ref.dir) ?? projection.title),
35677
+ objective: projection.objective,
35678
+ outcomes: projection.outcomes,
35679
+ constraints: projection.constraints,
35680
+ edgeCases: projection.edgeCases,
35681
+ // Kinds are preserved, not flattened. An OpenSpec change projects only `test`-shaped
35682
+ // checks because that is all its artifacts distinguish, but an intent.md records the
35683
+ // feedback loop it meant (fastest check, manual check, shipped signal, regression
35684
+ // guard), and rewriting all of those to `test` on import would lose the author's meaning
35685
+ // in the one direction where we control both ends.
35686
+ verification: {
35687
+ checks: (0, intent_compiler_1.toVerificationChecks)(projection.verification).map((c, i) => ({
35688
+ id: `vc${i}`,
35689
+ kind: c.kind,
35690
+ status: c.status ?? 'unknown',
35691
+ description: c.description,
35692
+ ...(c.verifies ? { verifies: c.verifies } : {}),
35693
+ })),
35694
+ },
35695
+ };
35696
+ // Confirmations ride along so a dimension confirmed in the file stays confirmed in the
35697
+ // imported intent, rather than being re-asked as if the author had never answered.
35698
+ if (projection.confirmations) {
35699
+ fields.confirmations = projection.confirmations;
35700
+ }
35701
+ return { fields, notes, hasContent: projection.hasContent };
35702
+ }
35703
+
35704
+
35705
+ /***/ }),
35706
+
35707
+ /***/ 80:
35708
+ /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
35709
+
35710
+ "use strict";
35711
+
35712
+ /**
35713
+ * The product-judgment adapter for an OpenSpec change.
35714
+ *
35715
+ * WHY THIS IS A SEPARATE LAYER. `scripts/openspec-corpus/openspec-reader.ts` is a frozen
35716
+ * instrument: the corpus measurements in docs/research/openspec-corpus-audit.md are only
35717
+ * reproducible while it keeps reading exactly what it read then (that is also why it derives the
35718
+ * title from the directory name rather than an H1, and why `openspec-import.ts` layers the nicer
35719
+ * title on top instead of editing it). Teaching that file about `intent.md` would silently
35720
+ * invalidate every recorded rate. So preference lives here, above it, and the reader stays
35721
+ * untouched.
35722
+ *
35723
+ * WHAT THIS FIXES. The `pathmode-intent` schema writes the product judgment to a change-local
35724
+ * `intent.md`, ahead of the proposal. The corpus reader has never heard of that file, so before
35725
+ * this adapter existed, a change authored faithfully through our own schema graded as
35726
+ * "nothing found" on five of six dimensions: we would have shipped a schema whose output our own
35727
+ * gate accused of having no product judgment. That is the false-accusation failure the whole
35728
+ * unconfirmed-not-absent program exists to remove, so it must not be reintroduced by two of our
35729
+ * own tools disagreeing about where judgment lives.
35730
+ *
35731
+ * THE PRECEDENCE RULES, and each one is a decision rather than a default:
35732
+ *
35733
+ * 1. A recognized change-local `intent.md` is AUTHORITATIVE for the product fields. It was
35734
+ * written to answer exactly these questions, in a format built for them.
35735
+ * 2. No `intent.md` -> fall back to `openSpecChangeToSpec()` unchanged, so a standard OpenSpec
35736
+ * repo grades exactly as it did before this file existed. Pinned by test.
35737
+ * 3. An `intent.md` that exists but cannot be read is an ERROR, never a silent fallback. Quietly
35738
+ * grading proposal.md instead would report a verdict about a document the author did not
35739
+ * write, and hide the broken file that caused it.
35740
+ * 4. Blank fields are NEVER backfilled from proposal.md. A missing objective in an intent.md is
35741
+ * an unfinished product judgment, and the gate saying so is the product working. Merging the
35742
+ * proposal's `## Why` over the gap would manufacture a pass out of a document written to
35743
+ * answer a different question.
35744
+ *
35745
+ * Read-only throughout: nothing here writes, moves, or creates a file.
35746
+ */
35747
+ var __importDefault = (this && this.__importDefault) || function (mod) {
35748
+ return (mod && mod.__esModule) ? mod : { "default": mod };
35749
+ };
35750
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
35751
+ exports.CHANGE_LOCAL_INTENT_FILE = void 0;
35752
+ exports.isProductReadError = isProductReadError;
35753
+ exports.readOpenSpecChangeForPathmode = readOpenSpecChangeForPathmode;
35754
+ const fs_1 = __importDefault(__nccwpck_require__(9896));
35755
+ const path_1 = __importDefault(__nccwpck_require__(6928));
35756
+ const openspec_reader_1 = __nccwpck_require__(7290);
35757
+ const local_reader_1 = __nccwpck_require__(3518);
35758
+ /** The file the `pathmode-intent` schema's `product-intent` artifact generates. */
35759
+ exports.CHANGE_LOCAL_INTENT_FILE = 'intent.md';
35760
+ function isProductReadError(r) {
35761
+ return r.error !== undefined;
35762
+ }
35763
+ /**
35764
+ * Does a verification object hold anything?
35765
+ *
35766
+ * A deliberately tiny local predicate rather than `toVerificationChecks` from the intent
35767
+ * compiler: importing that pulled roughly a thousand generated lines of the compiler into the
35768
+ * published CLI bundle, and nothing here needs to normalize checks. The verification object
35769
+ * travels verbatim, so all this has to answer is "is there content".
35770
+ */
35771
+ function hasVerificationContent(v) {
35772
+ if (!v || typeof v !== 'object')
35773
+ return false;
35774
+ const src = v;
35775
+ return Boolean(src.checks?.length || src.manualChecks?.length || src.unitTests?.length || src.e2eTests?.length);
35776
+ }
35777
+ /**
35778
+ * Does this parse carry any product judgment at all?
35779
+ *
35780
+ * Title alone does not count: `parseIntentMarkdown` defaults it to 'Untitled Intent', so a file
35781
+ * of pure comments would otherwise read as "recognized" and grade as five empty gates.
35782
+ */
35783
+ function carriesJudgment(intent) {
35784
+ return Boolean((intent.objective || '').trim() ||
35785
+ (intent.outcomes || []).length ||
35786
+ (intent.constraints || []).length ||
35787
+ (intent.edgeCases || []).length ||
35788
+ hasVerificationContent(intent.verification));
35789
+ }
35790
+ /** Project a parsed intent.md onto the six-gate shape, keeping what makes it an intent.md. */
35791
+ function intentToProjection(intent) {
35792
+ const spec = {
35793
+ // The title is taken as written, with NO fallback to the change-directory name. An
35794
+ // earlier version substituted the directory name whenever it saw the 'Untitled Intent'
35795
+ // placeholder, which quietly handed a passing title gate to a scaffold nobody had filled
35796
+ // in: the directory is always named something, so the gate could never report an
35797
+ // unnamed change. `parseIntentMarkdown` already defaults a title-less file to 'Untitled
35798
+ // Intent', and the gate knows that string is a placeholder, so passing it through is
35799
+ // what makes an unfinished file read as unfinished.
35800
+ title: intent.title || '',
35801
+ objective: (intent.objective || '').trim(),
35802
+ outcomes: (intent.outcomes || []).map(o => (typeof o === 'string' ? o : String(o?.text ?? ''))).filter(Boolean),
35803
+ constraints: (intent.constraints || []).map(c => (typeof c === 'string' ? c : String(c ?? ''))).filter(Boolean),
35804
+ edgeCases: (intent.edgeCases || [])
35805
+ .map(ec => ({ scenario: String(ec?.scenario ?? '').trim(), expectedBehavior: String(ec?.expectedBehavior ?? '').trim() }))
35806
+ .filter(ec => ec.scenario || ec.expectedBehavior),
35807
+ // Verbatim: kinds are semantics, not decoration.
35808
+ verification: intent.verification ?? {},
35809
+ hasContent: true,
35810
+ };
35811
+ // Only set the key when the file actually carried one, so a spec with no Confirmations
35812
+ // section is indistinguishable from one that never had the concept.
35813
+ if (intent.confirmations?.length)
35814
+ spec.confirmations = intent.confirmations;
35815
+ return spec;
35816
+ }
35817
+ /**
35818
+ * Read a change's product judgment, preferring a change-local intent.md.
35819
+ *
35820
+ * `readFile` is injectable so tests can simulate an unreadable file (a permissions error, a
35821
+ * directory where a file belongs) without depending on the filesystem to misbehave on cue.
35822
+ */
35823
+ function readOpenSpecChangeForPathmode(ref, io = {}) {
35824
+ const exists = io.exists ?? ((p) => { try {
35825
+ return fs_1.default.existsSync(p);
35826
+ }
35827
+ catch {
35828
+ return false;
35829
+ } });
35830
+ const readFile = io.readFile ?? ((p) => fs_1.default.readFileSync(p, 'utf-8'));
35831
+ const intentPath = path_1.default.join(ref.dir, exports.CHANGE_LOCAL_INTENT_FILE);
35832
+ if (!exists(intentPath)) {
35833
+ // Rule 2: a standard OpenSpec repo is graded exactly as before.
35834
+ return { spec: (0, openspec_reader_1.openSpecChangeToSpec)(ref), source: 'openspec-artifacts', notes: [] };
35835
+ }
35836
+ let raw;
35837
+ try {
35838
+ raw = readFile(intentPath);
35839
+ }
35840
+ catch (e) {
35841
+ // Rule 3. Name the file and the reason; do not grade something else instead.
35842
+ return { error: `Found ${exports.CHANGE_LOCAL_INTENT_FILE} in this change but could not read it: ${e instanceof Error ? e.message : String(e)}`, intentPath };
35843
+ }
35844
+ let intent;
35845
+ try {
35846
+ intent = (0, local_reader_1.parseIntentMarkdown)(raw, ref.id);
35847
+ }
35848
+ catch (e) {
35849
+ // gray-matter throws on malformed frontmatter. Same rule: an explicit failure beats a
35850
+ // verdict about a different document.
35851
+ return { error: `Found ${exports.CHANGE_LOCAL_INTENT_FILE} in this change but could not parse it: ${e instanceof Error ? e.message : String(e)}`, intentPath };
35852
+ }
35853
+ if (!carriesJudgment(intent)) {
35854
+ return {
35855
+ error: `Found ${exports.CHANGE_LOCAL_INTENT_FILE} in this change but read no product judgment from it. Expected at least one of ## Objective, ## Outcomes, ## Constraints, ## Edge Cases, or ## Verification. Renaming a heading makes its section invisible.`,
35856
+ intentPath,
35857
+ };
35858
+ }
35859
+ const notes = [
35860
+ `Product judgment read from ${exports.CHANGE_LOCAL_INTENT_FILE}; proposal.md, design.md, spec deltas, and tasks.md were not merged into these fields.`,
35861
+ ];
35862
+ // Rule 4, said out loud rather than inferred from an empty verdict line.
35863
+ const blank = [
35864
+ !(intent.objective || '').trim() && 'objective',
35865
+ !(intent.outcomes || []).length && 'outcomes',
35866
+ !(intent.constraints || []).length && 'constraints',
35867
+ !(intent.edgeCases || []).length && 'edge cases',
35868
+ !hasVerificationContent(intent.verification) && 'verification',
35869
+ ].filter(Boolean);
35870
+ if (blank.length) {
35871
+ notes.push(`${exports.CHANGE_LOCAL_INTENT_FILE} left ${blank.join(', ')} empty. These are reported as missing rather than filled in from proposal.md, because an unfinished product judgment is the thing worth seeing.`);
35872
+ }
35873
+ return { spec: intentToProjection(intent), source: 'intent.md', intentPath, notes };
35874
+ }
35875
+
35876
+
35877
+ /***/ }),
35878
+
35879
+ /***/ 1726:
35880
+ /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
35881
+
35882
+ "use strict";
35883
+
35884
+ /**
35885
+ * OpenSpec change writer: the inverse of scripts/openspec-corpus/openspec-reader.ts.
35886
+ *
35887
+ * Pathmode emits a change folder that OpenSpec's own workflow can pick up: proposal.md,
35888
+ * tasks.md, and a spec delta. The write is judged by TWO contracts, and the scenario-placement
35889
+ * episode is why both are named: an emission can satisfy either one alone and still be wrong.
35890
+ *
35891
+ * 1. Round-trip fidelity, held by scripts/openspec-corpus/openspec-write-roundtrip.test.ts:
35892
+ * reading the emitted change back through the OpenSpec reader reproduces the original spec's
35893
+ * readiness verdict, dimension by dimension, with field digests intact. Digest intactness is
35894
+ * what keeps a confirmation minted in Pathmode LIVE on the emitted change — the anchor is a
35895
+ * digest of the field text, so the field must survive verbatim (whitespace collapse aside,
35896
+ * which the digest normalizes anyway).
35897
+ * 2. Structural acceptance by OpenSpec's own tooling, verified empirically against 1.11.0:
35898
+ * `openspec validate` accepts a structurally complete emission (the first version passed
35899
+ * contract 1 while failing this one — every requirement must carry at least one scenario).
35900
+ *
35901
+ * Every placement below is dictated by where the reader looks, measured on 80 public repos:
35902
+ * - objective -> `## Why` in proposal.md (prose, read verbatim minus comment lines)
35903
+ * - outcomes -> `### Requirement:` bodies in the spec delta (requirements win over
35904
+ * `## What Changes` when both exist; What Changes is still emitted because
35905
+ * that is what a human skims)
35906
+ * - constraints-> `## Non-Goals` bullets in proposal.md. OpenSpec has no first-class
35907
+ * constraints; prohibitions inside requirement prose are the other native form,
35908
+ * but prefixing SHALL NOT onto the author's text would rewrite it and kill its
35909
+ * digest. Non-Goals round-trips verbatim.
35910
+ * - edge cases -> `#### Scenario:` blocks (scenario = header, expectedBehavior = the THEN line).
35911
+ * The reader takes THEN/AND clauses only, so WHEN is emitted for human readers
35912
+ * and ignored on the way back.
35913
+ * - verification -> `## Verification` checklist in tasks.md, because that is where the corpus
35914
+ * showed real authors put it (the finding the whole audit started from).
35915
+ *
35916
+ * What is deliberately NOT emitted: evidence anchors and confirmations (they live in Pathmode's
35917
+ * record, and a file in someone's repo cannot vouch for itself), and fabricated implementation
35918
+ * tasks (their /opsx workflow authors those; we contribute the verification contract only).
35919
+ *
35920
+ * Known seams, asserted in the round-trip test rather than hidden:
35921
+ * - An outcome containing "never"/"no writes" reads back as BOTH an outcome and a constraint,
35922
+ * because the reader mines requirement prose for prohibition sentences.
35923
+ * - Scenarios are distributed across requirements in sequential chunks to satisfy OpenSpec's
35924
+ * scenario-per-requirement rule; the assignment is mechanical, not a claimed linkage, and
35925
+ * requirements left bare (fewer edge cases than outcomes) are reported, never papered over.
35926
+ * - A multi-line objective containing its own `## ` heading would split the Why section. Real
35927
+ * objectives are prose; this is documented, not defended against.
35928
+ */
35929
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
35930
+ exports.openSpecChangeId = openSpecChangeId;
35931
+ exports.formatOpenSpecChange = formatOpenSpecChange;
35932
+ const intent_compiler_1 = __nccwpck_require__(6488);
35933
+ /** Kebab a title into a change id: diacritics folded, non-alphanumerics collapsed to hyphens. */
35934
+ function openSpecChangeId(title) {
35935
+ const kebab = (title || 'change')
35936
+ .normalize('NFD')
35937
+ .replace(/[̀-ͯ]/g, '')
35938
+ .toLowerCase()
35939
+ .replace(/[^a-z0-9]+/g, '-')
35940
+ .replace(/^-+|-+$/g, '')
35941
+ .slice(0, 64)
35942
+ .replace(/-+$/g, '');
35943
+ return kebab || 'change';
35944
+ }
35945
+ const PROVENANCE = '<!-- Emitted by Pathmode (intent_export, format: openspec). Read-only origin: re-exporting refuses to write into an existing change directory. -->';
35946
+ const oneLine = (s) => (s || '').replace(/\s+/g, ' ').trim();
35947
+ const outcomeText = (o) => oneLine(typeof o === 'string' ? o : String(o?.text ?? ''));
35948
+ /** First words of an outcome as a requirement name; the body carries the verbatim text. */
35949
+ function requirementName(text, index) {
35950
+ const words = oneLine(text).split(' ').slice(0, 8).join(' ').replace(/[.,;:!?]+$/, '');
35951
+ return words || `Outcome ${index + 1}`;
35952
+ }
35953
+ function formatOpenSpecChange(fields) {
35954
+ const changeId = openSpecChangeId(fields.title);
35955
+ const outcomes = (fields.outcomes ?? []).map(outcomeText).filter(Boolean);
35956
+ const constraints = (fields.constraints ?? []).map(oneLine).filter(Boolean);
35957
+ const edgeCases = (fields.edgeCases ?? [])
35958
+ .map(ec => ({ scenario: oneLine(ec?.scenario ?? ''), expectedBehavior: oneLine(ec?.expectedBehavior ?? '') }))
35959
+ .filter(ec => ec.scenario);
35960
+ const checks = (0, intent_compiler_1.toVerificationChecks)(fields.verification).map(c => oneLine(c.description)).filter(Boolean);
35961
+ const files = [];
35962
+ let uncoveredRequirements = [];
35963
+ // ── proposal.md ─────────────────────────────────────────────────────────
35964
+ const proposal = [PROVENANCE, '', `# ${oneLine(fields.title) || changeId}`, '', '## Why', ''];
35965
+ // Verbatim, not oneLine'd: Why is a prose section and the reader returns it whole. The
35966
+ // digest collapses whitespace, so paragraph structure is free.
35967
+ proposal.push((fields.objective ?? '').trim() || '(not yet stated)');
35968
+ if (outcomes.length) {
35969
+ proposal.push('', '## What Changes', '');
35970
+ for (const o of outcomes)
35971
+ proposal.push(`- ${o}`);
35972
+ }
35973
+ if (constraints.length) {
35974
+ proposal.push('', '## Non-Goals', '');
35975
+ for (const c of constraints)
35976
+ proposal.push(`- ${c}`);
35977
+ }
35978
+ files.push({ path: 'proposal.md', content: proposal.join('\n') + '\n' });
35979
+ // ── specs/<capability>/spec.md ──────────────────────────────────────────
35980
+ if (outcomes.length || edgeCases.length) {
35981
+ const spec = [PROVENANCE, '', '## ADDED Requirements'];
35982
+ /**
35983
+ * OpenSpec's validator (verified against 1.11.0) errors on any requirement without at
35984
+ * least one `#### Scenario:` block. Edge cases are assigned to requirements in
35985
+ * SEQUENTIAL CHUNKS: the first outcomes get one each, the last gets the remainder. Two
35986
+ * properties hang on that: document order of scenarios equals the original edge-case
35987
+ * order, so the round-trip reads them back exactly (the reader scans linewise); and the
35988
+ * assignment is visibly mechanical, so a reviewer re-homes a scenario in their own
35989
+ * editing pass rather than trusting an inferred linkage we never had. When there are
35990
+ * fewer edge cases than outcomes, the tail requirements stay bare and are reported in
35991
+ * `uncoveredRequirements` — a fabricated scenario would satisfy the validator by lying.
35992
+ */
35993
+ const uncovered = [];
35994
+ if (outcomes.length) {
35995
+ const perReq = outcomes.map(() => []);
35996
+ edgeCases.forEach((ec, i) => {
35997
+ perReq[Math.min(i, outcomes.length - 1)].push(ec);
35998
+ });
35999
+ outcomes.forEach((o, i) => {
36000
+ const name = requirementName(o, i);
36001
+ spec.push('', `### Requirement: ${name}`, '', o);
36002
+ if (!perReq[i].length)
36003
+ uncovered.push(name);
36004
+ for (const ec of perReq[i]) {
36005
+ spec.push('', `#### Scenario: ${ec.scenario}`, '');
36006
+ spec.push(`- **WHEN** ${ec.scenario}`);
36007
+ if (ec.expectedBehavior)
36008
+ spec.push(`- **THEN** ${ec.expectedBehavior}`);
36009
+ }
36010
+ });
36011
+ }
36012
+ else {
36013
+ // Edge cases with no outcomes: no requirement to hang them on. The reader still
36014
+ // finds them; OpenSpec's validator will ask for a requirement, which is the honest
36015
+ // message — this spec is missing its outcomes.
36016
+ for (const ec of edgeCases) {
36017
+ spec.push('', `#### Scenario: ${ec.scenario}`, '');
36018
+ spec.push(`- **WHEN** ${ec.scenario}`);
36019
+ if (ec.expectedBehavior)
36020
+ spec.push(`- **THEN** ${ec.expectedBehavior}`);
36021
+ }
36022
+ }
36023
+ files.push({ path: `specs/${changeId}/spec.md`, content: spec.join('\n') + '\n' });
36024
+ uncoveredRequirements = uncovered;
36025
+ }
36026
+ // ── tasks.md ────────────────────────────────────────────────────────────
36027
+ if (checks.length) {
36028
+ const tasks = [
36029
+ PROVENANCE,
36030
+ '',
36031
+ '# Tasks',
36032
+ '',
36033
+ '<!-- Implementation tasks are authored in your OpenSpec workflow; Pathmode contributes the verification contract below. -->',
36034
+ '',
36035
+ '## Verification',
36036
+ '',
36037
+ ];
36038
+ for (const c of checks)
36039
+ tasks.push(`- [ ] ${c}`);
36040
+ files.push({ path: 'tasks.md', content: tasks.join('\n') + '\n' });
36041
+ }
36042
+ // ── design.md — human context the reader deliberately does not grade ────
36043
+ const decisions = (fields.decisions ?? []).filter(d => d && (d.choice || d.reason));
36044
+ const scopeIn = (fields.scope?.inScope ?? []).map(oneLine).filter(Boolean);
36045
+ const scopeOut = (fields.scope?.outOfScope ?? []).map(oneLine).filter(Boolean);
36046
+ const health = (fields.healthMetrics ?? []).map(oneLine).filter(Boolean);
36047
+ const currentState = (fields.currentState ?? '').trim();
36048
+ if (decisions.length || scopeIn.length || scopeOut.length || health.length || currentState) {
36049
+ const design = [PROVENANCE, '', '# Design notes'];
36050
+ if (currentState)
36051
+ design.push('', '## Context', '', currentState);
36052
+ if (decisions.length) {
36053
+ design.push('', '## Decisions', '');
36054
+ for (const d of decisions) {
36055
+ const ruled = d.ruledOut ? ` (ruled out: ${oneLine(d.ruledOut)})` : '';
36056
+ design.push(`- ${oneLine(d.choice)}: ${oneLine(d.reason)}${ruled}`);
36057
+ }
36058
+ }
36059
+ if (scopeIn.length || scopeOut.length) {
36060
+ design.push('', '## Scope', '');
36061
+ for (const s of scopeIn)
36062
+ design.push(`- In scope: ${s}`);
36063
+ for (const s of scopeOut)
36064
+ design.push(`- Out of scope: ${s}`);
36065
+ }
36066
+ if (health.length) {
36067
+ design.push('', '## Health Metrics', '');
36068
+ for (const h of health)
36069
+ design.push(`- ${h}`);
36070
+ }
36071
+ files.push({ path: 'design.md', content: design.join('\n') + '\n' });
36072
+ }
36073
+ return { changeId, files, uncoveredRequirements };
36074
+ }
36075
+
36076
+
35571
36077
  /***/ }),
35572
36078
 
35573
36079
  /***/ 4681:
@@ -36564,6 +37070,598 @@ async function runSetup() {
36564
37070
  }
36565
37071
 
36566
37072
 
37073
+ /***/ }),
37074
+
37075
+ /***/ 7290:
37076
+ /***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
37077
+
37078
+ "use strict";
37079
+
37080
+ /**
37081
+ * OpenSpec project reader.
37082
+ *
37083
+ * A lot of the people who launch the keyless first run already plan with OpenSpec: they have a
37084
+ * change proposal sitting in the repo and no answer to "did this change deserve to be built".
37085
+ * OpenSpec answers whether the code matches the agreed change; this reads that change and puts
37086
+ * the deterministic Pathmode readiness gate on the product judgment behind it.
37087
+ *
37088
+ * Contract, enforced by openspec-reader.test.ts:
37089
+ * - READ ONLY. Nothing in this file writes, creates, or deletes. Not under `openspec/`, not in
37090
+ * a registered store, not anywhere.
37091
+ * - NO SUBPROCESS. The `openspec` CLI may not be installed, and spawning a binary off PATH
37092
+ * during a first run is not something a greeting gets to do. Everything comes off disk.
37093
+ * - NO NETWORK. Keyless stays keyless.
37094
+ * - SILENT ON FAILURE. Every read is individually wrapped and returns null. A malformed,
37095
+ * unreadable, or unrecognized layout falls back to the normal first run with no error
37096
+ * output and no stack trace. Nothing here writes to stderr — this runs at startup on the
37097
+ * stdio transport.
37098
+ * - `tasks.md` IS READ, BUT ONLY UNDER A VERIFICATION HEADING. Implementation work items are
37099
+ * the plan, not the judgment behind it, and stay out of every field. A section headed
37100
+ * Verification / Tests / Acceptance is where these authors actually record their checks.
37101
+ */
37102
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37103
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37104
+ };
37105
+ Object.defineProperty(exports, "__esModule", ({ value: true }));
37106
+ exports.readStorePointer = readStorePointer;
37107
+ exports.storeRegistryPath = storeRegistryPath;
37108
+ exports.resolveStorePath = resolveStorePath;
37109
+ exports.detectOpenSpec = detectOpenSpec;
37110
+ exports.openSpecChangeToSpec = openSpecChangeToSpec;
37111
+ exports.formatOpenSpecFirstRun = formatOpenSpecFirstRun;
37112
+ exports.localFirstRunInstructions = localFirstRunInstructions;
37113
+ exports.resolveOpenSpecSubject = resolveOpenSpecSubject;
37114
+ /**
37115
+ * PROJECTION FIX, 2026-08-28. An earlier version of this file read verification only from
37116
+ * `proposal.md` and `design.md`, on the reasoning that `tasks.md` is an execution plan rather
37117
+ * than a judgment artifact. Measured against 80 real changes from 80 distinct repositories,
37118
+ * that reasoning was wrong about how people use the file. The gate fired on 94% of real changes
37119
+ * because of this file, not because those authors skipped verification. Reading `tasks.md`, and
37120
+ * matching a bare `## Tests`, moves it from 6% to 63%.
37121
+ *
37122
+ * Two different counts of "where authors put verification" exist and must not be conflated,
37123
+ * because the distance between them is this reader's blind spot:
37124
+ *
37125
+ * what THIS READER CAN SOURCE h2 headings only, VERIFICATION_HEADING_RE below
37126
+ * tasks.md 47/80 | proposal.md 5/80 | design.md 5/80
37127
+ * what a SURVEY FINDS any heading depth h1-h6, plus `qa` and `success criteria`
37128
+ * tasks.md 51/80 | proposal.md 12/80 | design.md 12/80
37129
+ *
37130
+ * `grade.ts` reports the survey row, since its job is to say where the reader should look. The
37131
+ * gap is not noise: 7 of 12 changes that record verification in `design.md` do it under an h3
37132
+ * this reader cannot enter, and 5 proposals use a heading word it does not match. An earlier
37133
+ * revision of this comment cited 43 and 10; those came from a superseded regex and reproduce
37134
+ * from no current code path.
37135
+ *
37136
+ * WIDENING: REJECTED, 2026-08-28, by the blinded audit (`audit-prepare.ts`, `audit-score.ts`,
37137
+ * `audit-results.json`; a verdict-blinded single-model review — see the research doc for the
37138
+ * protocol). Matching h1-h6 with the survey's wider regex was priced at 63% -> 70%. The audit
37139
+ * read 40 changes blind and found six verification false negatives; the widening fixes NONE of
37140
+ * them, and this part rests on where verification physically sits in the artifacts, not on a
37141
+ * judgment call — not one is a heading-depth problem:
37142
+ *
37143
+ * three record verification under a Chinese heading (`## 3. 验证`, `## 5. 驗證與 witness`,
37144
+ * `### 2. 测试验证`) — VERIFICATION_HEADING_RE is English-only, at any depth;
37145
+ * one puts it under the name of the work (`## 2. Automated hygiene gate`);
37146
+ * one uses bold text, not a heading at all (`**Quality Gate:** PASSED`);
37147
+ * one has no `tasks.md`, and records the check in `design.md`'s migration plan.
37148
+ *
37149
+ * The audit also found the widening's own failure mode already firing at the current width: the
37150
+ * one verification FALSE POSITIVE in the sample is a section titled `## 1. Validation Module`,
37151
+ * where "validation" names the module being built, not a check being run. The reader scored its
37152
+ * "Create src/cli/import-validator.ts" work items as verification checks. Matching more heading
37153
+ * shapes makes that class worse, not better. Recovering these six needs a different mechanism
37154
+ * than a wider heading regex — the heading is not where the signal is.
37155
+ *
37156
+ * Do not narrow the verification sources again without re-running `grade.ts`. Grading someone
37157
+ * else's change against a gate we cannot correctly read is worse than not grading it.
37158
+ */
37159
+ const fs_1 = __importDefault(__nccwpck_require__(9896));
37160
+ const os_1 = __importDefault(__nccwpck_require__(857));
37161
+ const path_1 = __importDefault(__nccwpck_require__(6928));
37162
+ /** The judgment-bearing artifacts. `tasks.md` is deliberately not in this list. */
37163
+ const PROPOSAL_FILE = 'proposal.md';
37164
+ const DESIGN_FILE = 'design.md';
37165
+ const SPECS_DIR = 'specs';
37166
+ // ── Filesystem primitives: every one of these swallows its own failure ──────
37167
+ function safeExists(p) {
37168
+ try {
37169
+ return fs_1.default.existsSync(p);
37170
+ }
37171
+ catch {
37172
+ return false;
37173
+ }
37174
+ }
37175
+ function safeIsDir(p) {
37176
+ try {
37177
+ return fs_1.default.statSync(p).isDirectory();
37178
+ }
37179
+ catch {
37180
+ return false;
37181
+ }
37182
+ }
37183
+ function safeReadFile(p) {
37184
+ try {
37185
+ return fs_1.default.readFileSync(p, 'utf-8');
37186
+ }
37187
+ catch {
37188
+ return null;
37189
+ }
37190
+ }
37191
+ function safeReadDirNames(p) {
37192
+ try {
37193
+ return fs_1.default.readdirSync(p, { withFileTypes: true })
37194
+ .filter((e) => e.isDirectory())
37195
+ .map((e) => e.name)
37196
+ .filter((n) => !n.startsWith('.'))
37197
+ .sort();
37198
+ }
37199
+ catch {
37200
+ return [];
37201
+ }
37202
+ }
37203
+ // ── config.yaml `store:` pointer ────────────────────────────────────────────
37204
+ /**
37205
+ * Pull a top-level `store:` scalar out of `openspec/config.yaml`.
37206
+ *
37207
+ * Deliberately not a YAML parse. The package declares three runtime dependencies and a YAML
37208
+ * parser is only available transitively; depending on a transitive package is how a bundle
37209
+ * breaks on someone else's install. One top-level scalar does not justify a new dependency.
37210
+ * Anything that is not a plain top-level `store: <id>` returns null, which reads as "no store
37211
+ * pointer" and falls back to local artifacts.
37212
+ */
37213
+ function readStorePointer(configYaml) {
37214
+ if (!configYaml)
37215
+ return null;
37216
+ for (const rawLine of configYaml.split('\n')) {
37217
+ const line = rawLine.replace(/\r$/, '');
37218
+ if (/^\s*#/.test(line))
37219
+ continue;
37220
+ // Top level only: no leading whitespace, so a `store:` nested under some other key
37221
+ // is not mistaken for the pointer.
37222
+ const m = line.match(/^store:\s*(.*)$/);
37223
+ if (!m)
37224
+ continue;
37225
+ let value = m[1].trim();
37226
+ // Strip a trailing comment on an unquoted scalar.
37227
+ if (!/^["']/.test(value))
37228
+ value = value.replace(/\s+#.*$/, '').trim();
37229
+ const quoted = value.match(/^["'](.*)["']$/);
37230
+ if (quoted)
37231
+ value = quoted[1].trim();
37232
+ if (!value)
37233
+ return null;
37234
+ return value;
37235
+ }
37236
+ return null;
37237
+ }
37238
+ // ── Store registry ──────────────────────────────────────────────────────────
37239
+ /** Where OpenSpec 1.11.0 registers stores. XDG override honoured, home fallback otherwise. */
37240
+ function storeRegistryPath(env = process.env) {
37241
+ const base = env.XDG_DATA_HOME && env.XDG_DATA_HOME.trim()
37242
+ ? env.XDG_DATA_HOME
37243
+ : path_1.default.join(os_1.default.homedir(), '.local', 'share');
37244
+ return path_1.default.join(base, 'openspec', 'stores', 'registry.yaml');
37245
+ }
37246
+ /** Keys a store's local path has been seen under, or plausibly could be. Beta shape, so the
37247
+ * documented snake_case (store family) and camelCase (workflow family) spellings both count. */
37248
+ const STORE_PATH_KEYS = new Set(['local_path', 'localPath', 'path', 'root']);
37249
+ /**
37250
+ * Resolve a store id to a local path by scanning the registry's indentation.
37251
+ *
37252
+ * A bounded scanner rather than a YAML parse, for the dependency reason above and because the
37253
+ * shape is documented as beta: this looks for the block whose key is the store id, then returns
37254
+ * the first scalar beneath it whose key looks like a local path, at any depth. A rename of the
37255
+ * intervening `backend:` level does not break it. Anything unexpected returns null and the
37256
+ * caller reports the store as unresolved rather than claiming there is no OpenSpec project.
37257
+ */
37258
+ function resolveStorePath(registryYaml, storeId) {
37259
+ if (!registryYaml || !storeId)
37260
+ return null;
37261
+ const lines = registryYaml.split('\n').map((l) => l.replace(/\r$/, ''));
37262
+ const indentOf = (l) => l.length - l.trimStart().length;
37263
+ let storesIndent = -1;
37264
+ let storeIndent = -1;
37265
+ let inStore = false;
37266
+ for (const line of lines) {
37267
+ if (!line.trim() || /^\s*#/.test(line))
37268
+ continue;
37269
+ const indent = indentOf(line);
37270
+ const keyMatch = line.trim().match(/^([A-Za-z0-9_.\/-]+)\s*:\s*(.*)$/);
37271
+ if (!keyMatch)
37272
+ continue;
37273
+ const key = keyMatch[1];
37274
+ const value = keyMatch[2].trim();
37275
+ if (storesIndent === -1) {
37276
+ if (key === 'stores' && !value)
37277
+ storesIndent = indent;
37278
+ continue;
37279
+ }
37280
+ if (!inStore) {
37281
+ if (indent <= storesIndent) {
37282
+ // Left the `stores:` block without finding the id.
37283
+ storesIndent = -1;
37284
+ continue;
37285
+ }
37286
+ if (key === storeId) {
37287
+ inStore = true;
37288
+ storeIndent = indent;
37289
+ }
37290
+ continue;
37291
+ }
37292
+ // Inside the store block: dedent to or past the id means the block ended.
37293
+ if (indent <= storeIndent)
37294
+ return null;
37295
+ if (!STORE_PATH_KEYS.has(key) || !value)
37296
+ continue;
37297
+ const unquoted = value.replace(/^["'](.*)["']$/, '$1').trim();
37298
+ return unquoted || null;
37299
+ }
37300
+ return null;
37301
+ }
37302
+ // ── Detection ───────────────────────────────────────────────────────────────
37303
+ /**
37304
+ * Detect an OpenSpec project in `cwd`, or return null.
37305
+ *
37306
+ * The no-OpenSpec path is a single `existsSync`. Nothing else is stat'ed, opened, or walked, so
37307
+ * a repo without OpenSpec cannot pay measurable cost for this.
37308
+ */
37309
+ function detectOpenSpec(cwd = process.cwd(), env = process.env) {
37310
+ const openspecDir = path_1.default.join(cwd, 'openspec');
37311
+ if (!safeExists(openspecDir))
37312
+ return null;
37313
+ if (!safeIsDir(openspecDir))
37314
+ return null;
37315
+ const storeId = readStorePointer(safeReadFile(path_1.default.join(openspecDir, 'config.yaml'))) || undefined;
37316
+ let storeRoot;
37317
+ let storeUnresolved = false;
37318
+ if (storeId) {
37319
+ const resolved = resolveStorePath(safeReadFile(storeRegistryPath(env)), storeId);
37320
+ // A registered store whose directory is gone is as unresolved as an unregistered one.
37321
+ if (resolved && safeIsDir(path_1.default.join(resolved, 'openspec'))) {
37322
+ storeRoot = path_1.default.join(resolved, 'openspec');
37323
+ }
37324
+ else if (resolved && safeIsDir(resolved)) {
37325
+ storeRoot = resolved;
37326
+ }
37327
+ else {
37328
+ storeUnresolved = true;
37329
+ }
37330
+ }
37331
+ // Local artifacts win when they exist: a repo can carry a pointer and still hold changes.
37332
+ const localChangesDir = path_1.default.join(openspecDir, 'changes');
37333
+ const localChanges = listChanges(localChangesDir);
37334
+ let planningHome = openspecDir;
37335
+ let changes = localChanges;
37336
+ if (localChanges.length === 0 && storeRoot) {
37337
+ const storeChanges = listChanges(path_1.default.join(storeRoot, 'changes'));
37338
+ if (storeChanges.length > 0) {
37339
+ planningHome = storeRoot;
37340
+ changes = storeChanges;
37341
+ }
37342
+ }
37343
+ return { openspecDir, storeId, storeRoot, storeUnresolved, planningHome, changes };
37344
+ }
37345
+ function listChanges(changesDir) {
37346
+ if (!safeIsDir(changesDir))
37347
+ return [];
37348
+ return safeReadDirNames(changesDir)
37349
+ // `archive/` holds shipped changes; it is not an active change.
37350
+ .filter((name) => name !== 'archive')
37351
+ .map((name) => ({ id: name, dir: path_1.default.join(changesDir, name) }));
37352
+ }
37353
+ // ── Markdown extraction ─────────────────────────────────────────────────────
37354
+ /** Collect the body lines under every `## Heading` matching `test`. */
37355
+ /**
37356
+ * Remove fenced code blocks before any structural parsing.
37357
+ *
37358
+ * FIXED 2026-08-29. This reader was left deliberately unfixed while the blinded audit's numbers
37359
+ * stood on it, because changing it would have moved the corpus buckets the audit's stratified
37360
+ * sample was drawn from. It is fixed now because the product CLI reads OpenSpec changes through
37361
+ * this same function, and shipping a known false-positive generator to other people's repos is
37362
+ * not defensible. The audit artifacts are a dated record against the reader as it was; see
37363
+ * docs/research/openspec-corpus-audit.md for what the fix changed.
37364
+ *
37365
+ * The defect: `sectionsMatching` is line-based and `bulletsIn` matches a leading `-`/`*`, so a C
37366
+ * comment line (` * @brief ...`) or a YAML list inside a fence parsed as an outcome. One corpus
37367
+ * change contributed 21 Doxygen `@brief` lines that way and passed a gate three reviewers fail
37368
+ * it on. Mirrors lib/intentMdParse.stripFencedBlocks; same rules, same reasons.
37369
+ */
37370
+ function stripFencedBlocks(markdown) {
37371
+ const out = [];
37372
+ let fence = null;
37373
+ for (const line of markdown.split('\n')) {
37374
+ const marker = line.match(/^\s*(`{3,}|~{3,})/);
37375
+ if (marker) {
37376
+ const char = marker[1][0];
37377
+ const len = marker[1].length;
37378
+ if (!fence) {
37379
+ fence = { char, len };
37380
+ continue;
37381
+ }
37382
+ if (char === fence.char && len >= fence.len)
37383
+ fence = null;
37384
+ continue;
37385
+ }
37386
+ if (!fence)
37387
+ out.push(line);
37388
+ }
37389
+ return out.join('\n');
37390
+ }
37391
+ function sectionsMatching(markdown, test) {
37392
+ const out = [];
37393
+ let current = null;
37394
+ for (const line of stripFencedBlocks(markdown).split('\n')) {
37395
+ const h2 = line.match(/^##\s+(.+?)\s*$/);
37396
+ if (h2) {
37397
+ current = test(h2[1]) ? [] : null;
37398
+ if (current)
37399
+ out.push(current);
37400
+ continue;
37401
+ }
37402
+ if (/^#\s+/.test(line)) {
37403
+ current = null;
37404
+ continue;
37405
+ }
37406
+ if (current)
37407
+ current.push(line);
37408
+ }
37409
+ return out;
37410
+ }
37411
+ function stripComments(lines) {
37412
+ // Template scaffolds ship HTML comments as placeholder prompts. They are not content.
37413
+ return lines.filter((l) => !/^\s*<!--/.test(l) && !/-->\s*$/.test(l));
37414
+ }
37415
+ function bulletsIn(lines) {
37416
+ return stripComments(lines)
37417
+ .filter((l) => /^\s*[-*]\s/.test(l))
37418
+ .map((l) => l.replace(/^\s*[-*]\s+(\[.\]\s+)?/, '').trim())
37419
+ .filter(Boolean);
37420
+ }
37421
+ function proseIn(lines) {
37422
+ return stripComments(lines).join('\n').trim();
37423
+ }
37424
+ const VERIFICATION_HEADING_RE = /verif|\btests?\b|testing|test plan|validation|acceptance|how we(?:'ll)? know/i;
37425
+ /**
37426
+ * Map a change's judgment-bearing artifacts onto the six readiness gates.
37427
+ *
37428
+ * Scenarios feed `edgeCases` and deliberately NOT `verification`. A scenario states what must be
37429
+ * true; it does not say how you would observe it after shipping. Feeding one source into two
37430
+ * gates would make both pass off a single artifact and hollow out the verdict.
37431
+ *
37432
+ * RECALL IS UNVALIDATED. This is heading-matching over four filenames, so a change with checks
37433
+ * under an unfamiliar heading, nested deeper, or written as prose outside any section is invisible
37434
+ * to it. When the gate fires, the honest statement is "this reader found no check", never "the
37435
+ * author recorded none" — the difference between those two is exactly the mistake this file
37436
+ * already made once at scale. Same caution on `objective`, which is an exact `## Why` match, and
37437
+ * on `outcomes`, which maps OpenSpec requirements (system behaviour) onto Pathmode outcomes
37438
+ * (observable product change). Those are related but not the same field, and a low pass rate
37439
+ * there is a hypothesis, not a measurement.
37440
+ */
37441
+ function openSpecChangeToSpec(change) {
37442
+ const proposal = safeReadFile(path_1.default.join(change.dir, PROPOSAL_FILE)) || '';
37443
+ const design = safeReadFile(path_1.default.join(change.dir, DESIGN_FILE)) || '';
37444
+ const specDocs = readDeltaSpecs(path_1.default.join(change.dir, SPECS_DIR));
37445
+ const objective = proseIn(sectionsMatching(proposal, (h) => /^why\b/i.test(h)).flat());
37446
+ const requirements = specDocs.flatMap(extractRequirements);
37447
+ const whatChanges = sectionsMatching(proposal, (h) => /what changes/i.test(h)).flatMap(bulletsIn);
37448
+ const outcomes = requirements.length > 0 ? requirements.map((r) => r.statement).filter(Boolean) : whatChanges;
37449
+ const constraints = [
37450
+ ...requirements.flatMap((r) => r.prohibitions),
37451
+ ...sectionsMatching(design, (h) => /non-?goals/i.test(h)).flatMap(bulletsIn),
37452
+ ...sectionsMatching(proposal, (h) => /non-?goals/i.test(h)).flatMap(bulletsIn),
37453
+ ];
37454
+ const edgeCases = specDocs.flatMap(extractScenarios);
37455
+ const tasks = safeReadFile(path_1.default.join(change.dir, 'tasks.md')) || '';
37456
+ const verificationLines = [
37457
+ ...sectionsMatching(proposal, (h) => VERIFICATION_HEADING_RE.test(h)),
37458
+ ...sectionsMatching(design, (h) => VERIFICATION_HEADING_RE.test(h)),
37459
+ ...sectionsMatching(tasks, (h) => VERIFICATION_HEADING_RE.test(h)),
37460
+ ];
37461
+ const verificationItems = verificationLines.flatMap((lines) => {
37462
+ const bullets = bulletsIn(lines);
37463
+ return bullets.length > 0 ? bullets : (proseIn(lines) ? [proseIn(lines)] : []);
37464
+ });
37465
+ return {
37466
+ title: change.id.replace(/[-_]+/g, ' ').trim(),
37467
+ objective,
37468
+ outcomes,
37469
+ constraints,
37470
+ edgeCases,
37471
+ verification: { checks: verificationItems.map((description) => ({ kind: 'test', description })) },
37472
+ hasContent: !!(objective || outcomes.length || edgeCases.length),
37473
+ };
37474
+ }
37475
+ function readDeltaSpecs(specsDir) {
37476
+ if (!safeIsDir(specsDir))
37477
+ return [];
37478
+ const out = [];
37479
+ const walk = (dir, depth) => {
37480
+ if (depth > 6)
37481
+ return;
37482
+ let entries;
37483
+ try {
37484
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
37485
+ }
37486
+ catch {
37487
+ return;
37488
+ }
37489
+ for (const e of entries) {
37490
+ const full = path_1.default.join(dir, e.name);
37491
+ if (e.isDirectory())
37492
+ walk(full, depth + 1);
37493
+ else if (e.isFile() && e.name.endsWith('.md')) {
37494
+ const content = safeReadFile(full);
37495
+ if (content)
37496
+ out.push(content);
37497
+ }
37498
+ }
37499
+ };
37500
+ walk(specsDir, 0);
37501
+ return out;
37502
+ }
37503
+ /** `### Requirement: <name>` followed by normative prose, until the next `###`/`##`. */
37504
+ function extractRequirements(markdown) {
37505
+ const out = [];
37506
+ let body = null;
37507
+ const flush = () => {
37508
+ if (!body)
37509
+ return;
37510
+ const text = stripComments(body).join(' ').replace(/\s+/g, ' ').trim();
37511
+ if (text) {
37512
+ const prohibitions = text
37513
+ .split(/(?<=\.)\s+/)
37514
+ .map((s) => s.trim())
37515
+ .filter((s) => /\b(SHALL NOT|MUST NOT|never|no writes?)\b/i.test(s));
37516
+ out.push({ statement: text, prohibitions });
37517
+ }
37518
+ body = null;
37519
+ };
37520
+ for (const line of markdown.split('\n')) {
37521
+ if (/^###\s+Requirement:/i.test(line)) {
37522
+ flush();
37523
+ body = [];
37524
+ continue;
37525
+ }
37526
+ if (/^#{1,4}\s/.test(line)) {
37527
+ flush();
37528
+ continue;
37529
+ }
37530
+ if (body)
37531
+ body.push(line);
37532
+ }
37533
+ flush();
37534
+ return out;
37535
+ }
37536
+ /** `#### Scenario: <name>` with `- **WHEN**` / `- **THEN**` / `- **AND**` clauses. */
37537
+ function extractScenarios(markdown) {
37538
+ const out = [];
37539
+ let name = null;
37540
+ let thens = [];
37541
+ const flush = () => {
37542
+ if (name && thens.length > 0)
37543
+ out.push({ scenario: name, expectedBehavior: thens.join('; ') });
37544
+ name = null;
37545
+ thens = [];
37546
+ };
37547
+ for (const line of markdown.split('\n')) {
37548
+ const header = line.match(/^####\s+Scenario:\s*(.+?)\s*$/i);
37549
+ if (header) {
37550
+ flush();
37551
+ name = header[1].trim();
37552
+ continue;
37553
+ }
37554
+ if (/^#{1,4}\s/.test(line)) {
37555
+ flush();
37556
+ continue;
37557
+ }
37558
+ if (!name)
37559
+ continue;
37560
+ const clause = line.match(/^\s*[-*]\s+\*\*(THEN|AND)\*\*\s*(.+?)\s*$/i);
37561
+ if (clause) {
37562
+ const text = clause[2].trim();
37563
+ // A bare `**AND**` before any `**THEN**` is still part of the condition.
37564
+ if (/^THEN$/i.test(clause[1]) || thens.length > 0)
37565
+ thens.push(text);
37566
+ }
37567
+ }
37568
+ flush();
37569
+ return out;
37570
+ }
37571
+ // ── First-run copy ──────────────────────────────────────────────────────────
37572
+ /**
37573
+ * The OpenSpec-aware replacement for the generic first-run guidance. Names the change instead of
37574
+ * welcoming the user to an empty repo, and never guesses when there is more than one change.
37575
+ */
37576
+ function formatOpenSpecFirstRun(ctx) {
37577
+ const parts = [];
37578
+ const ids = ctx.changes.map((c) => c.id);
37579
+ if (ctx.storeUnresolved && ctx.storeId) {
37580
+ parts.push(`This repo plans with OpenSpec, and its planning lives in store "${ctx.storeId}", which is not registered or not readable on this machine. ` +
37581
+ `Preflight whatever is readable here.`);
37582
+ }
37583
+ else if (ctx.storeId && ctx.storeRoot) {
37584
+ parts.push(`This repo plans with OpenSpec; its planning lives in store "${ctx.storeId}".`);
37585
+ }
37586
+ else {
37587
+ parts.push('This repo plans with OpenSpec.');
37588
+ }
37589
+ if (ids.length === 1) {
37590
+ parts.push(`Active change: ${ids[0]}. ` +
37591
+ `First move: call check_intent_readiness with intentId "${ids[0]}" to preflight the product judgment behind it — ` +
37592
+ `six deterministic gates over the proposal and its specs, no model call and nothing leaves the machine. ` +
37593
+ `OpenSpec already answers whether the code matches the agreed change; this answers whether the change deserved to be built. ` +
37594
+ `A failing verdict names the exact blockers; repair them one targeted question at a time. Never block work on a failing verdict.`);
37595
+ }
37596
+ else if (ids.length > 1) {
37597
+ parts.push(`Changes found: ${ids.join(', ')}. ` +
37598
+ `Ask the user which one to preflight — do not pick for them — then call check_intent_readiness with that change id as intentId. ` +
37599
+ `It runs six deterministic gates over the proposal and its specs, no model call and nothing leaves the machine. ` +
37600
+ `A failing verdict names the exact blockers; repair them one targeted question at a time. Never block work on a failing verdict.`);
37601
+ }
37602
+ else {
37603
+ parts.push(`No active change yet. Offer to preflight the intent behind the next one: draft it from the conversation, mark inferred fields as assumptions, ` +
37604
+ `then call check_intent_readiness with the spec inline. Nothing leaves the machine and no account is needed.`);
37605
+ }
37606
+ parts.push('Pathmode reads OpenSpec read-only and never writes under openspec/ or into a store. Specs you save go to intent.md in this repo.');
37607
+ parts.push('To sync with a shared Pathmode workspace later, run: npx @pathmode/mcp-server setup pm_live_xxx');
37608
+ return parts.join(' ');
37609
+ }
37610
+ /**
37611
+ * Pick the first-run guidance. With no OpenSpec project this returns the caller's own constant
37612
+ * unchanged — by reference, so a repo without `openspec/` provably gets byte-identical guidance
37613
+ * to the one it got before this change.
37614
+ */
37615
+ function localFirstRunInstructions(fallback, ctx) {
37616
+ return ctx ? formatOpenSpecFirstRun(ctx) : fallback;
37617
+ }
37618
+ /**
37619
+ * Resolve what `check_intent_readiness` should preflight when no local intent answered.
37620
+ *
37621
+ * Never guesses between changes: with more than one and no id, it hands back the list. An
37622
+ * `intentId` that matches no change returns `none`, so the caller's own "no intent with that id"
37623
+ * message still wins rather than being replaced by an OpenSpec-flavoured near-miss.
37624
+ */
37625
+ function resolveOpenSpecSubject(ctx, intentId) {
37626
+ if (!ctx)
37627
+ return { kind: 'none' };
37628
+ if (intentId) {
37629
+ const match = ctx.changes.find((c) => c.id === intentId);
37630
+ if (!match)
37631
+ return { kind: 'none' };
37632
+ return projectionOrMessage(match);
37633
+ }
37634
+ if (ctx.changes.length === 0) {
37635
+ const where = ctx.storeUnresolved && ctx.storeId
37636
+ ? ` Its planning lives in store "${ctx.storeId}", which is not registered or not readable on this machine.`
37637
+ : '';
37638
+ return {
37639
+ kind: 'message',
37640
+ text: `This repo plans with OpenSpec and has no active change.${where} Nothing to preflight yet — draft the intent behind the next change from the conversation, mark inferred fields as assumptions, and pass it inline to preflight it before anyone builds it.`,
37641
+ };
37642
+ }
37643
+ if (ctx.changes.length > 1) {
37644
+ return {
37645
+ kind: 'message',
37646
+ text: `This repo plans with OpenSpec and has ${ctx.changes.length} changes: ${ctx.changes.map((c) => c.id).join(', ')}. Ask which one to preflight, then pass its id as intentId.`,
37647
+ };
37648
+ }
37649
+ return projectionOrMessage(ctx.changes[0]);
37650
+ }
37651
+ function projectionOrMessage(change) {
37652
+ const spec = openSpecChangeToSpec(change);
37653
+ if (!spec.hasContent) {
37654
+ // Every judgment-bearing artifact was missing or unreadable. A verdict computed from
37655
+ // nothing would be six failures that say more about the parse than about the change.
37656
+ return {
37657
+ kind: 'message',
37658
+ text: `OpenSpec change "${change.id}" has no readable proposal, design, or spec content, so there is no product judgment to preflight. Describe the change and pass the spec inline instead.`,
37659
+ };
37660
+ }
37661
+ return { kind: 'spec', spec, sourceNote: `OpenSpec change ${change.id} (proposal, design, and delta specs; tasks.md is not read)` };
37662
+ }
37663
+
37664
+
36567
37665
  /***/ }),
36568
37666
 
36569
37667
  /***/ 2613:
@@ -66142,7 +67240,7 @@ module.exports = /*#__PURE__*/JSON.parse('{"$schema":"http://json-schema.org/dra
66142
67240
  /***/ ((module) => {
66143
67241
 
66144
67242
  "use strict";
66145
- module.exports = /*#__PURE__*/JSON.parse('{"name":"@pathmode/mcp-server","version":"1.16.0","publishConfig":{"access":"public"},"mcpName":"io.github.pathmodeio/mcp-server","description":"Deterministic intent preflight before your agent builds: six calibrated gates, keyless, no model call. Draft and sharpen specs in conversation, or connect a Pathmode workspace to sync intent and evidence across a team.","main":"dist/index.js","bin":{"pathmode-mcp":"dist/index.js"},"files":["dist/","manifest.json","icon.svg","README.md","skills/"],"scripts":{"build":"rm -rf dist && ncc build src/index.ts -o dist","dev":"ts-node src/index.ts","prepublishOnly":"npm run build"},"keywords":["pathmode","mcp","model-context-protocol","claude-code","claude-code-skills","agent-skills","cursor","windsurf","intent-engineering","intent-compiler","ai-agents","product-development","dependency-graph","strategic-planning"],"author":"Pathmode","license":"MIT","type":"commonjs","engines":{"node":">=18.0.0"},"homepage":"https://pathmode.io","dependencies":{"@modelcontextprotocol/sdk":"^1.12.1","gray-matter":"^4.0.3","zod":"^3.24.0"},"devDependencies":{"@types/node":"^25.1.0","@vercel/ncc":"^0.38.4","ts-node":"^10.9.2","typescript":"^5.9.3"}}');
67243
+ module.exports = /*#__PURE__*/JSON.parse('{"name":"@pathmode/mcp-server","version":"1.16.1","publishConfig":{"access":"public"},"mcpName":"io.github.pathmodeio/mcp-server","description":"Deterministic intent preflight before your agent builds: six calibrated gates, keyless, no model call. Draft and sharpen specs in conversation, or connect a Pathmode workspace to sync intent and evidence across a team.","main":"dist/index.js","bin":{"pathmode-mcp":"dist/index.js"},"files":["dist/","manifest.json","icon.svg","README.md","skills/"],"scripts":{"build":"rm -rf dist && ncc build src/index.ts -o dist","dev":"ts-node src/index.ts","prepublishOnly":"npm run build"},"keywords":["pathmode","mcp","model-context-protocol","claude-code","claude-code-skills","agent-skills","cursor","windsurf","intent-engineering","intent-compiler","ai-agents","product-development","dependency-graph","strategic-planning"],"author":"Pathmode","license":"MIT","type":"commonjs","engines":{"node":">=18.0.0"},"homepage":"https://pathmode.io","dependencies":{"@modelcontextprotocol/sdk":"^1.12.1","gray-matter":"^4.0.3","zod":"^3.24.0"},"devDependencies":{"@types/node":"^25.1.0","@vercel/ncc":"^0.38.4","ts-node":"^10.9.2","typescript":"^5.9.3"}}');
66146
67244
 
66147
67245
  /***/ })
66148
67246
 
@@ -66242,6 +67340,9 @@ const api_client_1 = __nccwpck_require__(7475);
66242
67340
  const local_reader_1 = __nccwpck_require__(3518);
66243
67341
  const readiness_1 = __nccwpck_require__(3079);
66244
67342
  const save_policy_1 = __nccwpck_require__(6137);
67343
+ const openspec_writer_1 = __nccwpck_require__(1726);
67344
+ const openspec_import_1 = __nccwpck_require__(8388);
67345
+ const openspec_reader_1 = __nccwpck_require__(7290);
66245
67346
  const intent_compiler_1 = __nccwpck_require__(6488);
66246
67347
  const pathmode_section_1 = __nccwpck_require__(4681);
66247
67348
  const setup_1 = __nccwpck_require__(8294);
@@ -67436,6 +68537,7 @@ function startMcpServer() {
67436
68537
  e2eTests: zod_1.z.array(zod_1.z.string()).optional(),
67437
68538
  }).optional().describe('How to confirm it works'),
67438
68539
  };
68540
+ const intentSpecObject = zod_1.z.object(intentSpecSchema);
67439
68541
  server.prompt('compile-intent', 'Start a Socratic conversation to build a structured intent spec from user feedback or a problem description. No Pathmode account needed.', {}, async () => {
67440
68542
  return {
67441
68543
  messages: [{
@@ -67524,131 +68626,253 @@ function startMcpServer() {
67524
68626
  spec: zod_1.z.object(intentSpecSchema),
67525
68627
  path: zod_1.z.string().optional().describe('File path relative to the project root. Must stay inside the project (absolute paths and ".." are rejected). Defaults to intent.md'),
67526
68628
  overwrite: zod_1.z.boolean().optional().describe('Replace the file even when it already holds a DIFFERENT intent. Default false — the save is refused instead, so an unrelated spec is never clobbered.'),
67527
- }, async ({ spec, path, overwrite }) => {
67528
- const filePath = resolveWithinProject(path || 'intent.md');
67529
- const existing = (0, local_reader_1.readIntentMeta)(filePath);
67530
- const decision = (0, save_policy_1.decideSave)({
67531
- existing,
67532
- incomingId: spec.id,
67533
- overwrite,
67534
- mintId: () => (0, crypto_1.randomUUID)(),
67535
- });
67536
- if (decision.action === 'refuse') {
68629
+ }, async ({ spec, path, overwrite }) => performIntentSave(spec, path, overwrite));
68630
+ /**
68631
+ * The whole save path — collision policy, verdict stamp, confirmation carry-forward, cloud push
68632
+ * with identity settlement — extracted so intent_import can reuse it EXACTLY. Import must not be
68633
+ * a second save implementation: the list-projection defect family taught what a second copy of a
68634
+ * write path costs, and the confirmation carry-forward here is load-bearing (M1's tool boundary
68635
+ * assumes every writer goes through this).
68636
+ */
68637
+ async function performIntentSave(spec, path, overwrite, retryTool = 'intent_save') {
68638
+ {
68639
+ const filePath = resolveWithinProject(path || 'intent.md');
68640
+ const existing = (0, local_reader_1.readIntentMeta)(filePath);
68641
+ const decision = (0, save_policy_1.decideSave)({
68642
+ existing,
68643
+ incomingId: spec.id,
68644
+ overwrite,
68645
+ mintId: () => (0, crypto_1.randomUUID)(),
68646
+ });
68647
+ if (decision.action === 'refuse') {
68648
+ return {
68649
+ content: [{
68650
+ type: 'text',
68651
+ text: [
68652
+ `✗ Refusing to overwrite ${filePath}`,
68653
+ '',
68654
+ `It already holds a different intent (id: ${existing.id ?? 'unknown'}, status: ${existing.status}, version: ${existing.version}), and this spec is "${decision.id}".`,
68655
+ '',
68656
+ 'Save to another path (pass `path`), or pass overwrite=true to replace it deliberately.',
68657
+ ].join('\n'),
68658
+ }],
68659
+ };
68660
+ }
68661
+ const { id, version, status, created } = decision;
68662
+ // The preflight verdict travels with the file: stamped into frontmatter on every save,
68663
+ // recomputed from the spec alone (deterministic). A failing verdict never blocks the
68664
+ // save — the gate reports, the user decides.
68665
+ const fields = toIntentFields(spec);
68666
+ const verdict = (0, readiness_1.computeReadinessVerdict)(fields);
68667
+ // The push and the file write are one ordered unit; see push-spec.ts for why the write
68668
+ // cannot wait for the enrichment legs.
68669
+ let canonicalId = id;
68670
+ let specVersion;
68671
+ let sourceUrl;
68672
+ let didNotTravel = [];
68673
+ const writeSpecFile = (opts) => {
68674
+ let content = (0, intent_compiler_1.formatIntentMd)({ ...fields, id: opts.canonicalId }, {
68675
+ version, status, created,
68676
+ readiness: (0, readiness_1.formatReadinessFrontmatter)(verdict),
68677
+ specVersion: opts.specVersion,
68678
+ source: opts.sourceUrl,
68679
+ });
68680
+ // Carry existing confirmations across the rewrite. intent_save builds its content from
68681
+ // the incoming spec, which by design has no `confirmations` field (that absence is what
68682
+ // enforces M1: a confirmation cannot be minted in the write that authored the field).
68683
+ // Without this the very next save silently deletes every confirmation the file holds,
68684
+ // and the anchors would have caught nothing because the records would be gone.
68685
+ // Confirmations are re-validated on read, so a save that changed the field text simply
68686
+ // leaves them stale rather than wrongly live.
68687
+ // ONLY on an update. A create means this is a different intent, and the previous
68688
+ // file's confirmations vouch for a different intent's field text. They would be inert
68689
+ // (their anchors cannot match) but present, which misattributes who confirmed what.
68690
+ const prior = decision.action === 'update' && (0, fs_1.existsSync)(filePath)
68691
+ ? (0, local_reader_1.readIntentFile)(filePath)?.confirmations ?? []
68692
+ : [];
68693
+ if (prior.length) {
68694
+ content = (0, intent_compiler_1.spliceConfirmationsSection)(content, (0, intent_compiler_1.renderConfirmationsSection)(prior));
68695
+ }
68696
+ (0, fs_1.writeFileSync)(filePath, content, 'utf-8');
68697
+ };
68698
+ if (isLocalMode) {
68699
+ writeSpecFile({ canonicalId: id });
68700
+ }
68701
+ else {
68702
+ const result = await (0, push_spec_1.pushSpec)({
68703
+ client: requireCloudClient(),
68704
+ id,
68705
+ // Only an UPDATE may claim the previous record. On a create — including an
68706
+ // explicit overwrite of a different intent — passing the old specVersion sends
68707
+ // pushSpec down its updateIntent branch, which would rewrite the unrelated
68708
+ // workspace intent with this spec's content. The local decision says create; the
68709
+ // cloud call has to agree, or the two halves disagree about identity.
68710
+ existingSpecVersion: decision.action === 'update' ? existing?.specVersion : undefined,
68711
+ payload: {
68712
+ title: spec.title,
68713
+ objective: spec.objective,
68714
+ ...(spec.currentState !== undefined ? { currentState: spec.currentState } : {}),
68715
+ outcomes: spec.outcomes,
68716
+ ...(spec.constraints ? { constraints: spec.constraints } : {}),
68717
+ ...(spec.healthMetrics ? { healthMetrics: spec.healthMetrics } : {}),
68718
+ ...(spec.edgeCases ? { edgeCases: spec.edgeCases } : {}),
68719
+ ...(spec.verification ? { verification: spec.verification } : {}),
68720
+ ...(spec.scope ? { scope: spec.scope } : {}),
68721
+ ...(spec.productId ? { productId: spec.productId } : {}),
68722
+ },
68723
+ decisions: spec.decisions,
68724
+ implementationContext: spec.implementationContext,
68725
+ onIdentitySettled: writeSpecFile,
68726
+ });
68727
+ if (!result.ok) {
68728
+ return {
68729
+ content: [{
68730
+ type: 'text',
68731
+ text: [
68732
+ `✗ Not saved. The workspace refused the spec, so ${filePath} was left alone.`,
68733
+ '',
68734
+ result.error,
68735
+ ...(result.code === 'PRODUCT_REQUIRED' && result.products?.length
68736
+ ? [
68737
+ '',
68738
+ `Ask the user which product this belongs to, then call ${retryTool} again with productId:`,
68739
+ ...result.products.map(p => ` - ${p.id} ${p.name}${p.isExample ? ' (example product)' : ''}`),
68740
+ 'Do not guess: the product decides which repository merge verification applies to.',
68741
+ ]
68742
+ : []),
68743
+ '',
68744
+ 'Writing the file anyway would leave it disagreeing with the record, invisibly.',
68745
+ ].join('\n'),
68746
+ }],
68747
+ isError: true,
68748
+ };
68749
+ }
68750
+ canonicalId = result.canonicalId;
68751
+ specVersion = result.specVersion;
68752
+ sourceUrl = result.sourceUrl;
68753
+ didNotTravel = result.didNotTravel;
68754
+ }
68755
+ const action = decision.action === 'update' ? `Updated intent spec (v${version})` : 'Saved intent spec';
68756
+ const readinessNote = verdict.ready
68757
+ ? ''
68758
+ : `\n\n${(0, readiness_1.formatReadinessVerdict)(verdict, spec.title)}`;
67537
68759
  return {
67538
68760
  content: [{
67539
68761
  type: 'text',
67540
68762
  text: [
67541
- `✗ Refusing to overwrite ${filePath}`,
67542
- '',
67543
- `It already holds a different intent (id: ${existing.id ?? 'unknown'}, status: ${existing.status}, version: ${existing.version}), and this spec is "${decision.id}".`,
67544
- '',
67545
- 'Save to another path (pass `path`), or pass overwrite=true to replace it deliberately.',
68763
+ `✓ ${action} at ${filePath}`,
68764
+ ` id: ${canonicalId} · status: ${status} · readiness: ${(0, readiness_1.formatReadinessFrontmatter)(verdict)}`,
68765
+ ...(sourceUrl ? [` synced to ${sourceUrl}`] : []),
68766
+ ...(didNotTravel.length
68767
+ ? ['', 'Saved, but these did NOT reach the workspace:', ...didNotTravel.map(x => ` - ${x}`)]
68768
+ : []),
68769
+ readinessNote,
68770
+ ...(isLocalMode
68771
+ ? ['', 'To connect this to Pathmode for dependency tracking and team collaboration, visit pathmode.io']
68772
+ : []),
67546
68773
  ].join('\n'),
67547
68774
  }],
67548
68775
  };
67549
68776
  }
67550
- const { id, version, status, created } = decision;
67551
- // The preflight verdict travels with the file: stamped into frontmatter on every save,
67552
- // recomputed from the spec alone (deterministic). A failing verdict never blocks the
67553
- // save the gate reports, the user decides.
67554
- const fields = toIntentFields(spec);
67555
- const verdict = (0, readiness_1.computeReadinessVerdict)(fields);
67556
- // The push and the file write are one ordered unit; see push-spec.ts for why the write
67557
- // cannot wait for the enrichment legs.
67558
- let canonicalId = id;
67559
- let specVersion;
67560
- let sourceUrl;
67561
- let didNotTravel = [];
67562
- const writeSpecFile = (opts) => {
67563
- let content = (0, intent_compiler_1.formatIntentMd)({ ...fields, id: opts.canonicalId }, {
67564
- version, status, created,
67565
- readiness: (0, readiness_1.formatReadinessFrontmatter)(verdict),
67566
- specVersion: opts.specVersion,
67567
- source: opts.sourceUrl,
67568
- });
67569
- // Carry existing confirmations across the rewrite. intent_save builds its content from
67570
- // the incoming spec, which by design has no `confirmations` field (that absence is what
67571
- // enforces M1: a confirmation cannot be minted in the write that authored the field).
67572
- // Without this the very next save silently deletes every confirmation the file holds,
67573
- // and the anchors would have caught nothing because the records would be gone.
67574
- // Confirmations are re-validated on read, so a save that changed the field text simply
67575
- // leaves them stale rather than wrongly live.
67576
- const prior = (0, fs_1.existsSync)(filePath) ? (0, local_reader_1.readIntentFile)(filePath)?.confirmations ?? [] : [];
67577
- if (prior.length) {
67578
- content = (0, intent_compiler_1.spliceConfirmationsSection)(content, (0, intent_compiler_1.renderConfirmationsSection)(prior));
68777
+ }
68778
+ server.tool('intent_import', 'Adopt an existing OpenSpec change as the Pathmode intent record. Reads the change folder exactly as the preflight does (proposal.md, spec deltas, design non-goals, verification in tasks.md) and saves it as intent.md — with a workspace key, also as a workspace intent. The change folder is NEVER modified, and field text travels verbatim, so never retype fields from the change yourself. After importing, resolve any unconfirmed dimensions with confirm_intent_dimension.', {
68779
+ path: zod_1.z.string().optional().describe('OpenSpec change directory, e.g. openspec/changes/add-export. When omitted the repo is scanned: exactly one active change imports directly; several returns the list to pick from.'),
68780
+ intentPath: zod_1.z.string().optional().describe('Where the intent file is written. Defaults to intent.md.'),
68781
+ overwrite: zod_1.z.boolean().optional().describe('Replace the intent file even when it already holds a DIFFERENT intent. Default false — refused instead.'),
68782
+ productId: zod_1.z.string().optional().describe('Cloud mode: the product this intent belongs to, when the workspace asks for one.'),
68783
+ }, async ({ path, intentPath, overwrite, productId }) => {
68784
+ let ref;
68785
+ if (path) {
68786
+ const dir = resolveWithinProject(path);
68787
+ if (!(0, fs_1.existsSync)(dir)) {
68788
+ return { content: [{ type: 'text', text: `✗ Nothing at ${path}. Pass the change directory, e.g. openspec/changes/<change-id>.` }] };
67579
68789
  }
67580
- (0, fs_1.writeFileSync)(filePath, content, 'utf-8');
67581
- };
67582
- if (isLocalMode) {
67583
- writeSpecFile({ canonicalId: id });
68790
+ ref = { id: (0, path_1.basename)(dir), dir };
67584
68791
  }
67585
68792
  else {
67586
- const result = await (0, push_spec_1.pushSpec)({
67587
- client: requireCloudClient(),
67588
- id,
67589
- existingSpecVersion: existing?.specVersion,
67590
- payload: {
67591
- title: spec.title,
67592
- objective: spec.objective,
67593
- ...(spec.currentState !== undefined ? { currentState: spec.currentState } : {}),
67594
- outcomes: spec.outcomes,
67595
- ...(spec.constraints ? { constraints: spec.constraints } : {}),
67596
- ...(spec.healthMetrics ? { healthMetrics: spec.healthMetrics } : {}),
67597
- ...(spec.edgeCases ? { edgeCases: spec.edgeCases } : {}),
67598
- ...(spec.verification ? { verification: spec.verification } : {}),
67599
- ...(spec.scope ? { scope: spec.scope } : {}),
67600
- ...(spec.productId ? { productId: spec.productId } : {}),
67601
- },
67602
- decisions: spec.decisions,
67603
- implementationContext: spec.implementationContext,
67604
- onIdentitySettled: writeSpecFile,
67605
- });
67606
- if (!result.ok) {
68793
+ const ctx = (0, openspec_reader_1.detectOpenSpec)(process.cwd());
68794
+ if (!ctx || ctx.changes.length === 0) {
68795
+ const resolution = (0, openspec_reader_1.resolveOpenSpecSubject)(ctx);
67607
68796
  return {
67608
68797
  content: [{
67609
68798
  type: 'text',
67610
- text: [
67611
- `✗ Not saved. The workspace refused the spec, so ${filePath} was left alone.`,
67612
- '',
67613
- result.error,
67614
- ...(result.code === 'PRODUCT_REQUIRED' && result.products?.length
67615
- ? [
67616
- '',
67617
- 'Ask the user which product this belongs to, then call intent_save again with productId:',
67618
- ...result.products.map(p => ` - ${p.id} ${p.name}${p.isExample ? ' (example product)' : ''}`),
67619
- 'Do not guess: the product decides which repository merge verification applies to.',
67620
- ]
67621
- : []),
67622
- '',
67623
- 'Writing the file anyway would leave it disagreeing with the record, invisibly.',
67624
- ].join('\n'),
68799
+ text: resolution.kind === 'message'
68800
+ ? `✗ ${resolution.text}`
68801
+ : '✗ No OpenSpec project detected here. Pass the change directory as path, e.g. openspec/changes/<change-id>.',
67625
68802
  }],
67626
- isError: true,
67627
68803
  };
67628
68804
  }
67629
- canonicalId = result.canonicalId;
67630
- specVersion = result.specVersion;
67631
- sourceUrl = result.sourceUrl;
67632
- didNotTravel = result.didNotTravel;
68805
+ if (ctx.changes.length > 1) {
68806
+ return {
68807
+ content: [{
68808
+ type: 'text',
68809
+ text: `✗ This repo has ${ctx.changes.length} OpenSpec changes: ${ctx.changes.map(c => c.id).join(', ')}. Ask which one to import, then pass it as path.`,
68810
+ }],
68811
+ };
68812
+ }
68813
+ ref = ctx.changes[0];
68814
+ }
68815
+ /**
68816
+ * Import NEVER updates an existing intent. intent_save treats a spec without an id as
68817
+ * "the same intent, bump the version", which is right for an agent iterating on its own
68818
+ * spec and wrong here: an import cannot know that whatever intent.md already holds
68819
+ * corresponds to this change, and the first smoke run of this tool proved the failure
68820
+ * mode by silently morphing an unrelated v3 intent into the imported change.
68821
+ */
68822
+ const targetRel = intentPath || 'intent.md';
68823
+ const targetAbs = resolveWithinProject(targetRel);
68824
+ if ((0, fs_1.existsSync)(targetAbs) && !overwrite) {
68825
+ const existingMeta = (0, local_reader_1.readIntentMeta)(targetAbs);
68826
+ return {
68827
+ content: [{
68828
+ type: 'text',
68829
+ text: [
68830
+ `✗ ${targetRel} already holds an intent${existingMeta?.id ? ` (id: ${existingMeta.id}, version: ${existingMeta.version})` : ''}.`,
68831
+ 'Import never updates an existing intent: it cannot know that file corresponds to this change.',
68832
+ 'Write the import elsewhere (pass intentPath), or pass overwrite:true to replace it deliberately.',
68833
+ ].join('\n'),
68834
+ }],
68835
+ };
68836
+ }
68837
+ const imported = (0, openspec_import_1.importOpenSpecChange)(ref);
68838
+ if (!imported.hasContent) {
68839
+ return {
68840
+ content: [{
68841
+ type: 'text',
68842
+ // The adapter's own reason wins when it has one: a change whose intent.md is
68843
+ // unreadable must not be described as having no proposal content, which
68844
+ // points the author at files that were never the problem.
68845
+ text: imported.error
68846
+ ? `✗ ${imported.error}`
68847
+ : `✗ OpenSpec change "${ref.id}" has no readable proposal, design, or spec content — there is no judgment to import. Describe the change in conversation and save it with intent_save instead.`,
68848
+ }],
68849
+ };
67633
68850
  }
67634
- const action = decision.action === 'update' ? `Updated intent spec (v${version})` : 'Saved intent spec';
67635
- const readinessNote = verdict.ready
67636
- ? ''
67637
- : `\n\n${(0, readiness_1.formatReadinessVerdict)(verdict, spec.title)}`;
68851
+ /**
68852
+ * Import always mints a NEW identity. decideSave treats a spec with no id as "the same
68853
+ * intent, bump the version", so without this an overwrite would hand the imported change
68854
+ * the previous intent's id, version, and status — the silent morph the refusal above
68855
+ * blocks on the non-overwrite path, still reachable through the flag.
68856
+ *
68857
+ * Consequence, stated because it is a real trade: re-importing the same change produces a
68858
+ * new intent rather than a new version of the old one. We cannot prove that an existing
68859
+ * intent.md corresponds to this change, and inventing that continuity is the more
68860
+ * expensive mistake.
68861
+ */
68862
+ const saved = await performIntentSave({ ...imported.fields, id: (0, crypto_1.randomUUID)(), ...(productId ? { productId } : {}) }, intentPath, overwrite, 'intent_import');
68863
+ // A refusal or workspace error from the save layer stands on its own; wrapping it in an
68864
+ // import banner would put a checkmark above a failure.
68865
+ const savedText = saved.content[0]?.text ?? '';
68866
+ if (saved.isError || savedText.startsWith('✗'))
68867
+ return saved;
67638
68868
  return {
67639
68869
  content: [{
67640
68870
  type: 'text',
67641
68871
  text: [
67642
- `✓ ${action} at ${filePath}`,
67643
- ` id: ${canonicalId} · status: ${status} · readiness: ${(0, readiness_1.formatReadinessFrontmatter)(verdict)}`,
67644
- ...(sourceUrl ? [` synced to ${sourceUrl}`] : []),
67645
- ...(didNotTravel.length
67646
- ? ['', 'Saved, but these did NOT reach the workspace:', ...didNotTravel.map(x => ` - ${x}`)]
67647
- : []),
67648
- readinessNote,
67649
- ...(isLocalMode
67650
- ? ['', 'To connect this to Pathmode for dependency tracking and team collaboration, visit pathmode.io']
67651
- : []),
68872
+ `✓ Imported OpenSpec change "${ref.id}" the change folder was not modified.`,
68873
+ ...imported.notes.map(n => ` · ${n}`),
68874
+ '',
68875
+ savedText,
67652
68876
  ].join('\n'),
67653
68877
  }],
67654
68878
  };
@@ -67856,12 +69080,52 @@ function startMcpServer() {
67856
69080
  }],
67857
69081
  };
67858
69082
  });
67859
- server.tool('intent_export', 'Export an intent spec as .cursorrules, a CLAUDE.md or AGENTS.md section, or a Claude Managed Agents Outcomes rubric for AI agent consumption. Use agents-md for Codex, Cursor, and other AGENTS.md-aware agents.', {
67860
- format: zod_1.z.enum(['cursorrules', 'claude-md', 'agents-md', 'outcome-rubric']).describe('Export format'),
69083
+ server.tool('intent_export', 'Export an intent spec as .cursorrules, a CLAUDE.md or AGENTS.md section, a Claude Managed Agents Outcomes rubric, or an OpenSpec change folder. Use agents-md for Codex, Cursor, and other AGENTS.md-aware agents. Use openspec to hand the intent to an OpenSpec workflow: it writes proposal.md, a spec delta, and the verification contract in tasks.md, placed where the preflight reader finds them, and never writes into an existing change.', {
69084
+ format: zod_1.z.enum(['cursorrules', 'claude-md', 'agents-md', 'outcome-rubric', 'openspec']).describe('Export format'),
67861
69085
  spec: zod_1.z.object(intentSpecSchema),
67862
- path: zod_1.z.string().optional().describe('Output file path relative to the project root. Must stay inside the project (absolute paths and ".." are rejected). Defaults to .cursorrules, CLAUDE.md, or AGENTS.md'),
69086
+ path: zod_1.z.string().optional().describe('Output file path relative to the project root. Must stay inside the project (absolute paths and ".." are rejected). Defaults to .cursorrules, CLAUDE.md, AGENTS.md, or openspec/changes/<change-id>'),
67863
69087
  }, async ({ format, spec, path }) => {
67864
69088
  const fields = toIntentFields(spec);
69089
+ if (format === 'openspec') {
69090
+ const change = (0, openspec_writer_1.formatOpenSpecChange)(fields);
69091
+ const baseRel = path || `openspec/changes/${change.changeId}`;
69092
+ const baseAbs = resolveWithinProject(baseRel);
69093
+ /**
69094
+ * Refuse, never merge. A change folder is authored territory: their workflow may have
69095
+ * tasks half-checked and specs mid-review, and "merge" here would mean guessing which
69096
+ * of two proposals is the real one. The refusal names the way out.
69097
+ */
69098
+ if ((0, fs_1.existsSync)(baseAbs)) {
69099
+ return {
69100
+ content: [{
69101
+ type: 'text',
69102
+ text: `✗ ${baseRel} already exists. This tool never writes into an existing change. Pass a different path, or archive/remove the old change first.`,
69103
+ }],
69104
+ };
69105
+ }
69106
+ for (const f of change.files) {
69107
+ const abs = (0, path_1.join)(baseAbs, f.path);
69108
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(abs), { recursive: true });
69109
+ (0, fs_1.writeFileSync)(abs, f.content, 'utf-8');
69110
+ }
69111
+ return {
69112
+ content: [{
69113
+ type: 'text',
69114
+ text: [
69115
+ `✓ Exported OpenSpec change "${change.changeId}" to ${baseRel}`,
69116
+ ...change.files.map(f => ` ${f.path}`),
69117
+ ...(change.uncoveredRequirements.length ? [
69118
+ '',
69119
+ `⚠ OpenSpec requires at least one scenario per requirement, and ${change.uncoveredRequirements.length} requirement(s) have none: ${change.uncoveredRequirements.join('; ')}.`,
69120
+ ' Their validator will flag these. Add an edge case for each (scenario + expected behavior) rather than inventing one to satisfy the check.',
69121
+ ] : []),
69122
+ '',
69123
+ 'The change is readable by the OpenSpec workflow as-is. Verify the emit preserved the verdict:',
69124
+ ` npx -y @pathmode/cli preflight ${baseRel}`,
69125
+ ].join('\n'),
69126
+ }],
69127
+ };
69128
+ }
67865
69129
  if (format === 'cursorrules') {
67866
69130
  const content = (0, intent_compiler_1.formatCursorRules)(fields);
67867
69131
  const filePath = resolveWithinProject(path || '.cursorrules');