@nurix/nustack 0.23.12 → 0.23.14

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.
Files changed (72) hide show
  1. package/dist/commands/agent.js +1 -53
  2. package/dist/commands/check.js +73 -0
  3. package/dist/commands/deploy.js +2 -1
  4. package/dist/commands/doctor.js +2 -1
  5. package/dist/commands/harness.js +127 -48
  6. package/dist/commands/init.js +103 -3
  7. package/dist/commands/login.js +9 -2
  8. package/dist/commands/mcp.js +7 -0
  9. package/dist/commands/project-sync.js +2 -1
  10. package/dist/commands/read.js +19 -0
  11. package/dist/commands/records.js +10 -0
  12. package/dist/commands/review.js +3 -2
  13. package/dist/commands/sessions.js +4 -1
  14. package/dist/commands/sync.js +2 -1
  15. package/dist/index.js +4 -0
  16. package/dist/lib/agent/claudeHooks.js +4 -1
  17. package/dist/lib/agent/desktopDoor.js +55 -0
  18. package/dist/lib/auth_grant.js +15 -3
  19. package/dist/lib/auth_recovery.js +10 -3
  20. package/dist/lib/etna/manager.js +24 -6
  21. package/dist/lib/etna/profile.js +6 -0
  22. package/dist/lib/etna/reconcile.js +6 -0
  23. package/dist/lib/gate/bulkReadRelay.js +354 -0
  24. package/dist/lib/gate/hook.js +30 -3
  25. package/dist/lib/gate/outline.js +12 -0
  26. package/dist/lib/gate/settings.js +24 -1
  27. package/dist/lib/gate/tierOne.js +22 -0
  28. package/dist/lib/harness/codes.js +24 -0
  29. package/dist/lib/harness/hookCheck.js +325 -0
  30. package/dist/lib/harness/hookEntry.js +27 -0
  31. package/dist/lib/harness/legacyMount.js +231 -0
  32. package/dist/lib/harness/mount.js +199 -131
  33. package/dist/lib/harness/storeWaivers.js +43 -0
  34. package/dist/lib/harness/vendor.js +29 -0
  35. package/dist/lib/harness/versionsClient.js +54 -0
  36. package/dist/lib/local_store/database.js +18 -0
  37. package/dist/lib/local_store/routing.js +26 -0
  38. package/dist/lib/nustack_client.js +35 -2
  39. package/dist/lib/onboarding/attach_existing_git.js +14 -2
  40. package/dist/lib/onboarding/binding.js +46 -2
  41. package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
  42. package/dist/lib/onboarding/manifest_writer.js +19 -3
  43. package/dist/lib/onboarding/session_hook.js +76 -0
  44. package/dist/lib/origin_guard.js +43 -0
  45. package/dist/lib/project_contract.js +104 -0
  46. package/dist/lib/records/door.js +26 -19
  47. package/dist/lib/records/drain.js +10 -4
  48. package/dist/lib/records/refusalLog.js +30 -1
  49. package/dist/lib/session-engine/supervisor.js +6 -18
  50. package/dist/lib/sessions/client.js +0 -12
  51. package/dist/program.js +27 -4
  52. package/dist/vendor/harness/assemble/runtimes.js +32 -0
  53. package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
  54. package/dist/vendor/harness/digest/index.js +38 -0
  55. package/dist/vendor/harness/lib/findings.js +32 -0
  56. package/dist/vendor/harness/lib/frontmatter.js +79 -0
  57. package/dist/vendor/harness/lib/paths.js +31 -0
  58. package/dist/vendor/harness/mount/cache.js +65 -0
  59. package/dist/vendor/harness/mount/exclude.js +77 -0
  60. package/dist/vendor/harness/mount/files.js +38 -0
  61. package/dist/vendor/harness/mount/index.js +352 -0
  62. package/dist/vendor/harness/rules/changeRules.js +162 -0
  63. package/dist/vendor/harness/rules/context.js +38 -0
  64. package/dist/vendor/harness/rules/docsRoot.js +34 -0
  65. package/dist/vendor/harness/rules/index.js +109 -0
  66. package/dist/vendor/harness/rules/node.js +144 -0
  67. package/dist/vendor/harness/rules/outputGate.js +28 -0
  68. package/dist/vendor/harness/rules/ownership.js +178 -0
  69. package/dist/vendor/harness/rules/packageTable.js +113 -0
  70. package/dist/vendor/harness/rules/plans.js +96 -0
  71. package/dist/vendor/harness/rules/sessionWalls.js +31 -0
  72. package/package.json +5 -5
