@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
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ /**
3
+ * ADR-22 Drift-Guard Decision Module
4
+ *
5
+ * Implements the authority ladder and severity classification table from
6
+ * ADR-22 (docs/adr/0022-source-grounding-drift-guard.md).
7
+ *
8
+ * Design constraints:
9
+ * - Pure module: no I/O, no require() calls, no side effects.
10
+ * - All inputs are validated; unknown values throw a TypeError.
11
+ * - Consumed by the `gsd-tools drift-guard` CLI seam and by tests.
12
+ *
13
+ * Authority ladder (rung values determine MISSING severity):
14
+ * grep=0 intel=1 treesitter=2 lsp=3 scip=4
15
+ *
16
+ * Hard-block threshold: rung >= 3 (lsp, scip) — these adapters can prove
17
+ * absence, so MISSING is a definite error (severity HIGH, hardBlock true).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.AUTHORITY_RUNGS = void 0;
21
+ exports.getEffectiveAuthority = getEffectiveAuthority;
22
+ exports.classifyDriftSeverity = classifyDriftSeverity;
23
+ /**
24
+ * Frozen map from authority name to its rung number.
25
+ *
26
+ * Rung determines whether a MISSING symbol triggers a hard block:
27
+ * rung >= 3 (lsp, scip) → hard block; rung < 3 → acknowledgement only.
28
+ */
29
+ exports.AUTHORITY_RUNGS = Object.freeze({
30
+ grep: 0,
31
+ intel: 1,
32
+ treesitter: 2,
33
+ lsp: 3,
34
+ scip: 4,
35
+ });
36
+ /** Rung at which MISSING transitions to hard-block (inclusive). */
37
+ const HARD_BLOCK_RUNG_THRESHOLD = 3;
38
+ const VALID_AUTHORITIES = new Set(Object.keys(exports.AUTHORITY_RUNGS));
39
+ const VALID_STATUSES = new Set(['VERIFIED', 'MISSING', 'AMBIGUOUS', 'UNCHECKABLE']);
40
+ /**
41
+ * Validate and return an authority value, normalising undefined to 'grep'.
42
+ *
43
+ * Throws TypeError for any non-null unknown string value so callers surface
44
+ * configuration errors at call time rather than silently defaulting.
45
+ *
46
+ * @param value - raw authority string from config or CLI arg
47
+ * @returns a validated Authority value
48
+ */
49
+ function validateAuthority(value) {
50
+ if (value === undefined || value === null || value === '') {
51
+ return 'grep';
52
+ }
53
+ if (!VALID_AUTHORITIES.has(value)) {
54
+ throw new TypeError(`Unknown authority: ${JSON.stringify(value)}. ` +
55
+ `Valid values: ${[...VALID_AUTHORITIES].join(', ')}`);
56
+ }
57
+ return value;
58
+ }
59
+ /**
60
+ * Return the effective authority after applying the ADR-22 auto-upgrade rule.
61
+ *
62
+ * Auto-upgrade rule: if the configured authority is 'grep' AND intel is
63
+ * enabled (`intelEnabled === true`), upgrade to 'intel'. All other authority
64
+ * values are returned unchanged regardless of intelEnabled.
65
+ *
66
+ * @param authority - configured authority (undefined → 'grep')
67
+ * @param intelEnabled - whether the intel capability is active in this project
68
+ * @returns the effective Authority after upgrade
69
+ * @throws TypeError if authority is not one of the five valid values
70
+ */
71
+ function getEffectiveAuthority(authority, intelEnabled) {
72
+ const validated = validateAuthority(authority);
73
+ if (validated === 'grep' && intelEnabled === true) {
74
+ return 'intel';
75
+ }
76
+ return validated;
77
+ }
78
+ /**
79
+ * Classify a symbol verification result into a drift severity and hard-block flag.
80
+ *
81
+ * ADR-22 decision table:
82
+ *
83
+ * | Status | Authority rung | severity | hardBlock |
84
+ * |------------- |--------------- |----------------------- |---------- |
85
+ * | VERIFIED | any | 'none' | false |
86
+ * | MISSING | rung >= 3 | 'HIGH' | true |
87
+ * | MISSING | rung 0-2 | 'needs-acknowledgement'| false |
88
+ * | AMBIGUOUS | any | 'MEDIUM' | false |
89
+ * | UNCHECKABLE | any | 'INFO' | false |
90
+ *
91
+ * @param opts.status - verdict from the source-grounding adapter
92
+ * @param opts.authority - the effective authority adapter used
93
+ * @returns { severity, hardBlock }
94
+ * @throws TypeError for unknown status or authority values
95
+ */
96
+ function classifyDriftSeverity({ status, authority, }) {
97
+ if (!VALID_STATUSES.has(status)) {
98
+ throw new TypeError(`Unknown status: ${JSON.stringify(status)}. ` +
99
+ `Valid values: ${[...VALID_STATUSES].join(', ')}`);
100
+ }
101
+ // authority validation (also catches unknown values)
102
+ const validatedAuthority = validateAuthority(authority);
103
+ const rung = exports.AUTHORITY_RUNGS[validatedAuthority];
104
+ switch (status) {
105
+ case 'VERIFIED':
106
+ return { severity: 'none', hardBlock: false };
107
+ case 'MISSING':
108
+ if (rung >= HARD_BLOCK_RUNG_THRESHOLD) {
109
+ return { severity: 'HIGH', hardBlock: true };
110
+ }
111
+ return { severity: 'needs-acknowledgement', hardBlock: false };
112
+ case 'AMBIGUOUS':
113
+ return { severity: 'MEDIUM', hardBlock: false };
114
+ case 'UNCHECKABLE':
115
+ return { severity: 'INFO', hardBlock: false };
116
+ }
117
+ }
@@ -30,10 +30,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
30
30
  return (mod && mod.__esModule) ? mod : { "default": mod };
