Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
188 lines
8.3 KiB
JavaScript
188 lines
8.3 KiB
JavaScript
'use strict';
|
|
// hooks/lib/dispatch-identity.js — the ONE canonical owner of the
|
|
// `[msd:dispatch phase="…" plan="…"]` marker format and its prose fallback
|
|
// (#4594, epic #4630 Phase 1). See
|
|
// `.msd/phase/fix-4594-dispatch-identity-seam/40-design.md` for the full
|
|
// rationale (behavior table, negative space, rejected alternatives).
|
|
//
|
|
// COLD-LOAD CONSTRAINT (load-bearing, not a style choice): this module MUST
|
|
// require NOTHING — no `fs`, no `path`, and above all nothing under
|
|
// `msd-core/bin/lib/` or `ensure-runtime-build`. The guard hooks that consume
|
|
// this module must load on a raw plugin-marketplace install where the
|
|
// compiled lib is absent and the self-healing build seam has not run — a
|
|
// hook that dies at module load is worse than one carrying a mirror. See
|
|
// `tests/dispatch-identity.test.cjs`'s "cold tree load" test, which asserts
|
|
// this by monkeypatching `Module._load` to throw on exactly those paths.
|
|
|
|
// DISPATCH_PHASE_TOKEN_SOURCE is a DELIBERATE MIRROR of
|
|
// `CASE_FLEXIBLE_PHASE_NUMBER_TOKEN_SOURCE` in `src/phase-id.cts`
|
|
// (ADR-2121 owns the phase-token grammar; `msd-core/bin/lib/phase-id.cjs` is
|
|
// its compiled form). It cannot be an `require()`-based import: importing the
|
|
// compiled lib here would violate the cold-load constraint above (either a
|
|
// direct dependency on `msd-core/bin/lib/` or a forced self-heal via
|
|
// `ensure-runtime-build`), which is exactly the failure mode this module
|
|
// exists to avoid for the guard hooks that consume it.
|
|
//
|
|
// Because this is a hand-copied mirror and not a shared reference, a
|
|
// hand-edited grammar change on one side that is not mirrored on the other
|
|
// does NOT fail loudly — both sides remain independently valid regex
|
|
// sources, so the failure mode is a silent, invisible non-match (a dispatch
|
|
// whose phase token the two owners now parse differently), never a thrown
|
|
// error. `tests/dispatch-identity.test.cjs`'s "templates: prose token source
|
|
// matches the case-flexible phase-id grammar" test pins this string equal to
|
|
// the compiled source at test time specifically to turn that silent drift
|
|
// into a loud, in-CI failure.
|
|
const DISPATCH_PHASE_TOKEN_SOURCE = '\\d+[A-Za-z]?(?:\\.\\d+)*';
|
|
|
|
// Bounded marker grammar: `[msd:dispatch key="value" key2="value2"]`.
|
|
// - Key names bounded to {1,31} — no key we emit or expect is anywhere near
|
|
// that long; this is purely a backstop against pathological input.
|
|
// - Values bounded to {0,200} and forbidden from containing `"`, `]`, or any
|
|
// control character that could either close the marker early or forge a
|
|
// sibling key — enforced structurally by the negated character class, not
|
|
// by a separate validation pass.
|
|
// Global flag so `findMarker` can scan forward through a large prompt.
|
|
const MARKER_RE = /\[msd:dispatch((?:\s+[A-Za-z][A-Za-z0-9_-]{0,31}="[^"\]\r\n]{0,200}")*)\s*\]/g;
|
|
const MARKER_KV_RE = /([A-Za-z][A-Za-z0-9_-]{0,31})="([^"\]\r\n]{0,200})"/g;
|
|
|
|
// Prose fallback frame: `execute plan <token> of phase <PHASE TOKEN>`.
|
|
// - Case-insensitive, `\s+` throughout so CRLF (and any run of whitespace)
|
|
// parses identically to LF.
|
|
// - The plan token is bounded (`\S{1,80}`) but its VALUE is deliberately
|
|
// discarded by the caller (see `parseDispatchIdentity` below) — captured
|
|
// only so the phase token can be anchored correctly after it.
|
|
// - The phase token is bounded by `DISPATCH_PHASE_TOKEN_SOURCE`, replacing
|
|
// the old greedy `(\S+)`, so a directory-name suffix (`-auth`) or a
|
|
// sentence-terminating period is never swept into the token.
|
|
const PROSE_RE = new RegExp(
|
|
`execute\\s+plan\\s+(\\S{1,80})\\s+of\\s+phase\\s+(${DISPATCH_PHASE_TOKEN_SOURCE})`,
|
|
'i',
|
|
);
|
|
|
|
/**
|
|
* Render the `[msd:dispatch phase="…" plan="…"]` marker for a producer to
|
|
* embed verbatim in a dispatch description/prompt. Never throws.
|
|
*
|
|
* Emits only keys whose value is a non-empty string not containing `"`, `]`,
|
|
* `\r`, or `\n` — such a value is UNUSABLE and that key is omitted entirely,
|
|
* because embedding it could close the marker early and forge a second,
|
|
* attacker-controlled field. Key order is always `phase` then `plan`.
|
|
* Returns `''` when neither key is usable (nothing worth emitting).
|
|
*/
|
|
function renderDispatchIdentityMarker(input) {
|
|
const value = input && typeof input === 'object' ? input : {};
|
|
const parts = [];
|
|
for (const key of ['phase', 'plan']) {
|
|
const v = value[key];
|
|
if (typeof v === 'string' && v.length > 0 && !/["\]\r\n]/.test(v)) {
|
|
parts.push(`${key}="${v}"`);
|
|
}
|
|
}
|
|
if (parts.length === 0) return '';
|
|
return `[msd:dispatch ${parts.join(' ')}]`;
|
|
}
|
|
|
|
function emptyResult() {
|
|
return { phase: null, plan: null, source: null };
|
|
}
|
|
|
|
/**
|
|
* Scan `texts` in order for the first well-formed marker that yields at
|
|
* least one recognized key (`phase` and/or `plan`). Returns `{ phase, plan }`
|
|
* (one of the two possibly null, never both) or `null` if no QUALIFYING
|
|
* marker was found in any text. Unrecognized keys inside a marker are
|
|
* ignored (forward compatibility — a later `run=`/`wave=` key must not break
|
|
* a deployed parser).
|
|
*
|
|
* #4594 F1 fix: a marker that matches `MARKER_RE` but carries neither
|
|
* `phase=` nor `plan=` (e.g. only unrecognized keys, or an empty kv block)
|
|
* is NOT treated as "found" — it is skipped and scanning continues (later
|
|
* markers in the same text, then subsequent texts), falling through to the
|
|
* prose fallback if nothing qualifying turns up. Without this, prompt text
|
|
* that merely CONTAINS the literal marker syntax with no usable identifiers
|
|
* silently suppressed the prose fallback entirely, since the old
|
|
* implementation returned on the first syntactic match regardless of
|
|
* content.
|
|
*/
|
|
function findMarker(texts) {
|
|
for (const text of texts) {
|
|
if (typeof text !== 'string' || text.length === 0) continue;
|
|
MARKER_RE.lastIndex = 0;
|
|
let match;
|
|
while ((match = MARKER_RE.exec(text)) !== null) {
|
|
const kvBlock = match[1] || '';
|
|
let phase = null;
|
|
let plan = null;
|
|
MARKER_KV_RE.lastIndex = 0;
|
|
let kv;
|
|
while ((kv = MARKER_KV_RE.exec(kvBlock)) !== null) {
|
|
const [, key, val] = kv;
|
|
if (key === 'phase' && phase === null) phase = val;
|
|
else if (key === 'plan' && plan === null) plan = val;
|
|
}
|
|
if (phase === null && plan === null) continue;
|
|
return { phase, plan };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Scan `texts` in order for the first `execute plan <token> of phase
|
|
* <PHASE TOKEN>` frame. Returns `{ phase }` or `null`.
|
|
*
|
|
* The prose fallback returns `plan: null` DELIBERATELY (enforced by the
|
|
* caller, not here): the prose plan token (e.g. `02`) is a bare in-phase
|
|
* plan number, a different namespace from the sentinel's `plan_id` (e.g.
|
|
* `03-02-hardening`, which is phase-prefixed AND slugged). Reporting the
|
|
* prose plan token as the parsed `plan` is exactly the false-mismatch bug
|
|
* this module exists to fix — an absent value is honestly "cannot compare",
|
|
* while a wrong value silently forges a mismatch on every dispatch. Do not
|
|
* "fix" this by threading the plan token through — that was the bug.
|
|
*/
|
|
function findProse(texts) {
|
|
for (const text of texts) {
|
|
if (typeof text !== 'string' || text.length === 0) continue;
|
|
const match = PROSE_RE.exec(text);
|
|
if (match) {
|
|
return { phase: match[2] };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse dispatch identity out of one or more texts (a short description, a
|
|
* full prompt body, etc). Never throws for any input type; non-string
|
|
* entries are skipped.
|
|
*
|
|
* Pass 1: scan all texts in order for a well-formed marker; the first one
|
|
* wins outright (marker beats prose even if prose appears earlier in the
|
|
* same text — see the design doc's row 8).
|
|
* Pass 2 (only if no marker was found anywhere): scan all texts in order for
|
|
* the prose frame; the first match wins. `plan` is always `null` from this
|
|
* path (see `findProse`'s doc comment).
|
|
*
|
|
* Returns `{ phase, plan, source }` where `source` is `'marker' | 'prose' |
|
|
* null`.
|
|
*/
|
|
function parseDispatchIdentity(...texts) {
|
|
const marker = findMarker(texts);
|
|
if (marker) {
|
|
return { phase: marker.phase, plan: marker.plan, source: 'marker' };
|
|
}
|
|
|
|
const prose = findProse(texts);
|
|
if (prose) {
|
|
return { phase: prose.phase, plan: null, source: 'prose' };
|
|
}
|
|
|
|
return emptyResult();
|
|
}
|
|
|
|
module.exports = {
|
|
DISPATCH_PHASE_TOKEN_SOURCE,
|
|
renderDispatchIdentityMarker,
|
|
parseDispatchIdentity,
|
|
};
|