devlyn-cli 4.3.12 → 4.3.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 (27) hide show
  1. package/bin/cleanup-history.json +499 -1
  2. package/bin/devlyn.js +234 -33
  3. package/bin/skill-history.json +1 -0
  4. package/native/claude/.claude-plugin/plugin.json +1 -1
  5. package/native/codex/.agents/plugins/marketplace.json +1 -1
  6. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  7. package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
  8. package/node_modules/jsonc-parser/LICENSE.md +21 -0
  9. package/node_modules/jsonc-parser/README.md +364 -0
  10. package/node_modules/jsonc-parser/SECURITY.md +41 -0
  11. package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
  12. package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
  13. package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
  14. package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
  15. package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
  16. package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
  17. package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
  18. package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
  19. package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
  20. package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
  21. package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
  22. package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
  23. package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
  24. package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
  25. package/node_modules/jsonc-parser/package.json +37 -0
  26. package/package.json +3 -1
  27. package/release.json +104 -9
package/bin/devlyn.js CHANGED
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const os = require('os');
6
6
  const crypto = require('crypto');
7
7
  const { execSync, spawnSync } = require('child_process');
8
+ const { parseTree, findNodeAtLocation, applyEdits } = require('jsonc-parser');
8
9
 
9
10
  const CONFIG_SOURCE = path.join(__dirname, '..', 'config');
10
11
  const OPTIONAL_SKILLS_SOURCE = path.join(__dirname, '..', 'optional-skills');
@@ -887,8 +888,8 @@ function replaceFile(file, text) {
887
888
  }
888
889
  }
889
890
 
