@jenga-ai/agent 3.5.0 → 4.0.0

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 (136) hide show
  1. package/README.md +85 -78
  2. package/agents/developer.md +1 -1
  3. package/agents/scrum-master.md +20 -2
  4. package/agents/tester.md +3 -3
  5. package/hooks/on_session_end.sh +5 -5
  6. package/lib/generate-agent-context.js +2 -2
  7. package/lib/generate-copilot-hooks.js +1 -1
  8. package/lib/generate-skill-allow-list.js +79 -8
  9. package/lib/mirror.js +1 -1
  10. package/lib/postinstall-manifest.js +1 -1
  11. package/lib/skill-allow-list.json +2 -2
  12. package/mcp/help/index.js +8 -17
  13. package/mcp/help/scan.js +73 -0
  14. package/package.json +5 -1
  15. package/project/app/api/lib/resolve-project-root.js +1 -1
  16. package/project/app/api/parsers/knowledge-graph.js +100 -9
  17. package/project/app/api/routes/health.js +36 -0
  18. package/project/app/api/scripts/capture-snapshot.js +9 -6
  19. package/project/app/package.json +4 -0
  20. package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
  21. package/project/app/ui/dist/assets/index-C3oiuli_.js +104 -0
  22. package/project/app/ui/dist/index.html +2 -2
  23. package/project/app/ui/package.json +4 -0
  24. package/project/app/ui/scripts/build-snapshot-html.cjs +63 -2
  25. package/scripts/acquire-concurrency-slot.sh +35 -5
  26. package/scripts/apply-j-prefix.sh +46 -5
  27. package/scripts/build-pages-site.sh +1 -1
  28. package/scripts/check-public-playbook-steps.sh +158 -52
  29. package/scripts/check-publicignore-match.sh +2 -2
  30. package/scripts/compute-deploy-reconcile.sh +5 -5
  31. package/scripts/delete-bare-skill-dirs.sh +329 -0
  32. package/scripts/generate-legacy-shipped-paths.js +2 -2
  33. package/scripts/idea_manager.sh +273 -3
  34. package/scripts/mark-deployed.sh +2 -2
  35. package/scripts/populate-knowledge-graph.entity-resolution.test.js +254 -0
  36. package/scripts/populate-knowledge-graph.js +213 -5
  37. package/scripts/populate-knowledge-graph.staleness.test.js +130 -0
  38. package/scripts/postinstall.js +1 -1
  39. package/scripts/release-concurrency-slot.sh +34 -4
  40. package/scripts/render-ranked-list.sh +270 -0
  41. package/scripts/repoint-dead-bare-path-prose.py +81 -0
  42. package/scripts/repoint-skill-refs.sh +539 -0
  43. package/scripts/rewrite-stale-skill-preambles.py +188 -0
  44. package/scripts/strip-polyfill-frontmatter.py +166 -0
  45. package/scripts/todo_manager.sh +16 -1
  46. package/scripts/validate-typed-object.sh +750 -0
  47. package/scripts/verify-legacy-seed-reconcile.sh +10 -10
  48. package/scripts/verify-postinstall-reconcile.sh +7 -7
  49. package/scripts/write-context-digest.sh +1 -1
  50. package/skills/j-brainstorm/SKILL.md +3 -4
  51. package/skills/j-btw/SKILL.md +3 -4
  52. package/skills/j-clearify/SKILL.md +5 -6
  53. package/skills/j-close-story/SKILL.md +11 -12
  54. package/skills/j-close-story/scripts/check-privatized.sh +4 -4
  55. package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
  56. package/skills/j-commit/SKILL.md +3 -4
  57. package/skills/j-continue/SKILL.md +5 -6
  58. package/skills/j-deep-dive/SKILL.md +3 -4
  59. package/skills/j-distribute/CONFIG_SCHEMA.md +82 -5
  60. package/skills/j-distribute/SKILL.md +3 -4
  61. package/skills/j-do/SKILL.md +100 -18
  62. package/skills/j-doc/SKILL.md +3 -4
  63. package/skills/j-doc-sync/SKILL.md +4 -4
  64. package/skills/j-dooo/SKILL.md +6 -15
  65. package/skills/j-error/SKILL.md +3 -4
  66. package/skills/j-evaluate/SKILL.md +3 -4
  67. package/skills/j-examplify/SKILL.md +3 -4
  68. package/skills/j-gitignore/SKILL.md +157 -0
  69. package/skills/j-gitignore/assets/jenga-paths.txt +50 -0
  70. package/skills/j-gitignore/scripts/_catalog.sh +105 -0
  71. package/skills/j-gitignore/scripts/audit-gitignore.sh +194 -0
  72. package/skills/j-gitignore/scripts/repair-gitignore.sh +226 -0
  73. package/skills/j-gitignore/scripts/untrack-jenga-files.sh +210 -0
  74. package/skills/j-help/SKILL.md +3 -4
  75. package/skills/j-idea/SKILL.md +80 -9
  76. package/skills/j-idea/assets/idea_template.md +1 -1
  77. package/skills/j-improve/SKILL.md +4 -5
  78. package/skills/j-init/SKILL.md +23 -14
  79. package/skills/j-init/assets/scope-thresholds_template.json +5 -2
  80. package/skills/j-init/scripts/apply-scaffold-visibility.sh +9 -7
  81. package/skills/j-init/scripts/init.sh +4 -4
  82. package/skills/j-jbp/SKILL.md +3 -4
  83. package/skills/j-lgtm/SKILL.md +3 -4
  84. package/skills/j-pi-plan/SKILL.md +3 -4
  85. package/skills/j-playbook/SKILL.md +1 -1
  86. package/skills/j-proceed/SKILL.md +3 -4
  87. package/skills/j-publish/SKILL.md +4 -5
  88. package/skills/j-publish/adapters/npm-ci.md +6 -1
  89. package/skills/j-publish/adapters/npm.md +1 -1
  90. package/skills/j-publish/scripts/generate_release_notes.sh +1 -1
  91. package/skills/j-publish/scripts/npm_stage_inspect.sh +61 -0
  92. package/skills/j-publish/scripts/run_gates.sh +1 -1
  93. package/skills/j-reconcile/SKILL.md +40 -7
  94. package/skills/j-reconcile/assets/report_format.md +11 -0
  95. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +13 -13
  96. package/skills/j-reconcile-origin/SKILL.md +3 -4
  97. package/skills/j-redo/SKILL.md +4 -5
  98. package/skills/j-skillify/SKILL.md +3 -4
  99. package/skills/j-spinoff/SKILL.md +4 -5
  100. package/skills/j-status/SKILL.md +18 -4
  101. package/skills/j-todo/SKILL.md +44 -5
  102. package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
  103. package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
  104. package/skills/j-uncharted/SKILL.md +252 -13
  105. package/skills/j-uncharted/scripts/detect-dependencies.sh +80 -22
  106. package/skills/j-uncharted/scripts/detect-tests.sh +1 -1
  107. package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
  108. package/skills/j-uncharted/scripts/elicitation-state.sh +1 -1
  109. package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
  110. package/skills/j-uncharted/scripts/run-engine.sh +36 -2
  111. package/skills/j-uncharted/scripts/validate-proposed-items.sh +1 -1
  112. package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
  113. package/skills/j-wtf/SKILL.md +4 -5
  114. package/skills/jenga/SKILL.md +106 -11
  115. package/skills/jenga/playbooks/board-hygiene.json +32 -0
  116. package/skills/jenga/playbooks/schema.json +73 -6
  117. package/skills/jenga/playbooks/understand-then-commit.json +19 -0
  118. package/skills/jenga/scripts/load-nl-catalog.js +5 -2
  119. package/skills/jenga/scripts/load-nl-catalog.sh +1 -1
  120. package/skills/jenga/scripts/load-playbooks.sh +290 -5
  121. package/skills/jenga/scripts/match-playbook.sh +4 -4
  122. package/skills/jenga/scripts/run-playbook-step.sh +267 -1
  123. package/templates/SCRUM_BOARD_SCHEMA.md +14 -1
  124. package/templates/SKILL_TEMPLATE.md +12 -0
  125. package/templates/permission-levels/level-1-locked.json +1 -1
  126. package/templates/permission-levels/level-2-guarded.json +1 -1
  127. package/templates/permission-levels/level-3-standard.json +1 -1
  128. package/templates/permission-levels/level-4-elevated.json +2 -2
  129. package/templates/permission-levels/level-5-unrestricted.json +2 -2
  130. package/templates/playbook-types.json +40 -6
  131. package/project/app/ui/dist/assets/index-7fj-vllY.js +0 -104
  132. package/project/app/ui/dist/assets/index-CdK3Qrep.css +0 -1
  133. package/scripts/audit-twin-divergence.sh +0 -625
  134. package/scripts/generate-j-alias.sh +0 -333
  135. package/skills/j-dev-done/SKILL.md +0 -53
  136. package/skills/j-dev-done/scripts/classify-commit-outcome.sh +0 -114
