phronesis 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +66 -0
  2. package/package.json +34 -0
  3. package/src/act.js +61 -0
  4. package/src/active-context.js +56 -0
  5. package/src/adapter.js +386 -0
  6. package/src/cli.js +1893 -0
  7. package/src/codex-hooks-schema.js +92 -0
  8. package/src/compile.js +513 -0
  9. package/src/doctor.js +303 -0
  10. package/src/due.js +271 -0
  11. package/src/eval.js +467 -0
  12. package/src/event.js +212 -0
  13. package/src/export.js +136 -0
  14. package/src/guard.js +89 -0
  15. package/src/hooks.js +230 -0
  16. package/src/ingest.js +965 -0
  17. package/src/init.js +150 -0
  18. package/src/layout.js +394 -0
  19. package/src/links.js +83 -0
  20. package/src/lint.js +58 -0
  21. package/src/profile.js +116 -0
  22. package/src/prompts.js +26 -0
  23. package/src/registry.js +190 -0
  24. package/src/scaffold.js +134 -0
  25. package/src/search.js +139 -0
  26. package/src/skill-discovery.js +117 -0
  27. package/src/skills-bump.js +885 -0
  28. package/src/skills.js +355 -0
  29. package/src/subscription.js +5 -0
  30. package/src/validate.js +152 -0
  31. package/templates/claude/settings.json +86 -0
  32. package/templates/codex/INDEX.md +68 -0
  33. package/templates/codex/seed/ai-practice/compile-dont-just-capture.md +42 -0
  34. package/templates/codex/seed/ai-practice/design-for-the-distribution.md +42 -0
  35. package/templates/codex/seed/ai-practice/from-spec-author-to-outcome-curator.md +43 -0
  36. package/templates/codex/seed/ai-practice/recall-builds-trust-when-calibrated.md +70 -0
  37. package/templates/codex/seed/ai-practice/the-eval-is-the-spec.md +42 -0
  38. package/templates/codex/seed/ai-practice/the-gold-dust-leaves-no-trace.md +72 -0
  39. package/templates/codex/seed/ai-practice/the-model-is-not-the-moat.md +40 -0
  40. package/templates/codex/seed/ai-practice/when-generation-is-cheap-judgment-is-the-work.md +39 -0
  41. package/templates/codex/seed/judgment/contextual-stewardship-is-the-senior-skill.md +50 -0
  42. package/templates/codex/seed/judgment/decision-quality-is-distinct-from-outcome-quality.md +39 -0
  43. package/templates/codex/seed/judgment/embrace-reality-ego-and-blind-spots.md +38 -0
  44. package/templates/codex/seed/judgment/groups-seek-consensus-individuals-seek-truth.md +44 -0
  45. package/templates/codex/seed/judgment/heresies-are-where-the-edge-lives.md +42 -0
  46. package/templates/codex/seed/judgment/judgment-compounds-through-honest-reflection.md +38 -0
  47. package/templates/codex/seed/judgment/phronesis-is-judgment-in-the-particular.md +67 -0
  48. package/templates/codex/seed/judgment/show-me-the-apparatus.md +40 -0
  49. package/templates/codex/seed/judgment/small-judgment-gaps-compound-under-leverage.md +37 -0
  50. package/templates/codex/seed/judgment/taste-is-compressed-judgment.md +38 -0
  51. package/templates/codex/seed/judgment/trust-taste-only-where-feedback-is-valid.md +42 -0
  52. package/templates/codex/seed/judgment/weigh-the-reasoning-not-the-recommendation.md +43 -0
  53. package/templates/codex/seed/leadership/a-managers-output-is-the-teams-output.md +35 -0
  54. package/templates/codex/seed/leadership/most-team-problems-are-structural.md +40 -0
  55. package/templates/codex/seed/leadership/next-play.md +36 -0
  56. package/templates/codex/seed/leadership/psychological-safety.md +39 -0
  57. package/templates/codex/seed/leadership/right-size-your-teams.md +38 -0
  58. package/templates/codex/seed/leadership/spend-time-on-high-leverage-activities.md +36 -0
  59. package/templates/codex/seed/leadership/talent-density-is-the-master-variable.md +39 -0
  60. package/templates/codex/seed/leadership/under-pressure-calm-is-contagious.md +36 -0
  61. package/templates/codex/seed/leadership/your-best-alternative-is-your-leverage.md +37 -0
  62. package/templates/codex/seed/pm/a-problem-well-stated-is-half-solved.md +39 -0
  63. package/templates/codex/seed/pm/a-real-strategy-makes-planning-trivial.md +40 -0
  64. package/templates/codex/seed/pm/barrels-and-ammunition.md +39 -0
  65. package/templates/codex/seed/pm/develop-taste-in-your-beliefs.md +38 -0
  66. package/templates/codex/seed/pm/hire-for-progress.md +44 -0
  67. package/templates/codex/seed/pm/jobs-are-stable-solutions-churn.md +39 -0
  68. package/templates/codex/seed/pm/level-by-the-ambiguity-someone-can-own.md +39 -0
  69. package/templates/codex/seed/pm/listen-before-you-prescribe.md +38 -0
  70. package/templates/codex/seed/pm/live-in-the-future-and-build-whats-missing.md +38 -0
  71. package/templates/codex/seed/pm/match-effort-to-leverage.md +37 -0
  72. package/templates/codex/seed/pm/not-my-fault-but-my-problem.md +40 -0
  73. package/templates/codex/seed/pm/pick-the-job-then-refuse-everything-outside-it.md +37 -0
  74. package/templates/codex/seed/pm/retention-sets-the-ceiling.md +38 -0
  75. package/templates/codex/seed/pm/scale-with-leaders-not-process.md +38 -0
  76. package/templates/codex/seed/pm/solution-discovery-over-problem-validation.md +38 -0
  77. package/templates/codex/seed/pm/tenfold-better-on-a-painful-problem.md +40 -0
  78. package/templates/codex-surface/README.md +73 -0
  79. package/templates/codex-surface/hooks/_resolve.sh +36 -0
  80. package/templates/codex-surface/hooks/recall-guard.sh +30 -0
  81. package/templates/codex-surface/hooks/session-start.sh +18 -0
  82. package/templates/codex-surface/hooks/session-sweep-precompact.sh +20 -0
  83. package/templates/codex-surface/hooks/session-sweep-subagent.sh +23 -0
  84. package/templates/codex-surface/hooks/session-sweep.sh +19 -0
  85. package/templates/codex-surface/hooks.json +69 -0
  86. package/templates/domains/pm/AGENTS.md +111 -0
  87. package/templates/domains/pm/MEMORY.md +7 -0
  88. package/templates/domains/pm/USER.md +8 -0
  89. package/templates/phronesis-hooks/compile-active-context.sh +38 -0
  90. package/templates/phronesis-hooks/recall-guard.sh +103 -0
  91. package/templates/phronesis-hooks/session-start.sh +90 -0
  92. package/templates/phronesis-hooks/session-sweep.sh +140 -0
  93. package/templates/phronesis-hooks/skill-lifecycle.sh +37 -0
  94. package/templates/skills/compile/SKILL.md +279 -0
  95. package/templates/skills/compile/evals/rubric.md +75 -0
  96. package/templates/skills/decision-log/SKILL.md +156 -0
  97. package/templates/skills/decision-log/evals/rubric.md +97 -0
  98. package/templates/skills/extract-gold-dust/SKILL.md +311 -0
  99. package/templates/skills/extract-gold-dust/evals/rubric.md +52 -0
  100. package/templates/skills/ingest/SKILL.md +204 -0
  101. package/templates/skills/ingest/evals/rubric.md +117 -0
  102. package/templates/skills/lint/SKILL.md +246 -0
  103. package/templates/skills/lint/evals/rubric.md +139 -0
  104. package/templates/skills/meeting-prep/SKILL.md +195 -0
  105. package/templates/skills/meeting-prep/evals/rubric.md +93 -0
  106. package/templates/skills/onboard/SKILL.md +258 -0
  107. package/templates/skills/onboard/evals/rubric.md +60 -0
  108. package/templates/skills/prd-draft/SKILL.md +193 -0
  109. package/templates/skills/prd-draft/evals/rubric.md +38 -0
  110. package/templates/skills/prep-extraction/SKILL.md +217 -0
  111. package/templates/skills/prep-extraction/evals/rubric.md +45 -0
  112. package/templates/skills/recall/SKILL.md +155 -0
  113. package/templates/skills/recall/evals/rubric.md +37 -0
  114. package/templates/skills/research/SKILL.md +247 -0
  115. package/templates/skills/research/evals/rubric.md +139 -0
  116. package/templates/skills/stakeholder-update/SKILL.md +163 -0
  117. package/templates/skills/stakeholder-update/evals/rubric.md +111 -0
  118. package/vendor/core/package.json +25 -0
  119. package/vendor/core/src/action-registry.js +459 -0
  120. package/vendor/core/src/action-writers.js +201 -0
  121. package/vendor/core/src/actions.js +2140 -0
  122. package/vendor/core/src/active-context.js +167 -0
  123. package/vendor/core/src/artifacts.js +301 -0
  124. package/vendor/core/src/content-digest.js +21 -0
  125. package/vendor/core/src/contract.js +245 -0
  126. package/vendor/core/src/event-log.js +338 -0
  127. package/vendor/core/src/guard.js +398 -0
  128. package/vendor/core/src/hook-executor.js +527 -0
  129. package/vendor/core/src/index.js +180 -0
  130. package/vendor/core/src/ingest.js +286 -0
  131. package/vendor/core/src/install-root.js +42 -0
  132. package/vendor/core/src/layout.js +13 -0
  133. package/vendor/core/src/link-index.js +198 -0
  134. package/vendor/core/src/lint.js +368 -0
  135. package/vendor/core/src/schedule.js +535 -0
  136. package/vendor/core/src/search.js +534 -0
  137. package/vendor/core/src/session-hooks.js +129 -0
  138. package/vendor/core/src/skill-lifecycle.js +479 -0
  139. package/vendor/core/src/skill-registry.js +524 -0
  140. package/vendor/core/src/subscription.js +84 -0
  141. package/vendor/core/src/validate-object.js +62 -0
  142. package/vendor/core/src/workspace-scan.js +125 -0
