@indigoai-us/hq-cli 5.282.0 → 5.283.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 +22 -0
- package/assets/scaffold/.claude/hooks/validate-policy-frontmatter.sh +485 -0
- package/assets/scaffold/core/scripts/eval-trigger.sh +193 -0
- package/assets/scaffold/core/scripts/policy-age-report.sh +156 -0
- package/assets/scaffold/core/scripts/policy-retire.sh +199 -0
- package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +32 -0
- package/dist/command-catalog.generated.d.ts +67 -0
- package/dist/command-catalog.generated.js +83 -0
- package/dist/commands/core-knowledge-prefs.d.ts +10 -0
- package/dist/commands/core-knowledge-prefs.js +125 -0
- package/dist/commands/core-native-policy-flag.d.ts +9 -0
- package/dist/commands/core-native-policy-flag.js +12 -0
- package/dist/commands/core-ontology-native.d.ts +0 -1
- package/dist/commands/core-ontology-native.js +2 -30
- package/dist/commands/core-ontology.d.ts +4 -0
- package/dist/commands/core-ontology.js +8 -3
- package/dist/commands/core-policy-frontmatter.d.ts +8 -0
- package/dist/commands/core-policy-frontmatter.js +52 -0
- package/dist/commands/core-policy.d.ts +15 -0
- package/dist/commands/core-policy.js +982 -0
- package/dist/commands/core.js +14 -1
- package/dist/commands/lanes-shared.d.ts +4 -1
- package/dist/commands/lanes-shared.js +2 -1
- package/dist/commands/lanes.d.ts +8 -0
- package/dist/commands/lanes.js +294 -86
- package/dist/fast-lanes-list-route.d.ts +8 -0
- package/dist/fast-lanes-list-route.js +30 -0
- package/dist/fast-lanes-list.d.ts +3 -0
- package/dist/fast-lanes-list.js +252 -0
- package/dist/fast-scaffold.d.ts +5 -0
- package/dist/fast-scaffold.js +10 -0
- package/dist/index.js +10 -1
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +24 -1
- package/dist/lib/checkpoint-ontology/merge-copies.js +70 -1
- package/dist/lib/lanes/flag-keys.d.ts +3 -0
- package/dist/lib/lanes/flag-keys.js +3 -0
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +1 -1
- package/dist/lib/lanes/outpost-dispatch.js +4 -2
- package/dist/lib/lanes/outpost-hire-cache.d.ts +3 -0
- package/dist/lib/lanes/outpost-hire-cache.js +11 -3
- package/dist/lib/lanes/outpost-snapshot-cache.d.ts +26 -0
- package/dist/lib/lanes/outpost-snapshot-cache.js +215 -0
- package/dist/lib/lanes/store.d.ts +4 -0
- package/dist/lib/lanes/store.js +39 -0
- package/dist/lib/policy-validator.d.ts +2 -0
- package/dist/lib/policy-validator.js +136 -0
- package/dist/native-fast-route.d.ts +2 -0
- package/dist/native-fast-route.js +6 -2
- package/dist/utils/contribution-table.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { LanesError } from "./errors.js";
|
|
5
|
+
export const OUTPOST_SNAPSHOT_CACHE_TTL_MS = 3_000;
|
|
6
|
+
export const OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS = 45_000;
|
|
7
|
+
const CACHE_RELATIVE_PATH = path.join("workspace", "lanes-runs", ".cache", "outpost-snapshots");
|
|
8
|
+
const CACHE_SCHEMA_VERSION = "hq-lanes-outpost-snapshot-cache.v1";
|
|
9
|
+
const LOCK_POLL_MS = 20;
|
|
10
|
+
const LOCK_WAIT_MS = 60_000;
|
|
11
|
+
/** Deduplicates identical remote snapshot queries within one command invocation. */
|
|
12
|
+
export class OutpostSnapshotInvocationMemo {
|
|
13
|
+
requests = new Map();
|
|
14
|
+
getOrCreate(outpostId, query, fetch) {
|
|
15
|
+
const key = JSON.stringify([outpostId, query]);
|
|
16
|
+
const existing = this.requests.get(key);
|
|
17
|
+
if (existing)
|
|
18
|
+
return existing;
|
|
19
|
+
const request = fetch();
|
|
20
|
+
this.requests.set(key, request);
|
|
21
|
+
return request;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function outpostLaneListSnapshotKey(outpostId, normalizedQuery) {
|
|
25
|
+
return JSON.stringify(["lane-list", outpostId, normalizedQuery]);
|
|
26
|
+
}
|
|
27
|
+
export function outpostSnapshotCachePath(hqRoot, key) {
|
|
28
|
+
const digest = createKeyDigest(key);
|
|
29
|
+
return path.join(hqRoot, CACHE_RELATIVE_PATH, `${digest}.json`);
|
|
30
|
+
}
|
|
31
|
+
export async function getOrFetchOutpostSnapshot(input) {
|
|
32
|
+
const policy = input.policy ?? {};
|
|
33
|
+
const now = policy.now ?? Date.now;
|
|
34
|
+
const ttlMs = policy.ttlMs ?? OUTPOST_SNAPSHOT_CACHE_TTL_MS;
|
|
35
|
+
const staleLockMs = policy.staleLockMs ?? OUTPOST_SNAPSHOT_CACHE_STALE_LOCK_MS;
|
|
36
|
+
const lockWaitMs = policy.lockWaitMs ?? LOCK_WAIT_MS;
|
|
37
|
+
const pollMs = policy.pollMs ?? LOCK_POLL_MS;
|
|
38
|
+
const sleep = policy.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
39
|
+
const filePath = outpostSnapshotCachePath(input.hqRoot, input.key);
|
|
40
|
+
const cached = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
41
|
+
if (cached !== undefined)
|
|
42
|
+
return cached;
|
|
43
|
+
const lockPath = `${filePath}.lock`;
|
|
44
|
+
const token = `${process.pid}:${randomBytes(12).toString("hex")}`;
|
|
45
|
+
const startedAt = now();
|
|
46
|
+
const acquireLock = policy.acquireLock ?? createCacheLock;
|
|
47
|
+
while (true) {
|
|
48
|
+
try {
|
|
49
|
+
acquireLock(lockPath, token, now());
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const code = err.code;
|
|
54
|
+
if (code !== "EEXIST") {
|
|
55
|
+
process.stderr.write(`${JSON.stringify({
|
|
56
|
+
event: "lanes.outpost_snapshot_cache_lock_unavailable",
|
|
57
|
+
error: typeof code === "string" ? code : "UnknownError",
|
|
58
|
+
fallback: "uncached",
|
|
59
|
+
})}\n`);
|
|
60
|
+
const value = await input.fetch();
|
|
61
|
+
if (!input.isValue(value)) {
|
|
62
|
+
throw new LanesError("outpost_snapshot_cache_value_invalid", "Outpost snapshot fetch returned an invalid value", { key: input.key });
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
if (recoverStaleLock(lockPath, staleLockMs, now()))
|
|
67
|
+
continue;
|
|
68
|
+
const afterWait = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
69
|
+
if (afterWait !== undefined)
|
|
70
|
+
return afterWait;
|
|
71
|
+
if (now() - startedAt >= lockWaitMs) {
|
|
72
|
+
throw new LanesError("outpost_snapshot_cache_lock_timeout", "Timed out waiting for an Outpost snapshot refresh", { path: lockPath });
|
|
73
|
+
}
|
|
74
|
+
await sleep(pollMs);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const afterLock = readFreshCache(filePath, input.key, now(), ttlMs, input.isValue);
|
|
79
|
+
if (afterLock !== undefined)
|
|
80
|
+
return afterLock;
|
|
81
|
+
const value = await input.fetch();
|
|
82
|
+
if (!input.isValue(value)) {
|
|
83
|
+
throw new LanesError("outpost_snapshot_cache_value_invalid", "Outpost snapshot fetch returned an invalid value", { key: input.key });
|
|
84
|
+
}
|
|
85
|
+
writeCache(filePath, input.key, value, now());
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
releaseLock(lockPath, token);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function createCacheLock(lockPath, token, nowMs) {
|
|
93
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
94
|
+
const lockFd = fs.openSync(lockPath, "wx", 0o600);
|
|
95
|
+
try {
|
|
96
|
+
fs.writeSync(lockFd, `${token}\n${nowMs}\n`);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
try {
|
|
100
|
+
fs.closeSync(lockFd);
|
|
101
|
+
}
|
|
102
|
+
catch (closeErr) {
|
|
103
|
+
process.stderr.write(`lanes snapshot cache: lock close failed: ${errorText(closeErr)}\n`);
|
|
104
|
+
}
|
|
105
|
+
releaseLock(lockPath, token);
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
fs.closeSync(lockFd);
|
|
109
|
+
}
|
|
110
|
+
function createKeyDigest(key) {
|
|
111
|
+
// A lane-list query can contain lane ids. Keep raw ids out of cache filenames.
|
|
112
|
+
return createHash("sha256").update(key).digest("hex");
|
|
113
|
+
}
|
|
114
|
+
function readFreshCache(filePath, key, nowMs, ttlMs, isValue) {
|
|
115
|
+
let contents;
|
|
116
|
+
try {
|
|
117
|
+
contents = fs.readFileSync(filePath, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err.code === "ENOENT")
|
|
121
|
+
return undefined;
|
|
122
|
+
process.stderr.write(`lanes snapshot cache: read failed: ${errorText(err)}\n`);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
let raw;
|
|
126
|
+
try {
|
|
127
|
+
raw = JSON.parse(contents);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
process.stderr.write(`lanes snapshot cache: ignoring malformed cache: ${errorText(err)}\n`);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
134
|
+
return undefined;
|
|
135
|
+
const entry = raw;
|
|
136
|
+
if (Object.keys(entry).length !== 4 ||
|
|
137
|
+
entry.schema_version !== CACHE_SCHEMA_VERSION ||
|
|
138
|
+
entry.key !== key ||
|
|
139
|
+
!Number.isSafeInteger(entry.cached_at) ||
|
|
140
|
+
entry.cached_at > nowMs ||
|
|
141
|
+
nowMs - entry.cached_at >= ttlMs ||
|
|
142
|
+
!isValue(entry.value))
|
|
143
|
+
return undefined;
|
|
144
|
+
return entry.value;
|
|
145
|
+
}
|
|
146
|
+
function writeCache(filePath, key, value, nowMs) {
|
|
147
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
148
|
+
try {
|
|
149
|
+
const fd = fs.openSync(temporaryPath, "wx", 0o600);
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(fd, `${JSON.stringify({
|
|
152
|
+
schema_version: CACHE_SCHEMA_VERSION,
|
|
153
|
+
key,
|
|
154
|
+
cached_at: nowMs,
|
|
155
|
+
value,
|
|
156
|
+
})}\n`);
|
|
157
|
+
fs.fsyncSync(fd);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
fs.closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
fs.renameSync(temporaryPath, filePath);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
try {
|
|
166
|
+
fs.unlinkSync(temporaryPath);
|
|
167
|
+
}
|
|
168
|
+
catch (cleanupErr) {
|
|
169
|
+
if (cleanupErr.code !== "ENOENT") {
|
|
170
|
+
process.stderr.write(`lanes snapshot cache: temp cleanup failed: ${errorText(cleanupErr)}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// The cache is an optimization; a successful, validated remote result stays usable.
|
|
174
|
+
process.stderr.write(`lanes snapshot cache: write failed: ${errorText(err)}\n`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function recoverStaleLock(lockPath, staleLockMs, nowMs) {
|
|
178
|
+
let stat;
|
|
179
|
+
try {
|
|
180
|
+
stat = fs.statSync(lockPath);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return err.code === "ENOENT";
|
|
184
|
+
}
|
|
185
|
+
if (nowMs - stat.mtimeMs <= staleLockMs)
|
|
186
|
+
return false;
|
|
187
|
+
const stalePath = `${lockPath}.stale.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
188
|
+
try {
|
|
189
|
+
fs.renameSync(lockPath, stalePath);
|
|
190
|
+
fs.unlinkSync(stalePath);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
const code = err.code;
|
|
195
|
+
if (code === "ENOENT")
|
|
196
|
+
return true;
|
|
197
|
+
throw new LanesError("outpost_snapshot_cache_lock_recovery_failed", `Could not recover a stale Outpost snapshot lock: ${errorText(err)}`, { path: lockPath });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function releaseLock(lockPath, token) {
|
|
201
|
+
try {
|
|
202
|
+
const contents = fs.readFileSync(lockPath, "utf8");
|
|
203
|
+
if (contents.split("\n", 1)[0] === token)
|
|
204
|
+
fs.unlinkSync(lockPath);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (err.code !== "ENOENT") {
|
|
208
|
+
process.stderr.write(`lanes snapshot cache: lock release failed: ${errorText(err)}\n`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function errorText(err) {
|
|
213
|
+
return err instanceof Error ? err.message : String(err);
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=outpost-snapshot-cache.js.map
|
|
@@ -22,6 +22,8 @@ export interface StoreDeps {
|
|
|
22
22
|
unlinkSync: typeof fs.unlinkSync;
|
|
23
23
|
existsSync: typeof fs.existsSync;
|
|
24
24
|
readdirSync: typeof fs.readdirSync;
|
|
25
|
+
/** Bulk readers may provide a pre-indexed conflict-sidecar lookup. */
|
|
26
|
+
conflictSidecarPathsFor?: (recordPath: string) => readonly string[];
|
|
25
27
|
openSync: typeof fs.openSync;
|
|
26
28
|
closeSync: typeof fs.closeSync;
|
|
27
29
|
fsyncSync: typeof fs.fsyncSync;
|
|
@@ -50,6 +52,8 @@ export declare function mergeLaneRecords(live: LaneRecord, sidecars: readonly La
|
|
|
50
52
|
export declare function mergeQuestionRecords(live: QuestionRecord, sidecars: readonly QuestionRecord[]): QuestionRecord;
|
|
51
53
|
export declare function tryReadLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord | null;
|
|
52
54
|
export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
|
|
55
|
+
/** Reads every lane while scanning the lane directory for conflict sidecars once. */
|
|
56
|
+
export declare function readAllLanes(hqRoot: string, deps?: StoreDeps): LaneRecord[];
|
|
53
57
|
export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
|
|
54
58
|
export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
|
|
55
59
|
/** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -954,6 +954,9 @@ export function isConflictSidecarName(recordBasename, name) {
|
|
|
954
954
|
return name.startsWith(`${recordBasename}.conflict-`);
|
|
955
955
|
}
|
|
956
956
|
function sidecarPathsFor(recordPath, deps) {
|
|
957
|
+
if (deps.conflictSidecarPathsFor) {
|
|
958
|
+
return [...deps.conflictSidecarPathsFor(recordPath)];
|
|
959
|
+
}
|
|
957
960
|
const dir = path.dirname(recordPath);
|
|
958
961
|
const base = path.basename(recordPath);
|
|
959
962
|
if (!deps.existsSync(dir))
|
|
@@ -1157,6 +1160,42 @@ export function readLane(hqRoot, laneId, deps = defaultStoreDeps()) {
|
|
|
1157
1160
|
throw new LaneNotFoundError("lane", laneId);
|
|
1158
1161
|
return record;
|
|
1159
1162
|
}
|
|
1163
|
+
/** Reads every lane while scanning the lane directory for conflict sidecars once. */
|
|
1164
|
+
export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
|
|
1165
|
+
const laneIds = listLaneIds(hqRoot, deps);
|
|
1166
|
+
if (laneIds.length === 0)
|
|
1167
|
+
return [];
|
|
1168
|
+
const laneDir = path.join(lanesStoreRoot(hqRoot), LANE_RECORDS_DIR);
|
|
1169
|
+
let names;
|
|
1170
|
+
try {
|
|
1171
|
+
names = deps.readdirSync(laneDir);
|
|
1172
|
+
}
|
|
1173
|
+
catch (err) {
|
|
1174
|
+
if (err.code === "ENOENT")
|
|
1175
|
+
names = [];
|
|
1176
|
+
else
|
|
1177
|
+
throw err;
|
|
1178
|
+
}
|
|
1179
|
+
const sidecarsByRecord = new Map();
|
|
1180
|
+
for (const name of names) {
|
|
1181
|
+
if (typeof name !== "string")
|
|
1182
|
+
continue;
|
|
1183
|
+
const marker = name.indexOf(".json.conflict-");
|
|
1184
|
+
if (marker < 0 || name.startsWith("."))
|
|
1185
|
+
continue;
|
|
1186
|
+
const recordBasename = name.slice(0, marker + ".json".length);
|
|
1187
|
+
if (!isConflictSidecarName(recordBasename, name))
|
|
1188
|
+
continue;
|
|
1189
|
+
const sidecars = sidecarsByRecord.get(recordBasename) ?? [];
|
|
1190
|
+
sidecars.push(path.join(laneDir, name));
|
|
1191
|
+
sidecarsByRecord.set(recordBasename, sidecars);
|
|
1192
|
+
}
|
|
1193
|
+
const indexedDeps = {
|
|
1194
|
+
...deps,
|
|
1195
|
+
conflictSidecarPathsFor: (recordPath) => sidecarsByRecord.get(path.basename(recordPath)) ?? [],
|
|
1196
|
+
};
|
|
1197
|
+
return laneIds.map((laneId) => readLane(hqRoot, laneId, indexedDeps));
|
|
1198
|
+
}
|
|
1160
1199
|
export function tryReadQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
|
|
1161
1200
|
const filePath = questionRecordPath(hqRoot, questionId);
|
|
1162
1201
|
return readMergedRecord(filePath, parseQuestionRecord, mergeQuestionRecords, deps);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const VALIDATION_NOTE = '\n(The HQ_ALLOW_POLICY_NO_TRIGGER override requires explicit human permission.\nAn agent must never set, export, or write it on its own initiative.)\n';
|
|
2
|
+
function isPolicyPath(file) {
|
|
3
|
+
const low = file.toLowerCase().replaceAll('\\', '/');
|
|
4
|
+
const basename = low.slice(low.lastIndexOf('/') + 1);
|
|
5
|
+
return low.endsWith('.md')
|
|
6
|
+
&& (low.includes('/policies/') || low.startsWith('policies/'))
|
|
7
|
+
&& basename !== 'readme.md'
|
|
8
|
+
&& !low.includes('/audit/');
|
|
9
|
+
}
|
|
10
|
+
function normalizeValidatorText(content) {
|
|
11
|
+
return content.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
12
|
+
}
|
|
13
|
+
function hasValidTriggerGrammar(expression) {
|
|
14
|
+
let input = expression.replace(/[A-Za-z0-9_./][A-Za-z0-9_./-]*/g, '0');
|
|
15
|
+
if (/[^01&|!() \t]/.test(input))
|
|
16
|
+
return false;
|
|
17
|
+
let position = 0;
|
|
18
|
+
let valid = true;
|
|
19
|
+
const skip = () => { while (input[position] === ' ' || input[position] === '\t')
|
|
20
|
+
position += 1; };
|
|
21
|
+
let parseOr;
|
|
22
|
+
let parseAnd;
|
|
23
|
+
let parseNot;
|
|
24
|
+
let parseAtom;
|
|
25
|
+
parseAtom = () => {
|
|
26
|
+
skip();
|
|
27
|
+
const current = input[position];
|
|
28
|
+
if (current === '(') {
|
|
29
|
+
position += 1;
|
|
30
|
+
const result = parseOr();
|
|
31
|
+
skip();
|
|
32
|
+
if (input[position] !== ')') {
|
|
33
|
+
valid = false;
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
position += 1;
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
if (current === '0' || current === '1') {
|
|
40
|
+
position += 1;
|
|
41
|
+
return current === '1';
|
|
42
|
+
}
|
|
43
|
+
valid = false;
|
|
44
|
+
return false;
|
|
45
|
+
};
|
|
46
|
+
parseNot = () => {
|
|
47
|
+
skip();
|
|
48
|
+
if (input[position] === '!') {
|
|
49
|
+
position += 1;
|
|
50
|
+
return !parseNot();
|
|
51
|
+
}
|
|
52
|
+
return parseAtom();
|
|
53
|
+
};
|
|
54
|
+
parseAnd = () => {
|
|
55
|
+
let result = parseNot();
|
|
56
|
+
skip();
|
|
57
|
+
while (valid && input.slice(position, position + 2) === '&&') {
|
|
58
|
+
position += 2;
|
|
59
|
+
const rhs = parseNot();
|
|
60
|
+
result = result && rhs;
|
|
61
|
+
skip();
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
parseOr = () => {
|
|
66
|
+
let result = parseAnd();
|
|
67
|
+
skip();
|
|
68
|
+
while (valid && input.slice(position, position + 2) === '||') {
|
|
69
|
+
position += 2;
|
|
70
|
+
const rhs = parseAnd();
|
|
71
|
+
result = result || rhs;
|
|
72
|
+
skip();
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
};
|
|
76
|
+
input = input.replace(/\r/g, '');
|
|
77
|
+
if (input.replace(/[ \t]/g, '') === '')
|
|
78
|
+
return false;
|
|
79
|
+
parseOr();
|
|
80
|
+
skip();
|
|
81
|
+
return valid && position === input.length;
|
|
82
|
+
}
|
|
83
|
+
export function policyValidatorDiagnostic(content, filePath, env) {
|
|
84
|
+
if (!isPolicyPath(filePath))
|
|
85
|
+
return undefined;
|
|
86
|
+
const normalized = normalizeValidatorText(content);
|
|
87
|
+
const match = /^\s*---[ \t]*\n([\s\S]*?)\n---[ \t]*(\n|$)/.exec(normalized);
|
|
88
|
+
if (!match)
|
|
89
|
+
return `BLOCKED: policy file is missing required trigger frontmatter (missing: no-frontmatter).\n\nEvery policy under */policies/ must declare BOTH:\n when: <expression> # e.g. always | git && push | deploy || share\n on: [<events>] # any of PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent, SessionStart\nThese drive just-in-time policy injection. See\ncore/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").\nAdd both fields to the frontmatter, then retry.\n${VALIDATION_NOTE}`;
|
|
90
|
+
const frontmatter = match[1];
|
|
91
|
+
const whenLines = [...frontmatter.matchAll(/^[ \t]*when:[ \t]*(.*)$/gm)].map((entry) => entry[1] ?? '');
|
|
92
|
+
const hasWhen = whenLines.some((line) => /\S/.test(line));
|
|
93
|
+
const onMatch = /^[ \t]*on:[ \t]*\S/m.test(frontmatter);
|
|
94
|
+
const missing = [];
|
|
95
|
+
if (!hasWhen)
|
|
96
|
+
missing.push('when');
|
|
97
|
+
if (!onMatch)
|
|
98
|
+
missing.push('on');
|
|
99
|
+
if (missing.length > 0)
|
|
100
|
+
return `BLOCKED: policy file is missing required trigger frontmatter (missing: ${missing.join(',')}).\n\nEvery policy under */policies/ must declare BOTH:\n when: <expression> # e.g. always | git && push | deploy || share\n on: [<events>] # any of PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent, SessionStart\nThese drive just-in-time policy injection. See\ncore/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").\nAdd both fields to the frontmatter, then retry.\n${VALIDATION_NOTE}`;
|
|
101
|
+
const enforcement = /^[ \t]*enforcement:[ \t]*["']?([A-Za-z]+)["']?/m.exec(frontmatter)?.[1]?.toLowerCase();
|
|
102
|
+
if (enforcement === 'hard') {
|
|
103
|
+
const tautological = whenLines.some((line) => !/[&!]/.test(line.toLowerCase()) && /(^|[^A-Za-z0-9_./-])always([^A-Za-z0-9_./-]|$)/i.test(line));
|
|
104
|
+
const onLine = /^[ \t]*on:[ \t]*(.*)$/m.exec(frontmatter)?.[1] ?? '';
|
|
105
|
+
const reactive = ['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'AssistantIntent'].filter((event) => onLine.includes(event));
|
|
106
|
+
if (tautological && reactive.length > 0) {
|
|
107
|
+
const events = reactive.join(', ');
|
|
108
|
+
return `BLOCKED: an enforcement: hard policy pairs an unconditional \`when:\` with a\nreactive event (${events}).\n\n\`when: always\` is TRUE for every command, prompt, and message. On a reactive\nevent the injector ranks that as an event-specific match, so it takes the\nsession cap slot of a policy that genuinely matched the thing you are doing —\nthe loose trigger crowds out the precise one.\n\nPick one:\n when: always + on: [SessionStart] # ambient governance rule\n when: <real signal> + on: [PreToolUse, ...] # e.g. deploy || publish\n\nName the word(s) that actually appear when the rule is relevant. See\ncore/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").\n${VALIDATION_NOTE}`;
|
|
109
|
+
}
|
|
110
|
+
const after = normalized.slice(match.index + match[0].length);
|
|
111
|
+
const binding = [];
|
|
112
|
+
const stop = /^#+[ \t]*(rationale|rationale and context|background|change history|changelog|history|examples?|references?|related|see also|sources?|provenance|evidence)[ \t]*$/i;
|
|
113
|
+
for (const line of after.split('\n')) {
|
|
114
|
+
if (stop.test(line.trim().toLowerCase()))
|
|
115
|
+
break;
|
|
116
|
+
binding.push(line);
|
|
117
|
+
}
|
|
118
|
+
const byteCount = Buffer.byteLength(binding.join('\n').trim(), 'utf8');
|
|
119
|
+
const rawMax = env.HQ_POLICY_HARD_RULE_MAX_BYTES || '6144';
|
|
120
|
+
const max = Number.parseInt(rawMax, 10);
|
|
121
|
+
if (Number.isFinite(max) && max > 0 && byteCount > max) {
|
|
122
|
+
return `BLOCKED: enforcement: hard policy body is ${byteCount} bytes; the limit is ${rawMax}.\n\nA hard policy is injected VERBATIM into the session, so its length is a\nrecurring context cost for every session it fires in. Keep the binding rule\ntight and move the reasoning out of the injected span: everything from the\nfirst \`## Rationale\` / \`## Background\` / \`## Change history\` /\n\`## Examples\` / \`## References\` heading onward is NOT counted and NOT\ninjected, so long-form context belongs there.\n\nIf the rule genuinely needs more than that, either split it into separate\npolicies with distinct triggers, or raise HQ_POLICY_HARD_RULE_MAX_BYTES in\n.claude/settings.local.json "env".\n${VALIDATION_NOTE}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let malformedWhen;
|
|
126
|
+
for (const when of whenLines) {
|
|
127
|
+
if (!hasValidTriggerGrammar(when))
|
|
128
|
+
malformedWhen = when;
|
|
129
|
+
}
|
|
130
|
+
if (malformedWhen !== undefined) {
|
|
131
|
+
const when = malformedWhen;
|
|
132
|
+
return `BLOCKED: policy file has a malformed when: trigger expression:\n\n when: ${when}\n\nThe canonical trigger evaluator rejected it. A space is NOT an AND, and quoted\nphrases are NOT tokens. Use explicit boolean operators instead:\n "pull request" -> (pull && request)\n "force-push" -> force-push\n\nUse only identifiers joined by the documented boolean grammar:\n when: <identifier> # e.g. always | git | /deep-plan\n when: <expr> && <expr> # AND\n when: <expr> || <expr> # OR\n when: ! <expr> # NOT\n when: ( <expr> ) # grouping\nIdentifiers may contain letters, digits, _, ., /, and internal -. Quotes,\nYAML block scalars, adjacent identifiers without an operator, and other\npunctuation are not valid. See core/knowledge/public/hq-core/policies-spec.md\n("Trigger Expressions"). Fix the expression, then retry.\n${VALIDATION_NOTE}`;
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=policy-validator.js.map
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
/** Default-off flag check for native entrypoint routes. */
|
|
2
2
|
export declare function nativeFastRouteEnabled(argv: readonly string[], env?: NodeJS.ProcessEnv): Promise<boolean>;
|
|
3
|
+
/** Reads a default-off fast-route gate from its local override or fresh snapshot. */
|
|
4
|
+
export declare function fastFlagEnabled(flagKey: string, env?: NodeJS.ProcessEnv): Promise<boolean>;
|
|
3
5
|
//# sourceMappingURL=native-fast-route.d.ts.map
|
|
@@ -5,7 +5,11 @@ export async function nativeFastRouteEnabled(argv, env = process.env) {
|
|
|
5
5
|
const parsed = parseFastCore(argv);
|
|
6
6
|
if (!parsed || parsed.entry.kind !== "native")
|
|
7
7
|
return false;
|
|
8
|
-
|
|
8
|
+
return fastFlagEnabled(parsed.entry.flagKey, env);
|
|
9
|
+
}
|
|
10
|
+
/** Reads a default-off fast-route gate from its local override or fresh snapshot. */
|
|
11
|
+
export async function fastFlagEnabled(flagKey, env = process.env) {
|
|
12
|
+
const override = nativeRouteOverride(flagKey, env);
|
|
9
13
|
if (override !== null)
|
|
10
14
|
return override;
|
|
11
15
|
try {
|
|
@@ -16,7 +20,7 @@ export async function nativeFastRouteEnabled(argv, env = process.env) {
|
|
|
16
20
|
const { companyUid } = resolveProcessFlagCompanyContext(env);
|
|
17
21
|
if (!companyUid)
|
|
18
22
|
return false;
|
|
19
|
-
return readFlagSnapshotFile(companyUid, { env })?.flags[
|
|
23
|
+
return readFlagSnapshotFile(companyUid, { env })?.flags[flagKey] === true;
|
|
20
24
|
}
|
|
21
25
|
catch {
|
|
22
26
|
// The new path is a rollout optimization. Any unavailable local scope or
|
|
@@ -99,5 +99,5 @@ export declare const CONTRIBUTION_KEYS: ContributionKey[];
|
|
|
99
99
|
*/
|
|
100
100
|
export declare function payloadFor(key: ContributionKey, item: string): string;
|
|
101
101
|
/** Keys whose contributions are wired by a host symlink (skip `merge` rows). */
|
|
102
|
-
export declare const SYMLINK_KEYS: ("workers" | "mcp" | "commands" | "
|
|
102
|
+
export declare const SYMLINK_KEYS: ("workers" | "mcp" | "commands" | "hooks" | "skills" | "policies" | "knowledge" | "scripts")[];
|
|
103
103
|
//# sourceMappingURL=contribution-table.d.ts.map
|