@@ -1 +0,0 @@
1
- .loading-spinner{padding:2rem;text-align:center;color:#888;font-size:1rem}.error-message{padding:1rem;background:#fef2f2;border:1px solid #fca5a5;border-radius:4px;color:#b91c1c}.history-tab h2{margin-bottom:1rem}.history-empty{color:#888;padding:2rem;text-align:center}.history-list{list-style:none;padding:0;margin:0;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden}.history-entry{display:flex;align-items:center;gap:.75rem;padding:.65rem 1rem;cursor:pointer;border-bottom:1px solid #f3f4f6;transition:background .12s}.history-entry:last-child{border-bottom:none}.history-entry:hover,.history-entry:focus{background:#f0f7ff;outline:none}.history-entry.selected{background:#dbeafe}.history-badge{flex-shrink:0;font-size:.75rem;font-weight:600;padding:.2rem .55rem;border-radius:999px;white-space:nowrap}.badge-commit{background:#dbeafe;color:#1d4ed8}.badge-rapport{background:#dcfce7;color:#15803d}.history-subject{flex:1;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#1f2937}.history-date{flex-shrink:0;font-size:.78rem;color:#9ca3af}.panel-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000040;z-index:100}.detail-panel{position:fixed;top:0;right:0;bottom:0;width:min(480px,100vw);background:#fff;box-shadow:-4px 0 24px #00000026;z-index:101;overflow-y:auto;padding:1.5rem;animation:slideIn .2s ease-out}@keyframes slideIn{0%{transform:translate(100%)}to{transform:translate(0)}}.panel-close{float:right;background:none;border:none;font-size:1.25rem;cursor:pointer;color:#6b7280;padding:.25rem;line-height:1}.panel-close:hover{color:#111827}.panel-title{margin:0 0 1rem;font-size:1.1rem;color:#1f2937}.panel-meta{display:grid;grid-template-columns:auto 1fr;gap:.3rem 1rem;font-size:.85rem;margin-bottom:1rem}.panel-meta dt{font-weight:600;color:#6b7280;white-space:nowrap}.panel-meta dd{margin:0;color:#1f2937;word-break:break-all}.panel-subject{font-size:.95rem;color:#1f2937}.panel-body{font-size:.82rem;white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;padding:.75rem;max-height:40vh;overflow-y:auto}.panel-empty{color:#9ca3af;font-style:italic}.markdown-body{font-size:.9rem;line-height:1.6;color:#1f2937}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{margin:1em 0 .4em;line-height:1.3}.markdown-body p{margin:.5em 0}.markdown-body code{background:#f3f4f6;padding:.1em .35em;border-radius:3px;font-size:.85em}.markdown-body pre{background:#f3f4f6;padding:.75rem;border-radius:4px;overflow-x:auto;font-size:.82em}.markdown-body pre code{background:none;padding:0}.markdown-body blockquote{border-left:3px solid #d1d5db;margin:.75em 0;padding:.25em 1em;color:#6b7280}.markdown-body ul,.markdown-body ol{padding-left:1.5em;margin:.5em 0}@media (max-width: 480px){.history-entry{flex-wrap:wrap;row-gap:var(--space-xs)}.history-subject{flex-basis:100%;order:3;white-space:normal}.detail-panel{box-sizing:border-box;padding:var(--space-md)}.panel-close{padding:var(--space-sm);font-size:1.5rem;display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px}.panel-meta{grid-template-columns:1fr;gap:.15rem 0}.panel-meta dt{margin-top:var(--space-xs)}.markdown-body code{overflow-wrap:break-word;word-break:break-word}.markdown-body table{display:block;max-width:100%;overflow-x:auto}}.board-view{display:flex;flex-direction:column;gap:1.5rem}.epic-card{background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:1rem}.epic-header{display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem}.epic-title{font-size:1.1rem;font-weight:600;margin:0;cursor:pointer}.epic-id{color:#6b7280;font-size:.85rem}.stories-list{display:flex;flex-direction:column;gap:.75rem;padding-left:1rem}.story-card{background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;padding:.75rem}.story-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}.story-title{font-size:1rem;font-weight:500;margin:0;cursor:pointer}.story-id{color:#6b7280;font-size:.8rem}.tasks-list{display:flex;flex-direction:column;gap:.4rem;padding-left:1rem}.task-card{background:#fff;border:1px solid #e5e7eb;border-radius:3px;padding:.5rem .75rem;display:flex;align-items:center;gap:.5rem}.task-title{font-size:.9rem;flex:1;cursor:pointer}.task-id{color:#9ca3af;font-size:.75rem}.task-assigned{color:#6b7280;font-size:.8rem}.status-badge{padding:.2rem .5rem;border-radius:9999px;font-size:.75rem;font-weight:500}.badge-pending{background:#f3f4f6;color:#374151}.badge-running{background:#dbeafe;color:#1d4ed8}.badge-in-progress{background:#ffedd5;color:#c2410c}.badge-passed{background:#dcfce7;color:#15803d}.badge-passed-remarks{background:#fff;color:#15803d;border:1px solid #dcfce7}.badge-done{background:#dbeafe;color:#1d4ed8}.badge-failed{background:#fee2e2;color:#b91c1c}.empty-state{padding:2rem;text-align:center;color:#6b7280}.status-filter{display:flex;flex-wrap:wrap;gap:.75rem;padding:.75rem 1rem;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px}.status-filter-option{display:flex;align-items:center;gap:.35rem;font-size:.85rem;color:#374151;cursor:pointer}@media (max-width: 480px){.epic-header,.story-header{flex-wrap:wrap;row-gap:var(--space-xs)}.epic-title,.story-title{flex-basis:100%}.task-card{flex-wrap:wrap;row-gap:var(--space-xs)}.task-card .task-title{flex-basis:100%}.stories-list,.tasks-list{padding-left:var(--space-sm)}}.kanban-board{display:flex;align-items:flex-start;gap:.75rem;overflow-x:auto;padding-bottom:.5rem}.kanban-column{flex:0 0 220px;width:220px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:.6rem;display:flex;flex-direction:column;gap:.5rem}.kanban-column-empty{flex:0 0 140px;width:140px;opacity:.6}.kanban-column-header{display:flex;align-items:center;justify-content:space-between;gap:.4rem}.kanban-column-title{font-size:.8rem;font-weight:600;color:#374151;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.kanban-column-count{font-size:.7rem;color:#6b7280;background:#e5e7eb;border-radius:9999px;padding:.05rem .4rem}.kanban-column-empty-state{font-size:.75rem;color:#9ca3af;text-align:center;padding:.5rem 0}.kanban-column-items{display:flex;flex-direction:column;gap:.5rem}.kanban-card{background:#fff;border:1px solid #e5e7eb;border-radius:4px;padding:.5rem .6rem;display:flex;flex-direction:column;gap:.3rem}.kanban-card-header{display:flex;align-items:center;justify-content:space-between;gap:.4rem}.kanban-card-type{font-size:.65rem;text-transform:uppercase;letter-spacing:.03em;color:#9ca3af}.kanban-card-id{font-size:.7rem;color:#9ca3af}.kanban-card-title{font-size:.85rem;color:#111827;cursor:pointer}.kanban-card-queued{border-style:dashed;border-color:#c4b5fd;background:#fdfcff}.kanban-card-footer{display:flex;align-items:center;justify-content:space-between;gap:.4rem}.kanban-card-queued-marker{font-size:.65rem;text-transform:uppercase;letter-spacing:.03em;color:#6d28d9;background:#ede9fe;border:1px solid #ddd6fe;border-radius:9999px;padding:.05rem .4rem;white-space:nowrap;cursor:help}@media (max-width: 480px){.kanban-board{position:relative;gap:var(--space-sm);scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch}.kanban-column{flex:0 0 85%;width:85%;scroll-snap-align:start;scroll-snap-stop:always}.kanban-column-empty{flex:0 0 140px;width:140px}.kanban-board:after{content:"";position:sticky;top:0;right:0;flex:0 0 var(--space-xl);align-self:stretch;background:linear-gradient(to right,transparent,rgba(249,250,251,.95));pointer-events:none}}.architecture-tab{overflow-x:hidden}.architecture-tab h2{margin-bottom:1rem}.arch-empty{color:#9ca3af;font-style:italic}.arch-section{margin-bottom:2rem}.arch-section h3{font-size:1rem;font-weight:600;color:#374151;margin-bottom:.75rem;border-bottom:1px solid #e5e7eb;padding-bottom:.35rem}.tech-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.75rem}.tech-card{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:.85rem 1rem;display:flex;flex-direction:column;gap:.3rem;box-shadow:0 1px 3px #0000000d}.tech-name{font-weight:600;font-size:.9rem;color:#1f2937}.tech-description{font-size:.78rem;color:#6b7280}.dep-table{width:100%;border-collapse:collapse;font-size:.875rem}.dep-table th{text-align:left;padding:.5rem .75rem;background:#f9fafb;border:1px solid #e5e7eb;font-weight:600;color:#374151}.dep-table td{padding:.45rem .75rem;border:1px solid #f3f4f6;color:#1f2937}.dep-table tr:hover td{background:#f9fafb}.dep-name{font-family:monospace}.dep-version{color:#6b7280;font-family:monospace}.dep-badge{display:inline-block;font-size:.7rem;font-weight:600;padding:.18rem .55rem;border-radius:999px;white-space:nowrap}.dep-runtime{background:#dbeafe;color:#1d4ed8}.dep-dev{background:#f3f4f6;color:#4b5563}.dep-table-wrap{overflow-x:auto}.sad-map-scroll{width:100%;min-height:320px;overflow:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border, #e2e8f0);border-radius:8px;background:var(--bg-secondary, #f8fafc)}.sad-map-svg{display:block;min-width:800px;min-height:500px;padding:8px}.sad-empty{color:var(--text-muted, #94a3b8);font-style:italic}@media (max-width: 480px){.architecture-tab{padding-bottom:var(--space-sm)}.arch-section{margin-bottom:var(--space-lg)}.arch-section h3{padding-bottom:var(--space-xs)}.tech-grid{gap:var(--space-sm)}.tech-card{padding:var(--space-sm) var(--space-md)}.sad-map-scroll{min-height:180px}}.entry-list-empty{color:#888;padding:2rem;text-align:center}.entry-list{list-style:none;padding:0;margin:0;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden}.entry-list-item{display:flex;align-items:center;gap:.75rem;padding:.65rem 1rem;cursor:pointer;border-bottom:1px solid #f3f4f6;transition:background .12s}.entry-list-item:last-child{border-bottom:none}.entry-list-item:hover,.entry-list-item:focus{background:#f0f7ff;outline:none}.entry-list-item.selected{background:#dbeafe}.entry-badge{flex-shrink:0;font-size:.75rem;font-weight:600;padding:.2rem .55rem;border-radius:999px;white-space:nowrap;text-transform:capitalize;background:#ede9fe;color:#6d28d9}.entry-title{flex:1;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#1f2937}.entry-date{flex-shrink:0;font-size:.78rem;color:#9ca3af}@media (max-width: 480px){.entry-list-item{flex-wrap:wrap;row-gap:var(--space-xs)}.entry-title{flex-basis:100%;order:3;white-space:normal}}.pagination-controls{display:flex;align-items:center;justify-content:center;gap:var(--space-md);margin-top:var(--space-md)}.pagination-btn{padding:.4rem .9rem;font-size:.85rem;border:1px solid #e5e7eb;border-radius:6px;background:#fff;color:#1f2937;cursor:pointer}.pagination-btn:hover:not(:disabled){background:#f0f7ff;border-color:#93c5fd}.pagination-btn:disabled{opacity:.45;cursor:not-allowed}.pagination-status{font-size:.82rem;color:#6b7280;white-space:nowrap}@media (max-width: 480px){.pagination-controls{gap:var(--space-sm)}.pagination-btn{padding:.5rem .85rem;font-size:.9rem}.pagination-status{font-size:.78rem}}.rapports-search-input{display:block;width:100%;box-sizing:border-box;padding:.55rem .75rem;margin-bottom:var(--space-md);font-size:.9rem;border:1px solid #e5e7eb;border-radius:6px;color:#1f2937}.rapports-search-input:focus{outline:none;border-color:#93c5fd;box-shadow:0 0 0 2px #3b82f640}@media (max-width: 480px){.rapports-search-input{font-size:1rem;padding:.65rem .75rem}}:root{--space-xs: .25rem;--space-sm: .5rem;--space-md: 1rem;--space-lg: 1.5rem;--space-xl: 2rem;--breakpoint-mobile: 480px;--breakpoint-tablet: 768px}body{font-family:sans-serif;margin:0;background:#f5f5f5}.app{max-width:1200px;margin:0 auto;padding:1rem}.tabs{display:flex;gap:0;border-bottom:1px solid #ddd}.tab-btn{padding:.6rem 1.2rem;border:none;background:transparent;cursor:pointer;font-size:1rem;color:#555;border-bottom:2px solid transparent}.tab-btn.active{color:#2563eb;border-bottom-color:#2563eb;font-weight:600}.tab-content{padding:1rem 0}@media (max-width: 480px){.app{width:100%;box-sizing:border-box;padding:var(--space-sm)}.tabs{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch}.tab-btn{flex:0 0 auto;padding:var(--space-sm) var(--space-md)}}
@@ -1,625 +0,0 @@
1
- #!/usr/bin/env bash
2
- # scripts/audit-twin-divergence.sh — report UNEXPECTED content divergence between
3
- # each skills/<name>/ source and its skills/j-<name>/ twin (E50_S19_T01).
4
- #
5
- # Why this exists
6
- # ---------------
7
- # Every skills/j-<name>/ twin differs from its skills/<name>/ source. Most of that
8
- # difference is produced deliberately by scripts/generate-j-alias.sh and is CORRECT.
9
- # Some of it is missing content — a fix that landed in a bare directory and never
10
- # reached its twin. That second class is invisible in this repo (a bare path still
11
- # resolves, because the bare directory is still here) but is fatal downstream:
12
- # .publicignore blocks the bare-name directories, so the public mirror ships ONLY the
13
- # twins. A gap in a twin is a gap for every consumer.
14
- #
15
- # Confirmed instance, and this script's primary fixture: E22_S09_T07 (commit f3daaa8)
16
- # added parse_stage_id_from_text() to skills/publish/scripts/npm_stage_pipeline.sh and
17
- # a CI-log capture block to skills/publish/adapters/npm-ci.md. Neither reached
18
- # skills/j-publish/. A public-mirror consumer reported it as "E22_S09_T07 has not been
19
- # addressed" while the board read Merged. Both were right.
20
- #
21
- # This script is READ-ONLY. It reports; it never writes. Back-fill is E50_S19_T02.
22
- #
23
- # Classification rules — derived from the generator, not from observed diffs
24
- # -------------------------------------------------------------------------
25
- # Every rule below is a port of what scripts/generate-j-alias.sh actually does, with
26
- # its source line references. Inferring the rules from observed diffs instead would
27
- # let a transform the generator performs be misread as a gap and — far worse — let a
28
- # genuine gap be waved through as "probably just a path rewrite."
29
- #
30
- # Transform 1 (generate-j-alias.sh L180-182): full rmtree + copytree of
31
- # skills/<name>/ into skills/j-<name>/.
32
- # => Audited as a FILE-SET comparison. A file in the source with no counterpart in
33
- # the twin is MISSING_FILE; the reverse is EXTRA_FILE. Both are unexpected.
34
- #
35
- # Transform 2 (L176-177, L184-195): literal substring replace of "skills/<name>/"
36
- # with "skills/j-<name>/" across every copied file. This is also what rewrites the
37
- # .claude/skills/<name>/ and .agents/skills/<name>/ mirrored-install fallback
38
- # paths — they fall out of the same replace.
39
- # => Audited by reconstructing the expected twin file as
40
- # source.replace("skills/<name>/", "skills/j-<name>/") and comparing. A
41
- # mismatch is CONTENT_DRIFT. Note this is directional in both senses: a twin
42
- # that still carries a bare "skills/<name>/" self-reference fails here too,
43
- # which is the same defect class E50_S11 exists to remove.
44
- #
45
- # Transform 3 (L262): frontmatter rewrite name: j.<name> -> name: j.j-<name>.
46
- # => TOLERATED IN BOTH DIRECTIONS. The generator emits j.j-<name>, but E50_S10's
47
- # settled contract keeps the canonical twin at j.<name>, and E50_S15 lands that
48
- # rewrite. This audit must not force either value, so it accepts j.j-<name> or
49
- # j.<name> and reports neither. Any OTHER name: value is unexpected.
50
- #
51
- # Transform 4 (L264-308): description: reframed as a polyfill alias; keywords: gets
52
- # j-<name> and polyfill appended (list created if absent); examples: gets a
53
- # "j-<name>" entry appended if that list already exists; and a generated
54
- # lockstep/duplicate-alias note inserted just below the body's first H1.
55
- # => Reconstructed byte-for-byte and compared. A mismatch is SKILL_DRIFT.
56
- #
57
- # Anything a transform above does not explain is UNEXPECTED and is a candidate content
58
- # gap. That is the entire output of this script.
59
- #
60
- # Scope decisions
61
- # ---------------
62
- # Three bare-name directories have no twin and are skipped, reported as informational:
63
- # - skills/index/ — not a skill, has no SKILL.md; the generator
64
- # hard-errors on it (L118-121).
65
- # - skills/jenga/ — hard-excluded from twin generation (L110-113,
66
- # - skills/jenga-permission-level/ E50_S06_T01). A twin must never exist.
67
- # A j-<name> directory with no bare source is likewise skipped and reported: it is
68
- # already at its sole canonical name (e.g. skills/j-playbook/, renamed by E50_S13), so
69
- # there is no source to diverge from.
70
- #
71
- # The skills/init/ <-> skills/j-init/ pair IS AUDITED. This is a deliberate decision,
72
- # recorded here because the generator refuses that pair (L97-100) as hand-maintained
73
- # and predating it, so one could argue for skipping it outright. Skipping is the wrong
74
- # call: j-init's scripts/ tree is shipped to the public mirror exactly like every other
75
- # twin's, so it can carry exactly the content gap this script hunts, and excluding it
76
- # would create a blind spot precisely where nothing else is watching.
77
- #
78
- # Because the generator never runs on that pair, its SKILL.md's generator-authored
79
- # regions are hand-written by design and reconstruction is not the specification for
80
- # them. For a hand-maintained pair only, SKILL.md is compared as:
81
- # - frontmatter: every field EXCEPT name/description/keywords/examples must match the
82
- # source after transform 2 (this still catches, say, a frontmatter field added to
83
- # the source and never mirrored);
84
- # - body: compared from the first "## " heading onward, since the H1 and the
85
- # alias note above it are hand-authored.
86
- # The preamble between H1 and the first "## " heading is therefore not compared — so
87
- # to stop that becoming a silent blind spot, the script asserts the SOURCE's preamble
88
- # is empty and reports PREAMBLE_UNCOMPARED if it ever stops being empty. Everything
89
- # outside SKILL.md in that pair is compared with no tolerance at all.
90
- #
91
- # Usage:
92
- # scripts/audit-twin-divergence.sh [repo-root] [--diff]
93
- #
94
- # repo-root Root of the tree to audit. Defaults to $JENGA_PROJECT_DIR. Passing it
95
- # explicitly is what lets E50_S19_T03 run this against a
96
- # public-mirror-shaped tree (bare-name directories absent) rather than
97
- # only against the private repo, where the surviving bare directories
98
- # mask this whole defect class.
99
- # --diff Also print a unified diff for every unexpected divergence.
100
- # --min-pairs <n>
101
- # Fail unless at least <n> twinned pairs were actually audited.
102
- # Default 0 (no constraint).
103
- #
104
- # This exists because a clean exit here is otherwise fail-open in a
105
- # specific and easily-missed way: a tree containing NO twinned pairs at
106
- # all audits nothing, finds nothing, and reports success. E50_S19_T03 hit
107
- # exactly that — run against a public-mirror-shaped tree, where the
108
- # bare-name sources are stripped by .publicignore, this script audits 0
109
- # pairs and exits 0. That is a true statement and a worthless one, and it
110
- # would read as a green gate to anyone not checking the pair count. Pass
111
- # --min-pairs to assert the audit examined the population you expected.
112
- #
113
- # Exit codes:
114
- # 0 no unexpected divergence — usable as a gate
115
- # 1 at least one unexpected divergence
116
- # 2 usage or environment error, including --min-pairs not being met
117
-
118
- set -euo pipefail
119
-
120
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
121
-
122
- REPO_ROOT=""
123
- OPT_DIFF="0"
124
- OPT_MIN_PAIRS="0"
125
-
126
- usage() {
127
- echo "Usage: $(basename "$0") [repo-root] [--diff] [--min-pairs <n>]" >&2
128
- echo " Reports unexpected divergence between skills/<name>/ and skills/j-<name>/." >&2
129
- echo " Exits 0 when none remain, 1 when any do, 2 on a usage/environment error." >&2
130
- }
131
-
132
- while [ "$#" -gt 0 ]; do
133
- case "$1" in
134
- -h|--help)
135
- usage
136
- exit 0
137
- ;;
138
- --diff)
139
- OPT_DIFF="1"
140
- ;;
141
- --min-pairs)
142
- shift
143
- if [ "$#" -eq 0 ]; then
144
- echo "audit-twin-divergence.sh: error: --min-pairs requires a number" >&2
145
- usage
146
- exit 2
147
- fi
148
- case "$1" in
149
- ''|*[!0-9]*)
150
- echo "audit-twin-divergence.sh: error: --min-pairs expects a non-negative integer, got '$1'" >&2
151
- exit 2
152
- ;;
153
- esac
154
- OPT_MIN_PAIRS="$1"
155
- ;;
156
- -*)
157
- echo "audit-twin-divergence.sh: error: unknown option '$1'" >&2
158
- usage
159
- exit 2
160
- ;;
161
- *)
162
- if [ -n "$REPO_ROOT" ]; then
163
- echo "audit-twin-divergence.sh: error: more than one repo-root given ('$REPO_ROOT', '$1')" >&2
164
- usage
165
- exit 2
166
- fi
167
- REPO_ROOT="$1"
168
- ;;
169
- esac
170
- shift
171
- done
172
-
173
- if [ -z "$REPO_ROOT" ]; then
174
- # Only fall back to the resolver when no explicit root was given, so this script
175
- # stays runnable against a tree that is not a Jenga project checkout at all (the
176
- # mirror-shaped-tree case in E50_S19_T03).
177
- # SC1091 is disabled rather than left as an accepted info-level finding: this
178
- # script's AC requires a clean default-level shellcheck run, and the sourced file
179
- # does exist — shellcheck simply will not follow it without -x.
180
- # shellcheck source=lib/resolve-project-dir.sh disable=SC1091
181
- source "$SCRIPT_DIR/../lib/resolve-project-dir.sh"
182
- REPO_ROOT="$JENGA_PROJECT_DIR"
183
- fi
184
-
185
- if [ ! -d "$REPO_ROOT" ]; then
186
- echo "audit-twin-divergence.sh: error: repo root '$REPO_ROOT' is not a directory." >&2
187
- exit 2
188
- fi
189
-
190
- if [ ! -d "$REPO_ROOT/skills" ]; then
191
- echo "audit-twin-divergence.sh: error: '$REPO_ROOT' has no skills/ directory — not a tree this audit understands." >&2
192
- exit 2
193
- fi
194
-
195
- export REPO_ROOT OPT_DIFF OPT_MIN_PAIRS
196
-
197
- # Implemented as a python heredoc for the same reason generate-j-alias.sh is: the
198
- # classification is a byte-exact port of that generator's frontmatter/body rewrite,
199
- # which is written in python. Keeping both in one language is what makes "traceable to
200
- # the generator" checkable by reading rather than by trusting a re-derivation in awk.
201
- python3 - <<'PY'
202
- import difflib
203
- import os
204
- import re
205
- import sys
206
-
207
- repo_root = os.environ["REPO_ROOT"]
208
- want_diff = os.environ.get("OPT_DIFF") == "1"
209
- min_pairs = int(os.environ.get("OPT_MIN_PAIRS") or "0")
210
- skills_dir = os.path.join(repo_root, "skills")
211
-
212
- # Pairs the generator refuses outright. See generate-j-alias.sh L110-113 (hard error,
213
- # E50_S06_T01) and L118-121 (no SKILL.md).
214
- NEVER_TWINNED = ("jenga", "jenga-permission-level", "index")
215
-
216
- # Pairs the generator refuses because they are hand-maintained and predate it
217
- # (generate-j-alias.sh L97-100). Audited anyway — see this file's Scope decisions.
218
- HAND_MAINTAINED = ("init",)
219
-
220
- key_re = re.compile(r"^([A-Za-z_][\w-]*):(.*)$")
221
-
222
- findings = [] # (pair, relpath, code, detail, expected_text, actual_text)
223
- notes = [] # informational, never affects exit status
224
-
225
-
226
- def record(pair, relpath, code, detail, expected=None, actual=None):
227
- findings.append((pair, relpath, code, detail, expected, actual))
228
-
229
-
230
- def read_text(path):
231
- """Returns (text, is_text). Binary/unreadable files fall back to byte compare."""
232
- try:
233
- with open(path, "r", encoding="utf-8") as fh:
234
- return fh.read(), True
235
- except (UnicodeDecodeError, OSError):
236
- return None, False
237
-
238
-
239
- def read_bytes(path):
240
- with open(path, "rb") as fh:
241
- return fh.read()
242
-
243
-
244
- def walk_files(root):
245
- out = set()
246
- for dirpath, _dirnames, filenames in os.walk(root):
247
- for fn in filenames:
248
- out.add(os.path.relpath(os.path.join(dirpath, fn), root))
249
- return out
250
-
251
-
252
- def split_frontmatter(text, label):
253
- """Returns (opening_line, fields, closing_line, body_lines).
254
-
255
- Field parsing is the same informal line-oriented approach generate-j-alias.sh
256
- L217-234 uses — deliberately, so the two agree on what a 'field' is.
257
- """
258
- lines = text.splitlines(keepends=True)
259
- if not lines or lines[0].strip() != "---":
260
- return None, None, None, None
261
- close_idx = None
262
- for idx in range(1, len(lines)):
263
- if lines[idx].strip() == "---":
264
- close_idx = idx
265
- break
266
- if close_idx is None:
267
- return None, None, None, None
268
-
269
- fields = []
270
- i = 1
271
- while i < close_idx:
272
- line = lines[i]
273
- m = key_re.match(line)
274
- if not m:
275
- i += 1
276
- continue
277
- block = [line]
278
- j = i + 1
279
- if m.group(2).strip() == "":
280
- while j < close_idx and lines[j].startswith(" "):
281
- block.append(lines[j])
282
- j += 1
283
- fields.append({"key": m.group(1), "lines": block})
284
- i = j
285
- return lines[0], fields, lines[close_idx], lines[close_idx + 1:]
286
-
287
-
288
- def find_field(fields, key):
289
- for f in fields:
290
- if f["key"] == key:
291
- return f
292
- return None
293
-
294
-
295
- def build_expected_skill_md(src_text, name):
296
- """Byte-for-byte port of generate-j-alias.sh's transforms 2-4 on SKILL.md.
297
-
298
- Mirrors L176-195 (path replace), L262 (name), L264-271 (description),
299
- L273-286 (keywords/examples), L292-327 (note insertion + reassembly).
300
- """
301
- old_ref = "skills/%s/" % name
302
- new_ref = "skills/j-%s/" % name
303
-
304
- opening, fields, closing, body_lines = split_frontmatter(
305
- src_text.replace(old_ref, new_ref), "source"
306
- )
307
- if fields is None:
308
- return None
309
-
310
- name_field = find_field(fields, "name")
311
- desc_field = find_field(fields, "description")
312
- if name_field is None or len(name_field["lines"]) != 1:
313
- return None
314
- if desc_field is None or len(desc_field["lines"]) != 1:
315
- return None
316
-
317
- # L262
318
- name_field["lines"][0] = "name: j.j-%s\n" % name
319
-
320
- # L264-271
321
- original_desc = (
322
- key_re.match(desc_field["lines"][0]).group(2).strip().strip("\"'").rstrip(".")
323
- )
324
- desc_field["lines"][0] = (
325
- "description: Polyfill alias of the {n} skill under a collision-safe directory "
326
- "name. Identical behavior to /{n} — {d}. Use when the bare /{n} form is "
327
- "shadowed by another tool's own built-in command of the same name.\n"
328
- ).format(n=name, d=original_desc)
329
-
330
- # L273-286
331
- keywords_field = find_field(fields, "keywords")
332
- if keywords_field is not None:
333
- keywords_field["lines"].append(" - j-%s\n" % name)
334
- keywords_field["lines"].append(" - polyfill\n")
335
- else:
336
- fields.append({
337
- "key": "keywords",
338
- "lines": ["keywords:\n", " - j-%s\n" % name, " - polyfill\n"],
339
- })
340
- examples_field = find_field(fields, "examples")
341
- if examples_field is not None:
342
- examples_field["lines"].append(' - "j-%s"\n' % name)
343
-
344
- new_frontmatter = []
345
- for f in fields:
346
- new_frontmatter.extend(f["lines"])
347
-
348
- # L294-308
349
- note_text = (
350
- "This skill is a literal-directory-name duplicate of `skills/{n}/`. It exists "
351
- "so that `/j-{n}` (and `j.j-{n}`) give a guaranteed-unshadowed way to reach "
352
- "the same flow as `/{n}`, even if a host tool's own built-in command of the "
353
- "same name would otherwise shadow or override the bare `/{n}` alias (Claude "
354
- "Code's native skill resolution is a literal-string, directory-name-based "
355
- "match — see `docs/skill-authoring.md`'s \"Invocation Convention\").\n\n"
356
- "This file is generated/synced by `scripts/generate-j-alias.sh {n}` from "
357
- "`skills/{n}/SKILL.md` — do not hand-edit it; re-run the generator instead to "
358
- "pick up source changes."
359
- ).format(n=name)
360
- note_block = []
361
- for para in note_text.split("\n\n"):
362
- note_block.append(para + "\n")
363
- note_block.append("\n")
364
-
365
- heading_idx = None
366
- for idx, bl in enumerate(body_lines):
367
- if bl.startswith("# "):
368
- heading_idx = idx
369
- break
370
- if heading_idx is not None:
371
- insert_at = heading_idx + 1
372
- if insert_at < len(body_lines) and body_lines[insert_at].strip() == "":
373
- insert_at += 1
374
- prefix = body_lines[:insert_at]
375
- if prefix and prefix[-1].strip() != "":
376
- prefix = prefix + ["\n"]
377
- new_body = prefix + note_block + body_lines[insert_at:]
378
- else:
379
- new_body = note_block + body_lines
380
-
381
- return "".join([opening] + new_frontmatter + [closing] + new_body)
382
-
383
-
384
- def normalize_name_line(text, name):
385
- """Applies the transform-3 tolerance: j.j-<name> and j.<name> are both accepted.
386
-
387
- Rewrites the twin's `name:` line to the generator's j.j-<name> form purely for the
388
- purpose of comparison, and only when it holds one of the two accepted values. Any
389
- third value is left alone so the diff surfaces it as unexpected.
390
- """
391
- accepted = ("j.j-%s" % name, "j.%s" % name)
392
- out = []
393
- seen = False
394
- for line in text.splitlines(keepends=True):
395
- if not seen:
396
- m = key_re.match(line)
397
- if m and m.group(1) == "name":
398
- seen = True
399
- value = m.group(2).strip().strip("\"'")
400
- if value in accepted:
401
- out.append("name: j.j-%s\n" % name)
402
- continue
403
- out.append(line)
404
- return "".join(out)
405
-
406
-
407
- def body_from_first_h2(body_lines):
408
- for idx, bl in enumerate(body_lines):
409
- if bl.startswith("## "):
410
- return "".join(body_lines[idx:])
411
- return ""
412
-
413
-
414
- def preamble_between_h1_and_h2(body_lines):
415
- """Body text between the first H1 and the first H2, exclusive of both."""
416
- start = None
417
- for idx, bl in enumerate(body_lines):
418
- if bl.startswith("# "):
419
- start = idx + 1
420
- break
421
- if start is None:
422
- start = 0
423
- end = len(body_lines)
424
- for idx in range(start, len(body_lines)):
425
- if body_lines[idx].startswith("## "):
426
- end = idx
427
- break
428
- return "".join(body_lines[start:end]).strip()
429
-
430
-
431
- def audit_skill_md_hand_maintained(pair, src_text, twin_text):
432
- """SKILL.md comparison for a pair the generator refuses (L97-100).
433
-
434
- Compares every frontmatter field except the four transform-3/4 rewrites, and the
435
- body from the first H2 onward. See this file's Scope decisions for why.
436
- """
437
- old_ref = "skills/%s/" % pair
438
- new_ref = "skills/j-%s/" % pair
439
- src_text = src_text.replace(old_ref, new_ref)
440
-
441
- _o1, src_fields, _c1, src_body = split_frontmatter(src_text, "source")
442
- _o2, twin_fields, _c2, twin_body = split_frontmatter(twin_text, "twin")
443
- if src_fields is None or twin_fields is None:
444
- record(pair, "SKILL.md", "FRONTMATTER_UNPARSEABLE",
445
- "SKILL.md frontmatter could not be parsed on one or both sides")
446
- return
447
-
448
- generator_owned = ("name", "description", "keywords", "examples")
449
- for f in src_fields:
450
- if f["key"] in generator_owned:
451
- continue
452
- twin_f = find_field(twin_fields, f["key"])
453
- if twin_f is None:
454
- record(pair, "SKILL.md", "FRONTMATTER_FIELD_MISSING",
455
- "twin frontmatter has no '%s:' field, but the source does" % f["key"],
456
- "".join(f["lines"]), "")
457
- elif "".join(twin_f["lines"]) != "".join(f["lines"]):
458
- record(pair, "SKILL.md", "FRONTMATTER_FIELD_DRIFT",
459
- "twin frontmatter field '%s:' differs from the source" % f["key"],
460
- "".join(f["lines"]), "".join(twin_f["lines"]))
461
-
462
- # The uncompared region must stay empty, or this tolerance becomes a blind spot.
463
- src_preamble = preamble_between_h1_and_h2(src_body)
464
- if src_preamble:
465
- record(pair, "SKILL.md", "PREAMBLE_UNCOMPARED",
466
- "source now has body text between its H1 and its first '## ' heading, "
467
- "which this pair's hand-maintained tolerance does not compare — review "
468
- "by hand and mirror it into the twin if it belongs there",
469
- src_preamble, "")
470
-
471
- src_rest = body_from_first_h2(src_body)
472
- twin_rest = body_from_first_h2(twin_body)
473
- if src_rest != twin_rest:
474
- record(pair, "SKILL.md", "SKILL_DRIFT",
475
- "twin body (from the first '## ' heading onward) differs from the source",
476
- src_rest, twin_rest)
477
-
478
-
479
- def audit_pair(pair):
480
- src_dir = os.path.join(skills_dir, pair)
481
- twin_dir = os.path.join(skills_dir, "j-%s" % pair)
482
- old_ref = "skills/%s/" % pair
483
- new_ref = "skills/j-%s/" % pair
484
-
485
- src_files = walk_files(src_dir)
486
- twin_files = walk_files(twin_dir)
487
-
488
- for rel in sorted(src_files - twin_files):
489
- record(pair, rel, "MISSING_FILE",
490
- "present in skills/%s/ but absent from the twin the mirror ships" % pair)
491
- for rel in sorted(twin_files - src_files):
492
- record(pair, rel, "EXTRA_FILE",
493
- "present in the twin but absent from skills/%s/" % pair)
494
-
495
- for rel in sorted(src_files & twin_files):
496
- src_path = os.path.join(src_dir, rel)
497
- twin_path = os.path.join(twin_dir, rel)
498
-
499
- src_text, src_is_text = read_text(src_path)
500
- twin_text, twin_is_text = read_text(twin_path)
501
-
502
- if not (src_is_text and twin_is_text):
503
- if read_bytes(src_path) != read_bytes(twin_path):
504
- record(pair, rel, "BINARY_DRIFT",
505
- "binary (or non-UTF-8) file differs; the generator copies it verbatim")
506
- continue
507
-
508
- if rel == "SKILL.md":
509
- if pair in HAND_MAINTAINED:
510
- audit_skill_md_hand_maintained(pair, src_text, twin_text)
511
- continue
512
- expected = build_expected_skill_md(src_text, pair)
513
- if expected is None:
514
- record(pair, rel, "FRONTMATTER_UNPARSEABLE",
515
- "source SKILL.md frontmatter does not have the single-line "
516
- "name:/description: shape the generator requires (L247-250)")
517
- continue
518
- actual = normalize_name_line(twin_text, pair)
519
- if expected != actual:
520
- record(pair, rel, "SKILL_DRIFT",
521
- "twin SKILL.md differs from the source after the generator's "
522
- "transforms 2-4 are applied",
523
- expected, actual)
524
- continue
525
-
526
- expected = src_text.replace(old_ref, new_ref)
527
- if expected != twin_text:
528
- record(pair, rel, "CONTENT_DRIFT",
529
- "twin differs from skills/%s/%s after transform 2 (the "
530
- "skills/%s/ -> skills/j-%s/ path rewrite)" % (pair, rel, pair, pair),
531
- expected, twin_text)
532
-
533
-
534
- # ---- discover pairs -------------------------------------------------------------
535
-
536
- if not os.path.isdir(skills_dir):
537
- sys.exit("audit-twin-divergence.sh: error: %s is not a directory" % skills_dir)
538
-
539
- entries = sorted(
540
- d for d in os.listdir(skills_dir) if os.path.isdir(os.path.join(skills_dir, d))
541
- )
542
- bare_dirs = [d for d in entries if not d.startswith("j-")]
543
- twin_dirs = [d for d in entries if d.startswith("j-")]
544
-
545
- pairs = []
546
- for d in twin_dirs:
547
- bare = d[2:]
548
- if bare in bare_dirs:
549
- pairs.append(bare)
550
- else:
551
- notes.append(
552
- "skipped skills/%s/ — no skills/%s/ source; already at its sole canonical "
553
- "name, so there is nothing to diverge from" % (d, bare)
554
- )
555
-
556
- for d in bare_dirs:
557
- if ("j-%s" % d) in twin_dirs:
558
- continue
559
- if d in NEVER_TWINNED:
560
- notes.append(
561
- "skipped skills/%s/ — hard-excluded from twin generation by the generator; "
562
- "a twin must never exist for it" % d
563
- )
564
- else:
565
- notes.append(
566
- "skipped skills/%s/ — no skills/j-%s/ twin exists" % (d, d)
567
- )
568
-
569
- for pair in pairs:
570
- audit_pair(pair)
571
-
572
- # ---- report ---------------------------------------------------------------------
573
-
574
- for note in notes:
575
- print("note: %s" % note)
576
- if notes:
577
- print("")
578
-
579
- if findings:
580
- current_pair = None
581
- for pair, rel, code, detail, expected, actual in findings:
582
- if pair != current_pair:
583
- print("skills/j-%s/" % pair)
584
- current_pair = pair
585
- print(" %-26s %s" % (code, "skills/j-%s/%s" % (pair, rel)))
586
- print(" %s" % detail)
587
- if want_diff and expected is not None and actual is not None:
588
- diff = difflib.unified_diff(
589
- expected.splitlines(keepends=True),
590
- actual.splitlines(keepends=True),
591
- fromfile="expected (from skills/%s/%s)" % (pair, rel),
592
- tofile="actual (skills/j-%s/%s)" % (pair, rel),
593
- n=2,
594
- )
595
- for line in diff:
596
- sys.stdout.write(" | " + line if line.endswith("\n")
597
- else " | " + line + "\n")
598
- print("")
599
-
600
- affected = sorted({f[0] for f in findings})
601
- print("audit-twin-divergence.sh: audited %d twinned pair(s) under %s"
602
- % (len(pairs), skills_dir))
603
-
604
- # Checked before the findings verdict: "I audited fewer pairs than you expected" is a
605
- # statement about whether this run means anything at all, which has to be settled
606
- # before "and I found nothing wrong" is worth printing as a pass.
607
- if len(pairs) < min_pairs:
608
- print("audit-twin-divergence.sh: error: only %d twinned pair(s) were audited, but "
609
- "--min-pairs %d was required. A clean result over too small a population is "
610
- "not a pass — check the tree actually contains the pairs you expected."
611
- % (len(pairs), min_pairs))
612
- sys.exit(2)
613
-
614
- if findings:
615
- print("audit-twin-divergence.sh: %d unexpected divergence(s) across %d pair(s): %s"
616
- % (len(findings), len(affected), ", ".join(affected)))
617
- print("audit-twin-divergence.sh: each of the above is a candidate content gap — "
618
- "back-fill it into the twin, or record it as an intentional twin-only "
619
- "difference with a stated reason (E50_S19_T02).")
620
- sys.exit(1)
621
-
622
- print("audit-twin-divergence.sh: no unexpected divergence — every difference between "
623
- "each source and its twin is explained by the generator's transforms.")
624
- sys.exit(0)
625
- PY