@sabaiway/agent-workflow-kit 5.10.0 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ // worktrees-record.mjs — the worktrees handoff RECORD as a leaf (delegation Plan 3, Phase 2): the
2
+ // typed STOP, the exit codes, and the provision-record format (compose + parse).
3
+ //
4
+ // Extracted out of worktrees.mjs so a SECOND mode can read a satellite's record without importing
5
+ // the 3200-line worktrees tool — the satellite cold-start prompt does today, and the handoff-return
6
+ // rung will. worktrees.mjs re-exports every name below, so every existing import site and every
7
+ // asserted error `code` is unchanged.
8
+ //
9
+ // The move was byte-for-byte with ONE deliberate exception, stated rather than smuggled: the
10
+ // control-byte class was widened to cover C1 (U+0080-U+009F). That is a compatibility TIGHTENING —
11
+ // the record refuses strictly more than it did — taken because the same class now guards a second
12
+ // surface (the cold-start prompt, read in a terminal) and one class beats two that can drift. It is
13
+ // pinned by its own test rather than left to the extraction claim.
14
+ //
15
+ // A PURE leaf: Node built-ins only (it needs none), no fs, no git, no CLI, no side effects on
16
+ // import. Dependency-free, Node >= 22.
17
+
18
+ export const WORKTREES_STOP = 'WORKTREES_STOP';
19
+ export const stop = (message, fields = {}) =>
20
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'WorktreesStop', code: WORKTREES_STOP, ...fields });
21
+
22
+ export const EXIT = Object.freeze({ ok: 0, stop: 1, usage: 2 });
23
+ export const handoffBasename = (slug) => `handoff-${slug}.md`;
24
+
25
+ // The orientation facts a fresh satellite session cannot derive from its own checkout. They are
26
+ // CONSTANTS so the doc-parity registry can pin the mode doc to the exact strings the tool emits.
27
+ export const QUEUE_SHARED_RULE =
28
+ 'the series index is SHARED and lives ONLY in main: read it at the absolute path above, and never copy it into this worktree, because docs/plans is git-ignored and machine-local, so a copy silently diverges from what main and every other worktree are writing. This worktree never WRITES that file: reaching outside it is an fs_outside_repo action the autonomy policy denies by default. Put new findings in THIS handoff record instead — it is the channel that survives the landing, and main appends them to the index from here';
29
+
30
+ // The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
31
+ // is refused rather than written: a newline spills a second line the parser reads as a real field
32
+ // (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
33
+ // the whole section). Values reach here from the repo ROOT path and from --include, both of which
34
+ // may legally carry a newline on POSIX — so the guard is the only thing between them and a forged
35
+ // record. U+2028/U+2029 ride the same refusal: they are line terminators to the JS regex `.` but
36
+ // not to String.split('\n'), so such a value WRITES fine and is then silently DROPPED on read —
37
+ // a lost field with no error, which is the one outcome this codebase never allows.
38
+ // Fail closed: refuse to write, never sanitize silently.
39
+ // The class is built from a SOURCE STRING rather than a regex literal: the shipped-source guard
40
+ // forbids a stray control byte outright, and a string keeps the escapes visible to every scan.
41
+ // The range covers C1 (U+0080-U+009F) as well as C0: U+0085 is NEXT LINE and U+009B is the CSI
42
+ // introducer, and a value carrying either can forge a line VISUALLY in a terminal even where
43
+ // String.split('\n') never sees a break — which is the whole hazard, one surface further out.
44
+ const CONTROL_BYTE_CLASS = '[\\u0000-\\u001F\\u007F-\\u009F\\u2028\\u2029]';
45
+ const RECORD_CONTROL_BYTE = new RegExp(CONTROL_BYTE_CLASS);
46
+ const CONTROL_BYTE_GLOBAL = new RegExp(CONTROL_BYTE_CLASS, 'g');
47
+
48
+ // The same class, exposed as a predicate for a consumer that RENDERS rather than writes: the
49
+ // cold-start prompt is line-oriented too, so a control byte in any value it interpolates forges a
50
+ // line there exactly as it would forge a field here. That consumer must not reuse `recordValue`
51
+ // itself — edge whitespace is a record-round-trip hazard, not a rendering one, and a worktree path
52
+ // with a trailing space is a legal thing to print.
53
+ export const hasControlByte = (value) => RECORD_CONTROL_BYTE.test(String(value));
54
+
55
+ // And the same class again, for the OTHER thing untrusted text does: a refusal MESSAGE naming the
56
+ // value it refused. A STOP is read in the same terminal the prompt is, and it is emitted at the one
57
+ // moment the value is known to be hostile — so a diagnostic never repeats such a value raw. Every
58
+ // member of the class renders as a visible escape instead of doing what it would do.
59
+ export const displayValue = (value) => String(value).replace(
60
+ CONTROL_BYTE_GLOBAL,
61
+ (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`,
62
+ );
63
+
64
+ export const recordValue = (name, value) => {
65
+ const text = String(value);
66
+ if (RECORD_CONTROL_BYTE.test(text)) {
67
+ throw stop(`handoff record: the ${name} value carries a control character (newline/CR/NUL) — refusing to write a record whose fields could be forged by an injected line`);
68
+ }
69
+ // The parser `.trim()`s every value on read, and String.prototype.trim strips UNICODE whitespace
70
+ // — so an edge space (a Unicode one is legal even in a git branch name) writes fine and reads
71
+ // back as a DIFFERENT identity, stranding the worktree behind a record that no longer matches.
72
+ if (text !== text.trim()) {
73
+ throw stop(`handoff record: the ${name} value carries leading or trailing whitespace, which the record trims on read — the identity would change across a write→read round-trip: ${JSON.stringify(text)}`);
74
+ }
75
+ return text;
76
+ };
77
+
78
+ // An OPTIONAL field is omitted when absent, never rendered as "null": a record written by an
79
+ // earlier kit is re-composed from its PARSED form at every refresh (land --prepare), so a field
80
+ // that kit never wrote must survive the round-trip as absence, not as a literal null string.
81
+ export const optionalField = (name, value) => (value == null ? [] : [`- ${name}: ${recordValue(name, value)}`]);
82
+
83
+ export const composeProvisionRecordSection = ({ slug, branch, includes, nodeModules, vscode, install = null, sharedQueue = null, landing = null, prepared = null, preparedHead = null }) => [
84
+ '## Provision record',
85
+ '',
86
+ `- slug: ${recordValue('slug', slug)}`,
87
+ `- branch: ${recordValue('branch', branch)}`,
88
+ ...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${recordValue('include', p)}`)),
89
+ `- node_modules: ${recordValue('node_modules', nodeModules)}`,
90
+ `- vscode-settings: ${recordValue('vscode-settings', vscode)}`,
91
+ ...optionalField('install', install),
92
+ ...optionalField('shared-queue', sharedQueue),
93
+ ...optionalField('landing', landing),
94
+ ...optionalField('prepared-tree', prepared),
95
+ // prepared-head rides beside prepared-tree (D8): after a commit a clean index reproduces the
96
+ // committed tree, so the tree OID alone cannot show whether the prepared set is still pending.
97
+ ...optionalField('prepared-head', preparedHead),
98
+ '',
99
+ // The rule says "at the absolute path above", so it ships only WITH that path: a record from an
100
+ // earlier kit carries no shared-queue field, and a rule pointing at nothing is worse than silence.
101
+ ...(sharedQueue == null ? [] : [QUEUE_SHARED_RULE, '']),
102
+ ].join('\n');
103
+
104
+ // The `landing` value's shape, in ONE place: the record composes it and the cold-start prompt
105
+ // measures a divergence against it, so a drifting join would report a stale record on every
106
+ // prompt for a MAIN that never moved.
107
+ export const composeLandingValue = ({ rule, command }) => `${rule} — ${command}`;
108
+
109
+ export const composeHandoffStub = (fields) => [
110
+ `# Handoff — ${fields.slug}`,
111
+ '',
112
+ 'provisioned, nothing done yet',
113
+ '',
114
+ composeProvisionRecordSection(fields),
115
+ ].join('\n');
116
+
117
+ const ATX_SECTION_HEADING = /^ {0,3}#{1,2} /;
118
+
119
+ // Exactly the characters a JS `.` will not cross, which is what makes a field line unmatchable
120
+ // rather than merely odd. Built from a source string for the same reason the class above is.
121
+ const VANISHING_CLASS = '[\\r\\u2028\\u2029]';
122
+ const VANISHING_IN_FIELD = new RegExp(VANISHING_CLASS);
123
+ const VANISHING_GLOBAL = new RegExp(VANISHING_CLASS, 'g');
124
+
125
+ export const locateProvisionRecordSection = (text) => {
126
+ const source = String(text);
127
+ const lines = [...source.matchAll(/.*(?:\r?\n|$)/g)].filter((match) => match[0] !== '');
128
+ const headings = lines.filter((match) => match[0].replace(/\r?\n$/, '').trim() === '## Provision record');
129
+ if (headings.length === 0) throw stop('handoff record: missing required "## Provision record" section');
130
+ if (headings.length > 1) throw stop('handoff record: multiple "## Provision record" sections — the record is ambiguous');
131
+ const start = headings[0].index;
132
+ const nextHeading = lines.find((match) => match.index > start && ATX_SECTION_HEADING.test(match[0].replace(/\r?\n$/, '')));
133
+ return { source, start, end: nextHeading?.index ?? source.length };
134
+ };
135
+
136
+ // ONLY the required section is parsed, so decoy fields elsewhere cannot hijack identity.
137
+ // Duplicated single-valued fields are ambiguous identity → typed STOP, never last-wins.
138
+ export const parseProvisionRecord = (text) => {
139
+ const section = locateProvisionRecordSection(text);
140
+ const scan = section.source.slice(section.start, section.end).split('\n').slice(1);
141
+ const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, install: null, sharedQueue: null, landing: null, prepared: null, preparedHead: null };
142
+ const single = {
143
+ slug: 'slug', branch: 'branch', node_modules: 'nodeModules',
144
+ 'vscode-settings': 'vscode', 'prepared-tree': 'prepared', 'prepared-head': 'preparedHead',
145
+ install: 'install', 'shared-queue': 'sharedQueue', landing: 'landing',
146
+ };
147
+ const seen = new Set();
148
+ for (const line of scan) {
149
+ // The three bytes `.` cannot cross are refused, never skipped: with CR, U+2028 or U+2029 inside
150
+ // a value the match below FAILS, so the field reads back as ABSENT — and absence is a legitimate
151
+ // state (an older kit never wrote the field), which makes the loss silent and lets a consumer
152
+ // report "nothing diverged" about a value it never saw. Every other control byte is matched and
153
+ // carried through, where the WRITE guard and the render guard each refuse it in their own terms.
154
+ if (VANISHING_IN_FIELD.test(line) && /^\s*- [a-z_-]+:/.test(line.replace(VANISHING_GLOBAL, ''))) {
155
+ throw stop(`handoff record: a field line carries a character the record grammar cannot represent, so the field would silently read back as absent: ${displayValue(line)}`);
156
+ }
157
+ const m = line.match(/^- ([a-z_-]+): (.*)$/);
158
+ if (!m) continue;
159
+ const value = m[2].trim();
160
+ if (m[1] === 'include') {
161
+ if (value !== '(none)') record.includes.push(value);
162
+ continue;
163
+ }
164
+ const key = single[m[1]];
165
+ if (!key) continue;
166
+ if (seen.has(m[1])) throw stop(`handoff record: duplicate "${m[1]}" field — the record is ambiguous`);
167
+ seen.add(m[1]);
168
+ record[key] = value;
169
+ }
170
+ return record;
171
+ };