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

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 (131) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +1 -1
  3. package/agents/gsd-assumptions-analyzer.md +1 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-code-reviewer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debugger.md +1 -1
  8. package/agents/gsd-doc-writer.md +1 -1
  9. package/agents/gsd-eval-auditor.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-integration-checker.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +47 -0
  13. package/agents/gsd-nyquist-auditor.md +1 -0
  14. package/agents/gsd-phase-researcher.md +1 -1
  15. package/agents/gsd-plan-checker.md +1 -1
  16. package/agents/gsd-planner.md +1 -1
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +1 -1
  19. package/agents/gsd-roadmapper.md +55 -2
  20. package/agents/gsd-security-auditor.md +1 -0
  21. package/agents/gsd-ui-auditor.md +1 -1
  22. package/agents/gsd-ui-checker.md +1 -1
  23. package/agents/gsd-ui-researcher.md +1 -1
  24. package/agents/gsd-verifier.md +13 -2
  25. package/bin/install.js +61 -64
  26. package/commands/gsd/mempalace-capture.md +71 -0
  27. package/commands/gsd/mempalace-recall.md +102 -0
  28. package/commands/gsd/ns-context.md +4 -2
  29. package/commands/gsd/progress.md +2 -1
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +277 -95
  32. package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
  33. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
  35. package/gsd-core/bin/lib/capability-state.cjs +128 -21
  36. package/gsd-core/bin/lib/capability-writer.cjs +354 -0
  37. package/gsd-core/bin/lib/check-command-router.cjs +328 -1
  38. package/gsd-core/bin/lib/clusters.cjs +2 -0
  39. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  40. package/gsd-core/bin/lib/commands.cjs +33 -10
  41. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  42. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  43. package/gsd-core/bin/lib/config.cjs +81 -26
  44. package/gsd-core/bin/lib/core.cjs +5 -2
  45. package/gsd-core/bin/lib/edge-probe.cjs +25 -2
  46. package/gsd-core/bin/lib/frontmatter.cjs +53 -1
  47. package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
  48. package/gsd-core/bin/lib/init.cjs +36 -11
  49. package/gsd-core/bin/lib/install-profiles.cjs +57 -1
  50. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  51. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  52. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  53. package/gsd-core/bin/lib/phase.cjs +99 -23
  54. package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
  55. package/gsd-core/bin/lib/probe-core.cjs +117 -1
  56. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  57. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  58. package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
  59. package/gsd-core/bin/lib/roadmap.cjs +97 -7
  60. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1946 -0
  61. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +54 -30
  62. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
  63. package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
  64. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  65. package/gsd-core/bin/lib/state-document.cjs +46 -1
  66. package/gsd-core/bin/lib/state.cjs +461 -94
  67. package/gsd-core/bin/lib/verify.cjs +92 -8
  68. package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
  69. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  70. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  71. package/gsd-core/bin/shared/model-catalog.json +1 -0
  72. package/gsd-core/references/edge-probe.md +11 -0
  73. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  74. package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
  75. package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
  76. package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
  77. package/gsd-core/references/prohibition-probe.md +248 -0
  78. package/gsd-core/templates/config.json +1 -1
  79. package/gsd-core/templates/spec.md +14 -0
  80. package/gsd-core/workflows/audit-milestone.md +5 -3
  81. package/gsd-core/workflows/autonomous.md +10 -5
  82. package/gsd-core/workflows/code-review-fix.md +9 -7
  83. package/gsd-core/workflows/code-review.md +8 -6
  84. package/gsd-core/workflows/complete-milestone.md +1 -5
  85. package/gsd-core/workflows/discuss-phase.md +14 -0
  86. package/gsd-core/workflows/execute-phase.md +86 -146
  87. package/gsd-core/workflows/execute-plan.md +21 -6
  88. package/gsd-core/workflows/help/modes/full.md +7 -1
  89. package/gsd-core/workflows/new-project.md +3 -3
  90. package/gsd-core/workflows/next.md +50 -2
  91. package/gsd-core/workflows/pause-work.md +7 -1
  92. package/gsd-core/workflows/plan-phase.md +91 -221
  93. package/gsd-core/workflows/plan-review-convergence.md +14 -4
  94. package/gsd-core/workflows/pr-branch.md +4 -2
  95. package/gsd-core/workflows/profile-user.md +3 -1
  96. package/gsd-core/workflows/progress.md +58 -1
  97. package/gsd-core/workflows/quick.md +12 -8
  98. package/gsd-core/workflows/resume-project.md +17 -1
  99. package/gsd-core/workflows/review.md +19 -2
  100. package/gsd-core/workflows/secure-phase.md +4 -2
  101. package/gsd-core/workflows/settings-advanced.md +2 -0
  102. package/gsd-core/workflows/settings.md +27 -1
  103. package/gsd-core/workflows/ship.md +58 -5
  104. package/gsd-core/workflows/spec-phase.md +75 -0
  105. package/gsd-core/workflows/validate-phase.md +4 -2
  106. package/gsd-core/workflows/verify-phase.md +14 -4
  107. package/gsd-core/workflows/verify-work.md +27 -11
  108. package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
  109. package/hooks/dist/gsd-statusline.js +1 -1
  110. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  111. package/hooks/gsd-ensure-canonical-path.js +305 -0
  112. package/hooks/gsd-statusline.js +1 -1
  113. package/hooks/hooks.json +1 -0
  114. package/hooks/managed-hooks-registry.cjs +1 -0
  115. package/package.json +5 -4
  116. package/scripts/affected-tests-lib.cjs +16 -4
  117. package/scripts/build-hooks.js +7 -0
  118. package/scripts/changeset/new.cjs +17 -3
  119. package/scripts/fix-slash-commands.cjs +15 -3
  120. package/scripts/gen-capability-registry.cjs +373 -49
  121. package/scripts/gen-inventory-manifest.cjs +1 -4
  122. package/scripts/gen-loop-host-contract.cjs +55 -0
  123. package/scripts/issue-version-gate.cjs +140 -0
  124. package/scripts/lint-allow-test-rule-refs.allowlist.json +327 -0
  125. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  126. package/scripts/lint-test-file-count.allowlist.json +14 -0
  127. package/scripts/mutation-matrix.cjs +108 -7
  128. package/scripts/pr-target-policy.cjs +63 -0
  129. package/scripts/release-tarball-smoke.cjs +7 -1
  130. package/scripts/research-profiles.cjs +5 -5
  131. package/scripts/run-tests.cjs +178 -17
