@nurix/nustack 0.23.12 → 0.23.13

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 (63) hide show
  1. package/dist/commands/check.js +73 -0
  2. package/dist/commands/deploy.js +2 -1
  3. package/dist/commands/doctor.js +2 -1
  4. package/dist/commands/harness.js +127 -48
  5. package/dist/commands/init.js +103 -3
  6. package/dist/commands/login.js +9 -2
  7. package/dist/commands/mcp.js +7 -0
  8. package/dist/commands/project-sync.js +2 -1
  9. package/dist/commands/records.js +10 -0
  10. package/dist/commands/review.js +3 -2
  11. package/dist/commands/sessions.js +4 -1
  12. package/dist/commands/sync.js +2 -1
  13. package/dist/index.js +4 -0
  14. package/dist/lib/agent/claudeHooks.js +4 -1
  15. package/dist/lib/auth_grant.js +15 -3
  16. package/dist/lib/auth_recovery.js +10 -3
  17. package/dist/lib/etna/manager.js +24 -6
  18. package/dist/lib/etna/profile.js +6 -0
  19. package/dist/lib/etna/reconcile.js +6 -0
  20. package/dist/lib/harness/codes.js +24 -0
  21. package/dist/lib/harness/hookCheck.js +325 -0
  22. package/dist/lib/harness/hookEntry.js +27 -0
  23. package/dist/lib/harness/legacyMount.js +231 -0
  24. package/dist/lib/harness/mount.js +199 -131
  25. package/dist/lib/harness/storeWaivers.js +43 -0
  26. package/dist/lib/harness/vendor.js +29 -0
  27. package/dist/lib/harness/versionsClient.js +54 -0
  28. package/dist/lib/local_store/database.js +18 -0
  29. package/dist/lib/nustack_client.js +35 -2
  30. package/dist/lib/onboarding/attach_existing_git.js +14 -2
  31. package/dist/lib/onboarding/binding.js +46 -2
  32. package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
  33. package/dist/lib/onboarding/manifest_writer.js +19 -3
  34. package/dist/lib/onboarding/session_hook.js +76 -0
  35. package/dist/lib/origin_guard.js +43 -0
  36. package/dist/lib/project_contract.js +104 -0
  37. package/dist/lib/records/door.js +26 -19
  38. package/dist/lib/records/drain.js +10 -4
  39. package/dist/lib/records/refusalLog.js +30 -1
  40. package/dist/lib/session-engine/supervisor.js +6 -18
  41. package/dist/lib/sessions/client.js +0 -12
  42. package/dist/program.js +14 -3
  43. package/dist/vendor/harness/assemble/runtimes.js +32 -0
  44. package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
  45. package/dist/vendor/harness/digest/index.js +38 -0
  46. package/dist/vendor/harness/lib/findings.js +32 -0
  47. package/dist/vendor/harness/lib/frontmatter.js +79 -0
  48. package/dist/vendor/harness/lib/paths.js +31 -0
  49. package/dist/vendor/harness/mount/cache.js +65 -0
  50. package/dist/vendor/harness/mount/exclude.js +77 -0
  51. package/dist/vendor/harness/mount/files.js +38 -0
  52. package/dist/vendor/harness/mount/index.js +352 -0
  53. package/dist/vendor/harness/rules/changeRules.js +162 -0
  54. package/dist/vendor/harness/rules/context.js +38 -0
  55. package/dist/vendor/harness/rules/docsRoot.js +34 -0
  56. package/dist/vendor/harness/rules/index.js +109 -0
  57. package/dist/vendor/harness/rules/node.js +144 -0
  58. package/dist/vendor/harness/rules/outputGate.js +28 -0
  59. package/dist/vendor/harness/rules/ownership.js +178 -0
  60. package/dist/vendor/harness/rules/packageTable.js +113 -0
  61. package/dist/vendor/harness/rules/plans.js +96 -0
  62. package/dist/vendor/harness/rules/sessionWalls.js +31 -0
  63. package/package.json +3 -3
