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.
- package/README.md +66 -0
- package/package.json +34 -0
- package/src/act.js +61 -0
- package/src/active-context.js +56 -0
- package/src/adapter.js +386 -0
- package/src/cli.js +1893 -0
- package/src/codex-hooks-schema.js +92 -0
- package/src/compile.js +513 -0
- package/src/doctor.js +303 -0
- package/src/due.js +271 -0
- package/src/eval.js +467 -0
- package/src/event.js +212 -0
- package/src/export.js +136 -0
- package/src/guard.js +89 -0
- package/src/hooks.js +230 -0
- package/src/ingest.js +965 -0
- package/src/init.js +150 -0
- package/src/layout.js +394 -0
- package/src/links.js +83 -0
- package/src/lint.js +58 -0
- package/src/profile.js +116 -0
- package/src/prompts.js +26 -0
- package/src/registry.js +190 -0
- package/src/scaffold.js +134 -0
- package/src/search.js +139 -0
- package/src/skill-discovery.js +117 -0
- package/src/skills-bump.js +885 -0
- package/src/skills.js +355 -0
- package/src/subscription.js +5 -0
- package/src/validate.js +152 -0
- package/templates/claude/settings.json +86 -0
- package/templates/codex/INDEX.md +68 -0
- package/templates/codex/seed/ai-practice/compile-dont-just-capture.md +42 -0
- package/templates/codex/seed/ai-practice/design-for-the-distribution.md +42 -0
- package/templates/codex/seed/ai-practice/from-spec-author-to-outcome-curator.md +43 -0
- package/templates/codex/seed/ai-practice/recall-builds-trust-when-calibrated.md +70 -0
- package/templates/codex/seed/ai-practice/the-eval-is-the-spec.md +42 -0
- package/templates/codex/seed/ai-practice/the-gold-dust-leaves-no-trace.md +72 -0
- package/templates/codex/seed/ai-practice/the-model-is-not-the-moat.md +40 -0
- package/templates/codex/seed/ai-practice/when-generation-is-cheap-judgment-is-the-work.md +39 -0
- package/templates/codex/seed/judgment/contextual-stewardship-is-the-senior-skill.md +50 -0
- package/templates/codex/seed/judgment/decision-quality-is-distinct-from-outcome-quality.md +39 -0
- package/templates/codex/seed/judgment/embrace-reality-ego-and-blind-spots.md +38 -0
- package/templates/codex/seed/judgment/groups-seek-consensus-individuals-seek-truth.md +44 -0
- package/templates/codex/seed/judgment/heresies-are-where-the-edge-lives.md +42 -0
- package/templates/codex/seed/judgment/judgment-compounds-through-honest-reflection.md +38 -0
- package/templates/codex/seed/judgment/phronesis-is-judgment-in-the-particular.md +67 -0
- package/templates/codex/seed/judgment/show-me-the-apparatus.md +40 -0
- package/templates/codex/seed/judgment/small-judgment-gaps-compound-under-leverage.md +37 -0
- package/templates/codex/seed/judgment/taste-is-compressed-judgment.md +38 -0
- package/templates/codex/seed/judgment/trust-taste-only-where-feedback-is-valid.md +42 -0
- package/templates/codex/seed/judgment/weigh-the-reasoning-not-the-recommendation.md +43 -0
- package/templates/codex/seed/leadership/a-managers-output-is-the-teams-output.md +35 -0
- package/templates/codex/seed/leadership/most-team-problems-are-structural.md +40 -0
- package/templates/codex/seed/leadership/next-play.md +36 -0
- package/templates/codex/seed/leadership/psychological-safety.md +39 -0
- package/templates/codex/seed/leadership/right-size-your-teams.md +38 -0
- package/templates/codex/seed/leadership/spend-time-on-high-leverage-activities.md +36 -0
- package/templates/codex/seed/leadership/talent-density-is-the-master-variable.md +39 -0
- package/templates/codex/seed/leadership/under-pressure-calm-is-contagious.md +36 -0
- package/templates/codex/seed/leadership/your-best-alternative-is-your-leverage.md +37 -0
- package/templates/codex/seed/pm/a-problem-well-stated-is-half-solved.md +39 -0
- package/templates/codex/seed/pm/a-real-strategy-makes-planning-trivial.md +40 -0
- package/templates/codex/seed/pm/barrels-and-ammunition.md +39 -0
- package/templates/codex/seed/pm/develop-taste-in-your-beliefs.md +38 -0
- package/templates/codex/seed/pm/hire-for-progress.md +44 -0
- package/templates/codex/seed/pm/jobs-are-stable-solutions-churn.md +39 -0
- package/templates/codex/seed/pm/level-by-the-ambiguity-someone-can-own.md +39 -0
- package/templates/codex/seed/pm/listen-before-you-prescribe.md +38 -0
- package/templates/codex/seed/pm/live-in-the-future-and-build-whats-missing.md +38 -0
- package/templates/codex/seed/pm/match-effort-to-leverage.md +37 -0
- package/templates/codex/seed/pm/not-my-fault-but-my-problem.md +40 -0
- package/templates/codex/seed/pm/pick-the-job-then-refuse-everything-outside-it.md +37 -0
- package/templates/codex/seed/pm/retention-sets-the-ceiling.md +38 -0
- package/templates/codex/seed/pm/scale-with-leaders-not-process.md +38 -0
- package/templates/codex/seed/pm/solution-discovery-over-problem-validation.md +38 -0
- package/templates/codex/seed/pm/tenfold-better-on-a-painful-problem.md +40 -0
- package/templates/codex-surface/README.md +73 -0
- package/templates/codex-surface/hooks/_resolve.sh +36 -0
- package/templates/codex-surface/hooks/recall-guard.sh +30 -0
- package/templates/codex-surface/hooks/session-start.sh +18 -0
- package/templates/codex-surface/hooks/session-sweep-precompact.sh +20 -0
- package/templates/codex-surface/hooks/session-sweep-subagent.sh +23 -0
- package/templates/codex-surface/hooks/session-sweep.sh +19 -0
- package/templates/codex-surface/hooks.json +69 -0
- package/templates/domains/pm/AGENTS.md +111 -0
- package/templates/domains/pm/MEMORY.md +7 -0
- package/templates/domains/pm/USER.md +8 -0
- package/templates/phronesis-hooks/compile-active-context.sh +38 -0
- package/templates/phronesis-hooks/recall-guard.sh +103 -0
- package/templates/phronesis-hooks/session-start.sh +90 -0
- package/templates/phronesis-hooks/session-sweep.sh +140 -0
- package/templates/phronesis-hooks/skill-lifecycle.sh +37 -0
- package/templates/skills/compile/SKILL.md +279 -0
- package/templates/skills/compile/evals/rubric.md +75 -0
- package/templates/skills/decision-log/SKILL.md +156 -0
- package/templates/skills/decision-log/evals/rubric.md +97 -0
- package/templates/skills/extract-gold-dust/SKILL.md +311 -0
- package/templates/skills/extract-gold-dust/evals/rubric.md +52 -0
- package/templates/skills/ingest/SKILL.md +204 -0
- package/templates/skills/ingest/evals/rubric.md +117 -0
- package/templates/skills/lint/SKILL.md +246 -0
- package/templates/skills/lint/evals/rubric.md +139 -0
- package/templates/skills/meeting-prep/SKILL.md +195 -0
- package/templates/skills/meeting-prep/evals/rubric.md +93 -0
- package/templates/skills/onboard/SKILL.md +258 -0
- package/templates/skills/onboard/evals/rubric.md +60 -0
- package/templates/skills/prd-draft/SKILL.md +193 -0
- package/templates/skills/prd-draft/evals/rubric.md +38 -0
- package/templates/skills/prep-extraction/SKILL.md +217 -0
- package/templates/skills/prep-extraction/evals/rubric.md +45 -0
- package/templates/skills/recall/SKILL.md +155 -0
- package/templates/skills/recall/evals/rubric.md +37 -0
- package/templates/skills/research/SKILL.md +247 -0
- package/templates/skills/research/evals/rubric.md +139 -0
- package/templates/skills/stakeholder-update/SKILL.md +163 -0
- package/templates/skills/stakeholder-update/evals/rubric.md +111 -0
- package/vendor/core/package.json +25 -0
- package/vendor/core/src/action-registry.js +459 -0
- package/vendor/core/src/action-writers.js +201 -0
- package/vendor/core/src/actions.js +2140 -0
- package/vendor/core/src/active-context.js +167 -0
- package/vendor/core/src/artifacts.js +301 -0
- package/vendor/core/src/content-digest.js +21 -0
- package/vendor/core/src/contract.js +245 -0
- package/vendor/core/src/event-log.js +338 -0
- package/vendor/core/src/guard.js +398 -0
- package/vendor/core/src/hook-executor.js +527 -0
- package/vendor/core/src/index.js +180 -0
- package/vendor/core/src/ingest.js +286 -0
- package/vendor/core/src/install-root.js +42 -0
- package/vendor/core/src/layout.js +13 -0
- package/vendor/core/src/link-index.js +198 -0
- package/vendor/core/src/lint.js +368 -0
- package/vendor/core/src/schedule.js +535 -0
- package/vendor/core/src/search.js +534 -0
- package/vendor/core/src/session-hooks.js +129 -0
- package/vendor/core/src/skill-lifecycle.js +479 -0
- package/vendor/core/src/skill-registry.js +524 -0
- package/vendor/core/src/subscription.js +84 -0
- package/vendor/core/src/validate-object.js +62 -0
- 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
|
+
}
|