@smartmemory/compose 0.1.38-beta → 0.1.39-beta

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/bin/compose.js CHANGED
@@ -1589,6 +1589,7 @@ if (cmd === 'validate') {
1589
1589
  let code = null
1590
1590
  let blockOn = 'error'
1591
1591
  let asJson = false
1592
+ let externalXref = false
1592
1593
  for (let i = 0; i < args.length; i++) {
1593
1594
  const a = args[i]
1594
1595
  if (a === '--help' || a === '-h') {
@@ -1608,6 +1609,7 @@ Exit codes:
1608
1609
  process.exit(0)
1609
1610
  }
1610
1611
  if (a === '--json') { asJson = true; continue }
1612
+ if (a === '--external') { externalXref = true; continue }
1611
1613
  if (a.startsWith('--scope=')) scope = a.slice('--scope='.length)
1612
1614
  else if (a === '--scope') scope = args[++i]
1613
1615
  else if (a.startsWith('--code=')) code = a.slice('--code='.length)
@@ -1638,7 +1640,7 @@ Exit codes:
1638
1640
  try {
1639
1641
  result = scope === 'feature'
1640
1642
  ? await validateFeature(valCwd, code)
1641
- : await validateProject(valCwd)
1643
+ : await validateProject(valCwd, { external: externalXref })
1642
1644
  } catch (err) {
1643
1645
  if (err.code === 'INVALID_INPUT') {
1644
1646
  console.error(`Error [INVALID_INPUT]: ${err.message}`)
@@ -2,6 +2,14 @@
2
2
  # Compose pre-push hook — runs `compose validate` and blocks the push on
3
3
  # any error-severity drift finding. Installed by `compose hooks install --pre-push`;
4
4
  # placeholders below are substituted at install time.
5
+ #
6
+ # External-reference (xref) resolution is OFF here by default: github xref:
7
+ # citations / kind:"external" links emit XREF_RESOLUTION_SKIPPED (warning,
8
+ # non-blocking under --block-on=error) while local/url/malformed still
9
+ # surface. Opt in to network github resolution by exporting
10
+ # COMPOSE_XREF_ONLINE=1, or set `xref.prePushOnline: true` in
11
+ # .compose/compose.json — both are honored by `compose validate` directly
12
+ # (no flag change needed below). XREF_TARGET_MISSING stays error and blocks.
5
13
 
6
14
  set -u
7
15
  COMPOSE_NODE="__COMPOSE_NODE__"
@@ -68,12 +68,62 @@
68
68
  "type": "array",
69
69
  "items": {
70
70
  "type": "object",
71
- "required": ["kind", "to_code"],
72
71
  "properties": {
73
- "kind": { "type": "string", "enum": ["surfaced_by", "blocks", "depends_on", "follow_up", "supersedes", "related"] },
72
+ "kind": { "type": "string", "enum": ["surfaced_by", "blocks", "depends_on", "follow_up", "supersedes", "related", "external"] },
74
73
  "to_code": { "type": "string", "pattern": "^[A-Z][A-Z0-9-]*[A-Z0-9]$" },
75
- "note": { "type": "string" }
76
- }
74
+ "note": { "type": "string" },
75
+ "provider": { "type": "string", "enum": ["github", "local", "url", "jira", "linear", "notion", "obsidian"] },
76
+ "repo": { "type": "string" },
77
+ "issue": { "type": "integer", "minimum": 1 },
78
+ "url": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9+\\-.]*://" },
79
+ "expect": { "type": "string" }
80
+ },
81
+ "allOf": [
82
+ {
83
+ "if": { "not": { "required": ["kind"], "properties": { "kind": { "const": "external" } } } },
84
+ "then": { "required": ["kind", "to_code"] }
85
+ },
86
+ {
87
+ "if": { "properties": { "kind": { "const": "external" } }, "required": ["kind"] },
88
+ "then": {
89
+ "required": ["provider"],
90
+ "allOf": [
91
+ {
92
+ "if": { "properties": { "provider": { "const": "github" } }, "required": ["provider"] },
93
+ "then": {
94
+ "required": ["repo", "issue"],
95
+ "properties": {
96
+ "repo": { "pattern": "^[^\\s/#]+/[^\\s/#]+$" },
97
+ "expect": { "enum": ["open", "closed"] }
98
+ }
99
+ }
100
+ },
101
+ {
102
+ "if": { "properties": { "provider": { "const": "local" } }, "required": ["provider"] },
103
+ "then": {
104
+ "required": ["repo", "to_code"],
105
+ "properties": {
106
+ "repo": {
107
+ "pattern": "^[A-Za-z0-9._-]+$",
108
+ "not": { "enum": [".", ".."] }
109
+ },
110
+ "expect": { "enum": ["PLANNED", "IN_PROGRESS", "PARTIAL", "COMPLETE", "SUPERSEDED", "PARKED", "BLOCKED", "KILLED"] }
111
+ }
112
+ }
113
+ },
114
+ {
115
+ "if": { "properties": { "provider": { "enum": ["url", "jira", "linear", "notion", "obsidian"] } }, "required": ["provider"] },
116
+ "then": {
117
+ "required": ["url"],
118
+ "properties": {
119
+ "url": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9+\\-.]*://" }
120
+ }
121
+ }
122
+ }
123
+ ]
124
+ }
125
+ }
126
+ ]
77
127
  }
78
128
  },
