@sabaiway/agent-workflow-kit 14.29.0 → 14.30.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.
@@ -35,6 +35,11 @@ import {
35
35
 
36
36
  export const START_MARKER = '# >>> agent-workflow-kit hidden mode (managed; do not edit between markers) >>>';
37
37
  export const END_MARKER = '# <<< agent-workflow-kit hidden mode <<<';
38
+ // Any other line opening or closing with these prefixes (a damaged hidden-mode marker aside: it
39
+ // refuses) marks ANOTHER kit lane's block (the sandbox-masks lane's): this writer never absorbs,
40
+ // moves or re-encodes a byte of it, a byte that is not UTF-8 aside (the file is read as UTF-8).
41
+ const FOREIGN_OPEN = '# >>> agent-workflow';
42
+ const FOREIGN_CLOSE = '# <<< agent-workflow';
38
43
 
39
44
  // Recognized legacy forms we ABSORB (local) or MIGRATE (global). Matched by prefix so a minor wording
40
45
  // drift can't strand a line (D7/D8).
@@ -101,11 +106,18 @@ const detectEol = (content) => {
101
106
  };
102
107
  // Split into logical lines; `lines.join(eol)` reproduces the structure (CRLF normalized to `eol`).
103
108
  const splitLines = (content) => (content === '' ? [] : content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'));
104
- // Re-join, dropping trailing blank lines and ending with exactly one EOL (canonical, idempotent).
109
+ // Each logical line's own ending ('' on an unterminated last line), index-aligned with splitLines.
110
+ const lineEnds = (content) => {
111
+ if (content === '') return [];
112
+ const parts = content.split(/(\r\n|\n|\r)/);
113
+ return parts.filter((_, i) => i % 2 === 0).map((_, i) => parts[2 * i + 1] ?? '');
114
+ };
115
+ // Re-join, dropping trailing blank lines (canonical, idempotent): a plain line takes `eol`; a line
116
+ // given as { text, end } (a foreign block's) keeps its own ending, an absent one on the last line included.
105
117
  const joinLines = (lines, eol) => {
106
118
  const out = [...lines];
107
119
  while (out.length && out[out.length - 1] === '') out.pop();
108
- return out.length ? `${out.join(eol)}${eol}` : '';
120
+ return out.map((l, i) => (typeof l === 'string' ? `${l}${eol}` : `${l.text}${l.end || (i < out.length - 1 ? eol : '')}`)).join('');
109
121
  };
110
122
 
111
123
  // A raw exclude line → an anchored pattern (leading "/"), or null when blank/comment.
@@ -261,15 +273,44 @@ const assembleCandidates = (deps, dir, fsx, forcedPreserve) => {
261
273
 
262
274
  export const buildBlock = (patterns) => [...new Set(patterns)].sort();
263
275
 
264
- // Find the managed fence in a line array. { state:'none' } | { state:'ok', startIdx, endIdx } |
265
- // { state:'malformed', reason }. Malformed → caller STOPs with the file byte-for-byte unchanged.
276
+ // Scan every kit marker line (judged trimmed, as the masks lane reads its own): our fence and each
277
+ // foreign block. { state:'none'|'ok', startIdx, endIdx, foreign:Set<lineIdx>, blockStart:Map<closer
278
+ // idx, opener idx> } | { state:'malformed', reason }. A closer `# <<< <lane> <<<` closes only an
279
+ // opener whose lane (its name up to ' — ' or ' >>>') is <lane>; malformed → caller STOPs, file unchanged.
280
+ const DAMAGED_OWN = ['# >>> agent-workflow-kit hidden mode', '# <<< agent-workflow-kit hidden mode'];
266
281
  const findFence = (lines) => {
267
- const starts = lines.map((l, i) => (l === START_MARKER ? i : -1)).filter((i) => i >= 0);
268
- const ends = lines.map((l, i) => (l === END_MARKER ? i : -1)).filter((i) => i >= 0);
269
- if (starts.length === 0 && ends.length === 0) return { state: 'none' };
270
- if (starts.length !== 1 || ends.length !== 1) return { state: 'malformed', reason: `expected exactly one managed marker pair, found ${starts.length} start / ${ends.length} end` };
271
- if (ends[0] < starts[0]) return { state: 'malformed', reason: 'end marker precedes start marker' };
272
- return { state: 'ok', startIdx: starts[0], endIdx: ends[0] };
282
+ const bad = (reason) => ({ state: 'malformed', reason });
283
+ const foreign = new Set();
284
+ const blockStart = new Map();
285
+ const seen = new Set();
286
+ let open = null;
287
+ let own = null;
288
+ for (const [i, raw] of lines.entries()) {
289
+ const t = raw.trim();
290
+ const opens = t.startsWith(FOREIGN_OPEN);
291
+ if (!opens && !t.startsWith(FOREIGN_CLOSE)) continue;
292
+ const mine = t === START_MARKER || t === END_MARKER;
293
+ if (!mine && DAMAGED_OWN.some((p) => t.startsWith(p))) return bad(`line ${i + 1} is a damaged hidden-mode marker`);
294
+ if (opens) {
295
+ if (open) return bad(`line ${i + 1} opens a block inside the one opened at line ${open.i + 1}`);
296
+ open = { i, mine, name: t.slice(6) };
297
+ } else {
298
+ if (!open) return bad(`line ${i + 1} closes a block that was never opened`);
299
+ const lane = t.endsWith(' <<<') ? t.slice(6, -4) : null;
300
+ const closes = mine ? open.mine : !open.mine && lane !== null && open.name.split(/ — | >>>/)[0] === lane;
301
+ if (!closes) return bad(`line ${i + 1} closes another block than the one opened at line ${open.i + 1}`);
302
+ }
303
+ if (seen.has(t)) return bad(`line ${i + 1} repeats the marker of an earlier block`);
304
+ seen.add(t);
305
+ if (!opens) {
306
+ if (mine) own = { startIdx: open.i, endIdx: i };
307
+ else for (let j = open.i; j <= i; j += 1) foreign.add(j);
308
+ if (!mine) blockStart.set(i, open.i);
309
+ open = null;
310
+ }
311
+ }
312
+ if (open) return bad(`the block opened at line ${open.i + 1} is never closed`);
313
+ return { state: own ? 'ok' : 'none', ...own, foreign, blockStart };
273
314
  };
274
315
 
275
316
  // ── absorb (local pre-existing recognized lines → folded into the fence) ──────────
@@ -280,21 +321,24 @@ const findFence = (lines) => {
280
321
  // block. Low-risk rules fold regardless of presence (D8 preserve). A HIGH-RISK rule folds as consent
281
322
  // only while its file is still PRESENT; if its file is absent it is left exactly as-is (a deliberate
282
323
  // pre-emptive user hide is never silently removed, and there is no present-file report mismatch).
324
+ // A foreign block's line arrives as { text, end } and is kept whole; `removed` lists, in file order,
325
+ // every line taken out (the report names each one).
283
326
  const absorbOutside = (outsideLines, isPresent) => {
284
327
  const kept = [];
285
328
  const absorbed = [];
329
+ const removed = [];
286
330
  for (const raw of outsideLines) {
331
+ if (typeof raw !== 'string') { kept.push(raw); continue; }
287
332
  const t = raw.trim();
288
- if (isAd013Comment(t) || isLegacyGlobalHeader(t)) continue; // orphan header for an absorbed rule
289
333
  const pat = lineToPattern(raw);
290
334
  const canon = pat ? recognizeHideRule(pat) : null;
291
- if (canon && (LOWRISK_SET.has(canon) || isPresent(canon))) {
335
+ if (isAd013Comment(t) || isLegacyGlobalHeader(t)) removed.push(raw); // orphan header for an absorbed rule
336
+ else if (canon && (LOWRISK_SET.has(canon) || isPresent(canon))) {
292
337
  absorbed.push(canon);
293
- continue;
294
- }
295
- kept.push(raw); // unrecognized, or a high-risk recognized rule whose file is absent → leave it
338
+ removed.push(raw);
339
+ } else kept.push(raw); // unrecognized, or a high-risk recognized rule whose file is absent → leave it
296
340
  }
297
- return { kept, absorbed };
341
+ return { kept, absorbed, removed };
298
342
  };
299
343
 
300
344
  // ── verify (D5 — over the WROTE set; "hidden" ≡ untracked ∧ ignored-by-OUR-source) ─
@@ -396,9 +440,13 @@ export const hideFootprint = (opts = {}, deps = {}) => {
396
440
  }
397
441
  })();
398
442
  const eol = detectEol(content);
399
- const lines = splitLines(content);
400
- const fence = findFence(lines);
443
+ const ends = lineEnds(content);
444
+ const texts = splitLines(content);
445
+ const fence = findFence(texts);
401
446
  if (fence.state === 'malformed') throw stop(`refusing to edit a malformed managed block in ${excludeFile}: ${fence.reason} (file left unchanged)`);
447
+ const lines = texts.map((l, i) => (fence.foreign.has(i) ? { text: l, end: ends[i] } : l));
448
+ // A created fence goes before a foreign block that ends the file unterminated, so no byte of it moves.
449
+ const tailBlock = ends.at(-1) === '' ? lines[fence.blockStart.get(texts.length - 1)] : undefined;
402
450
 
403
451
  const before = fence.state === 'ok' ? lines.slice(0, fence.startIdx) : lines;
404
452
  const fenceBodyLines = fence.state === 'ok' ? lines.slice(fence.startIdx + 1, fence.endIdx) : [];
@@ -416,8 +464,8 @@ export const hideFootprint = (opts = {}, deps = {}) => {
416
464
 
417
465
  // ── absorb pre-existing recognized lines, assemble + classify candidates ───────
418
466
  const isPresentCanon = (canon) => presentOnDisk(canon, fsx, dir);
419
- const { kept: cleanBefore, absorbed: absorbedBefore } = absorbOutside(before, isPresentCanon);
420
- const { kept: cleanAfter, absorbed: absorbedAfter } = absorbOutside(after, isPresentCanon);
467
+ const { kept: cleanBefore, absorbed: absorbedBefore, removed: removedBefore } = absorbOutside(before, isPresentCanon);
468
+ const { kept: cleanAfter, absorbed: absorbedAfter, removed: removedAfter } = absorbOutside(after, isPresentCanon);
421
469
  const fenceBodyPatterns = fenceBodyLines.map((l) => recognizeHideRule(lineToPattern(l) ?? '')).filter(Boolean);
422
470
  // Every pre-existing recognized hide rule (current fence body + absorbed loose lines, glob children
423
471
  // included) is prior consent. LOW-RISK rules are force-preserved as candidates regardless of presence
@@ -455,8 +503,9 @@ export const hideFootprint = (opts = {}, deps = {}) => {
455
503
 
456
504
  // ── build the new file (splice the fence; preserve outside lines) ──────────────
457
505
  const fenceLines = writtenPatterns.length ? [START_MARKER, ...writtenPatterns, END_MARKER] : [];
506
+ const at = fence.state === 'none' && tailBlock ? cleanBefore.indexOf(tailBlock) : cleanBefore.length;
458
507
  const newLines = writtenPatterns.length
459
- ? [...cleanBefore, ...fenceLines, ...cleanAfter]
508
+ ? [...cleanBefore.slice(0, at), ...fenceLines, ...cleanBefore.slice(at), ...cleanAfter]
460
509
  : [...cleanBefore, ...cleanAfter];
461
510
  const newContent = joinLines(newLines, eol);
462
511
  const changed = newContent !== content;
@@ -482,6 +531,7 @@ export const hideFootprint = (opts = {}, deps = {}) => {
482
531
  wrote: writtenPatterns,
483
532
  added,
484
533
  removed,
534
+ removedOutside: [...removedBefore, ...removedAfter],
485
535
  asks: asks.filter((a) => !includedAsks.some((i) => i.pattern === a.pattern)).map((a) => ({ path: a.pattern, reason: a.reason, owner: a.owner })),
486
536
  needsUntrack: needsUntrack.map((a) => {
487
537
  const target = patternToProbe(a.pattern).replace(/\/$/, '');
@@ -558,6 +608,8 @@ export const formatReport = (r, dryRun) => {
558
608
  if (r.removed.length) lines.push(` • −${r.removed.length} removed: ${r.removed.join(', ')}`);
559
609
  if (!r.added.length && !r.removed.length) lines.push(' • +0/−0 — the hidden set is unchanged');
560
610
  }
611
+ // Each line taken out outside the fence, verbatim, on the whole-line machine channel (exempt by grammar).
612
+ for (const l of r.removedOutside ?? []) lines.push(` [hide-footprint] ${dryRun ? 'would-remove' : 'removed'}=${l}`);
561
613
  for (const a of r.asks) lines.push(` • ASK ${a.path} — ${a.reason}`);
562
614
  for (const n of r.needsUntrack) lines.push(` • tracked, NOT hidden: ${n.path} — run \`${n.command}\` to un-track (kept on disk)`);
563
615
  if (r.dropped.length) lines.push(` • skipped ${r.dropped.length} already-ignored (tracked .gitignore)`);
@@ -244,8 +244,8 @@ export const composeVerdict = ({ attention, optional, skipped }) => {
244
244
  // WHAT is never truncated: the leaf's suite pins every composed WHAT under the cap.
245
245
  export const WHATS = Object.freeze({
246
246
  'velocity-core': 'routine read-only commands still prompt — {n} audited read-only allowlist entr(ies) not seeded',
247
- 'velocity-core.rule': 'in auto mode a harness apply is judged instead of asked — the apply ask rule is not seeded',
248
- 'velocity-core.both': 'routine read-only commands still prompt, and in auto mode a harness apply is judged instead of asked — {n} entr(ies) not seeded',
247
+ 'velocity-core.rule': 'auto mode refuses a harness apply, so work cannot stay in auto mode — the apply ask rule is not seeded',
248
+ 'velocity-core.both': 'routine read-only commands still prompt, and auto mode refuses a harness apply — {n} entr(ies) not seeded',
249
249
  'kit-tools-tier': "the kit's own read-only tools still prompt — {n} kit-tools tier entr(ies) not seeded",
250
250
  'bridge-tier': 'bridge-wrappers tier incomplete — {n} entr(ies) missing (allow rules, exclusions, hosts or state dirs of used bridges)',
251
251
  'autonomy-policy': 'no {path} — the computed defaults apply implicitly (red-lines ask/deny; every activity floors at prompt)',
@@ -319,7 +319,7 @@ const templateBudget = (template) => ITEM_LINE_CAP - template.replace(/\{[A-Za-z
319
319
 
320
320
  // ── the frozen benefit registry (fact-true; pinned by tests) ────────────────────────────────────
321
321
  export const BENEFITS = Object.freeze({
322
- 'velocity-core': 'velocity — routine read-only commands stop prompting while the maintainer is away; the harness asks before each harness apply',
322
+ 'velocity-core': 'velocity — routine read-only commands stop prompting while the maintainer is away; all work stays in auto mode',
323
323
  'kit-tools-tier': "velocity — the kit's own read-only tools run promptless (audited, resolved-absolute tier)",
324
324
  'bridge-tier':
325
325
  'velocity — used bridges get <wrapper> * exclusions (codex-exec * if delegated), hosts, state dirs (allowWrite); reviews: code-mode allows',
@@ -1,8 +1,8 @@
1
1
  // script-priors.mjs — the APPEND-ONLY catalog of every enforcement-script body this family has shipped
2
2
  // into a project's `scripts/`, as sha256 digests, and the ONE classifier the spec-layer ensure asks
3
3
  // before it refreshes a deployed script: `current` (byte-equal to the bundled copy) · `prior` (a body
4
- // a release shipped, never edited since) · `custom` (anything else — preserved verbatim, never
5
- // overwritten). The lens-region idiom (AD-041) over whole script bodies.
4
+ // a release shipped, never edited since) · `custom` (anything else — preserved verbatim, replaced only by
5
+ // the replace run the user's yes starts). The lens-region idiom (AD-041) over whole script bodies.
6
6
  //
7
7
  // Append discipline: when a release changes one of PRIOR_FILES, append the OUTGOING body's row here
8
8
  // in the same release and copy its bytes to test/fixtures/script-priors/<firstShipped>/<file>.txt —
@@ -36,6 +36,7 @@ export const SCRIPT_PRIORS = Object.freeze([
36
36
  prior('check-docs-size.mjs', '4.6.0', '6.0.0', '22d020c3668cdbfb4cbc1f67a8a85b2baa2d0c4956808a50626d37409e81ab38'),
37
37
  prior('spec-schema.mjs', '5.0.0', '7.2.1', '6a7d1e836c158baab5a412a1ad76d34891934dc28c88486c264a4b5d46f5892a'),
38
38
  prior('spec-schema.test.mjs', '5.0.0', '7.2.1', '698564d7283281be1826019d214df475bb876687915de086818d8f5ece0e0336'),
39
+ prior('check-docs-size.mjs', '7.0.0', '8.1.5', '0cf90ae2cbfc809847574a8c71a6b1322c783359740b08e0ff179f0d9d04dffc'),
39
40
  ]);
40
41
 
41
42
  export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -34,7 +34,7 @@ export const UPGRADE_RUNLIST = Object.freeze([
34
34
  entry(
35
35
  'configs',
36
36
  'node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --cwd <project>',
37
- null,
37
+ 'a specs replace line → ONE yes naming every file the custom lines name and saying their edits are overwritten, asked even on a non-zero exit; on the yes run the printed line, then STOP if that run fails or an op it does not re-run failed; on a no STOP on any failed op. The printed line: node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --replace-custom <digest> --cwd <project>',
38
38
  [
39
39
  'seeded',
40
40
  'note-refreshed',
@@ -39,7 +39,7 @@ export const WORKFLOW_STAMP = 'docs/ai/.workflow-version';
39
39
  export const SAFE_DEFAULT_MODES = Object.freeze(['default', 'acceptEdits', 'plan']);
40
40
  export const UNSAFE_BYPASS_MODE = 'bypassPermissions';
41
41
  export const ACCEPT_EDITS_MODE = 'acceptEdits';
42
- // The harness asks before every harness apply; an ask rule allows nothing.
42
+ // Lets all work stay in auto mode: a harness apply meets the harness dialog, not a refusal; an ask rule allows nothing.
43
43
  export const APPLY_ASK_RULE = 'Bash(node *apply-danger-check.mjs*--apply*)';
44
44
 
45
45
  // The audited read-only core. Every entry is a Claude Code Bash allow pattern whose command runs NO
@@ -337,8 +337,8 @@ absolute path (args wildcard), run-gates.mjs as ONE exact project-root-pinned by
337
337
  (project-exec - it runs YOUR declared gates.json), source-size-check.mjs as ONE exact --check
338
338
  byte-string (its read-only mode only - its writers keep prompting), and the writers' exact arg-free
339
339
  dry-run preview byte-strings. Never touches settings.local.json.
340
- Every apply also appends the apply ask rule ${APPLY_ASK_RULE} to permissions.ask
341
- once, so the harness asks before every harness apply; it allows nothing.
340
+ Every apply also appends the apply ask rule ${APPLY_ASK_RULE} to permissions.ask once, so all work
341
+ can stay in auto mode: without it auto mode refuses a harness apply and you must leave auto mode for it; with it the harness shows its usual dialog. It allows nothing.
342
342
  --bridge-tier (own consent) derives each entry for a used bridge from the effective project recipes:
343
343
  review wrappers' CODE mode (codex-review code, agy-review code), the quoted grounding pre-step rule,
344
344
  and <wrapper> * exclusions in sandbox.excludedCommands. An execute slot delegating to codex adds