@@ -0,0 +1,162 @@
1
+ // packages/harness/src/rules/changeRules.ts - The change rules over a tree and a change set: plan-gate, ledger,
2
+ // frozen-record, changelog, changelog-entry and protected-file. Ported from etna harness/code/hooks/compliance.mjs,
3
+ // check-completion-integrity.mjs, check-changelog-presence.mjs, lib/frozen.mjs and lib/exempt.mjs.
4
+ // docs/architecture/systems/harness-records.md speaks for it (Status check and enforcement).
5
+ import { field, frontmatter } from '../lib/frontmatter.js';
6
+ import { basenamePosix, joinPosix } from '../lib/paths.js';
7
+ import { backtickTokens, markdownIn, SOURCE_EXT, waiverCovers } from './context.js';
8
+ import { inPlanScope, openGatedPlans, plansFolder, undischargedDocRows, undischargedRows } from './plans.js';
9
+ const changedPaths = (change) => (change.status === 'R' && change.from ? [change.path, change.from] : [change.path]);
10
+ export function planGateRule(ctx, root) {
11
+ const findings = [];
12
+ const seen = new Set();
13
+ const add = (finding) => {
14
+ const key = `${finding.id}|${finding.path}`;
15
+ if (!seen.has(key))
16
+ findings.push(finding), seen.add(key);
17
+ };
18
+ const sources = (ctx.changes ?? []).map((change) => change.path).filter((rel) => !(rel.startsWith(`${root}/`) || rel.startsWith('.claude/') || rel.endsWith('.md')) && SOURCE_EXT.test(rel));
19
+ if (sources.length === 0)
20
+ return findings;
21
+ const plans = openGatedPlans(ctx.head, root);
22
+ for (const rel of sources) {
23
+ for (const plan of plans) {
24
+ if (plan.scope.length && !inPlanScope(rel, plan.scope))
25
+ continue;
26
+ const boundary = /Boundary:\s*(\w+)/i.exec(plan.gates ?? '');
27
+ if (boundary && !/^passed$/i.test(boundary[1])) {
28
+ add({ id: /^blocked$/i.test(boundary[1]) ? 'plan-gate.boundary_blocked' : 'plan-gate.boundary_pending', path: plan.path, message: `${rel} changes inside ${plan.file}, whose Gates line reads 'Boundary: ${boundary[1]}'` });
29
+ }
30
+ if (!plan.hasLedger)
31
+ add({ id: 'plan-gate.no_test_ledger', path: plan.path, message: `${rel} changes inside ${plan.file}, which has no ## Test Obligations section` });
32
+ if (!plan.hasDocLedger)
33
+ add({ id: 'plan-gate.no_doc_ledger', path: plan.path, message: `${rel} changes inside ${plan.file}, which has no ## Doc Obligations section` });
34
+ }
35
+ }
36
+ return findings;
37
+ }
38
+ /** Completion integrity for every gated plan at head: discharged ledgers, active Spec docs, their checklists, an Abandoned plan's backlog. */
39
+ export function ledgerRule(ctx, root) {
40
+ const findings = [];
41
+ const folder = plansFolder(root);
42
+ for (const path of markdownIn(ctx.head, folder)) {
43
+ const file = basenamePosix(path);
44
+ const text = ctx.head.read(path) ?? '';
45
+ if (!/^\*\*Gates:\*\*/m.test(text))
46
+ continue;
47
+ const status = /^\*\*Status:\*\*\s*(\S+)/m.exec(text)?.[1];
48
+ if (status === 'Complete') {
49
+ const docRows = undischargedDocRows(text);
50
+ if (docRows.length)
51
+ findings.push({ id: 'ledger.doc_rows_undischarged', path, message: `${file} is Complete with ${docRows.length} Doc Obligations row(s) missing a discharge outcome (${docRows.join(', ')})` });
52
+ const rows = undischargedRows(text);
53
+ if (rows.length)
54
+ findings.push({ id: 'ledger.test_rows_undischarged', path, message: `${file} is Complete with ${rows.length} Test Obligations row(s) missing a discharge outcome (#${rows.join(', #')})` });
55
+ for (const match of text.matchAll(/^\*\*Spec:\*\*\s*\[[^\]]*\]\(([^)]+)\)/gm)) {
56
+ const spec = match[1];
57
+ const specPath = joinPosix(folder, spec);
58
+ const specText = specPath === null ? '' : (ctx.head.read(specPath) ?? '');
59
+ const specStatus = String(field(frontmatter(specText), 'status') ?? '');
60
+ if (specText && specStatus !== 'active')
61
+ findings.push({ id: 'ledger.spec_not_active', path, message: `${file} is Complete but its Spec ${spec} is status: ${specStatus || 'unknown'} (expected active)` });
62
+ const feature = basenamePosix(spec).replace(/\.md$/, '');
63
+ if (ctx.head.read(`${root}/tests/${feature}.md`) === null)
64
+ findings.push({ id: 'ledger.checklist_missing', path, message: `${file} is Complete but ${root}/tests/${feature}.md (the behavioral checklist) is missing` });
65
+ }
66
+ }
67
+ if (status === 'Abandoned' && !/backlog/i.test(text))
68
+ findings.push({ id: 'ledger.abandoned_without_backlog', path, message: `${file} is Abandoned without referencing the backlog` });
69
+ }
70
+ return findings;
71
+ }
72
+ /** Why a path is frozen in `tree` (a committed changelog entry, a Complete or Abandoned plan, an executed runbook), or null. */
73
+ export function frozenReason(tree, root, rel) {
74
+ if (rel.includes('.bkp.') || !rel.endsWith('.md'))
75
+ return null;
76
+ if (rel.startsWith(`${root}/history/changelog/`))
77
+ return tree.read(rel) !== null ? 'committed changelog entry' : null;
78
+ if (rel.startsWith(`${root}/history/plans/`))
79
+ return /^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(tree.read(rel) ?? '') ? 'plan with Status: Complete or Abandoned' : null;
80
+ if (rel.startsWith(`${root}/history/migrations/`))
81
+ return /^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(tree.read(rel) ?? '') ? 'outcome-stamped migration runbook' : null;
82
+ return null;
83
+ }
84
+ /** A change to a record frozen at base. With no base tree nothing is committed, so nothing is frozen. */
85
+ export function frozenRecordRule(ctx, root) {
86
+ if (!ctx.base)
87
+ return [];
88
+ const findings = [];
89
+ for (const change of ctx.changes ?? []) {
90
+ for (const rel of changedPaths(change)) {
91
+ if (change.status === 'A' && rel === change.path)
92
+ continue;
93
+ const reason = frozenReason(ctx.base, root, rel);
94
+ if (reason)
95
+ findings.push({ id: 'frozen-record.modified', path: rel, message: `${rel} is frozen (${reason}); a correction is a new dated record that links back` });
96
+ }
97
+ }
98
+ return findings;
99
+ }
100
+ const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
101
+ const HARNESS_PREFIXES = ['.claude/', '.agents/', '.codex/'];
102
+ const HARNESS_FILES = ['AGENTS.md', 'CLAUDE.md', '.mcp.json', '.vscode/settings.json', '.prettierignore', '.prettierrc.json'];
103
+ /** The changelog rule's closed exemption list, judged per file. */
104
+ export function changelogExemptFile(root, path) {
105
+ return (LOCKFILES.test(basenamePosix(path)) ||
106
+ HARNESS_PREFIXES.some((prefix) => path.startsWith(prefix)) ||
107
+ HARNESS_FILES.includes(path) ||
108
+ path.startsWith(`${root}/history/changelog/`) ||
109
+ path.includes('.bkp.') ||
110
+ path === `${root}/history/CHANGELOG.md`);
111
+ }
112
+ /** A non-exempt change set carries a changelog entry: bot authors, pure renames and exempt-only sets aside. */
113
+ export function changelogRule(ctx, root) {
114
+ const changes = ctx.changes ?? [];
115
+ if (changes.length === 0)
116
+ return [];
117
+ const isBot = /\[bot\]|actions@github\.com/i.test(ctx.commit?.author ?? '');
118
+ const allRenames = changes.every((change) => change.status === 'R');
119
+ const allExempt = changes.every((change) => changelogExemptFile(root, change.path));
120
+ const hasEntry = changes.some((change) => change.status !== 'D' && change.path.startsWith(`${root}/history/changelog/`) && change.path.endsWith('.md'));
121
+ if (isBot || allRenames || allExempt || hasEntry)
122
+ return [];
123
+ return [{ id: 'changelog.missing_entry', message: `the change set is not exempt and carries no ${root}/history/changelog/ entry` }];
124
+ }
125
+ /** A plan flipped Complete in the change set needs a changelog entry at head naming its slug. */
126
+ export function changelogEntryRule(ctx, root) {
127
+ const findings = [];
128
+ const complete = /^\*\*Status:\*\*\s*Complete\b/m;
129
+ const folder = `${root}/history/changelog`;
130
+ let entries = null;
131
+ for (const change of ctx.changes ?? []) {
132
+ if (change.status === 'D' || !change.path.startsWith(`${root}/history/plans/`) || !change.path.endsWith('.md'))
133
+ continue;
134
+ if (!complete.test(ctx.head.read(change.path) ?? ''))
135
+ continue;
136
+ if (ctx.base && complete.test(ctx.base.read(change.from ?? change.path) ?? ''))
137
+ continue;
138
+ const slug = basenamePosix(change.path).replace(/\.md$/, '');
139
+ entries ??= ctx.head
140
+ .list(`${folder}/`)
141
+ .filter((path) => path.endsWith('.md'))
142
+ .map((path) => ctx.head.read(path) ?? '');
143
+ if (!entries.some((text) => text.includes(slug)))
144
+ findings.push({ id: 'changelog-entry.missing', path: change.path, message: `${change.path} reached Status: Complete with no ${folder}/ entry naming ${slug}` });
145
+ }
146
+ return findings;
147
+ }
148
+ export const protectedListPath = (root) => `${root}/operations/protected-files.md`;
149
+ /** A changed path (or a rename's source) on the protected list, read from base so a change cannot drop a path and edit it at once. */
150
+ export function protectedFileRule(ctx, root) {
151
+ const listPath = protectedListPath(root);
152
+ const listed = new Set([listPath, ...backtickTokens((ctx.base ?? ctx.head).read(listPath) ?? '')]);
153
+ const findings = [];
154
+ for (const change of ctx.changes ?? []) {
155
+ for (const rel of changedPaths(change)) {
156
+ if (!listed.has(rel) || waiverCovers(ctx.waivers ?? [], 'protected-file', rel, ctx.today))
157
+ continue;
158
+ findings.push({ id: 'protected-file.modified', path: rel, message: `${rel} is on the protected list (${listPath})` });
159
+ }
160
+ }
161
+ return findings;
162
+ }
@@ -0,0 +1,38 @@
1
+ // packages/harness/src/rules/context.ts - The rule library's shared shapes: the tree a rule reads, the change set,
2
+ // the context, the result, waivers and the session facts a hook supplies. Imports no node: module, so the Worker
3
+ // runs every rule. docs/architecture/systems/harness-records.md speaks for it (Status check and enforcement).
4
+ import { resolveDocsRoot } from './docsRoot.js';
5
+ export const docsRootOf = (ctx) => ctx.docsRoot?.trim().replace(/\/+$/, '') || resolveDocsRoot(ctx.head);
6
+ export const waiverExpiry = (row) => /expires=(\d{4}-\d{2}-\d{2})/.exec(row.body)?.[1] ?? null;
7
+ /** Unexpired waived rows for a finding kind. */
8
+ export function unexpiredWaivers(rows, kind, today) {
9
+ return rows.filter((row) => {
10
+ if (row.kind !== 'waived')
11
+ return false;
12
+ if (kind && !row.body.includes(kind))
13
+ return false;
14
+ const expires = waiverExpiry(row);
15
+ return !!expires && expires >= today;
16
+ });
17
+ }
18
+ /** True when an unexpired waiver of `kind` names the subject in backticks; a bare substring never covers. */
19
+ export function waiverCovers(rows, kind, subject, today) {
20
+ return unexpiredWaivers(rows, kind, today).some((row) => row.body.includes(`\`${subject}\``));
21
+ }
22
+ /** Space-free backticked tokens in markdown, `./` stripped: the grammar of path-carrying prose. */
23
+ export function backtickTokens(text) {
24
+ const out = [];
25
+ for (const match of String(text || '').matchAll(/`([^`\n]+)`/g)) {
26
+ const candidate = match[1].trim().replace(/^\.\//, '');
27
+ if (candidate && !candidate.includes(' '))
28
+ out.push(candidate);
29
+ }
30
+ return out;
31
+ }
32
+ /** One definition of a source file: the plan gate and the drift detector agree on it. */
33
+ export const SOURCE_EXT = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|css|scss|vue|svelte)$/;
34
+ /** The markdown files directly inside a folder of the tree. */
35
+ export const markdownIn = (tree, folder) => tree
36
+ .list(`${folder}/`)
37
+ .filter((path) => path.endsWith('.md') && !path.slice(folder.length + 1).includes('/'))
38
+ .sort();
@@ -0,0 +1,34 @@
1
+ // packages/harness/src/rules/docsRoot.ts - The repository's docs root: `.nustack/workspace.json` docsRoot, else
2
+ // `.claude/harness.json` docsRoot, else `docs`. One function for the rules and the mount, applying the declaration
3
+ // rule the `project-contract` conformance seam binds every reader to. docs/architecture/systems/harness-records.md
4
+ // speaks for it.
5
+ export const DEFAULT_DOCS_ROOT = 'docs';
6
+ export const DOCS_ROOT_SOURCES = ['.nustack/workspace.json', '.claude/harness.json'];
7
+ /** A `docsRoot` value as a declaration: trimmed, trailing slashes stripped; empty, absolute or `..`-segmented is none. */
8
+ export function docsRootDeclaration(value) {
9
+ if (typeof value !== 'string')
10
+ return null;
11
+ const trimmed = value.trim().replace(/[\\/]+$/, '');
12
+ if (trimmed === '' || /^([A-Za-z]:)?[\\/]/.test(trimmed))
13
+ return null;
14
+ return trimmed.split(/[\\/]/).includes('..') ? null : trimmed;
15
+ }
16
+ function declaredRoot(text) {
17
+ if (!text)
18
+ return null;
19
+ try {
20
+ const parsed = JSON.parse(text);
21
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? docsRootDeclaration(parsed.docsRoot) : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ export function resolveDocsRoot(tree) {
28
+ for (const source of DOCS_ROOT_SOURCES) {
29
+ const root = declaredRoot(tree.read(source));
30
+ if (root)
31
+ return root;
32
+ }
33
+ return DEFAULT_DOCS_ROOT;
34
+ }
@@ -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
+ }