@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,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
+ }
@@ -0,0 +1,31 @@
1
+ /** Anchored at statement boundaries, so quoted text never trips a screen and a command on its own line never slips one. */
2
+ const AT_STATEMENT = String.raw `(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:npx\s+(?:--yes\s+)?)?`;
3
+ const statement = (body) => new RegExp(AT_STATEMENT + body);
4
+ const BRANCH_CREATE = statement(String.raw `git\s+(checkout\s+-b|switch\s+-c|branch\s+(?!-)\S+|worktree\s+add)`);
5
+ const LIVE_COMMAND = statement(String.raw `wrangler(?:@\S+)?\b.*(\bd1\s+execute\b.*--remote|\bsecret\s+(put|bulk)\b|\bdeploy\b)`);
6
+ const missing = (id, what) => [{ id: `${id}.context_missing`, message: `${id} needs ${what}, which the caller did not pass` }];
7
+ export function branchWorktreeRule(ctx) {
8
+ if (!ctx.action)
9
+ return missing('branch-worktree', 'the Bash command');
10
+ return BRANCH_CREATE.test(ctx.action.command) ? [{ id: 'branch-worktree.create', message: 'the command creates a branch or worktree; policy allows that only when the person asked for one' }] : [];
11
+ }
12
+ export function liveCommandRule(ctx) {
13
+ if (!ctx.action)
14
+ return missing('live-command', 'the Bash command');
15
+ const command = ctx.action.command;
16
+ return LIVE_COMMAND.test(command) && !/--dry-run\b|--local\b/.test(command) ? [{ id: 'live-command.live', message: 'the command deploys, writes a remote database or sets a secret' }] : [];
17
+ }
18
+ /** A rule over one session fact: missing when the fact is absent, a finding when `judge` says so. */
19
+ function factRule(id, key, judge) {
20
+ return (ctx) => {
21
+ const value = ctx.session?.[key];
22
+ if (value === undefined)
23
+ return missing(id, `the session fact ${String(key)}`);
24
+ return judge(value);
25
+ };
26
+ }
27
+ export const commitClaimRule = factRule('commit-claim', 'commitClaimHolder', (holder) => (holder ? [{ id: 'commit-claim.held', message: `another session (${holder}) holds the commit claim` }] : []));
28
+ export const foreignClaimRule = factRule('foreign-claim', 'foreignTouches', (touches) => (touches ?? []).map((touch) => ({ id: 'foreign-claim.touched', path: touch.path, message: `${touch.path} was touched by another live session (${touch.holder})` })));
29
+ export const openDenialsRule = factRule('open-denials', 'unresolvedDenials', (denials) => (denials ?? []).length ? [{ id: 'open-denials.unresolved', message: `${denials.length} gate denial(s) have no resolved line: ${denials.join('; ')}` }] : []);
30
+ export const exitStopRule = factRule('exit-stop', 'exitBlock', (block) => (block ? [{ id: 'exit-stop.blocked', message: block }] : []));
31
+ export const queueRule = factRule('queue', 'openObligations', (count) => ((count ?? 0) > 0 ? [{ id: 'queue.open_obligations', message: `${count} open obligation(s) in the session's queue` }] : []));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.23.12",
3
+ "version": "0.23.13",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -21,7 +21,6 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^0.11.0",
24
- "@nurix/etna": "0.20.25",
25
24
  "ajv": "^8.17.1",
26
25
  "ajv-formats": "^3.0.1",
27
26
  "commander": "^14.0.0"
@@ -35,6 +34,7 @@
35
34
  "@types/node": "^22.19.20",
36
35
  "typescript": "^5.7.2",
37
36
  "vitest": "^4.1.11",
37
+ "@nurix/harness": "0.0.0",
38
38
  "@nurix/nustack.studio": "0.0.0",
39
39
  "@nurix/nustack.ui": "0.0.0",
40
40
  "@nurix/contracts": "2.0.1"
@@ -43,7 +43,7 @@
43
43
  "access": "public"
44
44
  },
45
45
  "scripts": {
46
- "build": "tsc && node scripts/copy_schema.mjs",
46
+ "build": "tsc && node scripts/copy_schema.mjs && node scripts/copy_harness.mjs",
47
47
  "dev": "tsc --watch",
48
48
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tests.json",
49
49
  "test": "vitest run",