dsh-tower 2.6.0 → 2.7.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 (116) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.en.md +2 -1
  5. package/README.md +6 -1
  6. package/adapters/leases.js +20 -6
  7. package/adapters/legacy.js +149 -37
  8. package/adapters/stateFile.js +11 -1
  9. package/app/archive.js +30 -5
  10. package/app/briefing.js +95 -12
  11. package/app/checkCompletion.js +107 -18
  12. package/app/enrichedMissions.js +1 -1
  13. package/app/freshness.js +77 -0
  14. package/app/gate.js +26 -12
  15. package/app/merge.js +7 -4
  16. package/app/mission.js +24 -1
  17. package/app/missionContext.js +2 -1
  18. package/app/plan.js +64 -1
  19. package/app/provision.js +133 -22
  20. package/app/read.js +38 -5
  21. package/app/rebase.js +10 -8
  22. package/app/review.js +97 -61
  23. package/app/reviewCheckout.js +133 -0
  24. package/app/sandbox.js +121 -0
  25. package/app/sharedCheckout.js +90 -0
  26. package/app/spawn-prechecks.js +25 -24
  27. package/app/spawn-support.js +59 -8
  28. package/app/spawn.js +3 -0
  29. package/app/teardown.js +119 -2
  30. package/app/tower.js +29 -1
  31. package/cordis.patch.yml +50 -0
  32. package/docs/adr/0006-mission-shapes.md +306 -0
  33. package/docs/architecture/overview.md +6 -4
  34. package/docs/guide/install-upgrade-migration.md +26 -0
  35. package/docs/operations/runbook.md +1 -1
  36. package/docs/reference/blockers.md +8 -4
  37. package/docs/reference/component-surface.md +7 -2
  38. package/docs/reference/config.generated.md +6 -4
  39. package/docs/reference/config.md +1 -2
  40. package/docs/reference/doctor.md +7 -7
  41. package/docs/reference/operations.md +1 -1
  42. package/docs/reference/shapes.md +123 -0
  43. package/docs/reference/surface-budget.md +33 -1
  44. package/docs/reference/testing.md +4 -2
  45. package/docs/reference/tools.generated.md +5 -5
  46. package/docs/reference/tools.md +2 -2
  47. package/docs/zh/reference/blockers.md +2 -2
  48. package/docs/zh/reference/config.md +1 -2
  49. package/docs/zh/reference/testing.md +3 -2
  50. package/domain/batch.js +75 -105
  51. package/domain/collab/batch.js +30 -10
  52. package/domain/collab/spawn.js +53 -16
  53. package/domain/config/schema.js +47 -17
  54. package/domain/contracts/outputs.js +0 -1
  55. package/domain/contracts/tool-inputs/missions.js +9 -1
  56. package/domain/evidence.js +283 -0
  57. package/domain/gate.js +11 -31
  58. package/domain/integration/gate.js +17 -43
  59. package/domain/integration/init.js +3 -2
  60. package/domain/integration/provision.js +51 -0
  61. package/domain/integration/rebase.js +16 -1
  62. package/domain/mission.js +16 -33
  63. package/domain/missions/completion.js +46 -8
  64. package/domain/missions/findings.js +2 -1
  65. package/domain/missions/lifecycle.js +131 -35
  66. package/domain/missions/plan.js +30 -6
  67. package/domain/missions/review.js +2 -18
  68. package/domain/planning.js +5 -5
  69. package/domain/read/missionContext.js +17 -3
  70. package/domain/read/views.js +5 -1
  71. package/domain/roles.js +222 -0
  72. package/domain/roster.js +9 -3
  73. package/domain/scope.js +51 -0
  74. package/domain/shapes/build.js +254 -0
  75. package/domain/shapes/index.js +171 -0
  76. package/domain/shapes/survey.js +445 -0
  77. package/domain/state/archive.js +28 -4
  78. package/domain/state/migrations.js +111 -4
  79. package/domain/state/schema.js +69 -7
  80. package/domain/state/semantics.js +93 -13
  81. package/domain/state.js +5 -1
  82. package/domain/summary.js +5 -11
  83. package/domain/vocabulary.js +15 -33
  84. package/host/capability-registry.js +25 -9
  85. package/host/doctor.js +2 -1
  86. package/host/preset-survey-sandbox.js +19 -0
  87. package/host/preset-survey.js +18 -0
  88. package/host/presets.js +8 -3
  89. package/host/tool-registry.js +14 -12
  90. package/locale/en.json +16 -1
  91. package/locale/preset-survey/en.json +6 -0
  92. package/locale/preset-survey/zh.json +6 -0
  93. package/locale/preset-survey-sandbox/en.json +6 -0
  94. package/locale/preset-survey-sandbox/zh.json +6 -0
  95. package/locale/zh.json +16 -1
  96. package/package.json +13 -2
  97. package/ports/state.js +8 -1
  98. package/scripts/architecture-waivers.json +3 -3
  99. package/scripts/check-docs.mjs +1 -1
  100. package/scripts/check-pack.mjs +2 -0
  101. package/scripts/component-surface.json +68 -20
  102. package/scripts/generate-capability-locale.mjs +5 -0
  103. package/scripts/generate-shapes-reference.mjs +171 -0
  104. package/scripts/mount-smoke/surface.mjs +3 -0
  105. package/scripts/pack-manifest.txt +21 -0
  106. package/scripts/shape-audit.mjs +361 -0
  107. package/scripts/surface-baseline.json +68 -4
  108. package/survey-sandbox.js +48 -0
  109. package/survey.js +40 -0
  110. package/tools/complete.js +5 -5
  111. package/tools/diff.js +5 -3
  112. package/tools/plan.js +1 -1
  113. package/tools/preflight.js +3 -2
  114. package/tools/spawn.js +47 -49
  115. package/tools/test.js +2 -1
  116. package/types/index.d.ts +66 -5
