@nurix/nustack 0.23.15 → 0.23.16

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.
@@ -19,7 +19,14 @@ export const DEADLINE_GATE = "hook.deadline";
19
19
  const EVENT_NAMESPACE = "3f0c9a52-6d1e-4b7a-9c2f-8e5d4a1b7c60";
20
20
  const RECORDS_STORE_VERSION = 9;
21
21
  const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
22
- export const COMMIT_RULES = new Set(["changelog", "commit-gate", "changelog-presence", "harness-checks"]);
22
+ export const COMMIT_RULES = new Set([
23
+ "changelog",
24
+ "commit-gate",
25
+ "changelog-presence",
26
+ "harness-checks",
27
+ "bookkeeping",
28
+ "bookkeeping-clean",
29
+ ]);
23
30
  class CannotRun extends Error {
24
31
  }
25
32
  export function uuidV5(name, namespace = EVENT_NAMESPACE) {
@@ -29,6 +29,8 @@ export const REFUSAL_GATE_IDS = [
29
29
  "file-gate",
30
30
  "bash-write-screen",
31
31
  "harness-checks",
32
+ "bookkeeping",
33
+ "bookkeeping-clean",
32
34
  "hook.rule_unavailable",
33
35
  "hook.deadline",
34
36
  ];
@@ -0,0 +1,44 @@
1
+ // packages/harness/src/rules/bookkeeping.ts - The `bookkeeping` rule: a changed path owned by a document (narrower
2
+ // glob wins) whose owning document is not in the same change set. etna's commit wall, ported as a change rule so the
3
+ // status check can block owned code merged without its document. A caller's `RuleWaiver` scoped to `bookkeeping` and
4
+ // naming the owning document drops the finding. docs/architecture/systems/harness-records.md speaks for it (Status
5
+ // check and enforcement).
6
+ import { waiverCovers } from './context.js';
7
+ import { owningDoc, scanOwningDocs } from './ownership.js';
8
+ /** The waiver kind a bookkeeping finding is covered by: the caller names the owning document in backticks. */
9
+ export const BOOKKEEPING_WAIVER_KIND = 'bookkeeping';
10
+ const touchedPaths = (change) => (change.status === 'R' && change.from ? [change.path, change.from] : [change.path]);
11
+ export function bookkeepingRule(ctx, docsRoot) {
12
+ const changes = ctx.changes ?? [];
13
+ if (changes.length === 0)
14
+ return [];
15
+ const inChangeSet = new Set(changes.flatMap(touchedPaths));
16
+ const docs = scanOwningDocs(ctx.head, docsRoot);
17
+ if (docs.length === 0)
18
+ return [];
19
+ const missing = new Map();
20
+ for (const change of changes) {
21
+ for (const path of touchedPaths(change)) {
22
+ if (path.startsWith(`${docsRoot}/`) || path.endsWith('.md'))
23
+ continue;
24
+ const owner = owningDoc(ctx.head, path, docs);
25
+ if (!owner)
26
+ continue;
27
+ const candidates = [owner.doc.doc, ...owner.tied.map((entry) => entry.doc)];
28
+ if (candidates.some((doc) => inChangeSet.has(doc)))
29
+ continue;
30
+ const paths = missing.get(owner.doc.doc) ?? [];
31
+ if (!paths.includes(path))
32
+ paths.push(path);
33
+ missing.set(owner.doc.doc, paths);
34
+ }
35
+ }
36
+ const findings = [];
37
+ for (const [doc, paths] of missing) {
38
+ if (waiverCovers(ctx.waivers ?? [], BOOKKEEPING_WAIVER_KIND, doc, ctx.today))
39
+ continue;
40
+ for (const path of paths)
41
+ findings.push({ id: 'bookkeeping.owner_not_updated', path, message: `${path} is owned by ${doc}, which this change does not update` });
42
+ }
43
+ return findings;
44
+ }
@@ -35,11 +35,18 @@ export function planGateRule(ctx, root) {
35
35
  }
36
36
  return findings;
37
37
  }
