@opengsd/gsd-core 1.5.0-rc.2 → 1.5.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-mempalace-curator.md +47 -0
  3. package/bin/install.js +25 -7
  4. package/commands/gsd/mempalace-capture.md +71 -0
  5. package/commands/gsd/mempalace-recall.md +102 -0
  6. package/commands/gsd/ns-context.md +4 -2
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +110 -92
  9. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  10. package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
  11. package/gsd-core/bin/lib/capability-state.cjs +31 -18
  12. package/gsd-core/bin/lib/check-command-router.cjs +328 -1
  13. package/gsd-core/bin/lib/clusters.cjs +2 -0
  14. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  15. package/gsd-core/bin/lib/commands.cjs +33 -10
  16. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  17. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  18. package/gsd-core/bin/lib/config.cjs +1 -2
  19. package/gsd-core/bin/lib/core.cjs +5 -2
  20. package/gsd-core/bin/lib/init.cjs +8 -5
  21. package/gsd-core/bin/lib/install-profiles.cjs +2 -1
  22. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  23. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  24. package/gsd-core/bin/lib/phase.cjs +71 -11
  25. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  26. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  27. package/gsd-core/bin/lib/roadmap.cjs +97 -7
  28. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1700 -0
  29. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +20 -28
  30. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
  31. package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
  32. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  33. package/gsd-core/bin/lib/state-document.cjs +46 -1
  34. package/gsd-core/bin/lib/state.cjs +221 -35
  35. package/gsd-core/bin/lib/verify.cjs +19 -4
  36. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  37. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  38. package/gsd-core/bin/shared/model-catalog.json +1 -0
  39. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  40. package/gsd-core/templates/config.json +1 -1
  41. package/gsd-core/workflows/audit-milestone.md +5 -3
  42. package/gsd-core/workflows/autonomous.md +10 -5
  43. package/gsd-core/workflows/code-review-fix.md +9 -7
  44. package/gsd-core/workflows/code-review.md +8 -6
  45. package/gsd-core/workflows/discuss-phase.md +14 -0
  46. package/gsd-core/workflows/execute-phase.md +82 -143
  47. package/gsd-core/workflows/help/modes/full.md +3 -1
  48. package/gsd-core/workflows/new-project.md +3 -3
  49. package/gsd-core/workflows/plan-phase.md +89 -221
  50. package/gsd-core/workflows/profile-user.md +3 -1
  51. package/gsd-core/workflows/progress.md +58 -1
  52. package/gsd-core/workflows/quick.md +9 -6
  53. package/gsd-core/workflows/review.md +19 -2
  54. package/gsd-core/workflows/secure-phase.md +4 -2
  55. package/gsd-core/workflows/settings-advanced.md +2 -0
  56. package/gsd-core/workflows/ship.md +57 -0
  57. package/gsd-core/workflows/validate-phase.md +4 -2
  58. package/gsd-core/workflows/verify-work.md +27 -11
  59. package/package.json +3 -2
  60. package/scripts/affected-tests-lib.cjs +16 -4
  61. package/scripts/gen-capability-registry.cjs +359 -48
  62. package/scripts/gen-inventory-manifest.cjs +1 -4
  63. package/scripts/gen-loop-host-contract.cjs +55 -0
  64. package/scripts/issue-version-gate.cjs +140 -0
  65. package/scripts/lint-allow-test-rule-refs.allowlist.json +326 -0
  66. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  67. package/scripts/lint-test-file-count.allowlist.json +8 -0
  68. package/scripts/release-tarball-smoke.cjs +7 -1
  69. package/scripts/run-tests.cjs +74 -14
@@ -34,6 +34,9 @@ const SCHEMA_VERSION = '1';
34
34
  // registry generator and the loop-host-contract generator share one source of truth.
35
35
  const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
36
36
 
37
+ // Wired-points helper — tells us which points actually have render-hooks call sites.
38
+ const { getWiredLoopPoints } = require('./gen-loop-host-contract.cjs');
39
+
37
40
  // Canonical point order — explicit constant (do NOT rely on Set insertion order).
38
41
  // Used for point-ordering semantics in consumes-satisfiability validation and topo-sort.