@@ -0,0 +1,198 @@
1
+ // Link index + walk — Stage 2 retrieval (specs/compile-retrieve-loop.md §"Stage 2 —
2
+ // typed link walk"; core-0005 / T2.3). Builds the typed-link graph over a domain's
3
+ // compiled/ objects + the shared codex/, then walks N hops from any object to return the
4
+ // connected subgraph: the related decisions, the people involved, the sources they
5
+ // cite (ontology §"Link types").
6
+ //
7
+ // Derived, not authoritative: per change 0008 the machine retrieval index is derived and
8
+ // rebuildable from compiled/ + codex/, never source of truth. This module builds it in
9
+ // memory. Persistence, daemon reindex-on-write, and `phronesis index rebuild` are T5.2's
10
+ // machine-index substrate — out of scope here. Reuses contract.js (classifyTarget) for the
11
+ // locked link-target grammar and workspace-scan.js for object discovery.
12
+
13
+ import { resolve, join, relative } from 'node:path';
14
+ import { classifyTarget } from './contract.js';
15
+ import { listRetrievableObjectFiles, readObjectRecord } from './workspace-scan.js';
16
+
17
+ const DIRECTIONS = new Set(['out', 'in', 'both']);
18
+
19
+ // Leaf kind for a visited path that is not an indexed object — derived from the edge that
20
+ // reached it: a resolvable raw source vs a not-yet-(or no-longer-)compiled object target.
21
+ function leafKind(form) {
22
+ return form === 'raw-source' ? 'raw' : 'unresolved';
23
+ }
24
+
25
+ // One typed object's node metadata (the fields a retrieval consumer presents).
26
+ function nodeMeta(relPath, fm, ctx) {
27
+ return {
28
+ path: relPath,
29
+ kind: 'object',
30
+ type: fm.type ?? null,
31
+ name: fm.name ?? null,
32
+ title: fm.title ?? null,
33
+ status: fm.status ?? null,
34
+ exposure: fm.exposure ?? null,
35
+ stance: fm.stance ?? null,
36
+ domain: ctx.domain,
37
+ };
38
+ }
39
+
40
+ export class LinkIndex {
41
+ constructor({ root, nodes, outEdges, inEdges }) {
42
+ this.root = root;
43
+ this.nodes = nodes; // Map<relPath, nodeMeta>
44
+ this.outEdges = outEdges; // Map<relPath, Edge[]> (from === key)
45
+ this.inEdges = inEdges; // Map<relPath, Edge[]> (to === key)
46
+ }
47
+
48
+ get size() {
49
+ return this.nodes.size;
50
+ }
51
+
52
+ has(path) {
53
+ return this.nodes.has(path);
54
+ }
55
+
56
+ node(path) {
57
+ return this.nodes.get(path) ?? null;
58
+ }
59
+
60
+ // Resolve an operator-supplied query to a single node path. Accepts an exact
61
+ // install-relative path, a bare object name, or a `{type-dir}/{name}` suffix. Returns
62
+ // { path } on a unique hit, { error } otherwise (no match / ambiguous).
63
+ find(query) {
64
+ if (this.nodes.has(query)) return { path: query };
65
+ const q = String(query).replace(/\.md$/, '');
66
+ const hits = [];
67
+ for (const [path, meta] of this.nodes) {
68
+ if (meta.name === q || path.replace(/\.md$/, '').endsWith(`/${q}`)) hits.push(path);
69
+ }
70
+ if (hits.length === 1) return { path: hits[0] };
71
+ if (hits.length === 0) return { error: `no indexed object matches "${query}"` };
72
+ return { error: `"${query}" is ambiguous — matches ${hits.length} objects: ${hits.join(', ')}` };
73
+ }
74
+
75
+ // N-hop connected subgraph from `start` (an install-relative object path).
76
+ // direction: 'out' | 'in' | 'both' (default 'both' — see core-0005 Decisions: a hub
77
+ // object is reached by inbound edges, so the connected subgraph needs both; every edge
78
+ // preserves its own direction + rel regardless). Raw-source and forward/unresolved
79
+ // targets are terminal leaves: included in the result, never traversed. Cycles
80
+ // terminate (each node is visited once).
81
+ walk(start, { hops = 1, direction = 'both' } = {}) {
82
+ if (!this.nodes.has(start)) {
83
+ throw new Error(`walk: "${start}" is not an indexed object`);
84
+ }
85
+ if (!DIRECTIONS.has(direction)) {
86
+ throw new Error(`walk: direction must be one of ${[...DIRECTIONS].join(' | ')}`);
87
+ }
88
+ const maxHops = Math.max(0, Math.floor(hops));
89
+
90
+ const distance = new Map([[start, 0]]); // path -> hop distance
91
+ const leafForms = new Map(); // non-object path -> the form that reached it
92
+ const edges = [];
93
+ const seenEdge = new Set();
94
+ let frontier = [start];
95
+
96
+ for (let d = 0; d < maxHops && frontier.length; d += 1) {
97
+ const next = [];
98
+ for (const cur of frontier) {
99
+ const adj = [];
100
+ if (direction === 'out' || direction === 'both') adj.push(...(this.outEdges.get(cur) || []));
101
+ if (direction === 'in' || direction === 'both') adj.push(...(this.inEdges.get(cur) || []));
102
+ for (const e of adj) {
103
+ // JSON tuple, not a delimiter-joined string: a raw-source path can contain any
104
+ // character (incl. spaces), so a literal separator could collide two distinct
105
+ // edges into one and silently drop one. JSON.stringify is collision-free.
106
+ const key = JSON.stringify([e.from, e.to, e.rel]);
107
+ if (!seenEdge.has(key)) {
108
+ seenEdge.add(key);
109
+ edges.push(e);
110
+ }
111
+ const other = e.from === cur ? e.to : e.from;
112
+ if (distance.has(other)) continue;
113
+ distance.set(other, d + 1);
114
+ // Leaf-vs-traverse is decided by *resolvability to an indexed object*, not by the
115
+ // `forward` flag: a forward link whose target actually exists is a real node and is
116
+ // traversed; a raw source or a still-missing (typo'd / not-yet-compiled) target is a
117
+ // terminal leaf.
118
+ if (this.nodes.has(other)) next.push(other);
119
+ else leafForms.set(other, e.form);
120
+ }
121
+ }
122
+ frontier = next;
123
+ }
124
+
125
+ const nodes = [...distance.keys()].map((path) => {
126
+ const meta = this.nodes.get(path);
127
+ if (meta) return { ...meta, hop: distance.get(path) };
128
+ return { path, kind: leafKind(leafForms.get(path)), hop: distance.get(path) };
129
+ });
130
+
131
+ return { root: start, hops: maxHops, direction, nodes, edges };
132
+ }
133
+ }
134
+
135
+ // Build the link index from pre-parsed object records ({ rel, ctx, fm } triples — the
136
+ // same shape the disk pass below produces and the search snapshot (T5.2 / core-0014)
137
+ // persists). ONE edge-resolution implementation for both consumers: a re-typed sibling
138
+ // graph builder is the same drift class as a re-typed frontmatter regex (rules.md:335).
139
+ export function buildLinkIndexFromRecords(records, root) {
140
+ const parsed = records;
141
+ const nodes = new Map();
142
+ const outEdges = new Map();
143
+ const inEdges = new Map();
144
+ const ensureIn = (p) => {
145
+ if (!inEdges.has(p)) inEdges.set(p, []);
146
+ return inEdges.get(p);
147
+ };
148
+
149
+ for (const p of parsed) {
150
+ if (!p) continue;
151
+ nodes.set(p.rel, nodeMeta(p.rel, p.fm, p.ctx));
152
+ outEdges.set(p.rel, []);
153
+ ensureIn(p.rel);
154
+ }
155
+
156
+ // Second pass: resolve each object's outbound links to edges (both adjacency maps).
157
+ for (const p of parsed) {
158
+ if (!p) continue;
159
+ const links = Array.isArray(p.fm.links) ? p.fm.links : [];
160
+ for (const link of links) {
161
+ if (link === null || typeof link !== 'object') continue;
162
+ const target = link.target;
163
+ if (typeof target !== 'string' || target === '') continue;
164
+ const cls = classifyTarget(target);
165
+ if (cls.form === 'invalid') continue; // malformed — the validator flags it; skip here
166
+ const to = cls.relPath(p.ctx.domain);
167
+ if (to === null) continue; // unresolvable bare slug from a codex object — skip
168
+ // Never propagate a target that escapes the install (a raw-source target can carry
169
+ // `..`). Mirrors validate.js's containment guard so the index hands no downstream
170
+ // consumer a path-traversal string to resolve. The validator separately errors on it.
171
+ if (relative(root, join(root, to)).startsWith('..')) continue;
172
+ const edge = {
173
+ from: p.rel,
174
+ to,
175
+ rel: typeof link.rel === 'string' ? link.rel : null,
176
+ forward: link.forward === true,
177
+ form: cls.form,
178
+ };
179
+ outEdges.get(p.rel).push(edge);
180
+ ensureIn(to).push(edge);
181
+ }
182
+ }
183
+
184
+ return new LinkIndex({ root, nodes, outEdges, inEdges });
185
+ }
186
+
187
+ // Build the link index by scanning every typed object under domains/*/compiled/ + codex/.
188
+ // File→record parsing rides the ONE shared readObjectRecord (skip non-objects /
189
+ // unparseable — the index is best-effort over a possibly-imperfect workspace; the
190
+ // validator flags bad objects, the index just routes around them).
191
+ export async function buildLinkIndex({ dir, installRoot } = {}) {
192
+ const root = installRoot || resolve(process.cwd(), dir || '.');
193
+ // Retrieval set excludes the quarantined seed library (workspace-scan.js): the shipped
194
+ // codex/seed/ principles are never link-graph nodes — discoverability is codex/INDEX.md + lint.
195
+ const files = await listRetrievableObjectFiles(root);
196
+ const parsed = await Promise.all(files.map((rel) => readObjectRecord(root, rel)));
197
+ return buildLinkIndexFromRecords(parsed, root);
198
+ }
@@ -0,0 +1,368 @@
1
+ // Deterministic workspace lint (core-0022 / T5.3 PR1): the read-only mechanical
2
+ // foundation beneath the heavy judgment skill. This module computes exact graph /
3
+ // frontmatter findings from compiled/ + codex/ state, writes nothing, and deliberately
4
+ // loads the retrieval snapshot with persist:false so a health check cannot repair or
5
+ // create .phronesis/index/ as a side effect.
6
+
7
+ import { performance } from 'node:perf_hooks';
8
+ import { buildLinkIndexFromRecords } from './link-index.js';
9
+ import { loadSearchIndex } from './search.js';
10
+ import { readAuditLog } from './hook-executor.js';
11
+ import { listObjectFiles, readObjectRecord } from './workspace-scan.js';
12
+
13
+ export const DEFAULT_LINT_STALE_MONTHS = 6;
14
+
15
+ // Hook-hygiene bypass surface (core-0027 J4, re-homed from core-0022/L8): a hook bypassed
16
+ // frequently is a hook the operator is working around — a suggested removal
17
+ // (specs/hooks.md §"Bypass discipline" — "Lint surfaces frequent-bypass patterns as
18
+ // suggested hook removals"). A logged bypass is a `skipped-kill-switch` audit entry
19
+ // (PHRONESIS_SKIP=<id>). Deterministic: a count over a fixed trailing window.
20
+ export const LINT_BYPASS_WINDOW_DAYS = 7;
21
+ export const LINT_BYPASS_THRESHOLD = 5;
22
+
23
+ const SURFACE_ORDER = [
24
+ 'orphan',
25
+ 'inverse_pair_gap',
26
+ 'stale_forward_ref',
27
+ 'codex_citation',
28
+ 'seed_adoption',
29
+ 'relationship_value_consistency',
30
+ // Hook hygiene is its OWN section, separate from the knowledge-graph findings above
31
+ // (core-0022/L8 — "a clearly-separated 'hook hygiene' section"). Last so the graph
32
+ // findings stay grouped.
33
+ 'hook_hygiene',
34
+ ];
35
+
36
+ const INVERSE_RELS = {
37
+ supersedes: 'superseded-by',
38
+ 'superseded-by': 'supersedes',
39
+ };
40
+
41
+ function relSort(a, b) {
42
+ return a < b ? -1 : a > b ? 1 : 0;
43
+ }
44
+
45
+ function recordMeta(record) {
46
+ return {
47
+ path: record.rel,
48
+ type: record.fm.type ?? null,
49
+ name: record.fm.name ?? null,
50
+ title: record.fm.title ?? null,
51
+ };
52
+ }
53
+
54
+ function parseDateOnly(value) {
55
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
56
+ const [year, month, day] = value.split('-').map(Number);
57
+ return new Date(Date.UTC(year, month - 1, day));
58
+ }
59
+
60
+ function cutoffDate(now, months) {
61
+ const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
62
+ d.setUTCMonth(d.getUTCMonth() - months);
63
+ return d;
64
+ }
65
+
66
+ function objectDate(record) {
67
+ return record.fm.updated ?? record.fm.created ?? null;
68
+ }
69
+
70
+ function olderThanThreshold(dateValue, now, months) {
71
+ const parsed = parseDateOnly(dateValue);
72
+ if (!parsed) return false;
73
+ return parsed < cutoffDate(now, months);
74
+ }
75
+
76
+ function seedRefFromPath(path) {
77
+ const match = path.match(/^codex\/seed\/([^/]+)\/([^/]+)\.md$/);
78
+ return match ? `${match[1]}/${match[2]}` : null;
79
+ }
80
+
81
+ function orphanDetail(scope) {
82
+ if (!scope.all_domains) {
83
+ return 'domain compiled object has no inbound links from the scoped graph';
84
+ }
85
+ return 'domain compiled object has no inbound links that resolve within their source domain; bare targets from other domains do not count as cross-domain inbound links';
86
+ }
87
+
88
+ function lintRecords(records, scope) {
89
+ return records.filter((record) => {
90
+ if (record.ctx.layer === 'domain') return scope.domains.includes(record.ctx.domain);
91
+ if (record.ctx.layer === 'codex') return record.ctx.codexKind === 'personal' || record.ctx.codexKind === 'seed';
92
+ return false;
93
+ });
94
+ }
95
+
96
+ function indexEdges(index) {
97
+ const edges = [];
98
+ for (const [from, outbound] of index.outEdges) {
99
+ for (const edge of outbound) edges.push({ ...edge, from });
100
+ }
101
+ return edges.sort((a, b) => relSort(`${a.from}:${a.rel}:${a.to}`, `${b.from}:${b.rel}:${b.to}`));
102
+ }
103
+
104
+ function hasInverse(index, edge, inverseRel) {
105
+ const targetOutbound = index.outEdges.get(edge.to) ?? [];
106
+ return targetOutbound.some((candidate) => candidate.rel === inverseRel && candidate.to === edge.from);
107
+ }
108
+
109
+ function adoptedSeedMap(records) {
110
+ const adoptions = new Map();
111
+ for (const record of records) {
112
+ if (record.ctx.layer !== 'codex' || record.ctx.codexKind !== 'personal') continue;
113
+ const ref = record.fm.provenance?.adopted_from_seed;
114
+ if (typeof ref !== 'string' || ref.trim() === '') continue;
115
+ const key = ref.trim();
116
+ if (!adoptions.has(key)) adoptions.set(key, []);
117
+ adoptions.get(key).push(record.rel);
118
+ }
119
+ for (const paths of adoptions.values()) paths.sort(relSort);
120
+ return adoptions;
121
+ }
122
+
123
+ function normalizeRelationship(value) {
124
+ return String(value).trim().toLowerCase().replace(/[_\s]+/g, '-').replace(/-+/g, '-');
125
+ }
126
+
127
+ function buildSurfaces(findings) {
128
+ return SURFACE_ORDER.map((id) => {
129
+ const surfaceFindings = findings.filter((finding) => finding.surface === id);
130
+ return {
131
+ id,
132
+ findings: surfaceFindings,
133
+ empty_result: surfaceFindings.length === 0,
134
+ };
135
+ });
136
+ }
137
+
138
+ function countByKind(findings) {
139
+ const counts = {};
140
+ for (const finding of findings) counts[finding.kind] = (counts[finding.kind] ?? 0) + 1;
141
+ return counts;
142
+ }
143
+
144
+ // Frequent-bypass hook-hygiene findings (J4): a hook whose documented kill-switch
145
+ // (PHRONESIS_SKIP=<id>, audited as outcome `skipped-kill-switch`) was bypassed
146
+ // >= LINT_BYPASS_THRESHOLD times within the trailing LINT_BYPASS_WINDOW_DAYS is a
147
+ // suggested removal — the operator is routing around it. Counts only datable in-window
148
+ // bypasses; an entry with no usable ts is ignored (the window is the contract).
149
+ function hookHygieneFindings(auditEntries, now) {
150
+ const cutoff = new Date(now.getTime() - LINT_BYPASS_WINDOW_DAYS * 86_400_000).toISOString();
151
+ const counts = new Map();
152
+ for (const entry of auditEntries) {
153
+ if (entry.outcome !== 'skipped-kill-switch') continue;
154
+ if (typeof entry.hook !== 'string' || entry.hook === '') continue;
155
+ if (typeof entry.ts !== 'string' || entry.ts < cutoff) continue;
156
+ counts.set(entry.hook, (counts.get(entry.hook) ?? 0) + 1);
157
+ }
158
+ const findings = [];
159
+ for (const [hook, bypasses] of counts) {
160
+ if (bypasses < LINT_BYPASS_THRESHOLD) continue;
161
+ findings.push({
162
+ surface: 'hook_hygiene',
163
+ kind: 'frequent_bypass',
164
+ severity: 'warn',
165
+ hook,
166
+ bypasses,
167
+ window_days: LINT_BYPASS_WINDOW_DAYS,
168
+ detail: `hook bypassed ${bypasses} time(s) in the last ${LINT_BYPASS_WINDOW_DAYS} days (PHRONESIS_SKIP) — a hook routinely worked around is a suggested removal (specs/hooks.md §"Bypass discipline")`,
169
+ });
170
+ }
171
+ return findings;
172
+ }
173
+
174
+ export async function runWorkspaceLint({
175
+ dir,
176
+ installRoot,
177
+ cwd = process.cwd(),
178
+ domain,
179
+ allDomains = false,
180
+ staleMonths = DEFAULT_LINT_STALE_MONTHS,
181
+ now = new Date(),
182
+ } = {}) {
183
+ if (!Number.isInteger(staleMonths) || staleMonths < 1) {
184
+ throw new Error(`staleMonths must be a positive integer (got ${JSON.stringify(staleMonths)})`);
185
+ }
186
+
187
+ const t0 = performance.now();
188
+ const loaded = await loadSearchIndex({
189
+ dir,
190
+ installRoot,
191
+ cwd,
192
+ domain,
193
+ allDomains,
194
+ persist: false,
195
+ });
196
+
197
+ const parsed = (await Promise.all((await listObjectFiles(loaded.root)).map((rel) => readObjectRecord(loaded.root, rel))))
198
+ .filter(Boolean)
199
+ .sort((a, b) => relSort(a.rel, b.rel));
200
+ const records = lintRecords(parsed, loaded.scope);
201
+ const byPath = new Map(records.map((record) => [record.rel, record]));
202
+ const index = buildLinkIndexFromRecords(records, loaded.root);
203
+ const edges = indexEdges(index);
204
+ const findings = [];
205
+ const adoptions = adoptedSeedMap(records);
206
+
207
+ // 1. Orphans: domain compiled objects with no inbound object edge.
208
+ for (const record of records) {
209
+ if (record.ctx.layer !== 'domain') continue;
210
+ const inbound = (index.inEdges.get(record.rel) ?? []).filter((edge) => edge.from !== record.rel);
211
+ if (inbound.length === 0) {
212
+ findings.push({
213
+ surface: 'orphan',
214
+ kind: 'orphan',
215
+ severity: 'info',
216
+ ...recordMeta(record),
217
+ detail: orphanDetail(loaded.scope),
218
+ });
219
+ }
220
+ }
221
+
222
+ // 2. Inverse-pair gaps: supersedes <-> superseded-by.
223
+ for (const edge of edges) {
224
+ const inverseRel = INVERSE_RELS[edge.rel];
225
+ if (!inverseRel || !index.has(edge.to)) continue;
226
+ if (!hasInverse(index, edge, inverseRel)) {
227
+ findings.push({
228
+ surface: 'inverse_pair_gap',
229
+ kind: 'inverse_pair_gap',
230
+ severity: 'error',
231
+ path: edge.from,
232
+ target: edge.to,
233
+ rel: edge.rel,
234
+ missing_rel: inverseRel,
235
+ detail: `${edge.rel} link is missing its ${inverseRel} reciprocal`,
236
+ });
237
+ }
238
+ }
239
+
240
+ // 3. Stale forward refs: forward domain-object targets still missing past threshold.
241
+ for (const edge of edges) {
242
+ if (!edge.forward || edge.form !== 'domain-object' || index.has(edge.to)) continue;
243
+ const record = byPath.get(edge.from);
244
+ if (!record) continue;
245
+ const dateValue = objectDate(record);
246
+ if (!olderThanThreshold(dateValue, now, staleMonths)) continue;
247
+ findings.push({
248
+ surface: 'stale_forward_ref',
249
+ kind: 'stale_forward_ref',
250
+ severity: 'warn',
251
+ path: edge.from,
252
+ target: edge.to,
253
+ rel: edge.rel,
254
+ object_date: dateValue,
255
+ threshold_months: staleMonths,
256
+ detail: 'forward reference target is still missing past the lint staleness threshold',
257
+ });
258
+ }
259
+
260
+ // 4. Broken/unjustified codex citations.
261
+ for (const edge of edges) {
262
+ if (edge.form !== 'codex-object') continue;
263
+ if (!index.has(edge.to)) {
264
+ findings.push({
265
+ surface: 'codex_citation',
266
+ kind: 'broken_codex_citation',
267
+ severity: 'error',
268
+ path: edge.from,
269
+ target: edge.to,
270
+ rel: edge.rel,
271
+ detail: 'codex citation target does not resolve',
272
+ });
273
+ continue;
274
+ }
275
+ const seedRef = seedRefFromPath(edge.to);
276
+ if (seedRef && !adoptions.has(seedRef)) {
277
+ findings.push({
278
+ surface: 'codex_citation',
279
+ kind: 'unjustified_codex_seed_citation',
280
+ severity: 'warn',
281
+ path: edge.from,
282
+ target: edge.to,
283
+ seed_ref: seedRef,
284
+ rel: edge.rel,
285
+ detail: 'codex/seed citation has not been adopted into codex/personal',
286
+ });
287
+ }
288
+ }
289
+
290
+ // 5. Seed-adoption hygiene: unadopted old seed and duplicate adoption.
291
+ for (const record of records) {
292
+ const seedRef = seedRefFromPath(record.rel);
293
+ if (!seedRef || adoptions.has(seedRef)) continue;
294
+ const dateValue = objectDate(record);
295
+ if (!olderThanThreshold(dateValue, now, staleMonths)) continue;
296
+ findings.push({
297
+ surface: 'seed_adoption',
298
+ kind: 'unadopted_seed',
299
+ severity: 'info',
300
+ ...recordMeta(record),
301
+ seed_ref: seedRef,
302
+ object_date: dateValue,
303
+ threshold_months: staleMonths,
304
+ detail: 'seed principle has not been adopted past the lint staleness threshold',
305
+ });
306
+ }
307
+ for (const [seedRef, paths] of adoptions) {
308
+ if (paths.length < 2) continue;
309
+ findings.push({
310
+ surface: 'seed_adoption',
311
+ kind: 'duplicate_seed_adoption',
312
+ severity: 'warn',
313
+ seed_ref: seedRef,
314
+ paths,
315
+ detail: 'seed principle was adopted into more than one codex/personal object',
316
+ });
317
+ }
318
+
319
+ // 6. relationships value-consistency: values that normalize together but differ in spelling.
320
+ const relationshipGroups = new Map();
321
+ for (const record of records) {
322
+ const values = Array.isArray(record.fm.relationships) ? record.fm.relationships : [];
323
+ for (const value of values) {
324
+ if (typeof value !== 'string' || value.trim() === '') continue;
325
+ const normalized = normalizeRelationship(value);
326
+ if (!relationshipGroups.has(normalized)) relationshipGroups.set(normalized, new Map());
327
+ const variants = relationshipGroups.get(normalized);
328
+ if (!variants.has(value)) variants.set(value, new Set());
329
+ variants.get(value).add(record.rel);
330
+ }
331
+ }
332
+ for (const [normalized, variants] of relationshipGroups) {
333
+ if (variants.size < 2) continue;
334
+ const variantList = [...variants.keys()].sort(relSort);
335
+ const paths = [...new Set([...variants.values()].flatMap((set) => [...set]))].sort(relSort);
336
+ findings.push({
337
+ surface: 'relationship_value_consistency',
338
+ kind: 'relationship_value_drift',
339
+ severity: 'warn',
340
+ normalized,
341
+ variants: variantList,
342
+ paths,
343
+ detail: `relationship values collapse to "${normalized}" but use multiple spellings`,
344
+ });
345
+ }
346
+
347
+ // 7. Hook hygiene (J4): frequent kill-switch bypasses over .phronesis/audit.log —
348
+ // re-admitted to lint's read surface by core-0027 (core-0022/L8 had excluded it, gated on
349
+ // this packet's audit-retention landing). readAuditLog returns [] when the file is absent,
350
+ // so no audit ⇒ no hook-hygiene findings. This is the only read OUTSIDE compiled/+codex/,
351
+ // and it stays read-only — lint writes nothing. The read is the whole LIVE audit.log (not
352
+ // the archive — a 7-day bypass window is never in the 90-day-retention archive); J5 bounds
353
+ // that file's growth, so the read stays proportional to recent activity, not all history.
354
+ const auditEntries = await readAuditLog(loaded.root);
355
+ for (const finding of hookHygieneFindings(auditEntries, now)) findings.push(finding);
356
+
357
+ findings.sort((a, b) => relSort(`${a.surface}:${a.kind}:${a.path ?? a.hook ?? ''}:${a.target ?? ''}`, `${b.surface}:${b.kind}:${b.path ?? b.hook ?? ''}:${b.target ?? ''}`));
358
+
359
+ return {
360
+ scope: loaded.scope,
361
+ stale_months: staleMonths,
362
+ surfaces: buildSurfaces(findings),
363
+ findings,
364
+ counts: countByKind(findings),
365
+ empty_result: findings.length === 0,
366
+ latency_ms: Math.round(performance.now() - t0),
367
+ };
368
+ }