dev-loops 0.6.0 → 0.7.2

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 (203) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +8 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +2 -2
  5. package/.claude/agents/review.md +4 -3
  6. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  7. package/.claude/commands/loop-continue.md +15 -0
  8. package/.claude/commands/loop-enqueue.md +22 -0
  9. package/.claude/commands/loop-grill.md +17 -0
  10. package/.claude/commands/{info.md → loop-info.md} +2 -2
  11. package/.claude/commands/loop-queue-status.md +24 -0
  12. package/.claude/commands/{start-spike.md → loop-start-spike.md} +2 -2
  13. package/.claude/commands/{start.md → loop-start.md} +1 -1
  14. package/.claude/commands/{status.md → loop-status.md} +1 -1
  15. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  16. package/.claude/hooks/_hook-decisions.mjs +138 -15
  17. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  18. package/.claude/skills/copilot-pr-followup/SKILL.md +60 -34
  19. package/.claude/skills/dev-loop/SKILL.md +16 -12
  20. package/.claude/skills/docs/acceptance-criteria-verification.md +8 -3
  21. package/.claude/skills/docs/anti-patterns.md +5 -3
  22. package/.claude/skills/docs/artifact-authority-contract.md +30 -11
  23. package/.claude/skills/docs/confirmation-rules.md +1 -0
  24. package/.claude/skills/docs/contract-style-guide.md +37 -0
  25. package/.claude/skills/docs/copilot-loop-operations.md +19 -15
  26. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  27. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  28. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  29. package/.claude/skills/docs/issue-intake-procedure.md +26 -19
  30. package/.claude/skills/docs/local-planning-flow.md +1 -1
  31. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  32. package/.claude/skills/docs/merge-preconditions.md +30 -12
  33. package/.claude/skills/docs/plan-file-contract.md +1 -1
  34. package/.claude/skills/docs/pr-lifecycle-contract.md +44 -35
  35. package/.claude/skills/docs/public-dev-loop-contract.md +60 -44
  36. package/.claude/skills/docs/retrospective-checkpoint-contract.md +106 -79
  37. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  38. package/.claude/skills/docs/stop-conditions.md +30 -15
  39. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  40. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  41. package/.claude/skills/docs/validation-policy.md +4 -5
  42. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  43. package/.claude/skills/local-implementation/SKILL.md +62 -145
  44. package/.claude/skills/loop-grill/SKILL.md +163 -0
  45. package/AGENTS.md +1 -0
  46. package/CHANGELOG.md +101 -0
  47. package/README.md +9 -9
  48. package/agents/dev-loop.agent.md +11 -10
  49. package/agents/developer.agent.md +1 -1
  50. package/agents/docs.agent.md +1 -1
  51. package/agents/fixer.agent.md +2 -1
  52. package/agents/quality.agent.md +1 -1
  53. package/agents/refiner.agent.md +3 -3
  54. package/agents/review.agent.md +5 -4
  55. package/cli/index.mjs +35 -42
  56. package/extension/README.md +4 -4
  57. package/extension/checks.ts +1 -5
  58. package/extension/harness-types.ts +1 -0
  59. package/extension/index.ts +6 -0
  60. package/extension/pi-extension-adapter.ts +2 -0
  61. package/extension/presentation.ts +7 -19
  62. package/package.json +12 -12
  63. package/scripts/_core-helpers.mjs +2 -0
  64. package/scripts/claude/headless-dev-loop.mjs +1 -1
  65. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  66. package/scripts/docs/validate-state-machine-conformance.mjs +1092 -0
  67. package/scripts/github/_review-thread-mutations.mjs +5 -2
  68. package/scripts/github/capture-review-threads.mjs +2 -9
  69. package/scripts/github/comment-issue.mjs +2 -9
  70. package/scripts/github/create-label.mjs +133 -0
  71. package/scripts/github/create-pr.mjs +119 -10
  72. package/scripts/github/detect-checkpoint-evidence.mjs +259 -18
  73. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  74. package/scripts/github/edit-pr.mjs +259 -0
  75. package/scripts/github/fetch-ci-logs.mjs +2 -9
  76. package/scripts/github/list-issues.mjs +2 -9
  77. package/scripts/github/list-review-threads.mjs +277 -0
  78. package/scripts/github/manage-sub-issues.mjs +46 -10
  79. package/scripts/github/offer-human-handoff.mjs +8 -5
  80. package/scripts/github/post-gate-findings.mjs +20 -5
  81. package/scripts/github/probe-ci-status.mjs +8 -2
  82. package/scripts/github/probe-copilot-review.mjs +21 -14
  83. package/scripts/github/ready-for-review.mjs +26 -8
  84. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  85. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  86. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  87. package/scripts/github/request-copilot-review.mjs +98 -26
  88. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  89. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  90. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  91. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  92. package/scripts/github/upsert-checkpoint-verdict.mjs +105 -15
  93. package/scripts/github/verify-briefing-prefixes.mjs +191 -0
  94. package/scripts/github/verify-fresh-review-context.mjs +226 -32
  95. package/scripts/github/view-pr.mjs +150 -0
  96. package/scripts/github/wait-pr-checks.mjs +171 -0
  97. package/scripts/github/write-gate-context.mjs +458 -65
  98. package/scripts/github/write-gate-findings-log.mjs +128 -2
  99. package/scripts/lib/jq-output.mjs +18 -0
  100. package/scripts/loop/_post-convergence-change.mjs +211 -0
  101. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  102. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  103. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  104. package/scripts/loop/check-retro-tooling.mjs +14 -3
  105. package/scripts/loop/checkpoint-contract.mjs +7 -4
  106. package/scripts/loop/cleanup-worktree.mjs +12 -3
  107. package/scripts/loop/conductor-monitor.mjs +12 -18
  108. package/scripts/loop/copilot-pr-handoff.mjs +162 -14
  109. package/scripts/loop/debt-remediate.mjs +24 -12
  110. package/scripts/loop/detect-change-scope.mjs +38 -9
  111. package/scripts/loop/detect-copilot-loop-state.mjs +34 -8
  112. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  113. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  114. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  115. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  116. package/scripts/loop/detect-pr-gate-coordination-state.mjs +184 -69
  117. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  118. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  119. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  120. package/scripts/loop/ensure-worktree.mjs +8 -3
  121. package/scripts/loop/info.mjs +12 -4
  122. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  123. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  124. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  125. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  126. package/scripts/loop/inspect-run.mjs +8 -2
  127. package/scripts/loop/outer-loop.mjs +8 -4
  128. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  129. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  130. package/scripts/loop/pre-flight-gate.mjs +9 -9
  131. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  132. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  133. package/scripts/loop/provision-worktree.mjs +74 -3
  134. package/scripts/loop/resolve-dev-loop-startup.mjs +216 -10
  135. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  136. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  137. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  138. package/scripts/loop/run-queue.mjs +18 -9
  139. package/scripts/loop/run-refinement-audit.mjs +8 -9
  140. package/scripts/loop/run-watch-cycle.mjs +2 -9
  141. package/scripts/loop/sanctioned-commands.mjs +106 -0
  142. package/scripts/loop/steer-loop.mjs +29 -16
  143. package/scripts/loop/validate-pr-body-spec.mjs +223 -0
  144. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  145. package/scripts/pages/build-site.mjs +59 -8
  146. package/scripts/pages/build-state-atlas.mjs +443 -0
  147. package/scripts/projects/_resolve-project.mjs +1 -0
  148. package/scripts/projects/add-queue-item.mjs +60 -54
  149. package/scripts/projects/archive-done-items.mjs +49 -84
  150. package/scripts/projects/ensure-queue-board.mjs +10 -36
  151. package/scripts/projects/list-queue-items.mjs +59 -382
  152. package/scripts/projects/move-queue-item.mjs +21 -449
  153. package/scripts/projects/reconcile-queue.mjs +253 -0
  154. package/scripts/projects/reorder-queue-item.mjs +43 -68
  155. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  156. package/scripts/projects/sync-item-status.mjs +15 -10
  157. package/scripts/refine/_refine-helpers.mjs +21 -5
  158. package/scripts/refine/exit-spike.mjs +18 -8
  159. package/scripts/refine/promote-plan.mjs +22 -16
  160. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  161. package/scripts/refine/refine-plan-file.mjs +13 -4
  162. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  163. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  164. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  165. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  166. package/scripts/refine/validate-plan-file.mjs +1 -1
  167. package/scripts/refine/validate-spike-file.mjs +1 -1
  168. package/scripts/refine/verify.mjs +11 -6
  169. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  170. package/scripts/release/extract-changelog-section.mjs +16 -2
  171. package/skills/copilot-pr-followup/SKILL.md +60 -34
  172. package/skills/dev-loop/SKILL.md +11 -7
  173. package/skills/docs/acceptance-criteria-verification.md +8 -3
  174. package/skills/docs/anti-patterns.md +5 -3
  175. package/skills/docs/artifact-authority-contract.md +30 -11
  176. package/skills/docs/confirmation-rules.md +1 -0
  177. package/skills/docs/contract-style-guide.md +37 -0
  178. package/skills/docs/copilot-loop-operations.md +19 -15
  179. package/skills/docs/cross-harness-regression-contract.md +60 -0
  180. package/skills/docs/debt-remediation-contract.md +1 -1
  181. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  182. package/skills/docs/issue-intake-procedure.md +26 -19
  183. package/skills/docs/local-planning-flow.md +1 -1
  184. package/skills/docs/local-planning-worked-example.md +1 -1
  185. package/skills/docs/merge-preconditions.md +30 -12
  186. package/skills/docs/plan-file-contract.md +1 -1
  187. package/skills/docs/pr-lifecycle-contract.md +44 -35
  188. package/skills/docs/public-dev-loop-contract.md +60 -44
  189. package/skills/docs/required-rules.json +149 -0
  190. package/skills/docs/retrospective-checkpoint-contract.md +106 -79
  191. package/skills/docs/spike-mode-contract.md +14 -7
  192. package/skills/docs/stop-conditions.md +30 -15
  193. package/skills/docs/tracker-first-loop-state.md +13 -8
  194. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  195. package/skills/docs/validation-policy.md +4 -5
  196. package/skills/docs/workflow-handoff-contract.md +39 -0
  197. package/skills/local-implementation/SKILL.md +62 -145
  198. package/skills/loop-grill/SKILL.md +165 -0
  199. package/.claude/commands/continue.md +0 -15
  200. package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
  201. package/scripts/loop/conductor.mjs +0 -233
  202. package/scripts/loop/detect-stale-runner.mjs +0 -265
  203. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -26,6 +26,7 @@ import type {
26
26
 
27
27
  export function toHarnessContext(ctx: Partial<ExtensionContext> | undefined): HarnessContext {
28
28
  const ui = ctx?.ui;
29
+ const sender = (ctx as { sendUserMessage?: (message: string, options?: Record<string, unknown>) => unknown } | undefined)?.sendUserMessage;
29
30
  return {
30
31
  cwd: (ctx?.cwd as string) ?? process.cwd(),
31
32
  hasUI: Boolean(ctx?.hasUI),
@@ -34,6 +35,7 @@ export function toHarnessContext(ctx: Partial<ExtensionContext> | undefined): Ha
34
35
  setWidget: (key, lines, options) => ui?.setWidget?.(key, lines as never, options as never),
35
36
  setStatus: (key, text) => ui?.setStatus?.(key, text as never),
36
37
  },
38
+ sendUserMessage: sender ? (message, options) => sender.call(ctx, message, options) : undefined,
37
39
  };
38
40
  }
