devlog-tracker 0.22.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.
Files changed (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +234 -0
  3. package/bin/devlog-tracker.js +51 -0
  4. package/cli/init.js +55 -0
  5. package/cli/init.test.js +37 -0
  6. package/cli/merge-hooks.js +60 -0
  7. package/cli/merge-hooks.test.js +187 -0
  8. package/cli/platforms/codex.js +13 -0
  9. package/cli/platforms/cursor.js +13 -0
  10. package/cli/status.js +14 -0
  11. package/cli/status.test.js +33 -0
  12. package/cli/vendor.js +37 -0
  13. package/cli/vendor.test.js +84 -0
  14. package/codex/hooks/on-pre-tool.sh +19 -0
  15. package/codex/hooks/on-session-end.sh +31 -0
  16. package/codex/hooks/on-session-start.sh +37 -0
  17. package/codex/hooks/on-stop.sh +18 -0
  18. package/codex/hooks/on-user-prompt-submit.sh +15 -0
  19. package/codex/hooks/project-dir.sh +13 -0
  20. package/codex/hooks/test-adapters.sh +98 -0
  21. package/codex/hooks.json +54 -0
  22. package/commands/checkpoint.md +17 -0
  23. package/commands/clean.md +50 -0
  24. package/commands/compact.md +24 -0
  25. package/commands/continue.md +30 -0
  26. package/commands/keep.md +100 -0
  27. package/commands/lessons-drift.md +19 -0
  28. package/commands/lessons-off.md +16 -0
  29. package/commands/lessons-on.md +20 -0
  30. package/commands/lessons.md +20 -0
  31. package/commands/overview.md +32 -0
  32. package/commands/pause.md +20 -0
  33. package/commands/resume.md +18 -0
  34. package/commands/segment-watch.md +21 -0
  35. package/commands/span.md +13 -0
  36. package/commands/start.md +19 -0
  37. package/commands/status.md +14 -0
  38. package/cursor/hooks/on-pre-tool.sh +24 -0
  39. package/cursor/hooks/on-session-end.sh +11 -0
  40. package/cursor/hooks/on-session-start.sh +15 -0
  41. package/cursor/hooks/on-stop.sh +44 -0
  42. package/cursor/hooks/on-submit-prompt.sh +28 -0
  43. package/cursor/hooks/on-tool-failure.sh +16 -0
  44. package/cursor/hooks/project-dir.sh +10 -0
  45. package/cursor/hooks/test-adapters.sh +201 -0
  46. package/cursor/hooks.json +26 -0
  47. package/hooks/scripts/await-open.sh +23 -0
  48. package/hooks/scripts/checkpoint-set.sh +44 -0
  49. package/hooks/scripts/clean-devlog.sh +69 -0
  50. package/hooks/scripts/close-open-round.sh +186 -0
  51. package/hooks/scripts/compact-devlog.sh +72 -0
  52. package/hooks/scripts/detect-pending-question.sh +31 -0
  53. package/hooks/scripts/devlog-lock.sh +38 -0
  54. package/hooks/scripts/devlog-md.sh +222 -0
  55. package/hooks/scripts/devlog-path.sh +70 -0
  56. package/hooks/scripts/enforce-devlog.sh +629 -0
  57. package/hooks/scripts/files-snapshot.sh +70 -0
  58. package/hooks/scripts/json-field.sh +76 -0
  59. package/hooks/scripts/keep-move.sh +155 -0
  60. package/hooks/scripts/kept-list.sh +40 -0
  61. package/hooks/scripts/lessons-append.sh +93 -0
  62. package/hooks/scripts/lessons-drift-set.sh +42 -0
  63. package/hooks/scripts/lessons-off.sh +15 -0
  64. package/hooks/scripts/lessons-on.sh +21 -0
  65. package/hooks/scripts/lessons-read.sh +60 -0
  66. package/hooks/scripts/on-session-end.sh +14 -0
  67. package/hooks/scripts/on-stop-failure.sh +18 -0
  68. package/hooks/scripts/on-tool-failure.sh +25 -0
  69. package/hooks/scripts/pause-devlog.sh +13 -0
  70. package/hooks/scripts/redact-prompt.sh +19 -0
  71. package/hooks/scripts/resume-devlog.sh +41 -0
  72. package/hooks/scripts/round-start.sh +333 -0
  73. package/hooks/scripts/run-tests.sh +26 -0
  74. package/hooks/scripts/segment-watch-set.sh +47 -0
  75. package/hooks/scripts/segment-watch.sh +197 -0
  76. package/hooks/scripts/session-start-devlog.sh +157 -0
  77. package/hooks/scripts/span-close.sh +9 -0
  78. package/hooks/scripts/span-open.sh +21 -0
  79. package/hooks/scripts/start-devlog.sh +24 -0
  80. package/hooks/scripts/status-devlog.sh +43 -0
  81. package/hooks/scripts/tests/test-await-open.sh +112 -0
  82. package/hooks/scripts/tests/test-branch-scoped-integration.sh +108 -0
  83. package/hooks/scripts/tests/test-checkpoint-set.sh +36 -0
  84. package/hooks/scripts/tests/test-clean-devlog.sh +160 -0
  85. package/hooks/scripts/tests/test-cli-init-e2e.sh +37 -0
  86. package/hooks/scripts/tests/test-close-open-round.sh +325 -0
  87. package/hooks/scripts/tests/test-compact-devlog.sh +62 -0
  88. package/hooks/scripts/tests/test-devlog-lock.sh +37 -0
  89. package/hooks/scripts/tests/test-devlog-md.sh +530 -0
  90. package/hooks/scripts/tests/test-devlog-path.sh +118 -0
  91. package/hooks/scripts/tests/test-enforce-devlog-files.sh +233 -0
  92. package/hooks/scripts/tests/test-enforce-devlog-handoff-order.sh +223 -0
  93. package/hooks/scripts/tests/test-enforce-devlog-workspace.sh +333 -0
  94. package/hooks/scripts/tests/test-enforce-devlog.sh +1290 -0
  95. package/hooks/scripts/tests/test-files-snapshot.sh +142 -0
  96. package/hooks/scripts/tests/test-json-field.sh +57 -0
  97. package/hooks/scripts/tests/test-keep-move.sh +127 -0
  98. package/hooks/scripts/tests/test-kept-list.sh +80 -0
  99. package/hooks/scripts/tests/test-lessons-append.sh +138 -0
  100. package/hooks/scripts/tests/test-lessons-drift-set.sh +36 -0
  101. package/hooks/scripts/tests/test-lessons-on-off.sh +75 -0
  102. package/hooks/scripts/tests/test-lessons-read.sh +97 -0
  103. package/hooks/scripts/tests/test-on-interrupt.sh +186 -0
  104. package/hooks/scripts/tests/test-redact-prompt.sh +27 -0
  105. package/hooks/scripts/tests/test-resume-devlog.sh +35 -0
  106. package/hooks/scripts/tests/test-round-start.sh +825 -0
  107. package/hooks/scripts/tests/test-segment-watch-set.sh +75 -0
  108. package/hooks/scripts/tests/test-segment-watch.sh +468 -0
  109. package/hooks/scripts/tests/test-session-start-devlog.sh +508 -0
  110. package/hooks/scripts/tests/test-start-pause-devlog.sh +84 -0
  111. package/hooks/scripts/tests/test-status-span.sh +50 -0
  112. package/hooks/scripts/tests/test-workspace-snapshot.sh +130 -0
  113. package/hooks/scripts/workspace-snapshot.sh +69 -0
  114. package/package.json +37 -0
  115. package/skills/devlog-tracker/SKILL.md +411 -0
  116. package/skills/devlog-tracker/references/checkpoint-mode.md +42 -0
  117. package/skills/devlog-tracker/references/lessons-mode.md +32 -0
  118. package/skills/devlog-tracker/references/reply-fold.md +129 -0
  119. package/skills/devlog-tracker/references/round-segments.md +55 -0
  120. package/skills/devlog-tracker/references/span-mode.md +65 -0
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { mergeHooksTemplate } = require('./merge-hooks');
8
+
9
+ function tmpdir() {
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'devlog-tracker-merge-'));
11
+ }
12
+
13
+ test('creates the target file from the template when target is missing', () => {
14
+ const dir = tmpdir();
15
+ const templatePath = path.join(dir, 'template.json');
16
+ fs.writeFileSync(
17
+ templatePath,
18
+ JSON.stringify({
19
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'bash "${DEVLOG_TRACKER_ROOT}/codex/hooks/on-stop.sh"' }] }] },
20
+ })
21
+ );
22
+ const targetPath = path.join(dir, '.codex', 'hooks.json');
23
+
24
+ mergeHooksTemplate({ templatePath, targetPath, vendorRoot: '/proj/.devlog-tracker' });
25
+
26
+ const written = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
27
+ assert.equal(written.hooks.Stop[0].hooks[0].command, 'bash "/proj/.devlog-tracker/codex/hooks/on-stop.sh"');
28
+ });
29
+
30
+ test('re-running replaces only its own entries, keeps unrelated hooks untouched', () => {
31
+ const dir = tmpdir();
32
+ const templatePath = path.join(dir, 'template.json');
33
+ fs.writeFileSync(
34
+ templatePath,
35
+ JSON.stringify({
36
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'bash "${DEVLOG_TRACKER_ROOT}/codex/hooks/on-stop.sh"' }] }] },
37
+ })
38
+ );
39
+ const targetPath = path.join(dir, '.codex', 'hooks.json');
40
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
41
+ fs.writeFileSync(
42
+ targetPath,
43
+ JSON.stringify({
44
+ hooks: {
45
+ Stop: [
46
+ { hooks: [{ type: 'command', command: 'bash /some/other-tool/on-stop.sh' }] },
47
+ { hooks: [{ type: 'command', command: 'bash "/old/.devlog-tracker/codex/hooks/on-stop.sh"' }] },
48
+ ],
49
+ },
50
+ })
51
+ );
52
+
53
+ mergeHooksTemplate({ templatePath, targetPath, vendorRoot: '/proj/.devlog-tracker' });
54
+
55
+ const written = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
56
+ assert.equal(written.hooks.Stop.length, 2);
57
+ assert.equal(written.hooks.Stop[0].hooks[0].command, 'bash /some/other-tool/on-stop.sh');
58
+ assert.equal(written.hooks.Stop[1].hooks[0].command, 'bash "/proj/.devlog-tracker/codex/hooks/on-stop.sh"');
59
+ });
60
+
61
+ test('preserves top-level non-hooks keys such as version', () => {
62
+ const dir = tmpdir();
63
+ const templatePath = path.join(dir, 'template.json');
64
+ fs.writeFileSync(
65
+ templatePath,
66
+ JSON.stringify({
67
+ version: 1,
68
+ hooks: { stop: [{ command: 'bash "${DEVLOG_TRACKER_ROOT}/cursor/hooks/on-stop.sh"' }] },
69
+ })
70
+ );
71
+ const targetPath = path.join(dir, '.cursor', 'hooks.json');
72
+
73
+ mergeHooksTemplate({ templatePath, targetPath, vendorRoot: '/proj/.devlog-tracker' });
74
+
75
+ const written = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
76
+ assert.equal(written.version, 1);
77
+ assert.equal(written.hooks.stop[0].command, 'bash "/proj/.devlog-tracker/cursor/hooks/on-stop.sh"');
78
+ });
79
+
80
+ function writeTemplate(dir) {
81
+ const templatePath = path.join(dir, 'template.json');
82
+ fs.writeFileSync(
83
+ templatePath,
84
+ JSON.stringify({
85
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'bash "${DEVLOG_TRACKER_ROOT}/codex/hooks/on-stop.sh"' }] }] },
86
+ })
87
+ );
88
+ return templatePath;
89
+ }
90
+
91
+ function setup(existingText) {
92
+ const dir = tmpdir();
93
+ const templatePath = writeTemplate(dir);
94
+ const targetPath = path.join(dir, '.codex', 'hooks.json');
95
+ if (existingText !== undefined) {
96
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
97
+ fs.writeFileSync(targetPath, existingText);
98
+ }
99
+ return { templatePath, targetPath };
100
+ }
101
+
102
+ test('malformed existing JSON throws with the file path and re-run guidance', () => {
103
+ const { templatePath, targetPath } = setup('{ not json');
104
+ assert.throws(
105
+ () => mergeHooksTemplate({ templatePath, targetPath, vendorRoot: '/proj/.devlog-tracker' }),
106
+ (err) => {
107
+ assert.ok(err.message.startsWith(`Cannot parse existing ${targetPath}: `), err.message);
108
+ assert.ok(err.message.includes('Fix or move the file, then re-run `devlog-tracker init` (safe to re-run).'));
109
+ return true;
110
+ }
111
+ );
112
+ assert.equal(fs.readFileSync(targetPath, 'utf8'), '{ not json');
113
+ });
114
+
115
+ test('non-object top-level value throws naming the file path', () => {
116
+ for (const text of ['[]', '"str"', '42']) {
117
+ const { templatePath, targetPath } = setup(text);
118
+ assert.throws(
119
+ () => mergeHooksTemplate({ templatePath, targetPath, vendorRoot: '/proj/.devlog-tracker' }),
120
+ (err) => err.message.includes(targetPath),
121
+ text
122
+ );
123
+ }
124
+ });
125
+
126
+ test('non-object "hooks" value throws naming the file path', () => {
127
+ const { templatePath, targetPath } = setup('{"hooks": []}');
128
+ assert.throws(
129
+ () => mergeHooksTemplate({ templatePath, targetPath, vendorRoot: '/proj/.devlog-tracker' }),
130
+ (err) => err.message.includes(targetPath) && err.message.includes('hooks')
131
+ );
132
+ });
133
+
134
+ test('non-array event value throws naming the file path and event, without overwriting', () => {
135
+ const original = '{"hooks": {"Stop": {"oops": true}}}';
136
+ const { templatePath, targetPath } = setup(original);
137
+ assert.throws(
138
+ () => mergeHooksTemplate({ templatePath, targetPath, vendorRoot: '/proj/.devlog-tracker' }),
139
+ (err) => err.message.includes(targetPath) && err.message.includes('Stop')
140
+ );
141
+ assert.equal(fs.readFileSync(targetPath, 'utf8'), original);
142
+ });
143
+
144
+ test('empty, whitespace-only and null existing files are treated as empty', () => {
145
+ for (const text of ['', ' \n\t ', 'null']) {
146
+ const { templatePath, targetPath } = setup(text);
147
+ mergeHooksTemplate({ templatePath, targetPath, vendorRoot: '/proj/.devlog-tracker' });
148
+ const written = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
149
+ assert.equal(written.hooks.Stop[0].hooks[0].command, 'bash "/proj/.devlog-tracker/codex/hooks/on-stop.sh"');
150
+ }
151
+ });
152
+
153
+ test('vendorRoot containing a double quote or backslash still yields valid JSON', () => {
154
+ const vendorRoot = '/tmp/we"ird\\dir/.devlog-tracker';
155
+ const { templatePath, targetPath } = setup();
156
+ mergeHooksTemplate({ templatePath, targetPath, vendorRoot });
157
+ const written = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
158
+ assert.equal(written.hooks.Stop[0].hooks[0].command, `bash "${vendorRoot}/codex/hooks/on-stop.sh"`);
159
+ });
160
+
161
+ test('real templates quote the vendor path so project paths with spaces work', () => {
162
+ const vendorRoot = '/tmp/my project/.devlog-tracker';
163
+ for (const name of ['cursor', 'codex']) {
164
+ const dir = tmpdir();
165
+ const targetPath = path.join(dir, 'hooks.json');
166
+ mergeHooksTemplate({
167
+ templatePath: path.join(__dirname, '..', name, 'hooks.json'),
168
+ targetPath,
169
+ vendorRoot,
170
+ });
171
+ const written = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
172
+ const commands = [];
173
+ (function walk(v) {
174
+ if (Array.isArray(v)) v.forEach(walk);
175
+ else if (v && typeof v === 'object') {
176
+ for (const [k, val] of Object.entries(v)) {
177
+ if (k === 'command') commands.push(val);
178
+ else walk(val);
179
+ }
180
+ }
181
+ })(written.hooks);
182
+ assert.ok(commands.length >= 5, `${name} has commands`);
183
+ for (const c of commands) {
184
+ assert.ok(c.includes(`"${vendorRoot}/`), `${name}: unquoted command ${c}`);
185
+ }
186
+ }
187
+ });
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const { mergeHooksTemplate } = require('../merge-hooks');
4
+
5
+ function install({ repoRoot, targetDir, vendorRoot }) {
6
+ mergeHooksTemplate({
7
+ templatePath: path.join(repoRoot, 'codex', 'hooks.json'),
8
+ targetPath: path.join(targetDir, '.codex', 'hooks.json'),
9
+ vendorRoot,
10
+ });
11
+ }
12
+
13
+ module.exports = { install };
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const { mergeHooksTemplate } = require('../merge-hooks');
4
+
5
+ function install({ repoRoot, targetDir, vendorRoot }) {
6
+ mergeHooksTemplate({
7
+ templatePath: path.join(repoRoot, 'cursor', 'hooks.json'),
8
+ targetPath: path.join(targetDir, '.cursor', 'hooks.json'),
9
+ vendorRoot,
10
+ });
11
+ }
12
+
13
+ module.exports = { install };
package/cli/status.js ADDED
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ function status({ targetDir, currentVersion }) {
6
+ const versionFile = path.join(targetDir, '.devlog-tracker', 'VERSION');
7
+ if (!fs.existsSync(versionFile)) {
8
+ return { installed: false, vendoredVersion: null, upToDate: false };
9
+ }
10
+ const vendoredVersion = fs.readFileSync(versionFile, 'utf8').trim();
11
+ return { installed: true, vendoredVersion, upToDate: vendoredVersion === currentVersion };
12
+ }
13
+
14
+ module.exports = { status };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { status } = require('./status');
8
+
9
+ function tmpdir() {
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'devlog-tracker-status-'));
11
+ }
12
+
13
+ test('reports not installed when .devlog-tracker/VERSION is missing', () => {
14
+ const targetDir = tmpdir();
15
+ const result = status({ targetDir, currentVersion: '0.21.0' });
16
+ assert.deepEqual(result, { installed: false, vendoredVersion: null, upToDate: false });
17
+ });
18
+
19
+ test('reports up to date when versions match', () => {
20
+ const targetDir = tmpdir();
21
+ fs.mkdirSync(path.join(targetDir, '.devlog-tracker'), { recursive: true });
22
+ fs.writeFileSync(path.join(targetDir, '.devlog-tracker', 'VERSION'), '0.21.0\n');
23
+ const result = status({ targetDir, currentVersion: '0.21.0' });
24
+ assert.deepEqual(result, { installed: true, vendoredVersion: '0.21.0', upToDate: true });
25
+ });
26
+
27
+ test('reports out of date when versions differ', () => {
28
+ const targetDir = tmpdir();
29
+ fs.mkdirSync(path.join(targetDir, '.devlog-tracker'), { recursive: true });
30
+ fs.writeFileSync(path.join(targetDir, '.devlog-tracker', 'VERSION'), '0.20.0\n');
31
+ const result = status({ targetDir, currentVersion: '0.21.0' });
32
+ assert.deepEqual(result, { installed: true, vendoredVersion: '0.20.0', upToDate: false });
33
+ });
package/cli/vendor.js ADDED
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const VENDOR_ENTRIES = ['hooks/scripts', 'codex/hooks', 'cursor/hooks', 'skills', 'commands'];
6
+
7
+ function copyRecursive(src, dest) {
8
+ const stat = fs.statSync(src);
9
+ if (stat.isDirectory()) {
10
+ fs.mkdirSync(dest, { recursive: true });
11
+ for (const entry of fs.readdirSync(src)) {
12
+ copyRecursive(path.join(src, entry), path.join(dest, entry));
13
+ }
14
+ return;
15
+ }
16
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
17
+ fs.copyFileSync(src, dest);
18
+ }
19
+
20
+ function envShContent(vendorRoot) {
21
+ const escaped = vendorRoot.replace(/[\\"$`]/g, '\\$&');
22
+ return `export DEVLOG_TRACKER_ROOT="${escaped}"\n`;
23
+ }
24
+
25
+ function vendor({ repoRoot, targetDir, version }) {
26
+ const vendorRoot = path.join(targetDir, '.devlog-tracker');
27
+ for (const entry of VENDOR_ENTRIES) {
28
+ const src = path.join(repoRoot, entry);
29
+ if (!fs.existsSync(src)) continue;
30
+ copyRecursive(src, path.join(vendorRoot, entry));
31
+ }
32
+ fs.writeFileSync(path.join(vendorRoot, 'VERSION'), `${version}\n`);
33
+ fs.writeFileSync(path.join(vendorRoot, 'env.sh'), envShContent(vendorRoot));
34
+ return vendorRoot;
35
+ }
36
+
37
+ module.exports = { vendor, VENDOR_ENTRIES };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { vendor } = require('./vendor');
8
+
9
+ function tmpdir() {
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'devlog-tracker-vendor-'));
11
+ }
12
+
13
+ function makeFakeRepo(dir) {
14
+ fs.mkdirSync(path.join(dir, 'hooks', 'scripts'), { recursive: true });
15
+ fs.writeFileSync(path.join(dir, 'hooks', 'scripts', 'enforce-devlog.sh'), '#!/usr/bin/env bash\necho hi\n');
16
+ fs.mkdirSync(path.join(dir, 'codex', 'hooks'), { recursive: true });
17
+ fs.writeFileSync(path.join(dir, 'codex', 'hooks', 'on-stop.sh'), '#!/usr/bin/env bash\necho codex\n');
18
+ fs.mkdirSync(path.join(dir, 'cursor', 'hooks'), { recursive: true });
19
+ fs.writeFileSync(path.join(dir, 'cursor', 'hooks', 'on-stop.sh'), '#!/usr/bin/env bash\necho cursor\n');
20
+ fs.mkdirSync(path.join(dir, 'skills', 'devlog-tracker'), { recursive: true });
21
+ fs.writeFileSync(path.join(dir, 'skills', 'devlog-tracker', 'SKILL.md'), '# skill\n');
22
+ fs.mkdirSync(path.join(dir, 'commands'), { recursive: true });
23
+ fs.writeFileSync(path.join(dir, 'commands', 'start.md'), '# start\n');
24
+ }
25
+
26
+ test('vendor copies the expected directories and stamps VERSION + env.sh', () => {
27
+ const repoRoot = tmpdir();
28
+ const targetDir = tmpdir();
29
+ makeFakeRepo(repoRoot);
30
+
31
+ const vendorRoot = vendor({ repoRoot, targetDir, version: '0.21.0' });
32
+
33
+ assert.equal(vendorRoot, path.join(targetDir, '.devlog-tracker'));
34
+ assert.ok(fs.existsSync(path.join(vendorRoot, 'hooks', 'scripts', 'enforce-devlog.sh')));
35
+ assert.ok(fs.existsSync(path.join(vendorRoot, 'codex', 'hooks', 'on-stop.sh')));
36
+ assert.ok(fs.existsSync(path.join(vendorRoot, 'cursor', 'hooks', 'on-stop.sh')));
37
+ assert.ok(fs.existsSync(path.join(vendorRoot, 'skills', 'devlog-tracker', 'SKILL.md')));
38
+ assert.ok(fs.existsSync(path.join(vendorRoot, 'commands', 'start.md')));
39
+ assert.equal(fs.readFileSync(path.join(vendorRoot, 'VERSION'), 'utf8'), '0.21.0\n');
40
+ assert.equal(
41
+ fs.readFileSync(path.join(vendorRoot, 'env.sh'), 'utf8'),
42
+ `export DEVLOG_TRACKER_ROOT="${vendorRoot}"\n`
43
+ );
44
+ });
45
+
46
+ test('vendor preserves the ../../hooks/scripts relative layout codex/cursor wrappers rely on', () => {
47
+ const repoRoot = tmpdir();
48
+ const targetDir = tmpdir();
49
+ makeFakeRepo(repoRoot);
50
+
51
+ const vendorRoot = vendor({ repoRoot, targetDir, version: '0.21.0' });
52
+
53
+ const resolvedFromCodex = path.join(vendorRoot, 'codex', 'hooks', '..', '..', 'hooks', 'scripts', 'enforce-devlog.sh');
54
+ assert.ok(fs.existsSync(resolvedFromCodex));
55
+ });
56
+
57
+ test('re-running vendor overwrites rather than duplicating', () => {
58
+ const repoRoot = tmpdir();
59
+ const targetDir = tmpdir();
60
+ makeFakeRepo(repoRoot);
61
+
62
+ vendor({ repoRoot, targetDir, version: '0.21.0' });
63
+ fs.writeFileSync(path.join(repoRoot, 'commands', 'start.md'), '# start v2\n');
64
+ const vendorRoot = vendor({ repoRoot, targetDir, version: '0.22.0' });
65
+
66
+ assert.equal(fs.readFileSync(path.join(vendorRoot, 'commands', 'start.md'), 'utf8'), '# start v2\n');
67
+ assert.equal(fs.readFileSync(path.join(vendorRoot, 'VERSION'), 'utf8'), '0.22.0\n');
68
+ });
69
+
70
+ test('env.sh escapes shell metacharacters in the vendor path', () => {
71
+ const repoRoot = tmpdir();
72
+ const parent = tmpdir();
73
+ const targetDir = path.join(parent, 'a$(x)"b`c\\d');
74
+ fs.mkdirSync(targetDir);
75
+ makeFakeRepo(repoRoot);
76
+
77
+ const vendorRoot = vendor({ repoRoot, targetDir, version: '0.21.0' });
78
+
79
+ assert.equal(
80
+ fs.readFileSync(path.join(vendorRoot, 'env.sh'), 'utf8'),
81
+ `export DEVLOG_TRACKER_ROOT="${vendorRoot.replace(/[\\"$`]/g, '\\$&')}"\n`
82
+ );
83
+ assert.ok(fs.readFileSync(path.join(vendorRoot, 'env.sh'), 'utf8').includes('a\\$(x)\\"b\\`c\\\\d'));
84
+ });
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Codex PreToolUse wrapper. Codex's "command" hooks use the same exit-code
3
+ # contract as Claude Code (0 = allow, 2 = block), so this only needs the
4
+ # cwd->CLAUDE_PROJECT_DIR translation; segment-watch.sh's stderr message
5
+ # propagates unchanged. Fail-open like the Cursor adapter: only an exit code of
6
+ # exactly 2 blocks; anything else (including a missing hooks/scripts dir or a
7
+ # crash) allows.
8
+ set -uo pipefail
9
+ _src="${BASH_SOURCE[0]}"
10
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
11
+ PLUGIN_SCRIPTS="$(cd "$SCRIPT_DIR/../../hooks/scripts" 2>/dev/null && pwd)"
12
+ [ -d "$PLUGIN_SCRIPTS" ] || exit 0
13
+ INPUT="$(cat 2>/dev/null || true)"
14
+ ROOT="$(printf '%s' "$INPUT" | bash "$SCRIPT_DIR/project-dir.sh")"
15
+ export CLAUDE_PROJECT_DIR="$ROOT"
16
+ printf '%s' "$INPUT" | bash "$PLUGIN_SCRIPTS/segment-watch.sh"
17
+ RC=$?
18
+ [ "$RC" -eq 2 ] && exit 2
19
+ exit 0
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # Codex SessionEnd wrapper. on-session-end.sh (shared) reads a "reason"
3
+ # field. Codex's own field for "why the session ended" is documented
4
+ # inconsistently as either "why" or "reason" - try both, default to
5
+ # "unknown" rather than guessing further, and rebuild a synthetic payload
6
+ # so the shared script's json_str_field lookup always finds "reason".
7
+ set -uo pipefail
8
+ _src="${BASH_SOURCE[0]}"
9
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
10
+ PLUGIN_SCRIPTS="$(cd "$SCRIPT_DIR/../../hooks/scripts" && pwd)"
11
+ INPUT="$(cat 2>/dev/null || true)"
12
+ ROOT="$(printf '%s' "$INPUT" | bash "$SCRIPT_DIR/project-dir.sh")"
13
+ export CLAUDE_PROJECT_DIR="$ROOT"
14
+
15
+ REASON=""
16
+ if command -v jq >/dev/null 2>&1; then
17
+ REASON="$(printf '%s' "$INPUT" | jq -r '.why // .reason // empty' 2>/dev/null || true)"
18
+ else
19
+ REASON="$(printf '%s' "$INPUT" | grep -o '"why"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"why"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)"
20
+ [ -n "$REASON" ] || REASON="$(printf '%s' "$INPUT" | grep -o '"reason"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"reason"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)"
21
+ fi
22
+ [ -n "$REASON" ] || REASON="unknown"
23
+
24
+ if command -v jq >/dev/null 2>&1; then
25
+ PAYLOAD="$(jq -n --arg r "$REASON" '{reason:$r}')"
26
+ else
27
+ esc="$(printf '%s' "$REASON" | sed 's/\\/\\\\/g; s/"/\\"/g')"
28
+ PAYLOAD="$(printf '{"reason":"%s"}' "$esc")"
29
+ fi
30
+ printf '%s' "$PAYLOAD" | bash "$PLUGIN_SCRIPTS/on-session-end.sh"
31
+ exit 0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # Codex SessionStart wrapper. Claude Code's own hooks.json calls
3
+ # session-start-devlog.sh directly and its plain stdout becomes the injected
4
+ # context — no JSON envelope. Codex hooks are documented to share the same
5
+ # JSON-in shape as Claude Code, so we assume the same is true for stdout-out
6
+ # here and skip any envelope too.
7
+ #
8
+ # One documented field-name gap: Codex's SessionStart payload may call the
9
+ # "how did this session start" field `how` instead of Claude Code's `source`.
10
+ # We try both and default to "startup" on purpose: session-start-devlog.sh
11
+ # SKIPS the dangling-round heal when source is missing/unrecognized (same as
12
+ # `compact`); the wrapper forces SRC="startup" so healing runs.
13
+ set -uo pipefail
14
+ _src="${BASH_SOURCE[0]}"
15
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
16
+ PLUGIN_SCRIPTS="$(cd "$SCRIPT_DIR/../../hooks/scripts" && pwd)"
17
+ INPUT="$(cat 2>/dev/null || true)"
18
+ ROOT="$(printf '%s' "$INPUT" | bash "$SCRIPT_DIR/project-dir.sh")"
19
+ export CLAUDE_PROJECT_DIR="$ROOT"
20
+
21
+ SRC=""
22
+ if command -v jq >/dev/null 2>&1; then
23
+ SRC="$(printf '%s' "$INPUT" | jq -r '.how // .source // empty' 2>/dev/null || true)"
24
+ else
25
+ SRC="$(printf '%s' "$INPUT" | grep -o '"how"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"how"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)"
26
+ [ -n "$SRC" ] || SRC="$(printf '%s' "$INPUT" | grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"source"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)"
27
+ fi
28
+ [ -n "$SRC" ] || SRC="startup"
29
+
30
+ if command -v jq >/dev/null 2>&1; then
31
+ PAYLOAD="$(jq -n --arg s "$SRC" '{source:$s}')"
32
+ else
33
+ esc="$(printf '%s' "$SRC" | sed 's/\\/\\\\/g; s/"/\\"/g')"
34
+ PAYLOAD="$(printf '{"source":"%s"}' "$esc")"
35
+ fi
36
+ printf '%s' "$PAYLOAD" | bash "$PLUGIN_SCRIPTS/session-start-devlog.sh"
37
+ exit 0
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # Codex Stop wrapper. Same exit-code contract as PreToolUse; enforce-devlog.sh
3
+ # reads "stop_hook_active", which is Claude Code's own standard field name
4
+ # and not one of the documented Codex naming gaps, so it is forwarded as-is.
5
+ # Fail-open like the Cursor adapter: only an exit code of exactly 2 blocks;
6
+ # anything else (including a missing hooks/scripts dir or a crash) allows.
7
+ set -uo pipefail
8
+ _src="${BASH_SOURCE[0]}"
9
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
10
+ PLUGIN_SCRIPTS="$(cd "$SCRIPT_DIR/../../hooks/scripts" 2>/dev/null && pwd)"
11
+ [ -d "$PLUGIN_SCRIPTS" ] || exit 0
12
+ INPUT="$(cat 2>/dev/null || true)"
13
+ ROOT="$(printf '%s' "$INPUT" | bash "$SCRIPT_DIR/project-dir.sh")"
14
+ export CLAUDE_PROJECT_DIR="$ROOT"
15
+ printf '%s' "$INPUT" | bash "$PLUGIN_SCRIPTS/enforce-devlog.sh"
16
+ RC=$?
17
+ [ "$RC" -eq 2 ] && exit 2
18
+ exit 0
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+ # Codex UserPromptSubmit wrapper. round-start.sh is fail-open and side-effect
3
+ # only (writes .devlog files); the one case where it prints anything to
4
+ # stdout (workspace-mismatch notice) relies on the same plain-stdout-as-
5
+ # context contract as SessionStart, so this is a straight pass-through of
6
+ # the original payload after the cwd->CLAUDE_PROJECT_DIR translation.
7
+ set -uo pipefail
8
+ _src="${BASH_SOURCE[0]}"
9
+ SCRIPT_DIR="$(cd "${_src%/*}" && pwd)"
10
+ PLUGIN_SCRIPTS="$(cd "$SCRIPT_DIR/../../hooks/scripts" && pwd)"
11
+ INPUT="$(cat 2>/dev/null || true)"
12
+ ROOT="$(printf '%s' "$INPUT" | bash "$SCRIPT_DIR/project-dir.sh")"
13
+ export CLAUDE_PROJECT_DIR="$ROOT"
14
+ printf '%s' "$INPUT" | bash "$PLUGIN_SCRIPTS/round-start.sh"
15
+ exit 0
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # Codex hook payloads carry the project directory in a "cwd" field (unlike
3
+ # Cursor's workspace_roots[0] array). Same job as cursor/hooks/project-dir.sh,
4
+ # different field name.
5
+ set -uo pipefail
6
+ INPUT="$(cat 2>/dev/null || true)"
7
+ ROOT=""
8
+ if command -v jq >/dev/null 2>&1; then
9
+ ROOT="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"
10
+ else
11
+ ROOT="$(printf '%s' "$INPUT" | grep -o '"cwd"[[:space:]]*:[[:space:]]*"[^"]*"' 2>/dev/null | head -1 | sed 's/.*"cwd"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' || true)"
12
+ fi
13
+ printf '%s\n' "${ROOT:-.}"
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ TMP="$(mktemp -d)"
5
+ trap 'rm -rf "$TMP"' EXIT
6
+ FAIL=0
7
+
8
+ # project-dir reads cwd, not workspace_roots
9
+ ROOT="$(printf '{"cwd":"%s"}' "$TMP" | bash "$SCRIPT_DIR/project-dir.sh")"
10
+ if [ "$ROOT" = "$TMP" ]; then echo "PASS: project-dir reads cwd"; else echo "FAIL: project-dir [$ROOT]"; FAIL=1; fi
11
+
12
+ # sessionStart: injects last Round's content as plain stdout
13
+ mkdir -p "$TMP/project/.devlog"
14
+ cat > "$TMP/project/.devlog/devlog.md" <<'EOF'
15
+ ## Round 1 — now
16
+
17
+ ### Summary
18
+ done
19
+
20
+ ### Handoff
21
+ #### 現況
22
+ done
23
+
24
+ ### Status
25
+ DONE
26
+ EOF
27
+ OUT="$(printf '{"cwd":"%s","how":"startup"}' "$TMP/project" | bash "$SCRIPT_DIR/on-session-start.sh")"
28
+ case "$OUT" in *"Round 1"*) echo "PASS: sessionStart injects context" ;; *) echo "FAIL: sessionStart [$OUT]"; FAIL=1 ;; esac
29
+
30
+ # userPromptSubmit: opens a Round in .round-current.md
31
+ SUBMIT="$TMP/submit"
32
+ mkdir -p "$SUBMIT/.devlog"
33
+ touch "$SUBMIT/.devlog/.enabled"
34
+ bash "$SCRIPT_DIR/on-user-prompt-submit.sh" <<EOF2 >/dev/null
35
+ {"cwd":"$SUBMIT","prompt":"hello","session_id":"codex-1"}
36
+ EOF2
37
+ if grep -q 'hello' "$SUBMIT/.devlog/.round-current.md" 2>/dev/null; then
38
+ echo "PASS: userPromptSubmit writes round"
39
+ else
40
+ echo "FAIL: userPromptSubmit round"; FAIL=1
41
+ fi
42
+
43
+ # preTool: blocks (exit 2) once the silence window has expired, allows fresh writes
44
+ NOW="$(date +%s)"
45
+ OLD=$((NOW - 1000))
46
+ SUM="$(cksum < "$SUBMIT/.devlog/.round-current.md" | tr -d '\n')"
47
+ printf '{"last_change_epoch": %s, "last_seen_cksum": "%s", "max_silent_seconds": 900, "session_id": "codex-1"}\n' "$OLD" "$SUM" > "$SUBMIT/.devlog/.segment-state"
48
+ set +e
49
+ printf '{"cwd":"%s","tool_name":"Bash","tool_input":{},"session_id":"codex-1"}' "$SUBMIT" | bash "$SCRIPT_DIR/on-pre-tool.sh" >/dev/null 2>/dev/null
50
+ RC=$?
51
+ set -e
52
+ if [ "$RC" -eq 2 ]; then echo "PASS: expired tool blocked (exit 2)"; else echo "FAIL: preTool exit [$RC]"; FAIL=1; fi
53
+
54
+ set +e
55
+ printf '{"cwd":"%s","tool_name":"Write","tool_input":{"file_path":"%s/.devlog/.round-current.md"},"session_id":"codex-1"}' "$SUBMIT" "$SUBMIT" | bash "$SCRIPT_DIR/on-pre-tool.sh" >/dev/null 2>/dev/null
56
+ RC=$?
57
+ set -e
58
+ if [ "$RC" -eq 0 ]; then echo "PASS: devlog write allowed (exit 0)"; else echo "FAIL: allowed-tool exit [$RC]"; FAIL=1; fi
59
+
60
+ # stop: blocks (exit 2) while the round is unfinished
61
+ printf '{"round": 1, "opened_at": "now"}\n' > "$SUBMIT/.devlog/.round-open"
62
+ cksum < "$SUBMIT/.devlog/.round-current.md" > "$SUBMIT/.devlog/.turn-start"
63
+ set +e
64
+ printf '{"cwd":"%s"}' "$SUBMIT" | bash "$SCRIPT_DIR/on-stop.sh" >/dev/null 2>/dev/null
65
+ RC=$?
66
+ set -e
67
+ if [ "$RC" -eq 2 ]; then echo "PASS: stop blocks unfinished round (exit 2)"; else echo "FAIL: stop exit [$RC]"; FAIL=1; fi
68
+
69
+ # sessionEnd: closes the open round using "why" (not "reason")
70
+ printf '\n## Round 2 — now\n\n### Status\nIN_PROGRESS\n' > "$SUBMIT/.devlog/.round-current.md"
71
+ printf '{"round": 2, "opened_at": "now"}\n' > "$SUBMIT/.devlog/.round-open"
72
+ cksum < "$SUBMIT/.devlog/.round-current.md" > "$SUBMIT/.devlog/.turn-start"
73
+ printf '{"cwd":"%s","why":"windowClosed"}' "$SUBMIT" | bash "$SCRIPT_DIR/on-session-end.sh" >/dev/null
74
+ if grep -q 'SessionEnd:windowClosed' "$SUBMIT/.devlog/devlog.md" 2>/dev/null; then
75
+ echo "PASS: sessionEnd closes round via 'why'"
76
+ else
77
+ echo "FAIL: sessionEnd 'why' fallback"; FAIL=1
78
+ fi
79
+
80
+ # fail-open: when ../../hooks/scripts is missing, preTool/stop wrappers must allow (exit 0)
81
+ ORPHAN="$TMP/orphan"
82
+ mkdir -p "$ORPHAN/codex/hooks"
83
+ cp "$SCRIPT_DIR/on-pre-tool.sh" "$SCRIPT_DIR/on-stop.sh" "$SCRIPT_DIR/project-dir.sh" "$ORPHAN/codex/hooks/"
84
+ for W in on-pre-tool on-stop; do
85
+ set +e
86
+ printf '{"cwd":"%s"}' "$ORPHAN" | bash "$ORPHAN/codex/hooks/$W.sh" >/dev/null 2>/dev/null
87
+ RC=$?
88
+ set -e
89
+ if [ "$RC" -eq 0 ]; then echo "PASS: $W fails open without hooks/scripts (exit 0)"; else echo "FAIL: $W orphan exit [$RC]"; FAIL=1; fi
90
+ done
91
+
92
+ # sessionStart: payload with neither "how" nor "source" falls back to startup and still injects
93
+ OUT="$(printf '{"cwd":"%s"}' "$TMP/project" | bash "$SCRIPT_DIR/on-session-start.sh")"
94
+ case "$OUT" in *"Round 1"*) echo "PASS: sessionStart falls back to startup without how/source" ;; *) echo "FAIL: sessionStart no-source [$OUT]"; FAIL=1 ;; esac
95
+
96
+ if [ "$FAIL" -eq 0 ]; then echo "All checks passed."; exit 0
97
+ else echo "Some checks FAILED."; exit 1
98
+ fi