@@ -0,0 +1,171 @@
1
+ // Generate the shape reference from the one shape registry (#149).
2
+ //
3
+ // Every fact in docs/reference/shapes.md comes from domain/shapes/index.js and
4
+ // the two shape modules, so documentation cannot drift from the policy the
5
+ // generic layers actually resolve. `--check` is wired into `npm run check`.
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { SHAPE_KINDS, shapeFor } from '../domain/shapes/index.js'
10
+ import { RULES } from './shape-audit.mjs'
11
+
12
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
13
+ const target = path.join(root, 'docs', 'reference', 'shapes.md')
14
+
15
+ /**
16
+ * Resolve an audit rule by id. A verdict that names a rule the audit does not
17
+ * run fails generation, so the reference can never describe an unenforced rule.
18
+ * @param {string} id
19
+ */
20
+ function fitness(id) {
21
+ const rule = RULES.find((entry) => entry.id === id)
22
+ if (!rule) throw new Error(`unknown shape-audit rule "${id}" named by the shapes reference generator`)
23
+ return `\`${rule.id}\` — ${rule.rule}`
24
+ }
25
+
26
+ /** Rule ids as code spans; throws if a verdict names a rule the audit does not run. */
27
+ function rules(...ids) {
28
+ for (const id of ids) {
29
+ if (!RULES.some((entry) => entry.id === id)) {
30
+ throw new Error(`unknown shape-audit rule "${id}" named by the shapes reference generator`)
31
+ }
32
+ }
33
+ return ids.map((id) => `\`${id}\``).join(', ')
34
+ }
35
+
36
+ const fitnessRules = RULES.map((rule) => `| \`${rule.id}\` | \`${rule.owner}\` | ${rule.rule} |`)
37
+
38
+ const rows = SHAPE_KINDS.map((kind) => {
39
+ const shape = shapeFor(kind)
40
+ const hasTerminal = shape.statuses.some((status) => shape.isTerminal(status))
41
+ // #148 W2: a shape with a sandbox dimension resolves its placement from
42
+ // tuning config, so the reference names both placements instead of the static
43
+ // default only. Both placements render as text: a raw `|` would split the cell.
44
+ const provisioning = shape.fields.sandbox ? '`shared` / `detached`' : `\`${shape.provisioning}\``
45
+ return `| \`${kind}\` | \`${shape.statuses.join('`, `')}\` | ${hasTerminal ? 'yes' : 'no'} | ${provisioning} | ${shape.canMerge} | ${shape.canRebase} | ${shape.reach.ownsPaths} / \`${shape.reach.fence}\` | ${shape.requiredComponent ?? '—'} | ${shape.acceptsResult} |`
46
+ })
47
+
48
+ const transitions = SHAPE_KINDS.map((kind) => {
49
+ const shape = shapeFor(kind)
50
+ const body = shape.statuses
51
+ .map((status) => {
52
+ const targets = shape.transitions[status] ?? []
53
+ return `| \`${status}\` | ${shape.isTerminal(status) ? 'terminal' : targets.map((target) => `\`${target}\``).join(', ') || '—'} |`
54
+ })
55
+ .join('\n')
56
+ return `### ${kind}\n\n| From | To |\n|---|---|\n${body}`
57
+ })
58
+
59
+ const invariants = SHAPE_KINDS.map((kind) => {
60
+ const { completion } = shapeFor(kind)
61
+ const flags = Object.entries(completion)
62
+ .filter(([key]) => key.startsWith('requires'))
63
+ .map(([key, value]) => `${key}=${value}`)
64
+ .join(', ')
65
+ return `| \`${kind}\` | ${flags} | \`${completion.check.name}\` |`
66
+ })
67
+
68
+ const expected = `# Mission shapes (generated)
69
+
70
+ > Generated by \`npm run docs:generate:shapes\` from \`domain/shapes/index.js\`. Do not edit by hand.
71
+
72
+ A mission shape is the single place its kind's contract lives (#148/#149). Generic
73
+ layers resolve \`shapeFor(mission.kind)\` and never branch on a kind literal; the
74
+ shape audit (\`npm run shape:audit\`) refuses kind and role comparisons outside
75
+ \`domain/shapes/\`, the global \`TERMINAL_STATUSES\`/\`TRANSITIONS\` vocabulary,
76
+ one-argument \`isTerminal\`/\`canTransition\` calls, and the dimension regressions
77
+ recorded in the inventory below (review tip, batch status, lease ledger, blocked
78
+ writes, evidence reads, legacy vocabulary, path permission and role/shape
79
+ conflation).
80
+
81
+ ## Kinds
82
+
83
+ | Kind | Statuses | Has terminal | Provisioning | Can merge | Can rebase | Reach (owns / fence) | Component | Accepts result |
84
+ |---|---|---|---|---|---|---|---|---|
85
+ ${rows.join('\n')}
86
+
87
+ ## Transitions
88
+
89
+ ${transitions.join('\n\n')}
90
+
91
+ ## Survey placement (#148 W2)
92
+
93
+ \`survey.sandbox\` is tuning, not a component: the \`tower-survey\` row stays the
94
+ switch. The shape resolves both the placement and the preset, so generic layers
95
+ never read the raw key or compare a kind.
96
+
97
+ The decision is made once, at provisioning time. A mission with a recorded
98
+ \`mission.sandbox\` keeps the detached placement and its completion invariants
99
+ even if \`survey.sandbox\` later changes — a config flip can neither skip the
100
+ sandbox HEAD/zero-commit checks nor strand the disposable checkout. A survey
101
+ that never got a sandbox keeps the shared invariants for the same reason.
102
+
103
+ | \`survey.sandbox\` | Provisioning | Preset | Completion invariants |
104
+ |---|---|---|---|
105
+ | \`none\` (default) | \`shared\`: no checkout, the agent reads the main checkout | \`tower-survey\` (strictly read-only: no write, edit, or shell tool) | HEAD equals \`readCommit\`, tracked-status fingerprint unchanged, zero commits |
106
+ | \`detached\` | \`detached\`: \`git worktree add --detach <worktreeRoot>/<id>-sandbox <readCommit>\` (no branch, no \`baseCommit\`, never merged) | \`tower-survey-sandbox\` (read/write/edit/pwsh plus the mission tools; spawn/merge/rebase/review/diff still absent) | sandbox HEAD equals \`readCommit\`, zero commits; disposable write/shell side effects are ignored; sandbox removed on completion, abandon or teardown with a bounded retry |
107
+
108
+ ## Completion invariants
109
+
110
+ | Kind | Invariants | Check |
111
+ |---|---|---|
112
+ ${invariants.join('\n')}
113
+
114
+ ## Role
115
+
116
+ | Contract | Owner |
117
+ |---|---|
118
+ | Status, transitions, terminality, merge/rebase, provisioning, reach, plan fields, completion, dependencies, briefing, summary | \`domain/shapes/<kind>.js\` |
119
+ | Kind resolution and the action guard | \`domain/shapes/index.js\` |
120
+ | Role vocabulary and the role contract (preset, model, placement, briefing, prerequisites) | \`domain/roles.js\` |
121
+ | Generated reference and the audit | \`scripts/generate-shapes-reference.mjs\`, \`scripts/shape-audit.mjs\` |
122
+
123
+ ## Dimension inventory (#149)
124
+
125
+ Every dimension the registry audit covers. A **shape-owned** verdict lives in
126
+ \`domain/shapes/<kind>.js\`; a **correctly generic** verdict has one owner module
127
+ outside the shapes; a **redesigned** verdict replaced an earlier ambiguous
128
+ contract. The fitness rule is the mechanical check the audit (or the generated
129
+ invariant) enforces.
130
+
131
+ | Dimension | Verdict | Owner module | Fitness rule |
132
+ |---|---|---|---|
133
+ | Mission lifecycle | **Shape-owned.** Statuses, transitions and terminality are per kind. | \`domain/shapes/<kind>.js\` | ${rules('kind-literal', 'global-status-vocabulary', 'shape-entry-point')} |
134
+ | Role vocabulary | **Correctly generic.** Roles are actors, not mission kinds. | \`domain/roles.js\` | ${rules('role-literal')} |
135
+ | Provisioning | **Shape-owned.** A build gets a branch worktree; a survey is shared or a detached sandbox. | \`domain/shapes/<kind>.js\` | Generated invariant: only a build creates a branch/worktree; a recorded \`mission.sandbox\` decides a survey's placement. |
136
+ | Read-only completion | **Shape-owned.** \`resolveCompletion\` derives invariants from the recorded placement. | \`domain/shapes/survey.js\` | Generated invariant: shared survey keeps HEAD and tracked status; detached survey keeps HEAD pinned; both make zero commits. |
137
+ | Dependency | **Shape-owned.** \`dependencySatisfied\` names the terminal statuses that satisfy a dependent. | \`domain/shapes/<kind>.js\` | Generated invariant: a completed survey satisfies a dependent build; a merged build does too. |
138
+ | Archive / reanchor / planning | **Shape-owned.** Terminality is the one archive/reanchor predicate. | \`domain/shapes/<kind>.js\` | Generated invariant: a terminal shape leaves open work and never blocks reanchor. |
139
+ | Completion result | **Shape-owned.** Only a survey may record a completion result. | \`domain/shapes/<kind>.js\` | Generated invariant: \`shape.acceptsResult\`. |
140
+ | Required component | **Shape-owned.** A survey needs the \`tower-survey\` row. | \`domain/shapes/<kind>.js\` | Generated invariant: planning refuses with an actionable code while the required component is off. |
141
+ | Review | **Redesigned.** Commit-stamped evidence: a reviewer reads a disposable checkout at the recorded review commit; only a shape with a branch is reviewable. | \`app/review.js\`, \`app/reviewCheckout.js\`, \`domain/missions/review.js\` | ${fitness('review-tip-read')} |
142
+ | Batch | **Correctly generic second lifecycle.** A batch is finite evidence over missions, not a mission kind; no shape knows about batches. | \`domain/batch.js\`, \`domain/collab/batch.js\`, \`app/batch.js\` | ${fitness('batch-status-literal')} |
143
+ | Leases | **Correctly generic.** One fail-closed admission point; expiry is recovered only by an explicit \`force\` release. | \`adapters/leases.js\`, \`app/spawn-prechecks.js\` | ${fitness('lease-ledger')} |
144
+ | Paused vs blocked | **Redesigned.** \`blocked\` is derived machine state; \`paused\` is a deliberate stop with a cause. | \`domain/missions/lifecycle.js\`, \`domain/state/semantics.js\` | ${fitness('blocked-status-write')} |
145
+ | Test receipts / evidence | **Redesigned — one reader.** Completion checks and discovery receipts are one normalized evidence contract in \`domain/evidence.js\`; the shape declares whether a receipt is tip-bound. \`tower_complete\` no longer copies a synthetic \`tests\` check into the report. | \`domain/evidence.js\`; \`domain/shapes/<kind>.js\` declares \`evidence\` | ${fitness('evidence-single-read')} |
146
+ | Legacy import | **Redesigned — strict adapter.** One payload is one declared format; status remaps, assumed kinds and non-carried fields are reported as \`remapped\`/\`unresolved\`/\`dropped\` instead of approximated. Import stays read-only and never resumes a paused mission. | \`adapters/legacy.js\`; \`domain/batch.js\` reads only the canonical \`pausedMissionIds\` | ${fitness('legacy-vocabulary')} |
147
+ | Scope ownership | **Redesigned — shape.reach.** The shape owns path permission (\`reach.ownsPaths\`, \`reach.fence\`); the gate, planner, lifecycle and auto-deps derive from it through one fence. | \`domain/scope.js\` + \`shape.reach\` in \`domain/shapes/<kind>.js\` | ${fitness('scope-reach')} |
148
+ | Role vs kind | **Redesigned — orthogonal role contract.** Spawn, preset, model, placement, briefing, roster budget and gates resolve the role through \`domain/roles.js\`; the shape only supplies facts. | \`domain/roles.js\`; callers \`tools/spawn.js\`, \`app/spawn-prechecks.js\`, \`app/spawn-support.js\`, \`domain/roster.js\`, \`host/presets.js\` | ${fitness('role-shape-conflation')} |
149
+
150
+ ## Fitness rules
151
+
152
+ Generated from \`scripts/shape-audit.mjs\` \`RULES\`; the audit is wired into \`npm run check\`.
153
+
154
+ | Rule | Owner module | Contract |
155
+ |---|---|---|
156
+ ${fitnessRules.join('\n')}
157
+ `
158
+
159
+ if (process.argv.includes('--check')) {
160
+ const actual = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
161
+ if (actual !== expected) {
162
+ console.error('shapes reference is stale; run npm run docs:generate:shapes')
163
+ process.exit(1)
164
+ }
165
+ console.log(`shapes reference: ${SHAPE_KINDS.length} shapes in sync`)
166
+ } else {
167
+ fs.mkdirSync(path.dirname(target), { recursive: true })
168
+ fs.writeFileSync(target, expected)
169
+ console.log(`wrote ${path.relative(root, target)} (${SHAPE_KINDS.length} shapes)`)
170
+ }
171
+ process.exit(0)
@@ -36,6 +36,7 @@ export const OPTIONAL_ROW_IDS = [
36
36
  'tower-import',
37
37
  'tower-leases',
38
38
  'tower-skills',
39
+ 'tower-survey',
39
40
  'tower-tests',
40
41
  ]