38
- /** Completion integrity for every gated plan at head: discharged ledgers, active Spec docs, their checklists, an Abandoned plan's backlog. */
38
+ /**
39
+ * Completion integrity for the gated plans at head: discharged ledgers, active Spec docs, their checklists, an Abandoned
40
+ * plan's backlog. With a change set, only the plans it adds or modifies are judged, so a change is never blocked by an
41
+ * older plan's debt it did not touch; with none, every plan at head is.
42
+ */
39
43
  export function ledgerRule(ctx, root) {
40
44
  const findings = [];
41
45
  const folder = plansFolder(root);
46
+ const touched = ctx.changes ? new Set(ctx.changes.filter((change) => change.status !== 'D').map((change) => change.path)) : null;
42
47
  for (const path of markdownIn(ctx.head, folder)) {
48
+ if (touched && !touched.has(path))
49
+ continue;
43
50
  const file = basenamePosix(path);
44
51
  const text = ctx.head.read(path) ?? '';
45
52
  if (!/^\*\*Gates:\*\*/m.test(text))
@@ -7,6 +7,7 @@ import { changelogEntryRule, changelogRule, frozenRecordRule, ledgerRule, planGa
7
7
  import { outputGateRule } from './outputGate.js';
8
8
  import { ownershipRule } from './ownership.js';
9
9
  import { packageTableRule } from './packageTable.js';
10
+ import { bookkeepingRule } from './bookkeeping.js';
10
11
  import { branchWorktreeRule, commitClaimRule, exitStopRule, foreignClaimRule, liveCommandRule, openDenialsRule, queueRule } from './sessionWalls.js';
11
12
  export * from './context.js';
12
13
  export { resolveDocsRoot, docsRootDeclaration, DEFAULT_DOCS_ROOT, DOCS_ROOT_SOURCES } from './docsRoot.js';
@@ -15,7 +16,9 @@ export { listPlans, openGatedPlans, planScopePaths, inPlanScope, undischargedRow
15
16
  export { changelogExemptFile, frozenReason, protectedListPath } from './changeRules.js';
16
17
  export { approvedPackages, rejectedPackages, APPROVED_TABLE_PATH } from './packageTable.js';
17
18
  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 { BOOKKEEPING_WAIVER_KIND } from './bookkeeping.js';
20
+ export { PLAN_KEY_PATTERN, PLAN_KEY_SCAN_BYTES, formatPlanKey, readPlanKey, planKeyOf } from './planKey.js';
21
+ /** Every rule id, append-only: an id is never renamed or removed. Every id in RULE_ALIASES delegates to other rules. */
19
22
  export const RULE_IDS = [
20
23
  'ownership',
21
24
  'plan-gate',
@@ -39,6 +42,8 @@ export const RULE_IDS = [
39
42
  'file-gate',
40
43
  'bash-write-screen',
41
44
  'harness-checks',
45
+ 'bookkeeping',
46
+ 'bookkeeping-clean',
42
47
  ];
43
48
  export const isRuleId = (value) => RULE_IDS.includes(value);
44
49
  /** Seed ids naming a check another rule already makes: each runs its targets, whose findings keep their own ids. */
@@ -48,7 +53,8 @@ export const RULE_ALIASES = {
48
53
  'commit-gate': ['changelog'],
49
54
  'file-gate': ['plan-gate'],
50
55
  'bash-write-screen': ['frozen-record', 'protected-file'],
51
- 'harness-checks': ['ownership', 'ledger', 'changelog'],
56
+ 'harness-checks': ['ownership', 'ledger', 'changelog', 'bookkeeping'],
57
+ 'bookkeeping-clean': ['bookkeeping'],
52
58
  };
53
59
  const SESSION_RULES = ['branch-worktree', 'live-command', 'commit-claim', 'foreign-claim', 'open-denials', 'exit-stop', 'queue'];
54
60
  /** Change rules judge trees and a change set; session rules judge one session's action or facts, at the hook. */
@@ -63,6 +69,7 @@ const DIRECT_READS = {
63
69
  'changelog-entry': [...DOCS_SOURCES, '@docs/history/plans/**/*.md', '@docs/history/changelog/**/*.md'],
64
70
  'package-table': ['**/package.json', '.claude/hooks/approved-packages.md', '.claude/rules/general.md', '.claude/rules/testing.md'],
65
71
  'protected-file': [...DOCS_SOURCES, '@docs/operations/protected-files.md'],
72
+ bookkeeping: [...DOCS_SOURCES, '@docs/**/*.md'],
66
73
  };
67
74
  /** The path patterns each rule reads, docs-root-relative ones written `@docs/…`; a delegating id's is its targets' union. */
68
75
  export const RULE_READS = Object.fromEntries(RULE_IDS.map((id) => [id, [...new Set((RULE_ALIASES[id] ?? [id]).flatMap((target) => DIRECT_READS[target] ?? []))]]));
@@ -83,6 +90,7 @@ const RULES = {
83
90
  'open-denials': openDenialsRule,
84
91
  'exit-stop': exitStopRule,
85
92
  queue: queueRule,
93
+ bookkeeping: (ctx) => bookkeepingRule(ctx, docsRootOf(ctx)),
86
94
  };
87
95
  export class UnknownRuleError extends Error {
88
96
  constructor(id) {
@@ -0,0 +1,49 @@
1
+ // packages/harness/src/rules/planKey.ts - The plan key's one grammar: the header line `**Plan key:** <projectId>:<anchor>`
2
+ // a plan is written with at creation and never changes after, a rename included. Not a rule id; the status check, the
3
+ // CLI and the plan template writer read the same pattern. docs/architecture/systems/harness-records.md speaks for it
4
+ // (Record kinds, Status check and enforcement).
5
+ /**
6
+ * The key line. The anchor group restates ph07 Decision 4's plan anchor grammar (`^\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$`)
7
+ * rather than importing `@nurix/contracts`, which the bundled CLI may not carry.
8
+ */
9
+ export const PLAN_KEY_PATTERN = /^\*\*Plan key:\*\* ([^\s:]{1,128}):(\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*)$/m;
10
+ /** How far into a plan the key is looked for: a header-bytes read of this many bytes parses as a full read does. */
11
+ export const PLAN_KEY_SCAN_BYTES = 4096;
12
+ const KEY_LINE = /^\*\*Plan key:\*\*/;
13
+ export function formatPlanKey(projectId, anchor) {
14
+ return `**Plan key:** ${projectId}:${anchor}`;
15
+ }
16
+ /** The newline-terminated lines that end within the first PLAN_KEY_SCAN_BYTES bytes, so a cut line never parses. */
17
+ function scannedLines(text) {
18
+ const bytes = new TextEncoder().encode(text);
19
+ const head = bytes.length > PLAN_KEY_SCAN_BYTES ? bytes.subarray(0, PLAN_KEY_SCAN_BYTES) : bytes;
20
+ const decoded = new TextDecoder('utf-8', { fatal: false }).decode(head);
21
+ const lastNewline = decoded.lastIndexOf('\n');
22
+ if (lastNewline === -1)
23
+ return [];
24
+ return decoded.slice(0, lastNewline).split('\n').map((line) => line.replace(/\r$/, ''));
25
+ }
26
+ /** The key the plan's header block declares, or null: no line, a malformed line, or two lines. */
27
+ export function readPlanKey(text) {
28
+ const lines = scannedLines(text);
29
+ const title = lines.findIndex((line) => /^# /.test(line));
30
+ const header = [];
31
+ for (const line of lines.slice(title + 1)) {
32
+ if (/^## /.test(line))
33
+ break;
34
+ header.push(line);
35
+ }
36
+ const keyLines = header.filter((line) => KEY_LINE.test(line));
37
+ if (keyLines.length !== 1)
38
+ return null;
39
+ const match = PLAN_KEY_PATTERN.exec(keyLines[0]);
40
+ return match ? { projectId: match[1], anchor: match[2] } : null;
41
+ }
42
+ /** The plan's key: the declared one, else `projectId` with the file name's anchor. */
43
+ export function planKeyOf(text, path, projectId) {
44
+ const declared = readPlanKey(text);
45
+ if (declared)
46
+ return { ...declared, isDeclared: true };
47
+ const file = path.slice(path.lastIndexOf('/') + 1).replace(/\.md$/, '');
48
+ return { projectId, anchor: file, isDeclared: false };
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.23.15",
3
+ "version": "0.23.16",
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,
@@ -34,7 +34,7 @@
34
34
  "@types/node": "^22.19.20",
35
35
  "typescript": "^5.7.2",
36
36
  "vitest": "^4.1.11",
37
- "@nurix/contracts": "2.0.1",
37
+ "@nurix/contracts": "2.0.2",
38
38
  "@nurix/harness": "0.0.0",
39
39
  "@nurix/nustack.studio": "0.0.0",
40
40
  "@nurix/nustack.ui": "0.0.0"