@@ -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 {
@@ -451,8 +546,10 @@ function validateFeatureBody(cap) {
451
546
  }
452
547
 
453
548
  // ADR-857 phase 5e: Closed ConverterName enum — complete set used across 16 runtime descriptors,
454
- // all exported by bin/install.js. Any ArtifactKind with a non-null converter must use one of these.
549
+ // all exported by bin/install.js (commands/skills) and src/runtime-artifact-conversion.cts (agents).
550
+ // Any ArtifactKind with a non-null converter must use one of these.
455
551
  const VALID_CONVERTER_NAMES = new Set([
552
+ // commands / skills converters (pre-existing)
456
553
  'convertClaudeCommandToAntigravitySkill',
457
554
  'convertClaudeCommandToAugmentSkill',
458
555
  'convertClaudeCommandToClineSkill',
@@ -468,6 +565,16 @@ const VALID_CONVERTER_NAMES = new Set([
468
565
  'convertClaudeCommandToOpencodeSkill',
469
566
  'convertClaudeCommandToTraeSkill',
470
567
  'convertClaudeCommandToWindsurfSkill',
568
+ // agent converters (#1173 — descriptor-driven agent conversion wiring)
569
+ 'convertClaudeAgentToCopilotAgent',
570
+ 'convertClaudeAgentToAntigravityAgent',
571
+ 'convertClaudeAgentToCursorAgent',
572
+ 'convertClaudeAgentToWindsurfAgent',
573
+ 'convertClaudeAgentToAugmentAgent',
574
+ 'convertClaudeAgentToTraeAgent',
575
+ 'convertClaudeAgentToCodebuddyAgent',
576
+ 'convertClaudeAgentToClineAgent',
577
+ 'convertClaudeAgentToCodexAgent',
471
578
  ]);
472
579
 
473
580
  // C3: Validate role:runtime body
@@ -847,6 +954,78 @@ function validateRuntimeBody(cap) {
847
954
  return errors;
848
955
  }
849
956
 
957
+ function materializeHookFragments(cap, capDir) {
958
+ const errors = [];
959
+ const hookGroups = [
960
+ ['steps', Array.isArray(cap.steps) ? cap.steps : []],
961
+ ['contributions', Array.isArray(cap.contributions) ? cap.contributions : []],
962
+ ];
963
+
964
+ for (const [groupName, hooks] of hookGroups) {
965
+ for (let i = 0; i < hooks.length; i++) {
966
+ const hook = hooks[i];
967
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) continue;
968
+ const fragment = hook.fragment;
969
+ if (!fragment || typeof fragment !== 'object' || Array.isArray(fragment)) continue;
970
+ if (typeof fragment.inline === 'string') continue;
971
+ if (typeof fragment.path !== 'string') continue;
972
+
973
+ const abs = path.resolve(capDir, fragment.path);
974
+ const capRoot = path.resolve(capDir);
975
+ if (abs !== capRoot && !abs.startsWith(capRoot + path.sep)) {
976
+ errors.push(
977
+ cap.id + '/' + groupName + '[' + i + '].fragment.path escapes capability directory: ' +
978
+ fragment.path,
979
+ );
980
+ continue;
981
+ }
982
+
983
+ try {
984
+ fragment.inline = fs.readFileSync(abs, 'utf8');
985
+ } catch (err) {
986
+ errors.push(
987
+ cap.id + '/' + groupName + '[' + i + '].fragment.path could not be read: ' +
988
+ fragment.path + ' (' + err.message + ')',
989
+ );
990
+ }
991
+ }
992
+ }
993
+
994
+ return errors;
995
+ }
996
+
997
+ function validateFragment(fragment, prefix) {
998
+ const errors = [];
999
+
1000
+ if (typeof fragment !== 'object' || fragment === null || Array.isArray(fragment)) {
1001
+ errors.push(prefix + ' must be an object with path or inline key');
1002
+ return errors;
1003
+ }
1004
+
1005
+ const hasPath = Object.prototype.hasOwnProperty.call(fragment, 'path');
1006
+ const hasInline = Object.prototype.hasOwnProperty.call(fragment, 'inline');
1007
+ if (!hasPath && !hasInline) {
1008
+ errors.push(prefix + ' must have a "path" or "inline" key');
1009
+ }
1010
+ if (hasInline) {
1011
+ const inline = fragment.inline;
1012
+ if (typeof inline !== 'string') {
1013
+ errors.push(prefix + '.inline must be a string');
1014
+ } else if (inline === '') {
1015
+ errors.push(prefix + '.inline must be a non-empty string');
1016
+ }
1017
+ }
1018
+ // S1: fragment.path traversal guard — must be a relative path with no ".." segments
1019
+ if (hasPath) {
1020
+ const p = fragment.path;
1021
+ if (typeof p !== 'string' || p === '' || path.isAbsolute(p) || p.split(/[\\/]/).includes('..')) {
1022
+ errors.push(prefix + '.path must be a relative path with no ".." segments');
1023
+ }
1024
+ }
1025
+
1026
+ return errors;
1027
+ }
1028
+
850
1029
  /**
851
1030
  * Validate a single step entry.
852
1031
  *
@@ -866,15 +1045,17 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
866
1045
  }
867
1046
 
868
1047
  if (typeof step.ref !== 'object' || step.ref === null) {
869
- errors.push(prefix + '.ref must be an object with skill or agent key');
1048
+ errors.push(prefix + '.ref must be an object with skill, agent, or command key');
870
1049
  } else {
871
1050
  const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill');
872
1051
  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');
1052
+ const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command');
1053
+ const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length;
1054
+ if (dispatchCount === 0) {
1055
+ errors.push(prefix + '.ref must have a "skill", "agent", or "command" key');
1056
+ } else if (dispatchCount > 1) {
1057
+ // ref must be exclusive: skill XOR agent XOR command
1058
+ errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple');
878
1059
  }
879
1060
  if (hasSkill && typeof step.ref.skill !== 'string') {
880
1061
  errors.push(prefix + '.ref.skill must be a string');
@@ -904,6 +1085,9 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
904
1085
  [...declaredAgents].join(', ') + ']',
905
1086
  );
906
1087
  }
1088
+ if (hasCommand && typeof step.ref.command !== 'string') {
1089
+ errors.push(prefix + '.ref.command must be a string');
1090
+ }
907
1091
  }
908
1092
 
909
1093
  if (!Array.isArray(step.produces)) {
@@ -926,6 +1110,10 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
926
1110
  errors.push(prefix + '.when must be a string if present');
927
1111
  }
928
1112
 
1113
+ if (step.fragment !== undefined) {
1114
+ errors.push(...validateFragment(step.fragment, prefix + '.fragment'));
1115
+ }
1116
+
929
1117
  if (!VALID_ON_ERROR.has(step.onError)) {
930
1118
  errors.push(prefix + '.onError must be "skip" or "halt" (got: ' + step.onError + ')');
931
1119
  }
@@ -944,23 +1132,24 @@ function validateContribution(contrib, prefix) {
944
1132
  errors.push(prefix + '.into must be a string (agent role name)');
945
1133
  }
946
1134
 
947
- if (typeof contrib.fragment !== 'object' || contrib.fragment === null) {
948
- errors.push(prefix + '.fragment must be an object with path or inline key');
1135
+ if (!Array.isArray(contrib.produces)) {
1136
+ errors.push(prefix + '.produces must be an array');
949
1137
  } 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
- }
1138
+ for (const p of contrib.produces) {
1139
+ if (typeof p !== 'string') errors.push(prefix + '.produces entries must be strings');
1140
+ }
1141
+ }
1142
+
1143
+ if (!Array.isArray(contrib.consumes)) {
1144
+ errors.push(prefix + '.consumes must be an array');
1145
+ } else {
1146
+ for (const c of contrib.consumes) {
1147
+ if (typeof c !== 'string') errors.push(prefix + '.consumes entries must be strings');
961
1148
  }
962
1149
  }
963
1150
 
1151
+ errors.push(...validateFragment(contrib.fragment, prefix + '.fragment'));
1152
+
964
1153
  if (contrib.when !== undefined && typeof contrib.when !== 'string') {
965
1154
  errors.push(prefix + '.when must be a string if present');
966
1155
  }
@@ -1146,8 +1335,38 @@ function validateConsumesGlobal(capMap) {
1146
1335
  }
1147
1336
  }
1148
1337
 
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.
1338
+ // Duplicate-producer invariant: two capability steps may not produce the same artifact
1339
+ // at the same Loop Extension Point. Same-point dual production makes data-flow resolution
1340
+ // ambiguous and is rejected at gen time (Decision #6).
1341
+ for (const artifact of Object.keys(capHookProducers)) {
1342
+ if (artifact === '__proto__' || artifact === 'constructor' || artifact === 'prototype') continue;
1343
+ const producers = capHookProducers[artifact];
1344
+ // Group by pointIdx
1345
+ const byPoint = Object.create(null);
1346
+ for (const entry of producers) {
1347
+ if (!byPoint[entry.pointIdx]) byPoint[entry.pointIdx] = [];
1348
+ byPoint[entry.pointIdx].push(entry);
1349
+ }
1350
+ for (const pointIdxStr of Object.keys(byPoint)) {
1351
+ const group = byPoint[pointIdxStr];
1352
+ // Count distinct (capId, stepIdx) producer steps — a single step listing the same
1353
+ // artifact twice in its produces array pushes duplicate entries but represents only
1354
+ // ONE producer step and must not false-positive the cross-step gate.
1355
+ const distinctProducers = new Set(group.map((e) => e.capId + ' ' + e.stepIdx));
1356
+ if (distinctProducers.size >= 2) {
1357
+ const pointIdx = Number(pointIdxStr);
1358
+ const pointName = POINT_ORDER[pointIdx];
1359
+ const capIds = [...new Set(group.map((e) => e.capId))].sort().join(', ');
1360
+ throw new Error(
1361
+ 'duplicate-producer invariant violated: artifact "' + artifact + '" is produced by ' +
1362
+ 'two or more capability steps at the same Loop Extension Point "' + pointName + '" ' +
1363
+ '(capabilities: ' + capIds + '). ' +
1364
+ 'Two capability steps producing the same artifact at the same Loop Extension Point ' +
1365
+ 'makes data-flow resolution ambiguous and is rejected at gen time.',
1366
+ );
1367
+ }
1368
+ }
1369
+ }
1151
1370
 
1152
1371
  // Now check every hook step's consumes.
1153
1372
  // Self-consume rule: a step H cannot satisfy its own consumes[A] from its own produces[A].
@@ -1297,6 +1516,39 @@ function validateCrossCapability(capMap, centralKeys) {
1297
1516
  }
1298
1517
  }
1299
1518
 
1519
+ // runtimeCompat: explicit runtime ids must reference runtime capabilities.
1520
+ // The wildcard "*" means descriptor-backed runtimes are supported by default.
1521
+ const runtimeIds = new Set();
1522
+ for (const [id, cap] of capMap) {
1523
+ if (cap.role === 'runtime') runtimeIds.add(id);
1524
+ }
1525
+ for (const [capId, cap] of capMap) {
1526
+ if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue;
1527
+ for (const field of ['supported', 'unsupported']) {
1528
+ const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : [];
1529
+ for (const runtimeId of entries) {
1530
+ if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
1531
+ if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue;
1532
+ if (!runtimeIds.has(runtimeId)) {
1533
+ errors.push(
1534
+ 'capability "' + capId + '" runtimeCompat.' + field +
1535
+ ' references unknown runtime "' + runtimeId + '"',
1536
+ );
1537
+ }
1538
+ }
1539
+ }
1540
+ if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') {
1541
+ for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) {
1542
+ if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
1543
+ if (!runtimeIds.has(runtimeId)) {
1544
+ errors.push(
1545
+ 'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"',
1546
+ );
1547
+ }
1548
+ }
1549
+ }
1550
+ }
1551
+
1300
1552
  // requires: acyclic
1301
1553
  const cycleErrors = detectRequiresCycles(capMap);
1302
1554
  errors.push(...cycleErrors);
@@ -1384,14 +1636,7 @@ function computeRequiresClosure(id, capMap) {
1384
1636
 
1385
1637
  // ─── Topological ordering ─────────────────────────────────────────────────────
1386
1638
 
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) {
1639
+ function topoSortHookEntries(entries, hookKey, hookKind) {
1395
1640
  if (entries.length <= 1) return entries;
1396
1641
 
1397
1642
  // Build adjacency: entry A must come before entry B if B consumes something A produces
@@ -1400,10 +1645,10 @@ function topoSortSteps(entries) {
1400
1645
  const adj = Array.from({ length: n }, () => []);
1401
1646
 
1402
1647
  for (let i = 0; i < n; i++) {
1403
- const producesI = new Set(entries[i].step.produces || []);
1648
+ const producesI = new Set(entries[i][hookKey].produces || []);
1404
1649
  for (let j = 0; j < n; j++) {
1405
1650
  if (i === j) continue;
1406
- const consumesJ = entries[j].step.consumes || [];
1651
+ const consumesJ = entries[j][hookKey].consumes || [];
1407
1652
  for (const artifact of consumesJ) {
1408
1653
  if (producesI.has(artifact)) {
1409
1654
  adj[i].push(j);
@@ -1441,8 +1686,8 @@ function topoSortSteps(entries) {
1441
1686
  if (result.length < n) {
1442
1687
  const sortedIds = entries.map((e) => e.capId).join(', ');
1443
1688
  throw new Error(
1444
- 'produces/consumes cycle detected in steps at point "' +
1445
- (entries[0] && entries[0].step ? entries[0].step.point : '?') +
1689
+ 'produces/consumes cycle detected in ' + hookKind + ' at point "' +
1690
+ (entries[0] && entries[0][hookKey] ? entries[0][hookKey].point : '?') +
1446
1691
  '" among capabilities [' + sortedIds + ']: ' +
1447
1692
  'a cycle in hook produces/consumes prevents deterministic ordering',
1448
1693
  );
@@ -1450,6 +1695,21 @@ function topoSortSteps(entries) {
1450
1695
  return result;
1451
1696
  }
1452
1697
 
1698
+ /**
1699
+ * Topologically sort steps at a given point by produces/consumes.
1700
+ * Capability-id tiebreak for determinism.
1701
+ *
1702
+ * @param {{ capId: string, step: object }[]} entries
1703
+ * @returns {{ capId: string, step: object }[]}
1704
+ */
1705
+ function topoSortSteps(entries) {
1706
+ return topoSortHookEntries(entries, 'step', 'steps');
1707
+ }
1708
+
1709
+ function topoSortContributions(entries) {
1710
+ return topoSortHookEntries(entries, 'contrib', 'contributions');
1711
+ }
1712
+
1453
1713
  // ─── ADR-857 Phase 4a: Derived views ─────────────────────────────────────────
1454
1714
 
1455
1715
  // FIX 5 (lazy requires): paths are declared at top level but the actual require()
@@ -1716,6 +1976,54 @@ function runConfigFormatParityGate(capMap) {
1716
1976
  }
1717
1977
  }
1718
1978
 
1979
+ // ─── Gen-time wired guard ─────────────────────────────────────────────────────
1980
+
1981
+ /**
1982
+ * Validate that every hook point declared by a capability has a corresponding
1983
+ * `loop render-hooks <point>` call site in one of the host-loop workflow files.
1984
+ *
1985
+ * Only valid loop points (in VALID_LOOP_POINTS) are checked here. Invalid points
1986
+ * are already caught by validateStep/validateContribution/validateGate — do not
1987
+ * double-report.
1988
+ *
1989
+ * @param {object} cap Validated capability object.
1990
+ * @param {Set<string>} wiredSet Set of points that have call sites in host workflows.
1991
+ * @returns {string[]} Array of error strings; empty means all points are wired.
1992
+ */
1993
+ function validateHooksWired(cap, wiredSet) {
1994
+ const errors = [];
1995
+ const capId = cap.id || '(unknown)';
1996
+
1997
+ function checkPoint(point, groupName, idx) {
1998
+ // Only flag valid points that are unwired — invalid points are schema-validator's job.
1999
+ if (!VALID_LOOP_POINTS.has(point)) return;
2000
+ if (!wiredSet.has(point)) {
2001
+ errors.push(
2002
+ 'capability "' + capId + '" ' + groupName + '[' + idx + '].point "' + point +
2003
+ '" is declared but not wired in any host-loop workflow ' +
2004
+ '(no `loop render-hooks ' + point + '` call site). ' +
2005
+ 'Wire the call site in the host workflow ' +
2006
+ '(see scripts/gen-loop-host-contract.cjs STEP_WORKFLOWS) or remove the hook.',
2007
+ );
2008
+ }
2009
+ }
2010
+
2011
+ for (let i = 0; i < (cap.steps || []).length; i++) {
2012
+ const hook = cap.steps[i];
2013
+ if (hook.point !== undefined) checkPoint(hook.point, 'steps', i);
2014
+ }
2015
+ for (let i = 0; i < (cap.contributions || []).length; i++) {
2016
+ const hook = cap.contributions[i];
2017
+ if (hook.point !== undefined) checkPoint(hook.point, 'contributions', i);
2018
+ }
2019
+ for (let i = 0; i < (cap.gates || []).length; i++) {
2020
+ const hook = cap.gates[i];
2021
+ if (hook.point !== undefined) checkPoint(hook.point, 'gates', i);
2022
+ }
2023
+
2024
+ return errors;
2025
+ }
2026
+
1719
2027
  // ─── Registry builder ─────────────────────────────────────────────────────────
1720
2028
 
1721
2029
  /**
@@ -1737,6 +2045,10 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
1737
2045
  return { capMap, errors };
1738
2046
  }
1739
2047
 
2048
+ // Compute wired points ONCE before iterating capabilities so the filesystem
2049
+ // scan is not repeated per-capability. ROOT is the repo root (defined at top of file).
2050
+ const wiredSet = getWiredLoopPoints(ROOT);
2051
+
1740
2052
  const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true })
1741
2053
  .filter((e) => e.isDirectory())
1742
2054
  .map((e) => e.name)
@@ -1768,6 +2080,19 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
1768
2080
  continue;
1769
2081
  }
1770
2082
 
2083
+ // Gen-time wired guard: reject hooks that declare a valid point with no call site.
2084
+ const wiredErrors = validateHooksWired(cap, wiredSet);
2085
+ if (wiredErrors.length > 0) {
2086
+ for (const e of wiredErrors) errors.push(folderId + '/capability.json: ' + e);
2087
+ continue;
2088
+ }
2089
+
2090
+ const fragmentErrors = materializeHookFragments(cap, path.dirname(capPath));
2091
+ if (fragmentErrors.length > 0) {
2092
+ for (const e of fragmentErrors) errors.push(folderId + '/capability.json: ' + e);
2093
+ continue;
2094
+ }
2095
+
1771
2096
  capMap.set(cap.id, cap);
1772
2097
  }
1773
2098
 
@@ -1892,14 +2217,9 @@ function buildRegistry(capMap) {
1892
2217
  ...e.step,
1893
2218
  }));
1894
2219
 
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) => ({
2220
+ // Contributions: topological sort by produces/consumes, cap-id tiebreak
2221
+ const sortedContribs = topoSortContributions(pointContribs.get(point));
2222
+ byLoopPoint[point].contributions = sortedContribs.map((e) => ({
1903
2223
  capId: e.capId,
1904
2224
  ...e.contrib,
1905
2225
  }));
@@ -2238,12 +2558,14 @@ module.exports = {
2238
2558
  validateConsumesGlobal,
2239
2559
  validateCrossCapability,
2240
2560
  classifyCrossErrors,
2561
+ loadCentralConfigKeys,
2241
2562
  loadAndValidate,
2242
2563
  buildRegistry,
2243
2564
  serializeRegistry,
2244
2565
  computeRequiresClosure,
2245
2566
  topoSortSteps,
2246
2567
  normalizeLineEndings,
2568
+ stripGeneratedComment,
2247
2569
  validateConfigSliceEntry,
2248
2570
  VALID_CONFIG_SLICE_TYPES,
2249
2571
  LOOP_HOST_CONTRACT,
@@ -2252,12 +2574,14 @@ module.exports = {
2252
2574
  POINT_TO_CONTRACT,
2253
2575
  HOST_ARTIFACT_EARLIEST_POINT_IDX,
2254
2576
  SCHEMA_VERSION,
2577
+ validateHooksWired,
2255
2578
  // ADR-857 phase 4a: derived views + gates
2256
2579
  deriveCapabilityClusters,
2257
2580
  deriveProfileMembership,
2258
2581
  runConsistencyGate,
2259
2582
  // ADR-959: command entry validation
2260
2583
  validateCommandEntry,
2584
+ validateRuntimeCompat,
2261
2585
  // ADR-1016 phase 5a: runtime body validators + closed-vocab sets
2262
2586
  validateConfigHome,
2263
2587
  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) {