39
41
 
@@ -19,7 +19,7 @@ function checkMap(checks: DevLoopCheck[]): Map<DevLoopCheckId, DevLoopCheck> {
19
19
  return new Map(checks.map((check) => [check.id, check]));
20
20
  }
21
21
 
22
- export function orderedSetupSteps(checks: DevLoopCheck[]): string[] {
22
+ function orderedSetupSteps(checks: DevLoopCheck[]): string[] {
23
23
  const byId = checkMap(checks);
24
24
  const uniqueSteps = [...new Set(DEV_LOOP_CHECK_IDS.filter((id) => byId.get(id)?.ok === false).map((id) => SETUP_GUIDANCE[id]))];
25
25
  const steps = uniqueSteps.map((step, index) => `${index + 1}. ${step}`);
@@ -47,24 +47,12 @@ export function buildEntrypointLines(action: string, intent: string): string[] {
47
47
  export function buildHelpLines(): string[] {
48
48
  return [
49
49
  'dev-loops help',
50
- 'Workflow entry:',
51
- '- /skill:dev-loop single public entrypoint; routing handles the rest',
52
- 'Direct entrypoints:',
53
- '- /dev-loops start <issue>',
54
- '- /dev-loops auto <issue>',
55
- '- /dev-loops continue [issue|pr]',
56
- '- /dev-loops start-spike <question>',
57
- '- /dev-loops info <issue|pr>',
58
- 'Commands:',
59
- '- /dev-loops status',
60
- '- /dev-loops doctor',
61
- '- /dev-loops hide',
62
- '- /dev-loops inspect open [--repo <owner/name>]',
63
- '- /dev-loops inspect resume [--repo <owner/name>]',
64
- '- /dev-loops inspect status [--repo <owner/name>]',
65
- '- /dev-loops inspect stop [--repo <owner/name>]',
66
- '- /dev-loops inspect restart [--repo <owner/name>]',
67
- 'Use `pi install git:github.com/mfittko/dev-loops` to install skills and agents; packaged agents sync into `~/.agents/` on session start.',
50
+ '/skill:dev-loop <intent> starts the public loop',
51
+ '/dev-loops start <issue> | /dev-loops auto <issue>',
52
+ '/dev-loops continue [issue|pr] | /dev-loops info <issue|pr>',
53
+ '/dev-loops start-spike <question>',
54
+ '/dev-loops inspect open|resume|status|stop|restart',
55
+ '/dev-loops status | /dev-loops doctor | /dev-loops hide',
68
56
  ];
69
57
  }
70
58
 
package/package.json CHANGED
@@ -22,19 +22,20 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "verify": "npm test && npm run test:dev-loop",
25
- "test": "npm run test:assets && npm run test:extension && npm run test:scripts && npm run test:core && npm run test:docs",
25
+ "test": "npm run test:assets && npm run test:extension && npm run test:scripts && npm run test:core && npm run test:docs && npm run test:pack",
26
26
  "test:assets": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/dev-loop-init-phase-smoke.test.mjs test/contracts/*.test.mjs test/workflow-handoff-contract.test.mjs",
27
- "test:extension": "node --import tsx --test --test-reporter ./test/failure-summary-reporter.mjs test/extension-checks.test.mjs test/extension-post-merge-update.test.mjs test/extension-command-contract.test.mjs test/extension-package-contract.test.mjs test/extension-pi-adapter.test.mjs test/extension-claude-adapter.test.mjs test/dev-loops-core.test.mjs test/dev-loops-cli.test.mjs",
28
- "test:scripts": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs test/pages/*.test.mjs",
27
+ "test:extension": "node --import tsx --test --test-reporter ./test/failure-summary-reporter.mjs test/extension-checks.test.mjs test/extension-post-merge-update.test.mjs test/extension-command-contract.test.mjs test/extension-package-contract.test.mjs test/extension-pi-adapter.test.mjs test/dev-loops-core.test.mjs test/dev-loops-cli.test.mjs",
28
+ "test:scripts": "GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0=core.fsync GIT_CONFIG_VALUE_0=none GIT_CONFIG_KEY_1=core.fsyncObjectFiles GIT_CONFIG_VALUE_1=false node --test --test-reporter ./test/failure-summary-reporter.mjs test/github/*.test.mjs test/loop/*.test.mjs test/docs/*.test.mjs test/projects/*.test.mjs test/pages/*.test.mjs",
29
29
  "test:core": "node --test --test-reporter ./test/failure-summary-reporter.mjs packages/core/test/*.test.mjs",
30
30
  "test:dev-loop": "node --test --test-reporter ./test/failure-summary-reporter.mjs skills/dev-loop/scripts/dev-mode-context.test.mjs skills/dev-loop/scripts/render-template.test.mjs skills/dev-loop/scripts/post-gate-verdict-fallback.test.mjs",
31
- "test:playwright:viewer": "playwright test -c playwright.inspect-run-viewer.config.mjs",
32
- "test:playwright:deep-dive": "playwright test -c playwright.dev-loops-deep-dive.config.mjs",
33
- "test:playwright:intro-deck": "playwright test -c playwright.intro-deck.config.mjs",
34
- "test:playwright:intro-article": "playwright test -c playwright.intro-article.config.mjs",
35
- "test:playwright:deep-dive-article": "playwright test -c playwright.deep-dive-article.config.mjs",
31
+ "test:playwright:viewer": "PW_UI_SLICE=inspect-run-viewer playwright test --project=inspect-run-viewer",
32
+ "test:playwright:deep-dive": "PW_UI_SLICE=deep-dive-deck playwright test --project=deep-dive-deck",
33
+ "test:playwright:intro-deck": "PW_UI_SLICE=intro-deck playwright test --project=intro-deck",
34
+ "test:playwright:intro-article": "PW_UI_SLICE=intro-article playwright test --project=intro-article",
35
+ "test:playwright:deep-dive-article": "PW_UI_SLICE=deep-dive-article playwright test --project=deep-dive-article",
36
36
  "smoke:headless": "node scripts/claude/headless-info-smoke.mjs",
37
- "test:docs": "node scripts/docs/validate-links.mjs && node scripts/docs/validate-no-duplicate-rules.mjs",
37
+ "test:docs": "node scripts/docs/validate-links.mjs && node scripts/docs/validate-rule-ownership.mjs",
38
+ "test:pack": "node --test --test-reporter ./test/failure-summary-reporter.mjs test/packaged-install-smoke.test.mjs",
38
39
  "repo-wiki": "node scripts/repo-wiki.mjs",
39
40
  "repo-wiki:bootstrap": "node scripts/repo-wiki.mjs scan --repo . && node scripts/repo-wiki.mjs plan --repo . && node scripts/repo-wiki.mjs compile --repo .",
40
41
  "repo-wiki:scan": "node scripts/repo-wiki.mjs scan --repo .",
@@ -71,8 +72,7 @@
71
72
  ]
72
73
  },
73
74
  "dependencies": {
74
- "mermaid": "11.15.0",
75
- "@dev-loops/core": "^0.6.0"
75
+ "@dev-loops/core": "^0.7.2"
76
76
  },
77
77
  "repository": {
78
78
  "type": "git",
@@ -82,7 +82,7 @@
82
82
  "url": "https://github.com/mfittko/dev-loops/issues"
83
83
  },
84
84
  "homepage": "https://github.com/mfittko/dev-loops#readme",
85
- "version": "0.6.0",
85
+ "version": "0.7.2",
86
86
  "files": [
87
87
  "cli/",
88
88
  "lib/",
@@ -14,12 +14,14 @@ export {
14
14
  } from "@dev-loops/core/loop/phase-files";
15
15
 
16
16
  export {
17
+ containsBareCopilotSummon,
17
18
  extractReviewCommitSha,
18
19
  isCopilotLogin,
19
20
  normalizeTimestamp,
20
21
  parseGateReviewCommentBody,
21
22
  parseGateReviewCommentMarkerBody,
22
23
  resolveDraftGateRoundResetMs,
24
+ sanitizeCopilotSummonTokens,
23
25
  summarizeCopilotReviews,
24
26
  summarizeGateReviewCommentMarkers,
25
27
  summarizeGateReviewComments,
@@ -101,7 +101,7 @@ function main(argv) {
101
101
  const repoRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
102
102
 
103
103
  // --dry-run is side-effect-free: mint a run id in-memory without persisting the state file.
104
- const { runId } = ensureRunId({ env: process.env, root: opts.dryRun ? undefined : repoRoot });
104
+ const { runId } = ensureRunId({ env: opts.dryRun ? {} : process.env, root: opts.dryRun ? undefined : repoRoot });
105
105
  const prompt = opts.prompt ?? buildDevLoopPrompt({ issue: opts.issue, pr: opts.pr });
106
106
  const { command, args, env } = buildHeadlessClaudeInvocation({
107
107
  prompt,
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * validate-rule-ownership.mjs — L0/L1 contract rule ownership validator.
4
+ *
5
+ * This scan is deliberately lexical. It catches stable rule-ID ownership,
6
+ * required-rule deletion, machine-checkable references, defined-term drift,
7
+ * RFC-2119 modality conflicts, near-duplicate rule bodies, and duplicated
8
+ * free-text imperative sentences across the corpus. All findings are gating
9
+ * (exit 1). Behavioral contradictions belong to the L2/L3 harness; semantic
10
+ * contradictions belong to the gate contradiction lens.
11
+ *
12
+ * Also gates corpus→manifest completeness: every defined rule must be listed
13
+ * in required-rules.json's requiredRules or its optOutRules (unregistered_rule),
14
+ * the optOutRules manifest itself must be internally consistent (no ID in both
15
+ * lists — conflicting_manifest_entry; no opt-out for an undefined rule —
16
+ * dead_opt_out_entry), and the duplicate-sentence allowlist must not contain
17
+ * stale entries (dead_allowlist_entry).
18
+ *
19
+ * Subsumes former scripts/docs/validate-no-duplicate-rules.mjs (retired):
20
+ * the duplicate-imperative-sentence scan below ports its unique check,
21
+ * widened from skills/ only to every SOURCE_ROOTS directory.
22
+ */
23
+
24
+ import { readFile, readdir } from "node:fs/promises";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ import { isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+ const REPO_ROOT = path.resolve(__dirname, "..", "..");
32
+
33
+ const SOURCE_ROOTS = ["skills", "agents", "commands", "docs"];
34
+ const MARKER_RE = /<!--\s*rule:\s*([A-Z][A-Z0-9-]*)\s*-->/g;
35
+ const REF_RE = /<!--\s*rule-ref:\s*([A-Z][A-Z0-9-]*)\s*-->|\[([A-Z0-9]+(?:-[A-Z0-9]+){2,})\]\([^)]*\)/g;
36
+ const TERM_RE = /<!--\s*term:\s*(state|reason|gate):([a-zA-Z0-9_.:-]+)\s*-->/g;
37
+ const CODE_TOKEN_RE = /`([a-z][a-z0-9_:-]+)`/g;
38
+ const MODAL_RE = /\b(MUST NOT|SHALL NOT|SHOULD NOT|MAY NOT|MUST|SHALL|SHOULD|MAY)\b/g;
39
+
40
+ // Duplicate-imperative-sentence scan (ported from validate-no-duplicate-rules.mjs).
41
+ const IMPERATIVE_PATTERNS = [/\bmust\b/i, /\bnever\b/i, /\bdo not\b/i, /\brequire[sd]?\b/i];
42
+ const MIN_SENTENCE_LENGTH = 20;
43
+
44
+ // Contract docs that mirror each other's content by design.
45
+ const CANONICAL_MIRROR_DOCS = new Set(["skills/docs/copilot-loop-operations.md", "skills/docs/public-dev-loop-contract.md"]);
46
+
47
+ // Sentences deliberately duplicated across files (mirrored procedure text, or
48
+ // command trigger-phrasing that STYLE/(c) guardrails require to stay verbatim
49
+ // per file). Cross-file duplication of these is not a corpus authoring bug.
50
+ const KNOWN_INTENTIONAL_DUPLICATE_SENTENCES = new Set([
51
+ // agents/dev-loop.agent.md mirrors this CLI-fallback safety line from skills/dev-loop/SKILL.md;
52
+ // each doc's surrounding candidate-resolution list differs per install layout.
53
+ "NEVER fall back to or any unbounded filesystem walk to locate the CLI — it stalls and trips the needs-attention timeout.",
54
+ // commands/loop-auto.command.md and commands/loop-start.command.md — command trigger
55
+ // phrasing is load-bearing verbatim per-command guardrail (c); not a restatement bug.
56
+ "Do not pick an internal strategy name yourself.",
57
+ // docs/slides-story-review-loop.md and docs/ui-designer-review-loop.md are sibling
58
+ // non-normative review-loop docs (epic non-goal) that share boilerplate by design.
59
+ "The loop requires all of the following inputs before it may run:",
60
+ "If any required part of this bundle is missing, incomplete, or ambiguous, the loop fails closed instead of guessing.",
61
+ "- required acceptance criteria are missing",
62
+ ]);
63
+
64
+ const USAGE = `Usage: validate-rule-ownership.mjs [--help]
65
+
66
+ Validate rule markers, required IDs, rule references, term definitions,
67
+ near-duplicate/modality-conflict findings, and duplicated imperative
68
+ sentences. Also validates corpus→manifest completeness (every defined rule
69
+ must be in requiredRules or explicitly opted out via optOutRules in
70
+ required-rules.json), optOutRules manifest hygiene (no rule listed as both
71
+ required and opted-out; no opt-out for an undefined rule), and that the
72
+ duplicate-sentence allowlist has no stale (dead) entries. All findings are
73
+ gating (exit 1).
74
+
75
+ Options:
76
+ --help, -h Show this help`.trim();
77
+
78
+ export function isImperativeSentence(sentence) {
79
+ return IMPERATIVE_PATTERNS.some((pattern) => pattern.test(sentence));
80
+ }
81
+
82
+ export function normalizeSentence(text) {
83
+ return text.replace(/\s+/g, " ").trim();
84
+ }
85
+
86
+ export function extractImperativeSentences(content) {
87
+ const lines = content.split(/\r?\n/);
88
+ const sentences = [];
89
+ let inFencedBlock = false;
90
+ let fencedDelimiter = "";
91
+
92
+ for (let i = 0; i < lines.length; i++) {
93
+ let line = lines[i];
94
+ const rawTrimmed = line.trim();
95
+
96
+ if (/^\s*#/.test(line) || /^\s*>/.test(line)) continue;
97
+
98
+ const fenceMatch = rawTrimmed.match(/^(```|~~~)/);
99
+ if (fenceMatch) {
100
+ if (!inFencedBlock) {
101
+ inFencedBlock = true;
102
+ fencedDelimiter = fenceMatch[1];
103
+ continue;
104
+ } else if (rawTrimmed.startsWith(fencedDelimiter)) {
105
+ inFencedBlock = false;
106
+ fencedDelimiter = "";
107
+ continue;
108
+ }
109
+ }
110
+ if (inFencedBlock) continue;
111
+
112
+ line = line.replace(/`[^`]*`/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
113
+
114
+ const parts = line.split(/(?<=[.!?])\s+(?=[A-Z])/);
115
+ for (const part of parts) {
116
+ const normalized = normalizeSentence(part);
117
+ if (normalized.length >= MIN_SENTENCE_LENGTH && isImperativeSentence(normalized)) {
118
+ sentences.push({ text: normalized, line: i + 1 });
119
+ }
120
+ }
121
+ }
122
+ return sentences;
123
+ }
124
+
125
+ export function detectDuplicateImperativeSentences(fileContents) {
126
+ const bySentence = new Map();
127
+ for (const { file, content } of fileContents) {
128
+ for (const { text, line } of extractImperativeSentences(content)) {
129
+ if (!bySentence.has(text)) bySentence.set(text, []);
130
+ bySentence.get(text).push({ file, line });
131
+ }
132
+ }
133
+ const findings = [];
134
+ for (const [text, occurrences] of bySentence) {
135
+ if (KNOWN_INTENTIONAL_DUPLICATE_SENTENCES.has(text)) continue;
136
+ const files = new Set(occurrences.map((o) => o.file));
137
+ if (files.size <= 1) continue;
138
+ if ([...files].every((f) => CANONICAL_MIRROR_DOCS.has(f))) continue;
139
+ findings.push({ kind: "duplicate_imperative_sentence", text, occurrences });
140
+ }
141
+ return findings;
142
+ }
143
+
144
+ function toPosix(p) {
145
+ return p.replace(/\\/g, "/");
146
+ }
147
+
148
+ async function* walk(dir) {
149
+ let entries;
150
+ try {
151
+ entries = await readdir(dir, { withFileTypes: true });
152
+ } catch (err) {
153
+ if (err?.code === "ENOENT") return;
154
+ throw err;
155
+ }
156
+ for (const entry of entries) {
157
+ if (entry.name === "node_modules" || entry.name === "tmp" || entry.name === "site" || entry.name === ".claude") {
158
+ continue;
159
+ }
160
+ const full = path.join(dir, entry.name);
161
+ if (entry.isSymbolicLink()) continue;
162
+ if (entry.isDirectory()) yield* walk(full);
163
+ else if (entry.isFile() && entry.name.endsWith(".md")) yield full;
164
+ }
165
+ }
166
+
167
+ export async function collectContractMarkdownFiles(repoRoot = REPO_ROOT) {
168
+ const files = [];
169
+ for (const root of SOURCE_ROOTS) {
170
+ for await (const file of walk(path.join(repoRoot, root))) {
171
+ files.push(file);
172
+ }
173
+ }
174
+ return files.sort();
175
+ }
176
+
177
+ function lineFor(content, index) {
178
+ return content.slice(0, index).split(/\r?\n/).length;
179
+ }
180
+
181
+ function stripFences(content) {
182
+ const lines = content.split(/\r?\n/);
183
+ let inFence = false;
184
+ let fence = "";
185
+ return lines.map((line) => {
186
+ const m = line.trim().match(/^(```|~~~)/);
187
+ if (m) {
188
+ if (!inFence) {
189
+ inFence = true;
190
+ fence = m[1];
191
+ } else if (line.trim().startsWith(fence)) {
192
+ inFence = false;
193
+ fence = "";
194
+ }
195
+ return "";
196
+ }
197
+ return inFence ? "" : line;
198
+ }).join("\n");
199
+ }
200
+
201
+ function extractRuleBody(content, markerIndex) {
202
+ const after = content.slice(markerIndex).replace(MARKER_RE, "").trimStart();
203
+ const firstLine = after.split(/\r?\n/, 1)[0].trim();
204
+ return firstLine.replace(/^`[A-Z][A-Z0-9-]*`\s*\|\s*/, "").replace(/\s+/g, " ").trim();
205
+ }
206
+
207
+ export function extractRuleDefinitions(content, file) {
208
+ const defs = [];
209
+ const scan = stripFences(content);
210
+ for (const match of scan.matchAll(MARKER_RE)) {
211
+ defs.push({ id: match[1], file, line: lineFor(scan, match.index), body: extractRuleBody(scan, match.index) });
212
+ }
213
+ return defs;
214
+ }
215
+
216
+ export function extractRuleReferences(content, file) {
217
+ const refs = [];
218
+ const scan = stripFences(content);
219
+ for (const match of scan.matchAll(REF_RE)) {
220
+ const id = match[1] || match[2];
221
+ refs.push({ id, file, line: lineFor(scan, match.index) });
222
+ }
223
+ return refs;
224
+ }
225
+
226
+ export function extractTermDefinitions(content, file) {
227
+ const terms = [];
228
+ const scan = stripFences(content);
229
+ for (const match of scan.matchAll(TERM_RE)) {
230
+ terms.push({ kind: match[1], value: match[2], key: `${match[1]}:${match[2]}`, file, line: lineFor(scan, match.index) });
231
+ }
232
+ return terms;
233
+ }
234
+
235
+ export function extractTermUses(content, file) {
236
+ const terms = extractTermDefinitions(content, file);
237
+ if (terms.length === 0 && !/<!--\s*rule:\s*[A-Z][A-Z0-9-]*\s*-->/u.test(stripFences(content))) return [];
238
+ const defs = new Set(terms.map((t) => t.value));
239
+ const uses = [];
240
+ const scan = stripFences(content);
241
+ for (const match of scan.matchAll(CODE_TOKEN_RE)) {
242
+ const token = match[1];
243
+ if (defs.has(token) || /^(npm|node|gh|dev-loop|dev-loops|final_approval|wait_watch|issue_intake|copilot_pr_followup|autonomy|humanMergeOnly)$/.test(token)) {
244
+ continue;
245
+ }
246
+ if (/^(blocked|done|approval_ready|merge_ready|waiting|waiting_for_[a-z0-9_]+|needs_reconcile|[a-z]+_[a-z0-9_]*_gate)$/.test(token)) {
247
+ uses.push({ token, file, line: lineFor(scan, match.index) });
248
+ }
249
+ }
250
+ return uses;
251
+ }
252
+
253
+ export function detectNearDuplicates(definitions) {
254
+ const seen = new Map();
255
+ const findings = [];
256
+ for (const def of definitions) {
257
+ const normalized = def.body.toLowerCase().replace(/`[^`]+`/g, "").replace(/\b(must|shall|should|may|not)\b/g, "").replace(/[^a-z0-9]+/g, " ").trim();
258
+ if (!normalized || normalized.length < 24) continue;
259
+ const prior = seen.get(normalized);
260
+ if (prior && prior.id !== def.id) findings.push({ kind: "near_duplicate", a: prior, b: def });
261
+ else seen.set(normalized, def);
262
+ }
263
+ return findings;
264
+ }
265
+
266
+ export function detectModalityConflicts(definitions) {
267
+ // Order-insensitive: group every definition per normalized subject first,
268
+ // then compare all pairs, so file-walk order can never mask a conflict
269
+ // (this scan gates the build).
270
+ const bySubject = new Map();
271
+ for (const def of definitions) {
272
+ const modalities = [...def.body.matchAll(MODAL_RE)].map((m) => m[1]);
273
+ if (modalities.length === 0) continue;
274
+ const subject = def.body
275
+ .replace(MODAL_RE, "")
276
+ .replace(/`[^`]+`/g, "")
277
+ .replace(/[^a-zA-Z0-9]+/g, " ")
278
+ .toLowerCase()
279
+ .trim();
280
+ if (!subject) continue;
281
+ if (!bySubject.has(subject)) bySubject.set(subject, []);
282
+ bySubject.get(subject).push({ def, modalities });
283
+ }
284
+ const findings = [];
285
+ for (const entries of bySubject.values()) {
286
+ for (let i = 0; i < entries.length; i += 1) {
287
+ for (let j = i + 1; j < entries.length; j += 1) {
288
+ const a = entries[i];
289
+ const b = entries[j];
290
+ const aNegative = a.modalities.some((m) => m.endsWith("NOT"));
291
+ const bNegative = b.modalities.some((m) => m.endsWith("NOT"));
292
+ // MUST and SHALL are equivalent strong forms (RFC 2119); either
293
+ // downgrading to SHOULD/MAY (or the negative equivalents) flags.
294
+ const strongPositive = (mods) => mods.some((m) => m === "MUST" || m === "SHALL");
295
+ const strongNegative = (mods) => mods.some((m) => m === "MUST NOT" || m === "SHALL NOT");
296
+ const weakPositive = (mods) => mods.some((m) => m === "SHOULD" || m === "MAY");
297
+ const weakNegative = (mods) => mods.some((m) => m === "SHOULD NOT" || m === "MAY NOT");
298
+ const weaker = (strongPositive(a.modalities) && weakPositive(b.modalities))
299
+ || (strongPositive(b.modalities) && weakPositive(a.modalities))
300
+ || (strongNegative(a.modalities) && weakNegative(b.modalities))
301
+ || (strongNegative(b.modalities) && weakNegative(a.modalities));
302
+ if (aNegative !== bNegative || weaker) findings.push({ kind: "modality_conflict", a: a.def, b: b.def });
303
+ }
304
+ }
305
+ }
306
+ return findings;
307
+ }
308
+
309
+ async function readRequiredRules(repoRoot) {
310
+ const rulesPath = path.join(repoRoot, "skills", "docs", "required-rules.json");
311
+ const text = await readFile(rulesPath, "utf8");
312
+ let parsed;
313
+ try {
314
+ parsed = parseJsonText(text);
315
+ } catch (error) {
316
+ throw new Error(`Malformed JSON in ${rulesPath}`, { cause: error });
317
+ }
318
+ return {
319
+ requiredRules: Array.isArray(parsed.requiredRules) ? parsed.requiredRules : [],
320
+ // Explicit opt-out for a defined rule that is intentionally NOT deletion-protected. Empty by default.
321
+ optOutRules: Array.isArray(parsed.optOutRules) ? parsed.optOutRules : [],
322
+ };
323
+ }
324
+
325
+ // A KNOWN_INTENTIONAL_DUPLICATE_SENTENCES entry is "dead" when it no longer suppresses a real
326
+ // cross-file duplicate: the sentence would not be flagged even without the allowlist (absent,
327
+ // single-file, or only across canonical-mirror docs). A dead entry silently pre-authorizes
328
+ // reintroducing that exact duplicate, so it gates.
329
+ export function detectDeadAllowlistEntries(fileContents, allowlist = KNOWN_INTENTIONAL_DUPLICATE_SENTENCES) {
330
+ const filesBySentence = new Map();
331
+ for (const { file, content } of fileContents) {
332
+ for (const { text } of extractImperativeSentences(content)) {
333
+ if (!filesBySentence.has(text)) filesBySentence.set(text, new Set());
334
+ filesBySentence.get(text).add(file);
335
+ }
336
+ }
337
+ const dead = [];
338
+ for (const text of allowlist) {
339
+ const files = filesBySentence.get(text);
340
+ const wouldFlag = files && files.size > 1 && ![...files].every((f) => CANONICAL_MIRROR_DOCS.has(f));
341
+ if (!wouldFlag) dead.push(text);
342
+ }
343
+ return dead;
344
+ }
345
+
346
+ export async function validateRuleOwnership(repoRoot = REPO_ROOT) {
347
+ const files = await collectContractMarkdownFiles(repoRoot);
348
+ const definitions = [];
349
+ const references = [];
350
+ const termDefs = [];
351
+ const termUses = [];
352
+ const fileContents = [];
353
+
354
+ for (const filePath of files) {
355
+ const rel = toPosix(path.relative(repoRoot, filePath));
356
+ const content = await readFile(filePath, "utf8");
357
+ fileContents.push({ file: rel, content });
358
+ definitions.push(...extractRuleDefinitions(content, rel));
359
+ references.push(...extractRuleReferences(content, rel));
360
+ termDefs.push(...extractTermDefinitions(content, rel));
361
+ termUses.push(...extractTermUses(content, rel));
362
+ }
363
+
364
+ const errors = [];
365
+ const byId = new Map();
366
+ for (const def of definitions) {
367
+ if (!byId.has(def.id)) byId.set(def.id, []);
368
+ byId.get(def.id).push(def);
369
+ }
370
+ for (const [id, defs] of byId) {
371
+ if (defs.length !== 1) errors.push({ kind: "duplicate_rule_definition", id, locations: defs.map(({ file, line }) => `${file}:${line}`) });
372
+ }
373
+
374
+ const { requiredRules, optOutRules } = await readRequiredRules(repoRoot);
375
+ const requiredSet = new Set(requiredRules);
376
+ const optOutSet = new Set(optOutRules);
377
+ for (const id of requiredRules) {
378
+ if (!byId.has(id)) errors.push({ kind: "required_rule_missing", id });
379
+ }
380
+ // corpus→manifest completeness: every defined rule must be registered in the manifest or explicitly opted out.
381
+ for (const [id, defs] of byId) {
382
+ if (!requiredSet.has(id) && !optOutSet.has(id)) {
383
+ errors.push({ kind: "unregistered_rule", id, location: defs.map(({ file, line }) => `${file}:${line}`).join(", ") });
384
+ }
385
+ }
386
+ // optOutRules manifest hygiene, in file order for deterministic error ordering.
387
+ for (const id of optOutRules) {
388
+ if (requiredSet.has(id)) errors.push({ kind: "conflicting_manifest_entry", id });
389
+ if (!byId.has(id)) errors.push({ kind: "dead_opt_out_entry", id });
390
+ }
391
+
392
+ for (const ref of references) {
393
+ if (!byId.has(ref.id)) errors.push({ kind: "unresolved_rule_reference", id: ref.id, location: `${ref.file}:${ref.line}` });
394
+ }
395
+
396
+ const termsByKey = new Map();
397
+ for (const term of termDefs) {
398
+ if (!termsByKey.has(term.key)) termsByKey.set(term.key, []);
399
+ termsByKey.get(term.key).push(term);
400
+ }
401
+ for (const [key, defs] of termsByKey) {
402
+ if (defs.length !== 1) errors.push({ kind: "duplicate_term_definition", key, locations: defs.map(({ file, line }) => `${file}:${line}`) });
403
+ }
404
+ const termValues = new Set(termDefs.map((t) => t.value));
405
+ for (const use of termUses) {
406
+ if (!termValues.has(use.token)) errors.push({ kind: "undefined_term_use", token: use.token, location: `${use.file}:${use.line}` });
407
+ }
408
+
409
+ for (const finding of detectNearDuplicates(definitions)) {
410
+ errors.push({ kind: finding.kind, id: `${finding.a.id}~${finding.b.id}`, location: `${finding.a.file}:${finding.a.line} / ${finding.b.file}:${finding.b.line}` });
411
+ }
412
+ for (const finding of detectModalityConflicts(definitions)) {
413
+ errors.push({ kind: finding.kind, id: `${finding.a.id}~${finding.b.id}`, location: `${finding.a.file}:${finding.a.line} / ${finding.b.file}:${finding.b.line}` });
414
+ }
415
+ for (const finding of detectDuplicateImperativeSentences(fileContents)) {
416
+ errors.push({ kind: finding.kind, id: finding.text, location: finding.occurrences.map((o) => `${o.file}:${o.line}`).join(", ") });
417
+ }
418
+ for (const text of detectDeadAllowlistEntries(fileContents)) {
419
+ errors.push({ kind: "dead_allowlist_entry", id: text });
420
+ }
421
+
422
+ return { ok: errors.length === 0, filesScanned: files.length, rules: definitions.length, references: references.length, terms: termDefs.length, errors };
423
+ }
424
+
425
+ async function main(argv = process.argv.slice(2)) {
426
+ if (argv.includes("--help") || argv.includes("-h")) {
427
+ process.stdout.write(`${USAGE}\n`);
428
+ return 0;
429
+ }
430
+ const result = await validateRuleOwnership(REPO_ROOT);
431
+ if (result.ok) {
432
+ process.stdout.write(`Rule ownership validation passed: ${result.rules} rules, ${result.references} references, ${result.terms} terms, ${result.filesScanned} files scanned.\n`);
433
+ return 0;
434
+ }
435
+ process.stdout.write(`Rule ownership validation failed:\n`);
436
+ for (const error of result.errors) process.stdout.write(`- ${error.kind}: ${error.id || error.key || error.token} ${error.location || (error.locations || []).join(", ")}\n`);
437
+ return 1;
438
+ }
439
+
440
+ if (isDirectCliRun(import.meta.url)) {
441
+ process.exitCode = await main();
442
+ }