arkgate 3.6.1 → 3.8.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 (83) hide show
  1. package/CHANGELOG.md +122 -1145
  2. package/README.md +59 -19
  3. package/bin/ark-check-runtime.mjs +1598 -0
  4. package/bin/ark-check.mjs +32 -1565
  5. package/bin/ark-layer-match.mjs +2 -1
  6. package/bin/ark-mcp-runtime.mjs +1976 -0
  7. package/bin/ark-mcp.mjs +84 -1495
  8. package/bin/ark-shared.mjs +34 -38
  9. package/bin/ark.mjs +33 -66
  10. package/bin/lib/adapter-contract.mjs +161 -9
  11. package/bin/lib/agent-gates.mjs +1 -0
  12. package/bin/lib/analysis-completeness.mjs +28 -0
  13. package/bin/lib/analysis-engine.mjs +8 -8
  14. package/bin/lib/analysis-policy.mjs +27 -0
  15. package/bin/lib/architecture-scan.mjs +70 -304
  16. package/bin/lib/auto-patch.mjs +76 -8
  17. package/bin/lib/ci-and-commands.mjs +1 -1
  18. package/bin/lib/codex-home.mjs +43 -16
  19. package/bin/lib/design-delta.mjs +4 -0
  20. package/bin/lib/design-smells.mjs +67 -14
  21. package/bin/lib/doctor-advisories.mjs +23 -7
  22. package/bin/lib/doctor-plan.mjs +44 -47
  23. package/bin/lib/enforcement-state.mjs +2 -0
  24. package/bin/lib/github-enforcement.mjs +443 -0
  25. package/bin/lib/hook-templates.mjs +12 -148
  26. package/bin/lib/html-report-advisories.mjs +59 -0
  27. package/bin/lib/html-report-depth.mjs +9 -0
  28. package/bin/lib/html-report.mjs +5 -5
  29. package/bin/lib/install-migrate.mjs +83 -79
  30. package/bin/lib/managed-upgrade.mjs +622 -0
  31. package/bin/lib/mcp-adoption.mjs +3 -1
  32. package/bin/lib/parse-health.mjs +75 -0
  33. package/bin/lib/port-proof.mjs +2 -2
  34. package/bin/lib/prepare-change.mjs +68 -38
  35. package/bin/lib/prepare-write.mjs +7 -1
  36. package/bin/lib/reshape-decisions.mjs +284 -0
  37. package/bin/lib/resident-doctor-client.mjs +55 -0
  38. package/bin/lib/resident-hook.mjs +247 -0
  39. package/bin/lib/resolved-candidate-facts.mjs +1160 -0
  40. package/bin/lib/scan-files.mjs +19 -6
  41. package/bin/lib/snippet-analysis.mjs +119 -0
  42. package/bin/lib/source-policy.mjs +24 -0
  43. package/bin/lib/typescript-host.mjs +15 -18
  44. package/bin/lib/unavailable-analysis.mjs +76 -0
  45. package/bin/lib/upgrade-command.mjs +115 -0
  46. package/bin/lib/weakest-link.mjs +21 -179
  47. package/bin/lib/write-path-capabilities.mjs +167 -16
  48. package/bin/lib/write-path-detect.mjs +3 -2
  49. package/dist/eslint/index.cjs +3 -3
  50. package/dist/eslint/index.d.ts +3 -0
  51. package/dist/eslint/index.js +3 -3
  52. package/dist/index.cjs +7 -7
  53. package/dist/index.d.ts +1073 -141
  54. package/dist/index.js +7 -7
  55. package/docs/agent-guide.md +127 -52
  56. package/docs/ai-gates.md +100 -18
  57. package/docs/configuration.md +6 -0
  58. package/docs/demos/01-write-gate-self-correction.md +2 -2
  59. package/docs/enthusiast/README.md +10 -10
  60. package/docs/enthusiast/how-to-gallery-starter.md +2 -2
  61. package/docs/enthusiast/reference-commands.md +18 -1
  62. package/docs/enthusiast/tutorial-first-project.md +2 -2
  63. package/docs/package-surface.md +101 -14
  64. package/docs/typescript-support.md +118 -37
  65. package/package.json +33 -4
  66. package/schemas/ark.analysis-result.schema.json +159 -2
  67. package/schemas/ark.design-delta.schema.json +1 -0
  68. package/schemas/ark.enforcement-state.schema.json +84 -0
  69. package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
  70. package/server.json +2 -2
  71. package/templates/skills/ark-autopilot.md +12 -0
  72. package/templates/skills/ark-explore.md +12 -5
  73. package/templates/skills/ark-fix.md +12 -2
  74. package/templates/skills/ark-loop.md +14 -1
  75. package/templates/skills/ark-runtime.md +15 -8
  76. package/templates/skills/ark-upgrade.md +122 -182
  77. package/bin/lib/ai-velocity.mjs +0 -293
  78. package/bin/lib/graph-cycles.mjs +0 -6
  79. package/bin/lib/safety-diagnostics.mjs +0 -284
  80. package/bin/lib/ts-resolve.mjs +0 -227
  81. package/dist/configTypes-DAPvBqK6.d.cts +0 -61
  82. package/dist/eslint/index.d.cts +0 -146
  83. package/dist/index.d.cts +0 -986
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Y03/Z02 — parse honesty over the ASTs already created by architecture-scan.
3
+ * Counts are transported without raw TypeScript diagnostics; Z02 maps affected
4
+ * governed files to a partial analysis verdict.
5
+ */
6
+
7
+ export const PARSE_HEALTH_FILE_CAP = 12;
8
+
9
+ function unavailableParseHealth(scannedFiles = 0) {
10
+ return {
11
+ advisory: true,
12
+ available: false,
13
+ status: 'unavailable',
14
+ scannedFiles,
15
+ affectedFiles: 0,
16
+ diagnosticCount: 0,
17
+ files: [],
18
+ truncated: 0,
19
+ overflow: false,
20
+ };
21
+ }
22
+
23
+ /** Aggregate cached/per-file parse counts into a deterministic doctor surface. */
24
+ export function summarizeParseHealth(scanned) {
25
+ if (!Array.isArray(scanned)) return unavailableParseHealth();
26
+ const rows = scanned
27
+ .map(({ relFile, entry }) => ({
28
+ file: relFile,
29
+ diagnosticCount: entry?.parseDiagnosticCount,
30
+ }));
31
+ if (rows.some(({ file, diagnosticCount }) =>
32
+ typeof file !== 'string' || file.length === 0 ||
33
+ !Number.isSafeInteger(diagnosticCount) || diagnosticCount < 0
34
+ )) return unavailableParseHealth(rows.length);
35
+ const affected = rows
36
+ .filter(({ diagnosticCount }) => diagnosticCount > 0)
37
+ .sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
38
+ let diagnosticCount = 0;
39
+ for (const row of affected) {
40
+ if (!Number.isSafeInteger(diagnosticCount + row.diagnosticCount)) {
41
+ return unavailableParseHealth(rows.length);
42
+ }
43
+ diagnosticCount += row.diagnosticCount;
44
+ }
45
+ const truncated = Math.max(0, affected.length - PARSE_HEALTH_FILE_CAP);
46
+ return {
47
+ advisory: true,
48
+ available: true,
49
+ status: affected.length > 0 ? 'parse-diagnostics' : 'ok',
50
+ scannedFiles: rows.length,
51
+ affectedFiles: affected.length,
52
+ diagnosticCount,
53
+ files: affected.slice(0, PARSE_HEALTH_FILE_CAP),
54
+ truncated,
55
+ overflow: truncated > 0,
56
+ };
57
+ }
58
+
59
+ /** Human doctor detail; clean parse health stays quiet. */
60
+ export function printParseHealthSection(health, io) {
61
+ if (!health || health.affectedFiles === 0) return;
62
+ console.log('');
63
+ console.log(io.color.bold('Parse health (analysis completeness)'));
64
+ io.line(
65
+ io.warn,
66
+ `${health.affectedFiles} governed file(s) carry ${health.diagnosticCount} parse diagnostic(s) across ${health.scannedFiles} scanned file(s).`
67
+ );
68
+ for (const finding of health.files ?? []) {
69
+ io.line(io.warn, `${finding.file} — ${finding.diagnosticCount} parse diagnostic(s)`);
70
+ }
71
+ if (health.truncated > 0) {
72
+ io.line(' ', io.color.dim(`…(+${health.truncated} more affected file(s); list capped)`));
73
+ }
74
+ io.line(' ', io.color.dim('analysis incomplete — plan cannot be satisfied and strict merge fails closed'));
75
+ }
@@ -26,14 +26,14 @@ import path from 'node:path';
26
26
  /**
27
27
  * @param {object} ts typescript module
28
28
  * @param {string} source
29
- * @param {{ filePath?: string, importLocalName?: string, importSpecifier?: string }} [opts]
29
+ * @param {{ filePath?: string, importLocalName?: string, importSpecifier?: string, sourceFile?: object }} [opts]
30
30
  * @returns {{ eligible: boolean, reason?: string, bindingName?: string, methods?: string[], functionNames?: string[], specifier?: string }}
31
31
  */