890
- function cleanRetiredSettings(settings) {
891
- let changed = false;
891
+ function cleanRetiredSettings(settings, hookScripts = []) {
892
+ let changed = cleanJarvisSettings(settings, hookScripts);
892
893
  // Remove exactly the retired Stop hook command, and its entry only when nothing else is left in it.
893
894
  const retiredHook = (hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK;
894
895
  const stop = Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : [];
@@ -903,6 +904,91 @@ function cleanRetiredSettings(settings) {
903
904
  return changed;
904
905
  }
905
906
 
907
+ // A hook's event, matcher and complete command object must be shipped bytes (JSON key order aside).
908
+ // Extra owner hooks and wrapper keys survive. Script names alone never authorize a settings edit.
909
+ function cleanJarvisSettings(settings, scripts) {
910
+ let changed = false;
911
+ for (const [event, entries] of Object.entries(settings?.hooks || {})) {
912
+ if (!Array.isArray(entries)) continue;
913
+ for (let i = entries.length - 1; i >= 0; i--) {
914
+ const entry = entries[i];
915
+ if (!Array.isArray(entry?.hooks)) continue;
916
+ const kept = entry.hooks.filter((hook) => {
917
+ const hash = crypto.createHash('sha256').update(cleanupCanonical([event, entry.matcher, hook])).digest('hex');
918
+ return !scripts.some((script) => CLEANUP_HISTORY.hooks[script]?.entries.includes(hash));
919
+ });
920
+ if (kept.length === entry.hooks.length) continue;
921
+ changed = true;
922
+ entry.hooks = kept;
923
+ if (!kept.length && Object.keys(entry).every((key) => ['matcher', 'hooks'].includes(key))) entries.splice(i, 1);
924
+ }
925
+ }
926
+ return changed;
927
+ }
928
+
929
+ function cleanupHookReferenced(settings, script) {
930
+ // Conservative even for shell wrappers, absolute paths, Windows separators and malformed entries.
931
+ return JSON.stringify(settings).toLowerCase().includes(path.posix.basename(script).toLowerCase());
932
+ }
933
+
934
+ function cleanupReadSettings(base, name = 'settings.json') {
935
+ const file = path.join(base, '.claude', name);
936
+ if (!cleanupRealParents(base, file)) return { safe: false };
937
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
938
+ if (!stat) return { safe: true, settings: {} };
939
+ if (!stat.isFile() || stat.nlink !== 1) return { safe: false };
940
+ try {
941
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(file));
942
+ const settings = JSON.parse(text.replace(/^\uFEFF/, ''));
943
+ const tree = parseTree(text.replace(/^\uFEFF/, ' '));
944
+ const hooks = tree.children?.filter((node) => node.children?.[0]?.value === 'hooks') || [];
945
+ const duplicates = (node) => {
946
+ if (node.type === 'object') {
947
+ const names = node.children.map((property) => property.children[0].value);
948
+ if (new Set(names).size !== names.length) return true;
949
+ }
950
+ return node.children?.some(duplicates) || false;
951
+ };
952
+ // Duplicate owner keys elsewhere survive; duplicate hook paths make a surgical edit ambiguous.
953
+ if (hooks.length > 1 || (hooks.length && duplicates(hooks[0]))) return { safe: false };
954
+ return settings && typeof settings === 'object' && !Array.isArray(settings)
955
+ ? { safe: true, settings, text } : { safe: false };
956
+ } catch (error) {
957
+ if (error instanceof SyntaxError || error.code === 'ERR_ENCODING_INVALID_ENCODED_DATA') return { safe: false };
958
+ throw error;
959
+ }
960
+ }
961
+
962
+ function cleanupSettingsText(text, settings, scripts) {
963
+ const before = Object.entries(settings.hooks || {}).flatMap(([event, entries]) => !Array.isArray(entries) ? []
964
+ : entries.map((entry, index) => ({ event, entries, entry, index, hooks: Array.isArray(entry?.hooks) ? [...entry.hooks] : [] })));
965
+ if (!cleanRetiredSettings(settings, scripts)) return text;
966
+ const removals = [];
967
+ for (const { event, entries, entry, index, hooks } of before.reverse()) {
968
+ const location = ['hooks', event, index];
969
+ if (!entries.includes(entry)) removals.push(location);
970
+ else for (let i = hooks.length - 1; i >= 0; i--) if (!entry.hooks.includes(hooks[i])) removals.push([...location, 'hooks', i]);
971
+ }
972
+ // Only prune containers this cleanup emptied; pre-existing owner empties are not leftovers.
973
+ const emptiedEvents = [...new Set(before.map(({ event }) => event))].filter((event) => settings.hooks[event].length === 0);
974
+ removals.push(...emptiedEvents.map((event) => ['hooks', event]));
975
+ if (emptiedEvents.length === Object.keys(settings.hooks).length) removals.push(['hooks']);
976
+ // Remove array elements and object properties by AST ranges, including the adjacent comma, without serializing owner
977
+ // values. jsonc-parser 3.3.1's modify() truncates a last-element deletion without formatting.
978
+ for (const location of removals) {
979
+ let node = findNodeAtLocation(parseTree(text.replace(/^\uFEFF/, ' ')), location);
980
+ if (node?.parent?.type === 'property') node = node.parent;
981
+ if (!node || !['array', 'object'].includes(node.parent?.type)) throw new Error('Settings edit path changed');
982
+ const siblings = node.parent.children, index = siblings.indexOf(node);
983
+ const previous = siblings[index - 1], next = siblings[index + 1];
984
+ const offset = previous ? previous.offset + previous.length : node.offset;
985
+ const end = !previous && next ? next.offset : node.offset + node.length;
986
+ text = applyEdits(text, [{ offset, length: end - offset, content: '' }]);
987
+ }
988
+ const after = JSON.parse(text.replace(/^\uFEFF/, '')); // Refuse an invalid edit before changing any bytes.
989
+ return Object.keys(after).length === 0 ? null : text;
990
+ }
991
+
906
992
  // Shared user roots need one writer from ownership checks through the last addon write.
907
993
  // Never reap a foreign lock: PID reuse and slow installs make age/liveness unsafe evidence.
908
994
  const heldSkillLocks = new Set();
@@ -1545,20 +1631,22 @@ function cleanupSkillRoot(base, root, current) {
1545
1631
  }
1546
1632
 
1547
1633
  function cleanupCommands(base) {
1548
- const commands = path.join(base, '.claude', 'commands');
1549
- if (!cleanupRealParents(base, path.join(commands, 'x')) || !fs.lstatSync(commands, { throwIfNoEntry: false })?.isDirectory()) return [];
1550
- const names = new Set(DEPRECATED_FILES.map((file) => path.basename(file).toLowerCase()));
1551
- return fs.readdirSync(commands).sort().filter((name) => names.has(name.toLowerCase())
1552
- && fs.lstatSync(path.join(commands, name)).isFile())
1553
- .map((name) => {
1554
- const file = path.join(commands, name);
1555
- const proven = CLEANUP_HISTORY.commands[name.toLowerCase()]?.includes(contentHash(file));
1556
- return { kind: 'legacy-command', path: file, base, action: proven ? 'remove' : 'skip',
1557
- reasons: proven ? [] : [cleanupReason('modified-copy')] };
1558
- });
1634
+ return CLEANUP_HISTORY.commandDirectories.flatMap((directory) => {
1635
+ const commands = path.join(base, directory);
1636
+ if (!cleanupRealParents(base, path.join(commands, 'x')) || !fs.lstatSync(commands, { throwIfNoEntry: false })?.isDirectory()) return [];
1637
+ const names = new Set([...DEPRECATED_FILES.map((file) => path.basename(file).toLowerCase()), ...Object.keys(CLEANUP_HISTORY.commands)]);
1638
+ return fs.readdirSync(commands).sort().filter((name) => names.has(name.toLowerCase())
1639
+ && fs.lstatSync(path.join(commands, name)).isFile())
1640
+ .map((name) => {
1641
+ const file = path.join(commands, name);
1642
+ const proven = CLEANUP_HISTORY.commands[name.toLowerCase()]?.includes(contentHash(file));
1643
+ return { kind: 'legacy-command', path: file, base, action: proven ? 'remove' : 'skip',
1644
+ reasons: proven ? [] : [cleanupReason('modified-copy')] };
1645
+ });
1646
+ });
1559
1647
  }
1560
1648
 
1561
- function cleanupProjectFiles(base) {
1649
+ function cleanupProjectFiles(base, includedHooks = new Set()) {
1562
1650
  const items = [];
1563
1651
  const hash = (file) => crypto.createHash('sha256')
1564
1652
  .update(fs.readFileSync(file).toString('latin1').replace(/\r\n/g, '\n'), 'latin1').digest('hex');
@@ -1569,14 +1657,41 @@ function cleanupProjectFiles(base) {
1569
1657
  }
1570
1658
  }
1571
1659
  const settingsFile = path.join(base, '.claude', 'settings.json');
1572
- if (cleanupRealParents(base, settingsFile) && fs.lstatSync(settingsFile, { throwIfNoEntry: false })?.isFile()) {
1573
- try {
1574
- const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8').replace(/^/, ''));
1575
- const stop = Array.isArray(settings?.hooks?.Stop) ? settings.hooks.Stop : [];
1576
- if (stop.some((entry) => Array.isArray(entry?.hooks) && entry.hooks.some((hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK))) {
1577
- items.push({ kind: 'settings-entry', path: settingsFile, base, action: 'edit', reasons: [] });
1578
- }
1579
- } catch (error) { if (!(error instanceof SyntaxError)) throw error; }
1660
+ const { safe, settings } = cleanupReadSettings(base);
1661
+ const localSettings = cleanupReadSettings(base, 'settings.local.json');
1662
+ const hookScripts = [], hookCandidates = [];
1663
+ for (const [name, history] of Object.entries(CLEANUP_HISTORY.hooks)) {
1664
+ const file = path.join(base, name);
1665
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
1666
+ if (!stat) continue;
1667
+ const item = { kind: 'legacy-hook', path: file, base, action: 'skip', reasons: [cleanupReason('outside-link')] };
1668
+ items.push(item);
1669
+ if (!cleanupRealParents(base, file) || !stat.isFile() || stat.nlink !== 1) continue;
1670
+ const proven = history.hashes.includes(contentHash(file));
1671
+ Object.assign(item, { action: proven ? 'remove' : 'skip', reasons: proven ? [] : [cleanupReason('modified-copy')] });
1672
+ if (!safe || !localSettings.safe || cleanupHookReferenced(localSettings.settings, name)) {
1673
+ Object.assign(item, { action: 'skip', reasons: [cleanupReason('dependent-copy')] });
1674
+ continue;
1675
+ }
1676
+ if (!cleanupHookReferenced(settings, name)) continue;
1677
+ const after = structuredClone(settings);
1678
+ cleanJarvisSettings(after, [name]);
1679
+ // Unknown commands/matchers/extra command keys retain the script. Routes cannot split a pair:
1680
+ // a PR leaves the caller's tracked copy in place until the base is taken.
1681
+ if (cleanupHookReferenced(after, name) || cleanupTracked(file) !== cleanupTracked(settingsFile)) {
1682
+ Object.assign(item, { action: 'skip', reasons: [cleanupReason('dependent-copy')] });
1683
+ continue;
1684
+ }
1685
+ item.hookSettings = settingsFile;
1686
+ hookCandidates.push(name);
1687
+ if (proven || includedHooks.has(file)) hookScripts.push(name);
1688
+ }
1689
+ const stop = Array.isArray(settings?.hooks?.Stop) ? settings.hooks.Stop : [];
1690
+ const retiredStop = stop.some((entry) => Array.isArray(entry?.hooks) && entry.hooks.some((hook) => hook?.type === 'command' && hook.command === RETIRED_STOP_HOOK));
1691
+ if (safe && (retiredStop || hookCandidates.length)) {
1692
+ const edit = retiredStop || hookScripts.length > 0;
1693
+ items.push({ kind: 'settings-entry', path: settingsFile, base, action: edit ? 'edit' : 'skip',
1694
+ reasons: edit ? [] : [cleanupReason('dependent-copy')], hookScripts, hookCandidates });
1580
1695
  }
1581
1696
  return items;
1582
1697
  }
@@ -1690,7 +1805,7 @@ function cleanupRetainSupport(items) {
1690
1805
  return items;
1691
1806
  }
1692
1807
 
1693
- function cleanupProjectScope(project, current, includeModified = false) {
1808
+ function cleanupProjectScope(project, current, includeModified = false, includedHooks = new Set()) {
1694
1809
  const { repositoryIdentity } = require('./harness-status');
1695
1810
  const { physicalRoot } = require('./release');
1696
1811
  const root = physicalRoot(project);
@@ -1703,7 +1818,7 @@ function cleanupProjectScope(project, current, includeModified = false) {
1703
1818
  if (cleanupSameFile(base, os.homedir())) return { ...scope, items: whole('home-folder') };
1704
1819
  const items = [
1705
1820
  ...cleanupSkillRoots(['.agents', '.claude'].map((dir) => [base, path.join(base, dir, 'skills')]), current, includeModified),
1706
- ...cleanupCommands(base), ...cleanupProjectFiles(base), ...cleanupInstructions(base),
1821
+ ...cleanupCommands(base), ...cleanupProjectFiles(base, includedHooks), ...cleanupInstructions(base),
1707
1822
  ];
1708
1823
  const busy = identity && items.some((item) => cleanupAction(item, includeModified) !== 'skip') ? cleanupQueueBusy(base) : null;
1709
1824
  for (const item of items) {
@@ -1739,13 +1854,13 @@ function cleanupMachineScope(home, accountHomes, current, includeModified = fals
1739
1854
  return { kind: 'machine', root: home, name: null, remote: null, items: cleanupRetainSupport(items) };
1740
1855
  }
1741
1856
 
1742
- function cleanupScopes(inputs, current, includeModified = false) {
1857
+ function cleanupScopes(inputs, current, includeModified = false, includedHooks = new Set()) {
1743
1858
  const { sha256 } = require('./release');
1744
1859
  const scopes = [];
1745
1860
  if (inputs.machine) scopes.push({ id: 'machine', ...cleanupMachineScope(os.homedir(), inputs.accountHomes, current, includeModified) });
1746
1861
  for (const project of inputs.projects) {
1747
1862
  let scope;
1748
- try { scope = cleanupProjectScope(project, current, includeModified); }
1863
+ try { scope = cleanupProjectScope(project, current, includeModified, includedHooks); }
1749
1864
  catch (error) {
1750
1865
  scope = { kind: 'project', root: project, name: path.basename(project), remote: null, items: [{ kind: 'project', path: project, base: project,
1751
1866
  relative: '', route: 'local', action: 'skip', reasons: [cleanupReason(error.reason?.code === 'repository-unreadable' ? 'repository-unreadable' : 'project-unreadable', { detail: error.message })] }] };
@@ -1789,17 +1904,30 @@ function cleanupDigest(plan) {
1789
1904
  }
1790
1905
 
1791
1906
  // Apply one item a fresh plan proves right now; returns a reason when it is not what was proven.
1792
- function cleanupApplyItem(item) {
1907
+ function cleanupValidateItem(item) {
1793
1908
  const now = cleanupPreimage(item.path, Boolean(item.included));
1794
1909
  if (now.type !== item.before.type || now.sha256 !== item.before.sha256) return cleanupReason('stale-plan');
1795
1910
  if (!cleanupRealParents(item.base, item.path)) return cleanupReason('outside-link');
1911
+ return null;
1912
+ }
1913
+
1914
+ function cleanupApplyItem(item) {
1915
+ const invalid = cleanupValidateItem(item);
1916
+ if (invalid) return invalid;
1796
1917
  if (cleanupAction(item) === 'remove') {
1918
+ if (item.kind === 'legacy-hook') {
1919
+ const { safe, settings } = cleanupReadSettings(item.base);
1920
+ if (!safe || cleanupHookReferenced(settings, item.relative)) return cleanupReason('dependent-copy');
1921
+ }
1797
1922
  fs.rmSync(item.path, { recursive: true }); // lstat/realParents never follow a link; rm removes the entry itself.
1798
1923
  return null;
1799
1924
  }
1800
1925
  if (item.kind === 'settings-entry') {
1801
- const settings = JSON.parse(fs.readFileSync(item.path, 'utf8').replace(/^/, ''));
1802
- if (cleanRetiredSettings(settings)) replaceFile(item.path, JSON.stringify(settings, null, 2) + '\n');
1926
+ const { safe, settings, text } = cleanupReadSettings(item.base);
1927
+ if (!safe || text === undefined) return cleanupReason('stale-plan');
1928
+ const after = cleanupSettingsText(text, settings, item.hookScripts);
1929
+ if (after === null) fs.unlinkSync(item.path);
1930
+ else if (after !== text) replaceFile(item.path, after);
1803
1931
  return null;
1804
1932
  }
1805
1933
  if (item.kind === 'instructions') {
@@ -1822,11 +1950,56 @@ function cleanupProven(reviewed, fresh) {
1822
1950
  const same = match && match.action === item.action && (!item.included || (cleanupCanInclude(match) && item.before.sha256 !== null))
1823
1951
  && match.relative === item.relative && match.route === item.route
1824
1952
  && match.before.type === item.before.type && match.before.sha256 === item.before.sha256
1953
+ && cleanupCanonical(match.hookScripts || []) === cleanupCanonical(item.hookScripts || [])
1825
1954
  && !(cleanupSupport(item) && narrowed.has(path.dirname(item.path)));
1826
1955
  return { reviewed: item, fresh: same ? { ...match, included: item.included } : null };
1827
1956
  });
1828
1957
  }
1829
1958
 
1959
+ // A narrowed or stale review may not split a settings edit from any script it disables.
1960
+ function cleanupCoupledPairs(pairs) {
1961
+ const byPath = new Map(pairs.map((pair) => [pair.reviewed.path, pair]));
1962
+ for (const pair of pairs.filter(({ reviewed }) => reviewed.kind === 'settings-entry')) {
1963
+ const scripts = (pair.reviewed.hookScripts || []).map((name) => byPath.get(path.join(pair.reviewed.base, name)));
1964
+ if (!pair.fresh || scripts.some((script) => !script?.fresh || cleanupAction(script.fresh) !== 'remove')) {
1965
+ pair.fresh = null;
1966
+ for (const script of scripts) if (script) script.fresh = null;
1967
+ }
1968
+ }
1969
+ for (const pair of pairs) {
1970
+ if (pair.reviewed.hookSettings && (!byPath.get(pair.reviewed.hookSettings)?.fresh
1971
+ || !byPath.get(pair.reviewed.hookSettings).fresh.hookScripts.includes(pair.reviewed.relative))) pair.fresh = null;
1972
+ }
1973
+ return pairs;
1974
+ }
1975
+
1976
+ // Stage the scripts reversibly, then replace settings. A failed rename/write restores the scripts;
1977
+ // settings never references a script we permanently removed. Temp bytes are never source evidence.
1978
+ function cleanupApplyHookGroup(settings, scripts) {
1979
+ for (const item of [settings, ...scripts]) {
1980
+ const invalid = cleanupValidateItem(item);
1981
+ if (invalid) return invalid;
1982
+ }
1983
+ const staged = [];
1984
+ try {
1985
+ for (const item of scripts) {
1986
+ const temp = `${item.path}.${crypto.randomUUID()}.tmp`;
1987
+ fs.renameSync(item.path, temp);
1988
+ staged.push({ item, temp });
1989
+ }
1990
+ const error = cleanupApplyItem(settings);
1991
+ if (error) throw new Error(error.code);
1992
+ } catch (error) {
1993
+ const errors = [error];
1994
+ for (const { item, temp } of staged.reverse()) {
1995
+ try { fs.renameSync(temp, item.path); } catch (rollback) { errors.push(rollback); }
1996
+ }
1997
+ throw new AggregateError(errors, errors.map((e) => e.message).join('; '));
1998
+ }
1999
+ for (const { temp } of staged) fs.unlinkSync(temp);
2000
+ return null;
2001
+ }
2002
+
1830
2003
  // Support entries (`_shared`, the marker) go last, and only when nothing beside them was refused.
1831
2004
  function cleanupSupport(item) {
1832
2005
  return path.basename(item.path) === '_shared' || item.kind === 'install-marker';
@@ -1855,7 +2028,16 @@ function cleanupLocal(scope, items) {
1855
2028
  catch (error) { results.set(item.id, cleanupReason('apply-failed', { detail: error.message })); }
1856
2029
  };
1857
2030
  const run = () => {
1858
- for (const item of items.filter((item) => !cleanupSupport(item))) act(item);
2031
+ for (const settings of items.filter((item) => item.hookScripts?.length)) {
2032
+ const scripts = items.filter((item) => item.hookSettings === settings.path);
2033
+ const group = [settings, ...scripts];
2034
+ let error;
2035
+ try {
2036
+ error = group.some((item) => cleanupTracked(item.path)) ? cleanupReason('tracked-copy') : cleanupApplyHookGroup(settings, scripts);
2037
+ } catch (failure) { error = cleanupReason('apply-failed', { detail: failure.message }); }
2038
+ for (const item of group) results.set(item.id, error);
2039
+ }
2040
+ for (const item of items.filter((item) => !cleanupSupport(item) && !results.has(item.id))) act(item);
1859
2041
  for (const item of items.filter(cleanupSupport)) {
1860
2042
  const refused = items.some((other) => !cleanupSupport(other) && path.dirname(other.path) === path.dirname(item.path)
1861
2043
  && results.get(other.id) !== null);
@@ -2001,7 +2183,8 @@ function cleanupUpdateCheckout(scope) {
2001
2183
  // With every reviewed item already settled by the remote base, the worktree is released with
2002
2184
  // nothing to deliver.
2003
2185
  function cleanupDeliveryCommit(worktree, items, current, release, settledUpstream) {
2004
- const fresh = cleanupProjectScope(worktree, current, items.some((item) => item.included));
2186
+ const includedHooks = new Set(items.filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => path.join(worktree, item.relative)));
2187
+ const fresh = cleanupProjectScope(worktree, current, items.some((item) => item.included), includedHooks);
2005
2188
  const available = new Map(fresh.items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
2006
2189
  const pairs = items.map((reviewed) => {
2007
2190
  const fresh = available.get(`${reviewed.kind}\u0000${reviewed.relative}`);
@@ -2011,13 +2194,23 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
2011
2194
  ? fresh.action !== reviewed.action || fresh.route !== 'delivery'
2012
2195
  || (reviewed.included && (!cleanupCanInclude(fresh) || before.sha256 === null))
2013
2196
  || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256
2197
+ || cleanupCanonical(fresh.hookScripts || []) !== cleanupCanonical(reviewed.hookScripts || [])
2014
2198
  : !settledUpstream(reviewed.relative)));
2015
2199
  // Check the entire batch before mutating even one item; the remote base cannot replace the reviewed preimage.
2016
2200
  if (stale.length) return release(cleanupReason('stale-plan', { detail: stale.map(({ reviewed }) => reviewed.relative).join(', ') }));
2201
+ if (cleanupCoupledPairs(pairs.filter(({ fresh }) => fresh)).some((pair) => !pair.fresh)) return release(cleanupReason('stale-plan'));
2017
2202
  const present = pairs.filter(({ fresh }) => fresh);
2018
2203
  if (!present.length) return release();
2019
2204
  const applied = [];
2205
+ for (const { reviewed, fresh: settings } of present.filter(({ fresh }) => fresh.hookScripts?.length)) {
2206
+ const scripts = present.filter(({ fresh }) => fresh.hookSettings === settings.path)
2207
+ .map(({ reviewed, fresh }) => ({ ...fresh, before: reviewed.before, included: reviewed.included }));
2208
+ const error = cleanupApplyHookGroup({ ...settings, before: reviewed.before }, scripts);
2209
+ if (error) return release(error);
2210
+ applied.push(settings.relative, ...scripts.map((item) => item.relative));
2211
+ }
2020
2212
  for (const { reviewed, fresh } of present) {
2213
+ if (applied.includes(fresh.relative)) continue;
2021
2214
  const error = cleanupApplyItem({ ...fresh, before: reviewed.before, included: reviewed.included });
2022
2215
  if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
2023
2216
  applied.push(fresh.relative);
@@ -2052,12 +2245,20 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
2052
2245
  function cleanupApply(plan, { events = false } = {}) {
2053
2246
  const current = cleanupCurrentFingerprints();
2054
2247
  const includeModified = plan.scopes.some((scope) => scope.items.some((item) => item.included));
2055
- const fresh = new Map(cleanupScopes(plan.inputs, current, includeModified).map((scope) => [scope.id, scope]));
2248
+ const includedHooks = new Set(plan.scopes.flatMap((scope) => scope.items)
2249
+ .filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => item.path));
2250
+ // Explicit selection of a reviewed edited script also selects its reviewed settings preimage.
2251
+ for (const scope of plan.scopes) for (const item of scope.items) {
2252
+ if (!item.hookCandidates || (item.action === 'skip' && item.reasons[0]?.code !== 'dependent-copy')) continue;
2253
+ const selected = item.hookCandidates.filter((name) => includedHooks.has(path.join(item.base, name)));
2254
+ if (selected.length) Object.assign(item, { action: 'edit', reasons: [], hookScripts: [...new Set([...item.hookScripts, ...selected])].sort() });
2255
+ }
2256
+ const fresh = new Map(cleanupScopes(plan.inputs, current, includeModified, includedHooks).map((scope) => [scope.id, scope]));
2056
2257
  let seq = 0;
2057
2258
  const emit = (scope) => { if (events) process.stdout.write(JSON.stringify({ schemaVersion: 1, event: 'scope', seq: ++seq, scope }) + '\n'); };
2058
2259
  const scopes = [];
2059
2260
  for (const reviewed of plan.scopes) {
2060
- const pairs = cleanupProven(reviewed, fresh.get(reviewed.id) || { items: [] });
2261
+ const pairs = cleanupCoupledPairs(cleanupProven(reviewed, fresh.get(reviewed.id) || { items: [] }));
2061
2262
  const progress = { scopeId: reviewed.id, kind: reviewed.kind, name: reviewed.name, total: pairs.length, state: 'running', delivery: null, error: null };
2062
2263
  if (!pairs.length) { scopes.push({ ...progress, state: 'skipped' }); emit(scopes.at(-1)); continue; }
2063
2264
  emit(progress);
@@ -768,6 +768,7 @@
768
768
  "4.3.10",
769
769
  "4.3.11",
770
770
  "4.3.12",
771
+ "4.3.13",
771
772
  "4.3.2",
772
773
  "4.3.3",
773
774
  "4.3.4",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.12",
3
+ "version": "4.3.13",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "devlyn-harness-v4-3-12",
2
+ "name": "devlyn-harness-v4-3-13",
3
3
  "plugins": [
4
4
  {
5
5
  "name": "devlyn",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn",
3
- "version": "4.3.12",
3
+ "version": "4.3.13",
4
4
  "description": "Devlyn harness skills",
5
5
  "skills": "./skills/"
6
6
  }
@@ -0,0 +1,76 @@
1
+ 3.3.0 2022-06-24
2
+ =================
3
+ - `JSONVisitor.onObjectBegin` and `JSONVisitor.onArrayBegin` can now return `false` to instruct the visitor that no children should be visited.
4
+
5
+
6
+ 3.2.0 2022-08-30
7
+ =================
8
+ - update the version of the bundled Javascript files to `es2020`.
9
+ - include all `const enum` values in the bundled JavaScript files (`ScanError`, `SyntaxKind`, `ParseErrorCode`).
10
+
11
+ 3.1.0 2022-07-07
12
+ ==================
13
+ * added new API `FormattingOptions.keepLines` : It leaves the initial line positions in the formatting.
14
+
15
+ 3.0.0 2020-11-13
16
+ ==================
17
+ * fixed API spec for `parseTree`. Can return `undefine` for empty input.
18
+ * added new API `FormattingOptions.insertFinalNewline`.
19
+
20
+
21
+ 2.3.0 2020-07-03
22
+ ==================
23
+ * new API `ModificationOptions.isArrayInsertion`: If `JSONPath` refers to an index of an array and `isArrayInsertion` is `true`, then `modify` will insert a new item at that location instead of overwriting its contents.
24
+ * `ModificationOptions.formattingOptions` is now optional. If not set, newly inserted content will not be formatted.
25
+
26
+
27
+ 2.2.0 2019-10-25
28
+ ==================
29
+ * added `ParseOptions.allowEmptyContent`. Default is `false`.
30
+ * new API `getNodeType`: Returns the type of a value returned by parse.
31
+ * `parse`: Fix issue with empty property name
32
+
33
+ 2.1.0 2019-03-29
34
+ ==================
35
+ * `JSONScanner` and `JSONVisitor` return lineNumber / character.
36
+
37
+ 2.0.0 2018-04-12
38
+ ==================
39
+ * renamed `Node.columnOffset` to `Node.colonOffset`
40
+ * new API `getNodePath`: Gets the JSON path of the given JSON DOM node
41
+ * new API `findNodeAtOffset`: Finds the most inner node at the given offset. If `includeRightBound` is set, also finds nodes that end at the given offset.
42
+
43
+ 1.0.3 2018-03-07
44
+ ==================
45
+ * provide ems modules
46
+
47
+ 1.0.2 2018-03-05
48
+ ==================
49
+ * added the `visit.onComment` API, reported when comments are allowed.
50
+ * added the `ParseErrorCode.InvalidCommentToken` enum value, reported when comments are disallowed.
51
+
52
+ 1.0.1
53
+ ==================
54
+ * added the `format` API: computes edits to format a JSON document.
55
+ * added the `modify` API: computes edits to insert, remove or replace a property or value in a JSON document.
56
+ * added the `allyEdits` API: applies edits to a document
57
+
58
+ 1.0.0
59
+ ==================
60
+ * remove nls dependency (remove `getParseErrorMessage`)
61
+
62
+ 0.4.2 / 2017-05-05
63
+ ==================
64
+ * added `ParseError.offset` & `ParseError.length`
65
+
66
+ 0.4.1 / 2017-04-02
67
+ ==================
68
+ * added `ParseOptions.allowTrailingComma`
69
+
70
+ 0.4.0 / 2017-02-23
71
+ ==================
72
+ * fix for `getLocation`. Now `getLocation` inside an object will always return a property from inside that property. Can be empty string if the object has no properties or if the offset is before a actual property `{ "a": { | }} will return location ['a', ' ']`
73
+
74
+ 0.3.0 / 2017-01-17
75
+ ==================
76
+ * Updating to typescript 2.0
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Microsoft
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.