devlyn-cli 4.3.12 → 4.3.14

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 (33) hide show
  1. package/bin/cleanup-history.json +499 -1
  2. package/bin/devlyn.js +240 -36
  3. package/bin/skill-history.json +3 -0
  4. package/config/skills/_shared/task-complete.py +81 -19
  5. package/config/skills/_shared/task-completion.md +3 -1
  6. package/native/claude/.claude-plugin/plugin.json +1 -1
  7. package/native/claude/skills/_shared/task-complete.py +81 -19
  8. package/native/claude/skills/_shared/task-completion.md +3 -1
  9. package/native/codex/.agents/plugins/marketplace.json +1 -1
  10. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  11. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +81 -19
  12. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +3 -1
  13. package/node_modules/jsonc-parser/CHANGELOG.md +76 -0
  14. package/node_modules/jsonc-parser/LICENSE.md +21 -0
  15. package/node_modules/jsonc-parser/README.md +364 -0
  16. package/node_modules/jsonc-parser/SECURITY.md +41 -0
  17. package/node_modules/jsonc-parser/lib/esm/impl/edit.js +185 -0
  18. package/node_modules/jsonc-parser/lib/esm/impl/format.js +261 -0
  19. package/node_modules/jsonc-parser/lib/esm/impl/parser.js +659 -0
  20. package/node_modules/jsonc-parser/lib/esm/impl/scanner.js +443 -0
  21. package/node_modules/jsonc-parser/lib/esm/impl/string-intern.js +29 -0
  22. package/node_modules/jsonc-parser/lib/esm/main.d.ts +351 -0
  23. package/node_modules/jsonc-parser/lib/esm/main.js +178 -0
  24. package/node_modules/jsonc-parser/lib/umd/impl/edit.js +201 -0
  25. package/node_modules/jsonc-parser/lib/umd/impl/format.js +275 -0
  26. package/node_modules/jsonc-parser/lib/umd/impl/parser.js +682 -0
  27. package/node_modules/jsonc-parser/lib/umd/impl/scanner.js +456 -0
  28. package/node_modules/jsonc-parser/lib/umd/impl/string-intern.js +42 -0
  29. package/node_modules/jsonc-parser/lib/umd/main.d.ts +351 -0
  30. package/node_modules/jsonc-parser/lib/umd/main.js +194 -0
  31. package/node_modules/jsonc-parser/package.json +37 -0
  32. package/package.json +3 -1
  33. package/release.json +110 -15
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);
@@ -1885,13 +2067,17 @@ function cleanupDeliver(scope, items, current) {
1885
2067
  if (!python) return { error: cleanupReason('requirement-missing', { name: 'Python 3.11' }) };
1886
2068
  const run = (args, cwd) => spawnSync(python, ['-B', helper, ...args], { cwd, encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
1887
2069
  const lastJson = (text) => { try { return JSON.parse(text.trim().split('\n').at(-1)); } catch { return null; } };
2070
+ const failureCode = (reason) => /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason) ? 'auth-failed' : 'delivery-failed';
1888
2071
  const short = crypto.randomBytes(4).toString('hex');
1889
2072
  const branch = `devlyn/cleanup-${short}`;
1890
2073
  const worktree = path.join(path.dirname(scope.root), `${path.basename(scope.root)}.devlyn.noindex`, `devlyn-cleanup-${short}`);
1891
2074
  const allocated = lastJson(run(['allocate', '--repo', scope.root, '--task', `devlyn-cli ${PKG.version} legacy cleanup`,
1892
2075
  '--branch', branch, '--worktree', worktree, '--repository', scope.remote.repository,
1893
2076
  '--remote', 'origin', '--base', scope.remote.base], scope.root).stdout);
1894
- if (allocated?.status !== 'ALLOCATED') return { error: cleanupReason('delivery-failed', { detail: allocated?.reason || 'allocation failed' }) };
2077
+ if (allocated?.status !== 'ALLOCATED') {
2078
+ const reason = allocated?.reason || 'allocation failed';
2079
+ return { error: cleanupReason(failureCode(reason), { detail: reason }) };
2080
+ }
1895
2081
  const release = (error = null) => {
1896
2082
  for (const args of [['worktree', 'remove', '--force', worktree], ['branch', '-D', branch]]) {
1897
2083
  const result = cleanupGit(scope.root, args);
@@ -1926,8 +2112,7 @@ function cleanupDeliver(scope, items, current) {
1926
2112
  if (status === 'COMPLETE' || status === 'MERGED') return { ...cleanupUpdateCheckout(scope), delivery: { state: 'merged', url: completed.pr || null } };
1927
2113
  if (completed?.pr) return { delivery: { state: 'pr-open', url: completed.pr } };
1928
2114
  const reason = completed?.reason || 'delivery did not complete';
1929
- const auth = /\b(authentication|unauthorized|gh auth login|could not read Username|HTTP 40[13])\b/i.test(reason);
1930
- return { error: cleanupReason(auth ? 'auth-failed' : 'delivery-failed', { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
2115
+ return { error: cleanupReason(failureCode(reason), { detail: reason, ...(completed?.resume ? { resume: completed.resume } : {}) }) };
1931
2116
  }
1932
2117
 
1933
2118
  // With no fresh counterpart, the base settled an item only if it changed the path's content or
@@ -2001,7 +2186,8 @@ function cleanupUpdateCheckout(scope) {
2001
2186
  // With every reviewed item already settled by the remote base, the worktree is released with
2002
2187
  // nothing to deliver.
2003
2188
  function cleanupDeliveryCommit(worktree, items, current, release, settledUpstream) {
2004
- const fresh = cleanupProjectScope(worktree, current, items.some((item) => item.included));
2189
+ const includedHooks = new Set(items.filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => path.join(worktree, item.relative)));
2190
+ const fresh = cleanupProjectScope(worktree, current, items.some((item) => item.included), includedHooks);
2005
2191
  const available = new Map(fresh.items.map((item) => [`${item.kind}\u0000${item.relative}`, item]));
2006
2192
  const pairs = items.map((reviewed) => {
2007
2193
  const fresh = available.get(`${reviewed.kind}\u0000${reviewed.relative}`);
@@ -2011,13 +2197,23 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
2011
2197
  ? fresh.action !== reviewed.action || fresh.route !== 'delivery'
2012
2198
  || (reviewed.included && (!cleanupCanInclude(fresh) || before.sha256 === null))
2013
2199
  || before.type !== reviewed.before.type || before.sha256 !== reviewed.before.sha256
2200
+ || cleanupCanonical(fresh.hookScripts || []) !== cleanupCanonical(reviewed.hookScripts || [])
2014
2201
  : !settledUpstream(reviewed.relative)));
2015
2202
  // Check the entire batch before mutating even one item; the remote base cannot replace the reviewed preimage.
2016
2203
  if (stale.length) return release(cleanupReason('stale-plan', { detail: stale.map(({ reviewed }) => reviewed.relative).join(', ') }));
2204
+ if (cleanupCoupledPairs(pairs.filter(({ fresh }) => fresh)).some((pair) => !pair.fresh)) return release(cleanupReason('stale-plan'));
2017
2205
  const present = pairs.filter(({ fresh }) => fresh);
2018
2206
  if (!present.length) return release();
2019
2207
  const applied = [];
2208
+ for (const { reviewed, fresh: settings } of present.filter(({ fresh }) => fresh.hookScripts?.length)) {
2209
+ const scripts = present.filter(({ fresh }) => fresh.hookSettings === settings.path)
2210
+ .map(({ reviewed, fresh }) => ({ ...fresh, before: reviewed.before, included: reviewed.included }));
2211
+ const error = cleanupApplyHookGroup({ ...settings, before: reviewed.before }, scripts);
2212
+ if (error) return release(error);
2213
+ applied.push(settings.relative, ...scripts.map((item) => item.relative));
2214
+ }
2020
2215
  for (const { reviewed, fresh } of present) {
2216
+ if (applied.includes(fresh.relative)) continue;
2021
2217
  const error = cleanupApplyItem({ ...fresh, before: reviewed.before, included: reviewed.included });
2022
2218
  if (error) return release(cleanupReason(error.code, { ...error.params, detail: reviewed.relative }));
2023
2219
  applied.push(fresh.relative);
@@ -2052,12 +2248,20 @@ function cleanupDeliveryCommit(worktree, items, current, release, settledUpstrea
2052
2248
  function cleanupApply(plan, { events = false } = {}) {
2053
2249
  const current = cleanupCurrentFingerprints();
2054
2250
  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]));
2251
+ const includedHooks = new Set(plan.scopes.flatMap((scope) => scope.items)
2252
+ .filter((item) => item.kind === 'legacy-hook' && item.included).map((item) => item.path));
2253
+ // Explicit selection of a reviewed edited script also selects its reviewed settings preimage.
2254
+ for (const scope of plan.scopes) for (const item of scope.items) {
2255
+ if (!item.hookCandidates || (item.action === 'skip' && item.reasons[0]?.code !== 'dependent-copy')) continue;
2256
+ const selected = item.hookCandidates.filter((name) => includedHooks.has(path.join(item.base, name)));
2257
+ if (selected.length) Object.assign(item, { action: 'edit', reasons: [], hookScripts: [...new Set([...item.hookScripts, ...selected])].sort() });
2258
+ }
2259
+ const fresh = new Map(cleanupScopes(plan.inputs, current, includeModified, includedHooks).map((scope) => [scope.id, scope]));
2056
2260
  let seq = 0;
2057
2261
  const emit = (scope) => { if (events) process.stdout.write(JSON.stringify({ schemaVersion: 1, event: 'scope', seq: ++seq, scope }) + '\n'); };
2058
2262
  const scopes = [];
2059
2263
  for (const reviewed of plan.scopes) {
2060
- const pairs = cleanupProven(reviewed, fresh.get(reviewed.id) || { items: [] });
2264
+ const pairs = cleanupCoupledPairs(cleanupProven(reviewed, fresh.get(reviewed.id) || { items: [] }));
2061
2265
  const progress = { scopeId: reviewed.id, kind: reviewed.kind, name: reviewed.name, total: pairs.length, state: 'running', delivery: null, error: null };
2062
2266
  if (!pairs.length) { scopes.push({ ...progress, state: 'skipped' }); emit(scopes.at(-1)); continue; }
2063
2267
  emit(progress);
@@ -52,6 +52,7 @@
52
52
  "40f50f3c3c0d030d1efeb6955c0c6f32f518e737d8dd3bec6610ff02badebbdb",
53
53
  "430d156eb8c62c7edba3d660050f90b267f928976c3a3688e95d54f00dc34747",
54
54
  "450d578ef177a9e8f0b693940ad64b41c1d9719975ba6305f62a7c82ac3a892f",
55
+ "45ef27af9ec27089e2861c13b882521fc6bf073dc6b7a356b326b4a27c5e6e24",
55
56
  "4685be6a94ad5587ce53785c14a60cccf9a852d9d23391dba0a5bcd0c64931cf",
56
57
  "484db9dd2635de5f5acbcf692e2f329f33c0a483951e27bcd77bcbafbc052e19",
57
58
  "4ecb004b24a8adfe41c7416c3fa24e62cf76baa3b276ae1ce5091caef77e57b8",
@@ -768,6 +769,8 @@
768
769
  "4.3.10",
769
770
  "4.3.11",
770
771
  "4.3.12",
772
+ "4.3.13",
773
+ "4.3.14",
771
774
  "4.3.2",
772
775
  "4.3.3",
773
776
  "4.3.4",
@@ -324,10 +324,12 @@ def allocate(args, *, exclude_receipts=(), warnings=None):
324
324
  if local:
325
325
  receipt["local_only"] = True # Local work needs no remote: nothing is fetched or pushed.
326
326
  else:
327
- require(args.repository and args.base, "a remote allocation needs --repository <owner/repo> and --base <branch>; allocate local work with --local-base or --from-receipt")
327
+ if args.repository is None:
328
+ receipt["repository"] = repository_from_url(gref(receipt, "remote", "get-url", "--all", args.remote))
328
329
  policy(receipt, None)
329
330
  receipt["remote_url"] = remote_url(receipt)
330
331
  require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
332
+ repo_policy(receipt, allocating=True)
331
333
  target = Path(args.worktree).resolve() # A relative, `..` or symlinked (macOS /tmp) path names its real location.
332
334
  require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
333
335
  receipt["worktree"] = str(target)
@@ -541,9 +543,17 @@ def repository_info(repository):
541
543
  return info
542
544
 
543
545
 
544
- def repo_policy(receipt):
546
+ def repo_policy(receipt, *, allocating=False):
545
547
  info = repository_info(receipt["repository"])
546
- require(info["defaultBranchRef"]["name"] == receipt["base"] and receipt["branch"] != info["defaultBranchRef"]["name"], "base/default branch changed; retain resources")
548
+ default = info["defaultBranchRef"]["name"]
549
+ if allocating:
550
+ if receipt["base"] is None:
551
+ gref(receipt, "check-ref-format", "refs/heads/" + default)
552
+ receipt["base"] = default
553
+ require(receipt["branch"] != receipt["base"], "cannot own a base/default branch")
554
+ require(default == receipt["base"] and receipt["branch"] != default,
555
+ f"base {receipt['base']!r} differs from default branch {default!r}; the helper delivers only to the default branch; use a local allocation with --local-base for other bases"
556
+ if allocating else "base/default branch changed; retain resources")
547
557
  return info
548
558
 
549
559
 
@@ -1409,7 +1419,8 @@ class CompletionTests(unittest.TestCase):
1409
1419
  from unittest.mock import patch
1410
1420
  target = self.root / "absent" / "new"
1411
1421
  exists = Path.exists
1412
- with patch.object(Path, "exists", lambda path: False if path == target or path in target.parents else exists(path)), \
1422
+ with patch.dict(os.environ, self.env), \
1423
+ patch.object(Path, "exists", lambda path: False if path == target or path in target.parents else exists(path)), \
1413
1424
  patch.object(shutil, "disk_usage") as usage:
1414
1425
  with self.assertRaisesRegex(CompletionError, f"no existing ancestor of {re.escape(str(target))} to measure free space"):
1415
1426
  allocate(self.allocation_args("new", target))
@@ -1677,6 +1688,62 @@ class CompletionTests(unittest.TestCase):
1677
1688
  self.g("--no-literal-pathspecs", "check-ignore", "-q", "--", record, work=self.task)
1678
1689
  self.assertEqual(self.g("status", "--porcelain", "--untracked-files=all", work=self.task), "")
1679
1690
 
1691
+ def test_allocate_derives_repository(self):
1692
+ result, _ = self.cli("allocate", "--repo", self.work, "--task", "fixture",
1693
+ "--branch", "task/fixture", "--base", "main", "--worktree", self.root / "linked")
1694
+ self.assertEqual(result["status"], "ALLOCATED")
1695
+ self.assertEqual(read_json(Path(result["receipt"]))["repository"], "test/project")
1696
+
1697
+ def test_allocate_derives_default_base(self):
1698
+ self.g("push", "origin", "main:refs/heads/release")
1699
+ self.configure(default_branch="release")
1700
+ for name, flags in (("explicit", ["--repository", "test/project"]), ("derived", [])):
1701
+ with self.subTest(name=name):
1702
+ result, _ = self.cli("allocate", "--repo", self.work, "--task", name,
1703
+ "--branch", "task/" + name, "--worktree", self.root / name, *flags)
1704
+ self.assertEqual(result["status"], "ALLOCATED")
1705
+ receipt = read_json(Path(result["receipt"]))
1706
+ self.assertEqual((receipt["repository"], receipt["base"]), ("test/project", "release"))
1707
+ self.assertEqual(self.g("rev-parse", "HEAD", work=Path(result["worktree"])), receipt["baseline"])
1708
+
1709
+ def test_allocate_rejects_nondefault_base_before_resources(self):
1710
+ self.configure(default_branch="release")
1711
+ self.g("update-ref", "-d", "refs/remotes/origin/main")
1712
+ refs, worktrees = self.g("show-ref"), self.g("worktree", "list", "--porcelain")
1713
+ result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
1714
+ "--branch", "task/fixture", "--repository", "test/project", "--base", "main",
1715
+ "--worktree", self.root / "linked", success=False)
1716
+ self.assertNotEqual(process.returncode, 0)
1717
+ self.assertEqual(result["status"], "BLOCKED")
1718
+ for text in ("main", "release", "only to the default branch", "--local-base"):
1719
+ self.assertIn(text, result["reason"])
1720
+ self.assertEqual(self.g("show-ref"), refs)
1721
+ self.assertEqual(self.g("worktree", "list", "--porcelain"), worktrees)
1722
+ self.assertFalse((self.work / ".git/devlyn-completion").exists())
1723
+ self.assertFalse((self.root / "linked").exists())
1724
+
1725
+ def test_allocate_rejects_derived_base_as_task_branch(self):
1726
+ self.configure(default_branch="release")
1727
+ refs = self.g("show-ref")
1728
+ result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
1729
+ "--branch", "release", "--worktree", self.root / "linked", success=False)
1730
+ self.assertNotEqual(process.returncode, 0)
1731
+ self.assertEqual(result["status"], "BLOCKED")
1732
+ self.assertIn("cannot own a base/default branch", result["reason"])
1733
+ self.assertEqual(self.g("show-ref"), refs)
1734
+ self.assertEqual(self.g("branch", "--list", "release"), "")
1735
+ self.assertFalse((self.work / ".git/devlyn-completion").exists())
1736
+ self.assertFalse((self.root / "linked").exists())
1737
+
1738
+ def test_allocate_rejects_explicit_repository_mismatch(self):
1739
+ result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
1740
+ "--branch", "task/fixture", "--repository", "test/other", "--base", "main",
1741
+ "--worktree", self.root / "linked", success=False)
1742
+ self.assertNotEqual(process.returncode, 0)
1743
+ self.assertEqual(result, {"status": "BLOCKED", "reason": "repository differs from remote"})
1744
+ self.assertFalse((self.work / ".git/devlyn-completion").exists())
1745
+ self.assertFalse((self.root / "linked").exists())
1746
+
1680
1747
  def test_allocate_base_with_same_named_tag(self):
1681
1748
  self.g("tag", "main")
1682
1749
  result = self.allocate()
@@ -1687,6 +1754,7 @@ class CompletionTests(unittest.TestCase):
1687
1754
  base = "main\u00a0"
1688
1755
  self.g("switch", "-c", base)
1689
1756
  self.g("push", "origin", "HEAD:refs/heads/"+base)
1757
+ self.configure(default_branch=base)
1690
1758
  result = self.allocate(base=base)
1691
1759
  self.assertEqual(result["status"], "ALLOCATED")
1692
1760
  self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
@@ -1711,6 +1779,7 @@ class CompletionTests(unittest.TestCase):
1711
1779
  def test_allocate_rejects_short_base_alias(self):
1712
1780
  self.g("branch", "heads/main")
1713
1781
  self.g("tag", "main")
1782
+ self.configure(default_branch="heads/main")
1714
1783
  self.assert_base_identity_rejected("main", "heads/main")
1715
1784
 
1716
1785
  def test_allocate_uses_remote_base_with_dirty_foreign_anchor(self):
@@ -2295,7 +2364,8 @@ ctypes.CDLL(None).pthread_exit(None)
2295
2364
  self.merge_pr()
2296
2365
  receipt, task = self.receipt, self.task
2297
2366
  self.configure(default_branch="changed")
2298
- result = self.allocate("next")
2367
+ self.g("push", "origin", "main:refs/heads/changed")
2368
+ result = self.allocate("next", base="changed")
2299
2369
  self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
2300
2370
  self.assertIn("base/default branch changed", result["reconciled"][0]["reason"])
2301
2371
  self.assertTrue(task.exists())
@@ -2398,12 +2468,13 @@ ctypes.CDLL(None).pthread_exit(None)
2398
2468
  self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
2399
2469
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
2400
2470
  self.configure(default_branch="changed")
2471
+ self.g("push", "origin", "main:refs/heads/changed")
2401
2472
  server = json.loads(self.data.read_text())
2402
- result = self.allocate("retry")
2473
+ result = self.allocate("retry", base="changed")
2403
2474
  self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
2404
2475
  self.assertFalse(any(scratch.iterdir()))
2405
2476
  self.assertEqual(json.loads(self.data.read_text()), server)
2406
- self.assertEqual(self.allocate("later")["reconciled"], [])
2477
+ self.assertEqual(self.allocate("later", base="changed")["reconciled"], [])
2407
2478
 
2408
2479
  def test_reconcile_retries_unattempted_scratch_cleanup(self):
2409
2480
  scratch = Path(self.allocate()["scratch"])
@@ -3158,14 +3229,10 @@ ctypes.CDLL(None).pthread_exit(None)
3158
3229
  self.assertFalse((self.root / "next").exists())
3159
3230
 
3160
3231
  def test_local_allocation_needs_no_repository(self):
3161
- """E4. Prediction: without --repository, a remote allocation is refused naming it, with nothing created, while a
3162
- local allocation, here beside an origin that is not GitHub, is ALLOCATED and its local-only completion returns
3163
- LOCAL_ONLY with nothing pushed. Before: argparse required --repository, so both failed as an argparse error."""
3232
+ """A local allocation beside a non-GitHub origin needs no repository and completes without publication."""
3164
3233
  args = ["allocate", "--repo", self.work, "--base", "main"]
3165
- refused, _ = self.cli(*args, "--task", "next", "--branch", "task/next", "--worktree", self.root / "next", success=False)
3166
- self.assertIn("--repository", refused["reason"])
3167
- self.assertEqual((self.g("branch", "--list", "task/next"), (self.root / "next").exists()), ("", False))
3168
3234
  self.g("remote", "set-url", "origin", "https://gitlab.com/team/project.git")
3235
+ (self.bin / "gh").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
3169
3236
  result, _ = self.cli(*args, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked",
3170
3237
  "--local-base", self.g("rev-parse", "HEAD"))
3171
3238
  self.assertEqual(result["status"], "ALLOCATED")
@@ -3175,13 +3242,8 @@ ctypes.CDLL(None).pthread_exit(None)
3175
3242
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs", 0), 0)
3176
3243
 
3177
3244
  def test_local_allocation_needs_no_base(self):
3178
- """P4-2. Prediction: a local allocation with --local-base and no --base, the form agents wrote in 3 of 3 timed
3179
- runs, is ALLOCATED and completes LOCAL_ONLY, while a remote allocation without --base is refused naming it, with
3180
- nothing created."""
3245
+ """A local allocation with --local-base and no --base is ALLOCATED and completes LOCAL_ONLY."""
3181
3246
  args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--worktree", self.root / "linked"]
3182
- refused, _ = self.cli(*args, "--repository", "test/project", success=False)
3183
- self.assertIn("--base", refused["reason"])
3184
- self.assertEqual((self.g("branch", "--list", "task/fixture"), (self.root / "linked").exists()), ("", False))
3185
3247
  result, _ = self.cli(*args, "--local-base", self.g("rev-parse", "HEAD"))
3186
3248
  self.assertEqual(result["status"], "ALLOCATED")
3187
3249
  self.receipt, self.task = Path(result["receipt"]), Path(result["worktree"])
@@ -55,9 +55,11 @@ not write to the shared checkout.
55
55
  python3 "$DEVLYN_SHARED_DIR/task-complete.py" allocate --repo . \
56
56
  --task '<task identity>' --branch '<absent task branch>' \
57
57
  --worktree '<repo parent>/.devlyn-worktrees/<repo name>/<task directory>' \
58
- --repository '<owner/repo>' --remote origin --base main
58
+ --remote origin
59
59
  ```
60
60
 
61
+ The repository comes from the remote's GitHub URL and the base from the repository's default branch; passing either flag checks it instead.
62
+ A non-default base is refused at allocation.
61
63
  Every delivery allocation owns a linked worktree; `--worktree` is required and
62
64
  must name an absent path.
63
65
  Cleanup removes empty parents through the exact per-repo root shown above or its legacy `.devlyn.noindex`/`.devlyn` root, keeping the shared `.devlyn-worktrees` container. Allocation also sweeps empty legacy roots for this repo.