32
32
  export function provePortProofInject(ts, source, opts = {}) {
33
33
  if (!ts || typeof source !== 'string') {
34
34
  return { eligible: false, reason: 'missing-ts-or-source' };
35
35
  }
36
- const sf = ts.createSourceFile(
36
+ const sf = opts.sourceFile ?? ts.createSourceFile(
37
37
  opts.filePath || 'file.ts',
38
38
  source,
39
39
  ts.ScriptTarget.Latest,
@@ -1,9 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { loadArchitectureChangeMap, loadContract, preflightChange } from './analysis-engine.mjs';
3
+ import {
4
+ loadArchitectureChangeMap,
5
+ loadContract,
6
+ preflightResolvedChange,
7
+ } from './analysis-engine.mjs';
4
8
  import { createAdapterResult } from './adapter-contract.mjs';
5
- import { collectGovernedFiles, isGovernableSourceFile, normalize } from './scan-files.mjs';
6
- import { isScanExcludedRelative, layerForRelativePath } from '../ark-shared.mjs';
9
+ import { isGovernableSourceFile } from './scan-files.mjs';
10
+ import {
11
+ canonicalizeCandidateChanges,
12
+ resolveCandidateFacts,
13
+ } from './resolved-candidate-facts.mjs';
14
+ import { effectiveAnalysisConfig } from './analysis-policy.mjs';
15
+ import { isScanExcludedRelative } from '../ark-shared.mjs';
7
16
 
8
17
  function candidatePath(value) {
9
18
  if (typeof value !== 'string' || value.trim() === '') {
@@ -27,16 +36,21 @@ function isIncluded(relativePath, include) {
27
36
  });
28
37
  }
29
38
 
39
+ export function isCandidateSourceInScope(config, relativePath) {
40
+ return (
41
+ isGovernableSourceFile(path.basename(relativePath)) &&
42
+ isIncluded(relativePath, config.include) &&
43
+ !isScanExcludedRelative(relativePath, config)
44
+ );
45
+ }
46
+
30
47
  function assertGovernedSource(config, relativePath) {
31
48
  if (!isGovernableSourceFile(path.basename(relativePath))) {
32
49
  throw new Error(`Atomic preflight only accepts governed production source files: ${relativePath}`);
33
50
  }
34
- if (!isIncluded(relativePath, config.include) || isScanExcludedRelative(relativePath, config)) {
51
+ if (!isCandidateSourceInScope(config, relativePath)) {
35
52
  throw new Error(`Change path is outside the configured source scope: ${relativePath}`);
36
53
  }
37
- if (!layerForRelativePath(relativePath, config.layers)) {
38
- throw new Error(`Change path is not assigned to an architecture layer: ${relativePath}`);
39
- }
40
54
  }
41
55
 
42
56
  function assertInsideProject(root, relativePath) {
@@ -73,22 +87,6 @@ export function normalizeChangeSet(input) {
73
87
  });
74
88
  }
75
89
 
76
- function baseFilesForChange(root, config, changes) {
77
- const byPath = new Map(
78
- collectGovernedFiles(root, config).map((absolute) => [
79
- normalize(path.relative(root, absolute)),
80
- { path: normalize(path.relative(root, absolute)), content: fs.readFileSync(absolute, 'utf8') },
81
- ])
82
- );
83
- for (const change of changes) {
84
- if (byPath.has(change.path) || !isGovernableSourceFile(path.basename(change.path))) continue;
85
- const absolute = path.join(root, change.path);
86
- if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
87
- byPath.set(change.path, { path: change.path, content: fs.readFileSync(absolute, 'utf8') });
88
- }
89
- return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
90
- }
91
-
92
90
  export function prepareChangeFromRoot({
93
91
  root,
94
92
  config,
@@ -96,33 +94,65 @@ export function prepareChangeFromRoot({
96
94
  changes,
97
95
  changeMap,
98
96
  changeMapSource,
99
- compilerOptions,
97
+ ts,
98
+ tsconfig,
99
+ manifest,
100
+ overlayChanges,
100
101
  }) {
101
102
  const normalizedChanges = normalizeChangeSet(changes);
103
+ const normalizedOverlayChanges =
104
+ overlayChanges === undefined ? normalizedChanges : normalizeChangeSet(overlayChanges);
105
+ const effectiveConfig = effectiveAnalysisConfig(config, manifest);
102
106
  for (const change of normalizedChanges) {
103
107
  assertInsideProject(root, change.path);
104
- assertGovernedSource(config, change.path);
105
108
  }
106
- const contract = loadContract(config, configSource ?? path.join(root, 'ark.config.json'));
109
+ const contract = loadContract(
110
+ effectiveConfig,
111
+ configSource ?? path.join(root, 'ark.config.json')
112
+ );
107
113
  const loadedChangeMap =
108
114
  changeMap === undefined
109
115
  ? undefined
110
116
  : loadArchitectureChangeMap(changeMap, contract.config, changeMapSource);
111
- const result = preflightChange({
112
- contract,
113
- files: baseFilesForChange(root, config, normalizedChanges),
117
+ const canonicalChanges = canonicalizeCandidateChanges({
118
+ root,
119
+ config: contract.config,
114
120
  changes: normalizedChanges,
121
+ });
122
+ for (const change of canonicalChanges) assertGovernedSource(effectiveConfig, change.path);
123
+ const baseFacts = resolveCandidateFacts({
124
+ root,
125
+ config: contract.config,
126
+ ts,
127
+ ...(tsconfig ? { tsconfig } : {}),
128
+ });
129
+ const candidateFacts = resolveCandidateFacts({
130
+ root,
131
+ config: contract.config,
132
+ ts,
133
+ changes: normalizedOverlayChanges,
134
+ ...(tsconfig ? { tsconfig } : {}),
135
+ });
136
+ const result = preflightResolvedChange({
137
+ contract,
138
+ baseFacts,
139
+ candidateFacts,
140
+ changes: canonicalChanges,
115
141
  ...(loadedChangeMap ? { changeMap: loadedChangeMap } : {}),
116
- ...(compilerOptions ? { compilerOptions } : {}),
117
142
  });
118
- return {
119
- ...createAdapterResult({
120
- valid: result.valid,
121
- violations: result.violations,
122
- warnings: result.warnings,
123
- }),
124
- ...result,
125
- };
143
+ const completeness =
144
+ result.baseCompleteness === 'unavailable' || result.candidateCompleteness === 'unavailable'
145
+ ? 'unavailable'
146
+ : result.baseCompleteness === 'partial' || result.candidateCompleteness === 'partial'
147
+ ? 'partial'
148
+ : 'complete';
149
+ const { diagnostics } = createAdapterResult({
150
+ valid: result.valid,
151
+ completeness,
152
+ violations: result.violations,
153
+ warnings: result.warnings,
154
+ });
155
+ return { ...result, diagnostics };
126
156
  }
127
157
 
128
158
  export function renderChangePreflight(result) {
@@ -73,7 +73,7 @@ export function buildJudgmentBrief(violations) {
73
73
  * placement: object,
74
74
  * root: string,
75
75
  * ts: object,
76
- * validate: (source: string) => { valid: boolean, violations?: any[] },
76
+ * validate: (source: string) => { valid: boolean, completeness?: string, violations?: any[] },
77
77
  * resolveTargetAbs?: Function,
78
78
  * }} opts
79
79
  */
@@ -117,7 +117,13 @@ export function composePrepareWrite(opts) {
117
117
  ...(placement?.description ? { description: placement.description } : {}),
118
118
  // Q03: pass through golden pattern from ark_place (advisory; absent is normal).
119
119
  ...(placement?.goldenPattern ? { goldenPattern: placement.goldenPattern } : {}),
120
+ mode: gate.mode,
120
121
  valid: gate.valid,
122
+ lexicalValid: gate.lexicalValid,
123
+ ...(gate.completeness ? { completeness: gate.completeness } : {}),
124
+ ...(Array.isArray(gate.completenessReasons)
125
+ ? { completenessReasons: gate.completenessReasons }
126
+ : {}),
121
127
  violations: gate.violations,
122
128
  ...(gate.autoPatch ? { autoPatch: gate.autoPatch } : {}),
123
129
  ...(judgment ? { judgmentBrief: judgment } : {}),
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Y01 — explicit verdict memory for X04 reshape pilots.
3
+ *
4
+ * Decisions bind to a concept plus its complete, sorted anchor set. Counts,
5
+ * move samples, and change-map hashes are deliberately excluded: evidence may
6
+ * drift without overturning an adopter's verdict, while a changed physical
7
+ * layout makes the old record stale. Advisory only; mirror facts stay intact.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { classifyPhysical, computeReshapePilot } from './physical-cohesion.mjs';
12
+
13
+ export const RESHAPE_DECISIONS_PATH = '.ark/reshape-decisions.json';
14
+
15
+ const MAX_DECISION_BYTES = 64 * 1024;
16
+ const MAX_DECISIONS = 200;
17
+ const MAX_LIFECYCLE_ITEMS = 12;
18
+ const MAX_ANCHOR_EVIDENCE = 20;
19
+ const VERDICTS = new Set(['accepted', 'deferred', 'rejected']);
20
+
21
+ function normalizeAnchor(raw) {
22
+ const portable = String(raw).trim().replace(/\\/g, '/');
23
+ if (portable === '.') return '.';
24
+ if (!portable || portable.startsWith('/') || /^[A-Za-z]:\//.test(portable) || portable.includes('\0')) {
25
+ return null;
26
+ }
27
+ const segments = portable.split('/');
28
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..')) return null;
29
+ return segments.join('/');
30
+ }
31
+
32
+ function targetKey(concept, anchors) {
33
+ return JSON.stringify([concept, anchors]);
34
+ }
35
+
36
+ function compareText(left, right) {
37
+ return left < right ? -1 : left > right ? 1 : 0;
38
+ }
39
+
40
+ function sameStrings(left, right) {
41
+ return left.length === right.length && left.every((value, index) => value === right[index]);
42
+ }
43
+
44
+ function lifecycleStatus(decision, today) {
45
+ const reviewBy = decision.reviewBy;
46
+ if (reviewBy === undefined) return 'undated';
47
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(reviewBy)) return 'malformed';
48
+ const date = new Date(`${reviewBy}T00:00:00.000Z`);
49
+ if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== reviewBy) {
50
+ return 'malformed';
51
+ }
52
+ return typeof today === 'string' && reviewBy < today ? 'expired' : 'current';
53
+ }
54
+
55
+ /** Bounded, fail-loud loader. A broken file never suppresses a pilot. */
56
+ export function loadReshapeDecisions(root) {
57
+ const relPath = RESHAPE_DECISIONS_PATH;
58
+ const abs = path.join(root, relPath);
59
+ let stats;
60
+ try {
61
+ stats = fs.statSync(abs);
62
+ } catch {
63
+ return { path: relPath, exists: false, decisions: [] };
64
+ }
65
+ const invalid = (error) => ({ path: relPath, exists: true, invalid: true, error, decisions: [] });
66
+ if (!stats.isFile()) return invalid('not a regular file');
67
+ if (stats.size > MAX_DECISION_BYTES) {
68
+ return invalid(`larger than ${MAX_DECISION_BYTES} bytes`);
69
+ }
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
73
+ } catch (error) {
74
+ return invalid(error instanceof Error ? error.message : 'unreadable JSON');
75
+ }
76
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
77
+ return invalid('expected an object with decisions[]');
78
+ }
79
+ const unknownTop = Object.keys(parsed).filter((key) => !['schemaVersion', 'decisions'].includes(key));
80
+ if (unknownTop.length > 0) return invalid(`unknown field: ${unknownTop[0]}`);
81
+ if (parsed.schemaVersion !== undefined && parsed.schemaVersion !== '1') {
82
+ return invalid('schemaVersion must be "1" when present');
83
+ }
84
+ if (!Array.isArray(parsed.decisions)) return invalid('expected { decisions: [...] }');
85
+ if (parsed.decisions.length > MAX_DECISIONS) {
86
+ return invalid(`more than ${MAX_DECISIONS} entries`);
87
+ }
88
+
89
+ const decisions = [];
90
+ const seen = new Set();
91
+ for (const entry of parsed.decisions) {
92
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
93
+ return invalid('every decision must be an object');
94
+ }
95
+ const unknown = Object.keys(entry).filter((key) =>
96
+ !['concept', 'anchors', 'verdict', 'reason', 'reviewBy'].includes(key)
97
+ );
98
+ if (unknown.length > 0) return invalid(`decision has unknown field: ${unknown[0]}`);
99
+ const concept = typeof entry.concept === 'string' ? entry.concept.trim() : '';
100
+ const reason = typeof entry.reason === 'string' ? entry.reason.trim() : '';
101
+ if (!concept || !reason || !VERDICTS.has(entry.verdict)) {
102
+ return invalid('every decision needs concept, verdict (accepted/deferred/rejected), and reason');
103
+ }
104
+ if (entry.reviewBy !== undefined && typeof entry.reviewBy !== 'string') {
105
+ return invalid('reviewBy must be a string when present');
106
+ }
107
+ if (!Array.isArray(entry.anchors) || entry.anchors.length === 0) {
108
+ return invalid('every decision needs a non-empty anchors array');
109
+ }
110
+ if (entry.anchors.some((anchor) => typeof anchor !== 'string')) {
111
+ return invalid('every decision anchor must be a string');
112
+ }
113
+ const anchors = entry.anchors.map(normalizeAnchor);
114
+ if (anchors.some((anchor) => anchor === null)) {
115
+ return invalid('anchors must be canonical project-relative paths');
116
+ }
117
+ anchors.sort();
118
+ if (new Set(anchors).size !== anchors.length) return invalid('decision anchors must be unique');
119
+ const key = targetKey(concept, anchors);
120
+ if (seen.has(key)) return invalid('duplicate decision target');
121
+ seen.add(key);
122
+ decisions.push({
123
+ concept,
124
+ anchors,
125
+ verdict: entry.verdict,
126
+ reason,
127
+ ...(entry.reviewBy !== undefined ? { reviewBy: entry.reviewBy } : {}),
128
+ });
129
+ }
130
+ decisions.sort(
131
+ (left, right) =>
132
+ compareText(left.concept, right.concept) ||
133
+ compareText(targetKey(left.concept, left.anchors), targetKey(right.concept, right.anchors))
134
+ );
135
+ return { path: relPath, exists: true, decisions };
136
+ }
137
+
138
+ function anchorsByConcept(root, files) {
139
+ const result = new Map();
140
+ const resolvedRoot = path.resolve(root);
141
+ for (const file of Array.isArray(files) ? files : []) {
142
+ const abs = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
143
+ const rel = path.relative(resolvedRoot, abs);
144
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue;
145
+ const classified = classifyPhysical(rel);
146
+ if (!classified) continue;
147
+ if (!result.has(classified.concept)) result.set(classified.concept, new Set());
148
+ result.get(classified.concept).add(classified.anchor);
149
+ }
150
+ return new Map([...result].map(([concept, anchors]) => [concept, [...anchors].sort()]));
151
+ }
152
+
153
+ /** Pure lifecycle/staleness resolution; callers inject `today` in tests. */
154
+ export function analyzeReshapeDecisions(
155
+ root,
156
+ files,
157
+ state = { path: RESHAPE_DECISIONS_PATH, exists: false, decisions: [] },
158
+ today = null
159
+ ) {
160
+ const anchorSets = anchorsByConcept(root, files);
161
+ const current = [];
162
+ const expired = [];
163
+ const malformed = [];
164
+ const stale = [];
165
+ for (const decision of state.invalid ? [] : state.decisions ?? []) {
166
+ const currentAnchors = anchorSets.get(decision.concept) ?? [];
167
+ if (!sameStrings(decision.anchors, currentAnchors)) {
168
+ stale.push({
169
+ ...decision,
170
+ currentAnchorCount: currentAnchors.length,
171
+ currentAnchors: currentAnchors.slice(0, MAX_ANCHOR_EVIDENCE),
172
+ });
173
+ continue;
174
+ }
175
+ const status = lifecycleStatus(decision, today);
176
+ if (status === 'expired') expired.push(decision);
177
+ else if (status === 'malformed') malformed.push(decision);
178
+ else {
179
+ current.push({
180
+ ...decision,
181
+ lifecycle: status,
182
+ suppressesPilot: decision.verdict === 'deferred' || decision.verdict === 'rejected',
183
+ });
184
+ }
185
+ }
186
+ const summary = {
187
+ advisory: true,
188
+ explicitOnly: true,
189
+ neverChangesFacts: true,
190
+ decisionFile: {
191
+ path: state.path ?? RESHAPE_DECISIONS_PATH,
192
+ present: state.exists === true,
193
+ invalid: state.invalid === true,
194
+ ...(state.invalid ? { error: state.error ?? 'invalid' } : {}),
195
+ },
196
+ currentCount: current.length,
197
+ current: current.slice(0, MAX_LIFECYCLE_ITEMS),
198
+ lifecycle: {
199
+ undated: current.filter((decision) => decision.lifecycle === 'undated').length,
200
+ malformedCount: malformed.length,
201
+ malformed: malformed.slice(0, MAX_LIFECYCLE_ITEMS),
202
+ expiredCount: expired.length,
203
+ expired: expired.slice(0, MAX_LIFECYCLE_ITEMS),
204
+ staleCount: stale.length,
205
+ stale: stale.slice(0, MAX_LIFECYCLE_ITEMS),
206
+ },
207
+ };
208
+ return { summary, current, anchorSets };
209
+ }
210
+
211
+ /** Filesystem/clock wrapper for doctor and report callers. */
212
+ export function computeReshapeDecisionMemory(root, files, today = new Date().toISOString().slice(0, 10)) {
213
+ return analyzeReshapeDecisions(root, files, loadReshapeDecisions(root), today);
214
+ }
215
+
216
+ /** Select one actionable finding while respecting explicit current verdicts. */
217
+ export function computeDecisionAwareReshapePilot(cohesion, files, root, analysis) {
218
+ const findings = Array.isArray(cohesion?.findings) ? cohesion.findings : [];
219
+ if (findings.length === 0) return null;
220
+ const currentByTarget = new Map(
221
+ analysis.current.map((decision) => [targetKey(decision.concept, decision.anchors), decision])
222
+ );
223
+ for (const finding of findings) {
224
+ const anchors = analysis.anchorSets.get(finding.concept) ?? [];
225
+ const decision = currentByTarget.get(targetKey(finding.concept, anchors));
226
+ if (decision?.suppressesPilot) continue;
227
+ const pilot = computeReshapePilot({ ...cohesion, findings: [finding] }, files, root);
228
+ if (!pilot?.nextPilot) return pilot;
229
+ return {
230
+ ...pilot,
231
+ ...(decision ? { decision } : {}),
232
+ nextPilot: {
233
+ ...pilot.nextPilot,
234
+ decisionTarget: { concept: finding.concept, anchors },
235
+ decisionFile: RESHAPE_DECISIONS_PATH,
236
+ },
237
+ };
238
+ }
239
+ return {
240
+ proposed: false,
241
+ applied: false,
242
+ neverMechanicalSafe: true,
243
+ nextPilot: null,
244
+ suppressedByDecision: true,
245
+ note: 'Every displayed reshape target has an explicit current rejected/deferred decision; mirror facts remain visible.',
246
+ };
247
+ }
248
+
249
+ /** Human doctor section; lifecycle stays visible even after the sensor quiets. */
250
+ export function printReshapeDecisionsSection(memory, io) {
251
+ const lifecycle = memory?.lifecycle;
252
+ const hasContent =
253
+ memory?.decisionFile?.invalid ||
254
+ memory?.currentCount > 0 ||
255
+ lifecycle?.expiredCount > 0 ||
256
+ lifecycle?.malformedCount > 0 ||
257
+ lifecycle?.staleCount > 0;
258
+ if (!hasContent) return;
259
+ console.log('');
260
+ console.log(io.color.bold('Reshape decisions (advisory)'));
261
+ if (memory.decisionFile.invalid) {
262
+ io.line(io.warn, `${memory.decisionFile.path} is present but invalid — decisions are ignored.`);
263
+ }
264
+ for (const decision of memory.current.slice(0, 5)) {
265
+ const review = decision.reviewBy ? ` · review-by ${decision.reviewBy}` : '';
266
+ io.line(' ', `[${decision.concept}] ${decision.verdict}${review} — ${decision.reason}`);
267
+ }
268
+ if (memory.currentCount > memory.current.slice(0, 5).length) {
269
+ io.line(' ', io.color.dim(`…(+${memory.currentCount - 5} more current decision(s))`));
270
+ }
271
+ if (lifecycle.expiredCount > 0) {
272
+ io.line(io.warn, `${lifecycle.expiredCount} reshape decision(s) expired — pilot pressure is active again.`);
273
+ }
274
+ if (lifecycle.malformedCount > 0) {
275
+ io.line(io.warn, `${lifecycle.malformedCount} reshape decision(s) have malformed review-by dates — ignored.`);
276
+ }
277
+ if (lifecycle.staleCount > 0) {
278
+ io.line(io.warn, `${lifecycle.staleCount} reshape decision(s) have a changed anchor set — stale; update or delete them.`);
279
+ }
280
+ if (lifecycle.undated > 0) {
281
+ io.line(' ', io.color.dim(`${lifecycle.undated} current decision(s) have no review-by date.`));
282
+ }
283
+ io.line(' ', io.color.dim('explicit verdicts affect pilot pressure only; physical facts and the gate verdict are unchanged'));
284
+ }
@@ -0,0 +1,55 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import {
5
+ RESIDENT_HOOK_PROTOCOL_VERSION,
6
+ requestResidentHook,
7
+ residentDoctorEnvironment,
8
+ residentHookEndpoint,
9
+ } from './resident-hook.mjs';
10
+
11
+ const arkMcpLauncher = fileURLToPath(new URL('../ark-mcp.mjs', import.meta.url));
12
+
13
+ /** Try the opt-in resident doctor and let the caller continue through the cold fallback. */
14
+ export async function tryResidentDoctor(args) {
15
+ if (!args.resident) return false;
16
+ if (!args.doctor || !args.json) throw new Error('--resident requires --doctor --json.');
17
+ const endpoint = residentHookEndpoint({
18
+ root: args.root,
19
+ config: args.config,
20
+ manifest: args.manifest,
21
+ tsconfig: args.tsconfig,
22
+ launcher: arkMcpLauncher,
23
+ });
24
+ const configuredTimeout = Number(process.env.ARK_RESIDENT_DOCTOR_TIMEOUT_MS);
25
+ const response = await requestResidentHook({
26
+ socket: endpoint.socket,
27
+ timeoutMs:
28
+ Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 500,
29
+ request: {
30
+ protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION,
31
+ kind: 'doctor',
32
+ root: path.resolve(args.root),
33
+ config: args.config,
34
+ manifest: args.manifest ?? null,
35
+ tsconfig: args.tsconfig ?? null,
36
+ environment: residentDoctorEnvironment(),
37
+ },
38
+ });
39
+ if (
40
+ !response ||
41
+ response.fallback === true ||
42
+ !Number.isInteger(response.status) ||
43
+ typeof response.stdout !== 'string' ||
44
+ typeof response.stderr !== 'string'
45
+ ) {
46
+ if (process.env.ARK_RESIDENT_DOCTOR_REQUIRED === '1') {
47
+ throw new Error('Resident doctor was required but unavailable.');
48
+ }
49
+ return false;
50
+ }
51
+ if (response.stdout) process.stdout.write(response.stdout);
52
+ if (response.stderr) process.stderr.write(response.stderr);
53
+ process.exitCode = response.status;
54
+ return true;
55
+ }