79
129
  "completions": {
@@ -13,7 +13,19 @@
13
13
  *
14
14
  * Each finding: { severity: 'error'|'warning'|'info', kind, feature_code?, detail, source? }.
15
15
  *
16
- * Catalog (27 kinds): see docs/features/COMP-MCP-VALIDATE/design.md.
16
+ * Catalog (32 kinds). The original 27 cross-artifact kinds, plus the 5
17
+ * COMP-MCP-XREF-VALIDATE (#16) read-only external-reference kinds:
18
+ * - XREF_DRIFT (warning) resolved state blatantly contradicts the
19
+ * citing row / explicit expect=
20
+ * - XREF_TARGET_MISSING (error) github 404 / local target absent
21
+ * - XREF_MALFORMED (warning) <!--xref:…--> matched but failed grammar
22
+ * - XREF_RESOLUTION_SKIPPED (warning) offline / no-token / rate-limit / ≥500
23
+ * / gate off — NEVER error, never aborts
24
+ * - XREF_URL_UNCHECKED (info) url + reserved url-class providers
25
+ * (jira|linear|notion|obsidian) — recorded,
26
+ * not resolved
27
+ * Full catalog + trigger/degrade/gating contract:
28
+ * docs/features/COMP-MCP-VALIDATE/design.md
17
29
  */
18
30
 
19
31
  import fs from 'node:fs';
@@ -22,6 +34,8 @@ import { fileURLToPath } from 'node:url';
22
34
  import { FEATURE_CODE_RE_STRICT, validateCode } from './feature-code.js';
23
35
  import { parseRoadmap } from './roadmap-parser.js';
24
36
  import { listFeatures, readFeature } from './feature-json.js';
37
+ import { parseCitations } from './xref-citation.js';
38
+ import { GitHubApi } from './tracker/github-api.js';
25
39
  import { ArtifactManager } from '../server/artifact-manager.js';
26
40
  import { SchemaValidator } from '../server/schema-validator.js';
27
41
 
@@ -79,6 +93,11 @@ function loadValidationContext(cwd, options = {}) {
79
93
  // status values (PARTIAL, COMPLETE) match the strict code regex, or where
80
94
  // descriptions contain code-like uppercase tokens.
81
95
  let roadmapRows = [];
96
+ // COMP-MCP-XREF-VALIDATE #16: anon-row-safe citation capture. Independent
97
+ // of roadmapByCode (which drops rows whose code is not strict). Additive —
98
+ // does not change roadmapRows / position / roadmapByCode.
99
+ const citationRows = [];
100
+ let citePosition = 0;
82
101
  try {
83
102
  const text = fs.readFileSync(options.roadmapPath || paths.roadmap, 'utf8');
84
103
  let phaseId = '';
@@ -123,9 +142,18 @@ function loadValidationContext(cwd, options = {}) {
123
142
  if (codeIdx >= cols.length || statusIdx >= cols.length) continue;
124
143
 
125
144
  const codeRaw = cols[codeIdx].replace(/\*/g, '').replace(/`/g, '').trim();
126
- if (!FEATURE_CODE_RE_STRICT.test(codeRaw)) continue;
127
- const status = cols[statusIdx].replace(/\*/g, '').trim();
128
145
  const description = descIdx >= 0 && descIdx < cols.length ? cols[descIdx] : '';
146
+ const status = cols[statusIdx].replace(/\*/g, '').trim();
147
+ const isStrictCode = FEATURE_CODE_RE_STRICT.test(codeRaw);
148
+ // Anon-inclusive citation row capture (independent counter).
149
+ citePosition += 1;
150
+ citationRows.push({
151
+ code: isStrictCode ? codeRaw : null,
152
+ description,
153
+ status,
154
+ rowPosition: citePosition,
155
+ });
156
+ if (!isStrictCode) continue;
129
157
  position += 1;
130
158
  roadmapRows.push({ code: codeRaw, description, status, phaseId, position });
131
159
  }
@@ -177,6 +205,7 @@ function loadValidationContext(cwd, options = {}) {
177
205
  visionItems,
178
206
  visionStateRaw,
179
207
  foldersByCode,
208
+ citationRows,
180
209
  externalPrefixes: options.externalPrefixes || [],
181
210
  featureJsonMode: options.featureJsonMode !== false,
182
211
  };
@@ -605,6 +634,335 @@ export async function validateFeature(cwd, code, options = {}) {
605
634
  return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings };
606
635
  }
607
636
 
637
+ // ---------------------------------------------------------------------------
638
+ // COMP-MCP-XREF-VALIDATE #16 — read-only external-reference staleness checks
639
+ // ---------------------------------------------------------------------------
640
+
641
+ const WS_ID_RE = /^[a-z][a-z0-9-]{1,63}$/;
642
+ const URL_CLASS = new Set(['url', 'jira', 'linear', 'notion', 'obsidian']);
643
+ const TERMINAL_ISH = new Set(['COMPLETE', 'SUPERSEDED']);
644
+ const OPEN_ISH = new Set(['PLANNED', 'IN_PROGRESS']);
645
+
646
+ function resolveCitingWorkspaceId(cwd, options, cfg) {
647
+ if (options.citingWorkspaceId) return options.citingWorkspaceId;
648
+ if (cfg && typeof cfg.workspaceId === 'string' && WS_ID_RE.test(cfg.workspaceId)) {
649
+ return cfg.workspaceId;
650
+ }
651
+ const base = path.basename(cwd);
652
+ return base === 'forge' ? 'forge-top' : base;
653
+ }
654
+
655
+ function xrefGateOn(options, cfg) {
656
+ return options.external === true
657
+ || process.env.COMPOSE_XREF_ONLINE === '1'
658
+ || !!(cfg && cfg.xref && cfg.xref.prePushOnline === true);
659
+ }
660
+
661
+ // Build the normalized ExternalRef list from both carriers (roadmap citations
662
+ // — anon-row-safe — and feature.json links[] kind:"external"). Parse errors
663
+ // from the grammar become XREF_MALFORMED findings.
664
+ function collectExternalRefs(ctx, citingWorkspaceId, findings) {
665
+ const refs = [];
666
+ for (const row of ctx.citationRows || []) {
667
+ const { refs: parsed, errors } = parseCitations(row.description || '');
668
+ for (const e of errors) {
669
+ findings.push(finding(
670
+ 'warning', 'XREF_MALFORMED', row.code || undefined,
671
+ `row #${row.rowPosition}: malformed xref citation (${e.reason}) — "${String(row.description).slice(0, 80)}"`,
672
+ 'roadmap-citation',
673
+ ));
674
+ }
675
+ for (const p of parsed) {
676
+ refs.push({
677
+ source: 'roadmap-citation',
678
+ citing: {
679
+ workspaceId: citingWorkspaceId,
680
+ code: row.code || null,
681
+ rowPosition: row.rowPosition,
682
+ rowDescription: String(row.description || '').slice(0, 80),
683
+ status: row.status || null,
684
+ },
685
+ provider: p.provider, repo: p.repo, issue: p.issue,
686
+ toCode: p.toCode, url: p.url, expect: p.expect, note: p.note,
687
+ });
688
+ }
689
+ }
690
+ for (const [code, folder] of ctx.foldersByCode) {
691
+ if (!folder.hasFeatureJson) continue;
692
+ let fj;
693
+ try { fj = JSON.parse(fs.readFileSync(path.join(folder.dir, 'feature.json'), 'utf8')); }
694
+ catch { continue; }
695
+ if (!Array.isArray(fj.links)) continue;
696
+ for (const l of fj.links) {
697
+ if (l && l.kind === 'external') {
698
+ refs.push({
699
+ source: 'feature-json-link',
700
+ citing: {
701
+ workspaceId: citingWorkspaceId,
702
+ code,
703
+ rowPosition: null,
704
+ rowDescription: null,
705
+ status: fj.status || ctx.roadmapByCode.get(code)?.status || null,
706
+ },
707
+ provider: l.provider, repo: l.repo ?? null, issue: l.issue ?? null,
708
+ toCode: l.to_code ?? null, url: l.url ?? null,
709
+ expect: l.expect ?? null, note: l.note ?? null,
710
+ });
711
+ }
712
+ }
713
+ }
714
+ return refs;
715
+ }
716
+
717
+ function locatorDetail(ref, msg) {
718
+ // citing.workspaceId is the spec-contracted "citing label" (spec §3.3/§4):
719
+ // surface it so cross-repo findings name which workspace cited the ref.
720
+ const ws = ref.citing.workspaceId ? `[${ref.citing.workspaceId}] ` : '';
721
+ if (ref.citing.code) return `${ws}${msg}`;
722
+ return `${ws}row #${ref.citing.rowPosition}: "${ref.citing.rowDescription}" — ${msg}`;
723
+ }
724
+
725
+ function githubDrift(ref, state) {
726
+ // explicit expect is authoritative
727
+ if (ref.expect === 'open' || ref.expect === 'closed') {
728
+ return state !== ref.expect
729
+ ? `expected ${ref.repo}#${ref.issue} to be ${ref.expect} but it is ${state}`
730
+ : null;
731
+ }
732
+ // absent expect → derive from citing-row status; blatant contradiction only
733
+ const s = ref.citing.status;
734
+ if (TERMINAL_ISH.has(s) && state === 'open') {
735
+ return `citing row is ${s} but ${ref.repo}#${ref.issue} is still open`;
736
+ }
737
+ if (OPEN_ISH.has(s) && state === 'closed') {
738
+ return `citing row is ${s} but ${ref.repo}#${ref.issue} is already closed`;
739
+ }
740
+ return null;
741
+ }
742
+
743
+ async function resolveGithubRef(ref, gh, findings) {
744
+ const code = ref.citing.code || undefined;
745
+ let r;
746
+ try {
747
+ r = await gh.getIssueResult(ref.issue);
748
+ } catch (e) {
749
+ if (e && e.rateLimit) { const x = new Error('ratelimit'); x._rateLimit = true; throw x; }
750
+ // offline / fetch reject / unexpected — per-ref degrade
751
+ findings.push(finding(
752
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
753
+ locatorDetail(ref, `github resolution skipped for ${ref.repo}#${ref.issue}: ${e && e.message ? e.message : e}`),
754
+ ref.source,
755
+ ));
756
+ return;
757
+ }
758
+ if (r.status === 404) {
759
+ findings.push(finding(
760
+ 'error', 'XREF_TARGET_MISSING', code,
761
+ locatorDetail(ref, `github ${ref.repo}#${ref.issue} not found (404)`),
762
+ ref.source,
763
+ ));
764
+ return;
765
+ }
766
+ if (r.status < 200 || r.status >= 300) {
767
+ findings.push(finding(
768
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
769
+ locatorDetail(ref, `github ${ref.repo}#${ref.issue} unresolved (HTTP ${r.status})`),
770
+ ref.source,
771
+ ));
772
+ return;
773
+ }
774
+ if (!r.body || (r.body.state !== 'open' && r.body.state !== 'closed')) {
775
+ // 2xx but unparseable/missing state (github-api.js _req coerces JSON
776
+ // parse failures to {}) — degrade, do not assume a state.
777
+ findings.push(finding(
778
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
779
+ locatorDetail(ref, `github ${ref.repo}#${ref.issue} returned no parseable issue state (HTTP ${r.status})`),
780
+ ref.source,
781
+ ));
782
+ return;
783
+ }
784
+ const state = r.body.state;
785
+ const drift = githubDrift(ref, state);
786
+ if (drift) {
787
+ findings.push(finding('warning', 'XREF_DRIFT', code, locatorDetail(ref, drift), ref.source));
788
+ }
789
+ }
790
+
791
+ function resolveLocalRef(ref, cwd, findings) {
792
+ const code = ref.citing.code || undefined;
793
+ // Containment guard: repo token must resolve to a direct sibling of cwd.
794
+ // (The grammar already constrains roadmap citations; this also covers the
795
+ // feature.json-link carrier and is belt-and-suspenders against traversal.)
796
+ const parentDir = path.resolve(cwd, '..');
797
+ const citedRoot = path.resolve(parentDir, String(ref.repo || ''));
798
+ // Lexical check first (cheap, rejects obvious traversal / separators).
799
+ let unsafe = !ref.repo || /[\\/]/.test(ref.repo) || ref.repo === '.' || ref.repo === '..'
800
+ || path.dirname(citedRoot) !== parentDir;
801
+ // Canonicalize to defeat a valid-named sibling that is a symlink pointing
802
+ // outside the parent. realpath throws if the path is absent — that is just
803
+ // "target missing", handled by the same finding below.
804
+ if (!unsafe) {
805
+ try {
806
+ const realParent = fs.realpathSync(parentDir);
807
+ const realCited = fs.realpathSync(citedRoot);
808
+ if (path.dirname(realCited) !== realParent) unsafe = true;
809
+ } catch { unsafe = true; }
810
+ }
811
+ if (unsafe) {
812
+ findings.push(finding(
813
+ 'error', 'XREF_TARGET_MISSING', code,
814
+ locatorDetail(ref, `local repo token "${ref.repo}" is not a valid sibling directory (missing or escapes the workspace parent)`),
815
+ ref.source,
816
+ ));
817
+ return;
818
+ }
819
+ let resolvedStatus = null;
820
+ try {
821
+ const paths = resolveProjectPaths(citedRoot);
822
+ const fjPath = path.join(paths.features, ref.toCode, 'feature.json');
823
+ if (fs.existsSync(fjPath)) {
824
+ resolvedStatus = JSON.parse(fs.readFileSync(fjPath, 'utf8')).status || null;
825
+ } else {
826
+ // fall back to a ROADMAP row in the cited repo
827
+ const sub = loadValidationContext(citedRoot, {});
828
+ resolvedStatus = sub.roadmapByCode.get(ref.toCode)?.status || null;
829
+ }
830
+ } catch { resolvedStatus = null; }
831
+ if (resolvedStatus === null) {
832
+ findings.push(finding(
833
+ 'error', 'XREF_TARGET_MISSING', code,
834
+ locatorDetail(ref, `local ${ref.repo} ${ref.toCode} not found (no feature.json or ROADMAP row)`),
835
+ ref.source,
836
+ ));
837
+ return;
838
+ }
839
+ let drift = null;
840
+ if (ref.expect && resolvedStatus !== ref.expect) {
841
+ drift = `expected ${ref.toCode} to be ${ref.expect} but it is ${resolvedStatus}`;
842
+ } else if (!ref.expect) {
843
+ const s = ref.citing.status;
844
+ if (OPEN_ISH.has(s) && TERMINAL_ISH.has(resolvedStatus) === false && resolvedStatus === 'KILLED') {
845
+ drift = `citing row is ${s} but ${ref.toCode} is KILLED`;
846
+ } else if (TERMINAL_ISH.has(s) && OPEN_ISH.has(resolvedStatus)) {
847
+ drift = `citing row is ${s} but ${ref.toCode} is still ${resolvedStatus}`;
848
+ }
849
+ }
850
+ if (drift) {
851
+ findings.push(finding('warning', 'XREF_DRIFT', code, locatorDetail(ref, drift), ref.source));
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Read-only external-reference resolution. Extends validateProject; never
857
+ * writes any file or issue. Gated: full network resolution only when
858
+ * options.external / COMPOSE_XREF_ONLINE=1 / compose.json xref.prePushOnline.
859
+ * Degrade contract (spec §6): every resolution failure is a WARNING
860
+ * (XREF_RESOLUTION_SKIPPED), never an error, never aborts the run.
861
+ */
862
+ async function runExternalRefChecks(ctx, findings, options = {}) {
863
+ const cfg = readProjectConfig(ctx.cwd);
864
+ const citingWorkspaceId = resolveCitingWorkspaceId(ctx.cwd, options, cfg);
865
+ const refs = collectExternalRefs(ctx, citingWorkspaceId, findings);
866
+ if (refs.length === 0) return;
867
+
868
+ const gateOn = xrefGateOn(options, cfg);
869
+ let noTokenAggregated = false;
870
+ let githubShortCircuited = false;
871
+
872
+ for (const ref of refs) {
873
+ const code = ref.citing.code || undefined;
874
+ try {
875
+ if (ref.provider === 'local') {
876
+ resolveLocalRef(ref, ctx.cwd, findings);
877
+ continue;
878
+ }
879
+ if (URL_CLASS.has(ref.provider)) {
880
+ findings.push(finding(
881
+ 'info', 'XREF_URL_UNCHECKED', code,
882
+ locatorDetail(ref, `${ref.provider} pointer recorded, not status-resolved: ${ref.url}`),
883
+ ref.source,
884
+ ));
885
+ continue;
886
+ }
887
+ if (ref.provider === 'github') {
888
+ if (!gateOn) {
889
+ findings.push(finding(
890
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
891
+ locatorDetail(ref, `github ${ref.repo}#${ref.issue} not resolved (network off; pass --external / COMPOSE_XREF_ONLINE=1)`),
892
+ ref.source,
893
+ ));
894
+ continue;
895
+ }
896
+ if (githubShortCircuited) {
897
+ // An aggregate XREF_RESOLUTION_SKIPPED (no-token or rate-limit) was
898
+ // already emitted for the whole github batch — skip the rest
899
+ // silently rather than double-counting with a per-ref warning
900
+ // (and a wrong reason string for the no-token case).
901
+ continue;
902
+ }
903
+ let gh;
904
+ try {
905
+ gh = new GitHubApi(
906
+ { repo: ref.repo, auth: options.githubAuth || { tokenEnv: 'GITHUB_TOKEN' } },
907
+ options.githubTransport || null,
908
+ );
909
+ } catch (e) {
910
+ if (e && e.name === 'TrackerConfigError' && e.detail && e.detail.missing === 'token') {
911
+ if (!noTokenAggregated) {
912
+ noTokenAggregated = true;
913
+ findings.push(finding(
914
+ 'warning', 'XREF_RESOLUTION_SKIPPED', undefined,
915
+ 'github external refs skipped: no GitHub token (set tracker auth or `gh auth login`)',
916
+ 'xref',
917
+ ));
918
+ }
919
+ githubShortCircuited = true;
920
+ continue;
921
+ }
922
+ findings.push(finding(
923
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
924
+ locatorDetail(ref, `github client init failed: ${e && e.message ? e.message : e}`),
925
+ ref.source,
926
+ ));
927
+ continue;
928
+ }
929
+ try {
930
+ await resolveGithubRef(ref, gh, findings);
931
+ } catch (e) {
932
+ if (e && e._rateLimit) {
933
+ githubShortCircuited = true;
934
+ findings.push(finding(
935
+ 'warning', 'XREF_RESOLUTION_SKIPPED', undefined,
936
+ 'github external refs skipped: GitHub rate-limited (remaining github refs not resolved this run)',
937
+ 'xref',
938
+ ));
939
+ continue;
940
+ }
941
+ findings.push(finding(
942
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
943
+ locatorDetail(ref, `github resolution error: ${e && e.message ? e.message : e}`),
944
+ ref.source,
945
+ ));
946
+ }
947
+ continue;
948
+ }
949
+ // unknown provider that slipped past the grammar — treat as url-class info
950
+ findings.push(finding(
951
+ 'info', 'XREF_URL_UNCHECKED', code,
952
+ locatorDetail(ref, `provider "${ref.provider}" not resolvable; recorded only`),
953
+ ref.source,
954
+ ));
955
+ } catch (e) {
956
+ // absolute backstop: a single bad ref never poisons the run
957
+ findings.push(finding(
958
+ 'warning', 'XREF_RESOLUTION_SKIPPED', code,
959
+ locatorDetail(ref, `unexpected error resolving ref: ${e && e.message ? e.message : e}`),
960
+ ref.source,
961
+ ));
962
+ }
963
+ }
964
+ }
965
+
608
966
  export async function validateProject(cwd, options = {}) {
609
967
  const ctx = loadValidationContext(cwd, options);
610
968
  const findings = [];
@@ -624,6 +982,18 @@ export async function validateProject(cwd, options = {}) {
624
982
  runOrphanFolderCheck(ctx, findings);
625
983
  runChangelogReferenceCheck(ctx, findings);
626
984
  runJournalIndexDriftCheck(ctx, findings);
985
+ try {
986
+ await runExternalRefChecks(ctx, findings, options);
987
+ } catch (e) {
988
+ // Read-only staleness checks must never abort the validator run
989
+ // (spec §6: degrade, never hard-fail). Any unexpected pre-loop failure
990
+ // degrades to a single warning.
991
+ findings.push(finding(
992
+ 'warning', 'XREF_RESOLUTION_SKIPPED', undefined,
993
+ `external-reference checks skipped (unexpected error): ${e && e.message ? e.message : e}`,
994
+ 'xref',
995
+ ));
996
+ }
627
997
 
628
998
  return { scope: 'project', validated_at: nowIso(), findings };
629
999
  }
@@ -463,6 +463,14 @@ export async function linkArtifact(cwd, args) {
463
463
  */
464
464
  export async function linkFeatures(cwd, args) {
465
465
  validateCode(args.from_code);
466
+
467
+ // COMP-MCP-XREF-SCHEMA #15: external cross-project references. These do NOT
468
+ // resolve through same-project `to_code` semantics, so the validateCode /
469
+ // self-link / LINK_KINDS guards below are skipped for kind:"external".
470
+ if (args.kind === 'external') {
471
+ return linkFeatureExternal(cwd, args);
472
+ }
473
+
466
474
  validateCode(args.to_code);
467
475
  if (args.from_code === args.to_code) {
468
476
  throw new Error(`feature-writer: cannot link a feature to itself ("${args.from_code}")`);
@@ -513,6 +521,144 @@ export async function linkFeatures(cwd, args) {
513
521
  });
514
522
  }
515
523
 
524
+ const XREF_PROVIDERS = new Set(['github', 'local', 'url', 'jira', 'linear', 'notion', 'obsidian']);
525
+ const XREF_URL_CLASS = new Set(['url', 'jira', 'linear', 'notion', 'obsidian']);
526
+ const URI_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//;
527
+ // Carrier equivalence: the feature.json-link carrier must reject exactly what
528
+ // the inline citation grammar (lib/xref-citation.js) rejects at parse time, so
529
+ // a stored link can never carry a value #16's resolver would mishandle.
530
+ const XREF_GITHUB_EXPECT = new Set(['open', 'closed']);
531
+ const XREF_LOCAL_EXPECT = new Set([
532
+ 'PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE',
533
+ 'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED',
534
+ ]);
535
+ // No `#` in either half — the citation grammar uses `#` to delimit the issue
536
+ // (gh_target = owner/name#issue), so a repo token containing `#` is not
537
+ // representable in the inline carrier. Keep both carriers equivalent.
538
+ const XREF_GH_REPO_RE = /^[^\s/#]+\/[^\s/#]+$/;
539
+ const XREF_LOCAL_REPO_RE = /^[A-Za-z0-9._-]+$/;
540
+
541
+ /**
542
+ * Validate the external link variant in-code (the schema enforces the same
543
+ * shape on disk; this gives a clear error at the call site). Mirrors
544
+ * contracts/feature-json.schema.json links external branch.
545
+ */
546
+ function validateExternalArgs(args) {
547
+ const p = args.provider;
548
+ if (!p || !XREF_PROVIDERS.has(p)) {
549
+ throw new Error(
550
+ `feature-writer: external link requires provider ∈ {${[...XREF_PROVIDERS].join(', ')}}, got "${p}"`,
551
+ );
552
+ }
553
+ if (p === 'github') {
554
+ if (!args.repo || !Number.isInteger(args.issue) || args.issue < 1) {
555
+ throw new Error('feature-writer: external github link requires repo + integer issue ≥ 1');
556
+ }
557
+ if (!XREF_GH_REPO_RE.test(args.repo)) {
558
+ throw new Error(`feature-writer: external github repo "${args.repo}" must be "owner/name"`);
559
+ }
560
+ if (args.expect != null && !XREF_GITHUB_EXPECT.has(args.expect)) {
561
+ throw new Error(`feature-writer: external github expect must be open|closed, got "${args.expect}"`);
562
+ }
563
+ } else if (p === 'local') {
564
+ if (!args.repo || !args.to_code) {
565
+ throw new Error('feature-writer: external local link requires repo + to_code');
566
+ }
567
+ if (!XREF_LOCAL_REPO_RE.test(args.repo) || args.repo === '.' || args.repo === '..') {
568
+ throw new Error(
569
+ `feature-writer: external local repo "${args.repo}" must be a single sibling directory name `
570
+ + '([A-Za-z0-9._-], no path separators or "."/"..")',
571
+ );
572
+ }
573
+ if (!FEATURE_CODE_RE.test(args.to_code)) {
574
+ throw new Error(
575
+ `feature-writer: external local to_code "${args.to_code}" must match ${FEATURE_CODE_RE}`,
576
+ );
577
+ }
578
+ if (args.expect != null && !XREF_LOCAL_EXPECT.has(args.expect)) {
579
+ throw new Error(
580
+ `feature-writer: external local expect must be one of ${[...XREF_LOCAL_EXPECT].join('|')}, got "${args.expect}"`,
581
+ );
582
+ }
583
+ } else if (XREF_URL_CLASS.has(p)) {
584
+ if (!args.url) {
585
+ throw new Error(`feature-writer: external ${p} link (url-class) requires url`);
586
+ }
587
+ if (!URI_SCHEME_RE.test(args.url)) {
588
+ throw new Error(`feature-writer: url must be a valid URI (got: ${args.url})`);
589
+ }
590
+ // url-class: `expect` is recorded but never resolved (parity with the
591
+ // citation grammar, which also accepts but ignores it) — no validation.
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Store a kind:"external" cross-project reference on the source feature.
597
+ * Idempotency key is (kind=external, provider, repo, issue|to_code|url) so a
598
+ * re-link of the same external pointer is a noop. Read-only with respect to
599
+ * the cited repo/issue — this only writes the citing feature.json.
600
+ */
601
+ async function linkFeatureExternal(cwd, args) {
602
+ validateExternalArgs(args);
603
+
604
+ return maybeIdempotent({ ...args, cwd }, async () => {
605
+ const provider = await getProvider(cwd);
606
+ const feature = await provider.getFeature(args.from_code);
607
+ if (!feature) {
608
+ throw new Error(`feature-writer: feature "${args.from_code}" not found`);
609
+ }
610
+
611
+ const links = Array.isArray(feature.links) ? [...feature.links] : [];
612
+ const targetKey = args.provider === 'github'
613
+ ? String(args.issue)
614
+ : args.provider === 'local'
615
+ ? args.to_code
616
+ : args.url;
617
+ const matchIdx = links.findIndex(
618
+ l => l.kind === 'external'
619
+ && l.provider === args.provider
620
+ && (l.repo ?? null) === (args.repo ?? null)
621
+ && (
622
+ l.provider === 'github' ? String(l.issue) === targetKey
623
+ : l.provider === 'local' ? l.to_code === targetKey
624
+ : l.url === targetKey
625
+ ),
626
+ );
627
+
628
+ if (matchIdx !== -1 && !args.force) {
629
+ return {
630
+ from_code: args.from_code, kind: 'external',
631
+ provider: args.provider, noop: true,
632
+ };
633
+ }
634
+
635
+ const entry = { kind: 'external', provider: args.provider };
636
+ if (args.repo != null) entry.repo = args.repo;
637
+ if (args.issue != null) entry.issue = args.issue;
638
+ if (args.to_code != null) entry.to_code = args.to_code;
639
+ if (args.url != null) entry.url = args.url;
640
+ if (args.expect != null) entry.expect = args.expect;
641
+ if (args.note) entry.note = args.note;
642
+
643
+ if (matchIdx !== -1) links[matchIdx] = entry;
644
+ else links.push(entry);
645
+
646
+ await provider.putFeature(args.from_code, { ...feature, links });
647
+
648
+ await safeAppendEvent(cwd, {
649
+ tool: 'link_features',
650
+ code: args.from_code,
651
+ kind: 'external',
652
+ provider: args.provider,
653
+ note: args.note,
654
+ forced: matchIdx !== -1 ? true : undefined,
655
+ idempotency_key: args.idempotency_key,
656
+ });
657
+
658
+ return { from_code: args.from_code, kind: 'external', provider: args.provider };
659
+ });
660
+ }
661
+
516
662
  /**
517
663
  * Read both canonical and linked artifacts for a feature in one call.
518
664
  *
@@ -594,7 +740,18 @@ export async function getFeatureLinks(cwd, args) {
594
740
  }
595
741
  out.outgoing = (feature.links ?? [])
596
742
  .filter(l => !kind || l.kind === kind)
597
- .map(l => ({ kind: l.kind, to_code: l.to_code, note: l.note }));
743
+ .map((l) => (l.kind === 'external'
744
+ ? {
745
+ kind: l.kind,
746
+ provider: l.provider,
747
+ repo: l.repo,
748
+ issue: l.issue,
749
+ url: l.url,
750
+ to_code: l.to_code,
751
+ expect: l.expect,
752
+ note: l.note,
753
+ }
754
+ : { kind: l.kind, to_code: l.to_code, note: l.note }));
598
755
  }
599
756
 
600
757
  if (direction === 'incoming' || direction === 'both') {
@@ -12,7 +12,7 @@ function resolveToken(auth = {}, noGhFallback = false) {
12
12
  export class GitHubApi {
13
13
  constructor(cfg, transport = null) {
14
14
  this.repo = cfg.repo;
15
- if (!this.repo || !/^[^/]+\/[^/]+$/.test(this.repo)) {
15
+ if (!this.repo || !/^[^\s/#]+\/[^\s/#]+$/.test(this.repo)) {
16
16
  throw new TrackerConfigError(`tracker.github.repo must be "owner/name" (got "${this.repo}")`);
17
17
  }
18
18
  this.token = resolveToken(cfg.auth, cfg.auth?._noGhFallback || cfg._noGhFallback);
@@ -42,6 +42,13 @@ export class GitHubApi {
42
42
  return r.body;
43
43
  }
44
44
  async getIssue(number) { return (await this._req('GET', `/repos/${this.repo}/issues/${number}`)).body; }
45
+ /**
46
+ * GET /repos/:repo/issues/:number — status-returning sibling of getIssue().
47
+ * Returns { status, body, headers }; does NOT throw on 4xx (mirrors
48
+ * getRepo()). Used by COMP-MCP-XREF-VALIDATE (#16) to distinguish 404
49
+ * (target missing) from ≥500 (degrade). Read-only. getIssue() untouched.
50
+ */
51
+ async getIssueResult(number) { return this._req('GET', `/repos/${this.repo}/issues/${number}`); }
45
52
  async updateIssue(number, patch) { return (await this._req('PATCH', `/repos/${this.repo}/issues/${number}`, patch)).body; }
46
53
  async searchFeatureIssues() {
47
54
  return (await this._req('GET', `/search/issues?q=repo:${this.repo}+label:compose-feature`)).body.items ?? [];
@@ -0,0 +1,235 @@
1
+ /**
2
+ * xref-citation.js — pure parser for inline cross-project external-reference
3
+ * citations embedded in a ROADMAP/description cell (COMP-MCP-XREF-SCHEMA, #15).
4
+ *
5
+ * A citation is an HTML comment so it renders invisibly in markdown:
6
+ *
7
+ * <!-- xref: github owner/repo#123 expect=open -->
8
+ * <!-- xref: github smartmemory/compose#7 expect=closed note="shipped X" -->
9
+ * <!-- xref: local compose COMP-MCP-VALIDATE expect=COMPLETE -->
10
+ * <!-- xref: url https://example.com/spec note="design ref" -->
11
+ *
12
+ * Grammar (spec §3.1 EBNF):
13
+ * citation = "<!--" ws "xref:" ws provider ws target
14
+ * [ ws "expect=" expect ] [ ws "note=" qstring ] ws "-->"
15
+ * provider = "github" | "local" | "url" ; resolvable
16
+ * | "jira" | "linear" | "notion" | "obsidian" ; reserved url-class
17
+ * gh_target = repo "#" issue ; repo = owner "/" name
18
+ * local_target = repo_token ws feature_code
19
+ * url_target = URL ; url + every reserved provider
20
+ *
21
+ * This module performs ZERO I/O and ZERO network. It is a pure
22
+ * string → object function. Consumed by #16 (`runExternalRefChecks`); #15
23
+ * ships it standalone with no caller in the validator path.
24
+ */
25
+
26
+ // url-class = `url` + every reserved provider (carry a url_target, never
27
+ // resolved in v1). ALL_PROVIDERS is the full accepted set.
28
+ const RESOLVABLE_PROVIDERS = ['github', 'local', 'url'];
29
+ const RESERVED_PROVIDERS = ['jira', 'linear', 'notion', 'obsidian'];
30
+ const ALL_PROVIDERS = new Set([...RESOLVABLE_PROVIDERS, ...RESERVED_PROVIDERS]);
31
+
32
+ const GITHUB_EXPECT = new Set(['open', 'closed']);
33
+ const LOCAL_EXPECT = new Set([
34
+ 'PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE',
35
+ 'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED',
36
+ ]);
37
+ const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
38
+ const URI_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//;
39
+
40
+ // Anchored scan: only HTML comments whose body begins with `xref:` are
41
+ // considered. Any other `<!-- ... -->` is ignored entirely.
42
+ const CITATION_RE = /<!--\s*xref:\s*([\s\S]*?)\s*-->/g;
43
+
44
+ /**
45
+ * Structured parse error for a comment that matched `<!--\s*xref:` but
46
+ * failed the grammar. Consumed by #16 as the `XREF_MALFORMED` finding;
47
+ * #15 only surfaces it via the return value (never throws, never logs).
48
+ */
49
+ export class ParseError {
50
+ constructor(raw, reason) {
51
+ this.raw = raw;
52
+ this.reason = reason;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @typedef {object} PartialExternalRef
58
+ * @property {string} provider
59
+ * @property {string|null} repo github "owner/name" | local repo token | null
60
+ * @property {number|null} issue github only
61
+ * @property {string|null} toCode local only (target feature code)
62
+ * @property {string|null} url url-class only (url + reserved providers)
63
+ * @property {string|null} expect optional expected-state token
64
+ * @property {string|null} note
65
+ * @property {string} raw the citation body (for locatability)
66
+ */
67
+
68
+ /**
69
+ * Parse every `xref:` citation in a description cell.
70
+ * @param {string} descriptionCell
71
+ * @returns {{ refs: PartialExternalRef[], errors: ParseError[] }}
72
+ */
73
+ export function parseCitations(descriptionCell) {
74
+ const refs = [];
75
+ const errors = [];
76
+ if (typeof descriptionCell !== 'string' || descriptionCell.length === 0) {
77
+ return { refs, errors };
78
+ }
79
+
80
+ CITATION_RE.lastIndex = 0;
81
+ let m;
82
+ while ((m = CITATION_RE.exec(descriptionCell)) !== null) {
83
+ const raw = m[1];
84
+ try {
85
+ refs.push(parseOne(raw));
86
+ } catch (e) {
87
+ if (e instanceof ParseError) errors.push(e);
88
+ else errors.push(new ParseError(raw, String(e && e.message ? e.message : e)));
89
+ }
90
+ }
91
+ return { refs, errors };
92
+ }
93
+
94
+ function parseOne(raw) {
95
+ let rest = raw.trim();
96
+ if (rest.length === 0) throw new ParseError(raw, 'empty xref citation');
97
+
98
+ // Optional trailing options `expect=<tok>` and `note="..."`, order-
99
+ // independent. They are stripped **end-anchored** (must be the trailing
100
+ // whitespace-separated token), so a `note=`/`expect=` substring inside the
101
+ // target itself — e.g. a URL query `https://x/?note=a&expect=b` — is left
102
+ // in the target and never mis-consumed. Each option may appear at most once.
103
+ //
104
+ // Known v1 limitations (faithful to spec §3.1 EBNF, which defines
105
+ // `qstring = DQUOTE *CHAR DQUOTE` with no escape and an HTML-comment
106
+ // carrier): a `note="..."` value cannot contain `"` or the literal `-->`.
107
+ let note = null;
108
+ let expect = null;
109
+ for (let i = 0; i < 2; i++) {
110
+ let m;
111
+ if (note === null && (m = rest.match(/\s+note="([^"]*)"\s*$/))) {
112
+ note = m[1];
113
+ rest = rest.slice(0, m.index).trimEnd();
114
+ continue;
115
+ }
116
+ if (expect === null && (m = rest.match(/\s+expect=(\S+)\s*$/))) {
117
+ expect = m[1];
118
+ rest = rest.slice(0, m.index).trimEnd();
119
+ continue;
120
+ }
121
+ break;
122
+ }
123
+ // A `note=` option token that exists but was not consumable as a trailing
124
+ // quoted string is a hard parse error (don't silently fold it into target).
125
+ if (note === null && /(^|\s)note=/.test(rest)) {
126
+ if (/(^|\s)note="/.test(rest)) {
127
+ throw new ParseError(raw, 'unterminated or misplaced note="..." (must be a trailing double-quoted token)');
128
+ }
129
+ throw new ParseError(raw, 'note= value must be a double-quoted string');
130
+ }
131
+ // Likewise a stray trailing `expect=` token that wasn't consumed.
132
+ if (expect === null && /(^|\s)expect=\S*\s*$/.test(rest)) {
133
+ throw new ParseError(raw, 'malformed expect= option');
134
+ }
135
+ rest = rest.replace(/\s+/g, ' ').trim();
136
+
137
+ // Remaining: `<provider> <target...>`.
138
+ const firstWs = rest.search(/\s/);
139
+ if (firstWs === -1) {
140
+ throw new ParseError(raw, `missing target after provider "${rest}"`);
141
+ }
142
+ const provider = rest.slice(0, firstWs);
143
+ const target = rest.slice(firstWs + 1).trim();
144
+
145
+ if (!ALL_PROVIDERS.has(provider)) {
146
+ throw new ParseError(
147
+ raw,
148
+ `unknown provider "${provider}" (expected one of ${[...ALL_PROVIDERS].join(', ')})`,
149
+ );
150
+ }
151
+ if (target.length === 0) {
152
+ throw new ParseError(raw, `missing target for provider "${provider}"`);
153
+ }
154
+
155
+ const ref = {
156
+ provider,
157
+ repo: null,
158
+ issue: null,
159
+ toCode: null,
160
+ url: null,
161
+ expect: null,
162
+ note,
163
+ raw,
164
+ };
165
+
166
+ if (provider === 'github') {
167
+ // No `#` in either repo half — `#` delimits the issue (owner/name#issue)
168
+ // and GitHub owners/names cannot contain it. Keeps the citation carrier
169
+ // carrier-equivalent with the feature.json-link writer (XREF_GH_REPO_RE).
170
+ const gh = target.match(/^([^\s/#]+\/[^\s/#]+)#(\d+)$/);
171
+ if (!gh) {
172
+ throw new ParseError(raw, `github target must be "owner/name#issue", got "${target}"`);
173
+ }
174
+ ref.repo = gh[1];
175
+ ref.issue = Number(gh[2]);
176
+ if (expect !== null) {
177
+ if (!GITHUB_EXPECT.has(expect)) {
178
+ throw new ParseError(
179
+ raw, `github expect must be open|closed, got "${expect}"`,
180
+ );
181
+ }
182
+ ref.expect = expect;
183
+ }
184
+ } else if (provider === 'local') {
185
+ const parts = target.split(/\s+/);
186
+ if (parts.length !== 2) {
187
+ throw new ParseError(
188
+ raw, `local target must be "<repo> <FEATURE_CODE>", got "${target}"`,
189
+ );
190
+ }
191
+ const [repoTok, code] = parts;
192
+ // repo token must be a single safe directory name — it is resolved as a
193
+ // sibling dir (path.join(cwd, '..', repoTok)); reject anything with a
194
+ // path separator or traversal so a citation cannot escape the workspace.
195
+ if (!/^[A-Za-z0-9._-]+$/.test(repoTok) || repoTok === '.' || repoTok === '..') {
196
+ throw new ParseError(
197
+ raw,
198
+ `local repo token "${repoTok}" must be a single directory name `
199
+ + '([A-Za-z0-9._-], no path separators or "."/"..")',
200
+ );
201
+ }
202
+ if (!FEATURE_CODE_RE.test(code)) {
203
+ throw new ParseError(raw, `local target feature code "${code}" is not a valid code`);
204
+ }
205
+ ref.repo = repoTok;
206
+ ref.toCode = code;
207
+ if (expect !== null) {
208
+ if (!LOCAL_EXPECT.has(expect)) {
209
+ throw new ParseError(
210
+ raw,
211
+ `local expect must be one of ${[...LOCAL_EXPECT].join('|')}, got "${expect}"`,
212
+ );
213
+ }
214
+ ref.expect = expect;
215
+ }
216
+ } else {
217
+ // url-class: provider `url` and every reserved provider. The target is a
218
+ // single URL token; `expect=` is syntactically accepted but ignored
219
+ // (these refs are never resolved in v1 — spec §5.2/§9), never a ParseError.
220
+ if (/\s/.test(target)) {
221
+ throw new ParseError(
222
+ raw, `${provider} target must be a single URL, got "${target}"`,
223
+ );
224
+ }
225
+ if (!URI_SCHEME_RE.test(target)) {
226
+ throw new ParseError(
227
+ raw, `${provider} target must be a scheme:// URL, got "${target}"`,
228
+ );
229
+ }
230
+ ref.url = target;
231
+ if (expect !== null) ref.expect = expect; // recorded, not validated, not resolved
232
+ }
233
+
234
+ return ref;
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.1.38-beta",
3
+ "version": "0.1.39-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -291,10 +291,11 @@ export async function toolValidateFeature(args = {}) {
291
291
 
292
292
  export async function toolValidateProject(args = {}) {
293
293
  const { validateProject } = await import('../lib/feature-validator.js');
294
- const { external_prefixes, feature_json_mode } = args;
294
+ const { external_prefixes, feature_json_mode, external } = args;
295
295
  return validateProject(getTargetRoot(), {
296
296
  externalPrefixes: external_prefixes,
297
297
  featureJsonMode: feature_json_mode,
298
+ external: external === true,
298
299
  });
299
300
  }
300
301
 
@@ -363,12 +363,13 @@ const TOOLS = [
363
363
  },
364
364
  {
365
365
  name: 'validate_project',
366
- description: 'Run validate_feature for every code in vision-state, ROADMAP, and folders, plus cross-cutting checks (orphan folders, dangling cross-refs, CHANGELOG references, journal index drift). Returns the union of all findings.',
366
+ description: 'Run validate_feature for every code in vision-state, ROADMAP, and folders, plus cross-cutting checks (orphan folders, dangling cross-refs, CHANGELOG references, journal index drift) and read-only external-reference staleness (kind:"external" links + xref: roadmap citations). external:true enables network resolution of github refs (off by default — github refs then emit XREF_RESOLUTION_SKIPPED). Returns the union of all findings.',
367
367
  inputSchema: {
368
368
  type: 'object',
369
369
  properties: {
370
370
  external_prefixes: { type: 'array', items: { type: 'string' } },
371
371
  feature_json_mode: { type: 'boolean' },
372
+ external: { type: 'boolean', description: 'Resolve github external refs over the network (read-only). Default false: github refs degrade to XREF_RESOLUTION_SKIPPED.' },
372
373
  },
373
374
  },
374
375
  },
@@ -411,14 +412,19 @@ const TOOLS = [
411
412
  },
412
413
  {
413
414
  name: 'link_features',
414
- description: 'Register a typed cross-feature relationship. Stores on the source feature; query the inverse via get_feature_links(direction:"incoming"). Closed enum on kind; self-links rejected; dedups on (kind, to_code).',
415
+ description: 'Register a typed cross-feature relationship. Two shapes: (1) SAME-PROJECT — kind ∈ surfaced_by|blocks|depends_on|follow_up|supersedes|related, requires to_code; self-links rejected; dedups on (kind,to_code). (2) EXTERNAL (kind:"external") a cross-project pointer, NOT a same-project link: requires provider; three resolvable sub-shapes — github (repo "owner/name" + integer issue), local (repo token + to_code), url (url); plus reserved url-class providers jira|linear|notion|obsidian (parse-valid, require url, NOT resolved in v1). External dedups on (kind=external, provider, repo, issue|to_code|url). Stores on the source feature; query inverse via get_feature_links(direction:"incoming").',
415
416
  inputSchema: {
416
417
  type: 'object',
417
- required: ['from_code', 'to_code', 'kind'],
418
+ required: ['from_code', 'kind'],
418
419
  properties: {
419
420
  from_code: { type: 'string' },
420
- to_code: { type: 'string', description: 'Target feature code. Need not exist yet (you can link to a code you are about to create).' },
421
- kind: { type: 'string', enum: ['surfaced_by', 'blocks', 'depends_on', 'follow_up', 'supersedes', 'related'] },
421
+ to_code: { type: 'string', description: 'Same-project: target feature code (required unless kind:"external"). External local: the cited feature code. Need not exist yet.' },
422
+ kind: { type: 'string', enum: ['surfaced_by', 'blocks', 'depends_on', 'follow_up', 'supersedes', 'related', 'external'] },
423
+ provider: { type: 'string', enum: ['github', 'local', 'url', 'jira', 'linear', 'notion', 'obsidian'], description: 'Required when kind:"external". Resolvable: github|local|url. Reserved url-class (require url, not resolved in v1): jira|linear|notion|obsidian.' },
424
+ repo: { type: 'string', description: 'External github: "owner/name". External local: workspace-relative repo token.' },
425
+ issue: { type: 'integer', minimum: 1, description: 'External github: issue/PR number.' },
426
+ url: { type: 'string', description: 'External url-class (url|jira|linear|notion|obsidian): the pointer URL.' },
427
+ expect: { type: 'string', description: 'Optional expected state. github: open|closed. local: a status token. url-class: recorded, never resolved.' },
422
428
  note: { type: 'string' },
423
429
  force: { type: 'boolean' },
424
430
  idempotency_key: { type: 'string' },