hypomnema 1.7.4 → 1.8.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +1202 -0
- package/README.ko.md +4 -4
- package/README.md +5 -5
- package/commands/audit.md +1 -1
- package/commands/capture.md +1 -1
- package/commands/crystallize.md +3 -3
- package/commands/doctor.md +15 -1
- package/commands/feedback.md +1 -1
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +1 -1
- package/commands/upgrade.md +2 -2
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +8 -3
- package/docs/CONTRIBUTING.md +2 -1
- package/hooks/base-store.mjs +198 -0
- package/hooks/close-gate-store.mjs +3 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +10 -0
- package/hooks/hypo-compact-guard.mjs +5 -3
- package/hooks/hypo-cwd-change.mjs +8 -0
- package/hooks/hypo-personal-check.mjs +127 -67
- package/hooks/hypo-session-start.mjs +91 -10
- package/hooks/hypo-shared.mjs +241 -19
- package/hooks/version-check.mjs +44 -6
- package/package.json +9 -3
- package/scripts/capture.mjs +49 -17
- package/scripts/crystallize.mjs +20 -2092
- package/scripts/doctor.mjs +334 -14
- package/scripts/init.mjs +106 -81
- package/scripts/lib/crystallize-args.mjs +50 -0
- package/scripts/lib/crystallize-close-apply.mjs +1830 -0
- package/scripts/lib/crystallize-close-check.mjs +238 -0
- package/scripts/lib/crystallize-close-gate.mjs +58 -0
- package/scripts/lib/crystallize-helpers.mjs +55 -0
- package/scripts/lib/extensions.mjs +175 -49
- package/scripts/lib/git-hooks-dir.mjs +88 -3
- package/scripts/lib/plugin-detect.mjs +176 -21
- package/scripts/lint.mjs +12 -6
- package/scripts/proposal.mjs +3 -3
- package/scripts/upgrade.mjs +243 -20
- package/skills/crystallize/SKILL.md +15 -13
- package/skills/graph/SKILL.md +3 -3
- package/skills/ingest/SKILL.md +2 -2
- package/skills/lint/SKILL.md +3 -3
- package/skills/query/SKILL.md +3 -3
- package/skills/verify/SKILL.md +3 -3
- package/templates/hypo-automation.md +59 -17
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +12 -9
- package/templates/hypo-help.md +1 -1
package/hooks/hypo-shared.mjs
CHANGED
|
@@ -1063,6 +1063,63 @@ export function collectProjectWorkingDirs(hypoDir) {
|
|
|
1063
1063
|
return out;
|
|
1064
1064
|
}
|
|
1065
1065
|
|
|
1066
|
+
// One place that answers "is this string a slug naming a real project directory?".
|
|
1067
|
+
// Both resolveGateProjectOverride (a caller-supplied --project) and the gate's
|
|
1068
|
+
// mustEvaluate filter (slugs arriving from a transcript or a marker) need it, and
|
|
1069
|
+
// having the grammar in two inline copies is the duplication this release spent a
|
|
1070
|
+
// codex round removing elsewhere. Not exported: scripts/ has its own validator,
|
|
1071
|
+
// and importing that here would invert the one-way hooks/ <- scripts/ dependency.
|
|
1072
|
+
function isProjectSlugDirectory(hypoDir, slug) {
|
|
1073
|
+
if (typeof slug !== 'string' || !/^[A-Za-z0-9._-]+$/.test(slug)) return false;
|
|
1074
|
+
if (/^\.+$/.test(slug) || !/[A-Za-z0-9]/.test(slug)) return false;
|
|
1075
|
+
try {
|
|
1076
|
+
return statSync(join(hypoDir, 'projects', slug)).isDirectory();
|
|
1077
|
+
} catch {
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// session-close-scope-boundary spec §2: the one place that turns a caller-
|
|
1083
|
+
// supplied project name into precompactGateStatus's opts.projectOverride. Four
|
|
1084
|
+
// call sites share it (--mark-session-closed, apply's auto-marker, PreCompact,
|
|
1085
|
+
// Stop) so a foreign project's dangling close files demote to a notice instead
|
|
1086
|
+
// of blocking a session that never touched them, everywhere the gate runs.
|
|
1087
|
+
//
|
|
1088
|
+
// `project` is trusted nowhere upstream of this function: crystallize.mjs's
|
|
1089
|
+
// own --project only gets slug-syntax + directory-existence checked when the
|
|
1090
|
+
// CLI itself parses it (parseArgs + requireProjectDir); a value that arrives
|
|
1091
|
+
// via a hook payload or apply's JSON body never passes through that. So this
|
|
1092
|
+
// re-validates from scratch — same charset rule as project-create.mjs's
|
|
1093
|
+
// isValidProjectName, duplicated rather than imported because hooks/ cannot
|
|
1094
|
+
// import scripts/ (dependency direction is one-way) — plus a directory
|
|
1095
|
+
// existence check. Either failure returns null, the same as "no project was
|
|
1096
|
+
// given": a garbage slug must never narrow the gate's scope, only a real one
|
|
1097
|
+
// may (spec success criterion: "opts.projectOverride 가 없는 slug 이면 게이트가
|
|
1098
|
+
// 그것을 거절한다").
|
|
1099
|
+
//
|
|
1100
|
+
// With no `project`, the override is inferred from `sessionCwd` via
|
|
1101
|
+
// pickProjectByCwd's rejectAmbiguous mode — the same mode the gate's own
|
|
1102
|
+
// close-cwd check already trusts. Without rejectAmbiguous, two projects
|
|
1103
|
+
// sharing one working_dir (a monorepo with no uniqueness invariant) would
|
|
1104
|
+
// silently resolve to the first slug and could narrow the gate to a project
|
|
1105
|
+
// whose OWN close is still incomplete while the real cwd project goes
|
|
1106
|
+
// unchecked. A decline here (null) is not a failure: precompactGateStatus
|
|
1107
|
+
// falls back to the global, unscoped judgment, which is exactly the STOP-hook
|
|
1108
|
+
// call site's actual point (session-close-scope-boundary spec §2: "해석 실패면
|
|
1109
|
+
// 기존 fail-closed 가 받아 준다는 틀렸다 ... cwd 미해결을 마커 차단으로 만들지 않는다").
|
|
1110
|
+
//
|
|
1111
|
+
// @param {string} hypoDir
|
|
1112
|
+
// @param {{project?: string|null, sessionCwd?: string|null}} [opts]
|
|
1113
|
+
// @returns {string|null}
|
|
1114
|
+
export function resolveGateProjectOverride(hypoDir, { project = null, sessionCwd = null } = {}) {
|
|
1115
|
+
if (project) {
|
|
1116
|
+
return isProjectSlugDirectory(hypoDir, project) ? project : null;
|
|
1117
|
+
}
|
|
1118
|
+
return pickProjectByCwd(collectProjectWorkingDirs(hypoDir), sessionCwd, {
|
|
1119
|
+
rejectAmbiguous: true,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1066
1123
|
// A project without a working_dir anchor (no index.md, or an index.md missing
|
|
1067
1124
|
// the field) never enters collectProjectWorkingDirs' universe, so cwd-first
|
|
1068
1125
|
// resume always MISSes for it — the SessionStart/CwdChanged MISS branch would
|
|
@@ -1451,7 +1508,7 @@ function closeCandidateSlugs(hypoDir, dates) {
|
|
|
1451
1508
|
// slug yields a token that no longer maps to a `projects/<slug>/`
|
|
1452
1509
|
// directory — gating on disk keeps it out of the dangling-close set.
|
|
1453
1510
|
// Directory (not bare-exists) mirrors the apply-path project check
|
|
1454
|
-
// (crystallize.mjs
|
|
1511
|
+
// (crystallize-args.mjs).
|
|
1455
1512
|
const dir = join(projectsDir, m[1]);
|
|
1456
1513
|
if (existsSync(dir) && statSync(dir).isDirectory()) slugs.add(m[1]);
|
|
1457
1514
|
}
|
|
@@ -1526,10 +1583,17 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
|
1526
1583
|
// projectOverride (check-only): the caller (`crystallize --check-session-close
|
|
1527
1584
|
// --project=<slug>`) wants THIS project's close status, not the recency pick —
|
|
1528
1585
|
// bypass discovery and report the single project, preserving the global return
|
|
1529
|
-
// shape.
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1586
|
+
// shape. This is the ONLY caller of projectOverride now: the four marker-writing
|
|
1587
|
+
// paths (--mark / apply auto-marker / PreCompact / Stop) attribute through
|
|
1588
|
+
// `opts.attributionScope` instead (resolveCloseScope, below this function), which
|
|
1589
|
+
// only widens the mine/foreign partition's scope Set and leaves this function
|
|
1590
|
+
// evaluating every project, same as an unscoped call. A green status here is a
|
|
1591
|
+
// project-scoped diagnostic, not the global compact-readiness verdict. The
|
|
1592
|
+
// invariant that once justified keeping the writer paths fully unscoped
|
|
1593
|
+
// ("marker == compact-ready", codex design review) is superseded by that
|
|
1594
|
+
// partition, not restored by it — a marker's own `verified_scope`, attesting
|
|
1595
|
+
// exactly what it checked, is still missing (session-close-scope-boundary
|
|
1596
|
+
// spec §3, next wave).
|
|
1533
1597
|
if (opts.projectOverride) {
|
|
1534
1598
|
const s = sessionCloseFileStatus(hypoDir, { projectOverride: opts.projectOverride });
|
|
1535
1599
|
return {
|
|
@@ -1547,11 +1611,30 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
|
1547
1611
|
}
|
|
1548
1612
|
const dates = freshDates();
|
|
1549
1613
|
const recency = resolveActiveProject(hypoDir); // no cwd — close never picks by cwd
|
|
1550
|
-
|
|
1614
|
+
// opts.mustEvaluate: slugs this session ATTRIBUTED its close to (explicit
|
|
1615
|
+
// --project, transcript close-file edits, a prior marker). They are evaluated
|
|
1616
|
+
// even when they are not today-active candidates, because the marker writer
|
|
1617
|
+
// stamps the attribution scope: a slug that reaches the marker but never
|
|
1618
|
+
// reached this evaluation would be attested as closed without ever being
|
|
1619
|
+
// checked (codex pre-commit BLOCKER, reproduced with an empty projects/orphan/).
|
|
1620
|
+
// `projects/<slug>/` must exist on disk; a slug naming a directory that is gone
|
|
1621
|
+
// (a stale marker inside its 7-day TTL) is dropped rather than turned into a
|
|
1622
|
+
// permanent blocker for a project nobody can close any more.
|
|
1623
|
+
// existsSync alone would admit a file, or a traversal like '..', from a
|
|
1624
|
+
// malformed marker or transcript slug and turn it into a surprise blocker.
|
|
1625
|
+
// Same slug grammar the CLI and resolveGateProjectOverride validate against.
|
|
1626
|
+
const mustEvaluate = [...(opts.mustEvaluate || [])].filter((p) =>
|
|
1627
|
+
isProjectSlugDirectory(hypoDir, p),
|
|
1628
|
+
);
|
|
1629
|
+
// The fallback decision reads REAL today activity only. Letting mustEvaluate
|
|
1630
|
+
// populate it would mean an attributed slug suppresses the "this session closed
|
|
1631
|
+
// nothing at all" branch, and that branch is a fail-closed guard the partition
|
|
1632
|
+
// below depends on (`!close.fallback`).
|
|
1633
|
+
const activeCandidates = [...closeCandidateSlugs(hypoDir, dates)].filter((p) =>
|
|
1551
1634
|
hasTodayCloseActivity(hypoDir, p, dates),
|
|
1552
1635
|
);
|
|
1553
1636
|
|
|
1554
|
-
if (
|
|
1637
|
+
if (activeCandidates.length === 0) {
|
|
1555
1638
|
const legacy = sessionCloseFileStatus(hypoDir);
|
|
1556
1639
|
return {
|
|
1557
1640
|
ok: legacy.ok,
|
|
@@ -1567,6 +1650,8 @@ export function sessionCloseGlobalStatus(hypoDir, opts = {}) {
|
|
|
1567
1650
|
};
|
|
1568
1651
|
}
|
|
1569
1652
|
|
|
1653
|
+
const todayActive = [...new Set([...activeCandidates, ...mustEvaluate])];
|
|
1654
|
+
|
|
1570
1655
|
// primary = the recency project when it is itself today-active, else the first
|
|
1571
1656
|
// today-active slug (stable order from the candidate set). Used only as the
|
|
1572
1657
|
// single-slug alias for the message header and the flat `close.project` field.
|
|
@@ -3504,6 +3589,12 @@ export function partitionLintScope(findings, scope) {
|
|
|
3504
3589
|
* every close. Same path policy as the lint-scope matcher above: separators
|
|
3505
3590
|
* normalized to POSIX, exact prefix at a segment boundary.
|
|
3506
3591
|
*/
|
|
3592
|
+
// This function keeps the posixPath() normalization deliberately, unlike
|
|
3593
|
+
// isForeign (§2b), which reads the raw porcelain path so a literal-backslash
|
|
3594
|
+
// filename cannot be misread as a nested projects/<slug>/ path. Every caller
|
|
3595
|
+
// of isUnderProjectDirs would need to be checked before applying the same
|
|
3596
|
+
// raw-path treatment here, and that audit has not been done, so the existing
|
|
3597
|
+
// behavior stays until it has.
|
|
3507
3598
|
export function isUnderProjectDirs(file, slugs) {
|
|
3508
3599
|
const f = posixPath(file);
|
|
3509
3600
|
return (slugs || []).some((s) => s && (f === `projects/${s}` || f.startsWith(`projects/${s}/`)));
|
|
@@ -3550,6 +3641,15 @@ function projectsFromTouchedCloseFiles(transcriptPath, hypoDir) {
|
|
|
3550
3641
|
* the `project` that same writer recorded, so the two can never disagree.
|
|
3551
3642
|
*/
|
|
3552
3643
|
export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
|
|
3644
|
+
// `direct` is the subset attributable to THIS close attempt: explicit
|
|
3645
|
+
// --project/closeScope, this session's transcript close-file edits, and the
|
|
3646
|
+
// resolved attributionScope. A marker's `projects` is PAST evidence of a close
|
|
3647
|
+
// that already happened, so it belongs in the partition scope (it must not be
|
|
3648
|
+
// demoted as foreign debt) but NOT in the mandatory-evaluation set: a 7-day-TTL
|
|
3649
|
+
// marker naming a project that is no longer today-active would otherwise make
|
|
3650
|
+
// that project's stale close block an unrelated close today (codex round 2,
|
|
3651
|
+
// reproduced with a 24h-old marker). The returned Set carries `direct` as a
|
|
3652
|
+
// property so the caller does not re-derive it, and the transcript is parsed once.
|
|
3553
3653
|
const scope = new Set(opts.closeScope || []);
|
|
3554
3654
|
for (const p of projectsFromTouchedCloseFiles(opts.transcriptPath, hypoDir)) scope.add(p);
|
|
3555
3655
|
// Marker attribution (session-close attribution). A v4 marker carries `projects` — the
|
|
@@ -3561,6 +3661,20 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
|
|
|
3561
3661
|
// above (explicit close scope or transcript close-file edits) already corroborate
|
|
3562
3662
|
// the SAME slug; otherwise it stays a display hint. This narrow gap self-heals as
|
|
3563
3663
|
// pre-v4 markers expire (7-day TTL).
|
|
3664
|
+
// opts.attributionScope (session-close-scope-boundary spec §2/§3): a fourth
|
|
3665
|
+
// signal, one slug, from resolveGateProjectOverride. A cwd that unambiguously
|
|
3666
|
+
// owns one project, or an explicit --project the caller already validated, is
|
|
3667
|
+
// evidence this session is closing THAT project — the same standing the three
|
|
3668
|
+
// signals above already have. It is the opt key the four marker-writing paths
|
|
3669
|
+
// (PreCompact, Stop, --mark-session-closed, apply's auto-marker) pass instead
|
|
3670
|
+
// of `opts.projectOverride`: that key stays the check-only diagnostic's (it
|
|
3671
|
+
// also narrows sessionCloseGlobalStatus's own evaluation and turns off the
|
|
3672
|
+
// partition below), while this one only widens the set the partition demotes
|
|
3673
|
+
// everything outside of.
|
|
3674
|
+
if (opts.attributionScope) scope.add(opts.attributionScope);
|
|
3675
|
+
// Snapshot the direct signals BEFORE the marker union below widens the set.
|
|
3676
|
+
scope.direct = new Set(scope);
|
|
3677
|
+
|
|
3564
3678
|
if (Array.isArray(marker?.projects)) {
|
|
3565
3679
|
for (const p of marker.projects) if (p) scope.add(p);
|
|
3566
3680
|
}
|
|
@@ -3590,12 +3704,22 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
|
|
|
3590
3704
|
* opts.projectOverride (CHECK-ONLY) narrows BOTH the close status and the lint
|
|
3591
3705
|
* scope to a single project, for `--check-session-close --project=<slug>`. A
|
|
3592
3706
|
* green result is then a project-scoped diagnostic, NOT the global compact-ready
|
|
3593
|
-
* verdict: the caller must surface the scope.
|
|
3594
|
-
*
|
|
3595
|
-
*
|
|
3596
|
-
*
|
|
3597
|
-
*
|
|
3598
|
-
*
|
|
3707
|
+
* verdict: the caller must surface the scope. The four marker-writing paths
|
|
3708
|
+
* (--mark / apply auto-marker / PreCompact / Stop) never set this — they pass
|
|
3709
|
+
* `opts.attributionScope` instead, which only feeds resolveCloseScope's set and
|
|
3710
|
+
* leaves this status evaluating every project, so the mine/foreign partition
|
|
3711
|
+
* below can tell their own incomplete close from someone else's debt. The
|
|
3712
|
+
* invariant that once justified keeping those paths fully unscoped ("marker ==
|
|
3713
|
+
* compact-ready", codex design review) is replaced by that partition, not
|
|
3714
|
+
* restored by it — a marker's own `verified_scope`, attesting exactly what it
|
|
3715
|
+
* checked, is still missing (session-close-scope-boundary spec §3, next wave).
|
|
3716
|
+
* Either key ALSO feeds the git-dirty partition (spec §2b): with an untrusted
|
|
3717
|
+
* transcript (missing/corrupt), a dirty file structurally under a DIFFERENT
|
|
3718
|
+
* eligible project's own directory is demoted to a notice, because the path
|
|
3719
|
+
* alone proves it is not this session's; everything else keeps blocking.
|
|
3720
|
+
* When a log-only marker governs the session, log-only mode wins for the
|
|
3721
|
+
* close-status and lint scope (scope selection above); the git-dirty
|
|
3722
|
+
* partition in spec §2b still applies regardless of projectOverride.
|
|
3599
3723
|
*
|
|
3600
3724
|
* @param {string} hypoDir
|
|
3601
3725
|
* opts.sessionCwd (session-cwd close check): the authoritative cwd of the session being
|
|
@@ -3606,7 +3730,7 @@ export function resolveCloseScope(hypoDir, opts = {}, marker = null) {
|
|
|
3606
3730
|
* cwd yields a best-effort notice, not a block. apply never passes it (its launch
|
|
3607
3731
|
* cwd may differ from the authoritative payload.project).
|
|
3608
3732
|
*
|
|
3609
|
-
* @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
|
|
3733
|
+
* @param {{lintScope?: Iterable<string>, transcriptPath?: string|null, claudeHome?: string, projectOverride?: string|null, attributionScope?: string|null, sessionCwd?: string|null, sessionId?: string|null, logOnly?: boolean, closeScope?: string[]}} [opts]
|
|
3610
3734
|
* @returns {{ok: boolean, close: object, blockers: {type:string,reason:string}[], notices: {type:string,reason:string,file?:string}[], driftTargets: string[], skipped: {lint:boolean, feedback:boolean}}}
|
|
3611
3735
|
*/
|
|
3612
3736
|
export function precompactGateStatus(hypoDir, opts = {}) {
|
|
@@ -3656,10 +3780,25 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3656
3780
|
// would make almost every real transcript untrusted and defeat the scoping
|
|
3657
3781
|
// this fix exists to add.
|
|
3658
3782
|
let sessionTouchTrusted = false;
|
|
3783
|
+
// Transcript-derived files ONLY (not closeAccountableScope's mandatory-files
|
|
3784
|
+
// base, which comes from closeFileTargetsGlobal and would fold every close
|
|
3785
|
+
// file of every eligible project into "this session touched it"). §2b's
|
|
3786
|
+
// isForeign below needs exactly this narrower set: a file this session's own
|
|
3787
|
+
// Edit/Write proves it touched, even on a PARTIALLY-parsed transcript (the
|
|
3788
|
+
// trailing line of an append-in-progress JSONL truncates in the ordinary
|
|
3789
|
+
// course of a live session, not as an exceptional failure), must never be
|
|
3790
|
+
// classified foreign by path prefix alone. Widening this to
|
|
3791
|
+
// closeAccountableScope would re-open the exact foreign-close-file leak
|
|
3792
|
+
// (b) fixed above (a genuinely different project's close file would count
|
|
3793
|
+
// as "touched" just because it shares the mandatory-files base).
|
|
3794
|
+
let transcriptTouched = new Set();
|
|
3659
3795
|
if (opts.transcriptPath) {
|
|
3660
3796
|
const widened = extractTouchedWikiFilesWithTrust(opts.transcriptPath, hypoDir);
|
|
3661
3797
|
sessionTouchTrusted = widened.trusted;
|
|
3662
|
-
for (const f of widened.files)
|
|
3798
|
+
for (const f of widened.files) {
|
|
3799
|
+
closeAccountableScope.add(f);
|
|
3800
|
+
transcriptTouched.add(f);
|
|
3801
|
+
}
|
|
3663
3802
|
}
|
|
3664
3803
|
|
|
3665
3804
|
// 1. wiki git state. Uncommitted changes (real unsaved work) BLOCK, but only
|
|
@@ -3686,7 +3825,72 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3686
3825
|
const git = hypoIsClean(hypoDir);
|
|
3687
3826
|
if (git.uncommitted) {
|
|
3688
3827
|
const dirty = gitDirtyFiles(hypoDir);
|
|
3689
|
-
if (dirty.length === 0
|
|
3828
|
+
if (dirty.length === 0) {
|
|
3829
|
+
// Enumeration itself failed (or nothing is actually dirty despite the
|
|
3830
|
+
// porcelain status): "cannot attribute" is not "clean", block exactly
|
|
3831
|
+
// as before, override or not.
|
|
3832
|
+
blockers.push({ type: 'git', reason: git.reason });
|
|
3833
|
+
} else if (!sessionTouchTrusted && (opts.projectOverride || opts.attributionScope)) {
|
|
3834
|
+
// session-close-scope-boundary spec §2b: a broken/missing transcript
|
|
3835
|
+
// normally means "cannot attribute, fail closed" (the branch below).
|
|
3836
|
+
// But when the CALLER told us which project is ours (an explicit
|
|
3837
|
+
// --project, or the cwd-derived attributionScope), a dirty file that
|
|
3838
|
+
// lives structurally under a DIFFERENT eligible project's own directory
|
|
3839
|
+
// needs no attribution inference at all: the path alone proves it is
|
|
3840
|
+
// not this session's file. Everything else (this session's own
|
|
3841
|
+
// project, pages/, an unregistered or _template project dir) keeps the
|
|
3842
|
+
// pre-existing fail-closed behavior; only a provably-foreign path is
|
|
3843
|
+
// demoted.
|
|
3844
|
+
const effectiveOverride = opts.projectOverride || opts.attributionScope;
|
|
3845
|
+
// Computed once per gate call, never per file (collectProjectWorkingDirs
|
|
3846
|
+
// walks the projects/ dir and reads every index.md). A throw here (a
|
|
3847
|
+
// readdirSync failure it does not catch itself) must not escape as an
|
|
3848
|
+
// exception, the PreCompact hook's outermost catch would turn that
|
|
3849
|
+
// into a silent, fully-suppressed gate result, the opposite of "fail
|
|
3850
|
+
// closed". Treat any failure as "no eligible projects known", which
|
|
3851
|
+
// makes isForeign() below always false and every dirty file falls
|
|
3852
|
+
// through to the unconditional blocker.
|
|
3853
|
+
let eligibleSlugs = null;
|
|
3854
|
+
try {
|
|
3855
|
+
eligibleSlugs = new Set(collectProjectWorkingDirs(hypoDir).map((p) => p.slug));
|
|
3856
|
+
} catch {
|
|
3857
|
+
eligibleSlugs = null;
|
|
3858
|
+
}
|
|
3859
|
+
// Lexical, on the RAW git-porcelain path, BEFORE posixPath()'s
|
|
3860
|
+
// unconditional `\` -> `/` conversion. A file whose actual NAME
|
|
3861
|
+
// contains a literal backslash (`projects\other\x.md`, one path
|
|
3862
|
+
// segment, no real subdirectory) must not be reinterpreted as living
|
|
3863
|
+
// under `projects/other/` just because posixPath() would rewrite it
|
|
3864
|
+
// that way; testing the raw string here means it never matches this
|
|
3865
|
+
// regex and falls through to fail-closed instead.
|
|
3866
|
+
const isForeign = (f) => {
|
|
3867
|
+
// The transcript already PROVES this session edited f, whatever its
|
|
3868
|
+
// path prefix says: transcript evidence outranks the path-prefix
|
|
3869
|
+
// heuristic below, which only exists to cover files the (untrusted)
|
|
3870
|
+
// transcript could not vouch for either way.
|
|
3871
|
+
if (transcriptTouched.has(posixPath(f))) return false;
|
|
3872
|
+
if (!eligibleSlugs) return false;
|
|
3873
|
+
const m = /^projects\/([^/]+)\/.+$/.exec(f);
|
|
3874
|
+
if (!m) return false;
|
|
3875
|
+
const slug = m[1];
|
|
3876
|
+
return slug !== effectiveOverride && eligibleSlugs.has(slug);
|
|
3877
|
+
};
|
|
3878
|
+
const foreign = dirty.filter(isForeign);
|
|
3879
|
+
const rest = dirty.filter((f) => !isForeign(f));
|
|
3880
|
+
if (rest.length > 0) {
|
|
3881
|
+
blockers.push({
|
|
3882
|
+
type: 'git',
|
|
3883
|
+
reason: `uncommitted changes in ${hypoDir}: ${rest.join(', ')}`,
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
for (const f of foreign) {
|
|
3887
|
+
notices.push({
|
|
3888
|
+
type: 'git',
|
|
3889
|
+
file: f,
|
|
3890
|
+
reason: `uncommitted changes outside this session's scope: ${f}`,
|
|
3891
|
+
});
|
|
3892
|
+
}
|
|
3893
|
+
} else if (!sessionTouchTrusted) {
|
|
3690
3894
|
blockers.push({ type: 'git', reason: git.reason });
|
|
3691
3895
|
} else {
|
|
3692
3896
|
const mine = dirty.filter((f) => closeAccountableScope.has(posixPath(f)));
|
|
@@ -3739,8 +3943,21 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3739
3943
|
}
|
|
3740
3944
|
} else {
|
|
3741
3945
|
// projectOverride narrows the close status to one project (check-only); a
|
|
3742
|
-
// marker-writing caller never sets it
|
|
3743
|
-
|
|
3946
|
+
// marker-writing caller never sets it. It attributes instead through
|
|
3947
|
+
// opts.attributionScope, consumed a few lines down by resolveCloseScope, so
|
|
3948
|
+
// the status computed here stays global and only the partition below scopes it.
|
|
3949
|
+
// scopeSet is computed BEFORE the status so the attributed slugs become
|
|
3950
|
+
// mandatory evaluation targets (sessionCloseGlobalStatus's mustEvaluate).
|
|
3951
|
+
// Computing it after would let a slug reach the marker that the status never
|
|
3952
|
+
// looked at, which is the false-green this ordering exists to stop.
|
|
3953
|
+
const attributedScope = resolveCloseScope(hypoDir, opts, marker);
|
|
3954
|
+
close = sessionCloseGlobalStatus(hypoDir, {
|
|
3955
|
+
projectOverride: opts.projectOverride,
|
|
3956
|
+
// direct only: a past marker's projects must not become mandatory
|
|
3957
|
+
// evaluation targets (codex round 2). They stay in attributedScope so the
|
|
3958
|
+
// partition still treats them as this session's, not foreign debt.
|
|
3959
|
+
mustEvaluate: attributedScope.direct,
|
|
3960
|
+
});
|
|
3744
3961
|
|
|
3745
3962
|
// Attribute the close debt before blocking on it. An incomplete close belongs
|
|
3746
3963
|
// to whichever session performed it; charging it to an unrelated session is the
|
|
@@ -3771,7 +3988,7 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3771
3988
|
// pre-existing tradeoff documented there). And the marker is never written, so the
|
|
3772
3989
|
// Stop hook still refuses to end the session. Closing this properly needs a
|
|
3773
3990
|
// durable close-attempt record; it is not worth a new state-file lifecycle here.
|
|
3774
|
-
const scopeSet =
|
|
3991
|
+
const scopeSet = attributedScope;
|
|
3775
3992
|
const failed = (close.projects || []).filter((p) => !p.ok);
|
|
3776
3993
|
const partition =
|
|
3777
3994
|
!close.fallback && !opts.projectOverride && scopeSet.size > 0 && failed.length > 0;
|
|
@@ -3878,6 +4095,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
|
|
|
3878
4095
|
// monorepo tie pickProjectByCwd declined): we cannot attribute a close
|
|
3879
4096
|
// responsibility, so we do NOT hard-block (nothing proves there is anything
|
|
3880
4097
|
// to close). Surface a best-effort notice so the coverage gap is visible.
|
|
4098
|
+
// This reason names --project / --log-only, the crystallize CLI flags:
|
|
4099
|
+
// correct for a --check-session-close caller, but not a lever a
|
|
4100
|
+
// PreCompact hook reader has. The hook renderer (hypo-personal-check.mjs)
|
|
4101
|
+
// builds its own sentence for this notice type instead of echoing this
|
|
4102
|
+
// one verbatim; keep both in sync if the underlying condition changes.
|
|
3881
4103
|
notices.push({
|
|
3882
4104
|
type: 'close-cwd-unresolved',
|
|
3883
4105
|
reason:
|
package/hooks/version-check.mjs
CHANGED
|
@@ -156,16 +156,54 @@ export function selectPluginVersion(plugins) {
|
|
|
156
156
|
return typeof v === 'string' ? v : null;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/**
|
|
159
|
+
/** The command that runs an upgrade apply, in the form the caller's channel can
|
|
160
|
+
* actually run. A plugin-only install has no `hypomnema` on PATH: that binary is
|
|
161
|
+
* the npm package's bin, while the plugin manifest ships commands only. And the
|
|
162
|
+
* slash command takes no `--apply` argument, so naming the flag there describes
|
|
163
|
+
* a call it does not make: it runs the check and appends --apply only after the
|
|
164
|
+
* operator confirms.
|
|
165
|
+
*
|
|
166
|
+
* Lives here because both hooks and scripts need it and the dependency only
|
|
167
|
+
* runs one way (scripts import hooks, never the reverse). This file is already
|
|
168
|
+
* in hooks.json's `shared` list, so it reaches every standalone install.
|
|
169
|
+
*
|
|
170
|
+
* Use UPGRADE_APPLY_EITHER when the target channel is genuinely unknown, e.g.
|
|
171
|
+
* an instruction about a DIFFERENT machine. */
|
|
172
|
+
export function upgradeApplyHint(isPluginChannel) {
|
|
173
|
+
return isPluginChannel
|
|
174
|
+
? '`/hypo:upgrade` (confirm the apply step)'
|
|
175
|
+
: '`hypomnema upgrade --apply`';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const UPGRADE_APPLY_EITHER =
|
|
179
|
+
'`hypomnema upgrade --apply` (or `/hypo:upgrade` on a plugin install)';
|
|
180
|
+
|
|
181
|
+
/** Channel-specific one-line update instruction.
|
|
182
|
+
*
|
|
183
|
+
* Every channel ends with the same second step. Installing a release does NOT
|
|
184
|
+
* repoint the vault's git pre-commit hook: that hook holds an absolute path
|
|
185
|
+
* baked in at init time, and only `upgrade --apply` rewrites it. No hook can
|
|
186
|
+
* do it either (a hook must never invoke an apply path). Without this line the
|
|
187
|
+
* user updates, sees the new version string, and keeps committing through the
|
|
188
|
+
* install they had when they first ran init. */
|
|
160
189
|
export function buildUpdateLine(channel, current, latest) {
|
|
161
190
|
const head = `[Hypomnema] Update available! ${current} → ${latest}`;
|
|
191
|
+
// The repoint step has to be RUNNABLE on the channel it is printed for. A
|
|
192
|
+
// plugin-only install has no `hypomnema` on PATH (that binary is the npm
|
|
193
|
+
// package's bin; the plugin manifest ships commands only), so its form is the
|
|
194
|
+
// slash command. And the slash command takes no --apply argument: that
|
|
195
|
+
// command runs the check first and only appends --apply after the operator
|
|
196
|
+
// confirms, so naming the flag there would describe a call the command does
|
|
197
|
+
// not make. Both mistakes are the same class as naming a flag no script parses.
|
|
198
|
+
const repoint = (cmd) => `\n → then: ${cmd} (repoints the vault's git hook at the new install)`;
|
|
162
199
|
if (channel === 'plugin') {
|
|
163
|
-
return `${head}\n → run: /plugin marketplace update hypomnema then /reload-plugins`;
|
|
164
|
-
}
|
|
165
|
-
if (channel === 'npm') {
|
|
166
|
-
return `${head}\n → run: npm install -g hypomnema`;
|
|
200
|
+
return `${head}\n → run: /plugin marketplace update hypomnema then /reload-plugins${repoint(upgradeApplyHint(true))}`;
|
|
167
201
|
}
|
|
168
|
-
|
|
202
|
+
// The only remaining displayed channel is npm: computeNotice drops 'unknown'
|
|
203
|
+
// before anything renders, and a dual install reports the channel of the hook
|
|
204
|
+
// root actually running, never a third "mixed" value. A branch for one would
|
|
205
|
+
// be code no user reaches.
|
|
206
|
+
return `${head}\n → run: npm install -g hypomnema${repoint(upgradeApplyHint(false))}`;
|
|
169
207
|
}
|
|
170
208
|
|
|
171
209
|
// ── cache freshness + notice decision (pure) ─────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
"scripts/verify.mjs",
|
|
29
29
|
"scripts/weekly-report.mjs",
|
|
30
30
|
"scripts/lib/core-hooks.mjs",
|
|
31
|
+
"scripts/lib/crystallize-args.mjs",
|
|
32
|
+
"scripts/lib/crystallize-close-apply.mjs",
|
|
33
|
+
"scripts/lib/crystallize-close-check.mjs",
|
|
34
|
+
"scripts/lib/crystallize-close-gate.mjs",
|
|
35
|
+
"scripts/lib/crystallize-helpers.mjs",
|
|
31
36
|
"scripts/lib/design-history-stale.mjs",
|
|
32
37
|
"scripts/lib/extensions.mjs",
|
|
33
38
|
"scripts/lib/failure-type.mjs",
|
|
@@ -53,7 +58,8 @@
|
|
|
53
58
|
"templates/",
|
|
54
59
|
"docs/",
|
|
55
60
|
".claude-plugin/",
|
|
56
|
-
"README.md"
|
|
61
|
+
"README.md",
|
|
62
|
+
"CHANGELOG.md"
|
|
57
63
|
],
|
|
58
64
|
"keywords": [
|
|
59
65
|
"wiki",
|
|
@@ -92,6 +98,6 @@
|
|
|
92
98
|
"prepublishOnly": "npm test && npm run lint && npm run check:versions && npm run smoke:plugin && node scripts/check-pack-surface.mjs && npm run smoke-pack && npm run check:bilingual && npm run check:tracker-ids"
|
|
93
99
|
},
|
|
94
100
|
"devDependencies": {
|
|
95
|
-
"prettier": "
|
|
101
|
+
"prettier": "3.8.3"
|
|
96
102
|
}
|
|
97
103
|
}
|
package/scripts/capture.mjs
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* acquired through the normal sync path, never by injecting a SHA directly.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
+
import { UPGRADE_APPLY_EITHER } from '../hooks/version-check.mjs';
|
|
35
36
|
import {
|
|
36
37
|
existsSync,
|
|
37
38
|
readFileSync,
|
|
@@ -76,6 +77,8 @@ import {
|
|
|
76
77
|
scanSettingsHooks,
|
|
77
78
|
parseCapturableHookCommand,
|
|
78
79
|
HOOK_EVENT_ALLOWLIST,
|
|
80
|
+
HOOK_INTERPRETER_EXT,
|
|
81
|
+
DEFAULT_HOOK_INTERPRETER,
|
|
79
82
|
SKILL_ROOT_FILE,
|
|
80
83
|
EXT_PREFIX,
|
|
81
84
|
withSrcExecBits,
|
|
@@ -90,8 +93,9 @@ const HOME = homedir();
|
|
|
90
93
|
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
91
94
|
|
|
92
95
|
// Captured types and their singular manifest `type` value. commands/agents install
|
|
93
|
-
// as top-level `.md` files; hooks install as
|
|
94
|
-
//
|
|
96
|
+
// as top-level `.md` files; hooks install as an interpreter-specific executable
|
|
97
|
+
// (.mjs/.py/.sh) and additionally register a settings.json entry
|
|
98
|
+
// (reverse of forward-sync); skills install as a DIRECTORY.
|
|
95
99
|
const CAPTURE_TYPES = ['commands', 'agents', 'hooks', 'skills'];
|
|
96
100
|
// The flat, single-file types. `TYPE_EXT` has no `skills` entry on purpose: a skill
|
|
97
101
|
// has no install extension, and letting it through a flat helper would silently
|
|
@@ -101,7 +105,20 @@ const FLAT_TYPES = ['commands', 'agents', 'hooks'];
|
|
|
101
105
|
// registration instead, and skills have their own directory scanner.
|
|
102
106
|
const READDIR_TYPES = ['commands', 'agents'];
|
|
103
107
|
const TYPE_SINGULAR = { commands: 'command', agents: 'agent', hooks: 'hook', skills: 'skill' };
|
|
104
|
-
|
|
108
|
+
// `TYPE_EXT` has no `hooks` entry either, for the same reason as skills: a hook's
|
|
109
|
+
// extension depends on its interpreter (HOOK_INTERPRETER_EXT), not its type.
|
|
110
|
+
// captureFileExt() is the one place that resolves it; every hook-touching call
|
|
111
|
+
// site below goes through that, not this map.
|
|
112
|
+
const TYPE_EXT = { commands: '.md', agents: '.md' };
|
|
113
|
+
|
|
114
|
+
/** The install/wiki-storage extension for one capture candidate. hooks vary by
|
|
115
|
+
* interpreter (mirrors buildHookCommand/parseCapturableHookCommand in
|
|
116
|
+
* extensions.mjs); the other flat types have one fixed extension. */
|
|
117
|
+
function captureFileExt(c) {
|
|
118
|
+
return c.type === 'hooks'
|
|
119
|
+
? HOOK_INTERPRETER_EXT[c.interpreter ?? DEFAULT_HOOK_INTERPRETER]
|
|
120
|
+
: TYPE_EXT[c.type];
|
|
121
|
+
}
|
|
105
122
|
|
|
106
123
|
// Ceilings for a captured skill subtree (skills-capture design §3). A hand-authored
|
|
107
124
|
// skill is a handful of files; a vendored one (gstack: 14,311 files / 1.1GB, mostly
|
|
@@ -124,9 +141,14 @@ function pkgJsonPath() {
|
|
|
124
141
|
* hooks/commands + already-synced hypo-ext-* copies), anything already owned
|
|
125
142
|
* (a recorded SHA for its install path means the wiki already manages it), and
|
|
126
143
|
* non-.md files. Symlink/non-regular is checked by the caller (needs an fs stat).
|
|
144
|
+
* `type` must be a flat readdir type with a single fixed extension (TYPE_EXT);
|
|
145
|
+
* hooks have no entry there (extension depends on interpreter) and are never
|
|
146
|
+
* routed through this function — scanCandidates only calls it for READDIR_TYPES.
|
|
127
147
|
*/
|
|
128
148
|
export function isCaptureCandidate(type, file, recorded) {
|
|
129
|
-
|
|
149
|
+
const ext = TYPE_EXT[type];
|
|
150
|
+
if (!ext) return { ok: false, reason: `capture-by-readdir does not cover ${type}` };
|
|
151
|
+
if (!file.endsWith(ext)) return { ok: false, reason: `not a ${ext} file` };
|
|
130
152
|
const lower = file.toLowerCase();
|
|
131
153
|
if (lower === 'hypo' || lower.startsWith('hypo-')) {
|
|
132
154
|
return { ok: false, reason: 'reserved hypo namespace' };
|
|
@@ -296,10 +318,14 @@ export function planSkillCapture({
|
|
|
296
318
|
/**
|
|
297
319
|
* Reverse-generate the sidecar manifest for a capture candidate. commands/agents
|
|
298
320
|
* carry only `{ type, installName }`; hooks additionally carry the settings
|
|
299
|
-
* registration fields `{ event, matcher?, timeout? }`.
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
321
|
+
* registration fields `{ event, matcher?, timeout?, interpreter? }`. Every
|
|
322
|
+
* optional field is omitted when it would equal its default (an empty-string
|
|
323
|
+
* matcher is already normalized to undefined by the scanner; `node` is the
|
|
324
|
+
* default interpreter) via conditional spread, so the in-memory object never
|
|
325
|
+
* carries an `undefined`-valued key or a redundant explicit default, either of
|
|
326
|
+
* which would break the planCapture deep-equality check against a manifest
|
|
327
|
+
* captured before interpreter support landed (a `.mjs` hook's manifest must stay byte-for-byte
|
|
328
|
+
* identical, or every prior capture becomes a false conflict).
|
|
303
329
|
*/
|
|
304
330
|
function buildWantManifest(c) {
|
|
305
331
|
const manifest = { type: TYPE_SINGULAR[c.type], installName: c.stem };
|
|
@@ -307,6 +333,9 @@ function buildWantManifest(c) {
|
|
|
307
333
|
manifest.event = c.event;
|
|
308
334
|
if (c.matcher !== undefined && c.matcher !== '') manifest.matcher = c.matcher;
|
|
309
335
|
if (c.timeout !== undefined) manifest.timeout = c.timeout;
|
|
336
|
+
if (c.interpreter !== undefined && c.interpreter !== DEFAULT_HOOK_INTERPRETER) {
|
|
337
|
+
manifest.interpreter = c.interpreter;
|
|
338
|
+
}
|
|
310
339
|
}
|
|
311
340
|
return manifest;
|
|
312
341
|
}
|
|
@@ -438,8 +467,9 @@ function subsetOf(keys, allowed) {
|
|
|
438
467
|
* canonical.
|
|
439
468
|
*
|
|
440
469
|
* A candidate must satisfy ALL of: strict command grammar
|
|
441
|
-
* (`parseCapturableHookCommand
|
|
442
|
-
*
|
|
470
|
+
* (`parseCapturableHookCommand`, which also recovers the interpreter — node,
|
|
471
|
+
* python3, or bash) resolving under `~/.claude/hooks/`; the resolved
|
|
472
|
+
* file is a real regular file (no symlink / non-regular); `type === 'command'`;
|
|
443
473
|
* the event is in `HOOK_EVENT_ALLOWLIST`; a preservable shape (F3); the case-folded
|
|
444
474
|
* basename appears exactly once across all settings hooks (F6); and the stem is not
|
|
445
475
|
* a core hook, not the reserved hypo namespace, and not already wiki-owned.
|
|
@@ -479,7 +509,7 @@ export function scanHookCandidates(claudeHome, settingsPath, recorded, coreBasen
|
|
|
479
509
|
skipped.push({ type: 'hooks', command: rec.command, reason: parsed.reason });
|
|
480
510
|
continue;
|
|
481
511
|
}
|
|
482
|
-
const { stem, basename } = parsed;
|
|
512
|
+
const { stem, basename, interpreter } = parsed;
|
|
483
513
|
const label = `hooks/${basename}`;
|
|
484
514
|
|
|
485
515
|
// Duplicate first: a basename registered 2+ times is uniformly skipped so both
|
|
@@ -547,6 +577,7 @@ export function scanHookCandidates(claudeHome, settingsPath, recorded, coreBasen
|
|
|
547
577
|
event: rec.event,
|
|
548
578
|
matcher: rec.matcher,
|
|
549
579
|
timeout: rec.timeout,
|
|
580
|
+
interpreter,
|
|
550
581
|
label,
|
|
551
582
|
});
|
|
552
583
|
}
|
|
@@ -993,7 +1024,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
993
1024
|
captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped, created });
|
|
994
1025
|
continue;
|
|
995
1026
|
}
|
|
996
|
-
const fileExt =
|
|
1027
|
+
const fileExt = captureFileExt(c);
|
|
997
1028
|
const wikiStem = `${EXT_PREFIX}${c.stem}`;
|
|
998
1029
|
const typeDir = join(extDir, c.type);
|
|
999
1030
|
const filePath = join(typeDir, `${wikiStem}${fileExt}`);
|
|
@@ -1024,11 +1055,12 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
1024
1055
|
const plan = planCapture({ wantManifest, srcSha, existingFileSha, existingManifestRaw });
|
|
1025
1056
|
|
|
1026
1057
|
// installFile is the ORIGINAL name the far machine installs under (installName
|
|
1027
|
-
// adoption); for hooks that is `<stem
|
|
1058
|
+
// adoption); for hooks that is `<stem>` plus its interpreter's extension
|
|
1059
|
+
// (`.mjs`/`.py`/`.sh`), for commands/agents `<stem>.md`.
|
|
1028
1060
|
const installFile = `${c.stem}${fileExt}`;
|
|
1029
1061
|
// Ownership keys sync must record for the adoption to count. Hooks track BOTH
|
|
1030
|
-
// the
|
|
1031
|
-
// criterion d); commands/agents track only the single install file.
|
|
1062
|
+
// the main file and its `.manifest.json` sidecar under the install path
|
|
1063
|
+
// (success criterion d); commands/agents track only the single install file.
|
|
1032
1064
|
const requiredKeys =
|
|
1033
1065
|
c.type === 'hooks'
|
|
1034
1066
|
? [`hooks/${installFile}`, `hooks/${c.stem}.manifest.json`]
|
|
@@ -1173,7 +1205,7 @@ function report(captured, skipped, failed, dryRun) {
|
|
|
1173
1205
|
const dest =
|
|
1174
1206
|
c.type === 'skills'
|
|
1175
1207
|
? `extensions/skills/${EXT_PREFIX}${c.stem}/`
|
|
1176
|
-
: `extensions/${c.type}/${EXT_PREFIX}${c.stem}${
|
|
1208
|
+
: `extensions/${c.type}/${EXT_PREFIX}${c.stem}${captureFileExt(c)}`;
|
|
1177
1209
|
log(` ${c.type}/${c.file} → ${dest}`);
|
|
1178
1210
|
}
|
|
1179
1211
|
}
|
|
@@ -1182,7 +1214,7 @@ function report(captured, skipped, failed, dryRun) {
|
|
|
1182
1214
|
log(`Failed to adopt ${failed.length} (see sync warnings; wiki files rolled back).`);
|
|
1183
1215
|
if (!dryRun && captured.some((c) => c.status === 'captured')) {
|
|
1184
1216
|
log('');
|
|
1185
|
-
log(
|
|
1217
|
+
log(`Next: commit + push the wiki, then run ${UPGRADE_APPLY_EITHER} on another machine.`);
|
|
1186
1218
|
}
|
|
1187
1219
|
}
|
|
1188
1220
|
|