@@ -0,0 +1,109 @@
1
+ // packages/harness/src/rules/index.ts - The one rule library (program ruling R14): runRule over a RuleContext, the
2
+ // append-only RULE_IDS with their scopes, delegations and reads, and the ownership helpers ported from etna. Imports
3
+ // no node: module, so the Worker runs it over a repo mirror. docs/architecture/systems/harness-records.md speaks for it
4
+ // (Status check and enforcement).
5
+ import { docsRootOf } from './context.js';
6
+ import { changelogEntryRule, changelogRule, frozenRecordRule, ledgerRule, planGateRule, protectedFileRule } from './changeRules.js';
7
+ import { outputGateRule } from './outputGate.js';
8
+ import { ownershipRule } from './ownership.js';
9
+ import { packageTableRule } from './packageTable.js';
10
+ import { branchWorktreeRule, commitClaimRule, exitStopRule, foreignClaimRule, liveCommandRule, openDenialsRule, queueRule } from './sessionWalls.js';
11
+ export * from './context.js';
12
+ export { resolveDocsRoot, docsRootDeclaration, DEFAULT_DOCS_ROOT, DOCS_ROOT_SOURCES } from './docsRoot.js';
13
+ export { scanOwningDocs, listRepoFiles, deadGlobs, overlaps, owningDoc, owningDocs, scanDocEdges, globToRegExp, specificity } from './ownership.js';
14
+ export { listPlans, openGatedPlans, planScopePaths, inPlanScope, undischargedRows, undischargedDocRows } from './plans.js';
15
+ export { changelogExemptFile, frozenReason, protectedListPath } from './changeRules.js';
16
+ export { approvedPackages, rejectedPackages, APPROVED_TABLE_PATH } from './packageTable.js';
17
+ export { AGENT_BRANCH_PREFIX, parseAgentRef } from './outputGate.js';
18
+ /** Every rule id, append-only: an id is never renamed or removed. The last six delegate to other rules. */
19
+ export const RULE_IDS = [
20
+ 'ownership',
21
+ 'plan-gate',
22
+ 'ledger',
23
+ 'frozen-record',
24
+ 'changelog',
25
+ 'changelog-entry',
26
+ 'package-table',
27
+ 'protected-file',
28
+ 'output-gate',
29
+ 'branch-worktree',
30
+ 'live-command',
31
+ 'commit-claim',
32
+ 'foreign-claim',
33
+ 'open-denials',
34
+ 'exit-stop',
35
+ 'queue',
36
+ 'completion-integrity',
37
+ 'changelog-presence',
38
+ 'commit-gate',
39
+ 'file-gate',
40
+ 'bash-write-screen',
41
+ 'harness-checks',
42
+ ];
43
+ export const isRuleId = (value) => RULE_IDS.includes(value);
44
+ /** Seed ids naming a check another rule already makes: each runs its targets, whose findings keep their own ids. */
45
+ export const RULE_ALIASES = {
46
+ 'completion-integrity': ['ledger'],
47
+ 'changelog-presence': ['changelog'],
48
+ 'commit-gate': ['changelog'],
49
+ 'file-gate': ['plan-gate'],
50
+ 'bash-write-screen': ['frozen-record', 'protected-file'],
51
+ 'harness-checks': ['ownership', 'ledger', 'changelog'],
52
+ };
53
+ const SESSION_RULES = ['branch-worktree', 'live-command', 'commit-claim', 'foreign-claim', 'open-denials', 'exit-stop', 'queue'];
54
+ /** Change rules judge trees and a change set; session rules judge one session's action or facts, at the hook. */
55
+ export const RULE_SCOPES = Object.fromEntries(RULE_IDS.map((id) => [id, SESSION_RULES.includes(id) ? 'session' : 'change']));
56
+ const DOCS_SOURCES = ['.nustack/workspace.json', '.claude/harness.json'];
57
+ const DIRECT_READS = {
58
+ ownership: ['**'],
59
+ 'plan-gate': [...DOCS_SOURCES, '@docs/history/plans/*.md'],
60
+ ledger: [...DOCS_SOURCES, '@docs/**/*.md'],
61
+ 'frozen-record': [...DOCS_SOURCES, '@docs/history/plans/**/*.md', '@docs/history/changelog/**/*.md', '@docs/history/migrations/**/*.md'],
62
+ changelog: [...DOCS_SOURCES],
63
+ 'changelog-entry': [...DOCS_SOURCES, '@docs/history/plans/**/*.md', '@docs/history/changelog/**/*.md'],
64
+ 'package-table': ['**/package.json', '.claude/hooks/approved-packages.md', '.claude/rules/general.md', '.claude/rules/testing.md'],
65
+ 'protected-file': [...DOCS_SOURCES, '@docs/operations/protected-files.md'],
66
+ };
67
+ /** The path patterns each rule reads, docs-root-relative ones written `@docs/…`; a delegating id's is its targets' union. */
68
+ export const RULE_READS = Object.fromEntries(RULE_IDS.map((id) => [id, [...new Set((RULE_ALIASES[id] ?? [id]).flatMap((target) => DIRECT_READS[target] ?? []))]]));
69
+ const RULES = {
70
+ ownership: (ctx) => ownershipRule(ctx, docsRootOf(ctx)),
71
+ 'plan-gate': (ctx) => planGateRule(ctx, docsRootOf(ctx)),
72
+ ledger: (ctx) => ledgerRule(ctx, docsRootOf(ctx)),
73
+ 'frozen-record': (ctx) => frozenRecordRule(ctx, docsRootOf(ctx)),
74
+ changelog: (ctx) => changelogRule(ctx, docsRootOf(ctx)),
75
+ 'changelog-entry': (ctx) => changelogEntryRule(ctx, docsRootOf(ctx)),
76
+ 'package-table': packageTableRule,
77
+ 'protected-file': (ctx) => protectedFileRule(ctx, docsRootOf(ctx)),
78
+ 'output-gate': outputGateRule,
79
+ 'branch-worktree': branchWorktreeRule,
80
+ 'live-command': liveCommandRule,
81
+ 'commit-claim': commitClaimRule,
82
+ 'foreign-claim': foreignClaimRule,
83
+ 'open-denials': openDenialsRule,
84
+ 'exit-stop': exitStopRule,
85
+ queue: queueRule,
86
+ };
87
+ export class UnknownRuleError extends Error {
88
+ constructor(id) {
89
+ super(`unknown rule id ${JSON.stringify(id)}; RULE_IDS lists the rules this library runs`);
90
+ this.name = 'UnknownRuleError';
91
+ }
92
+ }
93
+ const RANK = { pass: 0, flag: 1, block: 2 };
94
+ /** Runs one rule: pass with no findings, else the configuration's rung (default block). An unknown id throws. */
95
+ export function runRule(id, ctx) {
96
+ if (!isRuleId(id))
97
+ throw new UnknownRuleError(id);
98
+ const targets = RULE_ALIASES[id];
99
+ if (targets) {
100
+ const results = targets.map((target) => runRule(target, ctx));
101
+ const verdict = results.reduce((worst, result) => (RANK[result.verdict] > RANK[worst] ? result.verdict : worst), 'pass');
102
+ const findings = results.flatMap((result) => result.findings);
103
+ return results.every((result) => result.notApplicable) ? { verdict, findings, notApplicable: true } : { verdict, findings };
104
+ }
105
+ if (RULE_SCOPES[id] === 'session' && (ctx.at ?? 'change') === 'change')
106
+ return { verdict: 'pass', findings: [], notApplicable: true };
107
+ const findings = RULES[id](ctx);
108
+ return { verdict: findings.length === 0 ? 'pass' : (ctx.rung ?? 'block'), findings };
109
+ }
@@ -0,0 +1,144 @@
1
+ // packages/harness/src/rules/node.ts - nodeRuleContext: a RuleContext over a local git repository. Trees come from
2
+ // `git ls-files` (the working tree, untracked files included) or `git ls-tree`/`git cat-file` at a ref, the change set
3
+ // from `git diff --name-status -M`, and the pull request's head ref from the checked-out branch. Also
4
+ // readBookkeepingWaivers, the `waived:` rows of the bookkeeping queue every local caller passes as waivers. Node only.
5
+ // docs/architecture/systems/harness-records.md speaks for it (Status check and enforcement).
6
+ import { execFileSync } from 'node:child_process';
7
+ import { readFileSync, statSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { isSafeRelativePath } from '../lib/paths.js';
10
+ import { resolveDocsRoot } from './docsRoot.js';
11
+ const MAX_BUFFER = 512 * 1024 * 1024;
12
+ function git(root, args) {
13
+ try {
14
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: MAX_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] });
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ const nulList = (out) => (out ?? '').split('\0').filter(Boolean);
21
+ /** A tree over the files committed at `ref`. */
22
+ export function gitRefTree(root, ref) {
23
+ const files = nulList(git(root, ['ls-tree', '-r', '-z', '--name-only', ref])).sort();
24
+ const known = new Set(files);
25
+ const cache = new Map();
26
+ return {
27
+ read(path) {
28
+ if (!known.has(path))
29
+ return null;
30
+ if (!cache.has(path))
31
+ cache.set(path, git(root, ['cat-file', 'blob', `${ref}:${path}`]));
32
+ return cache.get(path);
33
+ },
34
+ list: (prefix) => files.filter((path) => path.startsWith(prefix)),
35
+ };
36
+ }
37
+ /** A tree over the working tree: tracked and untracked-but-not-ignored files that exist; any safe path reads from disk. */
38
+ export function workingTree(root) {
39
+ const files = [...new Set(nulList(git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])))]
40
+ .filter((path) => {
41
+ try {
42
+ return statSync(join(root, path)).isFile();
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ })
48
+ .sort();
49
+ return {
50
+ read(path) {
51
+ if (!isSafeRelativePath(path))
52
+ return null;
53
+ try {
54
+ return readFileSync(join(root, path), 'utf8');
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ },
60
+ list: (prefix) => files.filter((path) => path.startsWith(prefix)),
61
+ };
62
+ }
63
+ /** `git diff --name-status -M -z` parsed; copies count as adds. */
64
+ function parseNameStatus(out) {
65
+ const fields = nulList(out);
66
+ const changes = [];
67
+ for (let i = 0; i < fields.length;) {
68
+ const status = fields[i];
69
+ const kind = status[0];
70
+ if (kind === 'R' || kind === 'C') {
71
+ const from = fields[i + 1];
72
+ const path = fields[i + 2];
73
+ changes.push(kind === 'R' ? { path, status: 'R', from } : { path, status: 'A' });
74
+ i += 3;
75
+ }
76
+ else {
77
+ changes.push({ path: fields[i + 1], status: kind === 'A' ? 'A' : kind === 'D' ? 'D' : 'M' });
78
+ i += 2;
79
+ }
80
+ }
81
+ return changes;
82
+ }
83
+ /**
84
+ * A rule context over the repository at `root`. With `headRef`, head is that commit and base is `baseRef` when given;
85
+ * without it, head is the working tree and base is `baseRef`, else HEAD.
86
+ */
87
+ export function nodeRuleContext(options) {
88
+ const { root, headRef } = options;
89
+ const hasHead = git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) !== null;
90
+ const baseRef = options.baseRef ?? (headRef || !hasHead ? undefined : 'HEAD');
91
+ const head = headRef ? gitRefTree(root, headRef) : workingTree(root);
92
+ const base = baseRef ? gitRefTree(root, baseRef) : undefined;
93
+ let changes = [];
94
+ if (baseRef) {
95
+ changes = parseNameStatus(git(root, ['diff', '--name-status', '-M', '-z', baseRef, ...(headRef ? [headRef] : [])]));
96
+ if (!headRef) {
97
+ const tracked = new Set(changes.map((change) => change.path));
98
+ for (const path of nulList(git(root, ['ls-files', '-z', '--others', '--exclude-standard'])))
99
+ if (!tracked.has(path))
100
+ changes.push({ path, status: 'A' });
101
+ }
102
+ }
103
+ const log = git(root, ['log', '-1', '--format=%an <%ae>%x00%B', headRef ?? 'HEAD']);
104
+ const [author, message] = log ? [log.slice(0, log.indexOf('\0')), log.slice(log.indexOf('\0') + 1).trimEnd()] : ['', ''];
105
+ const branch = (git(root, ['rev-parse', '--abbrev-ref', 'HEAD']) ?? '').trim();
106
+ return {
107
+ head,
108
+ ...(base ? { base } : {}),
109
+ changes,
110
+ commit: { author, message },
111
+ today: options.today ?? new Date().toISOString().slice(0, 10),
112
+ pullRequest: { headRef: branch, baseRef: options.baseRef ?? '' },
113
+ };
114
+ }
115
+ /** The bookkeeping queue, relative to the docs root. */
116
+ export const BOOKKEEPING_QUEUE_PATH = 'operations/bookkeeping.md';
117
+ const QUEUE_ROW = /^-\s+(auto|note|needs-session|waived):\s+(.*)$/;
118
+ /**
119
+ * The `waived:` rows of `<docs root>/operations/bookkeeping.md` as RuleWaivers, ported from etna's `readQueue`: one
120
+ * per row, its body trimmed. `scripts/verify/check-ownership.mjs` and `nustack check` both pass these, so the two
121
+ * never read waivers differently. None when the queue is absent.
122
+ */
123
+ export function readBookkeepingWaivers(root) {
124
+ const read = (path) => {
125
+ if (!isSafeRelativePath(path))
126
+ return null;
127
+ try {
128
+ return readFileSync(join(root, path), 'utf8');
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ };
134
+ const text = read(`${resolveDocsRoot({ read })}/${BOOKKEEPING_QUEUE_PATH}`);
135
+ if (text === null)
136
+ return [];
137
+ const waivers = [];
138
+ for (const line of text.split(/\r?\n/)) {
139
+ const match = QUEUE_ROW.exec(line.trim());
140
+ if (match && match[1] === 'waived')
141
+ waivers.push({ kind: 'waived', body: match[2].trim() });
142
+ }
143
+ return waivers;
144
+ }
@@ -0,0 +1,28 @@
1
+ export const AGENT_BRANCH_PREFIX = 'nustack/agent/';
2
+ const SEGMENT = /^[A-Za-z0-9_-]+$/;
3
+ /** `nustack/agent/<slug>/<runId>` split, or null when a ref under the prefix does not parse. */
4
+ export function parseAgentRef(ref) {
5
+ if (!ref.startsWith(AGENT_BRANCH_PREFIX))
6
+ return null;
7
+ const parts = ref.slice(AGENT_BRANCH_PREFIX.length).split('/');
8
+ return parts.length === 2 && SEGMENT.test(parts[0]) && SEGMENT.test(parts[1]) ? { agentSlug: parts[0], runId: parts[1] } : null;
9
+ }
10
+ export function outputGateRule(ctx) {
11
+ if (!ctx.pullRequest)
12
+ return [{ id: 'output-gate.context_missing', message: 'no pull request refs were passed; the output gate fails closed' }];
13
+ const ref = ctx.pullRequest.headRef;
14
+ const run = ctx.agentRun;
15
+ const namespaced = ref.startsWith(AGENT_BRANCH_PREFIX);
16
+ const parsed = parseAgentRef(ref);
17
+ const findings = [];
18
+ if (run && !namespaced) {
19
+ findings.push({ id: 'output-gate.outside_namespace', message: `agent ${run.agentSlug} run ${run.runId} opened ${ref}, outside ${AGENT_BRANCH_PREFIX}${run.agentSlug}/` });
20
+ }
21
+ if (namespaced && !parsed)
22
+ findings.push({ id: 'output-gate.malformed_ref', message: `${ref} is under ${AGENT_BRANCH_PREFIX} but is not <slug>/<runId>` });
23
+ if (parsed && run && parsed.agentSlug !== run.agentSlug)
24
+ findings.push({ id: 'output-gate.foreign_namespace', message: `${ref} is ${parsed.agentSlug}'s namespace, but the run is ${run.agentSlug}'s` });
25
+ if (namespaced && run === null)
26
+ findings.push({ id: 'output-gate.unclaimed_ref', message: `${ref} is in the agent namespace but no agent run claims it` });
27
+ return findings;
28
+ }
@@ -0,0 +1,178 @@
1
+ // packages/harness/src/rules/ownership.ts - The `ownership` rule and the ownership helpers over a RuleTree: dead owns:
2
+ // globs, equal-specificity overlaps and dangling doc edges. Ported from etna harness/code/hooks/check-ownership.mjs
3
+ // and lib/ownership.mjs, each helper taking a tree where etna took a project directory.
4
+ // docs/architecture/systems/harness-records.md speaks for it (Status check and enforcement).
5
+ import { asList, field, frontmatter } from '../lib/frontmatter.js';
6
+ import { waiverCovers } from './context.js';
7
+ import { resolveDocsRoot } from './docsRoot.js';
8
+ /** Folders under the docs root that never hold an owning document: frozen history and unharvested research. */
9
+ const NON_OWNING_DIRS = new Set(['history', 'research']);
10
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.claude', '.codex']);
11
+ const markdownUnder = (tree, root) => tree.list(`${root}/`).filter((path) => path.endsWith('.md'));
12
+ /** One entry per document under the docs root that declares owns:, history/, research/ and .bkp. files aside. */
13
+ export function scanOwningDocs(tree, docsRoot = resolveDocsRoot(tree)) {
14
+ const docs = [];
15
+ for (const path of markdownUnder(tree, docsRoot)) {
16
+ if (path.includes('.bkp.') || NON_OWNING_DIRS.has(path.slice(docsRoot.length + 1).split('/')[0]))
17
+ continue;
18
+ const block = frontmatter(tree.read(path) ?? '');
19
+ if (!block)
20
+ continue;
21
+ const owns = asList(field(block, 'owns'));
22
+ if (owns.length === 0)
23
+ continue;
24
+ docs.push({
25
+ doc: path,
26
+ owns,
27
+ module: String(field(block, 'module') || '—'),
28
+ keywords: asList(field(block, 'keywords')),
29
+ objects: asList(field(block, 'objects')),
30
+ status: String(field(block, 'status') || 'active').toLowerCase(),
31
+ });
32
+ }
33
+ return docs;
34
+ }
35
+ /** Every file of the tree outside the heavy and machine-local folders. */
36
+ export function listRepoFiles(tree) {
37
+ return tree.list('').filter((path) => !path.split('/').some((segment) => SKIP_DIRS.has(segment)));
38
+ }
39
+ /** One owns:-style glob (`**` any depth, `*` within a segment) as a RegExp; `a/**` also matches `a`. */
40
+ export function globToRegExp(glob) {
41
+ const g = glob.replace(/\\/g, '/').replace(/\/+$/, '');
42
+ const escaped = g.replace(/[\\^$.|?+()[\]{}]/g, '\\$&');
43
+ const re = escaped
44
+ .replace(/\/\*\*$/, '\0TAIL\0')
45
+ .replace(/\*\*\//g, '\0LEAD\0')
46
+ .replace(/\*\*/g, '\0ANY\0')
47
+ .replace(/\*/g, '[^/]*')
48
+ .replace(/\0TAIL\0/g, '(?:/.*)?')
49
+ .replace(/\0LEAD\0/g, '(?:.*/)?')
50
+ .replace(/\0ANY\0/g, '.*');
51
+ return new RegExp(`^${re}$`);
52
+ }
53
+ /** A glob's specificity as literal and wildcard segment counts; only an exact tie leaves an owner ambiguous. */
54
+ export function specificity(glob) {
55
+ let literal = 0;
56
+ let wildcard = 0;
57
+ for (const segment of String(glob).split('/')) {
58
+ if (segment === '')
59
+ continue;
60
+ if (segment.includes('*') || segment.includes('?'))
61
+ wildcard++;
62
+ else
63
+ literal++;
64
+ }
65
+ return { literal, wildcard };
66
+ }
67
+ export function owningDocs(tree, relPath, docs = scanOwningDocs(tree)) {
68
+ return docs.filter((entry) => entry.status !== 'deprecated' && entry.owns.some((glob) => globToRegExp(glob).test(relPath)));
69
+ }
70
+ /** The one owning doc for a path, most specific glob first, with every doc tied at that specificity. */
71
+ export function owningDoc(tree, relPath, docs) {
72
+ const matches = owningDocs(tree, relPath, docs);
73
+ if (matches.length <= 1)
74
+ return matches.length ? { doc: matches[0], glob: null, tied: [] } : null;
75
+ const scored = [];
76
+ for (const entry of matches) {
77
+ let best = null;
78
+ for (const glob of entry.owns) {
79
+ if (!globToRegExp(glob).test(relPath))
80
+ continue;
81
+ const s = specificity(glob);
82
+ if (!best || s.literal > best.s.literal || (s.literal === best.s.literal && s.wildcard < best.s.wildcard))
83
+ best = { glob, s };
84
+ }
85
+ if (best)
86
+ scored.push({ entry, ...best });
87
+ }
88
+ if (!scored.length)
89
+ return null;
90
+ scored.sort((a, b) => b.s.literal - a.s.literal || a.s.wildcard - b.s.wildcard);
91
+ const top = scored[0];
92
+ const tied = scored.filter((x) => x !== top && x.s.literal === top.s.literal && x.s.wildcard === top.s.wildcard);
93
+ return { doc: top.entry, glob: top.glob, tied: tied.map((x) => x.entry) };
94
+ }
95
+ /** Globs across all owning docs that match no file. */
96
+ export function deadGlobs(tree, repoFiles = listRepoFiles(tree), docs = scanOwningDocs(tree)) {
97
+ const dead = [];
98
+ for (const entry of docs) {
99
+ if (entry.status === 'deprecated')
100
+ continue;
101
+ for (const glob of entry.owns) {
102
+ const re = globToRegExp(glob);
103
+ if (!repoFiles.some((file) => re.test(file)))
104
+ dead.push({ glob, doc: entry.doc });
105
+ }
106
+ }
107
+ return dead;
108
+ }
109
+ /** Two globs of different docs matching one file at equal specificity, one example per glob pair. */
110
+ export function overlaps(tree, repoFiles = listRepoFiles(tree), docs = scanOwningDocs(tree)) {
111
+ const owners = [];
112
+ for (const entry of docs) {
113
+ if (entry.status === 'deprecated')
114
+ continue;
115
+ for (const glob of entry.owns) {
116
+ const s = specificity(glob);
117
+ owners.push({ doc: entry.doc, glob, key: `${s.literal}/${s.wildcard}`, re: globToRegExp(glob) });
118
+ }
119
+ }
120
+ const found = [];
121
+ const seen = new Set();
122
+ for (const file of repoFiles) {
123
+ const here = owners.filter((owner) => owner.re.test(file));
124
+ for (let i = 0; i < here.length; i++) {
125
+ for (let j = i + 1; j < here.length; j++) {
126
+ const a = here[i];
127
+ const b = here[j];
128
+ if (a.doc === b.doc || a.key !== b.key)
129
+ continue;
130
+ const pair = `${a.doc}·${a.glob}·${b.doc}·${b.glob}`;
131
+ if (seen.has(pair))
132
+ continue;
133
+ seen.add(pair);
134
+ found.push({ a: { doc: a.doc, glob: a.glob }, b: { doc: b.doc, glob: b.glob }, example: file });
135
+ }
136
+ }
137
+ }
138
+ return found;
139
+ }
140
+ /** Every `verifies:` edge declared under the docs root, with whether its target is absent. */
141
+ export function scanDocEdges(tree, docsRoot = resolveDocsRoot(tree)) {
142
+ const edges = [];
143
+ for (const path of markdownUnder(tree, docsRoot)) {
144
+ if (path.includes('.bkp.'))
145
+ continue;
146
+ const block = frontmatter(tree.read(path) ?? '');
147
+ if (!block)
148
+ continue;
149
+ for (const to of asList(field(block, 'verifies'))) {
150
+ const target = to.trim();
151
+ if (!target)
152
+ continue;
153
+ edges.push({ from: path, kind: 'verifies', to: target, missing: tree.read(target) === null && tree.list(`${target.replace(/\/+$/, '')}/`).length === 0 });
154
+ }
155
+ }
156
+ return edges;
157
+ }
158
+ export function ownershipRule(ctx, docsRoot) {
159
+ const tree = ctx.head;
160
+ const files = listRepoFiles(tree);
161
+ const docs = scanOwningDocs(tree, docsRoot);
162
+ const waivers = ctx.waivers ?? [];
163
+ const findings = [];
164
+ for (const dead of deadGlobs(tree, files, docs)) {
165
+ if (!waiverCovers(waivers, 'dead-glob', dead.glob, ctx.today))
166
+ findings.push({ id: 'ownership.dead_glob', path: dead.doc, message: `${dead.glob} → ${dead.doc} matches no file` });
167
+ }
168
+ for (const overlap of overlaps(tree, files, docs)) {
169
+ if (!waiverCovers(waivers, 'ownership-overlap', overlap.example, ctx.today)) {
170
+ findings.push({ id: 'ownership.overlap', path: overlap.example, message: `${overlap.example} is owned at equal specificity by ${overlap.a.glob} → ${overlap.a.doc} and ${overlap.b.glob} → ${overlap.b.doc}` });
171
+ }
172
+ }
173
+ for (const edge of scanDocEdges(tree, docsRoot).filter((candidate) => candidate.missing)) {
174
+ if (!waiverCovers(waivers, 'dangling-edge', edge.to, ctx.today))
175
+ findings.push({ id: 'ownership.dangling_edge', path: edge.from, message: `${edge.from} ${edge.kind}: ${edge.to}, which does not exist` });
176
+ }
177
+ return findings;
178
+ }
@@ -0,0 +1,113 @@
1
+ // packages/harness/src/rules/packageTable.ts - The `package-table` rule: a dependency a change set adds that the
2
+ // approved-packages table rejects, named with that row's replacement. Ported from etna harness/code/hooks/lib/approved.mjs
3
+ // and compliance.mjs packageGate. docs/architecture/systems/harness-records.md speaks for it (Status check and enforcement).
4
+ import { basenamePosix } from '../lib/paths.js';
5
+ export const APPROVED_TABLE_PATH = '.claude/hooks/approved-packages.md';
6
+ export const PACKAGE_RULE_SOURCES = [APPROVED_TABLE_PATH, '.claude/rules/general.md', '.claude/rules/testing.md'];
7
+ function approvedPart(line, banColumn) {
8
+ if (line.trimStart().startsWith('|')) {
9
+ if (banColumn < 0)
10
+ return line;
11
+ return line
12
+ .split('|')
13
+ .filter((_, index) => index !== banColumn)
14
+ .join('|');
15
+ }
16
+ const ban = line.search(/\*\*never\*\*|❌/i);
17
+ return ban === -1 ? line : line.slice(0, ban);
18
+ }
19
+ function banColumnOf(line, current) {
20
+ if (!line.trimStart().startsWith('|'))
21
+ return -1;
22
+ const header = line.split('|').findIndex((cell) => /don'?t use|never use/i.test(cell));
23
+ return header === -1 ? current : header;
24
+ }
25
+ /** Package names the table, general.md and testing.md approve; empty when no rules file is present. */
26
+ export function approvedPackages(tree) {
27
+ const names = new Set();
28
+ for (const source of PACKAGE_RULE_SOURCES) {
29
+ let banColumn = -1;
30
+ for (const line of (tree.read(source) ?? '').split('\n')) {
31
+ banColumn = banColumnOf(line, banColumn);
32
+ for (const match of approvedPart(line, banColumn).matchAll(/`(@?[a-z0-9][\w./-]*)`/g))
33
+ names.add(match[1]);
34
+ }
35
+ }
36
+ return names;
37
+ }
38
+ /** Every rejected package with the row it points at: the table's Don't-use cells, then testing.md's framework bans. */
39
+ export function rejectedPackages(tree) {
40
+ const rejected = new Map();
41
+ for (const line of (tree.read(APPROVED_TABLE_PATH) ?? '').split('\n')) {
42
+ const cells = line.split('|');
43
+ if (cells.length < 6)
44
+ continue;
45
+ const pkg = /`([^`]+)`/.exec(cells[1] ?? '');
46
+ if (!pkg || pkg[1] === 'Package' || pkg[1] === '---')
47
+ continue;
48
+ const version = /`?([^`|]+)`?/.exec((cells[2] ?? '').trim())?.[1] ?? '';
49
+ for (const match of (cells[4] ?? '').matchAll(/`(@?[a-z0-9][\w./-]*)`/g)) {
50
+ if (match[1] === pkg[1])
51
+ continue;
52
+ rejected.set(match[1], { use: pkg[1], version: version.trim(), purpose: (cells[3] ?? '').trim() });
53
+ }
54
+ }
55
+ let banColumn = -1;
56
+ for (const line of (tree.read('.claude/rules/testing.md') ?? '').split('\n')) {
57
+ banColumn = banColumnOf(line, banColumn);
58
+ if (banColumn < 0 || !line.trimStart().startsWith('|'))
59
+ continue;
60
+ for (const match of (line.split('|')[banColumn] ?? '').matchAll(/`(@?[a-z0-9][\w./-]*)`/g)) {
61
+ if (!rejected.has(match[1]))
62
+ rejected.set(match[1], { rule: 'testing.md § Frameworks by stack' });
63
+ }
64
+ }
65
+ return rejected;
66
+ }
67
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
68
+ function dependencies(text) {
69
+ const out = new Map();
70
+ if (!text)
71
+ return out;
72
+ try {
73
+ const manifest = JSON.parse(text);
74
+ for (const fieldName of DEPENDENCY_FIELDS) {
75
+ const block = manifest[fieldName];
76
+ if (block && typeof block === 'object')
77
+ for (const name of Object.keys(block))
78
+ out.set(name, (out.get(name) ?? new Set()).add(fieldName));
79
+ }
80
+ }
81
+ catch {
82
+ /* an unparsable package.json adds nothing the table can judge */
83
+ }
84
+ return out;
85
+ }
86
+ export function packageTableRule(ctx) {
87
+ const manifests = (ctx.changes ?? []).filter((change) => change.status !== 'D' && basenamePosix(change.path) === 'package.json');
88
+ if (manifests.length === 0)
89
+ return [];
90
+ const approved = approvedPackages(ctx.head);
91
+ if (approved.size === 0)
92
+ return [];
93
+ const rejected = rejectedPackages(ctx.head);
94
+ const approvedScopes = new Set([...approved].filter((name) => name.startsWith('@')).map((name) => name.split('/')[0]));
95
+ approvedScopes.add('@types');
96
+ const findings = [];
97
+ for (const change of manifests) {
98
+ const before = dependencies(ctx.base?.read(change.from ?? change.path) ?? null);
99
+ for (const [pkg, fields] of dependencies(ctx.head.read(change.path))) {
100
+ if (before.has(pkg) || approved.has(pkg))
101
+ continue;
102
+ const isDev = fields.size === 1 && fields.has('devDependencies');
103
+ if (isDev && pkg.startsWith('@') && approvedScopes.has(pkg.split('/')[0]))
104
+ continue;
105
+ const no = rejected.get(pkg);
106
+ if (!no)
107
+ continue;
108
+ const instead = no.use ? `use \`${no.use}\`${no.version ? ` ${no.version}` : ''}${no.purpose ? ` — ${no.purpose}` : ''}` : `the prescribed choice is in ${no.rule}`;
109
+ findings.push({ id: 'package-table.rejected', path: change.path, message: `${change.path} adds \`${pkg}\`, which ${APPROVED_TABLE_PATH} rejects: ${instead}` });
110
+ }
111
+ }
112
+ return findings;
113
+ }
@@ -0,0 +1,96 @@
1
+ // packages/harness/src/rules/plans.ts - The one reader of the plans folder and of a plan's two ledgers, over a
2
+ // RuleTree. Ported from etna harness/code/hooks/lib/plans.mjs and lib/ledger.mjs.
3
+ // docs/architecture/systems/harness-records.md speaks for it (Status check and enforcement).
4
+ import { backtickTokens, markdownIn } from './context.js';
5
+ import { globToRegExp } from './ownership.js';
6
+ /** A plan's scope: the path-shaped backticked tokens of its `## Proposed Changes` section. */
7
+ export function planScopePaths(text) {
8
+ const section = /^##\s+Proposed Changes\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/m.exec(text);
9
+ if (!section)
10
+ return [];
11
+ return backtickTokens(section[1]).filter((token) => token.includes('/'));
12
+ }
13
+ /** True when `rel` falls inside a scope entry: a glob, a folder (its subtree), or an exact file. */
14
+ export function inPlanScope(rel, scope) {
15
+ return scope.some((entry) => {
16
+ if (entry.includes('*'))
17
+ return globToRegExp(entry).test(rel);
18
+ if (entry.endsWith('/'))
19
+ return rel === entry.slice(0, -1) || rel.startsWith(entry);
20
+ return rel === entry || rel.startsWith(`${entry}/`);
21
+ });
22
+ }
23
+ export const plansFolder = (docsRoot) => `${docsRoot}/history/plans`;
24
+ export function listPlans(tree, docsRoot) {
25
+ const plans = [];
26
+ for (const path of markdownIn(tree, plansFolder(docsRoot))) {
27
+ const text = tree.read(path) ?? '';
28
+ const status = /^\*\*Status:\*\*\s*(.+)$/m.exec(text)?.[1]?.trim() ?? null;
29
+ if (!status)
30
+ continue;
31
+ plans.push({
32
+ file: path.slice(path.lastIndexOf('/') + 1),
33
+ path,
34
+ status,
35
+ gates: /^\*\*Gates:\*\*\s*(.+)$/m.exec(text)?.[1]?.trim() ?? null,
36
+ scope: planScopePaths(text),
37
+ hasLedger: /^##\s+Test Obligations\b/m.test(text),
38
+ hasDocLedger: /^##\s+Doc Obligations\b/m.test(text),
39
+ });
40
+ }
41
+ return plans;
42
+ }
43
+ /** Open plans carrying a `Gates:` line; plans without one are legacy and pass every plan gate. */
44
+ export const openGatedPlans = (tree, docsRoot) => listPlans(tree, docsRoot).filter((plan) => !/Complete|Abandoned/.test(plan.status) && plan.gates);
45
+ const OUTCOME = /^(satisfied|owes nothing|blocked)\b/i;
46
+ const DOC_OUTCOME = /^(updated|owes nothing|blocked)\b/i;
47
+ /** One table row's cells, split on unescaped pipes. */
48
+ const splitRow = (line) => line.split(/(?<!\\)\|/).map((cell) => cell.replace(/\\\|/g, '|').trim());
49
+ function sectionTable(planText, heading) {
50
+ const start = (planText || '').search(new RegExp(`^##\\s+${heading}\\b`, 'm'));
51
+ if (start === -1)
52
+ return null;
53
+ const body = [];
54
+ for (const line of planText.slice(start).split('\n').slice(1)) {
55
+ if (/^##\s/.test(line))
56
+ break;
57
+ body.push(line);
58
+ }
59
+ const rows = body.filter((line) => /^\s*\|/.test(line));
60
+ if (rows.length < 3)
61
+ return null;
62
+ const header = splitRow(rows[0]).map((cell) => cell.toLowerCase());
63
+ const col = header.indexOf('outcome');
64
+ return { rows: rows.slice(2).map(splitRow), col: col === -1 ? null : col };
65
+ }
66
+ /** Row ids of every Test Obligations row whose Outcome is empty or off-vocabulary. */
67
+ export function undischargedRows(planText) {
68
+ const table = sectionTable(planText, 'Test Obligations');
69
+ if (!table || table.col === null)
70
+ return [];
71
+ const out = [];
72
+ for (const cells of table.rows) {
73
+ if (!/^\d+[a-z]?$/i.test(cells[1] ?? ''))
74
+ continue;
75
+ const outcome = (cells[table.col] ?? '').replace(/\*/g, '').trim();
76
+ if (!OUTCOME.test(outcome))
77
+ out.push(cells[1]);
78
+ }
79
+ return out;
80
+ }
81
+ /** Doc names of every Doc Obligations row whose Outcome is empty or off-vocabulary. */
82
+ export function undischargedDocRows(planText) {
83
+ const table = sectionTable(planText, 'Doc Obligations');
84
+ if (!table || table.col === null)
85
+ return [];
86
+ const out = [];
87
+ for (const cells of table.rows) {
88
+ const doc = (cells[1] ?? '').replace(/`/g, '');
89
+ if (!doc || /^-+$/.test(doc))
90
+ continue;
91
+ const outcome = (cells[table.col] ?? '').replace(/\*/g, '').trim();
92
+ if (!DOC_OUTCOME.test(outcome))
93
+ out.push(doc);
94
+ }
95
+ return out;
96
+ }