@indigoai-us/hq-cli 5.282.0 → 5.284.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/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 +303 -91
- 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 +9 -1
- package/dist/lib/lanes/outpost-dispatch.js +14 -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/mesh/live/daemon/credentials.js +8 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +143 -5
- 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,982 @@
|
|
|
1
|
+
/** Native ports of policy-age-report.sh, policy-retire.sh, and
|
|
2
|
+
* read-policy-frontmatter.sh. Runtime policy edits stay rooted at the caller's
|
|
3
|
+
* live HQ tree; frontmatter extraction stays rooted at cwd. */
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { policyValidatorDiagnostic } from '../lib/policy-validator.js';
|
|
7
|
+
import { resolveLiveRoot, resolveBundledAsset } from '../utils/hq-roots.js';
|
|
8
|
+
import { runBundledScript } from '../utils/run-bundled-script.js';
|
|
9
|
+
import { describeCoreCommand } from './core-command-catalog.js';
|
|
10
|
+
import { coreNativePolicyEnabled } from './core-native-policy-flag.js';
|
|
11
|
+
import { runPolicyFrontmatter as runPolicyFrontmatterImpl } from './core-policy-frontmatter.js';
|
|
12
|
+
export const POLICY_AGE_REPORT_ASSET = 'core/scripts/policy-age-report.sh';
|
|
13
|
+
export const POLICY_RETIRE_ASSET = 'core/scripts/policy-retire.sh';
|
|
14
|
+
export const POLICY_FRONTMATTER_ASSET = 'core/scripts/read-policy-frontmatter.sh';
|
|
15
|
+
const AGE_HELP = [
|
|
16
|
+
'# hq-core: public',
|
|
17
|
+
'# policy-age-report.sh — measure policy usage and flag retirement candidates',
|
|
18
|
+
'# (2026-09-07).',
|
|
19
|
+
'#',
|
|
20
|
+
"# Why: nothing retired a policy. About 85% of one install's 1,251 personal",
|
|
21
|
+
'# policies were machine-written, and the only "usage" signal was that the',
|
|
22
|
+
'# loader printed them — which, above the host\'s output ceiling, meant nothing.',
|
|
23
|
+
'# This report gives the fade-out its inputs. It does NOT delete anything; it',
|
|
24
|
+
'# names candidates with the evidence, for a human (or /learn --retire) to act on.',
|
|
25
|
+
'#',
|
|
26
|
+
'# Signals per policy (from workspace/orchestrator/policy-trigger-state/*.txt,',
|
|
27
|
+
'# one ledger per session, and the policy files themselves):',
|
|
28
|
+
'# fired number of sessions whose ledger recorded the slug (emitted)',
|
|
29
|
+
'# retrieved number of sessions that pulled the full text (record-policy-retrieval.sh)',
|
|
30
|
+
'# last_fired newest ledger mtime that contains the slug (UTC date)',
|
|
31
|
+
'# age_days days since frontmatter `created:` (or file mtime)',
|
|
32
|
+
'# stale_refs backtick-quoted HQ-relative paths in the Rule text that no',
|
|
33
|
+
'# longer exist on disk (an inaccuracy proxy). Only core/,',
|
|
34
|
+
'# .claude/, companies/<co>/ paths are judged; lines that talk',
|
|
35
|
+
'# about a path being deleted/removed/legacy are not dependencies;',
|
|
36
|
+
'# a same-stem sibling (foo.sh -> foo.mjs) counts as renamed.',
|
|
37
|
+
'# single_incident frontmatter source is a one-off (user-correction,',
|
|
38
|
+
'# session-learning, back-pressure-failure) AND version == 1',
|
|
39
|
+
'#',
|
|
40
|
+
'# Candidate classes (--candidates):',
|
|
41
|
+
'# never-fired fired == 0 and age_days >= --min-age (default 30)',
|
|
42
|
+
'# dormant fired > 0 but last_fired older than --dormant-days (default 90)',
|
|
43
|
+
'# stale-refs stale_refs > 0 (rule points at something that is gone)',
|
|
44
|
+
'# single-incident-old single_incident, age_days >= --incident-days (default 60), and fired <= 1',
|
|
45
|
+
'#',
|
|
46
|
+
'# Usage:',
|
|
47
|
+
].join('\n') + '\n';
|
|
48
|
+
const RETIRE_HELP = [
|
|
49
|
+
'# hq-core: public',
|
|
50
|
+
'# policy-retire.sh — retire (or restore) a policy without deleting it (2026-09-07).',
|
|
51
|
+
'#',
|
|
52
|
+
'# Why: nothing retired policies, so the corpus only grew. Retirement is a',
|
|
53
|
+
'# frontmatter state, not a deletion: the file stays where it is (history,',
|
|
54
|
+
'# grep-ability, restore), and every consumer skips `status: retired` — the',
|
|
55
|
+
'# trigger loader, the company-bind digest, the age report.',
|
|
56
|
+
'#',
|
|
57
|
+
'# Usage:',
|
|
58
|
+
'# policy-retire.sh <slug-or-path> --reason "<why>" [--by <who>]',
|
|
59
|
+
'# policy-retire.sh <slug-or-path> --restore',
|
|
60
|
+
'# policy-retire.sh --from-report <age-report.json> --class <never-fired|dormant|stale-refs|single-incident-old> --reason "<why>" [--yes]',
|
|
61
|
+
'# policy-retire.sh --auto [--dir <policies-dir>]... [--classes a,b] [--report-dir <dir>] [--dry-run]',
|
|
62
|
+
'#',
|
|
63
|
+
'# --auto is the human-ON-the-loop mode (2026-09-07): it runs the age report,',
|
|
64
|
+
'# retires every candidate with a per-policy reason, and writes a dated report',
|
|
65
|
+
'# (default workspace/reports/policy-retirement-<date>.md) so a wrong call can',
|
|
66
|
+
'# be found and reversed with --restore. Nobody approves in advance. Classes and',
|
|
67
|
+
'# reasons:',
|
|
68
|
+
'# never-fired never emitted in any session ledger, older than 30 days',
|
|
69
|
+
'# dormant last emitted more than 90 days ago',
|
|
70
|
+
].join('\n') + '\n';
|
|
71
|
+
function policyResult(code, stdout = '', stderr = '') {
|
|
72
|
+
return { code, stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) };
|
|
73
|
+
}
|
|
74
|
+
function expectedFsMiss(error) {
|
|
75
|
+
const code = error.code;
|
|
76
|
+
return code === 'ENOENT' || code === 'ENOTDIR' || code === 'EACCES' || code === 'ELOOP';
|
|
77
|
+
}
|
|
78
|
+
function fileText(filePath) {
|
|
79
|
+
try {
|
|
80
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// These shell reads are all redirected or guarded, so missing and denied
|
|
84
|
+
// files contribute no text. Surface I/O and programming failures.
|
|
85
|
+
if (expectedFsMiss(error))
|
|
86
|
+
return undefined;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function listFiles(directory) {
|
|
91
|
+
try {
|
|
92
|
+
return fs.readdirSync(directory).sort();
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
// The legacy find/glob paths tolerate absent and inaccessible directories.
|
|
96
|
+
if (expectedFsMiss(error))
|
|
97
|
+
return [];
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function statMtimeSeconds(filePath) {
|
|
102
|
+
try {
|
|
103
|
+
return Math.floor(fs.statSync(filePath).mtimeMs / 1000);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (expectedFsMiss(error))
|
|
107
|
+
return undefined;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function isFile(filePath) {
|
|
112
|
+
try {
|
|
113
|
+
return fs.statSync(filePath).isFile();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (expectedFsMiss(error))
|
|
117
|
+
return false;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function isSymlink(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
return fs.lstatSync(filePath).isSymbolicLink();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (expectedFsMiss(error))
|
|
127
|
+
return false;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function dirExists(directory) {
|
|
132
|
+
try {
|
|
133
|
+
return fs.statSync(directory).isDirectory();
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (expectedFsMiss(error))
|
|
137
|
+
return false;
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function frontmatterScalar(file, key) {
|
|
142
|
+
const body = fileText(file);
|
|
143
|
+
if (body === undefined)
|
|
144
|
+
return '';
|
|
145
|
+
const lines = body.split('\n');
|
|
146
|
+
let depth = 0;
|
|
147
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
148
|
+
const line = lines[index];
|
|
149
|
+
if (index === 0 && !line.startsWith('---'))
|
|
150
|
+
return '';
|
|
151
|
+
if (line.startsWith('---')) {
|
|
152
|
+
depth += 1;
|
|
153
|
+
if (depth === 2)
|
|
154
|
+
break;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (depth !== 1)
|
|
158
|
+
continue;
|
|
159
|
+
const match = line.match(new RegExp(`^${key}:[ \\t]*(.*)$`));
|
|
160
|
+
if (!match)
|
|
161
|
+
continue;
|
|
162
|
+
let value = match[1] ?? '';
|
|
163
|
+
if (key === 'id')
|
|
164
|
+
value = value.replace(/^["']|["']$/g, '');
|
|
165
|
+
if (key === 'source' || key === 'created')
|
|
166
|
+
value = value.replaceAll('"', '');
|
|
167
|
+
if (key === 'created')
|
|
168
|
+
return value.slice(0, 10);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
function ruleBody(file) {
|
|
174
|
+
const text = fileText(file) ?? '';
|
|
175
|
+
const lines = text.split('\n');
|
|
176
|
+
const body = [];
|
|
177
|
+
let inside = false;
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
if (line === '## Rule') {
|
|
180
|
+
inside = true;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (inside && line.startsWith('## '))
|
|
184
|
+
break;
|
|
185
|
+
if (inside)
|
|
186
|
+
body.push(line);
|
|
187
|
+
}
|
|
188
|
+
return body.join('\n');
|
|
189
|
+
}
|
|
190
|
+
function extractRulePaths(file) {
|
|
191
|
+
const skipLine = /(deleted|removed|retired|deprecated|no longer|not exist|never (call|use|run|read)|do not (call|use|run|read)|must not|instead of|was renamed|old path|legacy)/i;
|
|
192
|
+
const paths = new Set();
|
|
193
|
+
for (const line of ruleBody(file).split('\n')) {
|
|
194
|
+
if (skipLine.test(line))
|
|
195
|
+
continue;
|
|
196
|
+
for (const match of line.matchAll(/`((?:core|personal|companies|repos|workspace|\.claude)\/[A-Za-z0-9_./-]+)`/g)) {
|
|
197
|
+
const reference = match[1];
|
|
198
|
+
const basename = path.posix.basename(reference);
|
|
199
|
+
if (/^(foo|bar|baz|example|placeholder|sample|your-|my-|x\.sh|dummy)/.test(basename))
|
|
200
|
+
continue;
|
|
201
|
+
if (/[{}*]/.test(reference))
|
|
202
|
+
continue;
|
|
203
|
+
paths.add(reference);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return [...paths].sort();
|
|
207
|
+
}
|
|
208
|
+
function refSummary(file, root) {
|
|
209
|
+
const refs = extractRulePaths(file);
|
|
210
|
+
const judged = refs.filter((reference) => {
|
|
211
|
+
if (!(reference.startsWith('core/') || reference.startsWith('.claude/') || reference.startsWith('companies/')))
|
|
212
|
+
return false;
|
|
213
|
+
if (reference.startsWith('.claude/state/') || reference.startsWith('.claude/worktrees/') || reference.startsWith('core/packages/'))
|
|
214
|
+
return false;
|
|
215
|
+
return true;
|
|
216
|
+
});
|
|
217
|
+
const stale = [];
|
|
218
|
+
const renamed = [];
|
|
219
|
+
for (const reference of refs) {
|
|
220
|
+
if (reference.startsWith('.claude/state/') || reference.startsWith('.claude/worktrees/') || reference.startsWith('core/packages/'))
|
|
221
|
+
continue;
|
|
222
|
+
if (!(reference.startsWith('core/') || reference.startsWith('.claude/'))) {
|
|
223
|
+
if (!reference.startsWith('companies/'))
|
|
224
|
+
continue;
|
|
225
|
+
const company = reference.slice('companies/'.length).split('/')[0];
|
|
226
|
+
if (!dirExists(path.join(root, 'companies', company)))
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (fs.existsSync(path.join(root, reference)))
|
|
230
|
+
continue;
|
|
231
|
+
const extension = path.posix.extname(reference);
|
|
232
|
+
const stem = extension ? reference.slice(0, -extension.length) : reference;
|
|
233
|
+
const parent = path.join(root, path.posix.dirname(stem));
|
|
234
|
+
const basename = path.posix.basename(stem);
|
|
235
|
+
const hasSibling = listFiles(parent).some((name) => name.startsWith(`${basename}.`));
|
|
236
|
+
if (hasSibling)
|
|
237
|
+
renamed.push(reference);
|
|
238
|
+
else
|
|
239
|
+
stale.push(reference);
|
|
240
|
+
}
|
|
241
|
+
return { stale, renamed, judged: judged.length };
|
|
242
|
+
}
|
|
243
|
+
function epochFromDate(value) {
|
|
244
|
+
if (!value)
|
|
245
|
+
return 0;
|
|
246
|
+
const parsed = Date.parse(`${value}T00:00:00Z`);
|
|
247
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString().slice(0, 10) !== value)
|
|
248
|
+
return 0;
|
|
249
|
+
return Math.floor(parsed / 1000);
|
|
250
|
+
}
|
|
251
|
+
function countLedgerRows(directory, withMtime, recursive) {
|
|
252
|
+
const counts = new Map();
|
|
253
|
+
const newest = new Map();
|
|
254
|
+
let anyFiles = false;
|
|
255
|
+
let stderr = '';
|
|
256
|
+
const walk = (current) => {
|
|
257
|
+
for (const entry of listFiles(current)) {
|
|
258
|
+
const full = path.join(current, entry);
|
|
259
|
+
let stat;
|
|
260
|
+
try {
|
|
261
|
+
stat = fs.lstatSync(full);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (expectedFsMiss(error))
|
|
265
|
+
continue;
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
if (stat.isSymbolicLink())
|
|
269
|
+
continue;
|
|
270
|
+
if (stat.isDirectory() && recursive) {
|
|
271
|
+
walk(full);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (!stat.isFile() || !entry.endsWith('.txt'))
|
|
275
|
+
continue;
|
|
276
|
+
anyFiles = true;
|
|
277
|
+
let content;
|
|
278
|
+
try {
|
|
279
|
+
content = fs.readFileSync(full, 'utf8');
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
const code = error.code;
|
|
283
|
+
if (withMtime && (code === 'EACCES' || code === 'EPERM')) {
|
|
284
|
+
stderr += `awk: fatal: cannot open file \`${full}' for reading: Permission denied\n`;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (expectedFsMiss(error))
|
|
288
|
+
continue;
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
const mtime = Math.floor(stat.mtimeMs / 1000);
|
|
292
|
+
for (const line of content.split('\n')) {
|
|
293
|
+
const slug = line.trim().split(/[ \t\r\f\v]+/)[0];
|
|
294
|
+
if (!slug)
|
|
295
|
+
continue;
|
|
296
|
+
counts.set(slug, (counts.get(slug) ?? 0) + 1);
|
|
297
|
+
if (withMtime)
|
|
298
|
+
newest.set(slug, Math.max(newest.get(slug) ?? 0, mtime));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
if (dirExists(directory))
|
|
303
|
+
walk(directory);
|
|
304
|
+
return { counts, newest, anyFiles, stderr };
|
|
305
|
+
}
|
|
306
|
+
function parseAgeOptions(args, scriptPath) {
|
|
307
|
+
const dirs = [];
|
|
308
|
+
let json = false;
|
|
309
|
+
let candidates = false;
|
|
310
|
+
let minAge = '30';
|
|
311
|
+
let dormantDays = '90';
|
|
312
|
+
let incidentDays = '60';
|
|
313
|
+
for (let index = 0; index < args.length;) {
|
|
314
|
+
const arg = args[index];
|
|
315
|
+
if (arg === '--dir' || arg === '--min-age' || arg === '--dormant-days' || arg === '--incident-days') {
|
|
316
|
+
if (index + 1 >= args.length) {
|
|
317
|
+
const line = arg === '--dir' ? 41 : arg === '--min-age' ? 44 : arg === '--dormant-days' ? 45 : 46;
|
|
318
|
+
return { kind: 'error', result: policyResult(1, '', `${scriptPath}: line ${line}: $2: unbound variable\n`) };
|
|
319
|
+
}
|
|
320
|
+
const value = args[index + 1];
|
|
321
|
+
if (arg === '--dir')
|
|
322
|
+
dirs.push(value);
|
|
323
|
+
else if (arg === '--min-age')
|
|
324
|
+
minAge = value;
|
|
325
|
+
else if (arg === '--dormant-days')
|
|
326
|
+
dormantDays = value;
|
|
327
|
+
else
|
|
328
|
+
incidentDays = value;
|
|
329
|
+
index += 2;
|
|
330
|
+
}
|
|
331
|
+
else if (arg === '--json') {
|
|
332
|
+
json = true;
|
|
333
|
+
index += 1;
|
|
334
|
+
}
|
|
335
|
+
else if (arg === '--candidates') {
|
|
336
|
+
candidates = true;
|
|
337
|
+
index += 1;
|
|
338
|
+
}
|
|
339
|
+
else if (arg === '-h' || arg === '--help')
|
|
340
|
+
return { kind: 'help' };
|
|
341
|
+
else
|
|
342
|
+
return { kind: 'error', result: policyResult(2, '', `policy-age-report: unknown arg ${arg}\n`) };
|
|
343
|
+
}
|
|
344
|
+
return { kind: 'options', dirs, json, candidates, minAge, dormantDays, incidentDays };
|
|
345
|
+
}
|
|
346
|
+
const BASH_INTEGER_MIN = -(1n << 63n);
|
|
347
|
+
const BASH_INTEGER_MAX = (1n << 63n) - 1n;
|
|
348
|
+
function bashInteger(value) {
|
|
349
|
+
if (!/^[+-]?[0-9]+$/.test(value))
|
|
350
|
+
return undefined;
|
|
351
|
+
const parsed = BigInt(value);
|
|
352
|
+
return parsed < BASH_INTEGER_MIN || parsed > BASH_INTEGER_MAX ? undefined : parsed;
|
|
353
|
+
}
|
|
354
|
+
function bashTestGreaterOrEqual(left, right, scriptPath, line) {
|
|
355
|
+
const parsed = bashInteger(right);
|
|
356
|
+
if (parsed === undefined) {
|
|
357
|
+
return {
|
|
358
|
+
value: false,
|
|
359
|
+
stderr: `${scriptPath}: line ${line}: [: ${right}: integer expression expected\n`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return { value: BigInt(left) >= parsed, stderr: '' };
|
|
363
|
+
}
|
|
364
|
+
export function runPolicyAgeReport(args, root, env = process.env, nowEpoch = Math.floor(Date.now() / 1000)) {
|
|
365
|
+
const parsed = parseAgeOptions(args, resolveBundledAsset(POLICY_AGE_REPORT_ASSET));
|
|
366
|
+
if (parsed.kind === 'help')
|
|
367
|
+
return policyResult(0, AGE_HELP);
|
|
368
|
+
if (parsed.kind === 'error')
|
|
369
|
+
return parsed.result;
|
|
370
|
+
let now = nowEpoch;
|
|
371
|
+
try {
|
|
372
|
+
if (env.HQ_POLICY_REPORT_NOW_EPOCH) {
|
|
373
|
+
const epoch = bashInteger(env.HQ_POLICY_REPORT_NOW_EPOCH);
|
|
374
|
+
if (epoch === undefined) {
|
|
375
|
+
const value = env.HQ_POLICY_REPORT_NOW_EPOCH;
|
|
376
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
377
|
+
throw new Error(`${resolveBundledAsset(POLICY_AGE_REPORT_ASSET)}: line 96: ${value}: unbound variable`);
|
|
378
|
+
}
|
|
379
|
+
throw new Error(`${resolveBundledAsset(POLICY_AGE_REPORT_ASSET)}: line 96: ${value}: invalid arithmetic value`);
|
|
380
|
+
}
|
|
381
|
+
now = Number(epoch);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
return policyResult(1, '', `${String(error.message)}\n`);
|
|
386
|
+
}
|
|
387
|
+
const ledgerDir = env.HQ_POLICY_LEDGER_DIR || path.join(root, 'workspace/orchestrator/policy-trigger-state');
|
|
388
|
+
const retrievalDir = env.HQ_POLICY_RETRIEVAL_LEDGER_DIR || path.join(root, 'workspace/orchestrator/policy-retrieval-state');
|
|
389
|
+
const usage = countLedgerRows(ledgerDir, true, true);
|
|
390
|
+
const retrieval = countLedgerRows(retrievalDir, false, false);
|
|
391
|
+
const dirs = parsed.dirs.length > 0 ? parsed.dirs : [path.join(root, 'personal/policies'), path.join(root, 'core/policies')];
|
|
392
|
+
const rows = [];
|
|
393
|
+
let stderr = usage.stderr + retrieval.stderr;
|
|
394
|
+
for (const requestedDir of dirs) {
|
|
395
|
+
const directory = path.isAbsolute(requestedDir) ? requestedDir : path.join(root, requestedDir);
|
|
396
|
+
if (!dirExists(directory))
|
|
397
|
+
continue;
|
|
398
|
+
for (const basename of listFiles(directory)) {
|
|
399
|
+
if (!basename.endsWith('.md') || basename === 'README.md' || basename === 'example-policy.md' || basename.startsWith('_digest') || basename.includes(' ') || basename.includes('.conflict-') || basename.includes('.sync-conflict-'))
|
|
400
|
+
continue;
|
|
401
|
+
const file = path.join(directory, basename);
|
|
402
|
+
if (!isFile(file) || isSymlink(file))
|
|
403
|
+
continue;
|
|
404
|
+
const text = fileText(file);
|
|
405
|
+
if (text === undefined)
|
|
406
|
+
continue;
|
|
407
|
+
if (/^status: retired/m.test(text))
|
|
408
|
+
continue;
|
|
409
|
+
const id = frontmatterScalar(file, 'id') || basename.slice(0, -3);
|
|
410
|
+
const enforcement = frontmatterScalar(file, 'enforcement') || 'unset';
|
|
411
|
+
const source = frontmatterScalar(file, 'source');
|
|
412
|
+
const version = frontmatterScalar(file, 'version');
|
|
413
|
+
const createdEpoch = epochFromDate(frontmatterScalar(file, 'created')) || statMtimeSeconds(file) || 0;
|
|
414
|
+
const ageDays = Math.trunc((now - createdEpoch) / 86400);
|
|
415
|
+
const fired = usage.counts.get(id) ?? 0;
|
|
416
|
+
const lastEpoch = usage.newest.get(id);
|
|
417
|
+
const lastFired = lastEpoch === undefined ? null : new Date(lastEpoch * 1000).toISOString().slice(0, 10);
|
|
418
|
+
const lastAge = lastEpoch === undefined ? -1 : Math.trunc((now - lastEpoch) / 86400);
|
|
419
|
+
const retrieved = retrieval.counts.get(id) ?? 0;
|
|
420
|
+
const refs = refSummary(file, root);
|
|
421
|
+
const singleIncident = ['user-correction', 'session-learning', 'back-pressure-failure'].includes(source) && (version || '1') === '1';
|
|
422
|
+
const classes = [];
|
|
423
|
+
if (fired === 0) {
|
|
424
|
+
const comparison = bashTestGreaterOrEqual(ageDays, parsed.minAge, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 130);
|
|
425
|
+
stderr += comparison.stderr;
|
|
426
|
+
if (comparison.value && usage.anyFiles)
|
|
427
|
+
classes.push('never-fired');
|
|
428
|
+
}
|
|
429
|
+
if (fired > 0) {
|
|
430
|
+
const comparison = bashTestGreaterOrEqual(lastAge, parsed.dormantDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 131);
|
|
431
|
+
stderr += comparison.stderr;
|
|
432
|
+
if (comparison.value)
|
|
433
|
+
classes.push('dormant');
|
|
434
|
+
}
|
|
435
|
+
if (refs.stale.length > 0)
|
|
436
|
+
classes.push('stale-refs');
|
|
437
|
+
if (refs.renamed.length > 0)
|
|
438
|
+
classes.push('renamed-refs');
|
|
439
|
+
if (singleIncident) {
|
|
440
|
+
const comparison = bashTestGreaterOrEqual(ageDays, parsed.incidentDays, resolveBundledAsset(POLICY_AGE_REPORT_ASSET), 135);
|
|
441
|
+
stderr += comparison.stderr;
|
|
442
|
+
if (comparison.value && fired <= 1)
|
|
443
|
+
classes.push('single-incident-old');
|
|
444
|
+
}
|
|
445
|
+
let displayPath = file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file;
|
|
446
|
+
if (!path.isAbsolute(file) && !file.startsWith(`${root}/`))
|
|
447
|
+
displayPath = file;
|
|
448
|
+
rows.push({
|
|
449
|
+
id,
|
|
450
|
+
path: displayPath,
|
|
451
|
+
enforcement,
|
|
452
|
+
age_days: ageDays,
|
|
453
|
+
fired,
|
|
454
|
+
last_fired: lastFired,
|
|
455
|
+
stale_refs: refs.stale,
|
|
456
|
+
single_incident: singleIncident,
|
|
457
|
+
candidate_classes: classes,
|
|
458
|
+
retrieved,
|
|
459
|
+
refs_judged: refs.judged,
|
|
460
|
+
renamed_refs: refs.renamed,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (parsed.json) {
|
|
465
|
+
const selected = parsed.candidates ? rows.filter((row) => row.candidate_classes.length > 0) : rows;
|
|
466
|
+
return policyResult(0, `${JSON.stringify(selected)}\n`, stderr);
|
|
467
|
+
}
|
|
468
|
+
const never = rows.filter((row) => row.fired === 0).length;
|
|
469
|
+
const staleTotal = rows.filter((row) => row.stale_refs.length > 0).length;
|
|
470
|
+
const candidateCount = rows.filter((row) => row.candidate_classes.length > 0).length;
|
|
471
|
+
let stdout = `policy-age-report: ${rows.length} policies, ${never} never fired, ${staleTotal} with stale references, ${candidateCount} retirement candidates (min-age ${parsed.minAge}d, dormant ${parsed.dormantDays}d, incident ${parsed.incidentDays}d)\n`;
|
|
472
|
+
if (!parsed.candidates)
|
|
473
|
+
stdout += ' (add --candidates to list them, --json for machine-readable rows)\n';
|
|
474
|
+
else {
|
|
475
|
+
const lines = rows.filter((row) => row.candidate_classes.length > 0).map((row) => ` ${row.candidate_classes.join(',').padEnd(14)} ${row.id.padEnd(60)} fired=${String(row.fired).padEnd(4)} last=${(row.last_fired ?? '-').padEnd(10)} age=${String(row.age_days).padEnd(4)}d ${row.stale_refs.length > 0 ? `stale:${row.stale_refs.join(',')}` : ''}`);
|
|
476
|
+
stdout += `${lines.sort().map((line) => `${line}\n`).join('')}`;
|
|
477
|
+
}
|
|
478
|
+
return policyResult(0, stdout, stderr);
|
|
479
|
+
}
|
|
480
|
+
export function runPolicyFrontmatter(args, cwd) {
|
|
481
|
+
return runPolicyFrontmatterImpl(args, cwd);
|
|
482
|
+
}
|
|
483
|
+
function parseRetireOptions(args, scriptPath, env) {
|
|
484
|
+
const value = {
|
|
485
|
+
auto: false,
|
|
486
|
+
dryRun: false,
|
|
487
|
+
dirs: [],
|
|
488
|
+
reportDir: '',
|
|
489
|
+
classes: '',
|
|
490
|
+
reason: '',
|
|
491
|
+
by: env.HQ_SESSION_ID || env.USER || 'unknown',
|
|
492
|
+
restore: false,
|
|
493
|
+
report: '',
|
|
494
|
+
className: '',
|
|
495
|
+
yes: false,
|
|
496
|
+
target: '',
|
|
497
|
+
};
|
|
498
|
+
const optionsWithValues = new Map([
|
|
499
|
+
['--dir', 'dirs'],
|
|
500
|
+
['--report-dir', 'reportDir'],
|
|
501
|
+
['--classes', 'classes'],
|
|
502
|
+
['--reason', 'reason'],
|
|
503
|
+
['--by', 'by'],
|
|
504
|
+
['--from-report', 'report'],
|
|
505
|
+
['--class', 'className'],
|
|
506
|
+
]);
|
|
507
|
+
for (let index = 0; index < args.length;) {
|
|
508
|
+
const arg = args[index];
|
|
509
|
+
if (arg === '--auto') {
|
|
510
|
+
value.auto = true;
|
|
511
|
+
index += 1;
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (arg === '--dry-run') {
|
|
515
|
+
value.dryRun = true;
|
|
516
|
+
index += 1;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (arg === '--restore') {
|
|
520
|
+
value.restore = true;
|
|
521
|
+
index += 1;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (arg === '--yes') {
|
|
525
|
+
value.yes = true;
|
|
526
|
+
index += 1;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (arg === '-h' || arg === '--help')
|
|
530
|
+
return { kind: 'help' };
|
|
531
|
+
const key = optionsWithValues.get(arg);
|
|
532
|
+
if (key !== undefined) {
|
|
533
|
+
if (index + 1 >= args.length) {
|
|
534
|
+
const lineByOption = {
|
|
535
|
+
'--dir': 44,
|
|
536
|
+
'--report-dir': 45,
|
|
537
|
+
'--classes': 46,
|
|
538
|
+
'--reason': 47,
|
|
539
|
+
'--by': 48,
|
|
540
|
+
'--from-report': 50,
|
|
541
|
+
'--class': 51,
|
|
542
|
+
};
|
|
543
|
+
return { kind: 'error', result: policyResult(1, '', `${scriptPath}: line ${lineByOption[arg]}: $2: unbound variable\n`) };
|
|
544
|
+
}
|
|
545
|
+
const next = args[index + 1];
|
|
546
|
+
if (key === 'dirs')
|
|
547
|
+
value.dirs.push(next);
|
|
548
|
+
else
|
|
549
|
+
value[key] = next;
|
|
550
|
+
index += 2;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (arg.startsWith('-'))
|
|
554
|
+
return { kind: 'error', result: policyResult(2, '', `policy-retire: unknown arg ${arg}\n`) };
|
|
555
|
+
value.target = arg;
|
|
556
|
+
index += 1;
|
|
557
|
+
}
|
|
558
|
+
return { kind: 'options', value };
|
|
559
|
+
}
|
|
560
|
+
function firstLine(content) {
|
|
561
|
+
const end = content.indexOf(0x0a);
|
|
562
|
+
if (end === -1)
|
|
563
|
+
return content.length > 0 ? content : undefined;
|
|
564
|
+
return content.subarray(0, end);
|
|
565
|
+
}
|
|
566
|
+
function awkRecords(content) {
|
|
567
|
+
const records = [];
|
|
568
|
+
let start = 0;
|
|
569
|
+
while (start < content.length) {
|
|
570
|
+
const end = content.indexOf(0x0a, start);
|
|
571
|
+
if (end === -1) {
|
|
572
|
+
records.push(content.subarray(start));
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
records.push(content.subarray(start, end));
|
|
576
|
+
start = end + 1;
|
|
577
|
+
}
|
|
578
|
+
return records;
|
|
579
|
+
}
|
|
580
|
+
function rewriteStatus(content, status, reason, by, now) {
|
|
581
|
+
const output = [];
|
|
582
|
+
let depth = 0;
|
|
583
|
+
let done = false;
|
|
584
|
+
for (const record of awkRecords(content)) {
|
|
585
|
+
const line = record.toString('latin1');
|
|
586
|
+
if (/^---[ \t]*$/.test(line)) {
|
|
587
|
+
depth += 1;
|
|
588
|
+
if (depth === 2 && !done) {
|
|
589
|
+
if (status === 'retired') {
|
|
590
|
+
output.push(Buffer.from(`status: retired\nretired_at: ${now.toISOString().replace(/\.\d{3}Z$/, 'Z')}\nretired_by: ${by}\nretired_reason: "${reason}"\n`));
|
|
591
|
+
}
|
|
592
|
+
done = true;
|
|
593
|
+
}
|
|
594
|
+
output.push(record, Buffer.from('\n'));
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (depth === 1 && /^(status|retired_at|retired_by|retired_reason):/.test(line))
|
|
598
|
+
continue;
|
|
599
|
+
output.push(record, Buffer.from('\n'));
|
|
600
|
+
}
|
|
601
|
+
return Buffer.concat(output);
|
|
602
|
+
}
|
|
603
|
+
function setPolicyStatus(filePath, status, reason, by, env, now, cwd = process.cwd()) {
|
|
604
|
+
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
605
|
+
let content;
|
|
606
|
+
try {
|
|
607
|
+
content = fs.readFileSync(resolvedPath);
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
if (expectedFsMiss(error))
|
|
611
|
+
return { ok: false, error: `policy-retire: ${filePath} has no frontmatter\n` };
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
if (!firstLine(content)?.toString('latin1').startsWith('---')) {
|
|
615
|
+
return { ok: false, error: `policy-retire: ${filePath} has no frontmatter\n` };
|
|
616
|
+
}
|
|
617
|
+
const diagnostic = policyValidatorDiagnostic(content.toString('utf8'), resolvedPath, env);
|
|
618
|
+
if (diagnostic !== undefined) {
|
|
619
|
+
// Bash command substitution in policy-retire.sh strips all trailing newlines
|
|
620
|
+
// from the validator output before printf adds its single final newline.
|
|
621
|
+
const trimmedDiagnostic = diagnostic.replace(/\n+$/, '');
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
error: `policy-retire: refusing to write ${filePath}; canonical policy validation failed:\n${trimmedDiagnostic}\n`,
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
const tempPath = `${resolvedPath}.tmp`;
|
|
628
|
+
fs.writeFileSync(tempPath, rewriteStatus(content, status, reason, by, now), { mode: 0o666 });
|
|
629
|
+
fs.renameSync(tempPath, filePath);
|
|
630
|
+
return { ok: true };
|
|
631
|
+
}
|
|
632
|
+
function displayPolicyPath(filePath, root) {
|
|
633
|
+
return filePath.startsWith(`${root}/`) ? filePath.slice(root.length + 1) : filePath;
|
|
634
|
+
}
|
|
635
|
+
function resolvePolicyTarget(target, root, cwd) {
|
|
636
|
+
let found = isFile(path.isAbsolute(target) ? target : path.resolve(cwd, target))
|
|
637
|
+
? (path.isAbsolute(target) ? target : target)
|
|
638
|
+
: undefined;
|
|
639
|
+
if (found === undefined) {
|
|
640
|
+
const dirs = [path.join(root, 'personal/policies'), path.join(root, 'core/policies')];
|
|
641
|
+
const companies = listFiles(path.join(root, 'companies')).map((name) => path.join(root, 'companies', name, 'policies'));
|
|
642
|
+
dirs.push(...companies);
|
|
643
|
+
for (const directory of dirs) {
|
|
644
|
+
if (isFile(path.join(directory, `${target}.md`))) {
|
|
645
|
+
found = path.join(directory, `${target}.md`);
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
if (isFile(path.join(directory, target))) {
|
|
649
|
+
found = path.join(directory, target);
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (found === undefined)
|
|
655
|
+
return undefined;
|
|
656
|
+
if (!isSymlink(path.isAbsolute(found) ? found : path.resolve(cwd, found)))
|
|
657
|
+
return found;
|
|
658
|
+
try {
|
|
659
|
+
return fs.realpathSync(path.isAbsolute(found) ? found : path.resolve(cwd, found));
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
if (expectedFsMiss(error)) {
|
|
663
|
+
try {
|
|
664
|
+
return fs.readlinkSync(path.isAbsolute(found) ? found : path.resolve(cwd, found));
|
|
665
|
+
}
|
|
666
|
+
catch (readError) {
|
|
667
|
+
if (expectedFsMiss(readError))
|
|
668
|
+
return undefined;
|
|
669
|
+
throw readError;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function sessionCompanySlug(root, env) {
|
|
676
|
+
const valid = (candidate) => candidate !== '' && candidate !== '.' && candidate !== '..' && /^[A-Za-z0-9._-]+$/.test(candidate);
|
|
677
|
+
const candidates = ['HQ_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_SESSION_ID', 'CODEX_SESSION_ID', 'CODEX_THREAD_ID', 'GROK_SESSION_ID'];
|
|
678
|
+
let sessionId = '';
|
|
679
|
+
for (const variable of candidates) {
|
|
680
|
+
const value = (env[variable] ?? '').replace(/[\s\u00a0]+/g, '');
|
|
681
|
+
if (valid(value)) {
|
|
682
|
+
sessionId = value;
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (!sessionId) {
|
|
687
|
+
const current = fileText(path.join(root, 'workspace/sessions/.current')) ?? '';
|
|
688
|
+
const value = current.replace(/[\s\u00a0]+/g, '');
|
|
689
|
+
if (valid(value))
|
|
690
|
+
sessionId = value;
|
|
691
|
+
}
|
|
692
|
+
if (!sessionId)
|
|
693
|
+
return '';
|
|
694
|
+
const metadata = fileText(path.join(root, 'workspace/sessions', sessionId, 'meta.yaml'));
|
|
695
|
+
if (metadata === undefined)
|
|
696
|
+
return '';
|
|
697
|
+
for (const line of metadata.split('\n')) {
|
|
698
|
+
if (line.trim().split(/[ \t]+/)[0] !== 'company_slug:')
|
|
699
|
+
continue;
|
|
700
|
+
return line.replace(/^[^:]+:[ \t]*/, '').replace(/^["]|["]$/g, '');
|
|
701
|
+
}
|
|
702
|
+
return '';
|
|
703
|
+
}
|
|
704
|
+
function resolveAutoDirs(options, root, env) {
|
|
705
|
+
if (options.dirs.length > 0)
|
|
706
|
+
return options.dirs;
|
|
707
|
+
const dirs = [path.join(root, 'personal/policies')];
|
|
708
|
+
const company = sessionCompanySlug(root, env);
|
|
709
|
+
if (company && company !== 'personal' && dirExists(path.join(root, 'companies', company, 'policies'))) {
|
|
710
|
+
dirs.push(path.join(root, 'companies', company, 'policies'));
|
|
711
|
+
}
|
|
712
|
+
return dirs;
|
|
713
|
+
}
|
|
714
|
+
function autoRetire(options, root, cwd, env, now) {
|
|
715
|
+
const dirs = resolveAutoDirs(options, root, env);
|
|
716
|
+
const ageArgs = dirs.flatMap((directory) => ['--dir', directory]).concat(['--json', '--candidates']);
|
|
717
|
+
const report = runPolicyAgeReport(ageArgs, root, env, Math.floor(now.getTime() / 1000));
|
|
718
|
+
const json = report.code === 0 ? report.stdout.toString('utf8') : '[]';
|
|
719
|
+
let candidates;
|
|
720
|
+
try {
|
|
721
|
+
candidates = JSON.parse(json);
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
throw new Error(`policy-retire: in-process age report returned invalid JSON: ${String(error)}`);
|
|
725
|
+
}
|
|
726
|
+
const today = now.toISOString().slice(0, 10);
|
|
727
|
+
const reportDir = options.reportDir || path.join(root, 'workspace/reports');
|
|
728
|
+
const outputPath = path.join(reportDir, `policy-retirement-${today}.md`);
|
|
729
|
+
const tempPath = `${outputPath}.tmp`;
|
|
730
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
731
|
+
let reportText = '';
|
|
732
|
+
if (!isFile(outputPath)) {
|
|
733
|
+
reportText += `# Automatic policy retirement — ${today}\n\nMode: human on the loop. Each policy below was retired by \`policy-retire.sh --auto\` with the reason shown.\nReverse any wrong call with \`bash core/scripts/policy-retire.sh <slug> --restore\` and fix the rule that produced it.\n\n`;
|
|
734
|
+
}
|
|
735
|
+
reportText += `## Run ${now.toISOString().slice(11, 19)}Z — dirs: ${dirs.join(' ')}\n\n`;
|
|
736
|
+
fs.writeFileSync(tempPath, reportText);
|
|
737
|
+
let retired = 0;
|
|
738
|
+
let failed = 0;
|
|
739
|
+
let needfix = 0;
|
|
740
|
+
let wouldRetire = 0;
|
|
741
|
+
let stdout = '';
|
|
742
|
+
let stderr = '';
|
|
743
|
+
for (const candidate of candidates) {
|
|
744
|
+
if (!candidate.path)
|
|
745
|
+
continue;
|
|
746
|
+
let file = path.join(root, candidate.path);
|
|
747
|
+
if (!isFile(file))
|
|
748
|
+
file = candidate.path;
|
|
749
|
+
if (!isFile(path.isAbsolute(file) ? file : path.resolve(cwd, file)))
|
|
750
|
+
continue;
|
|
751
|
+
const resolvedForRead = path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
|
752
|
+
const text = fileText(resolvedForRead) ?? '';
|
|
753
|
+
if (/^status: retired/m.test(text))
|
|
754
|
+
continue;
|
|
755
|
+
let classes = candidate.candidate_classes.join(',');
|
|
756
|
+
if (options.classes) {
|
|
757
|
+
const keep = [];
|
|
758
|
+
for (const name of options.classes.replaceAll(',', ' ').split(/[ \t\n]+/).filter(Boolean)) {
|
|
759
|
+
if (classes.split(',').includes(name))
|
|
760
|
+
keep.push(name);
|
|
761
|
+
}
|
|
762
|
+
classes = keep.join(',');
|
|
763
|
+
if (!classes)
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
let why = '';
|
|
767
|
+
if (classes.split(',').includes('renamed-refs')) {
|
|
768
|
+
reportText += `- needs-fix (not retired) **${candidate.id}** (${candidate.path}): referenced file was renamed: ${candidate.renamed_refs.join(', ')}\n`;
|
|
769
|
+
needfix += 1;
|
|
770
|
+
}
|
|
771
|
+
if (classes.split(',').includes('stale-refs')) {
|
|
772
|
+
if (candidate.refs_judged > 0 && candidate.stale_refs.length === candidate.refs_judged) {
|
|
773
|
+
why = `every path the rule depends on is gone (${candidate.stale_refs.join(', ')})`;
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
reportText += `- needs-fix (not retired) **${candidate.id}** (${candidate.path}): references gone: ${candidate.stale_refs.join(', ')}\n`;
|
|
777
|
+
needfix += 1;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (classes.split(',').includes('never-fired'))
|
|
781
|
+
why = `${why ? `${why}; ` : ''}never emitted in any session ledger over ${candidate.age_days} days`;
|
|
782
|
+
if (classes.split(',').includes('dormant'))
|
|
783
|
+
why = `${why ? `${why}; ` : ''}last emitted ${candidate.last_fired ?? 'never'}, more than the dormant window ago`;
|
|
784
|
+
if (classes.split(',').includes('single-incident-old'))
|
|
785
|
+
why = `${why ? `${why}; ` : ''}one-off correction, ${candidate.age_days} days old, fired ${candidate.fired}x`;
|
|
786
|
+
if (!why)
|
|
787
|
+
continue;
|
|
788
|
+
if (options.dryRun) {
|
|
789
|
+
stdout += `would retire: ${candidate.id} — ${why}\n`;
|
|
790
|
+
wouldRetire += 1;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const updated = setPolicyStatus(resolvedForRead, 'retired', `auto: ${why}`, options.by, env, now);
|
|
794
|
+
if (updated.ok) {
|
|
795
|
+
stdout += `retired: ${candidate.id} — ${why}\n`;
|
|
796
|
+
reportText += `- **${candidate.id}** (${candidate.path}): ${why}\n`;
|
|
797
|
+
retired += 1;
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
stderr += updated.error;
|
|
801
|
+
stderr += `failed: ${candidate.id} (${candidate.path}); policy was not retired\n`;
|
|
802
|
+
reportText += `- failed (not retired) **${candidate.id}** (${candidate.path}): canonical policy validation failed\n`;
|
|
803
|
+
failed += 1;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (options.dryRun) {
|
|
807
|
+
fs.rmSync(tempPath, { force: true });
|
|
808
|
+
stdout += `policy-retire --auto: ${wouldRetire} of ${candidates.length} candidates would be retired (dry run)\n`;
|
|
809
|
+
}
|
|
810
|
+
else if (retired > 0 || failed > 0 || needfix > 0) {
|
|
811
|
+
fs.appendFileSync(outputPath, fs.readFileSync(tempPath));
|
|
812
|
+
fs.rmSync(tempPath, { force: true });
|
|
813
|
+
stdout += `policy-retire --auto: ${retired} retired, ${failed} failed, ${needfix} need a fix; report ${displayPolicyPath(outputPath, root)}\n`;
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
fs.rmSync(tempPath, { force: true });
|
|
817
|
+
stdout += 'policy-retire --auto: nothing to retire\n';
|
|
818
|
+
}
|
|
819
|
+
return policyResult(failed > 0 ? 1 : 0, stdout, stderr);
|
|
820
|
+
}
|
|
821
|
+
function fromReport(options, root, cwd, env, now) {
|
|
822
|
+
if (!options.className || !options.reason)
|
|
823
|
+
return policyResult(2, '', '--from-report needs --class and --reason\n');
|
|
824
|
+
const reportPath = path.isAbsolute(options.report) ? options.report : path.resolve(cwd, options.report);
|
|
825
|
+
let reportText;
|
|
826
|
+
try {
|
|
827
|
+
reportText = fs.readFileSync(reportPath, 'utf8');
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
if (expectedFsMiss(error))
|
|
831
|
+
return policyResult(2, '', `cannot read ${options.report}\n`);
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
let report;
|
|
835
|
+
try {
|
|
836
|
+
report = JSON.parse(reportText);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return policyResult(2, '', `policy-retire: invalid JSON report ${options.report}\n`);
|
|
840
|
+
}
|
|
841
|
+
const rows = Array.isArray(report)
|
|
842
|
+
? report
|
|
843
|
+
: report !== null && typeof report === 'object'
|
|
844
|
+
? Object.values(report)
|
|
845
|
+
: [];
|
|
846
|
+
let retired = 0;
|
|
847
|
+
let failed = 0;
|
|
848
|
+
let selected = 0;
|
|
849
|
+
let stdout = '';
|
|
850
|
+
let stderr = report === null
|
|
851
|
+
? `jq: error (at ${reportPath}:0): Cannot iterate over null (null)\n`
|
|
852
|
+
: '';
|
|
853
|
+
for (const value of rows) {
|
|
854
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
855
|
+
continue;
|
|
856
|
+
const row = value;
|
|
857
|
+
const candidateClasses = row.candidate_classes;
|
|
858
|
+
const matchesClass = Array.isArray(candidateClasses)
|
|
859
|
+
? candidateClasses.includes(options.className)
|
|
860
|
+
: typeof candidateClasses === 'string' && candidateClasses.includes(options.className);
|
|
861
|
+
const reportPath = typeof row.path === 'string' ? row.path : '';
|
|
862
|
+
if (!matchesClass || !reportPath)
|
|
863
|
+
continue;
|
|
864
|
+
const file = path.join(root, reportPath);
|
|
865
|
+
if (!isFile(file))
|
|
866
|
+
continue;
|
|
867
|
+
if (/^status: retired/m.test(fileText(file) ?? ''))
|
|
868
|
+
continue;
|
|
869
|
+
selected += 1;
|
|
870
|
+
const reportId = typeof row.id === 'string' ? row.id : String(row.id ?? 'null');
|
|
871
|
+
if (!options.yes) {
|
|
872
|
+
stdout += `would retire: ${reportId} (${reportPath})\n`;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const updated = setPolicyStatus(file, 'retired', `${options.reason} [${options.className}]`, options.by, env, now, cwd);
|
|
876
|
+
if (updated.ok) {
|
|
877
|
+
retired += 1;
|
|
878
|
+
stdout += `retired: ${reportId} (${reportPath})\n`;
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
failed += 1;
|
|
882
|
+
stderr += `${updated.error}failed: ${reportId} (${reportPath}); policy was not retired\n`;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (options.yes) {
|
|
886
|
+
stdout += `policy-retire: ${retired} retired [${options.className}]; ${failed} failed\n`;
|
|
887
|
+
if (failed > 0)
|
|
888
|
+
return policyResult(1, stdout, stderr);
|
|
889
|
+
}
|
|
890
|
+
else
|
|
891
|
+
stdout += `policy-retire: ${selected} would be retired [${options.className}] (add --yes)\n`;
|
|
892
|
+
return policyResult(0, stdout, stderr);
|
|
893
|
+
}
|
|
894
|
+
export function runPolicyRetire(args, root, cwd, env = process.env, now = new Date()) {
|
|
895
|
+
const parsed = parseRetireOptions(args, resolveBundledAsset(POLICY_RETIRE_ASSET), env);
|
|
896
|
+
if (parsed.kind === 'help')
|
|
897
|
+
return policyResult(0, RETIRE_HELP);
|
|
898
|
+
if (parsed.kind === 'error')
|
|
899
|
+
return parsed.result;
|
|
900
|
+
const options = parsed.value;
|
|
901
|
+
if (options.auto)
|
|
902
|
+
return autoRetire(options, root, cwd, env, now);
|
|
903
|
+
if (options.report)
|
|
904
|
+
return fromReport(options, root, cwd, env, now);
|
|
905
|
+
if (!options.target)
|
|
906
|
+
return policyResult(2, RETIRE_HELP);
|
|
907
|
+
const file = resolvePolicyTarget(options.target, root, cwd);
|
|
908
|
+
if (file === undefined)
|
|
909
|
+
return policyResult(3, '', `policy-retire: no policy found for '${options.target}'\n`);
|
|
910
|
+
if (options.restore) {
|
|
911
|
+
const updated = setPolicyStatus(file, 'active', '', options.by, env, now, cwd);
|
|
912
|
+
if (!updated.ok)
|
|
913
|
+
return policyResult(1, '', updated.error);
|
|
914
|
+
return policyResult(0, `policy-retire: restored ${displayPolicyPath(file, root)}\n`);
|
|
915
|
+
}
|
|
916
|
+
if (!options.reason)
|
|
917
|
+
return policyResult(2, '', 'policy-retire: --reason is required\n');
|
|
918
|
+
const updated = setPolicyStatus(file, 'retired', options.reason, options.by, env, now, cwd);
|
|
919
|
+
if (!updated.ok)
|
|
920
|
+
return policyResult(1, '', updated.error);
|
|
921
|
+
return policyResult(0, `policy-retire: retired ${displayPolicyPath(file, root)} — ${options.reason}\n`);
|
|
922
|
+
}
|
|
923
|
+
function writeResult(result) {
|
|
924
|
+
if (result.stdout.length > 0)
|
|
925
|
+
process.stdout.write(result.stdout);
|
|
926
|
+
if (result.stderr.length > 0)
|
|
927
|
+
process.stderr.write(result.stderr);
|
|
928
|
+
if (result.code !== 0)
|
|
929
|
+
process.exitCode = result.code;
|
|
930
|
+
}
|
|
931
|
+
export function registerCorePolicyCommand(core) {
|
|
932
|
+
const group = core.command('policy').description('Policy maintenance commands');
|
|
933
|
+
const register = (name, asset, summary, rootKind) => {
|
|
934
|
+
const command = group
|
|
935
|
+
.command(name)
|
|
936
|
+
.description(summary)
|
|
937
|
+
.allowUnknownOption()
|
|
938
|
+
.allowExcessArguments()
|
|
939
|
+
.helpOption(false)
|
|
940
|
+
.argument('[args...]', 'arguments passed through to the scaffold command');
|
|
941
|
+
describeCoreCommand(command, { execution: 'native', asset, root: rootKind });
|
|
942
|
+
return command;
|
|
943
|
+
};
|
|
944
|
+
const ageReport = register('age-report', POLICY_AGE_REPORT_ASSET, 'Report policy usage and retirement candidates', 'live');
|
|
945
|
+
ageReport.action(async (args = [], _options, command) => {
|
|
946
|
+
const operands = command.args.length > 0 ? command.args : args;
|
|
947
|
+
if (!await coreNativePolicyEnabled()) {
|
|
948
|
+
const root = resolveLiveRoot({ hqRoot: core.opts().hqRoot });
|
|
949
|
+
const result = runBundledScript({ asset: POLICY_AGE_REPORT_ASSET, args: operands, cwd: process.cwd(), hqRoot: root });
|
|
950
|
+
if (result.code !== 0)
|
|
951
|
+
process.exitCode = result.code;
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const root = resolveLiveRoot({ hqRoot: core.opts().hqRoot });
|
|
955
|
+
writeResult(runPolicyAgeReport(operands, root));
|
|
956
|
+
});
|
|
957
|
+
const retire = register('retire', POLICY_RETIRE_ASSET, 'Retire or restore a policy', 'live');
|
|
958
|
+
retire.action(async (args = [], _options, command) => {
|
|
959
|
+
const operands = command.args.length > 0 ? command.args : args;
|
|
960
|
+
if (!await coreNativePolicyEnabled()) {
|
|
961
|
+
const root = resolveLiveRoot({ hqRoot: core.opts().hqRoot });
|
|
962
|
+
const result = runBundledScript({ asset: POLICY_RETIRE_ASSET, args: operands, cwd: process.cwd(), hqRoot: root });
|
|
963
|
+
if (result.code !== 0)
|
|
964
|
+
process.exitCode = result.code;
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const root = resolveLiveRoot({ hqRoot: core.opts().hqRoot });
|
|
968
|
+
writeResult(runPolicyRetire(operands, root, process.cwd()));
|
|
969
|
+
});
|
|
970
|
+
const frontmatter = register('frontmatter', POLICY_FRONTMATTER_ASSET, 'Extract policy frontmatter', 'cwd');
|
|
971
|
+
frontmatter.action(async (args = [], _options, command) => {
|
|
972
|
+
const operands = command.args.length > 0 ? command.args : args;
|
|
973
|
+
if (!await coreNativePolicyEnabled()) {
|
|
974
|
+
const result = runBundledScript({ asset: POLICY_FRONTMATTER_ASSET, args: operands, cwd: process.cwd() });
|
|
975
|
+
if (result.code !== 0)
|
|
976
|
+
process.exitCode = result.code;
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
writeResult(runPolicyFrontmatter(operands, process.cwd()));
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
//# sourceMappingURL=core-policy.js.map
|