31
31
  };
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.VALID_STATUS = void 0;
33
+ exports.PROHIBITION_VALIDATORS = exports.VALID_STATUS = void 0;
34
34
  exports.validateRequirement = validateRequirement;
35
35
  exports.validateResolution = validateResolution;
36
36
  exports.analyzeCoverage = analyzeCoverage;
37
+ exports.validateProhibitionResolution = validateProhibitionResolution;
38
+ exports.projectProhibitions = projectProhibitions;
39
+ exports.dispositionForProhibition = dispositionForProhibition;
37
40
  exports.runProbeCli = runProbeCli;
38
41
  const node_fs_1 = __importDefault(require("node:fs"));
39
42
  /** The LOCKED set of valid lifecycle statuses (the re-cut: no covered/backstop). */
@@ -203,6 +206,119 @@ function analyzeCoverage(items, resolutions = [], validators) {
203
206
  }
204
207
  return { items: merged, coverage: { applicable, resolved, unresolved, byVerification } };
205
208
  }
209
+ /**
210
+ * The prohibition adapter's injected runtime validators (ADR-550 #5). There is no closed
211
+ * category taxonomy (recall is open-vocabulary values/safety/ethics prose), so `categories`
212
+ * is intentionally empty — `analyzeCoverage` is not the prohibition entry point and the
213
+ * round-trip schema layer does not gate on category. The verification tiers are
214
+ * `test | judgment` (ADR-550 D7a); both require only a present `resolution`/`reason` per their
215
+ * lifecycle (a resolved prohibition's checkable content is the `statement`, validated by the
216
+ * schema layer, not a `resolution` string), so `requiredFieldsByVerification` is the minimal
217
+ * fail-closed set: a dismissed item still needs its reason (enforced by `validateResolution`).
218
+ */
219
+ exports.PROHIBITION_VALIDATORS = {
220
+ categories: [],
221
+ verification: ['test', 'judgment'],
222
+ // A resolved prohibition's checkable content is the `statement` (schema-layer validated), NOT a
223
+ // `resolution` string — the canonical fixtures and the reference doc's worked examples all carry
224
+ // `resolution: null`. So the per-tier required set is empty: `resolved` still requires a present
225
+ // verification tier (enforced in validateResolution) and `dismissed` still requires a reason
226
+ // (enforced unconditionally), but neither tier requires a `resolution`. This matches the corpus
227
+ // the docs-fixtures parity test pins; the validators.test.cjs regression keeps them aligned.
228
+ requiredFieldsByVerification: { test: [], judgment: [] },
229
+ };
230
+ /** Validate a prohibition resolution against the prohibition verification vocabulary. */
231
+ function validateProhibitionResolution(resolution) {
232
+ return validateResolution(resolution, exports.PROHIBITION_VALIDATORS);
233
+ }
234
+ /**
235
+ * Deterministically project resolved prohibition items into the `must_haves.prohibitions:`
236
+ * list shape (the SPEC<->plan projection; ADR-550 Decision 5c). This is a FUNCTION the parity
237
+ * assertion round-trips, never a prompt: the same input always yields the same output, and the
238
+ * output is the exact re-readable block shape `parseMustHavesBlock(content, 'prohibitions')`
239
+ * returns — `{ statement, status, verification }` plus `reason` only when present (a dismissed
240
+ * item's audit trail). `resolution`/`requirement_id`/`category` are recall-stage bookkeeping
241
+ * and are intentionally NOT projected into the plan block (which is keyed on the must-NOT
242
+ * statement, not the source requirement). A non-array input projects to `[]` (fail-soft on the
243
+ * empty/zero-prohibition case), never a throw.
244
+ */
245
+ function projectProhibitions(items) {
246
+ if (!Array.isArray(items))
247
+ return [];
248
+ const out = [];
249
+ for (const item of items) {
250
+ if (item == null || typeof item !== 'object')
251
+ continue;
252
+ const p = item;
253
+ const statement = typeof p.statement === 'string' ? p.statement : '';
254
+ const entry = {
255
+ statement,
256
+ status: typeof p.status === 'string' ? p.status : 'unresolved',
257
+ };
258
+ if (p.verification != null)
259
+ entry.verification = String(p.verification);
260
+ if (p.reason != null && String(p.reason).trim())
261
+ entry.reason = String(p.reason);
262
+ out.push(entry);
263
+ }
264
+ return out;
265
+ }
266
+ /**
267
+ * Deterministic verify-time disposition for a single prohibition — the FAIL-CLOSED default
268
+ * (ADR-550 Decision 5d, the safety half of the 2026-06-12 "B-with-guard" maintainer decision).
269
+ *
270
+ * This is the cheap safety guarantee: a well-formed prohibition that reaches verify-phase with NO
271
+ * wired enforcement evidence can NEVER be a silent pass. It is `{ status: 'unverified', flagged:
272
+ * true }` — never `green` — exactly like an unresolved judgment item. The HEAVY half (a real
273
+ * fail-first negative-test enforcement mechanism that, given evidence, would flip a test-tier item
274
+ * to green) is OUT of #644 scope and defers to a follow-up PR: #644's corpus is entirely
275
+ * judgment-tier, so wiring a contrived test-tier consumer here would be the delete-bad-tests /
276
+ * gold-plating failure mode. Until that follow-up lands, ANY prohibition without enforcement
277
+ * evidence — test- or judgment-tier — disposes as flagged-unverified.
278
+ *
279
+ * The function is pure: same input always yields the same disposition (no LLM judgment, ADR-550
280
+ * D5). The LLM-judge soft-gate for judgment-tier items is a verify-phase PROSE concern (the
281
+ * verifier records a non-authoritative verdict + the unverified-prohibition flag); this helper
282
+ * only owns the deterministic fail-closed default that the plan-01-01 CI safety assertion pins.
283
+ */
284
+ function dispositionForProhibition(prohibition, context = {}) {
285
+ const p = (prohibition ?? {});
286
+ const tier = p.verification === 'test' || p.verification === 'judgment' ? p.verification : null;
287
+ const evidence = Array.isArray(context.enforcementEvidence) ? context.enforcementEvidence : [];
288
+ const hasEnforcement = evidence.length > 0;
289
+ // FAIL CLOSED: no wired enforcement evidence -> flagged unverified, never green. This holds for
290
+ // every tier today (the real enforcement mechanism that could flip a test-tier item to green is
291
+ // deferred to a follow-up PR). The guard the safety assertion proves: an unwired item can never
292
+ // be silently skipped.
293
+ if (!hasEnforcement) {
294
+ return {
295
+ status: 'unverified',
296
+ flagged: true,
297
+ tier,
298
+ reason: tier === 'test'
299
+ ? 'test-tier prohibition has no wired enforcement evidence — flagged unverified (fail-closed; real negative-test enforcement deferred to a follow-up PR, ADR-550 D5d)'
300
+ : 'prohibition has no enforcement evidence — flagged unverified (fail-closed; never a silent pass, ADR-550 D5d)',
301
+ };
302
+ }
303
+ // D4 GUARD: a judgment-tier (or unknown-tier) prohibition is NEVER a silent green from this
304
+ // deterministic helper — it always routes to human/LLM judgment review (ADR-550 D4; verify-phase.md).
305
+ // Only a test-tier item with wired enforcement evidence may go green, and even that is the deferred
306
+ // heavy half until the real negative-test enforcement mechanism lands (no #644 caller passes evidence).
307
+ if (tier === 'test') {
308
+ return {
309
+ status: 'green',
310
+ flagged: false,
311
+ tier,
312
+ reason: 'test-tier prohibition has wired enforcement evidence',
313
+ };
314
+ }
315
+ return {
316
+ status: 'unverified',
317
+ flagged: true,
318
+ tier,
319
+ reason: 'judgment-tier prohibition routes to judgment review — never a silent green (ADR-550 D4)',
320
+ };
321
+ }
206
322
  /**
207
323
  * Read the requirements file (and optional resolutions file), run the adapter's `analyze`,
208
324
  * and write the report as pretty JSON + newline. With no requirements path, writes the usage
@@ -653,7 +653,21 @@ function cmdWriteProfile(cwd, options, raw) {
653
653
  }
654
654
  let outputPath = options.output;
655
655
  if (!outputPath) {
656
- outputPath = node_path_1.default.join(node_os_1.default.homedir(), '.claude', 'gsd-core', 'USER-PROFILE.md');
656
+ // #1114: resolve the ACTIVE runtime's config home so the profile is written
657
+ // where the runtime's own workflows look for it. Previously this hardcoded the
658
+ // Claude home, so a Codex run wrote the profile under the Claude config dir
659
+ // while Codex advisor-mode (installed under the Codex home) checked the Codex
660
+ // dir and never found it. Mirrors cmdGenerateDevPreferences' runtime resolution.
661
+ let effectiveRuntime = 'claude';
662
+ try {
663
+ const config = loadConfig(cwd);
664
+ effectiveRuntime = (0, runtime_name_policy_cjs_1.resolveRuntimeNameFromCandidates)(process.env['GSD_RUNTIME'], config['runtime'], 'claude') || 'claude';
665
+ }
666
+ catch {
667
+ effectiveRuntime = (0, runtime_name_policy_cjs_1.resolveRuntimeNameFromCandidates)(process.env['GSD_RUNTIME'], 'claude') || 'claude';
668
+ }
669
+ // path.join (not a string literal) keeps the cline-install leaked-path lint quiet.
670
+ outputPath = node_path_1.default.join((0, runtime_homes_cjs_1.getGlobalConfigDir)(effectiveRuntime), 'gsd-core', 'USER-PROFILE.md');
657
671
  }
658
672
  else if (!node_path_1.default.isAbsolute(outputPath)) {
659
673
  outputPath = node_path_1.default.join(cwd, outputPath);
@@ -911,8 +925,10 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
911
925
  targetPath = node_path_1.default.isAbsolute(options.output) ? options.output : node_path_1.default.join(cwd, options.output);
912
926
  }
913
927
  else {
914
- // Read claude_md_path from config, default to ./CLAUDE.md
915
- let configClaudeMdPath = './CLAUDE.md';
928
+ // Read claude_md_path from config; #1098 default is ./.claude/CLAUDE.md
929
+ // (kept consistent with cmdGenerateClaudeMd so the profile section and the
930
+ // managed sections land in the same file on a config-less project).
931
+ let configClaudeMdPath = './.claude/CLAUDE.md';
916
932
  try {
917
933
  const config = loadConfig(cwd);
918
934
  if (config['claude_md_path'])
@@ -986,7 +1002,12 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
986
1002
  }
987
1003
  }
988
1004
  let assemblyConfig = {};
989
- let configClaudeMdPath = './CLAUDE.md';
1005
+ // #1098: default the Claude-family instruction file to the project-scoped
1006
+ // `.claude/CLAUDE.md` (a valid auto-loaded memory location) rather than a
1007
+ // repo-root `CLAUDE.md`, so generated GSD content does not land next to — or
1008
+ // pollute — a hand-crafted repo-root CLAUDE.md. An explicit `claude_md_path`
1009
+ // config value or `--output` still wins.
1010
+ let configClaudeMdPath = './.claude/CLAUDE.md';
990
1011
  try {
991
1012
  const config = loadConfig(cwd);
992
1013
  if (config['claude_md_path'])
@@ -1037,6 +1058,26 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
1037
1058
  (0, shell_command_projection_cjs_1.platformEnsureDir)(node_path_1.default.dirname(outputPath));
1038
1059
  (0, shell_command_projection_cjs_1.platformWriteSync)(outputPath, existingContent);
1039
1060
  }
1061
+ else if (!/<!-- GSD:[a-z]+-start/.test(existingContent) && !options.force) {
1062
+ // #1098: the target instruction file already exists and contains NO GSD
1063
+ // section markers — it is a hand-crafted CLAUDE.md/AGENTS.md, not a
1064
+ // GSD-managed one. Do NOT clobber it with generated project documentation
1065
+ // (broad project detail belongs in PROJECT.md / REQUIREMENTS.md, which GSD
1066
+ // already owns). Leave the file untouched and report a skip; `--force`
1067
+ // overwrites intentionally.
1068
+ output({
1069
+ claude_md_path: outputPath,
1070
+ action: 'skipped',
1071
+ reason: 'existing instruction file has no GSD markers (hand-crafted); not overwriting. Pass --force to overwrite.',
1072
+ sections_generated: [],
1073
+ sections_fallback: [],
1074
+ sections_skipped: MANAGED_SECTIONS,
1075
+ sections_total: MANAGED_SECTIONS.length,
1076
+ profile_status: 'skipped',
1077
+ message: `Left existing ${node_path_1.default.basename(outputPath)} untouched (no GSD markers found). Broad project context lives in PROJECT.md / REQUIREMENTS.md; pass --force to overwrite this file with GSD-managed sections.`,
1078
+ }, raw, undefined);
1079
+ return;
1080
+ }
1040
1081
  else {
1041
1082
  action = 'updated';
1042
1083
  let fileContent = existingContent;
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+ /**
3
+ * Profile-pipeline command router — CLI dispatcher for gsd-tools profiling commands.
4
+ *
5
+ * ADR-857 phase 6 / ADR-959: profile-pipeline capability command cutover.
6
+ * Extracted from hardcoded case arms in gsd-tools.cjs (lines 1324-1410).
7
+ * Dispatch path: default → dispatchCapabilityCommand →
8
+ * require(profile-pipeline-command-router.cjs) → route<X>.
9
+ *
10
+ * Router signature: { args, cwd, raw, error } — identical to existing routers.
11
+ * Test seams: _pipeline / _output inject mock modules; _core injects mock core.
12
+ *
13
+ * Async note: cmdExtractMessages and cmdProfileSample are async functions.
14
+ * dispatchCapabilityCommand (gsd-tools.cjs:366-371) explicitly errors if a
15
+ * router returns a Promise. Therefore these router functions call the async
16
+ * function WITHOUT await and WITHOUT returning the Promise. The async functions
17
+ * end with output() or process.exit() so the process terminates correctly once
18
+ * the event loop drains. Unhandled rejections are caught by the .catch() wrapper
19
+ * to surface errors via the error() callback.
20
+ */
21
+ const { ERROR_REASON } = require('./io.cjs');
22
+
23
+ // ─── Pipeline phase commands ───────────────────────────────────────────────────
24
+
25
+ function routeScanSessions({ args, cwd, raw, error, _pipeline }) {
26
+ void cwd; void error;
27
+ const p = _pipeline ?? require('./profile-pipeline.cjs');
28
+ const pathIdx = args.indexOf('--path');
29
+ const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
30
+ const verboseFlag = args.includes('--verbose');
31
+ const jsonFlag = args.includes('--json');
32
+ // cmdScanSessions is synchronous — call directly.
33
+ p.cmdScanSessions(sessionsPath, { verbose: verboseFlag, json: jsonFlag }, raw);
34
+ }
35
+
36
+ function routeExtractMessages({ args, cwd, raw, error, _pipeline }) {
37
+ const p = _pipeline ?? require('./profile-pipeline.cjs');
38
+ const sessionIdx = args.indexOf('--session');
39
+ const sessionId = sessionIdx !== -1 ? args[sessionIdx + 1] : null;
40
+ const limitIdx = args.indexOf('--limit');
41
+ const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : null;
42
+ const pathIdx = args.indexOf('--path');
43
+ const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
44
+ // args[0] = 'extract-messages' (family name), args[1] = project positional
45
+ const projectArg = args[1];
46
+ if (!projectArg || projectArg.startsWith('--')) {
47
+ error('Usage: gsd-tools extract-messages <project> [--session <id>] [--limit N] [--path <dir>]\nRun scan-sessions first to see available projects.', ERROR_REASON.USAGE);
48
+ return;
49
+ }
50
+ // cmdExtractMessages is async — do NOT return the Promise.
51
+ // The function ends with output() or process.exit(); the event loop will drain.
52
+ void cwd;
53
+ p.cmdExtractMessages(projectArg, { sessionId, limit }, raw, sessionsPath)
54
+ .catch(e => { error(e && e.message ? e.message : String(e)); });
55
+ }
56
+
57
+ function routeProfileSample({ args, cwd, raw, error, _pipeline }) {
58
+ void cwd; void error;
59
+ const p = _pipeline ?? require('./profile-pipeline.cjs');
60
+ const pathIdx = args.indexOf('--path');
61
+ const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
62
+ const limitIdx = args.indexOf('--limit');
63
+ const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 150;
64
+ const maxPerIdx = args.indexOf('--max-per-project');
65
+ const maxPerProject = maxPerIdx !== -1 ? parseInt(args[maxPerIdx + 1], 10) : null;
66
+ const maxCharsIdx = args.indexOf('--max-chars');
67
+ const maxChars = maxCharsIdx !== -1 ? parseInt(args[maxCharsIdx + 1], 10) : 500;
68
+ // cmdProfileSample is async — do NOT return the Promise.
69
+ p.cmdProfileSample(sessionsPath, { limit, maxPerProject, maxChars }, raw)
70
+ .catch(e => { error(e && e.message ? e.message : String(e)); });
71
+ }
72
+
73
+ // ─── Output phase commands ─────────────────────────────────────────────────────
74
+
75
+ function routeWriteProfile({ args, cwd, raw, error, _output }) {
76
+ const o = _output ?? require('./profile-output.cjs');
77
+ const inputIdx = args.indexOf('--input');
78
+ const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
79
+ if (!inputPath) {
80
+ error('--input <analysis-json-path> is required', ERROR_REASON.USAGE);
81
+ return;
82
+ }
83
+ const outputIdx = args.indexOf('--output');
84
+ const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
85
+ o.cmdWriteProfile(cwd, { input: inputPath, output: outputPath }, raw);
86
+ }
87
+
88
+ function routeProfileQuestionnaire({ args, cwd, raw, error, _output }) {
89
+ void cwd; void error;
90
+ const o = _output ?? require('./profile-output.cjs');
91
+ const answersIdx = args.indexOf('--answers');
92
+ const answers = answersIdx !== -1 ? args[answersIdx + 1] : null;
93
+ o.cmdProfileQuestionnaire({ answers }, raw);
94
+ }
95
+
96
+ function routeGenerateDevPreferences({ args, cwd, raw, error, _output }) {
97
+ void error;
98
+ const o = _output ?? require('./profile-output.cjs');
99
+ const analysisIdx = args.indexOf('--analysis');
100
+ const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
101
+ const outputIdx = args.indexOf('--output');
102
+ const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
103
+ const stackIdx = args.indexOf('--stack');
104
+ const stack = stackIdx !== -1 ? args[stackIdx + 1] : null;
105
+ o.cmdGenerateDevPreferences(cwd, { analysis: analysisPath, output: outputPath, stack }, raw);
106
+ }
107
+
108
+ function routeGenerateClaudeProfile({ args, cwd, raw, error, _output }) {
109
+ void error;
110
+ const o = _output ?? require('./profile-output.cjs');
111
+ const analysisIdx = args.indexOf('--analysis');
112
+ const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
113
+ const outputIdx = args.indexOf('--output');
114
+ const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
115
+ const globalFlag = args.includes('--global');
116
+ o.cmdGenerateClaudeProfile(cwd, { analysis: analysisPath, output: outputPath, global: globalFlag }, raw);
117
+ }
118
+
119
+ function routeGenerateClaudeMd({ args, cwd, raw, error, _output }) {
120
+ void error;
121
+ const o = _output ?? require('./profile-output.cjs');
122
+ const outputIdx = args.indexOf('--output');
123
+ const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
124
+ const autoFlag = args.includes('--auto');
125
+ const forceFlag = args.includes('--force');
126
+ o.cmdGenerateClaudeMd(cwd, { output: outputPath, auto: autoFlag, force: forceFlag }, raw);
127
+ }
128
+
129
+ module.exports = {
130
+ routeScanSessions,
131
+ routeExtractMessages,
132
+ routeProfileSample,
133
+ routeWriteProfile,
134
+ routeProfileQuestionnaire,
135
+ routeGenerateDevPreferences,
136
+ routeGenerateClaudeProfile,
137
+ routeGenerateClaudeMd,
138
+ };
@@ -319,8 +319,17 @@ function stripFencedLines(text) {
319
319
  /**
320
320
  * Returns a filter function that checks whether a phase directory belongs
321
321
  * to the current milestone based on ROADMAP.md phase headings.
322
+ *
323
+ * @param cwd - Project working directory.
324
+ * @param versionOverride - Optional version string to scope the phase filter
325
+ * to a specific milestone (e.g. 'v1.2').
326
+ * @param phaseIdConvention - The resolved `phase_id_convention` config value.
327
+ * When `'milestone-prefixed'`, a deprecation warning is emitted for
328
+ * free-form ROADMAPs that lack versioned milestone headings. When absent or
329
+ * any other value, the warning is suppressed — legacy/default projects must
330
+ * never see spurious warnings.
322
331
  */
323
- function getMilestonePhaseFilter(cwd, versionOverride) {
332
+ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
324
333
  const milestonePhaseNums = new Set();
325
334
  let missingExplicitVersion = false;
326
335
  try {
@@ -331,9 +340,10 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
331
340
  let roadmap = extractCurrentMilestone(roadmapContent, cwd);
332
341
  const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
333
342
  const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
334
- if (!hasVersionedMilestonesGlobal && hasPhaseHeadings) {
343
+ if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
335
344
  console.warn('[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
336
- 'Set phase_id_convention in config.json to suppress this warning.');
345
+ 'The project has phase_id_convention set to "milestone-prefixed" in config.json but the ' +
346
+ 'ROADMAP does not use versioned milestone headings. Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default).');
337
347
  }
338
348
  if (versionOverride) {
339
349
  const escapedVersion = escapeRegex(versionOverride);
@@ -11,6 +11,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
11
11
  };
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
+ const clock_cjs_1 = require("./clock.cjs");
14
15
  // eslint-disable-next-line @typescript-eslint/no-require-imports
15
16
  const core = require("./core.cjs");
16
17
  const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, output, error, findPhaseInternal, phaseTokenMatches } = core;
@@ -386,7 +387,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
386
387
  }
