@event4u/agent-config 17.3.0 → 17.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +164 -342
- package/CREDITS.md +2 -2
- package/README.md +1 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -2
- package/dist/cli-delegate/{chunk-DHSSP7V3.js → chunk-5AYJGUA3.js} +0 -2
- package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
- package/dist/cli-delegate/{chunk-MT2WOOCY.js → chunk-WY5ZZPGL.js} +2 -2
- package/dist/cli-delegate/{chunk-ORJPBER2.js → chunk-X6LZ7W5A.js} +73 -10
- package/dist/cli-delegate/cmd_conformance.js +175 -183
- package/dist/cli-delegate/cmd_doctor.js +5 -5
- package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_update.js +24 -13
- package/dist/cli-delegate/cmd_upgrade.js +2 -2
- package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
- package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
- package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
- package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +3 -3
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/code_graph_refresh.chunk.cjs +56 -27
- package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -1
- package/dist/hooks/dispatch.js +982 -929
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/egress_engine.chunk.cjs +56 -6
- package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
- package/dist/hooks/update_notice.chunk.cjs +914 -0
- package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +71259 -66437
- package/dist/scripts/condense.js +9 -6
- package/package.json +4 -1
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/hook-bundle-budget.json +31 -6
- package/src/config/packed-binary-manifest.json +9 -0
- package/src/config/sigstore/npm_keys.json +50 -0
- package/src/config/sigstore/trust-material.json +9 -0
- package/src/config/sigstore/trusted_root.json +126 -0
- package/src/config/skill-body-budget.json +7 -1
- package/src/scripts/_cli/cmd_update.ts +48 -20
- package/src/scripts/_cli/cmd_upgrade.ts +7 -1
- package/src/scripts/_cli/upgrade_unattended.ts +90 -76
- package/src/scripts/_dispatch.bash +3 -2
- package/src/scripts/_lib/confusables.ts +0 -19
- package/src/scripts/_lib/egress_normalize.ts +53 -23
- package/src/scripts/_lib/egress_text.ts +5 -3
- package/src/scripts/_lib/egress_token.ts +7 -5
- package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
- package/src/scripts/_lib/git_common_dir.ts +10 -0
- package/src/scripts/_lib/graph_feeder_record.ts +3 -2
- package/src/scripts/_lib/install_reach_checks.ts +32 -2
- package/src/scripts/_lib/lineage_backfill.ts +136 -0
- package/src/scripts/_lib/lineage_ledger.ts +211 -0
- package/src/scripts/_lib/lineage_map.ts +55 -0
- package/src/scripts/_lib/lineage_query.ts +140 -0
- package/src/scripts/_lib/lineage_seed.ts +116 -0
- package/src/scripts/_lib/lineage_watch.ts +249 -0
- package/src/scripts/_lib/link_crypto.ts +8 -4
- package/src/scripts/_lib/production_reach.ts +3 -3
- package/src/scripts/_lib/project_registry.ts +227 -0
- package/src/scripts/_lib/provenance_verify.ts +14 -242
- package/src/scripts/_lib/sigstore_provenance.ts +177 -0
- package/src/scripts/_lib/source_id.ts +48 -0
- package/src/scripts/_lib/source_shape.ts +4 -0
- package/src/scripts/_lib/unattended_gates.ts +6 -1
- package/src/scripts/advisories.ts +26 -10
- package/src/scripts/ai_council/council_egress.ts +5 -6
- package/src/scripts/bench_graph_feeder_latency.ts +10 -3
- package/src/scripts/check_dependency_floors.ts +7 -0
- package/src/scripts/check_egress_mutations.ts +1 -0
- package/src/scripts/code_graph/build.ts +67 -3
- package/src/scripts/code_graph/cli.ts +131 -28
- package/src/scripts/code_graph/conventions_default.ts +20 -1
- package/src/scripts/code_graph/detect.ts +5 -5
- package/src/scripts/code_graph/estate_cli.ts +135 -0
- package/src/scripts/code_graph/estate_index.ts +394 -0
- package/src/scripts/code_graph/estate_manifests.ts +223 -0
- package/src/scripts/code_graph/estate_map.ts +141 -0
- package/src/scripts/code_graph/extract.ts +4 -1
- package/src/scripts/code_graph/extract_more.ts +137 -2
- package/src/scripts/code_graph/graph_budget.ts +158 -0
- package/src/scripts/code_graph/graph_home.ts +80 -0
- package/src/scripts/code_graph/lineage_cli.ts +315 -0
- package/src/scripts/code_graph/map.ts +22 -13
- package/src/scripts/code_graph/map_model.ts +67 -12
- package/src/scripts/code_graph/map_verify.ts +68 -5
- package/src/scripts/code_graph/map_viewer/viewer.css +12 -0
- package/src/scripts/code_graph/map_viewer/viewer.js +45 -15
- package/src/scripts/code_graph/project_ref.ts +25 -0
- package/src/scripts/code_graph/projects_cli.ts +254 -0
- package/src/scripts/code_graph/providers/catalog.ts +14 -5
- package/src/scripts/code_graph/providers/django.ts +52 -9
- package/src/scripts/code_graph/providers/index.ts +2 -0
- package/src/scripts/code_graph/providers/rails.ts +204 -0
- package/src/scripts/code_graph/providers/spring.ts +38 -1
- package/src/scripts/code_graph/query.ts +9 -9
- package/src/scripts/code_graph/refresh_lock.ts +12 -8
- package/src/scripts/code_graph/repo_identity.ts +169 -0
- package/src/scripts/code_graph/sqlite_store.ts +22 -25
- package/src/scripts/code_graph/types.ts +6 -2
- package/src/scripts/condense.ts +6 -6
- package/src/scripts/hook_chunks.mjs +7 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +20 -17
- package/src/scripts/hooks/code_graph_refresh_hook.ts +10 -7
- package/src/scripts/hooks/dispatch_hook.ts +6 -7
- package/src/scripts/hooks/host_semantics.ts +72 -0
- package/src/scripts/hooks/lazy_concern.ts +4 -0
- package/src/scripts/hooks/user_notice.ts +105 -0
- package/src/scripts/lint_harvest_provenance.ts +137 -11
- package/src/scripts/mcp_server/consumer_tool_catalog.json +69 -15
- package/src/scripts/mcp_server/graph_tools.ts +161 -41
- package/src/scripts/network_sources.ts +24 -6
- package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
- package/src/scripts/update_notice_hook.ts +75 -7
- package/src/vendor/grammars/README.md +7 -6
- package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
- /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
- /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
|
@@ -34,6 +34,8 @@ import * as fs from 'node:fs';
|
|
|
34
34
|
import * as path from 'node:path';
|
|
35
35
|
|
|
36
36
|
import { ROADMAP_ROOT_REL, scan as scanArtifactLocation } from './agent_artifact_location.js';
|
|
37
|
+
import * as installed_lock from './installed_lock.js';
|
|
38
|
+
import { compare_versions } from './version_order.js';
|
|
37
39
|
import type { WiringCheck } from './runtime_wiring_checks.js';
|
|
38
40
|
import { TOOL_TO_HOST, residualReport } from './mcp_consent_residual.js';
|
|
39
41
|
import { checkSkillReach } from './skill_reach.js';
|
|
@@ -353,7 +355,19 @@ export function readInstalledVersion(projectRoot: string): string | null {
|
|
|
353
355
|
* such a filename, and a checker that skipped the file would have called that
|
|
354
356
|
* tree clean.
|
|
355
357
|
*/
|
|
356
|
-
|
|
358
|
+
/** The version the GLOBAL install recorded — what every session's hooks, rules and skills run. */
|
|
359
|
+
export function readGlobalVersion(): string | null {
|
|
360
|
+
const recorded = (installed_lock.read_lockfile(installed_lock.lockfile_path()) as unknown as Record<string, unknown> | null)?.[
|
|
361
|
+
'agent_config_version'
|
|
362
|
+
];
|
|
363
|
+
return typeof recorded === 'string' && recorded.trim() !== '' ? recorded.trim().replace(/^v+/, '') : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function checkVersionAxis(
|
|
367
|
+
projectRoot: string,
|
|
368
|
+
resolvableNow: string | null,
|
|
369
|
+
globalVersion: string | null = readGlobalVersion(),
|
|
370
|
+
): WiringCheck {
|
|
357
371
|
const id = 'version-axis';
|
|
358
372
|
const pins = readVersionPins(projectRoot);
|
|
359
373
|
const installed = readInstalledVersion(projectRoot);
|
|
@@ -364,7 +378,23 @@ export function checkVersionAxis(projectRoot: string, resolvableNow: string | nu
|
|
|
364
378
|
pins.length === 0
|
|
365
379
|
? 'pinned: none'
|
|
366
380
|
: `pinned: ${pins.map((p) => `${p.version} (${p.file}${p.legacy ? ', LEGACY filename' : ''})`).join(' · ')}`;
|
|
367
|
-
const line =
|
|
381
|
+
const line =
|
|
382
|
+
`${pinText} · installed: ${installed ?? 'unknown'} · global: ${globalVersion ?? 'none'} · ` +
|
|
383
|
+
`resolvable now: ${resolvableNow ?? 'unknown'}`;
|
|
384
|
+
// Only the CLI follows a pin; hooks, rules and skills run from the global
|
|
385
|
+
// install. A pin ahead of it is a project naming a version no session runs,
|
|
386
|
+
// and nothing else in this tree compares the two.
|
|
387
|
+
const ahead = pins.filter((p) => globalVersion !== null && compare_versions(p.version, globalVersion) === 1);
|
|
388
|
+
if (ahead.length > 0) {
|
|
389
|
+
return {
|
|
390
|
+
id,
|
|
391
|
+
status: 'warn',
|
|
392
|
+
message:
|
|
393
|
+
`${line} — the pin names ${ahead.map((p) => p.version).join(', ')} but the global install, which ` +
|
|
394
|
+
`every session's hooks, rules and skills run from, is ${globalVersion as string}.`,
|
|
395
|
+
remedy: 'run `agent-config update` (it installs the pinned version globally) or `agent-config upgrade`',
|
|
396
|
+
};
|
|
397
|
+
}
|
|
368
398
|
const divergent = pins.filter((p) => installed !== null && p.version !== installed);
|
|
369
399
|
if (divergent.length === 0) {
|
|
370
400
|
return { id, status: 'ok', message: line, remedy: '' };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lineage backfill --draft` — turn the tree's existing ENC1 tokens into draft lineage rows.
|
|
3
|
+
*
|
|
4
|
+
* Years of roadmaps already carry sealed source links in their Provenance blocks. This
|
|
5
|
+
* decrypts them locally, gives each a source id, and proposes a draft row only where the
|
|
6
|
+
* block itself names a mechanism and a verdict on the token's line. Everything else is
|
|
7
|
+
* reported as unclear. It writes drafts and nothing else: confirming a draft into a ledger is
|
|
8
|
+
* the owner's step, because a row the agent confirmed itself is fabricated provenance.
|
|
9
|
+
*
|
|
10
|
+
* The decrypted output lands in `agents/.harvest-local/`, inside the work tree, so the run
|
|
11
|
+
* refuses unless git ignores that directory.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { gitEnv } from './git_env.js';
|
|
19
|
+
import { decrypt } from './link_crypto.js';
|
|
20
|
+
import { sourceId } from './source_id.js';
|
|
21
|
+
|
|
22
|
+
export const BACKFILL_DIR_REL = 'agents/.harvest-local/lineage-backfill';
|
|
23
|
+
|
|
24
|
+
const TOKEN_RE = /ENC1:[A-Za-z0-9+/=]+/g;
|
|
25
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
|
26
|
+
const MECHANISM_RE = /\bmechanism:\s*([a-z0-9]+(?:-[a-z0-9]+)*)/;
|
|
27
|
+
const VERDICT_RE = /\bverdict:\s*(adopt|adapt|reject|already|gap|deferred)\b/;
|
|
28
|
+
|
|
29
|
+
export interface DecryptedToken {
|
|
30
|
+
readonly file: string;
|
|
31
|
+
readonly line: number;
|
|
32
|
+
readonly source_id: string;
|
|
33
|
+
readonly identity: string;
|
|
34
|
+
readonly in_provenance: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DraftRow {
|
|
38
|
+
readonly file: string;
|
|
39
|
+
readonly line: number;
|
|
40
|
+
readonly source_id: string;
|
|
41
|
+
readonly mechanism_id: string;
|
|
42
|
+
readonly verdict: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface BackfillResult {
|
|
46
|
+
readonly tokens: DecryptedToken[];
|
|
47
|
+
readonly drafts: DraftRow[];
|
|
48
|
+
/** `file:line` of a Provenance-block token that names no mechanism and verdict. */
|
|
49
|
+
readonly unclear: string[];
|
|
50
|
+
/** `file:line` of a token no configured key opens. */
|
|
51
|
+
readonly undecryptable: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function git(root: string, args: string[]): { status: number; stdout: string } {
|
|
55
|
+
try {
|
|
56
|
+
return { status: 0, stdout: execFileSync('git', ['-C', root, ...args], { encoding: 'utf-8', env: gitEnv(), stdio: ['ignore', 'pipe', 'ignore'] }) };
|
|
57
|
+
} catch (e) {
|
|
58
|
+
const err = e as { status?: number; stdout?: string };
|
|
59
|
+
return { status: err.status ?? 1, stdout: err.stdout ?? '' };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isIgnored(root: string, rel: string): boolean {
|
|
64
|
+
return git(root, ['check-ignore', '-q', '--no-index', `${rel}/`]).status === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function scanTree(root: string, keys: readonly string[]): BackfillResult {
|
|
68
|
+
const files = git(root, ['ls-files', '-z', '--', '*.md']).stdout.split('\0').filter((f) => f !== '');
|
|
69
|
+
const result: BackfillResult = { tokens: [], drafts: [], unclear: [], undecryptable: [] };
|
|
70
|
+
for (const rel of files.sort()) {
|
|
71
|
+
let text: string;
|
|
72
|
+
try {
|
|
73
|
+
text = fs.readFileSync(path.join(root, rel), 'utf-8');
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (!text.includes('ENC1:')) continue;
|
|
78
|
+
let provenanceLevel = 0;
|
|
79
|
+
text.split('\n').forEach((lineText, i) => {
|
|
80
|
+
const h = HEADING_RE.exec(lineText);
|
|
81
|
+
if (h) {
|
|
82
|
+
const level = (h[1] as string).length;
|
|
83
|
+
if (/provenance/i.test(h[2] as string)) provenanceLevel = level;
|
|
84
|
+
else if (provenanceLevel > 0 && level <= provenanceLevel) provenanceLevel = 0;
|
|
85
|
+
}
|
|
86
|
+
const tokens = lineText.match(TOKEN_RE) ?? [];
|
|
87
|
+
for (const token of tokens) {
|
|
88
|
+
const at = `${rel}:${String(i + 1)}`;
|
|
89
|
+
let identity: string;
|
|
90
|
+
try {
|
|
91
|
+
identity = decrypt(token, [...keys]);
|
|
92
|
+
} catch {
|
|
93
|
+
result.undecryptable.push(at);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const id = sourceId(identity, keys[0] as string);
|
|
97
|
+
const inProvenance = provenanceLevel > 0;
|
|
98
|
+
result.tokens.push({ file: rel, line: i + 1, source_id: id, identity, in_provenance: inProvenance });
|
|
99
|
+
if (!inProvenance) continue;
|
|
100
|
+
const mech = MECHANISM_RE.exec(lineText);
|
|
101
|
+
const verdict = VERDICT_RE.exec(lineText);
|
|
102
|
+
if (mech && verdict && tokens.length === 1) {
|
|
103
|
+
result.drafts.push({ file: rel, line: i + 1, source_id: id, mechanism_id: mech[1] as string, verdict: verdict[1] as string });
|
|
104
|
+
} else {
|
|
105
|
+
result.unclear.push(at);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function writeBackfill(root: string, result: BackfillResult): string {
|
|
114
|
+
const out = path.join(root, BACKFILL_DIR_REL);
|
|
115
|
+
fs.mkdirSync(out, { recursive: true, mode: 0o700 });
|
|
116
|
+
const jsonl = (rows: readonly unknown[]): string => rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '');
|
|
117
|
+
fs.writeFileSync(path.join(out, 'tokens.jsonl'), jsonl(result.tokens), { mode: 0o600 });
|
|
118
|
+
fs.writeFileSync(path.join(out, 'drafts.jsonl'), jsonl(result.drafts), { mode: 0o600 });
|
|
119
|
+
const report = [
|
|
120
|
+
'# Lineage backfill — drafts for the owner to confirm',
|
|
121
|
+
'',
|
|
122
|
+
`${String(result.drafts.length)} draft(s), ${String(result.unclear.length)} unclear, ${String(result.undecryptable.length)} undecryptable, from ${String(result.tokens.length)} decrypted token(s).`,
|
|
123
|
+
'No ledger row was written. Confirming a draft is the owner\'s step.',
|
|
124
|
+
'',
|
|
125
|
+
'## Drafts',
|
|
126
|
+
'',
|
|
127
|
+
...result.drafts.map((d) => `- ${d.file}:${String(d.line)} — ${d.mechanism_id} — ${d.verdict} — ${d.source_id}`),
|
|
128
|
+
'',
|
|
129
|
+
'## Unclear',
|
|
130
|
+
'',
|
|
131
|
+
...result.unclear.map((u) => `- ${u}`),
|
|
132
|
+
'',
|
|
133
|
+
].join('\n');
|
|
134
|
+
fs.writeFileSync(path.join(out, 'report.md'), report, { mode: 0o600 });
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local lineage ledger — the half of source provenance that never leaves the machine.
|
|
3
|
+
*
|
|
4
|
+
* Council O5 split provenance in two. The tracked tree carries a source-free decision
|
|
5
|
+
* ledger (`provenance/mechanisms.jsonl`); everything that links a source to a mechanism —
|
|
6
|
+
* which source, at which pin, read where — lives here, under the user-global runtime:
|
|
7
|
+
*
|
|
8
|
+
* <event4u_root>/runtime/lineage/sources.jsonl one row per source, identity sealed
|
|
9
|
+
* <event4u_root>/runtime/lineage/edges.jsonl source → mechanism, pin and locator sealed
|
|
10
|
+
*
|
|
11
|
+
* Every write is refused when the target sits inside a git work tree: a ledger committed by
|
|
12
|
+
* accident would put keyed ids, relations and timestamps into public history for good,
|
|
13
|
+
* which is exactly what O5 decided against. An `EVENT4U_CONFIG_HOME` pointed into a
|
|
14
|
+
* checkout is the realistic way that happens.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { enclosingWorkTree } from './git_common_dir.js';
|
|
21
|
+
import { decrypt, is_token } from './link_crypto.js';
|
|
22
|
+
import { SOURCE_ID_RE } from './source_id.js';
|
|
23
|
+
import { event4u_root, type EnvMap } from './user_global_paths.js';
|
|
24
|
+
|
|
25
|
+
export const SOURCE_KINDS = ['repo', 'url', 'video', 'paper', 'unknown'] as const;
|
|
26
|
+
export type SourceKind = (typeof SOURCE_KINDS)[number];
|
|
27
|
+
/** A pin nobody recorded. Written instead of a guess, never replaced by one. */
|
|
28
|
+
export const PIN_UNKNOWN = 'unknown';
|
|
29
|
+
|
|
30
|
+
export interface SourceRow {
|
|
31
|
+
readonly source_id: string;
|
|
32
|
+
readonly kind: SourceKind;
|
|
33
|
+
/** ENC1 token of the source identity (URL or opaque reference). */
|
|
34
|
+
readonly identity: string;
|
|
35
|
+
/** SPDX id, or 'unknown'. Plaintext: a license names no source. */
|
|
36
|
+
readonly license: string;
|
|
37
|
+
readonly watch: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EdgeRow {
|
|
41
|
+
readonly source_id: string;
|
|
42
|
+
readonly mechanism_id: string;
|
|
43
|
+
/** ENC1 token of the revision the mechanism was read at, or `unknown`. */
|
|
44
|
+
readonly pin: string;
|
|
45
|
+
/** ENC1 token of where inside the source, or null when nobody recorded one. */
|
|
46
|
+
readonly locator: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LineageFinding {
|
|
50
|
+
readonly file: 'sources.jsonl' | 'edges.jsonl';
|
|
51
|
+
readonly line: number;
|
|
52
|
+
readonly message: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class LineageError extends Error {}
|
|
56
|
+
|
|
57
|
+
export function lineageDir(env?: EnvMap | null): string {
|
|
58
|
+
return path.join(event4u_root(env), 'runtime', 'lineage');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Throws when `target` would land inside a git work tree. */
|
|
62
|
+
export function assertOutsideWorkTree(target: string): void {
|
|
63
|
+
const tree = enclosingWorkTree(target);
|
|
64
|
+
if (tree !== null) {
|
|
65
|
+
throw new LineageError(
|
|
66
|
+
`refusing to write ${target}: it is inside the git work tree ${tree}, and lineage data never enters a repository`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Atomic write (temp + rename), refused inside a work tree. */
|
|
72
|
+
export function writePrivateFile(target: string, body: string): void {
|
|
73
|
+
assertOutsideWorkTree(target);
|
|
74
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
75
|
+
const tmp = `${target}.tmp-${String(process.pid)}`;
|
|
76
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
77
|
+
fs.renameSync(tmp, target);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readJsonl(file: string): unknown[] {
|
|
81
|
+
if (!fs.existsSync(file)) return [];
|
|
82
|
+
return fs
|
|
83
|
+
.readFileSync(file, 'utf-8')
|
|
84
|
+
.split('\n')
|
|
85
|
+
.filter((l) => l.trim() !== '')
|
|
86
|
+
.map((l) => {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(l) as unknown;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const jsonl = (rows: readonly unknown[]): string => rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '');
|
|
96
|
+
|
|
97
|
+
export interface Ledger {
|
|
98
|
+
readonly sources: SourceRow[];
|
|
99
|
+
readonly edges: EdgeRow[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function hasLedger(dir: string): boolean {
|
|
103
|
+
return fs.existsSync(path.join(dir, 'sources.jsonl'));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function readLedger(dir: string): Ledger {
|
|
107
|
+
return {
|
|
108
|
+
sources: readJsonl(path.join(dir, 'sources.jsonl')) as SourceRow[],
|
|
109
|
+
edges: readJsonl(path.join(dir, 'edges.jsonl')) as EdgeRow[],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function writeLedger(dir: string, ledger: Ledger): void {
|
|
114
|
+
writePrivateFile(path.join(dir, 'sources.jsonl'), jsonl(ledger.sources));
|
|
115
|
+
writePrivateFile(path.join(dir, 'edges.jsonl'), jsonl(ledger.edges));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A sealed value's plaintext, or the token itself when no key opens it. */
|
|
119
|
+
function opened(value: string | null, keys: readonly string[]): string | null {
|
|
120
|
+
if (value === null || !is_token(value) || keys.length === 0) return value;
|
|
121
|
+
try {
|
|
122
|
+
return decrypt(value, [...keys]);
|
|
123
|
+
} catch {
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Add sources and edges, keeping what is there: a known source keeps its watch flag and
|
|
130
|
+
* license. Edges are append-only: an edge is added unless the newest recorded edge for the
|
|
131
|
+
* same source and mechanism already carries its pin and locator, so a moved pin, a new
|
|
132
|
+
* locator and an `unknown` edge that becomes pinned are all recorded. Sealed fields compare
|
|
133
|
+
* by plaintext under `keys` — every seal is freshly salted, so two tokens of one pin differ.
|
|
134
|
+
* Returns the counts actually added.
|
|
135
|
+
*/
|
|
136
|
+
export function mergeIntoLedger(dir: string, add: Ledger, keys: readonly string[] = []): { sources: number; edges: number } {
|
|
137
|
+
const cur = readLedger(dir);
|
|
138
|
+
const known = new Set(cur.sources.map((s) => s.source_id));
|
|
139
|
+
const sources = [...cur.sources];
|
|
140
|
+
let addedSources = 0;
|
|
141
|
+
for (const s of add.sources) {
|
|
142
|
+
if (known.has(s.source_id)) continue;
|
|
143
|
+
known.add(s.source_id);
|
|
144
|
+
sources.push(s);
|
|
145
|
+
addedSources += 1;
|
|
146
|
+
}
|
|
147
|
+
const edgeKey = (e: EdgeRow): string => `${e.source_id}\0${e.mechanism_id}`;
|
|
148
|
+
const newest = new Map<string, { pin: string | null; locator: string | null }>();
|
|
149
|
+
const remember = (e: EdgeRow): void => void newest.set(edgeKey(e), { pin: opened(e.pin, keys), locator: opened(e.locator, keys) });
|
|
150
|
+
cur.edges.forEach(remember);
|
|
151
|
+
const edges = [...cur.edges];
|
|
152
|
+
let addedEdges = 0;
|
|
153
|
+
for (const e of add.edges) {
|
|
154
|
+
const last = newest.get(edgeKey(e));
|
|
155
|
+
if (last !== undefined && last.pin === opened(e.pin, keys) && last.locator === opened(e.locator, keys)) continue;
|
|
156
|
+
remember(e);
|
|
157
|
+
edges.push(e);
|
|
158
|
+
addedEdges += 1;
|
|
159
|
+
}
|
|
160
|
+
writeLedger(dir, { sources, edges });
|
|
161
|
+
return { sources: addedSources, edges: addedEdges };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const MECHANISM_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
165
|
+
|
|
166
|
+
function sealedOk(value: unknown, keys: readonly string[]): boolean {
|
|
167
|
+
if (!is_token(value)) return false;
|
|
168
|
+
if (keys.length === 0) return true;
|
|
169
|
+
try {
|
|
170
|
+
decrypt(value as string, [...keys]);
|
|
171
|
+
return true;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Validate both files. With `keys`, every sealed field must also authenticate, so a ledger
|
|
179
|
+
* sealed under a lost key is reported rather than discovered on first use.
|
|
180
|
+
*/
|
|
181
|
+
export function checkLedger(dir: string, keys: readonly string[] = []): LineageFinding[] {
|
|
182
|
+
const out: LineageFinding[] = [];
|
|
183
|
+
const sourceIds = new Set<string>();
|
|
184
|
+
const sealNote = keys.length > 0 ? 'an ENC1 token that decrypts with the configured key' : 'an ENC1 token';
|
|
185
|
+
readJsonl(path.join(dir, 'sources.jsonl')).forEach((raw, i) => {
|
|
186
|
+
const at = (message: string): void => void out.push({ file: 'sources.jsonl', line: i + 1, message });
|
|
187
|
+
if (typeof raw !== 'object' || raw === null) return at('not a JSON object');
|
|
188
|
+
const r = raw as Record<string, unknown>;
|
|
189
|
+
const extra = Object.keys(r).filter((k) => !['source_id', 'kind', 'identity', 'license', 'watch'].includes(k));
|
|
190
|
+
if (extra.length > 0) at(`unexpected field(s) ${extra.join(', ')}`);
|
|
191
|
+
if (typeof r.source_id !== 'string' || !SOURCE_ID_RE.test(r.source_id)) at('source_id must be src_ plus 10 hex');
|
|
192
|
+
else if (sourceIds.has(r.source_id)) at(`duplicate source_id ${r.source_id}`);
|
|
193
|
+
else sourceIds.add(r.source_id);
|
|
194
|
+
if (!(SOURCE_KINDS as readonly unknown[]).includes(r.kind)) at(`kind must be one of ${SOURCE_KINDS.join(' | ')}`);
|
|
195
|
+
if (!sealedOk(r.identity, keys)) at(`identity must be ${sealNote}`);
|
|
196
|
+
if (typeof r.license !== 'string' || r.license === '') at("license must be an SPDX id or 'unknown'");
|
|
197
|
+
if (typeof r.watch !== 'boolean') at('watch must be true or false');
|
|
198
|
+
});
|
|
199
|
+
readJsonl(path.join(dir, 'edges.jsonl')).forEach((raw, i) => {
|
|
200
|
+
const at = (message: string): void => void out.push({ file: 'edges.jsonl', line: i + 1, message });
|
|
201
|
+
if (typeof raw !== 'object' || raw === null) return at('not a JSON object');
|
|
202
|
+
const r = raw as Record<string, unknown>;
|
|
203
|
+
const extra = Object.keys(r).filter((k) => !['source_id', 'mechanism_id', 'pin', 'locator'].includes(k));
|
|
204
|
+
if (extra.length > 0) at(`unexpected field(s) ${extra.join(', ')}`);
|
|
205
|
+
if (typeof r.source_id !== 'string' || !sourceIds.has(r.source_id)) at(`source_id ${String(r.source_id)} names no row in sources.jsonl`);
|
|
206
|
+
if (typeof r.mechanism_id !== 'string' || !MECHANISM_ID_RE.test(r.mechanism_id)) at('mechanism_id must be a kebab-case slug');
|
|
207
|
+
if (r.pin !== PIN_UNKNOWN && !sealedOk(r.pin, keys)) at(`pin must be '${PIN_UNKNOWN}' or ${sealNote}`);
|
|
208
|
+
if (r.locator !== null && !sealedOk(r.locator, keys)) at(`locator must be null or ${sealNote}`);
|
|
209
|
+
});
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two lineage views, deliberately unequal (council O6).
|
|
3
|
+
*
|
|
4
|
+
* The tracked view lives next to the repo's shared map and lists mechanisms by verdict —
|
|
5
|
+
* nothing else. Not a source id, not an identity, not a per-source count: a list of what one
|
|
6
|
+
* source fed is more identifying than the source's name would be.
|
|
7
|
+
*
|
|
8
|
+
* The source view is the owner's: source → mechanisms with identities decrypted, rendered
|
|
9
|
+
* from the local ledger into the user-global home and never into a work tree.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MechanismRecord } from '../lint_harvest_provenance.js';
|
|
13
|
+
import type { Ledger } from './lineage_ledger.js';
|
|
14
|
+
import { latestDecisions, lineageFrom } from './lineage_query.js';
|
|
15
|
+
|
|
16
|
+
/** The tracked view's place: beside the shared map, carried over by every map redraw. */
|
|
17
|
+
export const LINEAGE_VIEW_REL = 'agents/map/lineage.md';
|
|
18
|
+
|
|
19
|
+
const VERDICT_ORDER: readonly MechanismRecord['verdict'][] = ['adopt', 'adapt', 'already', 'gap', 'deferred', 'reject'];
|
|
20
|
+
|
|
21
|
+
export function renderTrackedLineage(rows: readonly MechanismRecord[]): string {
|
|
22
|
+
const latest = [...latestDecisions(rows).values()];
|
|
23
|
+
const out = [
|
|
24
|
+
'# Lineage — mechanisms by verdict',
|
|
25
|
+
'',
|
|
26
|
+
`Generated by \`code-graph map --lineage\` from \`provenance/mechanisms.jsonl\`: ${String(latest.length)} mechanism(s), latest decision each.`,
|
|
27
|
+
'',
|
|
28
|
+
];
|
|
29
|
+
for (const verdict of VERDICT_ORDER) {
|
|
30
|
+
const group = latest.filter((r) => r.verdict === verdict).sort((a, b) => a.mechanism_id.localeCompare(b.mechanism_id));
|
|
31
|
+
if (group.length === 0) continue;
|
|
32
|
+
out.push(`## ${verdict} (${String(group.length)})`, '');
|
|
33
|
+
for (const r of group) {
|
|
34
|
+
const where = r.realised_in?.length ? ` — ${r.realised_in.map((p) => `\`${p}\``).join(', ')}` : '';
|
|
35
|
+
const owner = r.roadmap ? ` — owner \`${r.roadmap}\`` : '';
|
|
36
|
+
out.push(`- \`${r.mechanism_id}\` (${r.decided_at})${where}${owner}`);
|
|
37
|
+
}
|
|
38
|
+
out.push('');
|
|
39
|
+
}
|
|
40
|
+
return out.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function renderSourceLineage(ledger: Ledger, keys: readonly string[], rows: readonly MechanismRecord[]): string {
|
|
44
|
+
const out = ['# Lineage — sources (local, never shared)', ''];
|
|
45
|
+
for (const s of [...ledger.sources].sort((a, b) => a.source_id.localeCompare(b.source_id))) {
|
|
46
|
+
const found = lineageFrom(ledger, s.source_id, keys, rows);
|
|
47
|
+
if (found === null) continue;
|
|
48
|
+
out.push(`## ${s.source_id} — ${found.identity}`, '', `kind ${s.kind} · license ${s.license} · watch ${s.watch ? 'on' : 'off'}`, '');
|
|
49
|
+
for (const m of found.mechanisms) {
|
|
50
|
+
out.push(`- ${m.mechanism_id} — ${m.verdict ?? 'no decision'} — pin ${m.pin}${m.locator ? ` — at ${m.locator}` : ''}`);
|
|
51
|
+
}
|
|
52
|
+
out.push('');
|
|
53
|
+
}
|
|
54
|
+
return out.join('\n');
|
|
55
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-side of the lineage ledgers: what a mechanism is, what is missing, what was turned down,
|
|
3
|
+
* and — on the owner's machine only — which mechanisms one source fed.
|
|
4
|
+
*
|
|
5
|
+
* The tracked ledger is read leniently here: a row whose `realised_in` no longer exists is
|
|
6
|
+
* exactly what `lineage of` has to show, so this does not drop it the way the linter flags it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { MECHANISMS_REL, parseLedgerText, stripLineSuffix, type MechanismRecord } from '../lint_harvest_provenance.js';
|
|
13
|
+
import { PIN_UNKNOWN, type Ledger, type SourceRow } from './lineage_ledger.js';
|
|
14
|
+
import { decrypt } from './link_crypto.js';
|
|
15
|
+
import { SOURCE_ID_RE, sourceId } from './source_id.js';
|
|
16
|
+
|
|
17
|
+
/** Every well-shaped row of `<root>/provenance/mechanisms.jsonl`, in decision order. */
|
|
18
|
+
export function readDecisions(root: string): MechanismRecord[] {
|
|
19
|
+
const file = path.join(root, MECHANISMS_REL);
|
|
20
|
+
if (!fs.existsSync(file)) return [];
|
|
21
|
+
const rows = parseLedgerText(fs.readFileSync(file, 'utf-8'))
|
|
22
|
+
.parsed.map((p) => p.value)
|
|
23
|
+
.filter((v): v is MechanismRecord => typeof v === 'object' && v !== null && typeof (v as MechanismRecord).mechanism_id === 'string');
|
|
24
|
+
// Stable sort: the file is append-only, so file order breaks a same-day tie.
|
|
25
|
+
return rows.map((r, i) => ({ r, i })).sort((a, b) => a.r.decided_at.localeCompare(b.r.decided_at) || a.i - b.i).map((x) => x.r);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function latestDecisions(rows: readonly MechanismRecord[]): Map<string, MechanismRecord> {
|
|
29
|
+
const out = new Map<string, MechanismRecord>();
|
|
30
|
+
for (const r of rows) out.set(r.mechanism_id, r);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RealisedEdge {
|
|
35
|
+
readonly path: string;
|
|
36
|
+
/** `broken` when the file is not in the working tree — moved or deleted. */
|
|
37
|
+
readonly status: 'ok' | 'broken';
|
|
38
|
+
/** Whether the code graph has the file as a node; null when no graph was available. */
|
|
39
|
+
readonly in_graph: boolean | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LineageEntry {
|
|
43
|
+
readonly mechanism_id: string;
|
|
44
|
+
readonly verdict: MechanismRecord['verdict'];
|
|
45
|
+
readonly decided_at: string;
|
|
46
|
+
readonly realised: RealisedEdge[];
|
|
47
|
+
readonly decisions: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The lineage of one mechanism id, or of every mechanism realised in one path. Each
|
|
52
|
+
* `realised_in` is resolved against the working tree — a moved or deleted file is a broken
|
|
53
|
+
* edge rather than a silent stale claim — and, given the code graph's node lookup, against
|
|
54
|
+
* the graph too, so an edge the graph has not indexed yet is visible as such.
|
|
55
|
+
*/
|
|
56
|
+
export function lineageOf(root: string, query: string, graph: { has(id: string): boolean } | null = null): LineageEntry[] {
|
|
57
|
+
const rows = readDecisions(root);
|
|
58
|
+
const latest = latestDecisions(rows);
|
|
59
|
+
const q = stripLineSuffix(query.replace(/^\.\//, ''));
|
|
60
|
+
const hits = [...latest.values()].filter(
|
|
61
|
+
(r) => r.mechanism_id === query || (r.realised_in ?? []).some((p) => stripLineSuffix(p) === q),
|
|
62
|
+
);
|
|
63
|
+
return hits
|
|
64
|
+
.sort((a, b) => a.mechanism_id.localeCompare(b.mechanism_id))
|
|
65
|
+
.map((r) => ({
|
|
66
|
+
mechanism_id: r.mechanism_id,
|
|
67
|
+
verdict: r.verdict,
|
|
68
|
+
decided_at: r.decided_at,
|
|
69
|
+
realised: (r.realised_in ?? []).map((p) => {
|
|
70
|
+
const bare = stripLineSuffix(p);
|
|
71
|
+
return {
|
|
72
|
+
path: bare,
|
|
73
|
+
status: fs.existsSync(path.join(root, bare)) ? ('ok' as const) : ('broken' as const),
|
|
74
|
+
in_graph: graph === null ? null : graph.has(bare),
|
|
75
|
+
};
|
|
76
|
+
}),
|
|
77
|
+
decisions: rows.filter((x) => x.mechanism_id === r.mechanism_id).length,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface GapEntry {
|
|
82
|
+
readonly mechanism_id: string;
|
|
83
|
+
readonly verdict: 'gap' | 'deferred';
|
|
84
|
+
readonly owner: string;
|
|
85
|
+
readonly decided_at: string;
|
|
86
|
+
readonly reopen_if?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `gap` and `deferred` mechanisms that nothing has closed since. A later adopt, adapt or
|
|
91
|
+
* already closes one; so does a later reject — unless it carries `reopen_if`, in which case
|
|
92
|
+
* the mechanism stays listed with the condition, because the rejection itself says it may
|
|
93
|
+
* come back.
|
|
94
|
+
*/
|
|
95
|
+
export function gaps(rows: readonly MechanismRecord[]): GapEntry[] {
|
|
96
|
+
const open = new Map<string, GapEntry>();
|
|
97
|
+
for (const r of rows) {
|
|
98
|
+
if (r.verdict === 'gap' || r.verdict === 'deferred') {
|
|
99
|
+
open.set(r.mechanism_id, { mechanism_id: r.mechanism_id, verdict: r.verdict, owner: r.roadmap ?? 'open', decided_at: r.decided_at });
|
|
100
|
+
} else if (r.verdict === 'reject' && r.reopen_if !== undefined) {
|
|
101
|
+
const cur = open.get(r.mechanism_id);
|
|
102
|
+
if (cur) open.set(r.mechanism_id, { ...cur, reopen_if: r.reopen_if });
|
|
103
|
+
} else {
|
|
104
|
+
open.delete(r.mechanism_id);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return [...open.values()].sort((a, b) => a.mechanism_id.localeCompare(b.mechanism_id));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Mechanisms whose latest decision is a rejection. */
|
|
111
|
+
export function rejected(rows: readonly MechanismRecord[]): MechanismRecord[] {
|
|
112
|
+
return [...latestDecisions(rows).values()].filter((r) => r.verdict === 'reject').sort((a, b) => a.mechanism_id.localeCompare(b.mechanism_id));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SourceLineage {
|
|
116
|
+
readonly source: SourceRow;
|
|
117
|
+
readonly identity: string;
|
|
118
|
+
readonly mechanisms: { mechanism_id: string; verdict: MechanismRecord['verdict'] | null; pin: string; locator: string | null }[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Source → mechanisms. `query` is a source id or a plaintext identity, hashed with `keys[0]`. */
|
|
122
|
+
export function lineageFrom(ledger: Ledger, query: string, keys: readonly string[], rows: readonly MechanismRecord[]): SourceLineage | null {
|
|
123
|
+
const id = SOURCE_ID_RE.test(query) ? query : sourceId(query, keys[0] as string);
|
|
124
|
+
const source = ledger.sources.find((s) => s.source_id === id);
|
|
125
|
+
if (source === undefined) return null;
|
|
126
|
+
const latest = latestDecisions(rows);
|
|
127
|
+
const open = (token: string | null): string | null => (token === null ? null : decrypt(token, [...keys]));
|
|
128
|
+
return {
|
|
129
|
+
source,
|
|
130
|
+
identity: open(source.identity) as string,
|
|
131
|
+
mechanisms: ledger.edges
|
|
132
|
+
.filter((e) => e.source_id === id)
|
|
133
|
+
.map((e) => ({
|
|
134
|
+
mechanism_id: e.mechanism_id,
|
|
135
|
+
verdict: latest.get(e.mechanism_id)?.verdict ?? null,
|
|
136
|
+
pin: e.pin === PIN_UNKNOWN ? PIN_UNKNOWN : (open(e.pin) as string),
|
|
137
|
+
locator: open(e.locator),
|
|
138
|
+
})),
|
|
139
|
+
};
|
|
140
|
+
}
|