41
42
 
@@ -67,6 +68,8 @@ export function defaultOffToolIds() {
67
68
  export const PRESET_ROWS = Object.freeze([
68
69
  Object.freeze({ id: 'tower-worker', specifier: 'preset-worker', name: 'Tower Worker', order: 60 }),
69
70
  Object.freeze({ id: 'tower-reviewer', specifier: 'preset-reviewer', name: 'Tower Reviewer', order: 61 }),
71
+ Object.freeze({ id: 'tower-survey', specifier: 'preset-survey', name: 'Tower Survey', order: 62 }),
72
+ Object.freeze({ id: 'tower-survey-sandbox', specifier: 'preset-survey-sandbox', name: 'Tower Survey Sandbox', order: 63 }),
70
73
  ])
71
74
 
72
75
  /** Presets the bundle declares for its worker and reviewer roles. */
@@ -28,6 +28,7 @@ app/briefing.js
28
28
  app/checkCompletion.js
29
29
  app/enrichedMissions.js
30
30
  app/findings.js
31
+ app/freshness.js
31
32
  app/gate.js
32
33
  app/init.js
33
34
  app/merge.js
@@ -40,7 +41,10 @@ app/read.js
40
41
  app/rebase.js
41
42
  app/recording.js
42
43
  app/review.js
44
+ app/reviewCheckout.js
43
45
  app/roster.js
46
+ app/sandbox.js
47
+ app/sharedCheckout.js
44
48
  app/spawn-prechecks.js
45
49
  app/spawn-support.js
46
50
  app/spawn.js
@@ -54,6 +58,7 @@ docs/adr/0002-single-state-json.md
54
58
  docs/adr/0003-allow-agent-control-default-true.md
55
59
  docs/adr/0004-surface-budget.md
56
60
  docs/adr/0005-component-surface.md
61
+ docs/adr/0006-mission-shapes.md
57
62
  docs/adr/README.md
58
63
  docs/agents/domain.md
59
64
  docs/agents/issue-tracker.md
@@ -80,6 +85,7 @@ docs/reference/errors.generated.md
80
85
  docs/reference/errors.md
81
86
  docs/reference/events.md
82
87
  docs/reference/operations.md
88
+ docs/reference/shapes.md
83
89
  docs/reference/surface-budget.md
84
90
  docs/reference/testing.md
85
91
  docs/reference/tools.generated.md
@@ -116,6 +122,7 @@ domain/contracts/tool-inputs/missions.js
116
122
  domain/contracts/tool-inputs/ops.js
117
123
  domain/contracts/tool-inputs/shared.js
118
124
  domain/errors.js
125
+ domain/evidence.js
119
126
  domain/events.js
120
127
  domain/gate.js
121
128
  domain/integration/gate.js
@@ -138,8 +145,12 @@ domain/read/enrichedMissions.js
138
145
  domain/read/missionContext.js
139
146
  domain/read/views.js
140
147
  domain/report.js
148
+ domain/roles.js
141
149
  domain/roster.js
142
150
  domain/scope.js
151
+ domain/shapes/build.js
152
+ domain/shapes/index.js
153
+ domain/shapes/survey.js
143
154
  domain/state.js
144
155
  domain/state/archive.js
145
156
  domain/state/migrations.js
@@ -163,6 +174,8 @@ host/hmr.js
163
174
  host/modeState.js
164
175
  host/modeStore.js
165
176
  host/preset-reviewer.js
177
+ host/preset-survey-sandbox.js
178
+ host/preset-survey.js
166
179
  host/preset-worker.js
167
180
  host/presets.js
168
181
  host/tool-registry.js
@@ -184,6 +197,10 @@ locale/leases/en.json
184
197
  locale/leases/zh.json
185
198
  locale/preset-reviewer/en.json
186
199
  locale/preset-reviewer/zh.json
200
+ locale/preset-survey-sandbox/en.json
201
+ locale/preset-survey-sandbox/zh.json
202
+ locale/preset-survey/en.json
203
+ locale/preset-survey/zh.json
187
204
  locale/preset-worker/en.json
188
205
  locale/preset-worker/zh.json
189
206
  locale/skills/en.json
@@ -219,6 +236,7 @@ scripts/generate-component-surface.mjs
219
236
  scripts/generate-config-reference.mjs
220
237
  scripts/generate-error-reference.mjs
221
238
  scripts/generate-events-reference.mjs
239
+ scripts/generate-shapes-reference.mjs
222
240
  scripts/generate-tools-reference.mjs
223
241
  scripts/generate-types.mjs
224
242
  scripts/issue-audit.mjs
@@ -235,6 +253,7 @@ scripts/pack-manifest.txt
235
253
  scripts/regression-audit.mjs
236
254
  scripts/release-check.mjs
237
255
  scripts/release-notes.mjs
256
+ scripts/shape-audit.mjs
238
257
  scripts/surface-baseline.json
239
258
  scripts/surface-baseline.mjs
240
259
  scripts/types-coverage.mjs
@@ -244,6 +263,8 @@ skills.js
244
263
  skills/tower-recovery.md
245
264
  skills/tower-review.md
246
265
  skills/tower-worker-protocol.md
266
+ survey-sandbox.js
267
+ survey.js
247
268
  tools/adopt.js
248
269
  tools/batch.js
249
270
  tools/complete.js
@@ -0,0 +1,361 @@
1
+ // Shape audit (#149): one shape registry means generic layers must not branch on
2
+ // a kind or a role, and the old global status vocabulary must not come back.
3
+ //
4
+ // The audit is the ongoing mechanism, not a one-off cleanup: it fails on
5
+ // - a comparison against a kind literal ('build' | 'survey'),
6
+ // - a comparison against a role literal ('worker' | 'reviewer' | 'tower'),
7
+ // - the global TERMINAL_STATUSES / TRANSITIONS identifiers,
8
+ // - a one-argument isTerminal(...) or one-argument canTransition(...) call,
9
+ // - reading a review tip from a live worktree HEAD (review evidence is a commit),
10
+ // - comparing a batch status against a literal outside the batch owner module,
11
+ // - naming the lease ledger outside the lease adapter,
12
+ // - reading completion checks / test receipts outside domain/evidence.js,
13
+ // - the legacy format/status vocabulary outside adapters/legacy.js,
14
+ // - owning or re-implementing path permission instead of shape.reach + domain/scope.js,
15
+ // - combining a role decision with a shape decision outside domain/roles.js.
16
+ //
17
+ // Exemptions live in scripts/architecture-waivers.json under `shapeWaivers` and
18
+ // require a reason plus an owner or a ref, exactly like the architecture gate.
19
+ // A waiver with no reason is a contract failure, and a waiver for a path that no
20
+ // longer violates anything is reported as stale.
21
+ //
22
+ // Usage: node scripts/shape-audit.mjs [--root <path>] [--json]
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
28
+ export const WAIVER_FILE = 'scripts/architecture-waivers.json'
29
+ export const WAIVER_KEY = 'shapeWaivers'
30
+ export const SCAN_DIRS = ['domain', 'app', 'tools', 'host', 'adapters', 'ports']
31
+ export const SHAPE_DIR = 'domain/shapes'
32
+ /**
33
+ * The fitness rules this audit enforces. `docs/reference/shapes.md` is generated
34
+ * from this list, so a verdict cannot name a rule the audit does not run.
35
+ */
36
+ export const RULES = [
37
+ {
38
+ id: 'kind-literal',
39
+ rule: 'Generic layers never compare mission.kind with a literal.',
40
+ owner: 'domain/shapes/<kind>.js',
41
+ },
42
+ {
43
+ id: 'role-literal',
44
+ rule: 'Generic layers use the vocabulary role predicates, never a role literal.',
45
+ owner: 'domain/roles.js',
46
+ },
47
+ {
48
+ id: 'global-status-vocabulary',
49
+ rule: 'No module reaches for the global TERMINAL_STATUSES/TRANSITIONS tables.',
50
+ owner: 'domain/shapes/<kind>.js',
51
+ },
52
+ {
53
+ id: 'shape-entry-point',
54
+ rule: 'isTerminal(kind, status) and canTransition(kind, from, to) are the only entry points.',
55
+ owner: 'domain/shapes/index.js',
56
+ },
57
+ {
58
+ id: 'review-tip-read',
59
+ rule: 'A review never reads a live worktree HEAD; verdicts are stamped with a recorded commit.',
60
+ owner: 'app/reviewCheckout.js',
61
+ },
62
+ {
63
+ id: 'batch-status-literal',
64
+ rule: 'Batch status literals live in domain/batch.js; every other layer uses its constants.',
65
+ owner: 'domain/batch.js',
66
+ },
67
+ {
68
+ id: 'lease-ledger',
69
+ rule: 'Only adapters/leases.js reads or writes the lease ledger.',
70
+ owner: 'adapters/leases.js',
71
+ },
72
+ {
73
+ id: 'blocked-status-write',
74
+ rule: 'blocked is derived from open blockers; only domain/missions/lifecycle.js writes it.',
75
+ owner: 'domain/missions/lifecycle.js',
76
+ },
77
+ {
78
+ id: 'evidence-single-read',
79
+ rule: 'Completion checks and test receipts have one reader: domain/evidence.js.',
80
+ owner: 'domain/evidence.js',
81
+ },
82
+ {
83
+ id: 'legacy-vocabulary',
84
+ rule: 'One live vocabulary: legacy formats/statuses are read only by adapters/legacy.js.',
85
+ owner: 'adapters/legacy.js',
86
+ },
87
+ {
88
+ id: 'scope-reach',
89
+ rule: 'Path permission derives from shape.reach through domain/scope.js; no module owns or re-implements it.',
90
+ owner: 'domain/scope.js',
91
+ },
92
+ {
93
+ id: 'role-shape-conflation',
94
+ rule: 'A role decision never combines with a shape decision; role facts live in domain/roles.js.',
95
+ owner: 'domain/roles.js',
96
+ },
97
+ ]
98
+ /** Rules whose implementation module necessarily names the vocabulary it owns. */
99
+ export const RULE_EXEMPT_PATHS = {
100
+ 'batch-status-literal': ['domain/batch.js'],
101
+ 'lease-ledger': ['adapters/leases.js'],
102
+ 'blocked-status-write': ['domain/missions/lifecycle.js'],
103
+ 'evidence-single-read': ['domain/evidence.js', 'app/recording.js', 'domain/summary.js', 'domain/state/schema.js'],
104
+ 'legacy-vocabulary': ['adapters/legacy.js'],
105
+ 'scope-reach': ['domain/scope.js'],
106
+ 'role-shape-conflation': ['domain/roles.js'],
107
+ }
108
+
109
+ const KIND_PATTERN = /(?:\bkind\b\s*[!=]==?\s*['"](?:build|survey)['"])|(?:['"](?:build|survey)['"]\s*[!=]==?\s*\bkind\b)/
110
+ const ROLE_PATTERN = /(?:\brole\b\s*[!=]==?\s*['"](?:worker|reviewer|tower)['"])|(?:['"](?:worker|reviewer|tower)['"]\s*[!=]==?\s*\brole\b)/
111
+ const GLOBAL_VOCAB_PATTERN = /\b(?:TERMINAL_STATUSES|TRANSITIONS)\b/
112
+ const CALL_PATTERN = /(?<!\.)\b(isTerminal|canTransition)\s*\(([^()]*)\)/g
113
+ const REVIEW_TIP_PATTERN = /\brevParse\s*\(\s*[^)]*worktree\.path\s*,\s*['"]HEAD['"]\s*\)/
114
+ const BATCH_STATUS_PATTERN =
115
+ /(?:\bbatch\.status\s*[!=]==?\s*['"](?:open|closed|abandoned|created|active|pending)['"])|(?:['"](?:open|closed|abandoned|created|active|pending)['"]\s*[!=]==?\s*\bbatch\.status\b)/
116
+ const LEASE_LEDGER_PATTERN = /['"](?:leases\.json|leases\.json\.lock|leases\.lock)['"]/
117
+ const BLOCKED_WRITE_PATTERN = /(?:\bstatus\s*=\s*['"]blocked['"])|(?:['"]status['"]\s*:\s*['"]blocked['"])/
118
+ const EVIDENCE_READ_PATTERN = /\bmission\.testRuns\b|\btestRuns\s*[.[]|completionReport\?\.checks|completionReport\.checks/
119
+ const LEGACY_VOCABULARY_PATTERN = /\btower-codex\b|\bpausedIds\b|\bCODEX_(?:STATUSES|ID_RE)\b/
120
+ const SCOPE_FENCE_PATTERN = /\bownsScope\b|\bfileInScope\s*\(/
121
+ const ROLE_SHAPE_PATTERN = /\b(?:isWorkerRole|isReviewerRole|isTowerRole)\s*\(|\bROLE\.(?:worker|reviewer|tower)\b/
122
+ const SHAPE_DECISION_PATTERN = /\bshapeFor\s*\(|\bshape\s*[.[]/
123
+
124
+ /**
125
+ * Count top-level arguments in a call's argument text.
126
+ * @param {string} args
127
+ * @returns {number}
128
+ */
129
+ function argumentCount(args) {
130
+ const trimmed = args.trim()
131
+ if (trimmed.length === 0) return 0
132
+ let depth = 0
133
+ let count = 1
134
+ for (const char of trimmed) {
135
+ if (char === '(' || char === '[' || char === '{') depth += 1
136
+ else if (char === ')' || char === ']' || char === '}') depth -= 1
137
+ else if (char === ',' && depth === 0) count += 1
138
+ }
139
+ return count
140
+ }
141
+
142
+ /**
143
+ * @param {string} source
144
+ * @returns {Array<{ rule: string, detail: string }>}
145
+ */
146
+ export function inspectSource(source) {
147
+ const violations = []
148
+ const lines = source.split('\n')
149
+ lines.forEach((line, index) => {
150
+ const at = `line ${index + 1}`
151
+ const trimmed = line.trim()
152
+ // Documentation may quote the vocabulary it forbids; only code counts.
153
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return
154
+ if (KIND_PATTERN.test(line)) {
155
+ violations.push({ rule: 'kind-literal', detail: `${at}: ${line.trim()} — resolve shapeFor(mission.kind) instead` })
156
+ }
157
+ if (ROLE_PATTERN.test(line)) {
158
+ violations.push({ rule: 'role-literal', detail: `${at}: ${line.trim()} — use the vocabulary role predicates instead` })
159
+ }
160
+ if (GLOBAL_VOCAB_PATTERN.test(line)) {
161
+ violations.push({ rule: 'global-status-vocabulary', detail: `${at}: ${line.trim()} — use shape.isTerminal(status) / shape.transitions` })
162
+ }
163
+ if (REVIEW_TIP_PATTERN.test(line)) {
164
+ violations.push({
165
+ rule: 'review-tip-read',
166
+ detail: `${at}: ${line.trim()} — stamp reviews with a recorded commit (tower.branchTip(mission)) instead of a live worktree HEAD`,
167
+ })
168
+ }
169
+ if (BATCH_STATUS_PATTERN.test(line)) {
170
+ violations.push({
171
+ rule: 'batch-status-literal',
172
+ detail: `${at}: ${line.trim()} — use BATCH_STATUS / canTransitionBatch from domain/batch.js`,
173
+ })
174
+ }
175
+ if (LEASE_LEDGER_PATTERN.test(line)) {
176
+ violations.push({
177
+ rule: 'lease-ledger',
178
+ detail: `${at}: ${line.trim()} — the lease ledger is owned by adapters/leases.js`,
179
+ })
180
+ }
181
+ if (BLOCKED_WRITE_PATTERN.test(line)) {
182
+ violations.push({
183
+ rule: 'blocked-status-write',
184
+ detail: `${at}: ${line.trim()} — blocked is derived from open blockers: use markBlocked(mission, text)`,
185
+ })
186
+ }
187
+ if (EVIDENCE_READ_PATTERN.test(line)) {
188
+ violations.push({
189
+ rule: 'evidence-single-read',
190
+ detail: `${at}: ${line.trim()} — read evidence through domain/evidence.js (findEvidence/passingDiscovery/hasCheckEvidence)`,
191
+ })
192
+ }
193
+ if (LEGACY_VOCABULARY_PATTERN.test(line)) {
194
+ violations.push({
195
+ rule: 'legacy-vocabulary',
196
+ detail: `${at}: ${line.trim()} — legacy formats/statuses are normalised by adapters/legacy.js; do not introduce a second vocabulary`,
197
+ })
198
+ }
199
+ if (SCOPE_FENCE_PATTERN.test(line)) {
200
+ violations.push({
201
+ rule: 'scope-reach',
202
+ detail: `${at}: ${line.trim()} — path permission is shape.reach through scopeFenceBlocker(); no ownsScope flag or direct matcher`,
203
+ })
204
+ }
205
+ if (ROLE_SHAPE_PATTERN.test(line) && SHAPE_DECISION_PATTERN.test(line)) {
206
+ violations.push({
207
+ rule: 'role-shape-conflation',
208
+ detail: `${at}: ${line.trim()} — resolve the role through domain/roles.js; the shape supplies facts, not role branches`,
209
+ })
210
+ }
211
+ for (const match of line.matchAll(CALL_PATTERN)) {
212
+ const expected = match[1] === 'isTerminal' ? 2 : 3
213
+ if (argumentCount(match[2]) < expected) {
214
+ violations.push({
215
+ rule: 'shape-entry-point',
216
+ detail: `${at}: ${match[0]} — the only entry points are isTerminal(kind, status) and canTransition(kind, from, to)`,
217
+ })
218
+ }
219
+ }
220
+ })
221
+ return violations
222
+ }
223
+
224
+ /**
225
+ * @param {string} start
226
+ * @returns {string[]}
227
+ */
228
+ function walk(start) {
229
+ const files = []
230
+ if (!fs.existsSync(start)) return files
231
+ for (const entry of fs.readdirSync(start, { withFileTypes: true })) {
232
+ const full = path.join(start, entry.name)
233
+ if (entry.isDirectory()) {
234
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
235
+ files.push(...walk(full))
236
+ } else if (entry.name.endsWith('.js') || entry.name.endsWith('.mjs')) {
237
+ files.push(full)
238
+ }
239
+ }
240
+ return files
241
+ }
242
+
243
+ /**
244
+ * @param {string} repoRoot
245
+ * @returns {{ path: string, rule: string, detail: string }[]}
246
+ */
247
+ export function scan(repoRoot) {
248
+ const results = []
249
+ for (const dir of SCAN_DIRS) {
250
+ for (const file of walk(path.join(repoRoot, dir))) {
251
+ const relative = path.relative(repoRoot, file).split(path.sep).join('/')
252
+ if (relative.startsWith(`${SHAPE_DIR}/`)) continue
253
+ const source = fs.readFileSync(file, 'utf8')
254
+ for (const violation of inspectSource(source)) {
255
+ const exempt = RULE_EXEMPT_PATHS[violation.rule] ?? []
256
+ if (exempt.includes(relative)) continue
257
+ results.push({ path: relative, ...violation })
258
+ }
259
+ }
260
+ }
261
+ return results
262
+ }
263
+
264
+ /**
265
+ * @param {string} repoRoot
266
+ * @returns {{ waivers: Array<{ rule?: string, path?: string, reason?: string, owner?: string, ref?: string, match?: string }>, errors: string[] }}
267
+ */
268
+ export function loadShapeWaivers(repoRoot) {
269
+ const file = path.join(repoRoot, WAIVER_FILE)
270
+ if (!fs.existsSync(file)) return { waivers: [], errors: [`${WAIVER_FILE}: missing`] }
271
+ let document
272
+ try {
273
+ document = JSON.parse(fs.readFileSync(file, 'utf8'))
274
+ } catch (error) {
275
+ return { waivers: [], errors: [`${WAIVER_FILE}: not valid JSON (${String(error?.message ?? error)})`] }
276
+ }
277
+ const entries = document?.[WAIVER_KEY] ?? []
278
+ if (!Array.isArray(entries)) return { waivers: [], errors: [`${WAIVER_FILE}: ${WAIVER_KEY} must be an array`] }
279
+ const errors = []
280
+ for (const [index, entry] of entries.entries()) {
281
+ const where = `${WAIVER_FILE} ${WAIVER_KEY}[${index}]`
282
+ if (!entry || typeof entry !== 'object') {
283
+ errors.push(`${where}: must be an object`)
284
+ continue
285
+ }
286
+ if (typeof entry.rule !== 'string' || entry.rule.length === 0) errors.push(`${where}: rule is required`)
287
+ if (typeof entry.path !== 'string' || entry.path.length === 0) errors.push(`${where}: path is required`)
288
+ if (typeof entry.reason !== 'string' || entry.reason.length === 0) errors.push(`${where}: reason is required`)
289
+ if (!entry.owner && !entry.ref) errors.push(`${where}: owner or ref is required`)
290
+ }
291
+ return { waivers: errors.length === 0 ? entries : [], errors }
292
+ }
293
+
294
+ /**
295
+ * @param {{ path: string, rule: string, detail: string }[]} violations
296
+ * @param {Array<{ rule?: string, path?: string, match?: string }>} waivers
297
+ * @returns {{ violations: typeof violations, waived: typeof violations, unused: Array<{ rule?: string, path?: string }> }}
298
+ */
299
+ export function applyWaivers(violations, waivers) {
300
+ const waived = []
301
+ const used = new Set()
302
+ const remaining = []
303
+ for (const violation of violations) {
304
+ const index = waivers.findIndex((waiver) => {
305
+ if (waiver.path !== violation.path) return false
306
+ if (waiver.rule !== violation.rule) return false
307
+ if (waiver.match && !violation.detail.includes(waiver.match)) return false
308
+ return true
309
+ })
310
+ if (index >= 0) {
311
+ used.add(index)
312
+ waived.push(violation)
313
+ } else {
314
+ remaining.push(violation)
315
+ }
316
+ }
317
+ const unused = waivers.filter((_, index) => !used.has(index))
318
+ return { violations: remaining, waived, unused }
319
+ }
320
+
321
+ /**
322
+ * @param {string} repoRoot
323
+ * @returns {{ ok: boolean, violations: any[], waived: any[], errors: string[], unused: any[] }}
324
+ */
325
+ export function evaluator(repoRoot = root) {
326
+ const reported = []
327
+ const waivers = loadShapeWaivers(repoRoot)
328
+ if (waivers.errors.length > 0) return { ok: false, violations: [], waived: [], errors: waivers.errors, unused: [] }
329
+ const applied = applyWaivers(scan(repoRoot), waivers.waivers)
330
+ for (const violation of applied.violations) {
331
+ reported.push(`shape-audit error: [${violation.rule}] ${violation.path} ${violation.detail}`)
332
+ }
333
+ if (applied.unused.length > 0) {
334
+ for (const waiver of applied.unused) {
335
+ reported.push(`shape-audit error: [stale-waiver] ${waiver.path} ${waiver.rule} no longer matches a violation; remove it`)
336
+ }
337
+ }
338
+ return {
339
+ ok: reported.length === 0,
340
+ violations: applied.violations,
341
+ waived: applied.waived,
342
+ errors: reported,
343
+ unused: applied.unused,
344
+ }
345
+ }
346
+
347
+ function main(argv) {
348
+ const rootIndex = argv.indexOf('--root')
349
+ const repoRoot = rootIndex >= 0 ? path.resolve(argv[rootIndex + 1]) : root
350
+ const result = evaluator(repoRoot)
351
+ if (argv.includes('--json')) {
352
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
353
+ } else if (result.ok) {
354
+ process.stdout.write(`shape-audit: ok (waived: ${result.waived.length})\n`)
355
+ } else {
356
+ for (const line of result.errors) process.stderr.write(`${line}\n`)
357
+ }
358
+ process.exitCode = result.ok ? 0 : 1
359
+ }
360
+
361
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2))