387
388
  const isComplete = summaryCount >= planCount;
388
389
  const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
389
- const today = new Date().toISOString().split('T')[0];
390
+ const today = clock_cjs_1.realClock.today();
390
391
  if (!node_fs_1.default.existsSync(roadmapPath)) {
391
392
  output({ updated: false, reason: 'ROADMAP.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
392
393
  return;
@@ -397,25 +398,37 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
397
398
  const phasePattern = phaseMarkdownRegexSource(phaseNum);
398
399
  // Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
399
400
  const tableRowPattern = new RegExp(`^(\\|\\s*${phasePattern}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
400
- const dateField = isComplete ? ` ${today} ` : ' ';
401
401
  roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
402
402
  const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
403
+ const dateShape = /^\d{4}-\d{2}-\d{2}$/;
403
404
  if (cells.length === 5) {
404
405
  // 5-col: Phase | Milestone | Plans | Status | Completed
405
406
  cells[2] = ` ${summaryCount}/${planCount} `;
406
407
  cells[3] = ` ${status.padEnd(11)}`;
407
- cells[4] = dateField;
408
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
409
+ const existingDate5 = cells[4].trim();
410
+ cells[4] = isComplete
411
+ ? (dateShape.test(existingDate5) ? cells[4] : ` ${today} `)
412
+ : ' ';
408
413
  }
409
414
  else if (cells.length === 4) {
410
415
  // 4-col: Phase | Plans | Status | Completed
411
416
  cells[1] = ` ${summaryCount}/${planCount} `;
412
417
  cells[2] = ` ${status.padEnd(11)}`;
413
- cells[3] = dateField;
418
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
419
+ const existingDate4 = cells[3].trim();
420
+ cells[3] = isComplete
421
+ ? (dateShape.test(existingDate4) ? cells[3] : ` ${today} `)
422
+ : ' ';
414
423
  }
415
424
  return '|' + cells.join('|') + '|';
416
425
  });
417
- // Update plan count in phase detail section
418
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i');
426
+ // Update plan count in phase detail section.
427
+ // Three recognised forms (all tolerated; canonical template uses the first):
428
+ // `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
429
+ // `**Plans:** N plans` — bold "Plans:" (colon inside bold)
430
+ // `Plans: N plans` — plain text header
431
+ const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
419
432
  const planCountText = isComplete
420
433
  ? `${summaryCount}/${planCount} plans complete`
421
434
  : `${summaryCount}/${planCount} plans executed`;
@@ -434,6 +447,80 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
434
447
  const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
435
448
  roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
436
449
  }
450
+ // Compute the active (post-</details>) region offset ONCE. Both the
451
+ // missing-plan DETECTION and the row INSERTION must use the same active
452
+ // region string so that a plan row that exists only in an archived <details>
453
+ // block is not counted as "already present" in the active milestone section.
454
+ // (Finding 1 code-review: detection was previously running against the full
455
+ // roadmapContent, causing archived rows to suppress active-section inserts.)
456
+ const lastDetailsClose = roadmapContent.lastIndexOf('</details>');
457
+ const activeRegion = lastDetailsClose === -1
458
+ ? roadmapContent
459
+ : roadmapContent.slice(lastDetailsClose + '</details>'.length);
460
+ // Compute which plan files are MISSING a checkbox row in the ACTIVE region.
461
+ // This handles three cases:
462
+ // (a) Fresh template — no rows at all: all plans are missing.
463
+ // (b) Partial gap — some rows exist, others don't: only the absent ones.
464
+ // (c) All rows present — nothing to insert (idempotent).
465
+ //
466
+ // Detection is scoped to the active region so a plan that appears in an
467
+ // archived <details> block is still correctly detected as missing from the
468
+ // active milestone section.
469
+ const missingPlans = phaseInfo.plans.filter((planFile) => {
470
+ const planEscaped = escapeRegex(planFile);
471
+ return !new RegExp(`-\\s*\\[[x ]\\]\\s*(?:\\*\\*)?${planEscaped}`, 'i').test(activeRegion);
472
+ });
473
+ if (missingPlans.length > 0) {
474
+ // Insert missing plan checklist rows (#1163). We prefer to anchor to the
475
+ // bare `Plans:` checklist header (canonical template form) and fall back to
476
+ // the bold `**Plans**:`/`**Plans:**` summary line only when no bare header
477
+ // is present. Using two separate patterns avoids the lazy-quantifier trap
478
+ // where a single alternation would stop at the first matching alternative
479
+ // (the bold summary) before reaching the checklist header.
480
+ //
481
+ // Canonical template (gsd-core/templates/roadmap.md) uses BOTH lines:
482
+ // **Plans**: N plans ← summary (colon outside bold)
483
+ // Plans: ← checklist header (PREFERRED insertion anchor)
484
+ // Rows must land after `Plans:`, not between the summary and the header.
485
+ //
486
+ // Pattern A: anchor to bare `Plans:` header (preferred).
487
+ // Pattern B: fallback to bold summary when no bare header exists.
488
+ const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
489
+ const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
490
+ const sortedMissing = [...missingPlans].sort();
491
+ const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
492
+ const inserter = (match) => `${match}\n${newRows}`;
493
+ // Scope insertion to the active (post-</details>) milestone region to
494
+ // prevent duplicate phase headings in archived sections from receiving rows.
495
+ // replaceInCurrentMilestone only accepts a string replacement, so we
496
+ // perform the scoped replace manually here (same strategy as that helper).
497
+ // Note: lastDetailsClose was computed above (shared with detection).
498
+ const scopedReplace = (src, pat) => src.replace(pat, inserter);
499
+ let withRows;
500
+ if (lastDetailsClose === -1) {
501
+ // activeRegion === roadmapContent when there are no </details> blocks.
502
+ const regionA = scopedReplace(activeRegion, insertRowsPatternA);
503
+ withRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
504
+ }
505
+ else {
506
+ const beforeDetails = roadmapContent.slice(0, lastDetailsClose + '</details>'.length);
507
+ const regionA = scopedReplace(activeRegion, insertRowsPatternA);
508
+ const afterWithRows = regionA !== activeRegion ? regionA : scopedReplace(activeRegion, insertRowsPatternB);
509
+ withRows = beforeDetails + afterWithRows;
510
+ }
511
+ if (withRows !== roadmapContent) {
512
+ roadmapContent = withRows;
513
+ // Mark any newly-inserted rows that already have summaries as complete
514
+ for (const summaryFile of phaseInfo.summaries) {
515
+ const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
516
+ if (!planId)
517
+ continue;
518
+ const planEscaped = escapeRegex(planId);
519
+ const planCheckboxPattern = new RegExp(`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`, 'i');
520
+ roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
521
+ }
522
+ }
523
+ }
437
524
  (0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, roadmapContent);
438
525
  });
439
526
  output({
@@ -620,7 +707,10 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
620
707
  }
621
708
  }
622
709
  const newListBlock = annotatedLines.join('\n') + '\n';
623
- const newPhaseSection = phaseSection.replace(plansBlockMatch[0], plansHeader + newListBlock);
710
+ // #1103: when `(?:^|\n)` consumed a leading `\n` (mid-string match), re-emit it
711
+ // so the line preceding the Plans: header is not fused onto it.
712
+ const leadingNewline = plansBlockMatch[0].startsWith('\n') ? '\n' : '';
713
+ const newPhaseSection = phaseSection.replace(plansBlockMatch[0], leadingNewline + plansHeader + newListBlock);
624
714
  const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
625
715
  if (nextContent === content)
626
716
  return;