arkgate 4.3.0 → 4.4.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 (51) hide show
  1. package/CHANGELOG.md +36 -10
  2. package/README.md +10 -7
  3. package/bin/ark-check-runtime.mjs +16 -1
  4. package/bin/lib/agent-projection.mjs +1 -1
  5. package/bin/lib/ci-and-commands.mjs +11 -5
  6. package/bin/lib/doctor-plan.mjs +25 -0
  7. package/bin/lib/html-report-advisories.mjs +33 -0
  8. package/bin/lib/html-report-depth.mjs +24 -0
  9. package/bin/lib/improvement-compass-doctor.mjs +106 -0
  10. package/bin/lib/improvement-compass.mjs +630 -0
  11. package/bin/lib/status-manifest.mjs +38 -1
  12. package/dist/index.cjs +33 -33
  13. package/dist/index.d.ts +159 -2
  14. package/dist/index.js +33 -33
  15. package/docs/README.md +6 -5
  16. package/docs/agent-guide.md +20 -4
  17. package/docs/develop.md +6 -3
  18. package/docs/diagnostics.md +1 -1
  19. package/docs/package-surface.md +37 -34
  20. package/docs/product-voice.md +26 -0
  21. package/docs/use.md +37 -1
  22. package/package.json +1 -1
  23. package/schemas/ark.status-manifest.schema.json +26 -0
  24. package/server.json +2 -2
  25. package/templates/agent-skills/README.md +2 -2
  26. package/templates/agent-skills/ark-adopt/SKILL.md +20 -0
  27. package/templates/agent-skills/ark-architect/SKILL.md +21 -1
  28. package/templates/agent-skills/ark-autopilot/SKILL.md +25 -5
  29. package/templates/agent-skills/ark-contract/SKILL.md +20 -0
  30. package/templates/agent-skills/ark-coverage/SKILL.md +20 -0
  31. package/templates/agent-skills/ark-explain/SKILL.md +20 -0
  32. package/templates/agent-skills/ark-explore/SKILL.md +23 -3
  33. package/templates/agent-skills/ark-fix/SKILL.md +22 -2
  34. package/templates/agent-skills/ark-loop/SKILL.md +22 -2
  35. package/templates/agent-skills/ark-place/SKILL.md +20 -0
  36. package/templates/agent-skills/ark-runtime/SKILL.md +7 -0
  37. package/templates/agent-skills/ark-think/SKILL.md +20 -0
  38. package/templates/agent-skills/ark-upgrade/SKILL.md +20 -0
  39. package/templates/skills/ark-adopt.md +20 -0
  40. package/templates/skills/ark-architect.md +21 -1
  41. package/templates/skills/ark-autopilot.md +25 -5
  42. package/templates/skills/ark-contract.md +20 -0
  43. package/templates/skills/ark-coverage.md +20 -0
  44. package/templates/skills/ark-explain.md +20 -0
  45. package/templates/skills/ark-explore.md +23 -3
  46. package/templates/skills/ark-fix.md +22 -2
  47. package/templates/skills/ark-loop.md +22 -2
  48. package/templates/skills/ark-place.md +20 -0
  49. package/templates/skills/ark-runtime.md +7 -0
  50. package/templates/skills/ark-think.md +20 -0
  51. package/templates/skills/ark-upgrade.md +20 -0