39
42
  const POINT_ORDER = [
@@ -93,16 +96,42 @@ for (const entry of LOOP_HOST_CONTRACT) {
93
96
  * Loads the set of keys from the central config-schema manifest.
94
97
  * Returns a Set<string>. Used for collision detection.
95
98
  *
96
- * TODO: distinguish file-not-found (ok, return empty Set) from JSON-parse-error
97
- * (should warn — a parse error means the schema is broken, not just absent).
99
+ * Contract:
100
+ * - ENOENT (file not found): returns empty Set silently — legitimate absent case.
101
+ * - Any other read error OR JSON parse error: writes a prominent warning to stderr
102
+ * naming the schema path and the underlying error, then throws ExitError(1).
103
+ * A parse error clearly states the schema is broken (not merely absent).
104
+ *
105
+ * @param {string} [schemaPath] Path to the config-schema manifest. Defaults to
106
+ * CONFIG_SCHEMA_PATH (the real production path).
107
+ * Overridable for unit testing with fixture paths.
108
+ * @returns {Set<string>}
98
109
  */
99
- function loadCentralConfigKeys() {
110
+ function loadCentralConfigKeys(schemaPath = CONFIG_SCHEMA_PATH) {
111
+ let raw;
100
112
  try {
101
- const manifest = JSON.parse(fs.readFileSync(CONFIG_SCHEMA_PATH, 'utf8'));
102
- return new Set(Array.isArray(manifest.validKeys) ? manifest.validKeys : []);
103
- } catch (_) {
104
- return new Set();
113
+ raw = fs.readFileSync(schemaPath, 'utf8');
114
+ } catch (err) {
115
+ if (err.code === 'ENOENT') {
116
+ return new Set();
117
+ }
118
+ process.stderr.write(
119
+ ' ERROR Failed to read config-schema manifest at ' + schemaPath + ': ' + err.message + '\n',
120
+ );
121
+ throw new ExitError(1, 'could not read config-schema manifest');
105
122
  }
123
+
124
+ let manifest;
125
+ try {
126
+ manifest = JSON.parse(raw);
127
+ } catch (err) {
128
+ process.stderr.write(
129
+ ' ERROR Config-schema manifest at ' + schemaPath + ' is broken (JSON parse error): ' + err.message + '\n',
130
+ );
131
+ throw new ExitError(1, 'config-schema manifest JSON is malformed');
132
+ }
133
+
134
+ return new Set(Array.isArray(manifest.validKeys) ? manifest.validKeys : []);
106
135
  }
107
136
 
108
137
  // ─── Config-slice validation ──────────────────────────────────────────────────
@@ -205,6 +234,7 @@ const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
205
234
  const VALID_ROLES = new Set(['feature', 'runtime']);
206
235
  const VALID_TIERS = new Set(['core', 'standard', 'full']);
207
236
  const VALID_ON_ERROR = new Set(['skip', 'halt']);
237
+ const RUNTIME_COMPAT_WILDCARD = '*';
208
238
 
209
239
  /**
210
240
  * Validate a single capability declaration.
@@ -337,9 +367,74 @@ function validateCommandEntry(capId, entry, prefix) {
337
367
  return errors;
338
368
  }
339
369
 
370
+ function validateRuntimeCompat(capId, runtimeCompat) {
371
+ const errors = [];
372
+ const ctx = 'capability "' + capId + '" runtimeCompat';
373
+
374
+ if (typeof runtimeCompat !== 'object' || runtimeCompat === null || Array.isArray(runtimeCompat)) {
375
+ errors.push(ctx + ' must be an object with supported and unsupported arrays');
376
+ return errors;
377
+ }
378
+
379
+ const validateRuntimeArray = (field, { allowWildcard }) => {
380
+ const value = runtimeCompat[field];
381
+ if (!Array.isArray(value)) {
382
+ errors.push(ctx + '.' + field + ' must be an array of runtime ids' + (allowWildcard ? ' or ["*"]' : ''));
383
+ return;
384
+ }
385
+ if (field === 'supported' && value.length === 0) {
386
+ errors.push(ctx + '.supported must be a non-empty array');
387
+ }
388
+ let hasWildcard = false;
389
+ for (let i = 0; i < value.length; i++) {
390
+ const entry = value[i];
391
+ if (typeof entry !== 'string' || entry.length === 0) {
392
+ errors.push(ctx + '.' + field + '[' + i + '] must be a non-empty string');
393
+ continue;
394
+ }
395
+ if (entry === '__proto__' || entry === 'constructor' || entry === 'prototype') {
396
+ errors.push(ctx + '.' + field + '[' + i + '] "' + entry + '" is a reserved name');
397
+ }
398
+ if (entry === RUNTIME_COMPAT_WILDCARD) {
399
+ if (!allowWildcard) {
400
+ errors.push(ctx + '.' + field + ' must not include wildcard "*"');
401
+ }
402
+ hasWildcard = true;
403
+ } else if (!KEBAB_RE.test(entry)) {
404
+ errors.push(ctx + '.' + field + '[' + i + '] must be a kebab-case runtime id or "*"');
405
+ }
406
+ }
407
+ if (hasWildcard && value.length > 1) {
408
+ errors.push(ctx + '.' + field + ' wildcard "*" cannot be mixed with runtime ids');
409
+ }
410
+ };
411
+
412
+ validateRuntimeArray('supported', { allowWildcard: true });
413
+ validateRuntimeArray('unsupported', { allowWildcard: false });
414
+
415
+ if (runtimeCompat.notes !== undefined) {
416
+ if (typeof runtimeCompat.notes !== 'object' || runtimeCompat.notes === null || Array.isArray(runtimeCompat.notes)) {
417
+ errors.push(ctx + '.notes must be an object of runtime id to string if present');
418
+ } else {
419
+ for (const [key, value] of Object.entries(runtimeCompat.notes)) {
420
+ if (key !== RUNTIME_COMPAT_WILDCARD && !KEBAB_RE.test(key)) {
421
+ errors.push(ctx + '.notes key "' + key + '" must be a kebab-case runtime id or "*"');
422
+ }
423
+ if (typeof value !== 'string' || value.length === 0) {
424
+ errors.push(ctx + '.notes["' + key + '"] must be a non-empty string');
425
+ }
426
+ }
427
+ }
428
+ }
429
+
430
+ return errors;
431
+ }
432
+
340
433
  function validateFeatureBody(cap) {
341
434
  const errors = [];
342
435
 
436
+ errors.push(...validateRuntimeCompat(cap.id || '(unknown)', cap.runtimeCompat));
437
+
343
438
  if (!Array.isArray(cap.skills)) {
344
439
  errors.push('skills must be an array of strings');
345
440
  } else {
@@ -847,6 +942,78 @@ function validateRuntimeBody(cap) {
847
942
  return errors;
848
943
  }
849
944
 
945
+ function materializeHookFragments(cap, capDir) {
946
+ const errors = [];
947
+ const hookGroups = [
948
+ ['steps', Array.isArray(cap.steps) ? cap.steps : []],
949
+ ['contributions', Array.isArray(cap.contributions) ? cap.contributions : []],
950
+ ];
951
+
952
+ for (const [groupName, hooks] of hookGroups) {
953
+ for (let i = 0; i < hooks.length; i++) {
954
+ const hook = hooks[i];
955
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) continue;
956
+ const fragment = hook.fragment;
957
+ if (!fragment || typeof fragment !== 'object' || Array.isArray(fragment)) continue;
958
+ if (typeof fragment.inline === 'string') continue;
959
+ if (typeof fragment.path !== 'string') continue;
960
+
961
+ const abs = path.resolve(capDir, fragment.path);
962
+ const capRoot = path.resolve(capDir);
963
+ if (abs !== capRoot && !abs.startsWith(capRoot + path.sep)) {
964
+ errors.push(
965
+ cap.id + '/' + groupName + '[' + i + '].fragment.path escapes capability directory: ' +
966
+ fragment.path,
967
+ );
968
+ continue;
969
+ }
970
+
971
+ try {
972
+ fragment.inline = fs.readFileSync(abs, 'utf8');
973
+ } catch (err) {
974
+ errors.push(
975
+ cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' +
976
+ fragment.path + ' (' + err.message + ')',
977
+ );
978
+ }
979
+ }
980
+ }
981
+
982
+ return errors;
983
+ }
984
+
985
+ function validateFragment(fragment, prefix) {
986
+ const errors = [];
987
+
988
+ if (typeof fragment !== 'object' || fragment === null || Array.isArray(fragment)) {
989
+ errors.push(prefix + ' must be an object with path or inline key');
990
+ return errors;
991
+ }
992
+
993
+ const hasPath = Object.prototype.hasOwnProperty.call(fragment, 'path');
994
+ const hasInline = Object.prototype.hasOwnProperty.call(fragment, 'inline');
995
+ if (!hasPath && !hasInline) {
996
+ errors.push(prefix + ' must have a "path" or "inline" key');
997
+ }
998
+ if (hasInline) {
999
+ const inline = fragment.inline;
1000
+ if (typeof inline !== 'string') {
1001
+ errors.push(prefix + '.inline must be a string');
1002
+ } else if (inline === '') {
1003
+ errors.push(prefix + '.inline must be a non-empty string');
1004
+ }
1005
+ }
1006
+ // S1: fragment.path traversal guard — must be a relative path with no ".." segments
1007
+ if (hasPath) {
1008
+ const p = fragment.path;
1009
+ if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
1010
+ errors.push(prefix + '.path must be a relative path with no ".." segments');
1011
+ }
1012
+ }
1013
+
1014
+ return errors;
1015
+ }
1016
+
850
1017
  /**
851
1018
  * Validate a single step entry.
852
1019
  *
@@ -866,15 +1033,17 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
866
1033
  }
867
1034
 
868
1035
  if (typeof step.ref !== 'object' || step.ref === null) {
869
- errors.push(prefix + '.ref must be an object with skill or agent key');
1036
+ errors.push(prefix + '.ref must be an object with skill, agent, or command key');
870
1037
  } else {
871
1038
  const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill');
872
1039
  const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent');
873
- if (!hasSkill && !hasAgent) {
874
- errors.push(prefix + '.ref must have a "skill" or "agent" key');
875
- } else if (hasSkill && hasAgent) {
876
- // Fix #4: ref must be exclusive {skill} XOR {agent}
877
- errors.push(prefix + '.ref must have exactly one of "skill" or "agent", not both');
1040
+ const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command');
1041
+ const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length;
1042
+ if (dispatchCount === 0) {
1043
+ errors.push(prefix + '.ref must have a "skill", "agent", or "command" key');
1044
+ } else if (dispatchCount > 1) {
1045
+ // ref must be exclusive: skill XOR agent XOR command
1046
+ errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple');
878
1047
  }
879
1048
  if (hasSkill && typeof step.ref.skill !== 'string') {
880
1049
  errors.push(prefix + '.ref.skill must be a string');
@@ -904,6 +1073,9 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
904
1073
  [...declaredAgents].join(', ') + ']',
905
1074
  );
906
1075
  }
1076
+ if (hasCommand && typeof step.ref.command !== 'string') {
1077
+ errors.push(prefix + '.ref.command must be a string');
1078
+ }
907
1079
  }
908
1080
 
909
1081
  if (!Array.isArray(step.produces)) {
@@ -926,6 +1098,10 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
926
1098
  errors.push(prefix + '.when must be a string if present');
927
1099
  }
928
1100
 
1101
+ if (step.fragment !== undefined) {
1102
+ errors.push(...validateFragment(step.fragment, prefix + '.fragment'));
1103
+ }
1104
+
929
1105
  if (!VALID_ON_ERROR.has(step.onError)) {
930
1106
  errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')');
931
1107
  }
@@ -944,23 +1120,24 @@ function validateContribution(contrib, prefix) {
944
1120
  errors.push(prefix + '.into must be a string (agent role name)');
945
1121
  }
946
1122
 
947
- if (typeof contrib.fragment !== 'object' || contrib.fragment === null) {
948
- errors.push(prefix + '.fragment must be an object with path or inline key');
1123
+ if (!Array.isArray(contrib.produces)) {
1124
+ errors.push(prefix + '.produces must be an array');
949
1125
  } else {
950
- const hasPath = Object.prototype.hasOwnProperty.call(contrib.fragment, 'path');
951
- const hasInline = Object.prototype.hasOwnProperty.call(contrib.fragment, 'inline');
952
- if (!hasPath && !hasInline) {
953
- errors.push(prefix + '.fragment must have a "path" or "inline" key');
954
- }
955
- // S1: fragment.path traversal guard — must be a relative path with no ".." segments
956
- if (hasPath) {
957
- const p = contrib.fragment.path;
958
- if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
959
- errors.push(prefix + '.fragment.path must be a relative path with no ".." segments');
960
- }
1126
+ for (const p of contrib.produces) {
1127
+ if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings');
1128
+ }
1129
+ }
1130
+
1131
+ if (!Array.isArray(contrib.consumes)) {
1132
+ errors.push(prefix + '.consumes must be an array');
1133
+ } else {
1134
+ for (const c of contrib.consumes) {
1135
+ if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings');
961
1136
  }
962
1137
  }
963
1138
 
1139
+ errors.push(...validateFragment(contrib.fragment, prefix + '.fragment'));
1140
+
964
1141
  if (contrib.when !== undefined && typeof contrib.when !== 'string') {
965
1142
  errors.push(prefix + '.when must be a string if present');
966
1143
  }
@@ -1146,8 +1323,38 @@ function validateConsumesGlobal(capMap) {
1146
1323
  }
1147
1324
  }
1148
1325
 
1149
- // TODO: duplicate-producer invariant — if two capability steps produce the same artifact
1150
- // at the same point, that's ambiguous. Detect and reject as a follow-up.
1326
+ // Duplicate-producer invariant: two capability steps may not produce the same artifact
1327
+ // at the same Loop Extension Point. Same-point dual production makes data-flow resolution
1328
+ // ambiguous and is rejected at gen time (Decision #6).
1329
+ for (const artifact of Object.keys(capHookProducers)) {
1330
+ if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue;
1331
+ const producers = capHookProducers[artifact];
1332
+ // Group by pointIdx
1333
+ const byPoint = Object.create(null);
1334
+ for (const entry of producers) {
1335
+ if (!byPoint[entry.pointIdx]) byPoint[entry.pointIdx] = [];
1336
+ byPoint[entry.pointIdx].push(entry);
1337
+ }
1338
+ for (const pointIdxStr of Object.keys(byPoint)) {
1339
+ const group = byPoint[pointIdxStr];
1340
+ // Count distinct (capId, stepIdx) producer steps — a single step listing the same
1341
+ // artifact twice in its produces array pushes duplicate entries but represents only
1342
+ // ONE producer step and must not false-positive the cross-step gate.
1343
+ const distinctProducers = new Set(group.map((e) => e.capId + ' ' + e.stepIdx));
1344
+ if (distinctProducers.size >= 2) {
1345
+ const pointIdx = Number(pointIdxStr);
1346
+ const pointName = POINT_ORDER[pointIdx];
1347
+ const capIds = [...new Set(group.map((e) => e.capId))].sort().join(', ');
1348
+ throw new Error(
1349
+ 'duplicate-producer invariant violated: artifact "' + artifact + '" is produced by ' +
1350
+ 'two or more capability steps at the same Loop Extension Point "' + pointName + '" ' +
1351
+ '(capabilities: ' + capIds + '). ' +
1352
+ 'Two capability steps producing the same artifact at the same Loop Extension Point ' +
1353
+ 'makes data-flow resolution ambiguous and is rejected at gen time.',
1354
+ );
1355
+ }
1356
+ }
1357
+ }
1151
1358
 
1152
1359
  // Now check every hook step's consumes.
1153
1360
  // Self-consume rule: a step H cannot satisfy its own consumes[A] from its own produces[A].
@@ -1297,6 +1504,39 @@ function validateCrossCapability(capMap, centralKeys) {
1297
1504
  }
1298
1505
  }
1299
1506
 
1507
+ // runtimeCompat: explicit runtime ids must reference runtime capabilities.
1508
+ // The wildcard "*" means descriptor-backed runtimes are supported by default.
1509
+ const runtimeIds = new Set();
1510
+ for (const [id, cap] of capMap) {
1511
+ if (cap.role === 'runtime') runtimeIds.add(id);
1512
+ }
1513
+ for (const [capId, cap] of capMap) {
1514
+ if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue;
1515
+ for (const field of ['supported', 'unsupported']) {
1516
+ const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : [];
1517
+ for (const runtimeId of entries) {
1518
+ if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
1519
+ if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue;
1520
+ if (!runtimeIds.has(runtimeId)) {
1521
+ errors.push(
1522
+ 'capability "' + capId + '" runtimeCompat.' + field +
1523
+ ' references unknown runtime "' + runtimeId + '"',
1524
+ );
1525
+ }
1526
+ }
1527
+ }
1528
+ if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') {
1529
+ for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) {
1530
+ if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
1531
+ if (!runtimeIds.has(runtimeId)) {
1532
+ errors.push(
1533
+ 'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"',
1534
+ );
1535
+ }
1536
+ }
1537
+ }
1538
+ }
1539
+
1300
1540
  // requires: acyclic
1301
1541
  const cycleErrors = detectRequiresCycles(capMap);
1302
1542
  errors.push(...cycleErrors);
@@ -1384,14 +1624,7 @@ function computeRequiresClosure(id, capMap) {
1384
1624
 
1385
1625
  // ─── Topological ordering ─────────────────────────────────────────────────────
1386
1626
 
1387
- /**
1388
- * Topologically sort steps at a given point by produces/consumes.
1389
- * Capability-id tiebreak for determinism.
1390
- *
1391
- * @param {{ capId: string, step: object }[]} entries
1392
- * @returns {{ capId: string, step: object }[]}
1393
- */
1394
- function topoSortSteps(entries) {
1627
+ function topoSortHookEntries(entries, hookKey, hookKind) {
1395
1628
  if (entries.length <= 1) return entries;
1396
1629
 
1397
1630
  // Build adjacency: entry A must come before entry B if B consumes something A produces
@@ -1400,10 +1633,10 @@ function topoSortSteps(entries) {
1400
1633
  const adj = Array.from({ length: n }, () => []);
1401
1634
 
1402
1635
  for (let i = 0; i < n; i++) {
1403
- const producesI = new Set(entries[i].step.produces || []);
1636
+ const producesI = new Set(entries[i][hookKey].produces || []);
1404
1637
  for (let j = 0; j < n; j++) {
1405
1638
  if (i === j) continue;
1406
- const consumesJ = entries[j].step.consumes || [];
1639
+ const consumesJ = entries[j][hookKey].consumes || [];
1407
1640
  for (const artifact of consumesJ) {
1408
1641
  if (producesI.has(artifact)) {
1409
1642
  adj[i].push(j);
@@ -1441,8 +1674,8 @@ function topoSortSteps(entries) {
1441
1674
  if (result.length < n) {
1442
1675
  const sortedIds = entries.map((e) => e.capId).join(', ');
1443
1676
  throw new Error(
1444
- 'produces/consumes cycle detected in steps at point "' +
1445
- (entries[0] && entries[0].step ? entries[0].step.point : '?') +
1677
+ 'produces/consumes cycle detected in ' + hookKind + ' at point "' +
1678
+ (entries[0] && entries[0][hookKey] ? entries[0][hookKey].point : '?') +
1446
1679
  '" among capabilities [' + sortedIds + ']: ' +
1447
1680
  'a cycle in hook produces/consumes prevents deterministic ordering',
1448
1681
  );
@@ -1450,6 +1683,21 @@ function topoSortSteps(entries) {
1450
1683
  return result;
1451
1684
  }
1452
1685
 
1686
+ /**
1687
+ * Topologically sort steps at a given point by produces/consumes.
1688
+ * Capability-id tiebreak for determinism.
1689
+ *
1690
+ * @param {{ capId: string, step: object }[]} entries
1691
+ * @returns {{ capId: string, step: object }[]}
1692
+ */
1693
+ function topoSortSteps(entries) {
1694
+ return topoSortHookEntries(entries, 'step', 'steps');
1695
+ }
1696
+
1697
+ function topoSortContributions(entries) {
1698
+ return topoSortHookEntries(entries, 'contrib', 'contributions');
1699
+ }
1700
+
1453
1701
  // ─── ADR-857 Phase 4a: Derived views ─────────────────────────────────────────
1454
1702
 
1455
1703
  // FIX 5 (lazy requires): paths are declared at top level but the actual require()
@@ -1716,6 +1964,54 @@ function runConfigFormatParityGate(capMap) {
1716
1964
  }
1717
1965
  }
1718
1966
 
1967
+ // ─── Gen-time wired guard ─────────────────────────────────────────────────────
1968
+
1969
+ /**
1970
+ * Validate that every hook point declared by a capability has a corresponding
1971
+ * `loop render-hooks <point>` call site in one of the host-loop workflow files.
1972
+ *
1973
+ * Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points
1974
+ * are already caught by validateStep/validateContribution/validateGate — do not
1975
+ * double-report.
1976
+ *
1977
+ * @param {object} cap Validated capability object.
1978
+ * @param {Set<string>} wiredSet Set of points that have call sites in host workflows.
1979
+ * @returns {string[]} Array of error strings; empty means all points are wired.
1980
+ */
1981
+ function validateHooksWired(cap, wiredSet) {
1982
+ const errors = [];
1983
+ const capId = cap.id || '(unknown)';
1984
+
1985
+ function checkPoint(point, groupName, idx) {
1986
+ // Only flag valid points that are unwired — invalid points are schema-validator's job.
1987
+ if (!VALID_LOOP_POINTS.has(point)) return;
1988
+ if (!wiredSet.has(point)) {
1989
+ errors.push(
1990
+ 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point +
1991
+ '" is declared but not wired in any host-loop workflow ' +
1992
+ '(no `loop render-hooks ' + point + '` call site). ' +
1993
+ 'Wire the call site in the host workflow ' +
1994
+ '(see scripts/gen-loop-host-contract.cjs STEP_WORKFLOWS) or remove the hook.',
1995
+ );
1996
+ }
1997
+ }
1998
+
1999
+ for (let i = 0; i < (cap.steps || []).length; i++) {
2000
+ const hook = cap.steps[i];
2001
+ if (hook.point !== undefined) checkPoint(hook.point, 'steps', i);
2002
+ }
2003
+ for (let i = 0; i < (cap.contributions || []).length; i++) {
2004
+ const hook = cap.contributions[i];
2005
+ if (hook.point !== undefined) checkPoint(hook.point, 'contributions', i);
2006
+ }
2007
+ for (let i = 0; i < (cap.gates || []).length; i++) {
2008
+ const hook = cap.gates[i];
2009
+ if (hook.point !== undefined) checkPoint(hook.point, 'gates', i);
2010
+ }
2011
+
2012
+ return errors;
2013
+ }
2014
+
1719
2015
  // ─── Registry builder ─────────────────────────────────────────────────────────
1720
2016
 
1721
2017
  /**
@@ -1737,6 +2033,10 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
1737
2033
  return { capMap, errors };
1738
2034
  }
1739
2035
 
2036
+ // Compute wired points ONCE before iterating capabilities so the filesystem
2037
+ // scan is not repeated per-capability. ROOT is the repo root (defined at top of file).
2038
+ const wiredSet = getWiredLoopPoints(ROOT);
2039
+
1740
2040
  const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true })
1741
2041
  .filter((e) => e.isDirectory())
1742
2042
  .map((e) => e.name)
@@ -1768,6 +2068,19 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
1768
2068
  continue;
1769
2069
  }
1770
2070
 
2071
+ // Gen-time wired guard: reject hooks that declare a valid point with no call site.
2072
+ const wiredErrors = validateHooksWired(cap, wiredSet);
2073
+ if (wiredErrors.length > 0) {
2074
+ for (const e of wiredErrors) errors.push(folderId + '/capability.json: ' + e);
2075
+ continue;
2076
+ }
2077
+
2078
+ const fragmentErrors = materializeHookFragments(cap, path.dirname(capPath));
2079
+ if (fragmentErrors.length > 0) {
2080
+ for (const e of fragmentErrors) errors.push(folderId + '/capability.json: ' + e);
2081
+ continue;
2082
+ }
2083
+
1771
2084
  capMap.set(cap.id, cap);
1772
2085
  }
1773
2086
 
@@ -1892,14 +2205,9 @@ function buildRegistry(capMap) {
1892
2205
  ...e.step,
1893
2206
  }));
1894
2207
 
1895
- // Contributions: group by into, then capability-id order within group
1896
- const contribs = pointContribs.get(point);
1897
- contribs.sort((a, b) => {
1898
- const intoCompare = a.contrib.into.localeCompare(b.contrib.into);
1899
- if (intoCompare !== 0) return intoCompare;
1900
- return a.capId.localeCompare(b.capId);
1901
- });
1902
- byLoopPoint[point].contributions = contribs.map((e) => ({
2208
+ // Contributions: topological sort by produces/consumes, cap-id tiebreak
2209
+ const sortedContribs = topoSortContributions(pointContribs.get(point));
2210
+ byLoopPoint[point].contributions = sortedContribs.map((e) => ({
1903
2211
  capId: e.capId,
1904
2212
  ...e.contrib,
1905
2213
  }));
@@ -2238,6 +2546,7 @@ module.exports = {
2238
2546
  validateConsumesGlobal,
2239
2547
  validateCrossCapability,
2240
2548
  classifyCrossErrors,
2549
+ loadCentralConfigKeys,
2241
2550
  loadAndValidate,
2242
2551
  buildRegistry,
2243
2552
  serializeRegistry,
@@ -2252,12 +2561,14 @@ module.exports = {
2252
2561
  POINT_TO_CONTRACT,
2253
2562
  HOST_ARTIFACT_EARLIEST_POINT_IDX,
2254
2563
  SCHEMA_VERSION,
2564
+ validateHooksWired,
2255
2565
  // ADR-857 phase 4a: derived views + gates
2256
2566
  deriveCapabilityClusters,
2257
2567
  deriveProfileMembership,
2258
2568
  runConsistencyGate,
2259
2569
  // ADR-959: command entry validation
2260
2570
  validateCommandEntry,
2571
+ validateRuntimeCompat,
2261
2572
  // ADR-1016 phase 5a: runtime body validators + closed-vocab sets
2262
2573
  validateConfigHome,
2263
2574
  validateArtifactLayout,
@@ -61,7 +61,7 @@ const FAMILIES = [
61
61
  ];
62
62
 
63
63
  function buildManifest() {
64
- const manifest = { generated: new Date().toISOString().slice(0, 10), families: {} };
64
+ const manifest = { families: {} };
65
65
  for (const { name, dir, filter, toName } of FAMILIES) {
66
66
  manifest.families[name] = fs
67
67
  .readdirSync(dir)
@@ -78,9 +78,6 @@ function main() {
78
78
  if (flag === '--check') {
79
79
  const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
80
80
  const live = buildManifest();
81
- // Strip the generated date for comparison
82
- delete committed.generated;
83
- delete live.generated;
84
81
  const committedStr = JSON.stringify(committed, null, 2);
85
82
  const liveStr = JSON.stringify(live, null, 2);
86
83
  if (committedStr !== liveStr) {
@@ -449,6 +449,58 @@ function main() {
449
449
  }
450
450
  }
451
451
 
452
+ // ─── Derived single-source-of-truth exports ───────────────────────────────────
453
+
454
+ /**
455
+ * Repo-relative paths to every host-loop workflow file, derived from STEP_WORKFLOWS.
456
+ * This is the ONLY canonical enumeration of host-loop files — all consumers (tests,
457
+ * registry generator, conformance gate) must derive from this rather than maintaining
458
+ * a separate hardcoded list.
459
+ */
460
+ const HOST_LOOP_FILES = STEP_WORKFLOWS.map((w) => 'gsd-core/workflows/' + w.file);
461
+
462
+ /**
463
+ * Pure function: scan a text string for `loop render-hooks <point>` call sites.
464
+ * Returns a Set of matched point strings.
465
+ *
466
+ * @param {string} text Content of a workflow file (or any text).
467
+ * @returns {Set<string>}
468
+ */
469
+ function scanWiredPoints(text) {
470
+ const re = /loop render-hooks\s+([a-z:]+)/g;
471
+ const result = new Set();
472
+ let m;
473
+ while ((m = re.exec(text)) !== null) {
474
+ result.add(m[1]);
475
+ }
476
+ return result;
477
+ }
478
+
479
+ /**
480
+ * Read every host-loop workflow file and return the union of all wired loop points
481
+ * (i.e. points that have a `loop render-hooks <point>` call site).
482
+ *
483
+ * @param {string} [repoRoot] Path to the repository root. Defaults to ROOT.
484
+ * @returns {Set<string>}
485
+ */
486
+ function getWiredLoopPoints(repoRoot) {
487
+ const resolvedRoot = repoRoot !== undefined ? repoRoot : ROOT;
488
+ const result = new Set();
489
+ for (const relPath of HOST_LOOP_FILES) {
490
+ const absPath = path.join(resolvedRoot, relPath);
491
+ let content;
492
+ try {
493
+ content = fs.readFileSync(absPath, 'utf8');
494
+ } catch (err) {
495
+ throw new Error('getWiredLoopPoints: cannot read host-loop file ' + absPath + ': ' + err.message);
496
+ }
497
+ for (const point of scanWiredPoints(content)) {
498
+ result.add(point);
499
+ }
500
+ }
501
+ return result;
502
+ }
503
+
452
504
  // ─── Exports (for tests) ─────────────────────────────────────────────────────
453
505
 
454
506
  module.exports = {
@@ -459,9 +511,12 @@ module.exports = {
459
511
  serializeContract,
460
512
  normalizeLineEndings,
461
513
  STEP_WORKFLOWS,
514
+ HOST_LOOP_FILES,
462
515
  CANONICAL_POINTS,
463
516
  EXPECTED_POINTS_BY_STEP,
464
517
  ROLE_TO_AGENT,
518
+ scanWiredPoints,
519
+ getWiredLoopPoints,
465
520
  };
466
521
 
467
522
  // ─── CLI entry point ──────────────────────────────────────────────────────────