codiedev 0.9.2 → 0.9.4-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ import * as fs from "fs";
2
+ export type HashCache = Record<string, string>;
3
+ export interface Candidate {
4
+ rel: string;
5
+ hash: string;
6
+ abs?: string;
7
+ }
8
+ export declare function cacheKey(repoKey: string, rel: string): string;
9
+ export declare function loadCache(readFileSync?: typeof fs.readFileSync): HashCache;
10
+ export declare function saveCache(cache: HashCache, deps?: typeof fs): void;
11
+ export declare function applyCache(prev: HashCache, repoKey: string, candidates: Candidate[]): {
12
+ changed: Candidate[];
13
+ next: HashCache;
14
+ };
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.cacheKey = cacheKey;
37
+ exports.loadCache = loadCache;
38
+ exports.saveCache = saveCache;
39
+ exports.applyCache = applyCache;
40
+ const fs = __importStar(require("fs"));
41
+ const os = __importStar(require("os"));
42
+ const path = __importStar(require("path"));
43
+ const CACHE_PATH = path.join(os.homedir(), ".codiedev", "backup-state.json");
44
+ function cacheKey(repoKey, rel) {
45
+ return `${repoKey}::${rel}`;
46
+ }
47
+ function loadCache(readFileSync = fs.readFileSync) {
48
+ try {
49
+ return JSON.parse(readFileSync(CACHE_PATH, "utf8"));
50
+ }
51
+ catch {
52
+ return {}; // missing or corrupt → empty
53
+ }
54
+ }
55
+ function saveCache(cache, deps = fs) {
56
+ try {
57
+ deps.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
58
+ deps.writeFileSync(CACHE_PATH, JSON.stringify(cache), { mode: 0o600 });
59
+ }
60
+ catch {
61
+ // best-effort — a failed cache write just means a redundant push next turn,
62
+ // which the server idempotently no-ops.
63
+ }
64
+ }
65
+ // Pure: given the prior cache, a repo key, and this turn's candidates, return
66
+ // the subset whose hash changed plus the cache to persist.
67
+ function applyCache(prev, repoKey, candidates) {
68
+ const next = { ...prev };
69
+ const changed = [];
70
+ for (const c of candidates) {
71
+ const k = cacheKey(repoKey, c.rel);
72
+ if (prev[k] === c.hash)
73
+ continue;
74
+ next[k] = c.hash;
75
+ changed.push(c);
76
+ }
77
+ return { changed, next };
78
+ }
@@ -0,0 +1,2 @@
1
+ export declare function hasSkillFrontmatter(md: string): boolean;
2
+ export declare function isArtifactPath(rel: string, getContent: () => string): boolean;
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ // Pure path matcher for the auto-backup hook. Decides whether a repo-relative
3
+ // path is an "artifact" worth backing up. Deny rules win over allow rules.
4
+ // `getContent` is lazy: only called when a path-level allow needs the
5
+ // skill-frontmatter exclusion (skills are pushed deliberately, not auto-backed).
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.hasSkillFrontmatter = hasSkillFrontmatter;
8
+ exports.isArtifactPath = isArtifactPath;
9
+ const DENY_BASENAMES = new Set([
10
+ "AGENTS.md", "CLAUDE.md", "GEMINI.md", "README.md", "CHANGELOG.md",
11
+ "LICENSE.md", "CONTRIBUTING.md", "notes.md",
12
+ ]);
13
+ const DENY_DIR_SEGMENTS = ["node_modules/", ".git/", ".claude/", "dist/", "build/"];
14
+ function basename(p) {
15
+ const i = p.lastIndexOf("/");
16
+ return i === -1 ? p : p.slice(i + 1);
17
+ }
18
+ // A leading `---` block containing both `name:` and `description:` → skill.
19
+ function hasSkillFrontmatter(md) {
20
+ const m = md.match(/^?---\s*\n([\s\S]*?)\n---/);
21
+ if (!m)
22
+ return false;
23
+ const fm = m[1];
24
+ return /^\s*name\s*:/m.test(fm) && /^\s*description\s*:/m.test(fm);
25
+ }
26
+ function pathAllows(rel) {
27
+ const base = basename(rel);
28
+ if (!base.toLowerCase().endsWith(".md"))
29
+ return false;
30
+ if (/^(docs\/superpowers\/(plans|specs)\/).+\.md$/i.test(rel))
31
+ return true;
32
+ if (/(^|\/)(spec|plan|tasks)\.md$/i.test(rel))
33
+ return true;
34
+ if (/^\.specify\/.+\.md$/i.test(rel))
35
+ return true;
36
+ if (/^specs\/.+\.md$/i.test(rel))
37
+ return true;
38
+ if (/^(spec|decision|proposal|bugfix|review|rfc|adr|prd)[-_].+\.md$/i.test(base))
39
+ return true;
40
+ if (/^(SPEC|PLAN|DESIGN|PRD)\.md$/.test(base))
41
+ return true;
42
+ if (/^(ADR|RFC)-.+\.md$/i.test(base))
43
+ return true;
44
+ if (/-design\.md$/i.test(base))
45
+ return true;
46
+ return false;
47
+ }
48
+ function isArtifactPath(rel, getContent) {
49
+ const norm = rel.replace(/\\/g, "/");
50
+ for (const seg of DENY_DIR_SEGMENTS)
51
+ if (norm.includes(seg))
52
+ return false;
53
+ const base = basename(norm);
54
+ if (DENY_BASENAMES.has(base))
55
+ return false;
56
+ if (/^snapshot-.+\.md$/i.test(base))
57
+ return false;
58
+ if (!pathAllows(norm))
59
+ return false;
60
+ try {
61
+ if (hasSkillFrontmatter(getContent()))
62
+ return false;
63
+ }
64
+ catch {
65
+ // unreadable → treat as non-skill; the push will fail harmlessly later
66
+ }
67
+ return true;
68
+ }
@@ -0,0 +1,23 @@
1
+ import { type HashCache, type Candidate } from "./cache";
2
+ export interface SelectDeps {
3
+ readFile: (rel: string) => string;
4
+ sha256: (content: string) => string;
5
+ }
6
+ export declare function selectBackupFiles(input: {
7
+ repoKey: string;
8
+ statusPaths: string[];
9
+ prevCache: HashCache;
10
+ deps: SelectDeps;
11
+ }): {
12
+ changed: Candidate[];
13
+ next: HashCache;
14
+ };
15
+ import { readConfig } from "../utils";
16
+ import { loadCache, saveCache } from "./cache";
17
+ export declare function runBackup(cwd: string, hookScriptPath: string): void;
18
+ export interface BackupUploadDeps {
19
+ readConfig: typeof readConfig;
20
+ loadCache: typeof loadCache;
21
+ saveCache: typeof saveCache;
22
+ }
23
+ export declare function runBackupUpload(jobFile: string, deps?: BackupUploadDeps): Promise<void>;
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.selectBackupFiles = selectBackupFiles;
37
+ exports.runBackup = runBackup;
38
+ exports.runBackupUpload = runBackupUpload;
39
+ const match_1 = require("./match");
40
+ const cache_1 = require("./cache");
41
+ // Pure: from this turn's git-status paths, keep artifact files whose content
42
+ // hash changed since the cache. Returns the changed candidates + next cache.
43
+ function selectBackupFiles(input) {
44
+ const candidates = [];
45
+ for (const rel of input.statusPaths) {
46
+ let content;
47
+ const getContent = () => (content ??= input.deps.readFile(rel));
48
+ if (!(0, match_1.isArtifactPath)(rel, getContent))
49
+ continue;
50
+ let c;
51
+ try {
52
+ c = getContent();
53
+ }
54
+ catch {
55
+ continue; // deleted/unreadable → skip
56
+ }
57
+ if (c === undefined || c === null)
58
+ continue;
59
+ candidates.push({ rel, hash: input.deps.sha256(c) });
60
+ }
61
+ return (0, cache_1.applyCache)(input.prevCache, input.repoKey, candidates);
62
+ }
63
+ const fs = __importStar(require("fs"));
64
+ const os = __importStar(require("os"));
65
+ const path = __importStar(require("path"));
66
+ const crypto = __importStar(require("crypto"));
67
+ const child_process_1 = require("child_process");
68
+ const utils_1 = require("../utils");
69
+ const cache_2 = require("./cache");
70
+ const push = __importStar(require("../commands/push"));
71
+ function gitRoot(cwd) {
72
+ try {
73
+ return (0, child_process_1.execFileSync)("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
74
+ encoding: "utf8",
75
+ }).trim() || null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ function gitStatusPaths(root) {
82
+ try {
83
+ // --untracked-files=all is REQUIRED: the default collapses untracked files
84
+ // into their top-level directory (a new docs/superpowers/plans/x.md shows as
85
+ // just "docs/"), which would make the hook miss brand-new artifact files.
86
+ const out = (0, child_process_1.execFileSync)("git", ["-C", root, "status", "--porcelain", "-z", "--untracked-files=all"], { encoding: "utf8" });
87
+ // -z: NUL-separated, each entry "XY <path>"; rename entries have two paths.
88
+ return out
89
+ .split("\0")
90
+ .filter(Boolean)
91
+ .map((e) => e.slice(3))
92
+ .filter(Boolean);
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ }
98
+ const sha256 = (s) => crypto.createHash("sha256").update(s, "utf8").digest("hex");
99
+ // Stop/sessionEnd hook entry. Best-effort, never throws.
100
+ function runBackup(cwd, hookScriptPath) {
101
+ try {
102
+ if (process.env.CODIEDEV_NO_AUTOBACKUP)
103
+ return;
104
+ const config = (0, utils_1.readConfig)();
105
+ if (!config)
106
+ return;
107
+ const root = gitRoot(cwd);
108
+ if (!root)
109
+ return;
110
+ const repoKey = (0, utils_1.getGitRemoteUrl)(root) ?? root;
111
+ const statusPaths = gitStatusPaths(root);
112
+ if (statusPaths.length === 0)
113
+ return;
114
+ const prevCache = (0, cache_2.loadCache)();
115
+ const { changed } = selectBackupFiles({
116
+ repoKey,
117
+ statusPaths,
118
+ prevCache,
119
+ deps: { readFile: (rel) => fs.readFileSync(path.join(root, rel), "utf8"), sha256 },
120
+ });
121
+ if (changed.length === 0)
122
+ return;
123
+ // The detached child owns the cache: it writes each file's hash ONLY after a
124
+ // successful push, so a genuine push failure (network/500) leaves the hash
125
+ // uncached and the file retries next turn. (Persisting here optimistically
126
+ // would suppress that retry, since the server no-op only covers successes.)
127
+ // Job carries root + repoKey + files; the child re-reads config from disk
128
+ // via readConfig() (same machine), avoiding any config-shape guessing.
129
+ const job = {
130
+ root,
131
+ repoKey,
132
+ files: changed.map((c) => ({ rel: c.rel, hash: c.hash })),
133
+ };
134
+ const tmpFile = path.join(os.tmpdir(), `codiedev-backup-${process.pid}.json`);
135
+ fs.writeFileSync(tmpFile, JSON.stringify(job), { mode: 0o600 });
136
+ const child = (0, child_process_1.spawn)(process.execPath, [hookScriptPath, "backup-upload", tmpFile], {
137
+ detached: true,
138
+ stdio: "ignore",
139
+ });
140
+ child.unref();
141
+ }
142
+ catch {
143
+ // best-effort
144
+ }
145
+ }
146
+ const DEFAULT_BACKUP_UPLOAD_DEPS = { readConfig: utils_1.readConfig, loadCache: cache_2.loadCache, saveCache: cache_2.saveCache };
147
+ // Detached child: push each queued file privately, caching its hash ONLY on a
148
+ // successful push (so a failed push retries next turn), then delete the job
149
+ // file. Deps are injectable so the cache write is testable without touching the
150
+ // global ~/.codiedev cache.
151
+ async function runBackupUpload(jobFile, deps = DEFAULT_BACKUP_UPLOAD_DEPS) {
152
+ try {
153
+ const job = JSON.parse(fs.readFileSync(jobFile, "utf8"));
154
+ const config = deps.readConfig(); // same machine — read full config from disk
155
+ if (!config)
156
+ return;
157
+ const cache = deps.loadCache();
158
+ let dirty = false;
159
+ for (const f of job.files) {
160
+ try {
161
+ await push.pushFile(config, {
162
+ absPath: path.join(job.root, f.rel),
163
+ key: f.rel,
164
+ visibility: "private",
165
+ contentHash: f.hash,
166
+ });
167
+ // Only now that the push landed do we record the hash — a prior failure
168
+ // leaves it uncached so the next turn retries it.
169
+ cache[(0, cache_2.cacheKey)(job.repoKey, f.rel)] = f.hash;
170
+ dirty = true;
171
+ }
172
+ catch {
173
+ // one file failing must not abort the rest, and its hash stays uncached
174
+ }
175
+ }
176
+ if (dirty)
177
+ deps.saveCache(cache);
178
+ }
179
+ catch {
180
+ // best-effort
181
+ }
182
+ finally {
183
+ try {
184
+ fs.unlinkSync(jobFile);
185
+ }
186
+ catch { /* gone */ }
187
+ }
188
+ }
@@ -1 +1,37 @@
1
+ import { requireConfig } from "./shared";
2
+ interface PushResponse {
3
+ ok: boolean;
4
+ artifactId: string;
5
+ key: string;
6
+ version: number;
7
+ type: string;
8
+ kind: "doc" | "skill";
9
+ tags: string[];
10
+ destination: string;
11
+ workspaceName: string;
12
+ matchedRepo: {
13
+ fullName: string;
14
+ } | null;
15
+ }
16
+ export interface ParsedArgs {
17
+ file: string;
18
+ type?: string;
19
+ kind?: string;
20
+ tags: string[];
21
+ force: boolean;
22
+ as?: string;
23
+ private?: boolean;
24
+ }
25
+ export declare function parseArgs(args: string[]): ParsedArgs;
26
+ export declare function pushFile(config: ReturnType<typeof requireConfig>, opts: {
27
+ absPath: string;
28
+ key: string;
29
+ visibility?: "private" | "team";
30
+ contentHash?: string;
31
+ type?: string;
32
+ kind?: string;
33
+ tags?: string[];
34
+ force?: boolean;
35
+ }): Promise<PushResponse>;
1
36
  export declare function runPush(args: string[]): Promise<void>;
37
+ export {};
@@ -33,9 +33,12 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseArgs = parseArgs;
37
+ exports.pushFile = pushFile;
36
38
  exports.runPush = runPush;
37
39
  const fs = __importStar(require("fs"));
38
40
  const path = __importStar(require("path"));
41
+ const shared = __importStar(require("./shared"));
39
42
  const shared_1 = require("./shared");
40
43
  const utils_1 = require("../utils");
41
44
  const scope_1 = require("../scope");
@@ -53,6 +56,8 @@ function parseArgs(args) {
53
56
  let type;
54
57
  let kind;
55
58
  let force = false;
59
+ let as;
60
+ let priv = false;
56
61
  const tags = [];
57
62
  for (let i = 0; i < args.length; i++) {
58
63
  const a = args[i];
@@ -74,15 +79,24 @@ function parseArgs(args) {
74
79
  else if (a.startsWith("--tag=")) {
75
80
  tags.push(a.slice("--tag=".length));
76
81
  }
82
+ else if (a === "--as" && i + 1 < args.length) {
83
+ as = args[++i];
84
+ }
85
+ else if (a.startsWith("--as=")) {
86
+ as = a.slice("--as=".length);
87
+ }
77
88
  else if (a === "--force") {
78
89
  force = true;
79
90
  }
91
+ else if (a === "--private") {
92
+ priv = true;
93
+ }
80
94
  else if (!a.startsWith("--") && !file) {
81
95
  file = a;
82
96
  }
83
97
  }
84
98
  if (!file) {
85
- console.error("Usage: codiedev push <file.md> [--kind doc|skill] [--tag <name>] [--force]");
99
+ console.error("Usage: codiedev push <file.md> [--as <key>] [--kind doc|skill] [--tag <name>] [--private] [--force]");
86
100
  process.exit(1);
87
101
  }
88
102
  if (type && !VALID_TYPES.has(type)) {
@@ -93,10 +107,39 @@ function parseArgs(args) {
93
107
  console.error(`Invalid --kind. Valid kinds: ${[...VALID_KINDS].join(", ")}`);
94
108
  process.exit(1);
95
109
  }
96
- return { file, type, kind, tags, force };
110
+ // The override key is stored verbatim by the server (docs key off it, and a
111
+ // path-y value like "specs/feature-a/plan.md" populates folderPath). Strip a
112
+ // leading slash so it can't be confused with an absolute path, and reject an
113
+ // empty value — but otherwise allow slashes so callers can namespace.
114
+ if (as !== undefined) {
115
+ as = as.replace(/^\/+/, "").trim();
116
+ if (!as) {
117
+ console.error("--as requires a non-empty key.");
118
+ process.exit(1);
119
+ }
120
+ }
121
+ return { file, type, kind, tags, force, as, private: priv };
122
+ }
123
+ async function pushFile(config, opts) {
124
+ const markdown = fs.readFileSync(opts.absPath, "utf8");
125
+ const gitRemoteUrl = (0, utils_1.getGitRemoteUrl)(process.cwd()) ?? undefined;
126
+ return shared.apiRequest("POST", "/api/cli/push", {
127
+ config,
128
+ body: {
129
+ filename: opts.key,
130
+ markdown,
131
+ type: opts.type,
132
+ kind: opts.kind,
133
+ tags: opts.tags && opts.tags.length > 0 ? opts.tags : undefined,
134
+ force: opts.force,
135
+ gitRemoteUrl,
136
+ visibility: opts.visibility,
137
+ contentHash: opts.contentHash,
138
+ },
139
+ });
97
140
  }
98
141
  async function runPush(args) {
99
- const { file, type, kind, tags, force } = parseArgs(args);
142
+ const { file, type, kind, tags, force, as, private: priv } = parseArgs(args);
100
143
  const config = (0, shared_1.requireConfig)();
101
144
  let absolute = file;
102
145
  if (!path.isAbsolute(absolute)) {
@@ -111,21 +154,21 @@ async function runPush(args) {
111
154
  console.error(`File is empty: ${absolute}`);
112
155
  process.exit(1);
113
156
  }
114
- const filename = path.basename(absolute);
115
- const gitRemoteUrl = (0, utils_1.getGitRemoteUrl)(process.cwd()) ?? undefined;
157
+ // Stored key defaults to the basename (legacy behavior). `--as` overrides it
158
+ // so callers can namespace generically-named files — e.g. Spec Kit emits a
159
+ // `plan.md`/`spec.md` per feature, which would otherwise all collide on one
160
+ // key and silently version over each other.
161
+ const filename = as ?? path.basename(absolute);
116
162
  const start = Date.now();
117
163
  try {
118
- const res = await (0, shared_1.apiRequest)("POST", "/api/cli/push", {
119
- config,
120
- body: {
121
- filename,
122
- markdown,
123
- type,
124
- kind,
125
- tags: tags.length > 0 ? tags : undefined,
126
- force,
127
- gitRemoteUrl,
128
- },
164
+ const res = await pushFile(config, {
165
+ absPath: absolute,
166
+ key: filename,
167
+ type,
168
+ kind,
169
+ tags,
170
+ force,
171
+ visibility: priv ? "private" : undefined,
129
172
  });
130
173
  (0, shared_1.recordAgentEvent)(config, {
131
174
  tool: "codiedev_push",
package/dist/connect.js CHANGED
@@ -278,15 +278,15 @@ async function runConnect(args = []) {
278
278
  }
279
279
  };
280
280
  if (hasClaude) {
281
- runInstall("claude-code-hook", utils_1.installHook, "Claude Code SessionEnd (capture) + UserPromptSubmit (skill discovery) hooks (~/.claude/settings.json)", "claudeCode-hook");
281
+ runInstall("claude-code-hook", utils_1.installHook, "Claude Code SessionEnd (capture) + UserPromptSubmit (skill discovery) + Stop (auto-backup) hooks (~/.claude/settings.json)", "claudeCode-hook");
282
282
  runInstall("claude-code-instructions", utils_1.installClaudeCodeInstructions, "Claude Code agent instructions (~/.claude/CLAUDE.md)", "claudeCode-instructions");
283
283
  }
284
284
  if (hasCodex) {
285
- runInstall("codex-hook", utils_1.installCodexHook, "Codex Stop hook (~/.codex/hooks.json)", "codex-hook");
285
+ runInstall("codex-hook", utils_1.installCodexHook, "Codex Stop (capture + auto-backup) hooks (~/.codex/hooks.json)", "codex-hook");
286
286
  runInstall("codex-instructions", utils_1.installCodexInstructions, "Codex agent instructions (~/.codex/AGENTS.md)", "codex-instructions");
287
287
  }
288
288
  if (hasCursor) {
289
- runInstall("cursor-hook", utils_1.installCursorHook, "Cursor sessionEnd hook (~/.cursor/hooks.json)", "cursor-hook");
289
+ runInstall("cursor-hook", utils_1.installCursorHook, "Cursor sessionEnd (capture + auto-backup) hooks (~/.cursor/hooks.json)", "cursor-hook");
290
290
  runInstall("cursor-instructions", utils_1.installCursorInstructions, "Cursor agent instructions (~/.cursor/rules/codiedev.mdc)", "cursor-instructions");
291
291
  }
292
292
  // Tell the backend which targets we wired up so the portal's onboarding
package/dist/hook.js CHANGED
@@ -172,6 +172,14 @@ async function main() {
172
172
  }
173
173
  process.exit(0);
174
174
  }
175
+ // Detached backup uploader child (spawned by runBackup below). The job file
176
+ // carries only { root, files }; the child re-reads config from disk. No stdin
177
+ // needed, so handle it before readStdin() like the capture uploader above.
178
+ if (process.argv[2] === "backup-upload") {
179
+ const { runBackupUpload } = await Promise.resolve().then(() => __importStar(require("./backup/run")));
180
+ await runBackupUpload(process.argv[3]);
181
+ process.exit(0);
182
+ }
175
183
  let hookInput = {};
176
184
  try {
177
185
  const raw = await readStdin();
@@ -189,12 +197,22 @@ async function main() {
189
197
  }
190
198
  const mode = resolveMode(process.argv[2]);
191
199
  // Self-heal: a user who upgraded the CLI but hasn't re-run `connect` gets the
192
- // discovery hook wired on their next SessionEnd, without re-connecting.
200
+ // discovery + auto-backup hooks wired on their next SessionEnd, without
201
+ // re-connecting.
193
202
  (0, utils_1.ensureDiscoveryHookInstalled)();
203
+ (0, utils_1.ensureBackupHookInstalled)();
194
204
  const { session_id, transcript_path, cwd, workspace_roots } = hookInput;
195
205
  const workspaceRoot = workspace_roots && workspace_roots.length > 0
196
206
  ? workspace_roots[0]
197
207
  : cwd || process.cwd();
208
+ // Stop/sessionEnd backup path — separate from session capture and needs no
209
+ // transcript. Lists changed artifact files in the workspace and dispatches a
210
+ // detached child that backs them up privately. Best-effort, never blocks.
211
+ if (process.argv[2] === "backup") {
212
+ const { runBackup } = await Promise.resolve().then(() => __importStar(require("./backup/run")));
213
+ runBackup(workspaceRoot, process.argv[1]);
214
+ process.exit(0);
215
+ }
198
216
  const config = (0, utils_1.readConfig)();
199
217
  if (!config) {
200
218
  process.exit(0);
package/dist/utils.d.ts CHANGED
@@ -38,6 +38,7 @@ export declare function vscodeCopilotInstalled(): boolean;
38
38
  export declare function upsertCodiedevHook(hooks: Record<string, unknown>, eventName: string, subcommand: string): void;
39
39
  export declare function installHook(): void;
40
40
  export declare function ensureDiscoveryHookInstalled(): void;
41
+ export declare function ensureBackupHookInstalled(): void;
41
42
  export declare function installClaudeCodeInstructions(): void;
42
43
  export declare function installCodexInstructions(): void;
43
44
  export declare function installCursorInstructions(): void;
package/dist/utils.js CHANGED
@@ -46,6 +46,7 @@ exports.vscodeCopilotInstalled = vscodeCopilotInstalled;
46
46
  exports.upsertCodiedevHook = upsertCodiedevHook;
47
47
  exports.installHook = installHook;
48
48
  exports.ensureDiscoveryHookInstalled = ensureDiscoveryHookInstalled;
49
+ exports.ensureBackupHookInstalled = ensureBackupHookInstalled;
49
50
  exports.installClaudeCodeInstructions = installClaudeCodeInstructions;
50
51
  exports.installCodexInstructions = installCodexInstructions;
51
52
  exports.installCursorInstructions = installCursorInstructions;
@@ -274,6 +275,8 @@ function installHook() {
274
275
  upsertCodiedevHook(hooks, "SessionEnd", "capture");
275
276
  // UserPromptSubmit → discover (surface a matching team skill for the task).
276
277
  upsertCodiedevHook(hooks, "UserPromptSubmit", "discover");
278
+ // Stop → backup (auto-back-up changed artifact files to your private library).
279
+ upsertCodiedevHook(hooks, "Stop", "backup");
277
280
  if (!fs.existsSync(CLAUDE_DIR)) {
278
281
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
279
282
  }
@@ -306,6 +309,29 @@ function ensureDiscoveryHookInstalled() {
306
309
  // Best-effort — never block a session on self-heal.
307
310
  }
308
311
  }
312
+ // Self-heal: ensure the Stop → backup hook is present without a full `connect`
313
+ // re-run, mirroring ensureDiscoveryHookInstalled. Called from the capture hook
314
+ // so an upgraded-but-not-reconnected user gets auto-backup on their next
315
+ // session. Only writes when missing, to avoid churning settings.json every run.
316
+ function ensureBackupHookInstalled() {
317
+ try {
318
+ if (!fs.existsSync(CLAUDE_SETTINGS_PATH))
319
+ return;
320
+ const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8"));
321
+ const hooks = (settings.hooks ?? {});
322
+ const stop = (hooks.Stop ?? []);
323
+ const present = stop.some((h) => {
324
+ const inner = h.hooks;
325
+ return Array.isArray(inner) && inner.some((x) => (0, detection_1.isCodiedevHookCommand)(x.command));
326
+ });
327
+ if (present)
328
+ return;
329
+ settings.hooks = hooks;
330
+ upsertCodiedevHook(hooks, "Stop", "backup");
331
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf8");
332
+ }
333
+ catch { /* best-effort */ }
334
+ }
309
335
  // ─────────────────────────────────────────────────────────────────────────────
310
336
  // MCP server installation — writes the codiedev MCP server config into each
311
337
  // agent CLI's expected location so "npx codiedev connect" wires it up once.
@@ -731,6 +757,16 @@ function installCodexHook() {
731
757
  },
732
758
  ],
733
759
  });
760
+ // Stop → backup: auto-back-up changed artifact files to the private library.
761
+ filtered.push({
762
+ hooks: [
763
+ {
764
+ type: "command",
765
+ command: resolveHookCommand("backup"),
766
+ timeout: 30,
767
+ },
768
+ ],
769
+ });
734
770
  hooks.Stop = filtered;
735
771
  if (!fs.existsSync(CODEX_DIR)) {
736
772
  fs.mkdirSync(CODEX_DIR, { recursive: true });
@@ -772,6 +808,12 @@ function installCursorHook() {
772
808
  type: "command",
773
809
  timeout: 30,
774
810
  });
811
+ // sessionEnd → backup: auto-back-up changed artifact files to the private library.
812
+ filtered.push({
813
+ command: resolveHookCommand("backup"),
814
+ type: "command",
815
+ timeout: 30,
816
+ });
775
817
  hooks.sessionEnd = filtered;
776
818
  fs.writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify(hooksFile, null, 2), "utf8");
777
819
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.2",
3
+ "version": "0.9.4-beta.0",
4
4
  "description": "Connect Claude Code, Codex, Cursor, or VS Code Copilot to Codiedev for org-wide session capture and artifact collaboration",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",