@@ -0,0 +1,630 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/improvementCompass.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/improvement-compass.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const ARK_IMPROVEMENT_COMPASS_SCHEMA_VERSION = '1.0';
12
+ /** Closed 15 lens ids (stable order for projection). */
13
+ export const IMPROVEMENT_LENS_IDS = [
14
+ 'soc',
15
+ 'cohesion',
16
+ 'coupling',
17
+ 'srp',
18
+ 'dip',
19
+ 'ocp',
20
+ 'encapsulation',
21
+ 'modularity',
22
+ 'scalability',
23
+ 'resilience',
24
+ 'security',
25
+ 'maintainability',
26
+ 'testability',
27
+ 'domain',
28
+ 'stack',
29
+ ];
30
+ /** Cap for topResidual — short, agent-legible list (not a ranking score). */
31
+ export const IMPROVEMENT_COMPASS_TOP_RESIDUAL_CAP = 5;
32
+ /** Locked out-of-scope — never become residual from missing sensors. */
33
+ export const IMPROVEMENT_COMPASS_OUT_OF_SCOPE_LENSES = [
34
+ 'scalability',
35
+ 'resilience',
36
+ 'security',
37
+ ];
38
+ const OUT_OF_SCOPE_SET = new Set(IMPROVEMENT_COMPASS_OUT_OF_SCOPE_LENSES);
39
+ /**
40
+ * Residual sort priority (lower = earlier in topResidual). Product relevance,
41
+ * not a health score. Ties break by id.
42
+ */
43
+ const RESIDUAL_SORT_PRIORITY = {
44
+ soc: 10,
45
+ coupling: 20,
46
+ dip: 30,
47
+ domain: 40,
48
+ srp: 50,
49
+ cohesion: 60,
50
+ encapsulation: 70,
51
+ modularity: 80,
52
+ testability: 90,
53
+ maintainability: 100,
54
+ ocp: 110,
55
+ stack: 120,
56
+ scalability: 200,
57
+ resilience: 200,
58
+ security: 200,
59
+ };
60
+ const LENS_LABELS = {
61
+ soc: 'Separation of concerns',
62
+ cohesion: 'High cohesion',
63
+ coupling: 'Low coupling',
64
+ srp: 'Single responsibility (architecture)',
65
+ dip: 'Dependency inversion',
66
+ ocp: 'Open/closed',
67
+ encapsulation: 'Encapsulation',
68
+ modularity: 'Modularity',
69
+ scalability: 'Scalability / performance',
70
+ resilience: 'Resilience / fault tolerance',
71
+ security: 'Security by design',
72
+ maintainability: 'Maintainability',
73
+ testability: 'Testability',
74
+ domain: 'Domain alignment',
75
+ stack: 'Stack-specific practices',
76
+ };
77
+ const OUT_OF_SCOPE_SUMMARIES = {
78
+ scalability: 'ArkGate does not measure performance or horizontal scale. Use load tests and APM outside Ark.',
79
+ resilience: 'ArkGate does not measure app resilience or chaos readiness. Structural boundaries and optional experimental runtime are not a resilience score.',
80
+ security: 'ArkGate does not run SAST or app-security tooling. Structural least-privilege of effects is partial only — not a security rating.',
81
+ };
82
+ function smellIdOf(smell) {
83
+ const raw = smell.id ?? smell.smellId ?? '';
84
+ return typeof raw === 'string' ? raw.trim() : '';
85
+ }
86
+ function violationRuleId(v) {
87
+ const raw = v.ruleId ?? v.code ?? '';
88
+ return typeof raw === 'string' ? raw.trim() : '';
89
+ }
90
+ function pushEvidence(lens, source, ref, detail) {
91
+ if (!ref)
92
+ return;
93
+ // Dedup by source+ref for deterministic stability.
94
+ if (lens.evidence.some((e) => e.source === source && e.ref === ref))
95
+ return;
96
+ const entry = { source, ref };
97
+ if (detail && detail.trim())
98
+ entry.detail = detail.trim().slice(0, 240);
99
+ lens.evidence.push(entry);
100
+ }
101
+ function markResidual(lens, summary, nextAction) {
102
+ if (OUT_OF_SCOPE_SET.has(lens.id))
103
+ return;
104
+ lens.status = 'residual';
105
+ lens.summary = summary;
106
+ if (nextAction)
107
+ lens.nextAction = nextAction;
108
+ }
109
+ function defaultOkSummary(id) {
110
+ switch (id) {
111
+ case 'soc':
112
+ return 'No separation-of-concerns residual detected from current sensors.';
113
+ case 'cohesion':
114
+ return 'No cohesion residual (god-module / physical cohesion) from current sensors.';
115
+ case 'coupling':
116
+ return 'No coupling residual (import edges, cycles, peer isolation) from current sensors.';
117
+ case 'srp':
118
+ return 'No single-responsibility residual from current sensors.';
119
+ case 'dip':
120
+ return 'No dependency-inversion residual (pure / capability / forbidden walls) from current sensors.';
121
+ case 'ocp':
122
+ return 'Open/closed is not strongly instrumented — no switch-chain sensor.';
123
+ case 'encapsulation':
124
+ return 'No encapsulation residual from ArkRules structure sensors.';
125
+ case 'modularity':
126
+ return 'No modularity / placement residual from current sensors.';
127
+ case 'maintainability':
128
+ return 'No maintainability residual (design-weak / baseline honesty) from current sensors.';
129
+ case 'testability':
130
+ return 'No testability residual (impure domain / capability walls) from current sensors.';
131
+ case 'domain':
132
+ return 'No domain-alignment residual from current sensors.';
133
+ case 'stack':
134
+ return 'Stack practices are only partially instrumented (TypeScript / host / Ark idioms).';
135
+ default:
136
+ return `${LENS_LABELS[id]} — no residual from current sensors.`;
137
+ }
138
+ }
139
+ function initLenses() {
140
+ return IMPROVEMENT_LENS_IDS.map((id) => {
141
+ if (OUT_OF_SCOPE_SET.has(id)) {
142
+ const key = id;
143
+ return {
144
+ id,
145
+ status: 'out-of-scope',
146
+ summary: OUT_OF_SCOPE_SUMMARIES[key],
147
+ evidence: [],
148
+ nextAction: {
149
+ kind: 'docs',
150
+ ref: 'docs/use.md#improvement-compass',
151
+ summary: 'Out of scope for ArkGate — use dedicated tooling outside the gate.',
152
+ },
153
+ };
154
+ }
155
+ if (id === 'ocp') {
156
+ return {
157
+ id,
158
+ status: 'not-instrumented',
159
+ summary: defaultOkSummary(id),
160
+ evidence: [],
161
+ };
162
+ }
163
+ return {
164
+ id,
165
+ status: 'ok',
166
+ summary: defaultOkSummary(id),
167
+ evidence: [],
168
+ };
169
+ });
170
+ }
171
+ function mapDesignSmells(byId, smells) {
172
+ for (const smell of smells) {
173
+ const id = smellIdOf(smell);
174
+ if (!id)
175
+ continue;
176
+ const detail = smell.outcome || smell.message || undefined;
177
+ const evidencePaths = Array.isArray(smell.evidence) ? smell.evidence : [];
178
+ const pathHint = evidencePaths[0];
179
+ const attach = (lensId, summary, action) => {
180
+ const lens = byId.get(lensId);
181
+ if (!lens || OUT_OF_SCOPE_SET.has(lensId))
182
+ return;
183
+ pushEvidence(lens, 'designSmells', id, detail);
184
+ if (pathHint)
185
+ pushEvidence(lens, 'designSmells', pathHint, id);
186
+ markResidual(lens, summary, action);
187
+ };
188
+ const shapeAction = {
189
+ kind: 'skill',
190
+ ref: '/ark-explore',
191
+ summary: 'Map Shape residual (shape-focus), then one extraction pilot with user OK.',
192
+ };
193
+ const dipAction = {
194
+ kind: 'skill',
195
+ ref: '/ark-fix',
196
+ summary: 'Inject a port/adapter for I/O; keep domain pure.',
197
+ };
198
+ switch (id) {
199
+ case 'domain-logic-in-ui':
200
+ attach('soc', 'Business rules still mix with UI or presentation surfaces.', shapeAction);
201
+ attach('domain', 'Domain logic lives outside Domain — align rules with Domain ownership.', shapeAction);
202
+ break;
203
+ case 'facade-sql-in-routes':
204
+ attach('soc', 'Routes/controllers own SQL or ORM access — concerns are mixed.', shapeAction);
205
+ attach('dip', 'Transport depends on concrete persistence instead of a port.', dipAction);
206
+ break;
207
+ case 'io-under-application':
208
+ attach('soc', 'Application/business code reaches I/O directly — separation is weak.', shapeAction);
209
+ attach('dip', 'I/O is not inverted behind ports/adapters.', dipAction);
210
+ attach('testability', 'Direct I/O under application code hurts pure unit testing.', dipAction);
211
+ break;
212
+ case 'handler-in-persistence':
213
+ attach('soc', 'HTTP/transport handlers live under persistence folders.', shapeAction);
214
+ break;
215
+ case 'god-module':
216
+ attach('cohesion', 'Large multi-responsibility modules reduce cohesion.', shapeAction);
217
+ attach('srp', 'God modules own too many responsibilities — split by concern (one pilot).', {
218
+ kind: 'skill',
219
+ ref: '/ark-autopilot',
220
+ summary: 'One Shape pilot with user OK — never multi-pilot batch.',
221
+ });
222
+ break;
223
+ case 'mixed-pattern-cluster':
224
+ attach('modularity', 'Multiple layout styles coexist — placement is unclear for the next AI turn.', {
225
+ kind: 'skill',
226
+ ref: '/ark-explore',
227
+ summary: 'Pick a golden pattern and migrate one pilot cluster on touch.',
228
+ });
229
+ attach('cohesion', 'Mixed layout styles scatter the same concern across patterns.', shapeAction);
230
+ break;
231
+ case 'soft-contract':
232
+ attach('maintainability', 'Soft contract walls (layers without deny rules) hide maintainability debt.', {
233
+ kind: 'skill',
234
+ ref: '/ark-contract',
235
+ summary: 'Add real layer rules so the AI has hard walls.',
236
+ });
237
+ attach('coupling', 'Layers with files but almost no deny rules allow free peer coupling.', {
238
+ kind: 'skill',
239
+ ref: '/ark-contract',
240
+ summary: 'Tighten inter-layer allows/denies without weakening enforcement.',
241
+ });
242
+ break;
243
+ default:
244
+ // Unknown smell ids still feed maintainability residual (honest residual,
245
+ // not out-of-scope invention).
246
+ attach('maintainability', 'Design residual remains under an unrecognized smell id — review evidence.', shapeAction);
247
+ break;
248
+ }
249
+ }
250
+ }
251
+ function isTypeOnlyPlacementDebt(v) {
252
+ // Product voice: type-only edges are placement debt (failsStrict:false), not runtime coupling.
253
+ if (v.failsStrict === false)
254
+ return true;
255
+ if (v.typeOnly === true)
256
+ return true;
257
+ return false;
258
+ }
259
+ function mapViolations(byId, violations) {
260
+ for (const v of violations) {
261
+ const ruleId = violationRuleId(v);
262
+ if (!ruleId)
263
+ continue;
264
+ const detail = v.message;
265
+ const upper = ruleId.toUpperCase();
266
+ const attach = (lensId, summary, action) => {
267
+ const lens = byId.get(lensId);
268
+ if (!lens || OUT_OF_SCOPE_SET.has(lensId))
269
+ return;
270
+ pushEvidence(lens, 'violations', ruleId, detail);
271
+ if (v.file)
272
+ pushEvidence(lens, 'violations', v.file, ruleId);
273
+ markResidual(lens, summary, action);
274
+ };
275
+ // Type-only / non-blocking placement debt → modularity only (never coupling / DIP residual).
276
+ if (isTypeOnlyPlacementDebt(v)) {
277
+ attach('modularity', 'Type-only placement debt remains — prefer SharedTypes / owning layer (not runtime coupling).', {
278
+ kind: 'skill',
279
+ ref: '/ark-place',
280
+ summary: 'Place shared types in a layer both sides may import; type-only debt is not a value edge.',
281
+ });
282
+ continue;
283
+ }
284
+ const edgeAction = {
285
+ kind: 'skill',
286
+ ref: '/ark-fix',
287
+ summary: 'Clear the active edge residual, then re-doctor.',
288
+ };
289
+ if (upper === 'LAYER_IMPORT_VIOLATION' ||
290
+ upper.includes('LAYER_IMPORT') ||
291
+ upper === 'DYNAMIC_IMPORT_VIOLATION') {
292
+ attach('coupling', 'Import graph edges violate the layer contract.', edgeAction);
293
+ continue;
294
+ }
295
+ if (upper.includes('CYCLE') || upper === 'CIRCULAR_DEPENDENCY') {
296
+ attach('coupling', 'Import cycles couple modules tightly.', edgeAction);
297
+ continue;
298
+ }
299
+ if (upper.includes('PEER_ISOLATION') || upper === 'PEER_ISOLATION_VIOLATION') {
300
+ attach('coupling', 'Peer isolation residual — slices import each other freely.', {
301
+ kind: 'skill',
302
+ ref: '/ark-loop',
303
+ summary: 'Peer isolation fixes are judgment-class — one cluster at a time.',
304
+ });
305
+ continue;
306
+ }
307
+ if (upper === 'FORBIDDEN_GLOBAL' || upper.startsWith('FORBIDDEN_')) {
308
+ attach('dip', 'Forbidden globals / effect surfaces break dependency inversion.', {
309
+ kind: 'skill',
310
+ ref: '/ark-fix',
311
+ summary: 'Inject a port instead of the forbidden global.',
312
+ });
313
+ attach('testability', 'Forbidden ambient effects reduce pure-domain testability.', {
314
+ kind: 'skill',
315
+ ref: '/ark-fix',
316
+ summary: 'Replace ambient effects with injectable ports.',
317
+ });
318
+ continue;
319
+ }
320
+ if (upper === 'CAPABILITY_VIOLATION') {
321
+ attach('dip', 'Denied capability use — invert through an allowed adapter/port.', {
322
+ kind: 'skill',
323
+ ref: '/ark-fix',
324
+ summary: 'Capability walls require port injection (judgment, not mechanical-safe).',
325
+ });
326
+ attach('testability', 'Capability violations couple domain code to I/O — harder to unit-test.', {
327
+ kind: 'skill',
328
+ ref: '/ark-fix',
329
+ summary: 'Keep pure layers free of denied capabilities.',
330
+ });
331
+ continue;
332
+ }
333
+ if (upper.startsWith('ARKRULE_') || upper === 'INVARIANT_UNCOVERED') {
334
+ attach('encapsulation', 'ArkRules structure / invariant residual inside a layer.', {
335
+ kind: 'skill',
336
+ ref: '/ark-fix',
337
+ summary: 'Label [ArkRules]; structure fixes are judgment — never invent mechanical-safe.',
338
+ });
339
+ attach('domain', 'Intra-layer domain structure or invariant coverage residual.', {
340
+ kind: 'skill',
341
+ ref: '/ark-explore',
342
+ summary: 'Inventory candidates → one ArkRules pilot with coverage evidence.',
343
+ });
344
+ continue;
345
+ }
346
+ }
347
+ }
348
+ function mapCountsAndFlags(byId, facts) {
349
+ const cycleCount = Number(facts.cycleCount) || 0;
350
+ if (cycleCount > 0) {
351
+ const lens = byId.get('coupling');
352
+ pushEvidence(lens, 'cycles', `count:${cycleCount}`);
353
+ markResidual(lens, 'Import cycles couple modules tightly.', {
354
+ kind: 'skill',
355
+ ref: '/ark-fix',
356
+ summary: 'Break cycles with a judgment extraction — one pilot.',
357
+ });
358
+ }
359
+ const peer = typeof facts.peerIsolationCount === 'boolean'
360
+ ? facts.peerIsolationCount
361
+ ? 1
362
+ : 0
363
+ : Number(facts.peerIsolationCount) || 0;
364
+ if (peer > 0) {
365
+ const lens = byId.get('coupling');
366
+ pushEvidence(lens, 'peerIsolation', `count:${peer}`);
367
+ markResidual(lens, 'Peer isolation residual remains.', {
368
+ kind: 'skill',
369
+ ref: '/ark-loop',
370
+ summary: 'Peer isolation is judgment-class residual.',
371
+ });
372
+ }
373
+ const pc = Number(facts.physicalCohesionFindingCount) || 0;
374
+ if (pc > 0) {
375
+ const cohesion = byId.get('cohesion');
376
+ pushEvidence(cohesion, 'physicalCohesion', `findings:${pc}`);
377
+ markResidual(cohesion, 'Physical cohesion residual — mirrored concept clusters across anchors.', {
378
+ kind: 'skill',
379
+ ref: '/ark-explore',
380
+ summary: 'Review reshape pilot; one decision-aware pilot at a time.',
381
+ });
382
+ const srp = byId.get('srp');
383
+ pushEvidence(srp, 'physicalCohesion', `findings:${pc}`);
384
+ markResidual(srp, 'Mirrored clusters suggest split-by-concern residual (architecture SRP).', {
385
+ kind: 'skill',
386
+ ref: '/ark-autopilot',
387
+ summary: 'One reshape/extraction pilot with user OK.',
388
+ });
389
+ }
390
+ const pureN = Number(facts.pureOrCapabilityResidual) || 0;
391
+ const fgN = Number(facts.forbiddenGlobalResidual) || 0;
392
+ if (pureN > 0 || fgN > 0) {
393
+ const dip = byId.get('dip');
394
+ if (pureN > 0)
395
+ pushEvidence(dip, 'capability', `residual:${pureN}`);
396
+ if (fgN > 0)
397
+ pushEvidence(dip, 'forbiddenGlobals', `residual:${fgN}`);
398
+ markResidual(dip, 'Pure / capability / forbidden residual weakens dependency inversion.', {
399
+ kind: 'skill',
400
+ ref: '/ark-fix',
401
+ summary: 'Inject ports; keep pure layers free of effects.',
402
+ });
403
+ const test = byId.get('testability');
404
+ if (pureN > 0)
405
+ pushEvidence(test, 'capability', `residual:${pureN}`);
406
+ if (fgN > 0)
407
+ pushEvidence(test, 'forbiddenGlobals', `residual:${fgN}`);
408
+ markResidual(test, 'Impure domain or capability residual reduces testability.', {
409
+ kind: 'skill',
410
+ ref: '/ark-fix',
411
+ summary: 'Prefer ports over concrete I/O in pure/domain modules.',
412
+ });
413
+ }
414
+ const arkN = Number(facts.arkRulesStructureResidual) || 0;
415
+ if (arkN > 0) {
416
+ const enc = byId.get('encapsulation');
417
+ pushEvidence(enc, 'arkRules', `structureResidual:${arkN}`);
418
+ markResidual(enc, 'ArkRules structure residual — encapsulation inside the layer.', {
419
+ kind: 'skill',
420
+ ref: '/ark-fix',
421
+ summary: 'Fix structure sensors under [ArkRules] without inventing mechanical-safe.',
422
+ });
423
+ const domain = byId.get('domain');
424
+ pushEvidence(domain, 'arkRules', `structureResidual:${arkN}`);
425
+ markResidual(domain, 'ArkRules residual may mean domain shape is not yet under contract.', {
426
+ kind: 'skill',
427
+ ref: '/ark-explore',
428
+ summary: 'Map inventory candidates; one pilot rule at a time.',
429
+ });
430
+ }
431
+ else if (facts.arkRulesLoaded === false || facts.arkRulesLoaded == null) {
432
+ // No ArkRules → encapsulation stays ok (absence is valid), not residual.
433
+ // Domain remains ok unless other evidence marked it.
434
+ }
435
+ if (facts.designWeak === true) {
436
+ const m = byId.get('maintainability');
437
+ pushEvidence(m, 'designFitness', 'design-weak');
438
+ markResidual(m, 'Design-weak: checked edges may be clean, but design residual remains — not finished.', {
439
+ kind: 'skill',
440
+ ref: '/ark-explore',
441
+ summary: 'Shape door: explore shape-focus → dual-plan B → one pilot with OK.',
442
+ });
443
+ }
444
+ if (facts.dirtyBaselineRisk === true || (Number(facts.baselineStale) || 0) > 0) {
445
+ const m = byId.get('maintainability');
446
+ if (facts.dirtyBaselineRisk === true) {
447
+ pushEvidence(m, 'baseline', 'dirty-freeze-risk');
448
+ }
449
+ if ((Number(facts.baselineStale) || 0) > 0) {
450
+ pushEvidence(m, 'baseline', `stale:${facts.baselineStale}`);
451
+ }
452
+ markResidual(m, 'Baseline honesty residual — frozen debt or stale keys need review.', {
453
+ kind: 'command',
454
+ ref: 'ark-check --doctor',
455
+ summary: 'Review baseline freeze honesty; do not freeze new wrong debt.',
456
+ });
457
+ }
458
+ // Large frozen residual (baseline exists with many freezes) is maintainability debt —
459
+ // only when there is already a residual signal or a substantial freeze surface.
460
+ const frozenN = Number(facts.frozenResidual) || 0;
461
+ if (facts.baselineExists === true && frozenN >= 10 && byId.get('maintainability').status !== 'residual') {
462
+ const m = byId.get('maintainability');
463
+ pushEvidence(m, 'baseline', `frozen:${frozenN}`);
464
+ markResidual(m, 'Substantial frozen residual remains under the baseline — review debt honestly.', {
465
+ kind: 'command',
466
+ ref: 'ark-check --doctor',
467
+ summary: 'Review freezes; do not freeze new wrong debt to clear residual.',
468
+ });
469
+ }
470
+ const ungov = Number(facts.ungovernedDirCount) || 0;
471
+ const emptyL = Number(facts.emptyLayerCount) || 0;
472
+ if (ungov > 0 || emptyL > 0) {
473
+ const mod = byId.get('modularity');
474
+ if (ungov > 0)
475
+ pushEvidence(mod, 'coverage', `ungovernedDirs:${ungov}`);
476
+ if (emptyL > 0)
477
+ pushEvidence(mod, 'coverage', `emptyLayers:${emptyL}`);
478
+ markResidual(mod, 'Placement / modularity residual — ungoverned dirs or empty layer globs.', {
479
+ kind: 'skill',
480
+ ref: '/ark-contract',
481
+ summary: 'Classify ungoverned paths; fix empty layer patterns.',
482
+ });
483
+ }
484
+ // Missing golden pattern under design-weak → modularity residual (AI placement cue).
485
+ if (facts.designWeak === true && facts.goldenPatternPresent === false) {
486
+ const mod = byId.get('modularity');
487
+ pushEvidence(mod, 'goldenPattern', 'absent');
488
+ markResidual(mod, 'Design-weak without a golden pattern — new code lacks a placement norm for the AI.', {
489
+ kind: 'skill',
490
+ ref: '/ark-place',
491
+ summary: 'Record an advisory golden pattern for new code (does not clear design-weak).',
492
+ });
493
+ }
494
+ // Stack: TypeScript host partially instrumented; unknown → not-instrumented.
495
+ const stack = byId.get('stack');
496
+ const kind = facts.stackKind ?? null;
497
+ if (kind === 'typescript') {
498
+ // Partial instrumentation is still honest `ok` when no residual evidence.
499
+ if (stack.status === 'ok') {
500
+ stack.summary =
501
+ 'Stack practices are partially instrumented for TypeScript / host / Ark idioms only — not a full framework checklist.';
502
+ }
503
+ }
504
+ else {
505
+ stack.status = 'not-instrumented';
506
+ stack.summary =
507
+ 'Stack-specific best practices outside TypeScript/host/Ark idioms are not instrumented.';
508
+ stack.evidence = [];
509
+ stack.nextAction = {
510
+ kind: 'docs',
511
+ ref: 'docs/use.md#improvement-compass',
512
+ summary: 'Ark does not score non-TS stack idioms.',
513
+ };
514
+ }
515
+ }
516
+ function finalizeTopResidual(lenses) {
517
+ const residual = lenses
518
+ .filter((l) => l.status === 'residual' && !OUT_OF_SCOPE_SET.has(l.id))
519
+ .slice()
520
+ .sort((a, b) => {
521
+ const pa = RESIDUAL_SORT_PRIORITY[a.id] ?? 150;
522
+ const pb = RESIDUAL_SORT_PRIORITY[b.id] ?? 150;
523
+ if (pa !== pb)
524
+ return pa - pb;
525
+ return a.id.localeCompare(b.id);
526
+ });
527
+ return residual.slice(0, IMPROVEMENT_COMPASS_TOP_RESIDUAL_CAP).map((l) => l.id);
528
+ }
529
+ function humanLabel(id) {
530
+ return LENS_LABELS[id] ?? id;
531
+ }
532
+ /**
533
+ * Build a deterministic improvement compass from supplied doctor-side facts.
534
+ * Always returns all 15 lenses; always `notAScore: true`.
535
+ */
536
+ export function buildImprovementCompass(facts = {}) {
537
+ const lenses = initLenses();
538
+ const byId = new Map(lenses.map((l) => [l.id, l]));
539
+ if (Array.isArray(facts.designSmells) && facts.designSmells.length > 0) {
540
+ // Sort by smell id so projection is input-order independent.
541
+ const smells = [...facts.designSmells].sort((a, b) => smellIdOf(a).localeCompare(smellIdOf(b)));
542
+ mapDesignSmells(byId, smells);
543
+ }
544
+ if (Array.isArray(facts.violations) && facts.violations.length > 0) {
545
+ const violations = [...facts.violations].sort((a, b) => {
546
+ const ra = violationRuleId(a).localeCompare(violationRuleId(b));
547
+ if (ra !== 0)
548
+ return ra;
549
+ return String(a.file ?? '').localeCompare(String(b.file ?? ''));
550
+ });
551
+ mapViolations(byId, violations);
552
+ }
553
+ mapCountsAndFlags(byId, facts);
554
+ // Hard lock: out-of-scope can never be residual, even if bad facts arrive.
555
+ for (const id of IMPROVEMENT_COMPASS_OUT_OF_SCOPE_LENSES) {
556
+ const lens = byId.get(id);
557
+ lens.status = 'out-of-scope';
558
+ lens.summary = OUT_OF_SCOPE_SUMMARIES[id];
559
+ lens.evidence = [];
560
+ lens.nextAction = {
561
+ kind: 'docs',
562
+ ref: 'docs/use.md#improvement-compass',
563
+ summary: 'Out of scope for ArkGate — use dedicated tooling outside the gate.',
564
+ };
565
+ }
566
+ // Stable evidence order per lens (source then ref).
567
+ for (const lens of lenses) {
568
+ lens.evidence.sort((a, b) => {
569
+ const s = a.source.localeCompare(b.source);
570
+ if (s !== 0)
571
+ return s;
572
+ return a.ref.localeCompare(b.ref);
573
+ });
574
+ }
575
+ const topResidual = finalizeTopResidual(lenses);
576
+ return {
577
+ schemaVersion: ARK_IMPROVEMENT_COMPASS_SCHEMA_VERSION,
578
+ notAScore: true,
579
+ lenses: lenses.map((l) => {
580
+ const out = {
581
+ id: l.id,
582
+ status: l.status,
583
+ summary: l.summary,
584
+ evidence: l.evidence.map((e) => ({ ...e })),
585
+ };
586
+ if (l.nextAction) {
587
+ out.nextAction = { ...l.nextAction };
588
+ }
589
+ return out;
590
+ }),
591
+ topResidual,
592
+ };
593
+ }
594
+ /**
595
+ * Plain residual lens names for human doctor / compact router (never a score).
596
+ */
597
+ export function formatImprovementCompassResidualLabels(compass) {
598
+ return compass.topResidual.map((id) => humanLabel(id));
599
+ }
600
+ /**
601
+ * Primary next action from the first residual lens that carries one.
602
+ */
603
+ export function primaryImprovementCompassNextAction(compass) {
604
+ for (const id of compass.topResidual) {
605
+ const lens = compass.lenses.find((l) => l.id === id);
606
+ if (lens?.nextAction)
607
+ return { ...lens.nextAction };
608
+ }
609
+ return null;
610
+ }
611
+ /**
612
+ * Human doctor lines (no score bar). Caller prefixes section header.
613
+ */
614
+ export function formatImprovementCompassDoctorLines(compass) {
615
+ const residual = formatImprovementCompassResidualLabels(compass);
616
+ const outOfScope = IMPROVEMENT_COMPASS_OUT_OF_SCOPE_LENSES.map((id) => humanLabel(id));
617
+ const next = primaryImprovementCompassNextAction(compass);
618
+ const lines = [];
619
+ if (residual.length > 0) {
620
+ lines.push(`Residual: ${residual.join(' · ')}`);
621
+ }
622
+ else {
623
+ lines.push('Residual: none on instrumented lenses (not a score — green edges ≠ finished design).');
624
+ }
625
+ lines.push(`Out of scope (honest): ${outOfScope.join(' · ')}`);
626
+ if (next) {
627
+ lines.push(`Next: ${next.ref} — ${next.summary}`);
628
+ }
629
+ return lines;
630
+ }
@@ -278,7 +278,7 @@ export function buildStatusManifest(facts) {
278
278
  ...(binding.code ? { code: binding.code } : {}),
279
279
  ...(binding.message ? { message: binding.message } : {}),
280
280
  };
