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