@indigoai-us/hq-cli 5.193.0 → 5.194.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/CHANGELOG.md +32 -0
- package/dist/command-catalog.generated.d.ts +42 -0
- package/dist/command-catalog.generated.js +53 -0
- package/dist/commands/core-checkpoint.d.ts +13 -2
- package/dist/commands/core-checkpoint.js +25 -4
- package/dist/commands/core-ontology.d.ts +22 -0
- package/dist/commands/core-ontology.js +114 -0
- package/dist/commands/core.js +2 -0
- package/dist/commands/lanes.js +26 -5
- package/dist/commands/mesh.js +45 -6
- package/dist/lib/checkpoint-ontology/apply.d.ts +113 -0
- package/dist/lib/checkpoint-ontology/apply.js +493 -0
- package/dist/lib/checkpoint-ontology/company.d.ts +40 -0
- package/dist/lib/checkpoint-ontology/company.js +110 -0
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +83 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +264 -0
- package/dist/lib/checkpoint-ontology/identity.d.ts +37 -0
- package/dist/lib/checkpoint-ontology/identity.js +95 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +124 -0
- package/dist/lib/checkpoint-ontology/plan.js +284 -0
- package/dist/lib/checkpoint-ontology/tree.d.ts +51 -0
- package/dist/lib/checkpoint-ontology/tree.js +163 -0
- package/dist/lib/mesh/live/daemon/board-refresh.js +2 -1
- package/dist/lib/mesh/project-board.js +5 -3
- package/dist/lib/mesh/story-status.d.ts +22 -0
- package/dist/lib/mesh/story-status.js +49 -0
- package/dist/lib/work-context/prd-sync.d.ts +1 -1
- package/dist/lib/work-context/prd-sync.js +8 -3
- package/package.json +1 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company resolution and write bounds for checkpoint ontology.
|
|
3
|
+
*
|
|
4
|
+
* The company always comes from the checkpoint payload and must be a key of
|
|
5
|
+
* `companies/manifest.yaml` with a directory at `companies/<slug>/`. Every
|
|
6
|
+
* write goes through {@link safeWriteFile}, which refuses any target whose
|
|
7
|
+
* real path leaves the allowed roots.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import yaml from "js-yaml";
|
|
12
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
13
|
+
export function resolveOntologyCompany(hqRoot, slug) {
|
|
14
|
+
if (!slug)
|
|
15
|
+
return { ok: false, reason: "no company" };
|
|
16
|
+
if (!SLUG_RE.test(slug))
|
|
17
|
+
return { ok: false, reason: "invalid company" };
|
|
18
|
+
let manifest;
|
|
19
|
+
try {
|
|
20
|
+
manifest = yaml.load(fs.readFileSync(path.join(hqRoot, "companies", "manifest.yaml"), "utf8"), {
|
|
21
|
+
schema: yaml.CORE_SCHEMA,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return { ok: false, reason: `manifest unreadable (${error.name})` };
|
|
26
|
+
}
|
|
27
|
+
const companies = manifest?.companies;
|
|
28
|
+
const entry = companies && Object.prototype.hasOwnProperty.call(companies, slug) ? companies[slug] : undefined;
|
|
29
|
+
if (!entry || typeof entry !== "object")
|
|
30
|
+
return { ok: false, reason: "company not in manifest" };
|
|
31
|
+
const dir = path.join(hqRoot, "companies", slug);
|
|
32
|
+
let stat;
|
|
33
|
+
try {
|
|
34
|
+
stat = fs.lstatSync(dir);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { ok: false, reason: "company directory missing" };
|
|
38
|
+
}
|
|
39
|
+
if (!stat.isDirectory())
|
|
40
|
+
return { ok: false, reason: "company directory missing" };
|
|
41
|
+
const uid = entry.cloud_uid;
|
|
42
|
+
return { ok: true, company: { slug, cloudUid: typeof uid === "string" && uid.trim() ? uid.trim() : null, dir } };
|
|
43
|
+
}
|
|
44
|
+
/** The entity root (`companies/<co>/ontology/entities`) of a company. */
|
|
45
|
+
export function entitiesRoot(company) {
|
|
46
|
+
return path.join(company.dir, "ontology", "entities");
|
|
47
|
+
}
|
|
48
|
+
/** The machine-local, unsynced state directory for a company. */
|
|
49
|
+
export function stateRoot(hqRoot, company) {
|
|
50
|
+
return path.join(hqRoot, "workspace", "checkpoints", "ontology", company.slug);
|
|
51
|
+
}
|
|
52
|
+
export class WriteBoundsError extends Error {
|
|
53
|
+
constructor(message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "WriteBoundsError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function isInside(root, target) {
|
|
59
|
+
const relative = path.relative(root, target);
|
|
60
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
61
|
+
}
|
|
62
|
+
/** The nearest existing ancestor of `target` (or `target` itself). */
|
|
63
|
+
function nearestExisting(target) {
|
|
64
|
+
let current = target;
|
|
65
|
+
while (!fs.existsSync(current)) {
|
|
66
|
+
const up = path.dirname(current);
|
|
67
|
+
if (up === current)
|
|
68
|
+
return current;
|
|
69
|
+
current = up;
|
|
70
|
+
}
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Write `content` to `target` only when it stays inside one of `roots` with no
|
|
75
|
+
* symlink anywhere between `anchor` (the HQ root) and the file. A root or a
|
|
76
|
+
* directory under it that is a symlink fails closed, and so does a target that
|
|
77
|
+
* is itself a symlink. Missing directories are created only after that check.
|
|
78
|
+
* The file is written to a temporary sibling and renamed, so a reader never
|
|
79
|
+
* sees half a file.
|
|
80
|
+
*/
|
|
81
|
+
export function safeWriteFile(anchor, roots, target, content) {
|
|
82
|
+
const resolved = path.resolve(target);
|
|
83
|
+
const root = roots.map((candidate) => path.resolve(candidate)).find((candidate) => isInside(candidate, resolved));
|
|
84
|
+
if (!root)
|
|
85
|
+
throw new WriteBoundsError(`refusing write outside the ontology bounds: ${target}`);
|
|
86
|
+
const realAnchor = fs.realpathSync(anchor);
|
|
87
|
+
const expected = (dir) => path.join(realAnchor, path.relative(path.resolve(anchor), dir));
|
|
88
|
+
const parent = path.dirname(resolved);
|
|
89
|
+
const existing = nearestExisting(parent);
|
|
90
|
+
if (fs.realpathSync(existing) !== expected(existing)) {
|
|
91
|
+
throw new WriteBoundsError(`refusing write through a symlink: ${target}`);
|
|
92
|
+
}
|
|
93
|
+
if (!isInside(path.resolve(anchor), existing) && existing !== path.resolve(anchor)) {
|
|
94
|
+
throw new WriteBoundsError(`refusing write outside the HQ root: ${target}`);
|
|
95
|
+
}
|
|
96
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
97
|
+
try {
|
|
98
|
+
if (fs.lstatSync(resolved).isSymbolicLink()) {
|
|
99
|
+
throw new WriteBoundsError(`refusing write to a symlink: ${target}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code !== "ENOENT")
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const tmp = path.join(parent, `.${path.basename(resolved)}.${process.pid}.tmp`);
|
|
107
|
+
fs.writeFileSync(tmp, content);
|
|
108
|
+
fs.renameSync(tmp, resolved);
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=company.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read, validate, create and patch ontology entity files.
|
|
3
|
+
*
|
|
4
|
+
* The cloud read model validates every synced entity file (hq-pro
|
|
5
|
+
* `validator.ts`): required frontmatter fields, and `id` equal to
|
|
6
|
+
* sha256(type:canonical_name) unless `identity_version >= 2`. Every file this
|
|
7
|
+
* module produces is re-parsed and validated before it is written.
|
|
8
|
+
*
|
|
9
|
+
* Existing files are patched in place. Only three frontmatter keys are
|
|
10
|
+
* rewritten (`aliases`, `signal_count`, `last_updated`); every other line is
|
|
11
|
+
* kept byte for byte, and the result is checked to parse to the same values
|
|
12
|
+
* for every other key.
|
|
13
|
+
*/
|
|
14
|
+
export interface ParsedEntityFile {
|
|
15
|
+
frontmatter: Record<string, unknown>;
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
export interface EntityFields {
|
|
19
|
+
id: string;
|
|
20
|
+
type: string;
|
|
21
|
+
canonical_name: string;
|
|
22
|
+
aliases: string[];
|
|
23
|
+
signal_count: number;
|
|
24
|
+
identity_version?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Parse with the YAML 1.2 core schema, as the cloud's `yaml` package does.
|
|
28
|
+
* js-yaml's default schema would turn unquoted timestamps into Date objects.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseEntityFile(content: string): ParsedEntityFile | null;
|
|
31
|
+
/**
|
|
32
|
+
* The hq-pro validator rules this writer can break. Returns the entity's
|
|
33
|
+
* identity fields, or a reason string when the file is invalid.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validateEntityFrontmatter(fm: Record<string, unknown>): EntityFields | string;
|
|
36
|
+
export interface NewEntity {
|
|
37
|
+
type: string;
|
|
38
|
+
canonicalName: string;
|
|
39
|
+
aliases: string[];
|
|
40
|
+
confidence: number;
|
|
41
|
+
now: string;
|
|
42
|
+
signalCount: number;
|
|
43
|
+
}
|
|
44
|
+
/** A new entity file in the cloud schema, with a `# Name` heading. */
|
|
45
|
+
export declare function renderNewEntity(entity: NewEntity): string;
|
|
46
|
+
export interface EntityPatch {
|
|
47
|
+
addAliases?: string[];
|
|
48
|
+
signalCount?: number;
|
|
49
|
+
lastUpdated: string;
|
|
50
|
+
/** The caller changed the body, so `last_updated` moves even with no key change. */
|
|
51
|
+
bodyChanged?: boolean;
|
|
52
|
+
}
|
|
53
|
+
export type PatchResult = {
|
|
54
|
+
ok: true;
|
|
55
|
+
content: string;
|
|
56
|
+
changed: boolean;
|
|
57
|
+
} | {
|
|
58
|
+
ok: false;
|
|
59
|
+
reason: string;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Patch an existing, valid entity file. Aliases are only appended, and
|
|
63
|
+
* `signal_count` never goes down. When nothing but `last_updated` would
|
|
64
|
+
* change, the file is returned unchanged so a rerun writes nothing.
|
|
65
|
+
*/
|
|
66
|
+
export declare function patchEntityFile(content: string, patch: EntityPatch): PatchResult;
|
|
67
|
+
export interface SignalBackRef {
|
|
68
|
+
signalType: string;
|
|
69
|
+
preview: string;
|
|
70
|
+
href: string;
|
|
71
|
+
}
|
|
72
|
+
/** hq-pro `previewSignalContent`: first 80 characters, one line. */
|
|
73
|
+
export declare function previewSignalContent(canonicalContent: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* hq-pro `appendBackRefsToBody`: add `- Referenced in [<type>: <preview>](<href>)`
|
|
76
|
+
* lines to `## Relationships`, keyed on the link target. Existing lines are
|
|
77
|
+
* never removed or reworded.
|
|
78
|
+
*/
|
|
79
|
+
export declare function appendBackRefs(fullContent: string, refs: SignalBackRef[]): {
|
|
80
|
+
content: string;
|
|
81
|
+
added: number;
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=entity-file.d.ts.map
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read, validate, create and patch ontology entity files.
|
|
3
|
+
*
|
|
4
|
+
* The cloud read model validates every synced entity file (hq-pro
|
|
5
|
+
* `validator.ts`): required frontmatter fields, and `id` equal to
|
|
6
|
+
* sha256(type:canonical_name) unless `identity_version >= 2`. Every file this
|
|
7
|
+
* module produces is re-parsed and validated before it is written.
|
|
8
|
+
*
|
|
9
|
+
* Existing files are patched in place. Only three frontmatter keys are
|
|
10
|
+
* rewritten (`aliases`, `signal_count`, `last_updated`); every other line is
|
|
11
|
+
* kept byte for byte, and the result is checked to parse to the same values
|
|
12
|
+
* for every other key.
|
|
13
|
+
*/
|
|
14
|
+
import { isDeepStrictEqual } from "node:util";
|
|
15
|
+
import yaml from "js-yaml";
|
|
16
|
+
import { entityId } from "./identity.js";
|
|
17
|
+
// Same delimiters as hq-pro `parser.ts`.
|
|
18
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
19
|
+
/**
|
|
20
|
+
* Parse with the YAML 1.2 core schema, as the cloud's `yaml` package does.
|
|
21
|
+
* js-yaml's default schema would turn unquoted timestamps into Date objects.
|
|
22
|
+
*/
|
|
23
|
+
export function parseEntityFile(content) {
|
|
24
|
+
const match = FRONTMATTER_RE.exec(content);
|
|
25
|
+
if (!match)
|
|
26
|
+
return null;
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = yaml.load(match[1], { schema: yaml.CORE_SCHEMA });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
35
|
+
return null;
|
|
36
|
+
return { frontmatter: raw, body: match[2] };
|
|
37
|
+
}
|
|
38
|
+
function isStringArray(value) {
|
|
39
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The hq-pro validator rules this writer can break. Returns the entity's
|
|
43
|
+
* identity fields, or a reason string when the file is invalid.
|
|
44
|
+
*/
|
|
45
|
+
export function validateEntityFrontmatter(fm) {
|
|
46
|
+
if (typeof fm.id !== "string" || fm.id === "")
|
|
47
|
+
return "id";
|
|
48
|
+
if (typeof fm.type !== "string" || fm.type === "")
|
|
49
|
+
return "type";
|
|
50
|
+
if (typeof fm.canonical_name !== "string" || fm.canonical_name === "")
|
|
51
|
+
return "canonical_name";
|
|
52
|
+
if (!isStringArray(fm.aliases))
|
|
53
|
+
return "aliases";
|
|
54
|
+
if (typeof fm.confidence !== "number" || fm.confidence < 0 || fm.confidence > 1)
|
|
55
|
+
return "confidence";
|
|
56
|
+
if (fm.status !== "draft" && fm.status !== "confirmed")
|
|
57
|
+
return "status";
|
|
58
|
+
if (!isStringArray(fm.domain))
|
|
59
|
+
return "domain";
|
|
60
|
+
if (typeof fm.first_seen !== "string" || fm.first_seen === "")
|
|
61
|
+
return "first_seen";
|
|
62
|
+
if (typeof fm.last_updated !== "string" || fm.last_updated === "")
|
|
63
|
+
return "last_updated";
|
|
64
|
+
if (typeof fm.signal_count !== "number" || !Number.isInteger(fm.signal_count) || fm.signal_count < 0) {
|
|
65
|
+
return "signal_count";
|
|
66
|
+
}
|
|
67
|
+
let identityVersion;
|
|
68
|
+
if (fm.identity_version !== undefined) {
|
|
69
|
+
if (typeof fm.identity_version !== "number" || !Number.isInteger(fm.identity_version) || fm.identity_version < 1) {
|
|
70
|
+
return "identity_version";
|
|
71
|
+
}
|
|
72
|
+
identityVersion = fm.identity_version;
|
|
73
|
+
}
|
|
74
|
+
if ((identityVersion ?? 1) < 2 && fm.id !== entityId(fm.type, fm.canonical_name))
|
|
75
|
+
return "id_mismatch";
|
|
76
|
+
return {
|
|
77
|
+
id: fm.id,
|
|
78
|
+
type: fm.type,
|
|
79
|
+
canonical_name: fm.canonical_name,
|
|
80
|
+
aliases: fm.aliases,
|
|
81
|
+
signal_count: fm.signal_count,
|
|
82
|
+
...(identityVersion === undefined ? {} : { identity_version: identityVersion }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** A YAML double-quoted scalar. JSON string syntax is valid YAML. */
|
|
86
|
+
function quote(value) {
|
|
87
|
+
return JSON.stringify(value);
|
|
88
|
+
}
|
|
89
|
+
function renderAliases(aliases) {
|
|
90
|
+
if (aliases.length === 0)
|
|
91
|
+
return "aliases: []";
|
|
92
|
+
return ["aliases:", ...aliases.map((alias) => ` - ${quote(alias)}`)].join("\n");
|
|
93
|
+
}
|
|
94
|
+
/** A new entity file in the cloud schema, with a `# Name` heading. */
|
|
95
|
+
export function renderNewEntity(entity) {
|
|
96
|
+
return [
|
|
97
|
+
"---",
|
|
98
|
+
`id: ${entityId(entity.type, entity.canonicalName)}`,
|
|
99
|
+
`type: ${entity.type}`,
|
|
100
|
+
`canonical_name: ${quote(entity.canonicalName)}`,
|
|
101
|
+
renderAliases(entity.aliases),
|
|
102
|
+
`confidence: ${entity.confidence}`,
|
|
103
|
+
"status: draft",
|
|
104
|
+
"domain: []",
|
|
105
|
+
`first_seen: ${entity.now}`,
|
|
106
|
+
`last_updated: ${entity.now}`,
|
|
107
|
+
`signal_count: ${entity.signalCount}`,
|
|
108
|
+
"---",
|
|
109
|
+
"",
|
|
110
|
+
`# ${entity.canonicalName}`,
|
|
111
|
+
"",
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
const OWNED_KEYS = new Set(["aliases", "signal_count", "last_updated"]);
|
|
115
|
+
/**
|
|
116
|
+
* Replace one top-level key in a raw frontmatter block. The key's value may
|
|
117
|
+
* span several lines (a block sequence); continuation lines are the ones that
|
|
118
|
+
* start with whitespace or `- `. Returns null when the key is absent.
|
|
119
|
+
*/
|
|
120
|
+
function replaceTopLevelKey(lines, key, rendered) {
|
|
121
|
+
const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: `) || line.startsWith(`${key}:\t`));
|
|
122
|
+
if (start === -1)
|
|
123
|
+
return null;
|
|
124
|
+
let end = start + 1;
|
|
125
|
+
while (end < lines.length && (/^\s/.test(lines[end]) || lines[end].startsWith("- ") || lines[end] === "-"))
|
|
126
|
+
end += 1;
|
|
127
|
+
return [...lines.slice(0, start), ...rendered.split("\n"), ...lines.slice(end)];
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Append alias items to a block-style `aliases:` sequence, leaving the existing
|
|
131
|
+
* item lines untouched. Falls back to re-rendering the whole key for flow style
|
|
132
|
+
* (`aliases: []`, `aliases: [a, b]`). Returns null when the key is absent.
|
|
133
|
+
*/
|
|
134
|
+
function appendAliases(lines, all, added) {
|
|
135
|
+
const start = lines.findIndex((line) => line === "aliases:" || /^aliases:\s*(#.*)?$/.test(line));
|
|
136
|
+
if (start === -1)
|
|
137
|
+
return replaceTopLevelKey(lines, "aliases", renderAliases(all));
|
|
138
|
+
let end = start + 1;
|
|
139
|
+
while (end < lines.length && (/^\s/.test(lines[end]) || lines[end].startsWith("- ") || lines[end] === "-"))
|
|
140
|
+
end += 1;
|
|
141
|
+
const firstItem = lines.slice(start + 1, end).find((line) => /^\s*- /.test(line) || /^\s*-$/.test(line));
|
|
142
|
+
const indent = firstItem ? /^(\s*)/.exec(firstItem)[1] : " ";
|
|
143
|
+
return [...lines.slice(0, end), ...added.map((alias) => `${indent}- ${quote(alias)}`), ...lines.slice(end)];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Patch an existing, valid entity file. Aliases are only appended, and
|
|
147
|
+
* `signal_count` never goes down. When nothing but `last_updated` would
|
|
148
|
+
* change, the file is returned unchanged so a rerun writes nothing.
|
|
149
|
+
*/
|
|
150
|
+
export function patchEntityFile(content, patch) {
|
|
151
|
+
const match = FRONTMATTER_RE.exec(content);
|
|
152
|
+
const parsed = parseEntityFile(content);
|
|
153
|
+
if (!match || !parsed)
|
|
154
|
+
return { ok: false, reason: "unparseable" };
|
|
155
|
+
const before = validateEntityFrontmatter(parsed.frontmatter);
|
|
156
|
+
if (typeof before === "string")
|
|
157
|
+
return { ok: false, reason: `invalid_existing:${before}` };
|
|
158
|
+
const aliases = [...before.aliases];
|
|
159
|
+
for (const alias of patch.addAliases ?? []) {
|
|
160
|
+
if (!aliases.includes(alias))
|
|
161
|
+
aliases.push(alias);
|
|
162
|
+
}
|
|
163
|
+
const signalCount = Math.max(before.signal_count, patch.signalCount ?? 0);
|
|
164
|
+
const aliasesChanged = aliases.length !== before.aliases.length;
|
|
165
|
+
const countChanged = signalCount !== before.signal_count;
|
|
166
|
+
if (!aliasesChanged && !countChanged && !patch.bodyChanged)
|
|
167
|
+
return { ok: true, content, changed: false };
|
|
168
|
+
const newline = content.includes("\r\n") ? "\r\n" : "\n";
|
|
169
|
+
let lines = match[1].split(/\r?\n/);
|
|
170
|
+
if (aliasesChanged)
|
|
171
|
+
lines = appendAliases(lines, aliases, aliases.slice(before.aliases.length));
|
|
172
|
+
if (lines && countChanged)
|
|
173
|
+
lines = replaceTopLevelKey(lines, "signal_count", `signal_count: ${signalCount}`);
|
|
174
|
+
if (lines)
|
|
175
|
+
lines = replaceTopLevelKey(lines, "last_updated", `last_updated: ${patch.lastUpdated}`);
|
|
176
|
+
if (!lines)
|
|
177
|
+
return { ok: false, reason: "missing_owned_key" };
|
|
178
|
+
const head = content.slice(0, content.indexOf(match[1]));
|
|
179
|
+
const tail = content.slice(content.indexOf(match[1]) + match[1].length);
|
|
180
|
+
const next = `${head}${lines.join(newline)}${tail}`;
|
|
181
|
+
const check = checkPatched(parsed.frontmatter, next, aliases, signalCount, patch.lastUpdated);
|
|
182
|
+
if (check)
|
|
183
|
+
return { ok: false, reason: check };
|
|
184
|
+
return { ok: true, content: next, changed: true };
|
|
185
|
+
}
|
|
186
|
+
/** The patched file must validate and keep every key it does not own. */
|
|
187
|
+
function checkPatched(original, next, aliases, signalCount, lastUpdated) {
|
|
188
|
+
const reparsed = parseEntityFile(next);
|
|
189
|
+
if (!reparsed)
|
|
190
|
+
return "patch_unparseable";
|
|
191
|
+
if (typeof validateEntityFrontmatter(reparsed.frontmatter) === "string")
|
|
192
|
+
return "patch_invalid";
|
|
193
|
+
const keys = new Set([...Object.keys(original), ...Object.keys(reparsed.frontmatter)]);
|
|
194
|
+
for (const key of keys) {
|
|
195
|
+
if (OWNED_KEYS.has(key))
|
|
196
|
+
continue;
|
|
197
|
+
if (!isDeepStrictEqual(original[key], reparsed.frontmatter[key]))
|
|
198
|
+
return `patch_changed_key:${key}`;
|
|
199
|
+
}
|
|
200
|
+
if (!isDeepStrictEqual(reparsed.frontmatter.aliases, aliases))
|
|
201
|
+
return "patch_aliases";
|
|
202
|
+
if (reparsed.frontmatter.signal_count !== signalCount)
|
|
203
|
+
return "patch_signal_count";
|
|
204
|
+
if (reparsed.frontmatter.last_updated !== lastUpdated)
|
|
205
|
+
return "patch_last_updated";
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/** hq-pro `previewSignalContent`: first 80 characters, one line. */
|
|
209
|
+
export function previewSignalContent(canonicalContent) {
|
|
210
|
+
const collapsed = canonicalContent.replace(/\s+/g, " ").trim();
|
|
211
|
+
return collapsed.length > 80 ? collapsed.slice(0, 80) : collapsed;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The company-relative target of a back-reference link. The gardener writes
|
|
215
|
+
* `../../signals/...` and hq-cli writes `../../../signals/...`; both name the
|
|
216
|
+
* same signal.
|
|
217
|
+
*/
|
|
218
|
+
function backRefTarget(href) {
|
|
219
|
+
return href.replace(/^(?:\.\.\/)+/, "");
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* hq-pro `appendBackRefsToBody`: add `- Referenced in [<type>: <preview>](<href>)`
|
|
223
|
+
* lines to `## Relationships`, keyed on the link target. Existing lines are
|
|
224
|
+
* never removed or reworded.
|
|
225
|
+
*/
|
|
226
|
+
export function appendBackRefs(fullContent, refs) {
|
|
227
|
+
if (refs.length === 0)
|
|
228
|
+
return { content: fullContent, added: 0 };
|
|
229
|
+
const fmMatch = /^(---\r?\n[\s\S]*?\r?\n---\r?\n?)([\s\S]*)$/.exec(fullContent);
|
|
230
|
+
if (!fmMatch)
|
|
231
|
+
return { content: fullContent, added: 0 };
|
|
232
|
+
const frontmatter = fmMatch[1];
|
|
233
|
+
const body = fmMatch[2];
|
|
234
|
+
const sectionMatch = /(^|\n)(##\s+Relationships\s*\n)([\s\S]*?)(?=\n##\s|\s*$)/.exec(body);
|
|
235
|
+
const existing = new Set();
|
|
236
|
+
if (sectionMatch) {
|
|
237
|
+
for (const link of sectionMatch[3].matchAll(/\]\(([^)]+)\)/g))
|
|
238
|
+
existing.add(backRefTarget(link[1]));
|
|
239
|
+
}
|
|
240
|
+
const fresh = [];
|
|
241
|
+
for (const ref of refs) {
|
|
242
|
+
if (existing.has(backRefTarget(ref.href)))
|
|
243
|
+
continue;
|
|
244
|
+
existing.add(backRefTarget(ref.href));
|
|
245
|
+
fresh.push(ref);
|
|
246
|
+
}
|
|
247
|
+
if (fresh.length === 0)
|
|
248
|
+
return { content: fullContent, added: 0 };
|
|
249
|
+
const newLines = fresh.map((ref) => `- Referenced in [${ref.signalType}: ${ref.preview}](${ref.href})`).join("\n");
|
|
250
|
+
let nextBody;
|
|
251
|
+
if (sectionMatch) {
|
|
252
|
+
const before = body.slice(0, sectionMatch.index + sectionMatch[1].length);
|
|
253
|
+
const header = sectionMatch[2];
|
|
254
|
+
const section = sectionMatch[3].replace(/\s+$/, "");
|
|
255
|
+
const after = body.slice(sectionMatch.index + sectionMatch[0].length);
|
|
256
|
+
nextBody = `${before}${header}${section ? `${section}\n${newLines}` : newLines}\n${after}`;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const trimmed = body.replace(/\s+$/, "");
|
|
260
|
+
nextBody = `${trimmed.length > 0 ? `${trimmed}\n\n` : "\n"}## Relationships\n\n${newLines}\n`;
|
|
261
|
+
}
|
|
262
|
+
return { content: `${frontmatter}${nextBody}`, added: fresh.length };
|
|
263
|
+
}
|
|
264
|
+
//# sourceMappingURL=entity-file.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity identity rules shared with the hq-pro ontology.
|
|
3
|
+
*
|
|
4
|
+
* `normalizeEntityName` and `toSlug` are ports of hq-pro
|
|
5
|
+
* `src/ontology/entity-normalize.ts` and `src/ontology/file-writer.ts`, and
|
|
6
|
+
* `entityId` is hq-pro `generateEntityId`. The read model validates every
|
|
7
|
+
* synced entity file with these rules, so they must match byte for byte.
|
|
8
|
+
*/
|
|
9
|
+
/** The entity types this writer creates or updates. */
|
|
10
|
+
export declare const WRITABLE_ENTITY_TYPES: readonly ["person", "project", "company", "concept"];
|
|
11
|
+
export type WritableEntityType = (typeof WRITABLE_ENTITY_TYPES)[number];
|
|
12
|
+
export declare function isWritableEntityType(value: unknown): value is WritableEntityType;
|
|
13
|
+
/**
|
|
14
|
+
* hq-pro `normalizeEntityName`. Organisation types drop punctuation and
|
|
15
|
+
* trailing corporate suffixes; every other type keeps `+` and `#` so `C` and
|
|
16
|
+
* `C++` stay distinct.
|
|
17
|
+
*/
|
|
18
|
+
export declare function normalizeEntityName(name: string, type?: string): string;
|
|
19
|
+
/** hq-pro `toSlug`: the entity file name without `.md`. */
|
|
20
|
+
export declare function toSlug(canonicalName: string): string;
|
|
21
|
+
/** hq-pro `generateEntityId`: sha256 of `<type>:<canonical_name>`. */
|
|
22
|
+
export declare function entityId(type: string, canonicalName: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* The canonical form of a new name: NFC, trimmed, internal whitespace
|
|
25
|
+
* collapsed to one space. The id is hashed from exactly this string.
|
|
26
|
+
*/
|
|
27
|
+
export declare function canonicalizeName(raw: string): string;
|
|
28
|
+
/** A normalised name with its spaces removed (`recall ai` → `recallai`). */
|
|
29
|
+
export declare function squash(normalized: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* True when a name must not become a new entity: empty, one character, only
|
|
32
|
+
* digits, or a single common word. `github` and `slack` are here because the
|
|
33
|
+
* common-noun use dominates; the real companies already exist in the tree
|
|
34
|
+
* and are matched by tiers 1 and 2 before this filter runs.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isRejectedName(normalized: string): boolean;
|
|
37
|
+
//# sourceMappingURL=identity.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity identity rules shared with the hq-pro ontology.
|
|
3
|
+
*
|
|
4
|
+
* `normalizeEntityName` and `toSlug` are ports of hq-pro
|
|
5
|
+
* `src/ontology/entity-normalize.ts` and `src/ontology/file-writer.ts`, and
|
|
6
|
+
* `entityId` is hq-pro `generateEntityId`. The read model validates every
|
|
7
|
+
* synced entity file with these rules, so they must match byte for byte.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
/** The entity types this writer creates or updates. */
|
|
11
|
+
export const WRITABLE_ENTITY_TYPES = ["person", "project", "company", "concept"];
|
|
12
|
+
export function isWritableEntityType(value) {
|
|
13
|
+
return typeof value === "string" && WRITABLE_ENTITY_TYPES.includes(value);
|
|
14
|
+
}
|
|
15
|
+
const CORPORATE_SUFFIXES = new Set([
|
|
16
|
+
"inc", "incorporated", "llc", "llp", "lp", "ltd", "limited", "corp", "corporation",
|
|
17
|
+
"co", "company", "gmbh", "ag", "sa", "sas", "srl", "bv", "nv", "plc", "pty", "pte",
|
|
18
|
+
"kg", "oy", "ab", "as", "spa",
|
|
19
|
+
]);
|
|
20
|
+
const ORG_NORMALIZED_TYPES = new Set([
|
|
21
|
+
"company", "contact", "deal", "contract", "invoice", "account", "journal-entry",
|
|
22
|
+
"ar-invoice", "ap-bill",
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* hq-pro `normalizeEntityName`. Organisation types drop punctuation and
|
|
26
|
+
* trailing corporate suffixes; every other type keeps `+` and `#` so `C` and
|
|
27
|
+
* `C++` stay distinct.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeEntityName(name, type) {
|
|
30
|
+
if (type !== undefined && !ORG_NORMALIZED_TYPES.has(type)) {
|
|
31
|
+
return name.toLowerCase().replace(/[^a-z0-9+#]+/g, " ").trim();
|
|
32
|
+
}
|
|
33
|
+
const base = name.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
34
|
+
if (base === "")
|
|
35
|
+
return "";
|
|
36
|
+
let tokens = base.split(" ").filter((token) => token !== "");
|
|
37
|
+
while (tokens.length > 1 && CORPORATE_SUFFIXES.has(tokens[tokens.length - 1])) {
|
|
38
|
+
tokens = tokens.slice(0, -1);
|
|
39
|
+
}
|
|
40
|
+
return tokens.join(" ");
|
|
41
|
+
}
|
|
42
|
+
/** hq-pro `toSlug`: the entity file name without `.md`. */
|
|
43
|
+
export function toSlug(canonicalName) {
|
|
44
|
+
return canonicalName
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/\s+/g, "-")
|
|
47
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
48
|
+
.replace(/-+/g, "-")
|
|
49
|
+
.replace(/^-|-$/g, "");
|
|
50
|
+
}
|
|
51
|
+
/** hq-pro `generateEntityId`: sha256 of `<type>:<canonical_name>`. */
|
|
52
|
+
export function entityId(type, canonicalName) {
|
|
53
|
+
return createHash("sha256").update(`${type}:${canonicalName}`).digest("hex");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The canonical form of a new name: NFC, trimmed, internal whitespace
|
|
57
|
+
* collapsed to one space. The id is hashed from exactly this string.
|
|
58
|
+
*/
|
|
59
|
+
export function canonicalizeName(raw) {
|
|
60
|
+
return raw.normalize("NFC").replace(/\s+/g, " ").trim();
|
|
61
|
+
}
|
|
62
|
+
/** A normalised name with its spaces removed (`recall ai` → `recallai`). */
|
|
63
|
+
export function squash(normalized) {
|
|
64
|
+
return normalized.replace(/ /g, "");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Names that are never created as entities. The gardener's false positives on
|
|
68
|
+
* indigo were mostly single common words linked as entities ("metadata",
|
|
69
|
+
* "For", "AI", "Index", "personal").
|
|
70
|
+
*/
|
|
71
|
+
const STOPWORDS = new Set([
|
|
72
|
+
"a", "an", "the", "and", "or", "for", "to", "of", "in", "on", "at", "by", "with", "from",
|
|
73
|
+
"ai", "api", "app", "apps", "data", "index", "metadata", "personal", "private", "public",
|
|
74
|
+
"user", "users", "team", "teams", "company", "companies", "project", "projects", "product",
|
|
75
|
+
"system", "service", "services", "tool", "tools", "file", "files", "doc", "docs", "readme",
|
|
76
|
+
"config", "configuration", "test", "tests", "update", "updates", "new", "note", "notes",
|
|
77
|
+
"plan", "summary", "overview", "architecture", "conventions", "environment", "setup",
|
|
78
|
+
"meeting", "meetings", "customer", "customers", "client", "clients", "agent", "agents",
|
|
79
|
+
"worker", "workers", "session", "sessions", "task", "tasks", "issue", "issues", "bug",
|
|
80
|
+
"bugs", "feature", "features", "release", "releases", "email",
|
|
81
|
+
]);
|
|
82
|
+
/**
|
|
83
|
+
* True when a name must not become a new entity: empty, one character, only
|
|
84
|
+
* digits, or a single common word. `github` and `slack` are here because the
|
|
85
|
+
* common-noun use dominates; the real companies already exist in the tree
|
|
86
|
+
* and are matched by tiers 1 and 2 before this filter runs.
|
|
87
|
+
*/
|
|
88
|
+
export function isRejectedName(normalized) {
|
|
89
|
+
if (normalized.length < 2)
|
|
90
|
+
return true;
|
|
91
|
+
if (/^[0-9 ]+$/.test(normalized))
|
|
92
|
+
return true;
|
|
93
|
+
return STOPWORDS.has(normalized);
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=identity.js.map
|