281
- return {
281
+ const status = {
282
282
  schemaVersion: ARK_STATUS_MANIFEST_SCHEMA_VERSION,
283
283
  arkgateVersion: typeof facts.arkgateVersion === 'string' && facts.arkgateVersion.length > 0
284
284
  ? facts.arkgateVersion
@@ -289,6 +289,28 @@ export function buildStatusManifest(facts) {
289
289
  rules,
290
290
  nextAction: resolveStatusNextAction(facts, binding, activation, lastCheck, rules),
291
291
  };
292
+ const compass = normalizeStatusImprovementCompass(facts.improvementCompass);
293
+ if (compass)
294
+ status.improvementCompass = compass;
295
+ return status;
296
+ }
297
+ function normalizeStatusImprovementCompass(value) {
298
+ if (value == null || typeof value !== 'object')
299
+ return null;
300
+ if (value.notAScore !== true)
301
+ return null;
302
+ if (value.schemaVersion !== '1.0')
303
+ return null;
304
+ if (!Array.isArray(value.topResidual))
305
+ return null;
306
+ const topResidual = value.topResidual
307
+ .filter((id) => typeof id === 'string' && id.length > 0)
308
+ .slice(0, 15);
309
+ return {
310
+ schemaVersion: '1.0',
311
+ notAScore: true,
312
+ topResidual,
313
+ };
292
314
  }
293
315
  function numberOrNull(value) {
294
316
  if (value == null)
@@ -390,5 +412,20 @@ export const ARK_STATUS_MANIFEST_SCHEMA = {
390
412
  summary: { type: 'string', minLength: 1 },
391
413
  },
392
414
  },
415
+ improvementCompass: {
416
+ type: 'object',
417
+ description: 'Optional thin improvement-compass residual ids (notAScore). Never a gate input; full lenses on doctor JSON.',
418
+ additionalProperties: false,
419
+ required: ['schemaVersion', 'notAScore', 'topResidual'],
420
+ properties: {
421
+ schemaVersion: { const: '1.0' },
422
+ notAScore: { const: true },
423
+ topResidual: {
424
+ type: 'array',
425
+ items: { type: 'string', minLength: 1 },
426
+ maxItems: 15,
427
+ },
428
+ },
429
+ },
393
430
  },
394
431
  };