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